# Link capabilities

Source: https://permdock.com/docs/concepts/capabilities

A share link is a signed capability that holds roles on one resource, optionally narrowed to a few permissions; it resolves to a link principal, expires, can be revoked or used once, and reaches RLS through a short-lived Supabase token.

A guest quote page, a file share link, a read-only chat thread, a pre-boarding form: each lets someone without an account act on one record. PermDock models all of them as a **capability**, a signed token that holds roles on one resource instance. The resolver turns it into a subject whose only membership is on that resource, so `can()`, `where()`, snapshots and RLS treat the link holder exactly like a member of the resource and nothing more.

## Declaring what a link may do [#declaring-what-a-link-may-do]

A link holds ordinary resource-scoped roles. Declare them next to the others:

```ts
export const policy = definePolicy(permissions, {
  scopes: { organization: { key: "organization_id" } },
  roles: [
    role("staff", [allow(permissions.quote.read)], { on: "organization" }),
    role(
      "guest",
      [
        allow(permissions.quote.read, { where: { status: "sent" } }),
        allow(permissions.quote.accept, { where: { status: "sent" } }),
      ],
      { on: permissions.quote },
    ),
    role(
      "commenter",
      [allow(permissions.file.read), allow(permissions.file.comment)],
      { on: permissions.folder },
    ),
  ],
  subject: subjectFromSupabase,
});
```

A role held on a parent resource reaches its children through the child's `parent` field, so a `commenter` link on folder `f_1` reads the files whose `folder_id` is `f_1` and never a sibling folder's ([tenancy](/docs/concepts/tenancy)).

## Issuing a link [#issuing-a-link]

`signCapability(input, signer, { audience })` from `permdock` signs a `permdock-capability+jwt` with any `TokenSigner` ([JOSE](/docs/standards/jose)):

```ts
import { signCapability } from "permdock";
import { joseTokenSigner } from "permdock/jwt";

const signer = joseTokenSigner({
  key: privateJwk,
  alg: "ES256",
  kid: "2026-09",
  issuer: "https://app.example.com",
});

export async function shareQuote(permdock: PermDock, quote: Quote) {
  permdock.assert(permissions.quote.share, quote);
  const link = await db.insert(links).values({ quoteId: quote.id }).returning();
  const token = await signCapability(
    {
      id: link.id,
      on: { resource: permissions.quote, id: quote.id },
      roles: ["guest"],
      expiresAt: Math.floor(Date.now() / 1000) + 30 * 86_400,
    },
    signer,
    { audience: "https://app.example.com" },
  );
  return `https://app.example.com/portal/quotes?token=${token}`;
}
```

| Field | Meaning |
| --- | --- |
| `id` | The link id: the token's `sub` and the handle the application revokes. Keep a row per link. |
| `on` | The resource reference and instance id the roles are held on. |
| `roles` | Resource-scoped role names. A scope or global role in the list grants nothing, because a link has no scope membership. |
| `permissions` | Optional permission references that narrow the roles further, for example a read-only copy of an editor link. |
| `redeemer` | Who may open it: `'anyone'` (the default), `'signed-in'`, `{ user }` or `{ scope, id }`. |
| `once` | One request per token. |
| `expiresAt` | Required. It is the token's `exp` and the membership's `expiresAt`; a long-lived link pairs a long expiry with revocation. |

Issuing is application code. Guard it with its own permission (`quote.share` above) so only someone who may share the quote can mint a link for it.

## Opening a link [#opening-a-link]

`subjectFromCapability(token, options)` from `permdock/jwt` verifies the token and returns the subject it acts as. Like every `subjectFrom*` resolver it never throws: any failure is the anonymous subject, reported through `onAuth`.

```ts
import { subjectFromCapability } from "permdock/jwt";

const subject = await subjectFromCapability(url.searchParams.get("token"), {
  jwks: "https://app.example.com/.well-known/jwks.json",
  issuer: "https://app.example.com",
  audience: "https://app.example.com",
  revoked: async (id) =>
    (await db.query.links.findFirst({ where: eq(links.id, id) }))?.revokedAt !=
    null,
  replay, // a ReplayStore, required for once: true
  viewer: await session(), // the request's own verified subject, for redeemer checks
});
const permdock = createPermDock(policy, subject);
permdock.can(permissions.quote.read, quote); // true for the linked, sent quote only
```

The subject is:

```json
{
  "principal": {
    "id": "lnk_1",
    "kind": "link",
    "issuer": "https://app.example.com",
    "memberships": [
      {
        "on": { "resource": "quote", "id": "q_1" },
        "roles": ["guest"],
        "via": "link",
        "expiresAt": 1791600000
      }
    ],
    "capability": {
      "v": 1,
      "id": "lnk_1",
      "holder": "link",
      "on": { "resource": "quote", "id": "q_1" },
      "roles": ["guest"],
      "expiresAt": 1791600000
    }
  },
  "delegation": { "scopes": ["quote:read"] },
  "context": {},
  "expiresAt": 1791600000
}
```

`delegation` appears only when the capability lists `permissions`; a check outside them is denied with `not-delegated`. The membership's `via: 'link'` tells audit and UI that the access came from a link. A link principal never satisfies another link's `redeemer`.

The resolver refuses, in this order: a token that fails verification (`typ`, `iss`, `aud`, signature, `exp`), a `capability` claim that is not a valid v1 object or whose `id` is not `sub`, a `holder` other than `link`, an expiry in the past, a `redeemer` the `viewer` does not satisfy (`redeemer-mismatch`), a revoked id (`capability-revoked`) and a one-time token whose `jti` was already claimed (`capability-replayed`). A one-time capability without a `replay` store is refused, and a `revoked` callback or store that throws denies with reason `source-threw`. A link its scope's link policy refuses is `link-policy` (below). The one-time claim runs last, so a request refused for another reason does not burn the link ([JWT adapter](/docs/adapters/jwt)).

## Link policy per scope [#link-policy-per-scope]

A tenant can tighten what links on its resources may be, and resolution refuses a link that breaks the rules, including one issued before the tenant tightened them. A `LinkPolicy` has three optional rules, each of which only narrows:

| Rule | Refuses |
| --- | --- |
| `maxLifetime` | A link whose `expiresAt` is more than this many seconds after it was issued (`iat`). A token without `iat`, or a value that is not a finite non-negative number, is refused. |
| `redeemers` | A link whose redeemer kind (`anyone`, which also covers no `redeemer`, `signed-in`, `user` or `scope`) is not listed. An empty list refuses every link. |
| `once` | A link that is not one-time. |

The policy lives with the scope instance that owns it, in the application's tables, so the resolver asks for it through a callback that receives the verified capability. Return the policy of every scope instance the resource sits in (the organization and the customer, say); all of them must hold:

```ts
const subject = await subjectFromCapability(token, {
  jwks,
  issuer,
  audience,
  revoked,
  linkPolicy: async ({ on }) => {
    const quote = await db.query.quotes.findFirst({
      where: eq(quotes.id, on.id),
    });
    return quote === undefined
      ? { redeemers: [] }
      : db.linkPoliciesFor([quote.organization_id, quote.customer_id]);
  },
});
```

A refused link is anonymous with cause `link-policy`, and a callback that throws denies with reason `source-threw`. The check runs before revocation and one-time use, so a refused one-time link is not burned. Pass the same policies to `signCapability(input, signer, { linkPolicy })` and it rejects instead of signing a link resolution would refuse; `linkPolicyViolation(capability, policy, issuedAt)` returns the broken rule (`lifetime`, `redeemer` or `once`) for a UI that explains it.

## Snapshots and clients [#snapshots-and-clients]

A link subject is an ordinary subject: `permdock.snapshot()` carries its membership and delegation, and `fromSnapshot` in the browser agrees with the server for every portable grant ([snapshots](/docs/concepts/snapshots)). A capability in `searchParams` is request data: verify it at request time or in a private cache entry, never inside a shared `'use cache'` function.

## RLS [#rls]

The database cannot verify a PermDock capability, so the server exchanges a verified link for a short-lived Supabase access token and queries with that:

```ts
import { exchangeCapability } from "permdock/supabase";

const accessToken = await exchangeCapability(subject, {
  key: supabaseSigningJwk, // a private key imported into the project's JWT signing keys
  alg: "ES256",
  kid: "permdock-links",
  ttl: 300,
});
const supabase = createClient(url, publishableKey, {
  global: { headers: { Authorization: `Bearer ${accessToken}` } },
});
```

The token carries `role: 'anon'`, the capability under a `capability` claim, `iat` and `exp` (the `ttl`, default 300 seconds and at most an hour, never past the capability's own expiry), and no `sub`, because `auth.uid()` casts `sub` to a uuid and a link is not a user. A legacy project passes `{ key: { secret }, alg: 'HS256' }` with its JWT secret. `exchangeCapability` returns `undefined` for anything but a live link subject.

`permdock rls generate --capabilities` (or `rls.capabilities: true`) adds `permdock_capability_ids(p_resource, p_role, p_permission)`, which returns the resource id the claim names when its roles include `p_role`, its `permissions` (when present) include `p_permission` and it has not expired. Every resource-scoped grant gets an `anon` policy that calls it once per statement:

```sql
create policy quote_select_anon on public.quote for select to anon using (
  ("id"::text in (select permdock.permdock_capability_ids('quote', 'guest', 'quote.read'))) and ("status" = 'sent')
);
create policy file_select_anon on public.file for select to anon using (
  "folder_id"::text in (select permdock.permdock_capability_ids('folder', 'commenter', 'file.read'))
);
```

A resource role with no memberships table (a role only links hold, such as `guest`) gets only the `anon` policy, with a warning, instead of failing generation. Revocation reaches the database within the exchanged token's `ttl`. [RLS](/docs/cli/rls) has the flags.

## Reserved: user-bound keys [#reserved-user-bound-keys]

`holder: 'key'` stays reserved and the resolver refuses it. User-bound and service API keys shipped as opaque `pdk_` keys that the application looks up on every use, so revocation, a tightened tenant rule and the owner's current rights always apply ([API keys](/docs/concepts/credentials)); they are not capabilities and add no token format. `key` remains free for a stateless key, such as a calendar feed URL, should one ever be needed.

## Why [#why]

Share links are where most SaaS products leak: an unguessable URL with no expiry, no revocation and a hand-written RPC that bypasses row security. Modelling a link as a membership on one resource keeps it inside the same evaluation as everyone else, so a condition such as "only sent quotes" applies to guests without a second code path, and inheritance to child resources is the same parent walk a member gets. A link can never exercise a scope or global role, because it has no scope membership to match.

The capability is a signed token rather than a database lookup so a link works where the application has no session, and so the same object feeds `can()`, the snapshot and RLS. Signing reuses the `TokenSigner` and JWK Set every other PermDock output uses, and the dedicated `typ` means a capability is never accepted as an access token and an access token is never accepted as a capability. The link policy is read at resolution rather than trusted from the token, because a tenant that tightens its rules means the links already out there too, and it is a callback because only the application knows which scope instances a resource sits in. Revocation keys on the link id rather than the token's `jti` because a product revokes the link, whichever token it was sent as. One-time use keys on the `jti`, so reissuing a one-time link produces a fresh use.

For RLS the server exchanges the capability for a short-lived `anon` token signed with the project's key instead of calling a security-definer RPC per resource. The generated policies then read one claim through one helper, stay uncorrelated, and let the same `where` conditions that apply in memory narrow the rows; the database never has to trust anything PermDock did not sign through the project's own key. Keeping `holder` in the v1 format now lets user-bound keys arrive without another wire change.
