@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-jsPeer 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.systemis the MS-to-MS machine role.steward(secretaria, pastors) is a person who keeps the church data right: it passes whereverPersonpasses; each MS decides what more a steward may do.ProjectRole— per-project role string.adminis 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: requestAccept-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 entrySemantics (decided in the login plan):
superuseralways passes.stewardpasses whereverpersonpasses (and whereRole.Stewardis listed).systempasses 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 theX-Project-Idheader (X_PROJECT_ID_HEADER): the user needsprojectRoles: [{ 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-msbefore). Entries carryexpiresAt(ISO string, epoch ms orDate) and are pruned lazily on expiry.RevocationSyncService(wired by default, disable withenableRevocation: false) — on boot it GETs${AUTH_API_URL}/revocations/activewith the system token (background retry with backoff) and subscribes to theEXCHANGE_AUTH_REVOCATIONSexchange 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 errorConnection 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.
joseis 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).
beforein a revocation and numericexpiresAtare epoch milliseconds;iat/expin the JWT are seconds (as JWT requires).fetchImplandamqpConnectoptions exist purely so MS tests can mock the externals — they are not for production routing.