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

@opndev/zero-trust

v0.0.2

Published

Zero trust implemenation

Downloads

230

Readme

@opndev/zero-trust-client

A platform-agnostic client for the Adaptive Zero-Trust protocol. Establishes and maintains device trust by solving Proof-of-Work challenges, renewing tokens, and safely retrying interrupted requests without depending on any React Native or DOM API. The same client works unmodified in a React Native app and in a browser; only storage differs per platform, via a small adapter.

Why a separate package?

The core client (createTrustClient) has zero platform dependencies. No expo-* packages, no browser globals. Storage, the PoW hash function, and fetch are all injected. That's what makes one client usable on both targets, but it also means the storage adapters genuinely can't live in the same importable module as each other: the React Native adapter imports expo-secure-store, which doesn't exist outside Expo/RN at all, and would break module resolution for a web-only consumer that never touches it. Each adapter is its own subpath, so you only ever resolve the one your platform actually needs.

Installation

npm install @opndev/zero-trust-client

React Native projects also need:

npx expo install \
  expo-secure-store \
  @react-native-async-storage/async-storage \
  react-native-get-random-values

Quick start

React Native

import { sha3_256 } from 'js-sha3';
import { createTrustClient } from '@opndev/zero-trust-client';
import { createSecureStoreAdapter } from
  '@opndev/zero-trust-client/adapters/secure-store';

const trust = createTrustClient({
  trustUri: 'https://api.example.com/api/v1/trust',
  storage: createSecureStoreAdapter(),
  hash: sha3_256,
  validFor: [
    { prefix: 'https://api.example.com/api/v1' },
  ],
});

// One shared fetch for the whole app. Attaches auth for anything matching
// validFor, passes everything else through untouched.
export const authedFetch = trust.authedFetch();

Web

Only the adapter import changes, everything else is identical:

import { sha3_256 } from 'js-sha3';
import { createTrustClient } from '@opndev/zero-trust-client';
import { createWebStorageAdapter } from '@opndev/zero-trust-client/adapters/web';

const trust = createTrustClient({
  trustUri: 'https://api.example.com/api/v1/trust',
  storage: createWebStorageAdapter(),
  hash: sha3_256,
  validFor: [
    { prefix: 'https://api.example.com/api/v1' },
  ],
});

Note on the web adapter: it uses plain localStorage for everything, including the refresh token. Unlike the React Native adapter (which puts the refresh token in expo-secure-store, backed by Keychain/Keystore), localStorage gives it no special protection. Anything with script execution on the page can read it, same as any other stored value. That's a known, documented gap, not an oversight; see the adapter's own doc comment for what closing it properly would require.

API

createTrustClient(options)

| Option | Required | Default | Description | | --- | --- | --- | --- | | trustUri | yes | — | e.g. 'https://api.example.com/api/v1/trust'. POST/PATCH go here directly; proof submission goes to `${trustUri}/proof`. | | storage | yes | — | Adapter implementing get(key), set(key, value), remove(key) (all async). | | hash | yes | — | Synchronous (string) => hexString. Must match whatever the server's PoW policy expects — sha3_256 from js-sha3 for the default server-side policy. | | validFor | no | [] | Array of { prefix } — URL prefixes that authedFetch() should attach the current access token to. Merge rules from multiple sources with plain array spread ([...a, ...b]) — no separate merge mechanism needed. For a genuinely separate trust domain (different trustUri), compose two clients instead — see authedFetch's fallback argument below. | | fetchImpl | no | global fetch | The underlying fetch used both for this client's own requests to trustUri and, wrapped with auth, for whatever authedFetch() routes to it. | | generateId | no | crypto.randomUUID/crypto.getRandomValues | Generates attempt_id values. Throws if neither crypto source exists, rather than silently falling back to weak randomness — attempt_id needs real entropy (see SPEC.md's Security Considerations). Inject your own on a platform where neither exists (e.g. via react-native-get-random-values). | | solveBatchSize | no | 1000 | Hash attempts per tick before yielding back to the event loop, so a slow solve doesn't block the JS thread for its full duration. | | clockSkewMs | no | 5000 | Safety margin subtracted from the stored access token's expiry when deciding whether it's still usable. |

Returns:

{
  // The main entry point. Call this before any authenticated request.
  // Resolves instantly (no network) if the current token is still
  // valid; otherwise runs the full renewal flow. Concurrent callers
  // during a renewal share one in-flight attempt rather than racing.
  // Async, resolves with an access token string.
  ensureAccessToken,

  // Async, resolves with the stored device_id string, or null.
  getDeviceId,

  // Async, clears all locally stored trust state.
  reset,

  // Returns a fetch-shaped function (url, init) => Promise<Response>
  // that routes each request per validFor. Memoized: safe to call
  // more than once, always returns the same function. See "How
  // authedFetch routes requests" below.
  authedFetch,

  // The resolved trustUri passed in above. Read this back instead
  // of duplicating the literal string anywhere else that needs it.
  trustUri,
}

How authedFetch() routes requests

Every request through the function authedFetch() returns goes to one of three places:

  • trustUri itself (or `${trustUri}/proof`). Always passed straight to the plain underlying fetchImpl, never wrapped with auth. This is automatic, derived from trustUri directly. Not something you configure via validFor, and not something you can get wrong by rule ordering. Wrapping it would be circular: it's exactly what ensureAccessToken()'s own internal calls talk to.
  • A URL matching one of the validFor prefixes. Attaches the current access token, and on a 403, retries exactly once through a forced renewal before giving up. A 403 is deliberately ambiguous (ordinary token expiry and actual device revocation look identical from the status code alone), so this doesn't try to diagnose which one happened. ensureAccessToken({ force: true }) already knows how to get a valid token regardless, via the same renew() flow that correctly distinguishes them internally.
  • Anything else passed straight through to fallback (defaults to this client's own fetchImpl plain, unauthenticated passthrough).
const res = await authedFetch('https://api.example.com/api/v1/event/...');
const data = await res.json();

Composing multiple trust domains

authedFetch(fallback?) takes an optional fallback used for its "no match" case. Composing two genuinely separate trust domains (different trustUri, e.g. your own API plus an unrelated third-party trust relationship) is plain function chaining, not a separate router type each client's "no match" case just defers to the next:

const trust = createTrustClient(
  { trustUri: config.trust, validFor: [{ prefix: config.pow }],
    /* ... */ });
const trust2 = createTrustClient(
  { trustUri: config.otherTrust, validFor: [{ prefix: config.other }],
    /* ... */ });

const combined = trust.authedFetch(trust2.authedFetch());
setDefaultFetchImpl(combined);

trust tries its own trustUri/validFor first; anything matching neither defers to trust2.authedFetch(), which tries its own rules the same way, bottoming out at trust2's own plain fetchImpl if nothing matches there either. Extends to any number of trust domains by nesting further. Each layer already knows how to defer to the next.

authedFetch(), called with no arguments, is memoized. Safe to call more than once, always returns the same function. A call with an explicit fallback is a deliberate one-off composition and always builds fresh, since caching it under the same slot as the no-arg version could return the wrong one on a later call.

Storage adapter contract

Any object with these three async methods works. The two provided adapters are just two implementations of the same shape:

{
  get(key) {},    // async — resolves with the stored string, or null
  set(key, value) {}, // async
  remove(key) {},      // async
}

How it behaves

  • A still-valid access token never touches the network. ensureAccessToken() checks the stored expiry first.
  • No refresh token → start fresh. Whether or not a stale device_id is still stored locally, a missing refresh token always goes through POST /trust for a brand-new, server-generated device. There's no partial/bare-renewal path presenting nothing and presenting a stale token cost the same thing, by design (see SPEC.md).
  • A refresh token → the trusted renewal path, with a mandatory attempt_id. If the app is killed mid-request, the same attempt safely resumes next launch, no matter how long the gap was, instead of generating a new one and risking a replay-detection false positive.
  • A demoted device (the server responds to PATCH /trust with a challenge instead of tokens) is handled transparently.

The full protocol semantics live in Adaptive Zero-Trust protocol. This package only implements the client side of it.

License

MIT