@caronte-sdk/node
v0.2.4
Published
Node.js client for the Argos authorizer (OIDC + RBAC)
Maintainers
Readme
@caronte-sdk/node
Node.js client for the Argos authorizer — handles app authentication, JWT validation, permission checking and automatic operation sync.
Installation
npm install @caronte-sdk/node
# With framework-specific adapter
npm install @caronte-sdk/node express # Express
npm install @caronte-sdk/node fastify # Fastify
npm install @caronte-sdk/node @nestjs/common # NestJS
npm install @caronte-sdk/node @apollo/server # Apollo ServerQuick start
Express
import express from 'express';
import { CaronteClient, getRegistry } from '@caronte-sdk/node';
import { caronte } from '@caronte-sdk/node/express';
const client = new CaronteClient({
authorizerUrl: process.env.AUTHORIZER_URL!,
realmId: process.env.REALM_ID!,
appId: process.env.APP_ID!,
secret: process.env.APP_SECRET!,
});
const { middleware, guard } = caronte(client);
const app = express();
app.use(express.json(), middleware);
app.get('/health', guard('health:check', 'public'), (_req, res) => res.json({ status: 'ok' }));
app.get('/tasks', guard('tasks:list', 'private'), (_req, res) => res.json(tasks));
app.post('/tasks', guard('tasks:create', 'protected'), (req, res) => { /* req.caronteUser */ });
for (const op of getRegistry()) client.registerOperation(op);
await client.startup();
app.listen(3001);Fastify
import Fastify from 'fastify';
import { CaronteClient, getRegistry } from '@caronte-sdk/node';
import { caronteFastifyPlugin } from '@caronte-sdk/node/fastify';
const client = new CaronteClient({ ... });
const fastify = Fastify();
await fastify.register(caronteFastifyPlugin, { client });
fastify.get('/health',
{ preHandler: fastify.caronteGuard('health:check', 'public') },
async () => ({ status: 'ok' }),
);
fastify.get('/tasks',
{ preHandler: fastify.caronteGuard('tasks:list', 'private') },
async (req) => { /* req.caronteUser */ },
);
for (const op of getRegistry()) client.registerOperation(op);
await client.startup();
await fastify.listen({ port: 3002 });NestJS
// app.module.ts
import { Module } from '@nestjs/common';
import { CaronteModule } from '@caronte-sdk/node/nest';
@Module({
imports: [
CaronteModule.forRootAsync({
authorizerUrl: process.env.AUTHORIZER_URL!,
realmId: process.env.REALM_ID!,
appId: process.env.APP_ID!,
secret: process.env.APP_SECRET!,
}),
],
})
export class AppModule {}// tasks.controller.ts
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { CaronteGuard, CaronteUser, Operation } from '@caronte-sdk/node/nest';
import type { TokenClaims } from '@caronte-sdk/node';
@Controller('tasks')
@UseGuards(CaronteGuard)
export class TasksController {
@Get()
@Operation('tasks:list', 'private')
list() { return tasks; }
@Post()
@Operation('tasks:create', 'protected')
create(@CaronteUser() user: TokenClaims) {
// user.sub, user.groups
}
}Running with otelCollectorUrl on NestJS
CaronteModule.forRootAsync() alone is not enough to get request tracing
in a Nest app, even outside the ESM case above. NestFactory.create()
requires express (or fastify) internally to build the HTTP adapter
before any module's own providers — including CaronteModule — get a
chance to run. By the time CaronteModule's factory calls startup() (and,
through it, initOtel()), the HTTP framework has already finished loading
unpatched, so auto-instrumentation never attaches to it. This is a Nest
bootstrap-ordering issue, distinct from the ESM loader-hook issue — it
affects CommonJS Nest apps too.
Fix: call initOtel() yourself, before import { NestFactory } from
'@nestjs/core':
// main.ts
import 'reflect-metadata';
// Must run before @nestjs/core is required — see explanation above.
// CaronteModule still calls startup()/initOtel() too (harmless no-op,
// idempotent), so an app that skips this still gets traces/metrics/logs
// for everything except the HTTP layer itself.
import { initOtel } from '@caronte-sdk/node';
if (process.env.OTEL_COLLECTOR_URL) {
initOtel({
otelCollectorUrl: process.env.OTEL_COLLECTOR_URL,
appId: process.env.APP_ID!,
realmId: process.env.REALM_ID!,
});
}
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();This works because in CommonJS, require() calls run in the textual order
they appear — the same reason reflect-metadata is conventionally imported
first in this ecosystem. It only works if main.ts itself is CommonJS
(no "type": "module" in package.json, e.g. run via ts-node); an ESM
Nest app needs the caronte-node wrapper from the section above instead —
in that case skip this manual initOtel() call, the loader hook handles it.
Apollo Server
import { ApolloServer } from '@apollo/server';
import { CaronteClient, getRegistry } from '@caronte-sdk/node';
import { carontePlugin, createGuard, type CaronteContext } from '@caronte-sdk/node/apollo';
const client = new CaronteClient({ ... });
const guard = createGuard(client);
const resolvers = {
Query: {
health: guard('health:check', 'public', () => 'ok'),
tasks: guard('tasks:list', 'private', (_p, _a, ctx: CaronteContext) => {
const user = ctx.caronteUser; // TokenClaims
return tasks;
}),
},
Mutation: {
createTask: guard('tasks:create', 'protected', (_p, { input }, ctx: CaronteContext) => {
// ctx.caronteUser is the authenticated user
}),
},
};
for (const op of getRegistry()) client.registerOperation(op);
await client.startup();
const server = new ApolloServer<CaronteContext>({
typeDefs,
resolvers,
plugins: [carontePlugin(client)],
});Operation levels
| Level | Who can access |
|-------------|----------------|
| public | Everyone — no token required |
| private | Any authenticated user (token with at least one group) |
| protected | Only users whose groups intersect the operation's allowed_groups |
Method auto-detection
The method is inferred from the last segment of the operation id:
| Operation id suffix | Detected method |
|---------------------|-----------------|
| list, get, fetch, read | read |
| delete, remove, destroy | delete |
| stream, subscribe, watch, listen | stream |
| anything else | write |
You can always pass the method explicitly as the third argument to guard().
Configuration
| Parameter | Description |
|----------------|-------------|
| authorizerUrl | Base URL of Argos, including the API prefix (e.g. http://host/api) |
| realmId | Name or UUID of the realm (e.g. purp) |
| appId | UUID of the app registered in auth.apps |
| secret | Plain-text app secret — use env vars, never commit |
| otelCollectorUrl | Optional. OTLP HTTP endpoint (e.g. http://localhost:4318) of an OpenTelemetry Collector. When set, startup() initializes the Node OTel SDK — traces (auto-instrumentation for HTTP/Express/Fastify/GraphQL/pg, etc.), metrics (e.g. http.server.duration from that same auto-instrumentation), and a bootstrap log record proving the log pipeline is wired — all tagged with service.name=appId and service.namespace=realmId. Omit to skip OTel entirely — a misconfigured or unreachable collector never breaks startup(). |
Running with otelCollectorUrl on an ESM app
If your app is ESM ("type": "module" in package.json — this includes any app run via tsx), auto-instrumentation (HTTP/Express/Fastify/GraphQL spans and metrics) will not work unless a loader hook is registered before your app's own imports run. This is a Node.js platform constraint, not something otelCollectorUrl alone can fix from inside startup() — by the time startup() executes, your import express from 'express' (and everything it pulls in) has already finished loading. See Node's ESM instrumentation docs.
@caronte-sdk/node ships a caronte-node bin that handles this for you — put it in front of whatever already runs your app:
// package.json
"scripts": {
"dev": "caronte-node tsx watch src/main.ts",
"start": "caronte-node tsx src/main.ts"
}It's a no-op when otelCollectorUrl is unset (the hook is registered either way, but nothing gets instrumented unless startup() actually calls NodeSDK.start()). CommonJS apps (no "type": "module", e.g. via ts-node) aren't affected by this — auto-instrumentation there works without caronte-node — but running everything through it is still fine either way.
How it works
On startup(), the client:
- Authenticates — exchanges
appId+secretfor a short-lived app JWT - Fetches JWKS — caches the public keys for token validation
- Syncs operations — pushes the registered operation catalogue to the authorizer
- Fetches operations — retrieves the authorised operation list with group bindings
On each request, the adapter:
- Validates the Bearer token using the cached JWKS
- Checks
TokenClaims.groupsagainst the operation'sallowedGroups - Injects
caronteUser(TokenClaims) into the request context
License
CC0 1.0 Universal — public domain.
