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

@drvalue-oss/iam-nestjs

v0.9.0

Published

NestJS module for drvalue IAM: gateway signature verification, X-User-* parsing, decorators, proxy helpers, outbound subscription-mirror and user-lookup clients

Readme

@drvalue-oss/iam-nestjs

NestJS module for the drvalue IAM platform. Handles:

  • HMAC-SHA256 gateway signature verificationGatewaySignatureGuard, or createGatewayAuthMiddleware() / verifyGatewayRequest() for middleware/proxy paths where guards can't run
  • X-User-* header parsing — populates a typed req.user: IamUserPayload
  • @CurrentUser(), @CurrentGroup(), @Roles(), @Public(), @Authenticated(), @SkipGatewaySignature(), @RequireGatewaySignature() decorators
  • Proxy helpersforwardIamUserHeaders() (one-call strip + re-inject), plus the lower-level stripClientUserHeaders() / injectIamUserHeaders()

Install

pnpm add @drvalue-oss/iam-nestjs @drvalue-oss/iam-core

Peer dependencies: @nestjs/common, @nestjs/core, reflect-metadata, rxjs.

Setup

// app.module.ts
import { Module } from '@nestjs/common';
import { IamModule } from '@drvalue-oss/iam-nestjs';

@Module({
  imports: [
    IamModule.forRoot({
      // Required when enforceGatewayOnly=true. Same secret IAM Gateway uses to sign.
      gatewaySharedSecret: process.env.GATEWAY_SHARED_SECRET!,

      // PRODUCTION: must be true. Rejects requests without a valid signature.
      // DEV: false lets you `curl localhost:3000` directly.
      enforceGatewayOnly: process.env.NODE_ENV === 'production',

      // Optional. Default ±30,000 ms.
      signatureTimestampSkewMs: 30_000,

      // Optional. Default true = every route requires an authenticated user
      // unless marked @Public(). Set false to make routes public by default
      // and opt in with @Authenticated() / @Roles().
      secureByDefault: true,
    }),
  ],
})
export class AppModule {}

IamModule.forRoot() installs both guards as APP_GUARD-scoped globals by default, so every controller is protected without @UseGuards(). Pass global: false if you want to apply them selectively.

The guards cover two independent axes. Each has a module-level default plus a force-ON and a force-OFF decorator. Method-level decorators override class-level; on a same-level conflict the safe side (protect/require) wins.

| Axis | Module default | Force ON | Force OFF | |---|---|---|---| | User auth (IamUserGuard) | secureByDefault (default true) | @Authenticated() / @Roles() | @Public() | | Gateway signature (GatewaySignatureGuard) | enforceGatewayOnly | @RequireGatewaySignature() | @SkipGatewaySignature() |

@RequireGatewaySignature() forces signature verification on one route even when enforceGatewayOnly is false (e.g. a sensitive endpoint while local dev is otherwise relaxed); if it is required but no gatewaySharedSecret is configured, the request is rejected (fail closed).

Using decorators

import { Controller, Get } from '@nestjs/common';
import {
  CurrentUser,
  CurrentGroup,
  Roles,
  Public,
  Authenticated,
  SkipGatewaySignature,
  RequireGatewaySignature,
  type IamUserPayload,
  type GroupMembership,
} from '@drvalue-oss/iam-nestjs';

@Controller('orders')
export class OrdersController {
  @Get('mine')
  @Roles('USER') // PLATFORM_ADMIN bypasses
  list(@CurrentUser() user: IamUserPayload, @CurrentGroup() group: GroupMembership) {
    return { userId: user.sub, groupId: group.id, role: group.role };
  }

  @Get('me')
  @Authenticated() // any logged-in user (only needed when secureByDefault: false)
  me(@CurrentUser() user: IamUserPayload) {
    return { userId: user.sub };
  }

  @Get('public-stats')
  @Public() // Skip IamUserGuard (signature still required)
  stats() {
    return { ok: true };
  }

  @Get('settle')
  @Roles('ADMIN')
  @RequireGatewaySignature() // always require gateway origin, even in dev
  settle() {
    return { settled: true };
  }
}

@Controller('health')
export class HealthController {
  @Get()
  @SkipGatewaySignature()
  @Public()
  health() {
    return { status: 'ok' };
  }
}

Acting as a further proxy (BFF / gateway)

http-proxy-middleware runs as middleware, which executes before NestJS guards — so on a proxied route the guards never run and req.user is never set. Verify the gateway signature and populate req.user yourself with createGatewayAuthMiddleware(), then re-inject the user with forwardIamUserHeaders():

// app.module.ts
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { IamModule, createGatewayAuthMiddleware, forwardIamUserHeaders } from '@drvalue-oss/iam-nestjs';
import { createProxyMiddleware } from 'http-proxy-middleware';

const apiUserProxy = createProxyMiddleware({
  target: 'http://api-user:3001',
  changeOrigin: true,
  // NOTE: the forwarded request keeps the original gateway signature, which no
  // longer verifies downstream. If the downstream runs `enforceGatewayOnly: true`,
  // re-sign for the next hop — see "Re-signing for a second hop" below:
  //   forwardIamUserHeaders(proxyReq, req, { resign: { secret: process.env.DOWNSTREAM_GATEWAY_SECRET! } })
  on: { proxyReq: (proxyReq, req) => forwardIamUserHeaders(proxyReq, req) },
});

@Module({ imports: [IamModule.forRoot({ gatewaySharedSecret: process.env.GATEWAY_SHARED_SECRET!, global: false })] })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
        // verify gateway origin (HMAC binds all X-User-* identity headers) + set req.user, then proxy
        createGatewayAuthMiddleware({ secret: process.env.GATEWAY_SHARED_SECRET! }),
        apiUserProxy,
      )
      .forRoutes('api-user');
  }
}

createGatewayAuthMiddleware rejects unsigned/forged requests with 403 and is the same HMAC check GatewaySignatureGuard does — done at the middleware layer where guards can't reach. Need just the primitives? verifyGatewayRequest(req, secret) returns { ok, reason, user }, and verifyGatewaySignature(params) is the pure core (the canonical binds timestamp, method, path, and all seven X-User-* headers).

If you instead run the proxy from inside a guarded controller (@All('*')), the guards already populate req.user — there forwardIamUserHeaders(proxyReq, req) alone is enough.

Heads-up: the request forwarded this way still carries the original gateway signature, but it will no longer verify downstream — X-User-Groups-Detail is stripped without being re-injected, and the signed path/timestamp are bound to this hop. Downstream services must either run with signature verification off behind a network policy, or have this BFF re-sign — see Re-signing for a second hop.

Verifying only some paths (path.include)

When the middleware is mounted broadly (a global app.use(...), or a proxy in front of NestJS routing) you may want it to verify only a subset of paths and let the rest through untouched. Pass path.include — a whitelist of matchers:

app.use(
  createGatewayAuthMiddleware({
    secret: process.env.GATEWAY_SHARED_SECRET!,
    // Only these paths are verified; everything else is passed straight through.
    path: { include: ['/login/root/iam', '/api/**'] },
  }),
);

A request whose path matches any entry is verified exactly as before (403 on a bad/missing signature). A request that matches none is passed straight to next() — not verified, with req.user / req.iamGatewayVerified left unset. Each matcher is a glob string (* = one non-slash segment, ** = any run including slashes), a RegExp, or a (path) => boolean predicate. Omit path.include to verify every request (the default).

Security: include makes the unmatched paths unprotected by this middleware. Only narrow it for routes you intend to leave open — if you are registering through NestJS's MiddlewareConsumer, prefer .forRoutes() / .exclude() so route scoping lives in one place.

Re-signing for a second hop (BFF → downstream)

The gateway signature binds the request path, so once a BFF proxies onward (path rewrite, header re-injection, timestamp aging) the original signature can never verify downstream. Either disable signature verification downstream and rely on a network policy, or have the BFF re-sign for the next hop so the downstream service keeps enforceGatewayOnly: true:

const downstreamProxy = createProxyMiddleware({
  target: 'http://auth-svc:3001',
  changeOrigin: true,
  on: {
    proxyReq: (proxyReq, req) =>
      forwardIamUserHeaders(proxyReq, req, {
        resign: { secret: process.env.DOWNSTREAM_GATEWAY_SECRET! },
      }),
  },
});

app.use(
  '/auth',
  createGatewayAuthMiddleware({ secret: process.env.GATEWAY_SHARED_SECRET! }),
  downstreamProxy,
);

With resign set, forwardIamUserHeaders:

  1. strips client X-User-* headers and re-injects the verified user (as before),
  2. unconditionally drops (even when the request is unverified) the inbound X-Gateway-Signature / X-Gateway-Timestamp (they are bound to the previous hop's path — dead weight downstream), and
  3. signs the outgoing request against the path the downstream service will see — only when req.iamGatewayVerified === true, i.e. when createGatewayAuthMiddleware (or GatewaySignatureGuard) cryptographically verified the inbound signature. Unverified requests go out unsigned and are rejected by the downstream's enforceGatewayOnly (fail closed).

Notes:

  • A verified anonymous request is re-signed too (the gateway signs those with all identity values empty) — req.user presence is deliberately NOT the re-sign condition.
  • Prefer a different secret per hop: if a downstream service leaks its secret, the attacker still cannot forge requests into the edge. An empty resign.secret throws.
  • X-User-Groups-Detail is not re-injected by forwardIamUserHeaders, so it is signed as empty downstream; parse it at the BFF if you need it.
  • X-User-Active-Group and X-User-Phone are never signature-protected, on any hop — do not make authorization decisions from them.
  • Re-signing mints a fresh timestamp, so the downstream ±30s skew window re-anchors at the BFF; like the gateway scheme itself there is no nonce, so pair it with TLS / a trusted network between hops.
  • Low-level primitive: signGatewayRequest(proxyReq, { secret, method?, path?, now? }) signs any outgoing request whose headers are final.

Pushing subscription state to IAM (outbound)

Everything above is inbound (verifying requests from IAM Gateway). The module also ships an outbound client, IamSubscriptionService, so a product backend can mirror subscription state into IAM from its own billing/webhook handlers. IAM renders that mirror on the user's "내 구독 관리" portal and the admin dashboard.

It calls IAM's machine-to-machine API (PUT/DELETE /internal/api/products/{productSlug}/subscriptions/{userId}), authenticated with X-Internal-Api-Key. Configure it in forRoot():

IamModule.forRoot({
  gatewaySharedSecret: process.env.GATEWAY_SHARED_SECRET!,
  enforceGatewayOnly: process.env.NODE_ENV === 'production',

  // Outbound subscription client (omit if you don't push subscriptions):
  internalApiBaseUrl: process.env.IAM_INTERNAL_API_BASE_URL!, // e.g. http://iam-server:10732
  internalApiKey: process.env.INTERNAL_API_KEY!,              // X-Internal-Api-Key
  productSlug: 'hangeon-chat',                                // default; overridable per call
});

Inject it where your billing events land. userId is the IAM user UUID — your backend owns the mapping from its own customer id.

import { Injectable } from '@nestjs/common';
import { IamSubscriptionService } from '@drvalue-oss/iam-nestjs';

@Injectable()
export class BillingWebhookService {
  constructor(private readonly subscriptions: IamSubscriptionService) {}

  // Register or update — idempotent on (userId, productSlug). The first call
  // registers; later calls update in place. Use for every paid-state change.
  async onPaid(userId: string, nextBillingAt: Date) {
    await this.subscriptions.upsert(userId, {
      tier: 'Pro',
      priceKrw: 12000,
      status: 'ACTIVE',
      nextBillingAt,
      manageUrl: 'https://pay.example.com/manage',
    });
  }

  // "FREE 이용 중" card — push a free mirror (e.g. on signup) so the user always
  // has a card. Forces priceKrw 0 / status ACTIVE / metadata.is_free_tier = true.
  async onSignup(userId: string) {
    await this.subscriptions.registerFree(userId, {
      manageUrl: 'https://pay.example.com/pricing', // "업그레이드 알아보기" link
    });
  }

  // Cancel — renewal stops; tier functional until graceUntil. Thin wrapper that
  // forces status CANCELED (the server keeps no read endpoint, so pass full state).
  async onCanceled(userId: string, graceUntil: Date) {
    await this.subscriptions.cancel(userId, {
      tier: 'Pro',
      priceKrw: 12000,
      graceUntil,
      manageUrl: 'https://pay.example.com/manage',
    });
  }

  // Remove the mirror entirely (e.g. GDPR erasure). For routine downgrades push
  // status: 'EXPIRED' via upsert instead — that keeps the row for support queries.
  async onErased(userId: string) {
    await this.subscriptions.delete(userId);
  }
}

Statuses mirror IAM's narrow enum: ACTIVE / CANCELED / EXPIRED. There is no FREE status — a free plan is the metadata.is_free_tier: true flag (set for you by registerFree), which IAM renders as a "FREE 이용 중" card.

Date fields (currentPeriodStart, currentPeriodEnd, graceUntil, nextBillingAt) accept a Date or a string. IAM stores them as Java LocalDateTime (zone-naive). A Date is serialized to a bare UTC wall-clock at seconds precision (2026-07-01T00:00:00, no offset), which the server always accepts. A string is passed through verbatim — keep it offset-free: the server tolerates a trailing Z (silently dropping it) but rejects a numeric offset like +09:00 with a 400. An invalid Date (NaN) throws a clear error before the request is sent.

Errors: a non-2xx response throws IamInternalApiError (.status, .body, .method, .url); network failures propagate as the raw fetch rejection. The productSlug and userId are URL-encoded; an unknown userId returns 400. Config is validated lazily — the first call throws a clear error if internalApiBaseUrl / internalApiKey / productSlug are missing.

Uses the global fetch (Node ≥20.19) — no extra runtime dependency. Your service must have network reach to the IAM internal API and hold the INTERNAL_API_KEY; treat it as a secret (it bypasses the gateway).

Looking up users (outbound)

The module also ships IamUserService, an outbound client for IAM's user-lookup internal API. The main reason it exists: the phone number is intentionally absent from the JWT and the gateway X-User-* headers (per IAM's V19 hardening), so a backend that needs it fetches it server-to-server here instead of trusting a forwarded header.

It uses the same internalApiBaseUrl + internalApiKey config as the subscription client (no productSlug needed):

IamModule.forRoot({
  gatewaySharedSecret: process.env.GATEWAY_SHARED_SECRET!,
  enforceGatewayOnly: process.env.NODE_ENV === 'production',

  internalApiBaseUrl: process.env.IAM_INTERNAL_API_BASE_URL!, // e.g. http://iam-server:10732
  internalApiKey: process.env.INTERNAL_API_KEY!,              // X-Internal-Api-Key
});

Inject it and resolve a user by id, or by a single natural key (email / phone / username):

import { Injectable } from '@nestjs/common';
import { IamUserService } from '@drvalue-oss/iam-nestjs';

@Injectable()
export class NotificationService {
  constructor(private readonly users: IamUserService) {}

  // By IAM user UUID — e.g. read the phone the gateway never forwards.
  async sendSms(userId: string, message: string) {
    const user = await this.users.getById(userId);
    await this.sms.send(user.phone, message);
  }

  // By natural key — exactly one of email / phone / username. Validated
  // client-side first, so passing zero or two keys throws before any request.
  async findByEmail(email: string) {
    return this.users.lookup({ email }); // → IamUserDetail
  }
}

The response (IamUserDetail) carries id, username, name, email, phone, phoneVerified, role, enabled, lastLoginAt, createdAt, updatedAt. Date fields are zone-naive strings; name / lastLoginAt / updatedAt may be null.

Errors match the subscription client: a non-2xx throws IamInternalApiError (.status, .body, .method, .url) — an unknown user is 404. Config is validated lazily on first use.

⚠️ This response is PII (phone, email). Keep it server-side; never relay it verbatim to an untrusted client.

Security notes

  • IamUserGuard does NOT verify the JWT. Trust is established by GatewaySignatureGuard + a network policy that limits ingress to IAM Gateway only. Without the network policy, an attacker who can reach your service directly can forge X-User-Id: 1 and impersonate any user — enforceGatewayOnly: true is your only line of defense. The guard logs a warning at startup when enforceGatewayOnly is false.
  • @SkipGatewaySignature() routes must also be @Public() (or must not rely on req.user). Skipping the signature removes the proof of gateway origin, so the X-User-* headers on that route are forgeable — combining it with @Roles()/@Authenticated() and trusting the user is an auth bypass.
  • PLATFORM_ADMIN bypasses all @Roles() checks. Encode group-scoped role checks in a separate guard against user.activeGroup.role.
  • @Roles() requires at least one role (an empty call throws at startup). A method-level @Roles()/@Authenticated() overrides class-level @Public(), and a method-level @Roles() fully replaces a class-level @Roles() (the class is a default, not a floor) — audit controllers that mix these.

License

MIT