@ultimat3/policy 8.0.0 → 10.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,14 @@ 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
+ - **`enforce()` dispatches with `Object.hasOwn`, never a bare index** (`As of 2026-08-23`). The
36
+ adapter table is an object literal and so inherits `Object.prototype`: `adapters['valueOf']`
37
+ answered a FUNCTION, so `enforce('valueOf' as Surface, …)` called it with the table as receiver
38
+ and returned the table typed as a `SurfaceDenial` — truthy, so the public authz dispatcher failed
39
+ **closed with a denial carrying no code and no reason**. Same hazard, same fix and same reason as
40
+ the role map below (`Object.hasOwn`, `defineProperty`). An unknown surface is `X_POLICY_SURFACE_UNKNOWN`, whose `fix:`
41
+ lists `Object.keys(adapters)` so a fifth surface joins it by existing. Pinned in
42
+ `surfaces.test.ts`.
35
43
  - **A derived question about a policy TREE is answered in this PACKAGE, once.** `policyPermissions`
36
44
  (in `policy.ts`) and `admitsAnonymous` (in `policy-anonymous.ts`) both walk the combinators
37
45
  `policy.ts` declares, so an answer computed in a surface package would drift from them the first
package/README.md CHANGED
@@ -118,6 +118,12 @@ error shape; allowed returns `undefined`.
118
118
 
119
119
  Adding a fifth surface means adding an adapter here **and nothing else**.
120
120
 
121
+ `enforce(surface, policy, args)` dispatches over that table with `Object.hasOwn`, and a surface
122
+ with no adapter is `X_POLICY_SURFACE_UNKNOWN`. Not a formality: the table is an object literal, so
123
+ it inherits `Object.prototype` — `enforce('valueOf' as Surface, …)` used to call
124
+ `Object.prototype.valueOf` with the table as its receiver and return a truthy value, so an authz
125
+ dispatch failed **closed with a `SurfaceDenial` no caller could read**.
126
+
121
127
  ## Permissions and roles
122
128
 
123
129
  `definePermissions(['post:publish', ...])` gives a typed set; augmenting
@@ -170,7 +176,8 @@ it never turns an allowed request into a 500.
170
176
 
171
177
  ## Errors
172
178
 
173
- `X_FORBIDDEN` · `X_POLICY_MISSING` · `X_PERMISSION_UNKNOWN` · `X_ROLE_REDEFINED`
179
+ `X_FORBIDDEN` · `X_POLICY_MISSING` · `X_PERMISSION_UNKNOWN` · `X_POLICY_SURFACE_UNKNOWN` ·
180
+ `X_ROLE_REDEFINED`
174
181
 
175
182
  A missing policy is a **type** error, not a throw: `ActionDef.policy` is required, so an action
176
183
  without one does not compile. `policyMissing()` stays for a declaration site that cannot say it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "8.0.0",
3
+ "version": "10.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": "8.0.0"
34
+ "@ultimat3/core": "10.0.0"
35
35
  }
36
36
  }
package/src/errors.ts CHANGED
@@ -3,12 +3,13 @@
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 { nearestName, registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+ import { nearestName, registerErrorCodes, renderCauseValue, UltimateError } from '@ultimat3/core';
7
7
 
8
8
  export const POLICY_ERROR_CODES = [
9
9
  'X_FORBIDDEN',
10
10
  'X_POLICY_MISSING',
11
11
  'X_PERMISSION_UNKNOWN',
12
+ 'X_POLICY_SURFACE_UNKNOWN',
12
13
  'X_ROLE_REDEFINED',
13
14
  ] as const;
14
15
 
@@ -18,6 +19,7 @@ export const POLICY_ERROR_TITLES: Readonly<Record<PolicyErrorCode, string>> = {
18
19
  X_FORBIDDEN: 'policy denied this actor',
19
20
  X_POLICY_MISSING: 'an action was declared without a policy',
20
21
  X_PERMISSION_UNKNOWN: 'permission string is not in the permission set',
22
+ X_POLICY_SURFACE_UNKNOWN: 'enforce() was handed a surface no adapter answers to',
21
23
  X_ROLE_REDEFINED: 'two modules define the same role differently',
22
24
  };
23
25
 
@@ -37,7 +39,6 @@ export class PolicyError extends UltimateError {
37
39
  code: init.code,
38
40
  cause: init.cause,
39
41
  fix: init.fix,
40
- docs: `https://ultimate.dev/errors/${init.code}`,
41
42
  });
42
43
  }
43
44
  }
@@ -105,3 +106,22 @@ export const permissionUnknown = (permission: string, known: readonly string[]):
105
106
  : `use '${nearest}', the nearest declared permission — or add '${permission}' to definePermissions([...]) if it is genuinely new`,
106
107
  });
107
108
  };
109
+
110
+ /**
111
+ * `enforce()` was handed a surface with no adapter. Thrown rather than denied, because the value
112
+ * this reports on is one an index would have resolved to something: every object literal inherits
113
+ * `Object.prototype`, so `adapters['valueOf']` answers a function and `adapters['constructor']`
114
+ * answers a constructor. Both are truthy, so the dispatcher fails CLOSED and returns a
115
+ * `SurfaceDenial` no caller can read — a refusal with no code and no reason, which is worse than
116
+ * the refusal it is standing in for.
117
+ *
118
+ * `known` comes from the adapter table itself, so a fifth surface joins this fix line by existing.
119
+ * The received value goes through `renderCauseValue` and never `${}`: the parameter is typed
120
+ * `Surface`, and the call site this guard exists for is one no type reached.
121
+ */
122
+ export const surfaceUnknown = (surface: unknown, known: readonly string[]): PolicyError =>
123
+ new PolicyError({
124
+ code: 'X_POLICY_SURFACE_UNKNOWN',
125
+ cause: `enforce() was handed surface ${renderCauseValue(surface)}, which no policy adapter answers to`,
126
+ fix: `pass one of ${known.join(', ')} — e.g. enforce('${known[0] ?? 'http'}', policy, args)`,
127
+ });
package/src/surfaces.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // The shapes are declared structurally rather than imported: `@ultimat3/http` is a
10
10
  // sibling tier, and jobs/realtime/mcp are higher tiers that import this package.
11
11
 
12
- import { forbidden } from './errors';
12
+ import { forbidden, surfaceUnknown } from './errors';
13
13
  import { codeOf, type EvaluateArgs, evaluate, type PolicyEvaluation, reasonOf } from './evaluate';
14
14
  import type { Policy } from './policy';
15
15
 
@@ -121,12 +121,27 @@ const adapters: Readonly<Record<Surface, Adapter>> = {
121
121
  mcp: enforceMcp,
122
122
  };
123
123
 
124
- /** Dispatcher for code that is generic over surfaces (the action projector). */
124
+ /** The surfaces that have an adapter, derived from the table so the two cannot disagree. */
125
+ const SURFACES: readonly string[] = Object.keys(adapters);
126
+
127
+ /**
128
+ * Dispatcher for code that is generic over surfaces (the action projector).
129
+ *
130
+ * `Object.hasOwn` and not a truthiness check on `adapters[surface]`: the table is an object
131
+ * literal, so it inherits `Object.prototype`, and `enforce('valueOf' as Surface, …)` called
132
+ * `Object.prototype.valueOf` with `adapters` as its receiver — a truthy return, so the call failed
133
+ * CLOSED, and the adapter table typed as a `SurfaceDenial`, so nothing downstream could say what
134
+ * was denied. Every in-repo caller passes a literal; a config-driven table, a surface name off the
135
+ * wire or a JS host does not, and this is a public authz entry point.
136
+ */
125
137
  export const enforce = <I, R = unknown>(
126
138
  surface: Surface,
127
139
  policy: Policy<I, R>,
128
140
  args: EvaluateArgs<I, R>,
129
- ): SurfaceDenial | undefined => adapters[surface](policy, args);
141
+ ): SurfaceDenial | undefined => {
142
+ if (!Object.hasOwn(adapters, surface)) throw surfaceUnknown(surface, SURFACES);
143
+ return adapters[surface](policy, args);
144
+ };
130
145
 
131
146
  /** For call sites that would rather throw than branch. Same decision, same reason. */
132
147
  export const assertAllowed = <I, R = unknown>(