Skip to content

Spaces: the web app, and CORS on Harbor for it - #1178

Merged
ramnique merged 2 commits into
mainfrom
ramnique/spaces-web-app
Oct 12, 2026
Merged

ramnique merged 2 commits into
mainfrom
ramnique/spaces-web-app

Conversation

@ramnique

@ramnique ramnique commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Step 2b of the Spaces web-app plan: Spaces runs in a browser, using the desktop's own components.

Stacked on #1176, the shared channel table. Merge #1177 first. That's the Markdown sanitizer, and in a browser, message HTML is the main route to stealing someone's session.

Harbor (commit 1)

  • HARBOR_WEB_ORIGINS (comma-separated) lists the browser origins allowed cross-origin.
  • cors.ts mounts Hono's CORS middleware ahead of auth on every org face and the apex:
    • A preflight is answered before auth, so it never meets a 401.
    • Error responses keep the CORS headers, so a page can read a 401 and refresh its token.
    • Bearer only: no credentials, no cookies. WWW-Authenticate is exposed.
    • The live face is unchanged, since browser WebSockets send no preflight and already take ?token=.
  • Unset means no CORS, as before. The dev binary defaults to the web dev server, http://localhost:5174.
  • Docs: a CONTRACT.md bullet, plus HARBOR_WEB_ORIGINS in the Dockerfile header and the guide.
  • Tests: test/cors.test.ts covers the preflight without a bearer, headers on 200 and 401, unlisted origins, the unset case, and the apex.

The web app (commit 2)

  • A second Vite entry, web.html → src/web/main.tsx. The desktop's SpacesView and everything under it are unchanged.

  • host.ts installs the window.ipc contract in the tab and validates requests as the preload does. Channels the browser doesn't serve are refused by name.

  • handlers.ts serves the shared table from Spaces: one table of the channels that are a single Harbor call #1176 plus the browser's own channels: the org list from the apex, sign-in, org creation, uploads from bytes.

  • Sign-in (account.ts) is the phone's journey as a full-page redirect:

    • discovery from the apex, one dynamic client registration per browser, PKCE
    • tokens in localStorage, with refreshes under a Web Lock so two tabs never spend the same rotating refresh token
    • the OAuth helpers move into @x/spaces-client (oauth.ts)
  • Routes (routes.ts, app.tsx): /<org>/s/<space>, …/t/<thread>, …/a/<file>, …/w/<board>, /<org>/activity, /<org>/browse. Each org's path segment is its slug.

  • Hidden in the browser, because they need the person's own machine (LOCAL_RUNTIME, set only by the web host):

    • @rowboat and /ask
    • send-later, /remind and the Scheduled list
    • the @rowboat hints in placeholders

    Voice already hides itself when its config call fails. The desktop is unchanged: the preload never sets the flag, so it's on there.

  • vercel.json:

    • installs Harbor's protocol package, then the renderer's workspace dependencies, skipping Electron's binary
    • builds with build:web and rewrites every path to web.html
    • sets a CSP with no inline or foreign script; the theme bootstrap moves to web-public/theme.js for that reason
  • Docs: apps/x/apps/renderer/WEB.md covers running and shipping it. SPEC §4 says where the web app keeps tokens. The root guide links WEB.md.

Verified locally

  • Typecheck and tests:
    • npm run typecheck (apps/x) passes.
    • Renderer: 141 files, 1158 tests (new: routes.test.ts).
    • spaces-client: 44 tests.
    • Harbor: 592 tests (new: cors.test.ts).
  • In headless Chromium against a local pnpm dev Harbor:
    • It lands on the first space, and posting from the composer works.
    • A message Arjun sent through the API arrived live.
    • Reloading on a deep link lands in the same space.
    • No page errors.
  • The production build under its CSP, served with the same headers: loads and posts with no CSP violations.

Not verified: real sign-in. That needs a deployment whose HARBOR_WEB_ORIGINS lists the page.

Not in this PR (next)

  • Images and files. Images and avatars still use app:// URLs (next: a service worker). File-picker and drag-drop uploads send a path the browser doesn't have; pasted images upload fine.
  • Invite links opening in the browser.
  • Notifications.

Deploying it (needs a person)

  1. Set HARBOR_WEB_ORIGINS on staging Harbor (Render) to the staging web origin.
  2. Create a Vercel project with Root Directory apps/x/apps/renderer, and set VITE_SPACES_APEX_URL=https://spaces.staging.x.rowboatlabs.com for staging.
  3. DNS on Route 53, once there's a domain to point.

🤖 Generated with Claude Code

@ramnique
ramnique force-pushed the ramnique/spaces-web-shell branch from d555be4 to d6ae8a3 Compare October 9, 2026 09:55
ramnique and others added 2 commits October 9, 2026 15:25
The Spaces web app is served from its own origin and calls every org's
host and the apex cross-origin. HARBOR_WEB_ORIGINS (comma-separated)
names the origins allowed. cors.ts mounts Hono's CORS middleware ahead
of auth on the render face and the apex, so a preflight, which carries
no Authorization, is answered instead of refused with a 401. Error
responses keep the headers, so a page can read a 401 and refresh.
Bearer only: no credentials and no cookies. WWW-Authenticate is exposed.
The live face needs nothing, since WebSockets have no preflight and
already take ?token=.

Unset means no CORS, as before. The dev binary defaults to the web app's
dev server, http://localhost:5174.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A second Vite entry (web.html → src/web/main.tsx) builds the desktop's
Spaces components as a standalone web app. The components are unchanged;
only the host under them differs:

- host.ts installs the same window.ipc contract in the tab, validated
  as the preload validates it. A channel the browser doesn't serve is
  refused by name.
- handlers.ts serves the shared Harbor channel table from
  @x/spaces-client, plus the browser's own channels: the org list from
  the apex, sign-in, org creation, and uploads from bytes.
- account.ts is the phone's sign-in as a full-page redirect: discovery
  from the apex, one dynamic client registration per browser, PKCE.
  Tokens live in localStorage, and refreshes run under a Web Lock so two
  tabs never spend the same rotating refresh token. The OAuth helpers
  move into @x/spaces-client (oauth.ts).
- orgs.ts keeps one REST client and live socket per org. A dev Harbor
  (VITE_SPACES_DEV_HARBOR) runs it locally with dev tokens.
- routes.ts and app.tsx make the address bar the navigation:
  /<org>/s/<space>, …/t/<thread>, …/a/<file>, /<org>/activity.

What needs the person's own machine is hidden where LOCAL_RUNTIME
(feature-flags.ts) is false, which only the web host sets: @rowboat and
/ask, send-later, /remind and the Scheduled list, and the @rowboat text
in placeholders. Voice already hides itself when its config call fails.

vercel.json builds it, rewrites every path to web.html, and sets a CSP
with no inline or foreign script. The theme bootstrap moves to a file for
that reason. WEB.md says how to run and ship it, and SPEC §4 says where
the web app keeps tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ramnique
ramnique force-pushed the ramnique/spaces-web-app branch from 6c816e7 to 511bd96 Compare October 9, 2026 09:57
@ramnique
ramnique changed the base branch from ramnique/spaces-web-shell to main October 12, 2026 05:35
@ramnique
ramnique merged commit 9633229 into main Oct 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant