From 13e01cd52ab6536cc8394611c597fa590c6e3fa2 Mon Sep 17 00:00:00 2001 From: youssefhany-ixo Date: Wed, 22 Jul 2026 14:06:07 +0300 Subject: [PATCH] docs(build-an-oracle): document the `can` option on mintInvocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rtCtx.ucan.mintInvocation` now takes `opts.can` — the ability the invocation claims, defaulting to `'*'`. The docs showed only the default, which is an over-claim against every real delegation: a claim resolves against a grant only when the granted ability is `'*'`, equals the claim, or is a `prefix/*` covering it, so a `'*'` claim is satisfiable ONLY by a `'*'` grant. Oracles delegated `memory/*` or `sandbox/*` must claim that ability explicitly. Adds a "Claim only what you were granted" section covering the coverage rule, the failure mode, and the service-first rollout order — services match the invocation's `can` by strict equality, so claiming a narrow ability before the service registers it 401s every call. Refs ORA-364 --- build-an-oracle/develop/identity-and-auth.mdx | 44 ++++++++++++++++--- build-an-oracle/reference/runtime-context.mdx | 18 +++++++- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/build-an-oracle/develop/identity-and-auth.mdx b/build-an-oracle/develop/identity-and-auth.mdx index ede3c64..184a65e 100644 --- a/build-an-oracle/develop/identity-and-auth.mdx +++ b/build-an-oracle/develop/identity-and-auth.mdx @@ -207,10 +207,11 @@ handler: async (args, rtCtx: RuntimeContext) => { return JSON.stringify({ error: 'Could not resolve downstream service DID.' }); } - const invocation = await rtCtx.ucan.mintInvocation({ - did: serviceDid, - capability: 'ixo:downstream', - }); + 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: { @@ -226,7 +227,7 @@ 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.skipCache` forces a fresh signature. | +| `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`. | @@ -235,6 +236,39 @@ The full UCAN helper surface on `rtCtx.ucan`: See [`packages/oracle-runtime/src/modules/ucan/`](https://github.com/ixoworld/ixo-oracles-boilerplate/tree/main/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/*` covers `memory/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/`: + +```ts +// 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: + +1. service accepts the narrow ability **and** `'*'` +2. oracle switches to claiming the narrow ability +3. 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`. diff --git a/build-an-oracle/reference/runtime-context.mdx b/build-an-oracle/reference/runtime-context.mdx index ee81b43..2383cb2 100644 --- a/build-an-oracle/reference/runtime-context.mdx +++ b/build-an-oracle/reference/runtime-context.mdx @@ -56,7 +56,7 @@ export interface RuntimeContext { ucan: { requireCapability: (resource: string, action: string) => void; hasCapability: (resource: string, action: string) => boolean; - mintInvocation: (target: { did: string; capability: string }, opts?: { skipCache?: boolean }) => Promise; + mintInvocation: (target: { did: string; capability: string }, opts?: { skipCache?: boolean; can?: string }) => Promise; resolveServiceDid: (serviceUrl: string) => Promise; hasSigningKey: () => boolean; createInvocationFromDelegation: ( @@ -171,7 +171,21 @@ export interface RuntimeContext { - `requireCapability(resource, action)` — throws if the user's delegation doesn't include this capability. - `hasCapability(resource, action)` — boolean check. - - `mintInvocation({ did, capability }, opts?)` — mint a downstream invocation signed by the oracle's signing mnemonic. + - `mintInvocation({ did, capability }, opts?)` — mint a downstream invocation signed by the oracle's signing mnemonic. `opts.can` is the **ability** the invocation claims (default `'*'`); `opts.skipCache` bypasses the invocation cache, required for services that enforce single-use replay protection per invocation CID. + + + **Claim the ability the user's delegation actually grants.** A claim resolves against a delegation only when the granted ability is `'*'`, equals the claim, or is a `prefix/*` covering it. So the default `'*'` claim is satisfiable **only** by a `'*'` grant — if the user granted `memory/*`, a `'*'` claim is an over-claim and the service refuses it: + + ```ts + // ✅ delegation grants { can: 'memory/*', with: 'ixo:memory' } + await rtCtx.ucan.mintInvocation( + { did: memoryDid, capability: 'ixo:memory' }, + { can: 'memory/*' }, + ); + ``` + + The service must also register that ability: it matches an invocation's `can` by **strict equality**, so one that only defines `'*'` rejects a `memory/*` invocation as an unknown capability before authorization is considered. Roll out the service side first. + - `resolveServiceDid(serviceUrl)` — look up a downstream service's DID document; returns `id` or `null`. - `hasSigningKey()` — `true` once the oracle has loaded its Ed25519 signing mnemonic. **Gate registration of mint-capable tools on this**: without a key, minting is a no-op, so the tool should surface an error rather than pretend it worked. - `createInvocationFromDelegation(delegationCar, serviceUrl, capability, options?)` — mint an invocation from a **directly-supplied** delegation CAR (rather than the user's cached one), targeted at a specific service route. Returns `{ invocation }` on success or `{ error }` with a surfaced-verbatim reason (missing signing key, audience mismatch, did:web unreachable, …).