@digicredholdingsinc/did-graphql-client
v0.3.1
Published
GraphQL client that authorizes requests via ZCAP (W3C Authorization Capabilities) invocation headers.
Readme
@digicredholdingsinc/did-graphql-client
GraphQL client for the invoking side. It attaches a ZCAP invocation to every request as x-zcap-invocation and POSTs JSON to a GraphQL endpoint. It never holds keys and never signs — invokeCapability is injected by the caller, backed by whatever key store already holds the capability controller's key.
This page is the client API, the GraphQL ZCAP validation algorithm the client runs before it will send a capability anywhere, wire format, and optimizations.
DidGraphQLClient calls validateGraphqlZcap on construct and on setCapability. It will not POST x-zcap-invocation until that algorithm succeeds. That check is not signature verification — see GraphQL ZCAP validation algorithm.
Install
npm install @digicredholdingsinc/did-graphql-clientPublic on npmjs.org — no registry auth, no .npmrc, no token. The tarball ships a prebuilt dist/, so nothing compiles at install time.
Two legacy paths still work and are kept for consumers that haven't moved onto the registry yet:
A git dependency pinned to this repo's
clientworkspace (Yarn only — npm cannot install a single workspace out of a git monorepo):"@digicredholdingsinc/did-graphql-client": "git+https://github.com/DigiCred-Holdings/did-graphql.git#commit=<sha>&workspace=%40digicredholdingsinc%2Fdid-graphql-client"That path relies on
client/package.json'spostinstallscript and onfileslistingsrc/tsconfig.jsonalongsidedist— a git dependency fetch is packed usingfilesbefore any script runs, so without the source in that list there'd be nothing forpostinstallto build from. On a registry install the same script seesdist/already there and exits immediately.A local
file:…/clientpath dependency, which needs a build first:
cd client && npm install && npm run buildUsage
import { DidGraphQLClient } from '@digicredholdingsinc/did-graphql-client'
const client = new DidGraphQLClient({
capability: heldCapability,
expectedInvocationTarget: expectedTarget, // optional second copy, if you have one
invokeCapability: async (cap, capabilityAction, invocationTarget) => {
// Your key store signs the invocation — this package never holds a key.
return keyStore.signZcapInvocation({ capability: cap, capabilityAction, invocationTarget })
},
})
const result = await client.query({
query: 'query Items($limit: Int) { items(limit: $limit) { nodes { name } totalCount } }',
variables: { limit: 10 },
})Reuse one client for the life of a capability. Call setCapability() when the workflow re-delegates; do not construct a new client per query.
Configuration
| Option | Default | What it does |
|--------|---------|----------------|
| capability | required | Delegated leaf ZCAP. Runs validateGraphqlZcap on construct / setCapability. |
| expectedInvocationTarget | none | Independent pin — a second copy of the target from the delegating channel. MUST equal the capability's GraphQL URL. |
| allowedHosts | none | Hostname allowlist (*.example.org). If set, invocationTarget MUST match. |
| endpoint | capability.invocationTarget | If set, MUST canonicalize to the same URL. This client never POSTs a ZCAP to a different host than the capability names. |
| invokeCapability | none | Required for real query(). Signs a fresh invocation for this query string. |
| fetchImpl | fetch.bind(globalThis) | Swap the HTTP stack (tests, React Native). Bound to globalThis so browsers do not throw "'fetch' called on an object that does not implement interface Window." |
| checkExpiryBeforeSend | true | Throw CapabilityExpiredError locally instead of sending a request the server would reject. |
| allowInsecureEndpoint | false | Allow http:// and loopback/private hosts. Local development only. |
| timeoutMs | 10000 | Abort the fetch. 0 disables. Combined with the caller's AbortSignal if both are present. |
| unsafeMode | false | Skip signing. Sends the bare chain (same shape as checkAuth()). The server must also be in unsafe mode. Logs a console warning. Never enable from runtime input. |
const client = new DidGraphQLClient({
capability, // invocationTarget MUST already be http://localhost:4100/graphql
invokeCapability,
allowInsecureEndpoint: true,
timeoutMs: 15_000,
})GraphQL ZCAP validation algorithm
A client MUST run this before it sends the capability in a header. DidGraphQLClient does it automatically on new DidGraphQLClient(...) and setCapability(...). You can also call validateGraphqlZcap yourself (e.g. to show an error in UI before constructing the client).
This is not ZCAP-LD proof verification — the resource server still verifies signatures. These steps only decide: is this object safe to put on the wire as x-zcap-invocation?
validateGraphqlZcap(capability, options):
- The capability MUST be an object with
id,controller,invocationTarget,allowedAction, andproof. proofMUST includeverificationMethod.controllerMUST be a DID.invocationTargetMUST be an absolute GraphQL HTTP URL: no userinfo, no fragment, pathname/graphql(or ending in/graphql).- The protocol MUST be
https:unlessallowInsecureEndpoint. - The host MUST NOT be loopback, link-local, or RFC1918 unless
allowInsecureEndpoint. - If
allowedHostsis set, the host MUST match an entry. - If
expectedInvocationTargetis set, it MUST canonicalize to the same URL asinvocationTarget. - If
endpoint/fetchEndpointis set, it MUST be that same URL. The client MUST POST only toinvocationTarget. allowedActionMUST be a non-empty list of GraphQLquery/mutationdocuments (notsubscription).expiresMUST be present, parseable, and not in the past.
On success, the client POSTs only to the canonical invocationTarget. Fetch uses redirect: 'error' so the header cannot follow to another origin. Failures throw InvalidCapabilityError (or CapabilityExpiredError for step 11).
A delegating peer sends invocationTarget with the capability; a client generally will not have a global host list. That is the intended path. allowedHosts is optional app policy. expectedInvocationTarget is a same-channel pin (a second copy of the target vs. the capability's own), not a pre-provisioned allowlist. Call without allowedHosts, keep the algorithm’s HTTPS / private-IP / same-URL rules, and treat result.data as untrusted JSON from that peer.
import { validateGraphqlZcap } from '@digicredholdingsinc/did-graphql-client'
validateGraphqlZcap(artifacts.zcap.graphql, {
expectedInvocationTarget: template.catalog.zcap.graphql.invocationTarget,
})API
| Export | Role |
|--------|------|
| DidGraphQLClient | query(), checkAuth(), setCapability() |
| validateGraphqlZcap / isValidGraphqlZcap / collectGraphqlZcapProblems | GraphQL ZCAP algorithm (MUST rules above) |
| prepareInvokedRequest / prepareDiagnosticRequest | Pure header/body builders for a custom HTTP stack — no fetch |
| encodeInvocationHeader / decodeInvocationHeader | x-zcap-invocation = base64(JSON) |
| isExpired / validateCapabilityShape / isValidCapabilityShape | Local checks, no network |
| CapabilityExpiredError, InvalidCapabilityError, InsecureEndpointError, RequestTimeoutError, GraphQLTransportError | Typed failures |
query(request, { signal }) always signs a new invocation whose capabilityAction is the query text. That is the string allowedAction must match (or contain as a field subset) on the server.
checkAuth() POSTs query Auth { auth { zcap { valid } } } with no invocation. Dev/diagnostic only — not a production allowedAction. The same auth.zcap object can also select controller, invocationTarget, and allowedAction.
Wire format
POST {endpoint}
content-type: application/json
capability-invocation: zcap capability=<base64url(gzip(json))>, invocation=<base64url(gzip(json))>Body: { query, variables?, operationName? }.
This follows the ZCAP spec's HTTP binding for the capability, which specifies serializing it to JSON, gzipping, and base64url-encoding the result. The unsigned root is never sent; the verifier reconstructs it. invocation is omitted for checkAuth() and for unsafeMode queries.
Compression is not an optimization bolted on here — it is what the binding specifies, and it is what keeps the header under host limits. A real 9-query capability is ~5.9KB of JSON and ~1.8KB encoded this way, against an 8KB ceiling on hosts that have been measured.
Capability fields are the ZCAP-LD ones in camelCase: id, controller, invocationTarget, parentCapability, allowedAction, expires, proof. caveat is accepted and ignored.
Signing an invocation
Two mechanisms. RFC 9421 HTTP Message Signatures is the spec's, and what you should use:
new DidGraphQLClient({
capability,
httpSignature: {
keyid: 'did:key:z6Mk…#z6Mk…', // the verification method
sign: (base) => kms.sign(base), // Ed25519 over the signature base
},
})The request then carries Content-Digest, Signature-Input and Signature, and the proof covers the method, the path, the capability header, the content type and — via the digest — the exact body.
The older invokeCapability signer still works and produces an embedded eddsa-jcs-2022 invocation in an invocation parameter. It binds the target URL and the query text, and nothing else about the request. Servers accept both.
Either way this package holds no keys: keyid must be resolvable before signing, because it sits inside @signature-params, which is itself part of what gets signed.
Where this deviates from the spec, and why
The spec conveys the invocation proof with HTTP Signatures (Signature-Input / Signature), signing the HTTP request itself. This library instead sends an embedded eddsa-jcs-2022 Data Integrity invocation in an invocation parameter, encoded the same way as the capability.
That is deliberate, not an oversight: the proof is byte-compatible with the signer on the issuing side, pinned by a cross-implementation hash fixture. Switching to HTTP Signatures would invalidate that and require changes well outside this library.
The practical cost is worth knowing. HTTP Signatures cover a date component, so verifiers bound replay to a clock-skew window. An embedded invocation binds to the invocationTarget and to the exact query text — a captured header cannot be redirected or reused for a different query — but the only time bound is the capability's expires.
Migrating from x-zcap-invocation
Clients before 0.3.0 sent x-zcap-invocation: <base64 of uncompressed JSON>, wrapping the leaf in a chain array with no counterpart in the ZCAP data model. Servers still accept it, permanently, so upgrade servers before clients: a new client against an old server fails on every request, while an old client against a new server is fine.
encodeInvocationHeader / decodeInvocationHeader remain exported and deprecated for callers with their own transport.
Optimizations already in place
These are not knobs. They run unless you opt out of the related option.
Diagnostic header cache. Unsigned payloads (checkAuth(), unsafeMode queries) encode the same capability over and over. encodeInvocationHeader caches that base64 string in a WeakMap keyed by the capability object. Signed invocations are not cached — each one is a new proof.
GraphQL ZCAP algorithm before I/O. Constructor and setCapability() run validateGraphqlZcap (full MUST list above). The client will not send x-zcap-invocation until that succeeds.
Expiry preflight. With checkExpiryBeforeSend: true (default), query() also refuses an expired capability rather than hitting the network. Use isExpired() if the UI should prompt for re-delegation first.
Timeout + caller cancel. One AbortSignal wins: the per-request timeout and opts.signal are combined. The timeout timer is always cleared.
Isomorphic base64. Buffer in Node, btoa/atob with UTF-8 round-trip in the browser / React Native. No polyfill assumed.
Custom transport. prepareInvokedRequest returns { method, headers, body } if the caller already has an HTTP layer and only needs the header shape.
Reuse the client. setCapability() swaps the leaf, re-runs the validation algorithm, and POSTs to the new canonical invocationTarget.
Caching — what this package does and does not do
| Thing | Cached? | Configurable? |
|-------|---------|----------------|
| Unsigned x-zcap-invocation header | Yes, WeakMap on the capability object | No — always on. Drop the object (or setCapability with a new one) and the entry goes away. |
| Signed invocation | No. A proof is one-use for one query string, produced fresh by the invoking client. | Do not cache invokeCapability results across queries. |
| GraphQL response body | No. This is an auth transport, not an Apollo/urql cache. | Cache in the workflow UI (context_key on the instance, React Query, etc.). |
| HTTPS / timeout / expiry | Policy, not a cache | The options table above |
Recommended caller-side cache, not inside this library:
// One client per workflow instance; cache GraphQL data in instance context.
const client = new DidGraphQLClient({ capability, invokeCapability })
// After a successful query, store result.data on the workflow instance.
// Next screen reads context — no second signed POST for the same browse page.Do not cache across invokers, capabilities, or allowedAction documents. A new delegation (setCapability) must start a new data cache.
Errors
| Error | When |
|-------|------|
| InvalidCapabilityError | GraphQL ZCAP algorithm failed (construct / setCapability / validateGraphqlZcap) |
| CapabilityExpiredError | expires in the past |
| RequestTimeoutError | timeoutMs elapsed |
| GraphQLTransportError | Non-2xx HTTP |
| InsecureEndpointError | Exported for callers; HTTPS failures from the algorithm use InvalidCapabilityError |
| RequestTimeoutError | timeoutMs elapsed |
| GraphQLTransportError | Non-2xx HTTP |
| plain Error | query() without invokeCapability and without unsafeMode |
GraphQL { data, errors } is a 200 from the server; it is returned, not thrown.
unsafeMode
Sets the client to send an unsigned chain. Pair it with the server's unsafeMode. Useful for a detached preview, or a local resource server with no real capability to hand. It drops the only proof that the holder is the delegatee. Keep it a build-time constant.
