# Architecture

How DeepSpace apps are structured, deployed, and connected to the platform.

A DeepSpace app is a normal Cloudflare Worker. It serves your React SPA, exposes API routes, and owns the Durable Objects that hold its realtime data. Around your worker, the DeepSpace platform runs shared authentication, payments, integrations, file, and deployment services that your worker talks to over service bindings or HTTPS.

**New to DeepSpace?** You don't need to understand the platform internals to build an app. Skip to the [Quickstart](/get-started/quickstart) and return here when you need to declare custom bindings or debug a deploy.

## The pieces

A DeepSpace deployment has two halves: the worker you write, and the platform workers DeepSpace runs.

**Your app worker** is a Cloudflare Worker compiled from `worker.ts` and the [Vite](https://vite.dev/) build of `src/`. It serves the SPA, handles HTTP and WebSocket routes, and owns the per-app Durable Objects. You deploy it with `npx deepspace deploy`; it lives at `<name>.app.space`.

**The platform workers** are managed by DeepSpace; you never deploy or configure them. Your worker talks to three of them at runtime (auth, API, platform) via the helpers in [Talking to platform workers](#talking-to-platform-workers); the deploy and dispatch workers sit outside your request path.

| Platform worker     | Responsibility                                                                                     |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| **Auth worker**     | Better Auth integration, OAuth flows, JWT issuance (ES256, 5-minute lifetime).                     |
| **API worker**      | Stripe billing, the integration proxy (215+ third-party endpoints), user profiles, usage tracking. |
| **Platform worker** | App-file gateway, screenshot service, health endpoint, and the public app-registry projection.     |
| **Deploy worker**   | Receives `deepspace deploy` uploads; provisions custom bindings; manages subdomains.               |
| **Dispatch worker** | Routes `*.app.space` traffic to the correct deployed app via Workers for Platforms.                |

## Your worker

The scaffolded worker is a [Hono](https://hono.dev/) app. Its main routes:

| Route                            | What it serves                                                                                                             |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `GET /ws/:roomId` (and variants) | WebSocket upgrades for Durable Object rooms - records, Yjs, canvas, presence, cron, jobs                                   |
| `/api/auth/*`                    | Proxied to the platform's auth worker (sign-in, OAuth callback, sign-out)                                                  |
| `/api/integrations/*`            | Proxied to the platform's API worker - any method, including `DELETE /oauth/:provider/disconnect`                          |
| `POST /api/actions/:name`        | Server actions defined in `src/actions/index.ts`                                                                           |
| `/api/ai/*`                      | Streamed chat (`POST /chat`) and chat CRUD (`POST/PATCH/DELETE /chats[/:id]`), defined in `src/ai/chat-routes.ts`          |
| `/api/files/*`                   | Scoped R2 file storage, proxied to the platform worker (`?scope=app` or per-user)                                          |
| `/_deepspace/*`                  | Allowlisted same-origin proxy for SDK billing hooks (subscriptions, charges)                                               |
| `/api/debug/*`                   | RecordRoom debug endpoints, gated on `ALLOW_DEBUG_ROUTES` (set by `deepspace dev start` / `test run`, never in production) |
| Everything else                  | Static SPA assets (the Vite build output)                                                                                  |

The worker runs on Cloudflare Workers for Platforms, which means each deployed app is isolated in its own namespace. Your app's URL is `<wrangler.toml name>.app.space`.

### Platform-reserved routes (`run_worker_first`)

`wrangler.toml` ships a `run_worker_first` list - paths that reach your worker before the asset layer answers, so an unmatched `/api/*` call gets a real 404 instead of the SPA shell. The scaffold's baseline:

```toml
run_worker_first = ["/api/*", "/ws/*", "/internal/*", "/v1/*", "/_deepspace/*", "/llms.txt", "/llms-full.txt", "/.well-known/mcp", "/.well-known/mcp.json", "/.well-known/mcp/*"]
```

This baseline is platform-reserved: `deploy` strips these entries from your list and the deploy worker re-adds them from its own baseline, so apps can append routes but can never drop these. `/v1/*` is reserved so OpenAI-compatible routes you mount in `worker.ts` resolve ahead of the SPA fallback; `/_deepspace/*` is the billing-hook proxy the subscriptions and charges hooks call; the `llms.txt` and `.well-known/mcp` entries keep the machine surfaces from answering with your homepage. Append your own prefixes (`/oauth/*`, `/preview/*`, …) when you mount routes outside the baseline - `deploy` forwards the extras and merges them into the deployed config.

## Durable Objects

State that needs to be **shared** - across users, across tabs, in real time - lives in a Durable Object. The scaffold ships six DO classes; each is an SDK base class subclassed in `worker.ts`:

| Class          | Purpose                                                     | WebSocket route         |
| -------------- | ----------------------------------------------------------- | ----------------------- |
| `RecordRoom`   | SQLite-backed records (your collections)                    | `/ws/:roomId`           |
| `YjsRoom`      | Per-document Yjs CRDT state                                 | `/ws/yjs/:docId`        |
| `CanvasRoom`   | Collaborative canvas shapes + viewports                     | `/ws/canvas/:docId`     |
| `PresenceRoom` | Cursors, typing indicators, viewports                       | `/ws/presence/:scopeId` |
| `CronRoom`     | Scheduled task scheduler + history                          | `/ws/cron/:roomId`      |
| `JobRoom`      | Durable background jobs (AI generations, exports, fan-outs) | `/ws/jobs/:roomId`      |

You can add your own Durable Object classes or subclass these with custom behavior.

A DO instance is identified by its name. Same name = same instance with the same state; a different name is a different instance with its own.

## Scopes

A scope is a namespaced identifier that determines which DO instance you're talking to.

| Scope                | What it represents                                       | Hosted on   |
| -------------------- | -------------------------------------------------------- | ----------- |
| `app:<APP_ID>`       | Your app's main RecordRoom                               | Your worker |
| `chat:<channelId>`   | An optional isolated room for one app-owned chat channel | Your worker |
| Any app-defined name | Another isolated room owned and authorized by your app   | Your worker |

Scope ids key to the immutable `DEEPSPACE_APP_ID`, never the app name - names are mutable URL leases, ids are immutable, so a rename never strands your data. The scaffold exports `` SCOPE_ID = `app:${APP_ID}` `` from `src/constants.ts` and the worker keys its DOs the same way (`app:${env.DEEPSPACE_APP_ID}`). Your `RecordScope` provider mounts this scope; `useQuery` / `useMutations` operate against it.

There are no user-scoped DOs, by design. Don't mint a `user:<id>` room per user - user-scoped data lives in app DOs as collections with RBAC filtering (`read: 'own'` gives each user their own slice of one room).

The historical platform-owned `workspace:*`, `dir:*`, and `conv:*` RecordRoom namespaces have been removed. If two apps need to share data, expose an authenticated API from the app that owns it instead of placing both apps inside a global database.

## Talking to platform workers

The SDK exposes three fetch helpers in `deepspace/worker` for addressing platform services. `apiWorkerFetch` and `platformWorkerFetch` prefer a Cloudflare service binding when one is configured and fall back to an HTTPS URL otherwise, so the same code path works in production and under `deepspace dev start`. `authWorkerFetch` is URL-only by design.

```ts
import { authWorkerFetch, apiWorkerFetch, platformWorkerFetch } from 'deepspace/worker'
```

| Helper                | Signature                                          | Use it for                                                                                                             |
| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `authWorkerFetch`     | `(env, path, init?) => Promise<Response>`          | Sign-in flows, JWT issuance, session cookies. URL-only by design - there is no auth-worker service binding.            |
| `apiWorkerFetch`      | `(env, path, init?) => Promise<Response>`          | Integration proxy, billing, subscriptions, charges.                                                                    |
| `platformWorkerFetch` | `(env, pathOrRequest, init?) => Promise<Response>` | App files and platform HTTP services. Accepts a `Request` when a route needs to forward the original request verbatim. |

Calling `c.env.PLATFORM_WORKER.fetch(...)` directly works in production but breaks under `deepspace dev start`, where the binding is absent and the CLI writes a `PLATFORM_WORKER_URL` fallback into `.dev.vars` for the helpers to pick up.

## Security model - WebSocket identity

Durable Objects trust whatever identity the request URL carries. Verifying that identity is the worker's job - it strips anything the client sent, then re-applies it from a verified JWT. The SDK does this at two entry points:

**Per-app WebSocket route (`wsRoute`)** - the scaffold ships a local `wsRoute` helper in `src/server/realtime-routes.ts` (it is not an SDK export). It strips `userId`, `userName`, `userEmail`, `userImageUrl`, `role`, and `token` from the URL on every upgrade, then re-applies identity only from a verified JWT. Three states are possible:

* **No token** → anonymous (DO assigns `anon-<uuid>`)
* **Invalid token** → 401
* **Valid token** → identity = JWT `sub` / `name` / `email` / `image`

Anonymous connections are session-local guests, not accounts: the room assigns the `anon-<uuid>` id and a non-identifying label, omits email, image, and chosen role, and accepts only that connection's ephemeral presence state. Never persist an `anon-<uuid>` identity or promote it into an account.

**Never put identity in WebSocket URLs or `/api/*` headers.** The scaffold's `wsRoute` helper strips them; identity always comes from the JWT subject. There is no client-side override.

**`/ws/yjs/:docId` is special.** It is the only `/ws/*` route that requires a verified JWT (401 without one) and resolves a per-doc role (`admin` / `member` / `viewer`) from the `documents` collection's `ownerId`, `editors`, and `collaborators` fields - 403 when the caller has none. Do not collapse it into a bare `wsRoute` call. See [YjsRoom authentication and roles](/guides/collaborative-editing#yjsroom-authentication-and-roles).

## Build & deploy pipeline

`npx deepspace deploy` performs these steps in order:

Build with Vite

`npx vite build` runs the Cloudflare Workers Vite plugin, producing the client assets and the worker bundle in a single pass, plus a normalized `wrangler.json` under `.wrangler/deploy/`.

Extract manifests

The CLI reads the DO bindings and custom bindings (R2, KV, D1, Vectorize, AI, …) out of the build output; user secrets come from the app's remote secrets store - deploy never reads `.dev.vars` (see [Deployment](/concepts/deployment)).

Validate the binding manifest

`validateBindingManifest` checks custom bindings against allowed types and reserved names. Reserved or duplicate names abort the deploy with a file-pointing error.

Upload to the deploy worker

Worker bundle, assets, DO manifest, custom bindings, and user secrets are POSTed as a single FormData to the deploy worker.

Auto-provision resources

Server-side, the deploy worker creates any binding declared with `id = "auto"` (or `bucket_name = "auto"`, etc.) on the platform Cloudflare account on first deploy.

Register subdomain and dispatch route

The worker is loaded into the dispatch namespace under `<name>.app.space`; user secrets become `secret_text` bindings on the deployed worker. The dispatch worker routes incoming traffic to your worker's isolate.

Sync subscription plans and products (optional)

If `src/subscriptions.ts` or `src/products.ts` exist, the CLI bundles them with esbuild and posts the declarations to the API worker. Skipped silently when the files are absent.

## Next steps

* [Data model](/concepts/data-model) - collections, records, and how data is shaped.
* [Permissions](/concepts/permissions) - role-based access control on collections.
* [Real-time sync](/concepts/realtime-sync) - how WebSocket sync works under the hood.
* [Deployment](/concepts/deployment) - what happens when you run `deploy`.
