@ultimat3/policy 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -32,6 +32,15 @@ two differ, and it is why a surface that decides on input alone needs no edit.
32
32
 
33
33
  - **Never add a second authz path.** If a surface cannot use `evaluate()`, add an
34
34
  adapter to `surfaces.ts` — nothing else.
35
+ - **A derived question about a policy TREE is answered in this PACKAGE, once.** `policyPermissions`
36
+ (in `policy.ts`) and `admitsAnonymous` (in `policy-anonymous.ts`) both walk the combinators
37
+ `policy.ts` declares, so an answer computed in a surface package would drift from them the first
38
+ time one changes — and could not be shared:
39
+ `@ultimat3/action` and `@ultimat3/query` are the same tier and may not import each other, so a
40
+ copy in either is a second answer for the other. Both shipped that copy briefly and it was
41
+ hoisted here. `admitsAnonymous` in particular is EXACT for `actor === null` rather than a
42
+ heuristic, because `can()` short-circuits on the actor check before its predicate and
43
+ `allow()`/`deny()` ignore their arguments — no predicate is consulted, so the tree alone decides.
35
44
  - **One predicate shape.** A row-level rule reads `args.row`. Never pass a row through
36
45
  `input`, and never add a per-surface args type.
37
46
  - A policy is pure and synchronous. No I/O, no `await`. Load the row first, then decide.
@@ -111,7 +120,8 @@ reappearing there is a failing test.
111
120
 
112
121
  | File | Job |
113
122
  |---|---|
114
- | `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording |
123
+ | `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording, and `policyPermissions` |
124
+ | `policy-anonymous.ts` | `admitsAnonymous` — the one question a SURFACE asks of a built tree, apart from the file that builds them |
115
125
  | `evaluate.ts` | the single entry point; builds the trace, emits the one decision event |
116
126
  | `decisions.ts` | the `DecisionSink` seam — no-op default, one call site, never PII |
117
127
  | `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
package/README.md CHANGED
@@ -84,6 +84,26 @@ every permission a tree references, `not()` clauses included. It is what a compl
84
84
  to read: `label` renders a composite as `and(post:publish, org:administer)`, which is a sentence,
85
85
  never a permission.
86
86
 
87
+ `admitsAnonymous(policy)` is the other derived question `As of 2026-08`, and it is a **walk, not a
88
+ root read**:
89
+ whether an anonymous caller can be allowed at all. `policy.kind === 'allow'` is the read it
90
+ replaces, and it answered "needs a session" for `or(allow(), can('x:y'))` — so an HTTP route 401'd
91
+ a caller the policy itself allows, while the same policy over MCP or a job let that caller in.
92
+
93
+ ```ts
94
+ import { admitsAnonymous, allow, and, can, not, or } from '@ultimat3/policy';
95
+
96
+ admitsAnonymous(or(allow('public'), can('post:publish'))); // true
97
+ admitsAnonymous(and(allow('public'), can('post:publish'))); // false
98
+ admitsAnonymous(not(can('order:internal'))); // false — X_UNAUTHENTICATED propagates
99
+ ```
100
+
101
+ It is **exact for an anonymous caller, not a heuristic**: with `actor === null`, `can()`
102
+ short-circuits on the actor check before its predicate runs and `allow()`/`deny()` ignore their
103
+ arguments, so no predicate is ever consulted and the tree alone decides. `true` never means
104
+ "unguarded" — it says only that a 401 before the handler is wrong; the surface still calls
105
+ `enforce()`. `@ultimat3/action` and `@ultimat3/query` derive `RouteMeta.auth` from it.
106
+
87
107
  ## Four surfaces, four adapters, one rule
88
108
 
89
109
  `surfaces.ts` is the proof. Each adapter evaluates and maps a denial to that surface's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "7.0.0",
3
+ "version": "8.0.0",
4
4
  "description": "The one authz rule, evaluated identically in every surface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,6 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "7.0.0"
34
+ "@ultimat3/core": "8.0.0"
35
35
  }
36
36
  }
package/src/errors.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // an action with no policy never compiles. The code and its factory stay published for a
4
4
  // declaration site that cannot express the requirement in a type — a config-driven route table,
5
5
  // a policy resolved by name — and `policyMissing()` is how such a site says it.
6
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+ import { nearestName, registerErrorCodes, UltimateError } from '@ultimat3/core';
7
7
 
8
8
  export const POLICY_ERROR_CODES = [
9
9
  'X_FORBIDDEN',
@@ -42,12 +42,27 @@ export class PolicyError extends UltimateError {
42
42
  }
43
43
  }
44
44
 
45
+ /**
46
+ * A label `x policy explain` can resolve: one declared permission, `<resource>:<verb>`.
47
+ *
48
+ * Every other `Policy` renders its label as a DESCRIPTION — `and(post:publish, org:administer)`,
49
+ * `not(post:publish)`, `allow`, `deny(read-only mode)` — and `knownPolicySubjects()` holds
50
+ * permissions, action names and route paths, none of which those match. Interpolating one produced
51
+ * `x policy explain and(post:publish, org:administer)`, reproduced in `examples/dummy` as
52
+ * `X_DECLARATION_UNKNOWN`: a fix line whose only effect is a second error.
53
+ */
54
+ const BARE_PERMISSION = /^[a-z0-9_-]+:[a-z0-9_-]+$/;
55
+
45
56
  /** `reason` comes from a decision and is always safe to log: no row data, no PII. */
46
57
  export const forbidden = (label: string, reason: string): PolicyError =>
47
58
  new PolicyError({
48
59
  code: 'X_FORBIDDEN',
49
60
  cause: `${label} denied: ${reason}`,
50
- fix: `x policy explain ${label} --json # shows which clause decided and why`,
61
+ // `x policy list --json` is what `X_DECLARATION_UNKNOWN`'s own fix falls back to, so a reader
62
+ // who follows either one lands in the same place.
63
+ fix: BARE_PERMISSION.test(label)
64
+ ? `x policy explain ${label} --json # shows which clause decided and why`
65
+ : `x policy list --json # then: x policy explain <permission> --json for the clause that decided`,
51
66
  });
52
67
 
53
68
  export const policyMissing = (subject: string): PolicyError =>
@@ -68,9 +83,25 @@ export const roleRedefined = (role: string, first: string, second: string): Poli
68
83
  fix: `x policy list --json # then keep ONE definition of "${role}": rename the second, or fold its grants into the first`,
69
84
  });
70
85
 
71
- export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError =>
72
- new PolicyError({
86
+ /**
87
+ * The nearest declared permission comes FIRST, and the declare-it path second.
88
+ *
89
+ * The other order is what shipped: `add 'billing:wirte' to definePermissions([...])` reads as an
90
+ * instruction to declare the typo, and a permission nothing grants and nothing enforces is a
91
+ * silent hole — `assertPermission` then passes, every `can('billing:wirte')` denies, and the
92
+ * failure moves from this throw to a page that renders empty. Only the generated `policy.test.ts`
93
+ * caught it. A typo is by far the likelier of the two readings, so it leads.
94
+ */
95
+ export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError => {
96
+ const nearest = nearestName(permission, known);
97
+ return new PolicyError({
73
98
  code: 'X_PERMISSION_UNKNOWN',
99
+ // The COUNT, never the set: an app with 200 permissions would bury the fix line under names
100
+ // nobody asked for, and `x policy list --json` is one command away.
74
101
  cause: `"${permission}" is not in the permission set (${known.length} known)`,
75
- fix: `add '${permission}' to definePermissions([...]) — or fix the typo`,
102
+ fix:
103
+ nearest === undefined
104
+ ? `add '${permission}' to definePermissions([...]) if it is genuinely new — otherwise x policy list --json shows the ${known.length} already declared`
105
+ : `use '${nearest}', the nearest declared permission — or add '${permission}' to definePermissions([...]) if it is genuinely new`,
76
106
  });
107
+ };
package/src/index.ts CHANGED
@@ -54,17 +54,8 @@ export type {
54
54
  Recorder,
55
55
  TraceEntry,
56
56
  } from './policy';
57
- export {
58
- ALLOWED,
59
- allow,
60
- and,
61
- can,
62
- denied,
63
- deny,
64
- not,
65
- or,
66
- policyPermissions,
67
- } from './policy';
57
+ export { ALLOWED, allow, and, can, denied, deny, not, or, policyPermissions } from './policy';
58
+ export { admitsAnonymous } from './policy-anonymous';
68
59
  export type { Actor, RoleDef, RoleMap } from './roles';
69
60
  export {
70
61
  clearRoles,
@@ -0,0 +1,90 @@
1
+ // The one derived question a SURFACE asks of a policy tree: can an anonymous caller be allowed at
2
+ // all. Apart from `policy.ts` so each file stays one responsibility — that one CONSTRUCTS trees,
3
+ // this one projects a single boolean out of a built one — and in this package rather than in the
4
+ // surfaces, because the answer is a property of the combinators and a copy per surface drifts from
5
+ // them the first time one changes.
6
+ import type { Policy, PolicyKind } from './policy';
7
+
8
+ /**
9
+ * Whether an ANONYMOUS caller can be allowed by this policy — what a surface derives its
10
+ * "does this route need a session" flag from. `policy.kind === 'allow'` is the read this
11
+ * replaces, and it looked at the ROOT combinator only: `or(allow(), can('x:y'))` answered
12
+ * "needs a session", so `@ultimat3/http`'s auth stage 401'd a caller the policy itself ALLOWS,
13
+ * while the MCP tool and the job surface let that same caller through the same object. One
14
+ * policy, a different answer per surface, which is the thing this package exists to prevent.
15
+ *
16
+ * **EXACT for `actor === null`, not a heuristic.** With no actor `can()` short-circuits on the
17
+ * actor check before its predicate ever runs, and `allow()`/`deny()` ignore their arguments
18
+ * entirely — so no predicate is consulted and the tree alone decides. It lives in THIS package
19
+ * for that reason: the answer is a property of the combinators `policy.ts` declares, and a copy in
20
+ * a surface package would drift from them the first time one changes.
21
+ *
22
+ * `true` never means "unguarded". It says only that the 401 is not the stage's to raise; the
23
+ * surface still evaluates the policy through `enforce()`.
24
+ */
25
+ export const admitsAnonymous = <I = unknown, R = unknown>(policy: Policy<I, R>): boolean =>
26
+ anonymousOutcome(policy) === 'allowed';
27
+
28
+ /**
29
+ * Three-valued, because `not()` needs the distinction: it PROPAGATES `X_UNAUTHENTICATED` rather
30
+ * than inverting it, so `not(can('order:internal'))` denies an anonymous caller while
31
+ * `not(deny(…))` allows one.
32
+ */
33
+ type AnonymousOutcome = 'allowed' | 'denied' | 'unauthenticated';
34
+
35
+ /** The two fields the walk reads, so every `Policy<I, R>` satisfies it whatever its arguments. */
36
+ interface PolicyTree {
37
+ readonly kind: PolicyKind;
38
+ readonly children: readonly PolicyTree[];
39
+ }
40
+
41
+ /**
42
+ * Exhaustive over `PolicyKind` by construction: a seventh kind is a missing-key TYPE error here,
43
+ * never a silent "needs a session" on every route guarded by it. The import of `PolicyKind` is
44
+ * what makes that hold across the split — a local union would be the copy this file exists not to
45
+ * be.
46
+ */
47
+ const OUTCOME_BY_KIND = Object.freeze<Record<PolicyKind, (policy: PolicyTree) => AnonymousOutcome>>(
48
+ {
49
+ allow: () => 'allowed',
50
+ deny: () => 'denied',
51
+ // `can()` denies with X_UNAUTHENTICATED before its predicate, for every permission.
52
+ permission: () => 'unauthenticated',
53
+ // First non-allowance wins, left to right — `and`'s own short-circuit.
54
+ and: (policy) => {
55
+ for (const child of policy.children) {
56
+ const outcome = anonymousOutcome(child);
57
+ if (outcome !== 'allowed') return outcome;
58
+ }
59
+ return 'allowed';
60
+ },
61
+ // First allowance wins; if none allow, `or` reports the LAST denial.
62
+ or: (policy) => {
63
+ let last: AnonymousOutcome = 'denied';
64
+ for (const child of policy.children) {
65
+ last = anonymousOutcome(child);
66
+ if (last === 'allowed') return 'allowed';
67
+ }
68
+ return last;
69
+ },
70
+ not: (policy) => {
71
+ const inner = policy.children[0];
72
+ // A `not` with no child cannot come from `not()`; refusing anonymous is the safe reading.
73
+ if (inner === undefined) return 'unauthenticated';
74
+ const outcome = anonymousOutcome(inner);
75
+ if (outcome === 'allowed') return 'denied';
76
+ return outcome === 'unauthenticated' ? 'unauthenticated' : 'allowed';
77
+ },
78
+ },
79
+ );
80
+
81
+ function anonymousOutcome(policy: PolicyTree): AnonymousOutcome {
82
+ // `Object.hasOwn`, never the read alone: `kind` is a field of a plain object, so a foreign
83
+ // `Policy` can carry any string — and `OUTCOME_BY_KIND['valueOf']` is a function off the
84
+ // prototype chain that THROWS when called with no receiver, which would kill a route
85
+ // projection at mount. An unrecognised kind requires authentication.
86
+ const decide = Object.hasOwn(OUTCOME_BY_KIND, policy.kind)
87
+ ? OUTCOME_BY_KIND[policy.kind]
88
+ : undefined;
89
+ return decide === undefined ? 'unauthenticated' : decide(policy);
90
+ }