Node SDK · Minter
Use the minter when your application's frontend wants to display events directly. See Embed Tokens for the full model. Construct once at boot, mint per request:
import * as minter from "@everscribe/sdk-node/minter";
const m = new minter.Client(projectId, apiKey);
The token returned from mintToken is a JWT. Hand it to your frontend via your normal page-render path. Never expose your project API key to the browser.
Set
Cache-Control: no-storeon every token response. This is defense in depth. The embed component POSTs this endpoint, and a POST response is not cached without an explicit opt-in, so forgetting the header is no longer a leak by default. Set it anyway: a handler that also answersGET, or a CDN or proxy configured to cache POSTs, puts a per-user tenant-scoped token back in a shared cache, which is a cross-tenant leak. The handlers below set it. Keep it if you rewrite them.
Single-tenant
Use when your project has one customer per project, or when you're embedding the UI in your own internal tooling. The token has no tenant_id claim, so the embed shows every event in the project.
app.post("/everscribe/embed-token", async (req, res) => {
const user = await authenticate(req);
if (!user) {
res.status(401).send("unauthorized");
return;
}
try {
const token = await m.mintToken({
// No tenantId. The project has one customer (or is
// internal), so the token doesn't need to filter by tenant.
expiresIn: 60 * 60 * 1000, // 1 hour, in ms
allowedColumns: ["occurred_at", "action", "actor", "target"],
});
// Never let a proxy or CDN cache a per-user token.
res.set("Cache-Control", "no-store");
res.json({ token });
} catch {
res.status(500).send("mint failed");
}
});
Multi-tenant
Use when your project partitions events by tenant_id and each customer should only see their own. Mint a token whose tenantId matches the signed-in user's tenant. The server enforces the scope on every read.
app.post("/everscribe/embed-token", async (req, res) => {
const user = await authenticate(req);
if (!user) {
res.status(401).send("unauthorized");
return;
}
try {
const token = await m.mintToken({
// tenantId set to the signed-in user's tenant, so reads are
// filtered server-side. For an admin view (your internal
// support tool that should see every tenant), use the
// same minter but omit tenantId.
tenantId: user.tenantId,
expiresIn: 60 * 60 * 1000,
allowedColumns: ["occurred_at", "action", "actor", "target"],
allowedActions: ["user.*", "billing.invoice.created"],
});
// Never let a proxy or CDN cache a per-user token.
res.set("Cache-Control", "no-store");
res.json({ token });
} catch {
res.status(500).send("mint failed");
}
});
Multi-tenant products often expose two endpoints:
| Endpoint | Token | Used for |
|---|---|---|
/everscribe/embed-token/customer |
tenantId = signed-in user's tenant |
the end-customer's view of their own audit trail |
/everscribe/embed-token/admin |
tenantId omitted |
internal admin / support tool, shows every tenant |
Same minter, different scoping rules.
Mounting the route
app.post(...) registers the route as a side effect of the module being evaluated, which means it registers only if something evaluates the module. Put the handler in a new file that nothing imports and the route never exists: the embed component's fetch gets a 404 and the whole embed is dead. That failure is silent at build time, which is what makes it worth stating.
If the handler lives in the same file that creates app, the examples above are complete and there is nothing more to do. Otherwise export a Router and mount it:
// routes/everscribe.ts
import { Router } from "express";
import * as minter from "@everscribe/sdk-node/minter";
const m = new minter.Client(
process.env.EVERSCRIBE_PROJECT_ID!,
process.env.EVERSCRIBE_API_KEY!,
);
export const embedTokenRouter = Router();
embedTokenRouter.post("/everscribe/embed-token", async (req, res) => {
// exactly the handler body from above
});
// app.ts
import { embedTokenRouter } from "./routes/everscribe.js";
app.use(embedTokenRouter); // without this line the route does not exist
Fastify and Hono are the same two steps under different names:
// Fastify: export a plugin, register it
export async function embedTokenRoutes(app: FastifyInstance) {
app.post("/everscribe/embed-token", async (req, reply) => { /* ... */ });
}
app.register(embedTokenRoutes);
// Hono: export a sub-app, route it
export const embedTokenRoutes = new Hono();
embedTokenRoutes.post("/everscribe/embed-token", async (c) => { /* ... */ });
app.route("/", embedTokenRoutes);
Next.js App Router is the one exception. File placement is the registration, so there is no mount step: a POST export from a route.ts is reachable as soon as the file exists. The directory path under app/ is the URL, so put it where you want it served:
// app/everscribe/embed-token/route.ts -> POST /everscribe/embed-token
export async function POST() {
const token = await m.mintToken({ expiresIn: 60 * 60 * 1000 });
return Response.json({ token });
}
app/api/everscribe/embed-token/route.ts would serve /api/everscribe/embed-token instead. Either is fine as long as the tokenEndpoint you pass the component matches.
Two things about the route itself:
- The path is yours to pick. It only has to match the
tokenEndpointyou hand the embed component./everscribe/embed-tokenis what Everscribe's own tooling assumes, so prefer it unless you have a reason not to. - The verb is
POST. The component POSTstokenEndpointwith no body, so aGET-only route answers 405 and the embed never gets a token. Minting issues a bearer credential and consumes quota, so it is not a safe idempotent read.
Mount it behind the same authentication as the rest of your app. The handler is what decides the caller's scope, so an unauthenticated mint route hands your whole audit trail to anyone who asks for it.
TokenOptions
| Field | Type | Notes |
|---|---|---|
tenantId |
string |
Optional. Scopes reads to events with matching tenant_id. Trimmed; ≤ 256 chars. Omit for unscoped (single-tenant or admin view). |
expiresIn |
number |
Token lifetime in milliseconds. Rejected client-side outside [MIN_EXPIRES_IN_MS, MAX_EXPIRES_IN_MS], before any HTTP call. Omit or pass 0 for the server default (1h). |
allowedColumns |
string[] |
Optional whitelist of Event JSON field names (snake_case). Omit for no restriction; empty array rejected. |
allowedActions |
string[] |
Exact match or suffix wildcard (user.*). Omit for no restriction; empty array rejected. |
allowedFields |
string[] |
Restricts which catalog fields the token's DSL and NLP queries may reference. Omit for no restriction; empty array rejected. Entries are validated server-side. |
allowDSLInput |
boolean |
Unlocks the Query (advanced DSL) tab in the embed and ?q= on the read API. Default false. |
allowNLP |
boolean |
Unlocks the AI ("Ask in plain English") tab and the NLP endpoint. Default false. |
The SDK exports MIN_EXPIRES_IN_MS, MAX_EXPIRES_IN_MS, and ALLOWED_COLUMNS constants if you want to validate before calling, plus MinterError for inspecting a non-2xx mint response.