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.
- 1. The listeners
- 2. Errors
- 3. Operator API —
/admin/v1 - 4. Request path — the proxy
- 5. Tenant-facing connect pages
- 6. Service APIs
- 7. Tenant API —
/v1/tenant - 8. Not yet served
| 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.
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.
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.
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
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.
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.
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"}{} → {"operators":[{"subject":"uid:501","role":"admin","disabled":false, …}]}{"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.
{"subject":"cert:api-node","disabled":true} → {}Reversible. The audit trail keeps the row.
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.
{"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.
{"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.
{"tenant":"hdfc","ref":"airtable","reason":"offboarded"} → {}Ends both the credential and the connection: the credential stops working, and the dashboard says why.
{"tenant":"hdfc","scope":"tenant-kek","approvals":[…]} → {}scope is tenant-kek or root. Re-wraps; no plaintext is touched.
{"tenant":"hdfc","ref":"airtable","version":1,"approvals":[…]} → {}Irreversible. Cryptographic erasure of one version.
The api role holds credential-core's
adminrole, which does not carryUnwrap. No route here returns decrypted material, and none can.
{"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.
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"}{} → {"result":{"ok":true,"checkpoints":12, …}}Walks the signed checkpoint chain.
{"tenant":"hdfc"} → {"callers":[{"id":"…","name":"crm","refs":["airtable"], …}]}{"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.
{"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.
{} → {"certs":[{"file":"proxy-node.crt","identity":"proxy-node","kind":"client","expires":"…","status":"ok"}]}{"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.
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.
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-upclient_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-dbOpen https://127.0.0.1:8079/admin/ and pick the certificate when asked.
| Code | Status |
|---|---|
unauthenticated |
401 |
forbidden |
403 |
not_found |
404 |
bad_request |
400 |
conflict |
409 |
internal |
500 |
Default 127.0.0.1:8081. This is the surface a tenant's application uses.
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.
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.
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.
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.
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 403GET /stats on the proxy listener, behind the same authentication. Returns
per-provider breaker state, in-flight counts and latency.
| 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 |
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.
For an api_key or basic provider. Renders one password field.
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>
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.
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.
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.
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.
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 |
{"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.
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.
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-Tenantis not read for it. - A caller issued with
tenant: "*"is delegating and must name one tenant per request. Absent or*is abad_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.
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.
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.
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.
{}{"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.
{"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.
{"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.
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":"…"} 200A 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.
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 |
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.