# Build with AI Source: https://docs.qirtaas.io/ai/build-with-ai Point coding assistants at AI-readable versions of these docs, and use ready-made prompts to integrate Qirtaas. These docs are built to be read by AI coding assistants (Claude Code, Cursor, Copilot, ChatGPT, …) as easily as by you. This page covers the machine-readable endpoints, the docs MCP server, and copy-paste prompts for the common integration jobs. ## AI-readable docs Every page on this site is available as plain markdown, plus two index files: | URL | What it is | | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | [`/llms-full.txt`](https://docs.qirtaas.io/llms-full.txt) | The **entire documentation as one markdown file** — paste or fetch this to give an AI the full picture. | | [`/llms.txt`](https://docs.qirtaas.io/llms.txt) | A short index of all pages with descriptions, for tools that fetch pages selectively. | | Any page + `.md` | The raw markdown of a single page, e.g. [`/get-started/quickstart.md`](https://docs.qirtaas.io/get-started/quickstart.md). | You can also use the contextual menu on any page (top-right of the page body) to copy it as markdown or open it directly in ChatGPT or Claude. ## Docs MCP server The documentation is exposed as an MCP server at `https://docs.qirtaas.io/mcp`, giving your assistant a search tool over these docs. To add it to Claude Code: ```bash theme={null} claude mcp add --transport http qirtaas https://docs.qirtaas.io/mcp ``` For Cursor or VS Code, use the contextual menu on any docs page — it installs the MCP server with one click. ## Integration prompts Paste one of these into your coding assistant, in your project's repo. Each prompt points the assistant at the exact docs it needs; it will adapt the steps to your framework and backend. ### Set up the editor The [Quickstart](/get-started/quickstart), as a prompt — token endpoint plus a mounted editor: ```text theme={null} Integrate the Qirtaas rich-text editor (an embeddable editor for Islamic scholarly writing) into this app. First read https://docs.qirtaas.io/get-started/quickstart.md — follow it, adapting the code to this project's framework and backend. In short: 1. Install the package matching the frontend: @qirtaas/react, @qirtaas/vue, or @qirtaas/core (vanilla). 2. Add a backend endpoint that exchanges the QIRTAAS_API_KEY env var for a short-lived embed token via POST https://api.qirtaas.io/v1/embed/tokens/ with {"external_user_id": }. Gate it with the app's existing auth; derive external_user_id from the session, never from the request body. 3. Mount the editor with getToken wired to that endpoint, inside a container with a bounded height. Hard rules: the qrt_sk_ secret key must never reach the browser — the frontend only ever sees embed tokens. If any detail is unclear, consult https://docs.qirtaas.io/llms-full.txt rather than guessing the API. ``` ### Add a notes feature Full create / list / edit / delete on top of the setup above (the [document creation](/tutorials/document-creation) and [reading](/tutorials/document-reading) tutorials): ```text theme={null} Add a notes feature to this app using Qirtaas: signed-in users write rich documents, see them in a list, reopen them, read them cleanly, and delete them. Qirtaas is the notes backend — no new tables in this app. Read these two tutorials first and follow them, adapting to this project: - https://docs.qirtaas.io/tutorials/document-creation.md - https://docs.qirtaas.io/tutorials/document-reading.md Key points from the docs: - The only backend change is a token endpoint minting embed tokens from the QIRTAAS_API_KEY env var (never expose the key to the browser). - Create a shared client with createQirtaasClient({ getToken }); use listDocuments() / deleteDocument() for the list, and the editor and renderer components for compose and read views. - Mount the editor without a documentId to lazily create a document; record the id from onDocumentCreated. Remount (key) when switching documents. - The editor needs a bounded-height container; mount only one embed at a time when their auth sources differ. ``` ### Gate reads behind your access control Let other signed-in users read documents when your app allows it (the [access control](/tutorials/access-control) tutorial): ```text theme={null} Documents in this app are authored with Qirtaas. Add gated cross-user reading: another signed-in user may read a document only if this app's own access check passes. Read https://docs.qirtaas.io/tutorials/access-control.md first and follow it, adapting to this project's backend and ACL. In short: 1. Store a pointer to each document's qirtaas_doc_id alongside the domain object it belongs to. 2. Add a backend endpoint that authenticates the caller, runs the access check, and only then returns a local HMAC-SHA256 signature: hex(HMAC_SHA256(QIRTAAS_SIGNING_SECRET, "|")) with an exp about an hour out. Never sign before the check passes, and never sign ids taken unchecked from the request. 3. Mount the renderer with getSignature wired to that endpoint; on an invalid_signature error, fetch a fresh signature and remount. The signing secret stays in backend env only — it is as sensitive as the API key. ``` ### Add a draft/publish lifecycle Stable published versions while authors edit privately (the [draft and publish](/tutorials/draft-and-publish) tutorial): ```text theme={null} Documents in this app are authored with Qirtaas and read through signed renderer reads. Add a draft/published lifecycle: readers always see a stable published version while the author edits a private draft, and publishing is atomic. Read https://docs.qirtaas.io/tutorials/draft-and-publish.md first and follow it, adapting to this project. The pattern: draft and published are two documents, not a flag. Keep two pointers (qirtaas_doc_id for published, draft_doc_id for the work-in-progress); derive the publication state from the pointers instead of storing a status column. Only ever sign qirtaas_doc_id for readers. Publishing promotes draft_doc_id into qirtaas_doc_id in one transaction; revising clones the published document with duplicateDocument() and edits the clone; the replaced document is deleted after promotion. ``` ## Teach your own repo about Qirtaas If Qirtaas is a permanent part of your app, add this to your repo's `AGENTS.md` (or `CLAUDE.md`) so every future AI session gets it right without being told: ```markdown theme={null} ## Qirtaas - Qirtaas is the embedded rich-text editor. Docs index: https://docs.qirtaas.io/llms.txt (append `.md` to any docs URL for raw markdown; full docs at https://docs.qirtaas.io/llms-full.txt). - The qrt_sk_ API key and the signing secret live in backend env only and must never reach the browser. The frontend only ever sees short-lived embed tokens minted by our token endpoint. - The editor fills its container — always give it a bounded height. The renderer sizes to its content. - Mount the editor without documentId to lazily create a document; record the id from onDocumentCreated. Remount with a key when switching documents. - Cross-user reads use per-document HMAC signatures minted by our backend after its own access check — never mint another user's embed token. ``` # Authentication Source: https://docs.qirtaas.io/backend/authentication Server-side token exchange, per-document signatures, and share tokens. Qirtaas separates credentials by where they live and what they can do: | Credential | Lives | Grants | | ------------------------------- | --------------------- | -------------------------------------- | | **Secret API key** (`qrt_sk_…`) | Your backend only | Minting embed tokens | | **Embed token** (JWT, \~1 h) | Browser (short-lived) | Read/write the end user's documents | | **Signing secret** | Your backend only | Computing per-document read signatures | | **Signature** (`sig` + `exp`) | Browser (short-lived) | Read one document until `exp` | | **Share token** | Public | Read one explicitly shared document | Your backend is the broker: the browser never sees the secret key or signing secret — only the short-lived artifacts derived from them. Keys are provisioned per organization. Request them via the [key request form](https://qirtaas.io/developers?signup=1). Self-hosting? You issue your own — see [Self-hosting](/backend/self-hosting). ## Embed tokens (authoring) The editor authenticates every request with an embed token supplied by the SDK's `getToken` callback. Your backend mints one by exchanging its API key: ### `POST /v1/embed/tokens/` `Bearer qrt_sk_…` — your secret API key. A stable identifier for the end user **in your system** (a user id, UUID, or even a fixed value if all authors share one identity). Qirtaas auto-provisions an identity under your organization per distinct id; you own the human identity — no name/email is stored. Response `200`: ```json theme={null} { "token": "eyJhbGciOiJIUzI1NiIs…", "expires_at": "2026-07-04T13:22:05.104517" } ``` Errors: `400 { "error": "external_user_id_required" }`, `401` invalid or revoked key, `403 { "error": "organization_suspended" }`. Tokens expire after **1 hour**. The SDK calls `getToken` on init, proactively before expiry, and once more after a `401` — your endpoint should simply mint a fresh token on every call. ### Example endpoint Authenticate the request with your own session auth, then exchange: ```python Python theme={null} import os import requests QIRTAAS_API_KEY = os.environ["QIRTAAS_API_KEY"] def mint_embed_token(external_user_id: str) -> dict: resp = requests.post( "https://api.qirtaas.io/v1/embed/tokens/", headers={"Authorization": f"Bearer {QIRTAAS_API_KEY}"}, json={"external_user_id": external_user_id}, timeout=10, ) resp.raise_for_status() return resp.json() # {"token": "…", "expires_at": "…"} ``` ```ts Node theme={null} async function mintEmbedToken(externalUserId: string) { const res = await fetch("https://api.qirtaas.io/v1/embed/tokens/", { method: "POST", headers: { Authorization: `Bearer ${process.env.QIRTAAS_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ external_user_id: externalUserId }), }); if (!res.ok) throw new Error(`token exchange failed (${res.status})`); return res.json(); // { token, expires_at } } ``` Whoever can call your token endpoint can edit that identity's documents — gate it with your app's own authentication, and scope `external_user_id` to the authenticated user. ## Signatures (cross-user reads) Embed tokens are scoped to one identity's documents. To let *other* users **read** a document — a student viewing a teacher's lesson, say — the renderer uses a per-document, expiring **HMAC signature** instead. Your backend computes it locally (no network call) after running its own access check: ``` signature = hex(HMAC_SHA256(signing_secret, "|")) ``` `exp` is a unix timestamp. The renderer sends the pair as `?sig=&exp=` query parameters (signed-URL style — no custom header, so cross-origin reads stay CORS-simple), and Qirtaas recomputes the same HMAC under the document owner's secret to verify. ```python Python theme={null} import hashlib, hmac, time def sign_read(doc_id: str, ttl: int = 3600) -> dict: exp = int(time.time()) + ttl sig = hmac.new( SIGNING_SECRET.encode(), f"{doc_id}|{exp}".encode(), hashlib.sha256, ).hexdigest() # Matches the SDK renderer's getSignature() contract directly. return {"signature": sig, "exp": exp} ``` Feed it to the renderer via `getSignature`: ```ts theme={null} qirtaas.mountRenderer("#viewer", { documentId, getSignature: async () => { const res = await fetch(`/api/lessons/${lessonId}/signature`); return res.json(); // { signature, exp } }, }); ``` An invalid or expired signature is a `403 { "error": "invalid_signature" }` — signatures are not refreshable the way tokens are; the renderer surfaces the error via `onError`. ## Share tokens (public reads) A document explicitly shared by its author gets an opaque **share token** that resolves it publicly via `GET /v1/documents/shared/{token}/` — no key, no signature. Pass it straight to the renderer as `shareToken`. See [Renderer](/sdk/renderer#auth-modes). The token is **minted by the backend when sharing is turned on**, not by the SDK: call the [client](/sdk/client)'s `setSharing(documentId, true)` (which wraps `PATCH /v1/documents/{id}/share/` over the embed-token channel) and store or link the returned `share_token`. Turning sharing off revokes the token. Self-hosted backends implement the same [share endpoints](/backend/documents#patch-v1documentsidshare). ## Which read auth should I use? | Situation | Use | | -------------------------------------- | ------------------------------------------- | | Author viewing their own document | `getToken` (same embed token as the editor) | | Another user, access controlled by you | `getSignature` | | Anyone with the link | `shareToken` | # Documents API Source: https://docs.qirtaas.io/backend/documents The /v1 contract: document endpoints behind the SDK, for direct calls or a custom backend. Everything the SDK does runs over a small, **frozen `/v1` HTTP contract** (changes are additive only). You normally never call it yourself — the editor, renderer, and [client methods](/sdk/client#client-methods) wrap it — but it is documented here for two audiences: * hosts that want to hit an endpoint directly (e.g. server-side cleanup), and * self-hosters implementing their own backend against the contract — see [Self-hosting](/backend/self-hosting). All endpoints are relative to your `apiUrl`, e.g. `https://api.qirtaas.io/v1/documents/`. Unless noted, requests are authorized with an embed token: `Authorization: Bearer ` (see [Authentication](/backend/authentication)), and scoped to that token's identity — you can only see and touch your own documents. ## Document shape ```json theme={null} { "id": "b1f2c3d4-…", "title": "خطبة الجمعة", "content": { "type": "doc", "content": [ … ] }, "status": "CREATED", "created_at": "2026-07-01T10:00:00Z", "updated_at": "2026-07-04T09:30:00Z" } ``` `content` is TipTap JSON. `title` is **derived server-side** from the first words of the content — it is never written directly. `id`, `status`, and the timestamps are read-only. ## Endpoints ### `GET /v1/documents/` List the caller's documents (without `content`): `[{ id, title, status, created_at, updated_at }, …]`. The [client](/sdk/client)'s `listDocuments` wraps this. ### `POST /v1/documents/` Create a document. TipTap JSON. The title is derived from it. Returns `201` with the full document. Errors: `403 { "error": "document_limit_reached" }` when the identity is at its document cap, `413 { "error": "document_too_large" }` when the serialized content exceeds the size limit. ### `GET /v1/documents/{id}/` Fetch one document, `content` included. Two auth forms: * `Authorization: Bearer ` — own-document read. * `?sig=&exp=` — identity-less cross-user read, authorized purely by a valid [signature](/backend/authentication#signatures-cross-user-reads). Invalid or expired: `403 { "error": "invalid_signature" }`. ### `PATCH /v1/documents/{id}/` Update `content` (autosave uses this). Same `document_too_large` guard as create. ### `DELETE /v1/documents/{id}/` Delete the document. The [client](/sdk/client)'s `deleteDocument` wraps this. ### `GET /v1/documents/{id}/share/` Read the document's sharing state (embed-token authed, owner only): `{ "is_shared": boolean, "share_token": string | null }`. The token is `null` while sharing is off. The [client](/sdk/client)'s `getShareInfo` wraps this. ### `PATCH /v1/documents/{id}/share/` Turn public sharing on or off (embed-token authed, owner only). `true` mints an opaque share token for the document (or returns the existing one); `false` revokes it. Returns the same `{ is_shared, share_token }` shape. The token is the credential for the public read below and for the renderer's `shareToken` mode. The [client](/sdk/client)'s `setSharing` wraps this. ### `GET /v1/documents/shared/{token}/` Public read of an explicitly shared document by its share token — no auth. Returns `{ id, title, content }`. ## Images The editor manages document images through `POST /v1/documents/images/upload/`, `POST /v1/documents/images/{id}/confirm/`, and `GET /v1/documents/images/{id}/url/` (upload → confirm on save → resolve a display URL, embed-token authed; the URL endpoint also accepts signature auth for renderer reads). These are internal to the SDK's image node — hosts don't call them directly. ## Content endpoints Quran and hadith search/insertion data come from `/v1/quran/…` and `/v1/hadith/…`. On the managed cloud these are maintained for you; the SDK consumes them transparently. # Self-hosting Source: https://docs.qirtaas.io/backend/self-hosting Implement the /v1 contract on your own backend and point the SDK at it. The SDK is backend-agnostic: it talks to whatever `apiUrl` you give it, over the [frozen `/v1` contract](/backend/documents). Self-hosting means implementing that contract on your own backend and pointing the SDK at it. ```ts theme={null} const qirtaas = createQirtaasClient({ apiUrl: "https://qirtaas.your-domain.com", // your deployment getToken: () => fetchEmbedToken(), }); ``` Everything else in these docs — mounting, autosave, the renderer's auth modes — is identical to the managed cloud. ## What your backend must provide List/create/read/update/delete plus the shared-token read, exactly as specified in the [Documents API](/backend/documents). Paths are rooted at `/v1/…`. The editor sends `Authorization: Bearer ` with whatever your `getToken` returns. On your own backend you control both sides, so this can be the standard [embed-token exchange](/backend/authentication) or any token scheme of yours — the SDK only requires that a `401` means "refresh via getToken and retry once". If the token is a JWT with an `exp` claim, the SDK also refreshes proactively before expiry. For cross-user reads, verify `?sig=&exp=` as HMAC-SHA256 over `"|"` — see [Signatures](/backend/authentication#signatures-cross-user-reads). The editor's insertion dialogs read `/v1/quran/…` and `/v1/hadith/…`, so your backend needs to serve those endpoints and source the underlying datasets. On the managed cloud they are hosted and kept current for you. The upload/confirm/url image endpoints backed by S3-compatible storage, if your documents include images. ## When to choose this Self-hosting is the right call for data-residency requirements, air-gapped infrastructure, or when you already have a document store and only want the editor. For the trade-offs, see [Self-host vs Cloud](/get-started/self-host-vs-cloud). # Introduction Source: https://docs.qirtaas.io/get-started/introduction Embed a full Islamic rich-text editor into any web app. Qirtaas is a plug-and-play editor for Islamic content. Drop one component into your app and your users get a rich-text editor that can insert **Quran verses**, **hadith**, and **honourifics**, with full **Arabic RTL** support plus a read-only **renderer** for displaying saved documents. The SDK is framework-agnostic. Use it from vanilla JS, or with the thin [`@qirtaas/react`](/sdk/react) and [`@qirtaas/vue`](/sdk/vue) wrappers. ## Hosting Options The editor is free and open-source. The only choice you make is which **backend** stores documents and powers the content search/synchronisation: * **Self-host (Free, MIT)** — point the SDK at your own implementation of the `/v1` contract. * **Managed Cloud** — You hit our backend that serves the live Qirtaas app. You get an API key and ship. See [Self-host vs Cloud](/get-started/self-host-vs-cloud) for the full comparison. ## How it fits together Add [`@qirtaas/core`](/sdk/installation) to your frontend. Your server exchanges a secret API key for a short-lived embed token — see [Authentication](/backend/authentication). The secret never touches the browser. Create a client with [`createQirtaasClient`](/sdk/client) and call [`mountEditor`](/sdk/editor) or [`mountRenderer`](/sdk/renderer). ## Next steps Install to mounted in 60 seconds. Build authoring, reading, and access control end to end. Mount the editor and handle content, autosave and events. Server-side token exchange for embed access. # Quickstart Source: https://docs.qirtaas.io/get-started/quickstart From install to a mounted React editor in about five minutes. This guide mounts a working editor in a **React** app against the managed cloud. If you plan to run your own backend, everything below is identical except the `apiUrl` — see [Self-hosting](/backend/self-hosting). Using Vue or vanilla JS instead? The flow is exactly the same — install [`@qirtaas/vue`](/sdk/vue) or [`@qirtaas/core`](/sdk/editor) and swap the component code in steps 4–5 for their equivalents. ## 1. Get an API key Qirtaas cloud keys are provisioned per organization. Fill out the [key request form](https://qirtaas.io/developers?signup=1) and you'll receive: * a **secret API key** (`qrt_sk_…`) — used by *your backend only* to mint embed tokens. Never ship it to the browser. * a **signing secret** — only needed for cross-user renderer reads, see [Authentication](/backend/authentication#signatures-cross-user-reads). Self-hosting instead? Skip this step and issue your own tokens — see [Self-hosting](/backend/self-hosting). ## 2. Install ```bash theme={null} npm install @qirtaas/react ``` The package wraps the core SDK in two components — `` and `` — with only React as a peer dependency. ## 3. Add a token endpoint to your backend This step is **required** and happens outside the SDK: the editor authenticates with a short-lived **embed token**, and only your backend — holding the `qrt_sk_…` secret — can mint one. Add an endpoint that: 1. Authenticates the request with your app's own session/auth. 2. Exchanges the secret key for an embed token by calling `POST https://api.qirtaas.io/v1/embed/tokens/`. 3. Returns the token to the browser. ```ts Node (Express) theme={null} app.post("/api/qirtaas-token", requireAuth, async (req, res) => { const upstream = await fetch("https://api.qirtaas.io/v1/embed/tokens/", { method: "POST", headers: { Authorization: `Bearer ${process.env.QIRTAAS_API_KEY}`, "Content-Type": "application/json", }, // Any stable id for this end user in YOUR system. Qirtaas // auto-provisions an identity under your org for each id. body: JSON.stringify({ external_user_id: String(req.user.id) }), }); res.status(upstream.status).json(await upstream.json()); }); ``` ```python Python (FastAPI) theme={null} import os import httpx from fastapi import FastAPI, Depends app = FastAPI() QIRTAAS_API_KEY = os.environ["QIRTAAS_API_KEY"] # qrt_sk_… @app.post("/api/qirtaas-token") async def qirtaas_token(user=Depends(get_current_user)): # your auth async with httpx.AsyncClient() as client: resp = await client.post( "https://api.qirtaas.io/v1/embed/tokens/", headers={"Authorization": f"Bearer {QIRTAAS_API_KEY}"}, # Any stable id for this end user in YOUR system. Qirtaas # auto-provisions an identity under your org for each id. json={"external_user_id": str(user.id)}, timeout=10, ) resp.raise_for_status() return resp.json() # {"token": "…", "expires_at": "…"} ``` ```python Python (Django) theme={null} import os import requests from django.contrib.auth.decorators import login_required from django.http import JsonResponse from django.views.decorators.http import require_POST @login_required @require_POST def qirtaas_token(request): resp = requests.post( "https://api.qirtaas.io/v1/embed/tokens/", headers={"Authorization": f"Bearer {os.environ['QIRTAAS_API_KEY']}"}, json={"external_user_id": str(request.user.id)}, timeout=10, ) resp.raise_for_status() return JsonResponse(resp.json()) # {"token": "…", "expires_at": "…"} ``` Never put your `qrt_sk_…` secret key in the browser. The frontend only ever sees the short-lived embed token (1 hour TTL); the SDK calls your endpoint again before expiry and on `401`. Full endpoint reference: [Authentication](/backend/authentication). ## 4. Mount the editor Wire the component's `getToken` to the endpoint from step 3. ```tsx theme={null} import { QirtaasEditor } from "@qirtaas/react"; async function getToken() { const res = await fetch("/api/qirtaas-token", { method: "POST" }); const { token } = await res.json(); return token; } export function Compose() { return ( // The editor fills its container — give it a bounded height.
console.log("new document:", id)} onChange={(json) => console.log("content changed", json)} />
); } ``` That's it — the editor autosaves as the user types. See [React](/sdk/react) for all props, the ref handle, and remount rules, and [Editor](/sdk/editor) for autosave control. ## 5. Display a saved document Use the read-only renderer to show a document without editing: ```tsx theme={null} import { QirtaasRenderer } from "@qirtaas/react"; ``` See [Renderer](/sdk/renderer) for the three auth modes (share token, embed token, HMAC signature). ## Next steps The tutorials walk a real feature end to end — backend and frontend together: Create, list, edit, and delete documents with one backend endpoint. Render saved documents for their authors and the public. Gate cross-user reads behind your own ACL with signatures. # Self-host vs Cloud Source: https://docs.qirtaas.io/get-started/self-host-vs-cloud Same editor, two ways to run the backend that powers it. Qirtaas follows an open-core model: the **editor SDK is open source (MIT)**, and the **backend** is available as a managed service or you can plug your own backend. The frontend code you write is the same in both cases — only the `apiUrl` and who operates the backend differ. ## Comparison | | Self-host | Managed Cloud | | ------------------------------ | ----------------------------------------- | ------------------------------ | | Editor SDK + Arabic RTL | ✓ Included | ✓ Included | | Backend | Your implementation of the `/v1` contract | Hosted for you | | Auth | Self-managed | API key + embed token exchange | | Quran / hadith content updates | You pull updates | Always current | | Image storage | Your S3-compatible storage | Storage included | | Support | Community | Email & priority | | Price | Free (MIT) | Paid. Sustains the waqf | ## When to self-host While Qirtaas seems simple, the backend that powers it isn't. To self-host it, you need: 1. Elasticsearch for content API 2. S3-compatible storage 3. Background job processing for content API syncing You should self host if you already have a cluster that you can deploy these services to, or if you don't need all of the features in Qirtaas. ## When to use the cloud Choose managed cloud for zero backend maintenanc. This is often the cheapest option for most users. You mint embed tokens from an API key — see [Authentication](/backend/authentication). Qirtaas is a waqf project by the [Alrimaal](https://alrimaal.com) team. Revenue from managed plans keeps the editor free and open for students of knowledge. # Client Source: https://docs.qirtaas.io/sdk/client Configure the connection once with createQirtaasClient, then mount editors and renderers. `createQirtaasClient` bundles your connection (`apiUrl`) and embed-token source (`getToken`) once, then returns a client whose methods are bound to that config. This is the "configure once" shape — you don't re-pass `apiUrl`/`getToken` on every call. ```ts theme={null} import { createQirtaasClient } from "@qirtaas/core"; const qirtaas = createQirtaasClient({ apiUrl: "https://api.qirtaas.io", getToken: () => fetchEmbedToken(), }); ``` ## `createQirtaasClient(options)` ### QirtaasClientOptions Qirtaas API base URL. Point this at the managed cloud, or at your own backend when [self-hosting](/backend/self-hosting). Returns a short-lived embed token. Called on init, before the token expires, and again on a `401`. Shared by `mountEditor` and the mount-less ops (`listDocuments`, `deleteDocument`, `duplicateDocument`, `getShareInfo`, `setSharing`). Optional — a **renderer-only** client can omit it (the renderer carries its own per-read auth). The editor and the mount-less ops throw if it is missing. ## Client methods The returned `QirtaasClient` exposes: Boot the editor onto a host node, authorized by the client's `getToken`. See [Editor](/sdk/editor). Boot the read-only renderer. Its auth (signature / token / shareToken) is supplied **per call**, not on the client. See [Renderer](/sdk/renderer). List the identity's documents — `{ id, title, status, created_at, updated_at }`, no `content`. Titles are derived server-side from the content, so the result is directly displayable: a host can render a document list without keeping its own index of ids. Delete a document over the embed-token channel — no mount required. Copy a document's content into a new document, returning the new id. Lets a host keep a stable published document while the author edits a private clone. Turn public sharing on or off for a document the identity owns. Enabling mints (or returns the existing) opaque **share token** — the credential the [renderer's public read mode](/sdk/renderer#auth-modes) consumes. Disabling revokes it. Returns `{ is_shared, share_token }`. Read a document's current sharing state without changing it — same `{ is_shared, share_token }` shape (`share_token` is `null` while sharing is off). ## One live embed per page The SDK's HTTP transport is shared per page, so only **one** Qirtaas embed (editor *or* renderer) should be mounted at a time when their connections differ — a second concurrent mount with a different `apiUrl` or auth source (e.g. multiple renderers each carrying their own per-document `getSignature`) takes over the connection from the first. Two mounts on the same backend and token source coexist fine. `destroy()` the current embed before mounting the next when in doubt. ## Example: a document list without a backend model Because list/delete/duplicate live on the client, a host can drive a whole list view without mounting an editor — or keeping any document index of its own: ```ts theme={null} const docs = await qirtaas.listDocuments(); // [{ id, title, … }] renderList(docs); async function onDelete(id: string) { await qirtaas.deleteDocument(id); renderList(await qirtaas.listDocuments()); } ``` See the [document creation tutorial](/tutorials/document-creation) for this pattern end to end. # Editor Source: https://docs.qirtaas.io/sdk/editor Mount the editable Qirtaas editor and handle content, autosave, and events. Mount the editor with `client.mountEditor(el, options)`. `el` is an `Element` or a CSS selector string. It returns an [`EditorInstance`](#editorinstance) for imperative control. ```ts theme={null} const editor = qirtaas.mountEditor("#editor", { documentId: "b1f2…", locale: "ar", theme: "light", onReady: () => console.log("ready"), onChange: (json) => save(json), onSaveStateChange: (state) => setBadge(state), onDocumentCreated: (id) => (currentDocId = id), onError: (code, detail) => report(code, detail), onTokenExpired: () => refreshSession(), }); ``` The editor owns an internal scroll view (pinned toolbar, scrolling body) and fills its container — give the host element a **bounded height** (e.g. a sized dialog body or `height: 70vh`). ## EditorMountOptions Existing document id to load. Omit to lazy-create a document on the first content change — the new id arrives via `onDocumentCreated`. Initial content (TipTap JSON) when not loading from a `documentId` — in-memory editing. Editor UI and content language/direction. Color theme. Can be changed live via `setTheme`. Start read-only. Editing can be toggled later via `setEditable()`. Autosave behavior. See [Types](/sdk/types#autosaveoptions). Set `{ enabled: false }` to drive persistence yourself via `save()`. ### Callbacks Fired once the editor is mounted and (if loading) the document is in. Fired on every content change with the current TipTap JSON. Fired when autosave transitions between `idle | saving | saved | error`. Fired with the new id when a document is lazy-created. Fired on recoverable/unrecoverable errors with a stable [error code](/sdk/types#errorcode). Fired when a forced token refresh fails; autosave pauses until recovery. Analytics passthrough for editor interaction events. ## EditorInstance The object returned by `mountEditor`: Current editor content (TipTap JSON). Force an immediate save of any pending changes. Toggle read-only / editable live. Switch theme (`"light" | "dark"`) live. Tear down the editor and release the shared overlay root. Call this on unmount. ## Manual save (autosave off) ```ts theme={null} const editor = qirtaas.mountEditor("#editor", { autosave: { enabled: false }, onChange: (json) => setDirty(true), }); // later, e.g. on your own "Save" button: await editor.save(); ``` # Installation Source: https://docs.qirtaas.io/sdk/installation Install the Qirtaas SDK packages from npm. ## Packages | Package | Use it when | | ---------------- | --------------------------------------------------- | | `@qirtaas/core` | Vanilla JS / any framework. The mount API + client. | | `@qirtaas/react` | You want idiomatic React components. | | `@qirtaas/vue` | You want idiomatic Vue components. | The React and Vue packages are thin wrappers around `@qirtaas/core`'s mount API. Vue is bundled into `@qirtaas/core`, so a React host does **not** need Vue installed. ## Install ```bash core theme={null} npm install @qirtaas/core ``` ```bash react theme={null} npm install @qirtaas/react ``` ```bash vue theme={null} npm install @qirtaas/vue ``` ## Styles `@qirtaas/core` ships its own scoped stylesheet. In a bundler build it is imported as a side effect of the package entry and auto-injected at runtime — no manual CSS import is required for the mount API. If your setup needs the stylesheet explicitly (e.g. a strict CSP or a non-bundled ` ``` The editor owns an internal scroll view (pinned toolbar, scrolling body) and fills its container — the parent element must give it a **bounded height** (e.g. a sized dialog body or `h-[60vh]`). ### Props Props mirror [`EditorMountOptions`](/sdk/editor#editormountoptions), plus the connection config (the wrapper creates its client internally): Returns a short-lived embed token. Called on init, before expiry, and on `401`. See [Authentication](/backend/authentication). Qirtaas API base URL. Existing document to load. Omit to lazy-create on first edit (the `documentCreated` event delivers the new id). Initial content (TipTap JSON) when not loading from a `documentId`. See [Types](/sdk/types#autosaveoptions). Set `{ enabled: false }` and call `save()` on the template ref to drive persistence yourself. ### Events Each [editor callback](/sdk/editor#callbacks) is emitted as a Vue event: | Event | Payload | | -------------------- | ----------------------------------------------- | | `@ready` | — | | `@change` | `json: Json` | | `@save-state-change` | `state: SaveState` | | `@document-created` | `id: string` | | `@error` | `code: ErrorCode, detail?: unknown` | | `@token-expired` | — | | `@event` | `name: string, props?: Record` | ### Live updates vs. remount The component mounts the editor **once** (`onMounted`). Only two props are watched after mount: * `theme` — forwarded to `setTheme` * `readOnly` — forwarded to `setEditable` Changing anything else (`documentId`, `getToken`, `locale`, …) requires a remount — give the component a `:key` that changes with them: ```vue theme={null} ``` ### Exposed methods Available on the template ref: Current editor content (TipTap JSON). Force an immediate save of pending changes. ## QirtaasRenderer Read-only display. Supply exactly **one** auth source — `share-token`, `get-token`, or `get-signature` (see [Renderer auth modes](/sdk/renderer#auth-modes)). ```vue theme={null} ``` ### Props Document to render. With `shareToken`, the token resolves the document. Public read. Own-document read (embed token). Cross-user read (per-document HMAC signature). Events: `@ready`, `@error`. The template ref exposes `setTheme(theme)`. As with the editor, only `theme` is live — remount (via `:key`) for anything else. ## Imperative client operations For mount-less operations (e.g. driving a document list view), the package re-exports the [client](/sdk/client): ```ts theme={null} import { createQirtaasClient } from "@qirtaas/vue"; const qirtaas = createQirtaasClient({ getToken: fetchEmbedToken }); const docs = await qirtaas.listDocuments(); // [{ id, title, … }] await qirtaas.deleteDocument(id); ``` # Access-control content with signatures Source: https://docs.qirtaas.io/tutorials/access-control Gate cross-user document reads behind your own ACL: run the access check on your backend, then mint a per-document HMAC signature for the renderer. The first two tutorials cover authors reading their own documents (embed token) and fully public documents (share token). This one covers the case in between: **another signed-in user reads a document, and *your app* decides whether they may** — a student viewing a teacher's lesson, a member viewing club-only content. Embed tokens can't express this: they are scoped to one identity's *own* documents, and minting the author's token for a reader would let them edit. Instead, the renderer accepts a per-document, expiring **HMAC signature** that your backend computes *locally* — no network call — after running whatever access check you like: ``` signature = hex(HMAC_SHA256(signing_secret, "|")) ``` Qirtaas holds the same signing secret and recomputes the HMAC to verify. The signature is your backend's short-lived approval stamp: "this reader may read this document until `exp`". The ACL itself — enrolments, roles, payments — never leaves your database. **The example:** a course platform. Teachers author lesson documents (the [creation flow](/tutorials/document-creation), unchanged); **enrolled** students read them. This mirrors how production hosts embed Qirtaas. Prerequisite: your organization's **signing secret**, provisioned alongside the API key. Like the secret key, it must never reach the browser. ## 1. Backend — the data model The first two tutorials needed no host-side model — each user's embed identity was the whole access story. ACL is where one becomes necessary: before your backend can decide to authorize a read, it has to know **which document belongs to which lesson**. So store the pointer: ``` lessons(id, course_id, title, qirtaas_doc_id, …) enrolments(user_id, course_id, …) ``` A lesson row holds its `qirtaas_doc_id` (recorded from `onDocumentCreated` when the teacher authors it — the content itself stays in Qirtaas); enrolments are whatever your ACL already is — the signature flow doesn't care about its shape, only that you can answer "may this user read this lesson?". ## 2. Backend — a signing helper The signature is computed locally with your platform's standard crypto library. The returned shape `{ signature, exp }` matches the SDK renderer's `getSignature` contract directly, so you can hand the response straight through. ```ts Node theme={null} // env: QIRTAAS_SIGNING_SECRET=… import { createHmac } from "node:crypto"; const SIGNING_SECRET = process.env.QIRTAAS_SIGNING_SECRET!; export function signRead(docId: string, ttlSeconds = 3600) { const exp = Math.floor(Date.now() / 1000) + ttlSeconds; const signature = createHmac("sha256", SIGNING_SECRET) .update(`${docId}|${exp}`) .digest("hex"); return { signature, exp }; } ``` ```python Python theme={null} # env: QIRTAAS_SIGNING_SECRET=… import hashlib, hmac, os, time SIGNING_SECRET = os.environ["QIRTAAS_SIGNING_SECRET"] def sign_read(doc_id: str, ttl: int = 3600) -> dict: exp = int(time.time()) + ttl sig = hmac.new( SIGNING_SECRET.encode(), f"{doc_id}|{exp}".encode(), hashlib.sha256, ).hexdigest() return {"signature": sig, "exp": exp} ``` ## 3. Backend — the gated signature endpoint This endpoint is where your ACL and Qirtaas meet: authenticate the caller, run the access check, and **only then** sign. A signature is an unconditional read grant for that document until `exp` — the check must happen before it is minted, because Qirtaas cannot re-ask you. ```ts Node (Express) theme={null} import { signRead } from "./qirtaasSigning"; app.get("/api/lessons/:id/signature", requireAuth, async (req, res) => { const lesson = await db.lessons.findUnique({ where: { id: Number(req.params.id) }, }); if (!lesson) return res.status(404).json({ error: "Lesson not found" }); // The ACL check — entirely yours. Here: must be enrolled in the course. const enrolled = await db.enrolments.findFirst({ where: { user_id: req.user.id, course_id: lesson.course_id }, }); if (!enrolled) return res.status(403).json({ error: "not_enrolled" }); res.json(signRead(lesson.qirtaas_doc_id)); // { signature, exp } }); ``` ```python Python (FastAPI) theme={null} from .qirtaas_signing import sign_read @app.get("/api/lessons/{lesson_id}/signature") async def lesson_signature(lesson_id: int, user=Depends(get_current_user)): lesson = await db.lessons.find_first(id=lesson_id) if lesson is None: raise HTTPException(404, "Lesson not found") # The ACL check — entirely yours. Here: must be enrolled in the course. enrolled = await db.enrolments.find_first( user_id=user.id, course_id=lesson.course_id ) if enrolled is None: raise HTTPException(403, "not_enrolled") return sign_read(lesson.qirtaas_doc_id) # { signature, exp } ``` Alternatively, inline the signature into the response your lesson endpoint already returns (`{ lesson: …, signature: … }`) and save the round trip — the renderer's `getSignature` can resolve from anywhere. A separate endpoint keeps this tutorial composable. ## 4. Frontend — render with `getSignature` Give the renderer `getSignature` instead of `getToken`. The renderer sends the pair as `?sig=&exp=` query parameters on its read (signed-URL style, so cross-origin reads stay CORS-simple): ```tsx title="src/LessonView.tsx" theme={null} import { QirtaasRenderer } from "@qirtaas/react"; export function LessonView({ lesson }: { lesson: Lesson }) { async function getSignature() { const res = await fetch(`/api/lessons/${lesson.id}/signature`); if (!res.ok) throw new Error(`signature fetch failed (${res.status})`); return res.json(); // { signature, exp } } return ( console.error("render failed:", code)} /> ); } ``` The student never receives a token, never gets a Qirtaas identity, and can't write anything — the signature authorizes exactly one document, read-only, until `exp`. ## 5. Handle expiry Unlike embed tokens, signatures are **not refreshable**: an expired or invalid one is a hard `403 { "error": "invalid_signature" }`, surfaced through the renderer's `onError`. Two practical consequences: * **Pick a TTL that covers a reading session** — an hour is a sensible default. Short TTLs add security margin but expire under long reads. * **Recover by remounting.** On the error, fetch a fresh signature and remount the renderer (bump a `key`). If the signature endpoint itself returns `403`, the user genuinely lost access — show your paywall or enrolment prompt instead. ```tsx theme={null} const [attempt, setAttempt] = useState(0); { if (code === "invalid_signature") setAttempt((n) => n + 1); }} /> ``` ## 6. Going further — drafts and publishing Signatures compose with a **two-pointer** pattern for draft/publish flows, where the ACL question isn't just *who* may read but *which version* they may read. Give the lesson two document pointers: ``` lessons(…, qirtaas_doc_id, draft_doc_id) ``` * **Only ever sign `qirtaas_doc_id`** — the published pointer. Drafts sit in `draft_doc_id`, which the signature endpoint never signs, so students simply cannot read them: the read gate *is* the signing decision. * **Authoring a new lesson** writes the fresh document id into `draft_doc_id`; **publishing** promotes it (`qirtaas_doc_id = draft_doc_id; draft_doc_id = ""`) in one transaction. * **Revising a published lesson**: the teacher's frontend clones the live document with the client's [`duplicateDocument`](/sdk/client#client-methods) and registers the clone as the new draft. Students keep reading the stable published version while the teacher edits the clone; publishing promotes it and the old published document becomes an orphan the frontend deletes via `deleteDocument`. ```tsx theme={null} // Teacher clicks "Revise" on a published lesson: const { id: draftId } = await qirtaas.duplicateDocument(lesson.qirtaas_doc_id); await fetch(`/api/lessons/${lesson.id}/draft`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft_doc_id: draftId }), }); // …then mount as usual. ``` Note the division of labor holds throughout: document lifecycle (create/clone/delete) runs on the frontend over the embed channel; your backend only records pointers and decides what to sign. The full lifecycle — create-as-draft, publish, revise, discard — is walked end to end in [Draft and publish documents](/tutorials/draft-and-publish). ## Security checklist * The **signing secret** lives in backend env/config only — it is as sensitive as the API key. * **Check access before signing**, and sign only the specific document the check covered. Never sign ids taken from the request unchecked. * Signatures grant **read-only** access to **one document** — there is no wildcard signature, so a leaked one is contained to a single document until its `exp`. Full reference: [Authentication — signatures](/backend/authentication#signatures-cross-user-reads). # Embed Qirtaas for document creation Source: https://docs.qirtaas.io/tutorials/document-creation Add a notes feature to your website In this tutorial you add a **notes** feature to your website where an admin can create/edit/delete notes and any user can view them. The key architectural idea: **Qirtaas is the notes backend**. Documents are created, listed, and deleted from the frontend over the SDK's embed-token channel, and each user's embed identity scopes them to their own documents. Your backend needs exactly **one change**, a token endpoint, and zero new tables. **What you'll build** * Backend: one token endpoint (the only backend change). * Frontend: a compose page with ``, a note list driven by `listDocuments()`, reopen-to-edit, and delete. Frontend samples are React; the token endpoint is tabbed Node (Express) and Python (FastAPI). Prerequisites: a `qrt_sk_…` API key (see the [Quickstart](/get-started/quickstart#1-get-an-api-key)) and `npm install @qirtaas/react`. ## 1. Backend — add the token endpoint The editor authenticates every request with a short-lived embed token, and only the secret key holder can mint one. Gate the endpoint with your app's own auth and pass the authenticated user's id as `external_user_id`: Qirtaas auto-provisions an identity per distinct id, and that identity is what scopes each user to their own documents (creation, listing, and deletion all happen within it). ```ts Node (Express) theme={null} // .env: QIRTAAS_API_KEY=qrt_sk_… app.post("/api/qirtaas-token", requireAuth, async (req, res) => { const upstream = await fetch("https://api.qirtaas.io/v1/embed/tokens/", { method: "POST", headers: { Authorization: `Bearer ${process.env.QIRTAAS_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ external_user_id: String(req.user.id) }), }); res.status(upstream.status).json(await upstream.json()); }); ``` ```python Python (FastAPI) theme={null} # env: QIRTAAS_API_KEY=qrt_sk_… import os import httpx from fastapi import Depends QIRTAAS_API_KEY = os.environ["QIRTAAS_API_KEY"] @app.post("/api/qirtaas-token") async def qirtaas_token(user=Depends(get_current_user)): async with httpx.AsyncClient() as client: resp = await client.post( "https://api.qirtaas.io/v1/embed/tokens/", headers={"Authorization": f"Bearer {QIRTAAS_API_KEY}"}, json={"external_user_id": str(user.id)}, timeout=10, ) resp.raise_for_status() return resp.json() # {"token": "…", "expires_at": "…"} ``` Whoever can call this endpoint can edit that identity's documents — always gate it with your own authentication and derive `external_user_id` from the session, never from the request body. That's the entire backend. Everything below is frontend. ## 2. Frontend Create the client once and share it across your code. The same `getToken` powers the editor and the list/delete operations: ```ts title="src/qirtaas.ts" theme={null} import { createQirtaasClient } from "@qirtaas/react"; export async function getToken(): Promise { const res = await fetch("/api/qirtaas-token", { method: "POST" }); if (!res.ok) throw new Error(`token fetch failed (${res.status})`); const { token } = await res.json(); return token; } export const qirtaas = createQirtaasClient({ getToken }); ``` ## 3. Frontend — the compose page Mount `` **without** a `documentId`: the SDK lazily creates the document on the first keystroke and hands you the new id via `onDocumentCreated`. The editor takes care of persisting any changes. ```tsx title="src/ComposeNote.tsx" theme={null} import { QirtaasEditor } from "@qirtaas/react"; import { getToken } from "./qirtaas"; export function ComposeNote({ onCreated, }: { onCreated: (id: string) => void; }) { return ( // The editor fills its container — give it a bounded height.
onDocumentCreated={onCreated} onSaveStateChange={(state) => console.log("save:", state)} />
); } ``` Use `onDocumentCreated` for navigation concerns (swap `/notes/new` for `/notes/` so a refresh reopens the same note) and `onSaveStateChange` for a saved/saving badge. ## 4. Frontend — Notes Dashboard `listDocuments()` returns the identity's documents: ```tsx title="src/NoteList.tsx" theme={null} import { useEffect, useState } from "react"; import type { DocumentSummary } from "@qirtaas/react"; import { qirtaas } from "./qirtaas"; export function NoteList({ onOpen }: { onOpen: (id: string) => void }) { const [notes, setNotes] = useState([]); useEffect(() => { qirtaas.listDocuments().then(setNotes); }, []); return (
    {notes.map((note) => (
  • onOpen(note.id)}> {note.title || "Untitled"}
  • ))}
); } ``` Render the list with your own markup. Mount a Qirtaas embed only on the compose/read page, not per list item. ## 5. Frontend — reopen note To continue an existing note, pass its id as `documentId`. The component mounts the editor **once**, so when the user switches notes, force a remount with a `key`: ```tsx title="src/EditNote.tsx" theme={null} import { QirtaasEditor } from "@qirtaas/react"; import { getToken } from "./qirtaas"; export function EditNote({ docId }: { docId: string }) { return (
); } ``` Because the embed token is minted for the same `external_user_id`, the owner can reopen and edit any of their documents. ## 6. Frontend — delete a note ```tsx theme={null} import { qirtaas } from "./qirtaas"; async function deleteNote(id: string) { await qirtaas.deleteDocument(id); setNotes(await qirtaas.listDocuments()); // refresh the list } ``` ## Where you are Users can author, list, reopen, and delete rich documents, and your backend grew by one endpoint. Next: Show these notes read-only with the renderer. Let other users read them, gated by your own ACL. # Embed Qirtaas for document reading Source: https://docs.qirtaas.io/tutorials/document-reading Display saved documents read-only with the renderer: own-document reads with embed tokens, public reads with share tokens. This tutorial continues the notes app from [Document creation](/tutorials/document-creation): users can author notes, and now you add a clean **read view** — the document displayed without a toolbar or editing chrome — plus a public share view. Reading uses `` instead of the editor. The renderer's auth is **per read**, and which credential you use depends on who is reading: | Reader | Auth | Covered in | | ----------------------------------------- | ------------------------------------------- | ------------------------------------------- | | The document's author | `getToken` (same embed token as the editor) | this tutorial | | Anyone with the link | `shareToken` | this tutorial | | Another signed-in user, gated by your ACL | `getSignature` | [Access control](/tutorials/access-control) | The first two need **no backend change at all** — the token endpoint from the creation tutorial already covers own-document reads, and share tokens carry their own authorization. ## 1. Frontend — the read view Mount `` with the same `getToken` the editor uses. An embed token authorizes reads of *that identity's own* documents, so this works for the author viewing any note from their `listDocuments()` list: ```tsx title="src/ReadNote.tsx" theme={null} import { QirtaasRenderer } from "@qirtaas/react"; import { getToken } from "./qirtaas"; export function ReadNote({ docId }: { docId: string }) { return ( console.error("render failed:", code)} /> ); } ``` Unlike the editor, the renderer sizes to its content — no bounded-height container required. Mount **one** Qirtaas embed at a time when their connections differ — the SDK's HTTP transport is shared per page, so a second renderer with a different auth source takes over the connection from the first. For the note *list*, keep rendering plain `listDocuments()` data and mount the renderer only on the detail page. See [Client](/sdk/client#one-live-embed-per-page). ## 2. Frontend — switch between reading and editing A common pattern is a read view with an "Edit" button for the owner. Because read and edit are different mounts, just swap components: ```tsx theme={null} export function NotePage({ docId }: { docId: string }) { const [editing, setEditing] = useState(false); return editing ? ( // from the creation tutorial ) : ( <> ); } ``` Both mounts use the same embed token, so they can coexist with a plain swap — each component destroys its embed on unmount. ## 3. Public reads with a share token A document explicitly shared by its author carries an opaque **share token** that resolves it publicly — no key, no signature, no login. Your "share" action mints it through the client (and can revoke it the same way): ```ts theme={null} const { share_token } = await qirtaas.setSharing(documentId, true); // store it, or put it straight into a link ``` With that token stored, a public page is one prop: ```tsx title="src/SharedNote.tsx" theme={null} import { QirtaasRenderer } from "@qirtaas/react"; export function SharedNote({ shareToken }: { shareToken: string }) { // No documentId needed — the token resolves the document. return ; } ``` Anyone holding the token can read the document, so treat it like any capability URL: only surface it through an explicit "share" action. ## Where you are Authors can read their own notes cleanly and share individual documents with the world — still on a single backend endpoint. The remaining case — *other signed-in users* reading a document when **your app** decides who's allowed — needs neither of these credentials, and it's where your backend re-enters the picture: Gate cross-user reads behind your own ACL with per-document HMAC signatures. # Draft and publish documents Source: https://docs.qirtaas.io/tutorials/draft-and-publish Give documents a draft/published lifecycle with two pointers: readers see a stable published version while authors edit a private draft, and publishing is one atomic swap. The editor autosaves continuously — every keystroke lands in the stored document. That's exactly right for personal notes, but wrong for published content: once readers can see a document, an author reopening it would be live-editing in front of their audience, typos and half-finished thoughts included. This tutorial adds a **draft/published lifecycle** on top of the [creation](/tutorials/document-creation) and [access-control](/tutorials/access-control) flows. The pattern — taken from how production hosts run Qirtaas — is that draft and published are **two documents, not a flag**. Your table keeps two pointers: ``` lessons(…, qirtaas_doc_id, draft_doc_id) ``` * **`qirtaas_doc_id`** — the published document readers see. `""` until the first publish, and the **only** pointer your read gate ever signs. * **`draft_doc_id`** — the work-in-progress the author edits. `""` when there is no draft. Publishing promotes the draft pointer into the published slot — a pointer swap in your database, no content copied. Revising clones the published document and edits the clone. Autosave stays on the whole time; it just writes to a document nobody else can read. The example continues the course platform from [access control](/tutorials/access-control): teachers author and revise lesson documents, enrolled students only ever read the published version. ## 1. The publication state Everything the UI needs is derivable from the two pointers — don't store a separate status column that can drift: ```ts theme={null} type PublicationState = "draft" | "revising" | "published"; function publicationState(lesson: Lesson): PublicationState { if (!lesson.qirtaas_doc_id) return "draft"; // never published if (lesson.draft_doc_id) return "revising"; // published, edits in progress return "published"; // published and clean } ``` | State | `qirtaas_doc_id` | `draft_doc_id` | Readers see | | ----------- | ---------------- | -------------- | ----------------- | | `draft` | `""` | set | nothing | | `revising` | set | set | the published doc | | `published` | set | `""` | the published doc | ## 2. Create as a draft Authoring is the [creation flow](/tutorials/document-creation) unchanged — mount `` without a `documentId`, and when `onDocumentCreated` fires, record the new id. The only difference: store it in **`draft_doc_id`**, so the lesson starts private. ```tsx title="src/ComposeLesson.tsx" theme={null} import { QirtaasEditor } from "@qirtaas/react"; import { getToken } from "./qirtaas"; export function ComposeLesson({ lessonId }: { lessonId: number }) { async function onCreated(docId: string) { await fetch(`/api/lessons/${lessonId}/draft`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft_doc_id: docId }), }); } return (
); } ``` Because `qirtaas_doc_id` is still `""`, the signature endpoint from the access-control tutorial has nothing it will sign — an unpublished draft is unreadable by construction, with no extra checks to write. ## 3. Publish — promote the pointer Publishing moves the draft pointer into the published slot in one transaction. If the lesson was already published, the old published document is now an **orphan** — return its id so the frontend can delete it over the embed channel (your backend records pointers; it never brokers document lifecycle). ```ts Node (Express) theme={null} app.post("/api/lessons/:id/publish", requireTeacher, async (req, res) => { const lesson = await db.lessons.findUnique({ where: { id: Number(req.params.id) }, }); if (!lesson) return res.status(404).json({ error: "Lesson not found" }); if (!lesson.draft_doc_id) return res.status(400).json({ error: "no_draft_to_publish" }); const orphanDocId = lesson.qirtaas_doc_id || null; const updated = await db.lessons.update({ where: { id: lesson.id }, data: { qirtaas_doc_id: lesson.draft_doc_id, draft_doc_id: "" }, }); res.json({ lesson: updated, orphan_doc_id: orphanDocId }); }); ``` ```python Python (FastAPI) theme={null} @app.post("/api/lessons/{lesson_id}/publish") async def publish_lesson(lesson_id: int, user=Depends(require_teacher)): lesson = await db.lessons.find_first(id=lesson_id) if lesson is None: raise HTTPException(404, "Lesson not found") if not lesson.draft_doc_id: raise HTTPException(400, "no_draft_to_publish") orphan_doc_id = lesson.qirtaas_doc_id or None async with db.transaction(): lesson.qirtaas_doc_id = lesson.draft_doc_id lesson.draft_doc_id = "" await lesson.save() return {"lesson": lesson, "orphan_doc_id": orphan_doc_id} ``` The frontend calls publish, then cleans up the orphan best-effort — a failed delete leaves an unreachable document, not a broken lesson: ```tsx theme={null} import { qirtaas } from "./qirtaas"; async function publish(lessonId: number) { const res = await fetch(`/api/lessons/${lessonId}/publish`, { method: "POST" }); if (!res.ok) throw new Error(`publish failed (${res.status})`); const { lesson, orphan_doc_id } = await res.json(); if (orphan_doc_id) await qirtaas.deleteDocument(orphan_doc_id).catch(() => {}); return lesson; } ``` Don't skip the orphan cleanup. Every orphan is a full document that still counts against the identity's document quota — let them accumulate across many publish cycles and you'll start hitting rate limits and document caps on an identity that looks nearly empty in your own tables. "Best-effort" means a single failed delete is tolerable, not that deleting is optional. ## 4. Revise — clone, then edit the clone To edit a published lesson without readers watching, clone the published document with the client's [`duplicateDocument`](/sdk/client#client-methods) and register the clone as the working draft. Students keep reading the stable published version; publishing later promotes the clone via step 3. The draft endpoint accepts the clone's id — and rejects a second concurrent draft with a `409`, returning the existing one instead: ```ts Node (Express) theme={null} app.post("/api/lessons/:id/draft", requireTeacher, async (req, res) => { const lesson = await db.lessons.findUnique({ where: { id: Number(req.params.id) }, }); if (!lesson) return res.status(404).json({ error: "Lesson not found" }); const draftId = String(req.body.draft_doc_id ?? "").trim(); if (!draftId) return res.status(400).json({ error: "draft_doc_id is required" }); if (lesson.draft_doc_id) // A draft already exists — return it rather than losing it to the clone. return res.status(409).json({ error: "draft_already_exists", lesson }); const updated = await db.lessons.update({ where: { id: lesson.id }, data: { draft_doc_id: draftId }, }); res.json({ lesson: updated }); }); ``` ```python Python (FastAPI) theme={null} @app.post("/api/lessons/{lesson_id}/draft") async def start_draft(lesson_id: int, body: dict, user=Depends(require_teacher)): lesson = await db.lessons.find_first(id=lesson_id) if lesson is None: raise HTTPException(404, "Lesson not found") draft_id = str(body.get("draft_doc_id") or "").strip() if not draft_id: raise HTTPException(400, "draft_doc_id is required") if lesson.draft_doc_id: # A draft already exists — return it rather than losing it to the clone. return JSONResponse( {"error": "draft_already_exists", "lesson": lesson.dict()}, status_code=409, ) lesson.draft_doc_id = draft_id await lesson.save() return {"lesson": lesson} ``` On the frontend, "Revise" is clone → register → open the editor on the draft. On a `409`, the clone you just made is redundant — delete it and open the draft that already exists: ```tsx theme={null} import { qirtaas, getToken } from "./qirtaas"; async function startRevision(lesson: Lesson): Promise { const { id: draftId } = await qirtaas.duplicateDocument(lesson.qirtaas_doc_id); const res = await fetch(`/api/lessons/${lesson.id}/draft`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft_doc_id: draftId }), }); if (res.status === 409) { // Someone got there first — use their draft, discard our clone. await qirtaas.deleteDocument(draftId).catch(() => {}); const { lesson: current } = await res.json(); return current.draft_doc_id; } if (!res.ok) throw new Error(`draft registration failed (${res.status})`); return draftId; } // …then mount the editor on the returned id: // ``` ## 5. The read gate never signs a draft This is the security core of the pattern, and it costs nothing: the signature endpoint from [access control](/tutorials/access-control) already signs `lesson.qirtaas_doc_id` and nothing else. Keep it that way and drafts are unreadable to students **by construction** — there is no "is this a draft?" check to forget, because the draft pointer simply never reaches the signer. Two adjacent rules complete the gate: * **Public lists exclude unpublished rows** — filter out lessons where `qirtaas_doc_id = ""` for non-teachers, so a never-published draft doesn't appear as an empty shell. * **Detail views 404 unpublished lessons for non-teachers** — don't reveal that a draft exists. The author, meanwhile, needs no signature at all: their embed token covers their own documents, so the editor opens the draft directly. ## 6. Discard a draft To abandon a revision, clear the pointer and let the frontend delete the document — the mirror image of publishing: ```ts Node (Express) theme={null} app.delete("/api/lessons/:id/draft", requireTeacher, async (req, res) => { const lesson = await db.lessons.findUnique({ where: { id: Number(req.params.id) }, }); if (!lesson?.draft_doc_id) return res.status(404).json({ error: "No draft to discard" }); const orphanDocId = lesson.draft_doc_id; await db.lessons.update({ where: { id: lesson.id }, data: { draft_doc_id: "" }, }); res.json({ orphan_doc_id: orphanDocId }); }); ``` A `revising` lesson drops back to `published`; a never-published `draft` lesson goes back to having no document at all. ## Where you are Lessons now carry a full lifecycle — authored privately, published atomically, revised without readers noticing — and your backend still only stores two pointers per row. The division of labor from the earlier tutorials held throughout: document lifecycle (create, clone, delete) runs on the frontend over the embed channel; your backend records pointers and decides what to sign. The signature flow this read gate builds on. `duplicateDocument`, `deleteDocument`, and friends.