What identity looks like in code
Two layers — the oracle's persistent identity, set once at boot via env vars; and per-user identity, validated on every request via UCAN headers.
import { createOracleApp, type OracleConfig } from '@ixo/oracle-runtime';
const config: OracleConfig = {
name: 'My Oracle',
org: 'My Org',
description: 'What this oracle does.',
};
const app = await createOracleApp({ config });
// Runtime fills in `entityDid` from process.env.ORACLE_ENTITY_DID
// and builds the internal OracleIdentity used by every plugin.The OracleConfig type lives in packages/oracle-runtime/src/plugin-api/types.ts — plugins read the resolved identity off ctx.identity (a OracleIdentity).
| Layer | What it is | Where it comes from |
|---|---|---|
| Oracle identity | DID + Matrix bot user + on-chain entity | OracleConfig (inline) + ORACLE_DID / ORACLE_ENTITY_DID / MATRIX_ORACLE_ADMIN_* env vars |
| User identity | Per-request DID + UCAN capabilities | Headers on every incoming request — a user-signed UCAN invocation (Authorization: Bearer … + X-Auth-Type: ucan) for authentication, plus x-ucan-delegation for downstream authorization |
One-time oracle setup
Run the CLI to create the oracle's on-chain entity and Matrix account
qiforge-cli create-entity --no-interactive \
--network devnet \
--oracle-name "My Oracle" \
--org-name "My Org" \
--api-url http://localhost:3000 \
--model "anthropic/claude-sonnet-4"This creates a blockchain entity (did:ixo:entity:...), registers linked resources, provisions a Matrix bot account, and writes both oracle.config.json and .env. Run once per deployment — subsequent deploys reuse the identity.
create-entity registers --api-url http://localhost:4000 by default, but the runtime's own default PORT is 3000 (the example above pins the URL to :3000 to match). If you take the default URL instead, make your oracle reachable at it: either set PORT=4000 in .env, or change the registered URL later with qiforge-cli update-oracle-api-url.
See the CLI reference for every flag.
Provision the encryption key for per-room secrets
qiforge-cli setup-encryption-keySets up the P-256 keyAgreement on the oracle's Matrix account room. Without this, the secrets read path returns nothing (acceptable degraded mode for routes that don't need it).
Verify .env has every identity var
After CLI setup, .env contains the core identity vars validated by baseEnvSchema:
ORACLE_DID=did:ixo:ixo1...
ORACLE_ENTITY_DID=did:ixo:entity:...
ORACLE_NAME=My Oracle
NETWORK=devnet
MATRIX_BASE_URL=https://matrix.ixo.world
MATRIX_ORACLE_ADMIN_USER_ID=@oracle-bot:ixo.world
MATRIX_ORACLE_ADMIN_PASSWORD=...
MATRIX_ORACLE_ADMIN_ACCESS_TOKEN=...
MATRIX_ACCOUNT_ROOM_ID=...
MATRIX_VALUE_PIN=...
MATRIX_RECOVERY_PHRASE=...
SECP_MNEMONIC=... # signs UCAN invocations to downstream servicesThe runtime validates all of these at boot via baseEnvSchema; missing vars fail with a clear message.
Two auth-tuning vars have safe defaults — set them only to override:
UCAN_AUTH_MAX_TTL_SECONDS=900 # max lifetime the oracle accepts for a user auth invocation (default 15 min)
UCAN_REAUTH_PROMPT_THROTTLE_SECONDS=21600 # min gap between "please re-authorize" prompts (default 6 hours)UCAN_AUTH_MAX_TTL_SECONDS bounds the replay window server-side regardless of the TTL the client declares; UCAN_REAUTH_PROMPT_THROTTLE_SECONDS keeps a de-authorized user from being nagged on every message.
What the runtime loads on boot
After Matrix init completes in the background, the runtime reads two further pieces of secret material from the oracle's Matrix account room:
- UCAN signing mnemonic — used by
UcanServiceto mint downstream-service invocations. - P-256 encryption key — used by
SecretsServiceto decrypt per-room secrets.
If a key is missing, boot logs a warning naming the CLI command to fix it. Auth-requiring routes return 401 until provisioned; non-authenticated routes (/, /health, /docs, any host or plugin authExcludedRoutes) stay reachable.
Per-request user auth
Two distinct concerns, two distinct artifacts — AuthHeaderMiddleware verifies both on every protected route:
- Authentication (who is calling) = a user-signed UCAN invocation. Short-lived, replay-bounded, sent as
Authorization: Bearer <invocation>+X-Auth-Type: ucan. This is the primary auth path. - Authorization (what the oracle may do downstream) = the user→oracle delegation, sent as
x-ucan-delegation. Plugins read it to mint downstream-service invocations on the user's behalf.
A bare x-ucan-delegation (no invocation) is still accepted as a migration fallback for clients that haven't moved to invocation auth yet. New clients should send both: the invocation to authenticate, the delegation for downstream authorization.
| Header | Required | Purpose |
|---|---|---|
Authorization: Bearer <invocation> | yes (primary) | The user-signed UCAN invocation — proves who is calling. Only read when X-Auth-Type: ucan is also present. |
X-Auth-Type: ucan | yes (primary) | Selector that tells the middleware to read the bearer token as a UCAN invocation. Without it the bearer is ignored. |
x-ucan-delegation | fallback / downstream-authz | The user→oracle delegation. Carries the capabilities plugins use to mint downstream invocations. Also accepted as the auth artifact on its own for pre-invocation clients. |
x-did | no | The user's IXO DID (set by SDK; runtime derives the authenticated DID from the invocation/delegation regardless). Not used for authentication. |
x-matrix-access-token | no | For clients that already have a Matrix session. |
x-matrix-homeserver | no | Matrix homeserver for the user. |
x-timezone | no | Propagates to rtCtx.user.timezone. |
x-request-id | no | Correlation ID for logs and traces. |
When neither an invocation nor a delegation is present, the middleware returns 401 with:
Missing UCAN authentication: provide Authorization: Bearer <invocation> with X-Auth-Type: ucan, or an x-ucan-delegation headerA malformed or expired invocation returns 401 Invalid UCAN invocation.
AuthHeaderMiddleware:
Validates the invocation (primary)
When X-Auth-Type: ucan + Authorization: Bearer … are present, validateUcanInvocation verifies the signature and audience (the oracle's ORACLE_DID) and rejects any invocation whose lifetime exceeds UCAN_AUTH_MAX_TTL_SECONDS (default 900s). Results are cached by the token's SHA-256 hash with TTL = the invocation's own expiry, so reusing the same token until it expires (JWT-style) doesn't re-hit Blocksync. The invocation's signer becomes req.authData.did.
Falls back to the delegation (migration only)
When no invocation is sent, validateUcanDelegation authenticates the request from x-ucan-delegation instead. Delegation results cache with TTL = the delegation's expiry (or a 3-minute fallback when it declares none).
Trusts the delegation downstream only when it's the caller's own
A delegation is public and shareable, so the middleware acts on it downstream only when its issuer DID equals the authenticated DID. When they differ, the delegation is ignored downstream — req.authData.ucanDelegation.raw is set to '', and plugins branch on raw.length === 0. This stops a client from pairing their own invocation with someone else's delegation to make the oracle act on that person's behalf.
Attaches RuntimeUserContext to the request
The next middleware (RuntimeContextBuilder) reads req.authData to build the rtCtx.user field handed to every tool handler — see the Runtime context reference.
Opt routes out of auth
Two mechanisms to expose public routes — host-level and plugin-level. Both merge onto the runtime's built-in exclusion list: / (the JSON landing payload), /health, /docs, and /docs/(.*).
From the host — pass authExcludedRoutes to createOracleApp
import { RequestMethod } from '@nestjs/common';
import { createOracleApp, type AuthExcludedRoute } from '@ixo/oracle-runtime';
const HOST_AUTH_EXCLUDED_ROUTES: AuthExcludedRoute[] = [
{ path: 'version', method: RequestMethod.GET },
];
const app = await createOracleApp({
config,
nestModules: [VersionModule],
authExcludedRoutes: HOST_AUTH_EXCLUDED_ROUTES,
});Reference: apps/qiforge-example/src/main.ts.
From a plugin — implement getAuthExcludedRoutes
override getAuthExcludedRoutes(): AuthExcludedRoute[] {
return [{ path: 'weather/now', method: RequestMethod.GET }];
}Reference: weather.plugin.ts.
Any other plugin or host route returns 401 without a UCAN header — exclusion is path-and-method specific.
Mint downstream UCAN invocations
When a plugin calls a downstream service on the user's behalf, it does not reuse the user's invocation — it mints a fresh one signed by the oracle's SECP_MNEMONIC, derived from the user's stored delegation. The minted invocation goes out to the downstream service the same way the user authenticates to this oracle: Authorization: Bearer <invocation> + X-Auth-Type: ucan.
handler: async (args, rtCtx: RuntimeContext) => {
// No signing key (mnemonic not yet provisioned) → minting is a no-op.
if (!rtCtx.ucan.hasSigningKey()) {
return JSON.stringify({ error: 'Oracle signing key not provisioned.' });
}
// Resolve the service URL to its did:web identifier, then mint against it.
const serviceDid = await rtCtx.ucan.resolveServiceDid(
'https://downstream-service.example',
);
if (!serviceDid) {
return JSON.stringify({ error: 'Could not resolve downstream service DID.' });
}
const invocation = await rtCtx.ucan.mintInvocation(
{ did: serviceDid, capability: 'ixo:downstream' },
// Claim the ability the user's delegation grants — see below.
{ can: 'downstream/*' },
);
const resp = await fetch('https://downstream-service.example/data', {
headers: {
Authorization: `Bearer ${invocation}`,
'X-Auth-Type': 'ucan',
},
});
// ...
}The full UCAN helper surface on rtCtx.ucan:
| Method | Purpose |
|---|---|
mintInvocation(target, opts?) | Mint a service-targeted invocation from the user's cached delegation. target = { did, capability }; opts.can is the ability claimed (default '*'); opts.skipCache forces a fresh signature. |
requireCapability(resource, action) | Throws if the user's delegation doesn't include the capability. |
hasCapability(resource, action) | Returns a boolean — non-throwing variant. |
resolveServiceDid(serviceUrl) | Resolves a service URL to its did:web:... identifier. Returns null when the DID document is missing or has no id. |
hasSigningKey() | Returns true once the oracle has loaded its Ed25519 signing mnemonic. Gate tool registration on this — without a key, minting is a no-op. |
createInvocationFromDelegation(car, serviceUrl, capability, opts?) | Mint from a directly-supplied delegation CAR (rather than the per-user cached one). Returns { invocation } or { error }. Used by the editor's mint_invocation tool. |
See packages/oracle-runtime/src/modules/ucan/ for the service implementation.
Claim only what you were granted
An invocation says what the oracle is doing right now; the delegation says what the user permitted. The service accepts the invocation only if the delegation covers it — and coverage is narrower than it looks. A granted ability covers a claim when it is:
'*'— covers everything, or- exactly equal to the claim, or
- a
prefix/*pattern matching it (memory/*coversmemory/read).
Nothing else. In particular '*' is not a wildcard when you claim it — a '*' claim is satisfiable only by a '*' grant, because '*' does not start with memory/:
// delegation grants { can: 'memory/*', with: 'ixo:memory' }
// ❌ over-claim — refused: "Delegated capability not found"
await rtCtx.ucan.mintInvocation({ did, capability: 'ixo:memory' });
// ✅ claims exactly what was granted
await rtCtx.ucan.mintInvocation(
{ did, capability: 'ixo:memory' },
{ can: 'memory/*' },
);The service must register the ability you claim. It matches an invocation's can by strict string equality, so a service that only defines '*' rejects a memory/* invocation as an unknown capability before authorization is considered. When narrowing a claim, roll out in this order:
- service accepts the narrow ability and
'*' - oracle switches to claiming the narrow ability
- service tightens or upgrades
Reversing steps 1 and 2 produces a 401 on every call.
What plugins can and cannot do
- Can: read
rtCtx.user.did,rtCtx.user.matrixUserId,rtCtx.user.ucanDelegation,rtCtx.user.timezone. - Can: call
rtCtx.secrets.getIndex()/getValues()to read per-room secrets. - Can: mint downstream invocations via
rtCtx.ucan.mintInvocation. - Cannot: override the oracle's identity per request.
- Cannot: issue UCANs as anyone other than the oracle itself.