@ultimat3/policy 3.0.0 → 4.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
@@ -51,12 +51,47 @@ two differ, and it is why a surface that decides on input alone needs no edit.
51
51
  everything while nothing is declared, so the first declaration anywhere in the process turns
52
52
  strict checking on for everyone. A test that uses `can()` declares the set it uses and restores
53
53
  the one it found — never leans on the empty registry.
54
+ - **`clearPermissions()` / `clearRoles()` are one-way; `restorePermissions()` / `restoreRoles()` are the
55
+ other halves.** Both declaration calls run at MODULE scope, and a module evaluates once per `bun test`
56
+ process — so a clear in one test file is permanent for every file after it, whose own `import` is a
57
+ cache hit that declares nothing. `restoreRoles` takes the declaration sites too: `defineRoles()` derives
58
+ them from the CALLER's stack, so restoring through it would make `X_ROLE_REDEFINED` name the harness.
54
59
  - **`not()` never inverts `X_UNAUTHENTICATED`.** A null actor is not a fact about this
55
60
  actor's grants; inverting it makes `not(can('order:internal'))` a public door into the
56
61
  internal one. Any denial carrying that code propagates unchanged.
57
62
  - **`defineRoles()` merges** and refuses a role two modules define differently
58
63
  (`X_ROLE_REDEFINED`, naming both declaration sites). A re-declaration of an *identical*
59
64
  role is a no-op, which is what keeps `defineRoles({ ...roleDefinitions(), … })` legal.
65
+ - **A role name is ACTOR data, so the role map is read with `Object.hasOwn` and written with
66
+ `defineProperty`** (`roles.ts`, `As of 2026-08`). An app's map is a plain object literal, so
67
+ `map['constructor']` answered the `Object` FUNCTION, the `definition === undefined` guard passed,
68
+ and `for (const grant of definition.grants)` threw a bare `TypeError` out of `evaluate` — which
69
+ `@ultimat3/http` re-raises to the error boundary, so an actor holding a role named `constructor`,
70
+ `__proto__` or `toString` turned every authz decision it made into a **500 instead of a 403**.
71
+ `map[name] = value` is the same hazard writing: for `__proto__` it runs `Object.prototype`'s
72
+ setter and files no key at all. `test-kit.ts`'s verdict map has the same two rules for the same
73
+ reason — `allowedFor('constructor')` answered a truthy function — and `policyMatrix` builds it
74
+ through `Object.fromEntries`, which defines own keys whatever they spell.
75
+ - **`Actor` is an ALIAS of core's, and `PolicyActorFields` is deleted** (`As of 2026-08-19`).
76
+ It was `CoreActor & PolicyActorFields`, a four-field interface declared here. Three fields
77
+ (`id`, `roles`, `orgId`) were already core's, so the intersection only restated them, and
78
+ `PolicyActorFields.orgId`'s `| null` never survived it — it was intersected straight back to
79
+ core's `string | undefined`. The fourth, `permissions`, was the only real content, and declaring
80
+ it here is what made it **unbuildable**: core is tier 0 and cannot import this package, so
81
+ `build()` had no field to carry and `userActor({ permissions })` compiled and discarded the
82
+ argument. Every caller worked around it with `{ ...userActor({ id }), permissions: [...] }` — a
83
+ spread over a frozen actor, producing an unfrozen one — so **32 fixtures across `query`,
84
+ `action` and a tracked app proved authz against a shape no request mints**. `@ultimat3/auth`
85
+ kept a second, hand-synced copy of the same interface for the same tier reason. `permissions`
86
+ now sits beside `roles` and `scopes` in `@ultimat3/core`.
87
+ - **`testActor()` goes through `userActor()`** (`As of 2026-08-19`), so it is frozen with frozen
88
+ arrays and a field added to `Actor` arrives here without a second list to keep in sync. It used
89
+ to hand-roll the literal — it had to, since core's builder had no `permissions` — and omitted
90
+ `kind` and `scopes` behind an `as unknown as Actor`, so `hasScope(actor, …)` threw a bare
91
+ `TypeError` on every actor it built and `actorLabel()` rendered `undefined:editor`: a generated
92
+ scope-gated policy test failed as a 500-shaped throw instead of as the denial it asserts. The one
93
+ cast left is `orgId: null` — core declares `orgId?: string`, so nothing typed can mint the
94
+ `null` that `@ultimat3/query`'s `orgless()` guards against, and that test needs a producer.
60
95
  - No `any`. Never throw a bare `Error` — use `errors.ts`.
61
96
  - **This package owns `X_FORBIDDEN`** and registers its title with core. `http`, `auth`
62
97
  and every surface adapter reuse the code and must not re-register it.
@@ -82,7 +117,7 @@ reappearing there is a failing test.
82
117
  | `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
83
118
  | `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
84
119
  | `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
85
- | `test-kit.ts` | `policyMatrix()` for generated policy tests |
120
+ | `test-kit.ts` | `policyMatrix()` for generated policy tests, and `testActor()` |
86
121
 
87
122
  ## The hot path
88
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "3.0.0",
3
+ "version": "4.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": "3.0.0"
34
+ "@ultimat3/core": "4.0.0"
35
35
  }
36
36
  }
@@ -34,6 +34,9 @@ interface CacheEntry {
34
34
  const cache = new WeakMap<Actor, CacheEntry>();
35
35
 
36
36
  const buildIndex = (actor: Actor, map: RoleMap): GrantIndex => {
37
+ // `?? []` on both, though `Actor` declares them required and `userActor()` defaults them: this
38
+ // takes an `Actor | null` from surfaces that hand it a value parsed out of JSON, and an authz
39
+ // read that throws where it should have DENIED is the `testActor` defect one layer down.
37
40
  const exact = new Set<string>(actor.permissions ?? []);
38
41
  for (const grant of expandRoles(actor.roles ?? [], map)) exact.add(grant);
39
42
  const wildcards = new Set<string>();
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ export {
42
42
  isKnownPermission,
43
43
  knownPermissions,
44
44
  resourceOf,
45
+ restorePermissions,
45
46
  verbOf,
46
47
  } from './permissions';
47
48
  export type {
@@ -64,12 +65,13 @@ export {
64
65
  or,
65
66
  policyPermissions,
66
67
  } from './policy';
67
- export type { Actor, PolicyActorFields, RoleDef, RoleMap } from './roles';
68
+ export type { Actor, RoleDef, RoleMap } from './roles';
68
69
  export {
69
70
  clearRoles,
70
71
  defineRoles,
71
72
  expandRoles,
72
73
  grantMatches,
74
+ restoreRoles,
73
75
  roleDeclarationSites,
74
76
  roleDefinitions,
75
77
  roleMapGeneration,
@@ -75,3 +75,18 @@ export const definePermissions = <const P extends readonly Permission[]>(
75
75
 
76
76
  /** Test seam; production never forgets a permission it declared. */
77
77
  export const clearPermissions = (): void => declared.clear();
78
+
79
+ /**
80
+ * `clearPermissions()`'s other half, taking exactly what `knownPermissions()` answers.
81
+ *
82
+ * `definePermissions()` runs at MODULE scope — `@ultimat3/admin` declares `admin:*` on its
83
+ * barrel's import — and a module evaluates once per `bun test` process. So a clear in one test
84
+ * file is permanent for every file after it: that file's own `import` is a cache hit which
85
+ * registers nothing, and `can('admin:read')` throws X_PERMISSION_UNKNOWN for a permission the
86
+ * process really did declare. Only putting the captured set back repairs it; re-importing cannot.
87
+ * Replaces rather than merges — a capture is the whole truth about the process, not an addition.
88
+ */
89
+ export const restorePermissions = (permissions: readonly string[]): void => {
90
+ declared.clear();
91
+ for (const permission of permissions) declared.add(permission);
92
+ };
package/src/roles.ts CHANGED
@@ -7,18 +7,21 @@ import { roleRedefined } from './errors';
7
7
  import { resourceOf } from './permissions';
8
8
 
9
9
  /**
10
- * The fields policy evaluation reads off an actor. `Actor` itself is core's; these
11
- * are authz roles ("editor", "owner"), not core's runtime `Role` ("web", "worker").
10
+ * An **alias** of core's declaration, and nothing more (`As of 2026-08-19`) so the actor
11
+ * `userActor()` mints IS the actor a predicate decides about, with no spread and no second type.
12
+ *
13
+ * It used to be `CoreActor & PolicyActorFields`, a four-field interface declared here. Three of
14
+ * those fields (`id`, `roles`, `orgId`) were already core's, so the intersection restated them;
15
+ * `PolicyActorFields.orgId`'s `| null` never survived it at all, being intersected back to core's
16
+ * `string | undefined`. The fourth, `permissions`, was the only real content — and declaring THAT
17
+ * here is what made it unbuildable: core is tier 0, so `userActor({ permissions })` had no field
18
+ * to land in and dropped the argument in silence. It now lives beside `roles` and `scopes` in
19
+ * `@ultimat3/core`, where every actor is built, and this interface has nothing left to say.
20
+ *
21
+ * Same shape as `@ultimat3/entity`'s `MoneyValue`, aliased from `@ultimat3/schema` for the same
22
+ * reason: one declaration, re-exported by the package whose public API talks about it.
12
23
  */
13
- export interface PolicyActorFields {
14
- readonly id: string;
15
- readonly roles?: readonly string[] | undefined;
16
- /** Direct grants, bypassing roles. Used by service tokens. */
17
- readonly permissions?: readonly string[] | undefined;
18
- readonly orgId?: string | null | undefined;
19
- }
20
-
21
- export type Actor = CoreActor & PolicyActorFields;
24
+ export type Actor = CoreActor;
22
25
 
23
26
  export interface RoleDef {
24
27
  readonly grants: readonly string[];
@@ -52,6 +55,26 @@ const declarationSite = (): string => {
52
55
  return frame.trim() === '' ? 'unknown site' : frame.trim();
53
56
  };
54
57
 
58
+ /**
59
+ * A role name is caller data, and an app's role map is a plain object literal — so `map['constructor']`
60
+ * answers the `Object` FUNCTION rather than `undefined`, the `=== undefined` guard at the call site
61
+ * never fires, and reading `.grants` off it throws a bare `TypeError` from inside `evaluate`. That is
62
+ * an authz decision arriving at the error boundary as a 500 instead of a 403, for every request an
63
+ * actor holding a role named `constructor`, `__proto__` or `toString` makes. Same discriminator
64
+ * `@ultimat3/entity`'s `tenancy.ts` uses, for the same reason.
65
+ */
66
+ const own = <V>(map: Readonly<Record<string, V>>, name: string): V | undefined =>
67
+ Object.hasOwn(map, name) ? map[name] : undefined;
68
+
69
+ /**
70
+ * `map[name] = value` is the same hazard writing: for `name === '__proto__'` it runs the setter on
71
+ * `Object.prototype` and stores NO key, so the role would vanish between being merged and being
72
+ * read back. `defineProperty` files an own key whatever the name spells.
73
+ */
74
+ const put = <V>(map: Record<string, V>, name: string, value: V): void => {
75
+ Object.defineProperty(map, name, { value, writable: true, enumerable: true, configurable: true });
76
+ };
77
+
55
78
  const sameList = (left: readonly string[], right: readonly string[]): boolean => {
56
79
  if (left.length !== right.length) return false;
57
80
  const sortedRight = [...right].sort();
@@ -81,12 +104,12 @@ export const defineRoles = <const M extends RoleMap>(map: M): M => {
81
104
  const merged: Record<string, RoleDef> = { ...roleMap };
82
105
  const nextSites: Record<string, string> = { ...sites };
83
106
  for (const [role, definition] of Object.entries(map)) {
84
- const existing = merged[role];
107
+ const existing = own(merged, role);
85
108
  if (existing !== undefined && !sameDefinition(existing, definition)) {
86
- throw roleRedefined(role, sites[role] ?? 'unknown site', site);
109
+ throw roleRedefined(role, own(sites, role) ?? 'unknown site', site);
87
110
  }
88
- merged[role] = definition;
89
- nextSites[role] = nextSites[role] ?? site;
111
+ put(merged, role, definition);
112
+ put(nextSites, role, own(nextSites, role) ?? site);
90
113
  }
91
114
  roleMap = merged;
92
115
  sites = nextSites;
@@ -106,6 +129,23 @@ export const clearRoles = (): void => {
106
129
  generation += 1;
107
130
  };
108
131
 
132
+ /**
133
+ * `clearRoles()`'s other half, taking what `roleDefinitions()` and `roleDeclarationSites()`
134
+ * answer. `defineRoles()` runs at an app's MODULE scope and a module evaluates once per
135
+ * `bun test` process, so a clear in one test file is permanent for every file after it — the
136
+ * later file's `import` is a cache hit that declares nothing.
137
+ *
138
+ * `defineRoles()` cannot be the restore: it re-derives the declaration site from the CALLER's
139
+ * stack, so every role would report this frame as its origin and `X_ROLE_REDEFINED` would name a
140
+ * site no reader can open. The generation is bumped for `grant-index.ts`, whose per-actor memo is
141
+ * invalidated by that number alone.
142
+ */
143
+ export const restoreRoles = (map: RoleMap, declaredAt: Readonly<Record<string, string>>): void => {
144
+ roleMap = { ...map };
145
+ sites = { ...declaredAt };
146
+ generation += 1;
147
+ };
148
+
109
149
  /**
110
150
  * Depth-first expansion with a visited set: `owner -> admin -> editor` collapses to
111
151
  * one list, and `a -> b -> a` terminates instead of blowing the stack.
@@ -119,7 +159,7 @@ export const expandRoles = (
119
159
  const walk = (name: string): void => {
120
160
  if (seen.has(name)) return;
121
161
  seen.add(name);
122
- const definition = map[name];
162
+ const definition = own(map, name);
123
163
  if (definition === undefined) return;
124
164
  for (const grant of definition.grants) out.add(grant);
125
165
  for (const parent of definition.inherits ?? []) walk(parent);
package/src/test-kit.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // `policyMatrix()` turns "who can do this?" into a table a test can assert on in one
2
2
  // expression. `x g policy` generates a test that calls it, so every policy ships with
3
3
  // its allow/deny matrix and a change to a role shows up as a diff in that table.
4
+ import { userActor } from '@ultimat3/core';
4
5
  import { type EvaluateArgs, evaluate, reasonOf } from './evaluate';
5
6
  import type { Policy } from './policy';
6
7
  import type { Actor } from './roles';
@@ -59,15 +60,21 @@ export const policyMatrix = <I, R = unknown>(
59
60
  };
60
61
  });
61
62
 
62
- const verdicts: Record<string, boolean> = {};
63
- for (const row of rows) verdicts[row.actor] = row.allowed;
63
+ // `Object.fromEntries` rather than `verdicts[row.actor] = …`: an actor named `__proto__` assigns
64
+ // the PROTOTYPE through that spelling and files no key at all, so the matrix would report a
65
+ // verdict it never stored. Every name here is an own key.
66
+ const verdicts: Record<string, boolean> = Object.fromEntries(
67
+ rows.map((row) => [row.actor, row.allowed]),
68
+ );
64
69
 
65
70
  const width = Math.max(5, ...rows.map((row) => row.actor.length));
66
71
  return {
67
72
  label: policy.label,
68
73
  rows,
69
74
  verdicts,
70
- allowedFor: (name) => verdicts[name] ?? false,
75
+ // `verdicts[name] ?? false` answered the `Object` FUNCTION — truthy, and not a boolean — for
76
+ // `allowedFor('constructor')`, so a matrix asserting on an actor of that name read as allow.
77
+ allowedFor: (name) => (Object.hasOwn(verdicts, name) ? verdicts[name] === true : false),
71
78
  toTable: () =>
72
79
  rows
73
80
  .map((row) =>
@@ -77,16 +84,39 @@ export const policyMatrix = <I, R = unknown>(
77
84
  };
78
85
  };
79
86
 
80
- /** Builds an actor for tests without asserting anything about core's Actor shape. */
87
+ /**
88
+ * Builds a test actor through `userActor()` — core's own builder, and now the only one, since
89
+ * `permissions` moved to core's `Actor` (`As of 2026-08-19`).
90
+ *
91
+ * This function hand-rolled the object literal because it had to: a direct grant was declared on
92
+ * policy's `PolicyActorFields` and core's builder had no field for it. That is what made every
93
+ * actor it minted structurally different from a request's — it omitted `kind` and `scopes` behind
94
+ * an `as unknown as Actor`, so `hasScope()` threw a bare `TypeError` and `actorLabel()` rendered
95
+ * `undefined:editor` into logs and spans, and a generated policy test (`x g policy`) asserting a
96
+ * scope-gated denial failed as a 500-shaped throw rather than as the denial it wrote.
97
+ *
98
+ * Going through `userActor()` closes that by construction rather than by keeping a second field
99
+ * list in sync: a field added to `Actor` arrives here, and the result is FROZEN with frozen
100
+ * arrays, exactly as the actor `@ultimat3/auth` resolves per request is.
101
+ */
81
102
  export const testActor = (
82
103
  name: string,
83
- init: { roles?: readonly string[]; permissions?: readonly string[]; orgId?: string } = {},
84
- ): NamedActor => ({
85
- name,
86
- actor: {
104
+ init: {
105
+ roles?: readonly string[];
106
+ permissions?: readonly string[];
107
+ scopes?: readonly string[];
108
+ orgId?: string;
109
+ } = {},
110
+ ): NamedActor => {
111
+ const built = userActor({
87
112
  id: name,
88
113
  roles: init.roles ?? [],
114
+ scopes: init.scopes ?? [],
89
115
  permissions: init.permissions ?? [],
90
- orgId: init.orgId ?? null,
91
- } as unknown as Actor,
92
- });
116
+ });
117
+ // `orgId: null` is deliberate and load-bearing, and the ONE cast left. Core declares
118
+ // `orgId?: string | undefined`, so nothing typed can mint the `null` an app's own adapter still
119
+ // puts on the wire — and `@ultimat3/query`'s `orgless()` treats `null`, `undefined` and `''`
120
+ // alike precisely because it does reach there. Frozen, so this stays a production-shaped actor.
121
+ return { name, actor: Object.freeze({ ...built, orgId: init.orgId ?? null }) as Actor };
122
+ };