> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stellarium.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Permissions and grants

> Restrict who can call a resource function, and hand out access with signed, delegable grants.

By default, any node that can reach yours can call any function you register. Mark a function `restricted` and it runs only for callers that present a **grant** from your node: a signed statement that a given key may call given functions until an expiry.

Grants are capability tokens, not an access list. Your node doesn't keep a list of who may call. It checks the signatures on whatever grant the caller sends, so a caller can hand a narrower slice of its access to someone else without asking you.

<Note>
  Restriction and payment are independent. A function can be restricted, priced, both, or neither. A restricted, priced function needs both a grant and a payment voucher. See [Payments](/concepts/payments).
</Note>

<Note>
  Section 7.2 of the [whitepaper](https://github.com/qualletio/SIPs/blob/main/WHITEPAPER.md) proposes allow and deny lists called access manifests. `stellarium-ts` implements restriction with the grants described here instead.
</Note>

## The pieces

| Term | What it is |
| - | - |
| **Provider** | The node that hosts a restricted function. It issues root grants and checks every call. |
| **Capability** | One rule naming what a grant allows: a resource path, and optionally a resource name, function name, and fixed parameter values. |
| **Grant** | A signed statement that an `issuer` lets an `audience` use some capabilities until `expiresAt`. Both are Ed25519 public keys: a node's `SIGNING_PUBLIC_KEY`. |
| **Chain** | A root grant from the provider, followed by any delegated grants, each issued by the previous grant's audience. A chain holds at most four grants. |
| **Authorization** | What the caller sends with each call: the chain, plus a signature by the last grant's audience over this exact call. |

## Walkthrough

This walkthrough restricts the `com.example.Weather` resource from the [Quickstart](/quickstart), then gives one caller node access to it.

<Steps>
  <Step title="Mark the function restricted">
    On the provider, add `restricted: true` to the function:

    ```typescript highlight={10} theme={null}
    functions: [
      {
        name: "temperature",
        parameters: [{ name: "city", type: "string" }],
        returnType: "int32",
        exec: async (args) => {
          const city = args.find((arg) => arg.name === "city")?.value;
          return city === "Oslo" ? 12 : 20;
        },
        restricted: true,
      },
    ],
    ```

    The flag is published with the resource, so callers see `restricted: true` in `GET /v1/resources` during discovery. From now on the provider answers any call to `temperature` without a valid grant with `403`.
  </Step>

  <Step title="Find the caller's public key">
    A grant is addressed to a key. The caller node's key is its `SIGNING_PUBLIC_KEY`, which it also serves at `GET /v1/self`:

    ```bash theme={null}
    curl http://127.0.0.1:3002/v1/self
    ```

    ```json theme={null}
    { "baseUrl": "http://127.0.0.1:3002", "publicKey": "MCowBQYDK2VwAyEA...", "name": "http://127.0.0.1:3002" }
    ```
  </Step>

  <Step title="Issue a grant on the provider">
    Call `issueGrant` with the caller's key, the capabilities to grant, and how long each copy lasts:

    ```typescript provider.ts theme={null}
    const chain = await node.issueGrant(
      callerPublicKey,
      [{ resourcePath: "com.example", resourceName: "Weather" }],
      { lifetimeMs: 24 * 60 * 60 * 1000 },
    );
    ```

    `chain` is an array holding one root grant, signed by the provider:

    ```json theme={null}
    [
      {
        "id": "6f1c2e0a-...",
        "issuer": "<provider public key>",
        "issuerBaseUrl": "http://127.0.0.1:3000",
        "audience": "<caller public key>",
        "capabilities": [{ "resourcePath": "com.example", "resourceName": "Weather" }],
        "issuedAt": "2026-10-17T12:00:00.000Z",
        "expiresAt": "2026-10-18T12:00:00.000Z",
        "parent": null,
        "signature": "..."
      }
    ]
    ```

    Grants are signed, so the chain is safe to send over any channel. Only the audience's private key can use it.
  </Step>

  <Step title="Give the chain to the caller">
    On the caller node, store the chain with `addGrant`, which checks its signatures and that it is addressed to this node:

    ```typescript caller.ts theme={null}
    await node.addGrant(chain);
    ```

    Or pass it in the start options. A chain passed this way is stored at start, and a newer renewed copy already in the database is kept:

    ```typescript caller.ts theme={null}
    await node.start(fastify, { port: 3002, grants: [chain] });
    ```
  </Step>

  <Step title="Call the function">
    Nothing changes in the script. When a request calls a restricted remote function, the caller node finds a held chain from that provider that covers the call, signs an authorization for it, and sends both:

    ```bash theme={null}
    curl -X POST http://127.0.0.1:3002/v1/run \
      -H 'Content-Type: application/json' \
      -H "Authorization: Bearer $API_KEY" \
      -d '{"source":"request GetTemperature {\n  const weather: com.example.Weather\n  const reading = weather.temperature(city: \"Oslo\")\n  return reading.value\n}"}'
    ```

    If the caller holds no grant that covers the call, it doesn't contact the provider. The call evaluates to an `error` value in the script:

    ```text theme={null}
    Failed to invoke Weather.temperature: it requires a grant from http://127.0.0.1:3000, and this node holds none that covers the call
    ```
  </Step>
</Steps>

## What the provider checks

```mermaid theme={null}
sequenceDiagram
  participant C as Caller node
  participant P as Provider node
  C->>C: Find a held chain that covers the call
  C->>C: Sign an authorization over the call and the chain
  C->>P: POST /v1/run/resource + authorization
  P->>P: Verify the chain and the authorization
  P->>P: Check the payment voucher, if priced
  P->>P: Run exec
  P-->>C: Result
```

For a restricted function, the provider runs the function only if all of these hold:

* The first grant was issued by the provider's own key.
* Every grant's signature is valid, and none has expired.
* Each later grant was issued by the previous grant's audience, names it as its `parent`, expires no later than it, and allows nothing it doesn't.
* The authorization is signed by the last grant's audience, covers this exact resource, function, and parameters, and is under 2 minutes old.
* The authorization's nonce hasn't been used before, so a captured authorization can't be replayed.
* The last grant has a capability that matches the call.
* No grant in the chain has been revoked, and no audience in the chain is blocked.

The provider checks the grant before the payment voucher, so an unauthorized caller never reaches settlement.

## Writing capabilities

A grant holds one or more capabilities. A call is allowed when any one of them matches.

| Field | Matches |
| - | - |
| `resourcePath` | Required. An exact path such as `com.example`, a prefix such as `com.example.*`, or `*` for every path. A prefix matches on segment boundaries: `com.example.*` matches `com.example` and `com.example.maps`, not `com.examples`. |
| `resourceName` | One resource, such as `Weather`. Omit to match every resource under the path. |
| `functionName` | One function, such as `temperature`. Omit to match every function. |
| `constraints` | Parameter values the call must use. Each key is a parameter name, and the call's value must equal it (compared as JSON). |

```typescript theme={null}
// Every function on every resource under com.example.
{ resourcePath: "com.example.*" }

// Only Weather.temperature.
{ resourcePath: "com.example", resourceName: "Weather", functionName: "temperature" }

// Only Weather.temperature, and only for Oslo.
{ resourcePath: "com.example", resourceName: "Weather", functionName: "temperature", constraints: { city: "Oslo" } }
```

The provider refuses any call (`400`) whose parameters repeat a name or use a name the function doesn't declare. That way a constraint always checks the same value the function receives.

## Letting callers ask for grants

Issuing every grant by hand doesn't scale. Pass an `approveGrant` hook to `start` and callers can request grants themselves at `POST /v1/grants/request`. The hook receives the caller's key, the capabilities it asked for, and any `details` it sent, such as an invite code. Return the grant's options to approve, or `undefined` to refuse:

```typescript provider.ts theme={null}
await node.start(fastify, {
  port: 3000,
  approveGrant: ({ publicKey, capabilities, details }) =>
    details?.invite === process.env.INVITE_CODE
      ? { lifetimeMs: 24 * 60 * 60 * 1000 }
      : undefined,
});
```

The approval may also return `capabilities` narrower than the ones requested. The node checks the request's signature and freshness before calling the hook, and passes each signed request to the hook at most once, so the hook can safely use up single-use codes.

On the caller, `requestGrant` signs the request, sends it, and stores the grant it gets back. It throws if the provider refuses:

```typescript caller.ts theme={null}
await node.requestGrant(
  "http://127.0.0.1:3000",
  [{ resourcePath: "com.example", resourceName: "Weather" }],
  { invite: "letmein" },
);
```

When the hook is set, the provider's `403` for a restricted call includes a `grantEndpoint` field, so callers know where to ask. Without a hook, the provider refuses every request and only grants issued with `issueGrant` exist.

## Delegating a grant

A node that holds a grant can issue a narrower one to another key with `delegateGrant`. Name the held grant by the `id` of the last grant in its chain:

```typescript caller.ts theme={null}
const delegated = await node.delegateGrant(
  chain[chain.length - 1].id,
  otherPublicKey,
  [{ resourcePath: "com.example", resourceName: "Weather", functionName: "temperature", constraints: { city: "Oslo" } }],
  { lifetimeMs: 60 * 60 * 1000 },
);
```

The returned chain is the held chain with the new grant appended. Hand it to the other node, which stores it with `addGrant` like any other chain.

Delegation can only narrow access:

* Every delegated capability must be covered by one in the parent grant. A narrower path, an added resource or function name, or an added constraint is fine. Dropping a constraint the parent has is not.
* A delegated grant never expires later than its parent.
* A chain holds at most four grants, the root included.

The provider never sees the delegation happen. It checks the whole chain on each call.

## Expiry and renewal

Each copy of a grant lasts `lifetimeMs`, at most 30 days. Access outlives one copy through renewal: each node renews the grants it holds once they are past half their lifetime, by asking the grant's issuer at `issuerBaseUrl` for a fresh copy (`POST /v1/grants/renew`). The renewed copy keeps the same `id`.

| Option | Effect |
| - | - |
| `lifetimeMs` | How long each copy lasts. Required, at most 30 days. |
| `renewableUntil` | An ISO date when access ends. No copy expires later, and none is renewed after it. Omit to renew until the grant is revoked. |

The node checks for grants due for renewal every `grantRefreshIntervalMs` (start option, 5 minutes by default), so calls don't wait on renewal. If every held grant that covers a call has expired anyway, the node renews one before giving up. A held grant whose issuer still refuses to renew it 30 days after it expired is deleted.

## Revoking grants and blocking callers

The provider has two ways to cut access off.

**Revoking** refuses one grant, and everything delegated from it, in every call to the provider, and stops it being renewed. Revocation is permanent.

**Blocking** refuses a caller's key wherever the provider can identify it: anywhere in a restricted call's chain, in grant requests and renewals, and in paid calls, where the voucher carries the payer's key (`403 Caller is blocked`). Blocking a delegator also cuts off everything it handed on. Unblocking restores access.

<CodeGroup>
  ```typescript Code theme={null}
  // Revoke a grant this node issued.
  await node.revokeGrant(grantId);

  // Revoke a grant delegated further down a chain. Issuers choose their own ids, so name the issuer too.
  await node.revokeGrant({ issuer: delegatorPublicKey, id: grantId });

  // Block a caller's key, and lift the block later.
  await node.blockCaller(callerPublicKey, "abuse");
  await node.unblockCaller(callerPublicKey);
  ```

  ```bash HTTP theme={null}
  # Revoke a grant this node issued.
  curl -X PUT "http://127.0.0.1:3000/v1/admin/revoked-grants/$GRANT_ID" \
    -H "Authorization: Bearer $API_KEY"

  # Revoke a grant delegated further down a chain.
  curl -X PUT "http://127.0.0.1:3000/v1/admin/revoked-grants/$GRANT_ID?issuer=$DELEGATOR_KEY" \
    -H "Authorization: Bearer $API_KEY"

  # Block a caller's key, list blocked keys, and lift the block.
  curl -X PUT "http://127.0.0.1:3000/v1/admin/blocked-callers/$CALLER_KEY" \
    -H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
    -d '{"reason": "abuse"}'
  curl "http://127.0.0.1:3000/v1/admin/blocked-callers" -H "Authorization: Bearer $API_KEY"
  curl -X DELETE "http://127.0.0.1:3000/v1/admin/blocked-callers/$CALLER_KEY" \
    -H "Authorization: Bearer $API_KEY"
  ```
</CodeGroup>

Where a revocation happens matters. On the provider, it takes effect on the next call. On a delegating node, it only stops that node renewing the grant, because the provider never sees it, so the delegated grant keeps working until its current copy expires. To cut off a delegated grant at once, revoke it on the provider with its issuer's key.

<Warning>
  Unrestricted, free functions stay open to everyone, blocked keys included, because their callers don't identify themselves.
</Warning>

## Limits and responses

| Limit | Value |
| - | - |
| Lifetime of one grant copy | 30 days |
| Grants in a chain | 4, root included |
| Lifetime of an authorization, grant request, or renewal request | 2 minutes |
| Clock skew tolerated for signers running ahead | 30 seconds |

| Status | Body | When |
| - | - | - |
| `403` | `{ "error": "Authorization is required", "grantEndpoint"? }` | A restricted call arrived without an authorization |
| `403` | `{ "error": "Invalid authorization", "grantEndpoint"? }` | The chain or authorization failed a check, or a key in it is blocked |
| `403` | `{ "error": "Grant request refused" }` | `approveGrant` refused, or the request was invalid, reused, or from a blocked key |
| `403` | `{ "error": "This node does not issue grants on request" }` | The provider has no `approveGrant` hook |
| `403` | `{ "error": "Grant renewal refused" }` | The grant was revoked, is past `renewableUntil`, or its audience is blocked |
| `400` | `{ "error": "Parameters must be declared by the function and appear once each" }` | A call repeated or invented a parameter name |

<Note>
  Authorization nonces are stored in the node's SQLite database, so they survive a restart. Replicas of one node that don't share a database don't share nonces either.
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.