Skip to content
C.W.K.
Lesson 02 of 05 · published

The Hub Session as the Credential

~14 min · authentication, cookies, sessions, origins

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Who is asking?

On the Mac that serves Firelink, Voyager asks for the catalog over loopback, and Firelink's loopback session answers by itself. Nothing is read from a cookie store and nothing extra is sent. Everywhere else the request crosses the tailnet, and there Firelink answers only two kinds of caller: a launcher device paired with it, which presents a token as Authorization: Bearer …, or the hub's own signed-in web session. Anyone else gets a 401.

FirelinkHubSession.context(for:) draws that line from the hub address alone. A host of localhost, 127.0.0.1 or ::1 is loopback, and anything else is the tailnet.

Option 2: no pairing

The plan's first answer was the launcher's answer: Voyager would pair as a read-only device and keep the token in the Keychain. On 2026-10-07 Dad chose the other option. The plan records it as "option 2": no pairing, and away from the service host the catalog carries the session that Dad's own sign-in to the Firelink hub already left in Voyager.

The record keeps the choice without an argument for it, so look at what the code shows it saved. A paired device is a second credential with a life of its own: issued per device, stored, revoked when a Mac leaves the family. The hub session already exists, Dad already makes it by signing in, and it ends whenever the hub ends it. Voyager keeps none of it. Invariant 12 puts it in one clause: "the hub's session is WebKit's, read for one request and kept nowhere."

A cookie, never a Bearer

The tempting shortcut was a header slot the client already has. The kit's fetch takes an optional token and sends it as a Bearer. Voyager never fills it. Firelink looks for a presented launcher token before it looks at any session, takes whatever arrives as Authorization: Bearer for one, and refuses a token that is not a launcher's. A web session sent there would be refused as a bad launcher token, though the same value in a cookie is valid. The slot decides how a value is read.

So the session rides as a cookie, chosen by name. Firelink mints cwkfirelink_session_tls over HTTPS and cwkfirelink_session over plain HTTP, and a session minted over one scheme is refused over the other, so the name follows the hub's scheme. The name matters for a second reason. Cookies do not isolate by port, and Pippa's twin can sit on the same tailnet host as the hub, on another port, with her own pippa_session cookie. A read of every cookie for that host would pick hers up too. Asking for the one name keeps her session out of Firelink's request.

Track 3 showed the engine child's half: the child reads the identity's store itself and hands up a single Cookie header built only from the names asked for. The second block below is the parent's half. It asks only the default identity, the one family apps open in, and never a private window's store.

Only to the hub's own origin

FirelinkHubSession.transport wraps the kit's transport and adds the cookie to a request only when its scheme, host and port equal the hub's, with an omitted port read as the scheme's own. The kit's transport refuses redirects, so a 3xx cannot carry the header on to another host. The third block proves the rule against a recording transport. The catalog on the hub's port gets the cookie. Pippa's port on the same host, the same address over plain HTTP, and another host get nothing. An explicit :443 is the same origin as no port at all. With no session, nothing is added.

When Firelink says no

A refusal off the service host sets needsSignIn, and the message says which case this is: no session yet ("Firelink lists the family apps only after you sign in to it here in Voyager"), or a session the hub no longer accepts. The Home shows one action, Sign In to Firelink, which opens the hub in a tab of the default identity. A saved catalog, if there is one, stays on screen below it.

After that nobody presses Refresh. The engine reports every change to an identity's cookies, and while the catalog is waiting for a session, a change in the default identity starts a look. The model remembers the session the hub refused only as its hashValue for this run, never the value, and skips a cookie with that hash, so a stale cookie is not sent again on every unrelated cookie change. A session that lands while a read is already in flight is not lost: a flag marks it, and the model looks again when that read ends. Dad signs in once, in the tab that opened, and the tiles appear on the Home on their own.

Principle: Reuse a credential the person already makes, in the slot its issuer reads it from, and send it to exactly one origin. Every one of those three can be checked by a test that records what reached the wire.

Code

cwkVoyager, Sources/VoyagerCore/FamilyCatalog.swift: the hub session, its context, its cookie name and the one origin it may ride to (verbatim, lines 5-49)·swift
/// Firelink's hub web session, as the catalog request carries it off the service host.
///
/// Off the service host Firelink answers its catalog only to a paired launcher device or
/// to the hub's own signed-in session (cwkFirelink `launcher_principal`, then
/// `request_session`); on the service host the loopback session answers by itself.
/// Voyager pairs no device (Dad, 2026-10-07: reuse the hub sign-in, option 2).
/// It sends the session cookie Dad's sign-in to the hub left in the identity
/// family apps open in, read for each request and kept nowhere. Never as a
/// Bearer header: Firelink takes any Bearer for a launcher token and refuses
/// one that is not.
public enum FirelinkHubSession {
    /// Where the kit's client asks (`FamilyCatalogClient.fetch`).
    public static let catalogPath = "/api/v1/launcher/catalog"

    /// The catalog context for a hub address: loopback on the Mac that serves
    /// it, the tailnet everywhere else.
    public static func context(for hub: URL) -> String {
        ["localhost", "127.0.0.1", "::1", "[::1]"].contains(hub.host?.lowercased() ?? "") ? "loopback" : "tailnet"
    }

    /// Firelink's session cookie for the hub's scheme (`COOKIE_HTTPS_NAME` and
    /// `COOKIE_HTTP_NAME` in its `backend/auth.py`); a session minted over one
    /// scheme is refused over the other. Nil on loopback, which needs none.
    public static func cookieName(for hub: URL) -> String? {
        guard context(for: hub) != "loopback" else { return nil }
        switch hub.scheme?.lowercased() {
        case "https": return "cwkfirelink_session_tls"
        case "http": return "cwkfirelink_session"
        default: return nil
        }
    }

    /// `base` with `cookie` added to requests for the hub's own origin
    /// (scheme, host and port) and to nothing else. The kit's transport
    /// refuses redirects, so the header never follows one to another host.
    public static func transport(_ base: @escaping HTTPTransport, hub: URL, cookie: String?) -> HTTPTransport {
        guard let cookie, !cookie.isEmpty, let hubOrigin = servedOrigin(hub) else { return base }
        return { request in
            var request = request
            if let url = request.url, servedOrigin(url) == hubOrigin {
                request.setValue(cookie, forHTTPHeaderField: "Cookie")
            }
            return try await base(request)
        }
    }
cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: the parent reads the session from the default identity, and a cookie change may be Dad signing in (verbatim, lines 66-83)·swift
    /// The hub session as the identity family apps open in holds it (the
    /// default; a private window's store never). Read for one request, kept
    /// nowhere, and only Firelink's own cookie for the hub's own host.
    private var familyHubSession: FamilyCatalogModel.SessionReader {
        { [weak self] hub in
            guard let self, let origin = PageOrigin(url: hub), let name = FirelinkHubSession.cookieName(for: hub) else { return nil }
            return await engine.cookieHeader(
                for: origin, path: FirelinkHubSession.catalogPath, named: [name], identity: defaultIdentity)
        }
    }

    /// An identity's cookies changed. While the hub refuses the catalog, a
    /// change in the identity family apps open in may be Dad signing in.
    func cookiesChanged(in identity: IdentityID) {
        guard identity == defaultIdentity, identity != .privateSession, familyCatalog.awaitsHubSession else { return }
        let hub = settings.value.familyHub, session = familyHubSession
        Task { await familyCatalog.hubSessionMayHaveChanged(baseURL: hub, session: session) }
    }
cwkVoyager, Tests/VoyagerCoreTests/FamilyCatalogModelTests.swift: what reached the wire, address by address (verbatim, lines 137-155)·swift
    @Test func theSessionRidesOnlyToTheHubsOwnOrigin() async throws {
        let wire = Wire()
        let base: HTTPTransport = { request in await wire.record(request); return HTTPReply(statusCode: 204, data: Data()) }
        let session = "cwkfirelink_session_tls=abc"
        let carried = FirelinkHubSession.transport(base, hub: Self.hub, cookie: session)
        for address in [
            "https://hub.tail.test:9401/api/v1/launcher/catalog?context=tailnet",
            "https://hub.tail.test:9443/api/v1/session",   // Pippa's twin on the same host
            "http://hub.tail.test:9401/api/v1/launcher/catalog",
            "https://other.tail.test:9401/api/v1/launcher/catalog",
        ] { _ = try await carried(URLRequest(url: url(address))) }
        // The scheme's own port is the same origin as the address that omits it.
        _ = try await FirelinkHubSession.transport(base, hub: url("https://hub.tail.test/"), cookie: session)(
            URLRequest(url: url("https://hub.tail.test:443/api/v1/launcher/catalog")))
        // No session, nothing added.
        _ = try await FirelinkHubSession.transport(base, hub: Self.hub, cookie: nil)(
            URLRequest(url: url("https://hub.tail.test:9401/api/v1/launcher/catalog")))
        #expect(await wire.sent.map { $0.cookie } == [session, nil, nil, nil, session, nil])
    }

External links

Exercise

Your client calls a sibling service that already signs people in through its web pages. Instead of inventing a device token, reuse that web session: write a transport wrapper that adds one named cookie to requests whose scheme, host and port equal the service's, and to nothing else. Then write a test with a recording transport that sends five requests (the service, another port on the same host, plain HTTP to the same host, another host, and the same origin with the default port written out) and asserts exactly which ones carried the cookie.
Hint
Normalise the port before comparing: an address with no port means the scheme's default (443 or 80). Pick the cookie by name rather than by host, since a cookie store returns every cookie for a host regardless of port. And make sure the transport underneath refuses redirects, or the header can follow a 3xx somewhere you never checked.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.