Skip to content

Latest commit

 

History

History
886 lines (651 loc) · 31.1 KB

API.md

File metadata and controls

886 lines (651 loc) · 31.1 KB

keeplane API reference

Every HTTP surface keeplane serves: which listener, who may call it, the request and response bodies, and the error codes.

Every route is POST, including the reads. Identifiers in a URL path end up in access logs, proxy logs and shell history; in a request body they do not. The two exceptions are the request path itself (/p/…, which mirrors the provider's own method) and the browser-facing connect pages.

Bodies are JSON. Content-Type: application/json on every request that has one.


Contents


1. The listeners

Listener Default Audience Authentication
operator API 127.0.0.1:8079 /admin/v1 operators, and operator tooling bootstrap token, mTLS subject, OIDC — §3.1
tenant API the same listener, /v1/tenant tenants' own front ends a caller token, and never an operator identity — §7.1
proxy 127.0.0.1:8081 tenants' applications caller token, mTLS subject, unix peer uid, loopback — §4.1
connect public 127.0.0.1:8080 tenants' browsers the one-time state in the link — §5
credential-core run/credcore.sock other keeplane services unix peer uid, or mTLS — §6.1
catalog not served by default other keeplane services mTLS or peer uid — §6.2
connect service not served by default other keeplane services mTLS or peer uid — §6.3

The last three are peer surfaces. In a single-binary deployment they are in-process and no listener is opened; in a split deployment each node dials the ones it needs — see DEPLOYMENT.md.


2. Errors

Every keeplane refusal is the same envelope:

{ "error": { "code": "unknown_route", "message": "…" } }

Responses from the request path also carry a header naming keeplane as the refuser, so a client can tell a keeplane refusal from a provider's without parsing a body:

X-Keeplane-Refused: unknown_route

A control-plane refusal is never dressed up as a provider response. When keeplane refuses, the upstream body is absent and the code says so. When the provider refuses, you get the provider's own bytes and status, unmodified.

Codes are stable. They may be added to, never renamed, so a caller may branch on them.


3. Operator API — /admin/v1

Default 127.0.0.1:8079. Served only when the deployment has operators or the listener is loopback; otherwise the surface is absent and every route is a 404 — a deployment with no operators and a public listener serves nothing rather than serving something unauthenticated.

3.1 Authentication

Three mechanisms, tried in order — strongest evidence first. The first that identifies a subject wins, and a subject with no enabled operator row is a refusal, not a fall-through to the next one.

Mechanism Subject form How
mTLS cert:CN the client certificate's subject common name. Needs [api.listen] tls_cert — see §3.11
OIDC oidc:sub Authorization: Bearer <jwt>, verified against the configured issuer and audience
static token token:NAME Authorization: Bearer $KEEPLANE_ADMIN_TOKEN, compared in constant time. [api] static_token_name names the subject; the token itself is never in the config file

Ahead of all three, and only while no operator has ever existed, the bootstrap token grants admin as uid:N: Authorization: Bearer $(cat admin-bootstrap.token), loopback only. Adding the first operator ends it in the same process, with no restart.

There is no unix-uid mechanism. api listens on TCP, TCP carries no peer credential, and a rung that trusted a loopback address alone would grant admin to anything that could open a socket. uid:N survives only as the subject the bootstrap grant records.

An unauthenticated request:

{"error":{"code":"unauthenticated","message":"adminapi: unauthenticated: this deployment has no operators yet. Present the bootstrap token from admin-bootstrap.token: …"}}
401

3.2 Roles and capabilities

An operator holds one role. A route requires one capability.

Capability viewer editor security admin
read ✅ ✅ ✅ ✅
plan ✅ ✅ ✅
apply ✅ ✅
approve ✅ ✅
revoke ✅ ✅
destroy ✅ ✅
manage_callers ✅ ✅
manage_pki ✅ ✅
manage_operators ✅

security is deliberately not a superset of editor. It can revoke and destroy but not apply configuration; an editor can apply but cannot destroy. Neither can grant privilege — only admin manages operators.

3.3 Identity

POST /admin/v1/whoami

Any authenticated identity. Requires no capability.

curl -s -X POST http://127.0.0.1:8079/admin/v1/whoami \
     -H "Authorization: Bearer $(cat admin-bootstrap.token)" \
     -H 'Content-Type: application/json' -d '{}'
{"whoami":{"subject":"uid:501","role":"admin",
 "capabilities":["apply","approve","destroy","manage_callers","manage_operators",
                 "manage_pki","plan","read","revoke"],
 "implicit":true}}

implicit: true means the identity came from the bootstrap grant rather than an operator row.

POST /admin/v1/health

Unauthenticated by design — a load balancer has no operator identity and needs none to ask whether the process is up. It reports nothing about configuration.

{"status":"ok"}

3.4 Operators — manage_operators

POST /admin/v1/operators · read

{}  →  {"operators":[{"subject":"uid:501","role":"admin","disabled":false, …}]}

POST /admin/v1/operators/add · manage_operators

{"operator":{"subject":"cert:api-node","role":"editor","display_name":"CI"}}
{"operator":{"subject":"cert:api-node","role":"editor", …}}

Subject schemes: uid:N, cert:CN, oidc:SUB, token:NAME. Roles: viewer, editor, security, admin.

Adding the first operator ends the bootstrap grant permanently — including if that operator is later disabled.

POST /admin/v1/operators/disable · manage_operators

{"subject":"cert:api-node","disabled":true}  →  {}

Reversible. The audit trail keeps the row.

3.5 Configuration — plan and apply

POST /admin/v1/config/plan · plan

Changes nothing. Returns the diff applying the document would produce.

{"document":"<the raw file bytes>","prune":false}
{"plan":{"digest":"…","changes":[{"class":"EGRESS","summary":"…"}, …]}}

document is sent verbatim, not parsed client-side, so the server validates what the operator actually wrote.

Change classes: ordinary, EGRESS, PERMISSION, DESTRUCTIVE. The plan digest is computed over the changes, canonically sorted — not over the file — so reformatting does not invalidate an approval.

POST /admin/v1/config/apply · apply

{"document":"…","approvals":[{"class":"EGRESS","digest":"…","by":"ops@example"}],"prune":false}
{"result":{"applied":true,"changes":[…]}}

A change class that requires approval and has none is refused, naming the class and the digest to approve.

3.6 Connections

POST /admin/v1/connections · read

{"filter":{"tenant":"hdfc","status":"active"}}
{"connections":[{"tenant":"hdfc","ref":"airtable","status":"active", …}]}

An operator holding more than one tenant must name one: an unfiltered read would return every tenant's connections, and the refusal names the tenants they may ask about.

POST /admin/v1/connections/revoke · revoke

{"tenant":"hdfc","ref":"airtable","reason":"offboarded"}  →  {}

Ends both the credential and the connection: the credential stops working, and the dashboard says why.

3.7 Keys and destruction — destroy

POST /admin/v1/keys/rotate

{"tenant":"hdfc","scope":"tenant-kek","approvals":[…]}  →  {}

scope is tenant-kek or root. Re-wraps; no plaintext is touched.

POST /admin/v1/credentials/shred

{"tenant":"hdfc","ref":"airtable","version":1,"approvals":[…]}  →  {}

Irreversible. Cryptographic erasure of one version.

The api role holds credential-core's admin role, which does not carry Unwrap. No route here returns decrypted material, and none can.

3.8 Audit — read

POST /admin/v1/audit/search

{"query":{"tenant":"hdfc","action":"unwrap","since":"24h","limit":100}}
{"page":{"entries":[…],"next_before":"…"}}

Keyset paging, not offset: next_before from one page is before on the next. The read itself is audited — it goes through the trail.

POST /admin/v1/audit/export

Streams. Returns ndjson or csv rather than a JSON envelope, so it is not a handle() route and writes its own body. Flat memory whatever the size.

{"query":{"tenant":"hdfc","since":"30d"},"format":"ndjson"}

POST /admin/v1/audit/verify

{}  →  {"result":{"ok":true,"checkpoints":12, …}}

Walks the signed checkpoint chain.

3.9 Callers

POST /admin/v1/callers · read

{"tenant":"hdfc"}  →  {"callers":[{"id":"…","name":"crm","refs":["airtable"], …}]}

POST /admin/v1/callers/issue · manage_callers

{"tenant":"hdfc","name":"crm","refs":["airtable","asana"]}
{"issued":{"id":"1180f1576ab210be","token":"kpc_1180f1576ab210be_yHx2…"}}

The token exists outside the proxy's hash at this one moment. Nothing stores it; nothing can show it again.

tenant: "*" issues a delegating caller — one that names the tenant it acts for on each request. refs is then required. See §4.2.

POST /admin/v1/callers/revoke · revoke or manage_callers

{"id":"1180f1576ab210be"}  →  {}

Either capability is accepted: a security operator responding to a leaked token should not have to wait for an editor. Takes effect within one refresh interval.

3.10 PKI

POST /admin/v1/pki/certs · read

{}  →  {"certs":[{"file":"proxy-node.crt","identity":"proxy-node","kind":"client","expires":"…","status":"ok"}]}

POST /admin/v1/pki/issue · manage_pki

{"spec":{"identity":"proxy-node","kind":"client","hosts":[],"days":365}}
{"issued":{"cert":"pki/proxy-node.crt","key":"pki/proxy-node.key"}}

Returns paths, never PEM. The private key is written with mode 0600 by the process that generated it and does not travel.

3.11 Admin UI

A server-rendered console, in the same process and on the same port as /admin/v1. Same identities, same capability checks, no separate credential. CSRF is double-submit with no session store, and the pages carry no JavaScript (script-src 'none').

Page To view To act
/admin/ read —
/admin/connections read —
/admin/integrations read —
/admin/audit read —
/admin/callers read manage_callers to issue or revoke
/admin/revoke revoke revoke
/admin/config plan apply, plus approve for a gated change
/admin/operators read manage_operators to add or disable
/admin/certificates read manage_pki to issue

Viewing a list and changing it are separate capabilities. A viewer can open /admin/operators and see who the operators are — the same thing POST /admin/v1/operators allows them — but the add and disable forms are not rendered, and a POST is 403 even if one is forged by hand.

A capability you lack is 403, naming the role and what it may not do. The page set is documented and fixed, so hiding it would buy nothing. The whole console is 404 — the same body as any unknown path — when the admin surface is absent (§5); that case hides everything, including whether keeplane is running here at all.

Applying a gated change from /admin/config needs approve, which editor does not hold. The plan is not carried in the form; the document and its digest are, and apply recomputes the plan and refuses if the digest moved.

Reaching the console from a browser

An empty directory to a console in a browser, start to finish: ADMIN-CONSOLE-QUICKSTART.md.

A browser cannot authenticate to a plaintext console. Navigation cannot set an Authorization header, so the bearer mechanisms — OIDC, the static token, the bootstrap grant — are all unreachable from the URL bar. curl works; Chrome does not.

mTLS is the only mechanism a browser can present. Turn on TLS, because a plaintext listener can never receive a client certificate:

[api.listen]
addr      = "127.0.0.1:8079"
tls_cert  = "./pki/api.crt"
tls_key   = "./pki/api.key"
client_ca = "./pki/ca.crt"     # without tls_cert this is refused at start-up

client_ca is verified with VerifyClientCertIfGiven, not RequireAndVerifyClientCert: a certificate that does not chain to the CA fails the handshake, but a caller presenting none still reaches the identity ladder and gets a 401 naming the problem. That is what keeps the CLI's bearer token working against a TLS console.

Then issue a certificate and follow the printed steps:

keeplane pki issue --identity api   --kind server --host 127.0.0.1,localhost
keeplane pki issue --identity alice --kind client --use console

--use console prints the openssl pkcs12 line that turns the PEM pair into the single bundle a browser can import, the keeplane operator add --subject "cert:alice" that makes the certificate an operator, and the config above.

Your browser must also trust the CA that signed the server certificate, or it will refuse the connection before any of this matters:

# macOS — prompts for your password
sudo security add-trusted-cert -d -r trustRoot \
     -k /Library/Keychains/System.keychain ./pki/ca.crt
security import ./pki/alice.p12 -k ~/Library/Keychains/login.keychain-db

Open https://127.0.0.1:8079/admin/ and pick the certificate when asked.

3.12 Error codes

Code Status
unauthenticated 401
forbidden 403
not_found 404
bad_request 400
conflict 409
internal 500

4. Request path — the proxy

Default 127.0.0.1:8081. This is the surface a tenant's application uses.

4.1 Authentication

Four modes, tried in order:

Mode How
caller token Authorization: Bearer kpc_… — the usual case, and the only one that works in every topology
mTLS the client certificate's subject CN
unix peer the kernel-reported uid, over a socket
loopback zero-config, only on a loopback listener with no callers issued

An unknown token and a revoked one take the same time and return the same error.

4.2 The tenant

The tenant comes from the caller's row, not from a request — for an ordinary caller, a tenant header is not read at all.

A caller issued with tenant: "*" is delegating: it acts for a front end that authenticates end tenants itself, and names the tenant per request:

X-Keeplane-Tenant: hdfc

Required for such a caller, and refused if absent or *. Not read for any other caller, so an ordinary caller cannot reach another tenant by adding it.

4.3 The connection

X-Keeplane-Ref: airtable-finance

Optional. It defaults to the app named in the path, which is the connection connect start --ref airtable --integration airtable creates. Name it explicitly only when a tenant holds several connections to one app.

The caller's refs allow-list is checked before anything is resolved, and long before anything is decrypted.

4.4 By path — /p/{app}/{provider path}

Mirrors the provider's own method, path, query, headers and body.

curl -H "Authorization: Bearer $KP_TOKEN" \
     -H "X-Keeplane-Tenant: hdfc" \
     "http://127.0.0.1:8081/p/airtable/v0/meta/whoami"
{"id":"usrGNEQfnZ47ZSsPw","email":"you@example.com"}

The response is the provider's, streamed, with headers filtered through an allow-list. Use this form when you have a provider SDK: point its base URL at http://127.0.0.1:8081/p/airtable and it works unchanged.

Bodies stream and are not buffered, up to 32 MiB. Any content type.

4.5 By operation — /execute/{app}/operations/{operation}

Always POST; the provider's method comes from the route.

curl -X POST "http://127.0.0.1:8081/execute/airtable/operations/list_records" \
     -H "Authorization: Bearer $KP_TOKEN" \
     -H "X-Keeplane-Tenant: hdfc" \
     -H 'Content-Type: application/json' \
     -d '{"path_params": {"base_id": "appXXXXXXXXXXXXXX", "table_id": "Table 1"},
          "query_params": {"maxRecords": "3"},
          "data": null}'
Field Meaning
path_params one value per {placeholder}; each percent-encoded into exactly one segment
query_params appended as a query string, sorted
data the request body, passed through verbatim. Must be valid JSON.
method accepted and ignored — the route's method wins

{operation} is a route id from the connector document.

It reaches nothing /p/ cannot: it builds a path from the same route table the matcher then checks it against. It does not construct or template a body — data is bytes. Absent data sends no body and no Content-Type.

Because data is JSON, this door cannot send form-encoded, XML or binary bodies, and it buffers rather than streams. Use /p/ for those.

Disable with:

[proxy.execute]
enabled = false     # default true; disabled it is a 404, not a 403

4.6 Counters

GET /stats on the proxy listener, behind the same authentication. Returns per-provider breaker state, in-flight counts and latency.

4.7 Error codes

Code Status Meaning
unauthenticated 401 the token, certificate or peer was not recognised
bad_request 400 malformed path or body; a delegating caller naming no tenant
ref_not_allowed 403 a connection outside this caller's allow-list. Nothing resolved, nothing decrypted
unknown_integration 404 no such app, or no connection for this tenant. Deliberately indistinguishable — the difference would be an existence oracle
unknown_route 404 the connector does not declare that operation, method or path. Nothing was contacted
credential_not_usable 409 revoked, expired, or never stored. Read from the credential's metadata, never a connection row
insufficient_scope 403 the route needs a scope the credential lacks. Refused before the unwrap
egress_denied 403 the destination or its certificate failed the guard. Nothing was decrypted
request_too_large 413 over 32 MiB. A body declaring its size is refused before a byte is read
provider_unavailable 502 breaker open, in-flight budget full, or the connection failed. Carries Retry-After
upstream_timeout 504 the provider did not answer in time

5. Tenant-facing connect pages

Default 127.0.0.1:8080, behind TLS in production. Browser-facing, not an API: these are the two pages a tenant's user interacts with. They need no client certificate, because browsers have none.

The URL to send a user to comes from connect start (§6.3), and the one-time state it carries is the authentication — an attacker who cannot guess it cannot submit, and one who can could have started their own flow.

GET /connect/form?state=…

For an api_key or basic provider. Renders one password field.

POST /connect/form

curl -X POST http://localhost:8080/connect/form \
     --data-urlencode "state=wag2LZbMVRXMfxVDG6HXzQhd2RPdQevEvXfv8n0VPcw" \
     --data-urlencode "value=patXXXXXXXXXXXXXX.…"

Form-encoded, not JSON. No cookie or CSRF token — the one-time state is the protection.

Before storing anything, keeplane calls the connector's verify_route with the submitted value. If the provider rejects it, nothing is written and the page says the credential did not work.

200  <h1>Connected</h1>
400  <h1>Not connected</h1><p>the credential did not work when we tested it</p>

GET /connect/callback?code=…&state=…

The OAuth redirect target. Its value is [connect] public_url + /connect/callback, printed at startup, and must match what you registered with the provider byte for byte.

In one request it checks state, exchanges the code using the operator's client_id and secret plus the PKCE verifier, calls verify_route, and stores the access and refresh tokens — the refresh token as a separate credential at <ref>#refresh.


6. Service APIs

Peer surfaces, for keeplane's own components. In a single-binary deployment they are in-process and no listener is opened.

Authentication is by transport. Over a unix socket the caller is identified by kernel-reported peer credentials and there is nothing to send; over TCP it is the client certificate's subject. The peer's declared role decides which routes it may call and which tenants it may name — see [[vault.peers]] in DEPLOYMENT.md.

6.1 credential-core

The full contract is published as OpenAPI at ../api/openapi.yaml, generated from the server's own types.

Route Roles
POST /v1/credentials/resolve data-plane, lifecycle, admin, break-glass
POST /v1/credentials/unwrap data-plane, lifecycle, break-glass
POST /v1/credentials/save lifecycle, admin, break-glass
POST /v1/credentials/renew lifecycle, admin, break-glass
POST /v1/credentials/revoke admin, break-glass
POST /v1/credentials/shred admin, break-glass
POST /v1/keys/rotate admin, break-glass
POST /v1/audit/seal · /v1/audit/prune · /v1/audit/verify · /v1/audit/search admin, break-glass
POST /v1/health · /v1/ready any peer

admin does not carry unwrap. An identity that administers keeplane cannot read credentials; break-glass is the only role that holds both, and it is derived rather than configurable.

Plaintext travels base64-encoded in a field named plaintext_b64, so a value in a log can never be mistaken for a readable field.

6.2 catalog

Roles: reader, publisher.

Route Roles Body
POST /v1/catalog/snapshot reader, publisher {"have_digest":"…"} → {"snapshot":{…}} or empty when unchanged
POST /v1/catalog/profile reader, publisher {"name":"asana","version":""} → {"profile":{…}}
POST /v1/catalog/profiles reader, publisher {} → {"profiles":[…]}
POST /v1/catalog/compile reader, publisher {"tenant":"hdfc","name":"asana"} → {"integration":{…}}
POST /v1/catalog/integration reader, publisher {"tenant":"hdfc","ref":"asana"} → {"integration":{…}}
POST /v1/catalog/validate publisher a profile → validation result
POST /v1/catalog/egress-diff publisher hosts a publish would add
POST /v1/catalog/digest publisher the canonical digest of a document
POST /v1/catalog/publish publisher {"profile":{…},"approval":{…}}
POST /v1/catalog/register publisher {"integration":{…}}
POST /v1/catalog/apply-batch publisher many changes, one snapshot rebuild

Codes: not_found, invalid_profile, immutable, approval_required, approval_mismatch, forbidden, integration_exists, secret_ref_missing, bad_request.

6.3 connect

Roles: viewer, operator, worker.

Route Roles Body
POST /v1/connect/start operator {"tenant":"hdfc","ref":"asana","integration":"asana","return_to":""} → {"url":"…","expires_at":"…"}
POST /v1/connect/status viewer, operator, worker {"tenant":"hdfc","ref":"asana"} → {"connection":{…}}
POST /v1/connect/list viewer, operator, worker {"filter":{…}} → {"connections":[…]}
POST /v1/connect/apps viewer, operator {"tenant":"hdfc"} → {"apps":[…]}
POST /v1/connect/reauth operator a fresh flow for an existing connection
POST /v1/connect/revoke operator {"tenant":"…","ref":"…","reason":"…"}
POST /v1/connect/needs-reauth operator, worker the worker marking a connection unrenewable
POST /v1/connect/prune-states operator, worker expired flow nonces, in batches

The marketplace — /v1/connect/apps

{"tenant":"hdfc"}
{"apps":[
  {"app":"airtable","version":"2026-09-01","auth_kind":"api_key",
   "connections":[{"tenant":"hdfc","ref":"airtable","status":"active", …}]},
  {"app":"asana","version":"2026-09-01","auth_kind":"oauth2_authorization_code",
   "connections":[]}
]}

Returns every published app — publishing is the offer, and there is no per-tenant entitlement. connections is always a list and is [] when the tenant has not connected that app. local_only profiles are never returned.


7. Tenant API — /v1/tenant

On the api listener, alongside /admin/v1 and sharing nothing else with it.

This is the surface a tenant's own front end uses. A tenant has no shell on the box and no operator row; what it holds is a caller token — the same credential its application already uses on the request path.

7.1 Authentication

Authorization: Bearer kpc_…
X-Keeplane-Tenant: hdfc          ← only for a delegating caller

Exactly as on the request path (§4.2):

  • A caller issued with a real tenant acts for that tenant, and X-Keeplane-Tenant is not read for it.
  • A caller issued with tenant: "*" is delegating and must name one tenant per request. Absent or * is a bad_request.

No route here takes a tenant in its body. A tenant in a request would be a tenant the request could choose.

An operator identity does not open this surface: a loopback request with no caller token is 401, even where the bootstrap ladder would make it an admin. A caller token does not open the operator surface either.

If the node runs no proxy, no caller can be authenticated and every route here is 404 — absent rather than open.

7.2 The allow-list gates connecting, not just calling

Every route is filtered by the caller's --refs. A caller that may not call an app may not connect one either — otherwise --refs would describe half the blast radius.

POST /v1/tenant/apps

The marketplace, filtered to what this caller may use.

curl -s -X POST http://127.0.0.1:8079/v1/tenant/apps \
     -H "Authorization: Bearer $CRM_TOKEN" \
     -H "X-Keeplane-Tenant: hdfc" \
     -H 'Content-Type: application/json' -d '{}'
{"apps":[
  {"app":"airtable","version":"2026-09-01","auth_kind":"api_key","connections":[]},
  {"app":"asana","version":"2026-09-01","auth_kind":"oauth2_authorization_code","connections":[]}
]}

connections is always a list; [] means the app is on offer and this tenant has not connected it. auth_kind tells a UI which affordance to render — an OAuth button, or a field to paste a key into.

POST /v1/tenant/connect

Starts a flow and returns the URL to open in the tenant's browser.

{"app":"airtable","ref":"airtable","return_to":""}
{"url":"http://localhost:8080/connect/form?state=2U7-Mp1Y3Kkb1a9e_YbzwDUIU7OyN568PpsRJTKBpMY",
 "expires_at":"2026-09-01T09:53:05.716203Z"}
Field
app required — a published app, as named in the marketplace
ref optional; defaults to app, which is right for one account per provider
return_to optional; where to send the user afterwards. Must be allow-listed

For an OAuth app the URL is the provider's consent screen; for api_key or basic it is keeplane's own form (§5). Either way it is safe to show a user: the one-time state it carries is the authentication.

An app outside the caller's --refs is 403 and no flow is started.

POST /v1/tenant/connections

{}
{"connections":[
  {"tenant":"hdfc","ref":"airtable","status":"active","integration":"airtable",
   "provider":"airtable","connected_at":"2026-09-01T09:43:06.690893Z", …}
]}

This tenant's connections only. Carries no token and no secret.

POST /v1/tenant/connections/revoke

{"ref":"airtable","reason":"disconnected by the customer"}  →  {}

Ends both the credential and the connection. reason defaults to "revoked by the tenant" and is shown to a person, so it carries nothing sensitive.

POST /v1/tenant/connections/reauth

{"ref":"asana"}  →  {"url":"https://app.asana.com/-/oauth_authorize?…",
                     "expires_at":"2026-09-01T13:10:00Z"}

Starts a fresh authorization for a connection the tenant already has, for when an OAuth token can no longer be refreshed. Without it a tenant's only remedy is revoke-and-reconnect, which discards the connection's history — or asking the operator, which is the thing this surface exists to avoid.

Gated exactly as connecting is: the app must be one this caller may use.

7.3 A complete tenant lifecycle

Every step over HTTP, no CLI, run against a live deployment:

A=http://127.0.0.1:8079
H=(-H "Authorization: Bearer $CRM_TOKEN" -H "X-Keeplane-Tenant: hdfc"
   -H "Content-Type: application/json")

# 1. what can I connect?
curl -s -X POST $A/v1/tenant/apps "${H[@]}" -d '{}'
#    → airtable: connections []

# 2. start it
curl -s -X POST $A/v1/tenant/connect "${H[@]}" -d '{"app":"airtable"}'
#    → {"url":"http://localhost:8080/connect/form?state=…"}

# 3. the tenant's user opens that URL and submits their key  (§5)
#    → <h1>Connected</h1>

# 4. confirm
curl -s -X POST $A/v1/tenant/connections "${H[@]}" -d '{}'
#    → airtable, active

# 5. use it
curl -s -X POST http://127.0.0.1:8081/execute/airtable/operations/whoami "${H[@]}" -d '{}'
#    → {"id":"usr…","email":"…"}   200

A second tenant on the same token sees the same marketplace, has "connections":[], and is refused at step 5 with unknown_integration — by the absence of a credential, not a permission table.

7.4 Error codes

The /admin/v1 set (§3.12), with these meanings here:

Code Status
unauthenticated 401 no caller token, or one not recognised
bad_request 400 a delegating caller naming no tenant; a missing app or ref; an unknown field
forbidden 403 the app is outside this caller's --refs
not_found 404 no such connection — or the node serves no tenant surface

8. Not yet served

Connection hierarchy. A tenant may hold several connections to one app today, and /v1/tenant/apps returns them as a list, but parent and child connections — where revoking the parent revokes the children — are not implemented. The connection table carries nullable subject and parent_ref columns as the seam, written by nothing.