> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stardeck.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Constraints

> What the Graviton runtime refuses, which files the platform owns, and the operations that are configured outside your repository.

Everything here is a rule the platform or the runtime actually enforces — a build
that fails, a lint error, a call that throws, or a file that gets overwritten.
None of it is style advice.

## The runtime is a Cloudflare Worker

Your app is built with [OpenNext](https://opennext.js.org/cloudflare) and deployed
as a single Worker. There is no Node.js server process.

<Warning>
  **No Node.js runtime.** Anything that needs a real Node process is unavailable: the filesystem,
  native modules, child processes, long-lived background threads, and libraries that assume any of
  those. `nodejs_compat` covers common Node *APIs*, not a Node *server*.
</Warning>

What that rules out:

* **Work finishes inside the request.** There is no process that outlives a
  response. Anything periodic belongs in `scheduling-sdk`; anything slow belongs
  behind a job the platform triggers, not a `setInterval`.
* **Local files are not storage.** Writing to disk is not persistence — use a
  storage-type Data Store.
* **Keep `middleware.ts` named `middleware.ts`.** Next.js 16 deprecates it in
  favour of `proxy.ts` and prints a warning. Do not rename it. Proxy always runs
  on the Node.js runtime, which the Cloudflare adapter does not support, so the
  rename produces a build that cannot deploy. The warning is expected.

### Bundle size is a hard ceiling

Cloudflare rejects a Worker whose compressed script exceeds **10 MiB**. Stardeck
warns at **8 MiB**, because the failure mode is gradual — every route and every
dependency adds to the same bundle.

<Note>
  This is the constraint most likely to bite you from a single `npm install`. One transitive
  dependency — a 3.4 MB WebAssembly blob pulled in by an instrumentation package — pushed the base
  template past the limit and had to be stubbed out in `next.config.ts`. Before adding a heavy
  dependency, check whether it is Worker-compatible and what it weighs.
</Note>

Use `npm run build:check` in `apps/web` for a build that does not disturb your dev
server.

### Rendered output is not cached between requests

The app ships without an incremental cache configured. `open-next.config.ts` is
`defineCloudflareConfig({})`, and with no cache passed, OpenNext resolves
`incrementalCache`, `tagCache` and `queue` to its `dummy` overrides — every read
and write throws `"Dummy" cache does not cache anything` and is ignored. So treat
server rendering as per-request: `revalidate`, `revalidateTag` and the Next data
cache persist nothing, and background revalidation does not run.

If a page is expensive, cache deliberately — `Cache-Control` on a route response,
or a cached value in a Data Store — rather than assuming ISR is doing it for you.

## Data access

Data lives in **Data Stores**: Postgres databases owned by your organization,
connected to the app by the platform. Your app reads and writes them through
`data-store-sdk`, which hands you a [Kysely](https://kysely.dev) instance backed by
the Neon HTTP driver.

<Warning>
  **No interactive transactions.** The HTTP driver cannot hold a transaction open across round
  trips. `db.transaction()` and `db.startTransaction()` throw at runtime, and the Stardeck ESLint
  plugin fails the build before you get there.
</Warning>

```ts theme={null}
// Rejected by lint, throws at runtime
await db.transaction().execute(async (trx) => {
  await trx.insertInto("orders").values(order).execute();
  await trx.updateTable("inventory").set({ count }).execute();
});

// Fold the work into one statement
await db
  .with("new_order", (qb) => qb.insertInto("orders").values(order).returning("id"))
  .updateTable("inventory")
  .set({ count })
  .execute();

// Or run the statements in sequence and make the second one idempotent
await db.insertInto("orders").values(order).execute();
await db.updateTable("inventory").set({ count }).execute();
```

Design for this rather than around it: prefer a single statement, make repeated
writes idempotent, and reconcile instead of assuming atomicity.

### Schema changes are not app code

Creating tables and columns, changing types, and seeding are **platform
operations**, not migrations you commit. They are applied to the same Data Store
branch your app reads, and they are versioned by the platform. Do them from the
dashboard's Data Stores editor, or from your editor with
[Claude Code connected](/local-claude-code/tools#data-store).

<Warning>
  **Legacy: `apps/web/migrations/` and `npm run db:migrate`.** Older apps have a per-app database
  reached through `DATABASE_URL` with Kysely migration files. It still runs, and apps already using
  it keep working — but **if your app is not already using it, do not start.** New data belongs in a
  Data Store.
</Warning>

## Files the platform owns

Some files in the repository are generated or maintained by the platform. Edits to
them are overwritten on the next upgrade, refused by the editing tools, or both.

| Path                                                                                  | Owner               | Why                                                                                                                            |
| ------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `apps/web/next.config.ts`                                                             | upgrade rail        | Patched by platform upgrades — see [changing it](#changing-next-config) below                                                  |
| `apps/web/open-next.config.ts`                                                        | platform            | Deployment adapter configuration                                                                                               |
| `apps/web/src/app/api/*/[[...path]]/route.ts`                                         | SDKs                | Passthrough routes for auth, payments, storage, analytics                                                                      |
| `apps/web/src/*.gen.ts`                                                               | Module composition  | `modules.gen.ts`, `module-contributions.gen.ts`, `module-i18n.gen.ts`, `module-init.server.gen.ts`, `module-datastores.gen.ts` |
| Generated `page.tsx` / `route.ts` carrying a `@stardeck-module-rail-generated` marker | Module install rail | Change the Module's source instead                                                                                             |
| `AGENTS.md` (and its `CLAUDE.md` symlink)                                             | platform            | Regenerated on every upgrade; put project notes in a separate file                                                             |

Everything else under `apps/web/src` is yours.

<a id="changing-next-config" />

### Changing `next.config.ts`

The upgrade rail edits this file, so local changes can be reverted by a later
platform version. The common case is adding an image host to
`images.remotePatterns`.

**Ask Stardeck to add it** rather than editing the file yourself — the request is
quick and the result survives upgrades. A supported per-app override for
`next.config.ts` is something we are tracking; until it exists, an edit here is a
change you may have to make twice.

## Deploying

`apps/web` has `preview` and `deploy` scripts, but they are the commands **the
platform runs** — they need Stardeck's Cloudflare credentials, so running
`npm run deploy` from your machine fails. Shipping works like this:

```mermaid theme={null}
flowchart LR
    L["Your commit"] --> M["push to main"] --> S["Sandbox build<br/>(dashboard preview)"]
    M --> P["Publish from the dashboard"] --> W["Production Worker"]
```

* **Push to `main` to be seen.** The hosted sandbox builds from the platform's
  `main` branch, not from your working tree.
* **Publishing to production is a deliberate action** in the dashboard. See
  [Publishing and deployment](/publishing-deployment).
* **Production and sandbox are different data.** See [Environments](/environments).

## Authentication and permissions

Auth is `project-auth`, and the visibility boundary is enforced one hop before
your code. The two things that trip people up:

<Warning>
  **A permission gate cannot be verified in the sandbox.** When no real session exists,
  `getSession()` returns a mock user with `role: "admin"` but **no permissions at all**, so
  `await requireAuth("<key>")` redirects to `/unauthorized` and `hasPermission()` returns false
  however the gate is written. A gate that denies in preview proves nothing about whether it is
  correct. Mint a real persona session with the e2e helpers in
  `@stardeck-customer-apps/project-auth/e2e` instead.
</Warning>

* **Declaring a permission key does not grant it.** A team member holds an app
  permission only through what their organization role was granted for that app.
  Until the grant exists, a correct gate denies everyone.
* **API routes are never gated at the edge.** A `layout.tsx` does not wrap route
  handlers. Gate `/api/*` inside each handler.
* **`access: "internal"` on a Surface is authentication, not authorization.** It
  admits every team member. Anything narrower gates itself by permission.

See [User authentication](/user-authentication) and
[Members and roles](/members-and-roles).

## Environment variables

Values come from the platform, not from a committed `.env`. Pull the sandbox set
with `npm run env:pull` (see [Run your app locally](/local-claude-code/running-locally)),
and configure them in the dashboard — [Environment variables](/environment-variables).

* **Client exposure is Next.js's rule:** only `NEXT_PUBLIC_`-prefixed variables
  reach the browser. (The dashboard also mirrors non-secret variables with a
  `VITE_` prefix for legacy Vite apps; a Next.js app ignores those.)
* **Secrets are runtime-only.** They are not available during the build.
* **Do not construct platform URLs by hand.** Use `process.env.BASE_URL` on the
  server or `process.env.NEXT_PUBLIC_BASE_URL` in the browser, plus a path.
* **Never hand-roll a deployment secret.** SDK calls sign themselves; if you find
  yourself building an `X-Stardeck-Auth` header, you are using the wrong entry
  point.

## Cross-surface links are paths, never hostnames

A [Surface](/app-structure/surfaces) gets its own hostname in production, but app
code must never write that hostname.

```tsx theme={null}
// Bad — breaks in dev sandboxes and branch previews
<Link href="https://acme-admin.example.com/admin/orders/123">

// Good — the platform canonicalizes it to the owning surface host
<Link href="/admin/orders/123">
```

A cross-surface link is a full navigation, not client-side routing: client state
does not survive it. Cross-surface form POSTs do not canonicalize — post to a
same-surface route or `/api/*`.

## Modules have one public surface

A [Module](/app-structure/modules) lives at `src/modules/<name>/` and exposes
`index.ts`. Deep imports into another Module's internals — including type imports
and dynamic `import()` — are a lint error, because install and update swap those
files underneath you.

```ts theme={null}
// Bad
import { list } from "@/modules/booking/server/reservations";

// Good
import { listReservations } from "@/modules/booking";
```

<Note>Modules and Blueprints are in Alpha and available to selected organizations.</Note>

## Nothing inbound reaches your laptop

Webhooks, scheduled jobs, cross-app calls and integration events are delivered by
the platform to a deployed URL. A local dev server has none, and this is a
deliberate decision rather than a gap: use the sandbox to exercise inbound
traffic. Details in [Run your app locally](/local-claude-code/running-locally).

## Operations that do not live in code

These are configured through the platform. Each has a place you do it by hand.

| Operation                                           | Where you do it                                                                                |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Create tables, add columns, change types, seed data | Dashboard → Data Stores, or [Claude Code tools](/local-claude-code/tools#data-store)           |
| Declare an app permission key                       | Agent tooling or [Claude Code tools](/local-claude-code/tools#app-roles-permissions)           |
| Grant a permission to an organization role          | Dashboard app settings, or [Claude Code tools](/local-claude-code/tools#app-roles-permissions) |
| Connect a Data Store to the app                     | Dashboard → Data Stores → [Connecting](/data-stores/connecting)                                |
| Install or update a Module (Alpha)                  | Dashboard → Modules, via the agent                                                             |
| Apply a Blueprint update (Alpha)                    | Dashboard → Modules / Blueprint, or [Claude Code tools](/local-claude-code/blueprint-updates)  |
| Set environment variables                           | Dashboard → [Environment variables](/environment-variables)                                    |
| Add a custom domain                                 | Dashboard → [Custom domains](/custom-domains)                                                  |
| Publish to production                               | Dashboard → [Publishing and deployment](/publishing-deployment)                                |
| Capture or refresh Guide screenshots                | Agent tooling in the app's repository                                                          |

<Note>
  If you need one of these and have repository access but no Stardeck account with the right
  organization role, you are blocked on a grant, not on code. Ask an organization admin.
</Note>
