854 lines
24 KiB
Markdown
854 lines
24 KiB
Markdown
# 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": "^<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:
|
|
|
|
```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: "<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:
|
|
|
|
```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: "<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:
|
|
|
|
```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<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`:
|
|
|
|
```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:
|
|
|
|
```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 = "<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:
|
|
|
|
```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 = `
|
|
<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:
|
|
|
|
```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<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`:
|
|
|
|
```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.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 = "<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:
|
|
|
|
```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 = `
|
|
<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() }` or `null`
|
|
- only call `createMarketController()` after auth succeeds
|
|
|
|
Suggested helper:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```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 = "<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:
|
|
|
|
- `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
|