diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..0a3b754 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +VITE_BACKEND_URL=https://app.thoughtful-ai.com/api +VITE_OAUTH_CLIENT_ID=writing-tools-mindmap diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..121d7cc --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,54 @@ +name: Deploy Mindmap to GitHub Pages + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build Pages artifact + runs-on: ubuntu-latest + steps: + - name: Require main for a deployment + if: ${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' }} + run: | + echo "Mindmap Pages deployments must run from main." >&2 + exit 1 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build:pages + - run: npm run verify:pages + - name: Configure Pages + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + uses: actions/configure-pages@v5 + - name: Upload Pages artifact + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + uses: actions/upload-pages-artifact@v4 + with: + path: dist + + deploy: + name: Deploy Pages artifact + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + needs: build + runs-on: ubuntu-latest + concurrency: + group: mindmap-pages + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index fc09212..a4abe5a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ *.log tsconfig.tsbuildinfo eval/runs/ +playwright-report*/ +test-results/ diff --git a/README.md b/README.md index 2bfdee1..c71e241 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,94 @@ -# Reflective Mind-Map Prototype +# Mindmap -A writing-support prototype where the AI helps a user externalize their own -thinking into a node graph. Assistance contracts distinguish grounded reflection -from visibly AI-suggested contribution; every chat-derived structural change is -inert until explicit confirmation and retains its provenance. +Mindmap is a standalone writing-support app for externalizing a draft into a +user-controlled node graph. The AI can ask questions, reflect the writer's own +language, and propose structure, but map changes remain inert until the writer +confirms them. -Sibling to `prototype-word-bank` (the document-insertion coach); it reuses that -prototype's deterministic-grounding philosophy but externalizes into a mind map -instead of a draft. Uses the repo's `backend/` OpenAI proxy for AI calls. +The current product loop is deliberately manual: -## Writing Tools launcher +1. Copy text from Word, Google Docs, or another editor into the Draft panel. +2. Think with the coach and shape the map. +3. Use **Copy draft** or **Copy map** to move the result back to the editor. -Production builds require a launch from the Writing Tools tool launcher by -default. The launcher passes a short-lived `wt_grant` in the URL fragment; the -mindmap exchanges it for a scoped bearer token and removes the grant from the -URL. Development and test builds remain usable without a launcher token. +The app does not read or store a remote document. Drafts, maps, and conversation +state persist in the browser's local storage. -```text -VITE_BACKEND_URL=http://localhost:8000/api -VITE_REQUIRE_LAUNCH=false +## Local development + +```sh +npm install +npm run dev ``` -`VITE_REQUIRE_LAUNCH` enables the gate for a development or test build. When it -is unset, the gate is enabled for production builds and disabled for development -and tests. **Production builds always require a launch:** `VITE_REQUIRE_LAUNCH=false` -is ignored when `PROD` is set, so a misconfigured deploy environment cannot ship an -ungated bundle. A grant present in the URL is processed in every mode. +Development opens at `http://localhost:5181/` without a login prompt. Requests +without a bearer token use Writing Tools' capped sessionless development path. +To exercise the login gate locally, set `VITE_REQUIRE_LOGIN=true`; this flag is +development-only and can never disable login in a production build. -`VITE_BACKEND_URL` is **required** for production builds and throws at startup if -missing. Development and test builds fall back to `http://localhost:8000/api`; a -production bundle carrying that fallback would point every user's browser at their -own machine. +The development defaults are: -The Writing Tools registry uses `VITE_MINDMAP_TOOL_URL`. Its development default -is `http://localhost:5181/`; its production default is -`https://mindmap.thoughtful-ai.com/`. The existing Playwright smoke suite -continues to use Vite's development server at port 4173, so production-gate -verification remains a separate build check. +- backend: `http://localhost:8000/api` +- OAuth client: `writing-tools-mindmap` +- callback: `http://localhost:5181/` +- resource: `http://localhost:8000` -## Design principle: typed proposals with deterministic consequences +## Production authentication -- **Enforcement (code, not configurable):** a mirror must pass validation before - it is shown; the AI cannot commit structure (only the user confirms); - connections must come from user-articulated language; every committed unit - carries provenance back to the user's words. -- **Factual prompt context:** Source Bank evidence ids, map/draft state, explicit - UI selection, Think/Map preference, and support controls. +Production always requires **Connect to Writing Tools**. Mindmap uses OAuth +Authorization Code with PKCE S256 as a public client and requests only +`openai:chat`. The PKCE transaction and 12-hour JWT are stored in session +storage; local map work is stored separately and survives expiry, failed login, +403 access denial, and Disconnect. -## Provider transport +The production bundle pins: -The established transport remains the default: +- backend: `https://app.thoughtful-ai.com/api` +- client: `writing-tools-mindmap` +- callback: `https://mindmap.thoughtful-ai.com/` +- resource/audience: `https://app.thoughtful-ai.com` -```text -VITE_MINDMAP_PROVIDER_TRANSPORT=chat_json -``` +The resource is the backend origin only—no `/api` path and no trailing slash—and +is sent on both authorize and token requests. Browser requests use +`credentials: "omit"`; Disconnect clears only Mindmap's local OAuth state and +never signs the user out of Writing Tools. -The isolated provider-tool path is enabled locally with: +Before production login can work, the Writing Tools backend deployment must set: ```text -VITE_MINDMAP_PROVIDER_TRANSPORT=responses_tools -VITE_MINDMAP_MODEL=gpt-5.6-terra -VITE_MINDMAP_REASONING_EFFORT=low +MINDMAP_OAUTH_CLIENT_ID=writing-tools-mindmap +MINDMAP_OAUTH_REDIRECT_URIS=https://mindmap.thoughtful-ai.com/ ``` -The Responses transport exposes only `propose_reflection_v1` and -`propose_map_action_v1`. They create reviewable typed proposals; neither tool -confirms or applies a map mutation. - -## Enforcement core - -The pure validation and gateway modules remain independently unit tested even -though the prototype now includes a UI and provider adapters. +Restart the backend after setting them so startup provisioning creates or +updates the fixed client. Do not register localhost on the production server. -| Module | Role | -| --- | --- | -| `config.ts` | Pointer-validation thresholds, explicit UI pacing, and capability facts. | -| `types.ts` | Domain model: source utterances, candidate thoughts, mirror claims, confirmed reflections, thought units. | -| `normalize.ts` | Normalizer (matches `prototype-word-bank/ownership.ts`) + stopwords + light stemmer. | -| `validator.ts` | **The 3-check mirror validator.** Content overlap, source-span grounding, unsupported-word budget. | -| `stage1-loop.ts` | Typed model orchestration, one repair attempt, and proposal creation. | -| `action-gateway.ts` | Sole deterministic boundary for map-changing actions. | +## Verification -The three validator checks, coarsest to finest: - -1. **Content overlap** — are the reflection's content words the user's words? -2. **Source-span grounding** — does every claim trace to a user utterance that - actually supports it? (Catches new *relationships* assembled from real words.) -3. **Unsupported words** — are stray new content words under budget? (Catches a - single meaning-shifting insertion like "central" that the average let through.) +```sh +npm test +npm run build +npm run test:e2e +npm run test:e2e:pages +``` -When any check fails the mirror is blocked and the AI must fall back to a -clarifying question, targeted at the weakest span. +`test:e2e` runs against the ungated development server. `test:e2e:pages` builds +the production artifact, verifies its compiled backend/client configuration, +and runs login, auth-failure, persistence, and clipboard smoke tests against a +local preview with mocked OAuth endpoints. -## Roadmap +## GitHub Pages -- **M0** — enforcement core (here). -- **M1** — minimal chat loop wired to the backend OpenAI proxy: Question → - Mirror (gated) → Clarify. -- **M2** — `@xyflow/react` mind-map surface; confirmed chunks become thought - units with provenance; AI proposals are "pending" until confirmed. -- **M3** — thought-unit role changes (content ⇄ sub-node), connections, direct - user editing with symmetric primitives. +The Pages workflow builds and verifies pull requests but cannot publish them. +A deployment can run only through a manual `workflow_dispatch` from `main`. +The build job has read-only repository permission; Pages write and OIDC token +permissions exist only on the deployment job. -## Test +The custom domain serves the app from `/`, so Vite's base remains root. The +artifact verifier rejects missing or non-HTTPS production configuration, an +empty build, a bundle missing the backend URL or OAuth client ID, and any +accidentally copied `.env` file. -```sh -npm install -npm test -``` +Rooms, automatic document handoff, and refresh tokens are intentionally outside +this release. They can be revisited without weakening the standalone OAuth or +browser-local persistence boundaries. diff --git a/docs/login-bootstrap-spec.md b/docs/login-bootstrap-spec.md index 7b1b95d..38814de 100644 --- a/docs/login-bootstrap-spec.md +++ b/docs/login-bootstrap-spec.md @@ -66,14 +66,24 @@ On the connect action: `redirect_uri`, `response_type=code`, `scope=openai:chat`, `code_challenge` (S256), `state`, **and `resource`** -`resource` is the **canonical backend origin** (RFC 8707) — the origin of -`VITE_BACKEND_URL`, not the full URL including `/api`. It must be sent on -**both** the authorize request and the token exchange, and it must match what -Track A validates as the token audience. Omit it and every token fails audience -validation with an opaque 401. This is a hard contract with Track A; if the two -sides disagree about canonicalization (trailing slash, path, case), nothing -works — confirm the exact string against Track A's implementation rather than -deriving it independently. +`resource` is the **canonical backend origin** (RFC 8707). Track A is +implemented and tested, so this is no longer a guess: + +```ts +const resource = new URL(PLATFORM_BACKEND_URL).origin; +// VITE_BACKEND_URL = https://app.thoughtful-ai.com/api +// resource = https://app.thoughtful-ai.com ← origin only +``` + +**No path. No trailing slash.** Track A rejects both a missing `resource` and a +trailing-slash variant with `400 invalid_target`, and has tests pinning exactly +that. Send it on the authorize request **and** as a form field in the token +POST body — Track A validates it in both places. + +Two reasons this is not optional: Better Auth 1.6.22 issues an **opaque** token +when `resource` is absent, which the resource server cannot verify at all; and +the audience check then fails even if it could. If you ever see +`400 invalid_target`, this is why. On callback: @@ -104,6 +114,42 @@ match what Track A registers exactly. - Accept only a plausible token shape when reading from storage (compact JWT: three base64url segments). Do not accept "any string over N characters." +### 3b. Distinguish 401 from 403 — and do not loop + +Track A returns two different failures at the proxy, and they need opposite +handling: + +| Status | Meaning | What Mindmap must do | +|---|---|---| +| **401** | token invalid, expired, or absent | clear the token, offer *Connect* | +| **403** | token **valid**, account not on the beta allowlist | say so, and **do not offer login again** | + +**The failure to avoid:** a non-allowlisted user signs in perfectly, receives a +real 12-hour token, and then every AI call returns 403. If any auth failure is +treated as "log in again," they loop forever — succeeding at login, failing at +the proxy, being sent back to login. Signing in again cannot fix a 403; only an +allowlist change can. + +Show a terminal message for 403: their account isn't enabled for Writing Tools +AI, and who to contact. Keep their map work on screen. + +Both responses carry `X-Writing-Tools-Error: platform-auth`, and Track A +explicitly exposes that header to cross-origin JS (`exposeHeaders` on its CORS +config). Use it to tell a *platform* auth failure apart from an upstream model +error — do not infer it from the status code alone. + +### 3c. Logout means clearing local state — nothing more + +Clear the stored token and return to the connect screen. + +**Do not call `/api/auth/sign-out`.** That would end the user's Writing Tools +browser session, which is shared with the add-in and anything else they have +open. Mindmap logging out must not sign someone out of Writing Tools. + +Send `credentials: 'omit'` on every backend request. Mindmap authenticates with +a bearer token and must never rely on, or transmit, Writing Tools cookies — +that isolation is the whole point of the redirect flow. + ### 4. Delete the old path Remove grant-from-hash parsing, `/handoff/exchange`, launch-required states, @@ -163,6 +209,11 @@ path that no longer exists.** Rewrite all of them: - callback params are scrubbed from the URL, including on failure - the bearer is attached to subsequent AI calls - local map work survives a token clear +- the authorize request and the token POST both carry + `resource=` +- **a 403 does not send the user back to login** — this is the loop, and a test + is the only thing that will keep it fixed +- a 401 clears the token and offers *Connect* The skipped `wt_api` test is obsolete — delete it. diff --git a/e2e-pages/global-setup.ts b/e2e-pages/global-setup.ts new file mode 100644 index 0000000..9ce111f --- /dev/null +++ b/e2e-pages/global-setup.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; +import { preview } from "vite"; + +export default async function startPagesPreview(): Promise<(() => Promise) | undefined> { + if (process.env.PLAYWRIGHT_SKIP_SERVER) return undefined; + const port = Number(process.env.PLAYWRIGHT_PAGES_PORT ?? 4174); + const root = fileURLToPath(new URL("..", import.meta.url)); + const server = await preview({ root, preview: { host: "127.0.0.1", port, strictPort: true } }); + return async () => { await server.close(); }; +} diff --git a/e2e-pages/pages-smoke.spec.ts b/e2e-pages/pages-smoke.spec.ts new file mode 100644 index 0000000..6c83269 --- /dev/null +++ b/e2e-pages/pages-smoke.spec.ts @@ -0,0 +1,195 @@ +import { expect, test, type Page } from "@playwright/test"; + +const PRODUCTION_API = "https://app.thoughtful-ai.com/api"; +const RESOURCE = "https://app.thoughtful-ai.com"; +const CLIENT_ID = "writing-tools-mindmap"; +const SESSION_KEY = "prototype-mindmap-oauth-session-v1"; + +function base64Url(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function jwt(): string { + const exp = Math.floor(Date.now() / 1000) + 43_200; + return `${base64Url({ alg: "EdDSA", typ: "JWT" })}.${base64Url({ exp })}.signature`; +} + +function envelope(response: unknown) { + return { choices: [{ message: { content: JSON.stringify(response) } }] }; +} + +async function seedSession(page: Page, accessToken = jwt()) { + await page.addInitScript(({ key, token, expiresAt }) => { + window.sessionStorage.setItem(key, JSON.stringify({ version: 1, accessToken: token, expiresAt })); + }, { key: SESSION_KEY, token: accessToken, expiresAt: Date.now() + 43_200_000 }); +} + +async function installClipboard(page: Page) { + await page.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: async (text: string) => { + (window as Window & { __copied?: string }).__copied = text; + }, + }, + }); + }); +} + +async function mockLogin(page: Page, options: { holdToken?: boolean } = {}) { + let releaseToken: (() => void) | undefined; + const tokenGate = new Promise((resolve) => { releaseToken = resolve; }); + let signalToken: (() => void) | undefined; + const tokenRequested = new Promise((resolve) => { signalToken = resolve; }); + const accessToken = jwt(); + + await page.route(`${PRODUCTION_API}/auth/oauth2/authorize**`, async (route) => { + const url = new URL(route.request().url()); + expect(url.searchParams.get("client_id")).toBe(CLIENT_ID); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("openai:chat"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]+$/); + expect(url.searchParams.get("resource")).toBe(RESOURCE); + const callback = new URL(url.searchParams.get("redirect_uri")!); + callback.searchParams.set("code", "pages-code"); + callback.searchParams.set("state", url.searchParams.get("state")!); + await route.fulfill({ status: 302, headers: { location: callback.toString() } }); + }); + await page.route(`${PRODUCTION_API}/auth/oauth2/token`, async (route) => { + signalToken?.(); + if (options.holdToken) await tokenGate; + const form = new URLSearchParams(route.request().postData() ?? ""); + expect(form.get("grant_type")).toBe("authorization_code"); + expect(form.get("client_id")).toBe(CLIENT_ID); + expect(form.get("code")).toBe("pages-code"); + expect(form.get("code_verifier")).toMatch(/^[A-Za-z0-9_-]{43,}$/); + expect(form.get("resource")).toBe(RESOURCE); + expect(route.request().headers().cookie).toBeUndefined(); + await route.fulfill({ + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: "openai:chat", expires_in: 43_200 }), + }); + }); + return { accessToken, tokenRequested, releaseToken: () => releaseToken?.() }; +} + +test.beforeEach(async ({ page }) => { + await installClipboard(page); +}); + +test("production requires Connect and loads without console or asset failures", async ({ page }) => { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + const failedAssets: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); + page.on("response", (response) => { + if (response.url().startsWith("http://127.0.0.1:") && response.status() >= 400) failedAssets.push(response.url()); + }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Connect to Writing Tools" })).toBeVisible(); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); + expect(failedAssets).toEqual([]); +}); + +test("full PKCE login scrubs callback material and authenticates AI calls", async ({ page }) => { + const login = await mockLogin(page, { holdToken: true }); + await page.goto("/"); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await login.tokenRequested; + await expect.poll(() => new URL(page.url()).search).toBe(""); + login.releaseToken(); + await expect(page.getByRole("button", { name: "Disconnect" })).toBeVisible(); + + await page.route(`${PRODUCTION_API}/openai/chat/completions`, async (route) => { + expect(route.request().headers().authorization).toBe(`Bearer ${login.accessToken}`); + expect(route.request().headers().cookie).toBeUndefined(); + await route.fulfill({ + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify(envelope({ response: { kind: "question", text: "What matters here?", stance: "deepen" } })), + }); + }); + const composer = page.locator("textarea.composer-textarea"); + await composer.fill("Help me think."); + await composer.press("Enter"); + await expect(page.getByText("What matters here?")).toBeVisible(); +}); + +test("failed exchange still leaves no authorization material in the URL", async ({ page }) => { + await page.route(`${PRODUCTION_API}/auth/oauth2/authorize**`, async (route) => { + const url = new URL(route.request().url()); + const callback = new URL(url.searchParams.get("redirect_uri")!); + callback.search = new URLSearchParams({ code: "bad-code", state: url.searchParams.get("state")! }).toString(); + await route.fulfill({ status: 302, headers: { location: callback.toString() } }); + }); + await page.route(`${PRODUCTION_API}/auth/oauth2/token`, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + await route.fulfill({ status: 400, contentType: "application/json", headers: { "Access-Control-Allow-Origin": "*" }, body: JSON.stringify({ error: "invalid_grant" }) }); + }); + await page.goto("/"); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Connect to Writing Tools" })).toBeVisible(); + expect(new URL(page.url()).search).toBe(""); +}); + +test("401 clears OAuth only and preserves local map work", async ({ page }) => { + await seedSession(page); + await page.route(`${PRODUCTION_API}/openai/chat/completions`, (route) => route.fulfill({ + status: 401, + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": "X-Writing-Tools-Error", "X-Writing-Tools-Error": "platform-auth" }, + body: JSON.stringify({ error: "invalid_token" }), + })); + await page.goto("/"); + await page.getByRole("button", { name: "+ New card" }).click(); + await page.locator("textarea.map-card-editor").fill("keep this card"); + await page.locator("textarea.map-card-editor").blur(); + await page.locator("textarea.composer-textarea").fill("Trigger auth"); + await page.locator("textarea.composer-textarea").press("Enter"); + await expect(page.getByRole("heading", { name: "Connect to Writing Tools" })).toBeVisible(); + expect(await page.evaluate((key) => window.sessionStorage.getItem(key), SESSION_KEY)).toBeNull(); + expect(await page.evaluate(() => window.localStorage.getItem("prototype-mindmap-session-v1"))).toContain("keep this card"); +}); + +test("403 keeps the workspace and never offers another login", async ({ page }) => { + await seedSession(page); + await page.route(`${PRODUCTION_API}/openai/chat/completions`, (route) => route.fulfill({ + status: 403, + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": "X-Writing-Tools-Error", "X-Writing-Tools-Error": "platform-auth" }, + body: JSON.stringify({ error: "forbidden" }), + })); + await page.goto("/"); + await page.getByRole("button", { name: "+ New card" }).click(); + await page.locator("textarea.map-card-editor").fill("still visible"); + await page.locator("textarea.map-card-editor").blur(); + await page.locator("textarea.composer-textarea").fill("Trigger auth"); + await page.locator("textarea.composer-textarea").press("Enter"); + await expect(page.getByText(/Contact the Writing Tools team for access/)).toBeVisible(); + await expect(page.locator("textarea.map-card-editor")).toHaveValue("still visible"); + await expect(page.getByRole("button", { name: "Connect", exact: true })).toHaveCount(0); +}); + +test("clipboard output and explicit Disconnect preserve local work", async ({ page }) => { + await seedSession(page); + await page.goto("/"); + const draft = page.locator(".draft-editor"); + await draft.fill("Draft to copy"); + await page.getByRole("button", { name: "Copy draft" }).click(); + expect(await page.evaluate(() => (window as Window & { __copied?: string }).__copied)).toBe("Draft to copy"); + + await page.getByRole("button", { name: "+ New card" }).click(); + await page.locator("textarea.map-card-editor").fill("Map card"); + await page.locator("textarea.map-card-editor").blur(); + await page.getByRole("button", { name: "Copy map" }).click(); + expect(await page.evaluate(() => (window as Window & { __copied?: string }).__copied)).toBe("# Mindmap\n- Map card\n"); + + await page.getByRole("button", { name: "Disconnect" }).click(); + await expect(page.getByRole("heading", { name: "Connect to Writing Tools" })).toBeVisible(); + expect(await page.evaluate(() => window.localStorage.getItem("prototype-mindmap-session-v1"))).toContain("Map card"); +}); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..90def17 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; + +export default async function startDevelopmentServer(): Promise<(() => Promise) | undefined> { + if (process.env.PLAYWRIGHT_SKIP_SERVER) return undefined; + const port = Number(process.env.PLAYWRIGHT_PORT ?? 4173); + const root = fileURLToPath(new URL("..", import.meta.url)); + const server = await createServer({ + root, + server: { host: "127.0.0.1", port, strictPort: true }, + }); + await server.listen(); + return async () => { await server.close(); }; +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 5052ca9..687fa75 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -41,44 +41,24 @@ test("a chat-derived card remains off the map until confirm", async ({ page }) = await expect(page.locator("textarea.map-card-editor")).toHaveValue("human control"); }); -test("a launcher grant hydrates a labelled snapshot and authenticates the provider call", async ({ page }) => { - await page.route("**/handoff/exchange", async (route) => { - expect(route.request().postDataJSON()).toEqual({ grant_id: "grant-local" }); - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - access_token: "wtk_local", - expires_in: 3600, - client_id: "mindmap", - scopes: ["openai:chat", "doc:read"], - doc: { - documentLabel: "Launcher Essay.docx", - beforeCursor: "First ", - selectedText: "selected", - afterCursor: " paragraph.", - }, - }), - }); - }); +test("development opens directly and uses the sessionless provider path", async ({ page }) => { await page.route("**/openai/chat/completions", async (route) => { - expect(route.request().headers().authorization).toBe("Bearer wtk_local"); + expect(route.request().headers().authorization).toBeUndefined(); + expect(route.request().headers().cookie).toBeUndefined(); await route.fulfill({ contentType: "application/json", body: JSON.stringify(envelope({ - response: { kind: "question", text: "What matters in this snapshot?", stance: "deepen" }, + response: { kind: "question", text: "What matters in this draft?", stance: "deepen" }, })), }); }); - await page.goto("/#wt_grant=grant-local"); - await expect(page.getByText(/Snapshot of Launcher Essay\.docx captured at launch/)).toBeVisible(); - await expect(page.locator(".draft-editor")).toContainText("First selected paragraph."); - await expect.poll(() => new URL(page.url()).hash).toBe(""); - + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Connect to Writing Tools" })).toHaveCount(0); const composer = page.locator("textarea.composer-textarea"); - await composer.fill("Help me think about this."); + await composer.fill("Help me think about this draft."); await composer.press("Enter"); - await expect(page.getByText("What matters in this snapshot?")).toBeVisible(); + await expect(page.getByText("What matters in this draft?")).toBeVisible(); }); test("reader-language switching keeps populated cards mounted throughout translation", async ({ page }) => { diff --git a/package.json b/package.json index b5d08c0..f09f8fc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:pages": "npm run build", + "verify:pages": "node scripts/verify-pages-build.mjs", "preview": "vite preview", "test": "vitest run", "eval": "tsx eval/run.ts", @@ -14,6 +16,8 @@ "eval:typecheck": "tsc -p tsconfig.eval.json", "eval:report": "tsx eval/report.ts", "test:e2e": "playwright test", + "test:e2e:pages": "npm run build:pages && npm run verify:pages && npm run test:e2e:pages:built", + "test:e2e:pages:built": "playwright test --config playwright.pages.config.ts", "test:watch": "vitest" }, "dependencies": { diff --git a/playwright.config.ts b/playwright.config.ts index c91d311..f792b5e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,21 +1,10 @@ import { defineConfig } from "@playwright/test"; const port = Number(process.env.PLAYWRIGHT_PORT ?? 4173); -const viteCommand = - process.platform === "win32" - ? `set PORT=${port}&& node node_modules/vite/bin/vite.js --host 127.0.0.1 --port ${port}` - : `PORT=${port} node node_modules/vite/bin/vite.js --host 127.0.0.1 --port ${port}`; export default defineConfig({ testDir: "./e2e", timeout: 30_000, + globalSetup: "./e2e/global-setup.ts", use: { baseURL: `http://127.0.0.1:${port}`, headless: true }, - webServer: process.env.PLAYWRIGHT_SKIP_SERVER ? undefined : { - // Keep this on Vite's development server: launcher enforcement is intentionally - // off here. The production build is compiled separately in CI. - command: viteCommand, - url: `http://127.0.0.1:${port}`, - reuseExistingServer: !process.env.CI, - timeout: 30_000, - }, }); diff --git a/playwright.pages.config.ts b/playwright.pages.config.ts new file mode 100644 index 0000000..2426b2a --- /dev/null +++ b/playwright.pages.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test"; + +const port = Number(process.env.PLAYWRIGHT_PAGES_PORT ?? 4174); + +export default defineConfig({ + testDir: "./e2e-pages", + timeout: 30_000, + outputDir: "test-results/pages", + reporter: [["html", { outputFolder: "playwright-report-pages", open: "never" }]], + globalSetup: "./e2e-pages/global-setup.ts", + use: { baseURL: `http://127.0.0.1:${port}`, headless: true }, +}); diff --git a/scripts/verify-pages-build.mjs b/scripts/verify-pages-build.mjs new file mode 100644 index 0000000..bb89832 --- /dev/null +++ b/scripts/verify-pages-build.mjs @@ -0,0 +1,57 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; + +const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const distRoot = join(projectRoot, "dist"); +const productionEnv = loadEnv("production", projectRoot, "VITE_"); +const expectedBackendUrl = productionEnv.VITE_BACKEND_URL?.trim() ?? ""; +const expectedClientId = productionEnv.VITE_OAUTH_CLIENT_ID?.trim() ?? ""; +const searchableExtensions = new Set([".css", ".html", ".js", ".json", ".map", ".svg", ".txt"]); + +function fail(message) { + throw new Error(`Pages artifact verification failed: ${message}`); +} + +function validateConfiguration() { + if (!expectedBackendUrl) fail("VITE_BACKEND_URL is missing from .env.production."); + let parsed; + try { + parsed = new URL(expectedBackendUrl); + } catch { + fail(`VITE_BACKEND_URL is not a valid absolute URL: ${expectedBackendUrl}`); + } + if (parsed.protocol !== "https:") fail(`VITE_BACKEND_URL must use HTTPS, received: ${expectedBackendUrl}`); + if (!expectedClientId) fail("VITE_OAUTH_CLIENT_ID is missing from .env.production."); +} + +async function filesBelow(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await filesBelow(path)); + else if (entry.isFile()) files.push(path); + } + return files; +} + +validateConfiguration(); +const indexPath = join(distRoot, "index.html"); +if (!(await stat(indexPath).catch(() => null))?.isFile()) fail("dist/index.html is missing."); +const files = await filesBelow(distRoot); +if (files.length === 0) fail("dist is empty."); +const environmentFiles = files.filter((path) => { + const name = path.slice(distRoot.length + 1).split(/[\\/]/).at(-1) ?? ""; + return name === ".env" || name.startsWith(".env."); +}); +if (environmentFiles.length) fail(`environment files were published: ${environmentFiles.join(", ")}`); + +let compiledText = ""; +for (const path of files) { + if (searchableExtensions.has(extname(path).toLowerCase())) compiledText += await readFile(path, "utf8"); +} +if (!compiledText.includes(expectedBackendUrl)) fail(`the compiled output does not contain ${expectedBackendUrl}.`); +if (!compiledText.includes(expectedClientId)) fail(`the compiled output does not contain ${expectedClientId}.`); +console.log(`Pages artifact verified for ${expectedBackendUrl} and ${expectedClientId}.`); diff --git a/src/App.test.ts b/src/App.test.ts index 04bfc8a..2249526 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -3,8 +3,8 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AssistantResponseKindBadge, buildConversationHistory, deriveCurrentUserTurn, draftHtmlToPlainText, InfluenceBadge, MapActionProposalCard, migrateCandidateMemory, migrateLegacyMirrors, migrateStoredProposals, normalizeDraftPasteHtml, recoverFailedTurn, resolveMirrorDecision, restoreDraftHtml, restoreFailedMessageToComposer, TURN_PROGRESS_COPY } from "./App"; -import { hasPersistableWork, savedMindmapSummary, SESSION_STORAGE_KEY } from "./session-persistence"; +import { AssistantResponseKindBadge, buildConversationHistory, copyTextToClipboard, deriveCurrentUserTurn, draftHtmlToPlainText, InfluenceBadge, MapActionProposalCard, migrateCandidateMemory, migrateLegacyMirrors, migrateStoredProposals, normalizeDraftPasteHtml, recoverFailedTurn, resolveMirrorDecision, restoreDraftHtml, restoreFailedMessageToComposer, TURN_PROGRESS_COPY } from "./App"; +import { hasPersistableWork } from "./session-persistence"; import { UnderTheHoodPanel } from "./ControlRoom"; import { ASSISTANCE_CONTRACTS, snapshotContract } from "./assistance-contract"; import { ThoughtUnitStore } from "./map-store"; @@ -55,62 +55,12 @@ describe("resolveMirrorDecision", () => { }); }); -describe("persisted launcher snapshot metadata", () => { - it("keeps the historical storage key while reading version 7 document labels", () => { - expect(SESSION_STORAGE_KEY).toBe("prototype-mindmap-session-v1"); - const storage = { - getItem: (key: string) => key === SESSION_STORAGE_KEY ? JSON.stringify({ - version: 7, - draftSource: { - kind: "launch_snapshot", - documentLabel: "Essay.docx", - capturedAt: 10, - }, - draftText: "A real saved draft", - lastSavedAt: 20, - }) : null, - }; - expect(savedMindmapSummary(storage)).toEqual({ - documentLabel: "Essay.docx", - lastSavedAt: 20, - }); - }); - - it("migrates older saved sessions to a safe fallback label", () => { - const storage = { - getItem: () => JSON.stringify({ version: 6, draftText: "Legacy work" }), - }; - expect(savedMindmapSummary(storage)).toEqual({ - documentLabel: "Saved mindmap", - lastSavedAt: undefined, - }); - }); - - it("does not offer an untouched auto-persisted session as saved work", () => { - const storage = { - getItem: () => JSON.stringify({ - version: 7, - msgs: [], - draftText: "", - map: { units: [], positions: {}, connections: [] }, - lastSavedAt: 20, - }), - }; - expect(savedMindmapSummary(storage)).toBeNull(); - }); - - it("recognizes a map as real work even when chat and draft are empty", () => { - const storage = { - getItem: () => JSON.stringify({ - version: 7, - msgs: [], - draftText: "", - map: { units: [{ id: "card-1" }], positions: {}, connections: [] }, - }), - }; - expect(savedMindmapSummary(storage)).toMatchObject({ - documentLabel: "Saved mindmap", - }); +describe("clipboard output", () => { + it("writes the exact supplied text and fails when clipboard access is unavailable", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + await copyTextToClipboard("exact text", { writeText }); + expect(writeText).toHaveBeenCalledWith("exact text"); + await expect(copyTextToClipboard("text", undefined)).rejects.toThrow("Clipboard access is unavailable"); }); }); diff --git a/src/App.tsx b/src/App.tsx index b51fef3..27459f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,8 +19,9 @@ import { ASSISTANCE_CONTRACTS, contractForLevel, DEFAULT_ASSISTANCE_CONTRACT, no import { EventLedger, type LedgerEventKind } from "./event-ledger"; import { reconcileStoreSuggestionAdoption, type VisibleSuggestion } from "./suggestion-adoption"; import { provenanceTotals } from "./provenance-summary"; +import { formatMapAsMarkdown } from "./map-export"; import { useMutationAccess } from "./mutation-policy"; -import { interpolateUi, useUiLocale } from "./ui-locale"; +import { useUiLocale } from "./ui-locale"; import { useReaderView } from "./reader-view"; import { UnderTheHoodPanel } from "./ControlRoom"; import { @@ -31,7 +32,6 @@ import { type DraftPanelPos, type DraftPanelSize, type DraftSelectionFocus, - type DraftSourceMetadata, type PersistedPendingMirror, type PersistedSession, } from "./session-persistence"; @@ -2695,11 +2695,18 @@ function UnderhoodIcon() { export interface AppProps { providerRuntime?: ProviderRuntimeConfig; - initialDraft?: { text: string; source?: DraftSourceMetadata }; aiAccessDenied?: boolean; } -export default function App({ providerRuntime, initialDraft, aiAccessDenied = false }: AppProps) { +export async function copyTextToClipboard( + text: string, + clipboard: Pick | undefined = typeof navigator === "undefined" ? undefined : navigator.clipboard, +): Promise { + if (!clipboard) throw new Error("Clipboard access is unavailable."); + await clipboard.writeText(text); +} + +export default function App({ providerRuntime, aiAccessDenied = false }: AppProps) { const { locale, t } = useUiLocale(); const reader = useReaderView(); const mutationAccess = useMutationAccess(); @@ -2711,7 +2718,7 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa const initialState = useMemo(() => { const state = createConversationState(); if (!persistedSession) { - state.draft = initialDraft?.text ?? ""; + state.draft = ""; return state; } state.bank.replaceAll(persistedSession.bank); @@ -2731,7 +2738,7 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa state.currentDraftSnapshotId = persistedSession.conversation?.currentDraftSnapshotId; state.draftSnapshotText = persistedSession.conversation?.draftSnapshotText; return state; - }, [initialDraft?.text, persistedSession]); + }, [persistedSession]); const initialMapStore = useMemo(() => { const store = new ThoughtUnitStore(); @@ -2760,7 +2767,7 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa const initialMapRevision = persistedSession?.mapRevision ?? 0; const initialQuestionBias = snapQuestionBias(persistedSession?.questionBias ?? 35); const initialRequireConnectionLabel = persistedSession?.requireConnectionLabel ?? true; - const initialDraftText = persistedSession?.draftText ?? initialDraft?.text ?? ""; + const initialDraftText = persistedSession?.draftText ?? ""; const initialDraftHtml = restoreDraftHtml(persistedSession?.draftHtml, initialDraftText); const initialDraftCollapsed = persistedSession?.draftCollapsed ?? false; const initialDraftDocked = persistedSession?.draftDocked ?? false; @@ -2771,7 +2778,6 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa ? clampDraftPosition(persistedSession.draftPos, initialDraftSize) : { x: 0, y: 0 }; const initialStickyDraftFocus = persistedSession?.stickyDraftFocus; - const initialDraftSource = persistedSession?.draftSource ?? initialDraft?.source; const stateRef = useRef(initialState); const configRef = useRef(withQuestionIntentBias(defaultConfig, initialQuestionBias)); @@ -2805,7 +2811,6 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa const [contextSelectedCardIds, setContextSelectedCardIds] = useState>(new Set()); const [draftSelectionFocus, setDraftSelectionFocus] = useState(undefined); const [stickyDraftFocus, setStickyDraftFocus] = useState(initialStickyDraftFocus); - const [draftSource, setDraftSource] = useState(initialDraftSource); const ledgerRef = useRef(new EventLedger(initialSessionId)); const contract = contractForLevel(assistanceLevel); @@ -3046,6 +3051,7 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa // Draft panel state const [draftText, setDraftText] = useState(initialDraftText); + const [clipboardStatus, setClipboardStatus] = useState(""); const [draftHtml, setDraftHtml] = useState(initialDraftHtml); const [draftCollapsed, setDraftCollapsed] = useState(initialDraftCollapsed); const [draftDocked, setDraftDocked] = useState(initialDraftDocked); @@ -3564,7 +3570,6 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa draftDocked, draftPos, draftSize, - draftSource, lastSavedAt: Date.now(), stickyDraftFocus, conversation: { @@ -3598,7 +3603,6 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa draftPos, draftSize, draftText, - draftSource, lastCoachDebug, understandingSnapshot, mapRevision, @@ -3727,6 +3731,24 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa } } + const copyDraft = useCallback(async () => { + try { + await copyTextToClipboard(draftText); + setClipboardStatus(t("Draft copied")); + } catch { + setClipboardStatus(t("Could not copy the draft. Select it and copy manually.")); + } + }, [draftText, t]); + + const copyMap = useCallback(async () => { + try { + await copyTextToClipboard(formatMapAsMarkdown(mapStoreRef.current.snapshot())); + setClipboardStatus(t("Map copied")); + } catch { + setClipboardStatus(t("Could not copy the map. Please try again.")); + } + }, [t]); + // Runs a coach-only turn without synthetic user text. A panel request replaces // its prior coach move; a completed proposal appends a genuine continuation. async function requestMode(mode?: UserRequestedMode, proposalOutcome?: ProposalOutcomeContext, currentMapRevision = mapRevision, recoveryId?: number) { @@ -3974,7 +3996,6 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa setHighlightAnchor(undefined); setDraftSelectionFocus(undefined); setStickyDraftFocus(undefined); - setDraftSource(undefined); stateRef.current.draft = ""; } @@ -4144,7 +4165,7 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa
- {aiAccessDenied &&
{t("This account is not permitted to use AI features. Your draft and map remain available.")}
} + {aiAccessDenied &&
{t("This account is not enabled for Writing Tools AI. Contact the Writing Tools team for access. Your draft and map remain available.")}
} {error &&
{error}
} {stickyDraftFocus && (
@@ -4277,14 +4298,9 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa >
{t("Draft")} - {draftSource && ( - - {interpolateUi( - t("Snapshot of {document} captured at launch. Edits here do not sync back."), - { document: draftSource.documentLabel }, - )} - - )} +
+
{clipboardStatus}
); diff --git a/src/Map.tsx b/src/Map.tsx index b8ed7bd..470d529 100644 --- a/src/Map.tsx +++ b/src/Map.tsx @@ -183,6 +183,7 @@ interface ThoughtMapProps { onRequireConnectionLabelChange: (value: boolean) => void; canUndo: boolean; onUndo: () => void; + onCopyMap: () => void; onClearDraft: () => void; onClearMap: () => void; onContextCardToggle?: (id: string) => void; @@ -845,6 +846,7 @@ function ThoughtMapInner({ onRequireConnectionLabelChange, canUndo, onUndo, + onCopyMap, onClearDraft, onClearMap, onContextCardToggle, @@ -1707,6 +1709,9 @@ function ThoughtMapInner({
{draftDockSlot ?? draftDock} + diff --git a/src/PlatformBootstrap.test.ts b/src/PlatformBootstrap.test.ts index 04162e7..5fa885c 100644 --- a/src/PlatformBootstrap.test.ts +++ b/src/PlatformBootstrap.test.ts @@ -5,8 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppErrorBoundary, browserStorage, initialBootState } from "./PlatformBootstrap"; import { SESSION_STORAGE_KEY } from "./session-persistence"; import { - PLATFORM_SESSION_STORAGE_KEY, - type PlatformSession, + OAUTH_SESSION_STORAGE_KEY, } from "./platform-session"; function Boom(): ReactNode { @@ -72,27 +71,18 @@ describe("AppErrorBoundary", () => { getItem.mockRestore(); }); - it("routes an expired stored token to relaunch guidance without clearing saved work", () => { - const session: PlatformSession = { - version: 1, - accessToken: "wtk_expired", - expiresAt: Date.now() - 1, - scopes: ["openai:chat"], - doc: null, - capturedAt: Date.now() - 1000, - }; + it("ignores an expired OAuth token without clearing saved work", () => { + const session = { version: 1, accessToken: "a.b.c", expiresAt: Date.now() - 1 }; window.sessionStorage.setItem( - PLATFORM_SESSION_STORAGE_KEY, + OAUTH_SESSION_STORAGE_KEY, JSON.stringify(session), ); window.localStorage.setItem( SESSION_STORAGE_KEY, JSON.stringify({ version: 7, draftText: "keep me" }), ); - expect(initialBootState()).toEqual({ - kind: "blocked", - reason: "token_expired", - }); + expect(initialBootState()).toEqual({ kind: "ready", session: null }); + expect(window.sessionStorage.getItem(OAUTH_SESSION_STORAGE_KEY)).toBeNull(); expect(window.localStorage.getItem(SESSION_STORAGE_KEY)).toContain("keep me"); }); }); diff --git a/src/PlatformBootstrap.tsx b/src/PlatformBootstrap.tsx index 568ef26..eca401a 100644 --- a/src/PlatformBootstrap.tsx +++ b/src/PlatformBootstrap.tsx @@ -2,47 +2,31 @@ import { Component, useCallback, useEffect, useMemo, useRef, useState, type Erro import App from "./App"; import type { ProviderRuntimeConfig } from "./api"; import { MutationPolicyProvider } from "./mutation-policy"; +import { SESSION_STORAGE_KEY } from "./session-persistence"; import { - clearSavedMindmap, - savedMindmapSummary, - SESSION_STORAGE_KEY, - type DraftSourceMetadata, - type SavedMindmapSummary, -} from "./session-persistence"; -import { - clearPlatformSession, - exchangeGrant, - grantFromHash, - GrantExchangeError, - launchRequired, - PLATFORM_SESSION_STORAGE_KEY, - readPlatformSession, - scrubGrantFromUrl, - snapshotText, - writePlatformSession, - type PlatformSession, + OAUTH_RESOURCE, + OAUTH_SESSION_STORAGE_KEY, + clearOAuthSession, + consumeOAuthTransaction, + createAuthorizationRequest, + exchangeAuthorizationCode, + loginRequired, + oauthCallbackFromSearch, + oauthRedirectUri, + readOAuthSession, + scrubOAuthCallbackFromUrl, + writeOAuthSession, + type OAuthCallback, + type OAuthSession, } from "./platform-session"; import { ReaderViewProvider, useReaderView } from "./reader-view"; -import { interpolateUi, UiLocaleProvider, useUiLocale } from "./ui-locale"; - -type BlockingReason = - | "launch_required" - | "token_expired" - | "grant_expired" - | "grant_used" - | "grant_invalid" - | "network" - | "storage_unavailable"; +import { UiLocaleProvider, useUiLocale } from "./ui-locale"; type BootState = | { kind: "connecting" } - | { kind: "blocked"; reason: BlockingReason; detail?: string } - | { kind: "choice"; session: PlatformSession; saved: SavedMindmapSummary } - | { - kind: "ready"; - session: PlatformSession | null; - initialDraft?: { text: string; source?: DraftSourceMetadata }; - }; + | { kind: "connect_required"; detail?: string } + | { kind: "storage_unavailable" } + | { kind: "ready"; session: OAuthSession | null }; function ReaderMutationBoundary({ children }: { children: ReactNode }) { const reader = useReaderView(); @@ -56,82 +40,10 @@ function ReaderMutationBoundary({ children }: { children: ReactNode }) { ); } -function formatTime(value: number | undefined, unknownLabel: string): string { - if (!value) return unknownLabel; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(value)); -} - -function incomingLabel(session: PlatformSession): string { - return session.doc?.documentLabel?.trim() || "Document snapshot"; -} - -function initialDraft(session: PlatformSession): { text: string; source?: DraftSourceMetadata } { - if (!session.doc) return { text: "" }; - return { - text: snapshotText(session.doc), - source: { - kind: "launch_snapshot", - documentLabel: incomingLabel(session), - capturedAt: session.capturedAt, - }, - }; -} - -function blockedCopy(reason: BlockingReason): { title: string; body: string } { - switch (reason) { - case "token_expired": - return { - title: "Your Mindmap access has expired", - body: "Your saved mindmap is still here. Launch Mindmap again from Writing Tools, then choose your saved mindmap.", - }; - case "grant_expired": - return { - title: "This launch took too long", - body: "The two-minute launch grant expired before Mindmap could use it. Launch Mindmap again from Writing Tools.", - }; - case "grant_used": - return { - title: "This launch link was already used", - body: "Launch Mindmap again from Writing Tools to create a fresh link.", - }; - case "grant_invalid": - return { - title: "This launch link is invalid", - body: "Launch Mindmap again from Writing Tools.", - }; - case "network": - return { - title: "Mindmap could not reach Writing Tools", - body: "Check the connection and retry. If the link was consumed, launch Mindmap again.", - }; - case "storage_unavailable": - return { - title: "Mindmap needs browser storage", - body: "Browser storage is unavailable, so Mindmap cannot safely preserve your access or local work. Enable storage for this site and launch again.", - }; - default: - return { - title: "Launch Mindmap from Writing Tools", - body: "Open the Tools page in Writing Tools and launch Mindmap to connect your account.", - }; - } -} - /** - * Root error boundary. `Map.tsx` guards the canvas, but nothing sat above `App`, so any - * throw during render blanked the page with no way back — the failure shape behind the - * blank-map incident. - * - * It deliberately does NOT clear storage. A render crash must leave the saved mindmap - * exactly as intact as a token expiry does, so reloading resumes the session rather - * than starting the user over. If the crash is itself caused by bad persisted state, - * that is a bug to fix in the loader's validation, not something to paper over here by - * destroying the user's work. - * - * Class component because React exposes no hook equivalent for `componentDidCatch`. + * Root error boundary. A render crash must leave the locally saved mindmap intact; + * reloading may recover it, while clearing storage here would turn a UI defect into + * data loss. */ export class AppErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { state = { failed: false }; @@ -146,57 +58,16 @@ export class AppErrorBoundary extends Component<{ children: ReactNode }, { faile render() { if (!this.state.failed) return this.props.children; - // Plain strings, not `t()`: the locale provider sits inside this boundary, and a - // crash there would make the fallback itself throw. return (

Mindmap hit an unexpected error

-

- Your saved mindmap has not been changed. Reloading should bring it back — if - this keeps happening, relaunch Mindmap from Writing Tools. -

+

Your saved mindmap has not been changed. Reloading should bring it back.

); } } -export function initialBootState(): BootState { - if (typeof window === "undefined") return { kind: "connecting" }; - const hasGrant = Boolean(grantFromHash(window.location.hash)); - if (hasGrant) return { kind: "connecting" }; - const storage = browserStorage(); - if (!storage) return { kind: "blocked", reason: "storage_unavailable" }; - const stored = readPlatformSession(storage.session); - if (stored) { - if (stored.expiresAt <= Date.now()) { - return { kind: "blocked", reason: "token_expired" }; - } - const saved = savedMindmapSummary(storage.local); - if (!stored.decision && saved) return { kind: "choice", session: stored, saved }; - return { - kind: "ready", - session: stored, - ...(stored.decision === "start_new" && !saved - ? { initialDraft: initialDraft(stored) } - : {}), - }; - } - return launchRequired() - ? { kind: "blocked", reason: "launch_required" } - : { kind: "ready", session: null }; -} - -export default function PlatformBootstrap() { - return ( - - - - - - ); -} - interface BrowserStorage { session: Storage; local: Storage; @@ -207,9 +78,7 @@ export function browserStorage(): BrowserStorage | null { try { const session = window.sessionStorage; const local = window.localStorage; - // Property access and getItem can fail independently in privacy-restricted - // contexts. Probe both stores before accepting a launch snapshot. - session.getItem(PLATFORM_SESSION_STORAGE_KEY); + session.getItem(OAUTH_SESSION_STORAGE_KEY); local.getItem(SESSION_STORAGE_KEY); return { session, local }; } catch { @@ -217,136 +86,147 @@ export function browserStorage(): BrowserStorage | null { } } -function PlatformBootstrapContent() { - const { t } = useUiLocale(); - const [boot, setBoot] = useState(initialBootState); - const [accessDenied, setAccessDenied] = useState(false); - const exchangeStarted = useRef(false); - const capturedGrant = useRef(null); +export function initialBootState(): BootState { + if (typeof window === "undefined") return { kind: "connecting" }; + const storage = browserStorage(); + if (!storage) return { kind: "storage_unavailable" }; + if (oauthCallbackFromSearch(window.location.search)) return { kind: "connecting" }; + const session = readOAuthSession(storage.session); + if (session) return { kind: "ready", session }; + try { + clearOAuthSession(storage.session); + } catch { + return { kind: "storage_unavailable" }; + } + return loginRequired() ? { kind: "connect_required" } : { kind: "ready", session: null }; +} - const commitDecision = useCallback( - (session: PlatformSession, decision: "continue_saved" | "start_new") => { - const storage = browserStorage(); - if (!storage) { - setBoot({ kind: "blocked", reason: "storage_unavailable" }); - return; - } - const decided = { ...session, decision }; - try { - // Persist the decision before clearing anything. A quota/security failure - // must leave the existing mindmap untouched. - writePlatformSession(storage.session, decided); - if (decision === "start_new") clearSavedMindmap(storage.local); - } catch (error) { - setBoot({ - kind: "blocked", - reason: "storage_unavailable", - detail: error instanceof Error ? error.message : String(error), - }); - return; - } - setBoot({ - kind: "ready", - session: decided, - ...(decision === "start_new" - ? { initialDraft: initialDraft(decided) } - : {}), - }); - }, - [], +export default function PlatformBootstrap() { + return ( + + + + + ); +} - const beginExchange = useCallback( - async (grant: string) => { - const storage = browserStorage(); - if (!storage) { - setBoot({ kind: "blocked", reason: "storage_unavailable" }); - return; - } - setBoot({ kind: "connecting" }); - try { - const session = await exchangeGrant(grant); - try { - writePlatformSession(storage.session, session); - } catch (error) { - setBoot({ - kind: "blocked", - reason: "storage_unavailable", - detail: error instanceof Error ? error.message : String(error), - }); - return; - } - const saved = savedMindmapSummary(storage.local); - if (saved) { - setBoot({ kind: "choice", session, saved }); - return; - } - commitDecision(session, "start_new"); - } catch (error: unknown) { - if (error instanceof GrantExchangeError) { - const reason: BlockingReason = - error.code === "expired" - ? "grant_expired" - : error.code === "already_used" - ? "grant_used" - : error.code === "network" - ? "network" - : "grant_invalid"; - setBoot({ kind: "blocked", reason, detail: error.message }); - return; - } - setBoot({ kind: "blocked", reason: "network", detail: String(error) }); - } - }, - [commitDecision], +function PlatformBootstrapContent() { + const { t } = useUiLocale(); + const capturedCallback = useRef( + typeof window === "undefined" ? null : oauthCallbackFromSearch(window.location.search), ); + const callbackHandled = useRef(false); + const [boot, setBoot] = useState(initialBootState); + const [forbidden, setForbidden] = useState(false); useEffect(() => { - const grant = grantFromHash(window.location.hash); - if (!grant || exchangeStarted.current) return; - exchangeStarted.current = true; - capturedGrant.current = grant; + const callback = capturedCallback.current; + if (!callback || callbackHandled.current) return; + callbackHandled.current = true; + + // Authorization material must leave the address bar before any asynchronous + // exchange or failure path can run. try { - scrubGrantFromUrl(window.location, window.history); + scrubOAuthCallbackFromUrl(window.location, window.history); } catch { - // The grant is already captured in memory; an unusual history wrapper must - // not force a reload-based retry or expose the document a second time. + // Continue with the values already captured in memory. A history wrapper must + // not trigger a reload that could replay the authorization code. } - void beginExchange(grant); - }, [beginExchange]); - const chooseContinue = useCallback((session: PlatformSession) => { - commitDecision(session, "continue_saved"); - }, [commitDecision]); + const storage = browserStorage(); + if (!storage) { + setBoot({ kind: "storage_unavailable" }); + return; + } + const transaction = consumeOAuthTransaction(storage.session); + const validTransaction = Boolean( + callback.state && + transaction && + callback.state === transaction.state && + transaction.redirectUri === oauthRedirectUri(window.location) && + transaction.resource === OAUTH_RESOURCE, + ); + if (!validTransaction) { + setBoot({ kind: "connect_required", detail: t("The login response could not be verified. Please try again.") }); + return; + } + if (callback.error) { + setBoot({ + kind: "connect_required", + detail: callback.error === "access_denied" + ? t("Writing Tools access was not granted.") + : t("Writing Tools could not complete the login."), + }); + return; + } + if (!callback.code || !transaction) { + setBoot({ kind: "connect_required", detail: t("The login response could not be verified. Please try again.") }); + return; + } + setBoot({ kind: "connecting" }); + void exchangeAuthorizationCode(callback.code, transaction) + .then((session) => { + writeOAuthSession(storage.session, session); + setForbidden(false); + setBoot({ kind: "ready", session }); + }) + .catch(() => { + setBoot({ kind: "connect_required", detail: t("Writing Tools could not complete the login. Please try again.") }); + }); + }, [t]); - const chooseStartNew = useCallback((session: PlatformSession) => { - commitDecision(session, "start_new"); - }, [commitDecision]); + const connect = useCallback(async () => { + const storage = browserStorage(); + if (!storage) { + setBoot({ kind: "storage_unavailable" }); + return; + } + setBoot({ kind: "connecting" }); + try { + const request = await createAuthorizationRequest({ storage: storage.session, location: window.location }); + window.location.assign(request.url); + } catch { + setBoot({ kind: "connect_required", detail: t("Mindmap could not start a secure login. Please try again.") }); + } + }, [t]); + + const disconnect = useCallback(() => { + const storage = browserStorage(); + if (!storage) { + setBoot({ kind: "storage_unavailable" }); + return; + } + try { + clearOAuthSession(storage.session); + } catch { + setBoot({ kind: "storage_unavailable" }); + return; + } + setForbidden(false); + setBoot({ kind: "connect_required" }); + }, []); const onAccessError = useCallback((status: 401 | 403) => { if (status === 403) { - setAccessDenied(true); + setForbidden(true); return; } const storage = browserStorage(); - if (storage) { - try { - clearPlatformSession(storage.session); - } catch { - // The blocking screen remains honest; local mindmap work is never cleared. - } + if (!storage) { + setBoot({ kind: "storage_unavailable" }); + return; } - setBoot((current) => ({ - kind: "blocked", - // A standalone session never held a token, so "expired" would be a lie. The - // honest instruction is the same one an ungated visitor needs: go launch it. - reason: current.kind === "ready" && !current.session ? "launch_required" : "token_expired", - })); - }, []); + try { + clearOAuthSession(storage.session); + } catch { + setBoot({ kind: "storage_unavailable" }); + return; + } + setForbidden(false); + setBoot({ kind: "connect_required", detail: t("Your Writing Tools access expired. Your local work is still here.") }); + }, [t]); - // Supplied whenever the app renders, with or without a session. Returning `undefined` - // for the standalone case left 401s with no handler at all, so they surfaced as a raw - // "Backend 401" chat banner instead of a blocking screen. const providerRuntime = useMemo(() => { if (boot.kind !== "ready") return undefined; return { @@ -359,57 +239,36 @@ function PlatformBootstrapContent() { return

{t("Connecting to Writing Tools…")}

; } - if (boot.kind === "blocked") { - const copy = blockedCopy(boot.reason); + if (boot.kind === "storage_unavailable") { return (
-

{t(copy.title)}

-

{t(copy.body)}

- {boot.detail &&

{boot.detail}

} - {boot.reason === "network" && ( - - )} +

{t("Mindmap needs browser storage")}

+

{t("Browser storage is unavailable, so Mindmap cannot safely preserve your access or local work. Enable storage for this site and reload.")}

); } - if (boot.kind === "choice") { - const hasIncomingDocument = boot.session.doc !== null; + if (boot.kind === "connect_required") { return ( -
-

{t("Which mindmap would you like to open?")}

- - +
+

{t("Connect to Writing Tools")}

+

{t("Sign in to use Writing Tools AI. Your draft and map stay in this browser.")}

+ {boot.detail &&

{boot.detail}

} +
); } return ( - - - - - +
+ {boot.session && ( + + )} + + + + + +
); } diff --git a/src/api.ts b/src/api.ts index 6815b8e..d1d7c8f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -27,8 +27,17 @@ const viteEnv = (import.meta as ImportMeta & { env?: Record { const configured = viteEnv?.VITE_BACKEND_URL?.trim(); - if (configured) return configured; - if ((viteEnv as { PROD?: boolean } | undefined)?.PROD === true) { + const production = (viteEnv as { PROD?: boolean } | undefined)?.PROD === true; + if (configured) { + // Mirrors resolveBackendUrl: production never sends the bearer token over http. + let https = false; + try { https = new URL(configured).protocol === "https:"; } catch { /* invalid URL */ } + if (production && !https) { + throw new Error("VITE_BACKEND_URL must be an https:// URL for production builds of the mindmap."); + } + return configured; + } + if (production) { throw new Error("VITE_BACKEND_URL must be set for production builds of the mindmap."); } return "http://localhost:8000/api"; diff --git a/src/i18n/source.json b/src/i18n/source.json index 4804e8d..a2093c8 100644 --- a/src/i18n/source.json +++ b/src/i18n/source.json @@ -191,5 +191,23 @@ "Saved mindmap", "Restore to composer", "Mindmap needs browser storage", - "Browser storage is unavailable, so Mindmap cannot safely preserve your access or local work. Enable storage for this site and launch again." + "Browser storage is unavailable, so Mindmap cannot safely preserve your access or local work. Enable storage for this site and launch again.", + "Connect to Writing Tools", + "Sign in to use Writing Tools AI. Your draft and map stay in this browser.", + "Connect", + "Disconnect", + "Writing Tools access was not granted.", + "Writing Tools could not complete the login.", + "The login response could not be verified. Please try again.", + "Writing Tools could not complete the login. Please try again.", + "Mindmap could not start a secure login. Please try again.", + "Your Writing Tools access expired. Your local work is still here.", + "Browser storage is unavailable, so Mindmap cannot safely preserve your access or local work. Enable storage for this site and reload.", + "This account is not enabled for Writing Tools AI. Contact the Writing Tools team for access. Your draft and map remain available.", + "Copy draft", + "Copy map", + "Draft copied", + "Map copied", + "Could not copy the draft. Select it and copy manually.", + "Could not copy the map. Please try again." ] diff --git a/src/map-export.test.ts b/src/map-export.test.ts new file mode 100644 index 0000000..eb7a4d4 --- /dev/null +++ b/src/map-export.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { formatMapAsMarkdown } from "./map-export"; +import type { ThoughtUnitStoreSnapshot } from "./map-store"; +import type { ThoughtUnit } from "./types"; + +function unit(id: string, text: string, parentId?: string, role: ThoughtUnit["role"] = "node"): ThoughtUnit { + return { + id, + text, + role, + ...(parentId ? { parentId } : {}), + source: { utteranceIds: [], createdBy: "user" }, + roleHistory: [], + }; +} + +function snapshot( + units: ThoughtUnit[], + connections: ThoughtUnitStoreSnapshot["connections"] = [], +): ThoughtUnitStoreSnapshot { + return { units, positions: {}, connections }; +} + +describe("map Markdown export", () => { + it("preserves root, sibling, child, and multiline store order", () => { + expect(formatMapAsMarkdown(snapshot([ + unit("root", "Root"), + unit("child-1", "First\ncontinued", "root", "content"), + unit("child-2", "Second", "root", "content"), + unit("other", "Other"), + ]))).toBe("# Mindmap\n- Root\n - First\n continued\n - Second\n- Other\n"); + }); + + it("excludes label units and recovers orphans, empty cards, and malformed cycles", () => { + const a = unit("a", "A", "b", "content"); + const b = unit("b", "B", "a", "content"); + expect(formatMapAsMarkdown(snapshot([ + unit("label", "relates", undefined, "connection_label"), + unit("orphan", "", "missing", "content"), + a, + b, + ]))).toBe("# Mindmap\n- (empty card)\n- A\n - B\n"); + }); + + it("renders directed, reversed, undirected, labelled, and unlabelled relationships", () => { + const units = [unit("a", "Alpha"), unit("b", "Beta"), unit("c", "Gamma"), unit("l", "supports", undefined, "connection_label")]; + const base = { confirmedAt: 1, createdBy: "user" as const, labelUnitId: "l" }; + expect(formatMapAsMarkdown(snapshot(units, [ + { ...base, id: "c1", sourceId: "a", targetId: "b", layoutDirection: "source_to_target" }, + { ...base, id: "c2", sourceId: "a", targetId: "c", layoutDirection: "target_to_source" }, + { ...base, id: "c3", sourceId: "b", targetId: "c", layoutDirection: "none" }, + { ...base, id: "c4", sourceId: "a", targetId: "c", labelUnitId: "missing", layoutDirection: "source_to_target" }, + ]))).toContain("## Relationships\n- Alpha — supports → Beta\n- Gamma — supports → Alpha\n- Beta — supports — Gamma\n- Alpha → Gamma\n"); + }); + + it("drops relationships whose visible endpoints no longer exist", () => { + expect(formatMapAsMarkdown(snapshot([unit("a", "Alpha")], [{ + id: "bad", sourceId: "a", targetId: "missing", labelUnitId: "missing", + layoutDirection: "none", confirmedAt: 1, createdBy: "user", + }]))).toBe("# Mindmap\n- Alpha\n"); + }); +}); diff --git a/src/map-export.ts b/src/map-export.ts new file mode 100644 index 0000000..53af482 --- /dev/null +++ b/src/map-export.ts @@ -0,0 +1,83 @@ +import type { ThoughtConnection, ThoughtUnitStoreSnapshot } from "./map-store"; +import type { ThoughtUnit } from "./types"; + +const EMPTY_CARD = "(empty card)"; + +function visible(unit: ThoughtUnit): boolean { + return unit.role !== "connection_label"; +} + +function displayText(unit: ThoughtUnit | undefined): string { + return unit?.text.trim() || EMPTY_CARD; +} + +function bullet(text: string, depth: number): string[] { + const indent = " ".repeat(depth); + const lines = (text.trim() || EMPTY_CARD).split(/\r?\n/); + return [ + `${indent}- ${lines[0]}`, + ...lines.slice(1).map((line) => `${indent} ${line}`), + ]; +} + +function relationshipLine( + connection: ThoughtConnection, + units: ReadonlyMap, +): string | null { + const source = units.get(connection.sourceId); + const target = units.get(connection.targetId); + if (!source || !target || !visible(source) || !visible(target)) return null; + const label = units.get(connection.labelUnitId)?.text.trim(); + const sourceText = displayText(source).replace(/\s*\r?\n\s*/g, " "); + const targetText = displayText(target).replace(/\s*\r?\n\s*/g, " "); + const middle = label ? ` — ${label.replace(/\s*\r?\n\s*/g, " ")} ` : " "; + if (connection.layoutDirection === "target_to_source") { + return `- ${targetText}${middle}→ ${sourceText}`; + } + if (connection.layoutDirection === "source_to_target") { + return `- ${sourceText}${middle}→ ${targetText}`; + } + return `- ${sourceText}${label ? `${middle}— ` : " — "}${targetText}`; +} + +/** Deterministic, loss-averse Markdown representation of the locally stored map. */ +export function formatMapAsMarkdown(snapshot: ThoughtUnitStoreSnapshot): string { + const visibleUnits = snapshot.units.filter(visible); + const units = new Map(snapshot.units.map((unit) => [unit.id, unit])); + const children = new Map(); + for (const unit of visibleUnits) { + if (!unit.parentId) continue; + const siblings = children.get(unit.parentId) ?? []; + siblings.push(unit); + children.set(unit.parentId, siblings); + } + + const lines = ["# Mindmap"]; + const visited = new Set(); + const visit = (unit: ThoughtUnit, depth: number) => { + if (visited.has(unit.id)) return; + visited.add(unit.id); + lines.push(...bullet(unit.text, depth)); + for (const child of children.get(unit.id) ?? []) visit(child, depth + 1); + }; + + const roots = visibleUnits.filter((unit) => { + if (!unit.parentId) return true; + const parent = units.get(unit.parentId); + return !parent || !visible(parent); + }); + for (const root of roots) visit(root, 0); + // Malformed persisted cycles have no roots. Emit their first unvisited member and + // let `visited` terminate the loop rather than dropping or hanging on user content. + for (const unit of visibleUnits) visit(unit, 0); + + const relationships = snapshot.connections.flatMap((connection) => { + const line = relationshipLine( + { ...connection, layoutDirection: connection.layoutDirection ?? "none" }, + units, + ); + return line ? [line] : []; + }); + if (relationships.length) lines.push("", "## Relationships", ...relationships); + return `${lines.join("\n")}\n`; +} diff --git a/src/platform-session.test.ts b/src/platform-session.test.ts index 5bc5f1b..59e5378 100644 --- a/src/platform-session.test.ts +++ b/src/platform-session.test.ts @@ -1,25 +1,26 @@ import { describe, expect, it, vi } from "vitest"; import { - GrantExchangeError, - PLATFORM_SESSION_STORAGE_KEY, - clearPlatformSession, - exchangeGrant, - grantFromHash, - hashWithoutGrant, - launchRequired, - readPlatformSession, - snapshotText, - writePlatformSession, - type PlatformSession, + OAUTH_SCOPE, + OAUTH_SESSION_STORAGE_KEY, + OAUTH_TRANSACTION_MAX_AGE_MS, + OAUTH_TRANSACTION_STORAGE_KEY, + canonicalOAuthResource, + clearOAuthSession, + consumeOAuthTransaction, + createAuthorizationRequest, + exchangeAuthorizationCode, + isCompactJwt, + loginRequired, + oauthCallbackFromSearch, + oauthRedirectUri, + readOAuthSession, + resolveBackendUrl, + resolveOAuthClientId, + scrubOAuthCallbackFromUrl, + writeOAuthSession, + type OAuthTransaction, } from "./platform-session"; -function response(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - function memoryStorage(): Storage { const values = new Map(); return { @@ -32,140 +33,197 @@ function memoryStorage(): Storage { }; } -describe("platform launcher session", () => { - it("extracts and scrubs a grant without dropping other fragment values", () => { - expect(grantFromHash("#view=map&wt_grant=a%20b&lang=en")).toBe("a b"); - expect(hashWithoutGrant("#view=map&wt_grant=a%20b&lang=en")).toBe("#view=map&lang=en"); +function base64Url(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function jwt(exp = Math.floor(Date.now() / 1000) + 43_200): string { + return `${base64Url({ alg: "EdDSA", typ: "JWT" })}.${base64Url({ exp })}.signature`; +} + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +const transaction: OAuthTransaction = { + version: 1, + state: "state", + verifier: "v".repeat(64), + redirectUri: "https://mindmap.example/", + resource: "https://app.example", + createdAt: Date.now(), +}; + +describe("standalone OAuth session", () => { + it("fails closed in production while keeping development defaults", () => { + expect(resolveBackendUrl({ PROD: false })).toBe("http://localhost:8000/api"); + expect(resolveOAuthClientId({ PROD: false })).toBe("writing-tools-mindmap"); + expect(() => resolveBackendUrl({ PROD: true })).toThrow("VITE_BACKEND_URL"); + expect(() => resolveOAuthClientId({ PROD: true })).toThrow("VITE_OAUTH_CLIENT_ID"); }); - it("ignores malformed fragment encoding instead of throwing during boot", () => { - expect(() => grantFromHash("#%")).not.toThrow(); - expect(grantFromHash("#%")).toBeNull(); - expect(hashWithoutGrant("#%&view=map")).toBe("#%&view=map"); + it("refuses a non-HTTPS backend in production but allows http://localhost in dev", () => { + for (const url of ["http://insecure.example/api", "not a url", "ftp://app.example/api"]) { + expect(() => resolveBackendUrl({ PROD: true, VITE_BACKEND_URL: url }), url).toThrow("https://"); + } + expect(resolveBackendUrl({ PROD: true, VITE_BACKEND_URL: "https://app.example/api/" })) + .toBe("https://app.example/api"); + expect(resolveBackendUrl({ PROD: false, VITE_BACKEND_URL: "http://localhost:8000/api" })) + .toBe("http://localhost:8000/api"); }); - it("exchanges with omitted credentials and accepts a nullable document", async () => { - const fetcher = vi.fn().mockResolvedValue(response({ - access_token: "wtk_token", - expires_in: 3600, - client_id: "mindmap", - scopes: ["openai:chat", "doc:read"], - doc: null, - })); - const session = await exchangeGrant("wtg_grant", { - fetcher, + it("rejects abandoned or future-dated login transactions", () => { + const storage = memoryStorage(); + const now = 1_000_000_000; + const store = (createdAt: number) => + storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify({ ...transaction, createdAt })); + store(now - 60_000); + expect(consumeOAuthTransaction(storage, now)).not.toBeNull(); + store(now - OAUTH_TRANSACTION_MAX_AGE_MS - 1); + expect(consumeOAuthTransaction(storage, now)).toBeNull(); + expect(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY)).toBeNull(); + store(now + 5 * 60_000); + expect(consumeOAuthTransaction(storage, now)).toBeNull(); + }); + + it("treats any authorization parameter as callback material", () => { + expect(oauthCallbackFromSearch("?view=map")).toBeNull(); + expect(oauthCallbackFromSearch("?state=s")).toEqual({ state: "s" }); + expect(oauthCallbackFromSearch("?error_description=x")).toEqual({ errorDescription: "x" }); + }); + + it("keeps the earliest expiry when the JWT and expires_in disagree", async () => { + const now = 1_000_000_000_000; + const lateJwt = jwt(Math.floor(now / 1000) + 86_400); + const session = await exchangeAuthorizationCode("code", transaction, { + fetcher: vi.fn(async () => response({ + access_token: lateJwt, + token_type: "Bearer", + scope: OAUTH_SCOPE, + expires_in: 3600, + })) as unknown as typeof fetch, backendUrl: "https://app.example/api", - now: () => 1000, + clientId: "client", + now: () => now, }); + expect(session.expiresAt).toBe(now + 3_600_000); - expect(fetcher).toHaveBeenCalledWith( - "https://app.example/api/handoff/exchange", - expect.objectContaining({ - method: "POST", - credentials: "omit", - body: JSON.stringify({ grant_id: "wtg_grant" }), - }), - ); - expect(session).toMatchObject({ accessToken: "wtk_token", expiresAt: 3_601_000, doc: null }); + const storage = memoryStorage(); + writeOAuthSession(storage, { version: 1, accessToken: lateJwt, expiresAt: now + 3_600_000 }); + expect(readOAuthSession(storage, now + 3_600_001)).toBeNull(); }); - it("requires AI scope but treats document scope as optional and ignores an ungranted document", async () => { - const session = await exchangeGrant("wtg_grant", { - fetcher: vi.fn().mockResolvedValue(response({ - access_token: "wtk_token", - expires_in: 60, - client_id: "mindmap", - scopes: ["openai:chat"], - doc: { beforeCursor: "private", selectedText: "", afterCursor: "" }, - })), - now: () => 0, - }); - expect(session.doc).toBeNull(); + it("requires login unconditionally in production and only by opt-in in development", () => { + expect(loginRequired({ PROD: true, VITE_REQUIRE_LOGIN: "false" })).toBe(true); + expect(loginRequired({ PROD: false })).toBe(false); + expect(loginRequired({ PROD: false, VITE_REQUIRE_LOGIN: "true" })).toBe(true); + }); - await expect(exchangeGrant("wtg_grant", { - fetcher: vi.fn().mockResolvedValue(response({ - access_token: "wtk_token", - expires_in: 60, - client_id: "mindmap", - scopes: ["doc:read"], - doc: null, - })), - })).rejects.toMatchObject({ code: "invalid" }); + it("derives the byte-exact origin resource and callback", () => { + expect(canonicalOAuthResource("https://APP.Example/api/")).toBe("https://app.example"); + expect(oauthRedirectUri({ origin: "https://mindmap.example", pathname: "/" })).toBe("https://mindmap.example/"); }); - it("accepts a missing document field as an empty optional snapshot", async () => { - const session = await exchangeGrant("wtg_grant", { - fetcher: vi.fn().mockResolvedValue(response({ - access_token: "wtk_token", - expires_in: 60, - client_id: "mindmap", - scopes: ["openai:chat", "doc:read"], - })), + it("creates S256 authorization state and persists the transaction before redirect", async () => { + const storage = memoryStorage(); + const request = await createAuthorizationRequest({ + storage, + location: { origin: "https://mindmap.example", pathname: "/" }, + backendUrl: "https://app.example/api", + clientId: "writing-tools-mindmap", + now: () => 10, }); - expect(session.doc).toBeNull(); + const url = new URL(request.url); + expect(url.pathname).toBe("/api/auth/oauth2/authorize"); + expect(url.searchParams.get("client_id")).toBe("writing-tools-mindmap"); + expect(url.searchParams.get("redirect_uri")).toBe("https://mindmap.example/"); + expect(url.searchParams.get("scope")).toBe(OAUTH_SCOPE); + expect(url.searchParams.get("resource")).toBe("https://app.example"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("state")).toBe(request.transaction.state); + expect(JSON.parse(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY)!)).toEqual(request.transaction); }); - it("validates and concatenates a granted document while dropping contextData", async () => { - const session = await exchangeGrant("wtg_grant", { - fetcher: vi.fn().mockResolvedValue(response({ - access_token: "wtk_token", - expires_in: 60, - client_id: "mindmap", - scopes: ["openai:chat", "doc:read"], - doc: { - documentLabel: "Essay.docx", - beforeCursor: "before ", - selectedText: "selected", - afterCursor: " after", - contextData: [{ title: "private", content: "ignored" }], - }, - })), - now: () => 0, - }); - expect(snapshotText(session.doc)).toBe("before selected after"); - expect(session.doc).not.toHaveProperty("contextData"); + it("captures and scrubs callback material while preserving unrelated query and hash values", () => { + expect(oauthCallbackFromSearch("?view=map&code=c&state=s")).toEqual({ code: "c", state: "s" }); + const replaceState = vi.fn(); + scrubOAuthCallbackFromUrl( + { pathname: "/", search: "?view=map&code=c&state=s&error_description=no", hash: "#draft" }, + { state: { keep: true }, replaceState }, + ); + expect(replaceState).toHaveBeenCalledWith({ keep: true }, "", "/?view=map#draft"); }); - it("keeps an expired-by-browser-clock token as a server-authoritative expiry hint", () => { + it("consumes authorization state exactly once", () => { const storage = memoryStorage(); - const session: PlatformSession = { - version: 1, - accessToken: "wtk_token", - expiresAt: 1, - scopes: ["openai:chat"], - doc: null, - capturedAt: 0, - }; - writePlatformSession(storage, session); - expect(readPlatformSession(storage)).toEqual(session); - clearPlatformSession(storage); - expect(storage.getItem(PLATFORM_SESSION_STORAGE_KEY)).toBeNull(); + storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify(transaction)); + expect(consumeOAuthTransaction(storage)).toEqual(transaction); + expect(consumeOAuthTransaction(storage)).toBeNull(); }); - it("maps expired and already-used grants to distinct errors", async () => { - for (const code of ["expired", "already_used"] as const) { - try { - await exchangeGrant("wtg_grant", { - fetcher: vi.fn().mockResolvedValue(response({ detail: "No.", error: code }, 400)), - }); - throw new Error("expected exchange to fail"); - } catch (error) { - expect(error).toBeInstanceOf(GrantExchangeError); - expect((error as GrantExchangeError).code).toBe(code); - } - } + it("accepts only unexpired compact JWT sessions and clears OAuth state without local work", () => { + const storage = memoryStorage(); + const session = { version: 1 as const, accessToken: jwt(200), expiresAt: 200_000 }; + writeOAuthSession(storage, session); + expect(readOAuthSession(storage, 100_000)).toEqual(session); + expect(readOAuthSession(storage, 201_000)).toBeNull(); + expect(isCompactJwt("not-a-token")).toBe(false); + storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify(transaction)); + clearOAuthSession(storage); + expect(storage.getItem(OAUTH_SESSION_STORAGE_KEY)).toBeNull(); + expect(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY)).toBeNull(); }); - it("defaults launch enforcement by build mode and honors explicit overrides", () => { - expect(launchRequired({ PROD: true })).toBe(true); - expect(launchRequired({ PROD: false })).toBe(false); - expect(launchRequired({ PROD: false, VITE_REQUIRE_LAUNCH: "true" })).toBe(true); + it("exchanges with omitted credentials and repeats the exact resource", async () => { + const fetcher = vi.fn().mockResolvedValue(response({ + access_token: jwt(44_000), + token_type: "Bearer", + scope: OAUTH_SCOPE, + expires_in: 43_200, + })); + const session = await exchangeAuthorizationCode("code", transaction, { + fetcher, + backendUrl: "https://app.example/api", + clientId: "writing-tools-mindmap", + now: () => 1_000, + }); + expect(session.accessToken).toContain("."); + const [, init] = fetcher.mock.calls[0]!; + expect(init).toMatchObject({ method: "POST", credentials: "omit" }); + const form = init.body as URLSearchParams; + expect(form.get("resource")).toBe("https://app.example"); + expect(form.get("redirect_uri")).toBe("https://mindmap.example/"); + expect(form.get("code_verifier")).toBe(transaction.verifier); }); - it("ignores VITE_REQUIRE_LAUNCH=false in production builds", () => { - // Fail closed: a misconfigured deploy env must not be able to ship an ungated - // production bundle. The override stays available for dev and Playwright. - expect(launchRequired({ PROD: true, VITE_REQUIRE_LAUNCH: "false" })).toBe(true); - expect(launchRequired({ PROD: false, VITE_REQUIRE_LAUNCH: "false" })).toBe(false); + it.each([ + { token_type: "bearer", scope: OAUTH_SCOPE, expires_in: 43_200, access_token: jwt() }, + { token_type: "Bearer", scope: "doc:read", expires_in: 43_200, access_token: jwt() }, + { token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 86_401, access_token: jwt() }, + { token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 0, access_token: jwt() }, + { token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 43_200, access_token: "opaque" }, + ])("rejects a token response outside the Track A contract", async (body) => { + await expect(exchangeAuthorizationCode("code", transaction, { + fetcher: vi.fn().mockResolvedValue(response(body)), + })).rejects.toMatchObject({ code: "invalid" }); + }); + + it("accepts a shorter server-selected access-token lifetime", async () => { + const accessToken = jwt(3_700); + await expect(exchangeAuthorizationCode("code", transaction, { + fetcher: vi.fn().mockResolvedValue(response({ + token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 3_600, access_token: accessToken, + })), + now: () => 100_000, + })).resolves.toMatchObject({ accessToken }); + }); + + it("rejects a correctly shaped token that is already expired", async () => { + await expect(exchangeAuthorizationCode("code", transaction, { + fetcher: vi.fn().mockResolvedValue(response({ + token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 43_200, access_token: jwt(1), + })), + now: () => 2_000, + })).rejects.toMatchObject({ code: "invalid" }); }); }); diff --git a/src/platform-session.ts b/src/platform-session.ts index 9ca8724..bee60e1 100644 --- a/src/platform-session.ts +++ b/src/platform-session.ts @@ -1,58 +1,105 @@ /// -export const PLATFORM_SESSION_STORAGE_KEY = "prototype-mindmap-platform-session-v1"; -export const PLATFORM_SESSION_VERSION = 1; +export const OAUTH_SESSION_STORAGE_KEY = "prototype-mindmap-oauth-session-v1"; +export const OAUTH_TRANSACTION_STORAGE_KEY = "prototype-mindmap-oauth-transaction-v1"; +export const OAUTH_SESSION_VERSION = 1; +export const OAUTH_CLIENT_ID_DEFAULT = "writing-tools-mindmap"; +export const OAUTH_SCOPE = "openai:chat"; const viteEnv = (import.meta as ImportMeta & { env?: Record }).env; +type PlatformEnv = { + PROD?: boolean; + VITE_BACKEND_URL?: string | boolean; + VITE_OAUTH_CLIENT_ID?: string | boolean; + VITE_REQUIRE_LOGIN?: string | boolean; +}; + /** * A production bundle that quietly falls back to localhost points every user's browser * at their own machine — a confusing per-request failure rather than an obvious one. * Fail at module load so a misconfigured deploy is caught by the first smoke check. */ -export function resolveBackendUrl( - env: { PROD?: boolean; VITE_BACKEND_URL?: string | boolean } = viteEnv ?? {}, -): string { - const configured = typeof env?.VITE_BACKEND_URL === "string" ? env.VITE_BACKEND_URL.trim() : ""; - if (configured) return configured; - if (env?.PROD === true) { +export function resolveBackendUrl(env: PlatformEnv = viteEnv ?? {}): string { + const configured = typeof env.VITE_BACKEND_URL === "string" ? env.VITE_BACKEND_URL.trim() : ""; + if (configured) { + // The bearer token and every AI request travel to this URL; production must not + // ship a bundle that sends them in cleartext. (Dev keeps http://localhost.) + if (env.PROD === true && !isHttpsUrl(configured)) { + throw new Error("VITE_BACKEND_URL must be an https:// URL for production builds of the mindmap."); + } + return configured.replace(/\/+$/, ""); + } + if (env.PROD === true) { throw new Error("VITE_BACKEND_URL must be set for production builds of the mindmap."); } return "http://localhost:8000/api"; } -export const PLATFORM_BACKEND_URL = resolveBackendUrl(); +function isHttpsUrl(value: string): boolean { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} -export interface PlatformDocContext { - documentLabel?: string; - beforeCursor: string; - selectedText: string; - afterCursor: string; - contextData?: unknown; +export function resolveOAuthClientId(env: PlatformEnv = viteEnv ?? {}): string { + const configured = typeof env.VITE_OAUTH_CLIENT_ID === "string" + ? env.VITE_OAUTH_CLIENT_ID.trim() + : ""; + if (configured) return configured; + if (env.PROD === true) { + throw new Error("VITE_OAUTH_CLIENT_ID must be set for production builds of the mindmap."); + } + return OAUTH_CLIENT_ID_DEFAULT; +} + +export function canonicalOAuthResource(backendUrl: string): string { + return new URL(backendUrl).origin; } -export type LaunchDecision = "continue_saved" | "start_new"; +export const PLATFORM_BACKEND_URL = resolveBackendUrl(); +export const OAUTH_CLIENT_ID = resolveOAuthClientId(); +export const OAUTH_RESOURCE = canonicalOAuthResource(PLATFORM_BACKEND_URL); + +export function loginRequired(env: PlatformEnv = viteEnv ?? {}): boolean { + // Production always requires login, and `VITE_REQUIRE_LOGIN=false` cannot turn + // that off. The override exists only for dev servers and Playwright; honouring it + // in a PROD build would let one stray env var ship an ungated bundle, with nothing + // at runtime to signal that the gate was disabled. + if (env.PROD === true) return true; + return env.VITE_REQUIRE_LOGIN === "true"; +} -export interface PlatformSession { +export interface OAuthSession { version: 1; accessToken: string; expiresAt: number; - scopes: string[]; - doc: PlatformDocContext | null; - capturedAt: number; - decision?: LaunchDecision; } -export type GrantExchangeErrorCode = - | "expired" - | "already_used" - | "invalid" - | "network"; +export interface OAuthTransaction { + version: 1; + state: string; + verifier: string; + redirectUri: string; + resource: string; + createdAt: number; +} + +export interface OAuthCallback { + code?: string; + state?: string; + error?: string; + errorDescription?: string; +} + +export type OAuthFlowErrorCode = "invalid" | "denied" | "network"; -export class GrantExchangeError extends Error { - constructor(readonly code: GrantExchangeErrorCode, message: string) { +export class OAuthFlowError extends Error { + constructor(readonly code: OAuthFlowErrorCode, message: string) { super(message); - this.name = "GrantExchangeError"; + this.name = "OAuthFlowError"; } } @@ -60,195 +107,243 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -export function validDocContext(value: unknown): PlatformDocContext | null { - if (!isRecord(value)) return null; - if ( - typeof value.beforeCursor !== "string" || - typeof value.selectedText !== "string" || - typeof value.afterCursor !== "string" - ) return null; - if (value.documentLabel !== undefined && typeof value.documentLabel !== "string") return null; - return { - beforeCursor: value.beforeCursor, - selectedText: value.selectedText, - afterCursor: value.afterCursor, - ...(typeof value.documentLabel === "string" ? { documentLabel: value.documentLabel } : {}), - }; +export function isCompactJwt(value: unknown): value is string { + if (typeof value !== "string") return false; + const segments = value.split("."); + return segments.length === 3 && segments.every((segment) => /^[A-Za-z0-9_-]+$/.test(segment)); +} + +function decodeJwtExpiry(accessToken: string): number | null { + try { + const payload = accessToken.split(".")[1]; + if (!payload) return null; + const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const parsed = JSON.parse(atob(padded)) as unknown; + if (!isRecord(parsed) || typeof parsed.exp !== "number" || !Number.isFinite(parsed.exp)) return null; + return parsed.exp * 1000; + } catch { + return null; + } } -export function snapshotText(doc: PlatformDocContext | null): string { - return doc ? `${doc.beforeCursor}${doc.selectedText}${doc.afterCursor}` : ""; +/** The token is honoured only until the earliest expiry any source reports. */ +function earliest(...expiries: Array): number | null { + const known = expiries.filter((value): value is number => value !== null); + return known.length ? Math.min(...known) : null; } -export function grantFromHash(hash: string): string | null { - const parts = hash.replace(/^#/, "").split("&"); - for (const part of parts) { - const [rawKey, ...rawValue] = part.split("="); - let key: string; - let value: string; - try { - key = decodeURIComponent(rawKey || ""); - value = decodeURIComponent(rawValue.join("=")); - } catch { - continue; +export function readOAuthSession( + storage: Pick, + now: number = Date.now(), +): OAuthSession | null { + try { + const parsed = JSON.parse(storage.getItem(OAUTH_SESSION_STORAGE_KEY) ?? "null") as unknown; + if (!isRecord(parsed) || parsed.version !== OAUTH_SESSION_VERSION || !isCompactJwt(parsed.accessToken)) { + return null; } - if (key !== "wt_grant") continue; - return value || null; + const storedExpiry = typeof parsed.expiresAt === "number" && Number.isFinite(parsed.expiresAt) + ? parsed.expiresAt + : null; + const expiry = earliest(decodeJwtExpiry(parsed.accessToken), storedExpiry); + if (expiry === null || expiry <= now) return null; + return { version: 1, accessToken: parsed.accessToken, expiresAt: expiry }; + } catch { + return null; } - return null; } -export function hashWithoutGrant(hash: string): string { - const kept = hash - .replace(/^#/, "") - .split("&") - .filter((part) => { - try { - return decodeURIComponent(part.split("=")[0] || "") !== "wt_grant"; - } catch { - return true; - } - }) - .filter(Boolean); - return kept.length ? `#${kept.join("&")}` : ""; +export function writeOAuthSession(storage: Pick, session: OAuthSession): void { + storage.setItem(OAUTH_SESSION_STORAGE_KEY, JSON.stringify(session)); } -export function scrubGrantFromUrl( - location: Pick, - history: Pick, -): void { - history.replaceState( - history.state, - "", - `${location.pathname}${location.search}${hashWithoutGrant(location.hash)}`, - ); +export function clearOAuthSession(storage: Pick): void { + storage.removeItem(OAUTH_SESSION_STORAGE_KEY); + storage.removeItem(OAUTH_TRANSACTION_STORAGE_KEY); } -export function launchRequired( - env: { PROD?: boolean; VITE_REQUIRE_LAUNCH?: string | boolean } = viteEnv ?? {}, -): boolean { - // Production always requires a launch, and `VITE_REQUIRE_LAUNCH=false` cannot turn - // that off. The override exists for dev servers and Playwright; honouring it in a - // PROD build would let one stray env var ship an ungated bundle, with nothing at - // runtime to signal that the gate was disabled. - if (env?.PROD === true) return true; - return env?.VITE_REQUIRE_LAUNCH === "true"; -} +/** + * A login round trip takes seconds. A transaction older than this was abandoned, and + * its state/verifier should not be able to complete a login long after the fact. + */ +export const OAUTH_TRANSACTION_MAX_AGE_MS = 10 * 60 * 1000; -export function readPlatformSession(storage: Pick): PlatformSession | null { +export function readOAuthTransaction( + storage: Pick, + now: number = Date.now(), +): OAuthTransaction | null { try { - const parsed = JSON.parse(storage.getItem(PLATFORM_SESSION_STORAGE_KEY) ?? "null") as unknown; - if (!isRecord(parsed)) return null; + const parsed = JSON.parse(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY) ?? "null") as unknown; if ( - parsed.version !== PLATFORM_SESSION_VERSION || - typeof parsed.accessToken !== "string" || - !parsed.accessToken.startsWith("wtk_") || - typeof parsed.expiresAt !== "number" || - !Number.isFinite(parsed.expiresAt) || - typeof parsed.capturedAt !== "number" || - !Number.isFinite(parsed.capturedAt) || - !Array.isArray(parsed.scopes) || - !parsed.scopes.every((scope) => typeof scope === "string") || - !parsed.scopes.includes("openai:chat") + !isRecord(parsed) || + parsed.version !== 1 || + typeof parsed.state !== "string" || !parsed.state || + typeof parsed.verifier !== "string" || parsed.verifier.length < 43 || + typeof parsed.redirectUri !== "string" || + typeof parsed.resource !== "string" || + typeof parsed.createdAt !== "number" || !Number.isFinite(parsed.createdAt) || + now - parsed.createdAt > OAUTH_TRANSACTION_MAX_AGE_MS || + parsed.createdAt > now + 60_000 ) return null; - const hasDocRead = parsed.scopes.includes("doc:read"); - const doc = hasDocRead && parsed.doc !== null ? validDocContext(parsed.doc) : null; - if (hasDocRead && parsed.doc !== null && !doc) return null; - const decision = - parsed.decision === "continue_saved" || parsed.decision === "start_new" - ? parsed.decision - : undefined; - return { - version: 1, - accessToken: parsed.accessToken, - expiresAt: parsed.expiresAt, - capturedAt: parsed.capturedAt, - scopes: parsed.scopes as string[], - doc, - ...(decision ? { decision } : {}), - }; + return parsed as unknown as OAuthTransaction; } catch { return null; } } -export function writePlatformSession( +export function writeOAuthTransaction( storage: Pick, - session: PlatformSession, + transaction: OAuthTransaction, ): void { - storage.setItem(PLATFORM_SESSION_STORAGE_KEY, JSON.stringify(session)); + storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify(transaction)); +} + +export function consumeOAuthTransaction( + storage: Pick, + now: number = Date.now(), +): OAuthTransaction | null { + const transaction = readOAuthTransaction(storage, now); + storage.removeItem(OAUTH_TRANSACTION_STORAGE_KEY); + return transaction; +} + +function randomBase64Url(bytes: number, cryptoImpl: Crypto): string { + const values = new Uint8Array(bytes); + cryptoImpl.getRandomValues(values); + let binary = ""; + values.forEach((value) => { binary += String.fromCharCode(value); }); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function bytesToBase64Url(values: Uint8Array): string { + let binary = ""; + values.forEach((value) => { binary += String.fromCharCode(value); }); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function oauthRedirectUri(location: Pick): string { + return `${location.origin}${location.pathname}`; +} + +export async function createAuthorizationRequest(options: { + storage: Pick; + location: Pick; + cryptoImpl?: Crypto; + backendUrl?: string; + clientId?: string; + now?: () => number; +}): Promise<{ url: string; transaction: OAuthTransaction }> { + const cryptoImpl = options.cryptoImpl ?? crypto; + const backendUrl = options.backendUrl ?? PLATFORM_BACKEND_URL; + const resource = canonicalOAuthResource(backendUrl); + const verifier = randomBase64Url(48, cryptoImpl); + const state = randomBase64Url(32, cryptoImpl); + const digest = await cryptoImpl.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + const redirectUri = oauthRedirectUri(options.location); + const transaction: OAuthTransaction = { + version: 1, + state, + verifier, + redirectUri, + resource, + createdAt: (options.now ?? Date.now)(), + }; + writeOAuthTransaction(options.storage, transaction); + const url = new URL(`${backendUrl}/auth/oauth2/authorize`); + url.search = new URLSearchParams({ + client_id: options.clientId ?? OAUTH_CLIENT_ID, + redirect_uri: redirectUri, + response_type: "code", + scope: OAUTH_SCOPE, + code_challenge: bytesToBase64Url(new Uint8Array(digest)), + code_challenge_method: "S256", + state, + resource, + }).toString(); + return { url: url.toString(), transaction }; +} + +export function oauthCallbackFromSearch(search: string): OAuthCallback | null { + const params = new URLSearchParams(search); + // Any authorization parameter counts, so even a partial or forged callback (e.g. + // `?state=` alone) is scrubbed from the URL and rejected rather than left in place. + if (!["code", "state", "error", "error_description"].some((key) => params.has(key))) return null; + return { + ...(params.get("code") ? { code: params.get("code")! } : {}), + ...(params.get("state") ? { state: params.get("state")! } : {}), + ...(params.get("error") ? { error: params.get("error")! } : {}), + ...(params.get("error_description") ? { errorDescription: params.get("error_description")! } : {}), + }; } -export function clearPlatformSession(storage: Pick): void { - storage.removeItem(PLATFORM_SESSION_STORAGE_KEY); +export function scrubOAuthCallbackFromUrl( + location: Pick, + history: Pick, +): void { + const params = new URLSearchParams(location.search); + for (const key of ["code", "state", "error", "error_description"]) params.delete(key); + const search = params.toString(); + history.replaceState(history.state, "", `${location.pathname}${search ? `?${search}` : ""}${location.hash}`); } -export async function exchangeGrant( - grantId: string, +export async function exchangeAuthorizationCode( + code: string, + transaction: OAuthTransaction, options: { fetcher?: typeof fetch; backendUrl?: string; + clientId?: string; now?: () => number; } = {}, -): Promise { - const fetcher = options.fetcher ?? fetch; +): Promise { let response: Response; try { - response = await fetcher( - `${options.backendUrl ?? PLATFORM_BACKEND_URL}/handoff/exchange`, + response = await (options.fetcher ?? fetch)( + `${options.backendUrl ?? PLATFORM_BACKEND_URL}/auth/oauth2/token`, { method: "POST", credentials: "omit", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ grant_id: grantId }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: options.clientId ?? OAUTH_CLIENT_ID, + redirect_uri: transaction.redirectUri, + code, + code_verifier: transaction.verifier, + resource: transaction.resource, + }), }, ); } catch (error) { - throw new GrantExchangeError( - "network", - error instanceof Error ? error.message : "Could not reach Writing Tools.", - ); + throw new OAuthFlowError("network", error instanceof Error ? error.message : "Could not reach Writing Tools."); } - const body = await response.json().catch(() => ({})) as Record; if (!response.ok) { - const serverCode = body.error; - const code: GrantExchangeErrorCode = - serverCode === "expired" - ? "expired" - : serverCode === "already_used" - ? "already_used" - : "invalid"; - throw new GrantExchangeError(code, typeof body.detail === "string" ? body.detail : "Grant exchange failed."); + throw new OAuthFlowError( + body.error === "access_denied" ? "denied" : "invalid", + typeof body.error_description === "string" ? body.error_description : "Writing Tools rejected the login.", + ); } - - const scopes = Array.isArray(body.scopes) && body.scopes.every((scope) => typeof scope === "string") - ? body.scopes as string[] - : []; + const expiresIn = body.expires_in; + const scopes = typeof body.scope === "string" ? body.scope.trim().split(/\s+/).filter(Boolean) : []; if ( - typeof body.access_token !== "string" || - !body.access_token.startsWith("wtk_") || - body.client_id !== "mindmap" || - typeof body.expires_in !== "number" || - !Number.isFinite(body.expires_in) || - body.expires_in <= 0 || - !scopes.includes("openai:chat") + body.token_type !== "Bearer" || + scopes.length !== 1 || scopes[0] !== OAUTH_SCOPE || + typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || + expiresIn <= 0 || expiresIn > 86_400 || + !isCompactJwt(body.access_token) ) { - throw new GrantExchangeError("invalid", "Writing Tools returned an invalid mindmap session."); - } - - const hasDocRead = scopes.includes("doc:read"); - const doc = hasDocRead && body.doc != null ? validDocContext(body.doc) : null; - if (hasDocRead && body.doc != null && !doc) { - throw new GrantExchangeError("invalid", "Writing Tools returned an invalid document snapshot."); + throw new OAuthFlowError("invalid", "Writing Tools returned an invalid OAuth session."); } const now = (options.now ?? Date.now)(); + const tokenExpiry = decodeJwtExpiry(body.access_token); + if (tokenExpiry !== null && tokenExpiry <= now) { + throw new OAuthFlowError("invalid", "Writing Tools returned an expired OAuth session."); + } return { version: 1, accessToken: body.access_token, - expiresAt: now + body.expires_in * 1000, - scopes, - doc, - capturedAt: now, + // Honour whichever lifetime is shorter: the JWT's own exp or the advertised expires_in. + expiresAt: earliest(tokenExpiry, now + expiresIn * 1000)!, }; } diff --git a/src/platform.css b/src/platform.css index 75b0420..074e2f7 100644 --- a/src/platform.css +++ b/src/platform.css @@ -36,6 +36,40 @@ font-size: 12px; } +.platform-workspace { + min-height: 100vh; +} + +.platform-disconnect { + position: fixed; + z-index: 50; + top: 12px; + right: 16px; + border: 1px solid #c8c1b5; + border-radius: 8px; + padding: 7px 11px; + background: rgba(255, 255, 255, 0.92); + color: #514d46; + cursor: pointer; +} + +.clipboard-status { + position: fixed; + z-index: 60; + right: 18px; + bottom: 18px; + min-height: 1.2em; + padding: 7px 10px; + border-radius: 7px; + background: rgba(38, 35, 31, 0.9); + color: white; + font: 12px/1.3 Inter, ui-sans-serif, system-ui, sans-serif; +} + +.clipboard-status:empty { + display: none; +} + .platform-choice { grid-template-columns: minmax(260px, 368px) minmax(260px, 368px); gap: 18px; diff --git a/src/session-persistence.ts b/src/session-persistence.ts index 1321140..4fed423 100644 --- a/src/session-persistence.ts +++ b/src/session-persistence.ts @@ -2,12 +2,8 @@ * The persisted mindmap session: its shape, its storage key, and the read/write/clear * operations over `localStorage`. * - * Split out of `App.tsx` (item 7, slice 2). The launcher integration made the split - * necessary rather than merely tidy: `PlatformBootstrap` has to ask "is there real - * saved work?" *before* it mounts `App`, in order to decide between continuing a saved - * mindmap and starting fresh from a handed-off document. Importing that predicate from - * the component it is about to render made the boot layer depend on a 4,600-line module - * for two lines of storage logic. + * Split out of `App.tsx` so bootstrap and tests can probe browser-local work without + * importing the full application component. * * This module owns the persisted *shape*. It deliberately does not own the migrations * that transform loaded data into live stores (`migrateLegacyMirrors`, @@ -49,7 +45,6 @@ export interface ChatMsg { /** Missing means delivered for sessions written before optimistic-state tracking. */ deliveryStatus?: "pending" | "delivered" | "failed"; } - export interface DraftPanelPos { x: number; y: number; } export interface DraftPanelSize { w: number; h: number; } export interface DraftSelectionFocus { text: string; } @@ -64,15 +59,6 @@ export interface PersistedPendingMirror { editedTexts?: Record; } -/** Where the draft came from, when it was handed over by the launcher rather than - * typed here. Drives the "snapshot captured at launch" label and names the saved - * session on the relaunch choice screen. */ -export interface DraftSourceMetadata { - kind: "launch_snapshot"; - documentLabel: string; - capturedAt: number; -} - export interface PersistedSession { version: PersistedSessionVersion; sessionId?: string; @@ -92,7 +78,6 @@ export interface PersistedSession { draftDocked?: boolean; draftPos: DraftPanelPos; draftSize: DraftPanelSize; - draftSource?: DraftSourceMetadata; lastSavedAt?: number; stickyDraftFocus?: DraftSelectionFocus; conversation?: { @@ -115,12 +100,6 @@ export interface PersistedSession { map: ThoughtUnitStoreSnapshot; } -/** What the relaunch choice screen needs to name a saved mindmap, without loading it. */ -export interface SavedMindmapSummary { - documentLabel: string; - lastSavedAt?: number; -} - const SUPPORTED_VERSIONS: ReadonlySet = new Set(SUPPORTED_SESSION_VERSIONS); export function hasPersistableWork( @@ -168,26 +147,3 @@ export function writePersistedSession( } storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(persistable)); } - -/** - * Summarize a saved session, or null when there is nothing worth offering to continue. - * - * The emptiness check is load-bearing, not cosmetic. `App`'s persist effect runs on - * mount, so a session row exists the instant the app renders — without this guard every - * relaunch after the first would present a "Continue" option pointing at an untouched - * shell, and choosing it would silently discard the document the user just shared. - */ -export function savedMindmapSummary(storage: Pick): SavedMindmapSummary | null { - const saved = loadPersistedSession(storage); - if (!saved) return null; - return { - documentLabel: saved.draftSource?.documentLabel || "Saved mindmap", - lastSavedAt: saved.lastSavedAt, - }; -} - -/** Discard the saved mindmap. Only ever called for an explicit "start new" choice — - * never on token expiry, and never from an error path. */ -export function clearSavedMindmap(storage: Pick): void { - storage.removeItem(SESSION_STORAGE_KEY); -} diff --git a/src/ui-locale.test.ts b/src/ui-locale.test.ts index b16d113..aab9da8 100644 --- a/src/ui-locale.test.ts +++ b/src/ui-locale.test.ts @@ -67,7 +67,7 @@ describe("UI locale", () => { ); }); - it("has Chinese coverage for every literal explicitly opted into UI localization", () => { + it("registers every localized literal in the canonical English source", () => { const sources = ["App.tsx", "Map.tsx", "PlatformBootstrap.tsx"].map((file) => readFileSync(new URL(`./${file}`, import.meta.url), "utf8"), ); @@ -82,7 +82,10 @@ describe("UI locale", () => { "Asking a focused question instead...", "I couldn’t complete that response reliably. You can rephrase or try again.", ].forEach((key) => keys.add(key)); - const missing = [...keys].filter((key) => uiString(key, "zh") === undefined); + const canonical = new Set(JSON.parse( + readFileSync(new URL("./i18n/source.json", import.meta.url), "utf8"), + ) as string[]); + const missing = [...keys].filter((key) => !canonical.has(key)); expect(missing).toEqual([]); }); }); diff --git a/vite.config.ts b/vite.config.ts index 6831564..1b21238 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,7 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], test: { - exclude: ["e2e/**", "node_modules/**", "dist/**"], + exclude: ["e2e/**", "e2e-pages/**", "node_modules/**", "dist/**"], }, server: { // Honor a PORT override (used by preview tooling that assigns a free port);