This is the full developer documentation for Suite # Suite documentation > Suite is a local-first, collaborative canvas. A workspace owns its cards; boards are views of those cards. The document is a Loro CRDT, every write is… Suite is a local-first, collaborative canvas. A workspace owns its cards; boards are views of those cards. The document is a Loro CRDT, every write is validated in `@suite/graph`, and the canvas works offline. Current line: **Beta, 1.0.0**. The release is real; the Beta exit gates are not all closed. The gate status is tracked in the internal roadmap. ## Four ways in | | | | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | [Tutorials](./tutorials/index.md) Learn by doing. One guided path to a first success. | [How-to](./how-to/index.md) One task at a time: test, deploy, operate, release, contribute. | | [Reference](./reference/index.md) Facts to look up: the model, commands, limits, security boundary. | [Explanation](./explanation/index.md) Why the product and the code are shaped this way. | Start with [Run Suite locally](./tutorials/run-locally.md) if this is your first checkout. Each section index lists its pages newest first. The docs site adds full-text search, a tabbed dashboard, and LLM endpoints: ```sh pnpm docs:site # http://localhost:3001 pnpm docs:site:build # production build ``` `/llms.txt` is the summary an agent reads first, `/llms-full.txt` is every page, and any page is available as Markdown by dropping the trailing slash from its URL and appending `.md`. ## Outside this tree `internal/` holds the decision records, the programme documents, and the design reference. It is local-only, not part of this tree, and not published. The root [README](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/README.md), [AGENTS](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/AGENTS.md), [CONTRIBUTING](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/CONTRIBUTING.md), and [SECURITY](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/SECURITY.md) cover setup, agent rules, contribution, and vulnerability reporting. # Explanation > Why the product and the code are shaped this way, including the decisions that produced them. Why the product and the code are shaped this way, including the decisions that produced them. ## Direction | Article | What it covers | Updated | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | | [Design direction](./design.md) | Suite is a local-first canvas over a workspace of typed cards. One Loro document holds the whole workspace, the browser edits it offline, and a relay… | 2026-10-27 | # Design direction > Suite is a local-first canvas over a workspace of typed cards. One Loro document holds the whole workspace, the browser edits it offline, and a relay syncs it… Suite is a local-first canvas over a workspace of typed cards. One Loro document holds the whole workspace, the browser edits it offline, and a relay syncs it when one is reachable. A card exists on its own, and a board is a context that shows some of those cards at chosen positions. Two rules decide most questions here. The document is the only source of truth, and anything that can be recomputed from it is not stored. ## The document `WorkspaceDocument` in `packages/graph/src/loro/` wraps a single `LoroDoc`. Inside it, `cards`, `boards`, `placements`, `sections`, `types`, `tags`, `edges`, `views` and the per-card `titles` and `content` containers are Loro maps, keyed by id. Loro merges concurrent edits, and every write goes through a Zod-validated method on that wrapper, so no component touches raw CRDT calls. Field granularity is what makes that merge useful. A property write sets one key in `card.properties`, a section move writes `x` and `y`, a tag add writes one key. A whole-record write would turn every unrelated concurrent edit into a last-write-wins conflict that nobody sees happen. Stored facts are the ones a reader authored: identity, types, property values, tags, body text, placement geometry, board and section names, style rules. Everything else is derived at read time. Home board, section membership, display titles, reference counts, view results and share projections are computed from those facts, and a stored copy would be a cache with an invalidation problem (ADR-0012, ADR-0019). Presence is separate from the document. Pointer, drag, marquee and connector movement travel over Loro `EphemeralStore` channels with a time to live, so a dropped connection cannot leave a ghost drag. A position is persisted on release, which is the moment the reader has decided (ADR-0005, ADR-0009). ## Projections A projection is a read: a function from document state to something a surface draws, with no write back. The board face is a projection of placements through the placement and section code. The outline rail is a projection of section containment. `referencesFor` is a projection of mention nodes and card-valued fields. A share link serves a projection built from an allowlist, so the index of every title stays private (ADR-0022, ADR-0023). A projection may be cached only if it can be thrown away and rebuilt from the document. The reference index qualifies because it is recomputed from content. View results do not qualify and are not stored. A projection that changes something is an explicit command with a name and a report of what it touched. Promoting a selection into a view, applying a section as tags, publishing a card: each is idempotent and reports its changes. Nothing runs a loop that rewrites cards because layout moved. Splitting the workspace into one index document plus a document per card would buy isolated loading and per-card access control, and it would cost cross-document atomicity. Loro has no multi-document transaction, so creation order, trash retention and purge tombstones become a convergent sequence, and undo splits by scope. The split waits for a benchmark that asks for it and stays behind the `WorkspaceDocument` seam until then (ADR-0021). ## Nested boards A portal is a card whose primary type is the portal type and whose board property names another board. It is a card like any other: it is placed, tagged, searched and trashed by the same code, and the one thing it adds is a second board. On the board that holds it, a portal draws a live outline of the board it names, built from that board’s placements and links. When the named board has a cover picture, the picture is drawn in place of the outline. Opening a portal is a camera gesture: zoom into the card until it covers most of the canvas and its board replaces the one on screen. A board entered that way is left by zooming back out. The trail beside the tab strip lists the boards the reader passed through, and every step on it switches to that board with the viewport it had. The trail records history rather than ancestry: it is the order boards were left in, so a board reached from a tab and a board reached from a portal both appear on it, and the same board may appear more than once. One board can be named by portals on several boards, and by more than one portal on the same board, so the shape is a directed acyclic graph rather than a tree. No board has a parent. Deleting a board does not delete the portals that name it: each portal’s board property is cleared in the same commit, and the card stays on its board as a card that names no board. ## What is settled | Question | Decision | Record | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | Where truth lives | One Loro document per workspace, observed and mutated by clients; the relay persists and authenticates | ADR-0015, ADR-0020 | | What a board is | A named context showing placements of cards it does not own; removing from a board is not deleting | ADR-0010, ADR-0040 | | How a card is classified | One primary type plus supertags, with the other types unioning their fields | ADR-0053 | | How cards relate | References derived from `@` mentions and card-valued fields; edges carry no data | ADR-0019, ADR-0062 | | How a section is defined | A named region whose membership is the smallest containing header anchor | ADR-0017 | | How sharing works | A live projection room over a redacted, self-contained copy, scoped to a board or a selection | ADR-0022, ADR-0023 | | What owns the shell’s colour | Style rules stored in the document and resolved through the contrast floor; the shell never holds a palette | ADR-0046, ADR-0071 | | How many inspectors | One panel driven by the selection, opened from the canvas or a library row | ADR-0050 | | What a template is | A creation recipe duplicated on use; a type blueprint is itself a card | ADR-0038, ADR-0059 | | What a tutorial is | A card whose body holds tour steps, presentable on the canvas | ADR-0054 | | Which field kinds compose | `rating`, `group` and `list` compose from the built-in kinds | ADR-0057 | | What a workspace with no session may do | Local-only: it edits and exports with no account and never joins a relay, because sync needs an owner to attribute and to charge | ADR-0076 | ## What is not settled | Question | Where it stands | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Document split | Waiting on a benchmark: one index document plus per-card documents, with the crash and undo costs above | | External share semantics | Revocation, transitive visibility and whether a viewer sees the titles of unshared neighbours | | Search | An in-memory index over summaries first; body search would load card documents, because a central index conflicts with per-card access and offline use | | Card document retention | Trash retains content and purge tombstones first, while an offline client may still resurrect a purged record | | Publishing | A share draft publishes onto the workspace through a validated plan (`packages/graph/src/publish/`). Serving a workspace as a site, with routes, slugs and navigation, is not built | ## Judging a proposal Four questions settle most of them. * Which word in the [glossary](../reference/glossary.md) does this change, and does it stay inside that word’s scope? * Is the new value stored, or can it be recomputed from what is already stored? * What happens when two clients write at once: does the write land on a leaf field, or replace a record? * Does it survive offline use, or does it need a round trip before the reader sees the result? A proposal that cannot answer the second and third is not a design yet. The layer map, the data flow and the record shapes are in [reference/architecture.md](../reference/architecture.md); the editing surfaces are in [reference/editing.md](../reference/editing.md) and [reference/ui.md](../reference/ui.md). # How-to > One task at a time: the steps for a job you already know you need to do. One task at a time: the steps for a job you already know you need to do. | Article | What it covers | Updated | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [Observe a deployment](./observe.md) | Where to look when something is wrong, and what each surface can and cannot tell you. Written for two people working on one box: the owner and whoever… | 2026-10-28 | | [Try the paid flow on this machine](./try-the-paid-flow.md) | The whole product runs in five containers on one machine: the relay, the control plane, Postgres, Caddy, and the docs site. This page walks the parts… | 2026-10-28 | | [Deploy a server](./deploy.md) | One small instance, one Docker host, one app container, six SQLite stores on one volume, Caddy in front, Cloudflare in front of Caddy. | 2026-10-28 | | [Operate a deployment](./operate.md) | Suite runs as a single Node process with six SQLite stores on one directory; there is no external database and no multi-replica mode. | 2026-10-28 | | [Test and verify a change](./test-and-verify.md) | Suite runs three test runners: Vitest for the workspace projects, Playwright for end-to-end journeys, and node –test for @suite/billing, which sits o… | 2026-10-28 | | [Change the canvas](./change-the-canvas.md) | Adding to the canvas means answering three questions before writing code: which layer owns the input, what the document records, and what peers see wh… | 2026-10-28 | | [Import an Obsidian vault](./import-an-obsidian-vault.md) | Importing a vault folder reads every note, tag, link and canvas into one workspace. The import runs once, as a single change, so undo takes it back wh… | 2026-10-28 | | [Release and roll back](./release.md) | A release is a tag, an image built from it on the host that serves it, the image id that identifies it, a smoke test against a host, and a rollback pa… | 2026-10-28 | | [Add a feature](./add-a-feature.md) | The data flow fixes the order: the document package owns the shape, the workspace document owns the write, the snapshot store owns the read, and the c… | 2026-10-26 | | [Change the editor](./change-the-editor.md) | Add a node, a mark, a field renderer, a slash command or a mention action to the card editor, with the files each one touches and the tests that fail… | 2026-10-26 | | [Contribute a change](./contribute.md) | One change, from branch to merge. The coding standards are in reference/conventions.md, the repo map in AGENTS.md, and the local gate in test-and-veri… | 2026-10-26 | | [Change the data model](./change-the-data-model.md) | A record change travels one path: schema, reader, writer, facade, then the tests that prove two clients still converge. Skipping the reader step is ho… | 2026-10-26 | # Add a feature > The data flow fixes the order: the document package owns the shape, the workspace document owns the write, the snapshot store owns the read, and the component… The data flow fixes the order: the document package owns the shape, the workspace document owns the write, the snapshot store owns the read, and the component renders. Skip a step and the feature works until two clients disagree. The example throughout is a `dueAt` date on a card. The same steps cover a new field kind, an edge attribute or a board setting. ## 1. Model it in `@suite/graph` Schema first. Everything persisted is a Zod schema in `packages/graph/src/schema.ts`, and the TypeScript type is derived from it rather than written twice. * Add or extend the schema (`cardSchema`, `edgeSchema`, `boardSchema`, …) and export the inferred type beside it. * A new constrained value is a `z.enum` built from an `as const` tuple, so the UI can iterate it. * Shape changes are additive and optional, so an old document still parses. Bumping a stored format is an explicit decision, not a side effect of adding a field. * Update `packages/graph/src/schema.test.ts` with the parse and reject cases. The loop is `pnpm exec vitest run packages/graph/src/schema.test.ts`. ## 2. Put the write on `WorkspaceDocument` Graph writes go through the typed wrapper in `packages/graph/src/loro/`. The method lives in the module that owns the record (`cards/`, `placements.ts`, `edges.ts`, `sections.ts`, `boards.ts`) and is re-exported from `WorkspaceDocument.ts`. * Prefer a field write over a whole-record write: `mutateCard`-style methods keep concurrent edits to other fields from resolving last-write-wins. Do not add a `setCard(wholeRecord)` path for one field. * Validate with the Zod schema at the boundary. Every `set*` method does, and a new one must too. * Keep reads side-effect free: `peekContent`/`peekTitle` for fragments, and only creation inserts. * Commit once per gesture, so `Cmd-Z` undoes the whole drop rather than N single-record steps. * Add the method’s test beside the module (`packages/graph/src/loro/cards/*.test.ts`). If the write can conflict with another client, add a seeded case to `merge.property.test.ts`. ## 3. Expose it to the UI Read through the snapshot. The store holds one subscription and hands components plain objects. Read a collection with `useWorkspaceSnapshot()` from `apps/canvas/src/store/workspaceStore.ts`, and a single record with `useCard(cardId)` or `useCards(ids)`. `workspaceSnapshotStore.ts` projects the document into the snapshot; widen it there when the new value must reach a component, and keep selectors atomic (`useCanvasStore((s) => s.graph)`). Never call `WorkspaceDocument.observe` from a component, and never copy document data into `useState` or Zustand. Zustand holds selection, active card and menus only. UI state the feature adds (an open panel, a pending gesture) belongs in `useUiStore.ts`. ## 4. Build the surface Components live in the folder that owns the surface, under `apps/canvas/src/`: | Folder | Surface | | ---------------------- | ---------------------------------------------------------------------------- | | `canvas/` | The board, cards on it, sections, connectors, placement and the outline rail | | `cards/` | The card face, the panel, the body and title editors, field controls | | `editor/` | The Tiptap nodes, marks, suggestion menus and collaboration plugin | | `field/` | Field kind registration and the composing kinds | | `library/` | The drawer, its filters, rows and drag-out | | `chrome/` | Title bar, board tabs, views bar, dock | | `panels/` | Container panel, link inspector, type settings | | `dialogs/` | Modal shell, context menu, confirmations | | `command/` | The command registry, palette and ranking | | `styles/`, `theme/` | Style rules, the appearance dialog, theme tokens and resolution | | `presence/`, `cursor/` | Peer cursors, name tags, avatars, the status cursor | One component per file, named export, `PascalCase.tsx`. The two reference pages are the map for this work: [reference/editing.md](../reference/editing.md) for text, fields, chips and undo, and [reference/ui.md](../reference/ui.md) for the shell, focus and theme rules. Use the library primitive before writing plumbing: Tiptap `BubbleMenu`, `useEditorState` and `useNodesState`/`useEdgesState` on the board, Zustand selectors. A card may be mounted on several surfaces, so consult `editingSurface` in the UI store rather than starting a second editor. A reveal goes through `focusTarget` (`focusCard`, `focusSection`); a panel never calls `setCenter` or `fitBounds`. A modal panel gets `useFocusTrap` from `apps/canvas/src/dialogs/useFocusTrap.ts`, a menu gets `menuKeyDown` from `apps/canvas/src/dialogs/menuKeys.ts`, and Escape is registered with `useEscapeLayer` rather than a listener of its own. Format values through `displayTemplate(field)` and `ValueTemplate` instead of formatting by hand; that rule and its history are in [reference/editing.md](../reference/editing.md). ## 5. Story and test Tests sit beside the thing they test: `apps/canvas/src/cards/NodeCard.test.tsx` covers the card face, `apps/canvas/src/field/compositeKinds.test.tsx` covers the composing field kinds, and `packages/graph/src/schema.test.ts` covers the schema. * A story (`*.stories.tsx`) when the surface is visual, with the states a reviewer would ask about: default, empty, long content, error. Stories are what the visual baselines render. * A React Testing Library test (`*.test.tsx`) for behaviour that is not trivially presentational. A new node, mark or field renderer also has to keep `apps/canvas/src/editor/editorSchemaContract.test.ts` passing; the procedure is in [change-the-editor.md](./change-the-editor.md). * A Playwright spec under the matching `e2e/tests//` folder when the behaviour only exists across the server and the browser. The areas today are `account`, `appearance`, `boards`, `boot`, `cards`, `collab`, `input`, `library`, `links`, `selection`, `sharing`, `views` and `visual`. Tag the happy path `@smoke` if that area already has a smoke test, since `pnpm e2e:smoke` runs exactly those. ## 6. Touch the server only if the feature reaches it The collab server does not know about card fields; it stores documents. A feature reaches it when it adds a route, a stored format, a quota or a metric, and each has one home: | Concern | Home under `apps/collab-server/src` | | ---------------------------------------- | ----------------------------------- | | Route, request parsing, CORS, rate limit | `http/` | | Persisted file, migration, store schema | `store/` | | Share projection, room, sweep | `projection/` | | Socket, seat gate, room save | `sync/` | | Env parsing, limits resolution | `config/` | | Admin API, metrics, log line | `ops/` | The account control plane has the same shape in `services/auth/src/` (`http/`, `identity/`, `invitations/`, `licensing/`, `organizations/`, `store/`), and billing in `services/billing/src/` (`handler/`, `stripe/`, `store/`, `ops/`). A new quota is a constant in `config/limits.ts` plus a regenerated [reference/limits.md](../reference/limits.md), never a hand-edited number. A new stored format needs an N−1 fixture under `apps/collab-server/src/fixtures/n-1/` and a test that opens it. ## 7. Verify ```sh pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check && pnpm docs:symbols && pnpm shape pnpm check # biome, tsc, vitest run --changed, docs symbols, shape pnpm exec vitest run apps/canvas/src/cards # one area, while iterating ``` That is the whole per-change gate. `pnpm verify` runs it plus the billing tests, the story suite and the build. Playwright runs when the owner asks for a run, and [test-and-verify.md](./test-and-verify.md) has the area scripts. Then update the docs that describe the new fact. A user-visible capability is worth a line in the internal roadmap when it closes a gate, and a decision that changes ownership gets an ADR. The command list is in [test-and-verify.md](./test-and-verify.md); the standards are in [reference/conventions.md](../reference/conventions.md). # Change the canvas > Adding to the canvas means answering three questions before writing code: which layer owns the input, what the document records, and what peers see while the… Adding to the canvas means answering three questions before writing code: which layer owns the input, what the document records, and what peers see while the gesture is in flight. The three procedures below follow that order. The module map is in [canvas.md](../reference/canvas.md). The document rules are in [architecture.md](../reference/architecture.md). ## Add a gesture Decide who owns the pointer or key first. A gesture that shares a surface with another one needs an owner decided at press time, or the two will both act on the same event. 1. Pick the owner. A press on a card, the pane, the marquee, or the wheel belongs to React Flow, so the handler goes on the `` element in `apps/canvas/src/canvas/board/BoardSurface.tsx` (`onNodeDragStart`, `onNodeDragStop`, `onMoveEnd`, `onEdgeDoubleClick`). A press on a section goes in `apps/canvas/src/canvas/sections/SectionLayer.tsx`, because a section is not a node. A press on a connector dot goes through `apps/canvas/src/canvas/NodeConnectors.tsx`. A key goes in `apps/canvas/src/canvas/board/useBoardKeyboard.ts` as a named action, not as another branch in the dispatcher. 2. Read the capability first. Layout writes check `canEditLayout`, content writes check `canEditContent`, and creation checks `canCreateContent`; the flags come from the `ui` bundle (`board/useBoardUi.ts`). Returning early on a missing flag is the whole check, because the same flags gate the panel and library paths. 3. Keep the live value out of the document. While the gesture runs, hold the position, size or path fraction in the hook’s state or a ref. `board/useBoardRefs.ts` is where shared per-gesture refs live. Write through `@suite/graph` once, on release, in the command that owns the record (`board/useBoardCommands.ts`, `board/useBoardSections.ts`, `board/useBoardLinks.ts`). 4. Publish it if a peer has to see it. An in-flight gesture is a presence field, not document data; see the presence procedure below. Publish in flow coordinates, so a peer at a different zoom draws the same thing. 5. Add an Escape layer if the gesture can be abandoned. `useEscapeLayer` from `apps/canvas/src/shell/escapeLayers.ts` registers the cancel and orders it by when it started; an entry pushed last takes the key first. Tests: * Behaviour with React: `apps/canvas/src/canvas/CanvasBoard.test.tsx` mounts the board and fires pointer and key events. Add one case for the gesture and one for the capability it needs. * Geometry or a plan: a pure module beside the code, such as `canvas/arrangeCommand.test.ts` or `canvas/localEasing.test.ts`, with `pnpm exec vitest run `. * The real pointer path: `e2e/tests/input/` for modifier and purpose gestures, `e2e/tests/boards/` for board and section gestures. `e2e/tests/fixtures/marquee.ts` has the drag helpers. ## Add a node surface A node surface is a card face with its own chrome. It is still a card, still placed by a placement, and still edited by Tiptap for its text. 1. Model the marker in `packages/graph`. The existing surfaces use a card type or a property, which is how `whiteboard` and `board` portals are recognised; a new record class needs a schema change and a decision record first. 2. Add the predicate beside the others in `apps/canvas/src/cards/`. `cards/whiteboard.ts` exports `WHITEBOARD_TYPE_ID` and `isWhiteboardCard`; `cards/portal.ts` exports `portalTargetId`. 3. Write the component in `apps/canvas/src/canvas/`, next to `NoteNode.tsx`, `PortalNode.tsx` and `WhiteboardNode.tsx`. It receives `CardNodeData` from `canvas/nodeData.ts`: the card, its placement, its type and the derived `meta`. 4. Register the node type. `nodeTypes` is defined in `canvas/NoteNode.tsx` and extended in `board/BoardSurface.tsx`, which is also where the placement chooses it: the `type` field in `board/useBoardNodes.ts` maps a card to `whiteboard`, `portal` or `note`. 5. Decide how it paints at distance. `canvas/silhouette.ts` sets the outline and `canvas/surfacePaint.ts` resolves the paint roles; a face that ignores the zoom band (`canvas/zoomRange.ts`) will look wrong at 0.1 scale. 6. Add a story and a test. A visual surface gets a `*.stories.tsx` beside the component, and behaviour that is not pure paint gets a `*.test.tsx` such as `PortalCard.test.tsx`. `NodeCard` opens a card for editing on double click. Do not move that to a single click: the canvas interaction tests and the library press both depend on the double-click path. ## Add a presence channel Presence is one schema with typed fields, not one channel per feature. Adding a channel means adding a field to that schema in five places. 1. Declare the payload in `apps/canvas/src/collab/presence.ts`: an exported interface in flow units, one entry in `AWARENESS_FIELDS`, and a field on `PeerPresence`. Document what it means and which board it belongs to. 2. Project it in `apps/canvas/src/collab/peerProjection.ts`: read the field in `toPeerPresence`. A gesture that must expire goes in `GESTURE_FIELDS`, which drives the TTL check, the signature and the clear-out; a field left out of that list survives the sender’s disconnect as a ghost. 3. Publish it from `apps/canvas/src/collab/usePresenceStore.ts`. A gesture that can be abandoned uses `publishGesture`, which starts the keep-alive beat; standing state such as a purpose uses `publish`, which does not. 4. Sanitize anything a peer sends that another client will index or loop over. `sanitizeDraggedNodeIds` caps a dragged set at `MAX_DRAG_NODE_IDS` and drops malformed entries; a payload that names records needs the same treatment. 5. Draw it in `apps/canvas/src/canvas/presence/`. Peer pointers, selections and marquees are in `PresenceLayer.tsx`; previews of gestures that would otherwise hide a real edge are in `PeerReconnectLayer.tsx`. Read peers through `hooks/useBoardPeers.ts` so a cursor from another board is filtered out, and draw inside a `ViewportPortal` so the layer shares the camera. Tests: `apps/canvas/src/collab/peerProjection.test.ts` covers projection, sanitizing and the TTL, and `apps/canvas/src/collab/usePresenceStore.test.ts` covers the publish path. Two clients in `e2e/tests/collab/collab.spec.ts` are the only place a real round trip is checked. ## Before you open the pull request ```sh pnpm exec vitest run apps/canvas/src/canvas/ pnpm --filter @suite/canvas typecheck pnpm biome check apps/canvas/src/canvas node scripts/check-shape.mjs ``` `check-shape.mjs` fails on a second `WorkspaceDocument.observe` call site. Text and record changes reach a surface through `store/workspaceSnapshotStore.ts`, which owns the one subscription and exposes the card change stream to consumers. # Change the data model > A record change travels one path: schema, reader, writer, facade, then the tests that prove two clients still converge. Skipping the reader step is how a field… A record change travels one path: schema, reader, writer, facade, then the tests that prove two clients still converge. Skipping the reader step is how a field appears on one client and vanishes on reload. [data-model.md](../reference/data-model.md) explains why each layer exists; this page is the order to edit them in. The example is a `accent` colour on a board, which is a leaf on an existing record. A new nested container, a new record kind and a change to a derived index each touch a different subset of the same files, and the subsets are listed at the end. ## Decide what kind of change it is | Change | Needs a schema bump | First file to edit | | ----------------------------------------- | -------------------------- | ----------------------------------------- | | A new optional leaf on an existing record | no | `packages/graph/src/schema.ts` | | A new nested map or set inside a record | no | `packages/graph/src/schema.ts` | | A new record kind under an existing root | no | `packages/graph/src/schema.ts` | | A new root | yes, and a facade constant | `packages/graph/src/loro/keys.ts` | | An existing field changes meaning or type | yes | a new decision in `docs/decisions/` first | `SCHEMA_VERSION` in `packages/graph/src/loro/constants.ts` is the version of the document shape, currently `4`. It moves when a document written by this build can no longer be read by the previous one, or when an old value means something new. Adding an optional field does not move it, because the previous build ignores a key it does not know. ## 1. Describe the record `packages/graph/src/schema.ts` is the only description of the shape, and the TypeScript type is inferred from it. ```ts export const boardSchema = z.object({ id: entityIdSchema, name: z.string().min(1), color: sectionColorSchema.optional(), // … accent: z.string().regex(/^#[0-9a-f]{6}$/u).optional(), }); ``` Keep the field optional unless every existing document can supply a value. `exactOptionalPropertyTypes` is on, so a `undefined` write is not the same as no key: the writer deletes the key instead. Run the schema tests while editing: ```sh pnpm exec vitest run packages/graph/src/schema.test.ts ``` Add a parse case and a reject case to `packages/graph/src/schema.test.ts`. A reject case that only checks that something throws is not enough; assert which field the error names. ## 2. Read it back `packages/graph/src/records/` holds the one reader per record kind. | Record | Reader | | ------------------------------------------------- | ----------------------------------- | | Card | `cardFromMap` in `records/cards.ts` | | Board, placement, section, edge, tag, type, field | `records/entities.ts` | | Surface style and theme values | `records/values.ts` | | Property values, including composite kinds | `records/propertyValues.ts` | Read through the existing helpers rather than reaching into the map: `readString`, `readNumber`, `readIdSet` and `readSurfaceStyle` in `records/values.ts` already handle an absent key. An older document must read as a complete record. When the field has a sensible default, apply it in the reader. When the default depends on other records, apply it in `normalizeCard`, which is the one place a card is turned into a complete shape. Test the reader beside the writer. When the case is “a document written before this field existed still opens”, build the old shape with raw Loro in the test, as `packages/graph/src/loro/boardSectionTags.test.ts` does for a board stored before tags existed. ## 3. Write it Add the method to the module that owns the record under `packages/graph/src/loro/`: `boards.ts`, `edges.ts`, `placements.ts`, `sections.ts`, `tags.ts`, `cardTypes.ts`, `views.ts`, or `cards/` for anything on a card. ```ts setBoardAccent(id: string, accent: string | null): void { const fields = ctx.boards.get(id); if (!isMap(fields)) return; if (accent === null) fields.delete("accent"); else fields.set("accent", accent); fields.set("updatedAt", Date.now()); ctx.commit(`${STRUCT_ORIGIN}board-accent`); } ``` Rules that the review will ask about: * Write the leaf. `mutateCard(cardId, (fields) => fields.set(...))` exists for one field on a card, and a new `setCard(wholeCard)` call for one field is rejected because it reverts a concurrent rename. * A set is a map. Use `writeIdSet` or `fields.ensureMergeableMap("tags")` so two peers adding different ids both keep theirs. * Reads do not create. If the new value is a container an editor mounts, add a `peek*` read that returns `null` and an ensuring write that creates it, instead of one read that does both. * One commit per gesture. `ctx.commit(origin)` once at the end, or `ctx.batch(origin, fn)` when the caller already groups a gesture. * Validate at the write with the schema, as every other `set*` method does. Test it in the module’s own test file. `packages/graph/src/loro/workspace.convergence.test.ts` and `packages/graph/src/merge.property.test.ts` show the two-document pattern: build the same document twice, apply the new write on one and a different write on the other, merge, and assert neither edit is lost. ## 4. Expose it on the facade `WorkspaceDocument.ts` delegates to the domain module. A card method goes on `CardsFacade` in `packages/graph/src/loro/facade/cards.ts`; everything else goes on the facade class. * A method that a canvas component calls needs a line here, spelled exactly as the domain module spells it. * A new domain module is composed in `packages/graph/src/loro/api.ts` and given a field on `DocApi`. * A type a caller outside the package needs is exported from `packages/graph/src/index.ts`. Then check the shape rules, which fail on a file over 900 lines, a second `WorkspaceDocument.observe` call site, or a raw Loro write outside the graph package: ```sh node scripts/check-shape.mjs ``` ## 5. Carry it through publish and projection A share editor publishes a plan, so a field that a draft can edit needs the whole path: | Step | File | | -------------------------------------- | ------------------------------------------------- | | Capture the value from the draft | `packages/graph/src/publish/snapshot.ts` | | Compare draft, baseline and source | `packages/graph/src/publish/planCards.ts` | | Reject a key the scope may not publish | `packages/graph/src/publish/planGuards.ts` | | Replay the captured value | `packages/graph/src/publish/apply.ts` | | Add a cap, if the field is bounded | `packages/graph/src/publish/validation/limits.ts` | `apply.ts` keeps one named applier per change kind, so a new kind is a new function and one line in the `APPLIERS` table. A field a share must show also travels through the projection: `packages/graph/src/projection/workspace.ts` for the selection, `packages/graph/src/projection/containers.ts` for text, and `packages/graph/src/projection/references.ts` for a reference that stays behind. A field left out of the projection is invisible to every viewer, and no error says so. ## 6. Repair documents already stored There is no migration script. A document is repaired by the normalising reader on open, or by an explicit repair method that is idempotent and safe to run on every client: * A leaf with a default: fix it in the reader. * A derived set: add it to `ensureReferences`-style repair, which compares and writes only what differs. * A renamed key: read both spellings in the reader, write the new one on the next edit. A test that builds the old shape with raw Loro is the proof, and a test file is the only place that may do it. `packages/graph/src/loro/boardSectionTags.test.ts` is the model: raw maps for the stored shape, then the public read. ## 7. Verify and document ```sh pnpm exec vitest run --project graph pnpm --filter @suite/graph typecheck pnpm biome check packages/graph/src node scripts/check-docs-symbols.mjs ``` Update the record table in [data-model.md](../reference/data-model.md) when the shape changes. A new cap is a constant in `config/limits.ts` plus a regenerated [limits.md](../reference/limits.md); never hand-edit the numbers, because `node scripts/generate-limits.mjs --check` fails on a stale page. ## Subsets for the other cases A new nested container, such as a per-card checklist: steps 1 to 4, plus a migration note in step 6, and no publish change until a draft may edit it. A new record kind under an existing root: steps 1 to 4. Add `list*` and `get*` on the domain module, because a record nobody can list is not reachable by the canvas. A new root: add the key to `STRUCT_KEYS`, add it to `WorkspaceDocument.ROOT_KEYS`, open it in the facade constructor, and check `apps/collab-server/src/store/` and `packages/graph/src/projection/` for anything that copies roots by name. A change to the reference index only: edit `packages/graph/src/loro/cards/references.ts`, keep the recompute `INDEX_ORIGIN`-tagged, and add a case to `packages/graph/src/references.test.ts`. # Change the editor > Add a node, a mark, a field renderer, a slash command or a mention action to the card editor, with the files each one touches and the tests that fail when it… Add a node, a mark, a field renderer, a slash command or a mention action to the card editor, with the files each one touches and the tests that fail when it is half done. The editor is Tiptap over ProseMirror, bound to Loro per container. Two rules shape every change below: the schema is declared once in `apps/canvas/src/editor/cardEditorExtensions.ts` so the publish allowlist can be checked against it, and the publish allowlist in `packages/graph/src/publish/validation/richTextSchema.ts` is the contract a shared body is validated against. ## Add a node 1. Create the node in `apps/canvas/src/editor/`. A node with a React view needs a `Node.create` with `addAttributes`, `parseHTML` and `renderHTML`, plus a `ReactNodeViewRenderer` pointing at the view component. `apps/canvas/src/editor/imageLinkNode.tsx` is the smallest complete example; `apps/canvas/src/editor/attachmentNode.ts` shows one whose bytes live elsewhere. 2. Store ids and sizes in attributes, never content. A node that holds a document id reads its target live, which is what keeps a rename or a delete from being copied into every body. 3. Add the node to `cardBodySchemaExtensions` in `apps/canvas/src/editor/cardEditorExtensions.ts`. The title editor builds its own shorter list inside `CardTitleEditor` in `apps/canvas/src/cards/CardBodyEditor.tsx`, which is `StarterKit` plus `Mention`; a node the title should hold has to be added there too. 4. Add the node name to `RICH_TEXT_NODE_NAMES` and its attributes to `RICH_TEXT_NODE_ATTRIBUTE_NAMES` in `packages/graph/src/publish/validation/richTextSchema.ts`. This is the step people forget: `apps/canvas/src/editor/editorSchemaContract.test.ts` compares the two lists against the real schema, so a node missing from the allowlist fails there rather than in a share. 5. Write a test beside the node. Build an `Editor` with `StarterKit` and the node alone, then assert serialisation, attribute round-trip and the view. `apps/canvas/src/editor/imageLinkNode.test.tsx` and `apps/canvas/src/editor/attachmentNode.test.tsx` are the two shapes to copy. A node that reads the workspace needs the id it reads from. `TourStep.configure({ cardId })` is the pattern: the extension takes the owning card as an option, and the node view reads the store rather than receiving a copy through props. ## Add a mark 1. Define the mark in `apps/canvas/src/editor/`, or take it from `StarterKit` if Tiptap already ships it. The body currently accepts `bold`, `italic`, `strike`, `code`, `link` and `underline`. 2. Register it in `cardBodySchemaExtensions`. 3. Add the mark name to `RICH_TEXT_MARK_NAMES` in `packages/graph/src/publish/validation/richTextSchema.ts`. 4. Add a control to the `BubbleMenu` in `apps/canvas/src/cards/CardBodyEditor.tsx`, and add its pressed state to the `useEditorState` selector beside the existing ones. The selector is what keeps a keystroke from re-rendering the whole editor. 5. Extend `editorSchemaContract.test.ts` if the mark adds attributes, and add a case to the export tests if the mark changes serialised output. A mark does not need a Loro change: the CRDT stores the ProseMirror tree, so a new mark is a schema change only. ## Add a field renderer A built-in field kind is rendered by the switch in `FieldValueInput` in `apps/canvas/src/cards/PropertyField.tsx`. A kind that needs more than a native control registers an editor instead, which is how `rating`, `group` and `list` ship without touching the switch. 1. If the kind is new to the schema, add it to `fieldKinds` in `packages/graph/src/schema.ts`, extend `readPropertyValue` in `packages/graph/src/property.ts` so it has a textual reading, and add the parse and reject cases to `packages/graph/src/schema.test.ts`. That work is described in [add-a-feature.md](./add-a-feature.md). 2. Write the editor component in `apps/canvas/src/field/`. It receives `FieldEditorProps` from `apps/canvas/src/field/fieldKindRegistry.tsx`: the field, the value, `onCommit` (with `null` meaning clear), `autoFocus`, `onDone` and `onCancel`. 3. Register it with `registerFieldKind({ kind, editor })` at module scope. `apps/canvas/src/field/compositeKinds.tsx` is the example, and `apps/canvas/src/cards/PropertyField.tsx` imports it so the registration happens before any control renders; a new module needs an import from a component that is always mounted, or the kind registers nowhere. 4. Do not add a formatter. Read-only surfaces format through `formatPropertyValue` in `packages/graph/src/property.ts`; the registry has no `format` field on purpose, because two formatters for one value drifted once already. 5. Test the editor with React Testing Library: commit on change, commit `null` when cleared, and the `aria-label` a screen reader hears. `apps/canvas/src/field/compositeKinds.test.tsx` covers the three composing kinds. If the control is shared by the inspector and an inline block, keep the parse rules in `PropertyField.tsx` so both surfaces commit the same value. ## Add a command 1. Add the row to `apps/canvas/src/command/commandRegistry.ts`: id, group, the surfaces that list it, the capabilities it requires and the state predicate that decides whether it is available. Availability is one rule for every surface, so a read-only session loses the command everywhere at once. 2. Add the handler to the context type beside it. A command calls exactly one handler, and a surface adopts the command by supplying the handler it already owns. 3. If the row needs a target list (boards, cards, views, sections, types), extend `CommandContext` and the provider in `apps/canvas/src/command/paletteProviders.ts` rather than filtering in the palette component. 4. Update `apps/canvas/src/command/commandRegistry.test.ts`, which checks availability table-wise. A command with no test row is a command nobody has proved. 5. If the command should also appear in the `/` menu, add it to `SLASH_ITEMS` in `apps/canvas/src/editor/SlashCommands.ts` instead: those rows run against the editor and consume the typed query first. A palette row shows a title and an optional subtitle. Keep the title the verb, and put the target in the subtitle. ## Add a slash command 1. Append to `SLASH_ITEMS` in `apps/canvas/src/editor/SlashCommands.ts`: `id`, `title`, the `hint` line, a `glyph`, `keywords`, and `run(editor, range)`. Start `run` with `consumeQuery(editor, range)` so the typed `/query` never becomes text. 2. Add keywords for the names people actually type; the query matches them as well as the title. 3. Cover it in `apps/canvas/src/editor/SlashCommands.test.ts`: the row exists, and running it produces the expected document. 4. If the row needs input from the reader, insert a widget rather than opening a dialog. `ImageUrlPrompt` is the example: the caret and the editing session survive the answer. ## Add a mention action A mention row is not a slash row. Rows come from `apps/canvas/src/editor/SuggestionList.tsx`, and the three kinds are fixed by what the chip can store: a card, a field of the current card (a property block), and a field of another card (a `@card.field` chip). 1. Extend the row builder in `SuggestionList.tsx` and keep `MentionSuggestionItem.kind` accurate; the insert path branches on it. 2. If the row inserts a node, add that node to the body schema and the publish allowlist as described above. 3. Keep the picker’s keyboard contract: `ArrowDown` and `ArrowUp` move, `ArrowRight` or `.` enters a card’s fields, `ArrowLeft` leaves them, `Enter` inserts, `Escape` closes. `apps/canvas/src/editor/Mention.test.ts` and `apps/canvas/src/editor/SuggestionList.test.ts` cover the picker; `apps/canvas/src/editor/MentionChip.test.tsx` covers what the chip renders. ## Verify ```sh pnpm exec vitest run apps/canvas/src/editor apps/canvas/src/cards apps/canvas/src/field pnpm --filter @suite/canvas typecheck pnpm exec vitest run packages/graph/src ``` Then run the local gate from [test-and-verify.md](./test-and-verify.md) before you call it done. E2E runs on request; `pnpm e2e:smoke` is the narrowest run that covers the editor. Storybook is worth a look when the change is visible: `pnpm storybook`, then the `Components/` group. A new node that renders an image, a chip or a block belongs there. A schema change also needs the publish allowlist, the export tests and the share projection reviewed, because all three read the same node list. If the change alters what a shared body may contain, update `packages/graph/src/publish/validation/richTextSchema.ts` in the same commit. # Contribute a change > One change, from branch to merge. The coding standards are in reference/conventions.md, the repo map in AGENTS.md, and the local gate in test-and-verify.md. One change, from branch to merge. The coding standards are in [reference/conventions.md](../reference/conventions.md), the repo map in [AGENTS.md](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/AGENTS.md), and the local gate in [test-and-verify.md](./test-and-verify.md). ## Set up Node 22 or newer and pnpm 12.13.0, both pinned in `package.json`. Use pnpm; npm and yarn write a lockfile this repo does not read. ```sh pnpm install # also installs the git hooks (husky, lint-staged, commitlint) pnpm dev # canvas on :5173, collab server on :1234 ``` `.env.example` lists every variable a local run reads; copy it to `.env` only to change a default. The canvas keeps its document in IndexedDB, so a hard reload does not lose work. ## Branch `main` is integration. Branch from it and keep the branch to one logical change. ```sh git switch -c feat/card-tags ``` Prefixes that match the commit types (`feat/`, `fix/`, `docs/`, `refactor/`, `test/`, `chore/`) make the intent readable in `git branch` and in the pull request list. ## Prove it Run the local gate on every change: ```sh pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check && pnpm docs:symbols && pnpm shape ``` `pnpm check` runs Biome, `tsc`, `vitest run --changed`, `pnpm docs:symbols` and `pnpm shape`, so it is the one to repeat while editing. Playwright is not in the gate; [test-and-verify.md](./test-and-verify.md) has the e2e scripts for a run the owner asks for. A user-visible change needs a test that fails without it: * Bug fix: a regression test that reproduces the bug, committed with the fix. * Behaviour: a unit test in the package that owns the logic, or an e2e spec in `e2e/tests/` when the behaviour only exists across a server and a browser. * Multi-client behaviour: a two-document test. Drive both `WorkspaceDocument`s through conflicting edits, sync both ways with `doc.export({ mode: "update" })`, then assert the merge. * Non-trivial UI: a React Testing Library test, and a Storybook story when the surface is visual. ## Document it in the same change A decision that changes behaviour, ownership or a published number gets a record in the internal decision log, with a one-line summary. The log and its template are not part of this repository; ask the maintainer for the current copy. A command, port, store path or default changes in the same commit as [operate.md](./operate.md) or [deploy.md](./deploy.md). A quota changes in the constants, then `node scripts/generate-limits.mjs` rewrites [reference/limits.md](../reference/limits.md); never edit that page by hand. Retired vocabulary fails `node scripts/docs-check.mjs`: the old name for the typed document layer, the old name for the React binding, a stale store count, and a stale CRDT claim. Fix the sentence instead of adding the exemption marker. One canonical home per fact: commands in how-to, quotas in the generated reference, reasons in explanation. Link to the home rather than copying it. ## Commit Conventional commits, enforced by commitlint on `commit-msg`: ```text feat: add tag editing to the card inspector fix: keep section membership after a multi-card drop docs: rewrite the test gate ``` The subject is imperative and at most 72 characters; the body explains why and names the trade-off. One logical change per commit, because a formatting sweep in the same commit hides the change that matters. ## Open a pull request Fill in `.github/PULL_REQUEST_TEMPLATE.md`: summary (what and why), approach (the choice a reviewer would otherwise question), test plan (the commands you ran and their result), and the checklist: * [ ] The local gate from [test-and-verify.md](./test-and-verify.md) passes * [ ] An e2e run passed if the owner asked for one * [ ] Docs and conventions updated if needed A reviewer will ask which test fails without this change, what happens on a second client and on a reload, and whether the docs describing the touched command or number are in the same commit. Paste the command output rather than asserting a green run: no workflow runs on this account, so a local transcript is the evidence. ## Dependencies Add one with a stated reason, to the package that uses it, and prefer a primitive the repo already depends on. A major bump is a decision and lands on its own. `.github/dependabot.yml` groups minor and patch updates weekly for `npm` and `github-actions`, though whether it runs depends on the same account state that stops every workflow here, so assume you are the automation. ## Security and conduct Never commit a secret, a licence private key, a webhook secret or real customer data. Report a vulnerability privately through [SECURITY.md](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/SECURITY.md) rather than in a public issue. Participation is covered by [CODE_OF_CONDUCT.md](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/CODE_OF_CONDUCT.md). # Deploy a server > One small instance, one Docker host, one app container, six SQLite stores on one volume, Caddy in front, Cloudflare in front of Caddy. One small instance, one Docker host, one `app` container, six SQLite stores on one volume, Caddy in front, Cloudflare in front of Caddy. ```plaintext visitor ──▶ Cloudflare (proxy, WAF, cache bypass) ──▶ :443 Caddy ──▶ app:1235 /api, /health, /metrics └──────▶ app:1234 /ws │ /data volume (six stores) ``` There is no second replica, no shared database and no zero-downtime deploy: rooms, presence, projection state and rate-limit counters live in the `app` process, and SQLite is a local file. Scale comes from another instance with its own workspaces. Day-two tasks are in [operate.md](./operate.md); the quotas are generated into [reference/limits.md](../reference/limits.md). Paths below assume the repo at `/srv/suite`, compose project `suite`, hostname `canvas.example.com`. ## The AWS path, in order The sections below are the shape of a deployment in general. This is the same thing as a checklist for one AWS account in `us-east-2`, with the console page each step lives on, because the order matters: the IAM role has to exist before the instance can assume it, and the bucket has to exist before a backup can land in it. | # | What | Where | Section | | -- | ------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------ | | 1 | Bucket for dumps and snapshots, private, versioned, 30-day expiry | S3, `suite-backups--use2` | [§7](#7-backups-and-the-mount-guard) | | 2 | Instance role from `deploy/iam/instance-role-policy.json`, named `suite-host` | IAM, Policies then Roles, EC2 use case | §2 | | 3 | Security group: 80 and 443 from the world, 22 from your address only | EC2, Security Groups | §4 | | 4 | `t4g.medium` Ubuntu 24.04 arm64, 30 GB gp3 root, user-data from `deploy/user-data.sh` | EC2, Launch instance, Advanced details | §1, §2 | | 5 | Elastic IP, associated once the instance is running | EC2, Elastic IPs | §5 | | 6 | Data volume, gp3 20 GB, same availability zone, attached and mounted | EC2, Volumes | §3 | | 7 | RDS Postgres `db.t4g.micro`, private, same VPC, TLS required | RDS, Create database | [below](#the-account-service-on-a-hosted-launch) | | 8 | One `A` record, DNS only, at the Elastic IP | Cloudflare DNS | §5 | | 9 | `tailscale up --ssh` on the box, then the panel over the tailnet | Tailscale admin | §5 | | 10 | Five alarms and one topic | `deploy/cloudwatch/setup.sh` | [operate.md](./operate.md) | | 11 | Stripe products and prices in test, then live; one 100%-off promotion | Stripe dashboard, then `/admin/settings` | below | | 12 | Resend domain and API key, Turnstile site and secret keys | Resend, Cloudflare Turnstile, then `/admin/settings` | below | | 13 | `docs.suite.run` and the marketing page as static builds | Cloudflare Pages | [Docs and marketing](#docs-and-marketing) | Two of those steps are the ones people get wrong. The first is the key pair: a Tailscale-only box needs none for daily use, but a box with neither a key pair nor a working tailnet has no way in at all, so create one anyway and keep it as the break-glass path. The second is the database URL: it has to name the RDS endpoint, and the compose default names a `postgres` service that this host does not run. `deploy/preflight.sh` asks a finished `.env` for both, and for the rest of the failures that a boot does not name itself (§6). Every secret in the list lands in one of two places, and neither is a file in the repository. The control plane reads the values the panel owns from settings rows encrypted with `SUITE_SETTINGS_KEY`, so Stripe, Resend and Turnstile keys go in at `/admin/settings` and are invisible to `docker exec`. The rest (the auth secret, the two relay tokens, the database URL) go in `/srv/suite/.env` with mode 600, written once by hand. `deploy/backup.sh` reads the same file. ## 1. Pick a size The process holds every open room in memory. Loading a room and rebuilding a share projection are the expensive operations; a 5 000-card board costs seconds per rebuild, per open link. | Instance | Comfortable load | Why | | -------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------- | | 1 vCPU / 2 GB (Lightsail, `t4g.small`) | A handful of workspaces, \~10 concurrent editors | \~1 GB left for rooms, resident only while someone is connected. | | 2 vCPU / 4 GB (`t4g.medium`) | Dozens of editors, thousands of workspaces, board shares | Rebuilds are CPU-bound and hold the event loop; a second core absorbs them. | | 4 vCPU / 8 GB (`t4g.large`) | Hundreds of workspaces, heavy share use | Still one process. Past this, split workspaces across instances. | Measure instead of guessing: `/api/admin/status` has store sizes, rooms and clients, and `/metrics` has `suite_rss_bytes`, `suite_open_rooms` and `suite_projection_rebuilds_total`. `mem_limit` in `compose.yaml` turns a leak into a container restart instead of an OOM kill of the box. The volume is gp3, 20 GB minimum and encrypted at creation, because the application has no encryption at rest. That table is about serving. The same host also *builds* each release (§9), and the build needs more than the process does: 4 GB of memory and about 30 GB of root disk, on top of the data volume. A 2 GB instance serves a small deployment and then fails `pnpm build`, so either add swap or resize for the upgrade. §9 has both remedies and the numbers. ## 2. Create the instance and install Docker Lightsail: Ubuntu 24.04, 2 GB, static IP attached. EC2: Ubuntu 24.04 LTS, `t4g.small`, Elastic IP, security group from step 4. Instance store is not durable; root EBS is. Give the root volume 30 GB, because the build in §9 writes `node_modules`, the pnpm store and image layers there; the 2 GB Lightsail plan already includes 60 GB. The instance’s user-data does the installing: `deploy/user-data.sh` brings up Docker from Docker’s repository, `sqlite3`, the AWS CLI, Tailscale and a 2 GB swap file, creates the `suite` user in the `docker` group, and stops there. ```sh aws ec2 run-instances --region us-east-2 \ --image-id "$(aws ssm get-parameters --region us-east-2 \ --names /aws/service/canonical/ubuntu/server/24.04/stable/current/arm64/hvm/ebs-gp3/ami-id \ --query 'Parameters[0].Value' --output text)" \ --instance-type t4g.medium --key-name suite-host \ --iam-instance-profile "Name=suite-host" \ --security-group-ids sg-… --subnet-id subnet-… --user-data file://deploy/user-data.sh \ --block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=30,VolumeType=gp3,Encrypted=true}' \ --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=suite-app}]' ``` `--key-name` is the break-glass path and it is not optional by accident: Tailscale is what you use every day, but a tailnet that failed to come up on a fresh box would otherwise leave no way in at all. Then, once, by hand (both are decisions rather than setup): ```sh sudo -iu suite git clone /srv/suite sudo tailscale up --ssh # optional: prints a URL; click it and SSH and /admin arrive over the tailnet ``` A **private** repository needs credentials on the host, and the smallest credential that works is a read-only deploy key per repository rather than a personal token that also reads every other one: ```sh sudo -iu suite ssh-keygen -t ed25519 -N '' -C 'suite host' -f ~/.ssh/suite_deploy gh repo deploy-key add ~/.ssh/suite_deploy.pub --title 'suite host' --allow-write=false # from a workstation git clone git@github.com:/.git /srv/suite # on the host ``` Reaching the operator panel needs no tailnet: `SUITE_OPERATOR_ALLOW` already admits loopback, so an SSH tunnel terminates inside it. ```sh ssh -N -L 8443:127.0.0.1:443 suite@ # then, locally: curl -fsS --resolve suite.example.com:8443:127.0.0.1 https://suite.example.com:8443/admin.html ``` The same request from the public internet answers `403 Not found`, because the door only admits the networks named there. That is the cheapest way to prove the panel is not exposed: one probe from each side. `deploy/operator-tunnel.sh` opens that tunnel and prints the two URLs, so the command above does not have to be remembered: ```sh deploy/operator-tunnel.sh --host # or SUITE_TUNNEL_HOST deploy/operator-tunnel.sh --host --print # just show the commands ``` **A browser cannot pass `--resolve`.** Visiting `https://127.0.0.1:8443` sends `127.0.0.1` as the hostname, Caddy finds no site block for it, and the panel does not appear. Either add `127.0.0.1 app.suite.run` to `/etc/hosts` while the tunnel is up, or take the simpler route for a person at a known address: put that address in `SUITE_OPERATOR_ALLOW` and use `https://app.suite.run/admin.html` directly. ```sh # your own address, from the machine you will browse from curl -s https://api.ipify.org; echo # then: SUITE_OPERATOR_ALLOW=127.0.0.1/32 ::1/128 172.16.0.0/12 /32 # docker compose up -d --no-build --force-recreate caddy ``` That is a list of networks, so it excludes everyone else; drop the entry when the address changes. A home connection’s address moves, which is the argument for the tunnel as the durable path and the allowlist as the convenient one. **Who is an operator is decided by address, not by a role.** `SUITE_ADMIN_EMAILS` names the accounts that may use the panel, and the check compares the signed-in address against that list. There is no grant step and nothing to click: create the account, sign in, and the panel answers. An address that is not on the list gets `403 forbidden` and a sentence saying so, which is the honest answer rather than a lockout. The host needs the checkout because it builds every image it runs: `caddy` bakes `deploy/Caddyfile` and the canvas bundle, and `app` is compiled here from the same tree (§9). Nothing is pulled from a registry, so the clone is part of the deployment. ## 3. Attach the data volume Create the gp3 volume in the same availability zone and attach it. On Nitro it appears as `/dev/nvme1n1`; confirm with `lsblk`. ```sh sudo file -s /dev/nvme1n1 # "data" = unformatted, safe to mkfs sudo mkfs.ext4 /dev/nvme1n1 && sudo mkdir -p /mnt/suite-data sudo blkid /dev/nvme1n1 # copy the UUID into this fstab line: # UUID=0f0e… /mnt/suite-data ext4 defaults,nofail,x-systemd.device-timeout=10s 0 2 sudo mount -a && mountpoint /mnt/suite-data sudo docker volume create --driver local \ --opt type=none --opt device=/mnt/suite-data --opt o=bind suite_suite-data docker volume inspect suite_suite-data # confirms the bind ``` `nofail` keeps a slow attach from making the instance unreachable over SSH, and it creates a trap: the box then boots with `/mnt/suite-data` as an empty directory on the root disk, and a careless start would create six fresh databases there and look like data loss. The mount guard in step 7 catches that. Create the volume before the first `docker compose up`, because compose names volumes `_`. ## 4. Security group | Port | Source | Why | | ------- | --------------------------------------------------------------------- | ---------------------------------------- | | 80/tcp | `0.0.0.0/0` or [Cloudflare ranges](https://www.cloudflare.com/ips-v4) | HTTP to HTTPS redirect and ACME HTTP-01. | | 443/tcp | `0.0.0.0/0` or Cloudflare ranges | The only public application port. | | 22/tcp | your IP /32 only | SSH. | `1234` and `1235` are exposed inside the compose network and never published, so the admin API and `/metrics` go through Caddy, or from a container in the `app` network namespace: ```sh docker run --rm --network "container:$(docker compose ps -q app)" curlimages/curl:8 \ -fsS http://127.0.0.1:1235/metrics | head ``` ## 5. DNS and Cloudflare One proxied `A` record: `canvas` pointing at the Elastic or static IP. Before the first certificate exists, leave it DNS-only, or keep it proxied with Always Use HTTPS off. The first Let’s Encrypt validation needs plain HTTP on port 80, and the edge redirect refuses it before Caddy can answer. * SSL/TLS: Full (strict), minimum TLS 1.2, then Always Use HTTPS. HSTS comes from Caddy, so leave Cloudflare’s off. * WAF rate limit: one rule, since Free allows one, on `starts_with(http.request.uri.path, "/api/shares") or starts_with(http.request.uri.path, "/api/admin")` at 120 requests per minute per IP, action Block. A challenge page in front of a JSON API is useless to a client. * Cache: a Bypass rule for `/api/` and `/ws`, and Browser Cache TTL set to Respect Existing Headers so Caddy’s one-year `immutable` on `/assets/*` survives. Never Cache Everything: `index.html` and `admin.html` are `no-store` on purpose. * Network: WebSockets and Brotli on; Rocket Loader, Email Obfuscation and Under Attack Mode off. Rocket Loader breaks ES modules and the WASM bundle. * Client addresses: with the proxy on, Caddy sees the edge. Leave `SUITE_TRUST_PROXY=0` unless you also set Caddy’s `trusted_proxies` for Cloudflare’s ranges, or every visitor of one edge shares a rate-limit bucket. The operator door below depends on seeing the real address, which is why the app host is DNS-only. ### The operator door `/admin.html`, `/admin/*` and `/api/admin/*` are gated by the **account**, not the network: the panel asks the control plane who is signed in and whether that address is in `SUITE_ADMIN_EMAILS`, answers 401 without a session and 403 for a signed-in account that is not an operator. That is what lets the panel work from a phone or a hotel, where an address-based rule cannot. `/metrics` is the exception and stays address-gated by `SUITE_OPERATOR_ALLOW`, because it carries room and memory counts and a scraper has no business reaching it from the internet. The panel used to sit behind the same address rule, and the cost of that decision was an owner who could not reach their own deployment from anywhere but one connection. The trade is discoverability: `/admin.html` is reachable by anyone, and the lock is the session plus the operator list. **What `SUITE_OPERATOR_ALLOW` must contain on a compose host** is not obvious, and getting it wrong breaks the metrics scrape. Measured on a running stack, twice: | Client | Address the door sees | | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | a browser on the internet | its own public address, preserved through the port mapping | | a request from the host itself, including an SSH tunnel to `127.0.0.1:443` | the Docker **bridge gateway** (`172.17.0.1`, `172.18.0.1`, …), not loopback | So a list of loopback alone refuses the tunnel, and the bridge subnet is both safe and necessary: external addresses keep their identity, which is why `172.16.0.0/12` cannot be spoofed from outside. The compose default already carries it, so leave it there, and delete the tailnet entry only if you are not using one. The probe in `deploy/cloudwatch/probe.sh` scrapes inside the relay’s namespace, which is what makes the loopback entry sufficient for it. Tailscale’s personal plan is free and covers this. Install it on the box (the instance user-data does), run `tailscale up --ssh`, and connect your laptop: `ssh` and the panel both arrive over the tailnet, so port 22 never has to be open. A local `docker compose` stack sets `SUITE_OPERATOR_ALLOW` to the Docker bridge instead, because a browser on that machine arrives through it and is in no tailnet. An external monitor on `https://canvas.example.com/health`, expecting `200 {"ok":true}`, catches the instance being down; `deploy/alert.sh` from cron catches it being sick. The control plane’s own `/health` on `:1236` is the money path’s: it runs `SELECT 1` on every request and answers `503` with `checks: [{name: "database", state: "failed"}]` while Postgres is unreachable, so an RDS failover is visible rather than silent. Nothing has to be restarted afterwards, because the next request opens a fresh connection. That retry is why every pool here takes an `error` listener: an idle client dying is a warning in the log instead of a crash. ## 6. First boot ```sh cd /srv/suite && cp .env.example .env && chmod 600 .env openssl rand -base64 32 # SUITE_PROJECTION_TOKEN openssl rand -base64 32 # SUITE_SOURCE_TOKEN ``` Set `SUITE_DOMAIN` and `SUITE_ACME_EMAIL`. Leave `SUITE_IMAGE` commented: the build below writes the tag it produced into that line, and a tag pinned by hand before an image exists only makes compose refuse to start. For a hosted relay also set `SUITE_HOSTED=1` and `SUITE_LICENCE_PUBLIC_KEY`, generating and signing on a workstation so the private key never reaches the relay: ```sh pnpm --filter @suite/collab-server licence generate-keypair --out ./licence-private.pem pnpm --filter @suite/collab-server licence sign \ --key ./licence-private.pem --tier hosted --workspaces 25 \ --document-bytes 5242880 --attachment-bytes 26214400 --days 365 --email customer@example.com ``` `SUITE_HOSTED=1` without a key refuses to boot, since a relay that cannot verify a licence is an unlimited one. `SUITE_HOSTED=0` is a self-host: no licences, and no quotas unless you set them. `compose.yaml` puts all six stores on the one volume (`documents`, `shares`, `workspaces`, `settings`, `licences`, `attachments`) and sets `SUITE_REQUIRE_DURABLE_STORES=1`, so boot refuses any store that is unset, `:memory:`, relative, or outside the volume, naming each offender. Leave the flag unset for a local run. Every variable is in [reference/environment.md](../reference/environment.md). Run the preflight first. It reads the `.env` and names the failures that do not stop a boot: a settings key that makes every panel save answer 503, a Turnstile secret with no site key, a mail transport that prints verification links to a log nobody reads, a licence key pasted as a PEM file. ```sh deploy/preflight.sh # exit 1 when a blocker is found ``` ### Turnstile is two values and one policy The pair is split on purpose. `SUITE_TURNSTILE_SITE_KEY` is **public** and inlined into the canvas at build time, so it belongs on the build (`deploy/compose.aws.yaml` passes it, `Dockerfile` inlines it); changing it means rebuilding the `caddy` image. `SUITE_TURNSTILE_SECRET` is read by the control plane at runtime and only ever leaves it to Cloudflare, so it belongs in `.env` and a restart picks it up. Setting the secret makes the challenge **mandatory**: sign-up with no token answers `400 MISSING_RESPONSE`. That is why the site key has to be in the same deployment, and why the policy has to allow the widget. `deploy/Caddyfile` permits `challenges.cloudflare.com` in `script-src`, `connect-src` and `frame-src` on the two canvas hosts. Without those three the script is blocked, no token is produced, and every sign-up fails forever while the page itself looks perfect (ADR-0077). ```sh deploy/deploy.sh --no-backup # builds suite:v1.1.0, starts app, waits for health docker compose build caddy control-plane # the other two images, from the same checkout docker compose up -d --no-build # Caddy, the control plane and Postgres docker compose ps && docker compose logs -f caddy # wait for "certificate obtained successfully" curl -fsS https://canvas.example.com/health # {"ok":true} curl -fsS -o /dev/null -w '%{http_code}\n' https://canvas.example.com/admin ``` Open the canvas, create a workspace, and sign in. There is no recovery key any more: a workspace belongs to the organization that first opened it, and access is the account’s own sign-in. To keep the panel off the public internet, uncomment the two `remote_ip` lines in `deploy/Caddyfile`, list your office or VPN addresses, and rebuild Caddy. ## 7. Backups and the mount guard `deploy/backup.sh` snapshots all six stores with SQLite’s online backup, checks `PRAGMA integrity_check`, hashes each file, and writes a manifest with per-table row counts and an attachment-id checksum. With `SUITE_BACKUP_BUCKET` set it uploads one tarball to S3 or R2. ```sh sudo apt-get install -y sqlite3 curl -sS "https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip" -o awscliv2.zip unzip -q awscliv2.zip && sudo ./aws/install && rm -rf aws awscliv2.zip sudo aws configure --profile suite-backup # cron runs as root ``` Bucket: versioning on, Block Public Access on, SSE-S3 on, and an IAM user allowed only `s3:PutObject` on `arn:aws:s3:::suite-backups/suite/*`. The script resolves all six stores from `SUITE_DATA_DIR` and detects the `documents.sqlite` naming compose uses, so that variable plus the bucket is the whole configuration. ```sh sudo install -m 600 /dev/null /etc/suite/backup.env sudo tee /etc/suite/backup.env >/dev/null <<'EOF' SUITE_DATA_DIR=/mnt/suite-data SUITE_BACKUP_BUCKET=s3://suite-backups SUITE_BACKUP_PREFIX=suite AWS_PROFILE=suite-backup AWS_DEFAULT_REGION=eu-west-1 EOF ``` ```cron 20 3 * * * root set -a; . /etc/suite/backup.env; set +a; /srv/suite/deploy/backup.sh /srv/backups >> /var/log/suite-backup.log 2>&1 40 3 * * * root find /srv/backups -mindepth 1 -maxdepth 1 -name 'suite-*' -mtime +14 -exec rm -rf {} + */15 * * * * root mountpoint -q /mnt/suite-data || logger -t suite 'EBS data volume is NOT mounted' */5 * * * * root /srv/suite/deploy/alert.sh --url http://127.0.0.1:1235 >/dev/null ``` Retention (7 daily and 4 weekly copies) belongs to the bucket lifecycle, and the rule plus the restore caveats are in [operate.md](./operate.md). Snapshots are unencrypted: plaintext content, uploaded images, share and owner hashes. The bucket is encrypted at rest and the directory is root-only. Keep `/srv/backups` off the data volume, or a volume loss takes the backups with it. ## 8. Restore drill Monthly, read-only, because a backup you have never verified is a hope: ```sh /srv/suite/deploy/restore.sh "$(ls -d /srv/backups/suite-*/ | tail -1)" --verify-only /srv/suite/deploy/restore.sh /srv/backups/suite-20261023T001541Z --force # quarterly, full drill ``` In order: verify, refuse a non-empty volume without `--force`, stop `app`, delete the six files and their stale `-wal`/`-shm` sidecars, copy the snapshot in, start `app`, and require `/health` within 90 seconds. Check the workspace you know best and one share link, then record the duration, which is your RTO. Caddy keeps proxying while `app` is down, so visitors see 502 for the length of the copy. ## 9. Upgrade by building on the host Nothing is pulled: the host builds `app` from `/srv/suite`, which is why it needs 4 GB of memory and 30 GB of root disk (§1) and why a Graviton host produces an arm64 image. Neither the steps below nor `deploy/deploy.sh` depends on `.github/workflows/ci.yml` or `release.yml`, which cannot run on this account. The order is the part that matters. A host build replaces the image it runs, so the image that is running now is tagged *before* the build starts; that tag is the whole rollback. 1. **Snapshot the six stores.** A release that changes a stored format can only be undone from a backup taken before it opened the stores. ```sh cd /srv/suite sudo bash -c 'set -a; . /etc/suite/backup.env; set +a; /srv/suite/deploy/backup.sh /srv/backups' ``` 2. **Ask the running container which version it is.** The answer names the rollback tag; `v1.1.0` below stands for it. ```sh docker compose ps -q app | xargs docker inspect \ --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^SUITE_VERSION=//p' ``` 3. **Tag the running image before anything can replace it.** ```sh docker tag "$(docker compose images -q app)" suite:rollback-v1.1.0 ``` `docker compose images -q app` prints the image id the container runs from, so the tag names that exact image and keeps it after the build moves `suite:local`. 4. **Move the checkout onto the release.** The build reads the working tree, not a tag. ```sh git fetch --tags && git checkout v1.1.1 && git describe --tags --exact-match ``` 5. **Build the image with the tag baked in.** `SUITE_VERSION` is what `/metrics` and `/api/admin/status` report. Both `-t` flags matter: `suite:v1.1.1` is the record you can come back to, `suite:local` is what compose runs when `.env` names no image. ```sh docker build --target runtime --build-arg SUITE_VERSION=v1.1.1 \ -t suite:v1.1.1 -t suite:local . ``` 6. **Pin the tag and restart `app` alone.** ```sh sed -i -E 's|^#?[[:space:]]*SUITE_IMAGE=.*|SUITE_IMAGE=suite:v1.1.1|' .env docker compose up -d --no-build app # Caddy keeps running; 502 for the restart only ``` `--no-build` because the image already exists: without it compose may rebuild and retag over the tag being started. 7. **Check the version, not only health.** ```sh curl -fsS https://canvas.example.com/health # The operator API answers a session: sign in at /admin.html and use its token, # or read /api/admin/status from the box while SUITE_METRICS_LOCAL is on. curl -fsS http://127.0.0.1:1235/metrics | grep suite_build_info ``` A container that reports the previous version is still the previous release, however healthy `/health` looks. `deploy/deploy.sh` runs this sequence: it backs up, tags the running image, builds `suite:` and `suite:local` from the tag at `HEAD`, writes `SUITE_IMAGE` into `.env`, restarts `app` with `--no-build`, and waits 90 seconds for the compose healthcheck. A failure leaves the old image tagged and prints the rollback command. ```sh cd /srv/suite && git fetch --tags && git checkout v1.1.1 && deploy/deploy.sh ``` A host that writes its logs to CloudWatch carries a second compose file, and every `docker compose` command on that host has to name it. Put it in `.env` once, and `docker compose` and `deploy/deploy.sh` both read it from there: .env ```sh COMPOSE_FILE=compose.yaml:deploy/compose.aws.yaml ``` `app` is the only image `deploy/deploy.sh` builds, because it is the only one that changes every release. When the control plane changed too, rebuild it and include it in the restart: ```sh docker compose build control-plane docker compose up -d --no-build app control-plane ``` ### Tabs that outlive a deploy A canvas tab keeps running the bundle it loaded. `index.html` is `no-store` and the hashed chunks under `/assets` are immutable, so nothing about the deploy reaches a tab that is already open: the old engine keeps running, and a fix that shipped reads as a bug that survived it. Ask the reader to reload before believing a report against a tab that predates the build. `apps/canvas/src/app/useStaleBuild.ts` makes that visible instead of remembered. It compares the entry chunk this page loaded with the one the server would hand out now, on mount and whenever the tab comes back into view, and `StaleBuildNotice.tsx` shows a line with a Reload button when they differ. It never reloads on its own: a document can be mid-edit. ### Rollback Rollback restarts on the tag taken in step 3. Nothing is compiled and no old checkout is involved, so it takes as long as a container recreate. ```sh deploy/deploy.sh --rollback suite:rollback-v1.1.0 # the same thing by hand sed -i -E 's|^#?[[:space:]]*SUITE_IMAGE=.*|SUITE_IMAGE=suite:rollback-v1.1.0|' .env docker compose up -d --no-build app ``` `deploy/deploy.sh --list` prints the tags this host holds. Keep the last two `suite:rollback-*` and remove older ones with `docker image rm`: each is a full copy of the image and counts against the root volume. The tag returns the code; the data moves with it only as far as the schema allows. Stores migrate forward when opened, so a release that bumped `PRAGMA user_version` or changed a snapshot tag is undone by restoring the step 1 snapshot with `deploy/restore.sh --force` ([operate.md](./operate.md)), then starting the older tag. [release.md](./release.md) has the rest of that procedure. ### What the build needs The build runs on the same box that serves, so these are facts about the deployment. * **4 GB of memory, or swap on 2 GB.** `pnpm build` for the canvas and the Docker build that runs the same tree inside `node:22-bookworm` each want roughly 1 GB, and the second one runs inside a container whose memory is charged to the same box. On 2 GB (`t4g.small`, the $5 Lightsail plan) one of them usually ends in `JavaScript heap out of memory` or a killed `vite`, mid-upgrade, with the rollback tag already taken and `app` still on the old image. For 4 GB of swap: ```sh sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile sudo mkswap /swapfile && sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab ``` A swap-backed build takes several minutes. Resizing to `t4g.medium` for the upgrade is faster, and is what §1 recommends past a handful of workspaces. * **About 30 GB of root volume.** The checkout grows with history, `node_modules` and the pnpm store are several GB, and every image adds layers on top of the `node:22-bookworm` base. Reclaim space before the build: ```sh df -h / # the number that decides whether to build now docker builder prune # build cache, 5-10 GB after a few upgrades pnpm store prune # store entries no current lockfile names docker image prune # dangling layers; --all also drops tags, so check --list first ``` The builder cache is the one that surprises people: it keeps the intermediate layer of every stage of every build, and on a 30 GB disk two or three upgrades fill it. `deploy/deploy.sh --prune` runs the first two for you. * **Architecture follows the host.** The image is built for the machine doing the building: arm64 on Graviton, x86-64 on Intel. That is correct here because the host builds what it serves. An image moved from an x86 workstation with `docker save` and `docker load`, or pulled from a registry that built it elsewhere, starts and then dies with `exec format error`. A build that has to happen off-instance needs `--platform linux/arm64` and a builder of that architecture. ### A Caddyfile change A `deploy/Caddyfile` change needs `docker compose build caddy && docker compose up -d caddy`, which costs a couple of seconds of 502. Enable the commented `caddy-data` volume if you recreate Caddy often, or it re-issues certificates until Let’s Encrypt’s five-per-week limit stops you. ### When the certificate authority says no The limit is real and it is measured per exact hostname set: five duplicate certificates in seven days, then `429 rateLimited` until the window rolls over, which is days away and not shortened by asking sooner. Caddy logged this and kept retrying while every host answered nothing, so the shape of the incident was a working origin with no TLS. The fix is a second authority rather than a wait. `SUITE_ACME_CA` selects the directory: ```sh # .env on the host, then `docker compose build caddy && docker compose up -d caddy` SUITE_ACME_CA=https://acme.zerossl.com/v2/DV90 SUITE_ACME_EMAIL=you@example.com # ZeroSSL mints account credentials from the address ``` ZeroSSL hands Caddy its EAB credentials when an email is set, so nothing has to be registered by hand; the certificate is a normal 90-day DV certificate and the ACME account lives in `caddy-data` beside the Let’s Encrypt one. `deploy/Caddyfile` defaults to Let’s Encrypt, so this is a line in `.env`, not a change to the file. Two authorities side by side also mean a future rate limit costs one variable instead of an outage. The other half of the incident was ours: `caddy` had its state on `tmpfs`, so every recreate began with an empty `/data` and requested three fresh certificates. `deploy/compose.aws.yaml` mounts the `caddy-data` volume, and without it a handful of deploys in one afternoon spends the weekly allowance. ### Publishing to a registry again (optional) Not used today, and nothing above needs it. If a registry becomes available, GitHub Actions on a working account or Codeberg with a container registry, only step 5 changes: build with a registry tag, push it, and point `SUITE_IMAGE` at the digest it produced. `compose.yaml` accepts any reference in `SUITE_IMAGE`, so a pull-based host sets it to `ghcr.io//suite@sha256:…` and then runs `docker compose pull app` followed by `docker compose up -d --no-build app`. A host that builds keeps using `suite:`, and `caddy` is never pulled because it packages this checkout’s canvas bundle. ## Container hardening `compose.yaml` runs every service with the smallest surface it can and still work. The two server processes are the strict case, because they are the ones exposed to a document and to the internet: | Control | `app` | `control-plane` | `caddy` | | --------------- | ----------------------------------------------------- | -------------------- | -------------------------------------------- | | User | `1000:1000` (`node`, set by the image) | same | root, with one capability | | Root filesystem | `read_only: true` | `read_only: true` | `read_only: true` | | Writable paths | the `suite-data` volume at `/data`, `tmpfs` on `/tmp` | `tmpfs` on `/tmp` | `tmpfs` on `/tmp`, `/config`, `/data` | | Capabilities | `cap_drop: ALL` | `cap_drop: ALL` | `cap_drop: ALL`, `cap_add: NET_BIND_SERVICE` | | Escalation | `no-new-privileges:true` | same | same | | Limits | `mem_limit: 1536m`, `cpus: "1.0"`, `pids_limit: 256` | `512m`, `0.5`, `128` | `256m`, `0.5`, `128` | The relay never needed a writable root: its six stores are on the volume, and the `tmpfs` on `/tmp` is there because Node and `tsx` want a scratch directory. Caddy is the exception on two counts — ports 80 and 443 need `NET_BIND_SERVICE`, and its certificate cache would be tmpfs unless the commented `caddy-data` volume is enabled, in which case a certificate survives a recreate. Prove it rather than read it. The relay, with every store on the volume and a read-only root: ```sh docker build --target runtime -t suite-relay . docker run --rm -d --name suite-relay-probe \ --read-only --tmpfs /tmp:size=32m,mode=1777 \ --cap-drop ALL --security-opt no-new-privileges --user 1000:1000 \ -v suite-probe-data:/data \ -e PORT=1234 -e HEALTH_PORT=1235 \ -e SUITE_DB=/data/documents.sqlite -e SUITE_SHARES_DB=/data/shares.sqlite \ -e SUITE_WORKSPACES_DB=/data/workspaces.sqlite -e SUITE_SETTINGS_DB=/data/settings.sqlite \ -e SUITE_LICENCE_DB=/data/licences.sqlite -e SUITE_ATTACHMENTS_DB=/data/attachments.sqlite \ -e SUITE_REQUIRE_DURABLE_STORES=1 suite-relay docker exec suite-relay-probe node -e "fetch('http://127.0.0.1:1235/health').then(async r=>console.log(r.status, await r.text()))" # 200 {"ok":true} docker exec suite-relay-probe id # uid=1000(node) gid=1000(node) groups=1000(node) ``` The control plane needs Postgres, because it refuses to boot without a database URL: ```sh docker build --target control-plane -t suite-control-plane . docker run --rm -d --name suite-pg -e POSTGRES_USER=suite -e POSTGRES_PASSWORD=suite -e POSTGRES_DB=suite postgres:17-alpine docker run --rm -d --name suite-cp-probe --read-only --tmpfs /tmp:size=32m,mode=1777 \ --cap-drop ALL --security-opt no-new-privileges --user 1000:1000 \ -e PORT=1236 -e SUITE_CONTROL_PLANE_DATABASE_URL=postgres://suite:suite@:5432/suite \ -e SUITE_AUTH_ENABLED=0 -e SUITE_BILLING_ENABLED=0 suite-control-plane docker exec suite-cp-probe node -e "fetch('http://127.0.0.1:1236/health').then(async r=>console.log(r.status, await r.text()))" # 200 {"status":"ok",…,"checks":[{"name":"database","state":"ok"}]} ``` And that the runtime tree carries no toolchain: ```sh docker run --rm suite-relay sh -c 'ls node_modules/.pnpm | grep -E "^(typescript|vitest|@biomejs|@stryker|turbo)@" || echo clean' # clean ``` ## The account service on a hosted launch The relay above is one process on one box. The account service is the part that needs a managed database, because it holds accounts, organizations, seats and subscriptions, and losing it means every sign-in stops. Put it on RDS Postgres and leave the documents on the box. ```plaintext app.suite.run ──▶ Caddy ──┬──▶ control-plane:1236 /api/auth, /api/account, /admin/admissions ├──▶ app:1235 /api/workspaces, /health, /metrics └──▶ app:1234 /ws │ RDS Postgres (accounts, organizations, billing) EBS volume (six SQLite stores) ``` Creating that database, the fields that matter: | Field | Value | Why | | ------------ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Engine | Postgres 17 | the migrations are tested against it, and `pnpm db:verify` names the version if a host drifts | | Template | Free tier, `db.t4g.micro`, 20 GB gp3 | accounts and subscriptions are small; the documents are not here | | Connectivity | same VPC, private subnets, no public access | only the instance’s security group needs 5432, added as an inbound rule on the database’s group | | Encryption | on, at creation | it cannot be turned on afterwards | | Backup | as long as the plan allows; deletion protection | a lost accounts table is a lost deployment. A FREE-plan account caps automated retention at 1 day and refuses 7 with `FreeTierRestrictionError`, which is exactly why `deploy/backup.sh` writes its own `pg_dump` to S3 | | Parameters | `rds.force_ssl = 1` | the connection string carries `sslmode=require`, and this makes it enforced | Nothing migrates by hand. The control plane runs its migrations on the first request that needs identity, so a fresh database fills itself in and a failed migration clears the memo and retries rather than wedging the process. `pnpm db:verify` (or `scripts/db-verify.mjs` on the host) is the check that the eleven migrations landed. Two things RDS does not do for you, both of which fail in ways that look like something else: 1. **The database itself.** RDS creates `postgres` and nothing else, so a connection string ending `/suite` answers `database "suite" does not exist`, and only after the certificate verifies, so the message changes as you fix each layer. ```sh docker run --rm postgres:17-alpine \ psql "postgres://suite:PASS@HOST:5432/postgres?sslmode=require" -c 'CREATE DATABASE suite' ``` 2. **The certificate.** `sslmode=require` currently means `verify-full`, and RDS signs its endpoint with a CA no Node image carries: the control plane answers `503 checks:[{name:"database",state:"failed",detail:"self-signed certificate in certificate chain"}]`. `sudo deploy/rds-ca.sh` fetches the public bundle, and `deploy/compose.aws.yaml` mounts it and points `NODE_EXTRA_CA_CERTS` at it. Do not “fix” this with `uselibpqcompat=true&sslmode=require`, which encrypts without verifying anything. Set `app.suite.run` to **DNS only** in Cloudflare. The proxy’s idle timeout sits on a WebSocket that is supposed to stay open, and a collaboration socket behind it is dropped on a timer. Four variables decide whether a person who signs up can ever get in: | Variable | Why it has to be right | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SUITE_CONTROL_PLANE_DATABASE_URL` | Compose defaults it to the `postgres` service, which does not exist on a host that uses RDS: the name resolves to nothing and the control plane boots degraded. Point it at the server with `?sslmode=require`. | | `SUITE_AUTH_SECRET` | Signs the session. Changing it signs everyone out. | | `SUITE_MAIL_TRANSPORT` | `console` cannot deliver a verification link. Use `resend` with `RESEND_API_KEY` and `SUITE_MAIL_FROM`, or `smtp` with `SUITE_SMTP_URL`. | | `SUITE_ADMISSION_REQUIRED` | `1` holds every new account. Pair it with `SUITE_ADMIN_EMAILS` or the boot refuses: the queue would have no door. | | `STRIPE_WEBHOOK_SECRET` | Without it every delivery fails verification, so no payment is recorded. `pnpm stripe --listen` prints it locally; on the host it comes from the Stripe dashboard endpoint. | | `SUITE_AUTH_JWKS_URL` | The address the relay *fetches* the key set from, when that is not the public origin. Compose defaults it to `http://control-plane:1236/api/auth/jwks`; leave it set, or the relay asks the proxy for its own public hostname and no session verifies. | | `SUITE_ADMIN_EMAILS` | Who may use the operator surface. Comma-separated, matched against the signed-in address. Unset locks the admissions queue and the starter list to nobody. | **Name the first operator before the first boot.** `SUITE_ADMIN_EMAILS` is the only door to the admissions queue and the starter list, and an account becomes an operator by *address*, so create the account first and add its address to the variable: ```sh # 1. the address is in .env, then restart the control plane so it is read SUITE_ADMIN_EMAILS=you@example.com # 2. sign up once at https://app.suite.run, follow the confirmation mail # 3. sign in at https://app.suite.run/admin.html; the panel says whether you are an operator ``` If step 3 answers “not an operator”, the variable did not reach the process: it is read through a whitelist (`services/auth/src/http/env.ts`), and a missing value there mints `operator: false` rather than refusing to boot. Admitting the first users, once mail and the queue are live: sign in at `https://app.suite.run/admin.html` with an address from `SUITE_ADMIN_EMAILS` and use the Admissions tab. The same list opens `/admin/templates` for writing and the relay’s `/api/admin/*`. The account API needs the queue and starter list, which `deploy/Caddyfile` already routes to the control plane on the app origin, and it routes `/admin/settings`, `/admin/accounts`, `/api/account*` and `/api/invites/*` the same way: those are control-plane paths that the `/admin` panel rewrite and the `/api/*` relay default would otherwise answer with a page or a 404. An address that is not on the list gets `403 forbidden` and a sentence saying so, which is the honest answer rather than a lockout: the panel tells the reader their address needs adding. **A new account cannot sync until it subscribes.** Local and offline use is free and needs no account; an account is what buys sync, so the born-with licence grants no workspace and `POST /api/workspaces/claim` answers `402 plan-required` until a subscription replaces the row. The app shows the plan picker rather than a bare refusal. Testers skip payment with a 100%-off promotion code at checkout, which keeps them on the real paid path. ## Support access and impersonation The Accounts tab opens a support session: the operator becomes the account, sees what it sees, and stops when they are done. It is gated on consent the account itself gave, not on the operator’s role. | Fact | Where it lives | | ------------------------ | ------------------------------------------------------------------------------ | | Who may start one | `SUITE_ADMIN_EMAILS`, read per request | | Whether it exists at all | the `flags.impersonation` settings row, on by default | | Consent | the `operator_support_grants` table, written from the account’s own session | | The second factor | required: the admin plugin refuses an impersonation for an account without 2FA | An operator needs a second factor of their own before impersonating: the admin plugin refuses an account that can manage people without one, and the Accounts tab enrols it: the password, then the `otpauth://` URI into an authenticator, then one code. That is a condition of the role, not of the panel, so it holds however the request arrives. The account turns consent on from **Support access…** in the app’s File menu, and can turn it off there without asking anyone. The operator sees the grant in the Accounts list and may only *end* it; a request that tries to grant it is refused with `consent-is-the-accounts`, because a permission the operator can give themselves is not consent. Turning the flag off is the deployment-wide switch: set `flags.impersonation` to off in Settings and every impersonation request answers 403, grant or no grant, without a restart. Every impersonation also writes two rows and leaves one for the account: an `operator_audit` entry (who, when, and what came of it) and an `account_notices` sentence the account reads at its next sign-in. Neither names a secret, and the notice deliberately does not name the operator. ### Tables the control plane creates Four tables appear on first use rather than in a migration, so an older database boots: | Table | Written by | Read by | | ------------------------- | -------------------------------------- | ------------------------------------------------------------------ | | `suite_settings` | Settings | every process’s boot overlay, and the panel | | `operator_support_grants` | the account, or an operator ending one | the impersonation gate | | `operator_role_overrides` | an operator wearing a role | the memberships reader, so the session and the relay’s token agree | | `operator_audit` | every operator act | the Activity tab | | `account_notices` | an impersonation | the account’s own dialog and strip | All five live in Postgres, so `deploy/backup.sh` covers them with the `pg_dump` it already takes. None has a retention policy: audit rows are kept forever, which is stated rather than assumed. `SUITE_APP_URL` is where the admission notice sends someone. Unset admits without sending one. A held account is not broken: it can sign in, edit, import and export on its own device. What it cannot do is create an organization or open a sync connection, which is what “waiting for approval” means. ## Docs and marketing `docs.suite.run` is the static Astro tree described below. The marketing site is a second static build with no server, so both belong on Cloudflare Pages rather than on the box: a page that is a file does not need a machine that can go down. Both projects are uploaded as a prebuilt directory, not built by Pages, so nothing about the workspace has to be reproduced in Cloudflare’s builder: ```sh pnpm docs:site:build && pnpm site:build # apps/docs/dist and apps/site/dist npx wrangler pages deploy apps/docs/dist --project-name suite-docs npx wrangler pages deploy apps/site/dist --project-name suite ``` Then add the custom domains in the Pages project, and leave those two `A` or `CNAME` records proxied: unlike the app, a static origin has no long-lived socket for the proxy’s idle timeout to cut. `_headers` in each site’s `public/` directory is the Cloudflare equivalent of the Caddy policy, and it is copied into `dist` at build time. Keep the two in step: the same page should not have a different security posture depending on whether it was served from Pages or from the box. The docs site allows inline scripts because Starlight emits them; the marketing page does not need to. ## Docs site The reader-facing docs are a static Astro site in `apps/docs`, built from `docs/`. It needs no database, no volume and no relay: the image is the built tree at `/srv/docs`, which is the default of `SUITE_DOCS_ROOT` in `deploy/Caddyfile` and the root the `docs.suite.run` site block serves. ```sh docker compose --profile docs up -d --build docs curl -fsS http://127.0.0.1:3001/ > /dev/null curl -fsS http://127.0.0.1:3001/llms.txt > /dev/null ``` The image reads the Markdown at build time, so a docs edit needs a rebuild. The container serves that tree with Caddy’s file server on port 3001. The same build runs locally with `pnpm docs:site:build` and `pnpm docs:site:start`. The marketing page is the same shape in `apps/site`: `/srv/marketing`, `SUITE_MARKETING_ROOT`, and `pnpm site:build`. Starlight ships inline scripts for the theme, the search dialog and the sidebar, and the dashboard adds one for its tabs and filter. The docs host therefore carries `script-src 'self' 'unsafe-inline'`, and it is the only host that does: there is no user content on that origin, which is what makes the relaxation safe there and nowhere else. The marketing page has no scripts and keeps `'self'`. ## What not to do * A second `app` replica, through `--scale app=2`, an autoscaling group or a load balancer, corrupts the locking assumptions: two processes on one volume, or two volumes behind one hostname that silently split workspaces. * SQLite on a network filesystem, or one EBS volume Multi-Attached to two instances, breaks locking and WAL shared memory. * `docker compose down -v` removes the volume, and instance-store or spot-only data does not survive a replacement. * `/metrics` and `/admin` stay off the public internet. The token is all-or-nothing, so allowlist or tunnel it. * One Caddy container, and nothing else serving `/srv`. The caching and `no-store` headers live in `deploy/Caddyfile`. ## Cost List prices, one small instance, excluding tax. Check them before quoting. | Item | Monthly | | ------------------------------------------------- | -------- | | Lightsail 2 GB (1 vCPU, 60 GB SSD, 3 TB transfer) | \~$12 | | EC2 `t4g.small` with 20 GB gp3 and a public IPv4 | \~$17 | | 4 GB step (`t4g.medium`) | \~$24–30 | | EBS snapshots and S3 backups (5 GB) | \~$1–2 | | Cloudflare Free (proxy, TLS, one WAF rule) | $0 | The cost that bites is not on the table: a root volume filling with images, logs and local snapshots, or a box swapping because one board share rebuilds every few seconds on one core. Ten instances with ten separate workspace sets is the only scale shape, and it costs ten times one. # Import an Obsidian vault > Importing a vault folder reads every note, tag, link and canvas into one workspace. The import runs once, as a single change, so undo takes it back whole. Importing a vault folder reads every note, tag, link and canvas into one workspace. The import runs once, as a single change, so `undo` takes it back whole. There are three doors onto it and they share one importer: * **Drop** a folder on a workspace tile in the launcher. * **Pick** a folder: `Workspace → Import from Obsidian…` inside a workspace, or a launcher tile’s `Import` menu, → `Obsidian folder…`. This is the door for a tablet, where dragging a folder is not a gesture the OS offers. * **Pick** a `.zip`: the same two menus, → `Obsidian .zip…`. A tablet’s Files app offers one file rather than a folder, so a vault that arrived zipped is read directly; `DecompressionStream` does the inflate, so there is no dependency behind it. The archive’s name becomes the root board’s name. ## Read the vault `collectVaultFiles` in `packages/import-obsidian/src/drop.ts` walks the entries a drop hands over and returns the shape the importer takes: paths relative to the dropped folder, with text for notes and canvases. Its entry types are structural, so a browser `FileSystemEntry` satisfies them without a cast. ```ts const entries = Array.from(dataTransfer.items, (item) => item.webkitGetAsEntry()); const { files, skipped } = await collectVaultFiles(entries); ``` ## Import it ```ts import { importObsidianVault } from "@suite/import-obsidian"; const result = importObsidianVault(workspace, { files: collected.files, homeBoardName: "My vault", }); if (result.isErr()) return result.error.message; return reportSummary(result.value); ``` `importObsidianVault` writes through `@suite/graph`’s typed methods inside one batch. The workspace must already carry the field definitions a key should fill; a key with no matching field is kept as body text rather than dropped. ## The picker A folder input hands over flat `File`s whose `webkitRelativePath` is `MyVault/Notes/Alpha.md`. That is already the importer’s shape once the first segment is dropped, so `vaultFilesFromPicker` in `apps/canvas/src/launcher/vaultImport.ts` reads it directly and `importVaultIntoWorkspace` takes the files instead of walking entries: ```ts const { files, name } = await vaultFilesFromPicker(input.files ?? new FileList()); await importVaultIntoWorkspace({ workspaceId, files, homeBoardName: name }); ``` Only notes and canvases are read as text; an attachment is counted and reported, because reading a large video into a string to throw it away is how an import becomes a crash. `webkitdirectory` has no JSX spelling, so the input sets it through its `ref`. ## What the import needs * The workspace must exist on this device: the import merges into a document, it never creates one. * Pass `homeBoardName` from the folder’s name, so the vault root’s board reads as the vault. * Call `importObsidianVault` with that `WorkspaceDocument`, the same way `openRecoveryKey` opens one. ## The mapping The table is data in `packages/import-obsidian/src/mapping.ts`, so a change to what the importer carries is a change to one array, and the fixture vault’s test walks it. | Source | Target | What travels | | ------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Vault root | Home board | The workspace’s first board holds every note outside a folder. Its name is the vault folder’s name when the caller passes one. | | Folder | Board | One board per folder that holds at least one note, named by the folder’s whole path (`Projects / Notes`), so a nested hierarchy stays visible. A canvas file is a board of its own, so a folder of canvases needs no board. Boards have no parent, so the path is the hierarchy. | | Markdown note (`.md`) | Card | One card per note, placed on its folder’s board in a grid, because a note has no geometry of its own. | | First `#` heading | Card title | Used when the heading opens the note; that block leaves the body. A note without one is titled by its file name. | | Note body | Card body | Headings, paragraphs, block quotes, bullet and ordered lists (nested by indentation), fenced code with its language, and horizontal rules. Tables, HTML, footnotes and diagrams stay as the text they were written as. | | Frontmatter key matching a field name | Card property | Matched case-insensitively against the workspace’s field definitions and coerced by the field’s kind: a select by option label, multi-select by label list, checkbox by truthiness, number by value. `fieldByKey` overrides the match with a field id. | | Frontmatter key with no field | Body paragraph, plus a report row | Written as `key: value` at the end of the body under a `Properties` heading, and listed in `properties.keptAsText`. Every kept key appears in the report. | | Frontmatter that is not valid YAML | Body text, plus a report row | The block is kept where it was, the note still imports, and the reason is reported. | | `tags:` / `tag:` in frontmatter | Card tags | Every spelling Obsidian accepts: a comma string, a YAML list, or both keys. Names are normalised by `ensureTag`, which trims and matches case-insensitively. | | Inline `#tag` | Card tag | The tag token leaves the body text and becomes a tag on the card. A `#` inside code is text, and a purely numeric tag is text, because Obsidian does not treat it as a tag either. | | `[[Note]]` | Mention in the body, indexed by the reference index | Resolved against the vault by file name (then by path, then by the note’s own folder). A resolved link becomes a mention node, so backlinks come from the existing index rather than a second edge record. An unresolved target stays as the text `[[Note]]` and is counted in the report. | | \`\[\[Note | alias]]`, `\[\[Note#heading]]\` | Mention plus text | | External link | Link mark | A Markdown link whose address is `http`, `https` or `mailto` becomes a link mark on its label. Any other scheme keeps the label as text, drops the address, and is reported. | | `![[image.png]]` | Text, plus a report row | Attachment bytes are not imported: the mention becomes the file’s name as text and the path is listed under `attachments`. | | `.canvas` file | Board | Named after the file, with one placement per node at its real geometry. | | Canvas `text` node | Card | The node’s first line becomes the title and the Markdown under it becomes the body, so nothing is duplicated on the card. Its `x`/`y`/`width`/`height` become the placement, and its colour becomes the card’s surface role. | | Canvas `file` node | The imported card for that file | A file node whose target is not in the vault becomes a card titled by the file name, with the path as its body text. | | Canvas `link` node | Card | Titled by the address, which is also the card’s body, so the URL is never lost. | | Canvas `group` node | Section | Geometry and label carry over. A group whose label is empty gets a section titled `Group`. | | Canvas edge | Link between the two cards | The edge’s `label` becomes the link’s label. A side hint (`fromSide`/`toSide`) is not stored: the link uses the default connector layout, and the report says so. | | Canvas node colour | Section colour or card surface role | Obsidian’s six presets map to the palette the schema names. A hex colour has no slot: the styles name roles rather than values, so it is reported instead of approximated. | | `.obsidian/` and unknown extensions | Skipped, with a reason | Settings and files the importer cannot read appear in `skipped`; nothing is ignored silently. | ## The fidelity summary `importObsidianVault` returns a `Result`. The success value is what the caller shows after the import: | Field | What it says | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `files` | How many files were read as notes, canvases and attachments, and how many were skipped | | `records` | Boards, cards, sections, tags and links written | | `links.resolved`, `links.unresolved`, `links.ambiguous` | Wikilinks that became mentions, targets the vault does not have, and names two notes share | | `properties.mapped`, `properties.keptAsText` | Frontmatter keys that filled a field, and keys kept as text with the note they belong to | | `colours.sections`, `colours.cards`, `colours.unmapped` | Canvas colours that found a slot, and hex values that did not | | `attachments` | Embedded files kept as text, with the reason | | `skipped` | Files not read, with the reason | | `notes` | One sentence per class of loss, such as an edge side that is not stored | | `boards` | The boards the import wrote, so the caller can open one | An empty `unresolved`, `keptAsText`, `attachments`, `skipped` and `notes` means nothing was lost. ## What it does not carry Attachment bytes, canvas edge sides, and hex colours. Each one is reported in the summary instead of being approximated: an image becomes its file name as text, an edge takes the default connector layout, and a colour the palette cannot name is listed under `colours.unmapped`. ## Test it The fixture vault in `packages/import-obsidian/fixtures/vault` covers a nested folder, frontmatter with tags and custom keys, a wikilink to a note in the vault, a wikilink to a missing note, a canvas with all four node types and two edges, and an embedded image. ```sh pnpm exec vitest run --project importer ``` No test reads a live vault. A new mapping row needs a fixture file that exercises it. # Observe a deployment > Where to look when something is wrong, and what each surface can and cannot tell you. Written for two people working on one box: the owner and whoever is… Where to look when something is wrong, and what each surface can and cannot tell you. Written for two people working on one box: the owner and whoever is helping them. Everything here reads CloudWatch, so it needs `AWS_PROFILE=suite` and `AWS_REGION=us-east-2`. ## The three surfaces | Surface | Answers | Does not answer | | -------------------------------- | -------------------------------------------- | ----------------------------- | | Logs (`suite-app`) | what happened, in order, with the request id | whether it is still happening | | Alarms (`Suite/*`, `AWS/*`) | is anything red right now | why | | `/metrics` via `deploy/alert.sh` | rooms, memory, connections at this instant | what led there | The panel at `/admin.html` is a fourth, narrow one: it answers “who is signed in, what have they claimed, and what has an operator changed”, and the activity list is the audit trail for every operator write. ## Start here ```sh AWS_PROFILE=suite deploy/cloudwatch/queries.sh # the saved questions AWS_PROFILE=suite deploy/cloudwatch/queries.sh errors 6 # the last six hours ``` Six queries are saved, because each has already been asked once under pressure: `errors`, `auth`, `admin`, `webhooks`, `relay` and `csp`. Each names the symptom it addresses in its own output. They run against the `suite-app` group, where every container’s JSON lines land under a stream named after the service (`app`, `control-plane`, `caddy`). A line is JSON with `level`, `msg`, `component` and, for a request, `path` and `status`. When two lines disagree about what happened, the `requestId` on both is how they are tied together. ## Alarms ```sh aws cloudwatch describe-alarms --profile suite --region us-east-2 \ --query 'MetricAlarms[].[AlarmName,StateValue]' --output text ``` Six alarms, and what each one means in practice: | Alarm | Fires when | Usual cause | | ----------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | | `suite-health` | the probe’s `/health` fetch failed | the relay is down, or a container is unhealthy | | `suite-missing` | the health metric has not reported | the probe is not running, or it cannot publish | | `ec2-cpu`, `ec2-status` | the instance is saturated or failing its own checks | a build on the box, or a host fault | | `rds-storage` | Postgres is short of free space | attachments or the update log growing | | `suite-certificate` | `CertificateDays` is under 14, or was never published | the certificate was not renewed, or the origin serves none | The last one treats missing data as breaching, on purpose: “no certificate” is exactly the state that took the site down for an afternoon, and it arrives as an absence rather than as a failure. `CertificateDays` in the `Suite` namespace is the sixth, and the one that was added after an outage taught us why: `deploy/cloudwatch/probe.sh` reads `notAfter` from the certificate the browser actually gets and publishes the days left. Without it, a spent ACME rate limit looked exactly like a healthy deployment, because the relay answered `/health` on loopback the whole time the public site was refusing connections. The lesson generalises: an alarm on the *service* cannot see a failure in the layer *in front of* it. TLS, DNS and the proxy are all that layer. ## What is not covered Client-side exceptions. The app has scrubbed, opt-in crash reporting built (`apps/canvas/src/crash/report.ts`) and no DSN configured, so nothing leaves a reader’s browser. A JavaScript error in a card editor is therefore visible only through the diagnostics bundle the app can copy, which is a deliberate choice while the deployment has two users and no error tracker. That is the first thing a hosted tracker buys. Until then, when a browser-only problem is reported, ask for the copied diagnostics: it carries the build, the route, the storage state and the last uncaught error, which is most of what a tracker would have shown. ## The metrics probe `deploy/cloudwatch/probe.sh` runs from `/etc/cron.d/suite` every five minutes and publishes `Suite/Health` and `Suite/CertificateDays`, exiting non-zero when a check fails. `SUITE_PROBE_HOST` names the certificate it reads (default `app.suite.run`), and no certificate at all publishes nothing, so the alarm’s `INSUFFICIENT_DATA` says so rather than a fake zero. It scrapes `/metrics` inside the relay’s namespace because the relay only answers a loopback source, and publishes from the host because the instance metadata is unreachable from inside that namespace. If `suite-missing` is in `ALARM`, run the probe by hand first, then check that `SUITE_METRICS_LOCAL=1` is in `.env` on the host. Without it `/metrics` answers 401 and the probe has nothing to publish, which is precisely how both alarms came to be red while the site was fine. # Operate a deployment > Suite runs as a single Node process with six SQLite stores on one directory; there is no external database and no multi-replica mode. Suite runs as a single Node process with six SQLite stores on one directory; there is no external database and no multi-replica mode. Provisioning is [deploy.md](./deploy.md). Every quota, its variable and the code that enforces it is generated into [reference/limits.md](../reference/limits.md); every variable is in [reference/environment.md](../reference/environment.md). ## Run it Node 22 or newer, since the server runs TypeScript through `tsx`. No build step. ```sh pnpm install pnpm --filter @suite/collab-server start # from the repo root ``` | Port | Env | Serves | | ---- | ------------- | ------------------------------------------ | | 1234 | `PORT` | Loro WebSocket sync (`/ws`) | | 1235 | `HEALTH_PORT` | HTTP API (`/api/*`), `/health`, `/metrics` | `docker compose up -d --build` runs the same server behind Caddy, which terminates TLS and proxies `/api/*` and `/ws`. A deployed host builds the image from its own checkout, so Node is only needed inside the container, never on the host: ```sh deploy/deploy.sh # build, pin SUITE_IMAGE, restart, verify SUITE_IMAGE=suite:v1.1.0 docker compose up -d --no-build app # start a tag that already exists ``` `compose.yaml` keeps both `image:` and `build:`, so the reference compose runs is a name for a local image: `suite:` once `deploy/deploy.sh` has written it into `SUITE_IMAGE`, and `suite:local` when `.env` names none. Nothing is pulled from a registry. The version is baked into the process as `SUITE_VERSION` and reported by `/metrics`, `/api/admin/status` and the `server ready` line, so a container names its release without `docker inspect`. Upgrades and rollback are in [deploy.md](./deploy.md) §9 and [release.md](./release.md). ### Boot-time validation `config/config.ts` parses every variable once, at boot, and fails with one message per problem instead of a half-configured process. It refuses a port that is not a number or that equals the other port; an unreadable number or a soft disk watermark above the hard one; `SUITE_HOSTED=1` without `SUITE_LICENCE_PUBLIC_KEY`; and, with `SUITE_REQUIRE_DURABLE_STORES=1` as compose sets it, any store that is unset, `:memory:`, relative, or outside the one directory the others share, naming each offender. Leave the flag unset locally, where `:memory:` is the point. ### Stores | Store | Variable | Holds | | ----------- | ---------------------- | ------------------------------------------ | | documents | `SUITE_DB` | Workspace snapshots and share projections. | | shares | `SUITE_SHARES_DB` | Share rows and draft baselines. | | workspaces | `SUITE_WORKSPACES_DB` | Owner-key hashes and claims. | | settings | `SUITE_SETTINGS_DB` | Runtime limit overrides. | | licences | `SUITE_LICENCE_DB` | Hosted licence claims. | | attachments | `SUITE_ATTACHMENTS_DB` | Uploaded image bytes. | All six live in one directory on the data volume. A document stores only an attachment id, so a lost attachments store means permanently broken images, and restoring one file without the others leaves links orphaned or a paid workspace unentitled. ### Secrets `.env` and `/etc/suite/*.env` are credential files: `chmod 600`, never committed. `SUITE_AUTH_SECRET` is all-or-nothing, since it reads every workspace’s metadata and links, deletes workspaces, and serves `/metrics` and `/admin`; rotate it by writing a new value and running `docker compose up -d app`. `SUITE_PROJECTION_TOKEN` and `SUITE_SOURCE_TOKEN` are worker credentials: set them on a deployed host, or each start generates new ones and a worker cannot reconnect. The hosted licence private key never goes on the relay. Tokens are compared in constant time, and `share`, `token`, `key` and `owner` are redacted from every log line. ## The images Two server images are built from one checkout: `runtime` for the relay and `control-plane` for accounts and billing. Both end at the same `server-base` stage, and three properties of that stage are load-bearing. **The sources stay TypeScript and the image runs `tsx`.** There is no second compile pipeline. `tsx` 4.23.15 is installed globally and pinned in `Dockerfile`, which is what lets `services/auth` keep exporting `./src/index.ts` instead of a `dist` nobody builds. The cost is one runtime dependency; the alternative was a build step for a package that has never had one, and two type graphs that `pnpm typecheck` would only gate one of. **No development dependencies.** `prod-deps` runs `pnpm install --prod --frozen-lockfile --filter @suite/collab-server... --filter @suite/control-plane...`, so the shipped tree is the production closure of the two servers and the workspace packages they import. TypeScript, Vite, Vitest, Biome, Stryker, Turbo, Storybook and Playwright are absent. Verify it rather than trust it: ```sh docker run --rm suite-relay sh -c 'ls node_modules/.pnpm | grep -E "^(typescript|vitest|@biomejs|@stryker|turbo)@" || echo clean' ``` **The workspace packages a server imports are copied, not assumed.** The runtime stage carries `packages/graph/dist`, `packages/licence/dist`, `services/auth/src` and `services/auth/sql`; the control-plane stage swaps the last for `apps/control-plane/src` and `services/billing/dist`. A missing one is not a slow start, it is `ERR_MODULE_NOT_FOUND` after the process has already printed a boot line. Both images run as `node` (uid 1000) and declare `/data` as the only directory they own. `compose.yaml` adds `read_only: true`, a `tmpfs` on `/tmp`, `cap_drop: ALL`, `no-new-privileges`, `pids_limit` and a memory/CPU cap, so the root filesystem cannot be rewritten by a compromised process. The commands that prove this, and their output, are in [deploy.md](./deploy.md#container-hardening). ## Watch it ### `/health` `GET /health` answers `200 {"ok":true}` on the API port. It is a readiness probe: no counts, no sizes. The compose healthcheck polls it. The control plane answers its own `/health` on `PORT` (1236 by default) with a little more, because the question there is different: `status` (`ok` or `degraded`), `buildId`, `uptimeMs` and one entry per route module (`mounted`, `absent` or `failed`). A failed module makes it `503`, so a monitor sees “the control plane is up but billing is missing” instead of a bare green tick. Both answers are point-in-time. Neither reports uptime history, and no page in the product invents an availability percentage: `/admin` → Support shows the last `/health` answer with the time it was asked. ### Support, and the diagnostics bundle `/admin` has a **Support** tab beside its other sections. It is the in-app route to support: a status statement read live from `/health`, the two ways to reach a person (an issue for anything that is not a vulnerability, `SECURITY.md` for anything that is), and one button that produces a file a ticket can carry. A crash screen offers the same bundle, which is the case that matters: the tab is broken, so the person cannot reach a panel. Both call `buildDiagnosticBundle` in `apps/canvas/src/shell/diagnostics.ts`. The bundle contains: build version and mode, the sanitised route, the redacted configuration (relay origin, canvas surface, session kind, online state, viewport, user agent, language), storage state and estimate, connection state, document counts, room and store statistics when the operator API supplied them, the last ten uncaught errors with up to twelve stack frames each, and up to ten `x-request-id` values this page saw. A request id is only useful because the relay now returns `x-request-id` on every response and logs the same value on its `http.request` line. The bundle deliberately excludes, and has no field for: the session JWT, any share token, the licence token, the admin token, cookies, card titles or bodies, attachment names and bytes, document snapshots, and every request and response body. It is built from an allowlist rather than filtered afterwards, and every string that came from outside, including an error message, a stack frame or a configured URL, is scrubbed twice: once when it is recorded and once when the bundle is written. A URL-shaped value in the configuration is reduced to its origin, because a query is not ours to keep. ### CSP violation reports `POST /api/report/csp` is the collector both app hosts name in `report-uri` and `report-to` (`deploy/Caddyfile`). It is public: a browser posts there from a page that may already be broken. Reports are capped at 16 KiB and 120 per address per minute, are answered `204` whatever happens, and become one `csp.violation` log line with the document URL and source file redacted. The policy itself stays enforcing. Nothing in this deployment sends `Content-Security-Policy-Report-Only`, and a downgrade to report-only is a deliberate change with a note, not a fallback. ### `/metrics` Prometheus text, no client library, gated like `/api/admin/*`: `404` when the relay has no control plane to verify a session against, `401` without an operator session, `200` with one, `429` above the admin rate limit. Never cached. A cron scrape has no browser and cannot hold a session, so `SUITE_METRICS_LOCAL=1` lets a request from a loopback address read it with no credential. That opens `/metrics` and nothing else, and a request that arrived through the proxy is never a loopback request. The relay samples event-loop lag every 50 ms and exports it as a histogram, because a document write is a full-snapshot upsert and the interesting question is how late the loop was, not how long one write took. Five samples in a row at or over `SUITE_EVENT_LOOP_LAG_MS` (default 100) log one line at `warn` with `code: "event-loop-lag"`, the run length and the document write counters for the same window, so the first thing an operator reads says whether writes are the cause. ```sh curl -s http://127.0.0.1:1235/metrics ``` | Metric | Type | Meaning | | ------------------------------------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `suite_build_info{version}` | gauge `1` | The release, for joining with a deploy. | | `suite_rss_bytes`, `suite_uptime_seconds` | gauge | Resident set size (absent when unreadable, never `0`) and seconds since start. | | `suite_memory_split` | gauge, `segment` | The same process split into `heap`, `external`, `arrayBuffers` and `rss`. Loro documents live in WASM linear memory, so they appear under `arrayBuffers`, not `heap`; this is the gauge that shows what a room actually costs. | | `suite_database_bytes` | gauge | Document-store bytes, the number the disk watermarks compare. | | `suite_disk_soft_bytes`, `suite_disk_above_soft` | gauge | Watermark in force (absent when unlimited) and whether the store is above it. | | `suite_open_rooms`, `suite_projection_rooms`, `suite_documents` | gauge | Rooms the sync server tracks; projections open now, bounded by `SUITE_PROJECTION_ROOMS_MAX`; stored documents. | | `suite_http_requests_total{class}` | counter | Responses on the API port, one sample per status class. | | `suite_share_creates_total`, `suite_publish_attempts_total`, `suite_publish_conflicts_total` | counter | Links accepted; publishes that reached the worker; of those, the ones refused on a conflict. | | `suite_claim_attempts_total{outcome}` | counter | `owner` (first claim or matching key) against `mismatch`. | | `suite_rate_limit_rejections_total`, `suite_projection_rebuilds_total`, `suite_projection_failures_total` | counter | `429`s from any limiter; rebuild passes requested, where coalesced saves share one; rebuilds that rejected. | | `suite_event_loop_lag_seconds` | histogram | How late the event loop woke up, per sample, in 5 ms to 10 s buckets. This is the number a reader of a board feels, and the one the save path is judged by. | | `suite_event_loop_lag_max_seconds`, `suite_event_loop_lag_breaches_total` | gauge, counter | The worst sample since start, and how many samples were at or over `SUITE_EVENT_LOOP_LAG_MS`. | | `suite_document_writes_pending`, `suite_document_writes_total` | gauge, counter | Writes accepted by the document store that are not on disk yet, and writes made durable. A pending count that does not return to zero is a stuck writer. | | `suite_projection_skips_total`, `suite_projection_build_capped_total` | counter | Rebuilds skipped because the source had not changed; rebuilds refused or frozen by the card or build-time cap. | | `suite_peer_limit_rejections_total`, `suite_document_limit_rejections_total`, `suite_room_backpressure_total` | counter | Joins refused by a per-room cap, and rebuilds refused because one room already held its work bound. Those are per room; the `429` counter is per address. | Labels are closed sets. No workspace id, share id, room id or token ever becomes a label, so a scrape cannot multiply series or copy a capability into a metrics store. ### Admin status With an operator signed in, `GET /api/admin/status` returns uptime, pid, version, `rssBytes` (`null`, never `0`, when unreadable), per-store sizes, room and client counts, and the limits in force. Its `projections` block carries `open`, `limit`, `opened`, `evicted`, `refused` and `documentBytes`; `opened` read just after boot is the boot cost. Without a control plane configured every `/api/admin/*` path answers `404`, and without an operator session it answers `401` (or `403` for a signed-in account that is not on `SUITE_ADMIN_EMAILS`). Read-only siblings: `rooms`, `shares?workspaceId=`, `workspaces?limit=&offset=`, `workspaces/:id`, `limits`. Behind the same door: `POST fixtures`, `GET workspaces/:id/export`, `PUT limits` and `POST rooms/sweep`. ### Projection rooms are opened on demand A room opens because somebody is looking at the link or because it must be written, never because a share row exists. Opening a link opens its room and skips the workspace’s other links; a publish, reload or draft status opens the one room it is about; an owner save refreshes only the rooms already open and closes any whose link was revoked. Boot connects and stops there. With 486 stored links over one 200-card board, boot went from 3.62 GB RSS and a 152 s ready time to 119 MiB and about 5 s, and opening one of those links costs 40 MiB. If `projections.opened` starts tracking your link count, rooms are being opened for existing links rather than for being read. The open set is bounded by `SUITE_PROJECTION_ROOMS_MAX`, default `128`. At the ceiling, the least-recently-used room with no connected session is closed, and the open is refused and counted when every room has a reader. `SUITE_ROOM_IDLE_HOURS` is off on a self-host and `24` when `SUITE_HOSTED=1`. It sweeps rooms that no owner save, publish or read has touched for the window and that have no connected session; `POST /api/admin/rooms/sweep` runs the same sweep immediately. Sweep before a backup, because a backup carries every live projection with it. ### The `/admin` panel `/admin` is a separate entry point behind the same bearer token, held in `sessionStorage` and never in a URL or `localStorage`. It lists workspaces with sizes and quota state, links with last-used and expiry, open rooms with their `documentBytes`, and the limits with their source (`default`, `env` or `override`); mutations are audited as `admin.mutation` lines. `POST /api/admin/fixtures` seeds a disposable workspace from a fixture, so a bug can be reproduced without customer data. The Templates section lists the starter templates the launcher offers, with the boards, edges and cards each one holds, and writes the control plane’s `templates` rows through `/admin/templates`: a template can be renamed, re-described, downloaded as JSON and deleted, and a downloaded file pastes back into the panel’s “New template…” form. ### Logs and alerts Every line on stdout is one JSON object: ```json {"ts":"2026-10-22T18:12:24.001Z","level":"info","msg":"http.request","requestId":"9f1…","method":"POST","path":"/api/shares/redacted","status":201,"durationMs":3} ``` * Fields are `ts`, `level`, `msg`, plus whatever the call site adds. One `http.request` line per response: `requestId`, `method`, `path`, `status`, `durationMs`. * `x-request-id` is reused when it is 1 to 64 safe characters, otherwise a UUID is generated. * Capabilities are redacted before the line is written; bodies, cookies and headers never are. * Startup logs one `server ready` line with the store inventory (path, bytes, document count, journal mode); shutdown logs `server stopping`. stdout is the only sink, so point it at your collector. There is no built-in rotation. `deploy/alert.sh` checks `/health`, the disk watermark and RSS, then exits non-zero with one `ALERT:` line. It exits `2` with `CANNOT CHECK:` when it cannot check at all: no token, a wrong token, or `/metrics` answering `404`. An alerting path that silently passes is worse than one that pages. `--json` prints the same result for a wrapper. ```cron */5 * * * * root /srv/suite/deploy/alert.sh >/dev/null ``` Any runner that can `curl` and read an exit code works: a hosted Prometheus with a bearer token, or a dead-man’s switch on the successful run. Suite ships no agent, no SDK and no call-home. ## Backups `deploy/backup.sh` snapshots all six stores with SQLite’s online backup, which stays consistent while the server runs and includes whatever is still in the `-wal` file, and it dumps the control plane’s Postgres database beside them. Accounts, organizations, invitations, starter templates and the panel’s settings live there; a snapshot without them restores the documents and leaves every account behind. The dump takes one of two shapes, and the script refuses to write a snapshot with neither: * **A compose service** (the local stack): `docker compose exec` into `postgres`, whose image ships `pg_dump`, so neither the host nor the app image needs a client. `SUITE_POSTGRES_SERVICE`, `POSTGRES_USER` and `POSTGRES_DB` name the service and role it uses. * **A managed database** (RDS, the hosted shape): there is no service to exec into, so set `SUITE_PG_DUMP_URL` and the dump runs in a throwaway `postgres:17-alpine` container against that URL. `restore.sh` reads the same variable, so the way back matches the way out. The manifest records the dump’s table count and checksum beside the stores: ```sh deploy/backup.sh # writes backups/suite-/ SUITE_BACKUP_BUCKET=s3://suite-backups SUITE_BACKUP_PREFIX=suite deploy/backup.sh /srv/backups ``` Per store it runs `sqlite3 ".timeout 10000" ".backup '.sqlite'"`, then `PRAGMA integrity_check`, a SHA-256, and a line in `manifest.txt` with the row count and the payload bytes of every table that holds blobs. The manifest also records a SHA-256 over the attachment ids in sorted order, so a restore can prove it restored the same set of images. `SUITE_BACKUP_BUCKET` is an `s3://bucket` target, `SUITE_BACKUP_PREFIX` is the key inside it (default `suite`), and `SUITE_BACKUP_ENDPOINT` points at R2, MinIO and friends. With a bucket set, each run uploads one tarball to `suite/daily/` and, on Sunday in UTC, to `suite/weekly/` as well. If the bucket is set and the CLI is missing, the script fails rather than leaving you with only a local copy. Retention belongs to the bucket, not the script: 7 daily and 4 weekly copies. Schedule it off-host. One cron entry is enough; the script fails loudly (non-zero, and with `SUITE_BACKUP_BUCKET` set it refuses to finish without an upload) so the same entry can back a dead-man’s switch: ```cron 15 3 * * * root mkdir -p /srv/backups && cd /srv/suite && \ SUITE_BACKUP_BUCKET=s3://suite-backups SUITE_BACKUP_PREFIX=suite \ SUITE_BACKUP_ENDPOINT=https://.r2.cloudflarestorage.com \ deploy/backup.sh /srv/backups >> /var/log/suite-backup.log 2>&1 ``` Rehearse the restore as well as the snapshot. `pnpm drill:backup` seeds six throwaway stores, runs the real backup script over them, copies the snapshot into a fresh directory and compares the row counts, so a snapshot that verifies but comes back empty fails the drill rather than at the moment it is needed: ```sh pnpm drill:backup # cleans up after itself pnpm drill:backup --keep # leaves the snapshot and the restored copy in a temp directory ``` Every run verifies itself before it is trusted: `PRAGMA integrity_check` per store, a SHA-256 per file, a row count per table, and a SHA-256 over the sorted attachment ids. A snapshot that fails any of those never reaches the bucket. ```sh aws s3api put-bucket-lifecycle-configuration --bucket suite-backups --lifecycle-configuration \ '{"Rules":[{"ID":"daily-7","Status":"Enabled","Filter":{"Prefix":"suite/daily/"},"Expiration":{"Days":7}}, {"ID":"weekly-35","Status":"Enabled","Filter":{"Prefix":"suite/weekly/"},"Expiration":{"Days":35}}]}' ``` ### RPO and RTO | | Value | Basis | | --- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | RPO | 24 h | The daily run above; the off-host copy is written in the same run, so both copies share the bound. A more frequent schedule lowers it proportionally; nothing else in the pipeline adds delay. | | RTO | 2.2 s (measured) | `deploy/restore.sh` over a live `app`: verify, stop, replace six stores, start, `/health` answered, in 2 192 ms on 2026-10-26. Snapshot: six stores, 132 KB, one workspace document, two attachments (31 KiB of bytes), warm host with the image already pulled. See `deploy/drills/attachments-record.md`. | Both numbers are replayed, not estimated. RTO is dominated by things this fixture does not have: pulling an image, provisioning a host, and the copy itself at production data size: a 10 GiB attachments store is a `cp` of 10 GiB. Treat the measured 2.2 s as the floor of the procedure and `deploy/restore.sh --verify-only` on the monthly schedule as the part that is actually rehearsed. Restoring at production size is **unmeasured**; it has no published number yet. Honest caveats: * The six snapshots are sequential, not one cross-file transaction. For a byte-exact point in time, stop `app` first. * `sqlite3` must exist where you run it. The runtime image does not include it, so run the script on the host against the bind mount. * Backups are unencrypted and hold every workspace in plaintext, every image, and share and owner hashes. Store them encrypted, off the data volume. * A snapshot taken before a store existed restores with a warning naming it, and never invents an empty database silently. ## Restore Stop the server first, because replacing a live database under a running process loses writes and sockets. `deploy/restore.sh` performs the whole sequence: ```sh deploy/restore.sh SNAP # refuses a non-empty volume deploy/restore.sh SNAP --force # a real restore over live data deploy/restore.sh SNAP --verify-only # the monthly drill: verify, write nothing ``` It resolves the compose volume from the `app` container (`SUITE_DATA_VOLUME` overrides it), checks the manifest’s sizes, SHA-256, per-table row counts and the attachment-id checksum, runs `PRAGMA integrity_check` on every file, stops `app`, deletes the six files and their stale `-wal` and `-shm` sidecars, copies the snapshot in, starts `app`, and waits up to 90 s for `/health`. Anything it cannot verify aborts before the volume is touched; a failure after the stop leaves `app` down and says so. By hand the order is the same: stop, delete `*.sqlite`, `*.sqlite-wal` and `*.sqlite-shm`, copy all six in, start, then ask the container for `/health` and `/api/admin/status`, since `1235` is not published. The drill is replayable: `deploy/drills/attachments.sh --record deploy/drills/attachments-record.md` uploads two attachments with different byte lengths, opens a seeded workspace, recreates the container, checks the ids, lengths and bytes are identical, backs up, restores into a second volume, and checks them again, including the workspace loading through the restored app. The last run, with its manifest excerpt and timings, is [the attachments drill record](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/deploy/drills/attachments-record.md). ## Upgrade and stored formats A build only reads what it can prove it understands. * Schema bumps are explicit: new DDL, the migration that fills it, an N−1 fixture under `apps/collab-server/src/fixtures/n-1/`, and a test that opens it. * Every snapshot blob starts with `loro-snapshot-v1`; untagged blobs are the pre-tag format and pass through untouched. An unknown tag throws `UnsupportedStoreVersionError` naming both versions, so a downgrade fails loudly instead of importing a document it may truncate. * `PRAGMA user_version` records the store schema in `DOCUMENT_STORE_SCHEMA`. A file above it is refused at boot with one piece of advice: upgrade the binary, or restore a backup taken on this build. * Reading a document rewrites nothing, so no backfill happens behind your back. An upgrade is a new image built and tagged on the host, and a rollback after a schema change is a restore. See [release.md](./release.md). ## Tune it ### Limits Quotas degrade a workspace; they never drop a save. A workspace over its document budget keeps loading and saving for its owner, but cannot mint new share links, and its next status call says so. Refusal happens only where nothing has been written yet: a new workspace, a new share link, a new session in a room that is at its peer or projection-byte ceiling. The numbers and the refusal codes are in [reference/limits.md](../reference/limits.md). The stream limits are per room rather than per address: `SUITE_MAX_PEERS_PER_ROOM`, `SUITE_ROOM_OUTSTANDING_MAX`, `SUITE_MAX_PROJECTION_CARDS` and `SUITE_PROJECTION_BUILD_MS`. Each one lets one boarding room degrade itself instead of the process: a join beyond the peer cap is refused before a session exists, a rebuild past the outstanding bound is refused rather than queued, a board over the card cap is not projected, and a room whose rebuild exceeded the build-time cap stops rebuilding until somebody reloads or publishes into it. Every one of them logs a line with a `code` field, so `grep '"code":"peer-limit"'` answers “why was I refused” without reading the relay. Resolution order is built-in defaults, then `SUITE_*` environment, then runtime overrides, resolved in `config/limits.ts`. An unreadable value is reported at boot and the layer below wins; a contradiction such as soft above hard stops the process with the reason. ```sh # Signed in as an operator: the browser panel sends this, and so does this command. curl -s -H "authorization: Bearer $OPERATOR_SESSION" http://localhost:1235/api/admin/limits curl -s -X PUT -H "authorization: Bearer $OPERATOR_SESSION" -H 'content-type: application/json' \ -d '{"maxWorkspaces":50,"projectionBytesPerWorkspace":67108864,"diskSoftBytes":"unlimited"}' \ http://localhost:1235/api/admin/limits ``` `GET` returns the limits, their `sources`, the env names, the overrides in force, `diskBytes` and any `problems`. `PUT` accepts only known keys, rejecting an unknown one with `400`, validates the result and audits every attempt. Values are whole bytes, `"unlimited"`, or `null` to clear one override. Overrides live in `SUITE_SETTINGS_DB`, so they survive a restart and are included in a backup. ### Fixed limits and rate limits | Limit | Value | Where | | ------------------------------------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------- | | JSON request body | 64 KiB | `http/request.ts` | | Claim, rotate, resolve, create and publish per minute | 600 / 10 / 300 / 30 / 20 | `http/rateLimit.ts`, per client address | | Room save interval | 2 s, coalesced | `sync/server.ts` (`saveInterval`) | | Expired-share sweep and idle projection-room sweep | 60 s each; the idle sweep is a no-op while `SUITE_ROOM_IDLE_HOURS` is `0` | `sync/server.ts`, `projection/sweep.ts` | | `PRAGMA optimize` | document store on start and hourly | `store/persistence.ts` | | Journal mode | WAL on the document and attachment stores | `store/persistence.ts`, `store/attachments.ts` | | Disk measurement cache | 1 s per process | `config/limits.ts` | | Document writes | batched per turn of the loop, on a writer thread; `flush` blocks on close | `store/persistenceWriter.ts` | | Event-loop lag sample, breach threshold, sustained run | every 50 ms, 100 ms, 5 samples | `ops/eventLoopLag.ts` | Rate limits are fixed-window counters in this process, keyed by client address. Behind a proxy set `SUITE_TRUST_PROXY=1` so they key on the real client. They blunt casual abuse, not a determined flood, so add request and connection limits in Caddy or your load balancer. ### Sizing and capacity One writer: all rooms save into one SQLite file through one Node process. CPU and memory scale with open rooms and their document size, and disk grows with Loro history, deleted-but-not-vacuumed pages and the WAL while editing is busy. A `-wal` that grows without limit means checkpoints are starving, usually from long readers or a stalled backup; a restart checkpoints on close. Never put the directory on a network filesystem, where SQLite locking and WAL shared memory both break. A document write is a full-snapshot upsert, so it runs on a writer thread rather than on the event loop (`store/persistenceWriter.ts`). The read path keeps a connection of its own, and a read that follows a save in the same tick answers from the accepted-write overlay, so `save` then `load` never returns the previous bytes. Ordering is the whole design: the host numbers every write before queueing it, one writer thread applies the batches in arrival order inside one transaction, and a writer that fails is replaced by inline writes on the main thread in the same sequence, never by a second writer. `close` blocks until the queue is on disk, so a graceful shutdown loses nothing; a `SIGKILL` can lose the last partial batch, which is the same window the 2 s save interval already had. A projection room is rebuilt whenever the owner’s workspace is saved, unless the source version vector has not moved since the last rebuild: the worker records the vector it built from, so a save that changes a card outside a share costs one comparison per open room. A rebuild past the card cap or the build-time cap freezes the room at its last good projection. Those are the two numbers that used to make one big board everybody’s problem. Measured with `pnpm bench` (`packages/graph/bench/projection.bench.ts`), a 500-card board rebuilds in about 0.6 s to 1.04 MiB, 2 000 cards in 3.4 s to 4.15 MiB, and 5 000 cards in 8.3 s to 10.39 MiB, while a 200-card selection stays under 1 s and about 550 KiB at any board size. A whole-board share of a very large board is the expensive case; going below these numbers needs the deferred index and card split (ADR-0021) rather than more tuning. `sync/roomsLoad.test.ts` converges 200 edits from five editors in about a second and fails only past 20 s. ### What a load run measured `apps/collab-server/bench/load.mjs` (`pnpm --filter @suite/collab-server bench:load`) starts a real relay on a seeded workspace, opens real share rooms with real peers, drives edits, and prints rooms, document size, peers, p50 and p99 latency, peak RSS and event-loop lag. The numbers it produced on this checkout, and the profile they were produced at, are in [`apps/collab-server/bench/report.md`](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/apps/collab-server/bench/report.md). Read the profile line before the numbers: a run at four rooms is not a run at the published concurrency. Two ceilings that report found, both outside the relay’s own code: * A peer receives a document in one Loro message, and `loro-protocol` caps a message at 256 KiB (`MAX_MESSAGE_SIZE`). A board whose snapshot is larger cannot be delivered to a joining client at all: the socket stays open and the document never arrives. On this schema that starts somewhere between a 240-card and a 300-card board, which is far below `SUITE_MAX_PROJECTION_CARDS`. Chunked snapshots are the fix, and they are not in this build. * A shared board lags its owner by one save interval plus one rebuild: the relay coalesces socket saves at 2 s (`saveInterval`), the projection debounce adds 200 ms, and the rebuild itself follows the measured numbers above. That is the p50 a reader of a share actually sees. Run `VACUUM` by hand, never on a schedule: after large deletions through the admin CLI, or when a store is much larger than `/api/admin/status` suggests. It rewrites the file, needs free disk roughly equal to its size, takes an exclusive lock and can take minutes, so back up first. ```sh docker compose stop app docker run --rm -v suite_suite-data:/data alpine sh -c \ 'apk add --no-cache sqlite && for db in documents shares workspaces settings licences attachments; do sqlite3 "/data/$db.sqlite" "VACUUM;"; done' docker compose start app ``` ## Failure modes * An over-quota workspace keeps its bytes. The budget is enforced by degrading, not deleting: new links stop and the owner is told to export. Watch `documentBytes` and `overQuota` in `/api/admin/workspaces`. * One replica only. Rooms, presence, projection state and rate-limit counters live in one process. Two containers on one volume corrupt locking; two volumes behind one hostname split workspaces. * Capability tokens travel in URLs. The server redacts them from its own logs, but proxies, CDNs and browser history can record them. Keep `Referrer-Policy: no-referrer`, which Caddy sets, and do not log query strings at the proxy. * There is no encryption at rest, and the admin API is all-or-nothing: one bearer token, no scopes. * `sqlite3` is not in the runtime image, so the online backup runs on the host or in a throwaway container. * A dead or wrong admin token makes `alert.sh` exit `2` rather than `0`. Fix the credential; do not ignore the page. ## Prove the mail transport without a sign-up Settings → mail → **Send a test message** sends one message with the transport the saved rows configure, the same composition the account service boots with. A `smtp` or `resend` transport that cannot be built answers with its own error, so a wrong password or a missing `SUITE_MAIL_FROM` shows up in the panel rather than in a stranger’s sign-up. The `console` transport answers as success and writes the message to the control-plane log, which is what it was asked to do. `POST /admin/mail/test` with `{"to": "you@example.com"}` is the same call, for a script. ## Prove the money path without a payment provider `pnpm drill:billing` drives a running stack over HTTP and asserts each state the licence gate can be in, then the one property that makes a mode switch safe. | Step | What it proves | | ---------------------------------------- | -------------------------------------------------------------------------- | | `health` | the stack answers | | operator sign-in, then `/api/auth/token` | the relay accepts a session and mints the JWT it verifies | | a fixture workspace | the drill owns what it touches | | trial, past due, active, cancelled | an expired row refuses sync, an absent row is a cancellation | | a webhook signed by the active mode | the signature path works | | the same body signed by the *other* mode | it is refused, so a test event cannot write a licence on a live deployment | It seeds a fixture workspace and deletes it afterwards, including after a failure, and it charges nothing: the licence row is written directly and the webhook body is signed by the script itself. ```sh SUITE_DRILL_EMAIL=you@example.com SUITE_DRILL_PASSWORD='…' pnpm drill:billing # or against another host: pnpm drill:billing --origin=https://app.example.com ``` Without credentials it runs the steps that need no session and names the ones it skipped, so a green drill with skips is not the same as a green drill. Two facts make the run possible: * **`SUITE_HOSTED=1`.** The licence store only opens in hosted mode, so a self-host relay has no row to write and answers `501` to the licence route. `pnpm setup` writes the hosted shape locally, with a generated `SUITE_LICENCE_PUBLIC_KEY`. * **Both webhook secrets.** The pinning step needs the set the mode is *not* using; without it there is nothing to refuse and the step says so. ## Alerts and logs A deployment’s alarms live in CloudWatch, and the stack’s logs go there with them. Three pieces: | Piece | What it does | | -------------------------------------- | --------------------------------------------------- | | `deploy/alert.sh --cloudwatch` | publishes `Suite/Health` = 1 or 0 on every cron run | | `deploy/cloudwatch/setup.sh --email …` | one SNS topic, five alarms, the address subscribed | | `deploy/compose.aws.yaml` | sends the three services’ stdout to one log group | ```sh # on a workstation, with the operator profile deploy/cloudwatch/logs.sh # the group, 30-day retention deploy/cloudwatch/setup.sh --email you@example.com ``` The five alarms: `suite-health` (the probe failed), `suite-missing` (the probe stopped reporting, which is the failure a probe-only alarm cannot see), `ec2-status`, `ec2-cpu` above 80% for fifteen minutes, and `rds-storage` under 2 GiB. The stack is started with the override on a deployed host, and without it on a workstation: ```sh docker compose -f compose.yaml -f deploy/compose.aws.yaml up -d aws logs tail "${SUITE_LOG_GROUP:-suite-app}" --follow ``` Cron is the half that has to exist for any of it to mean anything: ```cron */5 * * * * root /srv/suite/deploy/cloudwatch/probe.sh >/dev/null 2>&1 ``` `deploy/cloudwatch/probe.sh` exists because the two halves of that job need different network namespaces. The relay answers `/metrics` without a session only when the *source* is loopback, and a request through a Docker port mapping arrives from the bridge gateway, so the scrape has to run inside the relay’s namespace. Inside that namespace the EC2 metadata service is unreachable, so `aws` has no credentials to publish with. The script scrapes in the namespace, publishes `Suite/Health` from the host, and exits with the probe’s own code either way. `SUITE_METRICS_LOCAL=1` lets a loopback scrape read `/metrics` without a session, which is what a cron timer can do. The panel and a non-local scrape stay gated. ## What is audited Every control-plane act an operator takes writes a row to `operator_audit`: impersonation started, stopped or refused, a support grant given by the account or ended by an operator, a setting written or reset, a test message sent, a role worn or cleared. A refusal that reached an authorisation answer is kept, because “somebody tried” is the fact an incident review wants, and a 401 is not, because it names no actor. A secret’s value is never recorded, only the fact that it was replaced. The **Activity** view reads it, newest first, filterable by actor and action. Rows are kept forever with no retention policy yet; that is stated here rather than assumed. The relay’s own licence writes are audited by the relay, in its log, not in this table. The two processes keep two trails because they own two stores; the panel’s Activity view covers the control plane’s. # Release and roll back > A release is a tag, an image built from it on the host that serves it, the image id that identifies it, a smoke test against a host, and a rollback path. This… A release is a tag, an image built from it on the host that serves it, the image id that identifies it, a smoke test against a host, and a rollback path. This page is written against `1.1.0`; every release edits the tag and the id recorded below. ## What a release is | Thing | Value | | ------------- | ------------------------------------------------------------------------------- | | Version | `1.1.0` in `package.json` and the newest `CHANGELOG.md` heading | | Git tag | `v1.1.0` on the release commit | | Image | `suite:v1.1.0`, built on the host that runs it and also tagged `suite:local` | | Identity | the image id from `docker image inspect --format '{{.Id}}' suite:v1.1.0` | | Change record | the matching `CHANGELOG.md` section | | Evidence | the `pnpm verify` run and the security scan output, quoted in the release notes | `SUITE_VERSION` is baked into the image at build time, so a running container reports what it is: `version` in `/api/admin/status`, `suite_build_info{version="…"} 1` in `/metrics`, and the `server ready` line. The policy behind the scheme is ADR-0026 in the internal decision log. The lead creates the tag after integration. A workstream does not push one. ## Cut one 1. `package.json` and the newest `CHANGELOG.md` heading both say `1.1.0`. 2. `node scripts/docs-check.mjs` and `node scripts/generate-limits.mjs --check` are clean. 3. `pnpm verify` is green on the commit being tagged, and a full `pnpm e2e` passed on that same commit, in that order, with nothing already listening on `:5173` or `:1235`. A tag is the milestone where the full suite runs; the commands are in [test-and-verify.md](./test-and-verify.md). 4. `git tag -a v1.1.0 -m "Suite 1.1.0"`. 5. `git push origin v1.1.0`. If workflows can start, `.github/workflows/release.yml` runs three jobs: | Job | Does | Fails when | | ----------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `preflight` | Installs, compares `GITHUB_REF_NAME` with `package.json`, runs both docs checks | The tag and the version disagree, or a doc is stale | | `image` | Builds the `runtime` target with `SUITE_VERSION=` and pushes `:`, `:sha-<12>` and `:latest` to GHCR | The build fails | | `release` | Opens a GitHub release whose body is the changelog section plus the image digest and a rollback link | `CHANGELOG.md` has no section for the version | Nothing on this page depends on either workflow file. Every workflow on this account still ends in `startup_failure` with zero jobs, which is account state rather than workflow content, so the release image is built on the host that serves it. ### Build the image on the host The build argument is the tag, and the two `-t` names are what the host later rolls back to and runs: ```sh git checkout v1.1.0 docker build --target runtime --build-arg SUITE_VERSION=v1.1.0 \ -t suite:v1.1.0 -t suite:local . docker image inspect --format '{{.Id}}' suite:v1.1.0 ``` Record the image id in the release notes. A host-built image has no `RepoDigests`, so the id is the identity every later rollback names, and `docker image ls` shows it next to the tag. `deploy/deploy.sh` runs the same build with the tag at `HEAD`, plus the backup, the rollback tag and the restart. Do not tag unless the local gate is green on the exact commit. The tag is the only quality claim this project can currently make. ## Smoke test the release Against the host that will serve it, before announcing anything: ```sh sed -i -E 's|^#?[[:space:]]*SUITE_IMAGE=.*|SUITE_IMAGE=suite:v1.1.0|' .env docker compose up -d --no-build app # the new tag; Caddy keeps running docker compose ps # app healthy, caddy up curl -fsS https://$SUITE_DOMAIN/health # {"ok":true} # The operator API answers a session, so this runs on the box: SUITE_METRICS_LOCAL=1 lets a # loopback request read /metrics with no credential, and the version stamp lives there. curl -fsS http://127.0.0.1:1235/metrics | grep suite_build_info ``` `/api/admin/status` must report `"version":"v1.1.0"` and `suite_build_info` must carry the same tag, because `SUITE_VERSION` reaches the process verbatim. Then, by hand: open a workspace, upload an attachment, reload, open the same workspace in a second tab, and confirm the published limits still match what the server reports on a refusal ([reference/limits.md](../reference/limits.md)). A container that reports the wrong version is not a release, however healthy `/health` looks. ## Rollback Rollback restarts `app` on the tag taken before the upgrade. It needs no code change and no build, and on a host build it is the only rollback there is: a build overwrites `suite:local`, so the pre-upgrade tag is what names the previous image. 1. Find the tag. `deploy/deploy.sh --list` prints every `suite:rollback-*` on the host, and the release notes record the version each upgrade started from. 2. Point `app` at it and recreate that service only: ```sh deploy/deploy.sh --rollback suite:rollback-v1.1.0 # by hand: SUITE_IMAGE=suite:rollback-v1.1.0 docker compose up -d --no-build app ``` 3. Confirm `/health` answers and `/api/admin/status` reports the expected `version`. 4. If the release changed a stored format (a schema bump, a new store, a backup manifest change), the older binary may refuse the newer data. Stop `app`, restore the pre-upgrade backup with `deploy/restore.sh --force` ([operate.md](./operate.md)), then start the older image. An older binary against newer data is not a rollback. 5. Record the tag restored, its image id, the time, and whether a restore was needed, in the release notes. `caddy` never moves during a rollback. It serves a static bundle from its own image and proxies to `app`. ## Not in a release A release omits image signing and verification on pull, SBOM generation and attestation, multi-architecture images, self-hosted runners, and a staging environment or canary. There is one host and one tag. Adding any of them changes `release.yml` and should land with an ADR rather than by habit. # Test and verify a change > Suite runs three test runners: Vitest for the workspace projects, Playwright for end-to-end journeys, and node --test for @suite/billing, which sits outside… Suite runs three test runners: Vitest for the workspace projects, Playwright for end-to-end journeys, and `node --test` for `@suite/billing`, which sits outside the pnpm workspace. Commands run from the repository root unless stated. ## The gate Every change runs this, locally: ```sh pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check && pnpm docs:symbols && pnpm shape ``` None of those commands needs a runner or an account. `pnpm check` runs Biome, `tsc`, `vitest run --changed`, the `node --test` suite under `services/billing`, `pnpm docs:symbols` and `pnpm shape`, which is why it belongs after each edit batch; it leaves out `pnpm docs:check` and the full test run. `pnpm verify` adds the story suite, the build, and the two fast security scans (`security:deps` and `security:secrets`, both described below). The billing suite is in the edit loop because `pnpm test` cannot see it: those tests are `node --test` and Vitest does not collect them. A rename that reached `deploy/stripe/fixtures.json` and not the tests naming its tiers passed every fast run and only failed at `pnpm verify`, which is a bad trade for half a second. Playwright stays outside the gate. No contributor or agent starts it by default, and no change waits on a run. E2E runs when the owner asks for it, or at a commit milestone the owner names. A full `pnpm e2e` is a milestone action; when a run is asked for, an area script is the cheaper evidence. `ci.yml` in `.github/workflows` has valid YAML, and every run on this account ends in `startup_failure` with zero jobs, so no workflow is a check on `main`. A self-hosted runner or an entitlement change would make the workflow usable again. ## What each command runs | Command | Covers | Run it | | ------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | `pnpm check` | Biome, `tsc` across the workspace, `vitest run --changed`, the billing suite, docs symbols, shape | After each edit batch | | `pnpm lint` | Biome over the whole tree | The gate, and after a formatting sweep | | `pnpm typecheck` | `tsc` across every workspace package | The gate, and after a type-only change | | `pnpm test` | Every Vitest project | The gate, and before you report a result | | `pnpm docs:check` | Relative links and anchors, code fences, banned prose, generated indexes, limits | A prose change, or a change to a limit | | `pnpm docs:symbols` | Backticked paths, root scripts and ADR references that resolve | A page that names a command or a file | | `pnpm shape` | File length, observe call sites, raw Loro writes | A change under `apps/` or `packages/` | | `pnpm verify` | The gate plus the story suite, the build, `security:deps` and `security:secrets` | Before a commit you want fully checked, and on a tag | | `pnpm test:stories` | The Storybook suite under jsdom plus axe, in both themes | A `.stories.tsx` changed, or you touched a component a story renders | ## Security scans Four scans, one `pnpm` script each. Nothing here needs a GitHub workflow, a runner or an account, and each script exits non-zero on a finding, so the same command is a check and a gate. | Command | Reads | Needs | | ----------------------- | -------------------------------------------------------------------------------------- | ------------------------ | | `pnpm security:deps` | the registry advisories for `pnpm-lock.yaml`, then every package’s published signature | the network | | `pnpm security:secrets` | every file in the checkout, then the staged diff from the pre-commit hook | Docker | | `pnpm security:osv` | `pnpm-lock.yaml`, against the OSV database | Docker and the network | | `pnpm security:image` | the package set inside a built image | Docker and a built image | `security:deps` runs `pnpm audit --audit-level high` and then `pnpm audit signatures`. The second half checks that each tarball came from the registry that published it, which is the part a lockfile alone cannot tell you. A high or critical advisory, or a package with no verified signature, fails it. `security:secrets` runs gitleaks in a pinned image (`zricethezav/gitleaks:v8.28.0`) with the rules in `.gitleaks.toml`. That file allows the gitignored paths a working copy has (`.env`, `dist/`, `storybook-static/`) and names each fixture credential the tests use, one value at a time, so test files are not exempt as a class and a real key pasted into a test still fails. Findings print redacted, with the file, the line and the rule. The same script runs from `.husky/pre-commit` as `pnpm security:secrets --staged`, which scans the index: a credential stops the commit instead of becoming a commit that has to be rewritten out of history. `security:osv` runs OSV-Scanner (`ghcr.io/google/osv-scanner:v2.7.0`) against `pnpm-lock.yaml`. It reads a different feed from `pnpm audit`, so the two report different sets of packages and different fixed versions, and running both is the point. The switches: ```sh pnpm security:osv --download # copy the vulnerability database into ~/.cache/suite/osv-scanner pnpm security:osv --offline # scan with no network at all, from that cache pnpm security:osv --licences # also report packages outside the licence allowlist ``` `--offline` fails when the cache is empty rather than reporting nothing, so an offline run is evidence. `--licences` checks the packages against a list in the script (MIT, Apache-2.0, ISC, BSD, 0BSD, CC0, BlueOak, Python-2.0, Unlicense, Zlib) and it is opt-in: what it currently reports (MPL-2.0 through lightningcss, ten LGPL-3.0-or-later packages, two `UNKNOWN`) is a licensing decision, and a build gate is not where that decision is made. `security:image` runs Trivy (`aquasec/trivy:0.76.1`) against `suite:local`, or against the image named as its argument (`pnpm security:image ghcr.io//suite:v1.1.0`). It reports HIGH and CRITICAL only, and only findings with a fixed version, because a finding with no fix cannot be closed by rebuilding the image. It is a release-time command: `pnpm verify` leaves it out because it needs a built image. Build one with `docker build --target runtime -t suite:local .`. Docker is the only prerequisite for three of the four. A missing daemon stops the command with an instruction; no scan is skipped without saying so. `git commit --no-verify` is the only way past the hook. ## E2E on request An e2e run starts its own canvas dev server on `:5173` and a relay on `:1235`, with rate limits raised and a database per run. Every run passes through `e2e/scripts/preflight.mjs` first: if a port is already in use, the run stops before a browser opens and names the process holding it. | Command | Covers | | ------------------ | ----------------------------------------------------------------------------------------------------------------------- | | `pnpm e2e:smoke` | The specs tagged `@smoke`: boot, boards and sections, cards, links, selection, library, views, sharing, sign-in, collab | | `pnpm e2e:canvas` | Boot, boards, cards, links, selection, input, library, appearance | | `pnpm e2e:collab` | Collab, sharing, account | | `pnpm e2e:views` | Views | | `pnpm e2e:changed` | Playwright’s `--only-changed origin/main`: the specs touched since that ref | | `pnpm e2e` | Every spec, the milestone run | | `pnpm e2e:doctor` | Reports the processes holding the e2e ports, and stops nothing | | `pnpm e2e:stop` | Stops only those listeners | | `pnpm e2e:logs` | The tail of the captured Vite and relay logs under `e2e/.artifacts/` | | Variable | Default | Meaning | | --------------- | ------- | ----------------------------------------------------------------------------------------------------------- | | `CI` | unset | Selects the `github` reporter and retries a failed test once. Servers are always fresh outside UI mode. | | `SUITE_WORKERS` | `3` | Playwright browser workers. Lower it to isolate a timing failure; raise it only on a machine with headroom. | | `SUITE_TRACE` | unset | `on` records a trace for every test; anything else keeps `retain-on-failure`. | Three workers keep the two-browser specs from starving the other browsers on one relay. Artifacts land under `e2e/test-results//`: `trace.zip` on failure, `test-failed-1.png` at the moment of failure, and `video.webm` only when a retry happens. The reporter is `list` or `github`, so there is no HTML report unless you pass `--reporter=html`. ## Projects | Project | Runner | Scope | Command | | --------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | `graph` | Vitest, node | `packages/graph/src/**/*.test.ts` | `pnpm exec vitest --project graph` | | `importer` | Vitest, node | `packages/import-obsidian/src/**/*.test.ts`: the drop walk, markdown and frontmatter parsing, canvas colour mapping, and the vault as a whole | `pnpm exec vitest --project importer` | | `licence` | Vitest, node | `packages/licence/src/**/*.test.ts` | `pnpm exec vitest --project licence` | | `server` | Vitest, node | `apps/collab-server/src/**/*.test.ts` (`http/`, `store/`, `projection/`, `config/`, `sync/`, `ops/`) | `pnpm exec vitest --project server` | | `control-plane` | Vitest, node | `apps/control-plane/src/**/*.test.ts` | `pnpm exec vitest --project control-plane` | | `auth` | Vitest, node | `services/auth/src/**/*.test.ts` (`http/`, `identity/`, `invitations/`, `licensing/`, `organizations/`, `store/`) | `pnpm exec vitest --project auth` | | `canvas` | Vitest, jsdom | `apps/canvas/src/**/*.test.{ts,tsx}`, with `apps/canvas/src/test/setup.ts` | `pnpm exec vitest --project canvas` | | `e2e` | Playwright, chromium | `e2e/tests/**/*.spec.ts`, grouped by area (`boot/`, `boards/`, `cards/`, `links/`, `collab/`, `sharing/`, …) | `pnpm e2e` | | billing | `node --test` | `services/billing/src/**/*.test.ts` | `pnpm --filter @suite/billing test` | `pnpm test` is `vitest run` and covers every Vitest project in `vitest.config.ts`; `pnpm --filter @suite/billing test` runs the billing suite outside it. UI mode is the one e2e command that reuses a running server, so the window can stay open between runs. ## One test at a time ```sh # One unit file, or one test in it. pnpm exec vitest run packages/graph/src/schema.test.ts pnpm exec vitest run apps/collab-server/src/store/storeUpgrade.test.ts -t "refuses a store a newer build wrote" # One spec, or one test in it. Extra arguments reach Playwright. pnpm e2e e2e/tests/collab/collab.spec.ts pnpm e2e -g "undoes a removal from the board" ``` A single spec is the right tool while iterating: it boots the same servers as the full run, so a pass in isolation is real evidence. Treat it as weaker evidence when the failure depends on order or on state an earlier spec left behind. ## Watching a failing run `pnpm e2e:ui` opens a persistent Playwright window on : test tree, live browser pane, timeline, and a DOM snapshot per step. One window at a time, since the port is pinned; a second call fails with `EADDRINUSE`. Free it with `pnpm e2e:ui:stop`. `pnpm e2e:headed` runs the real suite in a visible browser, one worker (`--workers=1`), sequential and followable. Use it when the failure depends on execution order, and UI mode when you want to pause and inspect. `pnpm e2e:trace` opens the newest `trace.zip` from `e2e/test-results/`. Traces are kept on failure by default; `SUITE_TRACE=on pnpm e2e` records one for every test. `pnpm e2e:logs` prints the tail of the captured Vite and relay logs under `e2e/.artifacts/`. Read it when a run stops producing test results or a browser loses its server. The relay log names the request that failed; the Vite log names the file that triggered a reload. ## Reading a failure An e2e spec that passes alone and fails in the suite usually depends on order or timing, not on a server another spec started. Run `pnpm e2e:headed` with `--workers=1` to keep order and watch the sequence. A jsdom test that fails on a measurement is the wrong tool: layout, scrolling and `getBoundingClientRect` are zero there, so the assertion belongs in an e2e spec or a story. A graph test that fails only in the full run usually shares a seed or reuses a container across cases. A 15 s timeout means real work. A flaky test is a bug in the test. `--retries` exists for videos and traces when `CI` is set; do not use it to turn a red test green locally. ## Coverage `pnpm coverage` runs the whole Vitest suite with `@vitest/coverage-v8` and writes `coverage/` (`coverage/lcov-report/index.html` for the browsable view). `coverage.include` names four sources: `graph`, `licence`, `server` and `canvas`, so `importer`, `control-plane` and `auth` run without being measured. Of the measured four only `@suite/graph` is gated, through one glob key in `coverage.thresholds` at the root of `vitest.config.ts`. The others are printed, not gated: their uncovered lines are socket, SQLite and entry-point wiring that e2e already drives, and a threshold would invite assertion-free tests. ## Mutation testing Stryker asks whether a test would notice if an expression were wrong. It is a diagnostic for choosing the next test, never a score to raise. ```sh pnpm mutation:quick --mutate packages/graph/src/views.ts # the normal way to try one file pnpm mutation:server # licence, limits, config pnpm mutation # the full graph run, minutes not seconds ``` Read `reports/mutation/.html`. A survivor is a missing behaviour, an equivalent mutant, or tool noise. Missing behaviour: add a test through the public API. Equivalent mutant: a `// Stryker disable next-line` comment with the reason. Never close one with `expect(result).toBeDefined()`. ## Property-based tests Five fast-check suites exist, and they are where “it must hold for every input” belongs: | Suite | Property | | ------------------------------------------------------- | ---------------------------------------------------------- | | `packages/graph/src/sections.property.test.ts` | derived section membership under arbitrary placement moves | | `packages/graph/src/merge.property.test.ts` | concurrent edits converge regardless of order | | `packages/graph/src/search.property.test.ts` | query semantics hold across generated workspaces | | `packages/licence/src/property.test.ts` | licence parsing rejects every mutation | | `apps/collab-server/src/config/limits.property.test.ts` | limit resolution order holds for any layer mix | They run inside `pnpm test`. Add a case to the matching suite rather than a table of examples when the invariant is the thing worth pinning. ## Visual baselines `e2e/tests/visual/visual.spec.ts` keeps a small curated set of layout snapshots as committed PNGs under `e2e/tests/visual/`, so a run needs no vendor service. Story surfaces are served from the built Storybook on `:6006`; the app states run against the normal `webServer`. ```sh pnpm --filter @suite/e2e e2e:visual # check the baselines pnpm --filter @suite/e2e e2e:visual:update # rewrite them ``` `--update-snapshots` is the only supported way to change a baseline, and never the first thing to try. A failure means the pixels moved: read `-diff.png` and `-actual.png` in `test-results/` and decide whether the movement is the change you intended. # Try the paid flow on this machine > The whole product runs in five containers on one machine: the relay, the control plane, Postgres, Caddy, and the docs site. This page walks the parts that need… The whole product runs in five containers on one machine: the relay, the control plane, Postgres, Caddy, and the docs site. This page walks the parts that need a human (signing in, the operator panel, and paying with a Stripe test card) against that stack rather than against a deployment. The sandbox keys are the only thing this needs from outside. Everything else is in the repository. ## 1. Set the three variables that make it a local stack `.env` is read by `docker compose`. Three values decide whether a local run works: ```sh SUITE_DOMAIN=localhost # Caddy's internal CA, not Let's Encrypt SUITE_ADMIN_EMAILS=you@example.com # who may use the operator surface STRIPE_SECRET_KEY=sk_test_... # the sandbox key STRIPE_WEBHOOK_SECRET=whsec_... # printed by `pnpm stripe --listen`, step 4 STRIPE_PRICE_TIERS='{"price_…":{"tier":"personal",…}}' # printed by `pnpm stripe` SUITE_BILLING_ENABLED=1 ``` `SUITE_MAIL_TRANSPORT` stays unset: the console transport writes every message to the control plane’s log, which is where the magic links and the verification links are read from in step 2. ## 2. Bring it up ```sh docker compose --profile docs up -d --build docker compose ps # five services, four healthy and one running ``` Caddy serves `https://app.localhost`, `https://docs.localhost` and `https://suite.localhost` with its own certificate authority. A browser warns once and can accept it. `curl` needs the address pinned and the warning ignored: ```sh curl -sk --resolve app.localhost:443:127.0.0.1 https://app.localhost/health ``` Accepting the warning by hand is enough for a look. A headless browser or a tool that refuses an unknown authority needs the root itself, which Caddy keeps in its volume: ```sh docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt ``` `SUITE_DOMAIN=canvas.example.com` in a local run makes Caddy ask Let’s Encrypt for a domain this machine does not own; every host then answers nothing while it retries. ## 3. Become the operator An operator is an **address**, not a token, so the account has to exist before its address can be trusted: 1. Open `https://app.localhost` and create the account with the address in `SUITE_ADMIN_EMAILS`. 2. Read the confirmation link out of the log: ```sh docker compose logs control-plane | grep "auth mail" | tail -1 ``` The line is JSON and the URL is in `line`. Open it. 3. Sign in. Then open `https://app.localhost/admin.html`; the panel says whether this account is an operator. If it says it is not, the variable did not reach the process — it is read through a whitelist (`services/auth/src/http/env.ts`): an empty value there mints `operator: false` rather than refusing to boot. The panel is where accounts are admitted (`SUITE_ADMISSION_REQUIRED=1`) and where starter templates are written. `https://app.localhost/metrics` is a scrape, and needs either an operator session or a request from inside the container while `SUITE_METRICS_LOCAL=1`. ## 4. Forward Stripe’s webhooks through Caddy The control plane publishes no port: in a deployment, Caddy is the only way in, so the local stack is the same shape. ```sh pnpm stripe # creates or reuses the two prices, prints STRIPE_PRICE_TIERS pnpm stripe --listen https://app.localhost/api/auth/stripe/webhook ``` `--listen` adds `--skip-verify` for a `localhost` target, because the certificate is Caddy’s own and the CLI has no way to trust it. The CLI also resolves names itself and does not treat `*.localhost` as loopback, so add one line to `/etc/hosts` first, or every delivery answers `no such host`: ```sh echo '127.0.0.1 app.localhost docs.localhost suite.localhost' | sudo tee -a /etc/hosts ``` Copy the `whsec_…` it prints into `STRIPE_WEBHOOK_SECRET` and restart the control plane: ```sh docker compose up -d control-plane ``` Leave the listener running. A delivery that arrives while it is down is retried by Stripe, but the sandbox forgets quickly, so a missed one is easiest to reproduce with a fresh purchase. ## 5. Pay with a test card Sign in as any account, open the workspace menu, and the Plan tab: the two plans come from `STRIPE_PRICE_TIERS`, so a plan that is missing there is missing from the page. | Field | Value | | ----------------- | --------------------- | | Card number | `4242 4242 4242 4242` | | Expiry | any future date | | CVC | any three digits | | Name and postcode | anything | The Checkout page is Stripe’s own, so no card detail reaches this origin. It also carries a **promotion code** field: a percentage or 100% coupon made in the sandbox dashboard is how a comp is given, and a 100%-off code is the fastest way to test the whole flow without money moving. ## 6. Check what the payment did Three places, in increasing order of directness: ```sh # 1. the webhook arrived and was verified docker compose logs control-plane | grep stripe/webhook # 2. the subscription row exists docker compose exec postgres psql -U suite -d suite -c 'select plan, status, "referenceId" from subscription' # 3. the entitlement the relay will enforce curl -sk --resolve app.localhost:443:127.0.0.1 \ -X POST https://app.localhost/api/billing/entitlement \ -H 'content-type: application/json' -d '{"organizationId":""}' ``` The organisation id is in the `subscription` row above. The Plan tab shows the same answer, and it comes from that read rather than from a copy in the browser. A renewal or a cancellation is easiest to see with a test clock: ```sh pnpm stripe --clock ``` It prints the customer and clock commands, including how to advance a month. ## 7. What this cannot test yet The account is still created by the sign-up form and the plan is bought afterwards. [ADR-0083](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0083-the-account-is-created-by-paying.md) records the door that replaces it: anonymous Checkout, then the account created from the completed session. Its `Remaining` list is the work. Until that lands, “buy without an account” has no path, and step 5 starts from a signed-in page. ## 8. The same walk on the deployment The hosted stack is `https://app.suite.run`, and it differs in three ways: the webhook is registered in the Stripe dashboard rather than by a CLI listener, the database is RDS rather than a local Postgres, and the promo code is already made. Prerequisites, all already true: `SUITE_BILLING_ENABLED=1`, `SUITE_BILLING_MODE=test`, the two test price ids in `billing.stripe.prices.test`, and a webhook endpoint for `https://app.suite.run/api/auth/stripe/webhook`. The mode and the catalogue are on the operator panel’s Settings tab, which is the answer to “are we live?” without an SSH session. 1. Sign in at `https://app.suite.run` and open a workspace. 2. The account menu, then Plan. Two plans, `personal` and `organization`, come from the deployment’s `billing.stripe.prices.test`; a plan missing from that page is missing from the setting. 3. Choose a plan. Stripe Checkout opens on Stripe’s own origin, so no card detail reaches us. 4. Apply the promo code `SUITE-TESTER`: 100% off, 20 redemptions, made for exactly this walk. A test card (`4242 4242 4242 4242`, any future expiry, any CVC) also works. 5. Back in the app, the Plan tab should show the tier and its limits, because that read comes from `/api/billing/entitlement` rather than from a copy in the browser. 6. Open a workspace that was capped before. The sync control should stop offering plans and claim the workspace instead, which is the whole paid loop landing. 7. Billing portal, then cancel. The entitlement should return to the free row, and the next claim should be capped again. When a step does not do what this page says, the log is the first place to look: ```sh AWS_PROFILE=suite deploy/cloudwatch/queries.sh webhooks 1 AWS_PROFILE=suite deploy/cloudwatch/queries.sh errors 1 ``` `webhooks` shows the delivery and its signature check. A rejected signature while in test mode usually means `billing.stripe.webhookSecret.test` was replaced by a different endpoint’s secret. ## 9. Put it down ```sh docker compose --profile docs down # keeps the volumes docker compose --profile docs down -v # forgets the accounts and the documents ``` # Reference > Facts to look up: the data model, the commands, the quotas, the security boundary. Facts to look up: the data model, the commands, the quotas, the security boundary. | Article | What it covers | Updated | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [Interface](./ui.md) | How the shell, the library, the panels, the styling surfaces and the command palette are assembled, and the focus, keyboard and theme rules they share… | 2026-10-28 | | [Environment](./environment.md) | Every variable the relay reads, its default, and what it changes. apps/collab-server/src/config/config.ts parses them once at boot and refuses to star… | 2026-10-28 | | [Billing and licences](./billing.md) | Billing is @better-auth/stripe registered on the auth instance (ADR-0078). The plugin owns the Stripe customer, Checkout, the billing portal, the webh… | 2026-10-28 | | [Limits](./limits.md) | What one Suite instance accepts, and what happens at the boundary. The numbers below are read from the code that enforces them, so they cannot drift f… | 2026-10-28 | | [Canvas](./canvas.md) | apps/canvas/src/canvas turns the workspace document into a board you can point at: placements become nodes, edges become routes, and every gesture end… | 2026-10-28 | | [Security model](./security-model.md) | Two credentials open a room: a session JWT for an account and a share token for a link. The owner key that used to be the third was deleted (ADR-0076)… | 2026-10-28 | | [Runtime](./runtime.md) | The browser process: boot order, persistence, the relay, sharing, and the diagnostics an operator can read. It does not cover the document model (data… | 2026-10-28 | | [Capacity](./capacity.md) | What one relay holds, what one more open room costs, and where the CPU stops keeping up. The numbers on this page are measured on this checkout by app… | 2026-10-27 | | [Glossary](./glossary.md) | One definition per term, in the sense this repository uses it. The model behind them is architecture.md, and the code identifiers are listed beside ea… | 2026-10-26 | | [Architecture](./architecture.md) | Suite is a local-first collaborative canvas. One Loro document per workspace is the source of truth, @suite/graph validates every write, and the React… | 2026-10-26 | | [Data model](./data-model.md) | This page does not cover how the canvas draws these records or how a share link is authenticated; architecture.md and security-model.md own those. It… | 2026-10-26 | | [Editing](./editing.md) | How card text, fields, templates and reference chips are stored and edited; the document model underneath is in architecture.md. | 2026-10-26 | | [Coding conventions](./conventions.md) | The rules as rules. The model they serve is architecture.md. The procedure that applies them is ../how-to/add-a-feature.md, and the gate is ../how-to/… | 2026-10-26 | # Architecture > Suite is a local-first collaborative canvas. One Loro document per workspace is the source of truth, @suite/graph validates every write, and the React canvas… Suite is a local-first collaborative canvas. One Loro document per workspace is the source of truth, `@suite/graph` validates every write, and the React canvas renders a projection of that document. A card exists whether or not a board displays it; a board is a view that places cards. This page is the map. Each subsystem has its own reference page, and the reasoning behind a choice is recorded in the internal decision log, which is not part of the deployed docs. ## Three rules 1. Offline works. The network accelerates sync; it is not required to open or edit a workspace. 2. Loro owns the document. The UI reads through `@suite/graph` and writes through typed methods, never raw Loro. 3. Every write is validated. Zod schemas in `packages/graph/src/schema.ts` parse at the boundary before a value reaches the CRDT. ## Repo map | Path | Responsibility | | -------------------- | ------------------------------------------------------------------------ | | `apps/canvas` | React 19 canvas, Tiptap editors, panels, launcher, Zustand UI state | | `apps/collab-server` | Loro WebSocket relay, HTTP API, SQLite stores, projection worker | | `packages/graph` | Schema, records, typed CRDT layer, pure planning and projection | | `services/auth` | Identity, organizations, invitations, seats, licence checks | | `services/billing` | Stripe webhook, hosted licence issue | | `apps/control-plane` | Accounts, billing and the Postgres they share, in one process of its own | | `apps/docs` | Starlight site built from `docs/` | | `apps/site` | Marketing page for `suite.run` | | `e2e` | Playwright journeys, grouped by area | | `scripts` | Docs, limits, shape and docs-symbol checks | The canvas app starts at `/` with a workspace launcher. A workspace open uses `?workspace=` (`?doc=` is a legacy alias), and a share link uses `?share=`. ## Data flow ```plaintext pointer / keyboard │ ▼ React surface ── typed method ──► WorkspaceDocument ── Zod ──► LoroDoc ▲ │ │ ▼ └──── snapshot store ◄──── observe ─┘ ``` `WorkspaceDocument` is the only writer. It exposes a facade over domain modules in `packages/graph/src/loro/`, and each mutation validates with the schema before it commits. `apps/canvas/src/store/workspaceSnapshotStore.ts` owns the one `WorkspaceDocument.observe` call site; components read stable records from that store through selectors. Rich text lives in `content` and `titles` containers. The snapshot store exposes `subscribeCardChanges` for consumers that need text edits, so no other module subscribes to the document itself. Zustand holds UI state only: selection, active board, open panels, connection status, transient gestures. Document truth never lives there. ## Subsystems | Page | Covers | | ---------------------------------------- | -------------------------------------------------------------------------------------- | | [data-model.md](./data-model.md) | Roots, record shapes, leaf writes, text containers, references, publish and projection | | [canvas.md](./canvas.md) | Boards, placements, sections, links, gestures, camera, presence | | [editing.md](./editing.md) | Tiptap, title/body, fields, templates, cards, references, undo | | [ui.md](./ui.md) | Shell, library, panels, dialogs, styles, command palette, focus, accessibility | | [runtime.md](./runtime.md) | Boot order, persistence, relay, shares, authorization, attachments, diagnostics | | [security-model.md](./security-model.md) | Trust boundary, credentials, capabilities, what a share exposes | | [billing.md](./billing.md) | Stripe events, licence claims, hosted mode | | [environment.md](./environment.md) | Every variable the relay and services read | | [limits.md](./limits.md) | Every quota, default and enforcing function | | [conventions.md](./conventions.md) | Code shape, naming, errors, tests | | [glossary.md](./glossary.md) | Terms used across the pages | ## Toolchain pnpm workspaces plus Turborepo. One package per bounded surface, so `@suite/graph` tests without React and the relay tests without a browser. Biome handles lint and format. Vitest covers graph, server, auth and canvas. Storybook runs the visual and accessibility stories. Playwright covers end-to-end journeys. Billing sits outside the pnpm workspace and runs under `node --test`. Commands and tiers are in [../how-to/test-and-verify.md](../how-to/test-and-verify.md). Deployment is [../how-to/deploy.md](../how-to/deploy.md). ## Status Local persistence, accounts, live collaboration, share links, publishing and billing are implemented. A browser with no session works locally and syncs nothing; a signed-in account has a personal organization from its first minute. Two processes are the target shape, behind one Caddy. The relay (`apps/collab-server`) keeps the socket, its SQLite stores and the projection. A control plane (`apps/control-plane`) hosts Postgres, identity and billing, and is the only process that depends on `pg`: it mounts `services/auth` at `/api/auth/*`, `services/billing` at `/api/billing/*` and `/stripe/*`, and the relay imports neither. The canvas is a static bundle, so a deployment serves three hosts from that pair (`deploy/Caddyfile`). The credential is a session JWT. The relay verifies it against the key set the control plane publishes at `/api/auth/jwks`, keeps that set in a five-minute cache, and answers a join from the token’s claims: `apps/collab-server/src/sync/seatGate.ts` needs no database read. The owner key is gone ([ADR-0076](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0076-the-owner-key-is-deleted.md)), and a share link stays a separate capability for a visitor with no account. [security-model.md](./security-model.md) states what that means for a deployment. The product direction and the decisions that are settled are in [../explanation/design.md](../explanation/design.md). # Billing and licences > Billing is @better-auth/stripe registered on the auth instance (ADR-0078). The plugin owns the Stripe customer, Checkout, the billing portal, the webhook, the… Billing is `@better-auth/stripe` registered on the auth instance (ADR-0078). The plugin owns the Stripe customer, Checkout, the billing portal, the webhook, the subscription row, trials and seat sync. This repository supplies the plan list, the organization rule and the entitlement read. **Who can buy is a separate question from what buying unlocks.** Today an account is created by the sign-up form and a plan is bought afterwards; [ADR-0083](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0083-the-account-is-created-by-paying.md) records the door that replaces it: anonymous Checkout, then the account, organization and membership created from the completed session, and lists what is still missing. Until that lands, the plan surface below is reachable only by an existing account, and the sync refusal is what tells a reader that a plan is the next step. A **promotional code** is a Stripe coupon, not a licence grant. Checkout is created with `allow_promotion_codes`, so the field appears on Stripe’s own page and an operator makes a percentage or 100% discount in the dashboard. This process holds no second table describing what somebody paid, which is the same reason ADR-0078 deleted the hand-written store. A licence is a signed token. A self-hosted relay reads one from `SUITE_LICENCE` and verifies it at boot ([ADR-0081](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0081-a-self-host-licence-arrives-in-the-environment.md)); a hosted relay reads none, because its proof is the session JWT the auth instance signs (ADR-0075). Local editing consults neither. ## What the plugin owns The plugin is registered inside the auth instance, so its routes sit under `/api/auth/*` and the auth module answers them. The control plane mounts that module for the prefix and mounts the billing module for `/api/billing/*`; neither duplicate the other. | Route | Method | Job | | ------------------------------------------- | ------ | ---------------------------------------------------- | | `/api/auth/subscription/upgrade` | `POST` | Checkout for a plan | | `/api/auth/subscription/billing-portal` | `POST` | Stripe’s own portal | | `/api/auth/subscription/cancel`, `/restore` | `POST` | End or resume a subscription | | `/api/auth/subscription/list` | `GET` | The live subscription for a reference | | `/api/auth/stripe/webhook` | `POST` | Every Stripe event, signature verified by the plugin | | `/api/billing/entitlement` | `POST` | `{ tier, limits }` for an organization | | `/api/billing/plans` | `POST` | The plan catalogue with its limits | `services/billing` contains `createBillingPlugin` (the plugin options), the tier table and the entitlement read. The hand-written webhook handler, checkout call, signature check, event claim, Postgres store, licence issuer and SES mailer that used to do this work are deleted rather than kept beside the plugin. Two paths writing subscription state is the failure this decision removes. ## The organization is the customer A subscription belongs to an organization, because a workspace is owned by one and seats come from its member rows. Checkout therefore sends `customerType: "organization"` and a `referenceId` holding the organization id, and the plugin stores that id on the `subscription` row. The plugin defaults `customerType` to `user`, which would bill an individual and put subscription state outside the tenant. Two callbacks close that: * `authorizeReference` asks the deployment whether the signed-in account administers the referenced organization, on checkout, the portal, cancel and restore. * `getCheckoutSessionParams` refuses a checkout whose reference the account does not administer, and writes the organization id onto the Checkout Session as `client_reference_id` and `metadata.organizationId` so the Stripe dashboard names the same tenant support does. `organization: { enabled: true }` is what gives an organization its own Stripe customer. The plugin also hooks the organization plugin: a rename updates the customer, a member change syncs the seat quantity when the plan carries `seatPriceId`, and deleting an organization with a live subscription is refused. ## Plans and limits `STRIPE_PRICE_TIERS` maps price ids onto tiers, and the plugin receives it as its static plan list. Nothing else holds a limit: the plugin’s `plans[].limits`, the entitlement read and the deployment’s configuration all come from this one table. ```json { "price_1PAbCdEfGhIjKl": { "tier": "personal", "seats": 1, "workspaces": 3, "documentBytes": 268435456, "attachmentBytes": 1073741824 }, "price_1PAbCdEfGhIjKm": { "tier": "organization", "seats": 1, "workspaces": 25, "documentBytes": 2147483648, "attachmentBytes": 8589934592 } } ``` The plugin sells one plan per tier name. Two prices naming one tier, as a monthly and an annual do, collapse to the first price in the table. A price missing from the table is a configuration failure: the customer paid, so the deployment has to be fixed rather than granting a free plan. `seats` is how many people the plan admits, the owner included. `seats` is not part of `limits`: the relay enforces the three caps against a document, and a seat count bounds member rows, so the two numbers have different readers. ## Seats in v1: one per plan Both hosted plans sell one seat, so an organization on a paid plan has its owner and no second member. `personal` is $9 a month and `organization` $24, and **Organization buys capacity, not people**: 25 workspaces and 2 GiB of documents, still for one person. The site says so where a buyer reads the price, because a plan called “Organization” that admits one member has to be honest about what it is selling (ADR-0082). The number is enforced, not asserted. `planList` refuses a table that declares more than one seat, because there is no per-seat price to charge for the rest: ```plaintext plan "team" sells 2 seats, and this build sells 1: there is no per-seat price to charge for the rest (ADR-0082) ``` `@better-auth/stripe` writes `subscription.seats` from the member count for any plan that carries a `seatPriceId`, and `licenceRowFor` copies that quantity into `organization_licences.seats`, which `checkSeatAllowance` treats as a ceiling on the organization’s members. Quantity and ceiling are then the same number, so the join that would raise the quantity is the join the ceiling refuses. Seat pricing needs a purchased quantity above the current headcount, and that is a change to the admission rule in `@suite/auth` as much as to this service. ## The entitlement read `POST /api/billing/entitlement` takes `{"organizationId":"org_1"}` and answers `{"tier":"personal","limits":{"workspaces":3,"documentBytes":268435456,"attachmentBytes":1073741824}}`. It reads the plugin’s `subscription` row and the tier table, and calls Stripe for nothing: a payment provider outage must not stop hosted sync for customers who already paid. | Subscription status | Answer | | ------------------------------------------------------------------ | -------------------------------------------------------- | | `active` | the plan’s tier and limits | | `trialing` | the plan’s tier and limits | | `past_due` | the plan while `periodEnd` is in the future, then `free` | | `canceled`, `unpaid`, `incomplete`, `incomplete_expired`, `paused` | `free` | | no row for the organization | `free` | | a status a later Stripe version adds | `free` | A cancelled subscription answers `free` on the next read. The row is the evidence, and a session token lives five minutes, so a wrong answer there is free service for as long as the customer keeps minting. `active` with `cancelAtPeriodEnd` set stays on the plan: the period is already paid, and the row turns `canceled` when it ends. `past_due` keeps the plan only until `periodEnd`, which bounds the grace window to the period Stripe last collected for. ## The born-with licence, and the wall The entitlement read answers `free` for an organization with no live subscription, and the seat check reads a different row to decide what that organization may hold: `organization_licences` in the control-plane database. The two agree about the tier label, and the licence row is the one with numbers on it. **Local and offline use is free and needs no account**, so a hosted account exists to buy sync. Every organization is therefore born with a licence that grants the owner’s seat and *no workspace*: migration `0009_free_licence_provisioning.sql` writes the row in the organization’s own transaction through a trigger, and `0011_no_free_workspace.sql` sets its numbers to zero workspaces, zero document bytes and zero attachment bytes. `FREE_LICENCE_TIER` in `services/auth/src/organizations/freeTier.ts` is the same object, and `pnpm db:verify` compares the row the trigger writes with it. The one number that is not zero is the seat, because a seat *is* a `member` row: an organization whose owner cannot be a member cannot exist. Zero seats would repeat the provisioning bug `0009` fixed; zero workspaces is the intended wall. `POST /api/workspaces/claim` is the only door from a local workspace to an owned one, and it reads this row, so the wall sits there. The refusal is `plan-required` with a **402**: the request was well formed and the account is real, it just has no plan behind it. The app turns that code into the plan picker. `workspace-limit` (403) is the neighbouring case: a plan that names workspaces and has spent them. That row is a default, and it is replaced rather than joined. `organization_licences` is keyed by `organization_id`, so one organization holds exactly one licence: a second row that would outrank the free one cannot exist. ### The projection `@better-auth/stripe` is the only writer of the `subscription` row, so its lifecycle hooks are the only notification that a subscription changed. `createLicenceProjection` (`services/billing/src/licence/projection.ts`) is called from `onSubscriptionCreated`, `onSubscriptionComplete`, `onSubscriptionUpdate` and `onSubscriptionDeleted`, and `apps/control-plane/src/modules/auth-billing.ts` is where it is built: the one file that knows both packages, because the free tier’s numbers live in `@suite/auth` and the write lives in `@suite/billing`. The decision is not a second policy. Both sides call `entitlementFor`: the token is minted from it and the licence row is written from it, so the tier a seat check enforces and the tier a token carries are the same value for the same organization at the same moment. Each status lands on one row: | Subscription status | Licence row written | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | `active` | the plan’s tier, its workspace and byte limits, `expires_at` = `periodEnd` | | `trialing` | the same: a trial is inside a period Stripe will bill | | `past_due`, `periodEnd` in the future | the plan, expiring at `periodEnd`: the grace window is bounded by the period Stripe last collected for | | `past_due`, `periodEnd` passed | the free row | | `canceled`, `paused`, `unpaid`, `incomplete`, `incomplete_expired` | the free row | | no subscription row | the free row | | a status a later Stripe version adds | the free row | A lapsed subscription falls back to the free row rather than deleting it: a lapsed account loses the plan and keeps its data, where a missing row would read as `licence-unprovisioned` (a provisioning fault no upgrade fixes) and a workspace nobody can reach. The fallback row is byte-for-byte the row the trigger writes, `free:` and all, so “downgraded” and “never subscribed” are the same state. `seats` is the subscription’s seat quantity, never below the born-with row’s one seat: a plan that sells no seats still admits the owner it always admitted. The plugin reports that quantity on create and on every update, and a quantity that is not a whole number is read as absent rather than as a fractional ceiling. A member whose join would take the count past it is refused with `seat-refused` and `reason: seat-allowance-exhausted` before the row is written, so the plan is never silently oversold. No plan prices seats today, so the quantity is one and so is the row; the section above says why. Two limits are worth stating rather than hiding: * **The hooks fire on Stripe’s events, not on a clock.** A `past_due` subscription whose period runs out with no further event keeps a paid row until one arrives. That row’s `expires_at` is the period end, so the seat check refuses at the same instant instead of granting the plan indefinitely, and the next event rewrites it. * **A failed write is logged, not retried.** The plugin catches a hook’s error and answers the webhook `200`, so `composeBilling` reports the failure through its logger and the following lifecycle event repairs the row. A missing licence row is therefore a fault and not a customer state: `services/auth` reports it as `licence-unprovisioned`, which says the row was never provisioned. A free organization that has spent its seat reports `seat-allowance-exhausted`, which is the answer a plan fixes. The read answers `free` with empty limits when the plan name is not in the table. Limits come from configuration, so a table edited under a live subscription loses caps rather than failing every token mint. ## The self-host licence A self-hosted relay starts with no control plane, so nothing mints it a session token and no subscription row holds its capacity. The entitlement is a token the operator holds: `SUITE_LICENCE`, one line of text, verified once at boot against `SUITE_LICENCE_PUBLIC_KEY`. The seller signs a licence for that operator: ```sh pnpm --filter @suite/collab-server licence sign --key ./licence-private.pem \ --tier team --workspaces 25 --document-bytes 5368709120 --attachment-bytes 26843545600 --days 365 ``` The command prints the token on stdout. The operator pastes that line, whole and unbroken, into the relay’s environment: ```sh SUITE_LICENCE=eyJ2IjoxLCJpZCI6InRlYW0tMjAyNiIs... SUITE_LICENCE_PUBLIC_KEY= ``` The relay exits on a token it cannot use, and the message names the case: `licence-malformed`, `licence-expired`, `licence-bad-signature`, `licence-unknown-version`. A token with no key configured is reported on `SUITE_LICENCE_PUBLIC_KEY`. Starting anyway would run the instance unlimited while the operator believes capacity was bought. The claims cap the instance. `workspaces` caps `SUITE_MAX_WORKSPACES`, `documentBytes` caps `SUITE_DOCUMENT_BYTES_PER_WORKSPACE` and `attachmentBytes` caps `SUITE_ATTACHMENT_BYTES_PER_WORKSPACE`, and a lower value in the environment still wins. `tier` is a label for support and gates nothing. `SUITE_HOSTED=1` and `SUITE_LICENCE` together refuse to boot. A hosted relay takes capacity from the organization’s subscription, so there is no second source to merge. A session token and a licence do not compete on a self-hosted relay. Capacity comes from `SUITE_LICENCE`; a session token, when the operator runs a control plane as well, authenticates a person and raises no cap, because the per-workspace claim record is written only in hosted mode. ## Environment | Variable | When | Meaning | | ---------------------------------- | ---------------- | ------------------------------------------------------------------------- | | `STRIPE_SECRET_KEY` | always | Secret or restricted key. Absent, the deployment mounts no billing plugin | | `STRIPE_WEBHOOK_SECRET` | always | Endpoint secret (`whsec_…`) printed by `stripe listen`; never logged | | `STRIPE_PRICE_TIERS` | always | The tier table above, as JSON keyed by price id | | `SUITE_CONTROL_PLANE_DATABASE_URL` | entitlement read | The one Postgres holding `subscription` (ADR-0070) | | `SUITE_CONTROL_PLANE_PORT` | local runs | Listener port, `1236` by default | | `SUITE_LICENCE` | self-host relay | The signed token, verified at boot against the public key | | `SUITE_LICENCE_PUBLIC_KEY` | self-host relay | The key that verifies a signed licence file | `/api/billing/*` answers `503` with `not-configured` when the tier table or the database URL is missing, and logs which one once. ## Local Stripe [The local Stripe page](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/deploy/stripe/README.md) has the full sequence. The short form, all in test mode: ```sh stripe login pnpm stripe # creates the fixture prices, prints the environment pnpm --filter @suite/control-plane dev pnpm stripe --listen # forwards to localhost:1236/api/auth/stripe/webhook ``` `pnpm stripe` reads the test key `stripe login` stored and creates each fixture price once. The forwarder prints the webhook secret the control plane needs. A checkout with test card `4242 4242 4242 4242`, then a read of `/api/billing/entitlement`, is the whole loop. `pnpm stripe --clock` creates a Stripe test clock for a renewal or a cancellation without waiting a month. ## Keys and rotation The hosted proof is the session JWT. Its signing key lives in the auth database and is published as a key set at `/api/auth/jwks`, so the relay verifies a token against whatever keys the plugin currently signs with and rotation there is the plugin’s own. The Ed25519 licence key covers the self-host path. Generate and sign with the relay’s operator CLI: ```sh pnpm --filter @suite/collab-server licence generate-keypair --out ./licence-private.pem pnpm --filter @suite/collab-server licence sign --key ./licence-private.pem \ --tier hosted --workspaces 25 --document-bytes 5242880 --attachment-bytes 26214400 --days 365 ``` Rotation of that key is a hard cut. `SUITE_LICENCE_PUBLIC_KEY` holds one key and `verifyLicence` takes one key, so there is no overlap window: from the moment a relay is given a new public key, every token signed with the old private key fails with `bad-signature`. The consequence is that hosted sync refuses those licences until each one is re-signed, and no document is lost, because local editing never consults a licence. Rotate on a compromise, accept that window, or wait for a period boundary and re-sign every active licence in one pass. A two-key grace period would change `packages/licence` to take a key set, and that is not implemented. ## Cancellation Cancelling through the portal sets `cancelAtPeriodEnd` and leaves the row `active`. The period is paid, so the tier holds until `periodEnd`, and the entitlement read keeps granting it until the row turns `canceled`. Cancelling immediately turns the row `canceled` and the next read answers `free`. Nothing pushes a revocation to the relay. A session token is valid for five minutes, so a cancellation takes effect at the next mint, at most that long after the webhook lands. Share links keep resolving as read-only projections, no document is deleted, and every device keeps editing its local copy. ## Deployment | Piece | Configuration | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Control plane | The auth module with `createBillingPlugin`, plus the billing module; `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_TIERS` | | Postgres | The control-plane database: the plugin’s `subscription` row is the record of what is paid | | Stripe webhook | `https:///api/auth/stripe/webhook`, subscribed to the subscription and invoice events | The plugin creates its own tables. Migration `0003_billing.sql` in `services/auth/sql` declared `billing_event` and `billing_customer` for the retired hand-written billing function; `0008_drop_billing_tables.sql` drops both, so the `subscription` row is the only record of what is paid. # Canvas > apps/canvas/src/canvas turns the workspace document into a board you can point at: placements become nodes, edges become routes, and every gesture ends in one… `apps/canvas/src/canvas` turns the workspace document into a board you can point at: placements become nodes, edges become routes, and every gesture ends in one `@suite/graph` write. The document model and the data flow are in [architecture.md](./architecture.md). This page maps the code that draws it, and which layer owns each input. ## Board and placements `board/BoardView.tsx` is the entry. It builds the board’s hooks in the order they depend on each other and hands the results to two components: `board/BoardSurface.tsx` hosts the React Flow `` element, and `board/BoardOverlays.tsx` holds the layers beside it: the context menu, the link description input, the link preview and the portal transition. A card reaches the board through a placement, not through the card record: | Field | Meaning | | ------------------- | -------------------------------------------------------------------------- | | `boardId`, `cardId` | which board shows which card | | `x`, `y` | top-left corner in flow units | | `z` | paint order within the board, fractional so a restack writes one placement | | `width`, `height` | resized dimensions; absent means the card’s natural size | `useBoardProjection` derives every board-scoped map from the snapshot once per change: `boardPlacements`, `cardsById`, `boardOrder`, `sectionMembershipMap` and `linkScene`. It writes nothing. `useBoardNodes` then maps each placement to a flow node, choosing the node type with `isWhiteboardCard` and `isPortalCard` from `cards/whiteboard.ts` and `cards/portal.ts`, and falling back to `note` (`NoteNode`). `nodeTypes` is in `canvas/NoteNode.tsx`, extended in `board/BoardSurface.tsx` with `portal` and `whiteboard`. The surface sets `onlyRenderVisibleElements`, so a card outside the viewport is unmounted. React Flow keeps its measured size and handle bounds, which is what lets an edge still route and a connector still measure when the card scrolls back in. Commands that change the document live in one hook, `board/useBoardCommands.ts`: create, duplicate, move, restack, unplace, trash and arrange. Each callback is one capability check plus the one `WorkspaceDocument` call it needs, so a share role never has to be re-checked in the component that triggered it. ## Sections A section is a labelled region on one board, stored in `sections`: | Field | Meaning | | --------------------------- | ---------------------------------------------------------------------- | | `boardId`, `title`, `color` | identity and paint | | `x`, `y`, `width`, `height` | its rect in flow units | | `z` | order among overlapping sections; cards always paint above all of them | | `connectors` | link attachment points, same 0..1 fractions a card uses | | `tags` | freeform labels for querying, never a membership rule | Sections never own cards. Membership is computed from geometry on every read: `sectionMembership` in `packages/graph/src/sections.ts` takes each placement’s anchor (its top-left corner plus `CARD_ANCHOR_INSET`, 8 flow units) and returns the smallest section whose rect contains it, with the higher `z` and then the id breaking ties. Two clients therefore agree about a card that straddles a border without exchanging anything. A section is not a React Flow node, so it cannot be selected, dragged or deleted through React Flow’s machinery. `sections/SectionLayer.tsx` draws the fill, border, header chip, member count and resize handles in a `ViewportPortal`, counter-scaled with zoom, and runs its own pointer gestures. `useBoardSections` holds the live offset while a drag is in flight and writes once on release. Cmd/Ctrl+G creates a section, wrapping the selection when there is one, at `NEW_SECTION_SIZE` (360 × 260) otherwise. The outline rail keeps its panel on a board with no sections and says the board has none, instead of disappearing; `canCreateContent` decides whether it also names Cmd/Ctrl+G. ## Links and routing A link is one `GraphEdge`. Both ends name a node and optionally a connector: * `source`, `target` name a card, board or section id; the missing `sourceKind` or `targetKind` means a card, so older documents stay valid. * `sourceConnector`, `targetConnector` name the 0..1 handle the line attaches to. * `kind` is `references`, `relates-to` or `tagged`. * `path` is `bezier`, `straight`, `step`, `smoothstep` or `avoid`. An unknown value draws as `bezier`. * `bends` is a polyline in flow units relative to the source anchor, capped at 24 points, so moving an endpoint translates the whole route instead of stranding the bends. * `layer` defaults to `below` the cards; `label` and `labelOffset` place the description chip on the path. `links/linkGeometry.ts` and `links/edgeRoute.ts` turn endpoints, sections and bends into anchors and an SVG path with no React, Loro or DOM in sight. The edge component, the section-link layer and the tests all call the same functions, which is what keeps a preview and the finished link on the same curve. A link is **one relationship per pair, per board**. Its identity is the two things it joins, not the dots it happens to attach to and not its direction (`pairKey` in `links.ts`). `addEdge` is therefore an upsert: asking for a link two things already have re-points that line at the dots named and returns the same record. The rule exists because the opposite one was indistinguishable from a bug. Unplacing a card keeps its links, so while identity was the dots, every place, unplace and re-place that left a card on a different side wrote *another* relationship between the same two things: a fan grew an extra spoke, a thread grew an extra hop, and the board filled with lines no single gesture had drawn. The corollaries are the whole of the lifecycle: | Action | What happens to the links | | ---------------------------- | ------------------------------------------------------------------------------------------------ | | Re-link the same pair | the existing line takes the new dots | | Unplace a card | the links that touch it on that board are deleted with the placement, one undo step | | Move a card to another board | the links it had on the board it left are deleted, because a link is a relationship *on* a board | | Trash a card | the links that touch it are deleted with it, one undo step | | Restore a trashed card | the card returns alone | | Purge a card | unchanged: its links go | Documents written under the old rule held several links over one pair, so `pruneDuplicateLinks()` runs once when a workspace opens, keeping the most recently written line of each pair. A board end has no position, so it draws through the first portal card pointing at that board. With no portal on the board there is no line, and the link survives in the inspector: the relation is with the board, which is still there. A link to a *card* is different, which is why unplacing a card deletes its links: a dormant record that came back with the card read as a second line every time the pair was linked again. A link whose two ends are not both on this board is not drawn at all. Peers see link gestures live. `useBoardNodes` hides the shared edge while a peer re-attaches one of its ends, and `presence/PeerReconnectLayer.tsx` draws the dashed preview from the same path helper, so the two never stack. `board/useBoardLinks.ts` owns the link gestures: the drag that creates one, re-attaching an end, and sliding a label. Each publishes its live state to peers and writes only on release. ### The link tool Three modes, chosen from one button in the widget pill (`links/ModeControl.tsx`): the glyph is the mode it is in, a click advances `place → thread → fan → place` (`nextMode`), and the label says what the next click switches to. One button rather than a segmented control because the pill is shared with the camera and the zoom, and a three-wide control there cost more room than the one decision it carried. A mode decides what a click on a dot does, and what a stack does with the cards left in it: | Mode | A click on a dot | The shape it draws | | -------- | ------------------------------------------------------------------ | ------------------ | | `place` | arms it; the next dot on another thing writes one link and lets go | one line | | `thread` | links from the armed dot and arms the dot just clicked | a chain, A→B, B→C | | `fan` | links from the armed dot and keeps it armed | a hub, A→B, A→C | Shift chains and Control fans for one gesture without leaving the mode, and Escape walks the mode back to `place`. This is `links/linkThread.ts` (`stepLink`, `stepThread`, `stepFan`), pure and unit-tested; nothing about it places cards. There is no run, no queue and no held stack behind a mode. Switching between `thread` and `fan` continues from the card just placed, and passing through `place` on the way does not count as a decision: the anchor is re-read from the last placement, not from the hub the previous mode was holding. A press is one undo step, link and placement together, and undoing a placement the stack made puts the card back in the hand (`history/undoWithStack.ts`), so every undo route (`Mod+Z`, the title bar, the palette, the history panel) hands it back rather than leaving it in the inbox. The two connectors a placement joined are lit for a beat, so a chain says which ends were used and not only that a line exists. A stack placed while a mode is armed writes a link as well as a placement. `canvas/attachDots.ts` answers which dots: the pair facing the way the cards travel. It is pure. `connectorToward` picks the dot that points at the other box, `attachDots` returns the pair for a placement, and `PlaceStackCursor` and `App.onDropSelection` both call it. A thread moves the armed end to the card it just placed; a fan keeps the hub it started from, so `thread` chains and `fan` spreads. ## Gestures and input ownership One pointer down has one owner. The owner is decided by where the press lands, and the handlers are registered so that a press reaches the most specific surface first. | Gesture | Owner | Where | | ------------------------------------ | --------------------------------- | ----------------------------- | | Node drag, click, marquee, pan, zoom | React Flow | `board/BoardSurface.tsx` | | New card on empty canvas | `usePaneDoubleClick` | `board/usePaneDoubleClick.ts` | | Section drag, resize, rename | `useBoardSections` | `sections/SectionLayer.tsx` | | Connector drag and reposition | `useBoardLinks`, `NodeConnectors` | `canvas/NodeConnectors.tsx` | | Link create, re-attach, label slide | `useBoardLinks` | `board/useBoardLinks.ts` | | Keys | the board key chain | `board/useBoardKeyboard.ts` | | Escape | the layer stack | `shell/escapeLayers.ts` | | Pointer presence | the board root | `board/BoardView.tsx` | `BoardView` listens on the capture phase for `pointerdown` and records the press in flow coordinates. It has to be capture: React Flow stops the pane’s own `pointerdown` from propagating, so a bubble handler on the board root never sees a press on empty canvas. The marquee then starts from the section the press landed in, which is what scopes a box to that section. React Flow’s `deleteKeyCode` is `null`, so it cannot remove a card behind the document’s back. Every key is read in one place, `useBoardKeyboard`, which runs five named actions in a fixed order: quick capture, the Escape ladder, modifier chords, Delete/Backspace, Enter. `Enter` opens a portal for a viewer, because navigating is not an edit, and every other card needs `canEditContent`. A second listener on the capture phase answers only the four arrow keys, so the five named actions keep their order. Empty canvas creates. React Flow has no pane double-click prop, so `usePaneDoubleClick` binds the pane element it builds and ignores a press that lands on a card, an edge, a section or a control, since those keep their own double-click meaning. `zoomOnDoubleClick` is off: one gesture cannot both make a card and move the camera. Creating is gated by `canCreateContent`, so a viewer’s double-click does nothing. Escape walks a stack. `useEscapeLayer` from `shell/escapeLayers.ts` pushes a dismiss function when a surface opens and pops it when it closes; the most recently pushed layer takes the key and the board’s own chain only sees Escape when the stack is empty. A text field inside a layer keeps the key unless that layer is a dialog. The board’s own layer is the armed link thread (`board/useBoardLinks.ts`). With the stack empty, the ladder climbs from the most specific thing on the board to the plainest: section edit → selected section → container inspector → card focus → card edit → focus target → a portal back → the selection → `mode` → `purpose` → the hand. The two mode rungs reset a choice rather than clear a surface, so pressing Escape with nothing left to close always ends with the reader placing on an exploration, whatever they had armed an hour ago; one more press drops a stack that is still in hand, which is the last thing there is to put down. ## Two axes, one of which is mostly implicit `mode` says what a link gesture does, and `purpose` says what the canvas is *for*. Purpose is deliberately small: `explore` is the resting state and needs no button, and `explain` is the only value a reader chooses from the canvas itself. There is no placement run: no shapes, no anchors, no bar. Cards are placed one at a time, by a drop, a dock action, a double-click, or from a stack — pick rows in the library, press `Place N`, and the stack follows the pointer, one press per card (`canvas/placeStack.ts`, `canvas/PlaceStackCursor.tsx`). Dragging a picked row out of the drawer carries the whole pick, and the drop’s dashed outline is the card’s own box at the current zoom, so where it is drawn is where it lands. A card that is already on this board is not a card to place: the stack and the drop pass over it and leave it where the reader put it, and the stack’s count is the number of presses that will actually happen. Placing many cards should never rearrange the ones already there. One card, one placement, per board: a placement is keyed `boardId:cardId` and a React Flow node is a card, so *several* placements of one card on one board is a model change of its own, not a side effect of a fix. The appearance panel is one panel: right-clicking a card inside a selection opens it for the whole selection (`app/AppearanceHost.tsx`), and nothing opens on its own when a second card is picked. ## Camera and viewport `board/useBoardCamera.ts` owns the camera. Viewports are UI state, not document data: `boardViewports` in `store/useUiStore.ts` records one viewport per board, written from `onMoveEnd` on the surface and from a `requestAnimationFrame` when a board first opens, so a return lands exactly where the reader left. Navigation is one-shot. A caller sets `viewportRequest` (`{ boardId, viewport, nonce }`), and the camera consumes it once; `viewport` null means “fit the board’s content”. `boardHistory` records how a board was entered, and an entry with `via: "portal"` is what makes Escape step back to the previous board and viewport instead of only clearing the selection. The zoom range is `ZOOM_MIN` 0.05 to `ZOOM_MAX` 4, clamped by `clampZoom` in `canvas/zoomRange.ts`. A presented tour is left unclamped, because a step may need a scale outside the editing band. Zoom bands change what a card paints: `zoomBand` switches at 0.35 and 0.75, the board root carries `data-zoom-band`, and the rules live in `apps/canvas/src/styles/paint.css`. A card never measures itself to decide how much detail to draw. Focus requests move the camera over `FOCUS_DURATION_MS` (300 ms), never past `FOCUS_MAX_ZOOM` (1.2), and wait up to `FOCUS_POLL_TIMEOUT_MS` (1500 ms) for React Flow to register a card that has just been placed. Camera controls are React Flow’s own `` pill, plus one button. Zoom in, zoom out and fit stay on the built-in control; `BoardSurface.tsx` adds zoom to the selection (`fitView` over the selected ids at `SELECTION_PADDING` 0.2) because the built-in pill has no equivalent. Zoom to selection follows `prefersReducedMotion()`: a reader who asked for less motion arrives instead of flying. The dot grid is always painted. It used to be a toggle beside a zoom readout in a second corner stack, which is a control bar of its own for a preference nobody changes twice. The outline moved to the navigation row beside the board tabs, and `[` still toggles it. ## Selection affordances Arrow keys move the selection by `NUDGE_STEP` (8 flow units), or `NUDGE_LARGE_STEP` (40) with Shift. `useBoardKeyboard` reads them on the capture phase, before React Flow’s focused-node handler, so one press is one step size and one call to `nudgeSelected` in `useBoardCommands`. That command runs the same `commitDrop` a pointer release does, which is why a card nudged past its section’s edge grows the section instead of leaving it. The step needs `canEditLayout`. `NudgeHint` (`canvas/BoardHints.tsx`) prints both steps whenever the selection is not empty, lifted above the bulk style bar when that bar is showing, which is what makes the gesture discoverable. A multi-selection edits style in bulk. `canvas/BulkStyleBar.tsx` patches each selected card’s own `SurfaceStyle` (shape and fill) through the same `setCardStyle` the single-card Style tab uses, with `bulkStyleWrites` in `canvas/styleTargets.ts` merging each card’s existing record. A key set back to `undefined` is cleared, so “Default” and “never set” stay the same state. `@suite/graph` has no batched style write, so a bulk edit is one surface write and one undo step per card. The canvas’s create verbs are registry commands: `card.createSketch`, `section.create` and `portal.create`. Each has `owner: "canvas"`, so the dock lists it through `commandsFor`, and a palette row, so the command palette lists the same three. A portal row names its target board (`New portal to `) because a portal needs one; its action is the optional `CommandActions.createPortalOnBoard`, so those rows stay hidden until the shell supplies that handler. ## Arranging An arrangement is a plan, not a rule. `canvas/interactions/arrangeCommand.ts` turns a selection and its placements into a `LayoutPlan`, and `WorkspaceDocument.applyLayoutPlan` commits every write in one undoable batch. Nothing is left behind that would move a card the reader places by hand afterwards. | Command | Planner | What it does | | ------------------------------ | ---------------- | --------------------------------------------------- | | Align an edge or a centre line | `planAlign` | Moves the selection onto one edge of its common box | | Distribute along an axis | `planDistribute` | Keeps the two extremes and evens the gaps between | | Arrange as a mind map | `planRadial` | Rings the selected card’s subtree around it | `planRadial` in `canvas/interactions/radialLayout.ts` is the mind-map mode of ADR-0049, in its simplified form: the selected card is the root and stays exactly where the reader put it, its children sit on the first ring at `RADIAL_RING_GAP` (320 flow units) and their children on the second, and each branch owns an arc proportional to how many cards hang off it. The tree is read from the document as it stands, through `childrenFromLinks`: an outbound card link or a body mention is a child, a portal link is not. The breadth-first walk means a cyclic or diamond-shaped graph draws each card once, at its shallowest depth. The command is `layout.arrangeRadial` in `apps/canvas/src/command/commandRegistry.ts`, so the palette and the dock cannot disagree about it. It is listed only while `CommandActions.arrangeRadial` is supplied. Two surfaces supply it from the same plan: the board’s menu runs `arrangeRadialSelection` in `board/useBoardCommands.ts`, and the shell’s palette row runs `useBoardPaletteActions` in `apps/canvas/src/app/useBoardPaletteActions.ts`, which reads the selection in paint order and hands the same `planRadial` and `childrenFromLinks` the board uses to `applyLayoutPlan`. Sections a moved card leaves grow to contain it again, by the same rule align and distribute follow. ## Folds A card folds to its header line and a section folds to its own header. Both are reader state, held in `collapsedCards` and `collapsedSections` in `apps/canvas/src/store/useUiStore.ts`, and neither is written to the document. Two people on one board can fold different things away. A folded card does not mount its body, so nothing inside it can hold a caret. Its placement keeps the height it had, which is why unfolding restores the card exactly. The height a folded card paints at is `COLLAPSED_CARD_HEIGHT` in `canvas/nodeData.ts`, and the header’s reference count is what the fold hides. A portal folds the same way, through the same `collapsedCards` flag: its shell shrinks to `PORTAL_HEADER_HEIGHT` (`canvas/PortalCard.tsx`), the miniature and the footer step out of the way, and the header keeps the icon, the board name and the count. The shell height is what shrinks rather than the placement, so a rolled-up portal leaves no hole on the board and unfolding restores the frame the reader had resized. A folded section stops drawing its members: `board/useBoardNodes.ts` skips a placement whose section is folded, so the cards are absent rather than invisible. `sections/SectionLayer.tsx` paints the frame at header height through `paintRect`, and the stored rect is untouched so unfolding puts every member back. A folded section shows no resize handles, because there is nothing to resize. ### Icons An entity’s icon is resolved by one function, `resolvedIcon` in `canvas/entityIcon.ts`, in this order: the paint record’s `icon`, then the record’s own field, then the type’s icon. A card can also say “nothing”, which is `iconHidden` and beats both of the first two. One writer, `setEntityIcon`, puts the choice in `style.icon` for a card and a portal and in the section’s own field for a section, so the inspector, the appearance panel and the canvas cannot disagree. A chosen icon replaces what the entity would otherwise draw: the card’s type glyph, the portal’s circled dot, or a section’s colour dot. A section keeps the dot when no icon is set, so the colour an author picked is never invisible. There is no separate summary surface. An earlier build carried a rollup rail in the corner of the board because neither a card nor a section could fold; the fold is on the two headers now and the rail is gone. ## Presence and cursors Presence rides one `EphemeralStore` per workspace, joined by `collab/loroProvider.ts` at `:presence`. Signals are fields in `AWARENESS_FIELDS` (`collab/presence.ts`), typed in `PeerPresence`: `board`, `user`, `pointer`, `focus`, `dragging`, `selection`, `selectionBox`, `connector`, `resizing`, `sectionDrag`, `sectionResize`, `reconnect`, `connect`, `labelDrag`, `libraryDrag`, `purpose`, `gesture` and `gestureBeat`. A new signal is a new field on that schema, which is what keeps every feature from inventing its own channel. Carets are separate. `loro-prosemirror`’s `CursorEphemeralStore` joins `:cursors` with a 30 second TTL, and only the editor surface that owns the keystrokes receives it, so a mirrored editor cannot clear the owner’s caret. Peer carets are content-anchored, so a caret published from the panel renders inside the collaborator’s canvas card. `collab/usePresenceStore.ts` publishes local fields and projects the remote ones. `collab/peerProjection.ts` stamps each gesture with the time it last changed on the receiving side, so clock skew does not matter, and drops the payload after `GESTURE_TTL_MS` (3000 ms) of silence. A one second sweep re-projects. A held gesture bumps `gestureBeat` every `GESTURE_BEAT_MS` (1000 ms) so a press with no movement does not age out. Selections and carets are state, so they outlive the TTL. Every published coordinate is in flow units. Presence is workspace-wide, so a board-scoped surface filters by `peer.board` through `hooks/useBoardPeers.ts`; a cursor from another board is not drawn at all. The ephemeral rooms are not replayed to late joiners, otherwise a tab that closed mid-gesture would remain as a frozen collaborator. ## Live drag versus persisted position A drag keeps two positions at once, and only one of them is written. While the pointer moves, React Flow owns the node’s position. `board/BoardSurface.tsx` eases the painted position toward the pointer sample with `LocalDragEase` (`canvas/localEasing.ts`), skipping the easing when the reader sets `prefers-reduced-motion`, and publishes the pointer position to peers through `setDragging` at most once per `DRAG_THROTTLE_MS` (40 ms) with a trailing sample. `useBoardRefs` holds `locallyDraggingIds` and `draggedNodeIds`, which are the flags other hooks read to know a gesture is in flight. On release, the easing finishes and lands the rendered nodes on the pointer, then `commitDrop` writes the new positions through `@suite/graph` in one commit. A peer never writes: `useBoardNodes` eases its node toward the broadcast position with the same kind of smoothing (`SMOOTHING`, 0.22) and stops when the presence payload clears. Sections, connectors and labels follow the same split. A section drag sets `localSectionDrag` and calls `updateSectionGeometry` on release. A connector move publishes its perimeter fraction and writes the final fraction. A label slide publishes `labelDrag` with a path fraction and writes `labelOffset` when the pointer is released. Snapping is local and transient. `canvas/snapping.ts` compares the dragged card’s left, centre and right (and top, middle and bottom) with the same lines of every other card on the board. A match inside `SNAP_THRESHOLD` (6 flow units) moves the rendered target and the drop position by that difference, and `SnapGuideLayer` draws the line through a `ViewportPortal`. The snap runs before the local easing, so the ease converges on the snapped position instead of fighting it, and the drop commits the position the guides showed. A multi-card drag does not snap: pulling the group onto one member’s edge would move the other members out of formation. ## Board module map | Path | Responsibility | | ------------------------------------- | ----------------------------------------------------------------------------- | | `board/BoardView.tsx` | builds the board hooks, hosts the surface and overlays, owns pointer presence | | `board/BoardSurface.tsx` | the `` element, drag and viewport handlers, capability gating | | `board/BoardOverlays.tsx` | context menu, edge description, peer drag layer and other portals | | `board/useBoardUi.ts` | one bundle of stores, camera helpers and capability flags for the board | | `board/useBoardProjection.ts` | snapshot to board-scoped maps; no writes | | `board/useBoardNodes.ts` | placements to flow nodes and edges, live offsets, peer easing | | `board/useBoardSelection.ts` | selection reconciliation, marquee to flow box, scope release | | `board/useBoardLinks.ts` | link thread, create, re-attach and label gestures | | `board/useBoardSections.ts` | section gestures, live offsets, `NEW_SECTION_SIZE` | | `board/useBoardCamera.ts` | viewport restore, fit, focus, portal entry, step back | | `board/useBoardCommands.ts` | document commands, one capability check each | | `board/useBoardKeyboard.ts` | the only keydown reader on the board | | `board/useBoardRefs.ts` | mutable gesture flags shared between the hooks | | `board/boardSupport.ts` | throttle, pointer sample, section-exit tolerance, edge conversion | | `board/usePaneDoubleClick.ts` | empty-canvas double-click creates a card | | `canvas/snapping.ts` | edge and centre snapping, pure geometry, guide extents | | `canvas/SnapGuideLayer.tsx` | alignment guides in a viewport portal | | `canvas/board/BoardSurface.tsx` | the React Flow surface, its dot grid and the control pill | | `canvas/BoardHints.tsx` | the arrow-key nudge hint | | `canvas/BulkStyleBar.tsx` | style patch for a multi-selection | | `canvas/nodeData.ts` | what a card node paints from, and the folded card height | | `canvas/interactions/radialLayout.ts` | mind-map rings from a card’s subtree | | `canvas/peek/` | hover-peek resolver, delay rules and the read-only surface | | `sections/SectionLayer.tsx` | section paint, header chrome, resize handles, pointer gestures | | `links/linkGeometry.ts` | anchors, rects and path fractions without React | | `links/edgeRoute.ts` | route modes and bend handling; pure geometry | | `canvas/NodeConnectors.tsx` | connector dots, drag, menu, live perimeter | | `canvas/zoomRange.ts` | zoom floor, ceiling, clamp and band breakpoints | | `presence/PresenceLayer.tsx` | peer pointers, selections and marquee in a viewport portal | | `store/workspaceSnapshotStore.ts` | the one `WorkspaceDocument.observe` call site and its card change stream | `store/workspaceSnapshotStore.ts` is also how the canvas learns that a card’s text moved. Its `subscribeCardChanges` reports card ids for record and rich-text edits, which is what the library index and the title list use instead of observing the document themselves. Changing a gesture, a node surface or a presence channel is [change-the-canvas.md](../how-to/change-the-canvas.md). # Capacity > What one relay holds, what one more open room costs, and where the CPU stops keeping up. The numbers on this page are measured on this checkout by… What one relay holds, what one more open room costs, and where the CPU stops keeping up. The numbers on this page are measured on this checkout by `apps/collab-server/bench/sweep.mjs`, which runs `bench/load.mjs` once per point in a fresh process and reads the relay’s own `/metrics`; the raw reports are in `apps/collab-server/bench/sweep.json`. Quotas, refusal codes and the save window are in [limits.md](./limits.md); this page is about what the machine does inside them. ## What was measured * **Profile.** One board of 200 cards, a 219 KB workspace document, one reader in each share room, and two edits per run in the memory sweep. 200 cards is the largest board a joining peer can be given in this build: a document over 256 KiB never arrives, and that starts between 240 and 300 cards ([limits.md](./limits.md#transport)). * **What a room is.** An open projection room: one share link that a reader resolved. The workspace room that editors connect to is one per workspace whatever the reader count, so it shows up in the fixed cost, not the slope. Each projection room holds its own copy of the document, which is why it is the expensive one. * **Steady state.** Three seconds after the last edit, the median of three scrapes 400 ms apart. A peak is one allocation burst away from being unrepresentative; the steady state is what the box has to hold for the next hour. * **The gauge.** `suite_memory_split{segment="heap"|"external"|"arrayBuffers"|"rss"}`. A Loro document is WASM linear memory, which Node reports under `external` and `arrayBuffers` and never under `heap`, so `suite_rss_bytes` on its own cannot say what the memory is for. * **The machine.** Intel Core i9-11900H, 16 logical CPUs, 33,328,463,872 bytes of RAM, Linux x64, Node v26.12.1. The relay is a child process of the load generator, so the two share cores, and the machine was also running other builds: `os.loadavg()` sat between 7.6 and 26.9 on 16 threads across these runs. **Read every number here as an upper bound on latency and a lower bound on capacity.** Relay and generator competed for the same cores, which inflates each stall the relay reports and understates how many rooms it would serve alone. The instance re-run is the confirmation, not a follow-up: a `t4g` core is slower than this laptop core and its memory is what an instance actually has. Nothing below substitutes for running `bench/sweep.mjs` on the instance. ## Memory per open room Rooms opened one after another, cards and peers held fixed, a fresh relay process per point: | Rooms | RSS MiB | arrayBuffers MiB | external MiB | heap MiB | Document bytes | | ----- | ------- | ---------------- | ------------ | -------- | -------------- | | 1 | 218.8 | 5.2 | 9.5 | 48.0 | 219 103 | | 4 | 260.9 | 7.8 | 12.2 | 50.6 | 219 176 | | 16 | 399.7 | 23.4 | 27.7 | 73.9 | 219 158 | | 32 | 546.5 | 17.0 | 21.3 | 59.3 | 219 546 | | 64 | 863.9 | 42.2 | 46.5 | 88.2 | 219 094 | | 128 | 1 506.2 | 73.4 | 77.7 | 211.6 | 219 236 | Least squares over those six points, `bytes = intercept + slope × rooms`: | Series | Intercept | Slope | R² | | ------------ | ------------------------- | ----------------------------- | ------ | | RSS | 233 429 325 B (222.6 MiB) | 10 530 089 B/room (10.04 MiB) | 0.9996 | | arrayBuffers | 7 169 156 B (6.8 MiB) | 547 585 B/room (0.52 MiB) | 0.9642 | | external | 11 700 323 B (11.2 MiB) | 547 471 B/room (0.52 MiB) | 0.9642 | | heap | 40 705 776 B (38.8 MiB) | 1 278 478 B/room (1.22 MiB) | 0.9068 | * **222.6 MiB before anyone connects.** That is Node, the TypeScript loader the bench runs, the SQLite connections and one workspace room holding a 219 KB document. * **10.04 MiB of resident set per open room**, linear to within four parts in ten thousand over a range of 128. The document itself is 0.52 MiB of it: a 219 KB Loro document in WASM memory. V8’s heap accounts for 1.22 MiB. The remaining \~8 MiB is memory the split does not attribute to live heap or to ArrayBuffers. Allocator arenas, and pages touched during a rebuild and not returned to the kernel, are the likely holders. That is why the resident set is the number to provision against and arrayBuffers is the number that explains where the documents are. * **The 32-room arrayBuffers reading is lower than the 16-room one.** A projection pass allocates temporary Loro documents, and a scrape can land after one has been released and before the next is created, so a 32-room point can read below a 16-room one. The slope is still 0.52 MiB/room at R² 0.96, and RSS, the number provisioning uses, does not have this wobble. * **The document cost is per room, not per peer.** A peer in a room is a socket and a presence entry in the relay; the document is one copy per room however many of the 64 peers are connected. At the documented ceiling of 128 open rooms, the regression predicts 222.6 + 128 × 10.04 = 1 507.7 MiB; the run measured 1 506.2 MiB. ## Where the CPU stops keeping up A rebuild follows a save, and a save window is 2 000 ms plus the worker’s 200 ms debounce. One pass of the projection worker costs one `projectWorkspace` plus one `mirrorWorkspace` per open room; on this board those stages measure 137.92 ms and 34.62–36.52 ms ([report.md](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/apps/collab-server/bench/report.md#per-stage-cost-of-one-rebuild)). So a pass costs about 138 ms + 37 ms per room, and one pass per window fits until roughly (2 200 − 138) / 36.52 ≈ 57 rooms. Above that the pass is longer than the window it follows and the queue grows. Two runs test that, both at 200 cards and one peer per room. **Raising the edit rate at 16 rooms.** Edits fired at a fixed rate and never waited for; the lag histogram is the relay’s own, sampled every 50 ms. | Edits/s | Edits | Lag p50 | Lag p99 | Samples ≥ 100 ms | Delivery p50 | | ------- | ----- | ------- | ---------- | ---------------- | ------------ | | 0.25 | 6 | 2.6 ms | 580.0 ms | 23 of 520 | 2 489.7 ms | | 1 | 20 | 2.9 ms | 1 840.0 ms | 36 of 376 | 5 182.2 ms | | 4 | 80 | 3.1 ms | 3 031.3 ms | 35 of 315 | 3 066.3 ms | | 16 | 320 | 3.3 ms | 3 690.0 ms | 39 of 262 | 3 862.5 ms | The lag p99 crosses 100 ms at the lowest rate offered, 0.25 edits per second, and the median never leaves single-digit milliseconds. Both have the same cause: `projectWorkspace` is 138 ms of synchronous work, so one rebuild is already a stall longer than the 100 ms breach threshold, and the median stays low because everything outside a rebuild is between stalls. **The p99 > 100 ms criterion therefore does not identify a room count: it identifies the first rebuild.** What rises with the rate is the tail and the delivery time, not the median. The last edit took 2.5 s to reach every room at a quarter of an edit per second and 3.9 s at 16, at the same 16 rooms; a save in a busier window is more likely to find the worker already inside a pass. **Raising the room count at one edit per save window.** 0.45 edits/s, held for 20 s; “delivery” is how long the last edit took to reach every peer, measured in the generator that made it, with a 30 s deadline. | Rooms | Lag p50 | Lag p99 | Samples ≥ 100 ms | Delivery p50 | Delivery p99 | Delivered | | ----- | ------- | ---------- | ---------------- | ------------ | ------------ | --------- | | 16 | 2.7 ms | 1 510.0 ms | 25 of 396 | 1 691.1 ms | 1 699.1 ms | 16/16 | | 32 | 3.0 ms | 1 925.0 ms | 43 of 345 | 3 145.0 ms | 3 264.4 ms | 32/32 | | 64 | 3.5 ms | 3 864.3 ms | 72 of 318 | 5 307.1 ms | 5 331.9 ms | 64/64 | | 128 | 4.2 ms | 499.2 ms | 132 of 429 | 12 179.0 ms | 12 390.5 ms | 128/128 | Delivery is the save window (up to 2.2 s) plus the pass. At 16 rooms it is 1.7 s; at 32 it is 3.1 s, which is one window plus a pass of about 0.9 s. At 64 it is 5.3 s, a pass of \~2.9 s, longer than the window it follows, so a second save is already waiting when the first pass ends. At 128 it is 12.2 s, roughly two passes plus two windows. Every edit did arrive at every room inside the 30 s deadline, so nothing is lost; the reader waits, and the wait grows faster than the room count. **The CPU ceiling is between 32 and 64 open rooms at 200 cards**, and the arithmetic above puts the crossover at about 57. The room cap in [limits.md](./limits.md#concurrency) is 128, so at this board size the CPU is the binding constraint, not memory and not the cap. **No build was refused.** `suite_projection_build_capped_total` stayed 0 at every point, including 128 rooms: the 5 s cap in `SUITE_PROJECTION_BUILD_MS` applies to one room’s rebuild (\~40 ms here), never to a whole pass over 128 rooms. A frozen projection is a very large board’s problem, not a many-room problem. ## What an instance costs Prices are AWS on-demand Linux list prices at the time of writing, in US dollars; they change, and they exclude the storage and network lines below. Rooms are open projection rooms at 200 cards. | Instance | vCPU / RAM | $/month | Rooms by memory\* | Room cap | Measured CPU ceiling | Binding ceiling | $/room-month | | ------------ | ---------- | ------- | ----------------- | -------- | -------------------- | --------------- | ------------ | | `t4g.medium` | 2 / 4 GiB | 24.53 | 334 | 128 | \~57 | 57 | 0.43 | | `t4g.large` | 2 / 8 GiB | 49.06 | 742 | 128 | \~57 | 57 | 0.86 | \* Memory allows (3584 − 222.6) / 10.04 rooms on 4 GiB and (7680 − 222.6) / 10.04 on 8 GiB, after reserving 512 MiB for the kernel, page cache and the rest of the image. t4g.medium and t4g.large have the same two vCPUs, so the extra 4 GiB buys memory only, and memory is not what binds. **Cost per concurrent editor, with the denominators named.** An “editor” here is one person with a share room open, the memory-bearing case, at one peer per room: * `t4g.medium`, 57 rooms, one reader each: **$0.43 per concurrent reader-month**. * `t4g.large`, 57 rooms, one reader each: **$0.86 per concurrent reader-month**. * Four readers sharing a room (`SUITE_MAX_PEERS_PER_ROOM` allows 64) divides those by four: **$0.11** and **$0.22 per reader-month**. * If the board is small enough that a pass fits the window at 128 rooms, the cap binds instead and the same arithmetic gives **$0.19** and **$0.38 per room-month**. An editor in a *workspace* room is cheaper than any of these: the relay gives them a socket and a presence entry, and the workspace document is shared by everyone in the room. The sweep did not isolate that cost, so this page does not quote one; the per-room figure is the number to plan with because share rooms are what grow. Two other lines belong in the monthly total: | Line | Price | Example | | ------------------ | -------------- | ------------------------------------------------------------------ | | EBS gp3 | $0.08/GB-month | 30 GB (checkout, `node_modules`, pnpm store, image layers) = $2.40 | | RDS `db.t4g.micro` | \~$11.68/month | Templates and accounts; plus its own storage, priced separately | So a relay with 30 GB of gp3 and a micro Postgres is about **$38.61/month** on `t4g.medium` and **$63.14/month** on `t4g.large`, before the IPv4 address, backups and data transfer. At 57 concurrent readers that is $0.68 and $1.11 per reader-month all in, and at four readers per room, $0.17 and $0.28. The CPU ceiling is the single number that moves this table, and it moves with board size: a 40-card board’s pass is a fraction of a 200-card one, so small boards reach the 128-room cap and large boards do not. The measured points are 200 cards because that is what a peer can be sent today. ## What was not measured * **Boards above \~240 cards.** A joining peer never receives a document over 256 KiB, so a share room of 2 000 cards cannot be measured until snapshots are chunked ([report.md](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/apps/collab-server/bench/report.md#the-published-maximum-is-not-reachable-yet)). * **More than one workspace in a process.** Every run used one workspace. A second workspace adds a workspace room and its own save schedule, not another copy per reader; it is not in the slope. * **Peers per room.** One peer per room, so the per-peer cost inside a room (socket, presence) is not in these numbers. The 64-peer cap is a per-room limit on sockets, and the document is shared. * **Graviton per-core speed.** The CPU ceiling was measured on an i9-11900H core. A `t4g` core is slower, so the room count on an instance is lower than 57 and the $/room figures above are floors. * **Disk.** Document write throughput, WAL growth and backup cost are not in this pass; the size figures in [limits.md](./limits.md) still bound a workspace. ## Reproducing ```sh pnpm install node apps/collab-server/bench/sweep.mjs # all three phases, ~15 min node apps/collab-server/bench/sweep.mjs --phase=memory # the table and the regression node apps/collab-server/bench/sweep.mjs --phase=edits # fixed rooms, rising edit rate node apps/collab-server/bench/sweep.mjs --phase=load # fixed rate, rising room count ``` Each phase writes into `bench/sweep.json` and keeps the phases already there, so one phase can be re-run on its own. `--memory-rooms`, `--edit-rooms`, `--edit-rates`, `--load-rooms` and `--load-rate` change the points. One relay process per point is the rule the script enforces; comparing two room counts measured in one process would compare a cold box with a warm one. # Coding conventions > The rules as rules. The model they serve is architecture.md. The procedure that applies them is ../how-to/add-a-feature.md, and the gate is… The rules as rules. The model they serve is [architecture.md](./architecture.md). The procedure that applies them is [../how-to/add-a-feature.md](../how-to/add-a-feature.md), and the gate is [../how-to/test-and-verify.md](../how-to/test-and-verify.md). ## Shape: pure core, imperative shell Pure modules take data and return data: schema, records, planning (`layoutPlan`), projection, publish planning, link geometry, name derivation. No DOM, no Loro, no `fetch`, no timers, and a test that needs none of them. Shell modules own state or I/O: `WorkspaceDocument` and its domain modules, `LoroIndexeddbPersistence`, the collab provider, the projection worker, HTTP handlers, React host components. Dependencies point inward. A pure module that imports `loro/WorkspaceDocument.ts` is a bug; the graph layer passes plain values down and interprets results up. One owner per piece of state. Document truth lives in Loro through `WorkspaceDocument`, UI state lives in Zustand, and transient view state may live in React. Never keep a second copy. ## Files and folders Folder by feature, not by file type: `apps/canvas/src/{canvas,cards,library,panels,command,field,shell,sharing,session,store,collab,editor,theme}`, `apps/collab-server/src/{http,store,projection,config,sync}`, `services/auth/src/{identity,organizations,invitations,licensing,http,store}`, and `packages/graph/src/loro/`. Each folder exposes one `index.ts` and nothing else as public surface. No barrel files that re-export everything. The facade (`WorkspaceDocument`) and the folder index are the API. Co-locate `*.test.ts(x)` and `*.stories.tsx` with the code they cover. One primary export per file; small helpers stay private. Named exports only, with `App` at the entry as the one default. Size is a signal: around 400 lines, look for a seam. `pnpm shape` fails a source file over 900 lines. The 400 number is a prompt, the 900 number is the gate. Components are `PascalCase.tsx`, hooks are `useCamelCase.ts`, other modules are `camelCase.ts`. ## Naming Functions read `verbNoun`: `placeCard`, `moveSectionsBatch`, `applyProjection`. Predicates read `isX`, `hasX` or `canX`: `isSelfLink`, `canManageSchema`. Conversions read `toX` and `fromX`; `parseX` returns a `Result` and `assertX` throws. Hooks are `useX`, component props are `XProps`, tagged error types are `XxxError`. A type pluralises by derivation: read `typePluralName(type)` where a type labels a set, and `type.name` where it labels one card. Tests describe the unit under test and use `it("should …")` for the behaviour. ## Data and errors Prefer plain readonly objects, discriminated unions and exhaustive `switch`. No boolean-flag arguments. Take a small options object, or write two named functions. `Result` from `neverthrow` belongs at fallible boundaries: parsing, validation, HTTP handlers, licence and admission checks. Use `ok` and `err`, then `map`, `andThen` or `match`, and convert once at the seam; `services/auth/src/http/response.ts` holds the single error to status mapping. Throw for programmer errors and invariants only, and never a bare `Error("…")` across a module boundary. Define a tagged error type. `remeda` is for transforms where it removes code or a loop. Do not wrap a one-liner. `zod` is the source of truth for persisted shapes: derive types with `z.infer`, never duplicate an interface, and validate on every write. No new dependency without a stated job and the correct package. ## Refactor on sight Fix these when the file is already open. Each one has a failure mode. | Smell | Smallest fix | | -------------------------------------------------------------- | --------------------------------------------- | | A whole-record write where two fields can change independently | Write the leaf field, or use a map per record | | A derivation stored back into the document | Recompute at read time | | An effect that exists to run one user action | Run it in the handler | | A second copy of geometry, formatting, or presence rules | One helper, one call site | | A read share that can write | Check the capability at the mutation | | A log line with a token, email, or document body | Redact at the logger | `pnpm shape` enforces the mechanical half: file length, parameter count, banned dynamic-code APIs, one `WorkspaceDocument.observe` call site, and no raw Loro writes to graph roots outside `@suite/graph`. `pnpm docs:symbols` enforces that every backticked path, root script, and ADR reference in the docs resolves. ## TypeScript Strict mode stays on, including `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax` and `isolatedModules`. Do not weaken it. No `any` without a `// biome-ignore lint/suspicious/noExplicitAny: ` and a real reason. Prefer `unknown` with narrowing, and discriminated unions. Type-only imports use `import type`. Write `.js` extensions in `@suite/graph` relative imports, because the package emits ESM. ## Imports External packages, then `@suite/*` workspace packages, then relative imports, separated by blank lines. Biome’s `organizeImports` enforces it; run `pnpm lint:fix`. ## Domain language A workspace is one `LoroDoc`, one sync room and one IndexedDB entry. A board is a view inside it. A card is the object, with content, primary type, tags, properties and connectors, and exists with or without a board. A placement is where a card sits on one board, so say “place” and “remove from board” and never “delete”. Trash is the soft delete through `deletedAt`; “delete forever” belongs to purging. Never write “node”, “document” or “note” for these. A node is a React Flow node, a note is the built-in card type, and a document is the whole workspace document. The full list is [glossary.md](./glossary.md). ## Libraries first Use the library’s own primitive before writing UI or state plumbing: Tiptap’s `BubbleMenu` and `useEditorState`, React Flow’s `useNodesState`, `useEdgesState` and `ViewportPortal`, Zustand selectors. Hand-roll only what the library leaves out, such as the buttons inside `BubbleMenu` or the canvas context menu. ## React Function components with hooks, no classes. Derive document data through the workspace snapshot store (`useWorkspaceSnapshot`, `useCard`, `useCards`) and never a `useState` copy. Never call `WorkspaceDocument.observe` from a component: the store owns the single subscription and selectors stay atomic. Keep components small and split presentational pieces into the feature folder. ## State Zustand holds UI state only. Document data is owned by the Loro doc. One editable surface per card per client. A card’s title and body may be mounted on several surfaces, and only one is editable, decided by `editingSurface` in the UI store. Never add a second cursor store or peer id to work around it. ## Navigation Reveal requests go through the UI store’s `focusTarget` (`focusCard`, `focusSection`): a surface asks and the canvas moves. Never call `setCenter` or `fitBounds` from a panel. A reveal changes the view only. It writes nothing and does not change selection unless the surface also selects. ## Presence and coordinates Broadcast presence in flow coordinates (`screenToFlowPosition`), never raw screen pixels, and render the overlay inside `` so it follows pan and zoom. Never render a shared cursor store across editors without filtering by Loro container, and publish carets only from the focused editor. Persist positions to the CRDT on drag stop only; live positions travel through `EphemeralStore`. Dragging a connector dot means “connect”, while repositioning uses the selected grab handle. Do not publish a pointer position while a local editor or canvas gesture is active. Peers show the name on the interacted element and the pointer returns on the next move. ## References and rich text A property block in a body is a view. It carries a `fieldId` and writes through `setProperty` and `clearProperty`; values live in the card index so facets and dashboards never parse text. Blocks are inserted from the body, not the panel. `/property` and `@` hand over to the one picker, and removing a block removes the view only. One stored style, many views. A value renders through `displayTemplate(field)` by `ValueTemplate`; never format a value by hand, never write a template into a value, and never prepend a marker the template does not name. A `@card.field` chip is a link. It stores `cardId` and `fieldId` and renders the live value. References are derived and rebuildable. `references.` is maintained from mention nodes; never hand-write it, never store a backlink on a card, and fix the index or let `ensureReferences` do it when content disagrees. A semantic link never creates a placement. Only an explicit place action puts a card on a board. No schema writes from the editor. Field creation stays in type settings, because body undo excludes structural origins. Arrow descriptions are free text. Arrows stay untyped, and meaning belongs to a property name. A label drag is a gesture. Publish `labelDrag` and persist once on release. The `@` list has three row kinds: `field` for a block on this card, `card` for a mention, `reference` for another card’s field. Own fields come first, then cards. A suggestion is previewed. The completion is a widget decoration, so arrowing cannot write into the card or land in the undo stack. ## Data layer Never mutate `WorkspaceDocument` internals from the UI. Add typed methods to `packages/graph` instead, and let every `set*` method validate with its Zod schema. Undo is split by scope: never add structural changes to an editor’s history, and never put text in the document’s `UndoManager` scope. Write fields, not records. Prefer `mutateCard`, `mutateEdge`, `updatePlacementPosition`, `updatePlacementSize`, `moveConnector` and `reconnectEdge` over `setCard` and `setEdge`. Reads never create shared state: use `peekContent` and `peekTitle` for fragments, and let creation paths insert containers. Rely on record identity to skip re-renders, and never rely on it flipping. Membership is a collection write: `setCardTypes`, `addCardType`, `removeCardType`, `addCardTag`, `removeCardTag`, `setProperty`, `clearProperty`. Never replace a whole map. Removing a field, switching a type or removing a tag leaves values dormant on purpose. Do not clean up other records; a later explicit command owns that. Required fields are advisory and never block a write. Section membership is derived. Read with `sectionMembership` and `sectionForCard`, write a drag with `moveSectionsBatch`, and write geometry on release rather than during a gesture. One commit per gesture. Never loop a single-record method over a selection, because that produces N undo steps. ## Testing Unit-test pure logic in `packages/graph`: schema, records and the `WorkspaceDocument` domain modules. Multi-client behaviour gets a two-document test. Drive both documents through conflicting edits, sync with `doc.export({ mode: "update" })` and `doc.import(...)` in both directions, then assert the merge. `workspace.convergence.test.ts` fuzzes this across seeds, so add a seeded case for a new kind of write. React Testing Library covers non-trivial UI in `apps/canvas`, and Playwright covers journeys in `e2e`. Tests run under Node’s runner in `services/billing` and Vitest elsewhere; the tiers are in [../how-to/test-and-verify.md](../how-to/test-and-verify.md). ## Commits Conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. The subject is imperative and at most 72 characters; commitlint enforces it. # Data model > This page does not cover how the canvas draws these records or how a share link is authenticated; architecture.md and security-model.md own those. It describes… This page does not cover how the canvas draws these records or how a share link is authenticated; [architecture.md](./architecture.md) and [security-model.md](./security-model.md) own those. It describes the records `@suite/graph` stores, the rules a write follows, and the two derived surfaces beside the document. ## One document, thirteen roots A workspace is one `LoroDoc`. Everything the workspace owns is reachable from thirteen root containers, and each root is a `LoroMap` keyed by record id. | Root | Holds | Opened by | | ------------ | ------------------------------------------------------------------------------- | ------------------------------------ | | `boards` | one map per board | `WorkspaceDocument.BOARDS_KEY` | | `cards` | one map per card, without its text | `CARDS_KEY` | | `placements` | one map per (board, card) pair | `PLACEMENTS_KEY` | | `edges` | one map per link | `EDGES_KEY` | | `types` | one map per card type and its field definitions | `TYPES_KEY` | | `tags` | one map per tag | `TAGS_KEY` | | `sections` | one map per section | `SECTIONS_KEY` | | `content` | one map per card body, holding a rich-text tree | `CONTENT_KEY` | | `titles` | one map per card title, holding a rich-text tree | `TITLES_KEY` | | `references` | derived index of which cards a card mentions | `REFERENCES_KEY` | | `meta` | workspace name, seeding flag, navigation board, shell paint, theme, style rules | `META_KEY` | | `views` | one map per saved view | `VIEWS_KEY` | | `drawings` | one scene per card | `WorkspaceDocument.drawingRoot(doc)` | `STRUCT_KEYS` in `packages/graph/src/loro/keys.ts` names the first twelve. The drawings root is not in that object: it is opened only through the facade, so no domain module can reach it without going past the typed writer. `WorkspaceDocument.ROOT_KEYS` lists all thirteen in the order a projection replaces them. The facade is `packages/graph/src/loro/WorkspaceDocument.ts`. Its card half is the `CardsFacade` class in `packages/graph/src/loro/facade/cards.ts`, which the facade extends, so `doc.listCards()` and `doc.titleId()` resolve on the same prototype as `doc.createBoard()`. Domain modules are plain functions over a shared `DocContext` (`packages/graph/src/loro/context.ts`), composed by `createDocApi` in `packages/graph/src/loro/api.ts`, and they call each other through `ctx.api`. ## Record shapes `packages/graph/src/schema.ts` is the only description of a record. Every read parses through it, so a malformed value throws at the boundary that read it rather than reaching the canvas. | Record | Stored on the record map | Nested containers | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | | Board | `name`, `color`, `coverAttachmentId`, `template`, `style`, `createdAt`, `updatedAt` | `tags`, a map of tag id to `true` | | Card | `createdAt`, `updatedAt`, `deletedAt`, `primaryTypeId`, `style` | `types`, `tags`, `properties`, `connectors` | | Placement | `boardId`, `cardId`, `x`, `y`, `z`, `createdAt`, `width`, `height` | none | | Section | `boardId`, `title`, `x`, `y`, `width`, `height`, `z`, `color`, `style`, `createdAt`, `updatedAt` | `tags`, `connectors` | | Edge | `boardId`, `source`, `target`, `sourceKind`, `targetKind`, `sourceConnector`, `targetConnector`, `kind`, `path`, `bidirectional`, `label`, `labelOffset`, `bends`, `layer`, `style`, `createdAt`, `updatedAt` | none | | Card type | `name`, `pluralName`, `glyph`, `color`, `templateCardId`, `createdAt`, `updatedAt` | `fields`, a map keyed by field id | | Tag | `name`, `color`, `createdAt` | none | | Saved view | `name`, `position` | `query`, a map holding the query facets | A placement is keyed by `placementKey(boardId, cardId)` from `packages/graph/src/loro/constants.ts`, which is `${boardId}/${cardId}`. One card can therefore sit on many boards without a second record kind. `z` is a fractional number. A reorder writes the one placement that moved instead of renumbering the stack, and `MIN_RANK_GAP` is the distance below which the board renumbers. `deletedAt` is a number on a card and a drawing element, null or absent everywhere else. It is the soft delete, not a second record. Readers live in `packages/graph/src/records/`. `cardFromMap` is the one card reader, `normalizeCard` backfills a field added after a document was created, and `readIdSet` turns an id-keyed map into the array a caller expects. ## How a write behaves A write touches the leaf that changed. `setBoardColor` sets the `color` key of one board map, so a peer renaming the same board concurrently keeps both edits. Membership is a map rather than an array for the same reason. A card’s `tags` is a map of tag id to `true`, so two peers adding different tags both survive the merge, and `applyCard` diffs that map instead of replacing it. `setCard` and `setEdge` are whole-record writes, and the code says so: `setCard` is documented as a full write whose field-level alternatives are beside it. `applyCard` rewrites every leaf and stamps `updatedAt`, so a stale copy republished through it reverts a concurrent rename even when the sets merge cleanly. Anything a person can edit while another person is editing it goes through `mutateCard(cardId, (fields) => ...)`, which writes one leaf and commits under a `struct:` origin. A read never creates shared state. `peekContent` and `peekTitle` return `null` for a card with no container, and only `getContent` and `getTitle` create one. A viewer mounting an editor for a card it cannot write must use the peek pair, or every peer’s document grows a container nobody authored. Every mutation commits once. `ctx.batch(origin, fn)` coalesces a gesture into one commit, `ctx.commit` appends when no batch is open, and the snapshot layer sees one change per gesture rather than one per leaf. ## Text containers A card’s title and body are separate roots, each holding one `LoroMap` per card. Inside that map a `children` `LoroList` mirrors the editor’s node tree, and every text leaf is a `LoroText`. The publish snapshot calls such a leaf `$text`. The split is the field granularity rule: renaming a card does not touch its body, so a peer typing in the body while another renames the card merges both edits. Origins decide which undo stack an edit lands in. `TEXT_ORIGIN_PREFIX` (`loroSyncPlugin`) and `BODY_BLOCK_ORIGIN` (`text:body`) belong to the editors. `struct:` prefixes belong to the facade. `INDEX_ORIGIN` (`struct:index-refs`) is excluded from both, because the reference index is rebuilt from content and an undo would make it disagree with the text it describes. ## The reference index `references` is derived: for each card, a map of the card ids its title, body and property values name. `packages/graph/src/loro/cards/references.ts` owns it. `ensureReferences` compares the stored set with the set the text currently names and rewrites only the entries that differ, so running it on every client after the first sync converges without coordination. `rebuildReferences` rewrites every entry and drops entries for cards that are gone. Text changes schedule the index through `scheduleReferences`, and the subscriber in `context.ts` skips its own writes by origin so an index write cannot schedule another one. The index is a query surface, not a truth: a caller that wants what the text says asks `contentTargets`. ## Queries and caches Reads are methods on the facade: `listBoards`, `listBoardCards`, `listInboxCards`, `listCardsOfType`, `listCardsWithTag`, `listLinksTo`, `referencesFor`, `listViews`. Each one reads the document maps and parses through the schema. `DocContext` holds a cache per record kind (`cardCache`, `boardCache`, `edgeCache`, and the rest) plus the container and reference caches. The subscription in `context.ts` invalidates the exact keys a commit touched and notifies the listeners that asked about them, which is why a cache is never the authority for what a document contains. A cache cannot be written to make a read faster in a way that changes an answer. Saved views store a query, never a result. `viewQuerySchema` in `packages/graph/src/views.ts` holds text facets, type and tag facets, scope, grouping and predicates, and `viewPredicates.ts` evaluates them against the document at read time. `viewsRevision` moves when a view record changes, so a renderer can subscribe to the view list without subscribing to every card. ## Publish: plan, then apply A share editor never writes the owner’s document. It edits a draft, and publishing copies the captured values onto the source in one commit. `packages/graph/src/publish/` owns both halves. `planPublish` is pure: it compares the draft, the baseline and the source, records `changes`, `conflicts` and `unsupported` keys, and captures the exact value of each change. `applyPublish` refuses a plan with a conflict or an unsupported key, then replays the captured values inside `source.batch(PUBLISH_ORIGIN, ...)`. Apply does not validate. `packages/graph/src/publishValidation.ts` is the validator, and planning is the only place that calls it. Apply’s guards are replay guards, so a plan applied twice writes nothing the second time, and each step compares before writing for that reason. `publish/apply.ts` keeps one named function per change kind, so the replay rule for each kind can be read on its own. The caps a plan enforces are in [limits.md](./limits.md): bytes per card text, bytes per draft, coordinate range, rich-text nesting and a fixed allowlist of node and mark names. ## Projection: a second document per share A board, card or selection share reads a projection, which is a fresh `LoroDoc` built from the owner’s document. `projectWorkspace` in `packages/graph/src/projection/workspace.ts` copies the board, the selected cards, their placements, their sections and the edges whose ends both travel, then commits once under `sys:projection`. Nothing outside the selected scope is copied, so the projection cannot leak a record the share does not cover. A reference to a card that stayed behind is reported with `private: true` instead of being copied, and a board share replaces the title with `PRIVATE_REFERENCE_TITLE`. The relay keeps one such document per open share room, and `apps/collab-server/src/projection/` rebuilds it when the owner’s copy changes. ## History is the oplog A saved point is a Loro change, not a record of our own. `WorkspaceDocument.listHistoryPoints` reads `doc.getAllChanges()` and reports each change as a point with its origin, its timestamp and the frontier that ends it (`packages/graph/src/history.ts`). Nothing new is persisted, so a workspace that never opens the panel pays nothing for it. Every commit carries its origin as the change’s message, which is what the panel shows. `DocContext` also sets `setChangeMergeInterval(-1)`, so one commit is one change: Loro otherwise folds same-message commits from the same second into one change and hides a point the panel could restore. `previewRestore` answers what a restore would do without writing: it forks the document, checks the fork out at the point, and compares record ids and card titles. `restoreToPoint` calls `doc.revertTo(frontier)` and then commits, so the revert is a new change that peers receive and that `undo()` reverses. A restore cannot reach a point older than the document’s shallow history, and it reverts every peer’s changes after the point, not only the reader’s. ## Templates are data `packages/graph/src/workspaceTemplate.ts` holds the shapes a workspace can start from as validated data: boards, regions and cards with authored geometry, plus tags, colours and card types by name. `applyWorkspaceTemplate` copies one into a document through the same typed methods a person would use, in one batch, so the whole template is one undo step and nothing is written at boot. A starter is a copy of authored records, which is what [ADR-0068](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0068-a-system-workspace-authors-what-workspaces-start-with.md) settled, rather than a code path that builds cards by hand. ## Files, backups and merge `packages/graph/src/workspaceFile.ts` wraps a document in the `.suite` envelope (`suite.workspace`, version 2, which added attachments). `packages/graph/src/portable.ts` exports a document as plain JSON (`suite.portable`, version 1) for interchange, and `merge.ts` merges a portable export into a document. `SCHEMA_VERSION` is 4. It is the version of the document shape the code expects, not the version of the `.suite` envelope, which carries its own number. ## Where the schema lives The chain for any record change is the same, and [../how-to/change-the-data-model.md](../how-to/change-the-data-model.md) walks it with the tests each step needs. 1. `packages/graph/src/schema.ts` describes the record. 2. `packages/graph/src/records/` reads it back, including a backfill for a field older documents lack. 3. `packages/graph/src/loro/` writes it, one leaf at a time. 4. `packages/graph/src/loro/api.ts` composes the domain module, and `WorkspaceDocument.ts` or `facade/cards.ts` exposes the method. 5. `packages/graph/src/index.ts` re-exports the type a consumer outside the package needs. A schema bump is a decision, not a side effect of adding a field: older clients keep editing the same document, so a field that changes meaning needs a backfill that reads both shapes. # Editing > How card text, fields, templates and reference chips are stored and edited; the document model underneath is in architecture.md. How card text, fields, templates and reference chips are stored and edited; the document model underneath is in [architecture.md](./architecture.md). ## Title and body A card owns two rich-text containers. The title is a single paragraph in `titles`, the body is a document in `content`, and both are mergeable maps keyed by card id inside the workspace document. `CardTitleEditor` and `CardBodyEditor` in `apps/canvas/src/cards/CardBodyEditor.tsx` are the only components that mount them. The title editor is shared by the canvas card and the panel, so the Enter, blur and Escape contract has one implementation: Enter commits unless the `@` picker is open and consuming the key, and an empty title draws the “Untitled” placeholder as decoration, never as text. The split keeps the two edit sessions apart. A title is short, single-line and read on every card face; a body is long and mounted only on the surface that owns the edit. Keeping them apart means renaming a card does not touch its body container, and the two have separate undo origins. ## The rich-text model The editor is Tiptap over ProseMirror, bound to Loro by `loro-prosemirror`. `LoroCollaboration` in `apps/canvas/src/editor/LoroCollaboration.ts` adds three plugins and no schema: `LoroSyncPlugin` mirrors one container into the editor state, `LoroUndoPlugin` supplies the undo stack, and `LoroEphemeralCursorPlugin` publishes carets on the shared cursor store. Each editor binds exactly one container, so a body edit and a title edit are separate transactions with separate origins (`text:body` and `text:title`). The schema lives in `apps/canvas/src/editor/cardEditorExtensions.ts`, apart from the collaboration plugin, so `editorSchemaContract.test.ts` can build the same schema without a document and compare it with the publish allowlist. | Node | File | What it stores | | ------------------------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | | `paragraph`, `heading`, headings’ lists, `blockquote`, `codeBlock`, `horizontalRule`, `hardBreak` | Tiptap `StarterKit` | Text and its block structure | | `mention` | `apps/canvas/src/editor/Mention.ts` | A target card id, an optional field id, an optional display template | | `propertyBlock` | `apps/canvas/src/editor/PropertyBlock.ts` | A field id and an optional display template | | `attachment` | `apps/canvas/src/editor/attachmentNode.ts` | Metadata for bytes held beside the document | | `image` | `apps/canvas/src/editor/imageLinkNode.tsx` | A URL, alt text and a size | | `tourStep` | `apps/canvas/src/editor/TourStep.ts` | Step id, targets, camera, caption and advance rule | Marks are the Tiptap defaults: `bold`, `italic`, `strike`, `code`, `link`, `underline`. They are formatted from the `BubbleMenu` in `CardBodyEditor`, which reads its pressed state through `useEditorState` rather than re-rendering on every keystroke. `packages/graph/src/publish/validation/richTextSchema.ts` is the allowlist a published body is checked against: node names, mark names and the attributes each node may carry. Adding a node or an attribute to the editor without extending it fails `editorSchemaContract.test.ts`, which is the point. A publish validates against that allowlist because a share projection copies content into a different document. The mentions and blocks in a shared body resolve against the copy, so a chip can point at a card the viewer cannot open; those render as inert with the title the projection kept. ## Reading and committing a value The draft control in `PropertyField.tsx` never writes on every keystroke. Enter blurs the input and the blur commits; Escape restores the stored value, marks the edit cancelled and blurs; a blur that follows a real change commits it, and a blur that does not ends the edit without a write. A number or date that fails to parse calls `onCancel`, so an invalid value is discarded rather than stored as text. The card surface around an inline control keeps the session alive across a re-render. `CardPanel` and `NodeCard` hold the blur in a 120 ms timer and cancel it when focus comes back, because a presence update or a sync tick can blur and refocus the editor without the reader having left. Without that grace period a collaborator’s cursor movement would end the edit under the writer. Editing is also exclusive per card. `editingField` and `editingSurface` in `apps/canvas/src/store/useUiStore.ts` decide which of the title, the body or the inspector owns the caret, so a card mounted on two surfaces has one editor with a caret and one read-only copy. ## The `@` picker `@` opens the mention picker in the title and in the body. `apps/canvas/src/editor/SuggestionList.tsx` renders it outside the React tree, which is why the keyboard rules live in its `onKeyDown` rather than in a component: | Key | Effect | | ---------------------- | ----------------------------------------------- | | `ArrowDown`, `ArrowUp` | Move the highlight, wrapping at both ends | | `ArrowRight` or `.` | Enter the highlighted card’s fields | | `ArrowLeft` | Leave a field list and return to its card | | `Enter` | Insert the highlighted row and close the picker | | `Escape` | Close the picker and leave the query text alone | Hovering a row moves the highlight. It never opens a submenu, because a list that changes shape under the pointer makes the next keystroke land on a different row. Three row kinds exist. A card inserts a `mention`; a field of the card being edited inserts a `propertyBlock`, which edits that field in place; a field of another card inserts a mention carrying a `fieldId`. The third form is what a reader writes as `@card.field`, and it reads the other card’s value live. ## The `/` menu `/` opens the block menu from `apps/canvas/src/editor/SlashCommands.ts`. Its rows are Heading 1, Heading 2, Bulleted list, Numbered list, Quote, Code block, Divider, Image from URL, Mention, Tour step and Property. Each row runs a command against the current block after deleting the typed `/query`, so the query never becomes text. The menu keeps the editor’s selection while a row is clicked: `onMouseDown` on the list calls `preventDefault`, because a focus change would collapse the range the command needs. A row that needs an answer inserts a widget instead of opening a dialog. Image from URL is the example: `ImageUrlPrompt` sits in the editor, so the caret and the editing session survive the answer. ## Images and attachments Two nodes hold a picture, and the difference is where the bytes are. An `attachment` stores an id, a MIME type, dimensions and a name, while the bytes live in IndexedDB and, when a relay is configured, on the relay. A share projection copies the metadata and not the pixels, which is what keeps a shared document small; a viewer without the bytes sees the upload state instead of a broken image. An `image` stores a URL, alt text and a size. It works with no relay at all, which is why a standalone build can still hold a picture. Pasting an image runs `PasteImages`, which measures the image, stores the bytes locally, uploads when a relay exists, and marks the node uploaded when the upload lands. A failed upload leaves the local copy in place, so the picture survives a reload. ## Read-only surfaces A card face, a search result and a shared document render the same content without an editor. `CardBodyEditor` takes `readOnly`, which leaves the placeholder out and never focuses the editor; the title editor draws “Untitled” as decorative text instead. A read-only property block closes the panel instead of opening an inline control. Nothing about the read path formats a field by hand. Values go through `formatPropertyValue`, which is also what produces the unavailable and not-shared labels a share needs. ## Fields A field is a `FieldDefinition`: id, name, kind, required flag, order, select options, optional sub-fields and the rating scale. Definitions belong to a type. Values belong to a card, in `card.properties`, keyed by field id. `fieldKinds` in `packages/graph/src/schema.ts` is the closed set: | Kind | Stored value | | -------------- | ---------------------------------------------------------------- | | `text`, `date` | `string`; a date is `YYYY-MM-DD` and must be a real calendar day | | `number` | finite `number` | | `checkbox` | `boolean` | | `select` | option id | | `multiSelect` | array of option ids | | `card` | card id | | `board` | board id | | `rating` | `number` with the field’s scale | | `group` | record of sub-field id to value | | `list` | array of values or of group records | Writes go through `WorkspaceDocument.setProperty(cardId, fieldId, value)` and `clearProperty`. Passing `null` clears, which is different from storing an empty string: `isPropertyValueEmpty` treats a blank text value as unanswered, and a deleted select option stays stored while it reads as unavailable. Sub-fields are addressed as `:` when a group or list renders them, so two groups can both hold a “Page” sub-field without colliding. ## Rendering a field `FieldValueInput` in `apps/canvas/src/cards/PropertyField.tsx` is the one control set. It owns the parse and commit rules for `text`, `date`, `number`, `checkbox`, `select`, `multiSelect`, `card` and `board`, so the inspector, the inline property block and any future cell cannot disagree about what a date is or what clearing means. A composing kind registers an editor instead. `registerFieldKind` in `apps/canvas/src/field/fieldKindRegistry.tsx` maps a kind name to a component, and `apps/canvas/src/field/compositeKinds.tsx` registers `rating`, `group` and `list` at module scope. Importing a module is what makes its kind exist, and `PropertyField.tsx` imports the composing kinds beside the switch, so a composite kind ships without editing the inspector and without each caller registering it. A read-only surface never formats a value itself. `readPropertyValue` and `formatPropertyValue` in `packages/graph/src/property.ts` produce the text, including the unavailable and not-shared labels, and the registry deliberately has no format function: two formatters for one value drifted, and the group summary in the registry counted a blank sub-field as filled while the graph did not. ## Display templates A display template decides how one value reads where it appears: literal text plus `{{label}}`, `{{value}}`, `{{card}}` and `{{type}}`. `packages/graph/src/template.ts` parses it into segments, `ValueTemplate` in `apps/canvas/src/cards/ValueTemplate.tsx` renders them with the label muted, and nothing richer is possible because a template is not markup. Templates are stored per field (`display`), and a single mention or property block may carry its own override. `DISPLAY_PRESETS` supplies the buttons in `DisplayStyleDialog`; the field default is `{{label}} · {{value}}` for a block and `@{{card}} · {{value}}` for a mention chip, because a chip is a reference from somewhere else and a bare name would not say where to. The maximum length is 160 characters. Empty values render as `EMPTY_VALUE_TEXT`, a single em dash, so a template never shows a dangling prefix. ## Cards and templates A card is identity plus `typeIds`, `properties`, `tags`, and its two text containers. It exists without a board, and a board shows it through a placement. Templates duplicate. Using a board template copies its cards into a new board with a name like `Recipe 2`, and a type blueprint is itself a card that a new type copies its fields from. Nothing keeps a link between a template and what it produced (ADR-0038, ADR-0059). ## References A reference is derived, never stored. `packages/graph/src/references.ts` walks card content for `mention` nodes, reads the `cardId` from the node’s attributes map, and adds card-valued fields, so `WorkspaceDocument.referencesFor(cardId)` answers “who points at this” without a relation store (ADR-0019). Edges are separate. An arrow between two cards carries a name and a path, no payload, and no reference row (ADR-0062). That keeps “this mentions that” and “this is drawn next to that” from being the same fact. Deleting a target leaves the mention in place. The chip reads the target live, so a missing card renders as “Unavailable card” and a projection that kept no title renders as “Not shared”; neither case silently deletes the text the reader wrote. ## Mention chips `@` opens one picker shared by the title and the body. Rows come from `apps/canvas/src/editor/SuggestionList.tsx`: a card inserts a chip, a field of the current card inserts a property block, and a field of another card inserts a `@card.field` chip. The chip stores the target id, the field id when there is one, and the per-mention display override. It reads the title, the field name and the value live through `useCard` and `useCardTitle`, so a rename shows up in every mention. `MentionChipNodeView` in `apps/canvas/src/editor/MentionChip.tsx` stops double-click propagation so a chip inside a card does not open the card behind it. Clicking a chip navigates or reveals: a card target opens it, a field target opens the inspector on that field through `focusTarget`. Renaming, retyping or deleting the target never rewrites the chip. ## Undo boundaries Each editor owns its undo stack. `LoroUndoPlugin` builds an `UndoManager` per editor with a 1000 ms merge interval and 100 steps, and excludes origins that belong to another surface: the body excludes `text:title`, the title excludes `text:body`, and both exclude `struct:` and `sys:init`. `Mod-z`, `Mod-y` and `Mod-Shift-z` are bound in `LoroCollaboration`. Structural edits are named `struct:` precisely so they stay out of the text undo stack. Creating a card, dropping a placement or removing a section commits once at the end of the gesture, so one `Mod-z` reverses the whole move instead of one record at a time. Undo is therefore scoped to the surface that has focus, and it never reaches into another card’s text. What a keystroke will undo is decided by which editor the caret is in. ## Where the pieces live | Concern | File | | ------------------------------ | ------------------------------------------------ | | Body schema and extensions | `apps/canvas/src/editor/cardEditorExtensions.ts` | | Collaboration plugins and undo | `apps/canvas/src/editor/LoroCollaboration.ts` | | Body and title editors | `apps/canvas/src/cards/CardBodyEditor.tsx` | | Field control set | `apps/canvas/src/cards/PropertyField.tsx` | | Composite kinds | `apps/canvas/src/field/compositeKinds.tsx` | | Field kind registry | `apps/canvas/src/field/fieldKindRegistry.tsx` | | Templates and presets | `packages/graph/src/template.ts` | | Value formatting | `packages/graph/src/property.ts` | | Reference index | `packages/graph/src/references.ts` | ## What covers this | Behaviour | Test | | ------------------------------------------- | ----------------------------------------------------- | | Editor schema against the publish allowlist | `apps/canvas/src/editor/editorSchemaContract.test.ts` | | Mention node attributes and resolution | `apps/canvas/src/editor/Mention.test.ts` | | Mention chip rendering, display override | `apps/canvas/src/editor/MentionChip.test.tsx` | | Slash menu rows and commands | `apps/canvas/src/editor/SlashCommands.test.ts` | | Field controls and commit rules | `apps/canvas/src/cards/PropertyField.test.tsx` | | Composite kinds | `apps/canvas/src/field/compositeKinds.test.tsx` | | Card panel tabs and editing | `apps/canvas/src/cards/CardPanel.test.tsx` | | Card gestures and the title gesture | `apps/canvas/src/cards/NodeCard.test.tsx` | | Display templates | `packages/graph/src/template.test.ts` | | Value formatting | `packages/graph/src/property.test.ts` | The procedure for adding a node, a mark, a field renderer or a command is in [change-the-editor.md](../how-to/change-the-editor.md). # Environment > Every variable the relay reads, its default, and what it changes. apps/collab-server/src/config/config.ts parses them once at boot and refuses to start on a… Every variable the relay reads, its default, and what it changes. `apps/collab-server/src/config/config.ts` parses them once at boot and refuses to start on a value it cannot read, so a wrong variable is a startup error naming itself, not a silent fallback. `.env.example` lists the same set, and `compose.yaml` passes all of it through. Setting a variable is [../how-to/deploy.md](../how-to/deploy.md); sizing the numbers is [../how-to/operate.md](../how-to/operate.md); the quotas are generated into [limits.md](./limits.md). ## What the panel manages, and what stays here A value the app can re-read on the next request belongs in the settings table, not in this file: `@suite/settings` holds the registry, and the operator panel at `/admin` edits it. Resolution is **organization → global → environment → default**, so a variable here is a boot default rather than a ceiling, and the panel shows which level answered. | Panel-managed | Applies | | --------------------------------------------------------- | ----------------------- | | Stripe keys, webhook secrets, price tiers, `billing.mode` | next control-plane boot | | SMTP host, port, user, password, From | next control-plane boot | | Licence public key and tier caps | next boot | | Backup bucket and endpoint | next backup run | | Flags | next request | These stay in `.env`, because the process needs them before it can read a row, and the panel shows them read-only: `SUITE_CONTROL_PLANE_DATABASE_URL`, `SUITE_SETTINGS_KEY` (it decrypts the stored secrets), `SUITE_AUTH_SECRET`, `SUITE_AUTH_URL`, `SUITE_ADMIN_EMAILS` (the first operator has to be able to sign in), `PORT` and `SUITE_DATA_DIR`. `pnpm setup` generates them; `pnpm doctor` says which one is missing. ## Ports | Variable | Default | Serves | | ------------- | ------- | ------------------------------------------------------- | | `PORT` | `1234` | the Loro WebSocket sync server | | `HEALTH_PORT` | `1235` | the HTTP API, `/health`, `/metrics` and the admin panel | The two must differ; anything else is refused at boot. ## Stores Six SQLite stores exist in code. `compose.yaml` puts all six on the `suite-data` volume at `/data`; when a variable is unset, the default in the table applies. | Variable | Default | Holds | | ---------------------- | -------------------------- | ----------------------------------------------------------- | | `SUITE_DB` | `suite.documents.sqlite` | Loro snapshots and update logs, one room per workspace | | `SUITE_SHARES_DB` | `suite.shares.sqlite` | share rows, token hashes, draft baselines, projection state | | `SUITE_WORKSPACES_DB` | `suite.workspaces.sqlite` | owner-key hashes and workspace rows | | `SUITE_SETTINGS_DB` | `:memory:` | per-workspace runtime quota overrides | | `SUITE_LICENCE_DB` | `:memory:` | licence claims, recorded per licence id | | `SUITE_ATTACHMENTS_DB` | `suite.attachments.sqlite` | attachment bytes and their metadata | A relative path resolves against the working directory, so `:memory:` and a bare filename are fine for a local run and wrong for a container. `SUITE_REQUIRE_DURABLE_STORES=1` (set by `compose.yaml`) requires each of the six to be an absolute file path in one directory, and refuses `:memory:`, a relative path or two stores in one file. Two stores must never share a file, whatever the mode. ## Mode and credentials | Variable | Default | Effect | | -------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUITE_HOSTED` | `0` | `1` enforces hosted licences and installs the hosted quota defaults; `SUITE_LICENCE_PUBLIC_KEY` becomes required and `SUITE_LICENCE` is refused. It also opens the relay’s licence claim store, so the panel can write the four plan states and `pnpm drill:billing` has something to walk; a self-host answers `501` to that route | | `SUITE_LICENCE` | none | signed licence token for a self-hosted instance, verified at boot; see [Self-host licence](#self-host-licence) | | `SUITE_LICENCE_PUBLIC_KEY` | none | Ed25519 public key that verifies `SUITE_LICENCE` and the relay’s licence tokens, as PEM or the base64url raw key | | `SUITE_RELAY_PURGE_TOKEN` | none | bearer the control plane presents to the relay’s own workspace delete, so an account erase removes documents, shares and attachments. It opens that one route and nothing else | | `SUITE_RELAY_URL` | none | where the control plane reaches that relay. Empty leaves the three stores in place and reports them as not-configured | | `SUITE_ADMIN_EMAILS` | none | comma-separated addresses that may use the operator surface: `/api/admin/*`, `/metrics` and `/admin`. Matched case-insensitively against the signed-in account, so revoking an operator is a restart rather than a rotation. Unset means nobody, which is the safe default | | `SUITE_METRICS_LOCAL` | `0` | `1` lets a request from a loopback address read `/metrics` with no credential, which is what a cron scrape on the box needs. It opens nothing else | | `SUITE_PROJECTION_TOKEN` | random per start | the projection worker’s credential; writes only `share-` rooms | | `SUITE_SOURCE_TOKEN` | random per start | the source worker’s credential; writes only a workspace with an active editor share | | `SUITE_INTERNAL_TOKEN` | none | legacy alias for `SUITE_PROJECTION_TOKEN`, still accepted | | `SUITE_VERSION` | `package.json` | the stamp reported by `/metrics`, `/api/admin/status` and the startup line; an empty value is refused | | `SUITE_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` | | `SUITE_TRUST_PROXY` | `0` | `1` uses the first `X-Forwarded-For` hop as the client address for rate limits | | `SUITE_ALLOWED_ORIGINS` | empty | browser origins for the HTTP API, comma-separated; empty answers loopback origins only, `*` answers any origin | | `SUITE_PUBLIC_URL` | none | base URL used for links created by `POST /api/admin/fixtures` | An unset worker token is generated per start, which only matters if a worker reconnects after a restart. Neither worker token may reach a browser or a log. ## Self-host licence A self-hosted relay starts with no control plane, so nothing mints it a session token and no organization subscription holds its capacity. `SUITE_LICENCE` is the whole entitlement: one signed token, the line `pnpm --filter @suite/collab-server licence sign` prints, plus `SUITE_LICENCE_PUBLIC_KEY` holding the public half of the key that signed it. The relay verifies the token at boot. A token that is malformed, expired, signed by another key, or of a format this build does not read stops the process, and the error names the case: `licence-malformed`, `licence-expired`, `licence-bad-signature`, `licence-unknown-version`. A token with no key configured is reported on `SUITE_LICENCE_PUBLIC_KEY`, and an empty value means no licence. The claims become the instance caps. `workspaces` caps `SUITE_MAX_WORKSPACES`, `documentBytes` caps `SUITE_DOCUMENT_BYTES_PER_WORKSPACE` and `attachmentBytes` caps `SUITE_ATTACHMENT_BYTES_PER_WORKSPACE`; a lower value set in the environment still wins. The boot writes each cap into that variable before the quota layer reads it, so `GET /api/admin/limits` reports the cap with the source `env`. Changing the licence or its caps takes effect at the next start. `SUITE_LICENCE` beside `SUITE_HOSTED=1` is a contradiction, and the process refuses to start. A hosted relay takes capacity from the organization’s subscription and reads it from the session token, so there is no merge to define. A session token on a self-hosted instance, which is possible when the operator runs a control plane as well, authenticates a person and adds no capacity: the per-workspace claim record is written only in hosted mode. [billing.md](./billing.md) has the seller’s command and the operator’s side. ## Quotas | Variable | Self-host | Hosted (`SUITE_HOSTED=1`) | | -------------------------------------- | --------- | ------------------------- | | `SUITE_MAX_WORKSPACES` | unlimited | unlimited | | `SUITE_DOCUMENT_BYTES_PER_WORKSPACE` | unlimited | unlimited | | `SUITE_PROJECTION_BYTES_PER_WORKSPACE` | unlimited | 32 MiB | | `SUITE_ATTACHMENT_BYTES_PER_WORKSPACE` | unlimited | unlimited | | `SUITE_MAX_SHARES_PER_WORKSPACE` | 100 | 100 | | `SUITE_MAX_SELECTION_CARDS` | 200 | 200 | | `SUITE_DISK_SOFT_BYTES` | unlimited | 4 GiB | | `SUITE_DISK_HARD_BYTES` | unlimited | 5 GiB | Bytes, counts or `unlimited`. A hosted licence carries its own `workspaces`, `documentBytes` and `attachmentBytes`, and the tighter of the licence and the instance value wins. A self-host licence caps the same three and follows the same rule. Setting `unlimited` by hand overrides the hosted guardrails, which is why leaving the variable unset is the safe choice. The exact numbers and the code that enforces each one are in [limits.md](./limits.md). ## Rate limits Per client address, fixed one-minute windows. Empty keeps the default shown. | Variable | Default | | -------------------------------- | ------- | | `SUITE_CLAIM_RATE_LIMIT` | 600 | | `SUITE_ROTATE_RATE_LIMIT` | 10 | | `SUITE_SHARE_RESOLVE_RATE_LIMIT` | 300 | | `SUITE_SHARE_CREATE_RATE_LIMIT` | 30 | | `SUITE_PUBLISH_RATE_LIMIT` | 20 | | `SUITE_ADMIN_RATE_LIMIT` | 300 | ## Canvas build These are inlined by Vite at build time, so they are fixed in the bundle and not read at runtime. | Variable | Default | Effect | | ---------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `VITE_SUITE_MODE` | `relay` | `standalone` drops share resolution, the owner claim, the provider and sharing; any other value keeps the full product | | `VITE_COLLAB_HTTP_URL` | `/api` | the HTTP API base the canvas calls | | `VITE_COLLAB_URL` | `ws(s):///ws` | the sync WebSocket URL | ## Accounts, billing and the compose front end The account service is mounted by `apps/control-plane/src/modules/auth.ts` at `/api/auth/*`, so these variables affect that process and not the relay. `SUITE_AUTH_ENABLED` (default `0`) turns it on; off, every account path answers `503 auth-not-configured`. | Variable | Default | Effect | | --------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUITE_AUTH_ENABLED` | `0` | `1` or `true` composes the service and mounts its routes | | `SUITE_AUTH_SECRET` | none | signing secret for cookies and the JWT key pair, at least 32 characters | | `SUITE_AUTH_JWKS_URL` | `${SUITE_AUTH_URL}/api/auth/jwks` | where the relay fetches the key set. Set it when the control plane is reachable under a different name than the public origin, which is every compose deployment: the token names `https://app.suite.run` and the relay reaches `control-plane:1236` | | `SUITE_AUTH_URL` | none | public origin the session cookie is scoped to, and the JWT’s `iss` and `aud` | | `SUITE_AUTH_DATABASE_URL` | none | Postgres for accounts, organizations and licences; required when the service is on | | `SUITE_AUTH_MIGRATE` | on | `0`, `false`, `no` or `off` skips the boot migration for an operator who applies `services/auth/sql` out of band | | `SUITE_AUTH_RP_ID` | `localhost` | WebAuthn relying-party id; the registrable domain passkeys are bound to | | `SUITE_AUTH_RP_NAME` | `Suite` | relying-party name shown by the authenticator, and the TOTP issuer | | `SUITE_AUTH_MAX_ORGS_PER_USER` | `1` | organizations one account may own | | `SUITE_GOOGLE_CLIENT_ID` | none | with the secret, registers the Google provider | | `SUITE_GOOGLE_CLIENT_SECRET` | none | one half of the pair without the other refuses to boot | | `SUITE_TURNSTILE_SECRET` | none | set it to require invisible Turnstile in front of sign-up; unset means no challenge | | `SUITE_TURNSTILE_SITE_KEY` | none | the same widget’s public site key, passed to the image build as a `VITE_` argument: Vite inlines it, so a runtime-only value never reaches the browser | | `SUITE_ADMISSION_REQUIRED` | `0` | `1` holds a new account until an operator admits it, gating `/organization/create`. Refused at startup unless `SUITE_ADMIN_EMAILS` names an operator, because the queue would have no door | | `SUITE_APP_URL` | none | where the admission notice sends someone; unset admits without sending one | | `SUITE_MAIL_TRANSPORT` | `console` | `console`, `resend` or `smtp`. Anything else refuses to boot | | `SUITE_MAIL_FROM` | none | sending address, e.g. `Suite `; required by `resend` and `smtp` | | `RESEND_API_KEY` | none | required by `SUITE_MAIL_TRANSPORT=resend` | | `SUITE_SMTP_URL` | none | required by `SUITE_MAIL_TRANSPORT=smtp`: `smtp://user:pass@host:587` or `smtps://host:465` | | `SUITE_SMTP_REQUIRE_TLS` | `1` | `0` accepts a relay with no StartTLS; only for a loopback or private network | | `SUITE_FEEDBACK_TO` | none | where the in-app feedback bubble sends; unset hides the bubble, because a form that cannot send is worse than none | | `SUITE_AUTH_REQUIRE_VERIFICATION` | on | `0` or `false` issues a session before the address is confirmed. For a local run with no mailbox; a deployment that sets it has an open sign-up | Sign-up is open, and an account cannot sign in until its address is verified. The service takes a `MailTransport` and the control plane chooses one from `SUITE_MAIL_TRANSPORT`: `console` writes the link to the process log with the token redacted, `resend` posts to the Resend API, and `smtp` speaks submission SMTP with StartTLS and `AUTH PLAIN` or `AUTH LOGIN`. A vendor transport missing its key or its sender refuses to boot rather than falling back to `console`, which would look healthy while every verification email went to a log file. The meanings are in [security-model.md](./security-model.md). Billing owns `SUITE_BILLING_ENABLED`, `SUITE_BILLING_STORE`, `SUITE_BILLING_DATABASE_URL`, `SUITE_APP_ORIGIN`, `SUITE_LICENCE_PRIVATE_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_TIERS`, `SUITE_SELF_HOST_URL`, `SES_FROM_ADDRESS` and `SUITE_SES_REGION`, all listed in [billing.md](./billing.md). `SUITE_BILLING_MODE` names which credential set is live: `test` (the default) reads `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` and `STRIPE_PRICE_TIERS`, and `live` reads only the `_LIVE` versions of those three. A webhook is verified with the active mode’s signing secret and no other, so a test event that arrives while the deployment is live fails verification instead of writing a licence. Switching to live with the `_LIVE` set incomplete leaves billing off, and the boot log names the variables it wanted. `SUITE_PG_DUMP_URL` is not read by a process; it tells `deploy/backup.sh` and `deploy/restore.sh` how to reach a managed Postgres. Unset, they dump through the compose `postgres` service instead, which is the local shape. The `postgres` service itself sits behind the `local-db` profile: `pnpm up` passes it, a deployed host with RDS does not. \| `SUITE_OPERATOR_ALLOW` | tailnet + loopback | space-separated CIDRs Caddy serves `/admin*`, `/metrics` and `/api/admin*` to. A local stack adds `172.16.0.0/12` for the Docker bridge; a deployed host keeps the tailnet and nothing else | `compose.yaml` adds three variables it consumes itself: `SUITE_DOMAIN` (the hostname Caddy serves), `SUITE_ACME_EMAIL` (the ACME account address) and `SUITE_IMAGE` (the image to run, defaulting to the image the host builds locally, `suite:local`). ## Generated check The tables above are written by hand from `config/config.ts`, `compose.yaml` and `.env.example`, and `pnpm test` covers the parser rather than this page. A later change may generate the variable lists from `config.ts` and fail the docs check when a variable is missing here; until then, the way to verify a variable is to grep `config.ts` for it. # Glossary > One definition per term, in the sense this repository uses it. The model behind them is architecture.md, and the code identifiers are listed beside each term. One definition per term, in the sense this repository uses it. The model behind them is [architecture.md](./architecture.md), and the code identifiers are listed beside each term. | Term | Means | In code | | ---------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | | workspace | One document, one sync room and one IndexedDB entry for a whole body of work | `WorkspaceDocument`, `meta`, `?workspace=` | | board | A view inside a workspace that shows cards at positions; it has a name and a tab | `boards`, `boardId` | | card | The object itself: content, primary type, tags, properties and connectors. It exists with or without a board | `cards`, `Card` | | placement | Where a card sits on one board, including size and layer rank | `placements`, `Placement` | | section | A rectangle on one board that groups the cards inside it. Membership is derived from position, never stored | `sections`, `sectionMembership` | | library | The dock and drawer that find cards not on the board in front of you | `apps/canvas/src/library` | | type | A card schema: a name, glyph, colour and a set of fields | `types`, `CardType` | | field | One definition inside a type, with a kind and optional options | `types..fields`, `FieldDefinition` | | property | One card’s value for one field | `cards..properties`, `PropertyValue` | | tag | A free label a card can carry, workspace-wide rather than owned by a type | `tags`, `tagIds` | | template | A card a type copies when it creates a new card | `CardType.templateCardId` | | reference | A mention or a `card` property pointing at another card. It is workspace truth and places nothing | `references`, `mention` | | link | A drawn arrow between two endpoints on a board. It carries no data beyond its label | `edges`, `GraphEdge` | | connector | A named point on a card’s perimeter where a link attaches | `connectors`, `Connector` | | trash | The soft delete state, recorded as `deletedAt`, from which a card can be restored | `deletedAt`, `trashCard` | | view | A saved query: search text, scope, facets, grouping and order, stored without any card ids | `views`, `ViewQuery` | | lens | What a saved view does to the canvas: it dims everything the query did not select | `activeViewId`, `runViewQuery` | | portal | A card that opens another board and shows a live miniature of it | `PortalNode`, `board` field | | share projection | The read-only copy of a board, card or selection that a share link opens | `projectWorkspace`, `share-` | | account | A person in the identity service, mapped to workspace capabilities through an organization role | `services/auth`, `account` | | licence | A signed Ed25519 token that sets a hosted relay’s workspace and byte caps, and never gates a feature | `licence.ts`, `LicenceClaims` | | presence | Ephemeral signals about a peer: pointer, selection, gesture, caret | `EphemeralStore`, `presence.ts` | | target | The one shape for “the thing I mean”, shared by focus, Follow, Goto and tours | `Target`, `focusTarget` | | handbook article | In-app documentation stored as a card | `handbook` | | tutorial | A card whose body holds step blocks | `tutorial.ts` | | tour | One tutorial’s steps presented over the canvas | `TourOverlay` | | capability | One permission flag a session carries, such as `canPublish` | `sessionCapabilities` | | inbox | The cards with no placement on any board | `listInboxCards`, `Inbox` tab | | home board | The board a card returns to and the workspace lands on; the oldest placement wins | `homeBoardId`, `homeBoardId(cardId)` | | layer rank | The fractional `z` value ordering cards within one board | `placements..z`, `moveLayer` | | claim | Recording the organization that owns a workspace, which happens on the first signed-in join | `workspace_orgs`, `claimWorkspaceForSession` | | fold | A card or a section reduced to its header. Reader state, never a document write | `collapsedCards`, `collapsedSections` | | swatch | One colour in the workspace palette: a name and a value for each mode | `palette`, `PaletteSwatch` | | type style | The default paint for every card of a type. A card’s own style wins over it | `cardTypeSchema.style` | ## Words we do not use A card is never a node, a note or a document. A node is a React Flow node, a note is the built-in card type, and a document is the whole workspace document. A placement is never a deletion: say “remove from board”. Trash is never a delete: “delete forever” belongs to purging. A board is not a canvas, and a workspace is not a file. The canvas is the surface that renders a board, and a file is a `.suite` backup on disk. Capitalisation follows the code: workspace, board, card, section and field are lower case in prose unless they start a sentence. Type names are capitalised when they name a user-defined type (“the Book type”), and lower case when they name the concept. # Limits > What one Suite instance accepts, and what happens at the boundary. The numbers below are read from the code that enforces them, so they cannot drift from the… What one Suite instance accepts, and what happens at the boundary. The numbers below are read from the code that enforces them, so they cannot drift from the behaviour. Two surfaces enforce them: the relay (`apps/collab-server`) and the publish validator (`packages/graph`). ## Workspaces and storage | Quota | Variable | Self-host | Hosted (`SUITE_HOSTED=1`) | | ---------------------------------- | -------------------------------------- | --------- | ------------------------- | | Workspaces per licence or instance | `SUITE_MAX_WORKSPACES` | unlimited | unlimited | | Document bytes per workspace | `SUITE_DOCUMENT_BYTES_PER_WORKSPACE` | unlimited | unlimited | | Projection bytes per workspace | `SUITE_PROJECTION_BYTES_PER_WORKSPACE` | unlimited | 32 MiB (33,554,432 bytes) | | Attachment bytes per workspace | `SUITE_ATTACHMENT_BYTES_PER_WORKSPACE` | unlimited | unlimited | A hosted licence carries its own `workspaces`, `documentBytes` and `attachmentBytes`; the tighter of the licence and the instance value wins. Self-hosting has no licence unless `SUITE_LICENCE` supplies one, so the storage quotas above are unlimited until then. `unlimited` is also accepted as a value for every quota. ### What an account actually gets The table above is the instance ceiling, not the answer a reader wants. A hosted account is capped by its licence row, and a new organization is born with no workspace at all: local and offline use is free and needs no account, so an account is what buys sync. These are the numbers the seat check enforces, read from `FREE_LICENCE_TIER`. | Cap | Born-with licence | Plan tier | | ------------------------------ | ----------------- | ------------- | | Workspaces | 0 | from the plan | | Seats | 1 | from the plan | | Document bytes per workspace | 0 bytes | from the plan | | Attachment bytes per workspace | 0 bytes | from the plan | A plan sells one seat in this build (ADR-0082): there is no per-seat price, so a plan that declared more would bill one person and admit one person. The plan figures are in `deploy/stripe/fixtures.json` and are not repeated here, because they change with pricing rather than with code. The projection budget is also a serving ceiling. A projection room whose stored document is over it refuses a new join with `document-limit`, because each peer holds its own copy of the document in memory. Its bytes are kept, the owner’s workspace is not affected, and the share re-opens once the workspace is trimmed or the budget is raised. ## Links, cards and selection | Cap | Variable | Value | Refusal code | Where it is enforced | | -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------ | ---------------------------------------------------------------------------- | | Active share links per workspace | `SUITE_MAX_SHARES_PER_WORKSPACE` | 100 | none | Creating a link; existing links keep resolving | | Cards in one selection share | `SUITE_MAX_SELECTION_CARDS` / `MAX_SELECTION_CARDS` | 200 | none | `createShareQuotaGuard`, `shares.ts` | | Cards in one share projection | `SUITE_MAX_PROJECTION_CARDS` | 2,000 | `card-limit` | `projectionCardCount`, `projection/snapshot.ts` | | Cards per workspace | none | bounded by the document budget | none | A workspace over its budget pauses new share links; no card count is refused | | Single attachment upload | none | 16 MiB (16,777,216 bytes) | none | `readBytes`, `http/attachmentHttp.ts` | Cards per workspace has no number of its own on purpose. What a board may hold is bounded by the document budget, because that is the number that costs disk, and by the card cap above, because that is the number that costs the event loop. Cards in one selection on the canvas stay bounded by what the browser can hold. ## Publish caps A draft is written by a browser that any client can run, so the publisher re-checks these on the projected plan. They are constants in `packages/graph/src/publishValidation.ts`; only the two byte caps have an environment override. A refused publish answers `409` and writes nothing. | Cap | Variable | Value | Refusal code | | -------------------------------------------- | ----------------------------------- | ------------------------- | ----------------- | | Text bytes per card (title and body) | `SUITE_PUBLISH_MAX_CARD_TEXT_BYTES` | 1 MiB (1,048,576 bytes) | none | | Bytes in one published draft | `SUITE_PUBLISH_MAX_DRAFT_BYTES` | 20 MiB (20,971,520 bytes) | `draft-too-large` | | Largest absolute coordinate, size or z-order | none | 10,000,000 | none | | Deepest rich-text nesting | none | 64 | none | | Section title bytes | none | 1,000 bytes | none | | Edge label bytes | none | 500 bytes | none | Only the draft byte cap has a code of its own. It is the one refusal an editor can act on without reading the plan: the route answers `draft-too-large` in the `409` body, so the editor says the draft is out of room rather than listing an unsupported change beside a size limit nobody can see. ## Transport A document reaches a peer over the room socket as Loro updates. `loro-protocol` refuses to encode a message larger than 256 KiB (262,144 bytes), so the relay sends a larger payload as a fragment header followed by chunks, and the peer reassembles the whole batch before it applies a byte. There is no document-size ceiling on the socket: a 2 MiB workspace arrives the way a 20 KB one does. | Frame | Source | Value | | ----------------------------------- | ------------------------------------------------ | ------------------------- | | Largest message either peer encodes | `loro-protocol` `MAX_MESSAGE_SIZE` | 256 KiB (262,144 bytes) | | Payload carried by one fragment | `sync/outboundFragments.ts` `MAX_FRAGMENT_BYTES` | 240 KiB (245,760 bytes) | | Fragments in one document update | none | as many as its bytes need | Before that was wired, the relay handed a joining peer its whole document in one message. A board of 240 cards fit and a board of 400 did not, so syncing stopped near 250 KB without a number anyone had chosen. That ceiling is gone: what bounds a workspace here is the document budget above, which is a disk quota. ## Concurrency These are per room, not per client address. The rate limits in [../how-to/operate.md](../how-to/operate.md#fixed-limits-and-rate-limits) answer “may this caller do more of this soon”; these answer “is this room too big to serve”, which is the question one huge board raises. | Limit | Variable | Value | Refusal code | Behaviour at the limit | | ------------------------- | ---------------------------- | -------- | ------------------------ | ----------------------------------------------------------------------------------------- | | Peers per room | `SUITE_MAX_PEERS_PER_ROOM` | 64 | `peer-limit` | A join is refused before a session exists, so nothing is written. | | Outstanding work per room | `SUITE_ROOM_OUTSTANDING_MAX` | 8 | `room-backpressure` | A projection rebuild is refused rather than queued; the next save tries again. | | Projection build time | `SUITE_PROJECTION_BUILD_MS` | 5,000 ms | `projection-build-limit` | The room keeps its last good projection and stops rebuilding until a reload or a publish. | | Open projection rooms | `SUITE_PROJECTION_ROOMS_MAX` | 128 | none | The least-recently-used room with no connected client is closed and re-opens on demand. | | Live rooms | none | no cap | none | A room lives while a client is connected; the process is single-replica by design. | Every projection room records the source version vector it was built from. A save whose source has not moved since that build is skipped rather than rewritten, and a skipped rebuild costs one version comparison per open room. `suite_projection_skips_total` counts them. A rebuild follows a **save**, never an edit: the relay coalesces a room’s writes at `saveInterval` (2 000 ms), and the worker then waits 200 ms more (`DEFAULT_DEBOUNCE_MS`). The floor on share freshness is therefore one save window plus one rebuild, measured at 2.0 s p50 and 2.1 s p99 for a 200-card board in four rooms with sixteen readers (`apps/collab-server/bench/report.md`). Nothing in the projection path can be fresher than the save it follows. ## Refusal codes Each cap above refuses with one of these, on the wire where the caller can see it and in the log line that records the refusal. | Code | Refuses | Variable | | ------------------------ | ------------------------------------------------------------------ | -------------------------------------- | | `document-limit` | Serving a projection room whose stored document is over its budget | `SUITE_PROJECTION_BYTES_PER_WORKSPACE` | | `peer-limit` | A join beyond the per-room peer cap | `SUITE_MAX_PEERS_PER_ROOM` | | `card-limit` | Building a projection from an oversized board | `SUITE_MAX_PROJECTION_CARDS` | | `room-backpressure` | A rebuild for a room already at its outstanding bound | `SUITE_ROOM_OUTSTANDING_MAX` | | `projection-build-limit` | Rebuilding a room that already exceeded the build-time cap | `SUITE_PROJECTION_BUILD_MS` | | `draft-too-large` | Publishing a draft over the projected size cap | `SUITE_PUBLISH_MAX_DRAFT_BYTES` | | `attachment-cap` | An upload that would put a workspace over its attachment budget | `SUITE_ATTACHMENT_BYTES_PER_WORKSPACE` | ## Event-loop lag The relay samples event-loop lag every 50 ms and exports the samples as the `suite_event_loop_lag_seconds` histogram, with `suite_event_loop_lag_max_seconds` for the worst sample so far and `suite_event_loop_lag_breaches_total` for the samples over the threshold. A save is judged by how late the loop was, not by how long the write took: the document write runs off the loop, so a slow disk shows up as throughput and a stalled loop shows up here. Five breaching samples in a row log one line at `warn`, with `code: "event-loop-lag"`, the threshold, the run length and the document write counters for the same window. The counter resets when a sample is under the threshold, so a second episode logs its own line. | Threshold | Variable | Value | | ---------------------------------- | ------------------------- | ------ | | Lag sample that counts as a breach | `SUITE_EVENT_LOOP_LAG_MS` | 100 ms | ## Disk watermarks | Watermark | Variable | Self-host | Hosted (`SUITE_HOSTED=1`) | | ----------------------------------------------- | ----------------------- | --------- | --------------------------- | | Document store size that logs a warning | `SUITE_DISK_SOFT_BYTES` | unlimited | 4 GiB (4,294,967,296 bytes) | | Document store size that refuses new workspaces | `SUITE_DISK_HARD_BYTES` | unlimited | 5 GiB (5,368,709,120 bytes) | A workspace that already exists keeps saving past the hard watermark; only a new workspace is refused. Nothing is deleted to make room. ## Restore targets `compose.yaml` keeps the stores on the named volume `suite-data`, mounted at `/data` in the `app` container. A backup or restore target is that volume, not an individual file: owner hashes, share rows and documents refer to each other. | Store | Variable | Path in the container | Survives a container recreate | | -------------- | ---------------------- | -------------------------- | ----------------------------- | | Documents | `SUITE_DB` | `/data/documents.sqlite` | yes | | Share links | `SUITE_SHARES_DB` | `/data/shares.sqlite` | yes | | Workspaces | `SUITE_WORKSPACES_DB` | `/data/workspaces.sqlite` | yes | | Settings | `SUITE_SETTINGS_DB` | `/data/settings.sqlite` | yes | | Licence claims | `SUITE_LICENCE_DB` | `/data/licences.sqlite` | yes | | Attachments | `SUITE_ATTACHMENTS_DB` | `/data/attachments.sqlite` | yes | Backup and restore use `deploy/backup.sh` and `deploy/restore.sh`; the procedure is in [../how-to/operate.md](../how-to/operate.md#backups) and [../how-to/release.md](../how-to/release.md). ## At the boundary * A save is never dropped. A workspace over its document budget keeps loading and saving for its owner; what it loses is the ability to mint new share links. * Only the places where nothing has been written yet are refused: a new workspace, a new share link, a new session in a room at its peer or byte ceiling, an oversized attachment, an oversized publish. A refused rebuild costs freshness for one share, never a byte of the workspace. * A workspace room is never refused for being large, because its owner has to be able to open it to trim it, and a document larger than one frame is fragmented rather than refused. The ceiling that refuses a join is on a projection room, which is a copy the relay can always rebuild. * Every refusal names the limit, the current value and the variable that sets it, so an operator can answer “why” without reading the code. * The server validates every quota at boot: a value it cannot read is a startup error naming the variable, not a silent fallback. # Runtime > The browser process: boot order, persistence, the relay, sharing, and the diagnostics an operator can read. It does not cover the document model… The browser process: boot order, persistence, the relay, sharing, and the diagnostics an operator can read. It does not cover the document model ([data-model.md](./data-model.md)) or the board gestures ([canvas.md](./canvas.md)). ## Boot order A client holds two copies of the document: IndexedDB and the server. Acting on an empty document is indistinguishable from acting on a deleted workspace, so the order is fixed. `main.tsx` resolves `?share=` before it imports `App`. `configureSession` records the room, the role and the read-only flag that `useCanvasStore` reads at module load. A plain workspace open uses `?workspace=`; `?doc=` is a legacy alias, and the default id is `default-workspace`. The store constructs the `LoroDoc`, `WorkspaceDocument`, presence stores and provider once, and starts `LoroIndexeddbPersistence` immediately. They are eager because cursor plugins need a stable `CursorEphemeralStore` on first render. `ready` flips when the local snapshot and update log are imported. Migrations run then, and again when `synced` flips. `synced` flips when the room join completes, or after `SYNC_WAIT_MS` (5 s) when offline. Seeding and board resolution wait for both. Seeding an empty local document before the first sync would resurrect starter cards in a workspace someone emptied on another client. The first editable frame waits only for local hydration. Until then the shell renders `BootSkeleton`, which mounts no canvas, whiteboard or editor. Boot marks live in `apps/canvas/src/dev/bootTiming.ts` and appear with `?perf=1`: `hydrate`, `to-ready`, `to-synced`, `to-canvas`. ## Persistence `LoroIndexeddbPersistence` stores a Loro snapshot plus the update log. On open it imports the snapshot, then each update, then starts `subscribeLocalUpdates`. The provider and persistence share one `LoroDoc`, so a local edit reaches both paths without a second serialization. The workspace id is both the IndexedDB key and the sync room. The active board rides in `?board=` and is not part of the document key. A backup is a versioned JSON envelope around `doc.export({ mode: "snapshot" })`. Import validates the file, refuses a newer version, writes the snapshot under a new workspace id and navigates. It never merges into the open document. JSON and Markdown exports are projections and cannot be imported. ## Offline scope Offline means the open tab keeps working: edits save to this device and sync when the connection returns. Reloading while offline needs the network, because Suite ships no service worker. That is a decision, not an omission. There is no service worker, no web manifest and no app-shell cache, so a reload while offline has nothing to load and the reader sees the browser’s own network error. Nothing is lost by it: the snapshot and the update log live in IndexedDB and load on the next open that can fetch the app. What is *not* promised is a cold start without the network, and no surface says otherwise. `StoragePanel` publishes the same sentence (`OFFLINE_SCOPE` in `apps/canvas/src/sharing/StoragePanel.tsx`). Changing one without the other is a bug: a promise in the UI that the runtime cannot keep is worse than the limitation itself. ## Launcher and workspace names The app starts at `/` on a workspace launcher: recent workspaces from `localStorage`, a blank workspace (`?fresh=1`), and `.suite` import. `localStorage` holds recents, never document truth. The workspace name lives in `meta.name`, so it syncs and travels in an export. `workspaceName()` falls back to the first board’s name and then “Untitled workspace”. `renameWorkspace` refuses empty, overlong or control-character names. An inline rename writes the document, or the `suite.workspaceNames.v1` mirror when the workspace is only a recent entry. ## Credentials A workspace syncs with two credentials: a session JWT for an account and a share token for a link. There is no owner key. [ADR-0076](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0076-the-owner-key-is-deleted.md) deleted it outright, with no migration for keys already issued, so a `.suitekey` file from an earlier release is not read. The client asks `GET /api/auth/token` on the control plane for a session JWT and presents it as `Authorization: Bearer ` on `/api/workspaces/`, `/api/shares`, `/api/attachments` (upload and delete) and the sync join. The relay verifies it against the key set at `/api/auth/jwks`; `apps/collab-server/src/http/sessionToken.ts` holds the cache and the refusal rules, which [security-model.md](./security-model.md) states. The first verified session that opens a workspace records its organization in the `workspace_orgs` table of `SUITE_WORKSPACES_DB`, and every later join, upload and link operation is authorized against that row. An empty credential never writes a workspace room, and a token that does not verify is a refused join. There is no second credential to fall back to. A share link is unchanged: the token is exchanged on first open for a short-lived `HttpOnly` session cookie, and a visitor with no account keeps exactly the access the link grants. ## Accounts `services/auth` holds identity, organizations, invitations, seats and licence checks. Its HTTP entry, memory and Postgres repositories, and capability mapping have tests. The shape is two processes behind one Caddy. `apps/control-plane` hosts that service and billing, and owns the only Postgres; the relay keeps the socket and its SQLite stores and imports neither. The relay verifies a short-lived session JWT against the control plane’s published key set, so it holds no identity table and makes no call per message. `apps/collab-server/src/sync/seatGate.ts` answers from the token’s claims: `org`, `tier` and `limits` are enough to decide a join, so the seat check reads no database. ## Connection lifecycle `status` is `connecting`, `connected` or `disconnected`. The UI stays usable in all three. `App` calls `connect()` through `LoroProvider`; the standalone build skips the call. Presence binds once the provider exists. A reconnect reuses the same document and sends the local oplog version, so a peer that missed updates receives the diff rather than a full snapshot. One message carries at most `loro-protocol`’s `MAX_MESSAGE_SIZE` of 256 KiB. A larger payload travels as a fragment header plus 240 KiB chunks, and the receiving peer applies the batch only after the last fragment arrives. The client fragments what it sends; `apps/collab-server/src/sync/outboundFragments.ts` fragments what the relay sends, which covers the join backfill and any large forward to another peer. Both numbers are published in [limits.md](./limits.md#transport). ## Share projections A board or card share opens `share-`, a room holding a self-contained projection. `apps/collab-server/src/projection/worker.ts` joins every active share with the internal token, keeps one `LoroDoc` per room, and rebuilds a workspace’s shares after each save. A rebuild calls `projectWorkspace` once per distinct share target (four viewer links to one board are one build), then `mirrorWorkspace` (`projection/mirror.ts`), which writes only the entries that differ from what the room already holds, in one commit. It used to call `applyProjection`, which clears and rewrites every projection root: a room document is a CRDT with history, so clearing it appended a whole board to that history on every save and the room grew by \~211 KB per rebuild round, taking the update each viewer receives, the stored row and the join with it. A mirror pass that finds nothing changed writes nothing, not even a commit. The room keeps one peer for its lifetime so each rebuild wins the LWW race against the previous one. A share row stores a `room_id` and only the token hash. The worker can name a room without seeing the token, and resolving a board or card token reports that room as its `workspaceId`, so a share link cannot be exchanged for a workspace link. Redaction runs before the projection is built. A projection carries whiteboard `drawings` for the cards in scope. A portal to a board outside the scope reads as “Unavailable board”. A reference row is emitted once per card crossing the boundary, from one walk over mention targets, card properties and edge endpoints. Per-card helpers made a large rebuild quadratic. A selection share filters the projection to card ids fixed at creation. It keeps the real board id, name and geometry, copies every section containing a selected anchor plus its ancestors, and copies an edge only when both endpoints are selected. `ShareDialog` caps it at `SUITE_MAX_SELECTION_CARDS`. ## Publish from a draft A board or card editor starts from a projection of its scope and publishes the diff back. A board draft can create cards, sections and edges on its board. A card draft edits only its card. Boards, types, tags, trash and links are not creatable from a draft. Publish preserves the client UUID, rejects an id that already exists, and requires a new card to be placed on the shared board. The operation is all or nothing. A rejected object keeps the draft and appears in the “Can’t publish yet” dialog. A card body publishes block by block. Each top-level block carries its own conflict key, `card::body:`, so two editors touching different paragraphs both publish. Identity prefers the block’s Loro container id; between two projections, which share no ids, the fallback is a structural key of node name, subtree hash and occurrence. Apply rewrites only changed blocks in one `share:publish` commit. When blocks cannot be lined up, the card degrades to one whole-body conflict, which still accepts the legacy `card::body` key. ## Room authorization Every sync join carries an `auth` payload. `authorize(roomId, auth, deps)` maps it to a permission: 1. The projection worker token writes only `share-` projection rooms. 2. The source worker token writes only a workspace room that currently has an active editor share. 3. A share token opens exactly its share room, `read` for a viewer and `write` for an editor, plus the derived `:presence` and `:cursors` rooms. 4. A session JWT opens the workspace room and its derived presence and cursor rooms when the organization it names is the one recorded for that workspace. The first verified session to open an unrecorded workspace records its organization. A token from another organization, an expired token and a token that does not verify are all refused. 5. An empty or mismatched credential is rejected. A workspace id is not a secret, and a session can never open a `share-` room. Live revocation closes sessions that already hold a link. `loro-websocket@0.6.2` keeps its socket and room maps private, so `patches/loro-websocket@0.6.2.patch` records each room’s credential and adds `disconnectRoomWhere` and `disconnectAuth`, covering the derived presence and cursor keys. `apps/collab-server/src/sync/roomSockets.ts` wraps it. Revoking a board or card link closes every session in `share-` except the projection worker. Revoking a workspace link closes only credentials that no longer resolve, and leaves the account’s own session alone. Nothing rotates a credential from this side any more: a session token expires and the control plane mints another. ## Standalone and relay `apps/canvas/src/session/mode.ts` owns the profile. `suiteMode()` reads `VITE_SUITE_MODE` (default `relay`). `bootPlan(mode, url)` returns `resolveShare`, `claimOwner`, `connectProvider` and `showSharing`, all true under relay and all false under standalone whatever the URL says. `sessionCapabilities` returns `STANDALONE_CAPABILITIES` before it looks at a share: local editing, layout, creation, boards, schema and trash, with `canManageLinks` and `canPublish` false. `main.tsx` boots standalone with no `ownerKey` and no `share`, and imports the share client and owner-key claim only inside the relay branch. A standalone bundle contains no `/api` path and no socket URL. `App` skips `connect()` and presence binding, marks `synced` itself and shows a “Local only” note. `WorkspaceActions` keeps the File menu and drops Share and the recovery key. `session/mode.test.ts` guards the split: a direct `fetch`, `WebSocket` or `collabHttpBase()` call may only live in a listed relay module. ## Opening a share link `main.tsx` resolves `?share=` before importing `App`. `configureSession` records the room, role and read-only flag. A board or card viewer targets `share-` only, so `workspaceId`, the IndexedDB key and the provider room are that projection room. The viewer never learns the owner’s workspace id. A workspace viewer targets the real room, learns the id and not the owner key, and joins presence and cursor rooms with the share token. `useUiStore.readOnly` closes every write surface: canvas drag, connect and delete, context menus, section gestures, the card menu, inline editors, panel type/tag/property edits, and the dock, library and share actions. Board tabs remain without create, rename or delete. A revoked or unknown token renders `ShareUnavailable` instead of the shell. ## Attachments Images are nodes that name bytes stored beside the document. An `attachment` inline atom holds `{ id, mime, width, height, name, bytes }`. The same allowlist lives in `publishValidation.ts`, and `editorSchemaContract.test.ts` keeps schema and allowlist aligned. A projection copies the document, so bytes in a node would put a private copy of every image in every share link. `apps/collab-server/src/store/attachments.ts` owns the bytes: `put`, `get`, `delete` and `usage` over one table (`id`, `workspace_id`, `mime`, `bytes`, `size`, `created_at`) in `SUITE_ATTACHMENTS_DB`. The interface is the seam for object storage. `apps/collab-server/src/http/attachmentHttp.ts` serves `/api/attachments`. * `POST /api/attachments/` takes a session JWT for the organization that owns the workspace, and never a share token. * `GET /api/attachments/` treats the id as the read capability, so a projected node resolves without exposing the workspace. * `DELETE /api/attachments/` takes a session JWT for the owning workspace’s organization. A GET serves only allowlisted types with `nosniff`, `content-disposition: inline`, immutable caching and `content-security-policy: default-src 'none'; sandbox`. SVG and HTML are refused at upload and at read because markup can carry script. `attachmentBytesPerWorkspace` is enforced inside `put`, in the transaction that inserts the row. A hosted licence’s `attachmentBytes` caps one image on top. A paste uploads under the node id when a relay is configured. Offline, standalone, or from a share session, the bytes go to this browser’s IndexedDB and the node is local-only. The record carries `local`, `synced` or `unknown`; `unknown` must never be read as `local`. Unreachable bytes render a placeholder with the name, and rendering never throws. `gcAttachments(workspaceId, referencedIds)` deletes every row the document no longer references. No admin route calls it yet. `workspaceFile.ts` version 2 adds an `attachments` section of `{ mime, name, width, height, data }` per id, base64 of the exact bytes. Import restores each image locally and does not upload it. An image the file cannot supply is named, not dropped. Export gathers the bytes it can and reports every miss by card title; import decodes what it can, then walks the imported document and reports each referenced id that is missing from the envelope or whose base64 does not decode, again by the card title that draws it (`ParsedWorkspaceFile.damaged`, `damagedAttachmentWarning`). The document always imports whole: losing one image must not cost the text that came with it. ## Stored formats and the N−1 policy Six SQLite stores hold the server’s data. A store written by the previous build has to open here, and a store written by a newer one must not be guessed at. The policy: * **What is guaranteed.** A store from the previous build opens: additive migrations (`ALTER TABLE … ADD COLUMN`) run, a backfill fills only what it can derive, every row is readable, and a document blob is never rewritten by a read. Each older shape has a committed fixture under `apps/collab-server/src/fixtures/n-1/`, and `apps/collab-server/src/store/storeUpgrade.test.ts` runs the current boot path against each one. * **For how long.** One schema bump. The commit that adds a migration also adds its fixture and keeps the previous fixture for at least the following release; a fixture is deleted only in the release that deletes its migration. * **Unknown data.** Refused, never guessed. `PRAGMA user_version` above `DOCUMENT_STORE_SCHEMA` throws `UnsupportedStoreVersionError` naming both versions, and a document blob whose tag is not `loro-snapshot-v1` throws the same error naming both. A column this build does not know stays in the row untouched, because migrations only add columns. There is no best-effort read and no downgrade path; rolling back a schema change means restoring a backup taken on that build. Every store sets the same three pragmas on the connection it opens, through `configureConnection(db, schemaVersion)` in `store/persistence.ts`. `WAL` is a property of the file, so the first opener holds it for every later connection; `busy_timeout` is a property of a connection, so each module sets its own; `user_version` records the schema the store claims, and a file stamped by a newer build is refused with `UnsupportedStoreVersionError` instead of read. | Store | Journal mode | `busy_timeout` | `user_version` | | ------------------------------- | ------------ | -------------- | ------------------------- | | Documents (`persistence.ts`) | WAL | 5000 ms | `DOCUMENT_STORE_SCHEMA` | | Attachments (`attachments.ts`) | WAL | 5000 ms | `ATTACHMENT_STORE_SCHEMA` | | Shares (`shares.ts`) | WAL | 5000 ms | `SHARE_STORE_SCHEMA` | | Workspaces (`workspaceOrgs.ts`) | WAL | 5000 ms | `WORKSPACE_ORG_SCHEMA` | | Settings (`settings.ts`) | WAL | 5000 ms | `SETTINGS_SCHEMA` | | Licence claims (`licence.ts`) | WAL | 5000 ms | `LICENCE_STORE_SCHEMA` | The workspaces store keeps its file name and its `SUITE_WORKSPACES_DB` variable, because `deploy/backup.sh` discovers the file by name. What it holds changed: a `workspace_orgs` table of workspace ids and the organization each belongs to, instead of owner-key hashes. A file written by an earlier build keeps its old `workspaces` table untouched and unread, because ADR-0076 ships no migration for issued keys. ## Diagnostics The relay logs one JSON line per request with a request id. Capability paths are redacted. The logger is `apps/collab-server/src/logging.ts`; server code does not call `console`. `GET /health` answers on `HEALTH_PORT` and is unauthenticated. `GET /metrics` needs an operator session: 404 when the relay has no control plane to verify one against, 401 without a valid session, 429 above the admin rate limit. A loopback request reads it with no credential while `SUITE_METRICS_LOCAL=1`, which is what a cron scrape needs. The admin API can read every workspace and delete any of them. Its panel is a separate Vite entry point, never linked from the app. A token is the access control; not linking it is not. Limits come from `apps/collab-server/src/config/limits.ts` and are published in [limits.md](./limits.md). Every environment variable the server reads is in [environment.md](./environment.md). # Security model > Two credentials open a room: a session JWT for an account and a share token for a link. The owner key that used to be the third was deleted (ADR-0076), with no… Two credentials open a room: a session JWT for an account and a share token for a link. The owner key that used to be the third was deleted ([ADR-0076](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0076-the-owner-key-is-deleted.md)), with no migration for keys already issued. This page describes the current architecture. It is not a promise of end-to-end encryption. ## Capabilities | Credential | Shape | Grants | Does not grant | | ------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | | Session JWT | `Authorization: Bearer ` on HTTP, the join payload on a socket | Read and write the workspace rooms the token’s organization owns, create and revoke share links, upload and delete attachments, delete the workspace | A workspace another organization recorded, a projection room, an admin route | | Workspace editor token | `?share=` | Read and write on the workspace document and its presence | Managing links, deleting the workspace, any account route | | Workspace viewer token | `?share=` | Read-only access to the workspace document | Any write, link management | | Board or card viewer link | `?share=` | Read-only access to a live projection of that board or card | The workspace index or id; any write | | Selection viewer link | `?share=` | Read-only access to a live projection of the selected cards on one board, the geometry of the sections those cards occupy, and the titles of cards they reference, marked not shared | The unselected cards, the workspace index or id, the selected ids, any write | | Board or card editor link | `?share=` | Editing a draft in its own `share-` room, then publishing that draft | Writing the workspace directly, managing links | | Internal worker tokens | `SUITE_PROJECTION_TOKEN` (legacy `SUITE_INTERNAL_TOKEN`) and `SUITE_SOURCE_TOKEN` | Server-to-server access for the projection worker: projection rooms only, and a workspace room only while it has an active editor share | Anything user-facing; they must never reach a browser or a log | | Workspace purge token | `SUITE_RELAY_PURGE_TOKEN`, presented by the control plane as a bearer token | Delete the workspace named in the path, with `{"confirm":""}` in the body. Nothing else | Every other workspace route, `/api/admin/*`, any read | A share token is random and stored only as a SHA-256 hash. The relay resolves it per room as `read` or `write`. A share token can never mint or revoke another link, and it is not a session: a management route answers `401` for one. When a worker token is unset, each server start generates a random one, which only matters if a worker reconnects. The app reads explicit capability flags rather than one read-only switch: `canEditContent`, `canEditLayout`, `canCreateContent`, `canCreateBoards`, `canManageBoards`, `canManageSchema`, `canManageTrash`, `canPublish` and `canManageLinks`. A workspace editor token gets the live workspace surface, meaning boards, types, tags and trash, with no share, export or account actions. A board draft can create cards, sections and edges and edit what it publishes; a card draft edits only its card. Neither can create boards, schema, trash or links. ## The session token The control plane mints a session JWT through Better Auth’s `jwt()` plugin and publishes the key set at `GET /api/auth/jwks`. The relay verifies the token locally. It holds no identity table, no Postgres credential, and makes no control-plane call per message. The token is Ed25519, its issuer and audience are both `SUITE_AUTH_URL`, and every claim is read server-side when it is minted: | Claim | Source | What the relay does with it | | -------- | ----------------------------------- | ---------------------------------------------------------------------------- | | `sub` | the session’s user | Audit context only; the relay never looks an account up | | `org` | `session.activeOrganizationId` | Compared with the organization the relay recorded for the workspace | | `role` | the `member` row | Sets the seat label and the app’s capability flags | | `tier` | the organization’s subscription row | A token with no tier is refused | | `limits` | the same row | Recorded per workspace, so a save that carries no token still meets the caps | The lifetime is 300 seconds (`RELAY_TOKEN_TTL_SECONDS` in `services/auth/src/identity/relayToken.ts`). That number is the revocation window and it is published on purpose: a seat removed, a role lowered or a subscription cancelled takes effect at the next mint, so a revoked member keeps a working socket for at most five minutes. The client asks `GET /api/auth/token` for a fresh token and presents it on the next join; a live socket is not re-authenticated mid-session. ### What the relay checks, and what it refuses `apps/collab-server/src/http/sessionToken.ts` verifies the signature, `iss`, `aud`, `exp` and `iat`, then narrows the payload to the five claims above. Anything else in the token is ignored. A token that fails any step is a refusal: the join closes, and nothing is retried as a share token or as a second credential. `apps/collab-server/src/sync/seatGate.ts` then answers from the claims alone, with no database read. It refuses a session whose `org` is empty and a token whose `tier` is empty, and it treats a share visitor as a capability rather than a seat. ### The key set cache The relay fetches the JWKS at most once per five minutes of use and reuses it in between, so a busy relay does not call the control plane. Two events force a refetch: the cache ageing out, and a token naming a key id the cache has never seen. The second is subject to a five-second cooldown, which is what keeps a forged `kid` from turning every join into a control-plane request; the cost is that a newly rotated key refuses joins for at most those five seconds. Clock tolerance is 30 seconds in each direction, because a relay whose clock has drifted should not refuse a valid token. The tolerance is small on purpose: it adds to the TTL, so a generous value would widen the revocation window by the same amount. The relay fetches nothing at boot. A control plane that is down means no account can join; a control plane that comes back is picked up by the next fetch, without restarting the relay. ## What a browser with no session can do A workspace with no account is local-only. It opens, edits, exports and deletes from the browser with no prompt and no credential, and it never reaches a relay. The socket answers `Authentication failed` because a room credential is a session token, and a token names an organization. This is a deliberate narrowing, not a gap left open. The owner key used to authorise a local room by being a bearer secret nobody else had. Deleting it removed the only attribution such a connection had, and inventing a second credential for it would have recreated the thing that was deleted. Sync also costs a seat, so it needs an owner to charge. Better Auth’s anonymous sessions are gone for the same reason. A session that could be created without a person is a session nobody can be charged for and nobody can be held to, and the browser never needed one: the document is in IndexedDB, and the workspace id in the URL is the whole of its identity. The local-first promise survives intact. Everything a reader can do alone still works with no account, and the document is theirs to export. What changes is the second browser. A workspace becomes visible to another machine at the moment it is claimed by a signed-in account, which is also the moment it can be paid for. The claim is the first join: the relay records the organization on the `workspace_orgs` row and the ids do not change, so a link shared point-to-point before sign-up still resolves afterwards. A self-hosted instance that runs only the relay therefore has no sync either. It is the same rule seen from the other side: the relay has no way to decide who may join, so it refuses everyone. A self-host that wants collaboration runs the control plane beside it, which `compose.yaml` already does. ## Which organization owns a workspace The control plane’s `workspaces` row is the authority: `workspaces.id` is the document id, and `workspaces.organization_id` is the tenant ([ADR-0065](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0065-accounts-own-workspaces.md)). The relay holds no Postgres credential, so it keeps its own copy in the `workspace_orgs` table of the file `SUITE_WORKSPACES_DB` names, and it writes that row when the first verified session opens the workspace. The first claim wins. A later join from another organization is refused with `403 This workspace belongs to a different organization.`, and the recorded row is untouched. ### A named limit: an unclaimed workspace’s id is its credential A workspace nobody has opened on this relay is claimable by the first account that names its id in a verified session. That is the first-claim rule the owner key had, and it is what ADR-0076 decided when it deleted the key: **a workspace’s identity is its document id**. Say it plainly, because the consequence is not obvious: while a workspace is unclaimed, holding its id is the same thing as owning it, and the relay cannot tell the browser that created it from any other. Three facts bound the exposure rather than removing it: * An id is a `crypto.randomUUID()`, so it is not guessable, and it reaches a second person only because the first one pasted it somewhere. * A **share link never carries it.** A published page is `/s/`, and the slug is the share token, which is stored hashed and can be revoked. So handing somebody a link hands them the page, not the workspace. * A claim needs a session whose organization the account administers with `canManageLinks`, and the refusal for an already-claimed workspace is tested (`workspace-owned-elsewhere`). An unclaimed workspace is therefore as private as its id is. Treat an unopened workspace as unclaimed until its owner opens it, and do not paste a workspace URL into a channel you would not paste a document into. [ADR-0084](https://github.com/mr-rogers-neighborhood/suite-canvas/blob/main/internal/decisions/open/0084-a-device-key-adopts-an-anonymous-workspace.md) records the change that closes this: a key the browser holds, with no ceremony for the reader. ## Share links Share links still work for a visitor with no account: resolving a link stays public, because the token is the credential the recipient holds. The first open exchanges the token for a short-lived `HttpOnly` session cookie, and later requests carry the session, which has a 15-minute TTL, is hashed at rest, and is deleted when the share is revoked. A link never becomes a seat and is never asked for one; it is bounded by its own expiry and revocation. Hosted links default to 30 days. A published page has one address: `/s/?share=`. The slug is derived once, when the link is created, from the name of the card or board it publishes, or from the scope word when there is no name; it is unique inside its workspace and never reused, revocation included, so a copied address never comes to mean a different page. The slug is not a credential: it resolves nothing on its own, and the token, or the session the token was exchanged for, remains the only thing that opens the projection. The address keeps its slug for good, so a reader can reload the page, or come back to the address the author copied, with no account; what they present is the session cookie, which slides while the page is used and dies with the link. A leaked share link is fixed by revoking that link. Revocation stops new joins, closes the open sockets that hold it, and removes the projected draft. It does not change the workspace, and it does not close the account that is editing the workspace. A reader who returns to a revoked address is told the page was unpublished; the shell never renders an empty projection in its place. What publishing costs in exposure: the card, and every link inside it, becomes readable by anyone holding the address, and the author is told that in the share dialog before the link exists. ## What the relay can see Documents are plaintext. The document store holds the full Loro snapshot of every workspace, and the operator can read all of it. An SVG or HTML upload is refused because a served attachment must never be executable content. Share tokens are stored as SHA-256 hashes, so a stolen database does not directly hand out working credentials. Session tokens are not stored at all: the relay holds a public key and verifies a signature, so a stolen relay database yields nothing that opens a room. The licence rows beside a workspace are bookkeeping, not credentials. A live socket keeps its credential in memory. The revoke path has to decide which open sockets belong to a share it just invalidated, so the patched sync server keeps each socket’s credential per joined room for as long as the socket is connected, and clears it on leave and on disconnect (`patches/loro-websocket@0.6.2.patch`). That does not expose a session to the operator; it does mean “not stored” should not be read as “never in memory”. Attribution stops at the link. An account’s join is attributable to the `sub` in its token for as long as the relay logs it, and presence (names, pointers, carets) is ephemeral and never written to the document, so every editor using a link shares that link’s identity. Metadata still exists: workspace ids, board and card ids in share rows, creation, last-use and revocation timestamps, the organization id recorded per workspace, and the IP addresses seen by the server or its proxy. The last-used timestamp is coarse, at most one write a minute, and says a link was opened, not by whom. Backups are unencrypted. All six SQLite stores and `.suite` exports contain the same plaintext documents, every uploaded image, and the licence and quota bookkeeping. Suite does not encrypt them. ## Sharing risks Anyone who receives a link can forward, copy or screenshot the workspace. Revoking a link cannot take back what was already read. A share token in a URL can leak through access logs, analytics, screenshots or browser sync. Serve the app with a restrictive `Referrer-Policy` and do not log query strings. A leaked session token is a working credential for up to five minutes, and it is as powerful as the person it was issued to: it syncs, it mints links, and it deletes the workspace. It is not bound to a device. The fixes are the TTL above, TLS everywhere, a restrictive CSP so XSS cannot read local storage or a token in memory, and server-side session revocation in the control plane, which stops the next mint. A link image is fetched from a third party by every reader. An `image` node stores a URL, so the viewer’s browser contacts that host, which learns the viewer’s IP address and when the card was opened. Each link image renders with `referrerpolicy="no-referrer"`, `loading="lazy"` and `decoding="async"`, so the workspace URL and share token are not sent along, and the URL itself is document content, so a share reader can see it. The CSP allows `https:` in `img-src` for this reason; an `attachment` keeps readers off third-party hosts. ## The operator surface `/metrics` and `/api/admin/*` answer an **operator session**, and both routes answer `404` when the relay has no control plane to verify one against. An operator is an account whose address is on `SUITE_ADMIN_EMAILS`: | Caller | Answer | | ------------------------------------------------------ | ------------------------------------- | | No session, or a session this deployment cannot verify | `401` | | A valid session for an account that is not on the list | `403 forbidden`, logged with the path | | An account on the list | `200`, with both scopes | Two things follow from using a session rather than an environment token. Revoking an operator is a restart, not a rotation of a secret that may be cached in a script or a shell history. And the token is the same one the canvas already holds, minted from `SUITE_ADMIN_EMAILS` on the control plane and read here as an `operator` claim, so the answer cannot differ between the two processes. The claim is **not** a role. `role` is the account’s role inside the organization the session acts in, so an organization owner is a perfectly valid session that an operator route still refuses: owning a tenant is not operating the deployment. `read` cannot mutate, and the router keeps that split even though every operator holds both scopes today: it describes the blast radius a narrower role would arrive with. A scrape timer has no session, so `SUITE_METRICS_LOCAL=1` lets a request from a loopback address read `/metrics` with no credential. That opens one route for one method, and a request that arrived through the proxy is never a loopback request. Every mutating admin request writes one audit row to the relay’s log, tagged `admin.mutation`: ```json {"msg":"admin.mutation","actor":"account:user-1","action":"delete-workspace","target":"ws-1","result":"deleted","path":"/api/admin/workspaces/ws-1"} ``` `actor` is `account:`, never a secret and never a role, because “somebody with the token did it” is not an audit trail. `result` is `ok`-family (`created`, `deleted`, `revoked`, `updated`, `swept`), `refused`, `refused-scope` or `failed`. The relay writes no other audit store, so ship these lines somewhere that keeps them: a log line that scrolls away is not an audit trail. ## Recovery and device hygiene There is no owner key to lose. Recovering access is the account’s job in the control plane, and this build offers two doors: a magic link and a passkey. Both need nothing the reader has to remember, which is why the sign-in screen leads with them; a password is one press away behind a disclosure for a reader who wants one. Google is not offered — an OAuth client is one more thing an operator has to provision before the first sign-in, and a deployment that has one can add it back without changing anything else. Creating an account works the same way: an address, a link, and the link is the proof. An account with no organization can sign in and read its own settings, and it cannot sync a workspace, because a session with no `org` has no tenant to be licensed under. Forget this browser clears the cached document, the recent-workspace entry and any local editor state on that browser only. It does not end the session; sign out in the app for that. An exported `.suite` file opens without an account. An exported `.suitekey` file from an earlier release is not read any more, and no support path restores it. Operator-side, `admin delete` removes a workspace’s documents, projected share rooms, share rows, draft baselines and recorded organization. `admin reset-owner` no longer exists: ownership is a row in the control plane, so the operator action is the control plane’s own delete or transfer, not a file edit on the relay. ## Deployment expectations TLS is required. Serve the app, the relay and the control plane over HTTPS and WSS, do not expose the plaintext HTTP ports, and prefer one same-origin deployment. `SUITE_ALLOWED_ORIGINS` is the browser allowlist: unset answers a loopback origin only, a list answers exactly those origins, and `*` answers any origin, which is a decision an operator makes rather than a default. Encrypt the host disk and the backups. SQLite has no encryption at rest in Suite, so use an encrypted volume for the data directory and encrypt off-site backups. Back up all six stores together. `documents`, `shares`, `workspaces`, `settings`, `licences` and `attachments` live in separate files under `/data` in `compose.yaml`. Copy them while the server is stopped, or use `deploy/backup.sh`, and include any `-wal` and `-shm` sidecars. A mixed backup can pair documents with the wrong share rows, and one that omits `attachments` leaves every image as a permanent placeholder, because the document stores only an id. Restore into a fresh volume with `deploy/restore.sh`, and do not put these files on a network filesystem. Run a single instance, because rooms, presence, projection state, the key-set cache and rate-limit counters live in one Node process and SQLite is local to it. There is no shared lock and no multi-replica mode; restarting drops live sockets and not documents. Expect rate limits and add proxy limits. The server applies in-process fixed-window limits per client address to workspace deletion, share creation, token resolution and publish, and answers `429` with `Retry-After`. Override with `SUITE_ROTATE_RATE_LIMIT`, `SUITE_SHARE_CREATE_RATE_LIMIT`, `SUITE_SHARE_RESOLVE_RATE_LIMIT` or `SUITE_PUBLISH_RATE_LIMIT`, and set `SUITE_TRUST_PROXY=1` behind a trusted proxy. These limits stop casual abuse, not a determined flood, so put request-size and connection limits in the reverse proxy too. ## Operator commands There is no user-facing delete everywhere action. The destructive operation is a SQLite command run on the host, and without `--force` it prints the stop-the-server instruction and exits non-zero without opening a database. ```sh # Stop the collab server first. Deletes the workspace document, share rows, # baselines, projected share documents and the recorded organization. pnpm --filter @suite/collab-server admin delete --force ``` The command reads `SUITE_DB`, `SUITE_SHARES_DB` and `SUITE_WORKSPACES_DB`, which default to `suite.documents.sqlite`, `suite.shares.sqlite` and `suite.workspaces.sqlite` relative to the package directory when run through `pnpm --filter`. Deletion is immediate and cannot be undone, so take a backup first. ## Threat model The rest of this page describes what the system does. This section says what it is defending against, so a mitigation can be read as an answer to a named adversary rather than as a feature list. ### Assets, in the order a leak hurts | Asset | Where it lives now | Blast radius if it is read | | ------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Document content | plaintext Loro snapshots in `documents.sqlite`, `.suite` exports, backups | Everything the workspace ever held. There is no encryption to break, only access to control. | | Attachment bytes | `attachments.sqlite` on the relay | The same, for images: pasted screenshots, scans, photos. | | Account identity | Postgres: users, sessions, organizations, members, invitations | Who works with whom, and every address an account signed up with. | | Share tokens | SHA-256 hashes in `shares.sqlite` | A working link, which is a working credential, until it is revoked or expires. | | An operator session | a signed token for an address on `SUITE_ADMIN_EMAILS`, held in a signed-in browser | Every workspace’s metadata, every link, and the ability to delete. Nothing in the environment grants it. | | Licence private key | never on the relay; the control plane signs with it | The ability to mint entitlements for any tier. | | Licence claims | `licences.sqlite` per workspace, `licence` rows in Postgres | Quota bookkeeping, not a credential. | ### Adversaries and the path each one takes | Adversary | Path | What stops it today | | ----------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Anonymous internet | probing `/api/*`, `/ws`, `/metrics`, `/api/admin/*` | Share resolution is the only unauthenticated read and it needs a token; `/metrics` and `/api/admin/*` answer `404` while no operator token is set; CORS names its origins; every route has a per-address rate limit. | | A share-link recipient | the link itself | The link is written as a hash, expires (30 days by default), and can be revoked; revocation closes the sockets holding it and drops the projected draft. It cannot be turned into an account credential or used to mint another link. | | A revoked member | a session JWT already held | The 5-minute TTL is the revocation window. A live socket is not re-authenticated mid-session, so a removed member keeps a working socket for at most that long. | | Another organization | naming someone else’s workspace id in a verified session | First claim wins: the relay records `workspace_orgs` on the first verified join and refuses a later one from a different `org` with `403`. The relay never consults Postgres per message. | | A hostile member of the same organization | a valid session for the same tenant | Nothing above the organization boundary. Members of one tenant are trusted with each other’s workspaces by design; the `role` claim only labels the seat and sets capability flags in the app, and the relay does not enforce a per-role split inside a tenant. | | An XSS payload in a shared document | the page’s own origin | The CSP in `deploy/Caddyfile` (`script-src 'self'`, no `unsafe-inline` on the app hosts) plus React’s escaping. A successful one would read whatever the tab can reach: the in-memory session token and, on the operator panel, the token in `sessionStorage`. | | A network attacker | the wire | TLS is required; `Strict-Transport-Security` is set on every site; the app never mixes an `https` page with a `ws://` socket. | | Someone holding a backup | the bucket or `/srv/backups` | Nothing in Suite: snapshots are plaintext. Encryption at rest is the host disk and the bucket’s server-side encryption. | | A compromised dependency | any package in the tree | Nothing specific to Suite beyond a frozen lockfile, a pinned pnpm version and a supply-chain policy check on install. Treat `better-auth`, `loro-*` and the Stripe SDK as trusted code. | | The operator | `docker exec`, the database files, `/metrics` | Nothing. An operator can read every document and delete any of them. This is a stated capability, not a vulnerability; the only control is the audit line every mutating admin request writes. | ### What this model does not claim There is no end-to-end encryption, so the relay is inside the trust boundary for content. There is no at-rest encryption of documents, so disk access equals content access. A share link’s reader cannot be attributed beyond “the link was opened”, and presence is discarded rather than recorded. Nothing here is a promise of forward secrecy or of surviving a compromised relay host. ## Content Security Policy and violation reporting Every site sends an enforcing `Content-Security-Policy`. `Content-Security-Policy-Report-Only` appears nowhere, because a report-only policy protects nobody and a site that downgraded to one without saying so would look safe and be open. The two sites that proxy `/api/*` to the relay also send `report-uri /api/report/csp` and `report-to suite-csp`, plus the `Report-To` and `Reporting-Endpoints` headers that name the same path. Both directive names are present on purpose: `report-to` is the Reporting API, `report-uri` is what the other browsers still implement. `apps/collab-server/src/http/report.ts` is the collector: * It accepts `application/csp-report`, `application/reports+json` and a plain JSON body, and answers `204` with no body whatever happens, because a malformed report is not an error a browser can act on. * The body is capped at 16 KiB and reports are limited to 120 per address per minute. Past either bound the request is dropped, still with `204`, so a page that reports in a loop cannot turn into a disk filler or a retry storm. * One `csp.violation` log line per accepted report carries `documentUri`, `blockedUri`, the directive, `disposition`, `sourceFile` and the line number. `documentUri` and `sourceFile` are redacted with the same rule as the request log, because the page that triggered a violation can be a share link whose URL carries a token in the query. * Nothing is stored and nothing is re-served. The collector is a log writer, not a database. ## Your data: export and deletion Two things are true at once, and the difference matters to anyone filing an erasure request. ### Workspaces A workspace export: the app writes a `.suite` file from the live document, and an operator can export the same workspace through `GET /api/admin/workspaces/:id/export`. The file opens without an account and contains the document; it does not contain account rows or the relay’s share records. A workspace delete, by the account that owns it (`DELETE /api/workspaces/:id`), by the control plane while it erases an account (the same route with `SUITE_RELAY_PURGE_TOKEN`), or by an operator (`admin delete`). It removes, in one pass: the workspace document and every projected share room, the share rows and their draft baselines, the recorded organization row, the licence record when the relay is hosted, and every attachment row and byte of that workspace. The response reports each count, so a delete that removed less than expected is visible instead of assumed. The local half: `purgeWorkspaceLocally` clears this browser’s cached document, recents entry, cached credential and licence token. Another device keeps its copy until it opens the workspace again and finds the relay empty. ### The account An account can take its data and close itself. Both actions are control-plane routes in `services/auth/src/http/routes.ts` and need the session cookie only. `GET /api/account/export` answers one download, `suite-account--.json`, with `content-type: application/json` and `content-disposition: attachment`. The shape is versioned with `format` (`suite-account`) and `version` (1): | Field | Holds | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | `account` | the `user` row: id, address, name, address verification, second-factor state, and the created and admitted times | | `organizations[]` | every organization the account belongs to, its name and slug, and the account’s own `membership` (role and joined time) | | `organizations[].workspaces[]` | the workspace id, name and timestamps, a `document` reference, and the workspace’s `shares` | | `shares[]` | share id, scope, role, created, expiry and revocation times | | `notes` | what the file does not contain, in the file itself | Document bytes are referenced, never copied. `document.exportedHere` is `false` and `document.exportPath` names the operator export route, because a `.suite` file already exports one workspace and this service holds no document store. `notes.documents` says the same thing to a reader who never saw this page. The file carries nothing that belongs to another person. A member row contributes the account’s own role, never somebody else’s id, role or address, and a share contributes no token and no hash: a hash is a working credential, and this file leaves the deployment. `notes.otherPeople` states that. Share metadata comes from the relay when the deployment injects a `WorkspaceDataStore` into the account routes. Without one, each workspace carries `sharesUnavailableReason` instead of an empty list, so “no links” and “could not read the links” do not look the same. `DELETE /api/account` requires the account’s own id in the body as `{"confirm":""}`, the shape the relay’s workspace delete already uses, so a stale tab cannot erase an account it never named. The erase runs in this order, and the order is the design: 1. Read the account once. An organization the account is the only member of is deleted whole. An organization another person still holds a seat in is kept, and only the account’s own member row is removed from it. 2. Delete the relay-owned data for the workspaces of the deleted organizations: the document and its projection rooms, the share rows and their baselines, the attachment rows and their bytes. Every one of those deletes is idempotent. 3. Delete every control-plane row in one Postgres transaction, with the `user` row last. A failure before step 3 leaves the account whole and able to sign in, so the same request can be sent again and the idempotent relay deletes finish the job. A failure inside step 3 rolls the transaction back, so the account is equally intact. The `user` row goes last because it is the row that authenticates the retry. The account is never usable and half-deleted: its rows leave in one transaction, and the part that cannot join that transaction runs first and is reported. The answer is a per-store report, never a bare success. `200` means the account row is gone and `500` means it is not; in both cases `deleted`, `complete`, `organizations`, `workspaces`, `stores` and `retry` describe what happened. Each entry in `stores` names one store (`organizations`, `members`, `workspaces`, `invitations`, `invites`, `licences`, `subscriptions`, `sessions`, `accounts`, `verifications`, `passkeys`, `twoFactor`, `users`, `documents`, `shares`, `attachments`) with a status (`removed`, `not-removed`, `not-attempted` or `not-configured`), the number it removed, and a reason and follow-up command when it removed less. The three relay stores report `removed` with the count the relay answered when the deployment sets `SUITE_RELAY_URL` and `SUITE_RELAY_PURGE_TOKEN`. The control plane then sends the same delete the account sends, on its own credential, and the report carries what came back. Without that pair they report `not-configured`: the account is still deleted, and the report names the route that finishes each workspace, which is `DELETE /api/workspaces/` with the owning account’s session, or `pnpm --filter @suite/collab-server admin delete --force`. The purge credential is its own token rather than the operator’s authority. The account service is the process with a public sign-in surface, and an operator session reaching it would let a fault there reach every workspace’s limits, rooms and links. This one deletes the workspace it is handed and opens nothing else, and it is set on each side independently, so rotating it costs one restart of each process. **Backups age out; they do not vanish.** A delete is immediate on the live stores and permanent for the relay, but the snapshot that contains the deleted rows is kept until its retention window passes: `deploy/backup.sh` writes a daily copy and a weekly copy on Sundays, and the bucket lifecycle rule expires `daily/` after 7 days and `weekly/` after 35 days. The local `/srv/backups` directory is pruned by the `find … -mtime +14` cron entry in [deploy.md](../how-to/deploy.md). So a deleted workspace or account can remain recoverable from backup for up to 35 days, and from a Postgres dump on the same schedule for the identity rows. That is the honest answer to “when is it really gone”: after the window, not on the click. ## The feedback endpoint `POST /api/feedback` is the one route in the control plane that spends money for an unauthenticated caller: it sends an email, and anyone can reach it. It is capped rather than closed, because the most useful message comes from somebody stuck before they have an account. Three limits hold it: * a fixed window per address (five messages an hour), keyed on the address the proxy reports, so one caller cannot turn the box into a relay; * an 8 KB body and a 4000-character message, refused before the transport is touched; * no storage. The message, the surface and the build id go out through the same transport the account mail uses, and nothing is written down. No workspace id, no document content and no IP address travel with it. `SUITE_FEEDBACK_TO` unset means the route answers `503` and the client hides the bubble: a form that cannot send is worse than no form. A transport failure answers `502` rather than reporting success, because a false thank-you for a message that never left is the one outcome worth being noisy about. # Interface > How the shell, the library, the panels, the styling surfaces and the command palette are assembled, and the focus, keyboard and theme rules they share; the… How the shell, the library, the panels, the styling surfaces and the command palette are assembled, and the focus, keyboard and theme rules they share; the document they read is described in [architecture.md](./architecture.md). ## Shell composition `apps/canvas/src/App.tsx` renders the shell in one column. The title bar sits at the top, the board fills the middle, and the dock and the library drawer sit over its bottom-left corner. The inspector, the card focus and the tour overlay are children of the board area, so they open in the same stacking context as the canvas; the dock, the drawer, the palette and the dialogs are siblings that portal or float above it. Three surfaces hold the chrome: the title bar, the navigation row inside it, and the dock. A board at rest shows nothing else over the canvas. Everything that used to float there (connection state, the saved state, the storage warning, the history toggle, the camera stack) is either a row in the sync panel or a control in the navigation row. | Surface | Component | What it owns | | ----------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | Title bar | `apps/canvas/src/chrome/TitleBar.tsx` | Workspace name and rename, the command bar, sync and storage status, the workspace menu, the account slot, the theme toggle | | Navigation row | `BoardTabs.tsx`, `UndoRedo.tsx`, the history and outline buttons in `app/WorkspaceShellView.tsx` | Home, the open boards with their peer counts, undo, redo, version history and the outline, in one row | | Sync panel | `apps/canvas/src/chrome/SyncPanel.tsx` | Connection state, unsent changes, this device’s copy, the relay’s room name, the storage warning and the storage surface | | Views bar | `apps/canvas/src/chrome/ViewsBar.tsx` | Saved views, applying one, renaming and deleting one | | Dock | `apps/canvas/src/chrome/Dock.tsx` | Library toggle with the inbox count, new card, new sketch, new section, new portal, type settings | | Outline rail | `apps/canvas/src/canvas/BoardOutline.tsx` | The active board’s section tree, in reading order, with counts and presence. Hidden while the board has no sections | | Inspector | `apps/canvas/src/cards/CardInspectorHost.tsx` | The one panel for the selected card or container | | Blueprint install | `apps/canvas/src/app/BlueprintInstallHost.tsx` | The type a card’s title and body declare, previewed before it is written | | Library drawer | `apps/canvas/src/library/LibraryDrawer.tsx` | Every card, board and trashed record, with search and facets | | Command palette | `apps/canvas/src/command/CommandPalette.tsx` | Navigation and actions from one query | | Presence | `apps/canvas/src/presence/` and `apps/canvas/src/cursor/` | Peer cursors, name tags, avatars and the status cursor | | Account | `apps/canvas/src/auth/SignInEntry.tsx` and `AccountPanel.tsx` | Sign in or the account menu, and one dialog for the account, members, devices and the plan | | Launcher | `apps/canvas/src/launcher/WorkspaceLauncher.tsx` | Recents with rename, export and delete, import, and the same account entry the title bar has | The shell holds no document data. Selection, the active board, open tabs, filters, the open surface and the palette query live in `apps/canvas/src/store/useUiStore.ts`; the document arrives through `useWorkspaceSnapshot`. ## The library The drawer has four tabs: `inbox` (cards on no board), `cards`, `boards` and `trash`. `apps/canvas/src/library/libraryFilters.ts` defines the facets: type, tag and section, with OR inside a facet and AND across facets. The section facet appears only when the drawer is scoped to one board, because a section title means nothing outside its board. Search and facets feed the same list. A saved view fills those same controls through `applyView`, so there is one filtering model and no second query language (ADR-0037). Rows share one action menu, opened from the `⋯` button or a right-click, and one piece of state (`rowMenu`), so both entry points cannot show different actions for the same row. A card row can be dragged onto the canvas: the hold and drop run through `useLibraryHold`, and the drop places the card at the pointer instead of at the drawer. A row also holds an open button, and that button is the row’s tab stop. Enter opens the card or the board; `ArrowUp`, `ArrowDown`, `Home` and `End` move between rows through `rowsKeyDown`; the `⋯` menu is how a keyboard reaches Place, Trash and the rest. The pointer gesture is unchanged: a press on the title still lifts the row for a drag, because the open button ignores pointer activations and only answers a keyboard click. `library-sort` chooses title, recently updated or relevance, and `useLibraryList` applies it to whatever the facets and the query left. Relevance is the search ranking, so a list with no query keeps its facet order. A search that reaches the 200-hit cap in `hooks/useLibrarySearch.ts` says so above the rows, because a list that stops at 200 otherwise reads as complete. Deleting a trashed card for good asks in the app’s own `Dialog`, through `pendingPurge` in `LibraryDrawer`. `useLibraryRowActions` only reports the request, because the menu is data and has no component to host a dialog. `libraryFull` widens the drawer to the whole window for browsing; the floating width stays the default because a row has to be draggable onto the canvas beside it. `LibrarySelectionBar` appears with the selection mode, not with the first row, and offers placing the selection as a run. ## Board navigation Board tabs are a navigation, not a tab strip. Switching a board replaces the canvas, so the active tab carries `aria-current="page"`, and the strip can hold the close button and the inline rename field it needs without breaking required children. A back control appears when the visit history has somewhere to go; the history records the board and the viewport, so returning restores both. The home board is a tab that is always reachable when it is not the active one. A portal’s entry and the library’s “go to board” both go through the same activation path, which is why the back stack exists for every route into another board. Peers show on tabs as small avatars, aggregated per board, so “where is everyone” is answerable without opening the outline. ## Panels One inspector exists. `CardPanel` renders in two variants, `inspector` (docked beside the canvas) and `embedded` (inside a card focus), and both read the same card model, so a property can be fixed without going to the canvas (ADR-0050). Every kind that can carry an icon shows the same row: `apps/canvas/src/inspector/IconField.tsx`, one picker with a button that names the current icon and a Clear that returns the entity to what it inherits. What the value means is `resolvedIcon`’s job (see [canvas.md](./canvas.md#icons)); the row only decides how choosing reads, which is why a card, a portal and a section cannot end up with three slightly different pickers. What an inspector shows is one table, `apps/canvas/src/inspector/capabilities.ts`, not a branch per entity. A tab and a body row each declare the kinds they serve, and a surface asks for the ones that serve what it is describing: a card gets Details, Content and Style; a portal gets Details, Contents and Style; a section gets Details and Contents, because its paint is the one colour it owns and everything else about it is the theme. A kind that has nothing for a tab never sees the tab, and a mixed selection intersects rather than unions. `InspectorTabs` draws the strip for all of them: one tab stop, arrow-key movement, and the selected tab the only one with `tabIndex=0`, so the panels differ in their body and not in their controls. `ContainerPanel` covers the container selection (section or portal), `LinkInspector` covers one edge, and `ExplainPanel` shows the explain context. `TypeSettingsDialog` edits the type registry and its fields. Each of these writes through `@suite/graph`; none keeps a copy of the record it edits. Two panels read a card’s metadata without owning it. `apps/canvas/src/panels/AnnotationReader.tsx` shows the passage an annotation card points into, resolved against the source’s text (ADR-0072). A record keeps the quoted text and the offsets it was captured at; the quote is the anchor and the offsets are a hint, so an edit above the passage shifts the range silently. Stale means one thing: the source no longer contains the quote at all, or the source card is gone. Only that state asks the reader to act. `apps/canvas/src/panels/BlueprintInstallPanel.tsx` shows what installing a type blueprint would write, one checkbox per field, and it reads its own footer from `planBlueprintInstall` rather than counting on its own. ## Hover peek A rest on a rendered entity names it (ADR-0055). `apps/canvas/src/canvas/peek/peekResolver.ts` answers for one `Target` at a time (a card, a board, a section or a link) and returns `null` for a kind it cannot describe, so the surface opens nothing rather than guessing. A card reads the same way in the outline rail, in the inspector and on a link endpoint because all three ask the same resolver. `usePeek` in `canvas/peek/usePeek.ts` holds the timing rules, which are the whole difficulty: | Rule | Value | Why | | -------------------------------------------- | ------------------------- | ----------------------------------------------------- | | Open after a rest | `openAfterMs` 450 | Long enough to separate a passing pointer from a rest | | Grace after leaving | `closeAfterMs` 120 | Lets the pointer cross the gap into the peek | | A focus this soon after a press came from it | `pointerFocusGraceMs` 250 | A click selects; it does not peek | Escape, a pointer press anywhere, a scroll and a zoom all dismiss, and those listeners live in the hook rather than in each surface, so no surface can forget one. Keyboard access is the same resolve: `focus` opens the peek at once, with no delay, and `PeekSurface` renders in place rather than through a portal so Tab from the trigger reaches its one button. The surface never takes focus itself, and it is a reading: a name, a kind, one line of context and a route to open the thing. Editing stays in the inspector. The outline rail is the first surface wired to it: `BoardOutline.tsx` gives each section row a `peek` controller, and the rail renders one `PeekSurface` for all of its rows. ## Error codes `apps/canvas/src/i18n/` ships codes before sentences. `codes.ts` is a closed union of `domain.subject.reason` strings, `messages.en.ts` is the English catalogue typed as `Record` so a code without a message fails the typecheck, and `messageFor` turns a code into a sentence with `{name}` placeholders filled. An unknown code is returned as itself, which keeps a log line greppable and leaves the interface short rather than blank. There is one locale and no plural or number formatting yet; a second catalogue is a new file and one entry in `catalogues`, because nothing outside the catalogue holds a sentence. ## Selection Selection is UI state with several readers: the canvas paints it, the inspector opens on it, and the commands that act on “this” resolve against it. `selectedNodeIds`, `selectedEdgeIds`, `selectedSectionId` and `containerInspector` in `useUiStore` are the whole of it. A modifier click belongs to multi-select and must not open the inspector; the card’s click handler returns early for shift, meta and ctrl, and the drag handler decides ownership on pointer down so pan and drag never both claim the same gesture. While a link is being drawn, the inspector fades. The preview passes under the panel and the destination is usually the thing the panel covers, so `linkingInProgress` exists for that one layout decision and nothing about the draft is written to the document. ## Presence surfaces Presence is drawn above the board and below the chrome. `PresenceCursor` shows a peer’s pointer with their name, in flow coordinates so it stays on the right card at every zoom. `PeerNameTag` labels the thing a peer is manipulating and picks its own ink, because a presence colour is chosen by a person and a chip cannot assume white text clears the floor. `PresenceAvatars` stacks the people on a card and collapses the overflow. `StatusCursor` in `apps/canvas/src/cursor/StatusCursor.tsx` is the single renderer for a pointer, and it says what the next click will do. The ring’s shape and glyph carry the purpose, its colour comes from the reader’s presence colour, and the bubble names both. It never relies on hue alone. Marquee and drag boxes ride the same ephemeral channels as the pointer. They are `pointer-events: none` and never wrote a position; the document write happens on release. ## Dialogs and menus Every overlay portals into `document.body`. A `position: fixed` menu rendered inside the canvas would be positioned against that transformed ancestor instead of the window, which puts it off screen; portalling is the reason a menu opens where the pointer is. The board’s right-click menu is one table too: `apps/canvas/src/canvas/board/menu/entityMenu.ts` holds rows, each declaring the kinds it serves, and a press resolves what it landed on and keeps the rows that serve it, drawn in group order (appearance, structure, layout, transfer, share, then anything destructive). A card, a portal, a section, a link and a connector therefore share a shape and differ only in which rows exist for them; a press inside a multi-selection resolves to the whole selection, which is why a verb can count (`Duplicate 4 cards`). `board/useBoardMenu.ts` builds the context and nothing else. `ContextMenu` clamps itself to the viewport and flips a submenu to the left of its parent when the menu was opened near the right edge. A submenu opens on hover, on focus and on click, and a click never toggles it shut, because the pointer already opened it. It also takes focus on its first item when it opens, for the reason in [Focus and keyboard](#focus-and-keyboard). `Dialog` is the confirmation shell: a title, optional description, and a list of actions marked primary or danger. The scrim is a labelled button, so a pointer press outside the panel is a dismissal a screen reader can also reach. Every dialog that says `aria-modal="true"` traps Tab. `SignInDialog`, `AdmissionPanel`, `ShareDialog`, the publish conflict dialog in `SharePublishButton.tsx`, `SettingsDialog` and the shortcut sheet all call `useFocusTrap`, and `AdmissionPanel` starts on the answer it recommends (“Keep working locally”) and closes on Escape. Card focus mode is a modal for the board rather than for the window: `CardFocusSurface` carries `role="dialog"` and the same trap, and a sketch’s drawing surface takes the initial focus, because a sketch listens for keys and had none of them before. Three questions that used `window.confirm` ask in the surface that owns them. Deleting a trashed card for good opens the library’s `Dialog` through `pendingPurge`. Revoking every share link and reloading a draft both replace the control with an inline confirmation inside the dialog they belong to, so the thing about to be lost stays on screen. One confirmation asks for more than a press. `DeleteWorkspaceDialog` requires the workspace name or id to be typed and reports a failure inline without touching the local copy. Leaving a workspace asks nothing: an in-app dialog and the browser’s tab-close prompt both stood there once, and both were removed because they fired for signed-in readers whose work was already synced and could not be acted on. Whether this browser keeps the copy is reported by the storage ladder, in `StoragePanel`. Storage is the one thing that warns without being asked, because a browser clears an origin’s data without telling anyone. `apps/canvas/src/feedback/storageFill.ts` samples `navigator.storage.estimate()` as the document grows (a leading-edge throttle in `storage/estimateWatch.ts`, so a keystroke burst measures once) and toasts at two crossings: `FILL_THRESHOLDS.low` at 70% as a neutral line, and `FILL_THRESHOLDS.critical` at 90% as an urgent failure. Both offer one action, *Export a copy*, because a file survives a cleanup and nothing else on that page does. A crossing is news once: the stage has to fall back to `fine` and climb again before the same line is repeated. The rung-based warning for a copy the browser has not promised to keep is a separate rule, and it stays in the panel rather than interrupting. `DisplayStyleDialog` edits one display template with preset buttons, a live preview through `ValueTemplate`, and a scope choice between this instance and the field. ## Styles and appearance Style is document data, at two levels and no more. `apps/canvas/src/styles/surface/StyleTab.tsx` edits a card, a link or a type; a card wears its type’s style and then its own, and everything else wears the theme. `apps/canvas/src/canvas/surfaceContext.ts` resolves the two levels and hands the result to `surfacePaintVars`, so a collaborator sees the same colours. The same editor is mounted in three places: the inspector, the type editor in `apps/canvas/src/panels/TypeSettingsDialog.tsx`, and `apps/canvas/src/app/AppearanceHost.tsx`, which opens where a reader right-clicks. The menu’s `Colour` row in `apps/canvas/src/canvas/board/menu/entityMenu.ts` adds the palette quick-picks to the same menu, so one colour is one click and a selection of cards is restyled in one. Colours come from the workspace palette: `PaletteEditor` in Settings edits it as one list, each swatch carrying a light and a dark value, and both travel in the document. A **shape** is the frame, not a decoration on one. `shapeClipPath` and `shapeOutline` in `apps/canvas/src/canvas/surfacePaint.ts` are the one table of silhouettes: the clip path is what the browser paints and the outline is what everything else reads — the connector dots (`silhouette.ts` projects a dot onto the paint), the inset that keeps a title inside a diamond (`shapeInset`), and the width of the title bar under it. Where a silhouette is not the box, the card’s own border, shadow and selection ring all stand down, because they are rectangles and a diamond shows all three; the reader’s feedback follows the paint as a drop-shadow glow instead. The edge editor collapses to one `Outline` row for the same reason: a diamond has no top edge to style apart from its bottom one. Device appearance is local, and it lives in Settings. `apps/canvas/src/styles/SettingsDialog.tsx` is a modal dialog with a focus trap and Escape through the escape stack. One section is document data and one is not: `PaletteEditor` writes the workspace palette, and `AppearanceSection` sets density, card radius, canvas background and a motion preference, written by `apps/canvas/src/theme/appearance.ts` as attributes on `` (`data-density`, `data-canvas`, `data-motion`) plus a `--reader-card-radius` variable. Nothing about it is synced, because it changes how this screen draws and never what a peer sees. The dialog opens from the theme menu (`theme-appearance`, which reads “Settings…”) and from the palette’s `system.settings` row. Both go through `openSettings` in `apps/canvas/src/styles/settingsEvents.ts`, a window event, so neither surface has to own the other’s state. The appearance controls keep their `appearance-*` test ids. Theme mode is separate again: `apps/canvas/src/theme/theme.ts` resolves `light`, `dark` or `system` and toggles the `dark` class plus `data-theme` on the root, so both the class-based and attribute-based CSS see one decision. ## Theme tokens `apps/canvas/src/styles/global.css` defines the token set for both themes at the top of the file. | Token | Role | | ------------------------------------------------------------------- | -------------------------------------------------------------- | | `--surface`, `--surface-strong`, `--surface-translucent` | Panel and card backgrounds | | `--hairline` | Every border, at 8 percent ink in light and 10 percent in dark | | `--ink`, `--ink-muted` | Body and secondary text | | `--canvas-bg`, `--rf-dot` | Canvas ground and dot grid | | `--accent`, `--accent-strong`, `--danger`, `--success`, `--warning` | Named roles, never a raw hue at a call site | | `--value-*-hue`, `--value-*-fill`, `--value-*-ink` | The eight value roles of the mode and purpose controls | Muted text carries a measured floor: `--ink-muted` is `#68686d` in light, which is 4.57:1 on the darkest muted surface it appears on, and `#a8a8ad` in dark, which is 5.29:1 on a hovered surface. The value palette deepens a Section hue when it is used as a chip fill, because `--section-blue` at 3.68:1 was never asked to carry text. A card whose paint the resolver had to correct reports `data-contrast-corrected="true"` instead of drawing an illegible pair (ADR-0046). ## Density and canvas treatment The reader’s density setting changes one rule: `[data-density="compact"] .node-card__body` reduces the body padding so more cards fit on one screen. The canvas treatment setting hides the dot grid with `[data-canvas="plain"] .react-flow__background` and moves nothing else. Card radius is a single variable, `--reader-card-radius`, and a card’s own stored radius still wins. `BootSkeleton` covers the wait while IndexedDB is read. It is a `status` region with `aria-busy` and a labelled progress bar, and it deliberately mounts neither the canvas nor any editor. ## The command palette `apps/canvas/src/command/commandRegistry.ts` describes every user-invokable action once: id, group (`navigation`, `create`, `edit`, `layout`, `view`, `system`), the surface that lists it (`palette`, `library`, `inspector`, `canvas`), the capabilities it needs and a predicate over the current state. `paletteProviders.ts` projects those definitions into rows, `paletteRank.ts` ranks them, and the palette draws the first 40. The palette holds only its query and its highlighted row, so closing it leaves nothing behind. Keys: typing filters, `ArrowDown` and `ArrowUp` move the highlight, `Enter` runs the row after closing, `Escape` closes. The search input is a `combobox` with `aria-activedescendant` pointing at the highlighted `option`, so a screen reader announces the row that Enter would run. A command that declares a `shortcut` prints it in a column at the row’s right edge, from the same field the shortcut sheet reads. The library, the inspector and the canvas context menu read the same registry. Availability is a property of the command, which is why a read-only session loses a command everywhere at once rather than in the surfaces someone remembered to guard. `help.shortcuts` opens the shortcut sheet and `system.settings` opens Settings, both through the window events their hosts listen for. `edit.undo` and `edit.redo` are listed only while `CommandActions` holds a handler. The shell supplies both from `apps/canvas/src/app/useBoardPaletteActions.ts`, which steps the document’s undo manager and clears the selection afterwards, exactly as the dock’s pair of buttons does. Three commands act on the document rather than on a surface, and each is listed only while its `CommandActions` field is supplied, so the palette and the dock never offer a row that cannot run. `layout.arrangeRadial` runs the shell’s arrangement, which reads the selection in paint order and hands `planRadial` and `childrenFromLinks` (the same pure functions `arrangeRadialSelection` in `apps/canvas/src/canvas/board/useBoardCommands.ts` uses for the board’s own menu) to `applyLayoutPlan`. `type.installBlueprint` opens `apps/canvas/src/app/BlueprintInstallHost.tsx` on the card the row names: the palette previews the type the card declares and installs it on one press through `installBlueprintCard`, the same call `installBlueprintSelection` makes. It needs `canManageSchema`, which the registry reads from the same capability matrix as every other write. ## Focus and keyboard Escape is a stack. `apps/canvas/src/shell/escapeLayers.ts` keeps one layer per open surface, the most recent wins, and the key is claimed in the capture phase so the canvas chain never also acts on it. A text field inside a non-modal surface keeps Escape for itself; a field inside a dialog does not, because a dialog is what Escape is for. A modal panel traps Tab. `useFocusTrap` in `apps/canvas/src/dialogs/useFocusTrap.ts` moves focus in, wraps Tab and Shift-Tab inside the panel, and returns focus to whatever opened it. It exists because `aria-modal="true"` says the page behind is inert, and before it the last Tab walked into the canvas behind the scrim. Menus answer the arrows. Every `role="menu"` container handles `ArrowUp`, `ArrowDown`, `Home` and `End` through `menuKeyDown` in `apps/canvas/src/dialogs/menuKeys.ts`, moving focus among its own items and skipping a nested submenu’s rows. Without it the role announced behaviour the keys did not have. A menu also takes focus when it opens. `focusFirstMenuItem` is the menu’s `ref`, so the first item holds focus on the frame the menu appears, which is what lets the key handler run at all; before it, focus stayed on the trigger and `ContextMenu`, portalled to `body`, sent Tab to the end of the document. The same ref restores focus to the opener when the menu unmounts, unless something else already took it. A combobox is not a menu. `apps/canvas/src/cards/CardPicker.tsx` keeps focus in its search field and names the highlighted row with `aria-activedescendant`, because focus moving into the list would end the query after the first keystroke. `focusFirstMenuItem` looks for `menuitem` rows, so it is not the helper that surface needs; `Home` and `End` stay caret keys there for the same reason. A tablist is one tab stop. `tabKeyDown` in `apps/canvas/src/dialogs/tabKeys.ts` handles `ArrowRight`, `ArrowLeft`, `Home` and `End`, and selection follows focus; `LibraryHeader` and `ContainerPanel` use it, and `CardPanelTabs` follows the same pattern. The shortcut sheet is the reference for every key. `apps/canvas/src/command/shortcutSheet.ts` builds its rows from the registry’s `shortcut` fields and adds the board keys the registry does not own (`[` for the outline, Enter, Delete, Shift+Delete, Escape). `ShortcutSheetHost` opens it on the `suite:shortcuts-open` event or on `?` outside a text field, and the dock’s `?` button is the visible trigger. Undo and redo are a pair of buttons in the dock (`apps/canvas/src/chrome/UndoRedo.tsx`). They read the document’s own `canUndo` and `canRedo`, subscribe through `useWorkspaceSnapshot`, and clear the selection after a step, because the card that just went away must not stay selected. Reveals are a request, not a scroll. A surface that wants a card or a section shown writes `focusTarget` (`focusCard`, `focusSection`), and the canvas consumes it once. No panel calls `setCenter` or `fitBounds`. Global shortcuts: `Mod+K` opens the palette, `Mod+Shift+K` opens the library on its cards tab and focuses the search box, `Mod+Enter` creates a card at the centre of the viewport, `Mod+G` creates a section around the selection, `Mod+Z` and `Mod+Shift+Z` undo and redo structure, `[` toggles the outline, and `?` opens the shortcut sheet. `Enter` opens the selected node from the canvas keyboard chain, and a title commits on `Enter` rather than adding a line. ## Accessibility Dialogs and the palette carry `role="dialog"` with `aria-modal="true"` and a label, and every one of them traps Tab. The library drawer is a labelled dialog without `aria-modal`, because it docks beside the canvas and the canvas stays usable. Focus is drawn one way. `--accent` is the ring colour for every `:focus-visible` rule in `apps/canvas/src/styles/`, at 2px with a 1px offset. The app bar, the board tabs, the dock buttons, the library tabs and the library rows had no rule of their own and fell back to the browser’s ring while the rest of the app used three different colours. State is never colour alone. The status cursor carries a glyph per purpose and names its owner and purpose in a label; sync status pairs a coloured dot with a word; the value controls use shapes and glyphs as well as the role hues. Motion is a preference before it is a style. `prefers-reduced-motion` removes the launcher slide, the peer cursor and name-tag entrances, the portal scrim and its own transitions, the tour spotlight travel and the live drag transitions. The reader’s `data-motion="reduced"` attribute does the same for every element, including transitions a component declared for itself. Focus is always visible on interactive elements, and a focusable read-only control is avoided: a card title opens on double-click, and the same button also answers `Enter` and `Space`, so the gesture has a keyboard path. ## Tests | Behaviour | Test | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Focus trap and focus restore | `apps/canvas/src/dialogs/useFocusTrap.test.tsx` | | Menu arrow keys, and focus entering the menu | `apps/canvas/src/dialogs/menuKeys.test.tsx` | | Tab strip keyboard | `apps/canvas/src/dialogs/tabKeys.test.tsx` | | Each menu takes focus on open | `ContextMenu.test.tsx`, `Dock.test.tsx`, `ViewsBar.test.tsx`, `BoardTabs.test.tsx`, `WorkspaceActions.test.tsx`, `ThemeToggle.test.tsx`, `CardTypePicker.test.tsx` and `SignInEntry.test.tsx` beside each component | | A combobox keeps focus in its field | `apps/canvas/src/cards/CardPicker.test.tsx` | | Each modal dialog traps Tab | `SignInDialog.test.tsx` and `AdmissionPanel.test.tsx` in `apps/canvas/src/auth/`, `ShareDialog.trap.test.tsx` and `ConflictDialog.test.tsx` in `apps/canvas/src/sharing/`, `SettingsDialog.test.tsx` | | Card focus mode as a dialog | `apps/canvas/src/cards/CardFocusSurface.test.tsx` | | Library rows by keyboard, sort and truncation | `apps/canvas/src/library/LibraryDrawer.test.tsx` | | Shortcut rows and the sheet | `apps/canvas/src/command/ShortcutSheet.test.tsx` | | Undo and redo buttons | `apps/canvas/src/chrome/UndoRedo.test.tsx` | | One focus ring token | `apps/canvas/src/styles/focusRing.test.ts` | | Questions that replaced the platform confirm | `useLibraryRowActions.test.ts` and `LibraryDrawer.test.tsx` in `apps/canvas/src/library/`, `ShareDialog.test.tsx` and `ConflictDialog.test.tsx` in `apps/canvas/src/sharing/` | | Palette ranking and rows | `apps/canvas/src/command/CommandPalette.test.tsx` | | Command availability | `apps/canvas/src/command/commandRegistry.test.ts` | | Tab strip keyboard | `apps/canvas/src/cards/CardPanel.test.tsx` | | Library filters and selection | `apps/canvas/src/library/*.test.ts` | | Theme and appearance resolution | `apps/canvas/src/theme/*.test.ts` | | Escape layer order | `apps/canvas/src/shell/escapeLayers.test.ts` | | Hover-peek timing and dismissal | `apps/canvas/src/canvas/peek/usePeek.test.ts` | | Peek resolution per entity kind | `apps/canvas/src/canvas/peek/peekResolver.test.ts` | | Outline peek wiring | `apps/canvas/src/canvas/peek/outlinePeek.test.tsx` | | Annotation range states | `apps/canvas/src/canvas/annotations/annotationRecord.test.ts` | | Blueprint install diff and partial install | `apps/canvas/src/canvas/blueprints/blueprint.test.ts` | | Error codes and the catalogue | `apps/canvas/src/i18n/i18n.test.ts` | Storybook covers the components whose look is the behaviour: `*.stories.tsx` beside each component in `apps/canvas/src/chrome/`, `apps/canvas/src/library/`, `apps/canvas/src/presence/` and `apps/canvas/src/dialogs/`. The surfaces that mount without a live document have a story: `WorkspaceLauncher.stories.tsx` (empty state, because the recents come from this browser), `CommandPalette.stories.tsx` (static rows, including the shortcut column), `ShortcutSheet.stories.tsx`, `SettingsDialog.stories.tsx`, `ConflictDialog.stories.tsx` in `apps/canvas/src/sharing/`, and `ContainerPanel.stories.tsx` in `apps/canvas/src/panels/` with a plain document fixture. Three surfaces cannot be mounted that way, so the story suite cannot catch a regression in them: the canvas (`apps/canvas/src/canvas/**`) needs the projection, the camera and a real `LoroDoc` fed by the provider; the shell (`apps/canvas/src/app/WorkspaceShellView.tsx`) needs the same document plus a live session; and the share dialogs that call the relay need a `/api/shares` answer, which is why only the pure `ConflictDialog` has a story. # Tutorials > Learn by doing. Each page is one guided path to a first success, with nothing to decide first. Learn by doing. Each page is one guided path to a first success, with nothing to decide first. | Article | What it covers | Updated | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [Run Suite locally](./run-locally.md) | This tutorial takes a checkout to a working canvas in one browser. It is one path with nothing to decide. Variants such as the standalone bundle, cust… | 2026-10-26 | | [Your first workspace](./first-workspace.md) | This tutorial creates a small workspace, adds cards, links two of them, and checks that the work survives a reload. Start the app first with Run Suite… | 2026-10-26 | # Your first workspace > This tutorial creates a small workspace, adds cards, links two of them, and checks that the work survives a reload. Start the app first with Run Suite locally. This tutorial creates a small workspace, adds cards, links two of them, and checks that the work survives a reload. Start the app first with [Run Suite locally](./run-locally.md). ## 1. Create a board The board tabs sit at the top. The control at their right opens a menu; choose **New board**. The new board is empty and selected, and the menu lists every other board in the workspace. ## 2. Add a card The dock is the floating toolbar at the bottom centre. **New card** opens a menu of card types; choose **Note**. The card is created at the centre of the viewport and its title is focused for typing. Click the card title and type `Launch plan`. The title is rich text, so bold and italics work there. **Library** in the same dock opens the drawer, which lists every card in the workspace and offers the same types. ## 3. Add a second card Create another card and title it `Interview notes`. Put the two cards side by side by dragging one. The live position follows the pointer; the drop writes the placement. ## 4. Link the cards Hover the first card. A connector dot appears on its edge. Drag from that dot to the second card and release. The link is stored as an edge with its own direction and description. Select the link to change its path style or add a description. ## 5. Take a card off the board Right-click `Interview notes` and choose **Remove from board**. The card leaves the board and stays in the workspace, which puts it in the drawer’s **Inbox** tab; the **Cards** tab still lists every card. The row menu in the library offers **Place on board** to put it back. ## 6. Reload Reload the page. The board and the card on it come back, and `Interview notes` is still in the Inbox. The link is still in the document; it draws again as soon as both of its cards are on the board. Move a card and reload again. The new position persists because the drop writes the placement to the CRDT. ## 7. Look at the model A workspace owns its cards. A board is a view that places those cards. A card exists without a board, which is why the library can show it after it leaves one. The model is described in [architecture.md](../reference/architecture.md). The layers that draw the board and own each gesture are in [canvas.md](../reference/canvas.md); changing a gesture is [change-the-canvas.md](../how-to/change-the-canvas.md). # Run Suite locally > This tutorial takes a checkout to a working canvas in one browser. It is one path with nothing to decide. Variants such as the standalone bundle, custom ports… This tutorial takes a checkout to a working canvas in one browser. It is one path with nothing to decide. Variants such as the standalone bundle, custom ports and a remote relay are how-tos. You need Node 22 or newer and pnpm 12. ## 1. Install ```sh pnpm install ``` The install also prepares the git hooks. ## 2. Start the app, the relay and the accounts ```sh pnpm dev:env # once: writes a .env with accounts on and a generated secret pnpm dev:db # Postgres on 127.0.0.1:5433, in a container pnpm dev ``` `pnpm dev` runs three watchers: the canvas on , the collab server on port 1234 for the Loro WebSocket and 1235 for its HTTP API, and the control plane on 1236 for accounts and billing. The dev server proxies `/api/auth`, `/api/billing`, `/api/workspaces`, `/api/invites` and `/stripe` to the control plane, and `/api` and `/ws` to the relay, so everything answers on the one origin the page is served from. That is the same shape Caddy provides in a deployment, and it is what makes the session cookie first-party: without it the account surface reports itself unavailable. `pnpm dev:env` never overwrites an existing `.env`. Run it with `--force` to write `.env.new` beside the current one and diff them. ## 2a. Sign in Open and use the account entry in the launcher or the title bar. Sign-up is open, and `.env` sets `SUITE_AUTH_REQUIRE_VERIFICATION=0` because there is no mailbox locally. With it unset the address must be confirmed first, and the console transport redacts the token it prints, so you could not finish a sign-up at all. A browser that never signs in is local-only: it edits and saves to IndexedDB and joins no relay. The sync control in the title bar says `Local`, and signing in changes that. Signing up creates an account, a personal organization named after you, and the owner and administrator rows that go with it. The personal organization is made active at the same time, so the first board you open joins the relay. The room credential is a five-minute token the relay verifies against the key set the control plane publishes. ## 2b. Check what is set up ```sh pnpm check:env ``` One line per dependency. Anything marked `FAIL` blocks the app; anything marked `note` is optional. Each line names the one command that fixes it. Signing in needs Node, pnpm, Docker, Postgres and the `SUITE_AUTH_*` values, and nothing else. ## 2c. Billing in test mode (optional) Sign-in, organizations, seats and sync all work without Stripe. Only checkout and the customer portal need it, and they stay switched off until the four values exist. The Stripe CLI is a separate install, and logging in is a browser flow: ```sh # install, then: pnpm stripe --login # opens a browser, then creates the fixture prices ``` It prints three values. Put them in `.env` next to the key you already have, set `SUITE_BILLING_ENABLED=1`, and restart `pnpm dev`: | Value | Where it comes from | | ----------------------- | --------------------------------------------------- | | `STRIPE_SECRET_KEY` | the CLI’s stored test key, printed by `pnpm stripe` | | `STRIPE_PRICE_TIERS` | printed by `pnpm stripe` | | `STRIPE_WEBHOOK_SECRET` | printed by `pnpm stripe --listen` | Forward deliveries in a fourth terminal: ```sh pnpm stripe --listen ``` `pnpm stripe --clock` creates a test clock for a renewal or cancellation run. Nothing here touches a live account. `pnpm check:env` says whether all four values are present. ## 3. Create a workspace The app opens on the workspace launcher. Choose **New workspace**. The workspace is written in this browser and opens straight away, with no account and no credential to keep: you can sign in later and the workspace comes with you. A new workspace starts blank: one board, no cards. Workspaces created before the blank default keep the two starter cards they were seeded with. The URL now names the workspace (`?workspace=`). Bookmark it. The id is the sync room and the IndexedDB key, so the same URL opens the same document. ## 4. Check that it persists Add a card with **New card**, type a title, then reload the page. The card and its title are still there. The document is stored in IndexedDB as a Loro snapshot plus an update log, so a reload rehydrates without the network. ## 5. Open a second window Open the same URL in a second window. Edit a title in one window. The other window shows the change, the caret and the peer name. The relay is carrying Loro updates between the two clients. Stop the app with `Ctrl-C`. ## If it does not start ```sh pnpm check:env ``` That is the first thing to run: it distinguishes “Postgres is not up” from “accounts are not configured” from “the processes are simply not running”, and says which command fixes each. Port 5173, 1234 or 1235 is already in use. Stop the process that owns it, or run the canvas alone with `pnpm --filter @suite/canvas dev` and skip sync. A canvas without the collab server still edits and persists in this browser; the title bar reads **Offline**. If the page loads but the document is empty, check the browser console for an IndexedDB error. A private window with storage disabled cannot persist a workspace. The environment variables, store paths and build profiles are in [environment.md](../reference/environment.md). The test tiers are in [test-and-verify.md](../how-to/test-and-verify.md).