diff --git a/apps/gittensory-extension/auth.js b/apps/gittensory-extension/auth.js
new file mode 100644
index 0000000000..bc5d7b8294
--- /dev/null
+++ b/apps/gittensory-extension/auth.js
@@ -0,0 +1,177 @@
+export const DEFAULT_API_ORIGIN = "https://gittensory-api.aethereal.dev";
+export const EXTENSION_SESSION_REQUIRED_MESSAGE = "Set an extension session token in Gittensory extension options.";
+export const EXTENSION_SESSION_EXPIRED_MESSAGE = "Extension session expired or revoked. Create a fresh extension token in Gittensory.";
+
+export const STORAGE_KEYS = {
+ apiOrigin: "apiOrigin",
+ sessionToken: "sessionToken",
+ sessionExpiresAt: "sessionExpiresAt",
+ sessionLogin: "sessionLogin",
+ sessionScopes: "sessionScopes",
+ lastAuthenticatedAt: "lastAuthenticatedAt",
+};
+
+const LOCAL_SESSION_KEYS = [
+ STORAGE_KEYS.sessionToken,
+ STORAGE_KEYS.sessionExpiresAt,
+ STORAGE_KEYS.sessionLogin,
+ STORAGE_KEYS.sessionScopes,
+ STORAGE_KEYS.lastAuthenticatedAt,
+];
+
+const GITHUB_TOKEN_PREFIX_PATTERN = /^(ghp|gho|ghu|ghs|ghr|github_pat)_/i;
+const GITTENSORY_SESSION_PATTERN = /^gts_[a-f0-9]{64}$/i;
+
+export function extensionStorage(chromeLike = globalThis.chrome) {
+ if (!chromeLike?.storage?.local || !chromeLike?.storage?.sync) {
+ throw new Error("Browser extension storage is unavailable.");
+ }
+ return { local: chromeLike.storage.local, sync: chromeLike.storage.sync };
+}
+
+export function normalizeApiOrigin(value) {
+ const raw = typeof value === "string" && value.trim() ? value.trim() : DEFAULT_API_ORIGIN;
+ try {
+ const url = new URL(raw);
+ if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
+ return DEFAULT_API_ORIGIN;
+ }
+ return url.origin;
+ } catch {
+ return DEFAULT_API_ORIGIN;
+ }
+}
+
+export function looksLikeGitHubPersonalAccessToken(value) {
+ return GITHUB_TOKEN_PREFIX_PATTERN.test(String(value ?? "").trim());
+}
+
+export function validateExtensionSessionToken(value) {
+ const token = String(value ?? "").trim();
+ if (!token) throw new Error(EXTENSION_SESSION_REQUIRED_MESSAGE);
+ if (looksLikeGitHubPersonalAccessToken(token)) {
+ throw new Error("GitHub personal access tokens are not accepted. Create a Gittensory extension token instead.");
+ }
+ if (!GITTENSORY_SESSION_PATTERN.test(token)) {
+ throw new Error("Extension tokens must be Gittensory session tokens that start with gts_.");
+ }
+ return token;
+}
+
+export async function loadExtensionSession(storage = extensionStorage()) {
+ const [syncState, localState] = await Promise.all([
+ storage.sync.get([STORAGE_KEYS.apiOrigin, ...LOCAL_SESSION_KEYS]),
+ storage.local.get(LOCAL_SESSION_KEYS),
+ ]);
+ await purgeLegacySyncSession(syncState, storage);
+ const sessionToken = typeof localState.sessionToken === "string" ? localState.sessionToken : "";
+ const expiresAt = typeof localState.sessionExpiresAt === "string" ? localState.sessionExpiresAt : "";
+ const sessionScopes = Array.isArray(localState.sessionScopes)
+ ? localState.sessionScopes.filter((scope) => typeof scope === "string")
+ : [];
+ return {
+ apiOrigin: normalizeApiOrigin(syncState.apiOrigin),
+ sessionToken,
+ expiresAt,
+ login: typeof localState.sessionLogin === "string" ? localState.sessionLogin : "",
+ scopes: sessionScopes,
+ lastAuthenticatedAt: typeof localState.lastAuthenticatedAt === "string" ? localState.lastAuthenticatedAt : "",
+ expired: isExpired(expiresAt),
+ };
+}
+
+export async function saveExtensionApiOrigin(apiOrigin, storage = extensionStorage()) {
+ await storage.sync.set({ [STORAGE_KEYS.apiOrigin]: normalizeApiOrigin(apiOrigin) });
+}
+
+export async function storeExtensionSessionToken(session, storage = extensionStorage()) {
+ const token = validateExtensionSessionToken(session?.token);
+ const next = {
+ [STORAGE_KEYS.sessionToken]: token,
+ [STORAGE_KEYS.sessionExpiresAt]: typeof session?.expiresAt === "string" ? session.expiresAt.trim() : "",
+ [STORAGE_KEYS.sessionLogin]: typeof session?.login === "string" ? session.login.trim() : "",
+ [STORAGE_KEYS.sessionScopes]: Array.isArray(session?.scopes)
+ ? session.scopes.filter((scope) => typeof scope === "string")
+ : [],
+ [STORAGE_KEYS.lastAuthenticatedAt]: new Date().toISOString(),
+ };
+ await storage.local.set(next);
+ await storage.sync.remove(LOCAL_SESSION_KEYS);
+ return next;
+}
+
+export async function clearExtensionSession(storage = extensionStorage()) {
+ await storage.local.remove(LOCAL_SESSION_KEYS);
+ await storage.sync.remove(LOCAL_SESSION_KEYS);
+}
+
+export async function requireUsableExtensionSession(storage = extensionStorage()) {
+ const session = await loadExtensionSession(storage);
+ if (!session.sessionToken) throw new Error(EXTENSION_SESSION_REQUIRED_MESSAGE);
+ if (session.expired) {
+ await clearExtensionSession(storage);
+ throw new Error(EXTENSION_SESSION_EXPIRED_MESSAGE);
+ }
+ return session;
+}
+
+export async function requestPullContext(target, options = {}) {
+ const storage = options.storage ?? extensionStorage();
+ const session = await requireUsableExtensionSession(storage);
+ const url = new URL("/v1/extension/pull-context", session.apiOrigin);
+ url.searchParams.set("owner", target.owner);
+ url.searchParams.set("repo", target.repo);
+ url.searchParams.set("pullNumber", String(target.pullNumber));
+ return fetchExtensionJson(url, session.sessionToken, { ...options, storage });
+}
+
+export async function logoutExtensionSession(options = {}) {
+ const storage = options.storage ?? extensionStorage();
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const session = await loadExtensionSession(storage);
+ if (session.sessionToken && !session.expired) {
+ const url = new URL("/v1/auth/logout", session.apiOrigin);
+ await fetchImpl(url.toString(), {
+ method: "POST",
+ headers: {
+ accept: "application/json",
+ authorization: `Bearer ${session.sessionToken}`,
+ },
+ }).catch(() => undefined);
+ }
+ await clearExtensionSession(storage);
+ return { ok: true };
+}
+
+async function fetchExtensionJson(url, token, options = {}) {
+ const storage = options.storage ?? extensionStorage();
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const response = await fetchImpl(url.toString(), {
+ headers: {
+ accept: "application/json",
+ authorization: `Bearer ${token}`,
+ },
+ });
+ const payload = await response.json().catch(() => ({}));
+ if (response.status === 401 || isExtensionAuthFailure(response.status, payload)) {
+ await clearExtensionSession(storage);
+ throw new Error(EXTENSION_SESSION_EXPIRED_MESSAGE);
+ }
+ if (!response.ok) throw new Error(typeof payload.error === "string" ? payload.error : `${response.status} ${response.statusText}`);
+ return payload;
+}
+
+function isExpired(expiresAt, now = Date.now()) {
+ if (!expiresAt) return false;
+ const time = Date.parse(expiresAt);
+ return Number.isFinite(time) && time <= now;
+}
+
+function isExtensionAuthFailure(status, payload) {
+ if (status !== 403) return false;
+ return ["browser_session_required", "extension_session_required", "insufficient_scope", "unauthorized"].includes(String(payload?.error ?? ""));
+}
+
+async function purgeLegacySyncSession(syncState, storage) {
+ if (LOCAL_SESSION_KEYS.some((key) => syncState[key] !== undefined)) await storage.sync.remove(LOCAL_SESSION_KEYS);
+}
diff --git a/apps/gittensory-extension/background.js b/apps/gittensory-extension/background.js
index a3b8f4a130..6266b1077f 100644
--- a/apps/gittensory-extension/background.js
+++ b/apps/gittensory-extension/background.js
@@ -1,29 +1,8 @@
-const DEFAULT_API_ORIGIN = "https://gittensory-api.aethereal.dev";
+import { logoutExtensionSession, requestPullContext } from "./auth.js";
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
- if (!message || message.type !== "gittensory:pull-context") return false;
- void loadPullContext(message)
- .then((payload) => sendResponse({ ok: true, payload }))
- .catch((error) => sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }));
+ if (!message || !["gittensory:pull-context", "gittensory:logout"].includes(message.type)) return false;
+ const task = message.type === "gittensory:logout" ? logoutExtensionSession() : requestPullContext(message);
+ void task.then((payload) => sendResponse({ ok: true, payload })).catch((error) => sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }));
return true;
});
-
-async function loadPullContext(message) {
- const settings = await chrome.storage.sync.get(["apiOrigin", "sessionToken"]);
- const apiOrigin = String(settings.apiOrigin || DEFAULT_API_ORIGIN).replace(/\/$/, "");
- const token = String(settings.sessionToken || "");
- if (!token) throw new Error("Set an extension session token in Gittensory extension options.");
- const url = new URL(`${apiOrigin}/v1/extension/pull-context`);
- url.searchParams.set("owner", message.owner);
- url.searchParams.set("repo", message.repo);
- url.searchParams.set("pullNumber", String(message.pullNumber));
- const response = await fetch(url.toString(), {
- headers: {
- accept: "application/json",
- authorization: `Bearer ${token}`,
- },
- });
- const payload = await response.json().catch(() => ({}));
- if (!response.ok) throw new Error(payload.error || `${response.status} ${response.statusText}`);
- return payload;
-}
diff --git a/apps/gittensory-extension/options.html b/apps/gittensory-extension/options.html
index 6608838819..452b956c89 100644
--- a/apps/gittensory-extension/options.html
+++ b/apps/gittensory-extension/options.html
@@ -39,6 +39,20 @@
font-weight: 700;
padding: 9px 12px;
}
+ button.secondary {
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ background: transparent;
+ color: inherit;
+ }
+ button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+ .actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
p {
color: rgba(244, 247, 245, 0.68);
}
@@ -50,7 +64,7 @@
Gittensory extension
- Paste an extension session token from the Gittensory app. The token is stored in browser sync storage and only sent to the Gittensory API.
+ Paste an extension session token from the Gittensory app. Session tokens stay in browser local storage, are never synced, and GitHub personal access tokens are rejected.
diff --git a/apps/gittensory-extension/options.js b/apps/gittensory-extension/options.js
index 22bc44c8ca..d71903bae7 100644
--- a/apps/gittensory-extension/options.js
+++ b/apps/gittensory-extension/options.js
@@ -1,23 +1,80 @@
-const DEFAULT_API_ORIGIN = "https://gittensory-api.aethereal.dev";
+import {
+ DEFAULT_API_ORIGIN,
+ clearExtensionSession,
+ loadExtensionSession,
+ logoutExtensionSession,
+ saveExtensionApiOrigin,
+ storeExtensionSessionToken,
+} from "./auth.js";
const form = document.querySelector("#settings");
const status = document.querySelector("#status");
const apiOrigin = document.querySelector("#apiOrigin");
const sessionToken = document.querySelector("#sessionToken");
+const sessionExpiresAt = document.querySelector("#sessionExpiresAt");
+const sessionSummary = document.querySelector("#sessionSummary");
+const logout = document.querySelector("#logout");
+const clearLocal = document.querySelector("#clearLocal");
-void chrome.storage.sync.get(["apiOrigin", "sessionToken"]).then((settings) => {
- apiOrigin.value = settings.apiOrigin || DEFAULT_API_ORIGIN;
- sessionToken.value = settings.sessionToken || "";
-});
+void refreshSettings();
form.addEventListener("submit", async (event) => {
event.preventDefault();
- await chrome.storage.sync.set({
- apiOrigin: apiOrigin.value.trim() || DEFAULT_API_ORIGIN,
- sessionToken: sessionToken.value.trim(),
- });
- status.textContent = "Saved.";
+ try {
+ await saveExtensionApiOrigin(apiOrigin.value.trim() || DEFAULT_API_ORIGIN);
+ const token = sessionToken.value.trim();
+ if (token) {
+ await storeExtensionSessionToken({ token, expiresAt: sessionExpiresAt.value.trim(), scopes: ["extension:pull_context"] });
+ }
+ await refreshSettings();
+ showStatus(token ? "Extension session saved locally." : "Settings saved.");
+ } catch (error) {
+ showStatus(error instanceof Error ? error.message : String(error));
+ }
+});
+
+logout.addEventListener("click", async () => {
+ logout.disabled = true;
+ try {
+ await logoutExtensionSession();
+ await refreshSettings();
+ showStatus("Logged out and cleared the local extension session.");
+ } catch (error) {
+ await clearExtensionSession();
+ await refreshSettings();
+ showStatus(error instanceof Error ? error.message : String(error));
+ } finally {
+ logout.disabled = false;
+ }
+});
+
+clearLocal.addEventListener("click", async () => {
+ await clearExtensionSession();
+ await refreshSettings();
+ showStatus("Local extension session cleared.");
+});
+
+async function refreshSettings() {
+ const settings = await loadExtensionSession();
+ apiOrigin.value = settings.apiOrigin || DEFAULT_API_ORIGIN;
+ sessionToken.value = "";
+ sessionToken.placeholder = settings.sessionToken ? "Stored locally - leave blank to keep current token" : "Paste gts_ extension session token";
+ sessionExpiresAt.value = settings.expiresAt || "";
+ logout.disabled = !settings.sessionToken;
+ clearLocal.disabled = !settings.sessionToken;
+ if (!settings.sessionToken) {
+ sessionSummary.textContent = "No extension session stored.";
+ } else if (settings.expired) {
+ sessionSummary.textContent = "Stored extension session is expired. Save a fresh token or clear local state.";
+ } else {
+ const expires = settings.expiresAt ? ` Expires ${settings.expiresAt}.` : "";
+ sessionSummary.textContent = `Extension session stored in browser local storage.${expires}`;
+ }
+}
+
+function showStatus(message) {
+ status.textContent = message;
window.setTimeout(() => {
status.textContent = "";
- }, 1800);
-});
+ }, 2600);
+}
diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json
index cda631c62e..91a3fe545c 100644
--- a/apps/gittensory-ui/public/openapi.json
+++ b/apps/gittensory-ui/public/openapi.json
@@ -9317,7 +9317,7 @@
"GittensoryBearer": {
"type": "http",
"scheme": "bearer",
- "description": "Static API/MCP token or GitHub device-flow Gittensory session token."
+ "description": "Static API/MCP token, GitHub device-flow Gittensory session token, or extension-scoped Gittensory session token where supported. GitHub personal access tokens are not accepted."
},
"GittensorySessionCookie": {
"type": "apiKey",
@@ -10998,6 +10998,9 @@
},
"401": {
"description": "Unauthorized"
+ },
+ "403": {
+ "description": "Extension-scoped session required"
}
},
"security": [
diff --git a/apps/gittensory-ui/src/routes/extension.tsx b/apps/gittensory-ui/src/routes/extension.tsx
index 81d928fca7..4e489c0f6d 100644
--- a/apps/gittensory-ui/src/routes/extension.tsx
+++ b/apps/gittensory-ui/src/routes/extension.tsx
@@ -105,7 +105,10 @@ function ExtensionPage() {
Privacy posture. The extension does not read the PR diff, post comments,
or open issues. It calls the same private Gittensory API endpoints you already use, then
- renders the response in your local DOM. No analytics SDKs, no third-party trackers.
+ renders the response in your local DOM. Its permission boundary is storage, GitHub PR
+ pages, and the configured Gittensory API origin. Extension tokens are scoped to pull
+ context, stored in browser local storage rather than sync storage, and cleared on logout,
+ expiry, or revoked-session responses. GitHub personal access tokens are rejected.
>
diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs
index c27be453ca..c84a5c47be 100644
--- a/scripts/build-extension.mjs
+++ b/scripts/build-extension.mjs
@@ -18,7 +18,7 @@ rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
mkdirSync(dirname(zipPath), { recursive: true });
-for (const file of ["manifest.json", "background.js", "content.js", "styles.css", "options.html", "options.js"]) {
+for (const file of ["manifest.json", "auth.js", "background.js", "content.js", "styles.css", "options.html", "options.js"]) {
cpSync(resolve(source, file), resolve(outDir, file));
}
diff --git a/src/api/routes.ts b/src/api/routes.ts
index dadbdf53a7..f38da5edb1 100644
--- a/src/api/routes.ts
+++ b/src/api/routes.ts
@@ -776,6 +776,8 @@ export function createApp() {
});
app.get("/v1/extension/pull-context", async (c) => {
+ const identity = await authenticateRequestIdentity(c);
+ if (!identity || identity.kind !== "session" || !isExtensionScopedSession(identity)) return c.json({ error: "extension_session_required" }, 403);
const owner = c.req.query("owner") ?? "";
const repoName = c.req.query("repo") ?? "";
const pullNumber = Number(c.req.query("pullNumber") ?? "");
diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts
index 86754a7485..5c25d209f5 100644
--- a/src/openapi/spec.ts
+++ b/src/openapi/spec.ts
@@ -558,6 +558,7 @@ export function buildOpenApiSpec() {
200: { description: "Browser extension PR context overlay payload", content: { "application/json": { schema: z.record(z.unknown()) } } },
400: { description: "Invalid pull context query" },
401: { description: "Unauthorized" },
+ 403: { description: "Extension-scoped session required" },
},
});
registry.registerPath({
@@ -647,7 +648,7 @@ function applySecurityMetadata(document: GeneratedOpenApiDocument): GeneratedOpe
GittensoryBearer: {
type: "http",
scheme: "bearer",
- description: "Static API/MCP token or GitHub device-flow Gittensory session token.",
+ description: "Static API/MCP token, GitHub device-flow Gittensory session token, or extension-scoped Gittensory session token where supported. GitHub personal access tokens are not accepted.",
},
GittensorySessionCookie: {
type: "apiKey",
diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts
index 49d301e197..a64283bfe4 100644
--- a/test/integration/api.test.ts
+++ b/test/integration/api.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { createSessionForGitHubUser } from "../../src/auth/security";
+import { createSessionForGitHubUser, hashToken } from "../../src/auth/security";
import {
upsertBounty,
upsertBurdenForecast,
@@ -1439,6 +1439,13 @@ describe("api routes", () => {
expect(extensionOverview.status).toBe(403);
await expect(extensionOverview.json()).resolves.toMatchObject({ error: "insufficient_scope" });
+ const staticExtensionContext = await app.request("/v1/extension/pull-context?owner=entrius&repo=allways-ui&pullNumber=12", { headers: apiHeaders(env) }, env);
+ expect(staticExtensionContext.status).toBe(403);
+ await expect(staticExtensionContext.json()).resolves.toMatchObject({ error: "extension_session_required" });
+ const fullBrowserSessionExtensionContext = await app.request("/v1/extension/pull-context?owner=entrius&repo=allways-ui&pullNumber=12", { headers: cookieHeaders }, env);
+ expect(fullBrowserSessionExtensionContext.status).toBe(403);
+ await expect(fullBrowserSessionExtensionContext.json()).resolves.toMatchObject({ error: "extension_session_required" });
+
const fallbackOriginEnv = createTestEnv({ ADMIN_GITHUB_LOGINS: "oktofeesh1" });
delete (fallbackOriginEnv as Partial).PUBLIC_API_ORIGIN;
const { token: noIdToken } = await createSessionForGitHubUser(fallbackOriginEnv, { login: "oktofeesh1" });
@@ -1481,6 +1488,29 @@ describe("api routes", () => {
pullNumber: 99,
panels: expect.arrayContaining([expect.objectContaining({ label: "Contributor", badge: "unknown" })]),
});
+
+ const expiringExtensionSession = await app.request("/v1/auth/extension/session", { method: "POST", headers: cookieHeaders }, env);
+ expect(expiringExtensionSession.status).toBe(201);
+ const expiringExtensionSessionBody = (await expiringExtensionSession.json()) as { token: string };
+ await env.DB.prepare("update auth_sessions set expires_at = ? where token_hash = ?").bind("2020-01-01T00:00:00.000Z", await hashToken(expiringExtensionSessionBody.token)).run();
+ const expiredExtensionContext = await app.request(
+ "/v1/extension/pull-context?owner=entrius&repo=allways-ui&pullNumber=12",
+ { headers: { authorization: `Bearer ${expiringExtensionSessionBody.token}` } },
+ env,
+ );
+ expect(expiredExtensionContext.status).toBe(401);
+ await expect(expiredExtensionContext.json()).resolves.toMatchObject({ error: "unauthorized" });
+
+ const extensionLogout = await app.request("/v1/auth/logout", { method: "POST", headers: { authorization: `Bearer ${extensionSessionBody.token}` } }, env);
+ expect(extensionLogout.status).toBe(200);
+ await expect(extensionLogout.json()).resolves.toMatchObject({ ok: true, revoked: true });
+ const revokedExtensionContext = await app.request(
+ "/v1/extension/pull-context?owner=entrius&repo=allways-ui&pullNumber=12",
+ { headers: { authorization: `Bearer ${extensionSessionBody.token}` } },
+ env,
+ );
+ expect(revokedExtensionContext.status).toBe(401);
+ await expect(revokedExtensionContext.json()).resolves.toMatchObject({ error: "unauthorized" });
});
it("covers live app auth, validation, and internal job queue edge routes", async () => {
diff --git a/test/unit/extension-auth.test.ts b/test/unit/extension-auth.test.ts
new file mode 100644
index 0000000000..5201d6f44a
--- /dev/null
+++ b/test/unit/extension-auth.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it, vi } from "vitest";
+
+// @ts-expect-error The extension runtime files are plain MV3 JavaScript, intentionally unbundled.
+import * as extensionAuth from "../../apps/gittensory-extension/auth.js";
+
+const {
+ EXTENSION_SESSION_EXPIRED_MESSAGE,
+ loadExtensionSession,
+ logoutExtensionSession,
+ requestPullContext,
+ saveExtensionApiOrigin,
+ storeExtensionSessionToken,
+ validateExtensionSessionToken,
+} = extensionAuth;
+
+const VALID_TOKEN = `gts_${"a".repeat(64)}`;
+
+describe("extension auth storage", () => {
+ it("stores session tokens only in extension local storage and keeps sync storage token-free", async () => {
+ const storage = fakeExtensionStorage({
+ sync: { apiOrigin: "https://gittensory-api.aethereal.dev/ignored/path", sessionToken: "legacy-sync-token" },
+ });
+
+ await saveExtensionApiOrigin("https://api.gittensory.test/v1", storage);
+ await storeExtensionSessionToken(
+ {
+ token: VALID_TOKEN,
+ expiresAt: "2030-01-01T00:00:00.000Z",
+ login: "oktofeesh1",
+ scopes: ["extension:pull_context", 123],
+ },
+ storage,
+ );
+
+ const session = await loadExtensionSession(storage);
+ expect(session).toMatchObject({
+ apiOrigin: "https://api.gittensory.test",
+ sessionToken: VALID_TOKEN,
+ expiresAt: "2030-01-01T00:00:00.000Z",
+ login: "oktofeesh1",
+ scopes: ["extension:pull_context"],
+ expired: false,
+ });
+ expect(storage.sync.dump()).toEqual({ apiOrigin: "https://api.gittensory.test" });
+ expect(storage.local.dump()).toMatchObject({ sessionToken: VALID_TOKEN, sessionScopes: ["extension:pull_context"] });
+ });
+
+ it("rejects GitHub personal access tokens and malformed tokens before storage", async () => {
+ const storage = fakeExtensionStorage();
+
+ expect(() => validateExtensionSessionToken("github_pat_123")).toThrow(/GitHub personal access tokens/i);
+ await expect(storeExtensionSessionToken({ token: "ghp_123" }, storage)).rejects.toThrow(/GitHub personal access tokens/i);
+ await expect(storeExtensionSessionToken({ token: "not-a-gittensory-session" }, storage)).rejects.toThrow(/gts_/i);
+ expect(storage.local.dump()).toEqual({});
+ expect(storage.sync.dump()).toEqual({});
+ });
+
+ it("clears locally expired sessions without calling the API", async () => {
+ const storage = fakeExtensionStorage({
+ sync: { apiOrigin: "https://api.gittensory.test" },
+ local: { sessionToken: VALID_TOKEN, sessionExpiresAt: "2020-01-01T00:00:00.000Z" },
+ });
+ const fetchImpl = vi.fn();
+
+ await expect(requestPullContext({ owner: "JSONbored", repo: "gittensory", pullNumber: 148 }, { storage, fetchImpl })).rejects.toThrow(EXTENSION_SESSION_EXPIRED_MESSAGE);
+
+ expect(fetchImpl).not.toHaveBeenCalled();
+ expect(storage.local.dump()).toEqual({});
+ expect(storage.sync.dump()).toEqual({ apiOrigin: "https://api.gittensory.test" });
+ });
+
+ it("clears revoked or insufficient-scope sessions returned by the API", async () => {
+ const storage = fakeExtensionStorage({
+ sync: { apiOrigin: "https://api.gittensory.test" },
+ local: { sessionToken: VALID_TOKEN, sessionExpiresAt: "2030-01-01T00:00:00.000Z" },
+ });
+ const fetchImpl = vi.fn(async () => jsonResponse(403, { error: "extension_session_required" }));
+
+ await expect(requestPullContext({ owner: "JSONbored", repo: "gittensory", pullNumber: 148 }, { storage, fetchImpl })).rejects.toThrow(EXTENSION_SESSION_EXPIRED_MESSAGE);
+
+ expect(fetchImpl).toHaveBeenCalledWith(
+ "https://api.gittensory.test/v1/extension/pull-context?owner=JSONbored&repo=gittensory&pullNumber=148",
+ expect.objectContaining({ headers: expect.objectContaining({ authorization: `Bearer ${VALID_TOKEN}` }) }),
+ );
+ expect(storage.local.dump()).toEqual({});
+ });
+
+ it("returns public-safe pull context payloads and logs out by revoking then clearing local state", async () => {
+ const storage = fakeExtensionStorage({
+ sync: { apiOrigin: "https://api.gittensory.test" },
+ local: { sessionToken: VALID_TOKEN, sessionExpiresAt: "2030-01-01T00:00:00.000Z" },
+ });
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse(200, { panels: [{ label: "Boundary", rows: [{ k: "public", v: "no" }] }] }))
+ .mockRejectedValueOnce(new Error("already revoked"));
+
+ await expect(requestPullContext({ owner: "JSONbored", repo: "gittensory", pullNumber: 148 }, { storage, fetchImpl })).resolves.toMatchObject({
+ panels: [{ label: "Boundary", rows: [{ k: "public", v: "no" }] }],
+ });
+ await expect(logoutExtensionSession({ storage, fetchImpl })).resolves.toEqual({ ok: true });
+
+ expect(fetchImpl).toHaveBeenLastCalledWith(
+ "https://api.gittensory.test/v1/auth/logout",
+ expect.objectContaining({ method: "POST", headers: expect.objectContaining({ authorization: `Bearer ${VALID_TOKEN}` }) }),
+ );
+ expect(storage.local.dump()).toEqual({});
+ expect(storage.sync.dump()).toEqual({ apiOrigin: "https://api.gittensory.test" });
+ });
+});
+
+function jsonResponse(status: number, body: unknown) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? "OK" : "Forbidden",
+ async json() {
+ return body;
+ },
+ };
+}
+
+function fakeExtensionStorage(seed: { local?: Record; sync?: Record } = {}) {
+ return {
+ local: fakeStorageArea(seed.local),
+ sync: fakeStorageArea(seed.sync),
+ };
+}
+
+function fakeStorageArea(seed: Record = {}) {
+ const values = new Map(Object.entries(seed));
+ return {
+ async get(keys?: string | string[]) {
+ if (Array.isArray(keys)) return Object.fromEntries(keys.map((key) => [key, values.get(key)]));
+ if (typeof keys === "string") return { [keys]: values.get(keys) };
+ return Object.fromEntries(values);
+ },
+ async set(next: Record) {
+ for (const [key, value] of Object.entries(next)) values.set(key, value);
+ },
+ async remove(keys: string | string[]) {
+ for (const key of Array.isArray(keys) ? keys : [keys]) values.delete(key);
+ },
+ dump() {
+ return Object.fromEntries(values);
+ },
+ };
+}