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/doctrine-lint

v0.2.0

Published

Nodii doctrine CI gates as one dependency: the D417 naming-conventions rules (gRPC FQN / RBAC key / outbox topic) plus the CROSS-TENANT RLS rule that blocks the membership-union policy shape and the untenanted user-session DB client — as named exports and

Readme

@nodii/doctrine-lint

Nodii doctrine CI gates as one dependency. Two bins ship here:

| Bin | Gate | |-----|------| | naming-conventions-lint | D417 naming conventions — gRPC FQN / RBAC key / outbox topic | | cross-tenant-lint | Cross-tenant RLS — the membership-union policy shape and the untenanted user-session DB client |

Both exit 1 on any violation. Neither has an advisory mode.


Gate 1 — D417 naming conventions

Replaces the hand-vendored scripts/review/naming-conventions-lint.ts that had been copied into 14 service repos and had drifted into four incompatible variants — so the "single source of truth" enforced a different rule depending on which repo you happened to be in.

Surfaces enforced

| # | Surface | Rule | |---|-----------|------| | 1 | gRPC FQN | served protos: package === nodii.<this-svc>.v1; vendored/client protos: nodii.<svc>.v1 (any svc) | | 2 | RBAC keys | <module>.<resource>.<verb>[.<qualifier>] — 3-4 dot segments, lowercase + underscores, no colons, no hyphens | | 3 | Outbox | <emitting-svc>.<aggregate>.<event>.vN — the .vN is MANDATORY |

CI adoption (one line)

// package.json
"gate:naming": "bunx naming-conventions-lint"
# .github/workflows/ci.yml
- run: bunx naming-conventions-lint

Add @nodii/doctrine-lint as a devDependency and delete scripts/review/naming-conventions-lint.ts.

CLI

bunx naming-conventions-lint                     # slug from ./package.json
NAMING_LINT_SVC=tenant bunx naming-conventions-lint
bunx naming-conventions-lint svc=tenant          # explicit arg (wins over env)
bunx naming-conventions-lint exclude=docs/       # extra path prefixes
bunx naming-conventions-lint --lint-colon-keys   # disable the D430 colon carve-out

Env equivalents: NAMING_LINT_SVC, NAMING_LINT_EXCLUDE (comma-separated), NAMING_LINT_COLON_KEYS=1. Exit 1 on any violation, 0 otherwise.

The output is a frozen byte contract — services grep it in CI, so it is byte-identical to what the vendored copies printed. tests/cli.test.ts pins it.

Bun only. The scanner uses Bun.Glob, exactly as all 14 vendored copies did, and the bin ships #!/usr/bin/env bun. Running the built bin under plain node fails with Cannot find package 'bun'. Every consuming repo already runs Bun in CI; engines.bun declares the requirement.

Library

import { RX_OUTBOX, isCanonicalOutboxTopic, lintNamingConventions } from "@nodii/doctrine-lint";

expect(isCanonicalOutboxTopic("tenant.tenant.created.v1")).toBe(true);
expect(isCanonicalOutboxTopic("tenant.usage.flush")).toBe(false); // missing .vN

const { violations, ok } = lintNamingConventions({ root: process.cwd() });

Variant provenance

Four distinct copies existed across 14 repos (hashes taken at consolidation time):

| Variant | Lines | Repos | |---------|-------|-------| | V-A | 208 | nodii-planning-hub (canonical upstream) | | V-B | 207 | nodii-crm-service | | V-C | 148 | nodii-auth, nodii-customer-service, nodii-hr-service, nodii-kyc-service, nodii-notification-service, nodii-task-tracking, nodii-tenant-service, nodii-ticketing-service | | V-D | 140 | nodii-billing-service, nodii-edge, nodii-geo-service, nodii-product-service |

Most of the line-count delta is formatting (prettier multi-line vs compact) and null-safety guards. Only two axes were semantic, and both are now explicit configuration rather than a silent choice:

C1 — the D430 colon carve-out (d430ColonCarveOut, default true)

V-B/V-C skip COLON-delimited literals in the RBAC scan; V-A/V-D judge them against the dot grammar. Introduced downstream by the "systemic-gap sweep" that reached only 9 of 14 repos and was never back-ported to the hub.

Resolved in favour of the carve-out (the LOOSER option), against a naive strictest-superset read. Enforcing the dot grammar on colon literals produces 14 false positives fleet-wide and zero real defects — every hit is a legitimate D430 S2S transport scope (tenant:role_catalog:write), a Node builtin specifier (node:crypto), or a doc fixture (a:b). Colon strings are a different namespace, not malformed RBAC keys. RX_S2S_SCOPE is exported for services that want to assert their scopes, but is deliberately not wired into the scan — the literal harvest cannot distinguish a scope from node:crypto.

Pass --lint-colon-keys to restore the pre-D430 behaviour.

C2 — the docs/ exclusion (exclude, default none)

V-A only. The hub's docs/ tree is the INGESTED planning corpus and vendors OTHER services' locked protos, which are registry DATA, not surfaces the hub serves — 24 false "served proto" violations without it. Repo-specific, so it is a caller option: the hub passes exclude=docs/; the other 13 repos pass nothing.

Known coverage boundary (inherited)

A bare unversioned literal in an outbox-named file — export const T = ["a.b.c"] — is not flagged. RX_TOPICISH_LITERAL (the broad 3a scan) itself requires .vN to harvest, so it structurally cannot flag a missing-.vN value; only the 3b emit-FIELD scan (topic: / eventType: / event_type:) catches that case. An unbound array literal is not proof the value is ever emitted, and harvesting every 2-dot string would false-positive broadly.

This matches the vendored V-A and V-C copies exactly and is pinned by the two BOUNDARY: tests in tests/scan.test.ts. Widening it is a decision, not a refactor — it would newly red every repo that keeps unversioned constants near outbox code.


Gate 2 — cross-tenant RLS (cross-tenant-lint)

The hole

Services define RLS for two runtime roles. The service pool is correctly fenced to one tenant:

CREATE POLICY tasks_svc_select_policy ON tasks FOR SELECT
  TO nodii_services USING (tenant_id = auth.get_tenant_id());

The user-session pool is fenced to a MEMBERSHIP UNION:

CREATE POLICY tasks_auth_select_policy ON tasks FOR SELECT
  TO authenticated
  USING (tenant_id IN (SELECT auth.user_tenant_ids(auth.get_user_id())));

auth.user_tenant_ids returns every tenant the principal is active in, and the session client sets only app.user_id. A user with memberships in tenants A and B, logged into A, reads and writes B. Reproduced live in nodii-task-tracking: a tenant-A session approved a tenant-B refund of ₹250,000 and got HTTP 200. Nothing was forged.

Operator ruling (2026-07-27): "enforce that even if this shape regresses we DON'T ALLOW ANYTHING CROSS-TENANT EVER."

This gate is the static half. The runtime half — a tenant-scoped session client and the replacement policy predicate — is @nodii/db-rls. They are complementary: a static rule catches the SQL being written; a runtime check catches behaviour a grep cannot see. Neither substitutes for the other.

What it flags

| Rule | Shape | |------|-------| | union-predicate | a policy reachable by the user-JWT role whose USING / WITH CHECK resolves tenancy through auth.user_tenant_ids(...) with no accompanying tenant_id = auth.get_tenant_id() conjunct | | unfenced-session-client | a DB context that writes app.user_id and never pins app.tenant_id | | invalid-waiver | a malformed escape-hatch marker (it suppresses nothing, and says so) |

"Reachable by the user-JWT role" means TO authenticated or no TO clause at all — an absent TO defaults to PUBLIC, which the user-JWT role is a member of. That is the same hole with less text, and a regex keyed on the literal TO authenticated misses it entirely.

USING and WITH CHECK are judged independently: a policy can fence reads and not writes.

A tenant pin that is a top-level OR branch is still flagged — a disjunctive pin constrains nothing. x = auth.get_tenant_id() AND (a OR b) is fine; the OR is inside parens.

What it does NOT flag

  • nodii_services / nodii_owner policies. Different roles; out of scope by construction.
  • The platform-staff cross-tenant path (D185). Staff access runs on a separate role, nodii_platform_ops, on its own pool, predicated on a per-grant platform_ops_grants_replica EXISTS check with a scope_kind, an expiry and a revocation column — auditable per row. It needs no waiver: it is a different role with a different predicate, so this rule never sees it. That is the point. Anything reaching for cross-tenant reach through the authenticated role is by construction not that path, and must not be able to borrow its legitimacy.
  • withPlatformOpsContext-shaped code. It writes app.user_id too, but it also writes app.platform_ops_service_id / app.platform_ops_permission, which identifies it structurally.
  • The target fix. USING (tenant_id = auth.get_tenant_id() AND tenant_id IN (SELECT auth.user_tenant_ids(auth.get_user_id()))) is clean, in either clause order, with or without a table qualifier, and with the raw current_setting('app.tenant_id') form. Flagging the shape every repo is migrating to would make the rule unadoptable.
  • Single-tenant authPoolMode. USING (tenant_id = auth.get_tenant_id()) on authenticated (live in nodii-finance-service) is clean.

Parsing, not pattern-matching

A policy is a multi-line statement whose clause order and whitespace vary by generator. The reader in src/sql-parse.ts tokenizes:

  • -- and nested /* */ comments are blanked to spaces — length- and newline-preserving, so offsets stay 1:1 and reported line numbers are exact. A commented-out policy is invisible; a trailing -- generated does not hide a real one.
  • '…' (with ''), "…" idents (with "") and $tag$…$tag$ bodies are skipped, so a ; or a USING inside a string neither splits nor fakes a statement.
  • USING / WITH CHECK bodies are extracted by balanced parens, quote-aware.
  • TO is parsed as a list, so TO nodii_services, authenticated is caught.

Policy DDL living inside code is read too — every service authors its policies in a scripts/db/gen-rls.ts that emits the DDL as concatenated string literals. Literal bodies are harvested in source order, \n escapes decoded, ${…} replaced with __EXPR__, and the same SQL reader runs over the result. Joining adjacent literals can only add text to a statement, never split one, so it cannot manufacture a false positive for a rule that fires on a MISSING clause.

The escape hatch

Deliberately awkward, and never silent:

-- doctrine-lint: allow-cross-tenant(D185) reviewed 2026-07-27: read-only catalog mirror, no tenant column
CREATE POLICY … ;
  • It must cite a locked hub decision in parentheses, and carry ≥ 12 characters of justification. A marker missing either is an invalid-waiver finding — it suppresses nothing and reports itself. A hatch you can trip over by accident is worse than no rule.
  • It reaches 3 lines, on or above the statement. It is not a file-level or directory-level switch; you cannot waive a tree.
  • Every honoured waiver is printed on stdout on every run, with the decision and the justification, and counted in the summary line — including on a clean (exit 0) run.

// comments work identically, and the same marker waives the session-client rule.

CI adoption

- run: bunx cross-tenant-lint
bunx cross-tenant-lint
bunx cross-tenant-lint exclude=drizzle/,legacy/   # path prefixes, as in the naming gate
bunx cross-tenant-lint --include-tests            # scan test files too (off by default)

Env equivalents: CROSS_TENANT_LINT_EXCLUDE, CROSS_TENANT_LINT_INCLUDE_TESTS=1.

Known blind spots

Documented on purpose. An undiscovered blind spot is the problem; a recorded one is a backlog item.

  1. USING (true) on authenticated is not flagged. A user-facing policy with no tenant fence at all is a bigger hole than the union — but the fleet legitimately uses that shape for platform-global catalog tables (17 in nodii-tenant-service/infra, 4 in nodii-hr-service: currencies, module_bundles, bundle_pricing_tiers, task_action_button_registry, …). Telling a tenant-less catalog from an unfenced tenant table needs the table's schema, which a static SQL rule does not have. Closing this needs a per-table allowlist or a column check against the Drizzle schema — a decision, not a tweak. This is why the real 0010_rls_policies.sql reports 194 findings from 195 user-facing clauses: the one skipped is task_action_button_registry_read_policy … USING (true), a genuinely tenant-less registry.
  2. A predicate that reaches tenancy through a helper the rule cannot see. If a service replaces auth.user_tenant_ids(...) with its own auth.my_tenants(...), the rule does not know. It keys on the one function name the doctrine defines. Renaming the helper defeats it.
  3. The session-client rule searches two enclosing FUNCTION bodies (control blocks in between are free). A tenant pinned three function levels above the app.user_id write reads as unfenced. Widening it would let a broken client hide behind a correct sibling in the same module — which is exactly the live nodii-task-tracking shape.
  4. The session-client rule is a text search, so prose fires it. A comment that merely mentions auth.set_user_id() inside a function with no tenant write is reported. Pinned by a test rather than pretended away.
  5. Python session clients are not checked for rule 2 (no braces to scope). Python policy DDL in string literals is read.
  6. Test files are skipped by default (--include-tests to look). A test that stands up the union policy against a throwaway database is a test double, and reporting it drowns the real hits.
  7. Runtime construction is invisible. A predicate assembled at runtime from variables — rather than written as a literal — is not seen. That is the seam @nodii/db-rls covers.
  8. The Drizzle withRls(col) builder is not judged at the call site. A schema file that writes ...withRls(t.tenantId) carries no policy text — the predicate is generated inside db-rls. It is caught only when the repo commits the generated migration SQL, which nodii-crm-service (41 files) and nodii-geo-service (8) do, so today the coverage holds. A repo that adopted the builder and ran drizzle-kit push without committing the SQL would be invisible to this gate. Judging the builder needs db-rls's authPoolMode default to change — which is the runtime half's job, not a regex's.

Blast radius (measured 2026-07-27, read-only sweep of the 15 service repos)

1,378 findings across 9 of 15 repos — 1,377 union-predicate + 1 unfenced-session-client — from 3,687 policies parsed. 1,371 are distinct policy+clause pairs (so this is not re-counting the same policy), and 734 sit outside append-only Drizzle journal directories.

| repo | findings | outside journal dirs | |------|---------:|---------------------:| | nodii-crm-service | 570 | 5 | | nodii-hr-service | 376 | 376 | | nodii-task-tracking | 196 | 196 | | nodii-tenant-service | 106 | 106 | | nodii-auth | 64 | 12 | | nodii-geo-service | 27 | 0 | | nodii-customer-service | 21 | 21 | | nodii-vendor-service | 12 | 12 | | nodii-product-service | 6 | 6 | | nodii-billing-service | 0 (158 policies) | | | nodii-finance-service | 0 (506 policies) | | | nodii-kyc-service | 0 (76 policies) | | | nodii-notification-service | 0 (42 policies) | | | nodii-edge, nodii-ticketing-service | 0 (no policies) | |

The six clean repos are the evidence that this is not "flags everything with authenticated": billing and notification already pin tenant_id = auth.get_tenant_id() on the user role, finance runs single-tenant authPoolMode, and kyc deliberately grants the authenticated pool no policy on its PII tables.

The single unfenced-session-client hit is nodii-task-tracking/src/db/setup/setup-db.tscreateSecureClient — the exact client behind the live incident.

nodii-libs itself reports 27 — and that is the important number, because it says the hole is upstream, not nine separate service mistakes:

| file | findings | what it is | |------|---------:|------------| | ts/audit-chain/src/migrations/001-audit-chain.sql | 6 | lib-emitted policies | | ts/saga/src/migrations/{index.ts,001-saga-state.sql} | 10 | lib-emitted policies | | go/saga/postgres_store.go | 5 | the Go port of the same | | ts/db-rls/src/rls-policy.ts | 2 | generateRlsPolicy — the canonical generator | | ts/db-rls/src/{context,drizzle-client,postgres-js-client}.ts, go/db-rls/pgx_session.go | 4 | withUserMembershipContext + its three ports |

withUserMembershipContext sets ONLY app.user_id on purpose — it is documented as the doctrine 08-rls § 4 multi-membership API. So the untenanted session is a first-class library contract, not an accident in one service. That is why the runtime fix belongs in @nodii/db-rls and why this static gate cannot substitute for it.