Sign In

@ultimat3/policy

Package Overview
Dependencies
Maintainers
1
Versions
18
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/policy - npm Package Compare versions

Comparing version
7.0.0
to
8.0.0
+90
src/policy-anonymous.ts
// The one derived question a SURFACE asks of a policy tree: can an anonymous caller be allowed at
// all. Apart from `policy.ts` so each file stays one responsibility — that one CONSTRUCTS trees,
// this one projects a single boolean out of a built one — and in this package rather than in the
// surfaces, because the answer is a property of the combinators and a copy per surface drifts from
// them the first time one changes.
import type { Policy, PolicyKind } from './policy';
/**
* Whether an ANONYMOUS caller can be allowed by this policy — what a surface derives its
* "does this route need a session" flag from. `policy.kind === 'allow'` is the read this
* replaces, and it looked at the ROOT combinator only: `or(allow(), can('x:y'))` answered
* "needs a session", so `@ultimat3/http`'s auth stage 401'd a caller the policy itself ALLOWS,
* while the MCP tool and the job surface let that same caller through the same object. One
* policy, a different answer per surface, which is the thing this package exists to prevent.
*
* **EXACT for `actor === null`, not a heuristic.** With no actor `can()` short-circuits on the
* actor check before its predicate ever runs, and `allow()`/`deny()` ignore their arguments
* entirely — so no predicate is consulted and the tree alone decides. It lives in THIS package
* for that reason: the answer is a property of the combinators `policy.ts` declares, and a copy in
* a surface package would drift from them the first time one changes.
*
* `true` never means "unguarded". It says only that the 401 is not the stage's to raise; the
* surface still evaluates the policy through `enforce()`.
*/
export const admitsAnonymous = <I = unknown, R = unknown>(policy: Policy<I, R>): boolean =>
anonymousOutcome(policy) === 'allowed';
/**
* Three-valued, because `not()` needs the distinction: it PROPAGATES `X_UNAUTHENTICATED` rather
* than inverting it, so `not(can('order:internal'))` denies an anonymous caller while
* `not(deny(…))` allows one.
*/
type AnonymousOutcome = 'allowed' | 'denied' | 'unauthenticated';
/** The two fields the walk reads, so every `Policy<I, R>` satisfies it whatever its arguments. */
interface PolicyTree {
readonly kind: PolicyKind;
readonly children: readonly PolicyTree[];
}
/**
* Exhaustive over `PolicyKind` by construction: a seventh kind is a missing-key TYPE error here,
* never a silent "needs a session" on every route guarded by it. The import of `PolicyKind` is
* what makes that hold across the split — a local union would be the copy this file exists not to
* be.
*/
const OUTCOME_BY_KIND = Object.freeze<Record<PolicyKind, (policy: PolicyTree) => AnonymousOutcome>>(
{
allow: () => 'allowed',
deny: () => 'denied',
// `can()` denies with X_UNAUTHENTICATED before its predicate, for every permission.
permission: () => 'unauthenticated',
// First non-allowance wins, left to right — `and`'s own short-circuit.
and: (policy) => {
for (const child of policy.children) {
const outcome = anonymousOutcome(child);
if (outcome !== 'allowed') return outcome;
}
return 'allowed';
},
// First allowance wins; if none allow, `or` reports the LAST denial.
or: (policy) => {
let last: AnonymousOutcome = 'denied';
for (const child of policy.children) {
last = anonymousOutcome(child);
if (last === 'allowed') return 'allowed';
}
return last;
},
not: (policy) => {
const inner = policy.children[0];
// A `not` with no child cannot come from `not()`; refusing anonymous is the safe reading.
if (inner === undefined) return 'unauthenticated';
const outcome = anonymousOutcome(inner);
if (outcome === 'allowed') return 'denied';
return outcome === 'unauthenticated' ? 'unauthenticated' : 'allowed';
},
},
);
function anonymousOutcome(policy: PolicyTree): AnonymousOutcome {
// `Object.hasOwn`, never the read alone: `kind` is a field of a plain object, so a foreign
// `Policy` can carry any string — and `OUTCOME_BY_KIND['valueOf']` is a function off the
// prototype chain that THROWS when called with no receiver, which would kill a route
// projection at mount. An unrecognised kind requires authentication.
const decide = Object.hasOwn(OUTCOME_BY_KIND, policy.kind)
? OUTCOME_BY_KIND[policy.kind]
: undefined;
return decide === undefined ? 'unauthenticated' : decide(policy);
}
+11
-1

@@ -35,2 +35,11 @@ # @ultimat3/policy

adapter to `surfaces.ts` — nothing else.
- **A derived question about a policy TREE is answered in this PACKAGE, once.** `policyPermissions`
(in `policy.ts`) and `admitsAnonymous` (in `policy-anonymous.ts`) both walk the combinators
`policy.ts` declares, so an answer computed in a surface package would drift from them the first
time one changes — and could not be shared:
`@ultimat3/action` and `@ultimat3/query` are the same tier and may not import each other, so a
copy in either is a second answer for the other. Both shipped that copy briefly and it was
hoisted here. `admitsAnonymous` in particular is EXACT for `actor === null` rather than a
heuristic, because `can()` short-circuits on the actor check before its predicate and
`allow()`/`deny()` ignore their arguments — no predicate is consulted, so the tree alone decides.
- **One predicate shape.** A row-level rule reads `args.row`. Never pass a row through

@@ -115,3 +124,4 @@ `input`, and never add a per-surface args type.

|---|---|
| `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording |
| `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording, and `policyPermissions` |
| `policy-anonymous.ts` | `admitsAnonymous` — the one question a SURFACE asks of a built tree, apart from the file that builds them |
| `evaluate.ts` | the single entry point; builds the trace, emits the one decision event |

@@ -118,0 +128,0 @@ | `decisions.ts` | the `DecisionSink` seam — no-op default, one call site, never PII |

+2
-2
{
"name": "@ultimat3/policy",
"version": "7.0.0",
"version": "8.0.0",
"description": "The one authz rule, evaluated identically in every surface",

@@ -34,4 +34,4 @@ "license": "MIT",

"dependencies": {
"@ultimat3/core": "7.0.0"
"@ultimat3/core": "8.0.0"
}
}

@@ -87,2 +87,22 @@ # @ultimat3/policy 🔐

`admitsAnonymous(policy)` is the other derived question `As of 2026-08`, and it is a **walk, not a
root read**:
whether an anonymous caller can be allowed at all. `policy.kind === 'allow'` is the read it
replaces, and it answered "needs a session" for `or(allow(), can('x:y'))` — so an HTTP route 401'd
a caller the policy itself allows, while the same policy over MCP or a job let that caller in.
```ts
import { admitsAnonymous, allow, and, can, not, or } from '@ultimat3/policy';
admitsAnonymous(or(allow('public'), can('post:publish'))); // true
admitsAnonymous(and(allow('public'), can('post:publish'))); // false
admitsAnonymous(not(can('order:internal'))); // false — X_UNAUTHENTICATED propagates
```
It is **exact for an anonymous caller, not a heuristic**: with `actor === null`, `can()`
short-circuits on the actor check before its predicate runs and `allow()`/`deny()` ignore their
arguments, so no predicate is ever consulted and the tree alone decides. `true` never means
"unguarded" — it says only that a 401 before the handler is wrong; the surface still calls
`enforce()`. `@ultimat3/action` and `@ultimat3/query` derive `RouteMeta.auth` from it.
## Four surfaces, four adapters, one rule

@@ -89,0 +109,0 @@

@@ -6,3 +6,3 @@ // The policy layer's stable error codes. `X_POLICY_MISSING` is enforced by the TYPE system,

// a policy resolved by name — and `policyMissing()` is how such a site says it.
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
import { nearestName, registerErrorCodes, UltimateError } from '@ultimat3/core';

@@ -46,2 +46,13 @@ export const POLICY_ERROR_CODES = [

/**
* A label `x policy explain` can resolve: one declared permission, `<resource>:<verb>`.
*
* Every other `Policy` renders its label as a DESCRIPTION — `and(post:publish, org:administer)`,
* `not(post:publish)`, `allow`, `deny(read-only mode)` — and `knownPolicySubjects()` holds
* permissions, action names and route paths, none of which those match. Interpolating one produced
* `x policy explain and(post:publish, org:administer)`, reproduced in `examples/dummy` as
* `X_DECLARATION_UNKNOWN`: a fix line whose only effect is a second error.
*/
const BARE_PERMISSION = /^[a-z0-9_-]+:[a-z0-9_-]+$/;
/** `reason` comes from a decision and is always safe to log: no row data, no PII. */

@@ -52,3 +63,7 @@ export const forbidden = (label: string, reason: string): PolicyError =>

cause: `${label} denied: ${reason}`,
fix: `x policy explain ${label} --json # shows which clause decided and why`,
// `x policy list --json` is what `X_DECLARATION_UNKNOWN`'s own fix falls back to, so a reader
// who follows either one lands in the same place.
fix: BARE_PERMISSION.test(label)
? `x policy explain ${label} --json # shows which clause decided and why`
: `x policy list --json # then: x policy explain <permission> --json for the clause that decided`,
});

@@ -74,7 +89,23 @@

export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError =>
new PolicyError({
/**
* The nearest declared permission comes FIRST, and the declare-it path second.
*
* The other order is what shipped: `add 'billing:wirte' to definePermissions([...])` reads as an
* instruction to declare the typo, and a permission nothing grants and nothing enforces is a
* silent hole — `assertPermission` then passes, every `can('billing:wirte')` denies, and the
* failure moves from this throw to a page that renders empty. Only the generated `policy.test.ts`
* caught it. A typo is by far the likelier of the two readings, so it leads.
*/
export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError => {
const nearest = nearestName(permission, known);
return new PolicyError({
code: 'X_PERMISSION_UNKNOWN',
// The COUNT, never the set: an app with 200 permissions would bury the fix line under names
// nobody asked for, and `x policy list --json` is one command away.
cause: `"${permission}" is not in the permission set (${known.length} known)`,
fix: `add '${permission}' to definePermissions([...]) — or fix the typo`,
fix:
nearest === undefined
? `add '${permission}' to definePermissions([...]) if it is genuinely new — otherwise x policy list --json shows the ${known.length} already declared`
: `use '${nearest}', the nearest declared permission — or add '${permission}' to definePermissions([...]) if it is genuinely new`,
});
};

@@ -57,13 +57,4 @@ // The public surface of @ultimat3/policy. Explicit, never `export *`.

} from './policy';
export {
ALLOWED,
allow,
and,
can,
denied,
deny,
not,
or,
policyPermissions,
} from './policy';
export { ALLOWED, allow, and, can, denied, deny, not, or, policyPermissions } from './policy';
export { admitsAnonymous } from './policy-anonymous';
export type { Actor, RoleDef, RoleMap } from './roles';

@@ -70,0 +61,0 @@ export {