@ultimat3/policy 9.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 +8 -0
- package/README.md +8 -1
- package/package.json +2 -2
- package/src/errors.ts +22 -2
- package/src/surfaces.ts +18 -3
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` · `
|
|
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": "
|
|
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": "
|
|
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
|
-
/**
|
|
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 =>
|
|
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>(
|