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
177 changes: 177 additions & 0 deletions apps/gittensory-extension/auth.js
Original file line number Diff line number Diff line change
@@ -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);
}
29 changes: 4 additions & 25 deletions apps/gittensory-extension/background.js
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 25 additions & 2 deletions apps/gittensory-extension/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -50,7 +64,7 @@
<body>
<main>
<h1>Gittensory extension</h1>
<p>Paste an extension session token from the Gittensory app. The token is stored in browser sync storage and only sent to the Gittensory API.</p>
<p>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.</p>
<form id="settings">
<label>
API origin
Expand All @@ -60,7 +74,16 @@ <h1>Gittensory extension</h1>
Extension session token
<input id="sessionToken" name="sessionToken" type="password" autocomplete="off" />
</label>
<button type="submit">Save</button>
<label>
Expires at
<input id="sessionExpiresAt" name="sessionExpiresAt" placeholder="Optional ISO timestamp" autocomplete="off" />
</label>
<p id="sessionSummary"></p>
<div class="actions">
<button type="submit">Save</button>
<button id="logout" class="secondary" type="button">Log out</button>
<button id="clearLocal" class="secondary" type="button">Clear local</button>
</div>
</form>
<p id="status" role="status"></p>
</main>
Expand Down
81 changes: 69 additions & 12 deletions apps/gittensory-extension/options.js
Original file line number Diff line number Diff line change
@@ -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);
}
5 changes: 4 additions & 1 deletion apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -10998,6 +10998,9 @@
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Extension-scoped session required"
}
},
"security": [
Expand Down
5 changes: 4 additions & 1 deletion apps/gittensory-ui/src/routes/extension.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ function ExtensionPage() {
<Callout variant="safety">
<strong>Privacy posture.</strong> 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.
</Callout>
</Section>
</>
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
Loading