@ultimat3/policy 24.0.0 → 25.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
@@ -66,7 +66,7 @@ two differ, and it is why a surface that decides on input alone needs no edit.
66
66
  `definePolicy` `check` returning anything else — `undefined`, `1`, `{ allowed: 'yes' }` — DENIES;
67
67
  it used to reach `decision.allowed` as a bare `TypeError`, and a truthy `allowed` read as ALLOWED.
68
68
  - **A denial keeps its code everywhere.** `assertAllowed` throws `codeOf(decision)` (`denialError`:
69
- `X_FORBIDDEN` through `forbidden()`, any other code as a `PolicyDenialError`) and
69
+ `X_FORBIDDEN` through `policyForbidden()`, any other code as a `PolicyDenialError`) and
70
70
  `HttpDenial.status` follows it — 401 for `X_UNAUTHENTICATED`, 403 otherwise. `X_UNAUTHENTICATED`
71
71
  is `@ultimat3/auth`'s (`POLICY_BORROWED_ERROR_CODES`): named here, titled there.
72
72
  - **`definePermissions()` merges, and an EMPTY registry is permissive.** It only ever `add`s, so a
@@ -177,7 +177,7 @@ reappearing there is a failing test.
177
177
  | `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
178
178
  | `declaration-site.ts` | the caller's stack frame a role or permission declaration records |
179
179
  | `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
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 })` |
180
+ | `test-kit.ts` | `policyMatrix()` for generated policy tests, and `testActor()` — whose `.actor` is typed `Actor`, never `null`, so it goes straight into `ctxOf({ actor })` |
181
181
 
182
182
  ## The hot path
183
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "24.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "The one authz rule, evaluated identically in every surface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,13 +25,13 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.0"
28
+ "bun": ">=1.4.2"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "24.0.0"
35
+ "@ultimat3/core": "25.0.0"
36
36
  }
37
37
  }
@@ -3,6 +3,9 @@
3
3
 
4
4
  export const UNKNOWN_SITE = 'unknown site';
5
5
 
6
+ /** `\` → `/`: Windows frames and `import.meta.dir` use `\`, and the match must hold on both. */
7
+ const toPosix = (text: string): string => text.replaceAll('\\', '/');
8
+
6
9
  /**
7
10
  * This package's own source directory — `src/` in the workspace, `dist/` or `node_modules/…` once
8
11
  * installed. A frame is internal by the FILE it runs in (one of the three below), never by a name it spells: matching
@@ -10,19 +13,32 @@ export const UNKNOWN_SITE = 'unknown site';
10
13
  * `definePermissionsForPosts`, and the site became whichever frame came next — a layout, or a
11
14
  * module outside the app root that the `policy` step then read as a package's declaration.
12
15
  */
13
- const OWN_DIR = `${import.meta.dir}/`;
16
+ const OWN_DIR = import.meta.dir;
14
17
 
15
18
  /** 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
+ const INTERNAL_NAMES = ['declaration-site', 'permissions', 'roles'] as const;
19
20
 
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
- });
21
+ /**
22
+ * The first frame of `stack` that runs outside `ownDir`'s three implementation files. Both sides
23
+ * are compared `/`-normalised — on Windows neither the frame nor `import.meta.dir` holds a `/`,
24
+ * so a raw `${dir}/` prefix matched nothing and every site was `callerSite` itself. The frame is
25
+ * returned as written: its readers (`frameFile`) take a native path.
26
+ */
27
+ export const pickCallerSite = (stack: string, ownDir: string): string => {
28
+ const dir = toPosix(ownDir).replace(/\/$/, '');
29
+ const prefixes = INTERNAL_NAMES.map((name) => `${dir}/${name}.`);
30
+ const isInternal = (frame: string): boolean => {
31
+ const posix = toPosix(frame);
32
+ return prefixes.some((prefix) => {
33
+ const at = posix.indexOf(prefix);
34
+ // `permissions.test.ts` shares the prefix; only `<name>.<ext>:<line>` is the file itself.
35
+ return at !== -1 && /^[cm]?[jt]sx?:\d/.test(posix.slice(at + prefix.length));
36
+ });
37
+ };
38
+ const frames = stack.split('\n').slice(1);
39
+ const chosen = frames.find((line) => !isInternal(line)) ?? frames[0] ?? '';
40
+ return chosen.trim() === '' ? UNKNOWN_SITE : chosen.trim();
41
+ };
26
42
 
27
43
  /**
28
44
  * The first frame outside this package. Not a throw: an `Error` is built only to read its stack,
@@ -31,8 +47,5 @@ const isInternal = (frame: string): boolean =>
31
47
  */
32
48
  export const callerSite = (): string => {
33
49
  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();
50
+ return stack === undefined ? UNKNOWN_SITE : pickCallerSite(stack, OWN_DIR);
38
51
  };
package/src/errors.ts CHANGED
@@ -98,7 +98,7 @@ export class PolicyError extends UltimateError {
98
98
  const BARE_PERMISSION = /^[a-z0-9_-]+:[a-z0-9_-]+$/;
99
99
 
100
100
  /** `reason` comes from a decision and is always safe to log: no row data, no PII. */
101
- export const forbidden = (label: string, reason: string): PolicyError =>
101
+ export const policyForbidden = (label: string, reason: string): PolicyError =>
102
102
  new PolicyError({
103
103
  code: 'X_FORBIDDEN',
104
104
  cause: `${label} denied: ${reason}`,
@@ -128,13 +128,13 @@ export class PolicyDenialError extends UltimateError {
128
128
  }
129
129
 
130
130
  /**
131
- * The error a denial is thrown as: `X_FORBIDDEN` through `forbidden()`, anything else under its
131
+ * The error a denial is thrown as: `X_FORBIDDEN` through `policyForbidden()`, anything else under its
132
132
  * own code. `assertAllowed` threw `X_FORBIDDEN` for every denial, so an anonymous caller read
133
133
  * "denied" where every adapter reports `X_UNAUTHENTICATED` — the code a 401 and a sign-in
134
134
  * redirect key on.
135
135
  */
136
136
  export const denialError = (label: string, reason: string, code: string): UltimateError => {
137
- if (code === 'X_FORBIDDEN') return forbidden(label, reason);
137
+ if (code === 'X_FORBIDDEN') return policyForbidden(label, reason);
138
138
  const subject = BARE_PERMISSION.test(label)
139
139
  ? `x policy explain ${label} --json`
140
140
  : 'x policy list --json';
package/src/index.ts CHANGED
@@ -13,13 +13,13 @@ export type { PolicyErrorCode } from './errors';
13
13
  export {
14
14
  denialError,
15
15
  emptyClauseList,
16
- forbidden,
17
16
  POLICY_BORROWED_ERROR_CODES,
18
17
  POLICY_ERROR_CODES,
19
18
  POLICY_ERROR_TITLES,
20
19
  PolicyDenialError,
21
20
  PolicyError,
22
21
  permissionUnknown,
22
+ policyForbidden,
23
23
  policyMissing,
24
24
  roleRedefined,
25
25
  } from './errors';
@@ -59,7 +59,18 @@ export type {
59
59
  Recorder,
60
60
  TraceEntry,
61
61
  } from './policy';
62
- export { ALLOWED, allow, and, can, denied, deny, not, or, policyPermissions } from './policy';
62
+ export {
63
+ ALLOWED,
64
+ allow,
65
+ and,
66
+ can,
67
+ denied,
68
+ deny,
69
+ not,
70
+ or,
71
+ policyCapability,
72
+ policyPermissions,
73
+ } from './policy';
63
74
  export { admitsAnonymous } from './policy-anonymous';
64
75
  export type { PreInputArgs } from './pre-input';
65
76
  export { decideBeforeInput, enforceBeforeInput } from './pre-input';
package/src/policy.ts CHANGED
@@ -197,6 +197,13 @@ export const policyPermissions = <I = unknown, R = unknown>(
197
197
  policy: Policy<I, R>,
198
198
  ): readonly Permission[] => policy.permissions;
199
199
 
200
+ /**
201
+ * The capability a policy names, for manifests, OpenAPI metadata and the `/_x` dashboard — a
202
+ * DISPLAY label. What a report MATCHES on is `policyPermissions`; this is the sentence beside it.
203
+ */
204
+ export const policyCapability = <I = unknown, R = unknown>(policy: Policy<I, R>): string =>
205
+ policy.label;
206
+
200
207
  const flatten = (children: readonly { readonly permissions: readonly Permission[] }[]) =>
201
208
  [...new Set(children.flatMap((child) => child.permissions))].sort();
202
209
 
package/src/test-kit.ts CHANGED
@@ -100,7 +100,7 @@ export const policyMatrix = <I, R = unknown>(
100
100
  * arrays, exactly as the actor `@ultimat3/auth` resolves per request is.
101
101
  *
102
102
  * The return type says the actor is THERE. `NamedActor` keeps `null` for the matrix's signed-out
103
- * row; a minted actor is never that, and while the type said it might be, `createContext({ actor })`
103
+ * row; a minted actor is never that, and while the type said it might be, `ctxOf({ actor })`
104
104
  * refused it — so every fixture that built a context reached for core's `userActor` instead and a
105
105
  * generated test held two idioms for one thing.
106
106
  */