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

@devx-retailos/audit-log

v0.3.0

Published

Append-only who-did-what audit trail for retailOS. Non-blocking middleware captures actor, scope, operation, HTTP status, and payload snapshot across admin and store surfaces.

Readme

@devx-retailos/audit-log

Append-only who-did-what audit trail for retailOS. Non-blocking middleware captures actor, scope, operation, HTTP status, and payload snapshot across admin and store surfaces.

Installation

pnpm add @devx-retailos/audit-log

Quick start

// medusa-config.ts
import type { AuditRule } from "@devx-retailos/audit-log"

export default defineConfig({
  plugins: [
    {
      resolve: "@devx-retailos/audit-log",
      options: {
        rules: [
          {
            method: "POST",
            pathPattern: "/admin/retailos/orders",
            module: "order",
            operation_type: "create",
            title: "Order created",
          },
          {
            method: "GET",
            pathPattern: "/admin/retailos/orders/:id",
            module: "order",
            operation_type: "read",
            title: "Order viewed",
          },
        ],
      },
    },
  ],
})

How it works

The package registers a non-blocking middleware on every /admin/retailos/* and /store/retailos/* request. After the response is sent (res.finish), the middleware:

  1. Resolves the matching AuditRule from the registry (by HTTP method + path pattern).
  2. Reads actor context from req.auth_context and organization/store headers.
  3. Redacts PII and secrets from the request body and from the request headers.
  4. Writes one retailos_audit_log_entry row — fire-and-forget, errors are swallowed.

If no rule matches, nothing is written. Routes not in the registry are invisible to the audit log.

Plugin options

| Option | Type | Default | Description | |---|---|---|---| | rules | AuditRule[] | [] | Rules to register at boot. | | capturePayload | boolean | true for non-GET | Whether to snapshot the request body. | | captureResponse | boolean | false | Snapshot the JSON response body into response_snapshot. Off by default — responses are often larger than requests and carry the same PII. See Response capture. | | maxBodyBytes | number | 100_000 | Byte cap on stored payload; truncated payloads include { _truncated: true, _original_bytes: N }. | | redact | (body) => unknown | built-in | Override the PII redactor. | | retentionDays | number | — | If set, the nightly retailos-audit-log-retention job deletes entries older than this. Unset means nothing is ever deleted. See Retention. | | getActorContext | async (req) => AuditActorContext \| null | RBAC's subjectFromRequest + headers | Custom actor resolver. The default resolves the authenticated Medusa admin actor only — mount this middleware on a surface with a different identity provider (storefront customer, API key, machine actor) and you must supply a resolver that returns that provider's subject_type, because the default returns null rather than guessing one. | | getLocationContext | async (req) => AuditLocationContext \| null | req.ip + user-agent | Custom location resolver. ip_address comes from req.ip, never from x-forwarded-for directly. See Client IP and trust proxy. Its return value may also include metadata — an arbitrary object stored on the row's metadata column, redacted with redact() and capped by maxBodyBytes the same as payload/response_snapshot/headers; nothing populates it by default. |

Request headers are captured unconditionally (there is no captureHeaders option) into the headers column — redacted the same way as payload/response_snapshot and subject to the same maxBodyBytes cap. Unlike the request body, headers are small and fixed-shape, and which headers a call carried (or omitted) is itself security-relevant even when the body is not.

Client IP and trust proxy

The default location resolver reads req.ip and never parses x-forwarded-for itself. Express derives req.ip from the forwarded chain only when the host app has configured trust proxy, and uses the socket address otherwise — so on a deployment that sits without a proxy a forged header cannot become an audit IP.

If your deployment sits behind a load balancer and you want the real client IP recorded, configure trust proxy — but scope it to the proxy hops or subnets you actually run, never true:

// The number of trusted proxy hops between the client and this app…
app.set("trust proxy", 1)
// …or the exact addresses/subnets of the load balancers themselves.
// Placeholder — substitute your balancer's own range, and only a range
// dedicated to it. Do not paste a broad private block such as 10.0.0.0/8:
// every reachable host inside it would then be trusted to set the audit IP.
app.set("trust proxy", ["<load-balancer-cidr>"])

trust proxy: true trusts the left-most x-forwarded-for entry from anyone. If a client can reach the app directly — a public container port, a VPC peer, a health-check path that bypasses the balancer — it can write an attacker-chosen IP into the audit trail, which is exactly the field an investigation relies on.

Two requirements go with it, and they are the host's to meet, not this plugin's:

  • Every path into the app must pass through the trusted proxy. Scoping the setting is only worth as much as the network boundary behind it.
  • The proxy must overwrite or strip client-supplied x-forwarded-for, rather than appending to whatever the client sent. A balancer that appends leaves the client in control of the left-most entry.

Audit rules

An AuditRule matches a request by method + path and controls what gets written:

interface AuditRule {
  method: string             // "GET" | "POST" | "PATCH" | "DELETE" | "*"
  pathPattern: string | RegExp  // "/admin/retailos/orders/:id" or a RegExp
  module: string             // e.g. "order"
  operation_type: string     // e.g. "create" | "read" | "update" | "delete"
  title: string              // short human-readable label
  describe?: (req: MedusaRequest, responseBody: unknown) => string | undefined  // optional dynamic description
}

:param placeholders in string patterns match any single path segment. First-match wins.

Rules can also be registered at runtime:

const auditService = container.resolve(AUDIT_LOG_MODULE)
auditService.registerAuditRule({
  method: "POST",
  pathPattern: "/admin/retailos/returns",
  module: "order",
  operation_type: "return",
  title: "Return initiated",
})

API routes

The list and single-entry routes require audit.read. The CSV export requires audit.export — it does not also check audit.read, so a role granted only audit.export can download the trail without being able to browse it. Grant each permission only to roles that need that action, and both only to roles that need both.

List entries

GET /admin/retailos/audit-logs

Query params: limit, offset, subject_id, subject_type, store_id, organization_id, module, source, operation_type, from (ISO date), to (ISO date), q (free-text on title/description/path).

Every filter — including from/to and q — is applied by the database before pagination, so count is the total number of matching rows and paging past the first page is safe. q is matched case-insensitively as a "contains"; % and _ in the search term are treated as literal characters.

q must be at least 3 characters — shorter terms are rejected with 400. A one- or two-character term produces no trigrams, so ILIKE '%te%' cannot use the GIN indexes and degrades to a sequential scan over the whole trail. An empty q still means "no search", so a cleared search box is not an error.

Get single entry

GET /admin/retailos/audit-logs/:id

Export CSV

GET /admin/retailos/audit-logs/export?from=2026-01-01&to=2026-06-30

Requires audit.export permission. from and to are required.

Accepts the same filters as the list route (subject_id, subject_type, store_id, organization_id, module, source, operation_type, q) so an export matches the screen it was launched from, including the same 3-character minimum on q. Exports larger than 10,000 rows are refused with RETAILOS_AUDIT_EXPORT_TOO_LARGE and the actual row count — narrow the date range and retry.

Response capture

With captureResponse: true, the JSON body a handler sends is stored on response_snapshot, so an operator can see what the system answered and not just what was asked. It is off by default: a response usually carries more data than the request that triggered it, including the same PII.

  • Only res.json is captured. res.send also carries CSV exports and binary downloads, which would bloat the table without telling an operator anything the request row does not already.
  • The body passes through the same redact function and maxBodyBytes cap as the request payload, so tokens and PII are masked before storage and oversized bodies are truncated with { _truncated: true, _original_bytes: N }.
  • A rule's describe(req, responseBody) receives the redacted response, so descriptions can quote values the handler produced (a new id, a computed total). Without captureResponse it receives null.

Indexes

The migration adds, alongside the primary key and the soft-delete index:

| Index | Type | Serves | |---|---|---| | IDX_retailos_audit_log_entry_occurred_at | btree, partial on deleted_at IS NULL | the from/to range and ORDER BY occurred_at DESC | | IDX_retailos_audit_log_entry_title_trgm | GIN trigram | q against title | | IDX_retailos_audit_log_entry_description_trgm | GIN trigram | q against description | | IDX_retailos_audit_log_entry_api_path_trgm | GIN trigram | q against api_path |

q runs as ILIKE '%term%'. A leading wildcard makes a btree index unusable, so the searched columns need trigram indexes; these require the pg_trgm extension, which the migration creates.

Who can create it. pg_trgm is a trusted extension from PostgreSQL 13 onward, so the migrating role does not need to be a superuser — CREATE privilege on the database is enough, which the owning application user normally has. Superuser (on RDS: the master user or rds_superuser) is only required on PostgreSQL 12 and older. Verify with:

-- Run as the same role the migration runs as.
SELECT current_user,
       current_setting('server_version')                                    AS pg_version,
       (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)         AS is_superuser,
       has_database_privilege(current_user, current_database(), 'CREATE')   AS can_create_in_db,
       (SELECT bool_or(trusted) FROM pg_available_extension_versions
          WHERE name = 'pg_trgm')                                           AS pg_trgm_trusted,
       EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')        AS already_installed;

already_installed, or pg_trgm_trusted AND can_create_in_db, means the migration will succeed. If neither holds, have a superuser or a provider role with equivalent privileges (on RDS: the master user or a member of rds_superuser) run CREATE EXTENSION IF NOT EXISTS pg_trgm; once beforehand; the migration's statement is then a no-op. The extension statement is deliberately left in the migration and is not permission-guarded — a role without the privilege must fail the migration loudly rather than leave the table unindexed behind a green deploy.

The GIN indexes are written by hand because the model DSL cannot express a GIN index. Schema diffing does not know about them — do not let a regenerated migration drop them.

Default PII redaction

The built-in defaultRedact strips values for keys matching (case-insensitive): password, passwd, token, secret, api_key, apikey, authorization, auth, access_token, refresh_token, credit_card, card_number, card_no, cvv, cvc, expiry, ssn, otp, pin, email, phone, mobile, phone_number, cookie, set-cookie, x-api-key, x-auth-token, x-access-token, proxy-authorization, x-csrf-token, x-xsrf-token.

The last eight are header names, redacted the same way when they show up in the captured headers object as when they show up in a request body under those same keys.

Redacted values are replaced with "[REDACTED]". The original object is never mutated.

Permissions

| Key | Description | |---|---| | audit.read | List and retrieve audit log entries | | audit.export | Export entries as CSV |

Register via RBAC:

# Grant to a role (using the RBAC API)
POST /admin/retailos/roles/:roleId/permissions
{ "permission_id": "<perm_id>" }

Immutability

Entries are append-only. updateAuditLogEntries and deleteAuditLogEntries on the service throw AuditLogImmutableError. The only sanctioned deletion path is auditService.pruneOlderThan(date), intended for scheduled retention jobs.

pruneOlderThan deletes the oldest qualifying entries in batches of 10,000 and returns how many it removed. A short batch means nothing older than the cutoff is left; a full batch does not prove the opposite, since a backlog that is an exact multiple of the batch size fills every batch and leaves nothing behind. Call hasEntriesOlderThan(date) when you need to know.

Retention

The plugin ships the scheduled job that calls it: retailos-audit-log-retention, nightly at 03:00, registered automatically like any other plugin job.

It is opt-in — with retentionDays unset (the default) nothing is ever deleted, which is the right default for an audit trail. Set it to enable retention:

{ resolve: "@devx-retailos/audit-log", options: { retentionDays: 365 } }

Each run drains at most 10 batches (100,000 rows) and stops early once a batch comes back short. The bound keeps a first run against a never-pruned table from holding the database busy for the whole backlog; whatever is left is picked up the next night. A run that hits the bound asks the database whether anything older than the cutoff survives, and logs more_remaining: true only when rows actually remain — a full final batch is not on its own proof of a backlog, since the last permitted batch can be the one that clears it. A failure mid-drain is logged, not thrown — the rows already deleted stand and the next run continues from the same cutoff.

pruneOlderThan assumes a single pruner. Two processes racing on the same cutoff still delete correctly, but both report the full batch. Do not run your own retention loop alongside the job.

To drive retention yourself instead, leave retentionDays unset and call the service directly:

let removed: number
do {
  removed = await auditService.pruneOlderThan(cutoff)
} while (removed === 10_000)

Error codes

| Code | Class | When | |---|---|---| | RETAILOS_AUDIT_IMMUTABLE | AuditLogImmutableError | update or delete attempted | | RETAILOS_AUDIT_NOT_FOUND | AuditLogEntryNotFoundError | entry id does not exist | | RETAILOS_AUDIT_EXPORT_TOO_LARGE | AuditLogExportTooLargeError | export exceeds 10,000 rows | | RETAILOS_AUDIT_EXPORT_MISSING_DATE_RANGE | AuditLogExportMissingDateRangeError | export called without from/to |