@ipalpha/shared-js

v1.0.0

Published

Shared helpers for IPAlpha core microservices: roles, guards, JWT/JWKS verification, revocations, system tokens, RabbitMQ wrapper, readiness, Mongo id mapping. Helpers only, no business logic.

Readme

@ipalpha/shared-js

Shared helpers for the IPAlpha core microservices (auth-api, person-api, organization-api, projects-api, notification-api). This package exists so that every core MS gets identical authentication, revocation, RabbitMQ, readiness and Mongo behavior without each MS developer having to think about it.

Rule: a near-static library

shared-js should almost never change. It only holds what ≥85% of the MSs certainly use: the token payload (JwtPayload, Role), auth guards/decorators, and infra helpers. A new feature must never need a shared-js release.

  • No feature types. Projects, roles, memberships, data kinds, localized texts, events: they live in the MS that owns them; consumers copy the part they use (each keeps only the fields relevant to it).
  • No project data or caches of it. Each MS that needs project config keeps its own copy (auth-api and person-api each have their own ProjectsCache).
  • No event/exchange names. Every MS reads them from required env vars (EXCHANGE_*) set in its configmap; requiredEnv(...) fails the boot when one is missing.

It is helpers only — no business logic, no data of its own. If you are building a core MS, import this package and read the section for each export below. If you are an external (ministry) consumer, you do not use this package at all: you talk to core over HTTP/webhooks with OAuth2 tokens, never with our internals.

Install

Published on npm as @ipalpha/shared-js:

npm install @ipalpha/shared-js

Peer dependencies (installed by each MS): @nestjs/common, @nestjs/core, @nestjs/swagger 11, mongoose 8, rxjs 7, reflect-metadata. Node >= 20. Output is CommonJS (dist/).

To test an unpublished change in an MS: npm run build here, then in the MS npm install ../shared-js --install-links (do not commit that change).

Quick start

import { CoreModule } from '@ipalpha/shared-js';

@Module({ imports: [CoreModule.forRoot()] })
export class AppModule {}

CoreModule.forRoot is a global module that wires everything and requires (option or env var, option wins):

| Option | Env var | Used for | |---|---|---| | authApiUrl | AUTH_API_URL | JWKS, system tokens, revocation boot sync | | rabbitmqUrl | RABBITMQ_URL | RabbitMQ connection | | authClientId | AUTH_CLIENT_ID | OAuth2 client_credentials (per-MS id/secret from the k8s secret) | | authClientSecret | AUTH_CLIENT_SECRET | OAuth2 client_credentials | | revocationsExchange | EXCHANGE_AUTH_REVOCATIONS | live revocations (only when enableRevocation) |

Options beyond the env vars: enableRevocation (default true), fetchImpl / amqpConnect (injection points used by tests to mock the externals), rabbitmqBaseRetryDelayMs / rabbitmqMaxRetryDelayMs (default 500 ms / 30 s).

Exports

1. Roles, JWT payload, languages

import { Role, ProjectRole, ProjectRoleEntry, JwtPayload, SupportedLanguage, resolveLanguage } from '@ipalpha/shared-js';
  • Role — global roles: superuser | system | person | steward. system is the MS-to-MS machine role. steward (secretaria, pastors) is a person who keeps the church data right: it passes wherever Person passes; each MS decides what more a steward may do.
  • ProjectRole — per-project role string. admin is the only agreed value (responsible staff ⇒ admin); the rest of the role vocabulary is still open, so this is a permissive string type.
  • JwtPayload — exactly what auth-api puts in a JWT: sub (personId), role, projectRoles, projectId? (the tapped app), name, lang, jti, iat, exp. No email, no phone.
  • requiredEnv(...names) — reads required env vars, throws at boot listing every missing one.
  • resolveLanguage(acceptLanguage?, stored?) — the agreed language order: request Accept-Language → stored preference → pt-BR. Understands quality values and primary-subtag fallback (pt → pt-BR, en → en-US).
  • SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE, isSupportedLanguage.

2. @CurrentUser()

@Get('me')
me(@CurrentUser() user: JwtPayload) { return user.sub; }
// or a single field:
me(@CurrentUser('sub') personId: string) { ... }

Returns the verified JWT payload bound by BindUserMiddleware; undefined when unauthenticated.

3. @AuthRequired(...roles) and @ProjectRoleRequired(role)

@AuthRequired()                 // any authenticated person (and always superuser)
@AuthRequired(Role.System)      // system tokens (MS-to-MS) + superuser
@AuthRequired(Role.Person, Role.System)
@ProjectRoleRequired('admin')   // needs X-Project-Id header + matching projectRoles entry

Semantics (decided in the login plan):

  • superuser always passes.
  • steward passes wherever person passes (and where Role.Steward is listed).
  • system passes only when explicitly listed. With no roles listed, any authenticated person passes — a machine token cannot reach a human endpoint by accident.
  • Unauthenticated ⇒ 401; authenticated but insufficient ⇒ 403.
  • @ProjectRoleRequired(role) compares against the X-Project-Id header (X_PROJECT_ID_HEADER): the user needs projectRoles: [{ projectId, role }] with an exact role match (no role hierarchy is designed yet). Missing header ⇒ 400, superuser bypasses.
  • Both also apply ApiBearerAuth() for Swagger.

4. JwksService + BindUserMiddleware

CoreModule.forRoot registers BindUserMiddleware for every route. It extracts the bearer token, verifies it locally with jose (ES256) against the JWKS fetched once from ${AUTH_API_URL}/.well-known/jwks.json (refetched only when a token shows an unknown kid), and binds req.user. Invalid, unverifiable or revoked tokens simply leave req.user unset — your guards decide the response. There is no per-request call to auth-api; that is the point.

You normally never touch JwksService directly; it is exported for tests.

5. RevocationStore + RevocationSyncService

Each MS keeps revoked tokens in memory for the token's remaining life:

  • RevocationStore — both revoke kinds: { jti } (one token) and { personId, before } (every token of a person issued before epoch-ms before). Entries carry expiresAt (ISO string, epoch ms or Date) and are pruned lazily on expiry.
  • RevocationSyncService (wired by default, disable with enableRevocation: false) — on boot it GETs ${AUTH_API_URL}/revocations/active with the system token (background retry with backoff) and subscribes to the EXCHANGE_AUTH_REVOCATIONS exchange so every pod receives revocations live.

BindUserMiddleware consults the store automatically — revoked tokens behave like missing ones.

6. SystemTokenService

OAuth2 client_credentials against POST ${AUTH_API_URL}/oauth/token (per-MS id/secret). Tokens are cached in memory and refreshed before expiry (30 s margin). Concurrent callers share one in-flight request. Use for every MS→MS HTTP call:

constructor(private readonly tokens: SystemTokenService) {}
const res = await fetch(url, { headers: { authorization: `Bearer ${await this.tokens.getToken()}` } });

7. RabbitMqService

amqplib wrapper with reconnect + exponential backoff (500 ms → 30 s), confirm-channel publishes, and automatic re-binding of subscriptions after reconnect:

constructor(private readonly mq: RabbitMqService) {}
const { EXCHANGE_PROJECTS_CHANGED } = requiredEnv('EXCHANGE_PROJECTS_CHANGED');
await this.mq.publish(EXCHANGE_PROJECTS_CHANGED, project);        // JSON to the exchange, waitForConfirms
this.mq.subscribeFanout(exchange, (msg) => ...);                   // broker queue for this connection only
this.mq.subscribeDurable(exchange, { queue: `person-api.${exchange}` }, async (msg) => ...); // durable, confirmed retry/dead-letter on error

Connection starts on module init in the background (a down broker never blocks boot); publish awaits a live connection. Publish always targets an exchange: it asserts the exchange and sends the payload there. There is no queue publish and no routing key. The subscriber decides whether to bind its own queue. subscribeFanout does not take a queue name: the broker creates one that lives only while that connection exists, so every pod gets its own copy. Non-JSON messages are discarded. Exchange names are not defined here: each MS takes them from its env.

Naming: exchanges are <publisher-ms>.<entity>.<past-tense> — named by who emits and what happened, never by who listens. Queues belong to the listener: <subscriber-ms>.<exchange>. subscribeFanout = no queue name; the broker queue lives only while the connection exists (every pod gets a copy); subscribeDurable = one named durable queue shared by all pods (work done once). Every durable consumer defaults to 10 total handler attempts, with 30 seconds between failed attempts, then stores the original message in <queue>.dead for developer inspection. Throw on transient failures (peer unavailable, key-fetch timeout, DB outage); return normally only after handling the event or deliberately rejecting invalid input. Optional subscription options maxAttempts and retryDelayMs override these defaults.

Retries live in <queue>.retry, a durable quorum queue with broker-managed TTL and at-least-once dead-lettering back to the original work queue. Attempt headers survive pod/broker restarts. The wrapper confirms persistent storage in the retry/dead queue before acknowledging the original, and requeues it when publication fails or is returned as unroutable. Transfers target only this subscriber's queue, never the fanout exchange. Delivery is at least once: handlers must remain idempotent, and a crash during a transfer can repeat an attempt.

Dead letters have no automatic expiry. Inspect the body, x-ipalpha-attempt and x-ipalpha-failed-at, fix the cause, and replay into the original queue after resetting the attempt header to 1. Keep access restricted: event bodies can contain person data and proofs. The auxiliary queues require RabbitMQ with quorum at-least-once dead-letter support.

9. mongoSchemaOptions / applyIdTransform(schema)

Mongo must never leak _id or __v in API output. Use one of:

new Schema({ name: String }, mongoSchemaOptions);
// or
const schema = applyIdTransform(new Schema({ name: String }));

Both map _id → id (string) and drop __v in toJSON and toObject. Index definitions, validators and collection shapes stay in each MS — only the id mapping is shared.

10. CoreModule.forRoot({ enableRevocation })

Wires all of the above as a global module: providers for JwksService, BindUserMiddleware (applied to all routes), RevocationStore + RevocationSyncService, SystemTokenService, RabbitMqService, readiness, plus a global exception filter that never leaks stack traces or internal error messages (unknown errors become a localized 500; 5 languages, language resolved from Accept-Language).

Inject what you need: JwksService, SystemTokenService, RabbitMqService, RevocationStore, ReadinessService.

11. redactIdentifiers(obj)

Log helper: deep-clones and masks phone/email/code/password-like keys (and email/phone-shaped values anywhere) with ***, handles nesting, arrays, dates and cycles, never mutates the input. Use it before logging anything that might contain person data. This is about LGPD, not aesthetics.

12. Liveness / readiness (/live, /ready)

import { ReadinessService, mongooseCheck, ioredisCheck } from '@ipalpha/shared-js';

CoreModule mounts two public routes on every MS: GET /live (always {live:true}) and GET /ready (200 {ready:true, checks} or 503 {ready:false, checks}). rabbitmq is registered for you; register your own (e.g. projects for an MS-local projects cache) infra with ReadinessService.register('mongo', mongooseCheck(connection)) / register('redis', ioredisCheck(client)) in an OnModuleInit provider.

Rules: the process always boots and listens — never block startup waiting for something. Never check a peer MS in /ready (calls to peers fail at call time; checking them would recreate boot-order cycles). Bodies carry only booleans — no URLs, versions or secrets. k8s liveness/readiness probes and the local ./run panel both use these paths.

13. Tests / publish

Every export has unit tests with all externals mocked (HTTP via fetchImpl, RabbitMQ via amqpConnect, no Mongo connection — mongoose schemas are exercised without a database). npm test runs jest; src/wiring.spec.ts additionally boots a real Nest + Express app to prove the wiring end to end. npm publish builds and tests first (prepublishOnly).

What NOT to do with this package

  • No business logic. If the code needs to know what a project or a person means, it belongs in a microservice, not here.
  • No feature types or event names. See Rule: a near-static library above.
  • No person data caching. LGPD: person data is fetched at the moment of use only. This package caches exactly two things, none of them person data: revocations and system tokens. Do not add caches of persons, phones or emails here.
  • No new contracts. Event shapes, queue names and endpoints belong to the MSs. Do not add them here — raise it with the core developer instead.
  • No MS-specific config or schemas. Mongoose document definitions, providers and templates live in each MS.

Cost notes (volunteer budget)

  • JWKS is fetched once (plus on key rotation) — no per-request auth-api load.
  • Revocations live in memory — no per-request auth-api calls, no Redis for them.
  • Revocations self-prune at token expiry; nothing accumulates.
  • Cache fanout subscriptions use exclusive auto-delete queues. Durable consumers keep work, retry and dead-letter queues on disk; developers inspect and clear resolved dead letters.
  • Boot loads retry in the background with capped backoff instead of crashing pods.
  • jose is pinned to v5 because v6 is ESM-only and core MSs compile to CommonJS.

Conventions used inside

  • JWT verification is ES256 only (auth-api signs ES256).
  • before in a revocation and numeric expiresAt are epoch milliseconds; iat/exp in the JWT are seconds (as JWT requires).
  • fetchImpl and amqpConnect options exist purely so MS tests can mock the externals — they are not for production routing.