@ultimat3/policy 25.0.0 → 25.1.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
@@ -111,6 +111,10 @@ two differ, and it is why a surface that decides on input alone needs no edit.
111
111
  which this file and `policy-anonymous.ts` both document as ALLOWED, and it states the wrong rule —
112
112
  `not` inverts a decision about grants, and whether one was made without an actor is `or`'s to
113
113
  report.
114
+ - **A wildcard grant reaches its PREFIX, never its first segment**: `grantMatches` and the grant
115
+ index both ask core's `grantCovers` (`auth` cuts API keys with it too — same tier, so the one
116
+ copy lives in core). `billing:invoice:*` never grants `billing:refund:issue`
117
+ (`grant-wildcard.test.ts`). `resourceOf` is for grouping (`byResource`), never for coverage.
114
118
  - **`defineRoles()` merges** and refuses a role two modules define differently
115
119
  (`X_ROLE_REDEFINED`, naming both declaration sites). A re-declaration of an *identical*
116
120
  role is a no-op, which is what keeps `defineRoles({ ...roleDefinitions(), … })` legal.
package/README.md CHANGED
@@ -151,7 +151,9 @@ dispatch failed **closed with a `SurfaceDenial` no caller could read**.
151
151
  `PermissionRegistry` (which `x g policy` generates) makes a typo a compile error, and
152
152
  `can()` throws `X_PERMISSION_UNKNOWN` at declaration time either way. Roles are sugar:
153
153
  `defineRoles({ owner: { grants: ['post:delete'], inherits: ['editor'] } })` expands
154
- depth-first to a flat set, cycles included. `post:*` and `*` are supported.
154
+ depth-first to a flat set, cycles included. `post:*` and `*` are supported. A wildcard reaches
155
+ its PREFIX and nothing beside it: `billing:invoice:*` grants `billing:invoice:read`, never
156
+ `billing:refund:issue` (`@ultimat3/core`'s `grantCovers`, the one reading `auth` shares).
155
157
 
156
158
  `defineRoles()` **merges** into the app's one role map. A second call in a new feature folder
157
159
  adds roles; it never deletes the first module's. A role two modules define *differently* is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "25.0.0",
3
+ "version": "25.1.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": "25.0.0"
35
+ "@ultimat3/core": "25.1.0"
36
36
  }
37
37
  }
@@ -2,18 +2,23 @@
2
2
  // stays put. Why it earns a file: a live query evaluates policy PER SUBSCRIBER on every change
3
3
  // event, and every `can()` clause asks this question — so an unmemoised expansion turned one
4
4
  // write on a channel with 10k subscribers into 10k role-graph walks in a single tick.
5
- import { type Permission, resourceOf } from './permissions';
5
+ import { grantCovers } from '@ultimat3/core';
6
+ import type { Permission } from './permissions';
6
7
  import { type Actor, expandRoles, type RoleMap, roleDefinitions, roleMapGeneration } from './roles';
7
8
 
8
9
  /**
9
10
  * The three shapes `grantMatches()` can take, pre-split so a lookup is a `Set.has` instead of a
10
- * scan: an exact grant, a `<resource>:*` wildcard, and the bare `*`. Equivalent to
11
+ * scan: an exact grant, a `<prefix>:*` wildcard, and the bare `*`. Equivalent to
11
12
  * `actorPermissions(actor).some((grant) => grantMatches(grant, permission))`, per grant kind.
12
13
  */
13
14
  interface GrantIndex {
14
15
  readonly exact: ReadonlySet<string>;
15
- /** Resources covered by a `<resource>:*` grant. */
16
- readonly wildcards: ReadonlySet<string>;
16
+ /**
17
+ * The `<prefix>:*` grants themselves, asked through core's `grantCovers` — a list, not a set of
18
+ * resources: keyed by the first segment, `billing:invoice:*` answered for `billing:refund:issue`.
19
+ * An actor holds a handful, so the scan is a few `startsWith`.
20
+ */
21
+ readonly wildcards: readonly string[];
17
22
  readonly all: boolean;
18
23
  /** Deduped and sorted, built once so `actorPermissions()` never sorts per clause. */
19
24
  readonly sorted: readonly string[];
@@ -39,11 +44,11 @@ const buildIndex = (actor: Actor, map: RoleMap): GrantIndex => {
39
44
  // read that throws where it should have DENIED is the `testActor` defect one layer down.
40
45
  const exact = new Set<string>(actor.permissions ?? []);
41
46
  for (const grant of expandRoles(actor.roles ?? [], map)) exact.add(grant);
42
- const wildcards = new Set<string>();
47
+ const wildcards: string[] = [];
43
48
  let all = false;
44
49
  for (const grant of exact) {
45
50
  if (grant === '*') all = true;
46
- else if (grant.endsWith(':*')) wildcards.add(resourceOf(grant));
51
+ else if (grant.endsWith(':*')) wildcards.push(grant);
47
52
  }
48
53
  return { exact, wildcards, all, sorted: [...exact].sort() };
49
54
  };
@@ -78,5 +83,5 @@ export const actorHas = (
78
83
  if (actor === null) return false;
79
84
  const index = indexFor(actor, map);
80
85
  if (index.all || index.exact.has(permission)) return true;
81
- return index.wildcards.has(resourceOf(permission));
86
+ return index.wildcards.some((grant) => grantCovers(grant, permission));
82
87
  };
package/src/roles.ts CHANGED
@@ -3,9 +3,9 @@
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 { grantCovers } from '@ultimat3/core';
6
7
  import { callerSite, UNKNOWN_SITE } from './declaration-site';
7
8
  import { roleRedefined } from './errors';
8
- import { resourceOf } from './permissions';
9
9
 
10
10
  /**
11
11
  * An **alias** of core's declaration, and nothing more (`As of 2026-08-19`) — so the actor
@@ -156,12 +156,12 @@ export const expandRoles = (
156
156
  return [...out].sort();
157
157
  };
158
158
 
159
- /** `post:*` matches every verb on `post`; `*` matches everything. */
160
- export const grantMatches = (grant: string, wanted: string): boolean => {
161
- if (grant === '*' || grant === wanted) return true;
162
- if (grant.endsWith(':*')) return resourceOf(grant) === resourceOf(wanted);
163
- return false;
164
- };
159
+ /**
160
+ * `post:*` matches every name under `post:`; `billing:invoice:*` only those under
161
+ * `billing:invoice:`; `*` matches everything. Core's `grantCovers` is the one reading — `auth`
162
+ * cuts an API key to its owner with it too, and the two may not share a copy at tier 2.
163
+ */
164
+ export const grantMatches = (grant: string, wanted: string): boolean => grantCovers(grant, wanted);
165
165
 
166
166
  /** For the `/_x` dashboard: which roles would satisfy a permission. */
167
167
  export const rolesGranting = (permission: string, map: RoleMap = roleMap): readonly string[] =>