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

@rwa-registry/sdk

v0.1.0

Published

TypeScript SDK for the RWA-Registry API — permanent, verifiable file publishing.

Readme

@rwa-registry/sdk

Tests npm version npm downloads TypeScript Bundle size Zero dependencies Node License: MIT

RWA-Registry publishes the evidence behind tokenized real-world assets — deeds, valuations, attestations — to permanent, content-addressed storage on Arweave, and keeps a verifiable record of what was published, when, and by whom. A workspace holds the records; an API key, scoped to exactly that workspace, is how a system outside the console reads or writes them.

@rwa-registry/sdk is the official TypeScript client for the public v1 API. It exists so an integration doesn't have to hand-roll the parts that are easy to get subtly wrong: retrying only what is actually safe to retry, mapping server errors onto types you can branch on, and verifying a webhook signature in constant time against the exact bytes that were sent.

Zero dependencies. Ships ESM and CommonJS with types for both. Node 18+.

npm install @rwa-registry/sdk
pnpm add @rwa-registry/sdk
yarn add @rwa-registry/sdk

Quick start

import { RWARegistry } from "@rwa-registry/sdk";

const registry = new RWARegistry({ apiKey: process.env.RWA_REGISTRY_API_KEY });

const file = await registry.files.uploadAndPublish({
  name: "title-deed.pdf",
  data: await readFile("title-deed.pdf"),
  contentType: "application/pdf",
  labels: ["q4-2026"],
});

console.log(file.fileID, file.status); // → file_… publishing

Issue a key in the console under Settings → API access. The plaintext is shown once, at creation, and cannot be retrieved afterwards.

Keys are secrets. Use them from a server. Never ship one in a browser bundle or a mobile app.

One key, one workspace

Every key belongs to exactly one workspace, and the binding is enforced server side. A key issued in workspace A can only ever read, write, and spend inside workspace A — there is no account-wide view to reach for by mistake. That is why SDK calls never ask for a workspaceID: the key is the workspace selector as well as the credential.

Keys carry a scope, set when they are issued:

| Scope | Can do | | --------- | -------------------------------------------------------------------------- | | read | List and read files and folders | | write | Also create folders and uploads, and edit metadata. Spends nothing | | publish | Everything above, plus publishing — the only thing that spends the balance |

A call beyond a key's scope fails with PermissionError.

Publishing

Publishing is permanent and costs money. Check the price first if that matters to you:

const quote = await registry.files.calculatePrice(bytes.byteLength);
console.log(`${quote.price} ${quote.currency}`); // → 0.04 USD

uploadAndPublish is the three underlying steps in one call. Run them yourself when you want to keep the rID. The server treats that ID as the operation's idempotency key: repeating an accepted publish returns the original operation without charging again.

const target = await registry.files.createUploadTarget();
const rID = await registry.files.upload(target, { name: "deed.pdf", data });
const file = await registry.files.publish({ name: "deed.pdf", rID });

The upload PUT goes straight to storage on a presigned URL, so it carries no API key. The reservation can be published once, and the URL expires after ten minutes; do not reuse it.

Listing

list() returns one page. iterate() walks all of them, fetching lazily — so breaking out of the loop stops the requests too:

for await (const file of registry.files.iterate({ labels: ["q4-2026"] })) {
  if (file.onChain) console.log(file.permaLink);
}

Webhooks

Endpoints belong to the workspace, not to the key that registered them. Every publish in the workspace is delivered to all of them — whoever made the publish, and whether it came through the API or the console.

await registry.webhooks.create("https://example.com/hooks/registry");
const endpoints = await registry.webhooks.list();

Events

| eventType | | | ---------------- | ------------------------------------------------------------ | | file.published | The file anchored successfully. permaLink is now set — read it back with registry.files.get(fileID). | | file.failed | The publish attempt failed and the charge was refunded in full. registry.files.get(fileID) returns status: "failed"; retry with a new upload. |

Both carry the same shape (fileID, eventType, uploadId, publishedBy, webhookID) — branch on event.eventType, and register one endpoint for both rather than one per event.

Each delivery carries two headers:

| Header | | | --------------------- | ------------------------------------------------ | | x-webhook-signature | HMAC-SHA256 digest, lowercase hex, no prefix | | x-webhook-timestamp | Milliseconds since the epoch |

The signature covers {timestamp}.{raw body}, so the timestamp is inside the digest and cannot be tampered with. That is what makes replay rejection safe.

Why the raw body matters

This is the one thing that trips everybody up, so it is worth being precise.

The sender computes the digest over the exact bytes it puts on the wire. To get the same digest you have to hash those same bytes. Once your framework has parsed the JSON you no longer have them — you have an object, and JSON.stringify() of that object is not guaranteed to reproduce the original.

The trap is that it often does. A round-trip through JSON.parse → JSON.stringify is byte-identical for a minified payload with ordinary keys, so a re-serialising handler passes every test you write against today's payload — and then breaks later, in production, on a delivery like these:

| Bytes on the wire | After a round-trip | | | ------------------- | ------------------- | --------------------------- | | {"n":1.0} | {"n":1} | number formatting | | {"n":1e3} | {"n":1000} | exponent expanded | | {"s":"é"} | {"s":"é"} | escape resolved | | {"2":"b","1":"a"} | {"1":"a","2":"b"} | integer-like keys reordered | | {\n "a": 1\n} | {"a":1} | whitespace dropped |

Any one of those changes the digest, and a perfectly genuine delivery fails verification. Nothing warns you: the endpoint just starts returning 400, and it reads like a secret problem rather than a body-parsing problem.

So capture the body as a Buffer or a string before any JSON middleware touches it, and hand that to the SDK. verifyWebhookSignature and constructWebhookEvent accept a Buffer, a Uint8Array, or a string.

Rule of thumb: if your handler receives req.body as an object, the bytes are already gone. Go back one layer.

Express

express.raw() on the webhook route only — leave express.json() for the rest of the app:

import express from "express";
import { constructWebhookEvent, readWebhookHeaders } from "@rwa-registry/sdk";

const app = express();

app.post(
  "/hooks/registry",
  express.raw({ type: "application/json" }), // NOT express.json()
  (req, res) => {
    const { signature, timestamp } = readWebhookHeaders(req.headers);

    try {
      const event = constructWebhookEvent({
        payload: req.body, // a Buffer, because of express.raw
        signature,
        timestamp,
        secret: process.env.RWA_REGISTRY_WEBHOOK_SECRET!,
      });

      res.sendStatus(200); // answer first — the sender waits 10s
      queueMicrotask(() => process(event));
    } catch {
      res.sendStatus(400);
    }
  },
);

// Everything else still gets parsed JSON.
app.use(express.json());

NestJS

Nest applies express.json() globally, so the route-level trick above does not help. Capture the bytes in json()'s verify hook instead, which runs with the raw buffer in hand, and stash it on the request.

src/common/middleware/rawBody.middleware.ts

import { Response } from "express";
import { json } from "body-parser";

import type RequestWithRawBody from "./requestWithRawBody.interface";

/** Paths whose exact bytes must survive parsing, for signature verification. */
const RAW_BODY_PATHS = ["/hooks/registry"];

export default function rawBodyMiddleware() {
  return json({
    limit: "10mb",
    // `verify` runs before the JSON is parsed and is handed the raw buffer.
    // Copying it here is the only chance to keep the exact bytes.
    verify: (
      request: RequestWithRawBody,
      _response: Response,
      buffer: Buffer,
    ) => {
      if (RAW_BODY_PATHS.includes(request.url) && Buffer.isBuffer(buffer)) {
        request.rawBody = Buffer.from(buffer);
      }

      return true;
    },
  });
}

src/common/middleware/requestWithRawBody.interface.ts

import { Request } from "express";

export default interface RequestWithRawBody extends Request {
  rawBody: Buffer;
}

src/main.ts — order is everything:

// MUST come before express.json(), or the bytes are gone by the time
// `verify` would have run.
app.use(rawBodyMiddleware());
app.use(express.json({ limit: "10mb" }));

src/webhooks/webhooks.controller.ts

import {
  BadRequestException,
  Controller,
  Headers,
  HttpCode,
  Logger,
  Post,
  Req,
} from "@nestjs/common";
import {
  SignatureVerificationError,
  constructWebhookEvent,
} from "@rwa-registry/sdk";

import type RequestWithRawBody from "../common/middleware/requestWithRawBody.interface";

@Controller("hooks")
export class WebhooksController {
  private readonly logger = new Logger(WebhooksController.name);

  // A webhook is authenticated by its signature, not by your session or a
  // CSRF token — exempt it from both, or the request never reaches you.
  @Public()
  @SkipCsrf()
  @HttpCode(200)
  @Post("registry")
  async handle(
    @Headers("x-webhook-signature") signature: string,
    @Headers("x-webhook-timestamp") timestamp: string,
    @Req() request: RequestWithRawBody,
  ): Promise<void> {
    // Missing rawBody means middleware order, not a bad delivery. Say so —
    // it is otherwise indistinguishable from a signature failure.
    if (!request.rawBody) {
      this.logger.error(`rawBody missing for ${request.url}`);
      throw new BadRequestException(
        "Webhook payload not captured. Check middleware order.",
      );
    }

    try {
      const event = constructWebhookEvent({
        payload: request.rawBody,
        signature,
        timestamp,
        secret: process.env.RWA_REGISTRY_WEBHOOK_SECRET!,
      });

      this.logger.log(`${event.eventType} ${event.fileID}`);
      // Deliveries are retried, so key your work on fileID and make it
      // idempotent. Hand anything slow to a queue and return now.
    } catch (error) {
      if (error instanceof SignatureVerificationError) {
        throw new BadRequestException("Invalid webhook signature.");
      }
      throw error;
    }
  }
}

If your Nest app has global JwtAuthGuard / CsrfGuard providers, the @Public() and @SkipCsrf() decorators are what let the delivery through. Without them every webhook gets a 401 or 403 before your handler runs.

Runnable versions of all three files are in examples/nestjs/.

Next.js, Fastify, Hono

| Framework | Getting the raw bytes | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | | Next.js (App Router) | await request.text() in the route handler — do not call request.json() first | | Next.js (Pages Router) | export const config = { api: { bodyParser: false } }, then read the stream | | Fastify | addContentTypeParser('application/json', { parseAs: 'buffer' }, …), or rawBody via fastify-raw-body | | Hono / Workers | await c.req.text() | | Node http | Concatenate the data chunks yourself — see examples/webhook-receiver.ts |

Rotating the secret

The signing secret is per workspace, on the console API access page. Deliveries older than five minutes are rejected as replays — pass toleranceMs to widen that. Return 200 within 10 seconds; failures are retried for about 17 hours.

After a rotation the previous secret keeps verifying for 24 hours, so retries already in flight still check out. Pass both for that window:

constructWebhookEvent({
  payload: rawBody,
  signature,
  timestamp,
  secret: [
    process.env.RWA_REGISTRY_WEBHOOK_SECRET!,
    process.env.RWA_REGISTRY_WEBHOOK_SECRET_PREVIOUS,
  ].filter(Boolean) as string[],
});

Errors

Every failure is a typed subclass of RWARegistryError, carrying status, path, and the server's message:

import {
  NotFoundError,
  RateLimitError,
  PermissionError,
} from "@rwa-registry/sdk";

try {
  await registry.files.get(fileID);
} catch (error) {
  if (error instanceof NotFoundError) return null;
  if (error instanceof PermissionError) alertOps("key scope too narrow");
  throw error;
}

| Class | Status | | ---------------------------- | -------------------------------------- | | BadRequestError | 400 | | AuthenticationError | 401 | | PermissionError | 403 | | NotFoundError | 404 | | ConflictError | 409 | | RateLimitError | 429 — carries retryAfterMs | | ServerError | 5xx | | ConnectionError | never reached the server, or timed out | | SignatureVerificationError | a webhook failed verification |

Retries

Rate limits are retried automatically, honouring Retry-After. Transient network and 5xx failures are retried only on GETs.

A state-changing call is not replayed after an ambiguous network or server failure. A timed-out publish may still have been accepted, so inspect the file list before starting a new upload. Reusing the same accepted rID is idempotent, but the SDK does not guess how your workflow should recover.

new RWARegistry({
  apiKey,
  maxRetries: 5,
  onRetry: ({ attempt, delayMs, path }) =>
    logger.warn({ attempt, delayMs, path }, "retrying"),
});

Configuration

| Option | Default | | | ------------ | ---------------------------------- | ---------------------------------------- | | apiKey | process.env.RWA_REGISTRY_API_KEY | Required | | baseUrl | https://rwa-api.a-registry.org | For staging or self-hosted | | timeoutMs | 60000 | Per request | | maxRetries | 2 | | | headers | {} | Added to every request | | fetch | global fetch | Inject a proxy agent, or a stub in tests | | onRetry | — | Called before each retry |

Per-call overrides go in a trailing options argument on any method:

await registry.files.get(fileID, {
  timeoutMs: 5_000,
  signal: controller.signal,
});

API

workspaces — get(), update(name)

files — calculatePrice(bytes), createUploadTarget(), upload(target, input), publish(params), uploadAndPublish(params), get(id), list(params?), iterate(params?), update(id, params), move(id, folderID?)

folders — list(params?), iterate(params?), create(params), update(id, name), move(id, parentID?), delete(id)

webhooks — list(), create(url), delete(webhookID)

Rate limits

Set per endpoint by the server. The heaviest is publishing, at 10/minute. Reads are 60/minute and most writes 30/minute. A 429 comes with Retry-After, which the client waits out for you.

Licence

MIT