Skip to content
Open
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
3 changes: 2 additions & 1 deletion apps/tv-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"dev:ios": "expo start --dev-client --ios",
"start": "expo start --dev-client",
"prebuild": "expo prebuild",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"test": "bun test src/lib/session.test.ts"
},
"dependencies": {
"@airwave/key-input": "workspace:*",
Expand Down
14 changes: 12 additions & 2 deletions apps/tv-native/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getServerUrl, getToken } from "./auth";
import { router } from "expo-router";

import { getServerUrl, getToken, invalidateToken } from "./auth";
import { getNetwork } from "./plex-connection";

/**
Expand All @@ -21,7 +23,8 @@ export class ApiError extends Error {

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken();
const res = await fetch(`${getServerUrl()}${path}`, {
const server = getServerUrl();
const res = await fetch(`${server}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Expand All @@ -30,6 +33,13 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
},
});
if (!res.ok) {
// A 401 means the server has no session for this bearer (expired / revoked) — clear it and go sign in
// again, keeping the server (tv-web / tv-tauri do the same from the guide). Network / 5xx / 403 failures
// never touch it. A late 401 from before a newer login, or from a previous server, is ignored.
if (res.status === 401 && token && token === getToken() && server === getServerUrl()) {
const invalidated = await invalidateToken(token, server);
if (invalidated && server === getServerUrl() && !getToken()) router.replace("/login");
}
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new ApiError(res.status, body?.error ?? `Request failed (${res.status})`);
}
Expand Down
40 changes: 32 additions & 8 deletions apps/tv-native/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const BAKED = (process.env.EXPO_PUBLIC_SERVER_URL ?? "").replace(/\/+$/, "");

let serverUrl = BAKED;
let token: string | null = null;
let tokenWrites: Promise<unknown> = Promise.resolve();

/** Hydrate the in-memory session from storage. Call once before rendering the app. */
export async function loadSession(): Promise<void> {
Expand Down Expand Up @@ -56,15 +57,38 @@ export async function clearServerUrl(): Promise<void> {
export function getToken(): string | null {
return token;
}

/**
* Every token write goes through one queue. A 401 invalidation re-checks, INSIDE the queue, that the token it
* was rejecting is still the current one: without that, a late 401 from a request sent before a fresh login
* could pass its check, then delete the new token once the login's write landed. A failed write doesn't
* block the next one.
*/
function writeToken(next: string | null, rejected?: { token: string; server: string }): Promise<boolean> {
const write = tokenWrites.then(async () => {
if (rejected && (token !== rejected.token || serverUrl !== rejected.server)) return false;
if (next) {
await CredStore.setItemAsync(TOKEN_KEY, next);
} else {
// Sign-out: clear better-auth's cookie/session alongside the bearer token so no stale session lingers.
await clearSyncCreds();
await CredStore.deleteItemAsync(TOKEN_KEY);
}
token = next;
return true;
});
tokenWrites = write.catch(() => {});
return write;
}

export async function setToken(next: string | null): Promise<void> {
if (next) {
await CredStore.setItemAsync(TOKEN_KEY, next);
} else {
// Sign-out: clear better-auth's cookie/session alongside the bearer token so no stale session lingers.
await clearSyncCreds();
await CredStore.deleteItemAsync(TOKEN_KEY);
}
token = next;
await writeToken(next);
}

/** The server rejected `rejectedToken` (401) — clear it, unless it has already been replaced or the server
* changed since the request went out. Resolves true only if this call cleared it. The server URL is kept. */
export function invalidateToken(rejectedToken: string, rejectedServer: string): Promise<boolean> {
return writeToken(null, { token: rejectedToken, server: rejectedServer });
}

/** Coerce user input into a base URL (add a scheme if missing, drop a trailing slash) — tv-web parity. */
Expand Down
86 changes: 86 additions & 0 deletions apps/tv-native/src/lib/session-test-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// A fresh Bun process per phase models JS module state being discarded on app termination. Only the OS
// storage, native-module and network boundaries are substituted; auth / cred-store / session-storage / api
// are the production modules. Adapted from the PR #63 suite (SHRIVES93) to today's storage (RN `Settings`).
import { mock } from "bun:test";
import { readFileSync, writeFileSync } from "node:fs";

const [file, operation, rawOptions] = process.argv.slice(2);
const options = JSON.parse(rawOptions ?? "{}");
const read = () => JSON.parse(readFileSync(file!, "utf8"));
function write(store: string, key: string, value: string | null) {
const data = read();
if (value === null) delete data[store][key];
else data[store][key] = value;
writeFileSync(file!, JSON.stringify(data));
}
const get = (store: string, key: string) => read()[store][key] ?? null;

mock.module("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async (key: string) => get("cache", key),
setItem: async (key: string, value: string) => write("cache", key, value),
removeItem: async (key: string) => write("cache", key, null),
},
}));
mock.module("react-native", () => ({
Platform: { OS: options.platform === "android" ? "android" : "ios", isTV: options.platform !== "ios" },
// RN `Settings` = NSUserDefaults on Apple TV.
Settings: {
get: (key: string) => get("defaults", key),
set: (values: Record<string, string>) => {
for (const [k, v] of Object.entries(values)) write("defaults", k, v);
},
},
}));
mock.module("expo-secure-store", () => ({
getItemAsync: async (key: string) => get("secure", key),
setItemAsync: async (key: string, value: string) => write("secure", key, value),
deleteItemAsync: async (key: string) => write("secure", key, null),
getItem: (key: string) => get("secure", key),
setItem: (key: string, value: string) => write("secure", key, value),
}));
let redirect: string | null = null;
mock.module("expo-router", () => ({ router: { replace: (route: string) => { redirect = route; } } }));
mock.module("./plex-connection", () => ({ getNetwork: () => "local" }));
process.env.EXPO_PUBLIC_SERVER_URL = "";

const auth = await import("./auth");
const { SyncCredStore } = await import("./cred-store");

// The entry gate in app/index.tsx.
const route = () => (!auth.hasServerUrl() ? "/setup" : auth.getToken() ? "/guide" : "/login");

let error = false;
try {
await auth.loadSession();
if (operation === "save") {
await auth.setServerUrl("example.test///");
await auth.setToken("test-token");
SyncCredStore.setItem("cg_cookie", "test-cookie");
} else if (operation === "overlap") {
await Promise.all([auth.invalidateToken("test-token", auth.getServerUrl()), auth.setToken("new-test-token")]);
} else if (operation === "stale-invalidation") {
await Promise.all([auth.setToken("new-test-token"), auth.invalidateToken("test-token", auth.getServerUrl())]);
} else if (operation === "request") {
globalThis.fetch = (async () => {
if (options.networkFailure) throw new TypeError("offline");
if (options.newToken) await auth.setToken("new-test-token");
if (options.newServer) await auth.setServerUrl("other.test");
return new Response(JSON.stringify({ packages: [] }), { status: options.status ?? 200 });
}) as unknown as typeof fetch;
const { api } = await import("./api");
await api.packages();
}
} catch {
error = true;
}
console.log(
JSON.stringify({
error,
redirect,
route: route(),
server: auth.getServerUrl(),
hasToken: !!auth.getToken(),
cookie: SyncCredStore.getItem("cg_cookie"),
}),
);
95 changes: 95 additions & 0 deletions apps/tv-native/src/lib/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

// 401 / session-recovery regressions, each phase in a fresh process (see session-test-worker.ts).
// Adapted from the PR #63 suite (SHRIVES93), scoped to the 401 lifecycle.

const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true });
});

function fixture() {
const dir = mkdtempSync(join(tmpdir(), "airwave-session-"));
dirs.push(dir);
const file = join(dir, "storage.json");
writeFileSync(file, JSON.stringify({ cache: {}, defaults: {}, secure: {} }));
return {
run(operation = "launch", options = {}) {
const proc = Bun.spawnSync([process.execPath, join(import.meta.dir, "session-test-worker.ts"), file, operation, JSON.stringify(options)]);
if (proc.exitCode !== 0) throw new Error(proc.stderr.toString());
return JSON.parse(proc.stdout.toString());
},
data: () => JSON.parse(readFileSync(file, "utf8")),
};
}

function signedIn(options = {}) {
const app = fixture();
expect(app.run("save", options)).toMatchObject({ error: false, route: "/guide" });
return app;
}

describe("Apple TV session recovery after the server rejects the token", () => {
test("a valid response preserves the session", () => {
expect(signedIn().run("request")).toMatchObject({ error: false, redirect: null, route: "/guide" });
});

test("401 clears the session, keeps the server, and goes to login — across a restart", () => {
const app = signedIn();
expect(app.run("request", { status: 401 })).toMatchObject({
error: true,
redirect: "/login",
hasToken: false,
server: "https://example.test",
cookie: null,
});
expect(app.run()).toMatchObject({ route: "/login", server: "https://example.test" });
});

test("signing in again after a 401 restores the guide", () => {
const app = signedIn();
app.run("request", { status: 401 });
expect(app.run("save")).toMatchObject({ route: "/guide" });
expect(app.run("request")).toMatchObject({ error: false, route: "/guide" });
});

for (const options of [{ networkFailure: true }, { status: 503 }, { status: 403 }]) {
test(`temporary / permission failure keeps the session: ${JSON.stringify(options)}`, () => {
expect(signedIn().run("request", options)).toMatchObject({
error: true,
redirect: null,
hasToken: true,
server: "https://example.test",
route: "/guide",
});
});
}

test("a late 401 does not invalidate a newer login", () => {
expect(signedIn().run("request", { status: 401, newToken: true })).toMatchObject({ hasToken: true, redirect: null });
});

test("a late 401 from the previous server does not sign out of the new one", () => {
expect(signedIn().run("request", { status: 401, newServer: true })).toMatchObject({ hasToken: true, redirect: null });
});

for (const operation of ["overlap", "stale-invalidation"]) {
test(`401 invalidation cannot erase a concurrent login: ${operation}`, () => {
const app = signedIn();
expect(app.run(operation)).toMatchObject({ error: false, hasToken: true, route: "/guide" });
expect(app.data().defaults["cg-tv-token"]).toBe("new-test-token");
});
}

test("iOS / Android: 401 clears the SecureStore token and keeps the server", () => {
for (const platform of ["ios", "android"]) {
const app = signedIn({ platform });
expect(app.run("request", { platform, status: 401 })).toMatchObject({ redirect: "/login", hasToken: false });
expect(app.data().secure["cg-tv-token"]).toBeUndefined();
expect(app.run("launch", { platform })).toMatchObject({ route: "/login", server: "https://example.test" });
}
});
});