Blame

30c8f2 Claude (MCP) 2026-03-14 23:18:39
[mcp] Draft Phase 3 frontend design: URL scheme, auth, framework, screens, hosting
1
---
2
category: reference
3
tags: [design, frontend, prd]
4
last_updated: 2026-03-14
5
confidence: medium
6
---
7
8
# Frontend Design
9
54f29b Claude (MCP) 2026-03-15 01:19:40
[mcp] Add superseded banner to Frontend
10
> **Superseded.** This page describes the frontend for wikibot.io (S3 + CloudFront, WorkOS auth, dual CloudFront distributions). See [[Design/VPS_Architecture]] for the current plan (Caddy file_server, ATProto auth). The SPA screens, framework choice, cross-subdomain cookie auth pattern, and UX decisions carry forward; the AWS hosting and WorkOS-specific flows do not.
11
30c8f2 Claude (MCP) 2026-03-14 23:18:39
[mcp] Draft Phase 3 frontend design: URL scheme, auth, framework, screens, hosting
12
**Status:** Draft — needs review
13
**Relates to:** [[Design/Platform_Overview]], [[Design/Auth]], [[Design/Implementation_Phases]], [[Design/CDN_Read_Path]], [[Design/Landing_Page]]
14
15
## What the frontend actually is
16
17
There are two distinct web experiences that need to coexist:
18
19
1. **The management app** — dashboard, wiki CRUD, collaborator management, MCP connection instructions, eventually billing. This lives at `wikibot.io`.
20
2. **The wiki** — Otterwiki's Flask-rendered pages. This lives at `{slug}.wikibot.io`.
21
22
They're served by different backends on different subdomains, but the user needs to move between them without friction. The management app links to your wiki; the wiki links back to management for platform-level settings. Auth needs to work across both.
23
24
## URL Scheme
25
26
Every wiki gets a subdomain: `{slug}.wikibot.io`. No path nesting, no username prefix. The slug is the wiki's globally unique identifier.
27
28
```
29
third-gulf-war.wikibot.io/ → wiki web UI (Otterwiki)
30
third-gulf-war.wikibot.io/api/v1/ → wiki REST API
31
third-gulf-war.wikibot.io/mcp → wiki MCP endpoint
32
third-gulf-war.wikibot.io/repo.git/* → git smart HTTP
33
```
34
35
For free-tier users, the wiki slug is the username. You sign up as `sderle`, your wiki lives at `sderle.wikibot.io`. Paid users can create additional wikis with slugs of their choosing — same namespace, same validation rules.
36
37
The management app and auth live on the root domain:
38
39
```
40
wikibot.io/ → landing page (unauthenticated)
41
wikibot.io/app/ → management SPA (authenticated)
42
wikibot.io/app/new → create wiki
43
wikibot.io/app/{slug} → wiki settings
44
wikibot.io/app/{slug}/collaborators → collaborator management
45
wikibot.io/app/{slug}/connect → MCP connection instructions
46
wikibot.io/app/account → account settings, eventually billing
47
wikibot.io/auth/callback → OAuth callback (server-side, mints JWT)
48
wikibot.io/auth/logout → clear auth state
49
wikibot.io/api/wikis → management API
50
wikibot.io/api/wikis/{slug} → wiki details
51
wikibot.io/api/wikis/{slug}/acl → collaborator management
52
wikibot.io/api/wikis/{slug}/token → token regeneration
53
wikibot.io/api/account → account info
54
```
55
56
The `/app/` prefix separates the SPA from the landing page (static HTML at `/`) and the API (`/api/`). CloudFront routes `/app/*` to the S3-hosted SPA (all paths serve `index.html`), `/api/*` and `/auth/*` to Lambda, and `/` to the static landing page.
57
58
### Namespace implications
59
60
Slugs and usernames share one global namespace. This simplifies routing (every subdomain is a wiki lookup — no "is this a username or a slug?" disambiguation) but requires careful validation:
61
62
- Slugs must be unique across all wikis and all usernames.
63
- When a user signs up and picks a username, it's reserved as a slug for their free wiki.
64
- Paid wiki slugs are chosen at creation time and checked against the same namespace.
65
- Reserved names (`api`, `auth`, `app`, `www`, `admin`, `mcp`, `docs`, `status`, `blog`, `help`, `support`, `billing`, `static`, `assets`, `null`, `undefined`, `wiki`, `robot`) are blocked for both usernames and wiki slugs.
66
67
The Data Model's `username-index` GSI and a wiki `slug-index` GSI both need to draw from the same blocklist, and uniqueness checks at wiki creation need to verify the slug isn't taken as a username either. A single `slug_reservations` table (or a single DynamoDB attribute with a GSI) that holds both usernames and wiki slugs would eliminate the dual-lookup.
68
69
### Impact on existing design documents
70
71
This URL scheme replaces the `{username}.wikibot.io/{wiki}/` pattern described in [[Design/Data_Model]], [[Design/Implementation_Phases]], and [[Design/Platform_Overview]]. The key changes:
72
73
- The `Wiki.custom_slug` field becomes unnecessary — every wiki has a slug that IS its subdomain. Remove `custom_slug`, replace with just `slug`.
74
- The subdomain resolution logic simplifies: parse subdomain → look up wiki by slug → done. No fallback to username resolution.
75
- The user's "dashboard" URL is no longer `{username}.wikibot.io/` — it's `wikibot.io/app/`.
76
- Git remote URLs simplify: `https://{slug}.wikibot.io/repo.git` instead of `https://{user}.wikibot.io/{wiki}.git`.
77
- MCP endpoint simplifies: `https://{slug}.wikibot.io/mcp`.
78
79
## Authentication
80
81
### Cross-subdomain auth via parent-domain cookie
82
83
The platform JWT is stored as an `HttpOnly`, `Secure`, `SameSite=Lax` cookie on `.wikibot.io` (note the leading dot — this is the parent domain, visible to all subdomains). Every request to any `*.wikibot.io` subdomain includes the cookie automatically. The middleware on each subdomain validates the JWT independently using the same RS256 public key.
84
85
Why this works:
86
87
- The management SPA at `wikibot.io/app/` needs auth → cookie is present.
88
- Otterwiki at `{slug}.wikibot.io` needs auth → same cookie is present.
89
- Public wiki reads don't need auth → middleware ignores the cookie and serves as anonymous.
90
- MCP bearer token auth is a separate path (Authorization header) → unaffected.
91
92
Why not localStorage or a subdomain-specific cookie: localStorage can't be shared across subdomains. Subdomain-specific cookies would require a separate auth flow for each wiki you visit. The parent-domain cookie gives you SSO for free.
93
94
Security consideration: the cookie is sent on every request to every subdomain, including public wiki reads. This is fine — the middleware already distinguishes authenticated from anonymous access. The cookie contains a signed JWT, not session state, so replaying it to a public wiki endpoint doesn't grant any access the user doesn't already have.
95
96
### OAuth flow
97
98
The SPA cannot set HttpOnly cookies (browser security restriction). So the auth flow is server-side:
99
100
```
101
1. User clicks "Sign in" on the SPA
102
2. SPA redirects to WorkOS AuthKit (hosted UI)
103
3. User authenticates with Google/GitHub/Microsoft/Apple
104
4. WorkOS redirects to wikibot.io/auth/callback with auth code
105
5. Callback Lambda exchanges code for user info via WorkOS API
106
6. Lambda looks up or creates User record in DynamoDB
107
7. Lambda mints platform JWT (RS256, signed with our key from Secrets Manager)
108
8. Lambda sets HttpOnly cookie on .wikibot.io
109
9. Lambda redirects to wikibot.io/app/ (the SPA dashboard)
110
```
111
112
The SPA never sees or handles the JWT directly. It just knows whether the cookie exists (via a non-HttpOnly companion cookie or a `/api/me` endpoint that returns 200 or 401).
113
114
### JWT details
115
116
These need pinning down:
117
118
- **Algorithm:** RS256 (asymmetric — the signing key stays in Secrets Manager, the public key can be distributed to any service that needs to validate).
119
- **Lifetime:** 24 hours. Short enough that revocation is eventually consistent, long enough that users aren't constantly re-authenticating.
120
- **Refresh:** Silent refresh via a `/auth/refresh` endpoint. When the SPA detects a 401, it hits `/auth/refresh` — if the user has a valid session with WorkOS (cookie on `.authkit.app`), WorkOS re-issues credentials without user interaction, and the callback mints a fresh JWT. If the WorkOS session has expired, the user gets redirected to login.
121
- **Claims:** `sub` (our User.id), `email`, `username`, `iat`, `exp`. No role claims — roles are per-wiki and live in DynamoDB, not the token.
122
- **Key rotation:** Support two valid keys simultaneously (JWKS with `kid` header). Rotate by: generate new key, add to JWKS, start signing with new key, retire old key after 48 hours (2× the token lifetime). This is zero-downtime rotation. The JWKS endpoint (`wikibot.io/.well-known/jwks.json`) serves both keys during the transition window.
123
124
### How the SPA knows if you're logged in
125
126
The SPA can't read the HttpOnly cookie. Two options:
127
128
**Option A: companion cookie.** The callback Lambda sets a second, non-HttpOnly cookie (`logged_in=1`, same domain/expiry) that the SPA can read. This cookie carries no auth weight — it's just a hint. The SPA uses it to decide whether to show the dashboard or the login page. If it's stale (the JWT expired but the hint cookie hasn't), the first API call returns 401 and the SPA redirects to login.
129
130
**Option B: `/api/me` probe.** On SPA load, fetch `/api/me`. If 200, you're logged in (and you get the user's display name, email, username for the UI). If 401, redirect to login. This adds a network round-trip to every SPA cold load.
131
132
Option A is snappier (no network round-trip to decide what to render). Option B is simpler (no second cookie to manage). For an app where the SPA loads are infrequent (users visit the dashboard, click through to their wiki, and spend most of their time in Otterwiki or MCP), the round-trip cost of Option B is negligible.
133
134
**Recommendation: Option B.** `/api/me` returns user info needed by the SPA anyway (username, display name, wiki list). One request, no companion cookie to manage, no stale-hint edge cases.
135
136
## Framework
137
138
### Recommendation: Svelte (not SvelteKit)
139
140
The management app has six screens, no server-side rendering needs, and no complex state. It's a thin CRUD layer over the management API. SvelteKit's filesystem routing and SSR machinery are overkill — the app is a static SPA deployed to S3 + CloudFront. A plain Svelte project with `svelte-spa-router` (or Svelte 5's built-in routing if stable) and Vite is the right level of tool for the job.
141
142
Why Svelte over React: smaller bundle (the management app should be under 100KB gzipped, total), less boilerplate for forms and lists, reactive declarations handle the minimal state without a state management library. The audience is developers — they won't be surprised by Svelte.
143
144
Why not SvelteKit: SvelteKit wants to own the server. Its adapter model (adapter-static, adapter-node, etc.) adds complexity for what is ultimately a static SPA. The SSR and form actions features are solutions to problems this app doesn't have. A plain Vite + Svelte project builds to static files, drops into S3, and is done.
145
146
Why not plain HTML: seriously considered. The management UI is mostly forms and lists — server-rendered HTML from Lambda would work. But the token show-once UX (display in a modal, copy to clipboard, dismiss and never show again) and the collaborator management flow (add, change role, remove without full page reloads) benefit from client-side interactivity. The incremental complexity of Svelte over plain HTML is small, and the UX payoff is worth it.
147
148
### Build tooling
149
150
- **Vite** for dev server and production build.
151
- **TypeScript** — the app is small but types help with API response shapes.
152
- **Tailwind CSS** — utility classes for layout. The landing page is hand-written CSS (per [[Design/Landing_Page]]), but the management SPA is a separate build and Tailwind is the fastest way to ship a clean, responsive UI without writing much CSS.
153
- **No state management library.** Svelte's reactive `$state` (Svelte 5 runes) or `writable` stores (Svelte 4) are sufficient. The state is: current user, list of wikis, current wiki details. Nothing more.
154
155
### Bundle budget
156
157
Target: < 80KB gzipped for the initial load (JS + CSS). Svelte compiles away the framework runtime, so this is achievable. The main risk is Tailwind — use the JIT compiler to tree-shake unused classes.
158
159
## Screens
160
161
### Dashboard (`/app/`)
162
163
The first thing you see after login. Shows your wikis as a list (not cards, not a grid — a list). Each row shows:
164
165
- Wiki slug (linked to `{slug}.wikibot.io`)
166
- Display name
167
- Page count
168
- Last activity timestamp
169
- Status indicator (active, lapsed) — only relevant once billing exists
170
171
Empty state for new users: a welcome message and a "Create your wiki" button. Since free users get exactly one wiki, the empty state could skip the list entirely and go straight to the creation flow.
172
173
### Create wiki (`/app/new`)
174
175
For free users (first wiki): a form with just the wiki display name. The slug is their username, pre-filled and non-editable. Submit creates the wiki and redirects to the connection instructions screen, which shows the MCP bearer token (this is the show-once moment).
176
177
For paid users (additional wikis): same form but with an editable slug field. Slug validation: lowercase alphanumeric + hyphens, 3–30 characters, no leading/trailing hyphens, not in the reserved list, not already taken (check via API on blur).
178
179
### Wiki settings (`/app/{slug}`)
180
181
Tabs or sections:
182
183
**General:** Display name (editable), slug (read-only after creation), public/private toggle, link to wiki web UI (`{slug}.wikibot.io`), link to Otterwiki admin panel (`{slug}.wikibot.io/-/admin`) for wiki-level preferences (site name, sidebar, editing conventions).
184
185
**Collaborators:** covered below.
186
187
**Connection:** covered below.
188
189
**Danger zone:** Delete wiki. Requires typing the slug to confirm. Red button, scary text, the usual.
190
191
### Collaborators (`/app/{slug}/collaborators`)
192
193
List of users with access to the wiki. Each row: email, display name, role badge (owner/editor/viewer), and a role dropdown or revoke button (except for the owner).
194
195
**Invite flow:** Email input + role selector + invite button.
196
197
What happens when you invite an email that isn't registered: this is a product decision that isn't resolved elsewhere in the design. Two options:
198
199
**Option A: Reject unregistered emails.** Simple. "This email doesn't have a WikiBot account. Ask them to sign up first, then try again." No pending state, no email sending, no invite tokens. The downside is friction — the collaborator has to sign up before the invite can happen.
200
201
**Option B: Pending invites.** Create an ACL record in "pending" state, keyed on email instead of user ID. When the invitee signs up, match their email to pending invites and activate them. Optionally send an email notification ("You've been invited to collaborate on {wiki}"). The upside is smoother onboarding; the downside is email sending infrastructure (SES), pending state management, and stale invite cleanup.
202
203
**Recommendation: Option A for launch.** The audience at launch is small and technical. "Have them sign up first" is fine. Pending invites can be added later when the friction matters. This avoids building email sending infrastructure, which adds cost and complexity disproportionate to its value at this stage.
204
205
### MCP connection instructions (`/app/{slug}/connect`)
206
207
This is the most important screen in the management app. It should be accessible from the dashboard (after wiki creation) and from wiki settings. It shows:
208
209
**Bearer token (for Claude Code and API clients):** Displayed once at wiki creation time. If the user navigates away and comes back, the token is gone — they see a "Regenerate token" button with a warning that existing connections will break. The token display uses a monospace font, a copy-to-clipboard button, and a yellow "save this now" callout.
210
211
**Claude Code setup:**
212
```bash
213
claude mcp add {slug} --transport streamable-http --url https://{slug}.wikibot.io/mcp --header "Authorization: Bearer YOUR_TOKEN"
214
```
215
216
This should be a pre-filled, copyable code block with the user's actual slug and (on first display) their actual token substituted in.
217
218
**Claude.ai setup:** For OAuth-based MCP connections, the user just adds the MCP URL in Claude.ai's settings. The instructions should show the URL (`https://{slug}.wikibot.io/mcp`) and walk through the Claude.ai UI for adding an MCP server. Screenshots would help here, but can wait until the UI is stable.
219
220
**Other MCP clients:** A generic "any Streamable HTTP MCP client" section with the endpoint URL and auth header format.
221
222
### Account settings (`/app/account`)
223
224
Username (read-only for MVP), email (from OAuth provider, read-only), connected OAuth provider, delete account button.
225
226
Eventually: billing management (Stripe customer portal link), subscription status.
227
228
## Otterwiki Admin Panel Boundary
229
230
The management SPA handles platform concerns: wiki CRUD, ACLs, tokens, billing. Otterwiki's admin panel handles wiki-level preferences: site name, description, sidebar layout, editing conventions (commit message style, page name casing, WikiLink syntax). These are different concerns and shouldn't be duplicated.
231
232
The SPA links to the Otterwiki admin panel (`{slug}.wikibot.io/-/admin`) from the wiki settings page. The admin panel links back to the SPA (a "Platform settings" link in the Otterwiki nav, pointing to `wikibot.io/app/{slug}`). This cross-linking is a small upstream-friendly addition: a template variable for an external settings URL, rendered as a nav link when set.
233
234
The Phase 2 decision to hide Repository Management, Permissions/Registration, User Management, and Mail Preferences from the Otterwiki admin panel stands. Those sections conflict with platform-managed settings and are correctly disabled.
235
236
## Error Handling
237
238
Patterns to define upfront so the UI is consistent:
239
240
**Loading states:** Skeleton placeholders for the wiki list on dashboard load. Not spinners — the layout should be visible immediately with placeholder content that resolves to real data. For individual actions (create wiki, invite collaborator), a disabled button with a loading indicator.
241
242
**API errors:** Inline error messages below the relevant form field or action button. Not toasts — toasts are easy to miss and hard to act on. If creating a wiki fails because the slug is taken, the error appears next to the slug field. If inviting a collaborator fails because the email isn't registered, the error appears next to the email field.
243
244
**Global errors:** If the management API is unreachable (network error, 5xx), a banner at the top of the page: "Something went wrong. Try refreshing." No retry loops, no exponential backoff in the UI — the user can refresh.
245
246
**Stale session:** If `/api/me` or any API call returns 401, redirect to login. If the WorkOS session is still valid, the user is silently re-authenticated and redirected back. If not, they see the login screen.
247
248
## Static Hosting and Routing
249
250
The SPA is a set of static files deployed to S3, served via CloudFront. The routing setup:
251
252
**CloudFront behaviors** (order matters — first match wins):
253
254
1. `/api/*` and `/auth/*` → API Gateway origin (management Lambda)
255
2. `/app/*` → S3 origin, with custom error response: 403 and 404 → `/app/index.html` with 200 status. This is standard SPA hosting — all routes resolve to the SPA shell, client-side routing handles the rest.
256
3. `/` → S3 origin, serves the static landing page (`index.html` at the S3 root).
257
4. Static assets (CSS, JS, images) → S3 origin with long TTL (1 year, content-hashed filenames).
258
259
The SPA's `index.html` gets `Cache-Control: no-cache` (or short TTL) so deploys take effect immediately. JS and CSS bundles get content-hashed filenames and `Cache-Control: public, max-age=31536000`.
260
261
**Wildcard subdomain setup:**
262
263
- ACM certificate: `*.wikibot.io` + `wikibot.io` (SAN certificate, or two certificates)
264
- Route 53: `*.wikibot.io` ALIAS to CloudFront distribution (wiki subdomains) + `wikibot.io` ALIAS to a separate CloudFront distribution (management app + landing page), OR a single CloudFront distribution with behaviors that route based on the Host header.
265
- One distribution is simpler to manage. CloudFront can route by Host header using cache behaviors with origin request policies.
266
267
**Single vs. dual distribution:** A single CloudFront distribution handling both `wikibot.io` and `*.wikibot.io` is possible with a Lambda@Edge or CloudFront Function on origin-request that inspects the Host header and routes to the appropriate origin (S3 for the management app, API Gateway for wiki subdomains). This avoids managing two distributions but adds a routing function at the edge.
268
269
Two distributions is operationally simpler: one for the root domain (S3 + API Gateway origins), one for wildcards (API Gateway origin for wiki subdomains). The tradeoff is two sets of cache behaviors and two certificates to manage.
270
271
**Recommendation: two distributions.** The routing logic is different enough that combining them adds more complexity than it saves. The root-domain distribution handles static files and the management API. The wildcard distribution handles wiki traffic (Otterwiki, REST API, MCP, git). Each has its own cache behaviors, origins, and error handling.
272
273
## Build and Deploy
274
275
The SPA builds to static files via Vite, uploaded to S3, served via CloudFront.
276
277
```
278
app/frontend/
279
src/
280
routes/ # page components
281
lib/ # API client, auth helpers, shared components
282
app.svelte # root layout
283
main.ts # entry point
284
static/ # favicon, etc.
285
vite.config.ts
286
package.json
287
tsconfig.json
288
```
289
290
CI/CD (GitHub Actions):
291
292
1. `npm run build` → produces `dist/` with `index.html`, hashed JS/CSS bundles
293
2. Upload `dist/` to S3 management bucket
294
3. Invalidate CloudFront paths: `/app/*`, `/app/index.html`
295
4. Smoke test: `curl -s https://wikibot.io/app/ | grep -q '<div id="app">'`
296
297
Source maps are generated but not uploaded to S3. Upload them to an error tracking service (Sentry) if/when that's set up.
298
299
Environment variables baked in at build time via Vite's `import.meta.env`:
300
301
- `VITE_API_BASE_URL` (e.g., `https://wikibot.io`)
302
- `VITE_WORKOS_CLIENT_ID` (for the login redirect)
303
304
No runtime config fetching. The SPA doesn't need to discover anything at load time — the API is always at the same origin.
305
306
## Mobile
307
308
The Phase Gate says "usable on phone." This is a management dashboard with lists and forms — responsive CSS handles it. No mobile-specific design needed. Tailwind's responsive utilities (`sm:`, `md:`, `lg:` prefixes) keep this simple.
309
310
The one screen that doesn't work well on mobile is the MCP connection instructions — those are terminal commands. That's fine. Users connecting MCP aren't doing it from their phone. The screen should render correctly (no horizontal overflow on the code blocks), but we're not optimizing for that use case.
311
312
## Upstream Contributions
313
314
The following changes to Otterwiki would be submitted upstream:
315
316
1. **External settings link in nav.** A config variable (e.g., `EXTERNAL_SETTINGS_URL`) that, when set, renders a link in the admin sidebar pointing to the platform management UI. Useful for any Otterwiki deployment that wraps the wiki in a larger platform.
317
318
2. **Any template changes needed for CDN fragment rendering** (per [[Design/CDN_Read_Path]]). These would be structured as pluggable hooks — not wikibot-specific modifications.
319
320
## Open Questions
321
322
1. **Single vs. dual CloudFront distribution.** Recommendation above is two, but single-distribution with a CloudFront Function router might be simpler in practice. Needs Pulumi prototyping to see which is less painful to configure.
323
324
2. **SPA framework version.** Svelte 5 (with runes) is the current stable release. Verify that the ecosystem (svelte-spa-router or equivalent, Tailwind integration) is solid before committing.
325
326
3. **Token storage for MCP OAuth flow.** The management SPA uses an HttpOnly cookie. The MCP OAuth flow (Claude.ai connecting to a wiki) uses WorkOS-issued tokens validated against WorkOS JWKS. These are separate token types on separate paths — but the user experience of "I logged into wikibot.io and now my MCP connection works" depends on both paths being configured correctly. The relationship between the platform JWT and the MCP OAuth token needs to be documented clearly in the connection instructions.
327
328
4. **Landing page → SPA transition.** The landing page is static HTML at `/`. The SPA is at `/app/`. When a logged-in user visits `/`, should they be redirected to `/app/`? Probably not — the landing page is useful even for logged-in users (docs, FAQ). But there should be a "Go to dashboard" link in the header that replaces "Sign in" when the user has an active session. This requires either a small JS snippet on the landing page that checks for the companion cookie / hits `/api/me`, or a CloudFront Function that detects the auth cookie and adds a header that the static page can use. The simplest approach: a small inline script on the landing page that checks for a `logged_in` cookie (non-HttpOnly, set alongside the JWT) and swaps the "Sign in" link for "Dashboard." This is the one case where a companion cookie (not `/api/me`) makes sense — the landing page is static HTML and can't make fetch calls elegantly without a framework.