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

# RP Server SDK

> Reference for @vibechecklabs/array-identity-server. Sign requests, verify tokens offline, poll roots, and burn nonces.

<Note>
  **Maturity:** Limited preview (`0.7.0`). Every verification in a
  relying-party integration happens here.
</Note>

<Warning>
  **Server-side only.** This package carries a scoped machine-to-machine (M2M) API key
  and authenticates server to server. Never ship it to a mobile app or a browser.
</Warning>

```bash theme={null}
pnpm add @vibechecklabs/array-identity-server
# fastify is an OPTIONAL peer, needed only for the /fastify subpath
```

## Two entry points

The package root is framework-free. Everything Fastify lives behind
`/fastify`, which is what keeps the optional peer dependency real rather than
aspirational.

```ts theme={null}
import { verifySelfIssuedToken } from "@vibechecklabs/array-identity-server";
import arrayIdentityServer, { selfIssuedPreHandler }
  from "@vibechecklabs/array-identity-server/fastify";
```

## Signing

Both signers use the same registered presenter key. The `typ` is what
distinguishes the artifacts and routes them on the device.

| Factory | Signs |
| - | - |
| `createPresentationRequestSigner({ privateJwk, kid })` | The inbound sign-in request |
| `createPresenterProofSigner({ privateJwk, kid })` | Per-request possession proof (DPoP-shaped) |

`OKP`/Ed25519 and `EC`/P-256 keys are both accepted. The proof signer is
structural, so anything with `sign({ htm, htu, grant, body? })` works. You can
sign from your own key management service.

## Verifying a login token

```ts theme={null}
const verdict = await verifySelfIssuedToken(token, {
  audience: "my-rp",                          // must equal the token aud exactly
  expectedMode: "disclosed",                  // the mode you requested, required
  consumeNonce: (n) => nonces.consume(n),
  // clockSkewSeconds? default ±60
  idFacetProof: {                             // required to reach unique: true
    roots: { isAcceptableRoot: (root, ref) => roots.isSignedAndFresh(root, ref) },
    dedup: myTagDedupStore,
  },
});
// → { tier, sub, unique, mode, idFacet }
```

Checks, in order: the explicit `typ` and algorithm pin, and rejection of `jku`,
`x5u` and unknown critical headers; the signature against the embedded
`did:jwk`; audience and issuer; the holder-of-key binding; the single-use
nonce; the subject mode against `expectedMode`; and, for chain-aware tokens,
self-certification plus the Baby Jubjub Schnorr key-binding signature. The last
two hard-fail and never downgrade the tier.

`expectedMode` is required. Pass the mode you asked for: `"disclosed"` if the
request set `scope: "arrayid"`, `"pairwise"` if it did not. A token of the other
mode throws `subject_mode_mismatch` before any proof work. This matters most if
you key accounts on the ArrayID. A pairwise token's `sub` is not bound to the
signing key unless a membership proof verifies, so without the check anyone
could sign a pairwise token naming someone else's ArrayID and be accepted as
them. A tier floor doesn't replace it, because a pairwise token with a valid
proof reaches `membership_proven`. `selfIssuedPreHandler` takes the same
options, so it needs `expectedMode` too.

Given an `idFacetProof` config the SDK additionally checks six obligations and
gates `unique` on all six. **You implement none of them.** Two are worth
knowing about because they are what makes the proof non-transferable: the scope
is recomputed from your own `client_id`, and the payload hash binds the
specific nonce you burned. A proof therefore fails at any other RP and cannot
be replayed at yours.

`IdFacetProofError.code` is one of `groth16`, `root`, `scope`, `slot`,
`payload`, `mode`, `tag`, `replay`, `malformed`, `verifier_unavailable`.

<Note>
  `verdict.mode` and `verdict.idFacet` are server-internal. The facet is the
  user's stable pseudonym at your RP, so keep it out of response bodies and
  per-user log lines.
</Note>

## The roots provider

```ts theme={null}
const roots = new DefaultRootsProvider({
  feedUrl: process.env.ROOTS_FEED_URL!,
  apiKey: process.env.ROOTS_FEED_API_KEY!,        // needs the roots:read scope
  fetchImpl: fetch,
  pinnedOperatorKeys: [pinnedBootstrapKey(process.env.ROOTS_OPERATOR_BOOTSTRAP_PUBKEY!)],
  expectedChainId: process.env.ROOTS_EXPECTED_CHAIN_ID!,
  expectedContractAddress: process.env.ROOTS_EXPECTED_CONTRACT_ADDRESS!,
  clamps: { minWindowDepth: 4, maxWindowDepth: 64, minPollIntervalS: 15, maxPollIntervalS: 600 },
});
await roots.start();
```

It polls in the background and verifies every entry against your pinned key, so
the verification path makes no network call. The feed is transport, never
authority, so a tampering relay can stall you but cannot forge for you. The
key set the feed serves is diagnostic and is never a verification input.

Freshness is **positional, not time-based**: the last N distinct roots, not
roots newer than some age. Roots advance only on tree activity, so a time rule
would reject valid proofs during quiet periods.

```ts theme={null}
if (!roots.warm) return reply.code(503).send({ code: "membership_verification_unavailable" });
```

<Warning>
  Gate on `warm` upstream of your verifier. A cold provider throws, and the right
  answer is 503, not 401. Cold is routine (fresh deploy, disabled poller, pins
  not yet issued), whereas a 401 tells the device its credential is bad.
  `selfIssuedPreHandler` maps any unrecognised throw to `401 verify_failed`, by
  which point the nonce is consumed.
</Warning>

## Nonce and state stores

The single-use nonce your verifier burns. **You run this, and Array never
hosts it.** It is a pure library with no calls to Array.

```ts theme={null}
const backend = new DynamoDbNonceBackend({ client: docClient, tableName: "rp-auth-nonce" });
const nonces = createNonceStore(backend);   // 300s TTL by default
const states = createStateStore(backend);   // shares the backend, no key collisions

const nonce = await nonces.issue();
const state = await states.issue(sessionId);
await states.consume(state, sessionId);     // false if replayed, expired, or wrong session
```

`consume` returns true exactly once per issued value. A backend outage makes
`consume` return false (fail-closed). `issue` throws rather than hand out a
value that was never durably stored. `MemoryNonceBackend` covers tests, and the
two-method `NonceBackend` interface takes a Redis or Postgres implementation.

<Warning>
  Use the exported `isConditionalCheckFailure` to tell a refusal from an outage.
  Do not test `err.name` yourself. The DynamoDB marker's location is not stable
  across `@aws-sdk` versions, so a name-only check reads every refusal as an
  outage on one version and works on another, turning a replayed proof into a
  503 instead of a 401.
</Warning>

## Also exported

The verdict ladder (`VERDICT_TIERS`, `tierAtLeast`); verify-side constructors
with byte-parity against the wire contract (`decodeDidJwk`, `selfCertifies`,
`schnorrVerifyFr` and others); and typed errors, namely `SelfIssuedVerifyError`,
`DpopVerifyError`, `PresenterProofError` and `ArrayIdentityError`. The wire
contract's types and constants are re-exported, so you rarely need to import
`@vibechecklabs/array-identity-contract` directly.


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