Charter — API Reference
Every export of @niscorp/charter. The package is a pure function library: no
state, no I/O, no side effects.
Types
Charter
type Charter = Record<string, RoleDef>;
The document: role name → role definition.
RoleDef
type RoleDef =
| string[] // sugar: an actions-only role
| {
extends?: string[]; // compose whole roles (per section)
without?: string[]; // subtract whole roles (per section)
allow?: string[]; // sugar → actions.allow
deny?: string[]; // sugar → actions.deny
actions?: Selection; // the actions universe
data?: Selection; // the data universe
layouts?: Selection; // the layouts universe
functions?: Selection; // the app's functions this role may call
scoping?: string; // how far this role reaches (see below)
};
A bare string[] is an actions-only role. Top-level allow/deny are sugar
for the actions section — an actions-only role never names a section. Using
both the sugar and an explicit actions: on one role is an error (the verifier
catches it).
scoping — how far a role reaches
A section says which ids a role holds. scoping says how far they reach when
they use them — an opaque name the governed target resolves, exactly like the
strings in every section. The charter does not know what it means; it only knows
which role said it.
{ member: { actions: ['me.*'], data: ['bookings.read'], scoping: 'personal' },
desk: { extends: ['member'], data: ['memberships.read'] } }
Both roles hold bookings.read. The member reaches their own rows; the desk
reaches the studio's. One grant, two reaches, decided by the rung.
scoping is NOT composed by extends, and that is deliberate. Every section
accumulates upward — a desk holding everything a member holds is exactly right
for actions and data. Reach is the opposite: a desk extends a member's screens
but must not extend a member's "only my own rows", or the roster it exists to
read would filter to the one person operating it. A role's reach is its own
answer or none.
One role in, one profile out. resolveScoping takes a single role, because
reach is a property of a role and a function handed several has no answer to give.
resolveScoping(charter, 'member') // 'personal'
resolveScoping(charter, 'desk') // undefined — not inherited
A principal holding several roles is normal, not a conflict: somebody who teaches
at a studio and trains there is an instructor and a member. Compile one policy
per role and merge them (vex: mergeScopePolicies); the principal may do
anything any of their roles permits, and each role reaches as far as its own
profile says.
mergeScopePolicies(
roles.map((role) => createScopePolicy(resolvePrincipal(charter, grants, [role], 'data'), behaviors, resolveScoping(charter, role))),
)
This function used to take a list and throw when two roles named different profiles. That rule was not defending against anything — it was written by analogy to two granted layout variants for one action, which genuinely conflict because a screen renders one. Two reaches do not conflict; they add.
Selection
type Selection = string[] | { allow?: string[]; deny?: string[] };
Within a section: a bare glob list (sugar for { allow }) or explicit
allow/deny.
Section
type Section = 'actions' | 'data' | 'layouts' | 'functions';
Which universe a selection resolves in. actions selects Nova action ids,
data selects table.verb capabilities, layouts selects layout-variant ids
(ring 2 — moss's dialect), functions selects the names of the app's
functions (moss's too). A role with no functions may call none.
Globs
matchGlob(pattern, id): boolean
One wildcard: * matches any run of characters, including dots. matchGlob('crm.*', 'crm.deal.form') is true. No **, no braces, no regex.
matchAll(patterns, ids): Set<string>
Every id in ids selected by any pattern in patterns.
Resolution
resolveRole(charter, universe, role, section, memo?, visiting?): ReadonlySet<string>
Resolve one role in one section against universe. Applies the algebra
(extends ∪ allow − deny − without). memo and visiting are internal
accumulators for recursion and cycle detection; callers pass neither. (memo is
keyed by role name alone, so one is never shared across sections or universes.)
Throws CharterError on an unknown role reference or a role cycle.
resolvePrincipal(charter, universe, roles, section?): Set<string>
The union of the resolved sets for every role the principal wears, in one
section. section defaults to 'actions'. This is the function a server calls
at login.
resolvePrincipal(charter, actionIds, ['sales', 'dev'], 'actions'); // granted actions
resolvePrincipal(charter, verbLeaves, ['sales', 'dev'], 'data'); // granted table.verb caps
resolveScoping(charter, role): string | undefined
The scoping name one role declares, or undefined — for a role that declares
none, a bare-array role, and a role the charter does not define (it does not
throw). Not composed by extends; see scoping.
normalizeRole(def, section): { allow, deny, extends, without }
The atoms of one role for one section, with sugar expanded. Exposed for tools
that inspect a role's raw selection (the verifier uses it); most callers want
resolveRole.
CharterError
Thrown by the resolver for unknown-role and cycle errors. verifyCharter
catches these and reports them as resolution errors rather than throwing.
Verification
verifyCharter(charter, universes, wearable?, closure?): VerifyReport
Run every coherence check, per section, against that section's universe. Call
at boot/CI; refuse to serve if report.errors is non-empty.
universes: { actions: readonly string[]; data: readonly string[]; layouts?: readonly string[]; functions?: readonly string[] }— the id sets each section resolves against, handed in by the composer. Thelayoutsuniverse is optional: absent, the section is inert (a charter'slayoutskeys resolve against nothing and are not verified); handed in, its dead-deny/dead-allow/orphan/leaves-only checks run like any other section. So isfunctions, with the same checks.wearable?: readonly (readonly string[])[]— the role combinations a principal may wear, one entry per combination (not one per person). Defaults to[]. Read only by thesubtractive-assignedcheck. A composer that has an assignment map (principal → roles) passesObject.values(map).closure?: ClosureAuditor— the injected per-role cross-action audit.
ClosureAuditor
type ClosureAuditor = (grantedIds: readonly string[], layoutIds?: readonly string[]) => string[];
Given a role's resolved action ids (and, when the app governs layouts, its
granted variant ids), return the cross-action wiring problems inside that
closure. Supplied by the consumer that owns actions. Moss supplies its own
whenever it verifies an app, at boot and in verifyApp(app, grants): built on
Nova's auditAction, it substitutes granted variants so the audit sees each
role's effective definitions. Absent, every role's issues is empty.
VerifyReport
type VerifyReport = {
errors: VerifyIssue[]; // refuse to boot if non-empty
warnings: VerifyIssue[]; // deploy, but review
perRole: RoleClosure[]; // resolved actions + data + layouts (sorted) + closure issues, per role
};
type VerifyIssue = { level: 'error' | 'warning'; rule: string; detail: string };
type RoleClosure = { role: string; actions: string[]; data: string[]; layouts: string[]; issues: string[] };
The checks
| rule | level | meaning |
|---|---|---|
resolution |
error | a cycle or unknown-role reference (per section) |
leaves-only |
error | an id is a namespace of another id — namespaces are never actions (checked in the actions universe, and in layouts when handed in; not in data) |
ambiguous-selection |
error | a role has both top-level allow/deny and an explicit actions: |
dead-deny |
error | a deny glob matches nothing — silent means unprotected |
dead-allow |
warning | an allow glob matches nothing — noise |
orphan |
warning | an action (or, when layouts is handed in, a layout variant) granted by no role — deployed but unreachable. An ungranted data id is not reported |
re-allow |
warning | a role re-allows an id an ancestor denied |
subtractive-assigned |
warning | a role used in without also appears in a wearable combination |
| (closure) | via RoleClosure.issues |
cross-action wiring problems from the injected auditor |
Usage pattern
import { resolvePrincipal, verifyCharter } from '@niscorp/charter';
import { createScopePolicy, scopeGrants } from '@niscorp/vex';
// 1. Boot: derive the universes from the governed targets, refuse incoherence.
// (An app served by moss does not write this step: moss runs it at boot,
// with its closure auditor, and `verifyApp(app, grants)` runs it for a check.)
const universes = {
actions: Object.keys(app.actions), // the shell's dialect
data: scopeGrants(tableNames), // vex's dialect — `table.verb` leaves
};
const wearable = Object.values(app.assignments); // the role combinations in use
const report = verifyCharter(app.charter, universes, wearable);
if (report.errors.length > 0) {
throw new Error(report.errors.map((e) => `${e.rule}: ${e.detail}`).join('\n'));
}
// 2. Per login: resolve, hand the sets to the enforcers.
const grantedActions = resolvePrincipal(app.charter, universes.actions, roles, 'actions');
const grantedData = resolvePrincipal(app.charter, universes.data, roles, 'data');
const policy = createScopePolicy(grantedData, app.behaviors); // vex enforces
// the shell mounts only grantedActions