Charter · DOCS.md

This is DOCS.md as @niscorp/charter 0.1.4 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/charter/DOCS.md

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. The layouts universe is optional: absent, the section is inert (a charter's layouts keys resolve against nothing and are not verified); handed in, its dead-deny/dead-allow/orphan/leaves-only checks run like any other section. So is functions, 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 the subtractive-assigned check. A composer that has an assignment map (principal → roles) passes Object.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