# Connector Reference

A connected service plugs custom modules, entities, and full UI pages into
cloud-erp. From the user's perspective it looks identical to a built-in
module — same sidebar, same chrome, same RBAC. From your perspective it's a
small HTTP service you control, with a stable wire contract documented below.

This reference covers six surfaces:

1. **Manifest** — JSON your service serves at `GET /manifest`. Cloud-erp
   reads it once at registration and caches it.
2. **HTTP** — the two layers of auth (HMAC + JWT) on every request, the URL
   shapes you handle, and the outbound REST surface for calling cloud-erp back.
3. **Pages** — sandboxed iframes that let you render any HTML inside
   cloud-erp's chrome, with ticket-based auth and a host-verb API
   (`cloudErpHost.toast`, `.confirm`, `.navigate`, …) for native integration.
4. **Webhooks** — outbound events cloud-erp emits to subscribers (HMAC-signed
   POSTs to your declared webhook URLs).
5. **SDK** — `@cloud/external-service-sdk` — verify helpers, manifest
   builders, typed host-verb client.
6. **Lifecycle + security** — connection states, error codes, glossary,
   troubleshooting, and the threat model you defend against.

---

## Contents

- [Getting started](#getting-started)
  - [Register it](#register-it)
  - [What just happened](#what-just-happened)
  - [Local dev gotcha](#local-dev-gotcha)
- [Recipes](#recipes)
  - [Add a custom page](#add-a-custom-page)
  - [Subscribe to a cloud-erp event](#subscribe-to-a-cloud-erp-event)
  - [Emit an event back into cloud-erp](#emit-an-event-back-into-cloud-erp)
  - [Use host verbs from a page](#use-host-verbs-from-a-page)
- [Manifest](#manifest)
  - [Top-level shape](#top-level-shape)
- [Field types](#field-types)
  - [Manifest field types](#manifest-field-types)
  - [No-code module field types](#no-code-module-field-types)
- [HTTP](#http)
  - [Authentication](#authentication)
  - [Request paths](#request-paths)
  - [Outbound (your service → cloud-erp)](#outbound-your-service--cloud-erp)
- [Endpoints reference](#endpoints-reference)
  - [Outbound REST (service → cloud-erp)](#outbound-rest-service--cloud-erp)
  - [OIDC / discovery (service ↔ cloud-erp)](#oidc--discovery-service--cloud-erp)
  - [Management oRPC (admin → cloud-erp)](#management-orpc-admin--cloud-erp)
  - [Iframe RPC (host-verbs)](#iframe-rpc-host-verbs)
- [Webhook events](#webhook-events)
  - [Subscribing](#subscribing)
  - [Delivery shape](#delivery-shape)
  - [Signing scheme](#signing-scheme)
  - [Retries](#retries)
  - [Known event names](#known-event-names)
  - [Synthetic workflow-trigger events](#synthetic-workflow-trigger-events)
- [Pages — sandboxed UI embed](#pages--sandboxed-ui-embed)
  - [Ticket verification](#ticket-verification)
  - [Iframe sandbox](#iframe-sandbox)
  - [Native chrome (`cloudErpHost`)](#native-chrome-clouderphost)
  - [Host verbs](#host-verbs)
  - [`toast`](#toast)
  - [`confirm`](#confirm)
  - [`navigate`](#navigate)
  - [`requestUser`](#requestuser)
  - [`openSheet`](#opensheet)
- [SDK reference](#sdk-reference)
  - [`verifyProxyRequest(opts) → { ok: true } | { ok: false, reason }`](#verifyproxyrequestopts---ok-true----ok-false-reason-)
  - [`verifyJwt(opts) → Promise<Record<string, unknown>>`](#verifyjwtopts--promiserecordstring-unknown)
  - [`verifyPageTicket(opts) → Promise<VerifyPageTicketResult>`](#verifypageticketopts--promiseverifypageticketresult)
  - [`defineManifest(input) → Manifest`](#definemanifestinput--manifest)
  - [`createHostClient(opts?) → CloudErpHostClient`](#createhostclientopts--clouderphostclient)
  - [`HOST_VERBS`](#host_verbs)
  - [Types: `Manifest`, `EntityDef`, `FieldDef`, `NavItem`, `PageDef`, `EventSub`, `FieldType`](#types-manifest-entitydef-fielddef-navitem-pagedef-eventsub-fieldtype)
- [Lifecycle](#lifecycle)
- [Security model](#security-model)
  - [Outbound API authentication (your service → cloud-erp)](#outbound-api-authentication-your-service--cloud-erp)
- [Error codes](#error-codes)
  - [Outbound REST (`/api/external-service/v1/*`)](#outbound-rest-apiexternal-servicev1)
  - [Proxy (`/api/v1/external-proxy/<connectionId>/*`)](#proxy-apiv1external-proxyconnectionid)
  - [Management oRPC (`external.*`)](#management-orpc-external)
  - [OAuth (`/oauth/*`)](#oauth-oauth)
  - [Page tickets (verify side)](#page-tickets-verify-side)
  - [Manifest fetcher](#manifest-fetcher)
- [Glossary](#glossary)
- [Troubleshooting](#troubleshooting)
  - [Manifest stays in `error` status with `SSRF: ...`](#manifest-stays-in-error-status-with-ssrf-)
  - [Manifest stays in `error` with a Zod issue path](#manifest-stays-in-error-with-a-zod-issue-path)
  - [Proxy returns 503 `connection_unavailable`](#proxy-returns-503-connection_unavailable)
  - [Proxy returns 503 `service_unavailable`](#proxy-returns-503-service_unavailable)
  - [Proxy returns 502 `upstream_unavailable`](#proxy-returns-502-upstream_unavailable)
  - [Service receives a request without `X-CloudERP-Signature`](#service-receives-a-request-without-x-clouderp-signature)
  - [`verifyJwt` throws "JWT expired"](#verifyjwt-throws-jwt-expired)
  - [Page iframe blank, console says CSP blocked](#page-iframe-blank-console-says-csp-blocked)
  - [`verifyPageTicket` returns `reason: 'already_redeemed'`](#verifypageticket-returns-reason-already_redeemed)
  - [`/oauth/token` returns `invalid_grant` on refresh](#oauthtoken-returns-invalid_grant-on-refresh)
  - [Webhook delivery never arrives](#webhook-delivery-never-arrives)
  - ["Connect" returns `CONFLICT: client_id already registered`](#connect-returns-conflict-client_id-already-registered)
  - [`requestUser` host-verb returns null email](#requestuser-host-verb-returns-null-email)
  - [Iframe loads but `cloudErpHost.toast` does nothing](#iframe-loads-but-clouderphosttoast-does-nothing)

## Getting started

The shortest service that registers and serves data is roughly 80 lines of
TypeScript. Here's a complete one — copy it, adjust the entity, run, register.

```ts
// src/index.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createRemoteJWKSet, jwtVerify } from 'jose'

const PORT = Number(process.env.PORT ?? 3030)
const CLOUD_ERP_URL = process.env.CLOUD_ERP_URL ?? 'http://localhost:8787'
// Filled in once you click "Connect" in cloud-erp's UI:
const SHARED_SECRET = process.env.SHARED_SECRET
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID

// Manifest declares what cloud-erp can show + which routes you handle.
const manifest = {
  moduleKey: 'demo',
  displayName: 'Demo',
  version: '0.1.0',
  scopes: ['module:read', 'module:write'],
  nav: [{ title: 'Items', path: '/item' }],
  entities: [
    {
      key: 'item',
      label: 'Item',
      labelPlural: 'Items',
      fields: [
        { key: 'sku', label: 'SKU', type: 'text', required: true, unique: true },
        { key: 'name', label: 'Name', type: 'text', required: true },
      ],
      listView: { columns: ['sku', 'name'] },
      form: { fields: ['sku', 'name'] },
    },
  ],
  events: [],
  pages: [],
  actions: [],
}

const jwks = createRemoteJWKSet(new URL(`${CLOUD_ERP_URL}/oauth/jwks.json`))

function verifyHmac(header: string, body: string): boolean {
  const parts = Object.fromEntries(
    header.split(',').map((s) => s.split('=', 2) as [string, string]),
  )
  const ts = Number(parts.t)
  if (!ts || Math.abs(Date.now() - ts) > 5 * 60 * 1000) return false
  const expected = createHmac('sha256', SHARED_SECRET!)
    .update(`${ts}\n`)
    .update(body)
    .digest('hex')
  try {
    return timingSafeEqual(Buffer.from(parts.v1!, 'hex'), Buffer.from(expected, 'hex'))
  } catch {
    return false
  }
}

async function authenticate(req: Request, body: string) {
  if (!SHARED_SECRET || !OIDC_CLIENT_ID) throw new Error('not_configured')
  const sig = req.headers.get('x-clouderp-signature')
  if (!sig || !verifyHmac(sig, body)) throw new Error('hmac_failed')
  const auth = req.headers.get('authorization')
  if (!auth?.startsWith('Bearer ')) throw new Error('missing_bearer')
  const { payload } = await jwtVerify(auth.slice(7), jwks, {
    issuer: CLOUD_ERP_URL,
    audience: OIDC_CLIENT_ID,
  })
  return { userId: payload.sub as string, tenantId: payload.tenant_id as string }
}

const items = new Map<string, { id: string; sku: string; name: string }>()

Bun.serve({
  port: PORT,
  async fetch(req) {
    const url = new URL(req.url)
    if (url.pathname === '/manifest' && req.method === 'GET') {
      return Response.json(manifest)
    }
    if (url.pathname === '/item') {
      const body = req.method === 'GET' ? '' : await req.text()
      try {
        await authenticate(req, body)
      } catch (err) {
        return Response.json({ error: String(err) }, { status: 401 })
      }
      if (req.method === 'GET') return Response.json([...items.values()])
      if (req.method === 'POST') {
        const input = JSON.parse(body) as { sku: string; name: string }
        const row = { id: crypto.randomUUID(), ...input }
        items.set(row.id, row)
        return Response.json(row, { status: 201 })
      }
    }
    return new Response('not found', { status: 404 })
  },
})

console.log(`listening on http://localhost:${PORT}`)
```

### Register it

1. `bun src/index.ts` (or `bun --watch`). The `/manifest` endpoint works
   immediately — it doesn't need credentials.
2. In cloud-erp, go to **Settings → Modules → New module → Connected service**.
3. **Service base URL** = `http://localhost:3030/`. Click **Preview manifest**;
   you should see one entity (`Items`).
4. Click **Connect**. Cloud-erp shows a credentials screen *once* with five
   values: `SHARED_SECRET`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
   `SERVICE_TOKEN`, `ORGANIZATION_ID`.
5. Paste the first two into your `.env`, restart your service. Items now show
   up in the cloud-erp sidebar under "Demo" and full CRUD works.

### What just happened

The proxy in cloud-erp signs every user request with HMAC over the body, mints
a short-lived JWT for the calling user, and forwards it to your service. Your
`authenticate()` verifies both, then your handlers do the rest. Nothing about
multi-tenancy, RBAC, or auth UX is your problem — that all stays in cloud-erp.

### Local dev gotcha

Webhook URLs and manifest URLs are SSRF-validated by cloud-erp at request
time — `localhost` and private IPs are blocked by default. For local dev
add `SSRF_ALLOWED_HOSTS=localhost,127.0.0.1` to cloud-erp's `.env`.

---

## Recipes

### Add a custom page

Pages let you ship arbitrary HTML inside cloud-erp's chrome. Useful for
calculators, dashboards, anything more elaborate than entity CRUD.

```ts
// 1. Declare it in the manifest:
manifest.pages = [
  { key: 'calculator', title: 'Calculator', path: '/pages/calculator' },
]
manifest.nav.push({ title: 'Calculator', path: '/pages/calculator' })
```

```ts
// 2. Handle the route. First load carries ?ticket=<jwt>; verify, set a
//    same-origin session cookie, redirect to a clean URL.
import { verifyPageTicket } from '@cloud/external-service-sdk'

const sessions = new Map<string, { tenantId: string; userId: string }>()
const redeemed = new Map<string, number>() // jti -> exp seconds

if (url.pathname === '/pages/calculator' && req.method === 'GET') {
  const ticket = url.searchParams.get('ticket')
  if (ticket) {
    const result = await verifyPageTicket({
      ticket,
      sharedSecret: SHARED_SECRET!,
      cloudErpUrl: CLOUD_ERP_URL,
      oidcClientId: OIDC_CLIENT_ID!,
      pageKey: 'calculator',
      isAlreadyRedeemed: async (jti) => redeemed.has(jti),
      markRedeemed: async (jti, exp) => { redeemed.set(jti, exp) },
    })
    if (!result.ok) return new Response(`ticket: ${result.reason}`, { status: 401 })
    const sid = crypto.randomUUID()
    sessions.set(sid, {
      tenantId: result.claims.tenant_id,
      userId: result.claims.sub,
    })
    const clean = new URL(req.url)
    clean.searchParams.delete('ticket')
    return new Response(null, {
      status: 302,
      headers: {
        location: clean.pathname,
        'set-cookie': `page-session=${sid}; Path=/; HttpOnly; SameSite=None; Secure; Max-Age=1800`,
      },
    })
  }
  // No ticket: existing session cookie or 401.
  const cookie = (req.headers.get('cookie') ?? '')
    .split(';').map(s => s.trim().split('=', 2))
    .find(([k]) => k === 'page-session')?.[1]
  if (!cookie || !sessions.has(cookie)) {
    return new Response('open from cloud-erp', { status: 401 })
  }
  return new Response(renderHtml(sessions.get(cookie)!), {
    headers: {
      'content-type': 'text/html',
      'content-security-policy': `frame-ancestors ${CLOUD_ERP_URL}`,
    },
  })
}
```

```html
<!-- 3. Inside renderHtml(): include host.js + theme.css for native feel -->
<link rel="stylesheet" href="${CLOUD_ERP_URL}/external-pages/theme.css">
<script src="${CLOUD_ERP_URL}/external-pages/host.js"></script>

<button onclick="cloudErpHost.toast({ kind:'success', message:'Saved' })">
  Save
</button>
```

That's it — your HTML now sits inside cloud-erp's sidebar layout, picks up
the user's theme, and can call host verbs for native UI.

### Subscribe to a cloud-erp event

```ts
// 1. Declare the subscription in the manifest:
manifest.events = [
  { pattern: 'finance.invoice.*', webhookUrl: '/webhooks/cloud-erp' },
]
```

```ts
// 2. Handle the delivery. Webhooks are HMAC-only (no JWT) — they're
//    server-to-server, not on behalf of a user.
if (url.pathname === '/webhooks/cloud-erp' && req.method === 'POST') {
  const body = await req.text()
  const sig = req.headers.get('x-clouderp-signature')
  if (!sig || !verifyHmac(sig, body)) {
    return new Response('bad signature', { status: 401 })
  }
  const { event, payload } = JSON.parse(body)
  console.log('cloud-erp event:', event, payload)
  return Response.json({ received: true })
}
```

### Emit an event back into cloud-erp

```ts
// SERVICE_TOKEN was on the credentials screen at register time.
await fetch(`${CLOUD_ERP_URL}/api/external-service/v1/events/emit`, {
  method: 'POST',
  headers: {
    authorization: `Bearer ${process.env.SERVICE_TOKEN}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    event: 'demo.item.created',
    payload: { sku: 'WIDGET-001' },
  }),
})
```

Other tenant connectors subscribed to `demo.item.*` will receive this on
their webhook URL. Workflows in cloud-erp can match it too.

### Use host verbs from a page

Host verbs let your iframe ask cloud-erp's parent window to do things its
chrome is good at — toasts, modals, navigation, side sheets. Each verb is
a `Promise`-returning function on `window.cloudErpHost` (loaded by
`host.js`).

```js
// Toast in cloud-erp's chrome (escapes the iframe — top-right of viewport)
await cloudErpHost.toast({ kind: 'success', message: 'Calculation saved' })

// Confirm modal — sized to the cloud-erp viewport, not your iframe
const ok = await cloudErpHost.confirm({
  title: 'Delete this?',
  body: 'Cannot be undone.',
  destructive: true,
})
if (!ok) return

// Navigate cloud-erp's router (in-app routes only)
await cloudErpHost.navigate({ path: '/hr/employees/12' })

// Identify the calling user (more than the JWT carries)
const user = await cloudErpHost.requestUser()
greet(`Hi ${user.name ?? user.email}`)

// Open a side sheet with another page from your service
const { result } = await cloudErpHost.openSheet({
  title: 'Edit related',
  contentUrl: '/pages/edit-related?id=42',
})
```

The full schema for each verb is below in **Host verbs**.

---

## Manifest

### Top-level shape

| Field | Type | Required | Notes |
|---|---|---|---|
| `moduleKey` | string | yes | pattern `^[a-z][a-z0-9-]{1,63}$` |
| `displayName` | string | yes | minLen 1, maxLen 120 |
| `icon` | string | no | maxLen 64 |
| `version` | string | yes | maxLen 32 |
| `scopes` | string[] | yes | default `[]` |
| `nav` | object[] | yes | — |
| `entities` | object[] | yes | — |
| `events` | object[] | yes | default `[]` |
| `pages` | object[] | yes | default `[]` |
| `actions` | unknown[] | yes | default `[]` |

## Field types

Two field-type vocabularies live in cloud-erp. The **manifest** vocabulary is what a
connected service declares in its `/manifest` — it's the wire contract for the proxy
and the auto-rendered list/detail/form UI. The **no-code** vocabulary is what tenant
admins use to design custom modules in the cloud-erp UI — it's richer because the
frontend renders it directly. If you're writing a connected service you only need the
first table; the second is here so you know what shapes you may encounter in
cross-module relations.

### Manifest field types

| Type | Description | Config | Validation |
|---|---|---|---|
| `text` | Short single-line string | `required`, `unique` | Trimmed string. Empty rejected when `required` is true. |
| `longtext` | Multi-line free-form text | `required` | String, no length cap from cloud-erp; clamp on your side. |
| `number` | Integer or decimal number | `required` | Coerced from string in form payloads; rejected if non-numeric. |
| `currency` | Monetary amount | `required` | Decimal number. Format with the tenant locale at render time. |
| `percentage` | Percentage (0..100) | `required` | Decimal number. |
| `boolean` | True / false toggle | `required` | Strict true / false. |
| `date` | Calendar date | `required` | ISO `YYYY-MM-DD` string. |
| `datetime` | Date + time (ISO 8601) | `required` | ISO 8601 string (RFC 3339) — `2026-05-26T14:30:00Z`. |
| `select` | Single choice from `options[]` | `required`, `options[]` (each `{ value, label }`) | Value must match one of `options[].value`. |
| `multiselect` | Multiple choices from `options[]` | `required`, `options[]` | Array of values, each must be in `options`. |
| `reference` | Opaque link to another row | `required` | Opaque string id. |
| `json` | Arbitrary structured JSON | `required` | Any JSON-serializable value. |

#### `text`

Short single-line strings: names, codes, IDs.

```json
{ "key": "sku", "label": "SKU", "type": "text", "required": true, "unique": true }
```

#### `longtext`

Multi-line notes, descriptions, free-form prose.

```json
{ "key": "notes", "label": "Notes", "type": "longtext" }
```

#### `number`

Integers or decimals — quantities, counts, scores.

```json
{ "key": "quantity", "label": "Quantity", "type": "number", "required": true }
```

#### `currency`

Monetary amounts. Pair with a separate currency code field if you need multi-currency.

```json
{ "key": "amount", "label": "Amount", "type": "currency" }
```

#### `percentage`

Rates, ratios — 0..100 (not 0..1).

```json
{ "key": "discount", "label": "Discount %", "type": "percentage" }
```

#### `boolean`

Toggles, flags.

```json
{ "key": "active", "label": "Active", "type": "boolean" }
```

#### `date`

Calendar dates without a time component (birthdays, due dates).

```json
{ "key": "due_date", "label": "Due", "type": "date" }
```

#### `datetime`

Date + time (timestamps, scheduled events).

```json
{ "key": "scheduled_at", "label": "Scheduled at", "type": "datetime" }
```

#### `select`

Pick exactly one of a fixed list — status, priority, category.

```json
{ "key": "status", "label": "Status", "type": "select",
  "options": [
    { "value": "draft", "label": "Draft" },
    { "value": "sent", "label": "Sent" }
  ] }
```

#### `multiselect`

Tag-like multi-select. Stored as an array of values.

```json
{ "key": "tags", "label": "Tags", "type": "multiselect",
  "options": [
    { "value": "vip", "label": "VIP" },
    { "value": "lead", "label": "Lead" }
  ] }
```

#### `reference`

Link to another row — yours or one in cloud-erp. The value is an opaque id; resolution is your service's responsibility.

```json
{ "key": "customer_id", "label": "Customer", "type": "reference", "required": true }
```

#### `json`

Escape hatch for structured data the other field types don't cover (nested arrays, polymorphic blobs).

```json
{ "key": "metadata", "label": "Metadata", "type": "json" }
```

### No-code module field types

Cloud-erp's no-code module designer (`Settings → Modules → Custom`) exposes a slightly
richer set than the manifest vocabulary. These types are not part of the connector wire
contract — they're here so connected services that consume cloud-erp custom-module data
(via `tenant:context` reads or the workflows bus) know what to expect.

| Type | Label | Description | Options? | Relation? |
|---|---|---|---|---|
| `text` | Text | Single-line text | no | no |
| `rich_text` | Rich text | Multi-line formatted text | no | no |
| `number` | Number | Integer or decimal | no | no |
| `date` | Date | Calendar date (no time) | no | no |
| `datetime` | Date & time | Date with time component | no | no |
| `boolean` | Checkbox | True / false toggle | no | no |
| `select` | Single select | Choose one from a list | yes | no |
| `multi_select` | Multi select | Choose many from a list | yes | no |
| `relation` | Relation | Link to another entity | no | yes |
| `user` | User | Reference a tenant user | no | no |
| `email` | Email | Email with validation | no | no |
| `url` | URL | Web link with validation | no | no |
| `currency` | Currency | Monetary amount with currency code | no | no |

## HTTP

### Authentication

Every proxied user request from cloud-erp carries two layers of auth:

1. **HMAC signature** in `X-CloudERP-Signature: t=<unixMs>,v1=<hex>`. `v1` is
   `hex(hmac-sha256(t + "\n" + body))` keyed by the per-connection
   `SHARED_SECRET` you copied from cloud-erp's credentials screen at register
   time. Reject anything older than 5 minutes.
2. **JWT bearer** in `Authorization: Bearer <jwt>`. RS256, signed by cloud-erp's
   OIDC issuer. Fetch the keys from `<CLOUD_ERP_URL>/oauth/jwks.json` (jose's
   `createRemoteJWKSet` caches them). Verify `iss === CLOUD_ERP_URL` and
   `aud === OIDC_CLIENT_ID`. Use `sub` as the calling user, `tenant_id` as
   the tenant.

Webhook deliveries from cloud-erp use HMAC only (no JWT — they're
server-to-server, not on behalf of a user).

The SDK exports `verifyProxyRequest` and `verifyJwt` wrappers.

### Request paths

| Method | Path | Sent for |
|---|---|---|
| `GET` | `/<entityKey>` | List view |
| `GET` | `/<entityKey>/:id` | Detail view |
| `POST` | `/<entityKey>` | Create |
| `PATCH` | `/<entityKey>/:id` | Update |
| `DELETE` | `/<entityKey>/:id` | Delete |
| `GET` | `/pages/<pageKey>?ticket=<jwt>` | Iframe first load (verify HS256 ticket, set session cookie) |
| `POST` | `/<eventWebhookUrl>` | Inbound event delivery |

`<entityKey>` is taken straight from the manifest's `entities[].key`. Cloud-erp
does not transform it.

### Outbound (your service → cloud-erp)

Service tokens (RS256, `aud = OIDC_CLIENT_ID`, `scope = "tenant:context tenant:events tenant:workflows"`)
let your service call cloud-erp:

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/external-service/v1/tenant/info` | Tenant id, name, branding |
| `POST` | `/api/external-service/v1/events/emit` | Emit an event into cloud-erp's bus |

The token expires; refresh via `/api/external-service/v1/auth/refresh` (TBD) or
fall back to re-registering when it expires.

## Endpoints reference

Every HTTP surface a connected service can touch. Three families:

- `/api/v1/external/*` — oRPC procedures the tenant admin uses to **manage** the
  connection (register, refresh, disconnect). Tenant-session-auth only — your
  service does not call these. Documented here for completeness.
- `/api/external-service/v1/*` — the **outbound** REST surface. Your service calls
  these from its own runtime, using the service token from the credentials screen
  as a bearer.
- `/oauth/*` — the OIDC surface. Your service calls `/oauth/jwks.json` to verify
  proxied user-on-behalf-of tokens, and `/oauth/token` (authorization code + PKCE)
  if you offer a "Sign in with cloud-erp" button.

### Outbound REST (service → cloud-erp)

Base: `POST/GET <CLOUD_ERP_URL>/api/external-service/v1/...`

Auth: `Authorization: Bearer <SERVICE_TOKEN>`. The token is the value
`serviceToken` returned by `external.connections.register` and shown on the
credentials screen. It's an RS256 JWT signed by cloud-erp's OIDC issuer with
`aud = <oidcClientId>` and `scope = "tenant:context tenant:events tenant:workflows"`.
Expires after 1h — request a fresh one by re-registering or by issuing your own
OIDC client-credentials grant.

| Method | Path | Scope | Purpose |
|---|---|---|---|
| `GET` | `/tenant/info` | `tenant:context` | Tenant id, name, branding (logo keys, trading name). |
| `POST` | `/events/emit` | `tenant:events` | Publish an event onto cloud-erp's bus. Other connectors with matching webhook patterns receive it. |
| `POST` | `/workflows/trigger` | `tenant:workflows` | Fire a synthetic event `external.<connectionId>.workflow.<workflowKey>` that tenant workflows can listen on. |

#### `GET /api/external-service/v1/tenant/info`

Returns identity + branding for the tenant the token was minted for.

```http
GET /api/external-service/v1/tenant/info HTTP/1.1
Host: cloud-erp.example
Authorization: Bearer eyJhbGciOi...
```

Response:

```json
{
  "tenantId": "8a1c…",
  "tenantName": "Acme Widgets",
  "branding": {
    "logoKey": "tenant/8a1c…/logo.png",
    "logoDarkKey": null,
    "tradingName": "Acme Widgets"
  }
}
```

Errors: `401 { "error": "invalid_token" }` if the token is missing, expired, or
the connection is disabled. `403 { "error": "insufficient_scope" }` if the token
lacks `tenant:context`.

#### `POST /api/external-service/v1/events/emit`

Request body:

| Field | Type | Notes |
|---|---|---|
| `event` | string | `/^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_.-]*$/` — must contain at least one dot (e.g. `demo.item.created`). |
| `payload` | object | Arbitrary JSON. Delivered verbatim as the webhook body to subscribers. Defaults to `{}`. |

```bash
curl -X POST https://cloud-erp.example/api/external-service/v1/events/emit \
  -H "Authorization: Bearer $SERVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "demo.item.created",
    "payload": { "sku": "WIDGET-001", "quantity": 12 }
  }'
```

Response: `200 { "ok": true }` on enqueue success (dispatch itself is async).
Errors: `400 { "error": "invalid_body" }`, `401`, `403`.

#### `POST /api/external-service/v1/workflows/trigger`

Request body:

| Field | Type | Notes |
|---|---|---|
| `workflowKey` | string | Must match `/^[a-z][a-z0-9_.-]*$/i`, 1..120 chars. |
| `payload` | object | Workflow input. Available as `event.data` in trigger conditions. |

Fire-and-forget — the response does not carry a run id. The customer's workflow
must subscribe to `external.<connectionId>.workflow.<workflowKey>` as its
trigger pattern.

Response: `200 { "ok": true, "dispatched": true }`. Errors: `400`, `401`, `403`.

### OIDC / discovery (service ↔ cloud-erp)

| Method | Path | Auth | Purpose |
|---|---|---|---|
| `GET` | `/oauth/.well-known/openid-configuration` | none | Discovery — issuer, endpoints, supported scopes. |
| `GET` | `/oauth/jwks.json` | none | RSA public keys for verifying proxied JWTs. Cache for ~5 min. |
| `GET` | `/oauth/authorize` | session cookie | Start an authorization-code + PKCE flow (for "Sign in with cloud-erp"). |
| `POST` | `/oauth/token` | client_secret_basic / _post | Exchange `code` + verifier or `refresh_token` for an access token + id token. |
| `GET` | `/oauth/userinfo` | bearer | Email + name + tenant claims for the access token's subject. |
| `POST` | `/oauth/revoke` | client_secret | Revoke a refresh token. |
| `POST` | `/oauth/introspect` | client_secret | Validity check on an opaque token reference. |

Supported scopes (from `/.well-known/openid-configuration`):
`openid`, `email`, `profile`, `module:read`, `module:write`,
`tenant:context`, `tenant:events`, `tenant:workflows`.

### Management oRPC (admin → cloud-erp)

These live on `/api/v1/external/*` via the session-cookie auth chain — they're
what cloud-erp's UI calls when a tenant admin configures the connection.
Documented here so you know what state-changing operations exist and what error
codes you may see in audit logs.

| Procedure | Action |
|---|---|
| `external.connections.list` | Enumerate all connections for the tenant. |
| `external.connections.get` | Fetch one by id. |
| `external.connections.register` | Provision OIDC client + shared secret, fetch manifest. Returns one-time secrets. |
| `external.connections.previewManifest` | Dry-run a manifest fetch without persisting. |
| `external.connections.refreshManifest` | Re-fetch + re-parse the manifest. ETags honored. |
| `external.connections.setEnabled` | Toggle the `enabled` flag (kills proxy traffic without losing credentials). |
| `external.connections.disconnect` | Deactivate the OIDC client and delete the connection row. |
| `external.consent.pending` | Show pending OIDC consent for a client_id + scopes. |
| `external.consent.approve` | Record consent so subsequent `/oauth/authorize` redirects skip the screen. |
| `external.consent.deny` | Revoke prior consent. |
| `external.pages.issueTicket` | Mint a single-use HS256 page ticket for the iframe renderer. RBAC-gated on `<moduleKey>:read`. |
| `external.oidcClients.*` | Admin-only — list, register, rotate, deactivate raw OIDC clients (used by `register` under the hood). |

### Iframe RPC (host-verbs)

The in-iframe postMessage RPC surface is **not** an HTTP endpoint — it's a
`window.postMessage` protocol. See **Host verbs** below for each verb's
parameter / return shape. Wire format:

```json
// Page → cloud-erp
{ "source": "cloud-erp-page", "type": "verb", "verb": "toast",
  "id": "<uuid>", "params": { "kind": "success", "message": "Saved" } }
// cloud-erp → page (reply)
{ "source": "cloud-erp-host", "type": "verb-reply",
  "id": "<uuid>", "ok": true, "result": null }
```

Per-call correlation by `id`. Replies arrive on the same `window.addEventListener('message', ...)`.

## Webhook events

Cloud-erp emits events onto an internal bus on every entity mutation that opts
in. A connected service subscribes by declaring an `events[]` entry in its
manifest — each entry pairs a glob pattern with a relative `webhookUrl` path
on the service.

### Subscribing

```json
"events": [
  { "pattern": "record.created", "webhookUrl": "/webhooks/cloud-erp" },
  { "pattern": "external.acme.*", "webhookUrl": "/webhooks/acme" }
]
```

Pattern matcher is intentionally strict:

- `pattern === event` → match.
- `pattern` ends with `.*` → matches `event` if `event === pattern.slice(0, -2)`
  or `event` starts with `pattern.slice(0, -2) + "."`.
- No other glob forms (no `*foo`, no `{a,b}`, no regex).

### Delivery shape

Webhook deliveries are POSTed by the BullMQ-backed worker
(`apps/backend/src/services/webhook-worker/worker.ts`). They are **HMAC-only**:
there is no JWT bearer, because the delivery is server-to-server and not on
behalf of a user.

```http
POST <serviceUrl><webhookUrl> HTTP/1.1
Content-Type: application/json
User-Agent: cloud-erp-external-webhooks/1.0
X-CloudERP-Event: record.created
X-CloudERP-Tenant-Id: <uuid>
X-CloudERP-Timestamp: 1716724800000
X-CloudERP-Signature: t=1716724800000,v1=<hex>

{ "source": "custom", "type": "record.created", "tenantId": "...",
  "moduleKey": "...", "entityKey": "...", "recordId": "...",
  "data": { ... }, "previousData": null, "userId": "...",
  "emittedAt": "2026-05-26T14:30:00.000Z" }
```

### Signing scheme

Same as the proxy: `X-CloudERP-Signature: t=<unixMs>,v1=<hex>` where `v1` is
`hex(hmac-sha256(t + "\n" + rawBody))` keyed by the connection's
`SHARED_SECRET`. Reject anything older than 5 minutes. The SDK's
`verifyProxyRequest({ secret, signatureHeader, body })` is identical to the
proxy verifier — webhook bodies sign the same way.

### Retries

5 attempts, exponential backoff starting at 2s, capped at 5 min
(~ 2s, 4s, 16s, 64s, 300s). Any non-2xx response counts as a failure. After
the 5th attempt the delivery is abandoned and the failure structurally logged.
There is no per-delivery audit table for external deliveries yet — failures
appear in backend logs under `webhook-worker: external delivery failed`.

### Known event names

The phase-2 module-events bus is shared by built-in modules and custom modules.
Today the only emitters in cloud-erp's tree are the custom-records router,
which emits:

| Event | When | Payload `data` |
|---|---|---|
| `record.created` | A row was inserted in a custom entity | Full inserted record |
| `record.updated` | A row in a custom entity was updated | New record; `previousData` carries the pre-update snapshot |
| `record.deleted` | A row in a custom entity was deleted | `null`; `previousData` carries the deleted row |

Plus events your **own** service publishes via `/events/emit` — those keep
whatever name you chose (e.g. `demo.item.created`). Other connected services
in the same tenant that subscribed to the matching pattern receive them with
the same envelope shape: cloud-erp wraps the publisher's `payload` as the
JSON body and stamps the same X-CloudERP-* headers.

Built-in modules (finance, hr, crm) do **not** yet emit named events — that's
on the roadmap. Until then, subscribing to e.g. `finance.invoice.*` produces
no deliveries.

### Synthetic workflow-trigger events

`POST /api/external-service/v1/workflows/trigger` emits
`external.<connectionId>.workflow.<workflowKey>` onto the module-events bus.
These are matched by **workflow triggers**, not by external webhook
subscriptions — they don't fan out to other connectors. Use them when you want
the customer's workflow engine to react to something happening in your service.

## Pages — sandboxed UI embed

Pages let your service render arbitrary HTML inside cloud-erp via a sandboxed
iframe. Declare them in the manifest's `pages[]` array; cloud-erp's iframe
renderer mints a short-lived ticket and loads
`<serviceUrl><page.path>?ticket=<jwt>`.

### Ticket verification

The ticket is HS256, keyed by your `SHARED_SECRET`. Use `verifyPageTicket`
from the SDK — it validates the algorithm, issuer, audience, expiration, jti
single-use (you provide the redemption store), and `page_key`. On success,
exchange it for a same-origin session cookie and 302 to the clean URL so the
ticket leaves the URL bar / referrer header.

### Iframe sandbox

Cloud-erp embeds your page with:

```
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
referrerPolicy="no-referrer"
```

`allow-same-origin` is required for your service to use its own cookies. The
cross-origin barrier between cloud-erp and your service is what protects
cloud-erp's session — sandbox flags are belt-and-suspenders.

You should set `Content-Security-Policy: frame-ancestors <cloudErpUrl>` on
your page response so other origins can't embed it.

### Native chrome (`cloudErpHost`)

Loading `<script src="<cloudErpUrl>/external-pages/host.js"></script>` in your
page exposes `window.cloudErpHost` — a small set of verbs for asking
cloud-erp's parent window to do things its chrome is good at (toasts, modals,
navigation). Each verb returns a `Promise` that resolves to the verb's
declared return type.

Same wire protocol is available via the SDK's `createHostClient()` if you
prefer typed imports.

### Host verbs

| Verb | Returns | Summary |
|---|---|---|
| `toast` | `void` | Show a toast notification in cloud-erp's chrome (escapes the iframe). |
| `confirm` | `boolean` | Open a modal AlertDialog in cloud-erp's chrome and resolve to true/false. Modal escapes the iframe — sized to the cloud-erp viewport, not the page. |
| `navigate` | `void` | Push an in-app route on cloud-erp's router. Path must start with "/" and cannot point at an external origin. |
| `requestUser` | `object` | Get the calling user's identity (id, name, email, locale, role). The service can already trust `tenant_id` + `sub` from the JWT — this is the display layer (full name, email, locale) that the JWT does not carry. |
| `openSheet` | `object` | Open a right-hand side sheet containing another sandboxed iframe pointed at the SAME service. Resolves when the sheet closes. Trying to point at a different origin is rejected. |

### `toast`

Show a toast notification in cloud-erp's chrome (escapes the iframe).

**Parameters**

| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | `"default"` \| `"success"` \| `"info"` \| `"warning"` \| `"error"` | yes | default `"default"` |
| `message` | string | yes | minLen 1, maxLen 500 |
| `description` | string | no | maxLen 1000 |
| `durationMs` | integer | no | min 500, max 60000 |

**Example**

```ts
await cloudErpHost.toast({ kind: 'success', message: 'Calculation saved' })
```

### `confirm`

Open a modal AlertDialog in cloud-erp's chrome and resolve to true/false. Modal escapes the iframe — sized to the cloud-erp viewport, not the page.

**Parameters**

| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | minLen 1, maxLen 200 |
| `body` | string | no | maxLen 2000 |
| `confirmLabel` | string | no | maxLen 40 |
| `cancelLabel` | string | no | maxLen 40 |
| `destructive` | boolean | no | — |

**Example**

```ts
const ok = await cloudErpHost.confirm({
  title: 'Delete calculation?',
  body: 'This cannot be undone.',
  destructive: true,
})
```

### `navigate`

Push an in-app route on cloud-erp's router. Path must start with "/" and cannot point at an external origin.

**Parameters**

| Field | Type | Required | Notes |
|---|---|---|---|
| `path` | string | yes | minLen 1, maxLen 2000, pattern `^\/[a-zA-Z0-9._~!$&'()*+,;=:@%/?#-]*$` |

**Example**

```ts
await cloudErpHost.navigate({ path: '/hr/employees/12' })
```

### `requestUser`

Get the calling user's identity (id, name, email, locale, role). The service can already trust `tenant_id` + `sub` from the JWT — this is the display layer (full name, email, locale) that the JWT does not carry.

**Parameters**

| Field | Type | Required | Notes |
|---|---|---|---|

**Example**

```ts
const user = await cloudErpHost.requestUser()
greet(`Hi ${user.name ?? user.email ?? 'there'}`)
```

### `openSheet`

Open a right-hand side sheet containing another sandboxed iframe pointed at the SAME service. Resolves when the sheet closes. Trying to point at a different origin is rejected.

**Parameters**

| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | minLen 1, maxLen 200 |
| `contentUrl` | string | yes | minLen 1, maxLen 2000, pattern `^(\/[^\s]*|https?:\/\/[^\s]+)$` |
| `widthPx` | integer | no | min 280, max 1400 |

**Example**

```ts
const { closed, result } = await cloudErpHost.openSheet({
  title: 'Edit related record',
  contentUrl: '/pages/edit-related?id=42',
})
```

## SDK reference

```bash
bun add @cloud/external-service-sdk
# or: pnpm add @cloud/external-service-sdk
```

The package re-exports everything from `hmac`, `jwt`, `manifest`,
`page-ticket`, `host-api`, and `types`. Tree-shake-friendly; the host-API
runtime helper is the only part that touches `window`.

### `verifyProxyRequest(opts) → { ok: true } | { ok: false, reason }`

Verify the `X-CloudERP-Signature` header on an inbound proxy or webhook
request. Returns a discriminated union, never throws.

```ts
import { verifyProxyRequest } from '@cloud/external-service-sdk'

const body = await req.text()
const result = verifyProxyRequest({
  secret: process.env.SHARED_SECRET!,
  signatureHeader: req.headers.get('x-clouderp-signature') ?? '',
  body,
  maxAgeMs: 5 * 60 * 1000, // optional, default 5 min
})
if (!result.ok) return new Response(`bad signature: ${result.reason}`, { status: 401 })
```

Possible `reason` values: `missing_timestamp`, `missing_signature`,
`expired`, `length_mismatch`, `mismatch`.

### `verifyJwt(opts) → Promise<Record<string, unknown>>`

Verify a JWT against a remote JWKS endpoint. The same JWKS resolver is reused
across calls per `jwksUrl` so the keys are fetched once and refreshed by jose
on its own schedule.

```ts
import { verifyJwt } from '@cloud/external-service-sdk'

const claims = await verifyJwt({
  token: bearer,
  jwksUrl: `${CLOUD_ERP_URL}/oauth/jwks.json`,
  issuer: CLOUD_ERP_URL,
  audience: OIDC_CLIENT_ID,
})
const userId = claims.sub as string
const tenantId = claims.tenant_id as string
```

### `verifyPageTicket(opts) → Promise<VerifyPageTicketResult>`

Verify the `?ticket=<jwt>` query param on the first hit to a page route.
Single-use is enforced by your service via the `isAlreadyRedeemed` /
`markRedeemed` callbacks (use Redis or a small in-memory cache for at least
60s — the ticket TTL).

```ts
import { verifyPageTicket } from '@cloud/external-service-sdk'

const result = await verifyPageTicket({
  ticket,
  sharedSecret: SHARED_SECRET,
  cloudErpUrl: CLOUD_ERP_URL,
  oidcClientId: OIDC_CLIENT_ID,
  pageKey: 'calculator',
  isAlreadyRedeemed: async (jti) => redeemed.has(jti),
  markRedeemed: async (jti, exp) => { redeemed.set(jti, exp) },
})
if (!result.ok) return new Response(`ticket: ${result.reason}`, { status: 401 })
const { sub, tenant_id, page_key } = result.claims
```

Possible `reason` values: `verify_failed` (signature or claims invalid),
`missing_claims`, `wrong_page` (ticket was for a different page key),
`already_redeemed`.

### `defineManifest(input) → Manifest`

Tiny convenience that fills in default `scopes`, empty arrays for nav /
entities / events / pages, and the always-empty `actions` field. No runtime
validation — use `manifestZ.safeParse` (the cloud-erp side) if you want that.

```ts
import { defineManifest, entity, field } from '@cloud/external-service-sdk'

export const manifest = defineManifest({
  moduleKey: 'demo',
  displayName: 'Demo',
  version: '1.0.0',
  entities: [
    entity({
      key: 'item',
      label: 'Item',
      labelPlural: 'Items',
      fields: [
        field({ key: 'sku', label: 'SKU', type: 'text', required: true }),
      ],
      listView: { columns: ['sku'] },
    }),
  ],
})
```

`entity(def)` and `field(def)` are identity helpers — they exist only for
inference / linting; the value passes through unchanged.

### `createHostClient(opts?) → CloudErpHostClient`

Build a typed client for the host-verb postMessage protocol from inside an
iframe. Returns one method per entry in `HOST_VERBS`, each typed against its
declared param + return Zod schema.

```ts
import { createHostClient } from '@cloud/external-service-sdk'

const host = createHostClient({ parentOrigin: 'https://cloud-erp.example' })
await host.toast({ kind: 'success', message: 'Saved' })
const user = await host.requestUser()
```

If you load `<script src="<cloudErpUrl>/external-pages/host.js"></script>` in
the page, `window.cloudErpHost` is pre-wired with the same wire protocol —
you only need `createHostClient` for typed imports in bundled code.

### `HOST_VERBS`

The Zod registry powering both the typed client and these docs. Importing it
lets you reflect on verb names + schemas from your own code (e.g. to expose a
debug panel).

### Types: `Manifest`, `EntityDef`, `FieldDef`, `NavItem`, `PageDef`, `EventSub`, `FieldType`

Hand-rolled TypeScript shapes mirroring the manifest Zod schema. Use them in
your service for autocomplete; the authoritative validator is `manifestZ` on
the cloud-erp side.

## Lifecycle

| State | Set by | Meaning |
|---|---|---|
| `pending` | initial register | Manifest fetch hasn't completed yet |
| `connected` | manifest fetched + parsed | Healthy; proxied traffic flows |
| `error` | manifest fetch failed | Re-fetch via Manifest tab → Refresh |
| `disabled` | tenant admin toggled it off | No traffic; record retained |

Disconnect deactivates the OIDC client and removes the connection row. Your
service should observe a flurry of JWT failures on outstanding requests and
then nothing.

## Security model

- **Multi-tenant isolation** is your responsibility. Every authenticated request
  carries a `tenant_id` claim — you must filter every query by it. The SDK
  helpers don't do this for you.
- **SSRF**: cloud-erp validates webhook URLs and manifest URLs at write time
  *and* re-validates at delivery time, blocking loopback / private / metadata
  addresses. For local dev, set `SSRF_ALLOWED_HOSTS=localhost,127.0.0.1` on
  cloud-erp.
- **Tickets are single-use**. Track redeemed `jti`s for at least the ticket
  TTL (60 s) on the service side — `verifyPageTicket` accepts an
  `isAlreadyRedeemed`/`markRedeemed` pair for this.
- **Frame-ancestors**: set CSP on your page responses so only the cloud-erp
  origin can embed them. Otherwise any site could iframe your page (with a
  ticket they don't have, but still).
- **Disconnect**: cloud-erp deactivates the OIDC client when the connection is
  removed. Your JWT verification will start failing — treat that as expected
  end-of-life and stop accepting requests on that connection.

### Outbound API authentication (your service → cloud-erp)

Three credential shapes can authenticate a request from your service back to
cloud-erp. Pick by the kind of caller:

#### Service token (RS256 bearer)

The credential you get on the **credentials screen** at register time. It's
an OIDC access token minted with:

- `alg`: `RS256` (signed by the active cloud-erp signing key)
- `iss`: `<CLOUD_ERP_URL>` (matches `/oauth/.well-known/openid-configuration` → issuer)
- `aud`: your `oidcClientId`
- `sub`: the connection id (not a user id — there is no user behind it)
- `scope`: `tenant:context tenant:events tenant:workflows`
- `tenant_id`: the tenant the connection lives in
- `exp`: 1h after `iat`

Use it as `Authorization: Bearer <SERVICE_TOKEN>` against
`/api/external-service/v1/*`. Cloud-erp verifies the signature against
`/oauth/jwks.json`, loads the connection row by `sub`, and rejects if the
connection is disabled or missing.

Refresh: the public **client_credentials** grant for this token shape is not
exposed yet. For now, when the token expires, either re-register (rotates
`sharedSecret` + `serviceToken` and shows them on a one-time screen) or have
your service implement the OIDC client_credentials flow against `/oauth/token`
using the `oidcClientId` + `oidcClientSecret` you also got on the credentials
screen.

#### OIDC user tokens (RS256 bearer, on behalf of a user)

When your service is the **caller** acting on behalf of a logged-in cloud-erp
user — for example, your service's "Sign in with cloud-erp" button — run a
standard authorization-code + PKCE flow:

1. Redirect to `<CLOUD_ERP_URL>/oauth/authorize?response_type=code&client_id=<id>&redirect_uri=<uri>&scope=openid+profile+email+module:read&code_challenge=<S256>&code_challenge_method=S256&state=<s>`.
2. Exchange the returned `code` at `POST /oauth/token` with
   `grant_type=authorization_code` + `code_verifier`.
3. Use the resulting `access_token` against `/oauth/userinfo` or your own
   service token-aware endpoints.

The returned bundle includes `access_token`, `id_token`, and `refresh_token`
(rotation enforced — re-using a refresh token invalidates it). Tokens expire
after 1h; refresh via `POST /oauth/token` with `grant_type=refresh_token`.

#### Proxied user token (incoming from cloud-erp)

You **receive** these — you don't mint them. Every request hitting your
service via the proxy carries an RS256 JWT in `Authorization: Bearer`. Same
shape as the OIDC user token above, with:

- `aud`: your `oidcClientId` (NOT `*` — exact match)
- `sub`: cloud-erp user id
- `tenant_id`: which tenant
- `scope`: `module:read module:write`

Verify with `verifyJwt({ token, jwksUrl, issuer, audience })`. The SDK caches
the JWKS resolver, so repeat calls don't refetch.

#### HMAC signature (alongside the bearer)

Every proxied user request **also** carries an `X-CloudERP-Signature` header
keyed by the per-connection `SHARED_SECRET`. Verify both — the HMAC proves
"this request came from cloud-erp's proxy" while the JWT proves "and these are
the user's claims". If only one is present, the request is invalid.

Same scheme for webhook deliveries (no JWT, since there is no calling user).

#### Signed page ticket (HS256, single-use)

The `?ticket=<jwt>` on the first request to a page route is an **HS256** JWT
keyed by your `SHARED_SECRET` (not the OIDC keys — it's symmetrically
signed). Claims:

- `iss`: `<CLOUD_ERP_URL>`
- `aud`: your `oidcClientId`
- `sub`: the user id
- `tenant_id`, `connection_id`, `page_key`
- `scope`: `page:render`
- `jti`: one-time identifier — single-use enforcement is **on the service
  side**, via `verifyPageTicket`'s `isAlreadyRedeemed`/`markRedeemed`
  callbacks
- `iat`, `exp`: 60 seconds TTL

Exchange the ticket for your own session cookie on first load, then redirect
to the clean URL so the ticket is removed from the URL bar and Referer.

#### Rate limits

The outbound REST surface (`/api/external-service/v1/*`) has **no separate
rate limit headers** today — it inherits whatever the OS / load balancer
enforces. The customer-facing `/api/public/v1/*` surface (a different API,
documented in `docs/public-api-smoke-test.md`) does have
`X-RateLimit-Limit` / `-Remaining` / `-Reset` headers and a per-key
`Retry-After`; do **not** assume the external-service surface emits the
same headers.

## Error codes

The proxy, outbound REST surface, page-ticket flow, and management oRPC routes
each surface errors a connected service might encounter. Codes are returned as
HTTP status + JSON body `{ "error": "<code>", … }` (REST surface) or as an
oRPC `ORPCError` with the code as its name (management surface). The viewer
in cloud-erp's UI shows the raw oRPC code.

### Outbound REST (`/api/external-service/v1/*`)

| Code | HTTP | Where | Meaning + recovery |
|---|---|---|---|
| `invalid_token` | 401 | all routes | Bearer missing, JWT failed to verify, tenant unresolved, or connection disabled / deleted. Re-register or rotate the OIDC secret. |
| `insufficient_scope` | 403 | all routes | Token does not carry the scope required by the route. Mint a new token with the right scope. |
| `invalid_body` | 400 | `/events/emit`, `/workflows/trigger` | Body wasn't valid JSON, or failed the Zod schema. The response includes `issues[]` with the path + message. |

### Proxy (`/api/v1/external-proxy/<connectionId>/*`)

| Code | HTTP | Meaning + recovery |
|---|---|---|
| `unauthenticated` | 401 | Session cookie missing or tenant context unresolved. The end user must sign in again. |
| `connection_unavailable` | 503 | Connection deleted, disabled, or not in `connected` status. Check the tenant admin's Settings → Modules panel. |
| `manifest_missing` | 503 | Connection row has no cached manifest (still in `pending`). Hit Refresh manifest. |
| `forbidden` | 403 | RBAC denied — user lacks `external.<moduleKey>:<action>`. |
| `service_unavailable` | 503 | The circuit breaker tripped open after 50 failures in a 5-min window. Auto-resets after 60s. |
| `upstream_unavailable` | 502 | The upstream service errored or timed out (30s). Check your service's logs. |

### Management oRPC (`external.*`)

| Code | Where | Meaning |
|---|---|---|
| `FORBIDDEN` | `connections.*`, `pages.issueTicket` | Caller lacks `external.connection:manage` or `<moduleKey>:read`. |
| `UNAUTHORIZED` | `connections.*` | Tenant context required (no `oid` cookie). |
| `NOT_FOUND` | `connections.get`, `refreshManifest`, `disconnect` | Connection id not present in this tenant. |
| `NOT_FOUND` (message `connection_not_found`) | `pages.issueTicket` | Connection id unknown. |
| `NOT_FOUND` (message `connection_disabled`) | `pages.issueTicket` | Connection is not `enabled` + `status=connected`. |
| `NOT_FOUND` (message `page_not_in_manifest`) | `pages.issueTicket` | Requested `pageKey` is not declared in the cached manifest. |
| `NOT_FOUND` (message `Client not found or inactive`) | `consent.*` | OIDC client_id not registered or marked inactive. |
| `INTERNAL_SERVER_ERROR` (message `manifest_missing_moduleKey`) | `pages.issueTicket` | Manifest cached but `moduleKey` is missing — shouldn't happen post-validation. Refresh the manifest. |
| `BAD_REQUEST` (`redirect_uri is not a valid URL`) | `oidcClients.register`, `connections.register` | A passed redirect URI failed `new URL()` parsing. |
| `BAD_REQUEST` (`redirect_uri must be HTTPS`) | same | Non-localhost redirect URIs must be HTTPS. |
| `CONFLICT` (`client_id already registered`) | `oidcClients.register` | The `preferredClientId` is taken. Drop it or pick a different one. |
| `NOT_FOUND` (`Client not found`) | `oidcClients.{get,rotate,deactivate}` | client_id not in the control DB. |

### OAuth (`/oauth/*`)

Standard OIDC error codes, returned as `{ "error": "<code>" }`:

| Code | HTTP | Meaning |
|---|---|---|
| `invalid_client` | 401 | client_id / secret invalid, or client deactivated. |
| `invalid_grant` | 400 | Authorization code consumed, redirect_uri mismatch, PKCE verifier mismatch, refresh token replayed, or tenant deleted. |
| `unsupported_grant_type` | 400 | Only `authorization_code` and `refresh_token` are accepted. |
| `unsupported_response_type` | 400 | `/authorize` only accepts `response_type=code`. |
| `invalid_request` | 400 | Missing required param, or `code_challenge_method` ≠ `S256` (PKCE is mandatory). |
| `invalid_redirect_uri` | 400 | `redirect_uri` is not in the client's registered list. |
| `invalid_scope` | 400 | A requested scope is not in the client's `scopesAllowed`. |
| `invalid_token` | 401 | Bearer to `/userinfo` failed to verify or the subject is gone. |

### Page tickets (verify side)

`verifyPageTicket` returns `{ ok: false, reason }`. Possible reasons:

| Reason | Meaning + recovery |
|---|---|
| jose error message | Signature, issuer, audience, or algorithm rejected. Confirm `SHARED_SECRET` and `OIDC_CLIENT_ID` match the credentials screen. |
| `missing_claims` | One of `sub`, `aud`, `iss`, `tenant_id`, `page_key`, `connection_id`, `jti`, `iat`, `exp` was absent. The ticket is invalid; do not retry. |
| `wrong_page` | `page_key` claim does not equal the `pageKey` you passed in. The ticket was issued for a different page. |
| `already_redeemed` | Your `isAlreadyRedeemed` callback returned true. Reject — replay attempt or duplicate browser load. |

### Manifest fetcher

When cloud-erp pulls `/manifest`, the `lastError` column on the connection
row captures the failure for the admin UI. Common values:

| `lastError` prefix | Cause |
|---|---|
| `SSRF: ...` | The manifest URL pointed at loopback / private / metadata IP and `SSRF_ALLOWED_HOSTS` didn't whitelist it. |
| `URL validation failed` | Manifest URL was malformed. |
| `HTTP 4xx` / `HTTP 5xx` | Upstream returned non-2xx. |
| `Manifest exceeds 1 MB` | Hard cap — trim your manifest. |
| `Invalid JSON` | `/manifest` didn't return parseable JSON. |
| Zod issue paths (`entities.0.key: ...`) | Manifest didn't match `manifestZ`. The first three issues are surfaced. |

## Glossary

- **connection** — A row in cloud-erp's `external.connection` table linking
  a tenant to your service. Created by `external.connections.register`.
  Holds the manifest URL, cached manifest JSON, OIDC client id, encrypted
  shared secret, and `status` (`pending` / `connected` / `error` /
  `disabled`).

- **manifest** — JSON your service serves at `GET /manifest`. Validated by
  `manifestZ` on the cloud-erp side. Defines `moduleKey`, entities, nav,
  pages, and event subscriptions.

- **moduleKey** — Stable identifier for what your service exposes. Lowercase
  alphanumeric / dashes; 2..64 chars; `^[a-z][a-z0-9-]{1,63}$`. Acts as the
  RBAC `moduleKey` (`external.<moduleKey>:read` etc.) and as the sidebar
  group name.

- **OIDC client** — A row in cloud-erp's control DB representing your
  service's identity as an OAuth2 / OIDC client. Created by
  `external.oidcClients.register` (directly) or
  `external.connections.register` (under the hood). Identified by
  `clientId` + hashed `clientSecret`; carries an allowlist of redirect URIs
  and scopes.

- **shared secret** — Per-connection symmetric key. Used to HMAC-sign
  proxied requests + webhook deliveries (server-to-server) and to HS256-sign
  page tickets. 32 random bytes, base64url-encoded. Stored encrypted at rest
  via the same control-DB encryption key as tenant DB URLs.

- **service token** — The RS256 OIDC access token shown on the credentials
  screen. Lets your service call `/api/external-service/v1/*` for 1h.
  Scope: `tenant:context tenant:events tenant:workflows`.

- **host-verb** — One operation your iframe can ask cloud-erp's chrome to
  perform (toast, confirm, navigate, requestUser, openSheet). Each verb has
  a Zod-defined param shape and return shape. Wire format is postMessage
  with per-call `id` correlation.

- **page ticket** — Short-lived (60s) HS256 JWT keyed by the shared secret,
  attached as `?ticket=<jwt>` on the first iframe load. Carries the user
  + tenant + page identity. Single-use — your service must record the
  `jti` after redemption.

- **jti** — JWT ID claim. RFC 7519 random identifier; used here to make
  page tickets single-use.

- **scope** — Space-separated list of capabilities a token bears. OIDC user
  scopes (`openid`, `email`, `profile`, `module:read`, `module:write`)
  govern proxied-user tokens. Service scopes (`tenant:context`,
  `tenant:events`, `tenant:workflows`) govern `/api/external-service/v1/*`.

- **signature (HMAC)** — `X-CloudERP-Signature: t=<unixMs>,v1=<hex>` where
  `v1` is hex(hmac-sha256(`<t>\n<body>`)) keyed by the shared secret. 5-min
  freshness window.

- **proxy** — The Hono sub-app at `/api/v1/external-proxy/<connectionId>/*`
  that forwards user requests to your service after RBAC + circuit-breaker
  checks, attaching both HMAC + JWT auth headers.

- **circuit breaker** — Redis-backed counter (50 failures / 5 min) that
  opens for 60s and short-circuits proxy calls to a misbehaving service.

- **event subscription** — A row in `external.event_subscription` derived
  from the manifest's `events[]`. Pairs a pattern with a webhookUrl on the
  service. Active subscriptions receive any matching event the tenant's bus
  carries.

- **SSRF allowlist** — `SSRF_ALLOWED_HOSTS` env var on cloud-erp. Hostnames
  in this list are exempt from the default loopback / private / metadata-IP
  block applied to manifest URLs + webhook URLs.

- **tenant** — One cloud-erp customer organization. Has its own Postgres DB.
  All claims (`tenant_id`, `tenant_slug`, `tenant_role`) reference this id.

- **module manifest cache** — `external.connection.manifestCachedJson` —
  the last successfully-parsed manifest. `refreshManifest` re-pulls it;
  ETags are honored when the previous fetch returned one.

- **host.js** — `<CLOUD_ERP_URL>/external-pages/host.js` — the parent-side
  postMessage proxy that exposes `window.cloudErpHost` inside your page.

- **theme.css** — `<CLOUD_ERP_URL>/external-pages/theme.css` — design tokens
  + base CSS that match cloud-erp's chrome. Including it makes your page look
  native.

- **issuer** — Cloud-erp's OIDC issuer URL — equal to the cloud-erp base URL.
  Discovery lives at `<issuer>/oauth/.well-known/openid-configuration`.

## Troubleshooting

Real errors from the codebase + how to clear them. Match the symptom to the
section, not the wording — error strings change.

### Manifest stays in `error` status with `SSRF: ...`

`fetchManifest` rejected your URL because it resolved to a loopback /
private / metadata IP. For local dev set
`SSRF_ALLOWED_HOSTS=localhost,127.0.0.1` on cloud-erp's `.env`, restart the
backend, click **Refresh manifest** in the Settings → Modules panel.

### Manifest stays in `error` with a Zod issue path

The manifest didn't match `manifestZ`. The first three issue paths are
surfaced — e.g. `entities.0.fields.0.type: Invalid enum value`. Cross-check
against the **Manifest** section above for the exact shape, and against
**Field types** for legal `type` values.

### Proxy returns 503 `connection_unavailable`

The connection is either missing, marked `enabled=false`, or its `status` is
not `connected`. Open the Connections list and either re-enable the toggle
or click Refresh manifest. If the status is `error`, fix the manifest first.

### Proxy returns 503 `service_unavailable`

Circuit breaker tripped — your service returned 50 failed responses in 5
minutes. Cloud-erp won't proxy to it for 60 seconds. Look at your service's
logs for the underlying failures, fix them, wait out the cooldown.

### Proxy returns 502 `upstream_unavailable`

`fetch` to your service either errored (DNS, connect, TLS) or timed out
(30 s). Check your service is up + reachable from the cloud-erp host on the
declared `serviceUrl`.

### Service receives a request without `X-CloudERP-Signature`

The proxy always sets the header. If it's missing, you're being hit
**directly** — not via the proxy. Tighten your inbound firewall, or reject
any request lacking the header.

### `verifyJwt` throws "JWT expired"

Cloud-erp's access tokens live 1h. The proxy mints a fresh one per request,
so this shouldn't happen for proxied user requests — but it can happen if
you've stashed an old service token in env. Re-register the connection (it
shows a fresh `serviceToken` on the one-time credentials screen) or call
`/oauth/token` with `grant_type=client_credentials` if you've wired that up.

### Page iframe blank, console says CSP blocked

Either your page response is missing
`Content-Security-Policy: frame-ancestors <CLOUD_ERP_URL>`, or the cloud-erp
origin you set in CSP doesn't match the real one. Inspect the response
headers on the iframe URL; fix CSP, reload.

### `verifyPageTicket` returns `reason: 'already_redeemed'`

The user reloaded an iframe that was already redeemed once. Either issue a
new ticket via `external.pages.issueTicket` (cloud-erp's UI does this on
re-mount) or, if you keep your own page session, accept the existing session
cookie instead of re-verifying.

### `/oauth/token` returns `invalid_grant` on refresh

Refresh tokens are **rotated** — every successful refresh invalidates the
prior one. If you accidentally replay an already-used refresh token, the
chain is permanently dead. Run the user through the authorization-code flow
again to mint a fresh token family.

### Webhook delivery never arrives

In order of likelihood: (1) `REDIS_URL` not set on cloud-erp — the queue is
no-op without Redis. (2) Your `webhookUrl` path didn't survive the SSRF
check at delivery time. (3) Your service returned non-2xx for 5 attempts
and the delivery was abandoned (look for `webhook-worker: external delivery
failed` in cloud-erp logs). (4) Your manifest's `events[]` pattern didn't
actually match the event name (the matcher is strict — see **Webhook events
→ Subscribing**).

### "Connect" returns `CONFLICT: client_id already registered`

You passed `preferredClientId` and that id is taken. Drop the field (one
will be generated as `ext-<random>`) or pick a different id.

### `requestUser` host-verb returns null email

The user's `email` column in the tenant DB is null (rare — admin-provisioned
accounts). Fall back to `name` or `id`. `requestUser` never throws for a
present session.

### Iframe loads but `cloudErpHost.toast` does nothing

Either (a) `host.js` wasn't loaded — verify the `<script>` tag is reachable
from your page origin, or (b) you opened the page outside cloud-erp (the
postMessage parent isn't listening). Page rendering requires the ticket
flow — opening the URL directly produces an iframe-less HTML response with
no host bridge.
