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

@wtfalch/audit

v0.5.0

Published

The estate's append-only audit ledger: the table's shape and walls, erasure, export and readers, with a signer a host builds once inside its trusted base and never exports. Per-app Postgres, a host-supplied vocabulary of closed sets, no framework.

Readme

@wtfalch/audit

The estate's append-only audit ledger, as a package: one table's shape and walls, a signer, readers, erasure and export. Per-app Postgres, a vocabulary the host supplies, no framework.

It is not a service you call from outside. A host constructs the ledger once inside its trusted base, keeps the signer there, and hands each module that wants auditing a writer already bound to the actor the base resolved and the event names that module may use. The guarantee that a row's actor is real is the host's, and it holds only while the signer stays private.

What the package enforces, and what the host does

| Concern | Where | | --- | --- | | Column bounds, JSON size, namespace.name action shape, subject pair, break-glass shape | migrations/0001_audit.sql CHECKs, and rowSchema before the insert | | Append-only: no DELETE, no TRUNCATE, UPDATE limited to what erasure touches | audit_events_guard trigger, plus a revoke from <database>_rt | | The closed sets: which events, actor classes, contexts, outcomes, reason codes | LedgerVocabulary in TypeScript at write time; the host's own CHECKs in the database when it has them | | Who may sign what | The host. The package checks no permission and reads no session | | Tenant visibility | Decided per event in the vocabulary, never per write | | Erasure | audit_erase_person(subject, pseudonym, subject_email), ledger-only, SECURITY DEFINER; the host cleans its own tables in the same transaction |

Install

pnpm add @wtfalch/audit
pnpm exec audit-migrations   # copies migrations/*.sql into drizzle/ as the next numbers

The copy is recorded in drizzle/.audit-migrations.json; running it again copies nothing. Apply the copied file with the host's own migrate script.

Use

import { core } from '@wtfalch/authz';
import { createLedger, ledgerVocabularyFromCore } from '@wtfalch/audit';

// Once, inside the trusted base.
const ledger = createLedger({
  vocabulary: ledgerVocabularyFromCore(core, {
    'invoice.paid': { tenantVisible: true },
  }),
});

// In a server action, inside the transaction that makes the change.
await db.transaction(async (tx) => {
  await tx.update(invoices).set({ paidAt: now }).where(eq(invoices.id, id));
  await ledger.sign(tx, {
    action: 'invoice.paid',
    tenantId,
    actor: { class: 'human', id: access.principal.id, display: access.principal.display },
    context: 'standard',
    target: { type: 'invoice', id, display: invoice.number },
    tenantDisplay: tenant.name,
    after: { paidAt: now },
    request: requestContext(),
  });
});

// Readers. The host gates; a tenant-facing page always passes tenantVisibleOnly.
const page = await ledger.page(db, { tenantId, tenantVisibleOnly: true, limit: 50 });
const rows = await ledger.exportRows(db, tenantId);

// Erasure, inside the host's own erasure transaction.
const touched = await ledger.erase(tx, { subject: personId, pseudonym, email });

ledgerVocabularyFromCore refuses a host event in a namespace the core uses: tenant.invoice_paid is out, invoice.paid is in. The core's namespaces are the trusted base's.

Names, not only ids

target.display and tenantDisplay are what the target and the tenant were CALLED when the row was written. Pass them whenever the caller already holds the object, which is nearly always: it is making the change.

The name is stored, not looked up later, because a lookup answers right up until it matters. The rows a trail exists for are the deleted file, the closed organisation, the revoked key — and by then there is nothing left to join to. A later rename does not reach back either: the row says what the thing was called at the time, which is what a reader of history wants.

Both are optional and both are null on every row written before 0.4.0, so a reader falls back to the id. audit_erase_person pseudonymises target_display when the target is the person being erased; tenant_display names an organisation, so nothing may change it once written.

A writer for an embedding service

A service that wants a trail (a forum's moderation, a mail admin's writes) gets a writer, never the signer. The host binds one per namespace:

export const forumAudit = ledger.writer({ namespace: 'thread', handle: db });
createThreads({ db, gates, audit: forumAudit });

The writer signs thread.* and refuses everything else; the actor is whoever the service resolved; context, actor class and tenant default to what the host bound. A caller passes its transaction as the second argument when the row must commit with the change it records.

A host's own columns

A host that needs a column beside the ledger's (a team, a region) declares its table over the package's builders and hands it to the ledger:

import { AUDIT_COLUMNS, auditIndexes, createLedger } from '@wtfalch/audit';

export const auditEvents = pgTable('audit_events', { ...AUDIT_COLUMNS, teamId: uuid('team_id') }, (t) =>
  auditIndexes(t),
);
const ledger = createLedger({ vocabulary, table: auditEvents, schemaVersion: core.version });
await ledger.sign(tx, { ...input, extra: { teamId } });

extra takes host columns only: a ledger column there is refused, and so is a key the table does not declare. The host's own migration adds the column; the package's migration never learns of it.

A service with no tenant database

Nothing in the ledger is per-app. A service that is not a tenant app, such as foundry recording who ran which plan, needs three things:

  • its own closed vocabulary through ledgerVocabulary({ events, actorClasses, contexts, outcomes }), with no @wtfalch/authz core;
  • tenantId: null on every row, with what the row is about as the target ({ type: 'org', id: orgId });
  • a Postgres of its own, with these migrations applied.
const ledger = createLedger({
  vocabulary: ledgerVocabulary({
    events: { 'plan.computed': { tenantVisible: false }, 'plan.applied': { tenantVisible: false } },
    actorClasses: ['human', 'service'],
    contexts: ['operator'],
    outcomes: ['success', 'refused', 'failed'],
  }),
  hashChain: true,
});
await ledger.sign(db, {
  action: 'plan.applied', tenantId: null, actor, context: 'operator',
  outcome: 'refused', reason, target: { type: 'org', id: orgId }, after: plan,
});

The database is the one thing the package cannot supply.

Tenant isolation in the database

migrations/0005_rls.sql turns on row-level security. For every role but the table's owner, a read inside a tenant-scoped transaction sees that tenant's rows and nothing else, whatever predicate the query forgot:

import { scopeAuditTenant } from '@wtfalch/audit';

await db.transaction(async (tx) => {
  await scopeAuditTenant(tx, tenantId); // set_config('audit.tenant_id', ..., true)
  const page = await ledger.page(tx, { tenantId, tenantVisibleOnly: true });
});

Unscoped reads still see every row, so applying the migration changes nothing until the host scopes. To make a forgotten scope see nothing instead, set it on the role the app connects as:

alter role <database>_rt set audit.require_tenant = 'on';

An operator surface that reads across tenants then connects as another role. Which role that is belongs to the host. An app that connects as the table's owner gets no RLS at all.

With hashChain on, sign() and erase() now read the chain tail and the pending erasures through audit_chain_tail() and audit_pending_erasures(), both added in 0005. Apply 0005 before deploying this version.

To a customer's SIEM

toCef(row) renders one ledger row as a CEF (Common Event Format) line, the format Splunk, QRadar, Sentinel and ArcSight ingest directly or over syslog; toCefLines(rows) does a page of them, one per line.

import { toCef, toCefLines } from '@wtfalch/audit';

const { items } = await ledger.page(db, { tenantId, limit: 500 });
const body = toCefLines(items, { vendor: 'Acme', product: 'Acme', version: '2.3' });

The push half (a webhook, a syslog socket, a file a shipper tails) is the host's: which transport, whose endpoint, whose credentials and whose retry policy are one decision per enterprise customer, not the package's.

Tests

pnpm test                                        # PGlite, in memory
TEST_DATABASE_URL=postgres://... pnpm test       # a real Postgres; drops its public schema first

The real run adds the runtime-role test: <database>_rt may insert and call audit_erase_person, and may not update, delete or truncate.

Release

Tag v*. The workflow builds, tests and publishes with npm trusted publishing; the first publish of a new package is done from a laptop.