# Logto Auth Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add Logto-based Chrome extension authentication with a popup login entry, background-owned auth flow, and content-script gating so the Xingtu enhancer is unusable until the user is authenticated. **Architecture:** Keep Logto-specific behavior inside `src/background/auth/*` and expose a small message contract from `src/shared/auth-messages.ts`. Add a standalone `popup` entry for sign-in / sign-out and dev diagnostics. Gate existing market content initialization behind an auth check so the page only boots the current enhancer after authentication succeeds. **Tech Stack:** Chrome MV3, TypeScript, Vitest, tsup build script, `@logto/chrome-extension` --- ## File Map ### New files - `src/shared/auth-config.ts` - `src/shared/auth-messages.ts` - `src/background/auth/types.ts` - `src/background/auth/state.ts` - `src/background/auth/client.ts` - `src/background/auth/controller.ts` - `src/popup/index.html` - `src/popup/index.ts` - `src/popup/view.ts` - `src/content/market/auth-gate.ts` - `tests/auth-config.test.ts` - `tests/auth-messages.test.ts` - `tests/background-auth-controller.test.ts` - `tests/popup-entry.test.ts` - `tests/market-auth-gating.test.ts` ### Existing files to modify - `package.json` - `package-lock.json` - `src/manifest.json` - `scripts/build.mjs` - `src/background/index.ts` - `src/content/index.ts` - `src/content/market/index.ts` - `tests/background-index.test.ts` - `tests/manifest.test.ts` - `README.md` - `externaldocs/2026-04-21-logto-auth-design.md` ### Responsibilities - `src/shared/auth-config.ts`: parse placeholder config, validate required fields, expose a stable config object. - `src/shared/auth-messages.ts`: define request / response types and type guards for popup-content-background communication. - `src/background/auth/*`: isolate Logto client creation, auth state mapping, and runtime message handling. - `src/popup/*`: render login/logout UI and dev diagnostics using background messages only. - `src/content/market/auth-gate.ts`: render the disabled state and “go login” guidance when auth is missing. ## Task 1: Shared Auth Contract And Config **Files:** - Create: `src/shared/auth-config.ts` - Create: `src/shared/auth-messages.ts` - Test: `tests/auth-config.test.ts` - Test: `tests/auth-messages.test.ts` - Modify: `package.json` - Modify: `package-lock.json` - [ ] **Step 1: Add the Logto SDK dependency in the manifest of the repo** Update `package.json` dependencies to include: ```json { "dependencies": { "@logto/chrome-extension": "^" } } ``` Run: `npm install` Expected: `package-lock.json` includes `@logto/chrome-extension` - [ ] **Step 2: Write the failing config tests** Create `tests/auth-config.test.ts` with cases for: ```ts import { describe, expect, test } from "vitest"; import { readAuthConfig } from "../src/shared/auth-config"; describe("auth-config", () => { test("returns the placeholder config in development", () => { expect(readAuthConfig()).toEqual({ apiResource: "", appId: "", enableDevAuthPanel: false, logtoEndpoint: "https://.logto.app", scopes: ["openid", "profile", "offline_access"] }); }); test("rejects empty endpoint values", () => { expect(() => readAuthConfig({ logtoEndpoint: "" }) ).toThrow(/logtoEndpoint/i); }); }); ``` Run: `npm test -- tests/auth-config.test.ts` Expected: FAIL because `readAuthConfig` does not exist yet - [ ] **Step 3: Write the failing message contract tests** Create `tests/auth-messages.test.ts` with cases for: ```ts import { describe, expect, test } from "vitest"; import { isAuthRequestMessage, isAuthResponseMessage } from "../src/shared/auth-messages"; describe("auth-messages", () => { test("accepts a get-state request", () => { expect(isAuthRequestMessage({ type: "auth:get-state" })).toBe(true); }); test("rejects unknown auth requests", () => { expect(isAuthRequestMessage({ type: "auth:wat" })).toBe(false); }); test("accepts a successful auth response envelope", () => { expect( isAuthResponseMessage({ ok: true, type: "auth:state", value: { isAuthenticated: false } }) ).toBe(true); }); }); ``` Run: `npm test -- tests/auth-messages.test.ts` Expected: FAIL because helpers and types do not exist yet - [ ] **Step 4: Implement the minimal shared config** Create `src/shared/auth-config.ts` with: ```ts export interface AuthConfig { apiResource: string; appId: string; enableDevAuthPanel: boolean; logtoEndpoint: string; scopes: string[]; } const defaultAuthConfig: AuthConfig = { apiResource: "", appId: "", enableDevAuthPanel: false, logtoEndpoint: "https://.logto.app", scopes: ["openid", "profile", "offline_access"] }; export function readAuthConfig( overrides: Partial = {} ): AuthConfig { const nextConfig = { ...defaultAuthConfig, ...overrides }; if (!nextConfig.logtoEndpoint.trim()) { throw new Error("auth config logtoEndpoint is required"); } if (!nextConfig.appId.trim()) { throw new Error("auth config appId is required"); } if (!nextConfig.apiResource.trim()) { throw new Error("auth config apiResource is required"); } return nextConfig; } ``` - [ ] **Step 5: Implement the minimal shared message contract** Create `src/shared/auth-messages.ts` with: ```ts export type AuthRequestMessage = | { type: "auth:get-state" } | { type: "auth:sign-in" } | { type: "auth:sign-out" } | { type: "auth:get-access-token" }; export interface AuthStateValue { accessTokenExpiresAt?: number | null; isAuthenticated: boolean; lastError?: string | null; resource?: string | null; scopes?: string[]; tokenAvailable?: boolean; userInfo?: { email?: string; name?: string; sub?: string; username?: string; } | null; } export type AuthResponseMessage = | { ok: true; type: "auth:state"; value: AuthStateValue } | { ok: true; type: "auth:token"; value: { accessToken: string } } | { ok: true; type: "auth:ack" } | { ok: false; type: "auth:error"; error: string }; ``` Also add `isAuthRequestMessage()` and `isAuthResponseMessage()` type guards. - [ ] **Step 6: Run the shared auth tests and verify green** Run: `npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts` Expected: PASS - [ ] **Step 7: Commit the shared contract slice** Run: ```bash git add package.json package-lock.json src/shared/auth-config.ts src/shared/auth-messages.ts tests/auth-config.test.ts tests/auth-messages.test.ts git commit -m "feat: add shared auth config and message contracts" ``` ## Task 2: Background Auth Controller **Files:** - Create: `src/background/auth/types.ts` - Create: `src/background/auth/state.ts` - Create: `src/background/auth/client.ts` - Create: `src/background/auth/controller.ts` - Modify: `src/background/index.ts` - Test: `tests/background-auth-controller.test.ts` - Modify: `tests/background-index.test.ts` - [ ] **Step 1: Write the failing background auth controller tests** Create `tests/background-auth-controller.test.ts` with cases for: ```ts import { describe, expect, test, vi } from "vitest"; import { createAuthController } from "../src/background/auth/controller"; describe("background-auth-controller", () => { test("returns unauthenticated state when the client is logged out", async () => { const controller = createAuthController({ authClient: { getAccessToken: vi.fn(), getIdTokenClaims: vi.fn(), isAuthenticated: vi.fn(async () => false), signIn: vi.fn(), signOut: vi.fn() } }); await expect(controller.getAuthState()).resolves.toEqual( expect.objectContaining({ isAuthenticated: false }) ); }); test("delegates sign in to the auth client", async () => { const signIn = vi.fn(async () => undefined); const controller = createAuthController({ authClient: { getAccessToken: vi.fn(), getIdTokenClaims: vi.fn(), isAuthenticated: vi.fn(async () => false), signIn, signOut: vi.fn() } }); await controller.signIn(); expect(signIn).toHaveBeenCalledTimes(1); }); }); ``` Run: `npm test -- tests/background-auth-controller.test.ts` Expected: FAIL because the controller does not exist - [ ] **Step 2: Extend the existing background runtime test with auth messages** Modify `tests/background-index.test.ts` to add: ```ts test("responds to auth:get-state with auth status", async () => { const listeners: Array = []; const sendResponse = vi.fn(); registerBackgroundMessageHandler( { runtime: { onMessage: { addListener(listener) { listeners.push(listener); } } } }, { authController: { getAccessToken: vi.fn(), getAuthState: vi.fn(async () => ({ isAuthenticated: false })), signIn: vi.fn(), signOut: vi.fn() } } ); const result = listeners[0]({ type: "auth:get-state" }, {}, sendResponse); expect(result).toBe(true); await new Promise((resolve) => setTimeout(resolve, 0)); expect(sendResponse).toHaveBeenCalledWith({ ok: true, type: "auth:state", value: { isAuthenticated: false } }); }); ``` Run: `npm test -- tests/background-index.test.ts` Expected: FAIL because `registerBackgroundMessageHandler` only supports CSV download - [ ] **Step 3: Implement the minimal background auth controller** Create `src/background/auth/types.ts`: ```ts export interface AuthClientLike { getAccessToken(resource?: string): Promise; getIdTokenClaims(): Promise | null>; isAuthenticated(): Promise; signIn(): Promise; signOut(): Promise; } ``` Create `src/background/auth/state.ts` with a helper that maps client state into the shared `AuthStateValue`. Create `src/background/auth/controller.ts` with: ```ts export function createAuthController(options: { authClient: AuthClientLike; config?: AuthConfig; }) { return { async getAuthState() { const isAuthenticated = await options.authClient.isAuthenticated(); if (!isAuthenticated) { return { isAuthenticated: false, resource: options.config?.apiResource ?? null }; } const claims = await options.authClient.getIdTokenClaims(); return { isAuthenticated: true, resource: options.config?.apiResource ?? null, scopes: options.config?.scopes ?? [], userInfo: { email: typeof claims?.email === "string" ? claims.email : undefined, name: typeof claims?.name === "string" ? claims.name : undefined, sub: typeof claims?.sub === "string" ? claims.sub : undefined, username: typeof claims?.username === "string" ? claims.username : undefined } }; }, signIn: () => options.authClient.signIn(), signOut: () => options.authClient.signOut(), async getAccessToken() { return options.authClient.getAccessToken(options.config?.apiResource); } }; } ``` - [ ] **Step 4: Add the Logto client factory wrapper** Create `src/background/auth/client.ts` as the only place that imports `@logto/chrome-extension`: ```ts import LogtoClient from "@logto/chrome-extension"; import { readAuthConfig } from "../../shared/auth-config"; export function createLogtoAuthClient() { const config = readAuthConfig(); return new LogtoClient({ appId: config.appId, endpoint: config.logtoEndpoint, resources: [config.apiResource], scopes: config.scopes }); } ``` - [ ] **Step 5: Integrate auth handling into `src/background/index.ts`** Refactor `registerBackgroundMessageHandler()` to accept optional dependencies: ```ts registerBackgroundMessageHandler(chromeLike?, { authController?: createAuthController(...) }); ``` Handle: - `auth:get-state` -> `{ ok: true, type: "auth:state", value }` - `auth:sign-in` -> `{ ok: true, type: "auth:ack" }` - `auth:sign-out` -> `{ ok: true, type: "auth:ack" }` - `auth:get-access-token` -> `{ ok: true, type: "auth:token", value: { accessToken } }` Keep the existing CSV download path intact. - [ ] **Step 6: Run background tests and verify green** Run: `npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts` Expected: PASS - [ ] **Step 7: Commit the background auth slice** Run: ```bash git add src/background/index.ts src/background/auth tests/background-auth-controller.test.ts tests/background-index.test.ts git commit -m "feat: add background auth controller" ``` ## Task 3: Popup Entry, Manifest, And Build Output **Files:** - Create: `src/popup/index.html` - Create: `src/popup/index.ts` - Create: `src/popup/view.ts` - Modify: `src/manifest.json` - Modify: `scripts/build.mjs` - Modify: `tests/manifest.test.ts` - Test: `tests/popup-entry.test.ts` - [ ] **Step 1: Write the failing popup rendering tests** Create `tests/popup-entry.test.ts` with a JSDOM fixture: ```ts import { describe, expect, test, vi } from "vitest"; import { bootPopup } from "../src/popup/index"; describe("popup-entry", () => { test("renders a sign-in button when unauthenticated", async () => { document.body.innerHTML = "
"; await bootPopup({ document, sendMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: false } })) }); expect(document.querySelector("button")?.textContent).toContain("登录"); }); }); ``` Run: `npm test -- tests/popup-entry.test.ts` Expected: FAIL because popup files do not exist - [ ] **Step 2: Extend manifest tests for auth permissions and popup** Modify `tests/manifest.test.ts` to assert: ```ts expect(manifest.permissions).toEqual( expect.arrayContaining(["downloads", "identity", "storage"]) ); expect(manifest.action?.default_popup).toBe("popup/index.html"); ``` Run: `npm test -- tests/manifest.test.ts` Expected: FAIL because manifest does not include popup / auth permissions - [ ] **Step 3: Implement the minimal popup view** Create `src/popup/view.ts` with rendering helpers: ```ts export function renderLoggedOut(root: HTMLElement, error?: string | null) { root.innerHTML = `

Star Chart Search Enhancer

登录后才能使用星图增强功能

${error ? `

${error}

` : ""}
`; } ``` Also add `renderLoggedIn()` and `renderDevPanel()` helpers. - [ ] **Step 4: Implement the minimal popup bootstrap** Create `src/popup/index.ts` with: ```ts import { renderLoggedIn, renderLoggedOut } from "./view"; export async function bootPopup(options = {}) { const currentDocument = options.document ?? document; const root = currentDocument.querySelector("#app") as HTMLElement | null; if (!root) { throw new Error("popup root #app is required"); } const sendMessage = options.sendMessage ?? ((message: unknown) => chrome.runtime.sendMessage(message) as Promise); const stateResponse = await sendMessage({ type: "auth:get-state" }); if (!stateResponse.ok || stateResponse.type !== "auth:state") { renderLoggedOut(root, "认证状态读取失败"); return; } if (!stateResponse.value.isAuthenticated) { renderLoggedOut(root, stateResponse.value.lastError); root.querySelector("[data-popup-sign-in='button']")?.addEventListener("click", async () => { await sendMessage({ type: "auth:sign-in" }); }); return; } renderLoggedIn(root, stateResponse.value); } ``` Create `src/popup/index.html`: ```html Star Chart Search Enhancer
``` - [ ] **Step 5: Update build and manifest** Modify `scripts/build.mjs` to: - build `src/popup/index.ts` into `dist/popup/index.js` - create `dist/popup` - copy `src/popup/index.html` into `dist/popup/index.html` Modify `src/manifest.json` to: ```json { "permissions": ["downloads", "identity", "storage"], "action": { "default_popup": "popup/index.html" } } ``` Preserve current background and content script entries. - [ ] **Step 6: Run popup and manifest tests** Run: `npm test -- tests/popup-entry.test.ts tests/manifest.test.ts` Expected: PASS - [ ] **Step 7: Commit the popup and manifest slice** Run: ```bash git add src/popup src/manifest.json scripts/build.mjs tests/popup-entry.test.ts tests/manifest.test.ts git commit -m "feat: add popup auth entry and manifest wiring" ``` ## Task 4: Content Auth Gating **Files:** - Create: `src/content/market/auth-gate.ts` - Modify: `src/content/index.ts` - Modify: `src/content/market/index.ts` - Test: `tests/market-auth-gating.test.ts` - Modify: `tests/market-content-entry.test.ts` - [ ] **Step 1: Write the failing content gating test** Create `tests/market-auth-gating.test.ts`: ```ts import { describe, expect, test, vi } from "vitest"; import { bootContentScript } from "../src/content/index"; describe("market-auth-gating", () => { test("shows a login gate instead of booting the market controller when unauthenticated", async () => { document.body.innerHTML = "
"; const createMarketController = vi.fn(); await bootContentScript({ createMarketController, document, sendAuthMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: false } })), window }); expect(createMarketController).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("请先登录插件"); }); }); ``` Run: `npm test -- tests/market-auth-gating.test.ts` Expected: FAIL because `bootContentScript` does not gate on auth yet - [ ] **Step 2: Add an integration assertion to the existing content entry tests** Modify `tests/market-content-entry.test.ts` to add: ```ts test("boots the controller only after auth succeeds", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); await bootContentScript({ createMarketController, document, sendAuthMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: true } })), window }); expect(createMarketController).toHaveBeenCalledTimes(1); }); ``` Run: `npm test -- tests/market-content-entry.test.ts` Expected: FAIL until `bootContentScript` accepts the new auth dependency - [ ] **Step 3: Implement the auth gate UI** Create `src/content/market/auth-gate.ts`: ```ts export function renderMarketAuthGate(document: Document): HTMLElement { const root = document.createElement("section"); root.dataset.marketAuthGate = "root"; root.innerHTML = ` 请先登录插件

打开扩展弹窗完成登录后刷新本页

`; root.querySelector("[data-market-auth-help='button']")?.addEventListener("click", () => { window.alert("请点击浏览器工具栏中的扩展图标完成登录"); }); document.body.prepend(root); return root; } ``` - [ ] **Step 4: Gate `bootContentScript()` before controller startup** Modify `src/content/index.ts` to: - accept an injectable `sendAuthMessage` - call background with `{ type: "auth:get-state" }` - if unauthenticated, render the gate and return `{ ready: Promise.resolve() }` or `null` - only call `createMarketController()` after auth succeeds Suggested helper: ```ts async function readAuthState( sendMessage: (message: unknown) => Promise ): Promise { const response = await sendMessage({ type: "auth:get-state" }); return response.ok && response.type === "auth:state" ? response.value : null; } ``` - [ ] **Step 5: Keep market controller auth-agnostic** Only touch `src/content/market/index.ts` if the existing controller assumes it always owns the toolbar root. Prefer to keep auth gating in `src/content/index.ts`; only add defensive guards if the current toolbar boot sequence conflicts with the auth gate DOM. - [ ] **Step 6: Run content gating tests** Run: `npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts` Expected: PASS - [ ] **Step 7: Commit the auth gate slice** Run: ```bash git add src/content/index.ts src/content/market/auth-gate.ts src/content/market/index.ts tests/market-auth-gating.test.ts tests/market-content-entry.test.ts git commit -m "feat: gate market tools behind authentication" ``` ## Task 5: Dev Diagnostics, Build Verification, And Docs **Files:** - Modify: `src/popup/view.ts` - Modify: `src/popup/index.ts` - Modify: `src/background/auth/controller.ts` - Modify: `README.md` - Modify: `externaldocs/2026-04-21-logto-auth-design.md` - [ ] **Step 1: Write the failing popup dev panel test** Extend `tests/popup-entry.test.ts` with: ```ts test("renders the dev auth panel when enabled", async () => { document.body.innerHTML = "
"; await bootPopup({ config: { enableDevAuthPanel: true }, document, sendMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { accessTokenExpiresAt: 1700000000000, isAuthenticated: true, resource: "https://api.example.test", scopes: ["openid", "profile"], tokenAvailable: true, userInfo: { email: "dev@example.com", name: "Dev" } } })) }); expect(document.body.textContent).toContain("resource"); expect(document.body.textContent).toContain("token"); }); ``` Run: `npm test -- tests/popup-entry.test.ts` Expected: FAIL because the dev panel is not rendered yet - [ ] **Step 2: Implement minimal diagnostics** Update popup rendering to show when `enableDevAuthPanel` is true: - `resource` - `scopes` - `tokenAvailable` - `accessTokenExpiresAt` - `lastError` Update background state mapping to return `tokenAvailable` and `accessTokenExpiresAt` placeholders even when the exact expiry cannot be resolved yet: ```ts return { accessTokenExpiresAt: null, isAuthenticated: true, resource: config.apiResource, scopes: config.scopes, tokenAvailable: true, userInfo }; ``` - [ ] **Step 3: Run the popup suite and the full test suite** Run: `npm test -- tests/popup-entry.test.ts` Expected: PASS Run: `npm test` Expected: PASS across existing and new tests - [ ] **Step 4: Run the build and verify output structure** Run: `npm run build` Expected: - `dist/manifest.json` - `dist/background/index.js` - `dist/content/index.js` - `dist/content/market-page-bridge.js` - `dist/popup/index.html` - `dist/popup/index.js` - [ ] **Step 5: Update docs** Update `README.md` with: - how to load the popup - what placeholder Logto config values must be replaced - what “unauthenticated” looks like on the market page Update `externaldocs/2026-04-21-logto-auth-design.md` only if implementation choices diverge from the design. Also fix any current encoding artifacts in that spec while editing. - [ ] **Step 6: Commit the verification and docs slice** Run: ```bash git add src/popup/view.ts src/popup/index.ts src/background/auth/controller.ts README.md externaldocs/2026-04-21-logto-auth-design.md git commit -m "docs: document logto auth flow and diagnostics" ``` ## Final Verification Checklist - [ ] `npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts` - [ ] `npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts` - [ ] `npm test -- tests/popup-entry.test.ts tests/manifest.test.ts` - [ ] `npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts` - [ ] `npm test` - [ ] `npm run build` - [ ] Load `dist/` in `chrome://extensions` - [ ] Verify popup shows logged-out state before configuration is real - [ ] Verify market page shows auth gate when unauthenticated ## Open Inputs Still Required From The User - Real `logtoEndpoint` - Real `appId` - Real `apiResource` - Any extra scopes beyond `openid profile offline_access` - Whether dev auth panel should be hard-disabled in production builds or controlled by config only