Titentiten.devꦠꦶꦠꦺꦤ꧀
v0.1.1 · npmGitHub
DocsIntegrateKeys & scopes

Keys & scopes

A Titen credential is a hashed bearer token carrying a scope list and a trust ceiling, and neither can be widened by the key that mints the next one.

titen_sk_ prefix34 scopes4 trust levelsSHA-256 at rest

What a key actually is

A raw key is titen_sk_ plus 43 base64url characters — 32 bytes from crypto.getRandomValues. Only its SHA-256 hash reaches storage, so a database copy cannot be replayed as a credential. Verification is one indexed lookup on that hash and nothing about the principal is cached, so revocation takes effect on the next request.

The row behind a key holds six things:

WHAT THE CREDENTIAL CARRIES

org_id the tenant. Never read from a request body.

principal_id the actor recorded as actor_id on the rows it writes.

principal_kind human, agent or service.

label a human name, so revoking the right key is not guesswork.

scopes a space-separated list, or * for all of them.

max_trust the ceiling on evidence this key may assert.

Tenant and actor authority come from the credential, always. A valid identifier belonging to another tenant returns the same non-disclosing 404 NOT_FOUND used for every cross-scope denial.

The 34 scopes

Every route except /healthz and /readyz requires exactly one of these. A key holding * satisfies all of them.

Scope Opens
projects:resolve POST /v1/projects/resolve
projects:create the create: true branch of that route; without it an unknown reference is a 404
observations:write POST /v1/observations
claims:write POST /v1/consolidations, and claim supersede, revoke, expire
context:compile POST /v1/context/compile
feedback:write POST /v1/context/:id/feedback
evidence:read GET /v1/claims/:id/evidence
checkpoints:read GET /v1/checkpoints
checkpoints:write POST /v1/checkpoints, DELETE /v1/checkpoints/:id
workspaces:read GET /v1/workspaces
workspaces:write POST /v1/workspaces
memberships:read GET /v1/memberships
memberships:write POST /v1/memberships, DELETE /v1/memberships/:id
leases:write POST /v1/leases, DELETE /v1/leases/:id
handoffs:read GET /v1/handoffs
handoffs:write POST /v1/handoffs, POST /v1/handoffs/:id/resolve
mcp:call POST /mcp — all seven tools
events:read GET /v1/events, GET /v1/events/:id
views:compile POST /v1/memory-views/compile
policies:read GET /v1/policies
policies:write POST /v1/policies
releases:read GET /v1/channel-releases/:id
releases:write POST /v1/channel-releases, /publish, /revoke
channel:read POST /v1/channel-context
audit:read GET /v1/audit
audit:export GET /v1/audit/export
federation:read GET peers, peer filters, federation log
federation:write peer create, suspend, filters, pull, push
webhooks:read GET /v1/webhooks, GET /v1/webhooks/:id/deliveries
webhooks:write webhook create, delete, pause, resume, deliver
index:write POST /v1/index/drain
keys:manage POST /v1/keys, GET /v1/keys, DELETE /v1/keys/:id
export:read GET /v1/export
import:write POST /v1/import

Reads and writes split for a reason. audit:export is separate from audit:read because pulling the trail out as NDJSON is a different act from paging it as JSON; webhooks:read without webhooks:write is the shape a monitoring job wants. A missing scope is 403 FORBIDDEN and names what was needed: Missing required scope "claims:write".

Trust ceilings

Four levels, ranked: unverified (0) < asserted (1) < verified (2) < policy_approved (3). max_trust is a ceiling on what a credential may assert, not a statement about the credential itself. Ask for more and the write fails before it touches anything:

{ "error": { "code": "FORBIDDEN",
    "message": "This credential may not assert \"verified\" trust." },
  "meta": { "request_id": "req_ec1a13fdae074509908eac6d5a62e7ee" } }

Two consequences. A claim’s trust may not exceed the trust of its supporting evidence, and consolidation checks the ceiling again, so a low-ceiling agent cannot launder its own output into fact by consolidating it. And verified describes evidence authority, not permission to disclose — publishing to a channel is a separately-scoped act.

Minting

titen bootstrap creates the organization and the first key: principal owner, kind human, scopes ["*"], ceiling policy_approved. That is the only key that should ever look like that.

titen key create --db titen.db --org-id org_724bbe3aab2a4286b25dabec65bce85e \
  --label deploy-agent --kind agent --trust asserted \
  --scopes "observations:write,claims:write,context:compile,feedback:write"

--org-id is required. Without --scopes you get the CLI’s default set — projects:resolve, observations:write, claims:write, context:compile, feedback:write, evidence:read — which is the kernel loop and nothing else. --trust defaults to asserted, --kind to agent. Add --print-sql to emit the INSERT for a remote database (Cloudflare D1) instead of writing locally; the raw key still goes to stderr, once.

Over HTTP, with keys:manage:

curl -X POST http://127.0.0.1:8787/v1/keys \
  -H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
  -d '{"label":"deploy-agent","scopes":["observations:write","context:compile"],"max_trust":"asserted"}'
{ "data": { "key_id": "key_3087befa94bc454fb8769630aa2eb1cf",
    "api_key": "titen_sk_…", "label": "deploy-agent",
    "scopes": ["observations:write", "context:compile"],
    "max_trust": "asserted", "principal_kind": "agent",
    "warning": "Store this key now. Titen keeps only its hash and cannot show it again." } }

principal_id defaults to a generated agent_… identifier and principal_kind to agent. A key can never mint more authority than it holds:

An unrecognized scope is 400 VALIDATION_ERRORUnknown scope "x".

A scope the caller lacks is 403 FORBIDDENThis credential may not grant scope "keys:manage".

A higher ceiling than the caller's is 403A new credential may not exceed the creating credential's trust ceiling.

The raw key appears exactly once, in this response. There is no recovery route and no support path that can reissue it.

Revoking

curl -X DELETE http://127.0.0.1:8787/v1/keys/key_3087befa94bc454fb8769630aa2eb1cf \
  -H "authorization: Bearer $TITEN_API_KEY"
# → {"data":{"key_id":"key_3087…","revoked_at":"2026-07-30T10:49:20.759Z","revoked":true}}

Idempotent: a second call returns the first revoked_at rather than moving it. An unknown or foreign key id is 404. The CLI equivalent is titen key revoke --id <key id>, and titen key list prints id, status, org, principal, ceiling, label and scopes for every key in the database.

There is no rotation route. Rotation is three deliberate steps: mint the replacement, move traffic to it, revoke the old key — with the overlap as short as you can make it.

Least privilege in practice

One key per agent identity, labelled. Shared keys make revocation a decision about which service to break.

Scope to the routes that agent actually calls. A retrieval-only agent needs context:compile and nothing that writes.

Give the lowest ceiling the work tolerates. policy_approved belongs to an operator, not to a loop that summarizes tool output.

Keep keys:manage on one operator credential. Every other key that holds it can list and revoke every key in the organization.

Treat mcp:call as write authority: the seven tools behind it cover observations, checkpoints, leases and handoffs.

Never put a key in a repository file, plugin manifest, tool description, URL query or command-line argument. Use the host's secret store or a mode-0600 file.

↑↓ navigate↵ open