Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions projects/hutch/src/runtime/web/api/collection-siren.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
11 changes: 11 additions & 0 deletions projects/hutch/src/runtime/web/api/collection-siren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
};
}
9 changes: 9 additions & 0 deletions projects/ios-readplace/App/AffordancePresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion projects/ios-readplace/App/ReadingListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
33 changes: 22 additions & 11 deletions projects/ios-readplace/Shared/ReadplaceAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions projects/ios-readplace/Tests/ReadingListViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}
}
16 changes: 16 additions & 0 deletions projects/ios-readplace/Tests/ReadplaceAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}