From 86a0f524ada62c20dd1aeb2c7d7541184a263d74 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Sat, 4 Jul 2026 23:20:38 +1000 Subject: [PATCH 1/2] feat(hutch,ios): discover the reader session-mint action instead of hard-coding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS reader mints a browser session cookie from the bearer token before loading the cookie-authenticated reader page. The client hard-coded that route and method (`POST /auth/session`), so a server-side rename would break every article tap until an App Store release — the exact URL coupling the hypermedia contract exists to remove. - Server: advertise a `create-session` action on the collection (href `/auth/session`, method POST). It carries no fields — the route reads the bearer from the Authorization header — and no title, since it is a machine capability a client invokes bespoke rather than a rendered control (like `save-articles`). - Client: discover the action from the loaded collection and follow its href and method to mint the session; map `create-session` to a non-toolbar presentation so it never renders as a control. A server that hasn't advertised the action yet falls back to the fixed path, so an older shipped build keeps working. The action is additive and non-breaking: a client without a handler skips it. --- .../runtime/web/api/collection-siren.test.ts | 15 +++++++++ .../src/runtime/web/api/collection-siren.ts | 11 +++++++ .../App/AffordancePresentation.swift | 9 +++++ .../App/ReadingListViewModel.swift | 7 +++- .../ios-readplace/Shared/ReadplaceAPI.swift | 33 ++++++++++++------- .../Tests/AffordancePresentationTests.swift | 9 ++--- .../Tests/ReadingListViewModelTests.swift | 30 +++++++++++++++++ .../Tests/ReadplaceAPITests.swift | 16 +++++++++ 8 files changed, 114 insertions(+), 16 deletions(-) diff --git a/projects/hutch/src/runtime/web/api/collection-siren.test.ts b/projects/hutch/src/runtime/web/api/collection-siren.test.ts index 1007086fd..521495300 100644 --- a/projects/hutch/src/runtime/web/api/collection-siren.test.ts +++ b/projects/hutch/src/runtime/web/api/collection-siren.test.ts @@ -258,6 +258,21 @@ describe("toArticleCollectionEntity", () => { expect(saveArticlesAction?.fields?.map((f) => f.name)).toEqual(["manifest", "content"]); }); + it("advertises a create-session action so a client discovers the reader session mint", () => { + const result: FindArticlesResult = { + articles: [], + total: 0, + page: 1, + pageSize: 20, + }; + + const entity = toArticleCollectionEntity(result, {}); + + const sessionAction = entity.actions?.find((a) => a.name === "create-session"); + expect(sessionAction?.href).toBe("/auth/session"); + expect(sessionAction?.method).toBe("POST"); + }); + it("includes search action with filter fields", () => { const result: FindArticlesResult = { articles: [], diff --git a/projects/hutch/src/runtime/web/api/collection-siren.ts b/projects/hutch/src/runtime/web/api/collection-siren.ts index c30dbdf4b..b012541a7 100644 --- a/projects/hutch/src/runtime/web/api/collection-siren.ts +++ b/projects/hutch/src/runtime/web/api/collection-siren.ts @@ -137,6 +137,17 @@ export function toArticleCollectionEntity( { name: "url", type: "url" }, ], }, + // Mints a browser session cookie from the bearer token so a client's + // in-app reader can load the cookie-authenticated reader page. Advertised + // (rather than the client hard-coding the route + method) so it can move + // without a client release; a client that doesn't open an in-app reader + // simply skips it. No fields: the route reads the bearer from the + // Authorization header. + { + name: "create-session", + href: "/auth/session", + method: "POST", + }, ], }; } diff --git a/projects/ios-readplace/App/AffordancePresentation.swift b/projects/ios-readplace/App/AffordancePresentation.swift index 3eacf0e55..9872abde0 100644 --- a/projects/ios-readplace/App/AffordancePresentation.swift +++ b/projects/ios-readplace/App/AffordancePresentation.swift @@ -44,6 +44,15 @@ struct AffordancePresentation { isDestructive = false removesItem = false isToolbarControl = false + case "create-session": + // Not a user control: the client invokes this bespoke to mint the reader + // session cookie (like a capture-only save), so it never renders in the + // toolbar even though it is advertised on the collection. + systemImage = "key" + tint = nil + isDestructive = false + removesItem = false + isToolbarControl = false case "update-status": systemImage = "checkmark.circle" tint = .brandSuccess diff --git a/projects/ios-readplace/App/ReadingListViewModel.swift b/projects/ios-readplace/App/ReadingListViewModel.swift index 676d78dbd..1aad5402e 100644 --- a/projects/ios-readplace/App/ReadingListViewModel.swift +++ b/projects/ios-readplace/App/ReadingListViewModel.swift @@ -28,6 +28,10 @@ final class ReadingListViewModel: ObservableObject { @Published private(set) var collectionAffordances: [Affordance] = [ReadingListViewModel.addLinksHelp] private var nextHref: String? + /// The server-advertised `create-session` action from the loaded collection, + /// followed to mint the reader's browser session. Nil against a server that + /// hasn't advertised it, in which case the API falls back to a fixed path. + private var sessionAction: SirenAction? private var isLoadingMore = false /// Whether rows beyond the first page are loaded. A post-action adoption /// replaces the list outright only while everything on screen came from one @@ -234,7 +238,7 @@ final class ReadingListViewModel: ObservableObject { /// a blank page. func mintReaderSession() async -> [HTTPCookie]? { do { - return try await api.bootstrapSession() + return try await api.bootstrapSession(action: sessionAction) } catch { handle(error) return nil @@ -271,6 +275,7 @@ final class ReadingListViewModel: ObservableObject { // the toolbar for the whole scroll. if replacing { applyToolbar(page) + sessionAction = page.action(named: "create-session") } warningText = page.warning?.message } diff --git a/projects/ios-readplace/Shared/ReadplaceAPI.swift b/projects/ios-readplace/Shared/ReadplaceAPI.swift index 86ba68ba3..1ed6c0ba6 100644 --- a/projects/ios-readplace/Shared/ReadplaceAPI.swift +++ b/projects/ios-readplace/Shared/ReadplaceAPI.swift @@ -182,18 +182,29 @@ final class ReadplaceAPI { // MARK: - Reader session - /// Mints a browser session from the current bearer token via the - /// session-bootstrap endpoint and returns every cookie the response set. The - /// in-app reader injects them so the cookie-authenticated reader page (and its - /// in-reader XHRs) load without bouncing to a sign-in page. The client never - /// selects a cookie by name — it forwards whatever the server set — so a server - /// cookie rename needs no app release. Reuses `send()`, so a stale bearer is - /// refreshed once before the session is minted; a response that sets no cookie - /// is a failed mint. - func bootstrapSession() async throws -> [HTTPCookie] { - guard let url = URL(string: "\(baseURL)/auth/session") else { throw APIError.decoding } + /// Mints a browser session from the current bearer token and returns every + /// cookie the response set. The in-app reader injects them so the + /// cookie-authenticated reader page (and its in-reader XHRs) load without + /// bouncing to a sign-in page. Follows the server-declared `action`'s href and + /// method when the collection advertised one (`create-session`), so the endpoint + /// can move without an app release; falls back to a fixed path only for a server + /// that hasn't advertised the action yet (an older shipped build must keep + /// working). The client never selects a cookie by name — it forwards whatever the + /// server set. Reuses `send()`, so a stale bearer is refreshed once before the + /// session is minted; a response that sets no cookie is a failed mint. + func bootstrapSession(action: SirenAction? = nil) async throws -> [HTTPCookie] { + let url: URL + let method: String + if let action { + url = try absoluteURL(action.href) + method = action.method + } else { + guard let fallback = URL(string: "\(baseURL)/auth/session") else { throw APIError.decoding } + url = fallback + method = "POST" + } var request = URLRequest(url: url) - request.httpMethod = "POST" + request.httpMethod = method let (data, http) = try await send(request) guard (200...299).contains(http.statusCode) else { throw apiError(from: data, status: http.statusCode) diff --git a/projects/ios-readplace/Tests/AffordancePresentationTests.swift b/projects/ios-readplace/Tests/AffordancePresentationTests.swift index 77c139e2c..94c1f8d4f 100644 --- a/projects/ios-readplace/Tests/AffordancePresentationTests.swift +++ b/projects/ios-readplace/Tests/AffordancePresentationTests.swift @@ -17,10 +17,11 @@ final class AffordancePresentationTests: XCTestCase { XCTAssertTrue(presentation.isToolbarControl) } - func testCaptureOnlySavesAreNotToolbarControls() { - // iOS can't capture a page from the toolbar, so save-html/save-content render - // nowhere in the toolbar — they are reached only through the Share Sheet. - for token in ["save-html", "save-content"] { + func testMachineOnlyActionsAreNotToolbarControls() { + // iOS can't capture a page from the toolbar, so save-html/save-content are + // reached only through the Share Sheet; create-session is invoked bespoke to + // mint the reader session. None render in the toolbar. + for token in ["save-html", "save-content", "create-session"] { let presentation = AffordancePresentation(token: token) XCTAssertFalse(presentation.isToolbarControl, "\(token) must not present as a toolbar control") XCTAssertFalse(presentation.removesItem) diff --git a/projects/ios-readplace/Tests/ReadingListViewModelTests.swift b/projects/ios-readplace/Tests/ReadingListViewModelTests.swift index 84df31442..bbb63fae3 100644 --- a/projects/ios-readplace/Tests/ReadingListViewModelTests.swift +++ b/projects/ios-readplace/Tests/ReadingListViewModelTests.swift @@ -898,4 +898,34 @@ final class ReadingListViewModelTests: XCTestCase { XCTAssertEqual(viewModel.warningText, "Cannot save that link.") } + + func testMintReaderSessionFollowsTheServersCreateSessionAction() async { + let queueWithSession = """ + { + "class": ["collection", "articles"], + "properties": { "total": 1, "page": 1, "pageSize": 20 }, + "entities": [\(Fixtures.article(id: "a1"))], + "links": [{ "rel": ["self"], "href": "/queue" }, { "rel": ["root"], "href": "/queue" }], + "actions": [{ "name": "create-session", "href": "/custom/session", "method": "POST" }] + } + """ + StubURLProtocol.setHandler { request, _ in + switch request.url?.path { + case "/": return .redirect(to: "/queue") + case "/queue": return .json(200, queueWithSession) + case "/custom/session": return StubURLProtocol.Stub(status: 204, headers: ["Set-Cookie": "sess=v; Path=/"]) + default: return .json(404, "{}") + } + } + let viewModel = makeViewModel(store: TestSupport.loggedInStore()) + await viewModel.refresh() + + let cookies = await viewModel.mintReaderSession() + + XCTAssertEqual(cookies?.first?.value, "v") + XCTAssertTrue( + StubURLProtocol.records.contains { $0.request.url?.path == "/custom/session" }, + "the reader session mint follows the discovered create-session action, not a hard-coded route" + ) + } } diff --git a/projects/ios-readplace/Tests/ReadplaceAPITests.swift b/projects/ios-readplace/Tests/ReadplaceAPITests.swift index de7a224a9..c7f5eea89 100644 --- a/projects/ios-readplace/Tests/ReadplaceAPITests.swift +++ b/projects/ios-readplace/Tests/ReadplaceAPITests.swift @@ -716,4 +716,20 @@ final class ReadplaceAPITests: XCTestCase { "the minted session cookie must never land in the process-wide shared jar" ) } + + func testBootstrapSessionFollowsADiscoveredActionsHrefAndMethod() async throws { + let store = TestSupport.loggedInStore() + StubURLProtocol.setHandler { request, _ in + XCTAssertEqual(request.url?.path, "/custom/session", "follows the action's href, not a hard-coded path") + XCTAssertEqual(request.httpMethod, "POST") + return StubURLProtocol.Stub(status: 204, headers: ["Set-Cookie": "sess=discovered; Path=/"]) + } + let action = SirenAction( + name: "create-session", href: "/custom/session", method: "POST", title: nil, type: nil, fields: nil + ) + + let cookies = try await makeAPI(store: store).bootstrapSession(action: action) + + XCTAssertEqual(cookies.first?.value, "discovered") + } } From 9d089ebd41b5135bcf41f2904808d309773a6722 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Sat, 4 Jul 2026 23:30:25 +1000 Subject: [PATCH 2/2] feat(hutch,ios): emit an explicit read-state so the client stops hard-coding "read" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS client derived an article's read indicator from the status vocabulary (`status == "read" || readAt != nil`) — a domain literal baked into the client that goes stale if the server's status vocabulary evolves (e.g. an `archived` state, or keeping `readAt` after a mark-unread). - Server: emit an explicit `isRead` boolean property on the article entity (already computed for the toggle label), so the read-state is server-authored. - Client: render read-state from the server's `isRead` when present, falling back to the old status/readAt derivation only for an older server that doesn't emit it (App Store back-compat). The property is additive and non-breaking. The optimistic row-removal prediction on the swipe mark-read (`removesItem`) still reads the update-status field value; that path is the client-side optimism that was deliberately kept, and its literal is out of scope here. --- .../src/runtime/web/api/article-siren.test.ts | 10 ++++++++++ .../src/runtime/web/api/article-siren.ts | 4 ++++ .../runtime/web/api/collection-siren.test.ts | 1 + .../ios-readplace/Shared/SirenModels.swift | 8 +++++++- .../Tests/SirenDecodingTests.swift | 20 +++++++++++++++++-- .../ios-readplace/Tests/TestSupport.swift | 10 ++++++++-- 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/projects/hutch/src/runtime/web/api/article-siren.test.ts b/projects/hutch/src/runtime/web/api/article-siren.test.ts index d68be3d0f..1c62ffe69 100644 --- a/projects/hutch/src/runtime/web/api/article-siren.test.ts +++ b/projects/hutch/src/runtime/web/api/article-siren.test.ts @@ -51,6 +51,7 @@ describe("toArticleSubEntity", () => { status: "unread", savedAt: "2026-03-04T10:00:00.000Z", readAt: null, + isRead: false, }, links: [ { rel: ["read"], title: "Read", href: `/queue/${ARTICLE_ID}/view` }, @@ -74,6 +75,15 @@ describe("toArticleSubEntity", () => { expect(deleteAction).toBeUndefined(); }); + it("emits isRead as an explicit boolean reflecting the read status", () => { + expect(toArticleSubEntity(makeArticle({ status: "unread" })).properties?.isRead).toBe(false); + expect( + toArticleSubEntity( + makeArticle({ status: "read", readAt: new Date("2026-03-04T12:00:00.000Z") }), + ).properties?.isRead, + ).toBe(true); + }); + it("toggles an unread item to a server-driven Mark as read with the target value", () => { const subEntity = toArticleSubEntity(makeArticle({ status: "unread" })); const updateStatus = subEntity.actions?.find((a) => a.name === "update-status"); diff --git a/projects/hutch/src/runtime/web/api/article-siren.ts b/projects/hutch/src/runtime/web/api/article-siren.ts index 200af1d3e..e283566ca 100644 --- a/projects/hutch/src/runtime/web/api/article-siren.ts +++ b/projects/hutch/src/runtime/web/api/article-siren.ts @@ -25,6 +25,10 @@ export function toArticleSubEntity(article: SavedArticle): SirenSubEntity { status: article.status, savedAt: article.savedAt.toISOString(), readAt: article.readAt?.toISOString() ?? null, + // An explicit presentational read-state so a client renders the read + // indicator from one server-authored boolean rather than re-deriving it + // from the `status` vocabulary it would otherwise have to hard-code. + isRead, }, links, actions: [ diff --git a/projects/hutch/src/runtime/web/api/collection-siren.test.ts b/projects/hutch/src/runtime/web/api/collection-siren.test.ts index 521495300..50b88b6e6 100644 --- a/projects/hutch/src/runtime/web/api/collection-siren.test.ts +++ b/projects/hutch/src/runtime/web/api/collection-siren.test.ts @@ -95,6 +95,7 @@ describe("toArticleCollectionEntity", () => { "status", "savedAt", "readAt", + "isRead", ]); }); diff --git a/projects/ios-readplace/Shared/SirenModels.swift b/projects/ios-readplace/Shared/SirenModels.swift index 916f7b2dc..c7c833986 100644 --- a/projects/ios-readplace/Shared/SirenModels.swift +++ b/projects/ios-readplace/Shared/SirenModels.swift @@ -137,6 +137,10 @@ struct ArticleProperties: Decodable { let status: String? let savedAt: String? let readAt: String? + /// The server's explicit presentational read-state. Optional so an older server + /// that doesn't emit it still decodes; the client falls back to deriving read + /// state from `status`/`readAt` only then. + let isRead: Bool? } struct SirenEntity: Decodable { @@ -387,7 +391,9 @@ extension Article { excerpt = props.excerpt imageURL = props.imageUrl.flatMap(URL.init(string:)) readTimeMinutes = props.estimatedReadTimeMinutes - isRead = props.status == "read" || props.readAt != nil + // Prefer the server's explicit read-state; fall back to deriving it from the + // status vocabulary only for an older server that doesn't emit `isRead`. + isRead = props.isRead ?? (props.status == "read" || props.readAt != nil) savedAt = props.savedAt.flatMap(SirenDate.parse) actions = entity.actions ?? [] links = entity.links ?? [] diff --git a/projects/ios-readplace/Tests/SirenDecodingTests.swift b/projects/ios-readplace/Tests/SirenDecodingTests.swift index 78fa5ab5b..9c7c01c6b 100644 --- a/projects/ios-readplace/Tests/SirenDecodingTests.swift +++ b/projects/ios-readplace/Tests/SirenDecodingTests.swift @@ -100,18 +100,34 @@ final class SirenDecodingTests: XCTestCase { XCTAssertFalse(article.isRead) } - func testStatusReadMarksAsRead() throws { + // The next two exercise the fallback derivation used only for an older server + // that doesn't emit the explicit read-state (the fixture omits `isRead`). + func testStatusReadMarksAsReadWhenServerOmitsIsRead() throws { let entity = try decodeEntity(Fixtures.article(status: "read")) let article = try XCTUnwrap(Article(entity: entity)) XCTAssertTrue(article.isRead) } - func testReadAtImpliesReadEvenWhenStatusUnread() throws { + func testReadAtImpliesReadWhenServerOmitsIsRead() throws { let entity = try decodeEntity(Fixtures.article(status: "unread", readAt: "2026-05-31T09:00:00.000Z")) let article = try XCTUnwrap(Article(entity: entity)) XCTAssertTrue(article.isRead) } + func testServerIsReadWinsOverTheStatusVocabulary() throws { + // The server says not-read even though `status` is "read": the explicit + // boolean is the client's read-state, not the status literal. + let entity = try decodeEntity(Fixtures.article(status: "read", isRead: false)) + let article = try XCTUnwrap(Article(entity: entity)) + XCTAssertFalse(article.isRead) + } + + func testServerIsReadTrueMarksReadRegardlessOfStatus() throws { + let entity = try decodeEntity(Fixtures.article(status: "unread", readAt: nil, isRead: true)) + let article = try XCTUnwrap(Article(entity: entity)) + XCTAssertTrue(article.isRead) + } + func testMinimalEntityFallsBackTitleToURL() throws { let json = """ { "properties": { "id": "x", "url": "https://example.com/x" } } diff --git a/projects/ios-readplace/Tests/TestSupport.swift b/projects/ios-readplace/Tests/TestSupport.swift index 506e9fc1b..1195e2fc7 100644 --- a/projects/ios-readplace/Tests/TestSupport.swift +++ b/projects/ios-readplace/Tests/TestSupport.swift @@ -136,7 +136,8 @@ enum Fixtures { readTime: Int? = 6, status: String = "unread", savedAt: String = "2026-05-30T10:00:00.000Z", - readAt: String? = nil + readAt: String? = nil, + isRead: Bool? = nil ) -> String { func field(_ key: String, _ value: String?) -> String { value.map { "\"\(key)\": \"\($0)\"" } ?? "\"\(key)\": null" @@ -144,6 +145,11 @@ enum Fixtures { func numField(_ key: String, _ value: Int?) -> String { value.map { "\"\(key)\": \($0)" } ?? "\"\(key)\": null" } + // Emitted only when set, so a fixture without it models an older server that + // doesn't advertise the explicit read-state. + func boolField(_ key: String, _ value: Bool?) -> String { + value.map { ", \"\(key)\": \($0)" } ?? "" + } return """ { "class": ["article"], @@ -159,7 +165,7 @@ enum Fixtures { \(numField("estimatedReadTimeMinutes", readTime)), "status": "\(status)", "savedAt": "\(savedAt)", - \(field("readAt", readAt)) + \(field("readAt", readAt))\(boolField("isRead", isRead)) }, "links": [{ "rel": ["read"], "href": "/queue/\(id)/view" }], "actions": [