24 KiB
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.tssrc/shared/auth-messages.tssrc/background/auth/types.tssrc/background/auth/state.tssrc/background/auth/client.tssrc/background/auth/controller.tssrc/popup/index.htmlsrc/popup/index.tssrc/popup/view.tssrc/content/market/auth-gate.tstests/auth-config.test.tstests/auth-messages.test.tstests/background-auth-controller.test.tstests/popup-entry.test.tstests/market-auth-gating.test.ts
Existing files to modify
package.jsonpackage-lock.jsonsrc/manifest.jsonscripts/build.mjssrc/background/index.tssrc/content/index.tssrc/content/market/index.tstests/background-index.test.tstests/manifest.test.tsREADME.mdexternaldocs/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:
{
"dependencies": {
"@logto/chrome-extension": "^<latest-compatible>"
}
}
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:
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: "<your-global-api-resource-indicator>",
appId: "<chrome-extension-app-id>",
enableDevAuthPanel: false,
logtoEndpoint: "https://<your-tenant>.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:
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:
export interface AuthConfig {
apiResource: string;
appId: string;
enableDevAuthPanel: boolean;
logtoEndpoint: string;
scopes: string[];
}
const defaultAuthConfig: AuthConfig = {
apiResource: "<your-global-api-resource-indicator>",
appId: "<chrome-extension-app-id>",
enableDevAuthPanel: false,
logtoEndpoint: "https://<your-tenant>.logto.app",
scopes: ["openid", "profile", "offline_access"]
};
export function readAuthConfig(
overrides: Partial<AuthConfig> = {}
): 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:
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:
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:
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:
test("responds to auth:get-state with auth status", async () => {
const listeners: Array<any> = [];
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:
export interface AuthClientLike {
getAccessToken(resource?: string): Promise<string>;
getIdTokenClaims(): Promise<Record<string, unknown> | null>;
isAuthenticated(): Promise<boolean>;
signIn(): Promise<void>;
signOut(): Promise<void>;
}
Create src/background/auth/state.ts with a helper that maps client state into the shared AuthStateValue.
Create src/background/auth/controller.ts with:
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:
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:
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:
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:
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 = "<main id='app'></main>";
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:
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:
export function renderLoggedOut(root: HTMLElement, error?: string | null) {
root.innerHTML = `
<section data-popup-state="logged-out">
<h1>Star Chart Search Enhancer</h1>
<p>登录后才能使用星图增强功能</p>
${error ? `<p data-popup-error="true">${error}</p>` : ""}
<button type="button" data-popup-sign-in="button">登录 Logto</button>
</section>
`;
}
Also add renderLoggedIn() and renderDevPanel() helpers.
- Step 4: Implement the minimal popup bootstrap
Create src/popup/index.ts with:
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<unknown>);
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:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Chart Search Enhancer</title>
</head>
<body>
<main id="app"></main>
<script src="./index.js"></script>
</body>
</html>
- Step 5: Update build and manifest
Modify scripts/build.mjs to:
- build
src/popup/index.tsintodist/popup/index.js - create
dist/popup - copy
src/popup/index.htmlintodist/popup/index.html
Modify src/manifest.json to:
{
"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:
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:
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 = "<div></div>";
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:
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:
export function renderMarketAuthGate(document: Document): HTMLElement {
const root = document.createElement("section");
root.dataset.marketAuthGate = "root";
root.innerHTML = `
<strong>请先登录插件</strong>
<p>打开扩展弹窗完成登录后刷新本页</p>
<button type="button" data-market-auth-help="button">去登录</button>
`;
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() }ornull - only call
createMarketController()after auth succeeds
Suggested helper:
async function readAuthState(
sendMessage: (message: unknown) => Promise<unknown>
): Promise<AuthStateValue | null> {
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:
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:
test("renders the dev auth panel when enabled", async () => {
document.body.innerHTML = "<main id='app'></main>";
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:
resourcescopestokenAvailableaccessTokenExpiresAtlastError
Update background state mapping to return tokenAvailable and accessTokenExpiresAt placeholders even when the exact expiry cannot be resolved yet:
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:
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.tsnpm test -- tests/background-auth-controller.test.ts tests/background-index.test.tsnpm test -- tests/popup-entry.test.ts tests/manifest.test.tsnpm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.tsnpm testnpm run build- Load
dist/inchrome://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