Skip to content

fix(ios-readplace): remove hard-coded server internals from the client#919

Closed
FagnerMartinsBrack wants to merge 4 commits into
mainfrom
ios/independent-contract-fixes
Closed

fix(ios-readplace): remove hard-coded server internals from the client#919
FagnerMartinsBrack wants to merge 4 commits into
mainfrom
ios/independent-contract-fixes

Conversation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member

Purpose

The iOS app is meant to be a generic hypermedia client: it knows only the entry point, the Siren media type, and the action names / link rels it supports — everything else (URLs, methods, field values, presentation) it must discover from the server or tolerate. A review found a cluster of places where the client instead hard-coded server internals or duplicated server domain logic, so a routine server-side change would break the app or force an App Store release.

This PR fixes the independent, client-only items from that review (the ones that need no coordinated server change). Each removes a concrete coupling and is individually shippable.

What changed and why it matters

A4 — Inject all bootstrap cookies; scrub by host, not by name

Problem: bootstrapSession() selected the reader session cookie by the hard-coded name hutch_sid, and sign-out deleted it by name. If the server ever renamed its session cookie (a server-internal change by definition), cookie injection would return nothing and every in-app reader open would fail until an App Store release.
Fix: the client now forwards every cookie the /auth/session response set into the reader web view, and scrubs sign-out cookies by the app's own server host. AppConfig.sessionCookieName is gone. The client no longer needs to know the cookie's name.

B1 — Re-read the collection on a reader status change instead of inferring "read"

Problem: the reader bridge fired on the presence of a status field and the client unconditionally treated it as "marked read", dropping the row. But update-status is a server toggle (value: "read" or "unread"), so a toggle back to unread wrongly removed the item.
Fix: on a reader status change the client re-reads the collection and adopts the server's truth (a shallow list) rather than inferring a direction. The row leaves only if the server's unread-only collection no longer lists it. (A deep-scrolled list holds its position and reconciles on the next pull-to-refresh.) The durable server-owned signal is a follow-up PR; this removes the incorrect inference now.

C1 — Tolerate an evolving/malformed collection warning

Problem: SirenWarning.code was a required decode key, decoded atomically, even though the client reads only warning.message. Dropping or renaming code server-side — a property no client reads — would throw and blank the entire collection. The non-fatal warning channel could take down the page.
Fix: code is now optional and the warning decodes leniently, so an evolving or malformed warning degrades to no banner, never a blank page.

C2 — Exclude a link from controls when any rel is structural

Problem: the client keyed structural-link detection on rel.first only. A multi-rel link like ["alternate", "next"] keyed as alternate, escaped the structural filter, and would render as a tappable control while also being followed for pagination.
Fix: a link carrying any structural rel (self/root/prev/next/item) is never a control, mirroring the browser extension's walker.

C3 — Surface an item's other semantic links as controls

Problem: each item reduced its links to a single read pick; every other semantic entity link (a future share, summary, …) was discarded and could never render — the exact failure the affordance loop exists to prevent.
Fix: the row now surfaces every non-structural, non-read entity link as a control (keeping read as the primary tap), so a newly-advertised item link renders with no client change.

D — Drop comments that reintroduce URL coupling in prose

Removed comments naming concrete server routes/methods/symbols (/queue, /auth/session, /oauth/token, SESSION_COOKIE_NAME, isFile, /login, /signup). The code discovers these from the response; a route baked into a comment rots silently and re-teaches the coupling the contract removes.

Not in this PR (need coordinated server changes)

Split out because they can't be done client-only without regressing test-pinned behaviour:

  • A2 — discover the session-bootstrap action instead of hard-coding POST /auth/session.
  • B2 — a server-owned reader mark-read signal (replaces the htmx-event sniffing; completes B1).
  • B3 — a server-provided read-state signal (the client hard-codes the "read" status literal in two places, both pinned by explicit tests encoding intended dual-signal/toggle behaviour).

Testing

Full XCTest suite green locally (207 tests, 0 failures) plus the STAGING smoke pass, run exactly as CI runs make test.

Several places had the client hard-coding server internals instead of
discovering or tolerating them, so a server-side change would break the app
or force an App Store release. This removes them:

- Inject every cookie the session-bootstrap response sets into the reader
  web view instead of selecting one by the hard-coded `hutch_sid` name, and
  scrub sign-out cookies by server host rather than by name. A server cookie
  rename no longer breaks the reader or needs an app release.
- On a reader status change, re-read the collection and adopt the server's
  truth instead of inferring "read" and dropping the row locally. A toggle
  back to unread no longer wrongly removes the item from the list.
- Decode a collection warning leniently and make `code` optional, so an
  evolving or malformed warning degrades to no banner instead of failing the
  whole collection decode and blanking the page.
- Exclude a link from toolbar controls when ANY of its rels is structural,
  not just the presentation token, so a multi-rel structural link can't
  render as a tappable control while also driving pagination.
- Surface an item's non-structural, non-`read` semantic links as row
  controls, so a future item link (e.g. `share`) renders instead of being
  silently discarded.
- Drop comments naming concrete server routes/methods/symbols (`/queue`,
  `/auth/session`, `/oauth/token`, `SESSION_COOKIE_NAME`, `isFile`, `/login`,
  `/signup`) that rot silently and reintroduce the URL coupling the
  hypermedia contract removes.
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 8m 9s —— View job


Review: remove hard-coded server internals from the iOS client

This is a coherent, well-reasoned PR that does what it says: each item removes a real coupling and is independently shippable. I traced every change end-to-end. The B1 fix is correct (convergeWithServer(droppingId: nil) no-ops for a deep-scrolled list and adopts server truth for a shallow one — verified in ReadingListViewModel.swift:187), C2's isStructuralLink is exercised by the existing toolbar test, C3's link controls are actually invocable (activateviewModel.open(link:) in ReadingListView.swift:204), and the D comment removals align with the code-comments skill. No security or data-loss concerns: the reader only ever receives the server's own same-host cookies, and sign-out over-deletes rather than under-deletes.

One genuine gap, and a few low-priority observations.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

1. The headline C3/C1 behaviors ship without tests

The two most novel guarantees in this PR have no test pinning them, and the iOS suite has no coverage gate (make test runs xcodebuild test with no -enableCodeCoverage / threshold), so CI stays green regardless. In a repo with this TDD culture, an explicit test is the only thing preventing a silent regression.

  • C3 — Affordance.isSemanticControlLink is never invoked by any test. The only rowControls test (SirenDecodingTests.swift:52) builds an article with no links, so links.compactMap(...).filter(\.isSemanticControlLink) (AffordancePresentation.swift:183) runs over an empty array and the new property (AffordancePresentation.swift:139) is never exercised. Nothing pins the PR's stated goal — that a share link surfaces as a control, that the read link is not double-surfaced (it's already the primary tap via readHref), that a structural item link (item/next) is excluded, or that a multi-rel ["alternate","next"] item link is excluded.
  • C1 — the degradation paths are untested. testCollectionWarningSurfaced (SirenDecodingTests.swift:309) only covers a warning with code. Nothing covers the two behaviors C1 exists to provide: a warning missing code still surfaces its message, and a malformed warning (e.g. missing the still-required message) degrades to warning == nil while the rest of the collection still decodes — the exact "never blank the page" claim in the CollectionProperties.init(from:) doc comment (SirenModels.swift:179).

Suggested additions (all pure decode/unit tests, no view host needed):

// C3 — item links become row controls, without double-rendering read
func testRowControlsSurfaceANonStructuralItemLinkAndNeverTheReadLink() throws {
    let json = """
    { "properties": { "id": "x", "url": "https://example.com/x" },
      "links": [
        { "rel": ["read"], "href": "/queue/x/view" },
        { "rel": ["share"], "href": "/queue/x/share", "title": "Share" },
        { "rel": ["item"], "href": "/queue/x" },
        { "rel": ["alternate", "next"], "href": "/queue?page=2" }
      ] }
    """
    let article = try XCTUnwrap(Article(entity: try decodeEntity(json)))
    XCTAssertEqual(article.rowControls.compactMap(\.link?.rel.first), ["share"],
        "only the non-structural, non-read item link surfaces as a control")
    XCTAssertEqual(article.readHref, "/queue/x/view", "read stays the primary tap, not a duplicate control")
}

// C1 — a code-less warning still shows; a malformed one degrades without blanking the page
func testWarningMissingCodeStillSurfacesItsMessage() throws { /* warning: { "message": "…" } → warning?.message set, code nil */ }
func testMalformedWarningDegradesToNoBannerButKeepsTheCollection() throws { /* warning: { "code": "x" } (no message) → warning == nil, articles still decode */ }

Fix this →

Low Priority Suggestions

1. removeReaderWebStoreData matches the cookie host by substring

AppSession.swift:123where cookie.domain.contains(host) over-matches: with host = "readplace.com", a cookie for readplace.com.attacker.example also contains that substring. On sign-out this only ever over-deletes (privacy-positive, and such a foreign cookie is unlikely in the app's own WebKit store), so it's not a bug — but a precise predicate reads better and can't drift: cookie.domain == host || cookie.domain.hasSuffix("." + host) (the hasSuffix form also covers the leading-dot .readplace.com domain).

2. The "no cookie ⇒ failed mint" guard is now weaker than its doc comment claims

ReadplaceAPI.swift:382 returns every cookie httpCookieStorage?.cookies(for: url) holds, and that jar is shared across all requests on this session. The bootstrapSession doc comment (ReadplaceAPI.swift:~343) says "a response that sets no cookie is a failed mint," but if the server ever set a same-host cookie on some earlier endpoint, a /auth/session that sets nothing would still find that stale cookie and pass the guard !cookies.isEmpty — reading a failed mint as success. Harmless today (bootstrap is realistically the only cookie-setter and the jar is ephemeral), but the guarantee is now assumption-based where the old name-filter made it explicit. If you want to keep the strong guarantee, snapshot the jar (or the response's Set-Cookie) and return only what this response added.

3. Affordance.init(link:) still keys presentation on rel.first

SirenModels.swift:438 — C2/C3 correctly made control-worthiness inspect every rel, but token = rel.first still keys the icon/tint off the first rel alone. There's no bug (structural links are filtered out before presentation, and an unknown token falls back to the neutral glyph), so this is purely cosmetic: a future multi-rel semantic link like ["alternate","share"] would render the neutral ellipsis.circle rather than a share glyph. Worth a note only because it's the same first-rel assumption this PR is otherwise removing.

4. B1 drops the optimistic removal (intended, flagging for awareness)

ReadingListViewModel.swift:145readerStatusChanged no longer removes the row before converging, so after the reader closes, a just-read row stays visible for one loadQueue() round-trip (shallow list) before the server collection replaces it. This is the correct trade to fix the toggle-back-to-unread bug — you can't optimistically drop a row without knowing the toggle direction — and the PR calls out B2/B3 as the durable follow-up. Noting it only so the brief linger is a known, intended change rather than a surprise in QA.

cc @FagnerMartinsBrack — No high or medium priority blocking issues; the one medium is a test-coverage gap on the PR's own headline behaviors (C3/C1), which matters more here because the iOS suite has no coverage gate to catch it. The lows are polish. Nicely scoped, self-consistent PR.
· ios/independent-contract-fixes

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Review: remove hard-coded server internals from the iOS client

This is a coherent, well-reasoned PR that does what it says: each item removes a real coupling and is independently shippable. I traced every change end-to-end. The B1 fix is correct (convergeWithServer(droppingId: nil) no-ops for a deep-scrolled list and adopts server truth for a shallow one — verified in ReadingListViewModel.swift:187), C2's isStructuralLink is exercised by the existing toolbar test, C3's link controls are actually invocable (activateviewModel.open(link:) in ReadingListView.swift:204), and the D comment removals align with the code-comments skill. No security or data-loss concerns: the reader only ever receives the server's own same-host cookies, and sign-out over-deletes rather than under-deletes.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

1. The headline C3/C1 behaviors ship without tests

The two most novel guarantees have no test pinning them, and the iOS suite has no coverage gate (make test runs xcodebuild test with no -enableCodeCoverage/threshold), so CI stays green regardless.

  • C3 — Affordance.isSemanticControlLink is never invoked by any test. The only rowControls test (SirenDecodingTests.swift:52) builds an article with no links, so the new property (AffordancePresentation.swift:139) and the item-links branch of rowControls (AffordancePresentation.swift:183) are never exercised. Nothing pins: a share link surfaces as a control; the read link is not double-surfaced; a structural item link is excluded; a multi-rel ["alternate","next"] link is excluded.
  • C1 — the degradation paths are untested. testCollectionWarningSurfaced only covers a warning with code. Nothing covers a warning missing code still surfacing its message, or a malformed warning degrading to warning == nil while the rest of the collection still decodes — the "never blank the page" claim in CollectionProperties.init(from:) (SirenModels.swift:179).

Low Priority Suggestions

1. removeReaderWebStoreData matches the cookie host by substring

AppSession.swift:123cookie.domain.contains(host) over-matches (readplace.com.attacker.example). Only over-deletes on sign-out, so not a bug; a precise predicate is cookie.domain == host || cookie.domain.hasSuffix("." + host).

2. The "no cookie ⇒ failed mint" guard is weaker than its doc comment

ReadplaceAPI.swift:382 returns every cookie the shared session jar holds for the URL; a /auth/session that sets nothing could pass guard !cookies.isEmpty on a stale same-host cookie. Harmless today; snapshot the jar / response Set-Cookie to keep the strong guarantee.

3. Affordance.init(link:) still keys presentation on rel.first

SirenModels.swift:438 — control-worthiness is now multi-rel-aware but the presentation token isn't. Cosmetic only (structural links are pre-filtered; unknown tokens fall back to neutral).

4. B1 drops the optimistic removal (intended, flagging for awareness)

ReadingListViewModel.swift:145 — a just-read row lingers for one loadQueue() round-trip before the server collection replaces it. Correct trade for the toggle-direction bug; B2/B3 are the durable follow-up.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Fix the review issues. Use your Read tool to read the local file .github/workflows/claude-PR-code-review-auto-apply.md for instructions. The file is in the checked-out repository.

Context

Review to Address

See the review comment above this one for the issues to fix.

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 17m 23s —— View job


Fixed the medium-priority review issue

Pushed e469a72b to ios/independent-contract-fixes.

  • Read AffordancePresentation.swift, SirenModels.swift, and existing tests for context
  • C3 — added test pinning rowControls / isSemanticControlLink
  • C1 — added tests for warning degradation
  • Verified (standalone Swift harness against the real decode logic)
  • Committed & pushed

What was fixed

The only medium issue was the test-coverage gap on the PR's headline C3/C1 behaviors (no high issues; the 4 low-priority items were intentionally left per the auto-apply scope). Added 3 XCTest cases to Tests/SirenDecodingTests.swift:

  • C3 — testRowControlsSurfaceANonStructuralItemLinkButNeverReadOrStructuralLinks: a share item link surfaces as a control; read stays the primary tap (readHref) and is not double-surfaced; a structural link (item) and a multi-rel link carrying a structural rel (["alternate","next"]) are excluded. This exercises Affordance.isSemanticControlLink and the item-links branch of rowControls, which had no invoking test.
  • C1 — testCollectionWarningMissingCodeStillSurfacesItsMessage: a warning with no code still surfaces its message.
  • C1 — testMalformedCollectionWarningDegradesToNoBannerButKeepsTheCollection: a malformed warning (missing the required message) degrades to warning == nil while the rest of the collection still decodes — pinning the "never blank the page" claim.

Approach & why

  • Placed the tests as pure decode/unit tests beside the existing rowControls and warning tests, matching the file's idiom (decodeEntity/decodeCollection helpers, Fixtures.article) — no view host needed.
  • Verification: xcodebuild is macOS-only and this runner is Linux, so I couldn't run make test here. Instead I compiled the real SirenModels.swift with swiftc alongside a verbatim copy of the Foundation-only Affordance/Article extension members and ran all three scenarios — 7/7 assertions passed, confirming both the fixtures parse and the pinned behaviors match the shipped logic. The full XCTest suite runs on the macOS CI runner.
  • Commit type: used test(ios-readplace): … rather than the generic fix: default. The change is test-only (Conventional Commits test), and the git-commit skill requires the project scope, consistent with the repo's *(ios-readplace) history. No commit-msg hook enforces the literal default string; the pre-commit pnpm check passed.
    · ios/independent-contract-fixes

…haviors

Addresses the medium-priority gap from the PR #919 review: the C3 row-control
surfacing (isSemanticControlLink / rowControls) and the C1 warning degradation
paths shipped without tests, and the iOS suite has no coverage gate to catch a
regression.

- C3: a row surfaces a non-structural item link (share) as a control, keeps
  read as the primary tap (readHref) without double-surfacing it, and excludes
  a structural link (item) and a multi-rel link carrying a structural rel
  (["alternate","next"]).
- C1: a warning missing code still surfaces its message; a malformed warning
  (missing the required message) degrades to no banner while the rest of the
  collection still decodes.

Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 8m 56s —— View job


Review complete — PR #919 (commit e469a72b)

  • Read review instructions
  • Gather context (git diff against origin/main, full commit range)
  • Review A4 — cookie injection/scrubbing changes
  • Review B1 — reader status-change reconciliation
  • Review C1/C2/C3 — warning tolerance + link controls
  • Review D — comment removals
  • Review the newly-added tests (commit e469a72b)
  • Apply relevant skills (hypermedia-api-design, test-driven-design, code-comments)
  • Post review

Verdict: 0 high, 0 medium. The prior review's sole medium — the C3/C1 test-coverage gap — is resolved by commit e469a72b. I traced all three added tests against the real decode logic: they genuinely exercise isSemanticControlLink + the rowControls link branch (C3) and both warning-degradation paths (C1), with positive, non-vacuous assertions. The test-only commit changed no production code, so it introduces no new production risk.

I also confirmed the cross-client contract holds: the iOS structuralRels set (self/root/prev/next/item) and the "any rel is structural ⇒ not a control" logic match the browser-extension walker (siren-reading-list.ts:291) exactly, so an alternate-only link surfacing as a control is consistent cross-client, not a divergence.

Low-priority (non-blocking):

  1. AppSession.swift:121cookie.domain.contains(host) over-matches (readplace.com.attacker.example); only over-deletes on sign-out, so not a bug. Prefer == host || hasSuffix("." + host).
  2. ReadplaceAPI.swift:216sessionCookies returns the whole jar for the URL, so the bootstrapSession "no cookie ⇒ failed mint" guarantee (ReadplaceAPI.swift:191) is now assumption-based. Harmless today (ephemeral jar, bootstrap is the only cookie-setter).
  3. SirenModels.swift:438Affordance.init(link:) still keys presentation token on rel.first; a future multi-rel semantic link renders the neutral glyph. Cosmetic (structural links are pre-filtered).
  4. ReaderWebView.swift:128 — the ReaderBridge.script doc says the native side "drops the row," but B1 changed this to converge-with-server (the row leaves only if the server's unread-only collection no longer lists it). Minor comment staleness.
  5. Minor test completeness — C2's toolbar multi-rel path (isStructuralLink via isToolbarControl) isn't directly tested; the sibling row predicate (isSemanticControlLink) now pins the multi-rel logic, so the risk is low.

Nicely scoped, self-consistent PR. Ready for human review.
· ios/independent-contract-fixes

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

The prior review's sole medium — the C3/C1 test-coverage gap — is resolved by commit e469a72b. I traced all three added tests against the real decode logic:

  • C3 testRowControlsSurfaceANonStructuralItemLinkButNeverReadOrStructuralLinks genuinely exercises Affordance.isSemanticControlLink (AffordancePresentation.swift:139) and the link branch of rowControls (AffordancePresentation.swift:183): a share link surfaces, read stays the primary tap via readHref (not double-surfaced), and both a structural item link and a multi-rel ["alternate","next"] link are excluded. The multi-rel case pins C2's "any rel is structural" logic.
  • C1 testCollectionWarningMissingCodeStillSurfacesItsMessage and testMalformedCollectionWarningDegradesToNoBannerButKeepsTheCollection cover both degradation paths — a code-less warning still shows its message, and a malformed warning degrades to warning == nil while the collection alongside it still decodes (["good"]), pinning the "never blank the page" claim.

Assertions are positive and non-vacuous (the malformed-warning test pairs XCTAssertNil(page.warning) with a positive page.articles == ["good"], so nil can't pass for the wrong reason). The commit changed no production code, so it introduces no new production risk. Cross-client contract verified: the iOS structuralRels set and "any rel is structural ⇒ not a control" logic match the browser-extension walker (siren-reading-list.ts:291) exactly.

Low Priority Suggestions

1. removeReaderWebStoreData matches the cookie host by substring

AppSession.swift:121cookie.domain.contains(host) over-matches (a cookie for readplace.com.attacker.example contains readplace.com). On sign-out this only ever *over-*deletes (privacy-positive, and such a foreign cookie is unlikely in the app's own WebKit store), so it's not a bug — but a precise predicate can't drift: cookie.domain == host || cookie.domain.hasSuffix("." + host). Fix this →

2. The "no cookie ⇒ failed mint" guard is weaker than its doc comment

ReadplaceAPI.swift:216sessionCookies returns every cookie the shared session jar holds for the URL, but the bootstrapSession doc comment (ReadplaceAPI.swift:191) says "a response that sets no cookie is a failed mint." If the server ever set a same-host cookie on an earlier endpoint, a /auth/session that sets nothing would still pass guard !cookies.isEmpty. Harmless today (the jar is ephemeral and bootstrap is realistically the only cookie-setter), but the guarantee is now assumption-based where the old name-filter made it explicit.

3. Affordance.init(link:) still keys presentation on rel.first

SirenModels.swift:438 — C2/C3 correctly made control-worthiness inspect every rel, but token = rel.first still keys the icon/tint off the first rel alone. No bug (structural links are filtered out before presentation, and an unknown token falls back to the neutral glyph), so this is purely cosmetic: a future ["alternate","share"] link would render ellipsis.circle rather than a share glyph. Noted only because it's the same first-rel assumption this PR is otherwise removing.

4. A B1 comment now describes the pre-B1 disposition

ReaderWebView.swift:128 — the ReaderBridge.script doc says the bridge "signals the native side, which closes the sheet and drops the row." After B1, onMarkedRead routes to readerStatusChanged()convergeWithServer, so the row leaves only if the server's unread-only collection no longer lists it (a toggle-back-to-unread keeps it). "Drops the row" now overstates the disposition. Minor comment staleness (the B2 follow-up will replace this bridge entirely). Fix this →

5. C2's toolbar multi-rel path isn't directly tested

The new C3 test pins the multi-rel exclusion through isSemanticControlLink (the row predicate), and testToolbarExcludesCaptureOnlySavesAndStructuralLinks covers isToolbarControl with single-rel structural links. Nothing directly exercises isToolbarControlisStructuralLink with a multi-rel link like ["alternate","next"]. The two predicates share structuralRels and the same rel.contains { … } shape, so the behavior is pinned in substance and the regression risk is low — an optional completeness test, not a gap that matters.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Please address the following low-priority review suggestion. It's a clean, in-scope precision fix — no design decision needed.

Tighten the cookie-host match in removeReaderWebStoreDataprojects/ios-readplace/App/AppSession.swift

The sign-out scrub matches cookies with cookie.domain.contains(host), which over-matches any domain that merely contains the host string (e.g. readplace.com.attacker.example). Replace the where predicate with an exact-or-subdomain match:

where cookie.domain == host || cookie.domain.hasSuffix("." + host)

This still matches the exact host and every legitimate subdomain (.readplace.com, app.readplace.com) while excluding spoofed foreign domains, so the sign-out wipe doesn't regress. Please follow CLAUDE.md and run the relevant iOS checks before pushing.

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 11m 37s —— View job


Tightened the cookie-host match in removeReaderWebStoreData

Pushed d2a5aed4 to ios/independent-contract-fixes.

  • Read AppSession.swift and confirm the exact predicate
  • Replace cookie.domain.contains(host) with an exact-or-subdomain match
  • Verify the changed construct compiles + behaves correctly (Swift 6.3.2 harness)
  • Pre-commit pnpm check (36 projects, 47 tasks — all green, coverage thresholds met)
  • Commit & push

What changed

AppSession.swift:121 — the sign-out scrub now matches by exact host or dot-prefixed subdomain instead of a substring:

for cookie in await store.httpCookieStore.allCookies()
where cookie.domain == host || cookie.domain.hasSuffix("." + host) {

The old cookie.domain.contains(host) matched any domain that merely contained the host string, so a spoof like readplace.com.attacker.example was in scope. On sign-out this only ever over-deleted (privacy-positive, not a bug), so this is a precision fix that narrows the wipe to exactly the app's own origins.

Verification & why it's sound

  • Compiler + semantics: AppSession.swift imports Apple-only WebKit and xcodebuild is macOS-only, so it can't build on this Linux runner. I reproduced the exact predicate (verbatim, including the for … where … continuation) in a Foundation-only harness and ran it under the real Swift 6.3.2 compiler: exact host, the leading-dot .readplace.com domain cookie, and subdomain app.readplace.com are all still wiped; the spoof readplace.com.attacker.example — which the old contains predicate did wipe — is now excluded. 5/5 assertions green, including a regression proof that the old predicate over-matched the spoof.
  • No test added: removeReaderWebStoreData is the OS-boundary seam that the XCTest suite replaces with a spy (wipeReaderWebStore), and the trigger was scoped to the predicate change with "no design decision needed" — so I kept the diff to the predicate rather than introducing a new testable seam. The full XCTest suite (207 tests) runs on the macOS CI runner via make test.
  • Doc comment unchanged: the existing comment already says the wipe "leaves cookies for other origins untouched" — the tightened predicate makes that claim more accurate, so no update was needed.

The pre-commit gate (pnpm check) passed in full, so the TS side of the monorepo is unaffected.
· ios/independent-contract-fixes

removeReaderWebStoreData matched the reader's WebKit cookies with
cookie.domain.contains(host), which also matches any domain that merely
contains the host as a substring (e.g. readplace.com.attacker.example).
Match the exact host or a dot-prefixed subdomain instead, so the sign-out
wipe still covers the host, the leading-dot domain cookie (.readplace.com),
and legitimate subdomains (app.readplace.com) without reaching a spoofed
foreign origin. On sign-out the old predicate only ever over-deleted, so
this narrows the scrub to exactly the app's own origins.

Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 4m 47s —— View job


Review — PR #919 (commit d2a5aed4)

The only change since the last review (e469a72bd2a5aed4) is the one-line cookie-host predicate in AppSession.swift, which resolves the previous review's low-priority #1. I verified the fix and re-traced the surrounding changes; nothing regressed.

The cookie-host fix is correct and complete. AppSession.swift:121 now scrubs sign-out cookies with cookie.domain == host || cookie.domain.hasSuffix("." + host):

  • Matches the exact host (readplace.com), the leading-dot domain cookie (.readplace.com, via hasSuffix(".readplace.com")), and legitimate subdomains (app.readplace.com).
  • Excludes the substring spoof (readplace.com.attacker.example) the old contains(host) reached.
  • The new match set is a strict subset of the old one, and the only domains removed from the wipe are foreign origins the app never authenticates against — so there is no under-deletion regression for the app's own origins. The doc comment ("leaves cookies for other origins untouched") is now strictly more accurate. Commit message is Conventional-Commits-correct with the ios-readplace scope.

I also re-confirmed the items from the prior pass still hold: the three C3/C1 tests added in e469a72b are correct and non-vacuous (the malformed-warning test pairs XCTAssertNil(page.warning) with a positive page.articles == ["good"], so nil can't pass for the wrong reason); and the cross-client contract is intact — the browser-extension walker (siren-reading-list.ts:305) excludes a link when rel.some(...) is structural over the identical {self, root, prev, next, item} set, matching iOS isStructuralLink/isSemanticControlLink exactly.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

The prior review's sole medium — the C3/C1 test-coverage gap — was resolved by e469a72b and remains covered. This commit changed one line of production code (the cookie predicate) and no tests, introducing no new production risk.

Low Priority Suggestions

These are the standing (non-blocking) items carried from the last review; #1 is now fixed by this commit, so they're renumbered. All were previously surfaced and consciously deferred — listed only for completeness.

1. ReaderBridge.script doc comment now describes the pre-B1 disposition

ReaderWebView.swift:130 — the doc says the native side "closes the sheet and drops the row." After B1, onMarkedRead (ReadingListView.swift:72) routes to readerStatusChanged()convergeWithServer(droppingId: nil), so the row leaves only if the server's unread-only collection no longer lists it (a toggle-back-to-unread keeps it). "Drops the row" overstates the disposition. Minor comment staleness (B2 will replace this bridge). Fix this →

2. The "no cookie ⇒ failed mint" guard is weaker than its doc comment

ReadplaceAPI.swift:216sessionCookies returns every cookie the shared session jar holds for the URL, but the bootstrapSession doc (ReadplaceAPI.swift:191) says "a response that sets no cookie is a failed mint." If the server ever set a same-host cookie on an earlier endpoint, a /auth/session that sets nothing could still pass guard !cookies.isEmpty. Harmless today (the jar is ephemeral and bootstrap is realistically the only cookie-setter), but the guarantee is assumption-based where the old name-filter made it explicit. Snapshot the jar / response Set-Cookie to keep the strong guarantee.

3. Affordance.init(link:) still keys presentation on rel.first

SirenModels.swift:438 — C2/C3 made control-worthiness inspect every rel, but token = rel.first still keys the icon/tint off the first rel alone. No bug (structural links are pre-filtered; an unknown token falls back to the neutral glyph), so purely cosmetic: a future ["alternate","share"] link would render ellipsis.circle rather than a share glyph. Noted only because it's the same first-rel assumption this PR is otherwise removing.

4. C2's toolbar multi-rel path isn't directly tested

The new C3 test pins the multi-rel exclusion through isSemanticControlLink (the row predicate), and testToolbarExcludesCaptureOnlySavesAndStructuralLinks covers isToolbarControl with single-rel structural links. Nothing directly exercises isToolbarControlisStructuralLink with a multi-rel link like ["alternate","next"]. The two predicates share structuralRels and the same rel.contains { … } shape, so the behavior is pinned in substance and the regression risk is low — an optional completeness test, not a gap that matters.

cc @FagnerMartinsBrack - No high or medium priority issues found. This PR is ready for human review. The one-line cookie fix in d2a5aed4 is correct, resolves the prior review's low #1, and introduces no regression; the remaining lows are the same deferred, non-blocking items.
· ios/independent-contract-fixes

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Review — PR #919 (commit d2a5aed4)

The only change since the last review (e469a72bd2a5aed4) is the one-line cookie-host predicate in AppSession.swift, which resolves the previous review's low-priority #1. I verified the fix and re-traced the surrounding changes; nothing regressed.

The cookie-host fix is correct and complete. AppSession.swift:121 now scrubs sign-out cookies with cookie.domain == host || cookie.domain.hasSuffix("." + host):

  • Matches the exact host (readplace.com), the leading-dot domain cookie (.readplace.com), and legitimate subdomains (app.readplace.com).
  • Excludes the substring spoof (readplace.com.attacker.example) the old contains(host) reached.
  • The new match set is a strict subset of the old one, and the only domains removed from the wipe are foreign origins the app never authenticates against — so there is no under-deletion regression for the app's own origins.

I also re-confirmed the C3/C1 tests added in e469a72b are correct and non-vacuous, and the cross-client contract is intact — the browser-extension walker (siren-reading-list.ts:305) excludes a link when rel.some(...) is structural over the identical {self, root, prev, next, item} set, matching iOS isStructuralLink/isSemanticControlLink.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

Standing (non-blocking) items carried from the last review; #1 is now fixed by this commit.

1. ReaderBridge.script doc comment describes the pre-B1 disposition

ReaderWebView.swift:130 — the doc says the native side "closes the sheet and drops the row." After B1, onMarkedRead (ReadingListView.swift:72) routes to readerStatusChanged()convergeWithServer(droppingId: nil), so the row leaves only if the server's unread-only collection no longer lists it. "Drops the row" overstates it. Minor comment staleness (B2 will replace this bridge).

2. The "no cookie ⇒ failed mint" guard is weaker than its doc comment

ReadplaceAPI.swift:216sessionCookies returns every cookie the shared jar holds for the URL, but the bootstrapSession doc (ReadplaceAPI.swift:191) says "a response that sets no cookie is a failed mint." Harmless today (ephemeral jar, bootstrap is the only cookie-setter), but the guarantee is now assumption-based.

3. Affordance.init(link:) still keys presentation on rel.first

SirenModels.swift:438 — control-worthiness is now multi-rel-aware but token = rel.first isn't. Cosmetic (structural links pre-filtered; unknown tokens fall back to the neutral glyph).

4. C2's toolbar multi-rel path isn't directly tested

The new C3 test pins multi-rel exclusion through isSemanticControlLink; isToolbarControlisStructuralLink isn't directly exercised with a multi-rel link. Both share structuralRels and the same rel.contains { … } shape, so risk is low — an optional completeness test.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude CI has FAILED. Use your Read tool to read the local file .github/workflows/claude-PR-CI-failure-fixer.md for instructions. The file is in the checked-out repository.

Context

Failed Jobs

  • ios-tests: Run the tests on a discovered iPhone simulator

@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 11m 47s —— View job


CI failure: the new coverage gate, not a test failure

All 212 tests pass (** TEST SUCCEEDED **). ios-tests fails only at the per-file coverage gate added in f5dfe07a (#920):

iOS coverage gate FAILED — files below their floor:
  AppSession.swift: 78% < floor 79%
  LoginView.swift: 88% < floor 91%
  SaveSharedPage.swift: 91% < floor 93%
  • Read PR commit history to understand intent
  • Pull the failed job logs (Run 28741531518)
  • Identify root cause
  • Implement the fix — blocked on your approval (loosening a coverage gate needs sign-off; details below)

Root cause: a brand-new gate calibrated off a local run

The gate (scripts/check-coverage.py + coverage-baseline.json) was added in f5dfe07a, and this is its first CI run (the commit landed the same minute this run started). Its floors were recorded from a local make test; CI's Xcode 26.3 measures three files lower:

  • LoginView.swift (88% vs 91%) and SaveSharedPage.swift (91% vs 93%) are not modified by this PR — it can't have regressed them. LoginView has no dedicated test; its coverage is incidental from SwiftUI rendering, which shifts between Xcode/simulator versions — exactly the drift a locally-set floor misses.
  • AppSession.swift (78% vs 79%) is the one file this PR touched. The 1% is real but benign: the PR grew removeReaderWebStoreData — the OS-boundary WebKit wipe that tests replace with a spy and never execute (AppSession.swift:118, seam at :39) — while deleting the covered isSessionCookie helper. More uncovered lines, fewer covered ones.

Why I'm not auto-applying

Both CLAUDE.md ("Never add excludes/ignore patterns to coverage without approval") and the baseline file itself ("Floors are a RATCHET … never lower them without approval") require human sign-off to loosen the gate. I also can't measure iOS coverage on this runner to pursue the "raise coverage instead" path — xcodebuild is macOS-only and this fixer runs on Linux. Since you just deliberately added this gate, I'd rather confirm than quietly lower it.

Recommended fix (reply to approve)

Calibrate the new gate to the CI environment — 2 of the 3 files aren't even in this PR:

File Now Proposed Why
SaveSharedPage.swift floor 93 floor 91 deterministic logic, recalibrate to the CI-measured value
AppSession.swift floor 79 floor 78 the OS-boundary wipe grew; tests never run it (spy-replaced)
LoginView.swift floor 91 move to excluded pure SwiftUI view with no dedicated test — matches ReadingListView/ArticleRow/ReaderSheet, already excluded. A bare floor 88 would re-fail on the next render-drift.

Reply go and I'll apply exactly that.

Alternative — don't loosen AppSession: I can instead extract the domain == host || hasSuffix(".host") predicate into a unit-tested static func, which raises AppSession's coverage and closes the standing low-priority review note — no floor change for it. LoginView/SaveSharedPage still need recalibration (out of this PR's scope, unverifiable without a Mac). Say the word if you'd prefer this.
· ios/independent-contract-fixes

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Superseded by #927, which consolidates all 9 stacked PRs into one PR targeting main (so CI runs and it's independently deployable). See #927 for the full description.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant