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

@nodii/role-catalog

v0.14.0

Published

Boot-time permission-catalog publisher for Nodii module-services. Type-safe definePermissions + publishCatalogAtBoot wrapper (cold-start retry, idempotency, non-boot-blocking degrade + background repair per D410(3)) on top of the tenant-service RoleCatalo

Readme

@nodii/role-catalog

Boot-time permission-catalog publisher for Nodii module-services. Type-safe definePermissions + publishCatalogAtBoot (cold-start retry, idempotency, non-boot-blocking degrade + background repair) over the tenant-service RoleCatalogService gRPC contract.

Spec: planning-hub feature_doc serviceId=nodii-libs docKey=role-catalog, 07-rbac-doctrine, D410(3).

A publish failure does not kill your process. Per D410(3) the catalog publish is best-effort and non-boot-blocking. publishCatalogAtBoot records a queryable degraded state, logs at ERROR, keeps retrying in the background, and lets boot continue. See Failure behaviour.

Note: 07-rbac-doctrine.md § 5 and the role-catalog.md feature_doc § 5.6 still contain the pre-D410 sentence "Service refuses to start…". That text is stale and contradicts the locked decision — drift b8c94370-0133-40fb-b645-0721c88e4fe7 is open against both. Build to D410(3).


domain_key is NOT module — read this first

A permission key is <domain>.<resource>.<verb>[.<qualifier>], e.g. crm.lead.view. That leading crm is the domain key: an RBAC permission NAMESPACE.

It is not the sellable commerce module a tenant buys, even though the two frequently share a spelling. Two distinct things:

| Concept | What it is | Where it lives | | --- | --- | --- | | domain_key | RBAC permission namespace — first segment of every permission key | this library; PublishCatalogRequest.domain_key | | module (commerce) | the sellable module / add-on a tenant subscribes to | the commerce catalog; surfaced here only as PermissionDef.entitlementKey |

They are wired together, deliberately and in exactly one place: entitlementKey names the commerce module that UNLOCKS a permission.

Since 0.11.0 it is REQUIRED and there is no default (D655, implementing D649 §3). The old rule — omitted ⇒ defaults to the permission's own domain_key — is exactly why the two concepts got conflated, and why, before 0.7.0, both were called module_key. It is dead: a permission with no commerce gate now says so explicitly with SUBSTRATE_ENTITLEMENT ("substrate"), and "substrate" is a reserved namespace so nothing can publish a substrate.* domain that aliases it.

If you are adding a permission, you are choosing a domain_key. If you are deciding what a tenant has to pay for, you are choosing an entitlementKey.

Quick start

import {
  definePermissions,
  defineStarterRoles,
  publishCatalogAtBoot,
} from "@nodii/role-catalog";

// entitlementKey is REQUIRED since 0.11.0 (D655 / D649 §3): name the sellable
// commerce module/add-on that unlocks the key, or SUBSTRATE_ENTITLEMENT if the
// key has no commerce gate at all. There is no default and no escape hatch.
export const CRM_CATALOG = definePermissions("crm", {
  leadView:   { resource: "lead", verb: "view",   entitlementKey: "crm" },
  leadCreate: { resource: "lead", verb: "create", entitlementKey: "crm" },
  leadDelete: {
    resource: "lead",
    verb: "delete",
    isCritical: true,
    entitlementKey: "crm",
  },
  // Enforced by crm, but UNLOCKED by an add-on a tenant buys separately.
  collectorRun: {
    resource: "collector",
    verb: "run",
    entitlementKey: "crm.collector",
  },
});

export const CRM_STARTER_ROLES = defineStarterRoles("crm", CRM_CATALOG, [
  {
    // `key` is the MACHINE IDENTITY (required since 0.11.0, D654). Rename the
    // display name freely — identity does not move.
    key: "crm_admin",
    // `name` is DISPLAY-ONLY and must be Title Case. A slug here is rejected
    // with a targeted error telling you to put it in `key`.
    name: "CRM Administrator",
    description: "Full access to CRM.",
    // Concrete keys only — wildcards are rejected (NEW-BM-D7).
    permissionKeys: [
      CRM_CATALOG.leadView.key,
      CRM_CATALOG.leadCreate.key,
      CRM_CATALOG.leadDelete.key,
    ],
    roleClass: "admin",
  },
]);

// Does NOT throw on a publish failure. Awaiting it is correct: it awaits ONE
// attempt so a healthy boot is fully published, then degrades + retries in the
// background if that attempt failed.
const state = await publishCatalogAtBoot({
  domainKey: "crm",
  catalogVersion: "1.0.0",
  serviceOwner: "nodii-crm-service",
  permissions: CRM_CATALOG,
  starterRoles: CRM_STARTER_ROLES,
  tenantServiceUrl: process.env.TENANT_SERVICE_GRPC_URL!,
  logger,  // recommended — routes the ERROR lines into your log pipeline
});
// state.status: "published" | "degraded"

Scaffold the above into a service with bunx @nodii/role-catalog migrate-gen <service-name>.


Failure behaviour

Default (onPublishFailure: "degrade") — a publish failure is loud but non-fatal, per D410(3):

  • the failure is recorded as durable, queryable state,
  • every failed attempt logs at ERROR (the default logger is console-backed — there is no configuration in which this goes quiet),
  • a bounded retry ladder (5s, 15s, 30s, 60s, 120s) then a slow refresh (15 min) keeps re-attempting on unref'd timers, so an out-of-band repair heals the process with no restart and no redeploy,
  • boot continues and the service serves.

Surface the state from /health so a degraded catalog is visible without scraping boot logs:

import { getCatalogPublishState } from "@nodii/role-catalog";

const catalog = getCatalogPublishState("crm");
// { status: "published" | "degraded" | "pending", attempts, lastError,
//   terminal, retrying, publishedAt, response }

Why degrading is safe — it is fail-CLOSED

A published catalog is what lets a tenant hold a permission key. If the publish never lands, the domain's keys are absent from tenant-service, the D544 roles→perms resolution yields nothing for them, and guarded routes 403. There is no path where skipping the publish grants access that a successful publish would have withheld.

Crashing instead would convert a partial, fail-closed degradation into a total outage of surfaces that have nothing to do with RBAC — and it cannot fix a FAILED_PRECONDITION, which is a data condition in tenant-service. That is exactly how nodii-task-tracking ended up crash-looping 12 times on ECS with its deploy pipeline blocked.

Do not wrap the call in a try/catch that exits. If you want the old behaviour, ask for it explicitly:

await publishCatalogAtBoot({ ...opts, onPublishFailure: "throw" });
// throws CatalogPublishBootFailed (carries the recorded state)

A local authoring error (bad key grammar, reserved namespace, duplicate key, oversize catalog) is recorded with terminal: true and is not retried — the catalog is compiled into the build, so re-sending identical bytes can never succeed. Fix it and redeploy.


Migrating 0.8.x → 0.9.0 (non-boot-blocking by default)

publishCatalogAtBoot now returns CatalogPublishState instead of PublishCatalogResponse, and no longer throws by default. The return-type change is deliberate: it makes the behaviour flip a compile-time migration rather than a silent runtime one.

| 0.8.x | 0.9.0 | | --- | --- | | const res = await publishCatalogAtBoot(o) | const st = await publishCatalogAtBoot(o); st.response | | throws on failure (default) | degrades; pass onPublishFailure: "throw" to keep throwing | | failFastOnVersionMismatch: true | onPublishFailure: "throw" (old spelling still honoured) | | failFastOnVersionMismatch: falsestill threw, with the raw grpc error | genuinely degrades | | — | publishCatalogOnce — the raw single-shot publish, always throws a typed error |

Passing both onPublishFailure and failFastOnVersionMismatch with conflicting meanings throws — this library will not guess whether a publish failure should kill your process.

If your service hand-rolled a try/catch around the publish to keep boot alive, delete it — the library owns that now, and your catch is likely narrower (most only absorbed cold-start exhaustion, not the FAILED_PRECONDITION that actually took task-tracking down).


0.11.0 DELETED the 0.6.x → 0.7.0 module_key compat layer

src/compat.ts is gone, and with it the {domainKey} | {moduleKey} option unions, the warn-once deprecation shim, resetDeprecationWarnings, the NODII_ROLE_CATALOG_SUPPRESS_DEPRECATIONS env lever, and the deprecated aliases (validateModuleKey, isSubstrateModuleKey, isModuleKeyReserved, serviceNameToModuleKey, SUBSTRATE_MODULE_KEYS, SubstrateModuleKey, InvalidModuleKey).

domainKey is the only spelling. This was grep-verified before deletion: every live publish call site in the fleet already passes it, and the remaining moduleKey: hits are structured-LOG fields with their own deprecation comments (kyc, notification, task-tracking), not options.

The two 0.7.0 changes that a shim could never have rescued are unchanged and still worth knowing about, because they change by VALUE and nothing flags them:

  1. ValidationFailureKind "invalid_module_key""invalid_domain_key" is a TELEMETRY LABEL. A panel filtering the old literal renders fine and matches nothing. emitLegacyValidationFailureKind: true still dual-emits while you migrate (off by default — it double-counts).
  2. InvalidPermissionSegment.which "module_key""domain_key". The old literal is retained in the union so e.which === "module_key" still compiles; it just stops matching.

By contrast, the 0.11.0 labels (invalid_entitlement_key, starter_role_invalid_key, starter_role_name_not_title_case, starter_role_duplicate_key, namespace_validation_rejected) are purely ADDITIVE — nothing is renamed, so no existing panel goes dark.

Dev commands

bun run --cwd ts/role-catalog typecheck
bun run --cwd ts/role-catalog test
bun run --cwd ts/role-catalog build

# cross-language byte-equivalence gate (needs the local stack: bun run stack:up)
bash synthetic-consumers/role-catalog/parity-fence/run-parity-fence.sh