@ultimat3/policy 23.0.0 → 24.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
@@ -62,6 +62,13 @@ two differ, and it is why a surface that decides on input alone needs no edit.
62
62
  say it in a type (a config-driven route table, a policy resolved by name). Never default
63
63
  to allow.
64
64
  - `can()` validates its permission at declaration time, not at request time.
65
+ - **Only `true` or a real decision allows** (`isDecision`, `policy.ts`). A predicate or a
66
+ `definePolicy` `check` returning anything else — `undefined`, `1`, `{ allowed: 'yes' }` — DENIES;
67
+ it used to reach `decision.allowed` as a bare `TypeError`, and a truthy `allowed` read as ALLOWED.
68
+ - **A denial keeps its code everywhere.** `assertAllowed` throws `codeOf(decision)` (`denialError`:
69
+ `X_FORBIDDEN` through `forbidden()`, any other code as a `PolicyDenialError`) and
70
+ `HttpDenial.status` follows it — 401 for `X_UNAUTHENTICATED`, 403 otherwise. `X_UNAUTHENTICATED`
71
+ is `@ultimat3/auth`'s (`POLICY_BORROWED_ERROR_CODES`): named here, titled there.
65
72
  - **`definePermissions()` merges, and an EMPTY registry is permissive.** It only ever `add`s, so a
66
73
  package that declares its own names at import time (`@ultimat3/admin`'s policy bridge) cannot
67
74
  clobber an app's set. What it does change is the mode: `isKnownPermission` answers `true` to
@@ -73,6 +80,14 @@ two differ, and it is why a surface that decides on input alone needs no edit.
73
80
  process — so a clear in one test file is permanent for every file after it, whose own `import` is a
74
81
  cache hit that declares nothing. `restoreRoles` takes the declaration sites too: `defineRoles()` derives
75
82
  them from the CALLER's stack, so restoring through it would make `X_ROLE_REDEFINED` name the harness.
83
+ - **Every permission records WHERE it was declared** (`permissionDeclarationSites()`, `As of 2026-10-02`):
84
+ one stack frame per distinct `definePermissions()` call, through `declaration-site.ts`, the helper
85
+ `defineRoles()` uses. All sites, not the first — the question asked of it is provenance:
86
+ `@ultimat3/cli`'s `policy` step flags an app rule whose permission only a package declared
87
+ (`defineAdmin()`'s `<entity>:read`), `X_PERMISSION_BORROWED`. `restorePermissions(names, sites)`
88
+ takes them back for the same reason `restoreRoles` does, and the second argument is REQUIRED — a
89
+ name restored with none has no provable origin and is not judged, so a default would have let a
90
+ forgotten argument switch the check off.
76
91
  - **`and()` and `or()` REFUSE an empty clause list** (`X_POLICY_CLAUSE_EMPTY`, `As of 2026-08-25`),
77
92
  at the call that builds them. An empty `and()` found nothing to deny and answered ALLOWED, so
78
93
  `and(...requiredCaps.map(can))` over a list that filtered to nothing — a config-driven or
@@ -160,6 +175,7 @@ reappearing there is a failing test.
160
175
  | `decisions.ts` | the `DecisionSink` seam — no-op default, one call site, never PII |
161
176
  | `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
162
177
  | `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
178
+ | `declaration-site.ts` | the caller's stack frame a role or permission declaration records |
163
179
  | `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
164
180
  | `test-kit.ts` | `policyMatrix()` for generated policy tests, and `testActor()` — whose `.actor` is typed `Actor`, never `null`, so it goes straight into `createContext({ actor })` |
165
181
 
package/README.md CHANGED
@@ -121,13 +121,24 @@ error shape; allowed returns `undefined`.
121
121
 
122
122
  | Adapter | Denial shape |
123
123
  |---|---|
124
- | `enforceHttp` | `403` + RFC-9457 fields |
124
+ | `enforceHttp` | RFC-9457 fields, and the status its code means: `401` for `X_UNAUTHENTICATED` (no actor), `403` for every other denial. The title is the code's registered one |
125
125
  | `enforceLive` | close frame `4403` |
126
126
  | `enforceJob` | `failed`, `retryable: false` — the answer will not change on retry |
127
127
  | `enforceMcp` | `isError: true` with readable text |
128
128
 
129
129
  Adding a fifth surface means adding an adapter here **and nothing else**.
130
130
 
131
+ `assertAllowed(policy, args)` is the throwing form, and it throws the code the DECISION carries:
132
+ `X_UNAUTHENTICATED` for no actor, `X_FORBIDDEN` for an actor who may not, and the code a predicate
133
+ chose with `denied(reason, code)` otherwise (`denialError`, a `PolicyDenialError` for a code this
134
+ package does not own).
135
+
136
+ A predicate — `can(p, fn)` or `definePolicy`'s `check` — allows only by returning `true` or an
137
+ `{ allowed: true }` decision. `false` denies, and so does anything that is neither a boolean nor a
138
+ decision (`undefined` from a forgotten `return`, `1`, `{ allowed: 'yes' }`): with `check` under
139
+ the declared `deny` key, with `can()` as `<permission> predicate returned neither a boolean nor a
140
+ decision`. Never a bare `TypeError`, and never an allow.
141
+
131
142
  `enforce(surface, policy, args)` dispatches over that table with `Object.hasOwn`, and a surface
132
143
  with no adapter is `X_POLICY_SURFACE_UNKNOWN`. Not a formality: the table is an object literal, so
133
144
  it inherits `Object.prototype` — `enforce('valueOf' as Surface, …)` used to call
@@ -147,6 +158,11 @@ adds roles; it never deletes the first module's. A role two modules define *diff
147
158
  `X_ROLE_REDEFINED`, naming both declaration sites — and an identical re-declaration is a no-op,
148
159
  so `defineRoles({ ...roleDefinitions(), … })` stays legal.
149
160
 
161
+ `permissionDeclarationSites()` answers where each permission was declared — one stack frame per
162
+ distinct `definePermissions()` call. `x verify`'s `policy` step reads it to refuse an app rule
163
+ whose permission only a package declared (`X_PERMISSION_BORROWED`: `defineAdmin()` declares
164
+ `<entity>:read|write|delete`, and a process that never mounts the admin does not).
165
+
150
166
  The flattened grant set is memoised per actor and invalidated the moment the role map changes.
151
167
  It is keyed on the actor object, so it lives exactly as long as the request does: `@ultimat3/auth`
152
168
  re-reads the user row every request, and a revoked role takes effect on the next one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "The one authz rule, evaluated identically in every surface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,6 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "23.0.0"
35
+ "@ultimat3/core": "24.0.0"
36
36
  }
37
37
  }
@@ -0,0 +1,38 @@
1
+ // Single responsibility: the stack frame of whoever called a declaration — `defineRoles()` or
2
+ // `definePermissions()` — which is the only place the declaring module's name exists by then.
3
+
4
+ export const UNKNOWN_SITE = 'unknown site';
5
+
6
+ /**
7
+ * This package's own source directory — `src/` in the workspace, `dist/` or `node_modules/…` once
8
+ * installed. A frame is internal by the FILE it runs in (one of the three below), never by a name it spells: matching
9
+ * `/definePermissions/` against the whole line skipped an app helper named
10
+ * `definePermissionsForPosts`, and the site became whichever frame came next — a layout, or a
11
+ * module outside the app root that the `policy` step then read as a package's declaration.
12
+ */
13
+ const OWN_DIR = `${import.meta.dir}/`;
14
+
15
+ /** The three implementation files a declaration passes through — a test beside them is a caller. */
16
+ const INTERNAL_FILES = ['declaration-site', 'permissions', 'roles'].map(
17
+ (name) => `${OWN_DIR}${name}.`,
18
+ );
19
+
20
+ const isInternal = (frame: string): boolean =>
21
+ INTERNAL_FILES.some((prefix) => {
22
+ const at = frame.indexOf(prefix);
23
+ // `permissions.test.ts` shares the prefix; only `<name>.<ext>:<line>` is the file itself.
24
+ return at !== -1 && /^[cm]?[jt]sx?:\d/.test(frame.slice(at + prefix.length));
25
+ });
26
+
27
+ /**
28
+ * The first frame outside this package. Not a throw: an `Error` is built only to read its stack,
29
+ * because `X_ROLE_REDEFINED` is unactionable without both sides of a collision and the policy step
30
+ * cannot say which module declared a permission without it.
31
+ */
32
+ export const callerSite = (): string => {
33
+ const stack = new Error().stack;
34
+ if (stack === undefined) return UNKNOWN_SITE;
35
+ const frames = stack.split('\n').slice(1);
36
+ const chosen = frames.find((line) => !isInternal(line)) ?? frames[0] ?? '';
37
+ return chosen.trim() === '' ? UNKNOWN_SITE : chosen.trim();
38
+ };
package/src/define.ts CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type { KnownPermission } from './permissions';
17
17
  import type { Policy, PolicyArgs, PolicyDecision } from './policy';
18
- import { can, denied } from './policy';
18
+ import { can, denied, isDecision } from './policy';
19
19
 
20
20
  export interface DefinePolicyInput<I, R = unknown> {
21
21
  /**
@@ -25,7 +25,8 @@ export interface DefinePolicyInput<I, R = unknown> {
25
25
  readonly deny: string;
26
26
  /**
27
27
  * Pure decision over the actor, the already-loaded input and the already-loaded row.
28
- * Returning `false` denies with `deny`; return a `PolicyDecision` for a more specific reason.
28
+ * Returning `false` — or anything that is neither `true` nor a `PolicyDecision` — denies with
29
+ * `deny`; return a `PolicyDecision` for a more specific reason.
29
30
  *
30
31
  * Must not perform I/O — see the file header. A rule that needs a row reads `args.row`,
31
32
  * which the surface loaded and passed in; `row` is `null` when the rule decides on input
@@ -63,9 +64,10 @@ export const definePolicy = <I = unknown, R = unknown>(
63
64
  const { deny, check } = input;
64
65
  if (check === undefined) return can<I, R>(permission);
65
66
  return can<I, R>(permission, (args) => {
66
- const outcome = check(args);
67
+ // `unknown`, whatever the type says: only `true` allows and only a real decision speaks for
68
+ // itself. `undefined` from a forgotten `return` — and anything else — denies with `deny`.
69
+ const outcome: unknown = check(args);
67
70
  if (outcome === true) return true;
68
- if (outcome === false) return denied(deny);
69
- return outcome;
71
+ return isDecision(outcome) ? outcome : denied(deny);
70
72
  });
71
73
  };
package/src/errors.ts CHANGED
@@ -111,6 +111,47 @@ export const forbidden = (label: string, reason: string): PolicyError =>
111
111
  callerFix: deniedCallerFix(BARE_PERMISSION.test(label) ? label : undefined),
112
112
  });
113
113
 
114
+ /**
115
+ * The codes a DENIAL may carry that this package does not own. `X_UNAUTHENTICATED` is
116
+ * `@ultimat3/auth`'s — a sibling tier, so it is named here and titled there — and it is the one
117
+ * `can()` itself decides with, for a null actor.
118
+ */
119
+ export const POLICY_BORROWED_ERROR_CODES = ['X_UNAUTHENTICATED'] as const;
120
+
121
+ /**
122
+ * A denial thrown under the code its DECISION carries, when that code is not `X_FORBIDDEN`: the
123
+ * borrowed `X_UNAUTHENTICATED`, or one an app's own predicate chose with `denied(reason, code)`.
124
+ * Not a `PolicyError` — that class is closed over the codes this package owns.
125
+ */
126
+ export class PolicyDenialError extends UltimateError {
127
+ override readonly name = 'PolicyDenialError';
128
+ }
129
+
130
+ /**
131
+ * The error a denial is thrown as: `X_FORBIDDEN` through `forbidden()`, anything else under its
132
+ * own code. `assertAllowed` threw `X_FORBIDDEN` for every denial, so an anonymous caller read
133
+ * "denied" where every adapter reports `X_UNAUTHENTICATED` — the code a 401 and a sign-in
134
+ * redirect key on.
135
+ */
136
+ export const denialError = (label: string, reason: string, code: string): UltimateError => {
137
+ if (code === 'X_FORBIDDEN') return forbidden(label, reason);
138
+ const subject = BARE_PERMISSION.test(label)
139
+ ? `x policy explain ${label} --json`
140
+ : 'x policy list --json';
141
+ return new PolicyDenialError({
142
+ code,
143
+ cause: `${label} denied: ${reason}`,
144
+ fix:
145
+ code === 'X_UNAUTHENTICATED'
146
+ ? `${subject} # the rule needs an actor: evaluate it with the request's actor, after the caller has signed in`
147
+ : `${subject} # shows which clause decided and why`,
148
+ callerFix:
149
+ code === 'X_UNAUTHENTICATED'
150
+ ? 'sign in, or send a session cookie or an Authorization header, then call again'
151
+ : deniedCallerFix(BARE_PERMISSION.test(label) ? label : undefined),
152
+ });
153
+ };
154
+
114
155
  export const policyMissing = (subject: string): PolicyError =>
115
156
  new PolicyError({
116
157
  code: 'X_POLICY_MISSING',
package/src/index.ts CHANGED
@@ -11,10 +11,13 @@ export type { DefinePolicyInput } from './define';
11
11
  export { definePolicy } from './define';
12
12
  export type { PolicyErrorCode } from './errors';
13
13
  export {
14
+ denialError,
14
15
  emptyClauseList,
15
16
  forbidden,
17
+ POLICY_BORROWED_ERROR_CODES,
16
18
  POLICY_ERROR_CODES,
17
19
  POLICY_ERROR_TITLES,
20
+ PolicyDenialError,
18
21
  PolicyError,
19
22
  permissionUnknown,
20
23
  policyMissing,
@@ -42,6 +45,7 @@ export {
42
45
  definePermissions,
43
46
  isKnownPermission,
44
47
  knownPermissions,
48
+ permissionDeclarationSites,
45
49
  resourceOf,
46
50
  restorePermissions,
47
51
  verbOf,
@@ -72,6 +76,7 @@ export {
72
76
  rolesGranting,
73
77
  } from './roles';
74
78
  export type {
79
+ DenialStatus,
75
80
  HttpDenial,
76
81
  JobDenial,
77
82
  LiveDenial,
@@ -1,6 +1,7 @@
1
1
  // Permissions are `resource:verb` strings. Two layers of protection: the template
2
2
  // literal type rejects a malformed string at compile time, and an app that augments
3
3
  // `PermissionRegistry` (which `x g policy` does) turns a typo into a type error.
4
+ import { callerSite } from './declaration-site';
4
5
  import { permissionUnknown } from './errors';
5
6
 
6
7
  export type Permission = `${string}:${string}`;
@@ -36,9 +37,27 @@ export interface PermissionSet<P extends Permission> {
36
37
  }
37
38
 
38
39
  const declared = new Set<string>();
40
+ /** Every distinct module that declared each name — a `Map`, because a name is caller data. */
41
+ const sites = new Map<string, Set<string>>();
39
42
 
40
43
  export const knownPermissions = (): readonly string[] => [...declared].sort();
41
44
 
45
+ /**
46
+ * Where each permission was declared: one stack frame per distinct declaring call, as
47
+ * `roleDeclarationSites()` answers for roles. ALL of them rather than the first, because the
48
+ * question asked of it is provenance — the `policy` step flags an app rule that holds only because
49
+ * a package it imports (`@ultimat3/admin`'s `defineAdmin()`) declared the name, and an app that
50
+ * declares the same name itself is then not leaning on anything.
51
+ */
52
+ export const permissionDeclarationSites = (): Readonly<Record<string, readonly string[]>> =>
53
+ Object.fromEntries([...sites].map(([name, at]) => [name, [...at]]));
54
+
55
+ const recordSite = (permission: string, site: string): void => {
56
+ const at = sites.get(permission);
57
+ if (at === undefined) sites.set(permission, new Set([site]));
58
+ else at.add(site);
59
+ };
60
+
42
61
  /**
43
62
  * Runtime membership check. It stays silent until an app has declared its set,
44
63
  * because there is nothing to check against before then — `x verify` is what fails
@@ -59,7 +78,11 @@ export const verbOf = (permission: string): string => permission.split(':')[1] ?
59
78
  export const definePermissions = <const P extends readonly Permission[]>(
60
79
  list: P,
61
80
  ): PermissionSet<P[number]> => {
62
- for (const permission of list) declared.add(permission);
81
+ const site = callerSite();
82
+ for (const permission of list) {
83
+ declared.add(permission);
84
+ recordSite(permission, site);
85
+ }
63
86
  const all = [...list] as P[number][];
64
87
  return {
65
88
  all,
@@ -74,7 +97,10 @@ export const definePermissions = <const P extends readonly Permission[]>(
74
97
  };
75
98
 
76
99
  /** Test seam; production never forgets a permission it declared. */
77
- export const clearPermissions = (): void => declared.clear();
100
+ export const clearPermissions = (): void => {
101
+ declared.clear();
102
+ sites.clear();
103
+ };
78
104
 
79
105
  /**
80
106
  * `clearPermissions()`'s other half, taking exactly what `knownPermissions()` answers.
@@ -85,8 +111,22 @@ export const clearPermissions = (): void => declared.clear();
85
111
  * registers nothing, and `can('admin:read')` throws X_PERMISSION_UNKNOWN for a permission the
86
112
  * process really did declare. Only putting the captured set back repairs it; re-importing cannot.
87
113
  * Replaces rather than merges — a capture is the whole truth about the process, not an addition.
114
+ *
115
+ * `declaredAt` is what `permissionDeclarationSites()` answered, and the same rule `restoreRoles`
116
+ * states: re-declaring through `definePermissions` would name THIS frame as every permission's
117
+ * origin. REQUIRED, never defaulted: an omitted argument restored every name with no site, and a
118
+ * name with no site is one the policy step cannot judge — so a forgotten argument silently turned
119
+ * `X_PERMISSION_BORROWED` off for the rest of the process. `{}` says "no provenance" on purpose.
88
120
  */
89
- export const restorePermissions = (permissions: readonly string[]): void => {
121
+ export const restorePermissions = (
122
+ permissions: readonly string[],
123
+ declaredAt: Readonly<Record<string, readonly string[]>>,
124
+ ): void => {
90
125
  declared.clear();
91
- for (const permission of permissions) declared.add(permission);
126
+ sites.clear();
127
+ for (const permission of permissions) {
128
+ declared.add(permission);
129
+ const at = Object.hasOwn(declaredAt, permission) ? declaredAt[permission] : undefined;
130
+ for (const site of at ?? []) recordSite(permission, site);
131
+ }
92
132
  };
package/src/policy.ts CHANGED
@@ -84,11 +84,33 @@ const record = (
84
84
  return decision;
85
85
  };
86
86
 
87
- const asDecision = (result: boolean | PolicyDecision, label: string): PolicyDecision => {
88
- if (typeof result === 'boolean') {
89
- return result ? ALLOWED : denied(`${label} predicate returned false`);
90
- }
91
- return result;
87
+ /**
88
+ * A value that IS a decision, by its shape and nothing looser: `allowed` exactly `true`, or
89
+ * exactly `false` with the reason and code a denial carries. `{ allowed: 'yes' }` is not one —
90
+ * read as a decision it was truthy, and every surface took it for ALLOWED.
91
+ */
92
+ export const isDecision = (value: unknown): value is PolicyDecision => {
93
+ if (typeof value !== 'object' || value === null) return false;
94
+ const { allowed, reason, code } = value as Readonly<Record<string, unknown>>;
95
+ return (
96
+ allowed === true ||
97
+ (allowed === false && typeof reason === 'string' && typeof code === 'string')
98
+ );
99
+ };
100
+
101
+ /**
102
+ * `true` allows. Everything that is not `true` and not a decision DENIES: a predicate is typed
103
+ * `boolean | PolicyDecision`, but a JS caller, an `as` and a forgotten `return` all hand back
104
+ * something else, and passing it through made `decision.allowed` a bare `TypeError` out of `run`.
105
+ */
106
+ const asDecision = (result: unknown, label: string): PolicyDecision => {
107
+ if (result === true) return ALLOWED;
108
+ if (isDecision(result)) return result;
109
+ return denied(
110
+ result === false
111
+ ? `${label} predicate returned false`
112
+ : `${label} predicate returned neither a boolean nor a decision`,
113
+ );
92
114
  };
93
115
 
94
116
  /**
package/src/roles.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // evaluator never has to reason about hierarchy — and a cycle is caught here, once.
4
4
  // The per-actor flattening and its memo live in `grant-index.ts`; this file owns the map.
5
5
  import type { Actor as CoreActor } from '@ultimat3/core';
6
+ import { callerSite, UNKNOWN_SITE } from './declaration-site';
6
7
  import { roleRedefined } from './errors';
7
8
  import { resourceOf } from './permissions';
8
9
 
@@ -42,19 +43,6 @@ let generation = 0;
42
43
  */
43
44
  export const roleMapGeneration = (): number => generation;
44
45
 
45
- /** Frames inside this file — never the answer to "who declared this role?". */
46
- const INTERNAL_FRAME = /declarationSite|defineRoles/;
47
-
48
- const declarationSite = (): string => {
49
- // Not a throw: the stack is the only place the declaring module's name exists at this point,
50
- // and `X_ROLE_REDEFINED` is unactionable without both sides of the collision.
51
- const stack = new Error().stack;
52
- if (stack === undefined) return 'unknown site';
53
- const frames = stack.split('\n').slice(1);
54
- const frame = frames.find((line) => !INTERNAL_FRAME.test(line)) ?? frames[0] ?? '';
55
- return frame.trim() === '' ? 'unknown site' : frame.trim();
56
- };
57
-
58
46
  /**
59
47
  * A role name is caller data, and an app's role map is a plain object literal — so `map['constructor']`
60
48
  * answers the `Object` FUNCTION rather than `undefined`, the `=== undefined` guard at the call site
@@ -100,13 +88,13 @@ const sameDefinition = (left: RoleDef, right: RoleDef): boolean =>
100
88
  * and every request 403'd on whichever bundler ordering CI happened to pick.
101
89
  */
102
90
  export const defineRoles = <const M extends RoleMap>(map: M): M => {
103
- const site = declarationSite();
91
+ const site = callerSite();
104
92
  const merged: Record<string, RoleDef> = { ...roleMap };
105
93
  const nextSites: Record<string, string> = { ...sites };
106
94
  for (const [role, definition] of Object.entries(map)) {
107
95
  const existing = own(merged, role);
108
96
  if (existing !== undefined && !sameDefinition(existing, definition)) {
109
- throw roleRedefined(role, own(sites, role) ?? 'unknown site', site);
97
+ throw roleRedefined(role, own(sites, role) ?? UNKNOWN_SITE, site);
110
98
  }
111
99
  put(merged, role, definition);
112
100
  put(nextSites, role, own(nextSites, role) ?? site);
package/src/surfaces.ts CHANGED
@@ -9,19 +9,23 @@
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, surfaceUnknown } from './errors';
12
+ import { describeErrorCode } from '@ultimat3/core';
13
+ import { denialError, surfaceUnknown } from './errors';
13
14
  import { codeOf, type EvaluateArgs, evaluate, type PolicyEvaluation, reasonOf } from './evaluate';
14
15
  import type { Policy } from './policy';
15
16
 
16
17
  export type Surface = 'http' | 'live' | 'job' | 'mcp';
17
18
 
19
+ /** 401 when the decision says nobody is signed in, 403 for every other denial. */
20
+ export type DenialStatus = 401 | 403;
21
+
18
22
  export interface HttpDenial {
19
23
  readonly surface: 'http';
20
- readonly status: 403;
24
+ readonly status: DenialStatus;
21
25
  /** RFC-9457 fields `@ultimat3/http` renders verbatim. */
22
26
  readonly problem: {
23
27
  readonly title: string;
24
- readonly status: 403;
28
+ readonly status: DenialStatus;
25
29
  readonly detail: string;
26
30
  readonly code: string;
27
31
  };
@@ -54,20 +58,32 @@ const reason = (evaluation: PolicyEvaluation): string => reasonOf(evaluation.dec
54
58
 
55
59
  const code = (evaluation: PolicyEvaluation): string => codeOf(evaluation.decision) ?? 'X_FORBIDDEN';
56
60
 
61
+ /**
62
+ * The status a denial's code means. Only `X_UNAUTHENTICATED` is "come back signed in"; every other
63
+ * code — `X_FORBIDDEN`, or one an app's predicate chose — is a refusal of this actor. It was a
64
+ * pinned `403` beside a problem whose own `code` said `X_UNAUTHENTICATED`.
65
+ */
66
+ const statusOf = (denialCode: string): DenialStatus =>
67
+ denialCode === 'X_UNAUTHENTICATED' ? 401 : 403;
68
+
57
69
  export const enforceHttp = <I, R = unknown>(
58
70
  policy: Policy<I, R>,
59
71
  args: EvaluateArgs<I, R>,
60
72
  ): HttpDenial | undefined => {
61
73
  const evaluation = evaluate(policy, args, { surface: 'http' });
62
74
  if (evaluation.allowed) return undefined;
75
+ const denialCode = code(evaluation);
76
+ const status = statusOf(denialCode);
63
77
  return {
64
78
  surface: 'http',
65
- status: 403,
79
+ status,
66
80
  problem: {
67
- title: 'policy denied this actor',
68
- status: 403,
81
+ // The CODE's registered title, so the document reads as one fact: the owner of the code
82
+ // wrote it once, and `X_FORBIDDEN`'s own title is this package's.
83
+ title: describeErrorCode(denialCode).title,
84
+ status,
69
85
  detail: reason(evaluation),
70
- code: code(evaluation),
86
+ code: denialCode,
71
87
  },
72
88
  };
73
89
  };
@@ -143,12 +159,12 @@ export const enforce = <I, R = unknown>(
143
159
  return adapters[surface](policy, args);
144
160
  };
145
161
 
146
- /** For call sites that would rather throw than branch. Same decision, same reason. */
162
+ /** For call sites that would rather throw than branch. Same decision, same reason, same CODE. */
147
163
  export const assertAllowed = <I, R = unknown>(
148
164
  policy: Policy<I, R>,
149
165
  args: EvaluateArgs<I, R>,
150
166
  ): PolicyEvaluation => {
151
167
  const evaluation = evaluate(policy, args);
152
- if (!evaluation.allowed) throw forbidden(policy.label, reason(evaluation));
168
+ if (!evaluation.allowed) throw denialError(policy.label, reason(evaluation), code(evaluation));
153
169
  return evaluation;
154
170
  };