From 7087d44b6562017e68c45881e0f38a903f4dc08e Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Wed, 5 Aug 2026 18:19:44 -0400 Subject: [PATCH 1/5] docs: close the Track B gaps that Track A made concrete Track A is implemented and tested, so several things this spec left to inference are now facts worth stating: - The resource value is the origin of VITE_BACKEND_URL, no path and no trailing slash, sent on both the authorize request and the token POST body. Track A has tests rejecting a missing resource and a trailing-slash variant, and Better Auth issues an unverifiable opaque token when it is absent. - 401 and 403 need opposite handling. A non-allowlisted user signs in successfully, receives a real 12-hour token, and is then refused at the proxy. Treating that as a prompt to log in again loops forever, because signing in cannot fix it. Added a required test for the loop. - Logout clears local state only. Calling sign-out would end the shared Writing Tools browser session that the add-in also uses. - Every backend request sends credentials: omit. Mindmap must never rely on or transmit Writing Tools cookies. --- docs/login-bootstrap-spec.md | 67 +++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 8 deletions(-) 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. From f0ba127f5193c9b75eef131df744f3c930668cb4 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Thu, 6 Aug 2026 00:39:28 -0400 Subject: [PATCH 2/5] feat: replace launcher grants with standalone OAuth --- src/App.test.ts | 61 +---- src/App.tsx | 27 +- src/PlatformBootstrap.test.ts | 22 +- src/PlatformBootstrap.tsx | 471 ++++++++++++---------------------- src/i18n/source.json | 14 +- src/platform-session.test.ts | 270 +++++++++---------- src/platform-session.ts | 397 ++++++++++++++++------------ src/platform.css | 17 ++ src/session-persistence.ts | 48 +--- src/ui-locale.test.ts | 7 +- 10 files changed, 581 insertions(+), 753 deletions(-) diff --git a/src/App.test.ts b/src/App.test.ts index 04bfc8a..71daf4d 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -4,7 +4,7 @@ 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 { hasPersistableWork } from "./session-persistence"; import { UnderTheHoodPanel } from "./ControlRoom"; import { ASSISTANCE_CONTRACTS, snapshotContract } from "./assistance-contract"; import { ThoughtUnitStore } from "./map-store"; @@ -55,65 +55,6 @@ 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("session migration", () => { it("invalidates unresolved legacy pending mirrors instead of granting current-contract provenance", () => { const migrated = migrateLegacyMirrors([{ id: "m1", reflection: { claims: [{ id: "c1", text: "human control", candidateId: "candidate", target: "idea", sourceSpans: [] }] }, claims: [], decisions: { c1: "pending" } }], 7); diff --git a/src/App.tsx b/src/App.tsx index b51fef3..c25e30f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,7 @@ import { EventLedger, type LedgerEventKind } from "./event-ledger"; import { reconcileStoreSuggestionAdoption, type VisibleSuggestion } from "./suggestion-adoption"; import { provenanceTotals } from "./provenance-summary"; 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 +31,6 @@ import { type DraftPanelPos, type DraftPanelSize, type DraftSelectionFocus, - type DraftSourceMetadata, type PersistedPendingMirror, type PersistedSession, } from "./session-persistence"; @@ -2695,11 +2694,10 @@ function UnderhoodIcon() { export interface AppProps { providerRuntime?: ProviderRuntimeConfig; - initialDraft?: { text: string; source?: DraftSourceMetadata }; aiAccessDenied?: boolean; } -export default function App({ providerRuntime, initialDraft, aiAccessDenied = false }: AppProps) { +export default function App({ providerRuntime, aiAccessDenied = false }: AppProps) { const { locale, t } = useUiLocale(); const reader = useReaderView(); const mutationAccess = useMutationAccess(); @@ -2711,7 +2709,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 +2729,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 +2758,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 +2769,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 +2802,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); @@ -3564,7 +3560,6 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa draftDocked, draftPos, draftSize, - draftSource, lastSavedAt: Date.now(), stickyDraftFocus, conversation: { @@ -3598,7 +3593,6 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa draftPos, draftSize, draftText, - draftSource, lastCoachDebug, understandingSnapshot, mapRevision, @@ -3974,7 +3968,6 @@ export default function App({ providerRuntime, initialDraft, aiAccessDenied = fa setHighlightAnchor(undefined); setDraftSelectionFocus(undefined); setStickyDraftFocus(undefined); - setDraftSource(undefined); stateRef.current.draft = ""; } @@ -4144,7 +4137,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 +4270,6 @@ 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 }, - )} - - )} ); } } -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/i18n/source.json b/src/i18n/source.json index 4804e8d..6409500 100644 --- a/src/i18n/source.json +++ b/src/i18n/source.json @@ -191,5 +191,17 @@ "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." ] diff --git a/src/platform-session.test.ts b/src/platform-session.test.ts index 5bc5f1b..4f04fcb 100644 --- a/src/platform-session.test.ts +++ b/src/platform-session.test.ts @@ -1,25 +1,25 @@ 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_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 +32,146 @@ 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: 1, +}; + +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("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); }); - 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("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("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", - now: () => 1000, + clientId: "writing-tools-mindmap", + now: () => 10, }); + 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); + }); - expect(fetcher).toHaveBeenCalledWith( - "https://app.example/api/handoff/exchange", - expect.objectContaining({ - method: "POST", - credentials: "omit", - body: JSON.stringify({ grant_id: "wtg_grant" }), - }), + 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(session).toMatchObject({ accessToken: "wtk_token", expiresAt: 3_601_000, doc: null }); + expect(replaceState).toHaveBeenCalledWith({ keep: true }, "", "/?view=map#draft"); }); - 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(); - - 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("consumes authorization state exactly once", () => { + const storage = memoryStorage(); + storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify(transaction)); + expect(consumeOAuthTransaction(storage)).toEqual(transaction); + expect(consumeOAuthTransaction(storage)).toBeNull(); }); - 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"], - })), - }); - expect(session.doc).toBeNull(); + 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("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, + 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(snapshotText(session.doc)).toBe("before selected after"); - expect(session.doc).not.toHaveProperty("contextData"); - }); - - it("keeps an expired-by-browser-clock token as a server-authoritative expiry hint", () => { - 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(); + 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("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.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("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("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("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("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..1f995fb 100644 --- a/src/platform-session.ts +++ b/src/platform-session.ts @@ -1,58 +1,90 @@ /// -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) 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(); +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 interface PlatformDocContext { - documentLabel?: string; - beforeCursor: string; - selectedText: string; - afterCursor: string; - contextData?: unknown; +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 +92,222 @@ 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)); } -export function snapshotText(doc: PlatformDocContext | null): string { - return doc ? `${doc.beforeCursor}${doc.selectedText}${doc.afterCursor}` : ""; +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 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 = 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"; -} - -export function readPlatformSession(storage: Pick): PlatformSession | null { +export function readOAuthTransaction(storage: Pick): 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) ) 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, +): OAuthTransaction | null { + const transaction = readOAuthTransaction(storage); + 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 clearPlatformSession(storage: Pick): void { - storage.removeItem(PLATFORM_SESSION_STORAGE_KEY); +export function oauthRedirectUri(location: Pick): string { + return `${location.origin}${location.pathname}`; } -export async function exchangeGrant( - grantId: string, +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); + if (!params.has("code") && !params.has("error")) 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 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 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, + expiresAt: tokenExpiry ?? now + expiresIn * 1000, }; } diff --git a/src/platform.css b/src/platform.css index 75b0420..33ee53c 100644 --- a/src/platform.css +++ b/src/platform.css @@ -36,6 +36,23 @@ 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; +} + .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([]); }); }); From 75d55ce04217e4b6610d56a209cacdb023e5f606 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Thu, 6 Aug 2026 00:42:55 -0400 Subject: [PATCH 3/5] feat: add draft and map clipboard export --- src/App.test.ts | 11 +++++- src/App.tsx | 33 +++++++++++++++++ src/Map.tsx | 5 +++ src/i18n/source.json | 8 +++- src/map-export.test.ts | 62 +++++++++++++++++++++++++++++++ src/map-export.ts | 83 ++++++++++++++++++++++++++++++++++++++++++ src/platform.css | 17 +++++++++ 7 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 src/map-export.test.ts create mode 100644 src/map-export.ts diff --git a/src/App.test.ts b/src/App.test.ts index 71daf4d..2249526 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -3,7 +3,7 @@ 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 { 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"; @@ -55,6 +55,15 @@ describe("resolveMirrorDecision", () => { }); }); +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"); + }); +}); + describe("session migration", () => { it("invalidates unresolved legacy pending mirrors instead of granting current-contract provenance", () => { const migrated = migrateLegacyMirrors([{ id: "m1", reflection: { claims: [{ id: "c1", text: "human control", candidateId: "candidate", target: "idea", sourceSpans: [] }] }, claims: [], decisions: { c1: "pending" } }], 7); diff --git a/src/App.tsx b/src/App.tsx index c25e30f..27459f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ 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 { useUiLocale } from "./ui-locale"; import { useReaderView } from "./reader-view"; @@ -2697,6 +2698,14 @@ export interface AppProps { aiAccessDenied?: boolean; } +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(); @@ -3042,6 +3051,7 @@ export default function App({ providerRuntime, aiAccessDenied = false }: AppProp // 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); @@ -3721,6 +3731,24 @@ export default function App({ providerRuntime, aiAccessDenied = false }: AppProp } } + 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) { @@ -4270,6 +4298,9 @@ export default function App({ providerRuntime, aiAccessDenied = false }: AppProp >
{t("Draft")} +
+
{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/i18n/source.json b/src/i18n/source.json index 6409500..a2093c8 100644 --- a/src/i18n/source.json +++ b/src/i18n/source.json @@ -203,5 +203,11 @@ "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." + "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.css b/src/platform.css index 33ee53c..074e2f7 100644 --- a/src/platform.css +++ b/src/platform.css @@ -53,6 +53,23 @@ 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; From 7498f5ee4b8fc6a5ebb04854aaa4c72cb3fa6af8 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Thu, 6 Aug 2026 00:55:32 -0400 Subject: [PATCH 4/5] ci: prepare standalone Mindmap Pages deployment --- .env.production | 2 + .github/workflows/deploy-pages.yml | 54 ++++++++ .gitignore | 2 + README.md | 152 ++++++++++------------ e2e-pages/global-setup.ts | 10 ++ e2e-pages/pages-smoke.spec.ts | 195 +++++++++++++++++++++++++++++ e2e/global-setup.ts | 14 +++ e2e/smoke.spec.ts | 36 ++---- package.json | 4 + playwright.config.ts | 13 +- playwright.pages.config.ts | 12 ++ scripts/verify-pages-build.mjs | 57 +++++++++ vite.config.ts | 2 +- 13 files changed, 428 insertions(+), 125 deletions(-) create mode 100644 .env.production create mode 100644 .github/workflows/deploy-pages.yml create mode 100644 e2e-pages/global-setup.ts create mode 100644 e2e-pages/pages-smoke.spec.ts create mode 100644 e2e/global-setup.ts create mode 100644 playwright.pages.config.ts create mode 100644 scripts/verify-pages-build.mjs 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/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/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); From d9ad079c8afccfd053bd3e59f6cf5170f30a99b9 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:13:15 -0400 Subject: [PATCH 5/5] fix(oauth): require HTTPS in production and tighten login state handling - Production refuses a non-HTTPS VITE_BACKEND_URL at module load, in both platform-session.ts and the duplicated resolver in api.ts. The bearer token and every AI request go to that URL. (verify:pages already rejects it in the deploy workflow; this makes the bundle itself fail closed too.) - Token expiry is the earliest of the JWT exp, the advertised expires_in, and the stored expiry, both at exchange time and when read back. - Login transactions expire after 10 minutes (and are rejected if dated in the future), so an abandoned state/verifier can't complete a login later. - Any of code, state, error or error_description marks the URL as a callback, so partial or forged callback material is scrubbed and rejected. Tests cover each; all four fail without the change. Co-Authored-By: Claude Opus 5.5 --- src/api.ts | 13 +++++++-- src/platform-session.test.ts | 54 +++++++++++++++++++++++++++++++++++- src/platform-session.ts | 50 ++++++++++++++++++++++++++++----- 3 files changed, 107 insertions(+), 10 deletions(-) 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/platform-session.test.ts b/src/platform-session.test.ts index 4f04fcb..59e5378 100644 --- a/src/platform-session.test.ts +++ b/src/platform-session.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { OAUTH_SCOPE, OAUTH_SESSION_STORAGE_KEY, + OAUTH_TRANSACTION_MAX_AGE_MS, OAUTH_TRANSACTION_STORAGE_KEY, canonicalOAuthResource, clearOAuthSession, @@ -50,7 +51,7 @@ const transaction: OAuthTransaction = { verifier: "v".repeat(64), redirectUri: "https://mindmap.example/", resource: "https://app.example", - createdAt: 1, + createdAt: Date.now(), }; describe("standalone OAuth session", () => { @@ -61,6 +62,57 @@ describe("standalone OAuth session", () => { expect(() => resolveOAuthClientId({ PROD: true })).toThrow("VITE_OAUTH_CLIENT_ID"); }); + 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("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", + clientId: "client", + now: () => now, + }); + expect(session.expiresAt).toBe(now + 3_600_000); + + const storage = memoryStorage(); + writeOAuthSession(storage, { version: 1, accessToken: lateJwt, expiresAt: now + 3_600_000 }); + expect(readOAuthSession(storage, now + 3_600_001)).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); diff --git a/src/platform-session.ts b/src/platform-session.ts index 1f995fb..bee60e1 100644 --- a/src/platform-session.ts +++ b/src/platform-session.ts @@ -22,13 +22,28 @@ type PlatformEnv = { */ export function resolveBackendUrl(env: PlatformEnv = viteEnv ?? {}): string { const configured = typeof env.VITE_BACKEND_URL === "string" ? env.VITE_BACKEND_URL.trim() : ""; - if (configured) return configured.replace(/\/+$/, ""); + 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"; } +function isHttpsUrl(value: string): boolean { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + export function resolveOAuthClientId(env: PlatformEnv = viteEnv ?? {}): string { const configured = typeof env.VITE_OAUTH_CLIENT_ID === "string" ? env.VITE_OAUTH_CLIENT_ID.trim() @@ -112,6 +127,12 @@ function decodeJwtExpiry(accessToken: string): number | null { } } +/** 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 readOAuthSession( storage: Pick, now: number = Date.now(), @@ -124,7 +145,7 @@ export function readOAuthSession( const storedExpiry = typeof parsed.expiresAt === "number" && Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : null; - const expiry = decodeJwtExpiry(parsed.accessToken) ?? storedExpiry; + const expiry = earliest(decodeJwtExpiry(parsed.accessToken), storedExpiry); if (expiry === null || expiry <= now) return null; return { version: 1, accessToken: parsed.accessToken, expiresAt: expiry }; } catch { @@ -141,7 +162,16 @@ export function clearOAuthSession(storage: Pick): void { storage.removeItem(OAUTH_TRANSACTION_STORAGE_KEY); } -export function readOAuthTransaction(storage: Pick): OAuthTransaction | null { +/** + * 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 readOAuthTransaction( + storage: Pick, + now: number = Date.now(), +): OAuthTransaction | null { try { const parsed = JSON.parse(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY) ?? "null") as unknown; if ( @@ -151,7 +181,9 @@ export function readOAuthTransaction(storage: Pick): OAuthTr typeof parsed.verifier !== "string" || parsed.verifier.length < 43 || typeof parsed.redirectUri !== "string" || typeof parsed.resource !== "string" || - typeof parsed.createdAt !== "number" || !Number.isFinite(parsed.createdAt) + typeof parsed.createdAt !== "number" || !Number.isFinite(parsed.createdAt) || + now - parsed.createdAt > OAUTH_TRANSACTION_MAX_AGE_MS || + parsed.createdAt > now + 60_000 ) return null; return parsed as unknown as OAuthTransaction; } catch { @@ -168,8 +200,9 @@ export function writeOAuthTransaction( export function consumeOAuthTransaction( storage: Pick, + now: number = Date.now(), ): OAuthTransaction | null { - const transaction = readOAuthTransaction(storage); + const transaction = readOAuthTransaction(storage, now); storage.removeItem(OAUTH_TRANSACTION_STORAGE_KEY); return transaction; } @@ -232,7 +265,9 @@ export async function createAuthorizationRequest(options: { export function oauthCallbackFromSearch(search: string): OAuthCallback | null { const params = new URLSearchParams(search); - if (!params.has("code") && !params.has("error")) return null; + // 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")! } : {}), @@ -308,6 +343,7 @@ export async function exchangeAuthorizationCode( return { version: 1, accessToken: body.access_token, - expiresAt: tokenExpiry ?? now + expiresIn * 1000, + // Honour whichever lifetime is shorter: the JWT's own exp or the advertised expires_in. + expiresAt: earliest(tokenExpiry, now + expiresIn * 1000)!, }; }