# MicroSaaS Platform — agent manual

This is `/docs/llms.txt`. If you are an LLM agent working with this
platform, read this file end-to-end before doing anything else. Everything
below is stable per major API version.

## Authentication

- Header: `Authorization: token <api_key>:<api_secret>`
- API Credential tokens (minted from the dashboard) also need
  `X-Auth-Source: API Credential` on every request.
- Session cookies also work if you're driven by a browser session.
- Every method call runs under the identity that owns the token: DocPerm,
  Layer 2 User Permission scoping and Layer 3 staff-only rules all apply
  to you.

## The Golden Path — signup → live SaaS in five calls

1. `POST /api/method/platform_control.api.micro_saas.create_micro_saas`
   with `{account, slug, end_user_type?, isolation_tier?, region?}`.
   Returns `{micro_saas, provisioning_job, status}`.
2. Poll `POST /api/method/platform_control.api.micro_saas.get_provisioning_status`
   with `{name: <slug>}` until `ready` is true (`status == "Active"` and the
   provisioning job — which also installs the starter apps — has finished;
   module/app calls answer 409 `provisioning_in_progress` before that).
3. `POST /api/method/platform_control.credentials.reveal` with
   `{name: <slug>}`. Returns `{site_name, desk_hostname, app_hostname,
   admin_user, admin_password, revealed_at}`.
   **This is a ONE-TIME response.** Store the password immediately; the
   next call to `reveal` returns `409 credentials_already_revealed`.
4. If you lost the password (or a scheduled rotation is due):
   `POST /api/method/platform_control.credentials.rotate` with
   `{name: <slug>}` — mints a fresh password AND returns the same reveal
   payload in one hop.
5. The new product comes ready to use: BASE app installed (SPA shell with
   login / sign-up / password reset / Dashboard), NOTES starter app
   installed (uninstallable), and end-user self-signup ENABLED. From here
   on the site is a standard XCORE tenant site. Log in as `Administrator`
   with the revealed password, or start using tenant-facing endpoints.

To BUILD the tenant's product (DocTypes + a single-page app) on our framework —
not just provision the site — read "Building the tenant SPA on our framework"
below: the `ctx` runtime, the manifest, and the author → install → iterate loop.

## Endpoints index

Every `/v1/*` endpoint has a whitelisted method shape:
`/api/method/platform_control.<module>.<function>`. This file lists the
verbs; the full request/response spec is at
`/api/method/platform_control.api.docs.get_openapi_json` (also served at
`/v1/openapi.json`).

- `micro_saas.list_micro_saas(account?, limit?)` — GET, scoped to caller.
- `micro_saas.get_micro_saas(name)` — GET one row.
- `micro_saas.create_micro_saas(account, slug, ...)` — POST, triggers
  provisioning.
- `micro_saas.get_provisioning_status(name)` — GET status + latest job.
- `credentials.reveal(name)` — POST, one-time.
- `credentials.rotate(name)` — POST, mints fresh + reveals.
- `api.credentials.issue(label, micro_saas?, scope_preset?, expires_at?)` /
  `.reveal(name)` / `.rotate(name)` / `.revoke(name)` /
  `.list_credentials(micro_saas?)` — agent API tokens (distinct from the
  admin-password `credentials.*` above). `reveal` is one-time; `rotate`
  mints + reveals in one hop; `revoke` is irreversible.

### Site lifecycle (§16 backups + delete/restore, all T3)

- `api.lifecycle.take_backup(name, kind?)` — POST, kind ∈
  manual/scheduled/pre_delete/pre_restore. Returns a Site Backup id.
- `api.lifecycle.list_backups(name, limit?)` — GET.
- `api.lifecycle.archive(name)` — POST, site stays on disk but refuses
  traffic (410). Fully reversible.
- `api.lifecycle.unarchive(name)` — POST.
- `api.lifecycle.delete(name, confirm=<slug>, approval_ok?)` — POST.
  Auto-takes a pre_delete backup, then drops the site. Row kept; slug
  retained. `confirm` must equal `slug` or 400. Token callers need an
  Approved `approval_ok` (action "lifecycle.delete") — see Blast-radius.
- `api.lifecycle.restore(name, backup?, approval_ok?)` — POST. Takes a
  safety backup of the current site (if any), recreates it if Deleted,
  restores DB from the chosen backup. Defaults to the most recent Done
  backup. Token callers need `approval_ok` (action "lifecycle.restore").
- `api.lifecycle.lifecycle_summary(name)` — GET, one-shot state.

### Frontend files (§11 Surface A, tenant-side)

Layered: writes land in DRAFT, `publish()` promotes to LIVE, snapshots
are the rollback path. Called on the TENANT hostname
(`<slug>.<domain>`), not on control.

- `microsaas_desk_theme.files_api.tree(path?, layer=draft)` — GET.
- `microsaas_desk_theme.files_api.read(path, layer=draft)` — GET.
- `microsaas_desk_theme.files_api.write(path, content, if_match_sha?)`
  — POST. Draft layer only. `.py` refused with
  `native_python_requires_container`.
- `microsaas_desk_theme.files_api.upload(path, content_base64)` —
  POST. Binary path (300 MiB cap). Accepts `data:...;base64,...` URLs.
- `microsaas_desk_theme.files_api.delete(path)` — POST, draft only.
- `microsaas_desk_theme.files_api.publish(comment?)` — POST. Snapshot
  current live + rsync draft to live.
- `microsaas_desk_theme.files_api.list_snapshots()` — GET.
- `microsaas_desk_theme.files_api.rollback(snapshot_id)` — POST.
  Takes a safety snapshot first.
- `microsaas_desk_theme.files_api.revert_draft()` — POST. Reset draft
  to live (previous draft saved as a snapshot).
- `microsaas_desk_theme.files_api.status()` — GET. `{draft_file_count,
  live_file_count, snapshot_count, added, modified, removed,
  has_changes, last_snapshot}` — one shot for the UI + agent.

Preview a draft in the browser: request the app hostname with
`?_preview=draft`. Requires a session with a developer role.

**Custom (site-local) SPA apps — connect your files to the shell.**
`apps.json` and `apps/` are marketplace-managed (publish preserves them),
so write YOUR module under `custom/<slug>/` and register it:
- `microsaas_desk_theme.custom_apps.register_app(slug, name?,
  entry="main.js", styles?, routes?, roles?)` — POST, after publish;
  merges a `custom: true` entry (asset_root `custom/<slug>/`) into
  apps.json. The shell loads it like any marketplace app; `roles` gates
  its sidebar visibility. Idempotent upsert.
- `microsaas_desk_theme.custom_apps.unregister_app(slug)` /
  `.list_custom_apps()` — POST / GET.

**Every tenant also serves its own agent manual** at
`http://<slug>.<domain>/docs/llms.txt` — a tenant key + that one URL is
enough to build the whole product (the Build-with-AI Desk page hands
owners exactly that pair). Tenant schema evolution beyond create:
`schema_api.update_field(doctype, fieldname, changes)`,
`.remove_field(doctype, fieldname)`, `.set_permissions(doctype,
permissions)`, `.delete_doctype(name, confirm=<name>)` — custom
DocTypes only, same Schema Manager gate.

### Usage + quotas (§15.1)

- `api.usage.list_usage(micro_saas, metric?, limit=30)` — GET.
- `api.usage.check_micro_saas_quota` fires implicitly on
  `create_micro_saas`; refusal is `402 plan_quota_exceeded`.

### Marketplace (§13)

- `api.marketplace.list_items(status='Approved', kind?, limit=50)`
  — GET.
- `api.marketplace.get_item(slug)` — GET, returns item + versions[].
- `api.marketplace.lint_manifest(manifest)` — POST. Preflight-validate a
  manifest's SHAPE (no tenant needed): `{ok, errors[], warnings[]}`. Call this
  BEFORE creating a version — it catches unsupported fieldtypes, a Link without
  `options`, a reserved role, snake_case fieldname violations, and the big
  multi-tenant-safety one: an end-user role with `read` but no `if_owner`.
- `api.marketplace.check_compatibility(item, version, micro_saas)` —
  GET. Dry-run against a TENANT: `{ok, tier_gate, collisions[], warnings[]}`.
  Call BEFORE `install` so you never hit an unexpected 409.
- `api.marketplace.install(item, version, micro_saas)` — POST. Full
  install: validates (container-tier gate for `native` items, Approved-only,
  duplicate install, `depends_on`, shell exclusivity, DocType/custom-field
  collision matrix), then ships the payload to the tenant — creates the
  DocTypes/roles, lands the frontend bundle, regenerates `apps.json` — and
  records the `Marketplace Install` row.
- `api.marketplace.upgrade(install, to_version)` — POST. In-place,
  **data-preserving** upgrade of an installed app: additively applies the new
  version's schema (new DocTypes/fields/roles — never a drop, so every row
  survives) and swaps the frontend bundle. Atomic — a pre-upgrade snapshot +
  DB rollback restore the prior version on any failure.
- `api.marketplace.uninstall(install, force=0)` — POST. Refuses with a
  409 (`tenant_data_present`) when the app's DocTypes hold records unless
  `force=1`. Back the data up first: on the tenant,
  `microsaas_desk_theme.app_backup.app_data_summary(install_id)` (GET —
  which tables, how many rows) and
  `microsaas_desk_theme.app_backup.download_app_backup(install_id)` (GET —
  full-record JSON file, child tables included). Uninstall removes the
  app's DocTypes, frontend files and apps.json entry; the underlying table
  rows are retained orphaned and re-attach on a reinstall of the same
  app, but treat that as an implementation detail, not a contract — the
  backup file is the contract. The tenant Desk marketplace page wraps all
  of this in a backup-offering uninstall dialog.
- `api.marketplace.list_installs(micro_saas)` — GET.

Available first-party modules: `base` (the Base Shell — the SPA host),
`notes`, `projects`, `contacts`, `expenses` (depends on `contacts`) and
`saas-billing-kit`. Apps: `team-workspace` (Projects + Notes),
`small-business` (Contacts + Expenses, Projects optional).

**Vocabulary.** A **module** is one building block (DocTypes, roles, screens).
An **app** is what people open: a named group of modules with its own icon,
menu (navigation) and ready-made role bundles — like an Odoo app. In the API a
module is `kind: "app"` and an app is `kind: "package"` (stored values kept for
compatibility; v2 manifests say `"module"` / `"app"`).

### Apps — `api.modules.*_app` (install a whole app in one call)

1. `api.modules.preview_app(micro_saas, app, optional_modules?)` — GET. The
   app (`modules[]` with `version_range`, `optional`, `selected`, `installed`;
   `navigation`; `role_bundles`; `icon`, `color`), the ordered `steps` (modules
   to install or update, then `configure` = menus + roles), `problems`,
   `conflicts` and `mode` (`install` | `upgrade` | `repair` | `noop`).
2. `api.modules.install_app(micro_saas, app, optional_modules=["projects"]?, wait=60)`
   — POST. Installs the app's modules (dependencies first, each at the
   highest Approved release every version range allows), then writes the
   app's menu and role bundles to the workspace. Idempotent; all-or-nothing
   (a failure undoes every module it installed and the menus). Call again
   with more `optional_modules` to add them; modules the app brought in are
   `auto`, so removing the app removes them.
3. `api.modules.upgrade_app(micro_saas, app, wait=60)` — POST. Moves the app
   to its newest release (new required modules installed, menus updated).
4. `api.modules.preview_remove_app(micro_saas, app)` (GET) /
   `api.modules.remove_app(micro_saas, app, force=0, wait=60)` (POST). Menus
   and role bundles go first, then the modules the app brought in that
   nothing else needs. Modules you installed yourself or another app uses
   stay (`kept`, with reasons). Records → 409 `tenant_data_present` until
   you back up and pass `force=1` (tables stay on the site).
`modules_overview` lists installed apps under `apps` (`app_version`,
`app_update_available`, `modules[]`, `status`) and installable ones in
`available` with `type: "app"`. Codes: `not_an_app`, `app_not_installed`,
`version_conflict`, `marketplace_item_not_found`.

**Version ranges.** Dependencies (`depends_on`) and app module lists take a
`version` range: `">=1.0 <2"`, `"^1.2"` (>=1.2.0 <2), `"~1.2"` (>=1.2.0 <1.3),
`"1.2.3"` (exact), `"1.x"`, or omitted (any). The resolver picks the highest
Approved release that satisfies EVERY range on a module — from your request,
from modules that depend on it, and from installed modules and apps. An
installed module that is too old is updated in the same plan; when no release
fits everyone → 409 `version_conflict` naming who needs what. Updates respect
ranges too: a newer release held back by an installed app shows in
`preview_upgrade.blocked` / `modules_overview.installed[].blocked_by`, and
`upgrade_modules(modules=[that one])` answers 409 `blocked_by_constraint`
("update Shop first").

### Modules — `api.modules.*` (use these to install / remove / upgrade)

The module manager works like apt/Odoo; use it instead of the single-item
`install`/`uninstall` above.

**Recipe (agents):**
1. `api.modules.modules_overview(micro_saas)` — GET. Installed modules
   (`version`, `latest_version`, `upgrade_available`, `installed`: `manual`|`auto`,
   `required_by`, `packages`), installable modules (`depends_on`), packages and
   any `running_operation`.
2. `api.modules.ensure_modules(micro_saas, modules=["contacts","expenses"], wait=60)`
   — POST. Installs what's missing **including dependencies** (in order),
   skips what's there (idempotent — safe to retry). All-or-nothing: on failure
   everything this call installed is removed again (`status: "Rolled Back"`).
   `package="CRM"` groups the modules (an Active group of that name is
   extended, not duplicated); `package_item="small-business"` installs a
   catalogue app (same as `install_app`). Modules can carry a range:
   `modules=[{"item": "contacts", "version": "^1"}]`.
3. `api.modules.remove_modules(micro_saas, modules=[...], cascade=0, autoremove=1, force=0, wait=60)`
   — POST. 409 `has_dependents` while an installed module needs one of them →
   retry with `cascade=1` to remove those too. `autoremove` also removes
   auto-installed modules nothing needs any more. Records → 409
   `tenant_data_present` until you back up and pass `force=1`.
4. `api.modules.upgrade_modules(micro_saas, modules?, package?, wait=60)` — POST.
   Newest Approved versions every installed range allows, data kept; new
   dependencies installed first; held-back modules listed in `blocked`.
5. `api.modules.remove_package(package, force=0, wait=60)` — POST. Removes what
   the package brought in that nothing else needs; modules you installed
   separately, shared with another package, or still required stay (`kept`,
   with reasons).

Every change returns an **operation** `{operation, status: Queued|Running|
Succeeded|Failed|Rolled Back, done, progress_pct, steps[{action, item, version,
reason, status, note}], error}`. `wait` (≤ 90 s) blocks until it's done;
otherwise poll `api.modules.get_operation(operation)`. Nothing to do →
`{operation: null, status: "Succeeded", summary}`.
Dry-runs (GET, change nothing): `preview_install`, `preview_remove`,
`preview_upgrade`, `preview_remove_package`. Log: `list_operations(micro_saas)`.
Packages: `list_packages(micro_saas)` (status `Installed`/`Partial`/`Not
installed`, derived live), `rename_package(package, title)`.

Rules: an install's `installed` is `manual` when you asked for it and `auto`
when a dependency or package brought it in (asking for an auto module makes it
manual). One change per site at a time (409 `operation_in_progress`; the single-
item calls wait too). Codes: `nothing_selected`, `too_many_modules` (25),
`plan_invalid` (call the matching preview for per-module problems),
`version_conflict`, `blocked_by_constraint`, `has_dependents`,
`tenant_data_present`, `not_a_package`, `item_is_package`, `package_not_found`,
`operation_not_found`.

**App manifest (v2).** An app ships no schema or files — only composition:

    {"manifest_version": 2, "kind": "app", "slug": "shop", "name": "Shop",
     "version": "1.0.0", "icon": "store", "color": "#7c3aed",
     "modules": [{"item": "contacts", "version": ">=1.0 <2"},
                 {"item": "inventory", "version": "^1", "optional": true}],
     "navigation": [{"label": "Sales", "items": ["/contacts", "/invoices"]}],
     "landing": "/contacts",
     "roles": [{"name": "Shop Manager", "includes": ["Contacts User", "Invoicing User"]}]}

`navigation` items are route paths of its modules (omit it to list every
module's pages, grouped by module); `roles` become one-click role presets on
the workspace (`microsaas_desk_theme.user_admin.list_role_bundles` /
`assign_role_bundle(email, bundle)`). A v2 module manifest is a v1 module
manifest plus `"manifest_version": 2, "kind": "module"` and ranged
`depends_on`. v1 manifests (`kind: "package"`) keep working.
`lint_manifest` validates both.

## Building the tenant SPA on our framework

Provisioning gives you an empty site; this section is how you turn it into a
product. The end-user product is a single-page app on the tenant's APP hostname
(`<slug>-app.<domain>`) backed by DocTypes — a custom SPA in front of the
platform data layer, NOT the admin Desk. A product = DocTypes (your data, one REST resource each at
`/api/resource/<DocType>`) + a frontend bundle (an ES module plugged into the
Base Shell).

### Shell vs module apps
- The **Base Shell** (exactly one per site) serves `index.html` and exposes the
  runtime as `window.MicroSaaS` (aka `ctx`): login/logout, sidebar, routing, a
  dashboard, global search, and a themed UI toolkit.
- A **module app** (many per site) ships `main.js` (+ optional `main.css`) plus
  its DocTypes/roles. `main.js` exports `register(ctx, app)`, which the shell
  calls at load; every route it registers is grouped under the app's name.

    export function register(ctx, app) {
      const page = ctx.ui.crud({ doctype: "Task", title: "Tasks", singular: "task",
        columns: [{ key: "title", label: "Title" }],
        fields:  [{ name: "title", label: "Title", type: "text", required: true }] });
      ctx.route("/tasks", { label: "Tasks", icon: "grid", mount: page.mount });
    }

### The runtime — `ctx` (== window.MicroSaaS)
- Identity: `ctx.user`, `ctx.fullName`, `ctx.roles`, `ctx.hasRole(r)`,
  `ctx.hasAnyRole([...])`, `ctx.logout()`.
- Data over `/api/resource` (all return Promises; reject with a clean
  `Error.message` safe to show in a toast):
  `ctx.api.list(dt, {fields, filters, orderBy, limit, start})`,
  `.get(dt, name)`, `.create(dt, doc)`, `.update(dt, name, patch)`,
  `.remove(dt, name)`, `.count(dt, filters)`,
  `.call(method, args)` (POST a whitelisted method),
  `.method(method, args)` (GET a whitelisted read-only method).
  Filters use the platform's standard shape: `[[field, operator, value], ...]`.
- Routing: `ctx.route(path, spec)`, `ctx.navigate(path)`, `ctx.back()`,
  `ctx.params()`, `ctx.currentPath()`. `spec` = {label, icon, mount(el, ctx),
  group, roles, section, hidden, order}. `:param` paths + `hidden: true` give
  you list→detail pages with no extra sidebar link.
- UI toolkit `ctx.ui`: `page`, `card`, `grid`, `stat`, `button`, `badge`,
  `empty`, `spinner`, `field`, `form`, `table`, `modal`, `confirm`, and
  `crud({doctype, title, singular, columns, fields, scope?, orderBy?})` — a
  full list+create+edit+delete page for a DocType in ONE call.
- Third-party / AI APIs: `ctx.connectors.call(name, path, body?, {method,
  query, raw, form, contentType})`, `ctx.connectors.stream(...)` (async
  iterator over a streamed reply) and `.streamResponse(...)` (Base Shell
  1.9.0+), `ctx.connectors.available()`, `.setMyKey(name, key)`,
  `.clearMyKey(name)` (Base Shell 1.8.0+). See **Connectors** below.
- Recordings & background work (Base Shell 1.10.0+): `ctx.api.uploadLarge(file,
  {doctype, docname, fieldname, pipeline, onProgress, uploadId})` (chunked,
  resumable, up to 1 GiB video/audio), `ctx.pipelines.{start, get, list,
  cancel, retry, wait}`, and `ctx.branding` (the workspace's login branding).
  See **Recordings, background processing & login branding** below.
- Extension slots: `ctx.dashboard.widget(fn)` (add a dashboard tile),
  `ctx.search.provider(fn)` (feed results into the global search bar).
- Utilities: `ctx.h(tag, attrs, children)` (hyperscript), `ctx.notify(msg, kind)`,
  `ctx.icon(name)`, `ctx.fmt.{date,datetime,number,since}`,
  `ctx.store.{get,set}` (namespaced localStorage).
- Icon names: home, grid, note, user, users, box, settings, chart, plus, edit,
  trash, search, apps, store, receipt, calendar, folder, check, star, chat,
  book, heart, cart, globe, briefcase, shield, bolt, key.
- Base Shell 1.12+ shows an **app launcher**: Home is a grid of the apps the
  user can open (catalogue apps with their menu, site-built apps and
  standalone modules); inside an app the sidebar shows only that app's menu
  and the top-bar switcher jumps between apps. Old `apps.json` files render
  the same way (each module / custom app is its own app).

### Manifest — `marketplace_manifest.json`
Field names are exact; the install engine reads `declares.doctypes_created` and
`declares.roles[].role_name` (NOT `doctypes` / `name`).

    {
      "slug": "tasks", "name": "Tasks", "publisher": "Acme",
      "version": "1.0.0", "class": "declarative", "kind": "app",
      "declares": {
        "doctypes_created": [
          { "name": "Task",
            "fields": [
              { "fieldname": "title", "fieldtype": "Data", "label": "Title", "reqd": 1 },
              { "fieldname": "done",  "fieldtype": "Check", "label": "Done" }
            ],
            "permissions": [
              { "role": "Task User", "read": 1, "write": 1, "create": 1, "delete": 1, "if_owner": 1 }
            ]
          }
        ],
        "roles": [ { "role_name": "Task User", "desk_access": 0 } ],
        "custom_fields": [], "fixtures": []
      },
      "bundle": {
        "entry": "main.js", "styles": ["main.css"],
        "routes": [ { "path": "/tasks", "label": "Tasks", "icon": "grid" } ]
      }
    }

- `if_owner: 1` on the permission is what makes each end user see ONLY their own
  rows — the core multi-tenant-safety rule. Use it for all end-user data.
- A **module** app sets `bundle.entry`; a **shell** app sets `bundle.shell_entry`
  instead (and declares no module routes).

### Process (author → run) — each step is a whitelisted API
1. Author the manifest + `main.js` (+ `main.css`).
2. Package: zip the bundle; record its sha256.
3. Submit a `Marketplace Item Version` (Draft) carrying the manifest.
4. Install onto a Micro SaaS: `api.marketplace.check_compatibility(item,
   version, micro_saas)` then `api.marketplace.install(item, version,
   micro_saas)`. Control ships the payload to the tenant — creates the
   DocTypes/roles and regenerates `apps.json` (which the shell reads to import
   modules + prebuild the sidebar).
5. Iterate live with the tenant `files_api` (draft → publish → rollback — see
   "Frontend files" above). Preview a draft at the app hostname with
   `?_preview=draft`.

### Security rules on the app hostname
- Only `/api/resource/*` and your own whitelisted `/api/method/<your.method>`
  are reachable; `frappe.client.*` and `frappe.desk.*` are 404 by design.
- Owner-scope end-user data with `if_owner` permissions (above).
- No external hosts: the CSP blocks remote scripts/styles/fonts — inline
  everything and ship plain ES modules + CSS. There is no build step.
- No API keys in the browser, and `connect-src 'self'` blocks direct calls to
  `api.openai.com` & co. Call third-party APIs through a **Connector** (below).

### Connectors — AI & third-party APIs without exposing keys

The tenant owner stores a provider key (OpenAI, Anthropic, Gemini, Mistral,
Groq, OpenRouter, DeepSeek, or any REST API) in a Connector, in Desk → **App
Settings → Connectors**. The SPA calls its own site and the server adds the
key, then forwards to an owner-approved route:

    const out = await ctx.connectors.call("openai", "/v1/chat/completions",
      { model: "gpt-4o-mini", messages: [{ role: "user", content: "Hi" }] });

Guarantees: keys are write-only and never sent to browsers; only the App
Owner can set a key or change a keyed connector's policy (AI-agent
identities can draft connectors, not key them); changing the host erases
stored keys; only allow-listed method+path routes are callable; per-user
and per-connector rate/daily/token caps fail closed; outbound calls are
pinned to public IPs (no SSRF / DNS rebinding, no redirects); Guests are
refused; bring-your-own-key per end user is supported (`key_source:
"end_user"`). Every call is logged in `Connector Usage Log`.

Streaming (`connectors.stream`: LLM SSE, TTS audio) passes the provider's
reply through as it arrives, under the same policy, quotas, slots and
logging. `call`/`stream` also take multipart `form` (speech-to-text, OpenAI
Realtime WebRTC SDP exchange) and raw text bodies (`content_type`). The app
hostname allows WASM, blob: workers and blob:/data: media for 3D/voice apps.

Tenant API: `microsaas_desk_theme.connectors.{presets, save_connector,
list_connectors, test_connector, usage_summary, delete_connector}` (owner /
admin) and `{call, stream, available, set_my_key, clear_my_key}` (signed-in users).
The full reference (fields, envelope, error codes) is in the TENANT manual:
`http://<slug>.<domain>/docs/llms.txt` → "Connectors".
Optional config: `connector_max_concurrent_bench` (common_site_config;
in-flight calls across ALL sites, default gunicorn_workers - 2),
`connector_max_concurrent` (per site, default 4),
`connector_max_concurrent_per_user` (2), `connector_log_retention_days` (30).

### Camera, microphone & location

Blocked on the app hostname by default (`Permissions-Policy`). Enable each
with `microsaas_desk_theme.device_permissions.configure(enabled=[...])`
(tenant owner, App Admin or AI agent; it replaces the list), or in Desk →
**App Settings → Camera, microphone & location**. Each change records who
made it. Enabled features are granted to the app's own origin only
(`camera=(self)`), and the CSP gains `media-src blob:` for playback. Apps
check `device_permissions.available()` (public) and then call
`getUserMedia` / `navigator.geolocation` directly. Details: tenant manual →
"Camera, microphone & location".
Connector timeouts are capped at 90 s (below gunicorn's 120 s worker timeout).

### Recordings, background processing & login branding

- **Large uploads** — video (mp4, mov, webm, mkv, 3gp) and audio (mp3, m4a,
  aac, wav, ogg, opus, flac), checked by content, stored PRIVATE on a record:
  `microsaas_desk_theme.attachments.{start_upload, upload_chunk,
  upload_status, finish_upload, abort_upload}` (8 MiB multipart chunks,
  resumable). Limits (site_config): `max_media_bytes` (1 GiB),
  `max_attachment_bytes` (10 MiB, other types),
  `max_upload_bytes_per_user_per_day` (10 GiB).
- **Background processing** — `microsaas_desk_theme.pipelines.*`: the owner /
  agent saves a Processing Pipeline (JSON steps: connector calls, ffmpeg
  `media_frames` / `media_audio`, `parse_json`, `for_each`, `create_record`,
  `set_fields`); end users start runs (`start`, or `pipeline=` on
  `start_upload`) that finish on the `long` worker queue after the browser is
  closed, AS that user (their permissions and connector quotas). Runs on one
  record take turns. Needs a `long` queue worker; the media steps need ffmpeg
  on the server (site_config `ffmpeg_path`, else PATH).
- **Login branding** — `microsaas_desk_theme.branding.set_login_branding(
  app_name, heading, tagline, footer, logo, favicon, styles, colors,
  dark_colors)` (owner / App Admin / agent): written into the shell page, so
  signed-out visitors see the app's look. Sign-in itself is unchanged.
Agents can finish all of this with no human step: on tenant sites an `AI
Agent` identity may also set connector keys (write-only) and keyed-connector
policy, configure sign-up and default roles, set upload limits (within the
`max_media_bytes_ceiling` / `max_upload_bytes_per_user_per_day_ceiling`
operator ceilings) and upgrade installed apps
(`marketplace_bridge.upgrade` → `api.tenant_bridge.upgrade`, own site only).
Full reference: tenant manual → "Large recordings", "Background processing",
"Login branding", "Upgrading installed apps".

### Creating DocTypes at runtime — the Schema API (fast path)

Two ways to create DocTypes; pick per situation:
- **Manifest** (above) — for a packaged, installable app. Versioned, snapshotted,
  uninstallable. Use this for anything you ship through the marketplace.
- **Schema API** — create/evolve DocTypes LIVE on a tenant, no package cycle.
  Ideal for an agent shaping data models on the fly while iterating. Called on
  the TENANT Desk hostname (`<slug>.<domain>`); requires the `Schema Manager`
  role. NOTE: `X-Dry-Run: true` is honoured only by CONTROL-plane endpoints
  (platform_control's idempotency middleware) — the tenant schema_api ignores
  the header and writes for real. To preflight, lint the shape with
  `platform_control.api.marketplace.lint_manifest` or inspect first via
  `list_doctypes` / `get_json`.

  - `microsaas_desk_theme.schema_api.list_doctypes(limit?)` — GET, your custom DocTypes.
  - `microsaas_desk_theme.schema_api.create_doctype(name, fields, permissions?)`
    — POST. `fields` = `[{fieldname, fieldtype, label, reqd?, options?}]`
    (Link/Table/Select need `options`). `permissions` (optional) =
    `[{role, read, write, create, delete, if_owner}]` — a role that doesn't
    exist yet is auto-created (deskless), so ONE call yields an end-user-ready,
    owner-scoped DocType. Omit `permissions` → admin-only.
  - `microsaas_desk_theme.schema_api.add_field(doctype, field_spec)` — POST,
    append a field to a custom DocType.
  - `microsaas_desk_theme.schema_api.get_json(doctype)` — GET the full DocType JSON.
  - `microsaas_desk_theme.schema_api.get_dts(doctype)` — GET a generated
    TypeScript `.d.ts` interface for the DocType (type your frontend for free).

  Example — a live, end-user-ready "Ticket" DocType in one call:

      POST /api/method/microsaas_desk_theme.schema_api.create_doctype
      { "name": "Ticket",
        "fields": [
          {"fieldname":"subject","fieldtype":"Data","label":"Subject","reqd":1},
          {"fieldname":"status","fieldtype":"Select","label":"Status","options":"Open\nClosed"},
          {"fieldname":"priority","fieldtype":"Select","label":"Priority","options":"Low\nHigh"} ],
        "permissions": [
          {"role":"Ticket User","read":1,"write":1,"create":1,"delete":1,"if_owner":1} ] }

  Then end users with `Ticket User` do CRUD at `/api/resource/Ticket`, seeing
  only their own rows. Wire a UI with `ctx.ui.crud({doctype:"Ticket", ...})`.

### Giving an autonomous agent its own tenant token

The Schema API needs the `Schema Manager` role, so an autonomous agent needs a
TENANT identity holding it. Two supported ways to authenticate an agent to a
tenant:
  1. **As the owner** — the owner already holds `Schema Manager` (the Owner
     role profile grants it), so an agent driving the owner's session can call
     schema_api directly.
  2. **A dedicated agent identity** (recommended for autonomy) — the owner (a
     `App Admin`) creates a tenant User holding BOTH `AI Agent` and
     `Schema Manager`, then mints it a scoped API key:
       `POST microsaas_desk_theme.tenant_api_keys.issue_agent_api_key(user)`
       → returns `{api_key, api_secret}` ONCE. (Rotate with
       `rotate_agent_api_key(user)`, revoke with `revoke_agent_api_key(user)`.)
     The agent then authenticates every tenant call with
       `Authorization: token <api_key>:<api_secret>`
     — note: a plain tenant-User token needs NO `X-Auth-Source`
     header (that header is only for control-plane API Credentials). The minting
     rules refuse Administrator, refuse a target more privileged than the
     caller, and require the target to hold `AI Agent`.

### CRUD over `/api/resource` — the exact calls (agent, server-to-server)

The SPA `ctx.api.*` helpers wrap these; an agent can hit them directly. Owner /
DocType permissions apply. Non-GET needs the session CSRF token (from
`microsaas_desk_theme.session_api.whoami`) OR an API token.

    # list (fields + filters are JSON):
    GET  /api/resource/Ticket?fields=["name","subject","status"]&filters=[["status","=","Open"]]&limit_page_length=20
    # one doc:
    GET  /api/resource/Ticket/<name>
    # create:
    POST /api/resource/Ticket            {"subject":"Login broken","status":"Open"}
    # update (partial):
    PUT  /api/resource/Ticket/<name>     {"status":"Closed"}
    # delete:
    DELETE /api/resource/Ticket/<name>

Filters use the platform's standard shape `[[field, operator, value], ...]` (operators:
`=,!=,>,<,>=,<=,like,in,not in,between`).

### End-user auth (tenant)

Self-signup for a tenant's END USERS, on the tenant hostname:

- `microsaas_desk_theme.end_user_auth.configure_signup(enabled, roles,
  allowed_domains?)` — POST, owner or the site's AI agent. Enabling requires at least one
  existing, deskless, non-privileged role.
- `microsaas_desk_theme.end_user_auth.signup_config()` — GET, guest.
  Returns `{enabled}` only (never leaks the role list).
- `microsaas_desk_theme.end_user_auth.register(email, password, full_name?)`
  — POST, guest. Creates a tenant-native Website User carrying ONLY the
  configured non-privileged roles, logs them in, returns the whoami shape.
- `microsaas_desk_theme.end_user_auth.request_password_reset(email)` —
  POST, guest. Always answers generically.

Auth endpoints (login / register / password reset) are throttled by the
tighter `auth` rate-limit bucket (next section).

### View as user — owner/support impersonation of an end user (§9.1c)

Desk personas are deliberately refused a session on the app hostname, so
"open my live site" lands on the end-user login. To see the SPA as a
specific customer sees it, mint a one-time view-as URL from the Desk
hostname (also in the Desk UI: floating ✦ menu → "View as user"):

- `microsaas_desk_theme.impersonation.mint_view_as_token(target_user,
  mode="read"|"write", reason)` — POST, interactive Desk sessions only
  (requires the platform-managed `Impersonator` role, held by the
  Owner/Admin/Manager/Developer/Support profiles; API tokens are refused).
  `reason` is mandatory and audited on both ends. Returns
  `{view_as_url, expires_in: 900, mode, target_user}`.
- `microsaas_desk_theme.impersonation.list_view_as_targets(search?)` —
  GET. Eligible targets only (enabled, non-Desk, ceiling-satisfying).
- Opening `view_as_url` (one-time, 15 min) starts a session on the app
  hostname AS the target; `whoami` then carries `impersonated_by`,
  `impersonation_mode`, `impersonation_expires_at`, and the shell shows a
  non-dismissible banner. The session dies with the token.
- `mode="read"` (default, T3): every non-GET request is refused with 403
  at the request layer. `mode="write"` (T4): support acting on the user's
  behalf — the target user gets a notification.
- Privilege ceiling, enforced at mint AND consume: the target may not
  hold `Desk Access` (teammates are never impersonatable) nor any
  platform-managed/framework role the caller lacks. `platform.secret()`
  is refused for the whole session.
- `microsaas_desk_theme.impersonation.exit_view_as` — GET; ends the
  session and bounces back to the Desk hostname.

### Tenant rate limiting

Per-tenant fixed-window limits, APP hostname only (the Desk hostname is
unthrottled). Fail-open: a broken limiter never blocks traffic. Login is
counted in the tighter `auth` bucket on every dispatch path
(`/api/method/login`, `/api/v1|v2/method/login`, and legacy `cmd=login`).

- `microsaas_desk_theme.rate_limit.configure_rate_limit(enabled?,
  api_per_min?, auth_per_min?)` — POST, owner-only; persists to
  site_config.
- site_config keys: `app_rate_limit_enabled`, `app_rate_limit_per_min`
  (default 300), `auth_rate_limit_per_min` (default 20).

## Billing

Two independent layers — keep them straight:

**1. The platform bills the OWNER's account** (control plane, `api.billing.*`).
Runs in mock mode by default (no Stripe account needed); the same calls hit
`api.stripe.com` once real keys are in the vault.
- `api.billing.list_plans(account?)` — GET, public priced plans (Free/Pro/…).
- `api.billing.get_billing_summary(account)` — GET, current plan + usage +
  subscription + invoices.
- `api.billing.start_checkout(account, plan)` — POST, returns `{checkout_url}`
  (send the owner there). On payment the subscription goes Active + an invoice is
  written + `Account.plan` flips so quota caps update.
- `api.billing.cancel_subscription(account, at_period_end=1)` — POST.
- Owners manage all this in the UI at `/billing`. Webhooks land at
  `api.billing.webhook` (signature-verified).

**2. The TENANT bills THEIR OWN end users** (tenant plane, "SaaS Billing Kit",
`microsaas_desk_theme.billing.*` on the tenant Desk host). The platform takes
**0%** and never touches this money. DocTypes (Billing Plan/Customer/
Subscription/Invoice) are preinstalled on every tenant. Keys live in the tenant's
own `Platform Secret` via `microsaas_desk_theme.helpers.secret(name)`.
- `billing.create_plan(plan_code, plan_name, amount_cents, interval, currency, ...)`.
- `billing.create_checkout_session(plan_code, customer_email)` — start a checkout.
- `billing.record_payment(payload, signature)` — the tenant's Stripe webhook
  (HMAC-verified); marks Invoices Paid + Subscriptions Active.
- `billing.list_plans()`, `billing.list_subscriptions()`.

### Completing a mock checkout

Control plane: `api.billing.start_checkout(account, plan)` returns a
`checkout_url` (mock mode: our own hosted page). In mock mode, complete
via `api.billing.complete_mock_checkout(account, plan, session?)` —
owner-scoped, refused outside mock mode — the exact state change the
Stripe webhook drives in live mode (subscription Active + Paid invoice +
`Account.plan` flips). Live mode completes ONLY via the webhook.

Tenant plane is analogous:
`microsaas_desk_theme.billing.complete_mock_checkout(plan_code,
customer_email)` — the caller must be that customer themselves or hold
App Admin; you cannot complete a checkout for someone else's email.

## Error contract

Every refusal returns a typed envelope on `frappe.local.response["error"]`:

```
{
  "code": "invalid_slug",                       # stable identifier
  "message": "Slug 'foo' is not a valid ...",   # human-readable
  "hint": "Slug must be 3-31 characters ...",   # for an agent to self-correct
  "docs_url": "https://.../invalid_slug"        # deep link
}
```

Read `hint` first — it is written for you to fix the request and retry
without a round trip to a human.

## Blast-radius tiers (design doc §6.5)

- T0 — GETs, dry-runs. Always allowed.
- T1 — small writes. Allowed within your token budget.
- T2 — bulk writes, DocType create, file delete. Allowed, flagged in audit.
- T3 — bulk delete, drop DocType, credential rotate, invite user.
- T4 — delete site/account, plan change, payment methods, Developer Mode
       enable, `end_user_type=system` switch.

What is ENFORCED today: **your token's `scope_preset` is a hard per-call
ceiling** (2026-08-15) — every control-plane endpoint carries a tier
(read_only→T0, read_write→T1, build→T2, admin→T3); calling above your
preset returns `403 scope_preset_exceeded` with the required preset named
in the hint. GETs are always T0; structural calls (install/upgrade/
archive/provision/promote) are T2; destructive or secret-touching calls
(delete/restore/reveal/rotate/issue/jump) are T3. `api.approvals.approve`
/ `.deny` refuse EVERY non-staff token call outright
(`403 approval_decision_requires_human`) — an agent cannot approve its
own requests. Also enforced: Token Budget caps (`429
token_budget_exceeded`), the container-tier gate on `native` installs
(`403 app_requires_container`), one-time credential reveal (409 on a
second read), the slug-typed confirmation on `api.lifecycle.delete`
(`confirm` must equal the slug), AND the T3/T4 approval gate for
TOKEN-authenticated calls:

  `credentials.rotate`, `credentials.reveal`, `lifecycle.delete`,
  `lifecycle.restore`, `domains.remove_custom_domain` refuse a token call
  with `428 approval_not_yet_approved` unless you pass
  `approval_ok=<approval_id>` referencing an APPROVED, unexpired
  `Approval Request` for the same action + Micro SaaS. Your flow:
    1. `api.approvals.request_approval(action=..., micro_saas=...)`
       -> returns `approval_id` (status Pending).
    2. A human approves it in the control Desk (or
       `api.approvals.approve(approval_id)` by an authorized approver).
    3. Re-call the T3 endpoint with `approval_ok=<approval_id>`.
  Interactive Desk sessions (a human clicking) and platform staff are not
  gated. Site operators can disable with `enforce_t3_approvals: 0`.

## Idempotency & determinism

- `Idempotency-Key` header on any non-GET `/api/` call. First call claims
  the key and runs; a 2xx response is stored 24h. A RETRY with the same
  key + body returns the stored response verbatim (look for the
  `Idempotency-Replayed: true` response header) and does NOT re-execute.
  A concurrent duplicate while the first is in flight gets
  `409 idempotency_conflict`. Non-2xx outcomes are never cached — retry
  re-executes. Keys are scoped per user + method + path + body, so reusing
  a key for a different call is safe (it's a different claim).
  Use it on every non-GET call.
- `X-Dry-Run: true`: control-plane endpoints only — returns the intended
  diff without writing. Tenant-side methods (schema_api etc.) ignore it.
- File writes: `if_match_sha` — 409 on stale.

## Rate limits & budgets

Every token has a `Token Budget` (writes_per_hour_max, writes_per_day_max,
cost_per_day_usd_max). Breach returns `429` with `retry_after` and pings the
Owner. A credential with no budget row is NOT unlimited — the platform
self-heals a default budget (600/h, 5000/d) on first use. Kill switch:
`POST /api/method/platform_control.api.credentials.revoke` with
`{name: <API Credential name>}` — irreversible; issue a new credential
to resume.

## When you're stuck

- `hint` fields on 4xx envelopes are the fastest fix path.
- `/api/method/platform_control.api.docs.get_openapi_json` is the full
  spec, machine-parseable.
- Support is a human loop, not a token loop.
