Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_BACKEND_URL=https://app.thoughtful-ai.com/api
VITE_OAUTH_CLIENT_ID=writing-tools-mindmap
54 changes: 54 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist/
*.log
tsconfig.tsbuildinfo
eval/runs/
playwright-report*/
test-results/
152 changes: 68 additions & 84 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 59 additions & 8 deletions docs/login-bootstrap-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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=<origin only, no trailing slash>`
- **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.

Expand Down
10 changes: 10 additions & 0 deletions e2e-pages/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { fileURLToPath } from "node:url";
import { preview } from "vite";

export default async function startPagesPreview(): Promise<(() => Promise<void>) | 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(); };
}
Loading
Loading