From 86a0f524ada62c20dd1aeb2c7d7541184a263d74 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Sat, 4 Jul 2026 23:20:38 +1000 Subject: [PATCH] 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") + } }