diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a643120..d5cd2f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,19 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test:coverage - run: pnpm test:types + + # Node 22 only: the docs build exercises VitePress, not the package runtime, + # so the 18/20/22 matrix would just repeat the same work three times. + docs: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + # A broken docs build (including dead links) should fail the PR, not the deploy. + - run: pnpm run docs:build diff --git a/.gitignore b/.gitignore index a40cf65..c1c7475 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,8 @@ dist .cursor/plans .pnpm-store + +# VitePress (the bare `dist` / `.cache` rules above do not match these paths) +docs/.vitepress/dist +docs/.vitepress/cache +/.claude/launch.json diff --git a/CLAUDE.md b/CLAUDE.md index 4f26eb5..024dc54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +19,10 @@ pnpm test:coverage # c8 coverage over src/ pnpm lint # oxlint src test pnpm format # oxfmt src test (format:check for CI) pnpm bench # ops/sec benchmark harness (bench/bench.js) +pnpm docs:dev # VitePress dev server for docs/ (docs:build / docs:preview too) ``` -Linting/formatting is **oxlint/oxfmt** (`.oxlintrc.json`, `.oxfmtrc.json`; the `correctness` category is intentionally off) — their native bindings require Node ≥20.19, so CI (`.github/workflows/ci.yml`) runs `lint`/`format:check` in a single job pinned to Node 22, separate from the `test` job, which runs `test:coverage` + `test:types` across the Node 18/20/22 matrix. +Linting/formatting is **oxlint/oxfmt** (`.oxlintrc.json`, `.oxfmtrc.json`; the `correctness` category is intentionally off) — their native bindings require Node ≥20.19, so CI (`.github/workflows/ci.yml`) runs `lint`/`format:check` in a single job pinned to Node 22, separate from the `test` job, which runs `test:coverage` + `test:types` across the Node 18/20/22 matrix, and a `docs` job (Node 22) that runs `docs:build`. Lint/format deliberately target `src test scripts bench` only, so `docs/` is not covered by them. Style: 2-space indent, single quotes, semicolons, 120-char lines (see `.editorconfig`, `.oxfmtrc.json`). @@ -92,7 +93,19 @@ Two layers, both SpiceDB-inspired (see the "borrow vs skip" notes in `README.md` ### Public exports -The full package surface is assembled in `src/index.js` (main entry), `tests.js` (dev-only `/tests` subpath) and `relations.js` (`/relations` subpath) — check all three when adding a new export, and update `index.d.ts` / `tests.d.ts` / `relations.d.ts` in the repo root accordingly, since types are hand-maintained (not generated). +The full package surface is assembled in `src/index.js` (main entry), `tests.js` (dev-only `/tests` subpath) and `relations.js` (`/relations` subpath) — check all three when adding a new export, and update `index.d.ts` / `tests.d.ts` / `relations.d.ts` in the repo root accordingly, since types are hand-maintained (not generated). Also update `docs/api/exports.md`, which mirrors those three tables for the docs site. + +### Documentation site (`docs/`) + +VitePress site deployed to Vercel (`vercel.json` at the repo root pins the build command and output dir); `docs/` is never published to npm — the `files` field in `package.json` is an explicit list that omits it. Structure follows the migronaut sibling repo: a single `docs/.vitepress/config.mts`, a `theme/` that only extends `DefaultTheme` with a `custom.css` of brand CSS variables (Cerbos-style amber `#FFC11E` on ink `#1B1C1E`), local MiniSearch, and `docs/public/` for `robots.txt` / `llms.txt` / logo assets. + +Conventions and gotchas: + +- Only `docs/index.md` carries frontmatter (`layout: home`); every other page starts directly with its `# H1`. Links between pages are absolute and extensionless (`/guide/scopes#…`) to match `cleanUrls: true`. +- `README.md` deliberately keeps the full narrative (npm renders it) — the docs pages duplicate it. When you change a documented behavior, update both. +- `ignoreDeadLinks` is intentionally off: `pnpm docs:build` failing on a dead link is the check that cross-page anchors are still valid. +- Mermaid comes from `vitepress-plugin-mermaid`. Its transitive deps (`@braintree/sanitize-url`, `dayjs`, `debug`, `cytoscape`, `cytoscape-cose-bilkent`) are direct devDependencies **because** the plugin hardcodes them into `optimizeDeps.include` and pnpm's strict linking otherwise leaves them unresolvable in `docs:dev`. Diagram labels also need the `line-height` override at the bottom of `custom.css` — mermaid sizes nodes without knowing VitePress' global line-height, so multi-line labels get clipped without it. +- The version in the nav dropdown (`v3.1.0`) and the `hostname` constant are hand-synced — bump the former with `package.json` at release time. ### Why the package doesn't ship a separate ESM build diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..618e352 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,199 @@ +import { defineConfig } from 'vitepress'; +import { withMermaid } from 'vitepress-plugin-mermaid'; + +const ogTitle = 'Kerberos.js — embedded authorization engine for Node.js & the browser'; +const ogDescription = + 'Zero-dependency, in-process authorization engine for JavaScript. Cerbos-style RBAC + ABAC policies, ' + + 'Zanzibar-inspired ReBAC relations and Cerbos-compatible query plans — no server to deploy, ~25 KB min+gzip.'; +const repo = 'https://github.com/Alexis-Technologies/kerberos'; +const base = '/'; +const hostname = 'https://kerberosjs.vercel.app/'; +const ogImage = `${hostname}logo.png`; + +// Mirrors package.json "keywords" — kept in one line-per-term list so the two stay easy to diff. +const keywords = [ + 'authorization', + 'authorization engine', + 'access control', + 'fine-grained authorization', + 'permissions', + 'policy-as-code', + 'rbac', + 'abac', + 'rebac', + 'zanzibar', + 'spicedb', + 'openfga', + 'cerbos', + 'cerbos alternative', + 'derived roles', + 'query plan', + 'in-process authorization', + 'zero dependency authorization', + 'nodejs authorization', + 'browser authorization', + 'opentelemetry', + 'kerberos.js', + '@alexify/kerberos', +].join(', '); + +// schema.org structured data — helps search and AI engines understand the package +// as a software entity, not just text on a page. +const jsonLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: '@alexify/kerberos', + alternateName: 'Kerberos.js', + description: ogDescription, + applicationCategory: 'DeveloperApplication', + operatingSystem: 'Node.js >= 18, modern browsers', + url: hostname, + downloadUrl: 'https://www.npmjs.com/package/@alexify/kerberos', + codeRepository: repo, + license: 'https://opensource.org/licenses/MIT', + keywords, + author: { '@type': 'Organization', name: 'Alexis Technologies' }, + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, +}; + +// https://vitepress.dev/reference/site-config +export default withMermaid( + defineConfig({ + title: '@alexify/kerberos', + titleTemplate: ':title — Kerberos.js', + description: ogDescription, + lang: 'en-US', + base, + cleanUrls: true, + lastUpdated: true, + sitemap: { hostname }, + + head: [ + ['link', { rel: 'icon', type: 'image/svg+xml', href: `${base}logo-mark.svg` }], + ['link', { rel: 'icon', type: 'image/png', href: `${base}favicon.png` }], + ['meta', { name: 'theme-color', content: '#FFC11E' }], + ['meta', { name: 'author', content: 'Alexis Technologies' }], + ['meta', { name: 'keywords', content: keywords }], + ['meta', { name: 'robots', content: 'index, follow' }], + ['meta', { property: 'og:type', content: 'website' }], + ['meta', { property: 'og:site_name', content: '@alexify/kerberos' }], + ['meta', { property: 'og:title', content: ogTitle }], + ['meta', { property: 'og:description', content: ogDescription }], + ['meta', { property: 'og:image', content: ogImage }], + ['meta', { name: 'twitter:card', content: 'summary_large_image' }], + ['meta', { name: 'twitter:title', content: ogTitle }], + ['meta', { name: 'twitter:description', content: ogDescription }], + ['meta', { name: 'twitter:image', content: ogImage }], + ['script', { type: 'application/ld+json' }, JSON.stringify(jsonLd)], + ], + + // Per-page canonical + og:url for clean SEO indexing + transformPageData(pageData) { + const path = pageData.relativePath.replace(/index\.md$/, '').replace(/\.md$/, ''); + const canonical = `${hostname}${path}`; + pageData.frontmatter.head ??= []; + pageData.frontmatter.head.push( + ['link', { rel: 'canonical', href: canonical }], + ['meta', { property: 'og:url', content: canonical }], + ); + }, + + themeConfig: { + logo: '/logo-mark.svg', + + // ─── Top navigation ────────────────────────────────────────────── + nav: [ + { text: 'Guide', link: '/guide/why', activeMatch: '/guide/' }, + { text: 'API', link: '/api/kerberos', activeMatch: '/api/' }, + { text: 'Reference', link: '/reference/plan-operators', activeMatch: '/reference/' }, + { + // Hand-synced with package.json "version" — part of the release checklist. + text: 'v3.1.0', + items: [ + { text: 'Changelog', link: `${repo}/blob/main/CHANGELOG.md` }, + { text: 'npm', link: 'https://www.npmjs.com/package/@alexify/kerberos' }, + { text: 'Releases', link: `${repo}/releases` }, + ], + }, + ], + + // ─── Sidebar ───────────────────────────────────────────────────── + sidebar: { + '/guide/': [ + { + text: 'Introduction', + items: [ + { text: 'Why Kerberos.js?', link: '/guide/why' }, + { text: 'Installation', link: '/guide/installation' }, + { text: 'Quick Start', link: '/guide/getting-started' }, + { text: 'Policy Types', link: '/guide/policy-types' }, + { text: 'Scopes & Versions', link: '/guide/scopes' }, + ], + }, + { + text: 'Core features', + items: [ + { text: 'Configuration', link: '/guide/configuration' }, + { text: 'Outputs', link: '/guide/outputs' }, + { text: 'Decision metadata', link: '/guide/decision-metadata' }, + { text: 'Schema validation', link: '/guide/schema-validation' }, + { text: 'Testing', link: '/guide/testing' }, + ], + }, + { + text: 'Advanced', + items: [ + { text: 'Caching & dynamic policies', link: '/guide/caching' }, + { text: 'Serialization & security', link: '/guide/serialization' }, + { text: 'ReBAC (Relations)', link: '/guide/rebac' }, + { text: 'Built-in resolver', link: '/guide/relations-resolver' }, + { text: 'Query plans', link: '/guide/query-plans' }, + { text: 'OpenTelemetry', link: '/guide/telemetry' }, + { text: 'Benchmarks', link: '/guide/benchmarks' }, + ], + }, + ], + '/api/': [ + { + text: 'API Reference', + items: [ + { text: 'Kerberos class', link: '/api/kerberos' }, + { text: 'Errors', link: '/api/errors' }, + { text: 'Exports', link: '/api/exports' }, + ], + }, + ], + '/reference/': [ + { + text: 'Reference', + items: [ + { text: 'Plan operators', link: '/reference/plan-operators' }, + { text: 'Safe builtins', link: '/reference/safe-builtins' }, + { text: 'Security', link: '/reference/security' }, + ], + }, + ], + }, + + // ─── Local, zero-config full-text search ───────────────────────── + search: { provider: 'local' }, + + socialLinks: [{ icon: 'github', link: repo }], + + editLink: { + pattern: `${repo}/edit/main/docs/:path`, + text: 'Edit this page on GitHub', + }, + + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright © 2026 Alexis Technologies', + }, + + docFooter: { + prev: 'Previous page', + next: 'Next page', + }, + }, + }), +); diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..b83734b --- /dev/null +++ b/docs/.vitepress/theme/custom.css @@ -0,0 +1,135 @@ +/** + * Brand theme for Kerberos.js — same approach Pinia/Vite/Vue use: + * override VitePress CSS variables to restyle the default theme. + * Palette: Cerbos amber (#FFC11E / #FFCD4B / #FFE08F) on ink (#1B1C1E). + * + * Note on contrast: pure #FFC11E fails WCAG AA as link text on white, so in the + * light theme brand-1/-2 are darkened amber (text/links) and the pure brand + * yellow is reserved for filled surfaces, where the text on top is ink. + */ + +:root { + /* ─── Brand colors (buttons, links, accents) ──────────────────────── */ + --vp-c-brand-1: #8a6100; + --vp-c-brand-2: #b37e00; + --vp-c-brand-3: #ffc11e; + --vp-c-brand-soft: rgba(255, 193, 30, 0.16); + + /* Default theme alias mappings (kept in sync with brand) */ + --vp-c-default-1: var(--vp-c-gray-1); + --vp-c-default-2: var(--vp-c-gray-2); + --vp-c-default-3: var(--vp-c-gray-3); + --vp-c-default-soft: var(--vp-c-gray-soft); + + --vp-c-tip-1: var(--vp-c-brand-1); + --vp-c-tip-2: var(--vp-c-brand-2); + --vp-c-tip-3: var(--vp-c-brand-3); + --vp-c-tip-soft: var(--vp-c-brand-soft); +} + +.dark { + --vp-c-brand-1: #ffc11e; + --vp-c-brand-2: #ffcd4b; + --vp-c-brand-3: #b37e00; + --vp-c-brand-soft: rgba(255, 193, 30, 0.18); +} + +/* ─── Buttons: ink on amber, the way Cerbos does its CTAs ──────────── */ +:root { + --vp-button-brand-bg: #ffc11e; + --vp-button-brand-hover-bg: #ffcd4b; + --vp-button-brand-active-bg: #b37e00; + --vp-button-brand-border: transparent; + --vp-button-brand-hover-border: transparent; + --vp-button-brand-active-border: transparent; + --vp-button-brand-text: #1b1c1e; + --vp-button-brand-hover-text: #1b1c1e; + --vp-button-brand-active-text: #1b1c1e; +} + +/* ─── Home hero: gradient title + glowing logo blob (the "Pinia look") ─ */ +:root { + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient( + 120deg, + #ffc11e 25%, + #ffe08f + ); + + /* Deeper than the mark itself: the logo is #FFC11E, so an equally bright halo + would swallow it. Bronze keeps the mark reading as the brightest thing. */ + --vp-home-hero-image-background-image: linear-gradient( + -45deg, + #b37e00 40%, + #6b4b00 100% + ); + --vp-home-hero-image-filter: blur(48px); +} + +@media (min-width: 640px) { + :root { + --vp-home-hero-image-filter: blur(72px); + } +} + +@media (min-width: 960px) { + :root { + --vp-home-hero-image-filter: blur(96px); + } +} + +/* ─── Code block inline highlight accent ──────────────────────────────── */ +:root { + --vp-code-block-bg: var(--vp-c-bg-alt); +} + +/* Slightly larger, friendlier hero on wide screens */ +.VPHero .name { + letter-spacing: -0.02em; +} + +/* ─── Home feature cards: glow on hover (clickable, no layout shift) ───── */ +.VPFeatures .VPFeature { + transition: + border-color 0.25s ease, + background-color 0.25s ease, + box-shadow 0.25s ease; +} + +.VPFeatures .VPFeature.link:hover { + border-color: var(--vp-c-brand-3); + box-shadow: 0 8px 24px rgba(179, 126, 0, 0.16); +} + +.dark .VPFeatures .VPFeature.link:hover { + box-shadow: 0 8px 24px rgba(255, 193, 30, 0.14); +} + +/* Make the "learn more" link adopt the brand color on card hover */ +.VPFeatures .VPFeature.link:hover .link-text-value { + color: var(--vp-c-brand-1); +} + +/* ─── Mermaid diagrams: centre them and let wide graphs scroll ─────────── */ +.mermaid { + display: flex; + justify-content: center; + overflow-x: auto; + margin: 20px 0; +} + +/** + * Mermaid sizes a node from its own measurement pass, which does not know about + * VitePress' global line-height (1.5). Multi-line labels then overflow the box + * they were fitted to. Pinning the label line-height back to mermaid's own + * assumption keeps the text inside the shape. + */ +.mermaid .nodeLabel, +.mermaid .edgeLabel, +.mermaid .cluster-label, +.mermaid foreignObject div, +.mermaid foreignObject span, +.mermaid foreignObject p { + line-height: 1.25; + margin: 0; +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..e6c8514 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,10 @@ +import DefaultTheme from 'vitepress/theme'; +import type { Theme } from 'vitepress'; +import './custom.css'; + +// Extends the default VitePress theme with our brand styling (see custom.css). +// This is exactly the pattern Pinia / Vite / Vue use to brand their docs — +// the layout/components stay the default theme, the look comes from CSS variables. +export default { + extends: DefaultTheme, +} satisfies Theme; diff --git a/docs/api/errors.md b/docs/api/errors.md new file mode 100644 index 0000000..04c3acd --- /dev/null +++ b/docs/api/errors.md @@ -0,0 +1,11 @@ +# Errors + +All error classes are exported from the main entry. Evaluation-phase errors follow the [`onError`](/guide/configuration#options) option; `KerberosValidationError` always throws. + +| Class | Thrown when | +| ----- | ----------- | +| `KerberosValidationError` | Malformed method arguments or request shapes (always propagates — a programming error, not a deny). | +| `KerberosCacheError` | A transient `cache.get` failure persists after the [`cacheRetry`](/guide/configuration#options) attempts. | +| `KerberosCodecError` | A cached policy/tuple document is corrupt or fails to deserialize (for policies it is logged and counts as a miss; for ReBAC tuple documents it throws — see [Dynamic tuples](/guide/relations-resolver#dynamic-tuples-cache-backed)). | +| `KerberosExprError` | A `{ $expr }` string uses a construct outside the [safe allowlist](/reference/safe-builtins), exceeds codec limits, or fails to parse. | +| `KerberosRelationsError` | The built-in ReBAC resolver hits `maxDepth`, a throwing caveat, or invalid relation data. | diff --git a/docs/api/exports.md b/docs/api/exports.md new file mode 100644 index 0000000..95ff751 --- /dev/null +++ b/docs/api/exports.md @@ -0,0 +1,39 @@ +# Exports + +The full public surface of the package, by entry point. + +## Main entry — `@alexify/kerberos` + +| Export | Purpose | +| ------ | ------- | +| `Kerberos` | Main authorization engine. | +| `Effect` | `{ Allow: 'EFFECT_ALLOW', Deny: 'EFFECT_DENY' }`. | +| `ResourcePolicy`, `PrincipalPolicy`, `RolePolicy`, `DerivedRoles` | Policy classes (rarely constructed directly). | +| `Conditions`, `Variables`, `Constants`, `Outputs` | DSL building blocks. | +| `createSafeExprCodec`, `serializePolicy`, `deserializePolicy` | Safe AST codec for [dynamic/stored policies](/guide/caching). | +| `PlanKind` | `{ AlwaysAllowed, AlwaysDenied, Conditional }` — [query plan](/guide/query-plans) filter kinds. | +| `expandRelationOperands` | Materializes ReBAC `relation` operands of a [query plan](/guide/query-plans) into id filters. | +| `KerberosValidationError`, `KerberosCacheError`, `KerberosCodecError`, `KerberosExprError`, `KerberosRelationsError` | Typed [error classes](/api/errors). | +| `registerAjvKeywords`, `createAjvAdapter` | [Validation](/guide/schema-validation) helpers. | +| `JsonSchemas`, `TypeBoxSchemas`, `ZodSchemas`, `KerberosJsonSchemas`, `ResourcePolicyJsonSchemas`, `PrincipalPolicyJsonSchemas`, `RolePolicyJsonSchemas`, … | Schema builders for the three backends. | +| `ALL_ACTIONS`, `ALL_ROLES`, `ALL_RESOURCES`, `DEFAULT_VERSION`, `BASE_SCOPE` | Wildcard/default tokens (`'*'`, `'default'`, `''`). | + +## `@alexify/kerberos/relations` + +Opt-in ReBAC — kept out of the main entry so non-ReBAC bundles do not grow: + +| Export | Purpose | +| ------ | ------- | +| `RelationResolver` | The built-in [Zanzibar-lite resolver](/guide/relations-resolver) (check / list / lookupSubjects / lookupResources). | +| `RelationSchema` | Compiles the relation-schema DSL standalone (validated schemas reusable across resolvers). | +| `Relations*Schemas`, parse helpers | Schema builders / parsers for the resolver's shapes (three validation backends). | + +## `@alexify/kerberos/tests` + +Dev/test only — not loaded by the main entry: + +| Export | Purpose | +| ------ | ------- | +| `KerberosTest`, `KerberosTests` | Cerbos-style declarative test runner. | +| `PrincipalMock`, `PrincipalsMock`, `ResourceMock`, `ResourcesMock` | Named fixtures for test suites. | +| `*ZodSchemas`, `*JsonSchemas`, `*TypeBoxSchemas` | Schema builders for the test harness. | diff --git a/docs/api/kerberos.md b/docs/api/kerberos.md new file mode 100644 index 0000000..80270c2 --- /dev/null +++ b/docs/api/kerberos.md @@ -0,0 +1,73 @@ +# Kerberos class + +`Kerberos` is the sole runtime engine: you construct it once with your policies and derived roles, then ask it questions. Three public methods answer them — [`isAllowed`](#kerberos-isallowed-args-promise-boolean) for a single decision, [`checkResources`](#kerberos-checkresources-args-effectasboolean-false-promise-checkresourcesresponse) for a batch, and [`planResources`](#kerberos-planresources-args-promise-planresourcesresponse) for a database filter. + +## `new Kerberos(policies, derivedRoles?, options?)` + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `policies` | `Array` | Static policies loaded into memory. Plain objects are auto-detected by their `resourcePolicy` / `principalPolicy` / `rolePolicy` key. May be empty when policies are resolved from a `cache`. | +| `derivedRoles` | `Array` | Optional derived-role definition sets. | +| `options` | `object` | Optional configuration — see [Configuration Options](/guide/configuration). | + +## `kerberos.isAllowed(args) => Promise` + +Evaluates a **single** action against a single resource and returns a boolean. + +- `args.principal` — the principal (`id`, `roles`, optional `policyVersion`, `scope`, `attr`). +- `args.action` — the action to check. +- `args.resource` — the resource (`id`, `kind`, optional `policyVersion`, `scope`, `attr`). +- `args.reqId` — optional correlation id echoed in logs. +- `args.includeMeta` — when `true`, enables decision tracing (visible in audit logs). + +```javascript +const allowed = await kerberos.isAllowed({ + principal: { id: 'user1', roles: ['USER'], policyVersion: 'default', scope: 'acme.corp' }, + action: 'view', + resource: { id: 'expense1', kind: 'expense', attr: { amount: 5000, status: 'OPEN' } }, + reqId: 'optional-correlation-id', // optional +}); +``` + +## `kerberos.checkResources(args, effectAsBoolean = false) => Promise` + +Evaluates **multiple resources and actions** in a single request. + +- `args.principal` — the principal (`id`, `roles`, optional `policyVersion`, `scope`, `attr`). +- `args.resources` — array of `{ resource, actions }` entries. +- `args.reqId` — optional correlation id echoed in the response and logs. +- `args.includeMeta` — when `true`, includes evaluation [metadata](/guide/decision-metadata). +- `effectAsBoolean` — when `true`, action results are `true`/`false` instead of `EFFECT_ALLOW`/`EFFECT_DENY`. + +```javascript +const response = await kerberos.checkResources({ + principal: { id: 'user1', roles: ['USER'] }, + resources: [{ resource: { id: 'expense1', kind: 'expense' }, actions: ['view', 'create'] }], +}); +// { +// kerberosCallId: 'b9c4362d-…', // always present, for audit correlation +// reqId: '…', // present only if provided in the request +// results: [{ resource, actions, outputs, meta? }], +// } +``` + +## `kerberos.planResources(args) => Promise` + +Builds a **resources query plan**: instead of a yes/no decision for one resource, it returns a *filter* describing **which** resources of a kind the principal may act on — ready to translate into a database query. See [Query Plans](/guide/query-plans). + +- `args.principal` — the principal (`id`, `roles`, optional `policyVersion`, `scope`, `attr`). +- `args.resource` — the resource **kind** (`kind`, optional `policyVersion`, `scope`, `attr`). No `id`: `attr` carries only the *known* attributes; everything else stays unknown and surfaces in the filter. +- `args.action` **or** `args.actions` — exactly one of them; multiple actions plan the conjunction (Cerbos AND semantics). The wildcard `'*'` cannot be planned. +- `args.reqId` / `args.includeMeta` — as in `checkResources`; `includeMeta` adds `filterDebug`, `matchedScopes` and the `resolution` trace. + +```javascript +const plan = await kerberos.planResources({ + principal: { id: 'user1', roles: ['USER'] }, + resource: { kind: 'expense' }, + action: 'view', +}); +// { +// kerberosCallId: '…', action: 'view', resourceKind: 'expense', policyVersion: 'default', +// filter: { kind: 'KIND_ALWAYS_ALLOWED' | 'KIND_ALWAYS_DENIED' | 'KIND_CONDITIONAL', condition? }, +// } +``` diff --git a/docs/guide/benchmarks.md b/docs/guide/benchmarks.md new file mode 100644 index 0000000..0e69f45 --- /dev/null +++ b/docs/guide/benchmarks.md @@ -0,0 +1,24 @@ +# Benchmarks + +Measured with the zero-dependency harness in [`bench/bench.js`](https://github.com/Alexis-Technologies/kerberos/blob/main/bench/bench.js) (1s timed run after 2k warmup iterations per scenario). Reproduce with: + +```bash +pnpm bench +``` + +Apple Silicon (M-series), Node v24: + +| Scenario | ops/sec | +| -------- |---------:| +| `isAllowed` — simple role match | ~320,000 | +| `isAllowed` — derived roles + variables + condition | ~300,000 | +| `checkResources` — 10 resources × 3 actions | ~41,000 | +| `isAllowed` — cache-backed dynamic policy (`$expr`, in-memory Map) | ~150,000 | +| `planResources` — `$expr` policy (variables + deny rule) | ~60,000 | +| `relations.check` — direct tuple (flat) | ~850,000 | +| `relations.check` — deep walk (3 arrows + nested groups) | ~120,000 | +| `isAllowed` — relation-backed derived role (deep walk) | ~80,000 | + +`checkResources` evaluates resources **concurrently** (`Promise.allSettled`): with a remote policy store, N resources cost one parallel wave of lookups instead of N sequential round-trips (measured ~8x faster with a 2ms-latency cache and 10 resources), and one failing resource never fails the batch — it fail-closes to `EFFECT_DENY` for its actions only. + +Numbers vary by hardware and Node version — treat them as relative guidance, not absolutes. The harness exists primarily to catch performance regressions between releases. diff --git a/docs/guide/caching.md b/docs/guide/caching.md new file mode 100644 index 0000000..61c73e6 --- /dev/null +++ b/docs/guide/caching.md @@ -0,0 +1,206 @@ +# Caching / Storing policies + +Kerberos.js can resolve policies dynamically from a remote store (Redis, MongoDB, PostgreSQL, in-memory, ...) instead of loading every policy up front. Following the same delegating philosophy as the `logger` option, Kerberos stays **agnostic**: it does not implement caching, TTL or invalidation logic itself. You pass a `cache`, and Kerberos simply calls `cache.get(key)` when it needs a policy. Everything else — storage, layering, expiry, and multi-host invalidation — is delegated to dedicated solutions such as [`keyv`](https://keyv.org), [`cacheable`](https://cacheable.org) (`CacheSync`) and [`qified`](https://qified.org). + +## How it works (fallback layer) + +Static policies passed to the constructor stay in memory and are always checked first. The `cache` is only consulted on a **miss**: + +1. Resolve the policy by `kind` / `id` / `role` + `policyVersion` + scope chain in memory. +2. On a miss, and only if a `cache` is configured, call `await cache.get(key)` for each scope in the chain. +3. On a hit, the JSON document is handled according to the `codec` option (see below). +4. If nothing matches, the action falls back to `EFFECT_DENY` (unchanged behavior). + +Cache keys follow this layout: + +| Policy type | Key format | +| --------------- | ----------------------------------------- | +| Resource policy | `resource:::` | +| Principal policy| `principal:::` | +| Role policy | `role:::` | +| Derived roles | `derivedRoles:` | + +`` defaults to `default`, and `` is empty for unscoped policies (e.g. `resource:expense:default:`). + +## `CacheLike` + +The only requirement is a single `get` method, so any cache backend works: + +```typescript +type CacheLike = { + get(key: string): unknown | Promise; +}; +``` + +## `codec` option — three modes + +The `codec` option controls how a value returned from the cache is transformed before being passed to the policy constructor: + +| Provided option | Behaviour | +| --------------- | --------- | +| `codec: { jsep }` | Kerberos uses the **built-in AST allowlist evaluator** with the pre-configured `jsep` instance you supply. `{ $expr: "..." }` descriptors are resolved into runtime evaluator functions. | +| `codec: { deserialize }` | Your own **custom deserialization** function is called on the raw cached value. | +| *(omit `codec`)* | The cached value is **passed as-is** to the policy constructor — no `{ $expr }` transformation. Use this when your stored JSON documents don't contain expression descriptors (e.g. plain rules with static `effect` and `roles`). | + +::: warning +**`jsep` is not a dependency of `@alexify/kerberos`.** It is deliberately kept out so you only pay for it when you need expression-based policies. Install it (and any plugins) separately and pass the instance to Kerberos. +::: + +```bash +npm install jsep @jsep-plugin/object @jsep-plugin/ternary @jsep-plugin/new +``` + +## Dynamic policy format + +Because a remote store can be Redis/Mongo/Postgres/etc., dynamic policies must be **JSON documents**. JSON has no concept of a JavaScript function, so `conditions`, `variables` and `outputs` are authored as **expression descriptors** `{ "$expr": "..." }` instead of JS functions: + +```javascript +// In-memory policy (function form): +condition: { match: ({ R, P }) => R.attr.ownerId === P.id } + +// Dynamic/stored policy (JSON, $expr form): +"condition": { "match": { "$expr": "R.attr.ownerId == P.id" } } +``` + +A full stored resource policy document looks like: + +```json +{ + "resourcePolicy": { + "version": "default", + "resource": "document", + "importDerivedRoles": ["doc_roles"], + "variables": { "isOpen": { "$expr": "R.attr.status == 'OPEN'" } }, + "rules": [ + { "actions": ["*"], "effect": "EFFECT_ALLOW", "roles": ["ADMIN"] }, + { "actions": ["view"], "effect": "EFFECT_ALLOW", "derivedRoles": ["OWNER"] }, + { + "name": "edit-when-open", + "actions": ["edit"], + "effect": "EFFECT_ALLOW", + "derivedRoles": ["OWNER"], + "condition": { "match": { "$expr": "V.isOpen" } }, + "output": { "when": { "ruleActivated": { "$expr": "({ owner: R.attr.ownerId, by: P.id })" } } } + } + ] + } +} +``` + +Expressions are evaluated against the same request context as functions: `P` (principal), `R` (resource), `V` (variables), `C` (constants), plus a curated set of **safe language builtins** (see below). + +## Allowed safe builtins + +The default codec exposes a small, allowlisted subset of JavaScript that is useful in policy conditions without opening an `eval` trust boundary — `Math`, `Date`, coercion/parsing helpers and safe string/array methods. See [Safe builtins](/reference/safe-builtins) for the full list. + +Anything outside that list — arbitrary constructors (`new Function`, `new Object`, ...), global roots like `process` / `require` / `globalThis`, or member keys such as `constructor` / `__proto__` — is rejected by the AST allowlist interpreter. + +Example: a time-window condition (equivalent to the in-memory expense delete rule) in `{ $expr }` form: + +```json +{ + "condition": { + "match": { + "$expr": "(Date.now() - new Date(R.attr.createdAt).getTime()) < 3600000 && R.attr.status == 'OPEN'" + } + } +} +``` + +## Example 1: a simple Keyv cache + +```javascript +import { Keyv } from 'keyv'; +import jsep from 'jsep'; +import jsepObject from '@jsep-plugin/object'; +import jsepTernary from '@jsep-plugin/ternary'; +import jsepNew from '@jsep-plugin/new'; +import { Kerberos, serializePolicy } from '@alexify/kerberos'; + +// 1. Configure jsep once — register plugins and any extra unary operators. +jsep.plugins.register(jsepObject, jsepTernary, jsepNew); +jsep.addUnaryOp('typeof'); + +const keyv = new Keyv(); + +// 2. Serialize policies into JSON-safe documents (validates $expr ASTs via jsep). +await keyv.set('derivedRoles:doc_roles', serializePolicy({ + name: 'doc_roles', + definitions: [ + { name: 'OWNER', parentRoles: ['USER'], condition: { match: { $expr: 'R.attr.ownerId == P.id' } } }, + ], +}, { jsep })); + +await keyv.set('resource:document:default:', serializePolicy({ + resourcePolicy: { + version: 'default', + resource: 'document', + importDerivedRoles: ['doc_roles'], + rules: [ + { actions: ['view'], effect: 'EFFECT_ALLOW', derivedRoles: ['OWNER'] }, + ], + }, +}, { jsep })); + +// 3. Pass the same jsep instance so Kerberos can evaluate $expr at runtime. +const kerberos = new Kerberos([], [], { cache: keyv, codec: { jsep } }); + +const allowed = await kerberos.isAllowed({ + principal: { id: 'u1', roles: ['USER'] }, + action: 'view', + resource: { id: 'doc1', kind: 'document', attr: { ownerId: 'u1' } }, +}); +// -> true +``` + +`serializePolicy(shape, { jsep })` validates every `{ $expr }` string via full AST parse and returns a JSON-safe document. Passing `{ jsep }` is optional — without it the function still rejects raw JS functions but skips AST validation (expressions are validated at deserialize time instead). You can also store hand-written JSON directly. + +## Example 2: Keyv + Cacheable + Qified (recommended for multi-host invalidation) + +For production deployments running multiple Kerberos instances, the recommended setup combines: + +- **`keyv`** — the storage engine (Redis, Mongo, Postgres, ...); +- **`cacheable`** — high-performance layer 1 / layer 2 caching with `CacheSync`; +- **`qified`** — the pub/sub transport that propagates `CacheSync` invalidation messages across hosts. + +::: tip +**This is the recommended way to invalidate your policies across multiple hosts.** When a policy changes, update the store; `cacheable`'s `CacheSync` broadcasts the invalidation over `qified` pub/sub so every Kerberos instance drops its stale layer-1 copy. Kerberos itself only ever calls `cache.get` — it never has to know about invalidation. +::: + +```javascript +import { Cacheable } from 'cacheable'; +import { createKeyv } from '@keyv/redis'; +import { Qified } from 'qified'; +import { createQified } from '@qified/redis'; +import jsep from 'jsep'; +import jsepObject from '@jsep-plugin/object'; +import jsepTernary from '@jsep-plugin/ternary'; +import jsepNew from '@jsep-plugin/new'; +import { Kerberos } from '@alexify/kerberos'; + +// Configure jsep once per process. +jsep.plugins.register(jsepObject, jsepTernary, jsepNew); +jsep.addUnaryOp('typeof'); + +// Layer 2 (distributed) storage + layer 1 (in-process) cache. +const secondary = createKeyv('redis://localhost:6379'); + +// CacheSync over qified pub/sub keeps every host's layer-1 cache coherent. +const cacheSync = createQified({ uri: 'redis://localhost:6379' }); + +const cacheable = new Cacheable({ + secondary, + cacheId: 'kerberos-policies', + cacheSync, // distributed invalidation via qified pub/sub +}); + +const kerberos = new Kerberos([], [], { cache: cacheable, codec: { jsep } }); + +// Reads transparently use layer 1 -> layer 2; writes/invalidations are handled +// by cacheable + qified, not by Kerberos. +const allowed = await kerberos.isAllowed({ + principal: { id: 'u1', roles: ['USER'] }, + action: 'view', + resource: { id: 'doc1', kind: 'document', attr: { ownerId: 'u1' } }, +}); +``` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md new file mode 100644 index 0000000..193545d --- /dev/null +++ b/docs/guide/configuration.md @@ -0,0 +1,79 @@ +# Configuration Options + +The Kerberos constructor accepts an optional third parameter with configuration options: + +```javascript +const kerberos = new Kerberos(policies, derivedRoles, { + logger: true, // Legacy console audit logging with summary + table + debug(json) + onError: 'deny', // 'throw' (default) or 'deny' — fail-closed evaluation errors + telemetry, // Optional: OpenTelemetry traces + metrics ({ api } or { tracer, meter }) + cache, // Optional: any cache solution exposing get(key) (keyv, cacheable, ...) + cacheRetry: { attempts: 3 }, // Optional: retry policy for transient cache.get failures + codec, // Optional: (de)serialization codec for dynamic policies ({ jsep } or { deserialize }) + relations, // Optional: ReBAC resolver for relation-backed derived roles + z, // Optional: validate with Zod + ajv, // Optional: validate with Ajv + typebox: Type, // Optional: switch Ajv validation to TypeBox builders + getCallId: () => `custom-${Date.now()}`, // Custom call ID generator (optional) +}); +``` + +## Options + +- **`logger`** (boolean | KerberosLogger): Enable audit logging. + - `true` keeps the legacy console behavior with `group + summary + table + debug(json)` + - `false` or omitted disables logging + - a custom `console`-like logger keeps the legacy table/json flow + - a structured logger such as `Pino` receives one structured audit entry per evaluated action + - Logging is pure observability: it never changes decisions or error behavior (that is [`onError`](#options)'s job), and a throwing logger is swallowed — it can never affect authorization. +- **`onError`** (`'throw' | 'deny'`, default `'throw'`): What happens when policy **evaluation** fails at runtime (a throwing condition function, a failing cache backend, a ReBAC resolver error). + - `'throw'` propagates the error to the caller; + - `'deny'` fails closed: `isAllowed` resolves to `false`, `checkResources` to `{ results: [], kerberosCallId, reqId? }`, `planResources` to a `KIND_ALWAYS_DENIED` filter. + - Malformed **arguments** are programming errors and always throw `KerberosValidationError`, regardless of this option. + + ```javascript + // Fail-closed setup: evaluation errors deny instead of throwing. + const kerberos = new Kerberos(policies, derivedRoles, { onError: 'deny' }); + ``` + +- **`telemetry`** (KerberosTelemetryOptions): Enable OpenTelemetry traces and metrics. Pass `{ api }` (the `@opentelemetry/api` module) or `{ tracer, meter }` instances — see [OpenTelemetry](/guide/telemetry). +- **`cache`** (CacheLike): An optional cache used as a fallback source for dynamic/stored policies. Any object exposing a `get(key)` method is accepted (keyv, cacheable, cache-manager, ...). See [Caching / Storing policies](/guide/caching). +- **`cacheRetry`** (`{ attempts?: number }`, default `{ attempts: 3 }`): Retry policy for transient `cache.get` failures. After the attempts are exhausted the failure surfaces as `KerberosCacheError` (and then follows `onError`). `attempts: 1` disables retrying. +- **`codec`** (PolicyCodec): How cached policy documents are transformed before construction: `{ jsep }` enables the built-in safe `$expr` evaluator, `{ deserialize }` plugs in your own logic, and when omitted cached values are passed to policy constructors **as-is** — see [`codec` option — three modes](/guide/caching#codec-option-three-modes). +- **`relations`** (KerberosRelationsResolver): ReBAC resolver used by relation-backed derived roles — any object with a `check(args, opts)` method (and an optional batched `list`). See [ReBAC (Relations)](/guide/rebac). +- **`z`**: Enables validation using the built-in Zod schema builders. +- **`ajv`**: Enables validation using the built-in JSON Schema builders compiled with Ajv. +- **`typebox`**: When used together with `ajv`, switches validation to the built-in TypeBox builders. +- **`getCallId`** (function): Custom function to generate call IDs for audit tracking. + - **Default behavior**: Uses `crypto.randomUUID()` in Node.js, `window.crypto.randomUUID()` in browsers, or falls back to a pseudo UUID generator + - **Custom example**: `() => \`req-\${Date.now()}-\${Math.random()}\`` + +## Using Pino for Production Logging + +If you want machine-readable audit logs in production, pass a `Pino` instance as the `logger` option: + +```javascript +import pino from 'pino'; +import { Kerberos } from '@alexify/kerberos'; + +const logger = pino({ level: 'info' }); + +const kerberos = new Kerberos(policies, derivedRoles, { + logger, +}); +``` + +With `Pino`, Kerberos emits structured audit entries that include `callId`, `reqId`, `reqKind`, `principalId`, `resourceId`, `action`, `effect`, `outputs`, and `meta`. This mode is better suited for production ingestion than the default console table output. + +It also emits lifecycle logs such as `IsAllowed.start`, `IsAllowed.error`, `IsAllowed.finish`, `CheckResources.start`, `CheckResources.finish` and `PlanResources.*`. Errors are always logged, but whether they are rethrown or converted into a fail-closed response is decided solely by the [`onError`](#options) option — never by the logger. + +## Call ID Generation + +Every request (`isAllowed` / `checkResources` / `planResources`) automatically generates a unique `kerberosCallId` for audit tracking: + +- **Node.js**: Uses `crypto.randomUUID()` +- **Browser**: Uses `window.crypto.randomUUID()` +- **Fallback**: Pseudo UUID v4 generator if crypto APIs are unavailable +- **Custom**: Provide your own `getCallId` function for custom ID formats + +This ID is included in both the response and audit logs for correlation. diff --git a/docs/guide/decision-metadata.md b/docs/guide/decision-metadata.md new file mode 100644 index 0000000..1c0c283 --- /dev/null +++ b/docs/guide/decision-metadata.md @@ -0,0 +1,86 @@ +# Decision metadata (includeMeta) + +When `includeMeta: true` is set, the response includes additional metadata about policy evaluation: + +```javascript +const results = await kerberos.checkResources({ + principal: { + id: 'alice', + scope: 'acme.corp', + roles: ['employee'] + }, + resources: [ + { + resource: { + id: 'XX125', + kind: 'leave_request', + policyVersion: '20210210', + scope: 'acme.corp' + }, + actions: ['view:public', 'approve'] + } + ], + includeMeta: true +}); + +console.log(results); +// { +// reqId: 'test-request', +// kerberosCallId: 'b9c4362d-b92a-4c2b-9d49-845f00d7a372', +// results: [ +// { +// resource: { +// id: 'XX125', +// kind: 'leave_request', +// policyVersion: '20210210', +// scope: 'acme.corp' +// }, +// actions: { +// 'view:public': 'EFFECT_ALLOW', +// 'approve': 'EFFECT_DENY' +// }, +// outputs: [ +// { +// src: 'resource.leave_request.v20210210/acme.corp#rule-001', +// val: 'create_allowed:john' +// } +// ], +// meta: { +// actions: { +// 'view:public': { +// matchedPolicy: 'resource.leave_request.v20210210/acme.corp', +// matchedRule: 'resource.leave_request.v20210210/acme.corp#rule-001', +// matchedScope: 'acme.corp' +// }, +// 'approve': { +// matchedPolicy: 'resource.leave_request.v20210210/acme.corp', +// reason: 'condition-not-met' +// } +// }, +// effectiveDerivedRoles: [ +// 'employee_that_owns_the_record', +// 'any_employee' +// ], +// resolution: [ +// { source: 'principal', id: 'alice', version: 'default', +// scopesSearched: ['acme.corp', 'acme', ''], matchedScope: null }, +// { source: 'resource', id: 'leave_request', version: '20210210', +// scopesSearched: ['acme.corp', 'acme', ''], matchedScope: 'acme.corp' } +// ] +// } +// } +// ] +// } +``` + +Per action, `meta.actions[action]` includes: + +- **matchedPolicy**: The policy source that produced the decision — a resource source such as `resource.expense.vdefault/acme.corp`, a principal source such as `principal.sally.vdefault/acme.corp`, or a role source such as `role.USER.vdefault` +- **matchedRule**: The exact rule that produced the decision +- **matchedScope**: The scope of the matched policy (present for scoped policies) +- **reason** (denied actions only): why nothing allowed the action — `'rule-miss'` (no rule targeted the action / matched the principal's roles), `'condition-not-met'` (a rule targeted it but its condition failed) or `'policy-miss'` (no applicable policy existed at all) + +At the result level: + +- **effectiveDerivedRoles**: derived roles that activated for this resource +- **resolution** (decision trace): every policy lookup that was attempted — `{ source, id, version, scopesSearched, matchedScope, origin? }` entries (with `origin: 'cache'` for cache-resolved policies) plus `{ source: 'relations', name, relation, matched, reason? }` entries for [relation-backed derived roles](/guide/rebac). The same trace appears in [`planResources` meta](/guide/query-plans). diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..fe066ff --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,59 @@ +# Quick Start + +A resource policy with a **derived role** (a role computed per request — here, "the owner of this expense"), checked through both public APIs: + +```javascript +import { Kerberos, Effect } from '@alexify/kerberos'; + +const expensePolicy = { + resourcePolicy: { + resource: 'expense', // applies to resources of kind 'expense' + version: 'default', + importDerivedRoles: ['common_roles'], + rules: [ + { actions: ['*'], effect: Effect.Allow, roles: ['ADMIN'] }, + { actions: ['view', 'delete'], effect: Effect.Allow, derivedRoles: ['OWNER'] }, + { + actions: ['view'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: ({ R }) => R.attr.status === 'OPEN' }, + }, + ], + }, +}; + +const commonRoles = { + name: 'common_roles', + definitions: [ + { name: 'OWNER', parentRoles: ['USER'], condition: { match: ({ P, R }) => R.attr.ownerId === P.id } }, + ], +}; + +const kerberos = new Kerberos([expensePolicy], [commonRoles]); + +// Single decision: +await kerberos.isAllowed({ + principal: { id: 'sally', roles: ['USER'] }, + action: 'delete', + resource: { id: 'expense1', kind: 'expense', attr: { ownerId: 'sally', status: 'OPEN' } }, +}); // → true (OWNER derived role) + +// Batch decisions: +const response = await kerberos.checkResources({ + principal: { id: 'frank', roles: ['USER'] }, + resources: [ + { resource: { id: 'expense1', kind: 'expense', attr: { ownerId: 'sally', status: 'OPEN' } }, actions: ['view', 'delete'] }, + ], +}); +// { +// kerberosCallId: 'b9c4362d-…', // generated UUID for audit correlation +// results: [{ +// resource: { id: 'expense1', kind: 'expense' }, +// actions: { view: 'EFFECT_ALLOW', delete: 'EFFECT_DENY' }, +// outputs: [], +// }], +// } +``` + +From here: [principal and role policies](/guide/policy-types) for overrides and allowlists, [`planResources`](/guide/query-plans) for "which resources can this principal access" filters, [dynamic policies](/guide/caching) for cache-stored rules, and [ReBAC](/guide/rebac) for relationship-based access. diff --git a/docs/guide/installation.md b/docs/guide/installation.md new file mode 100644 index 0000000..ca500bb --- /dev/null +++ b/docs/guide/installation.md @@ -0,0 +1,42 @@ +# Installation + +::: code-group + +```bash [npm] +npm install @alexify/kerberos +``` + +```bash [pnpm] +pnpm add @alexify/kerberos +``` + +```bash [yarn] +yarn add @alexify/kerberos +``` + +::: + +Requires **Node.js ≥ 18** (or any modern browser through a bundler). The package is CommonJS; both `require('@alexify/kerberos')` and `import { Kerberos } from '@alexify/kerberos'` (via Node/bundler ESM interop) work — the examples throughout these docs use `import`. + +## Bundle size + +Zero runtime dependencies. Measured with `pnpm size` (esbuild browser bundle, fully minified with identifier mangling, then gzipped): + +| Entry | min | min+gzip | +| ----- | ---:| --------:| +| `@alexify/kerberos` (main entry, query planner included) | 93.6 KB | **25.1 KB** | +| `@alexify/kerberos/relations` (opt-in ReBAC resolver) | 57.2 KB | 15.1 KB | + +The `/relations` and `/tests` subpaths are only bundled if you import them. Optional tooling (`jsep`, `zod`, `ajv`, `@sinclair/typebox`, `@opentelemetry/api`) is never included — you install what you use. + +## Browser usage + +The package ships two entrypoints: a Node.js entry (`index.js`, uses `node:crypto` / `node:perf_hooks` directly) and a browser entry (`browser.js`) declared via the package.json `browser` field and the `browser` condition in `exports`. Browser bundlers pick the browser build automatically — **no configuration needed** for webpack 5, Vite, esbuild (`platform: 'browser'`), Parcel or Bun. Rollup users need [`@rollup/plugin-node-resolve`](https://github.com/rollup/plugins/tree/master/packages/node-resolve) with `browser: true`. + +The browser build contains **zero Node.js builtins** — the only platform-specific code (`generateCallId`, `getNow`) is swapped to a browser implementation backed by `globalThis.crypto.randomUUID` and `globalThis.performance`. + +::: info Notes +- In insecure contexts (plain HTTP), where `crypto.randomUUID` is unavailable, call IDs fall back to a `Math.random`-based pseudo UUID. Call IDs are **correlation identifiers, not security tokens**, so this is safe. +- The package is CommonJS, so browser usage requires a bundler (no bare `