npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@digicredholdingsinc/did-graphql-server

v0.7.1

Published

Server-side ZCAP-LD invocation checking for a GraphQL resource server — verifies did:key + eddsa-jcs-2022 entirely in-process (no agent, no database). Includes an explicit UNSAFE_MODE for dev/test without real capabilities.

Readme

@digicredholdingsinc/did-graphql-server

Resource-server ZCAP checks for a GraphQL API. It decodes the Capability-Invocation header, enforces allowedAction, and verifies the chain and invocation entirely in-process — did:key + eddsa-jcs-2022 Data Integrity proofs, no external agent call and no database read from inside this package. This package holds no signing keys of its own; the public key it verifies against comes straight from the presented did:key string. Signing the invocation is the invoking client's job, never this package's.

See the repo README for how the pieces fit. This page is the server API, attenuation rules, and the optimizations that are already in place.

Install

npm install @digicredholdingsinc/did-graphql-server

Public on npmjs.org — no registry auth, no .npmrc, no token. The tarball ships a prebuilt dist/, so nothing compiles at install time.

Node-only. Depends on graphql (query parse / field-subset) and bs58 (did:key decoding). JCS canonicalization for eddsa-jcs-2022 is vendored (src/jcs.ts) rather than taken as a dependency — see that file for why.

Design: who resolves what

This package does pure cryptographic and structural verification only. It is deliberately ignorant of two things a real resource server needs, on purpose:

  • Which root capability is trusted for this request. A client only ever sends the delegated leaf — the root it descends from is never transmitted (unsigned, trusted by local dereference per the ZCAP-LD spec). Resolving which root is trusted for a given request — normally a database lookup keyed by (controller, id, invocationTarget) — is the caller's job. This package never queries a database and never reconstructs a root on its own; you hand it the root capability you already trust, and it checks the presented leaf against exactly that object.
  • Which account or tenant a request belongs to. Not a concept this package has at all. Whatever resolved rootCapability you pass in already implies it; there is no separate resolution step here.

Concretely: the library never calls out to an agent or key service, and never opens a database connection. Both are the consuming resource server's responsibility, using whatever store it keeps its trusted roots in.

Usage

import {
  configureZcap,
  decodeInvocationHeader,
  checkAuthOnly,
  checkInvocation,
} from '@digicredholdingsinc/did-graphql-server'

// rootCapability is whatever YOUR lookup resolved for this request —
// e.g. a `zcap_capabilities` row keyed by (controller, id, invocationTarget)
// derived from the Host header. Never reconstructed by this package.
const zcapConfig = configureZcap({
  rootCapability,                          // { id, controller, invocationTarget, ... }
  expectedInvocationTarget: 'https://…/graphql', // derived from this request's Host header
})

// Hand it the headers; it finds what it needs. Works with Node's
// req.headers, a fetch Headers, or a plain record.
const payload = decodeInvocationHeader(req.headers)

// Diagnostic: query Auth { auth { zcap { valid } } } — chain only, no invocation.
const auth = checkAuthOnly(zcapConfig, payload)

// Real resolver: chain + allowedAction + signed invocation.
const gate = checkInvocation(zcapConfig, payload, rawQueryText)
if (!gate.ok) {
  // gate.code: CAPABILITY_INVALID | QUERY_NOT_ALLOWED | INVOCATION_INVALID
  // gate.problems: ProblemDetail[] — typeURI-tagged, see "Problem details" below
}

checkAuthOnly/checkInvocation are synchronous — no I/O happens inside this package at all.

GraphQL modules

authModule and caseModule() are GraphqlModules. Each splices exactly one field onto type Queryauth and case — with its own fields on a namespace type behind it, so a host server's own root fields never collide with a module's. composeModules concatenates SDL, merges resolvers, and unions defaultQueries (GraphiQL / sandbox allowedAction).

  • authquery Auth { auth { zcap { valid } } } (checkAuthOnly, no invocation), under Query.auth.
  • case — raw IMS CASE 1.1 (cfDocuments, cfPackage, cfItem, …) under Query.case, gated by checkInvocation. Any opinionated shape over that vocabulary stays in the consuming server. Full field/query reference: src/case/README.md.
import { authModule, caseModule, composeModules, mergeResolvers, attachResolvers } from '@digicredholdingsinc/did-graphql-server'

const composed = composeModules([authModule, caseModule()])
const schema = buildSchema(`${myTypeDefs}\n${composed.sdl}`) // or splice queryFields into your Query
attachResolvers(schema, mergeResolvers(composed.resolvers, { Query: myQueryResolvers }))

GraphiQL defaultQuery is authModule.defaultQueries[0] (AUTH_QUERY).

Type names are global

Query fields are namespaced (Query.auth, Query.case), but GraphQL has no namespacing for type names — a schema has exactly one flat type registry, and buildSchema rejects a duplicate definition outright. Composing these modules therefore claims these names in your schema:

| From | Types | |---|---| | auth | AuthQueries, Zcap | | case | CaseQueries, CFDocument, CFDocumentResults, CFItem, CFItemResults, CFItemTypeCount, CFPackage, CFAssociation, CFAssociationResults, CFAssociationEndpoint, CFURIReference, and the JSON scalar |

The CF* names come from the CASE 1.1 vocabulary and are unlikely to collide. JSON is the one to watch: plenty of servers define their own scalar JSON, and if yours does, buildSchema fails on the duplicate. Zcap is generic enough to be worth a glance too.

A collision in SDL fails loudly, at startup, which is the safe direction.

Resolver collisions throw, they don't merge

The quieter hazard used to be the resolver merge. Resolver maps are keyed by type name and field name, and merging them with spreads ({ ...existing, ...fields }) means last writer wins: two modules, or a module and your own map, naming the same type and field would silently run whichever came last. That matters when the loser is the gated one — the SDL still advertises a gated field while the wired resolver never calls checkInvocation, and unlike most wiring mistakes this one fails open, with data flowing and nothing logged.

composeModules now refuses it, and mergeResolvers is exported for merging your own maps against a module's:

mergeResolvers(composed.resolvers, { Query: myQueryResolvers })
// ResolverCollisionError: map #2 redeclares Query.case, already provided by module 'case' —
// a silently shadowed resolver can drop an authorization check; merge deliberately if you meant to override it

Three things collide: the same field on the same type, the same custom scalar twice, and a type one map declares as a custom scalar while another declares field resolvers on it (either order — that last one would otherwise drop a whole resolver entry without a word). Adding your own distinct fields to a type a module also resolves is fine. Pass { label, resolvers } instead of a bare map to get your own name in the message — a string label is what distinguishes it from a map with types of those names. A deliberate override is still possible by spreading by hand; it just has to be deliberate.

This is worth caring about most when a single resolver carries a whole surface's authorization. Hoisting a ZCAP check onto a namespace field (catalog: async (…) => { await requireAuthorizedQuery(…); return {} }, with no per-field checks underneath) is a real simplification — one check, impossible to forget on a new field — but it also means shadowing that one resolver ungates every field behind it at once. If you do that, a test that runs an unauthorized document through the composed schema and asserts it is refused is the cheap way to notice.

Configuration

ZcapServerConfig

A union of two shapes:

| Field | Required | What it does | |-------|----------|----------------| | rootCapability | yes (real mode) | The trusted root capability object for this request, resolved by the caller's own lookup. Its id is what the leaf's parentCapability is checked against; its controller (must be a did:key) is who the leaf's delegation proof must be signed by. | | expectedInvocationTarget | yes (real mode) | The target this request expects — derived from e.g. the Host header + a fixed path. The leaf's own invocationTarget must equal this. | | unsafeMode | default false | Skip all cryptographic verification. Structural shape + expiry + allowedAction only, checked against trust.trustedRootController/trust.expectedInvocationTarget (a fixed pair, not a per-request lookup). Dev/test only. |

Only did:key root controllers are supported — any other DID method fails closed with an UNSUPPORTED_CONTROLLER problem. There is no agent fallback for other methods.

What the gate actually checks

checkInvocation, in order:

  1. Leaf present.
  2. Chain valid (verifyChain in localVerify.ts):
    • both root and leaf controllers are did:key,
    • leaf invocationTarget matches expectedInvocationTarget (the Host-header cross-check),
    • leaf parentCapability matches the resolved root's id (a separate, explicit check — not folded into any lookup),
    • leaf not expired,
    • leaf's delegation proof (proofPurpose: capabilityDelegation) is signed by the root's controller, and verifies as eddsa-jcs-2022.
  3. allowedAction membership (see below).
  4. A real capabilityInvocation proof, signed by the leaf's own controller (the current invoker — a different signer than step 2's delegation proof), matching this capability/target/query, and verifying as eddsa-jcs-2022.

checkAuthOnly stops after step 2 (unsafe mode: structural + expiry + optional target pin).

allowedAction attenuation

Entries are real GraphQL documents, not coarse verbs. Two matches:

  1. Exact — whitespace-normalized string equality. Cheap; this is the common case when a client sends a registered query verbatim.
  2. Field subset — the operation type matches, and the request's root fields, and every nested field under them, are a subset of some registered entry. The operation-type check matters: a schema may expose the same name on Query and Mutation, so matching on fields alone would let a capability granting query Thing { thing { a } } authorize mutation Thing { thing { a } }. An anonymous { ... } is a query, per GraphQL's own default. Trimming, reordering, or dropping fields of an already-allowed query works with no extra catalog entry. __typename is ignored (GraphQL metadata). Named fragment spreads are not supported and fail closed.

Argument values (limit, filter, …) are not constrained. A client allowed to query a field may pass any variables to it. Value-level caveats are a separate, unbuilt axis.

Inline fragments (... on SomeType) are walked, as an interface- or union-typed field needs them. Aliased duplicate root fields union their selections.

What the gate does not cover: schema introspection

checkInvocation runs inside a field resolver. __schema and __type are graphql-js built-in meta-fields with no resolver of ours, so a document selecting only those reaches no gated code path and is answered straight from the schema — with no capability present at all. No row of data leaks, but the whole API shape does: every type, field, and argument name, including any administrative surface a host has composed in.

Close it with one call per request, before graphql():

import { checkIntrospection } from '@digicredholdingsinc/did-graphql-server'

const introspection = checkIntrospection(zcapConfig, payload, body.query)
if (!introspection.ok) {
  // introspection.code === 'INTROSPECTION_NOT_ALLOWED'
  return sendJson(200, { data: null, errors: [{ message: introspection.message, extensions: { code: introspection.code } }] })
}

A document that doesn't introspect always returns { ok: true }, so this is safe to call unconditionally — the per-field gate still does all the real authorization work.

Validate query first, though. A JSON body yields whatever the client sent: {} gives you undefined, and {"query": 123} gives you a number. This function tolerates a non-string (it answers false/{ ok: true } rather than throwing, since a non-string is not a document), but your own handler still has to reject it — otherwise it reaches graphql() as a bad source, and any throw inside an async request handler is an unhandled rejection that ends the process rather than the request:

if (typeof body.query !== 'string' || body.query.trim() === '') {
  return sendJson(400, { error: 'body.query must be a non-empty string' })
}

Three policies, as the fourth argument:

| Policy | Introspection allowed for | |---|---| | authorized (default) | Any request presenting a structurally valid, unexpired chain for this invocationTarget — what checkAuthOnly reports. Not allowedAction membership: no real capability lists GraphiQL's introspection document, and knowing the shape of an API you already hold a capability for discloses strictly less than the data behind it. In unsafeMode this accepts the same structural check everything else does, so a dev GraphiQL page keeps working. | | public | Everyone. The behavior before this existed — correct for an intentionally public schema. | | off | Nobody, capability or not. |

containsSchemaIntrospection(query) is exported separately if you want the predicate without the policy. It follows aliases ({ s: __schema { … } }), inline fragments, and named fragment spreads — introspection hidden a hop away in a fragment is the case a naive string or root-field check misses:

query Q { ...F }
fragment F on Query { __schema { types { name } } }

__typename is not treated as introspection: it discloses only the type of something the caller already selected, the same reason matchesAllowedAction ignores it.

Conformance with the ZCAP spec

The capability data model, the Capability-Invocation header encoding, root dereferencing, allowedAction, caveat and capabilityChain all follow the spec. Three things deliberately do not, and it is better to state them than to let someone discover them.

The header value is emitted bare, and parsed either way. The spec's examples show capability={base64url(gzip(json(capability)))} with no quotes; unpadded base64url contains nothing that needs quoting. Both forms are accepted on parse, since a sender may reasonably quote.

Invocation via HTTP trailers is not supported. §Example 10 shows the same headers sent as trailers under chunked encoding. decodeInvocationHeader reads request headers, not trailers.

Two invocation proof mechanisms, and the spec's is preferred. RFC 9421 HTTP Message Signatures — Content-Digest, Signature-Input, Signature, over exactly the components §Example 9 lists — is supported. The older embedded eddsa-jcs-2022 Data Integrity invocation, carried in a non-spec invocation parameter, is still accepted so existing signers can migrate on their own schedule.

They are not equivalent:

| | HTTP Signatures | embedded invocation | |---|---|---| | binds method and path | yes | no | | binds the request body | yes, via Content-Digest | the query text only | | binds the capability header | yes | no | | freshness | created, windowed | created, windowed |

A request carrying Signature-Input is verified that way and the embedded path is not consulted, so a sender cannot present a weak proof alongside a strong one and have the weak one accepted.

Verifying a signature needs the request itself — method, path, headers, body — none of which is reconstructible from the capability header, so checkInvocation takes an optional fourth argument carrying them. Omit it and only the embedded path is available.

No action property. This is not a divergence. §4.3 makes action optional — a target "MAY support the action property ... as one common behavioral direction technique" — and states that "targets are free to choose their own mechanisms for directing behavior". Authorizing by literal GraphQL document is such a mechanism.

It would not fit even if it were required: action "points to a URI as a form of vocabulary" (https://datastore.example/WriteFile), so it names a coarse verb from a controlled vocabulary. A GraphQL document is neither a URI nor coarse, and a URI naming Query or Mutation would grant every query or every mutation — the opposite of per-document authorization. The delegated examples (§Example 9, 10) carry only capability=; the action is the request body, bound by Content-Digest and the signature.

Root zcaps cannot be invoked (id= form). Only delegated capabilities are accepted. A root here is purely a trust anchor: it carries no proof, and the verifier resolves it rather than receiving it. Admin-style access is expected to use a delegated leaf per admin, which gives per-admin revocation and attribution that direct root invocation would not.

Multi-level delegation is refused, not partly checked. capabilityChain longer than root → leaf is rejected outright rather than verified one link deep and trusted for the rest.

Invocation freshness

An invocation proof binds the invocationTarget and the exact query text, so a captured header cannot be pointed at another endpoint or reused for a different query. It did not bind time: the only deadline was the capability's expires, typically months out, which left a captured header replayable for its one query for that whole period.

proof.created is now checked against a window. It is inside the signed proof options, so it cannot be adjusted by whoever captured the header.

| option | default | meaning | |---|---|---| | invocationMaxAgeSeconds | 300 | How long a signed invocation stays valid. 0 disables the check. | | invocationClockSkewSeconds | 60 | How far ahead of this server's clock a created may be. |

Five minutes is the usual HTTP-Signatures window — long enough to absorb ordinary clock drift and a slow mobile network, short enough that a captured header is worth little. Skew is allowed in both directions because client clocks run fast about as often as slow, and rejecting those is the same outage as rejecting stale ones.

A missing or unparseable created fails closed. Every signer in use sets it, so its absence is either a broken client or an attempt to opt out of the window — treating it as fresh would make the check trivially bypassable.

Rejections surface as INVOCATION_STALE, distinct from PROOF_INVALID: a replayed header and a forged one warrant very different responses.

This is a behaviour change for existing deployments. A client whose clock is off by more than the window will start failing. If that happens, fix the clock rather than widening the window — and invocationMaxAgeSeconds: 0 restores the previous behaviour if you need to unblock first.

Problem details

Every rejection reason is a ProblemDetail ({ typeURI, title, detail }), drawn from a fixed vocabulary in problemDetails.ts:

urn:zcap:problemDetail:error:{SLUG}MALFORMED_CAPABILITY, UNSUPPORTED_CONTROLLER, UNSUPPORTED_CRYPTOSUITE, PROOF_INVALID, ROOT_CAPABILITY_UNKNOWN (raised by the caller's own lookup, not this package — reserved here for that purpose), PARENT_CAPABILITY_MISMATCH, INVOCATION_TARGET_MISMATCH, ATTENUATION_INVALID, EXPIRED, ACTION_NOT_ALLOWED, INVOCATION_MISSING.

urn:zcap:problemDetail:warning:{SLUG}LEGACY_ROOT_FIELDS, EXPIRES_SOON. Warnings never cause verified: false on their own.

checkAuthOnly's PresentedZcap.problems and checkInvocation's InvocationCheckResult.problems both carry the raw list; reason/message are the same information flattened to a string for convenience.

Optimizations already in place

No I/O, ever, in the real path. Every check in localVerify.ts is a pure function over the objects you hand it — no network call, no agent, no database.

Exact match before parse. matchesAllowedAction compares normalized strings first. The GraphQL parser and field-subset walk run only on a miss.

Subset attenuation. Register the widest query you are willing to allow. Leaner client queries (fewer fields, different order) do not need their own allowedAction rows.

Fail closed on exotic GraphQL. Unparseable documents, missing operations, or named fragments return "not allowed" rather than a partial allow.

unsafeMode short-circuit. Dev/test skips every cryptographic check. Production must leave this off; configureZcap warns when it is on.

Caching

There is nothing to cache here that this package owns — no access token, no agent round-trip, no DB connection. The one thing worth caching is the caller's own root-capability lookup (the (controller, id, invocationTarget) read) — that's outside this package's scope, and belongs in whatever store the consuming server keeps its roots in.

unsafeMode

configureZcap({
  unsafeMode: true,
  trust: { trustedRootController: 'did:unsafe:placeholder' },
})

Accepts a structurally valid, unexpired leaf with a matching allowedAction, no signature. Pair with the client's unsafeMode. Never point this at real catalog data.

Errors from checkInvocation

| code | Meaning | |--------|---------| | CAPABILITY_INVALID | Missing leaf, bad shape, expired, chain verify failed, target mismatch | | QUERY_NOT_ALLOWED | Document is not an exact/subset match of allowedAction | | INVOCATION_INVALID | Missing invocation, wrong signer, or the invocation proof failed verification |

decodeInvocationHeader returns null rather than throwing, for a missing header, bad base64url, a non-gzip payload, malformed JSON, or an inflate over the size cap.

describeInvocationHeader returns the same payload plus a reason of 'ok' | 'absent' | 'undecodable'. Worth using in logs: a header truncated by a proxy and a client that sent none are very different operational problems, and reporting both as "missing capability" is what previously sent debugging in the wrong direction on a header-size failure.

Header encoding

The Capability-Invocation header follows the ZCAP spec's HTTP binding: zcap capability="<base64url(gzip(json))>", with the invocation carried in a second parameter encoded the same way (that part is this library's own — see the client README on where and why this deviates).

The inflate is bounded (256KB output, 64KB input). These bytes are attacker-controlled and gzip expands cheaply, so an unbounded inflate here would be a memory-exhaustion vector.

Legacy x-zcap-invocation is accepted permanently, so servers can be upgraded before clients — and they must be, since a client at 0.3.0+ sends only the new header.

Upgrading the package is not quite sufficient on its own, but the two remaining steps are one-time and then permanent:

  1. Pass the headers, not a header. decodeInvocationHeader(req.headers) instead of naming a header yourself. A server that keeps reading only x-zcap-invocation still compiles and still decodes old clients, so it fails silently as "missing capability" rather than as a type error — the headers-object form removes that failure mode for good, including for any future header.
  2. Let it through CORS. 'access-control-allow-headers': zcapAllowedHeaders('content-type'). Miss this and a browser client fails its preflight, surfacing as a CORS error that mentions nothing about capabilities. ZCAP_REQUEST_HEADERS is exported for callers assembling the value themselves.

Both forms mean a later header change is picked up by upgrading the package, with no further edits.