@jantstack/adonis-authz 1.1.0 → 2.4.0-alpha.1

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.
Files changed (236) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +837 -51
  3. package/build/commands/authz_catalog_diff.d.ts +28 -0
  4. package/build/commands/authz_catalog_diff.d.ts.map +1 -0
  5. package/build/commands/authz_catalog_diff.js +67 -0
  6. package/build/commands/authz_catalog_diff.js.map +1 -0
  7. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  8. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  9. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  10. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  11. package/build/commands/authz_catalog_sync.d.ts +37 -0
  12. package/build/commands/authz_catalog_sync.d.ts.map +1 -0
  13. package/build/commands/authz_catalog_sync.js +81 -0
  14. package/build/commands/authz_catalog_sync.js.map +1 -0
  15. package/build/commands/authz_freeze.d.ts +44 -0
  16. package/build/commands/authz_freeze.d.ts.map +1 -0
  17. package/build/commands/authz_freeze.js +95 -0
  18. package/build/commands/authz_freeze.js.map +1 -0
  19. package/build/commands/authz_reconcile.d.ts +102 -0
  20. package/build/commands/authz_reconcile.d.ts.map +1 -0
  21. package/build/commands/authz_reconcile.js +294 -0
  22. package/build/commands/authz_reconcile.js.map +1 -0
  23. package/build/commands/authz_relations_reconcile.d.ts +73 -0
  24. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  25. package/build/commands/authz_relations_reconcile.js +225 -0
  26. package/build/commands/authz_relations_reconcile.js.map +1 -0
  27. package/build/commands/authz_scopes_relay.d.ts +47 -0
  28. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  29. package/build/commands/authz_scopes_relay.js +141 -0
  30. package/build/commands/authz_scopes_relay.js.map +1 -0
  31. package/build/commands/authz_unfreeze.d.ts +37 -0
  32. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  33. package/build/commands/authz_unfreeze.js +92 -0
  34. package/build/commands/authz_unfreeze.js.map +1 -0
  35. package/build/commands/main.d.ts +8 -1
  36. package/build/commands/main.d.ts.map +1 -1
  37. package/build/commands/main.js +8 -1
  38. package/build/commands/main.js.map +1 -1
  39. package/build/commands/openfga_provision.d.ts +46 -4
  40. package/build/commands/openfga_provision.d.ts.map +1 -1
  41. package/build/commands/openfga_provision.js +91 -7
  42. package/build/commands/openfga_provision.js.map +1 -1
  43. package/build/configure.d.ts +11 -0
  44. package/build/configure.d.ts.map +1 -1
  45. package/build/configure.js +37 -1
  46. package/build/configure.js.map +1 -1
  47. package/build/index.d.ts +75 -7
  48. package/build/index.d.ts.map +1 -1
  49. package/build/index.js +67 -5
  50. package/build/index.js.map +1 -1
  51. package/build/providers/authz_provider.d.ts +26 -2
  52. package/build/providers/authz_provider.d.ts.map +1 -1
  53. package/build/providers/authz_provider.js +48 -2
  54. package/build/providers/authz_provider.js.map +1 -1
  55. package/build/services/relations.d.ts +14 -0
  56. package/build/services/relations.d.ts.map +1 -0
  57. package/build/services/relations.js +17 -0
  58. package/build/services/relations.js.map +1 -0
  59. package/build/src/catalog/catalog.d.ts +289 -0
  60. package/build/src/catalog/catalog.d.ts.map +1 -0
  61. package/build/src/catalog/catalog.js +859 -0
  62. package/build/src/catalog/catalog.js.map +1 -0
  63. package/build/src/catalog/catalog_cache.d.ts +324 -0
  64. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  65. package/build/src/catalog/catalog_cache.js +666 -0
  66. package/build/src/catalog/catalog_cache.js.map +1 -0
  67. package/build/src/clock.d.ts +24 -0
  68. package/build/src/clock.d.ts.map +1 -0
  69. package/build/src/clock.js +7 -0
  70. package/build/src/clock.js.map +1 -0
  71. package/build/src/define_config.d.ts +201 -7
  72. package/build/src/define_config.d.ts.map +1 -1
  73. package/build/src/define_config.js.map +1 -1
  74. package/build/src/drivers/database_driver.d.ts +324 -15
  75. package/build/src/drivers/database_driver.d.ts.map +1 -1
  76. package/build/src/drivers/database_driver.js +1107 -128
  77. package/build/src/drivers/database_driver.js.map +1 -1
  78. package/build/src/drivers/database_relations_driver.d.ts +75 -0
  79. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  80. package/build/src/drivers/database_relations_driver.js +450 -0
  81. package/build/src/drivers/database_relations_driver.js.map +1 -0
  82. package/build/src/drivers/openfga_driver.d.ts +963 -55
  83. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  84. package/build/src/drivers/openfga_driver.js +2693 -372
  85. package/build/src/drivers/openfga_driver.js.map +1 -1
  86. package/build/src/drivers/openfga_facts.d.ts +369 -0
  87. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  88. package/build/src/drivers/openfga_facts.js +813 -0
  89. package/build/src/drivers/openfga_facts.js.map +1 -0
  90. package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
  91. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  92. package/build/src/drivers/openfga_relations_driver.js +466 -0
  93. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  94. package/build/src/errors.d.ts +586 -0
  95. package/build/src/errors.d.ts.map +1 -1
  96. package/build/src/errors.js +583 -0
  97. package/build/src/errors.js.map +1 -1
  98. package/build/src/expiry.d.ts +27 -0
  99. package/build/src/expiry.d.ts.map +1 -0
  100. package/build/src/expiry.js +50 -0
  101. package/build/src/expiry.js.map +1 -0
  102. package/build/src/freeze.d.ts +120 -0
  103. package/build/src/freeze.d.ts.map +1 -0
  104. package/build/src/freeze.js +172 -0
  105. package/build/src/freeze.js.map +1 -0
  106. package/build/src/hierarchical_resolver.d.ts +56 -0
  107. package/build/src/hierarchical_resolver.d.ts.map +1 -0
  108. package/build/src/hierarchical_resolver.js +87 -0
  109. package/build/src/hierarchical_resolver.js.map +1 -0
  110. package/build/src/{middleware → http}/app_access_middleware.d.ts +8 -6
  111. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  112. package/build/src/{middleware → http}/app_access_middleware.js +10 -26
  113. package/build/src/http/app_access_middleware.js.map +1 -0
  114. package/build/src/http/resource_access_middleware.d.ts +105 -0
  115. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  116. package/build/src/http/resource_access_middleware.js +81 -0
  117. package/build/src/http/resource_access_middleware.js.map +1 -0
  118. package/build/src/identity.d.ts +228 -0
  119. package/build/src/identity.d.ts.map +1 -0
  120. package/build/src/identity.js +457 -0
  121. package/build/src/identity.js.map +1 -0
  122. package/build/src/manager.d.ts +536 -7
  123. package/build/src/manager.d.ts.map +1 -1
  124. package/build/src/manager.js +2676 -23
  125. package/build/src/manager.js.map +1 -1
  126. package/build/src/memoize_ancestors.d.ts +23 -0
  127. package/build/src/memoize_ancestors.d.ts.map +1 -0
  128. package/build/src/memoize_ancestors.js +42 -0
  129. package/build/src/memoize_ancestors.js.map +1 -0
  130. package/build/src/models/authz_assignment.d.ts +11 -11
  131. package/build/src/models/authz_assignment.d.ts.map +1 -1
  132. package/build/src/models/authz_deny.d.ts +11 -11
  133. package/build/src/models/authz_deny.d.ts.map +1 -1
  134. package/build/src/models/authz_permission.d.ts +17 -11
  135. package/build/src/models/authz_permission.d.ts.map +1 -1
  136. package/build/src/models/authz_permission.js +4 -0
  137. package/build/src/models/authz_permission.js.map +1 -1
  138. package/build/src/models/authz_role.d.ts +19 -12
  139. package/build/src/models/authz_role.d.ts.map +1 -1
  140. package/build/src/models/authz_role.js +6 -1
  141. package/build/src/models/authz_role.js.map +1 -1
  142. package/build/src/models/authz_role_permission.d.ts +11 -11
  143. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  144. package/build/src/openfga.d.ts +29 -0
  145. package/build/src/openfga.d.ts.map +1 -0
  146. package/build/src/openfga.js +26 -0
  147. package/build/src/openfga.js.map +1 -0
  148. package/build/src/reconcile.d.ts +37 -0
  149. package/build/src/reconcile.d.ts.map +1 -0
  150. package/build/src/reconcile.js +69 -0
  151. package/build/src/reconcile.js.map +1 -0
  152. package/build/src/relation_partition_trigger.d.ts +8 -0
  153. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  154. package/build/src/relation_partition_trigger.js +85 -0
  155. package/build/src/relation_partition_trigger.js.map +1 -0
  156. package/build/src/relations/define_relations_config.d.ts +58 -0
  157. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  158. package/build/src/relations/define_relations_config.js +144 -0
  159. package/build/src/relations/define_relations_config.js.map +1 -0
  160. package/build/src/relations/manager.d.ts +38 -0
  161. package/build/src/relations/manager.d.ts.map +1 -0
  162. package/build/src/relations/manager.js +156 -0
  163. package/build/src/relations/manager.js.map +1 -0
  164. package/build/src/relations/reconcile.d.ts +62 -0
  165. package/build/src/relations/reconcile.d.ts.map +1 -0
  166. package/build/src/relations/reconcile.js +138 -0
  167. package/build/src/relations/reconcile.js.map +1 -0
  168. package/build/src/relations_config_store.d.ts +22 -0
  169. package/build/src/relations_config_store.d.ts.map +1 -0
  170. package/build/src/relations_config_store.js +74 -0
  171. package/build/src/relations_config_store.js.map +1 -0
  172. package/build/src/scope_outbox.d.ts +69 -0
  173. package/build/src/scope_outbox.d.ts.map +1 -0
  174. package/build/src/scope_outbox.js +291 -0
  175. package/build/src/scope_outbox.js.map +1 -0
  176. package/build/src/shared/backend_guard.d.ts +106 -0
  177. package/build/src/shared/backend_guard.d.ts.map +1 -0
  178. package/build/src/shared/backend_guard.js +246 -0
  179. package/build/src/shared/backend_guard.js.map +1 -0
  180. package/build/src/shared/sql_expiry.d.ts +53 -0
  181. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  182. package/build/src/shared/sql_expiry.js +66 -0
  183. package/build/src/shared/sql_expiry.js.map +1 -0
  184. package/build/src/sql_descendants.d.ts +97 -0
  185. package/build/src/sql_descendants.d.ts.map +1 -0
  186. package/build/src/sql_descendants.js +203 -0
  187. package/build/src/sql_descendants.js.map +1 -0
  188. package/build/src/testing/contract.d.ts +212 -7
  189. package/build/src/testing/contract.d.ts.map +1 -1
  190. package/build/src/testing/contract.js +3449 -24
  191. package/build/src/testing/contract.js.map +1 -1
  192. package/build/src/testing/main.d.ts +10 -2
  193. package/build/src/testing/main.d.ts.map +1 -1
  194. package/build/src/testing/main.js +5 -1
  195. package/build/src/testing/main.js.map +1 -1
  196. package/build/src/testing/migration_contract.d.ts +284 -0
  197. package/build/src/testing/migration_contract.d.ts.map +1 -0
  198. package/build/src/testing/migration_contract.js +586 -0
  199. package/build/src/testing/migration_contract.js.map +1 -0
  200. package/build/src/testing/relations_contract.d.ts +51 -0
  201. package/build/src/testing/relations_contract.d.ts.map +1 -0
  202. package/build/src/testing/relations_contract.js +654 -0
  203. package/build/src/testing/relations_contract.js.map +1 -0
  204. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  205. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  206. package/build/src/testing/relations_reconcile_contract.js +172 -0
  207. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  208. package/build/src/testing/scope_tree.d.ts +65 -0
  209. package/build/src/testing/scope_tree.d.ts.map +1 -0
  210. package/build/src/testing/scope_tree.js +145 -0
  211. package/build/src/testing/scope_tree.js.map +1 -0
  212. package/build/src/traits/authz_scopes.d.ts +30 -6
  213. package/build/src/traits/authz_scopes.d.ts.map +1 -1
  214. package/build/src/traits/authz_scopes.js +30 -18
  215. package/build/src/traits/authz_scopes.js.map +1 -1
  216. package/build/src/traits/has_uuid.d.ts +12 -12
  217. package/build/src/traits/has_uuid.d.ts.map +1 -1
  218. package/build/src/types.d.ts +1313 -28
  219. package/build/src/types.d.ts.map +1 -1
  220. package/build/src/types.js +20 -4
  221. package/build/src/types.js.map +1 -1
  222. package/build/stubs/config/app_acl.stub +4 -2
  223. package/build/stubs/config/authorization.stub +168 -14
  224. package/build/stubs/migration.stub +183 -13
  225. package/build/stubs/scopes_outbox_migration.stub +57 -0
  226. package/package.json +14 -7
  227. package/build/commands/openfga_import.d.ts +0 -28
  228. package/build/commands/openfga_import.d.ts.map +0 -1
  229. package/build/commands/openfga_import.js +0 -74
  230. package/build/commands/openfga_import.js.map +0 -1
  231. package/build/src/catalog.d.ts +0 -3
  232. package/build/src/catalog.d.ts.map +0 -1
  233. package/build/src/catalog.js +0 -103
  234. package/build/src/catalog.js.map +0 -1
  235. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  236. package/build/src/middleware/app_access_middleware.js.map +0 -1
@@ -1,4 +1,71 @@
1
+ var _a;
1
2
  import { Exception } from '@adonisjs/core/exceptions';
3
+ import { v7 as uuidv7 } from 'uuid';
4
+ import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, scopeSpellings, } from './identity.js';
5
+ import { expiryChanged } from './expiry.js';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { DEFAULT_FREEZE_LEASE_MS, acquireFreeze, freezeIsLive, freezeKindOf, readFreezeRow, releaseFreeze, renewFreeze, } from './freeze.js';
8
+ import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
9
+ import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
10
+ import { assertAssignableAt } from './catalog/catalog.js';
11
+ import { systemClock } from './clock.js';
12
+ import { ActorRequiredError, AuthorizationBackendTimeoutError, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, } from './errors.js';
13
+ import { APP_SCOPE_TYPE } from './types.js';
14
+ import { memoizeAncestors } from './memoize_ancestors.js';
15
+ import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
16
+ /** Longitudes de `authz_roles.name`/`description` (el esquema publicado). */
17
+ const ROLE_NAME_MAX = 100;
18
+ const ROLE_DESCRIPTION_MAX = 500;
19
+ /** Descripción corta de una respuesta inválida de `authorizeMany`, para el mensaje del 500. */
20
+ function describeAnswer(answer) {
21
+ if (!Array.isArray(answer))
22
+ return `${answer === null ? 'null' : typeof answer} (no es un array)`;
23
+ const offending = answer.find((b) => typeof b !== 'boolean');
24
+ if (offending !== undefined || answer.some((b) => typeof b !== 'boolean')) {
25
+ return `un array de ${answer.length} con un elemento que no es boolean (${typeof offending})`;
26
+ }
27
+ return `un array de ${answer.length}`;
28
+ }
29
+ /**
30
+ * Configs que ya recibieron el aviso de seguridad opt-in (B7/B1): una vez
31
+ * por config, no por instancia — `forRequest()` construye managers hijos con
32
+ * la misma config y no debe repetirlo en cada request.
33
+ */
34
+ const warnedConfigs = new WeakSet();
35
+ /**
36
+ * Expande los `excludedSubtrees` de un `all` (2D · F10) a la lista plana de
37
+ * scopes que hay que restar: cada scope denegado y TODOS sus descendientes,
38
+ * con el `descendantsOf` del config. Es la forma correcta del `NOT IN`;
39
+ * restar solo los scopes con deny dejaría dentro sus subárboles.
40
+ */
41
+ export function expandExcludedSubtrees(view, excluded) {
42
+ return view.expandExcludedSubtrees(excluded);
43
+ }
44
+ /** Cotas por defecto de `authorizedScopes` (config `scopes.maxScopes` / `scopes.maxDescendants`). */
45
+ export const DEFAULT_MAX_SCOPES = 1_000;
46
+ export const DEFAULT_MAX_DESCENDANTS = 10_000;
47
+ /**
48
+ * Tope sano de `maxScopes`/`maxDescendants` (2.5-B, auditor ⚪6): por encima
49
+ * de ~4,29e9 el hint `SET_VAR(cte_max_recursion_depth)` de MySQL sale de
50
+ * rango y un ciclo en la tabla deja de ser el 422 «posible ciclo» del
51
+ * contrato (503); y ya con 1e6 un ciclo cuesta segundos de CPU por llamada.
52
+ * Una cota mayor es config rota (500), nunca una pregunta.
53
+ */
54
+ export const MAX_SCOPE_BOUND = 10_000_000;
55
+ /**
56
+ * Cotas por defecto de `authz:scopes:relay` (3b-2d). El lote es el tamaño de
57
+ * cada `pending()`; el límite, cuántos cambios aplica una pasada antes de
58
+ * volver (lo que quede sigue pendiente: drenar es reanudable por diseño y
59
+ * una pasada eterna no es reanudable).
60
+ */
61
+ export const DEFAULT_RELAY_BATCH = 100;
62
+ export const DEFAULT_RELAY_LIMIT = 10_000;
63
+ /** Páginas que `authz:reconcile` pasea para MEDIR la ventana del relay (3b-3a). */
64
+ const RELAY_WINDOW_MAX_PAGES = 1_000;
65
+ /** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
66
+ export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
67
+ /** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
68
+ const monotonicNow = () => performance.now();
2
69
  /**
3
70
  * Manager de autorización — la fachada que usan middleware, services y
4
71
  * seeders. Resuelve el driver activo del config y notifica cada escritura
@@ -11,10 +78,107 @@ import { Exception } from '@adonisjs/core/exceptions';
11
78
  export class AuthorizationManager {
12
79
  #config;
13
80
  #driver = null;
81
+ /** Manager del que esta vista toma el driver (solo en vistas de `forRequest`). */
82
+ #parent = null;
83
+ /** Resolutor memoizado de ESTA vista; `null` = leer con el driver tal cual. */
84
+ #readResolver = null;
85
+ #readDriver = null;
86
+ /** Memo del catálogo propio, solo si el driver no expone el suyo (composición sin puerto). */
87
+ #ownCatalog = null;
88
+ /** Instante (reloj monótono, `#clock`) a partir del cual esta vista ya no puede leer; `null` = sin límite / no es vista. */
89
+ #readsUntil = null;
90
+ /** Reloj monótono con el que se mide `#readsUntil` (inyectable solo en tests). */
91
+ #clock = monotonicNow;
92
+ /**
93
+ * El freeze que ESTE manager sostiene (su token, su renovador), o `null`.
94
+ * Vive en el manager RAÍZ —una vista de `forRequest()` no es otro motor—
95
+ * pero desde 3b-7 el ESTADO del freeze es la fila `id = 2` de
96
+ * `authz_catalog_version`: esto es solo el lado del dueño (quién renueva y
97
+ * qué token puede levantarlo). Que la barrera alcance a las vistas y al
98
+ * resto de la FLOTA lo garantiza la fila, no esta referencia.
99
+ */
100
+ #heldFreeze = null;
14
101
  constructor(config) {
15
102
  this.#config = config;
103
+ if (config.clock !== undefined && typeof config.clock !== 'function') {
104
+ throw new AuthorizationConfigError(`AuthorizationManager: config.clock debe ser una función () => Date (llegó ${typeof config.clock})`);
105
+ }
106
+ this.#warnOptInSecurity();
107
+ }
108
+ /**
109
+ * `requireWithin` y `requireActor` son opt-in en 2.1 (auditor E2, aceptado
110
+ * y nombrado): con los defaults, un call-site que no pase `within` concede
111
+ * donde le digan y una escritura sin `actor` se audita sin autor. Se avisa
112
+ * UNA vez por config al construir el manager; `warnOnOptInSecurity: false`
113
+ * es la forma explícita de asumirlo.
114
+ */
115
+ #warnOptInSecurity() {
116
+ const config = this.#config;
117
+ if (config.warnOnOptInSecurity === false)
118
+ return;
119
+ const missing = ['requireWithin', 'requireActor'].filter((flag) => !config[flag]);
120
+ if (missing.length === 0 || warnedConfigs.has(config))
121
+ return;
122
+ warnedConfigs.add(config);
123
+ const consequences = {
124
+ requireWithin: "las escrituras sin 'within' van al scope que les digan, sea de quien sea",
125
+ requireActor: 'las escrituras se auditan sin autor',
126
+ };
127
+ console.warn(`authz: seguridad opt-in sin activar — ${missing.join(', ')}: ` +
128
+ `${missing.map((flag) => consequences[flag]).join('; ')}. ` +
129
+ `Actívalo en config/authorization.ts o silencia este aviso con warnOnOptInSecurity: false.`);
130
+ }
131
+ /**
132
+ * Vista por request (2A/A3): las LECTURAS (`authorize`, `hasRole`, `list*`)
133
+ * resuelven ancestros con `memoizeAncestors(config.scopes.resolveChain)`
134
+ * —una llamada al árbol por scope durante la vida de la vista—; las
135
+ * ESCRITURAS (`grant`, `revoke`, `deny`, `removeDeny`, `scopes.*`) siguen
136
+ * resolviendo en fresco, porque una lectura obsoleta caduca sola y un
137
+ * grant sobre una cadena que ya cambió queda escrito para siempre (auditor
138
+ * C3/E3). El memo es de ANCESTROS, nunca de decisiones: un deny escrito
139
+ * entre dos `authorize` de la misma vista cambia la segunda respuesta.
140
+ *
141
+ * Patrón en Adonis: un middleware hace `ctx.authz = authorization.forRequest()`
142
+ * y controladores y policies leen de `ctx.authz`. Sin `AsyncLocalStorage`:
143
+ * la vista es un objeto explícito con la vida que le des. Sin
144
+ * `config.scopes.resolveChain`, o con un driver de terceros sin
145
+ * `withChainResolver`, la vista lee con el driver tal cual (sin memo)
146
+ * y sigue siendo correcta.
147
+ */
148
+ forRequest(options = {}) {
149
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_VIEW_MAX_AGE_MS;
150
+ if (!Number.isInteger(maxAgeMs) || maxAgeMs < 0) {
151
+ throw new AuthorizationConfigError(`forRequest: maxAgeMs debe ser un entero >= 0 (0 = sin límite; llegó ${String(maxAgeMs)})`);
152
+ }
153
+ if (options.now !== undefined && typeof options.now !== 'function') {
154
+ throw new AuthorizationConfigError(`forRequest: now debe ser una función (llegó ${typeof options.now})`);
155
+ }
156
+ const view = new _a(this.#config);
157
+ view.#parent = this.#parent ?? this;
158
+ const resolver = this.#config.scopes?.resolveChain;
159
+ view.#readResolver = resolver ? memoizeAncestors(resolver) : null;
160
+ view.#clock = options.now ?? monotonicNow;
161
+ view.#readsUntil = maxAgeMs === 0 ? null : view.#clock() + maxAgeMs;
162
+ return view;
16
163
  }
164
+ /**
165
+ * El driver activo, TAL CUAL. Es la salida explícita de las barreras del
166
+ * manager (2D · G4, auditor 8): lo que escribas por aquí no pasa por
167
+ * `actor`/`requireActor`, `within`/`requireWithin` ni `onWrite`, y lo que
168
+ * leas no pasa por el memo de ancestros. Está pensado para el código de
169
+ * PLATAFORMA (seeders, comandos, la escritura en la raíz con
170
+ * `requireWithin: 'non-root'`) y para tests; un call-site de tenant nunca
171
+ * debería llamarlo. No se ofrece nada más por aquí a propósito.
172
+ *
173
+ * Lo único que el manager le aplica al resolverlo es el reloj del config
174
+ * (`clock`, 2.5 · J1) vía `withClock`: no es una barrera, es la hora con
175
+ * la que el driver decide, y vale igual para la plataforma, los tests y
176
+ * cada vista de `forRequest()` (todas leen el driver del padre). Un driver
177
+ * sin `withClock` con `clock` declarado es 500 `E_AUTHZ_CONFIG`.
178
+ */
17
179
  async driver() {
180
+ if (this.#parent)
181
+ return this.#parent.driver();
18
182
  if (this.#driver)
19
183
  return this.#driver;
20
184
  const registry = this.#config.drivers;
@@ -23,53 +187,2541 @@ export class AuthorizationManager {
23
187
  throw new Exception(`Driver de autorización '${this.#config.default}' no registrado. ` +
24
188
  `Registrados: ${Object.keys(this.#config.drivers).join(', ')}`, { status: 500 });
25
189
  }
26
- const driver = await factory();
190
+ let driver = await factory();
191
+ const clock = this.#config.clock;
192
+ if (clock !== undefined) {
193
+ if (typeof driver.withClock !== 'function') {
194
+ throw new AuthorizationConfigError(`config.clock está declarado pero el driver '${this.#config.default}' no implementa withClock(now): ` +
195
+ `el reloj no llegaría a ninguna decisión. Implementa withClock en el driver o quita clock del config.`);
196
+ }
197
+ driver = driver.withClock(clock);
198
+ }
199
+ this.#assertScopeDriftGuarded(driver);
27
200
  this.#driver = driver;
28
201
  return driver;
29
202
  }
203
+ /**
204
+ * **El gate de deriva del árbol, en el MANAGER** (3b-2e · E3; cierra el
205
+ * agujero que declaró el 3b-2d).
206
+ *
207
+ * El driver `facts` ya se niega a construirse sin `outbox` ni firma, pero
208
+ * ese gate mira SU opción `outbox` — y quien ENCOLA es el manager, que lee
209
+ * `config.scopes.outbox`. Pasarle la instancia solo al driver dejaba el
210
+ * gate contento y la mitigación apagada: `manager.scopes.*` seguía
211
+ * escribiendo en el backend dentro de la transacción del consumidor, que
212
+ * es exactamente S5. Aquí se cierra, y se cierra porque el driver DECLARA
213
+ * su `hierarchy` (`capabilities.hierarchyFacts`, la pieza de capacidades de
214
+ * este lote).
215
+ *
216
+ * Un driver sin `capabilities` (2.x, o de terceros) se trata como
217
+ * `hierarchyFacts: false`: no hay dos árboles y no hay deriva que mitigar.
218
+ */
219
+ #assertScopeDriftGuarded(driver) {
220
+ if (!driver.capabilities?.hierarchyFacts)
221
+ return;
222
+ if (this.#config.scopes?.outbox)
223
+ return;
224
+ if (this.#config.scopes?.acceptScopeDriftRisk === true)
225
+ return;
226
+ throw new ScopeDriftUnguardedError(`El driver '${this.#config.default}' declara el árbol como hechos propios (hierarchy: 'facts') y ` +
227
+ "config/authorization.ts no trae 'scopes.outbox': el manager escribiría el árbol en el backend DENTRO de tu " +
228
+ 'transacción, y un rollback posterior no lo deshace (el backend queda adelantado a tu base y esa escalada no ' +
229
+ "se ve desde ella). Declarar la outbox solo en el driver NO basta: quien encola es el manager. Pon la MISMA " +
230
+ "instancia en scopes.outbox, o firma el riesgo con scopes.acceptScopeDriftRisk: true.");
231
+ }
232
+ /**
233
+ * **Congela las ESCRITURAS del motor, DURABLE** (3b-7; decisión del dueño
234
+ * del 2026-08-31 (3b): B + E-analista). Operación de PLATAFORMA, como
235
+ * `driver()`: no se expone por HTTP.
236
+ *
237
+ * El estado ya NO vive en el proceso: vive en la fila `id = 2` de
238
+ * `authz_catalog_version`, así que alcanza a **todos los procesos que
239
+ * comparten las tablas `authz_*`** (invariante 14: el comando ace y los
240
+ * workers hablan con la misma base). Mientras el freeze está vivo, toda
241
+ * escritura del manager —las cuatro de hechos, las tres de árbol, la API
242
+ * de delegación, `pruneOrphanRoles({force})` y `relayScopeChanges`—
243
+ * responde 503 `E_AUTHZ_FROZEN` **reintentable** y no llega al driver; las
244
+ * LECTURAS siguen respondiendo con normalidad (la asimetría deliberada:
245
+ * `authorize` no se congela ni un milisegundo).
246
+ *
247
+ * Devuelve el **token del dueño** (`{ fence, holder }`): `unfreeze(token)`
248
+ * solo levanta el freeze cuyo token coincide — el `finally` de una ventana
249
+ * ajena o rezagada no puede levantar la tuya (auditor A1.3). Un freeze
250
+ * VIVO de otro dueño ⇒ 423 `E_AUTHZ_FREEZE_HELD`, nunca dos dueños.
251
+ *
252
+ * El **lease** (default 15 s) se renueva solo (`leaseMs / 3`, `unref()`)
253
+ * mientras este proceso vive; si el proceso muere (`SIGKILL`, OOM), el
254
+ * lease vence y la flota vuelve a escribir SOLA en ≤ `leaseMs` — nadie
255
+ * limpia nada a mano. `leaseMs: null` = sin caducidad: la ventana del
256
+ * OPERADOR (`authz:freeze`), que dura hasta su `authz:unfreeze`.
257
+ *
258
+ * Lo que el freeze **NO congela**, a propósito y documentado (auditor
259
+ * 🟠 5): `syncAuthzCatalog` (función libre que no ve al manager),
260
+ * `manager.driver()` (la salida documentada de TODAS las barreras) y el
261
+ * árbol SQL del consumidor (sus tablas, su SQL). Y lo que no puede
262
+ * prometer: una escritura que ya pasó su barrera cuando el freeze aterriza
263
+ * ENTRA (no hay atomicidad entre una fila SQL y un backend externo) — la
264
+ * promesa publicada es «otro proceso recibe 503», jamás «ninguna escritura
265
+ * entra en la ventana».
266
+ */
267
+ async freeze(reason, options = {}) {
268
+ const root = this.#root();
269
+ if (root.#heldFreeze) {
270
+ throw new FreezeHeldError(`freeze: este manager ya sostiene el freeze (fence ${root.#heldFreeze.token.fence}, ` +
271
+ `motivo: ${root.#heldFreeze.reason}). Una ventana dentro de otra corre DENTRO (withFrozenWrites/reconcile); ` +
272
+ `si quieres otra ventana, levanta antes la tuya con unfreeze(token).`);
273
+ }
274
+ const kind = options.kind ?? 'platform';
275
+ const leaseMs = options.leaseMs === undefined ? DEFAULT_FREEZE_LEASE_MS : options.leaseMs;
276
+ if (leaseMs !== null && (!Number.isInteger(leaseMs) || leaseMs < 1)) {
277
+ throw new AuthorizationConfigError(`freeze: leaseMs debe ser un entero >= 1 o null (llegó ${String(leaseMs)})`);
278
+ }
279
+ const finalReason = reason ?? 'una operación de plataforma';
280
+ const holder = `${kind}:${process.pid}:${randomBytes(4).toString('hex')}`;
281
+ const nowMs = root.#wallMs();
282
+ const token = await acquireFreeze({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
283
+ if (token === null) {
284
+ const row = await readFreezeRow({ driver: this.#config.default });
285
+ throw new FreezeHeldError(`freeze: ya hay un freeze VIVO de otro dueño (${row.holder ?? '?'}, fence ${row.fence}, motivo: ${row.reason ?? '?'}). ` +
286
+ `Espera a que termine, o levántalo con authz:unfreeze si su proceso murió sin lease.`);
287
+ }
288
+ const held = { token, reason: finalReason, kind, leaseMs, timer: null, lapsed: false };
289
+ if (leaseMs !== null) {
290
+ const interval = Math.max(250, Math.floor(leaseMs / 3));
291
+ held.timer = setInterval(() => void root.#renewHeldFreeze(held), interval);
292
+ held.timer.unref?.();
293
+ }
294
+ root.#heldFreeze = held;
295
+ return token;
296
+ }
297
+ /**
298
+ * Renovación CONDICIONAL del lease (fence + holder + «aún no venció»).
299
+ * 0 filas ⇒ el lease se PERDIÓ a mitad (pausa de GC más larga que el
300
+ * lease, base caída, otro dueño): se marca `lapsed`, se deja de renovar y
301
+ * NUNCA se «recupera» — la pasada que lo sostenía no se certifica.
302
+ */
303
+ async #renewHeldFreeze(held) {
304
+ if (held.lapsed || held.leaseMs === null)
305
+ return;
306
+ const nowMs = this.#wallMs();
307
+ try {
308
+ const renewed = await renewFreeze(held.token, { untilMs: nowMs + held.leaseMs, nowMs }, { driver: this.#config.default });
309
+ if (!renewed) {
310
+ held.lapsed = true;
311
+ if (held.timer)
312
+ clearInterval(held.timer);
313
+ }
314
+ }
315
+ catch {
316
+ // Base caída: transitorio. Los escritores tampoco pueden escribir (su
317
+ // barrera es la misma base, fail-closed); si la caída dura más que el
318
+ // lease, la SIGUIENTE renovación toca 0 filas y marca lapsed.
319
+ }
320
+ }
321
+ /**
322
+ * Levanta el freeze de ESTE token; uno ajeno o rezagado no toca nada (esa
323
+ * es toda la garantía del fence). Devuelve si de verdad lo levantó.
324
+ */
325
+ async unfreeze(token) {
326
+ if (!token || typeof token.fence !== 'number' || typeof token.holder !== 'string') {
327
+ throw new AuthorizationConfigError('unfreeze: hace falta el token que devolvió freeze() ({ fence, holder }). Levantar el freeze de otro es authz:unfreeze.');
328
+ }
329
+ const root = this.#root();
330
+ const { released, lapsed } = await releaseFreeze(token, { nowMs: root.#wallMs() }, { driver: this.#config.default });
331
+ const held = root.#heldFreeze;
332
+ if (held && held.token.fence === token.fence && held.token.holder === token.holder) {
333
+ if (held.timer)
334
+ clearInterval(held.timer);
335
+ held.lapsed = held.lapsed || lapsed;
336
+ root.#heldFreeze = null;
337
+ }
338
+ return released;
339
+ }
340
+ /**
341
+ * ¿SOSTIENE este manager un freeze? (proceso-local: su token vive aquí.)
342
+ * Para saber si el MOTOR está congelado —por quien sea— pregunta
343
+ * `freezeStatus()`: eso es la fila, no la memoria.
344
+ */
345
+ get frozen() {
346
+ return this.#root().#heldFreeze !== null;
347
+ }
348
+ /** El freeze VIVO de la fila compartida, o `null`. Lo lee cualquiera; solo el token lo levanta. */
349
+ async freezeStatus() {
350
+ const row = await readFreezeRow({ driver: this.#config.default });
351
+ if (!freezeIsLive(row, this.#root().#wallMs()))
352
+ return null;
353
+ return {
354
+ reason: row.reason,
355
+ holder: row.holder ?? '?',
356
+ kind: freezeKindOf(row.holder),
357
+ untilMs: row.untilMs,
358
+ fence: row.fence,
359
+ };
360
+ }
361
+ /**
362
+ * `freeze()` + `finally unfreeze(token)`. El `finally` es la parte que
363
+ * importa: una migración que revienta a la mitad no puede dejar la
364
+ * aplicación sin poder escribir (y si además el proceso muere sin
365
+ * `finally`, el lease vence solo). **El anidado corre DENTRO** (auditor
366
+ * A1.1/A1.3): si este manager ya sostiene el freeze, la ventana interior
367
+ * no toma otro ni lo levanta al salir — la exterior sigue en pie.
368
+ */
369
+ async withFrozenWrites(reason, fn) {
370
+ const context = await this.#durableFreezeContext(reason, 'platform', { operatorAsContext: false });
371
+ try {
372
+ return await fn();
373
+ }
374
+ finally {
375
+ await context.release();
376
+ }
377
+ }
378
+ /**
379
+ * El contexto de una ventana congelada: quién la sostiene, cómo se cierra
380
+ * y cómo se sabe si el lease se perdió a mitad (`lapsed`). Tres formas:
381
+ *
382
+ * 1. Este manager YA sostiene un freeze ⇒ la ventana corre DENTRO y el
383
+ * `release` es un no-op (la exterior manda).
384
+ * 2. Hay un freeze de OPERADOR vivo y `operatorAsContext` ⇒ el cutover:
385
+ * `reconcile` corre dentro de la ventana del operador, no la renueva
386
+ * ni la levanta, y su `lapsed` es «¿seguía la MISMA ventana viva al
387
+ * terminar?».
388
+ * 3. Nadie ⇒ se toma uno propio (lease renovado) y se suelta al salir.
389
+ * Un freeze vivo de otro dueño ⇒ 423 (lo lanza `freeze()`).
390
+ */
391
+ async #durableFreezeContext(reason, kind, options) {
392
+ const root = this.#root();
393
+ const outer = root.#heldFreeze;
394
+ if (outer) {
395
+ return {
396
+ fence: outer.token.fence,
397
+ leaseMs: outer.leaseMs,
398
+ release: async () => { },
399
+ lapsed: () => root.#tokenLapsed(outer.token, outer.lapsed),
400
+ };
401
+ }
402
+ if (options.operatorAsContext) {
403
+ const status = await this.freezeStatus();
404
+ if (status !== null && status.kind === 'operator') {
405
+ const token = { fence: status.fence, holder: status.holder };
406
+ return {
407
+ fence: status.fence,
408
+ leaseMs: null,
409
+ release: async () => { },
410
+ lapsed: () => root.#tokenLapsed(token, false),
411
+ };
412
+ }
413
+ }
414
+ const token = await this.freeze(reason, { kind });
415
+ const held = root.#heldFreeze;
416
+ return {
417
+ fence: token.fence,
418
+ leaseMs: held.leaseMs,
419
+ release: async () => {
420
+ try {
421
+ await this.unfreeze(token);
422
+ }
423
+ catch (error) {
424
+ // La base no respondió al soltar: el lease vence solo en <= leaseMs
425
+ // y los escritores ya están recibiendo 503 de esa misma base.
426
+ console.warn('authz: no se pudo soltar el freeze al cerrar la ventana (el lease vencerá solo)', error);
427
+ }
428
+ },
429
+ lapsed: () => root.#tokenLapsed(token, held.lapsed),
430
+ };
431
+ }
432
+ /** ¿Se perdió la ventana de ESTE token en algún momento? (la renovación fallida, o la fila ya no es suya / venció). */
433
+ async #tokenLapsed(token, alreadyLapsed) {
434
+ if (alreadyLapsed)
435
+ return true;
436
+ const row = await readFreezeRow({ driver: this.#config.default });
437
+ const mine = row.fence === token.fence && row.holder === token.holder;
438
+ return !mine || (row.untilMs !== null && row.untilMs <= this.#wallMs());
439
+ }
440
+ /** Milisegundos de PARED con el reloj del config (el mismo que decide caducidades). */
441
+ #wallMs() {
442
+ return (this.#config.clock ?? systemClock)().getTime();
443
+ }
444
+ /** El manager raíz: el de una vista de `forRequest()` es su padre. */
445
+ #root() {
446
+ return this.#parent ?? this;
447
+ }
448
+ /**
449
+ * La barrera del freeze, delante de TODA escritura del manager. Va antes
450
+ * de validar identidades y de tocar el árbol: durante la migración una
451
+ * escritura no se valida a medias, se rechaza entera. Desde 3b-7 es la
452
+ * FILA compartida (consulta propia por PK, sin memo: +0,14 ms p50 por
453
+ * escritura, medidos; 0 en `authorize`) — un freeze cacheado 30 s no es un
454
+ * freeze, y con `catalogRevalidate: { everyMs }` la fila del memo ni se
455
+ * lee.
456
+ */
457
+ async #assertNotFrozen(operation, transaction) {
458
+ const root = this.#root();
459
+ const held = root.#heldFreeze;
460
+ if (held) {
461
+ throw new AuthorizationFrozenError(`${operation}: el motor de autorización está congelado (${held.reason}) y no acepta escrituras. ` +
462
+ `Las lecturas siguen funcionando; reintenta esta escritura cuando la operación termine.`);
463
+ }
464
+ // Si la escritura llegó con la TRANSACCIÓN del consumidor, la fila se lee
465
+ // por ella: exigir una segunda conexión con la suya abierta interbloquea
466
+ // un pool de 1 (SQLite `:memory:`). El precio va declarado en freeze.ts.
467
+ const client = transaction && typeof transaction.from === 'function'
468
+ ? transaction
469
+ : undefined;
470
+ const row = await readFreezeRow({ driver: this.#config.default, client });
471
+ if (!freezeIsLive(row, root.#wallMs()))
472
+ return;
473
+ const lift = freezeKindOf(row.holder) === 'operator' ? ' (la levanta authz:unfreeze)' : '';
474
+ throw new AuthorizationFrozenError(`${operation}: el motor de autorización está congelado (${row.reason})${lift} y no acepta escrituras. ` +
475
+ `Las lecturas siguen funcionando; reintenta esta escritura cuando la ventana termine.`);
476
+ }
30
477
  /** Solo tests: fuerza re-resolución del driver. */
31
478
  clearCachedDriver() {
32
479
  this.#driver = null;
480
+ this.#readDriver = null;
481
+ }
482
+ /**
483
+ * El driver para LEER: en una vista de `forRequest`, el driver con el
484
+ * resolutor memoizado (si el driver sabe darlo); fuera de una vista, el
485
+ * driver tal cual. Las escrituras nunca pasan por aquí.
486
+ */
487
+ /**
488
+ * Una vista caducada no lee (F9): su memo de ancestros puede describir un
489
+ * árbol que ya cambió. Ruidoso a propósito. Lo mide el reloj monótono (H3).
490
+ * Pasan por aquí TODAS las lecturas, `expandExcludedSubtrees` incluida (I2).
491
+ */
492
+ #assertReadable() {
493
+ if (this.#readsUntil !== null && this.#clock() >= this.#readsUntil) {
494
+ throw new ViewExpiredError(`La vista de forRequest() superó su maxAgeMs y ya no puede leer: su memo de ancestros puede estar obsoleto. ` +
495
+ `Crea la vista por request (un middleware) o pasa forRequest({ maxAgeMs: 0 }) a sabiendas.`);
496
+ }
33
497
  }
498
+ async #reader() {
499
+ this.#assertReadable();
500
+ const driver = await this.driver();
501
+ if (!this.#readResolver)
502
+ return driver;
503
+ if (this.#readDriver)
504
+ return this.#readDriver;
505
+ this.#readDriver = driver.withChainResolver?.(this.#readResolver) ?? driver;
506
+ return this.#readDriver;
507
+ }
508
+ /**
509
+ * El árbol de scopes es un hecho del contrato: el consumidor notifica sus
510
+ * cambios aquí, en TODOS los drivers, y el PAQUETE valida antes de tocar
511
+ * el driver — la raíz no cuelga de nada, el padre tiene que existir y no
512
+ * puede haber ciclos. FGA acepta un ciclo de `parent` y lo evalúa (un grant
513
+ * en cualquier nodo concede en la raíz, S2), así que la barrera es esta.
514
+ * Espía: si la validación falla, cero llamadas al driver.
515
+ */
516
+ scopes = {
517
+ // `within` (2D · F2; origen y destino desde 2E · H1) se contrasta con el
518
+ // PADRE —colgar o mover algo bajo un scope es escribir en ese scope— Y con
519
+ // la cadena ACTUAL del hijo cuando ya está en el árbol: llevarse un
520
+ // subárbol de otro tenant es peor que purgarlo (se hereda todo lo robado).
521
+ // Por eso el consumidor notifica ANTES de recolgar su fila: la cadena que
522
+ // se contrasta es la de origen, resuelta en fresco.
523
+ attached: async (child, parent, options) => {
524
+ const actor = await this.#writeOptions(options, 'scopes.attached');
525
+ const edge = await this.#assertEdge(child, parent, 'scopes.attached');
526
+ this.#assertWithinChain(parent, edge.chain, options, 'scopes.attached');
527
+ // Un hijo que el árbol ya conoce se está MOVIENDO (el `attach` de un
528
+ // nodo existente es un `move`): su origen también tiene que estar dentro.
529
+ await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
530
+ const outbox = this.#outbox();
531
+ if (outbox) {
532
+ await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
533
+ return;
534
+ }
535
+ await (await this.driver()).onScopeAttached?.(child, parent);
536
+ },
537
+ /**
538
+ * `moved` NO vuelve a juzgar el catálogo, y no tiene por qué (3b-1 · D3,
539
+ * auditor 3G): mover un scope es un hecho del árbol, no una escritura de
540
+ * catálogo. Lo que hay que tener escrito es la consecuencia: la relación
541
+ * «A ensombrece a B» es función del árbol de HOY, así que un `moved` que
542
+ * mete un subárbol bajo un scope que ya tiene el homónimo **crea la
543
+ * sombra sin que se juzgue ningún rango en ninguna parte** — y el dueño
544
+ * del subárbol movido puede no poder repararla (su rango se mide en la
545
+ * cadena del owner de la sombra). Por eso «sobre un rol solo actúa quien
546
+ * lo supera en rango» (3G · W3) es una comprobación de ESCRITURA y no un
547
+ * invariante del sistema. Es ruidosa: `authz:catalog:diff` la lista como
548
+ * `shadowedByAncestor` (y `--fail-on-shadows` la cuenta como deriva).
549
+ */
550
+ moved: async (child, newParent, options) => {
551
+ const actor = await this.#writeOptions(options, 'scopes.moved');
552
+ const edge = await this.#assertEdge(child, newParent, 'scopes.moved');
553
+ this.#assertWithinChain(newParent, edge.chain, options, 'scopes.moved');
554
+ await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
555
+ const outbox = this.#outbox();
556
+ if (outbox) {
557
+ await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
558
+ return;
559
+ }
560
+ await (await this.driver()).onScopeMoved?.(child, newParent);
561
+ },
562
+ /**
563
+ * Hechos primero (el driver demuestra cero o lanza), arista después
564
+ * (S6): si la purga muere a medias, el subárbol sigue colgado y los
565
+ * denies heredados siguen valiendo. Sin `within` no comprueba que el
566
+ * scope exista (el consumidor puede haber borrado ya su fila); con
567
+ * `within` (2D · F2) el hijo tiene que seguir en el árbol para
568
+ * contrastar su cadena: purga ANTES de borrar la fila.
569
+ *
570
+ * **Purga HECHOS y solo hechos** (invariante 11; 3b-0 · Z1). Entre 3D y
571
+ * 3G esta operación arrastraba además los roles LOCALES cuyo owner era
572
+ * ese scope (y, con `descendantsOf`, los de todo el subárbol), con su
573
+ * propia policy de rango, su degradación y un valor de retorno que
574
+ * contaba lo purgado. Cinco lotes la tocaron y TRES de las cuatro
575
+ * regresiones de la Fase 3 nacieron ahí, siempre por COMPOSICIÓN de
576
+ * piezas correctas por separado (3E · P3 + 3F · S1/S2 ⇒ 3G · W1). El
577
+ * requisito que lo pedía —un rol cuyo owner desaparece queda
578
+ * indeleteable y ocupa su `(slug, nivel)`— se resuelve más simple y
579
+ * fuera del camino de un tenant: el rol queda DORMIDO (no concede, no es
580
+ * membresía, no se asigna) y la PLATAFORMA lo retira con
581
+ * `authz:catalog:prune-orphans` (Z2). Así `scopes.detached` vuelve a ser
582
+ * O(1), sin rango que medir, sin árbol que enumerar y sin nada que
583
+ * declarar a medias.
584
+ */
585
+ detached: async (child, options) => {
586
+ const actor = await this.#writeOptions(options, 'scopes.detached');
587
+ this.#resolver('scopes.detached');
588
+ assertScope(child);
589
+ if (child.type === APP_SCOPE_TYPE) {
590
+ throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
591
+ }
592
+ await this.#assertWithin(child, options, 'scopes.detached');
593
+ // La identidad CANÓNICA (3E · P2, auditor A2): hasta 3D los hechos se
594
+ // canonizaban dentro del driver, así que un alias del uuid del scope
595
+ // —el mismo uuid sin guiones, que el tipo `uuid` de PostgreSQL
596
+ // resuelve a la misma fila y `assertScope` acepta— purgaba unas cosas
597
+ // y dejaba otras. Se resuelve UNA vez, aquí, y vale para todo.
598
+ //
599
+ // Y cuando NO hay cadena —la fila ya no existe, que es el orden
600
+ // soportado de `detached` (3F · S1)— no hay con qué canonizar: se
601
+ // purgan TODAS las ortografías de las que el uuid del llamante puede
602
+ // ser alias (`scopeSpellings`, 3b-2h · 🟠 3). Con la fila viva esto es
603
+ // exactamente una, la de la tabla; sin ella, la del llamante y la
604
+ // canónica que un motor pudo fundir con la suya. Antes se usaba la del
605
+ // llamante a secas: `purgeScope` demostraba cero sobre un objeto que no
606
+ // existe, devolvía OK, y el scope real seguía concediendo para siempre.
607
+ const chain = await resolveChain(this.#freshResolver(), child, 'scopes.detached');
608
+ const targets = chain ? [chain[0]] : scopeSpellings(child);
609
+ const outbox = this.#outbox();
610
+ if (outbox) {
611
+ // La identidad se resuelve AQUÍ, con la fila del consumidor todavía
612
+ // viva si la hay: al relevar el cambio ya no resolvería. Y no se
613
+ // audita `scope_purged` todavía, porque todavía no ha pasado nada.
614
+ for (const target of targets) {
615
+ await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor });
616
+ }
617
+ return;
618
+ }
619
+ const driver = await this.driver();
620
+ for (const purged of targets) {
621
+ const event = { action: 'scope_purged', scope: purged, ...actor };
622
+ await this.#write(event, () => driver.purgeScope(purged));
623
+ await driver.onScopeDetached?.(purged);
624
+ await this.#notify(event);
625
+ }
626
+ },
627
+ };
628
+ /**
629
+ * **Drena la outbox del árbol y aplica los cambios al driver** (3b-2d).
630
+ * Es lo que hay detrás de `node ace authz:scopes:relay`.
631
+ *
632
+ * Operación de PLATAFORMA, como `pruneOrphanRoles`: se salta `requireActor`
633
+ * y `requireWithin` a propósito —la policy ya se juzgó al ENCOLAR, con el
634
+ * árbol y la sesión de aquel momento— así que **no se expone por HTTP**.
635
+ * Aquí solo se propaga lo que ya se validó.
636
+ *
637
+ * Reanudable y nunca silenciosa: el reporte dice QUÉ se aplicó (no un
638
+ * contador: la pasada no es atómica), qué falló, qué se aplazó y si queda
639
+ * trabajo.
640
+ *
641
+ * **El orden del árbol importa, pero solo entre cambios que se tocan**
642
+ * (3b-2h · 🔴 2, auditor R2). Hasta el 2h la pasada PARABA en el primer
643
+ * fallo, y eso convertía una entrada que ya no se puede aplicar —el padre
644
+ * del `attached` encolado se borró antes del relevo, la arista cerraría
645
+ * ahora un ciclo, el nodo acabó con dos padres— en un **tapón permanente
646
+ * para todos los tenants**: `pending()` devuelve lo no aplicado ordenado
647
+ * por id, así que la envenenada era la cabecera de la cola en TODAS las
648
+ * pasadas siguientes y ningún cambio del árbol volvía a llegar al store
649
+ * (medido: una unit nueva nunca recibía su arista `parent`, el deny de su
650
+ * organization nunca la alcanzaba y un `detached` posterior nunca purgaba).
651
+ * Ahora un fallo **contamina los scopes que nombra**: los cambios
652
+ * posteriores que tocan alguno de ellos se APLAZAN sin intentarse (y
653
+ * contaminan a su vez, así que la dependencia es transitiva), y los demás
654
+ * se aplican. El par ordenado que importaba —`attached(P, org)` antes que
655
+ * `attached(C, P)`, `moved` antes que `detached`— sigue respetado porque
656
+ * comparten scope; lo que ya no pasa es que el tenant A congele el árbol
657
+ * del tenant B.
658
+ *
659
+ * **Escritor ÚNICO** (3b-2h · 🟠 4): si la outbox sabe dar un lease
660
+ * (`acquire`), la pasada lo toma y una segunda pasada simultánea no hace
661
+ * nada y lo dice (`busy`). Sin lease, dos pasadas trabajan sobre el mismo
662
+ * lote —`pending()` no reserva y el lote no se relee— y la rezagada
663
+ * re-aplica cambios viejos sobre el árbol nuevo.
664
+ *
665
+ * Lo que esta pieza NO arregla, y va escrito en el README con estas
666
+ * palabras: entre el commit del consumidor y esta pasada hay un lag
667
+ * (segundos) durante el cual el backend decide con el árbol VIEJO. Es un
668
+ * **fail-open temporal** —el tenant antiguo conserva acceso tras un
669
+ * `moved`, los denies heredados no aplican tras un `attached`—. No hay
670
+ * 2PC; es el precio de tener el árbol en dos sitios.
671
+ */
672
+ async relayScopeChanges(options = {}) {
673
+ // El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
674
+ // como cualquier otra escritura (lo que quede en la cola sigue ahí).
675
+ await this.#assertNotFrozen('authz:scopes:relay');
676
+ const outbox = this.#outbox();
677
+ if (!outbox) {
678
+ throw new AuthorizationConfigError("authz:scopes:relay necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada que drenar " +
679
+ '(y sin cola tampoco hay mitigación: el manager estaría escribiendo en el backend dentro de tu transacción).');
680
+ }
681
+ const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
682
+ const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
683
+ const dryRun = options.dryRun === true;
684
+ /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE. */
685
+ const dead = await _a.#deadLetters(outbox, batchSize);
686
+ if (dryRun) {
687
+ const batch = await outbox.pending(limit);
688
+ const extra = batch.length >= limit ? true : false;
689
+ return {
690
+ applied: [],
691
+ failed: null,
692
+ failures: [],
693
+ deferred: [],
694
+ dead,
695
+ busy: false,
696
+ remaining: batch.length > 0 || extra,
697
+ dryRun: true,
698
+ wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
699
+ };
700
+ }
701
+ // El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
702
+ // como hasta ahora (y el README dice que entonces el relay tiene que
703
+ // correr de uno en uno).
704
+ const lease = outbox.acquire ? await outbox.acquire() : null;
705
+ if (outbox.acquire && lease === null) {
706
+ return {
707
+ applied: [],
708
+ failed: null,
709
+ failures: [],
710
+ deferred: [],
711
+ dead,
712
+ busy: true,
713
+ remaining: (await outbox.pending(1)).length > 0,
714
+ dryRun: false,
715
+ wouldApply: [],
716
+ };
717
+ }
718
+ try {
719
+ const driver = await this.driver();
720
+ const applied = [];
721
+ const deferred = [];
722
+ const failures = [];
723
+ /** Claves de scope contaminadas: lo que las toque se aplaza. */
724
+ const blocked = new Set();
725
+ // Una outbox que no marca lo aplicado devolvería el mismo pendiente
726
+ // para siempre: el relay no puede quedarse dando vueltas ni
727
+ // "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
728
+ // vuelve a ver un id que YA aplicó.
729
+ const done = new Set();
730
+ /** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
731
+ const parked = new Set();
732
+ /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
733
+ let after;
734
+ outer: while (applied.length < limit) {
735
+ // **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
736
+ // mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
737
+ // (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
738
+ // adquiriera el freeze durable: escrituras que no salen en ningún
739
+ // contador de la pasada certificada y que pueden invalidar su
740
+ // resultado. El trade-off documentado en freeze.ts cubre «una
741
+ // escritura que ya pasó su barrera», no una pasada entera. El coste
742
+ // (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
743
+ // medidos y aceptados) va fuera del camino caliente. Un freeze
744
+ // adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
745
+ // lo ya aplicado está marcado en la outbox (la pasada es reanudable)
746
+ // y el resto sigue pendiente para después de la ventana.
747
+ await this.#assertNotFrozen('authz:scopes:relay');
748
+ const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after);
749
+ if (batch.length === 0)
750
+ break;
751
+ let progress = false;
752
+ for (const item of batch) {
753
+ const id = String(item.id);
754
+ if (done.has(id)) {
755
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
756
+ 'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
757
+ }
758
+ if (parked.has(id))
759
+ continue;
760
+ progress = true;
761
+ after = item.id;
762
+ if (applied.length >= limit)
763
+ break outer;
764
+ const keys = _a.#changeKeys(item.change);
765
+ const collision = keys.find((key) => blocked.has(key));
766
+ if (collision !== undefined) {
767
+ for (const key of keys)
768
+ blocked.add(key);
769
+ parked.add(id);
770
+ deferred.push({
771
+ id: item.id,
772
+ change: item.change,
773
+ attempts: item.attempts,
774
+ error: `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada`,
775
+ });
776
+ continue;
777
+ }
778
+ try {
779
+ await this.#applyScopeChange(driver, item);
780
+ }
781
+ catch (error) {
782
+ const message = error instanceof Error ? error.message : String(error);
783
+ await outbox.markFailed(item.id, message);
784
+ for (const key of keys)
785
+ blocked.add(key);
786
+ parked.add(id);
787
+ failures.push({ id: item.id, change: item.change, error: message });
788
+ continue;
789
+ }
790
+ await outbox.markApplied(item.id);
791
+ done.add(id);
792
+ applied.push({ id: item.id, change: item.change, attempts: item.attempts });
793
+ }
794
+ // Una outbox que ignora `after` devuelve el mismo lote atascado: la
795
+ // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
796
+ // haya detrás, sigue pendiente para la siguiente).
797
+ if (!progress)
798
+ break;
799
+ }
800
+ return {
801
+ applied,
802
+ failed: failures[0] ?? null,
803
+ failures,
804
+ deferred,
805
+ dead,
806
+ busy: false,
807
+ remaining: (await outbox.pending(1)).length > 0,
808
+ dryRun: false,
809
+ wouldApply: [],
810
+ };
811
+ }
812
+ finally {
813
+ await lease?.release();
814
+ }
815
+ }
816
+ /**
817
+ * **`authz:reconcile --to=<driver>`** (3b-3a): la ÚNICA primitiva de
818
+ * migración y verificación del paquete, y el motivo de la fase entera —
819
+ * «todo en un driver o todo en otro, con una migración idempotente y
820
+ * bidireccional». Sustituye a `openfga:import`, que el 2k borró.
821
+ *
822
+ * Operación de PLATAFORMA, como `driver()` y `relayScopeChanges`: no lleva
823
+ * actor, no mide rangos y **no se expone por HTTP**.
824
+ *
825
+ * El driver de destino se resuelve **por nombre del registro**
826
+ * (`config.drivers[to]`), no por `config.default`: la migración de verdad
827
+ * es «el motor sigue corriendo con `database` mientras se llena el store de
828
+ * `openfga`», y con el default no habría forma de nombrar al destino. Un
829
+ * driver que no sabe reconstruirse lo dice (500 `E_AUTHZ_UNSUPPORTED`
830
+ * nombrando `reconcile`); el driver `database` es ese caso: sus tablas SON
831
+ * el origen y llenarlas desde un store es la otra dirección (3b-3b).
832
+ *
833
+ * Durante la pasada que ESCRIBE, las escrituras del motor están CONGELADAS
834
+ * (`withFrozenWrites`): un `grant` que aterrizara entre la lectura del
835
+ * origen y la escritura del destino no llegaría al destino y no aparecería
836
+ * en ningún contador. Las lecturas siguen. El `finally` descongela pase lo
837
+ * que pase.
838
+ *
839
+ * **`--dry-run` NO congela** (3b-6, panel 3 · juez §3). El verificador es
840
+ * read-only por contrato: no escribe nada, así que no tiene NADA que
841
+ * proteger, y congelar ahí sería apagar las escrituras a cambio de cero.
842
+ * Está publicado para correrlo en CI y en un cron, o sea justo el sitio
843
+ * desde el que un mecanismo de indisponibilidad se dispara solo — hoy
844
+ * contra el proceso del job, y contra la flota entera el día que el freeze
845
+ * sea durable. El único contraargumento posible —que congelar estabiliza
846
+ * sus números— no vale: los números de un verificador read-only no son una
847
+ * garantía de nada.
848
+ *
849
+ * Lo que añade el manager al reporte del driver es lo único que el driver
850
+ * no puede ver: **la ventana del relay** —los cambios del árbol encolados y
851
+ * sin aplicar, que son la deriva que el store todavía no conoce (decisión
852
+ * del dueño del 2026-08-30, consecuencia 4)— y las entradas APARCADAS, que
853
+ * no son una ventana sino una divergencia permanente.
854
+ */
855
+ async reconcile(options) {
856
+ const name = options.to;
857
+ const factory = this.#config.drivers?.[name];
858
+ if (!factory) {
859
+ throw new AuthorizationConfigError(`authz:reconcile --to=${name}: ese driver no está registrado en config/authorization.ts ` +
860
+ `(registrados: ${Object.keys(this.#config.drivers ?? {}).join(', ') || 'ninguno'}). ` +
861
+ `El destino se nombra por su clave en 'drivers', no por el driver activo: migrar es llenar el ` +
862
+ `destino mientras el motor sigue corriendo con el otro.`);
863
+ }
864
+ let target = await factory();
865
+ const clock = this.#config.clock;
866
+ if (clock !== undefined) {
867
+ if (typeof target.withClock !== 'function') {
868
+ throw new AuthorizationConfigError(`config.clock está declarado pero el driver '${name}' no implementa withClock(now): la migración ` +
869
+ `escribiría caducidades decididas con otro reloj que el motor.`);
870
+ }
871
+ target = target.withClock(clock);
872
+ }
873
+ if (typeof target.reconcile !== 'function') {
874
+ throw new UnsupportedOperationError('reconcile', `authz:reconcile --to=${name}`, name, `El driver '${name}' no sabe reconstruirse desde 'authz_*' + el árbol del consumidor. ` +
875
+ `El driver 'database' es ese caso a propósito: sus tablas son el ORIGEN.`);
876
+ }
877
+ // **De dónde salen los HECHOS** (3b-5): la decisión que faltaba, y la que
878
+ // el destino no puede tomar por su cuenta. Ver `#factsOrigin`.
879
+ const origin = await this.#factsOrigin(name, target, options.from);
880
+ const source = {
881
+ enumerateEdges: this.#edgesEnumerator(),
882
+ resolveChain: this.#freshResolver(),
883
+ // Los hechos del ORIGEN, **perezosos** (3b-3b): la dirección que lee
884
+ // `authz_*` no construye ningún driver de más. Y el origen se resuelve
885
+ // UNA vez.
886
+ facts: origin.enumerate,
887
+ factsOrigin: { name: origin.name, authzTables: origin.authzTables },
888
+ };
889
+ const pass = async () => {
890
+ const report = await target.reconcile(source, options);
891
+ const { pending, dead } = await this.#relayWindow();
892
+ report.drift.pendingRelay = pending;
893
+ report.drift.deadRelay = dead;
894
+ // Quién fue el origen se DICE, siempre: es la diferencia entre una
895
+ // migración y una pasada de mantenimiento contra el driver activo.
896
+ report.factsFrom = origin.resolved();
897
+ return report;
898
+ };
899
+ // La pasada que escribe congela; el verificador NO (ver el docblock).
900
+ if (options.dryRun === true)
901
+ return pass();
902
+ // El freeze de la pasada es DURABLE y con dueño (3b-7): si este manager
903
+ // ya sostiene uno, la pasada corre DENTRO; si hay una ventana de
904
+ // OPERADOR viva (`authz:freeze`, el cutover), la pasada la reconoce como
905
+ // contexto propio —no la toma, no la renueva, no la levanta—; si el
906
+ // freeze vivo es de otro `reconcile`, 423: dos pasadas no se pisan. Y el
907
+ // reporte publica la garantía en vez de suponerla: `frozen.lapsed=true`
908
+ // significa que el lease se perdió a mitad y la pasada NO se certifica
909
+ // (el comando sale distinto de cero).
910
+ const window = await this.#durableFreezeContext(`authz:reconcile --to=${name}`, 'reconcile', {
911
+ operatorAsContext: true,
912
+ });
913
+ try {
914
+ const report = await pass();
915
+ report.frozen = {
916
+ durable: true,
917
+ lapsed: await window.lapsed(),
918
+ leaseMs: window.leaseMs,
919
+ fence: window.fence,
920
+ };
921
+ return report;
922
+ }
923
+ finally {
924
+ await window.release();
925
+ }
926
+ }
927
+ /**
928
+ * **Quién es la FUENTE DE VERDAD de los hechos de esta pasada** (3b-5, los
929
+ * dos 🔴 del auditor final de la Fase 3b). Es la pregunta que
930
+ * `authz:reconcile --to=openfga` no se hacía: leía `authz_assignments`/
931
+ * `authz_denies` SIEMPRE, y en un despliegue `hierarchy: 'facts'` esas
932
+ * tablas no son la fuente de verdad de los hechos —lo son las tuplas del
933
+ * store—, así que la pasada resucitaba lo revocado después del cutover,
934
+ * `--prune` borraba los denies vivos y el barrido de visibilidad del
935
+ * invariante 18 no se aplicaba nunca (`forbidden` salía vacío porque
936
+ * `wanted.facts` salía vacío).
937
+ *
938
+ * Las tres respuestas, en este orden:
939
+ *
940
+ * 1. **El destino es el driver ACTIVO y sus hechos son SUYOS**
941
+ * (`to === config.default` y `capabilities.hierarchyFacts`): entonces
942
+ * `authz_*` no puede ser su origen —el motor lleva desde el cutover
943
+ * escribiendo los hechos en el destino— y la pasada es de
944
+ * MANTENIMIENTO: los hechos se leen del propio destino por el puerto
945
+ * (`enumerateFacts`), se rehace lo DERIVADO (marcador, catálogo, árbol)
946
+ * y se aplica el barrido de visibilidad del invariante 18 con el árbol
947
+ * y el catálogo de HOY. No se inventa ni se borra un solo hecho. Un
948
+ * destino activo con `hierarchyFacts` que no sepa enumerar sus hechos
949
+ * es 500 `E_AUTHZ_UNSUPPORTED`: leerle `authz_*` sería justo el defecto.
950
+ * 2. **`--from=<nombre>` manda**, y por eso se resuelve YA: de la
951
+ * naturaleza de ese driver depende de dónde salen los hechos (si sabe
952
+ * `enumerateFacts`, del puerto; si no, es un driver cuyos hechos son
953
+ * `authz_*` —el `database` del paquete— y los lee el destino).
954
+ * 3. **Sin `--from` y sin ser el activo**: la MIGRACIÓN de siempre. Los
955
+ * hechos son `authz_*`, el esquema PUBLICADO del paquete, y el destino
956
+ * los lee él mismo; si el destino los pide por el puerto (`--to=database`)
957
+ * el origen se resuelve entonces, perezosamente y con la regla ruidosa
958
+ * de 3b-3b (`#factsEnumerator`).
959
+ */
960
+ async #factsOrigin(to, target, from) {
961
+ if (from === undefined && to === this.#config.default && target.capabilities?.hierarchyFacts === true) {
962
+ if (typeof target.enumerateFacts !== 'function') {
963
+ throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `El motor SIRVE desde '${to}' y ese driver declara que el árbol y los hechos viven en su backend ` +
964
+ `(hierarchyFacts), así que 'authz_assignments'/'authz_denies' NO son la fuente de verdad de sus ` +
965
+ `hechos: reconstruirlo desde ellas reescribiría lo que hayas revocado desde el cutover. Para poder ` +
966
+ `verificarlo y repararlo hace falta que sepa entregar sus hechos (enumerateFacts).`);
967
+ }
968
+ return {
969
+ name: to,
970
+ authzTables: false,
971
+ enumerate: (page) => target.enumerateFacts(page),
972
+ resolved: () => to,
973
+ };
974
+ }
975
+ if (from !== undefined) {
976
+ const driver = await this.#originDriver(from, to);
977
+ return {
978
+ name: from,
979
+ authzTables: typeof driver.enumerateFacts !== 'function',
980
+ enumerate: async (page) => {
981
+ if (typeof driver.enumerateFacts !== 'function') {
982
+ throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --from=${from}`, from, `El driver '${from}' no sabe entregar sus hechos. El driver 'database' es ese caso a propósito: ` +
983
+ `sus hechos son 'authz_assignments'/'authz_denies' y el destino los lee de ahí.`);
984
+ }
985
+ return driver.enumerateFacts(page);
986
+ },
987
+ resolved: () => from,
988
+ };
989
+ }
990
+ let resolvedName = AUTHZ_TABLES_ORIGIN;
991
+ const enumerate = await this.#factsEnumerator(to, (name) => {
992
+ resolvedName = name;
993
+ });
994
+ return { name: AUTHZ_TABLES_ORIGIN, authzTables: true, enumerate, resolved: () => resolvedName };
995
+ }
996
+ /** El driver que `--from` nombra, con los dos errores de 3b-3b intactos. */
997
+ async #originDriver(from, to) {
998
+ const registered = Object.keys(this.#config.drivers ?? {});
999
+ if (from === to) {
1000
+ throw new AuthorizationConfigError(`authz:reconcile --from=${from} --to=${to}: el origen y el destino son el mismo driver. ` +
1001
+ `Si lo que quieres es VERIFICAR y reparar lo derivado del driver activo, no lo digas con --from: ` +
1002
+ `la pasada ya lee sus hechos de él cuando es el driver por defecto.`);
1003
+ }
1004
+ const factory = this.#config.drivers?.[from];
1005
+ if (!factory) {
1006
+ throw new AuthorizationConfigError(`authz:reconcile --from=${from}: ese driver no está registrado en config/authorization.ts ` +
1007
+ `(registrados: ${registered.join(', ') || 'ninguno'}).`);
1008
+ }
1009
+ return factory();
1010
+ }
1011
+ /**
1012
+ * **Quién es el ORIGEN de `authz:reconcile --to=<destino>`** (3b-3b), y su
1013
+ * enumerador de hechos — perezoso: se resuelve la PRIMERA vez que el
1014
+ * destino lo pide, así que la dirección que lee `authz_*` (`--to=openfga`)
1015
+ * no construye ningún driver de más.
1016
+ *
1017
+ * La regla es determinista y RUIDOSA, nunca «el que haya» (`--from` lo
1018
+ * resuelve antes `#factsOrigin`, 3b-5):
1019
+ * - se busca entre los drivers registrados distintos del
1020
+ * destino los que sepan ser origen (`capabilities.enumerateFacts` o el
1021
+ * método): **exactamente uno** ⇒ ése; **ninguno** ⇒ 500
1022
+ * `E_AUTHZ_UNSUPPORTED` nombrando `enumerateFacts`; **más de uno** ⇒ 500
1023
+ * pidiendo `--from`, porque elegir por ti es elegir de dónde sale lo que
1024
+ * va a quedar escrito.
1025
+ *
1026
+ * Nunca «cero hechos» en silencio: un origen que no responde y un `--prune`
1027
+ * detrás vacían el destino, y eso no puede depender de adivinar.
1028
+ */
1029
+ async #factsEnumerator(to, onResolved) {
1030
+ let resolved = null;
1031
+ const build = async () => {
1032
+ const registered = Object.keys(this.#config.drivers ?? {});
1033
+ const candidates = [];
1034
+ for (const candidate of registered) {
1035
+ if (candidate === to)
1036
+ continue;
1037
+ const driver = await this.#config.drivers[candidate]();
1038
+ if (typeof driver.enumerateFacts === 'function')
1039
+ candidates.push({ name: candidate, driver });
1040
+ }
1041
+ if (candidates.length === 1) {
1042
+ onResolved?.(candidates[0].name);
1043
+ return candidates[0].driver;
1044
+ }
1045
+ if (candidates.length === 0) {
1046
+ throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `Ningún driver registrado (${registered.join(', ') || 'ninguno'}) sabe ser el ORIGEN de esta ` +
1047
+ `migración. Sin hechos que leer, la pasada escribiría cero y con --prune vaciaría el destino.`);
1048
+ }
1049
+ throw new AuthorizationConfigError(`authz:reconcile --to=${to}: hay más de un origen posible ` +
1050
+ `(${candidates.map((c) => c.name).join(', ')}). Dilo con --from=<driver>: de dónde salen los ` +
1051
+ `hechos decide lo que va a quedar escrito, y eso no se adivina.`);
1052
+ };
1053
+ return async (page) => {
1054
+ resolved ??= await build();
1055
+ return resolved.enumerateFacts(page);
1056
+ };
1057
+ }
1058
+ /**
1059
+ * `scopes.enumerateEdges` o 500: sin el árbol del consumidor no se puede
1060
+ * reconstruir el del backend, y suponerlo plano sería inventar una
1061
+ * jerarquía (y con ella una concesión).
1062
+ */
1063
+ #edgesEnumerator() {
1064
+ const enumerate = this.#config.scopes?.enumerateEdges;
1065
+ if (typeof enumerate !== 'function') {
1066
+ throw new AuthorizationConfigError("authz:reconcile necesita 'scopes.enumerateEdges' en config/authorization.ts: es el árbol ENTERO, " +
1067
+ 'paginado, y es lo que se migra (y lo que dice qué aristas del backend ya no respalda nadie). ' +
1068
+ 'sqlScopeEdges(...) lo implementa sobre una tabla con columna padre.');
1069
+ }
1070
+ return enumerate;
1071
+ }
1072
+ /**
1073
+ * **La ventana del relay, medida** (decisión del dueño del 2026-08-30,
1074
+ * consecuencia 4): cuántos cambios del árbol están encolados sin aplicar
1075
+ * —el backend decide con el árbol viejo mientras tanto— y cuántos están
1076
+ * APARCADOS, que ya no es una ventana sino una divergencia permanente.
1077
+ *
1078
+ * Se mide con las escrituras congeladas, así que la cola no crece durante
1079
+ * la cuenta. Sin outbox no hay ventana (el manager escribe en línea) y los
1080
+ * dos números son cero.
1081
+ */
1082
+ async #relayWindow() {
1083
+ const outbox = this.#outbox();
1084
+ if (!outbox)
1085
+ return { pending: 0, dead: 0 };
1086
+ let pending = 0;
1087
+ let after;
1088
+ for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
1089
+ const batch = await outbox.pending(DEFAULT_RELAY_BATCH, after);
1090
+ pending += batch.length;
1091
+ if (batch.length < DEFAULT_RELAY_BATCH)
1092
+ break;
1093
+ after = batch[batch.length - 1].id;
1094
+ }
1095
+ const dead = typeof outbox.dead === 'function' ? (await outbox.dead(DEFAULT_RELAY_BATCH)).length : 0;
1096
+ return { pending, dead };
1097
+ }
1098
+ /**
1099
+ * Los scopes que un cambio del árbol NOMBRA: son las claves con las que se
1100
+ * decide si otro cambio depende de él (3b-2h · 🔴 2). Dos cambios que no
1101
+ * comparten ninguna no pueden interactuar en el árbol —toda dependencia
1102
+ * (recolgar, cerrar un ciclo, purgar) viaja por un nodo nombrado—, así que
1103
+ * el orden RELATIVO que hay que conservar es exactamente este.
1104
+ */
1105
+ static #changeKeys(change) {
1106
+ return change.op === 'detached'
1107
+ ? [scopeKey(change.child)]
1108
+ : [scopeKey(change.child), scopeKey(change.parent)];
1109
+ }
1110
+ /** Lo aparcado por la outbox (si sabe aparcar), listo para el reporte. */
1111
+ static async #deadLetters(outbox, limit) {
1112
+ if (typeof outbox.dead !== 'function')
1113
+ return [];
1114
+ const rows = await outbox.dead(limit);
1115
+ return rows.map((item) => ({
1116
+ id: item.id,
1117
+ change: item.change,
1118
+ attempts: item.attempts,
1119
+ ...(item.lastError === undefined ? {} : { error: item.lastError }),
1120
+ }));
1121
+ }
1122
+ /**
1123
+ * Aplica UN cambio del árbol al driver. Es el mismo camino que
1124
+ * `scopes.*` sin outbox, incluido el orden de `detached`: **hechos primero
1125
+ * —el driver demuestra cero o lanza—, arista al final** (S6). Al revés,
1126
+ * una purga muerta a medias dejaría grants vivos en un scope sin ancestro,
1127
+ * los denies heredados dejarían de aplicar y esos permisos serían
1128
+ * INDENEGABLES (invariante 2).
1129
+ */
1130
+ async #applyScopeChange(driver, item) {
1131
+ const change = item.change;
1132
+ if (change.op === 'attached') {
1133
+ await driver.onScopeAttached?.(change.child, change.parent);
1134
+ return;
1135
+ }
1136
+ if (change.op === 'moved') {
1137
+ await driver.onScopeMoved?.(change.child, change.parent);
1138
+ return;
1139
+ }
1140
+ // La auditoría no pierde al autor por haber pasado por una cola.
1141
+ const event = {
1142
+ action: 'scope_purged',
1143
+ scope: change.child,
1144
+ ...(item.actor ? { actor: item.actor } : {}),
1145
+ };
1146
+ await this.#write(event, () => driver.purgeScope(change.child));
1147
+ await driver.onScopeDetached?.(change.child);
1148
+ await this.#notify(event);
1149
+ }
1150
+ /** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
1151
+ static #positive(value, fallback, name) {
1152
+ if (value === undefined)
1153
+ return fallback;
1154
+ if (!Number.isInteger(value) || value < 1) {
1155
+ throw new AuthorizationConfigError(`authz:scopes:relay: ${name} debe ser un entero >= 1 (llegó ${String(value)})`);
1156
+ }
1157
+ return value;
1158
+ }
1159
+ /**
1160
+ * Valida las opciones comunes de una escritura (B7) ANTES de identidad,
1161
+ * catálogo, árbol y driver: `actor` bien formado si viene; obligatorio con
1162
+ * `requireActor`. Devuelve `{ actor }` listo para fundir en el evento (o
1163
+ * `{}` si no hay actor: el evento no inventa autores).
1164
+ */
1165
+ async #writeOptions(options, operation) {
1166
+ await this.#assertNotFrozen(operation, options?.transaction);
1167
+ if (options?.actor !== undefined)
1168
+ assertSubject(options.actor);
1169
+ if (this.#config.requireActor === true && !options?.actor) {
1170
+ throw new ActorRequiredError(`${operation}: el config exige 'actor' en toda escritura (requireActor: true) y no llegó ninguno.`);
1171
+ }
1172
+ return options?.actor ? { actor: options.actor } : {};
1173
+ }
1174
+ /** El resolutor con el que LEE este manager: el memo de la vista, o el fresco. */
1175
+ #readResolverOrFresh() {
1176
+ return this.#readResolver ?? this.#freshResolver();
1177
+ }
1178
+ /**
1179
+ * El catálogo para las composiciones (B5/B3): el memo del driver si lo
1180
+ * expone (`driver.catalog`, ambos drivers del paquete: una sola carga por
1181
+ * proceso) y, si no, uno propio del manager. El catálogo es propiedad
1182
+ * local siempre (`authz_*`), así que leerlo desde el manager no acopla a
1183
+ * ningún driver.
1184
+ */
1185
+ #catalogFor(driver) {
1186
+ const shared = driver.catalog;
1187
+ if (shared instanceof CatalogCache)
1188
+ return shared;
1189
+ if (this.#parent)
1190
+ return this.#parent.#catalogFor(driver);
1191
+ this.#ownCatalog ??= new CatalogCache({ driver: this.#config.default });
1192
+ return this.#ownCatalog;
1193
+ }
1194
+ /** Un método opcional del puerto, o 500 `E_AUTHZ_UNSUPPORTED` nombrándolo. */
1195
+ #optional(driver, method, primitive, hint) {
1196
+ const fn = driver[method];
1197
+ if (typeof fn !== 'function') {
1198
+ throw new UnsupportedOperationError(method, primitive, this.#config.default, hint);
1199
+ }
1200
+ return fn.bind(driver);
1201
+ }
1202
+ /** El resolutor FRESCO del config (o solo-raíz): el de las escrituras y de `isWithin`. */
1203
+ #freshResolver() {
1204
+ return this.#config.scopes?.resolveChain ?? rootOnlyResolver;
1205
+ }
1206
+ static #sameScope(a, b) {
1207
+ return a.type === b.type && (a.uuid ?? null) === (b.uuid ?? null);
1208
+ }
1209
+ /**
1210
+ * ¿`outer` contiene a `inner`? = `outer ∈ chain(inner)`, inclusive: un scope
1211
+ * se contiene a sí mismo y `APP_SCOPE` contiene todo. Un `inner` que el
1212
+ * árbol no conoce no está dentro de nada (`false`). Siempre con el resolutor
1213
+ * fresco (nunca el memo por request): la contención decide escrituras.
1214
+ */
1215
+ async isWithin(inner, outer) {
1216
+ assertScope(inner);
1217
+ assertScope(outer);
1218
+ const chain = await resolveChain(this.#freshResolver(), inner, 'isWithin');
1219
+ if (!chain)
1220
+ return false;
1221
+ return chain.some((s) => _a.#sameScope(s, outer));
1222
+ }
1223
+ /**
1224
+ * Contención de una escritura (B1; las seis desde 2D · F2). El scope tiene
1225
+ * que existir (422 `E_AUTHZ_UNKNOWN_SCOPE`, la misma regla que el driver
1226
+ * aplicará después) y `within`, si viene, estar en su cadena (422
1227
+ * `E_AUTHZ_NOT_WITHIN`). Con `requireWithin`, omitirlo es 422
1228
+ * `E_AUTHZ_WITHIN_REQUIRED`; con `'non-root'`, `APP_SCOPE` como `within`
1229
+ * es 422 `E_AUTHZ_WITHIN_ROOT_FORBIDDEN`. Todo antes del driver: nada se
1230
+ * escribe. Siempre con el resolutor fresco (nunca el memo por request).
1231
+ */
1232
+ async #assertWithin(scope, options, operation) {
1233
+ const within = this.#requiredWithin(scope, options, operation);
1234
+ if (!within)
1235
+ return;
1236
+ const chain = await assertKnownScope(this.#freshResolver(), scope, operation);
1237
+ this.#assertWithinChain(scope, chain, options, operation);
1238
+ }
1239
+ /**
1240
+ * Contención del ORIGEN de un movimiento (2E · H1, auditor 1): la cadena
1241
+ * ACTUAL del hijo, resuelta en fresco, también tiene que contener `within`.
1242
+ * Con `'required'` (`scopes.moved`) el hijo tiene que existir en el árbol
1243
+ * (422 `E_AUTHZ_UNKNOWN_SCOPE`: sin cadena no hay origen que contrastar);
1244
+ * con `'if-known'` (`scopes.attached`) un hijo nuevo (`null`) no tiene
1245
+ * origen y pasa, y uno ya colgado se trata como un `move`. Solo cuando hay
1246
+ * `within` que contrastar: sin él no se consulta el árbol de más.
1247
+ */
1248
+ async #assertWithinOrigin(child, options, operation, presence) {
1249
+ const within = this.#requiredWithin(child, options, operation);
1250
+ if (!within)
1251
+ return;
1252
+ const resolver = this.#freshResolver();
1253
+ const chain = presence === 'required'
1254
+ ? await assertKnownScope(resolver, child, operation)
1255
+ : await resolveChain(resolver, child, operation);
1256
+ if (!chain)
1257
+ return;
1258
+ this.#assertWithinChain(child, chain, options, operation);
1259
+ }
1260
+ /** Lo mismo con una cadena ya resuelta (en fresco) por el llamante. */
1261
+ #assertWithinChain(scope, chain, options, operation) {
1262
+ const within = this.#requiredWithin(scope, options, operation);
1263
+ if (!within)
1264
+ return;
1265
+ if (!chain.some((s) => _a.#sameScope(s, within))) {
1266
+ throw new NotWithinError(`${operation}: ${scope.type}:${scope.uuid ?? ''} no está dentro de ` +
1267
+ `${within.type}:${within.uuid ?? ''} (la cadena es ${chain.map((s) => `${s.type}:${s.uuid ?? ''}`).join(' → ')}); ` +
1268
+ `no se escribe fuera del scope declarado.`);
1269
+ }
1270
+ }
1271
+ /** El `within` a contrastar, validado y exigido según `requireWithin`; `null` si no hay que contrastar nada. */
1272
+ #requiredWithin(scope, options, operation) {
1273
+ const within = options?.within;
1274
+ const policy = this.#config.requireWithin;
1275
+ if (within === undefined) {
1276
+ if (policy === true || policy === 'non-root') {
1277
+ throw new WithinRequiredError(`${operation}: el config exige 'within' (requireWithin: ${JSON.stringify(policy)}) y la escritura sobre ` +
1278
+ `${scope.type}:${scope.uuid ?? ''} no lo declara.`);
1279
+ }
1280
+ return null;
1281
+ }
1282
+ assertScope(within);
1283
+ if (policy === 'non-root' && within.type === APP_SCOPE_TYPE) {
1284
+ throw new WithinRootForbiddenError(`${operation}: el config exige un 'within' que acote (requireWithin: 'non-root') y llegó la raíz 'app', ` +
1285
+ `que contiene todo. Declara el tenant; la plataforma escribe en la raíz con manager.driver() o con una config sin el flag.`);
1286
+ }
1287
+ return within;
1288
+ }
1289
+ /**
1290
+ * La outbox del árbol, si el consumidor la declaró (3b-2d). Con ella,
1291
+ * `scopes.attached/moved/detached` NO tocan el driver: encolan el cambio
1292
+ * en la transacción del consumidor y lo aplica `authz:scopes:relay`.
1293
+ *
1294
+ * Por qué no es una recomendación sino un mecanismo (panel 2, cruce 4 ·
1295
+ * S5): sin outbox, el paquete escribe en el backend DENTRO de la
1296
+ * transacción del consumidor y un `rollback` posterior no lo deshace. El
1297
+ * árbol del backend queda adelantado al de la base del consumidor y en
1298
+ * modo `facts` eso es una escalada persistente e invisible —el backend es
1299
+ * el PDP, y la aplicación lista y audita contra su propia base—.
1300
+ *
1301
+ * Lo que la outbox NO arregla: el lag del relay. Durante esos segundos el
1302
+ * backend decide con el árbol VIEJO, y eso es un **fail-open temporal**
1303
+ * (el tenant antiguo conserva acceso tras un `moved`; los denies heredados
1304
+ * no aplican tras un `attached`). No hay 2PC.
1305
+ */
1306
+ #outbox() {
1307
+ return this.#config.scopes?.outbox;
1308
+ }
1309
+ #resolver(operation) {
1310
+ const resolver = this.#config.scopes?.resolveChain;
1311
+ if (!resolver) {
1312
+ throw new AuthorizationConfigError(`${operation} necesita 'scopes.resolveChain' en config/authorization.ts: ` +
1313
+ `sin el árbol del consumidor no se puede validar la arista.`);
1314
+ }
1315
+ return resolver;
1316
+ }
1317
+ /**
1318
+ * Valida la arista y devuelve la cadena (fresca) del padre y el HIJO
1319
+ * CANÓNICO (invariante 17). El hijo canónico se devuelve desde 3b-2d
1320
+ * porque la outbox lo encola: lo que se guarda en la cola es la fila del
1321
+ * árbol, no lo que escribió el llamante — si no, el relay abriría días
1322
+ * después una rama nueva en el store por un alias del uuid.
1323
+ */
1324
+ async #assertEdge(child, parent, operation) {
1325
+ const resolver = this.#resolver(operation);
1326
+ assertScope(child);
1327
+ assertScope(parent);
1328
+ if (child.type === APP_SCOPE_TYPE) {
1329
+ throw new InvalidIdentityError(`${operation}: la raíz \`app\` no puede colgar de nada`);
1330
+ }
1331
+ // 422 E_AUTHZ_UNKNOWN_SCOPE si el padre no existe.
1332
+ const chain = await assertKnownScope(resolver, parent, operation);
1333
+ // El hijo, si el árbol ya lo conoce, con su identidad canónica (K1): un
1334
+ // alias del uuid no puede colarse por debajo de la comprobación de ciclo.
1335
+ const known = await resolveChain(resolver, child, operation);
1336
+ const canonicalChild = known ? known[0] : child;
1337
+ const childKey = _a.#scopeKey(canonicalChild);
1338
+ if (chain.some((s) => _a.#scopeKey(s) === childKey)) {
1339
+ throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
1340
+ `colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
1341
+ }
1342
+ return { chain, child: canonicalChild };
1343
+ }
1344
+ // La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
1345
+ // pregunta mal formada (uuid ausente, `{app, uuid}`, slug con `~`) es 422
1346
+ // sin tocar catálogo, árbol ni backend, y sin que el hook `onWrite` audite
1347
+ // una escritura que no ocurrió. Los drivers repiten la misma función por
1348
+ // defensa en profundidad (el juez y un driver suelto no pasan por aquí).
34
1349
  async authorize(subject, permission, scope) {
35
- return (await this.driver()).authorize(subject, permission, scope);
1350
+ assertIdentity({ subject, permission, scope });
1351
+ return (await this.#reader()).authorize(subject, permission, scope);
36
1352
  }
37
1353
  async hasRole(subject, role, scope) {
38
- return (await this.driver()).hasRole(subject, role, scope);
1354
+ assertIdentity({ subject, role, scope });
1355
+ return (await this.#reader()).hasRole(subject, role, scope);
1356
+ }
1357
+ /**
1358
+ * `authorize` sobre varios scopes, un booleano por posición (2.1, B6).
1359
+ * Delegado al driver si trae `authorizeMany` (openfga: un batchCheck);
1360
+ * si no, `Promise.all` de `authorize` sobre una vista con los ancestros
1361
+ * memoizados (una llamada al árbol por scope distinto, aunque se repita).
1362
+ * Idéntico a N `authorize`: duplicados por posición, desconocido ⇒ false,
1363
+ * y si una posición no se puede responder, lanza entero. Vacío ⇒ `[]`
1364
+ * sin tocar backend ni árbol.
1365
+ */
1366
+ async authorizeMany(subject, permission, scopes) {
1367
+ assertIdentity({ subject, permission });
1368
+ if (!Array.isArray(scopes)) {
1369
+ throw new InvalidIdentityError(`authorizeMany: se esperaba un array de scopes y llegó ${typeof scopes}`);
1370
+ }
1371
+ for (const scope of scopes)
1372
+ assertIdentity({ scope });
1373
+ if (scopes.length === 0)
1374
+ return [];
1375
+ // Fuera de una vista, la composición abre una propia para que los N
1376
+ // scopes compartan el memo de ancestros durante esta llamada.
1377
+ const view = this.#readResolver ? this : this.forRequest();
1378
+ const driver = await view.#reader();
1379
+ if (typeof driver.authorizeMany === 'function') {
1380
+ const answer = await driver.authorizeMany(subject, permission, scopes);
1381
+ // Un `boolean[]` desalineado se leería por posición (F5, CR3): es un
1382
+ // bug del driver, no una decisión. 500 nombrando al culpable.
1383
+ if (!Array.isArray(answer) || answer.length !== scopes.length || answer.some((b) => typeof b !== 'boolean')) {
1384
+ throw new AuthorizationInternalError(`authorizeMany: el driver '${this.#config.default}' devolvió ${describeAnswer(answer)} para ${scopes.length} scopes; ` +
1385
+ `el puerto exige un boolean[] con exactamente una posición por scope.`);
1386
+ }
1387
+ return answer;
1388
+ }
1389
+ return Promise.all(scopes.map((scope) => driver.authorize(subject, permission, scope)));
39
1390
  }
1391
+ /** Holders con asignación vigente del rol en ese scope exacto. `{ uuid }` es la forma exacta (3D · M1). */
40
1392
  async listSubjects(role, scope) {
41
- return (await this.driver()).listSubjects(role, scope);
1393
+ assertIdentity({ role, scope });
1394
+ return (await this.#reader()).listSubjects(role, scope);
42
1395
  }
43
1396
  async listScopes(subject, permission) {
44
- return (await this.driver()).listScopes(subject, permission);
1397
+ assertIdentity({ subject, permission });
1398
+ return (await this.#reader()).listScopes(subject, permission);
45
1399
  }
46
1400
  async listRoles(subject, scope) {
47
- return (await this.driver()).listRoles(subject, scope);
1401
+ assertIdentity({ subject, scope });
1402
+ return (await this.#reader()).listRoles(subject, scope);
48
1403
  }
49
1404
  async listRoleScopes(subject, scopeType) {
50
- return (await this.driver()).listRoleScopes(subject, scopeType);
1405
+ assertIdentity({ subject, scopeType });
1406
+ return (await this.#reader()).listRoleScopes(subject, scopeType);
51
1407
  }
52
- async grant(subject, role, scope, options) {
53
- await (await this.driver()).grant(subject, role, scope, options);
54
- await this.#notify({
55
- action: 'granted',
56
- subject,
57
- scope,
1408
+ /** Denies directos del holder (scope exacto, o todos). 500 `E_AUTHZ_UNSUPPORTED` si el driver no lo implementa. */
1409
+ async listDenies(subject, scope) {
1410
+ assertIdentity(scope ? { subject, scope } : { subject });
1411
+ const driver = await this.#reader();
1412
+ return this.#optional(driver, 'listDenies', 'listDenies')(subject, scope);
1413
+ }
1414
+ /**
1415
+ * Permisos efectivos del holder en un scope (2.1, B5): la unión de lo que
1416
+ * conceden sus roles vigentes en toda la cadena (`listRoles` por nivel +
1417
+ * catálogo) MENOS lo denegado en cualquier nivel de la cadena
1418
+ * (`listDenies` por nivel). Es exactamente el conjunto `{ p | authorize(p) }`,
1419
+ * calculado sin preguntar permiso a permiso. Scope desconocido ⇒ `[]`.
1420
+ * Prerrequisito de `catalog/` (Fase 3).
1421
+ */
1422
+ async effectivePermissions(subject, scope) {
1423
+ assertIdentity({ subject, scope });
1424
+ const driver = await this.#reader();
1425
+ this.#optional(driver, 'listDenies', 'effectivePermissions');
1426
+ const chain = await resolveChain(this.#readResolverOrFresh(), scope, 'effectivePermissions');
1427
+ if (!chain)
1428
+ return [];
1429
+ const catalog = await this.#catalogFor(driver).view();
1430
+ const { granted } = await this.#rolesAlong(driver, subject, chain, catalog);
1431
+ const denied = await this.#deniedAlong(driver, subject, chain, 'effectivePermissions');
1432
+ return [...granted].filter((permission) => !denied.has(permission));
1433
+ }
1434
+ /**
1435
+ * Lo que los roles VIGENTES del holder conceden a lo largo de una cadena
1436
+ * (ya resuelta), y el rank más alto entre ellos (3B · B3). Roles de toda la
1437
+ * cadena en UNA lectura (`rolesInChain`, G5) o, sin el método opcional, N
1438
+ * `listRoles`.
1439
+ *
1440
+ * **Por UUID, nunca por slug (3D · M1).** `rolesInChain` devuelve
1441
+ * `CatalogRoleRef`, así que aquí se lee el rol EXACTO que el holder tiene
1442
+ * y sus permisos por uuid. El ida y vuelta por slug —resolver otra vez con
1443
+ * `roleVisible`— atribuía al holder los permisos de un homónimo: el
1444
+ * auditor lo llevó hasta una escalada completa (V1, `effectivePermissions`
1445
+ * decía `billing:write` mientras `authorize` decía `false`, y
1446
+ * `defineScopedRole` delegaba lo que el actor no tenía). Se conserva la
1447
+ * defensa en profundidad: el rol tiene que seguir en el catálogo,
1448
+ * declarado para el nivel de la asignación y visible desde ese nivel.
1449
+ *
1450
+ * Un driver de terceros sin `rolesInChain` solo sabe hablar en slugs: la
1451
+ * composición pasa por `roleVisible`, que desde M1 falla CERRADA (422
1452
+ * `E_AUTHZ_AMBIGUOUS_ROLE`) si hay homónimos visibles. Nunca elige uno.
1453
+ */
1454
+ async #rolesAlong(driver, subject, chain, catalog) {
1455
+ const keysFrom = chainKeysFrom(chain);
1456
+ const levelIndex = new Map(chain.map((s, i) => [scopeKey(s), i]));
1457
+ const roles = typeof driver.rolesInChain === 'function'
1458
+ ? await driver.rolesInChain(subject, chain)
1459
+ : await this.#rolesFromSlugs(driver, subject, chain, keysFrom, catalog);
1460
+ const granted = new Set();
1461
+ let rank = 0;
1462
+ for (const { scope: level, role } of roles) {
1463
+ if (!role)
1464
+ continue;
1465
+ const index = levelIndex.get(scopeKey(level));
1466
+ if (index === undefined)
1467
+ continue;
1468
+ const declared = catalog.roleByUuid(role.uuid);
1469
+ if (!declared || declared.scopeType !== level.type)
1470
+ continue;
1471
+ if (!isRoleVisibleWith(declared, keysFrom[index]))
1472
+ continue;
1473
+ if (declared.rank > rank)
1474
+ rank = declared.rank;
1475
+ for (const permission of catalog.rolePermissionsOf(declared.uuid))
1476
+ granted.add(permission);
1477
+ }
1478
+ return { granted, rank };
1479
+ }
1480
+ /**
1481
+ * La composición por defecto de `#rolesAlong` cuando el driver NO trae
1482
+ * `rolesInChain` (opcional en el puerto): `listRoles` devuelve slugs y hay
1483
+ * que volver del slug al catálogo. Es el camino de un driver de terceros
1484
+ * escrito para 2.0/2.1, y hasta 3E tenía dos defectos (tester 3D · R3):
1485
+ *
1486
+ * - usaba `roleVisible`, que desde M1 LANZA 422 `E_AUTHZ_AMBIGUOUS_ROLE`
1487
+ * con dos homónimos visibles: `effectivePermissions` —una LECTURA que
1488
+ * promete una lista— explotaba con un 422 en cuanto un `scopes.moved`
1489
+ * legítimo juntaba dos roles del mismo nombre;
1490
+ * - y elegir uno sería la escalada del auditor V1 (atribuir al holder los
1491
+ * permisos del homónimo que NO tiene).
1492
+ *
1493
+ * La salida es no elegir NI adivinar: con homónimos visibles se pregunta
1494
+ * al driver por `{ uuid }` —resolución exacta, parte del puerto desde 3D ·
1495
+ * M1— cuál tiene de verdad. Cuesta una consulta más por slug ambiguo (que
1496
+ * es deriva y el diff la reporta), y solo en drivers sin `rolesInChain`.
1497
+ */
1498
+ async #rolesFromSlugs(driver, subject, chain, keysFrom, catalog) {
1499
+ const roles = [];
1500
+ for (const [index, level] of chain.entries()) {
1501
+ for (const slug of await driver.listRoles(subject, level)) {
1502
+ const visible = catalog.rolesNamed(slug, level.type).filter((role) => isRoleVisibleWith(role, keysFrom[index]));
1503
+ if (visible.length === 1) {
1504
+ roles.push({ scope: level, role: visible[0] });
1505
+ continue;
1506
+ }
1507
+ for (const role of visible) {
1508
+ if (await driver.hasRole(subject, { uuid: role.uuid }, level))
1509
+ roles.push({ scope: level, role });
1510
+ }
1511
+ }
1512
+ }
1513
+ return roles;
1514
+ }
1515
+ /**
1516
+ * El/los rol(es) a los que apunta un `RoleQuery` en un scope, para el
1517
+ * EVENTO de auditoría (3E · Q7). Best-effort a propósito: NUNCA cambia el
1518
+ * resultado de la escritura —lo que decide es el driver, con su catálogo y
1519
+ * su árbol—, solo enriquece lo que se notifica. Si algo no cuadra (scope
1520
+ * que el árbol no conoce, rol fuera del catálogo, ambigüedad en un grant)
1521
+ * el evento sale sin `roles` y el driver dirá lo que corresponda.
1522
+ *
1523
+ * Solo se calcula si hay un `onWrite` que lo vaya a leer: sin hook no
1524
+ * cuesta ni una consulta.
1525
+ */
1526
+ async #resolvedRoles(role, scope, operation) {
1527
+ if (!this.#config.hooks?.onWrite)
1528
+ return undefined;
1529
+ try {
1530
+ const driver = await this.driver();
1531
+ const chain = await resolveChain(this.#freshResolver(), scope, operation);
1532
+ if (!chain)
1533
+ return undefined;
1534
+ const catalog = await this.#catalogFor(driver).view();
1535
+ const target = chain[0];
1536
+ const keys = chainKeysFrom(chain)[0];
1537
+ const query = normalizeRoleQuery(role);
1538
+ if (query.uuid !== undefined) {
1539
+ const declared = catalog.roleByUuid(query.uuid);
1540
+ if (!declared || declared.scopeType !== target.type || !isRoleVisibleWith(declared, keys))
1541
+ return undefined;
1542
+ return [declared];
1543
+ }
1544
+ if (query.scopeType !== undefined && query.scopeType !== target.type)
1545
+ return undefined;
1546
+ const visible = catalog.rolesNamed(query.slug, target.type).filter((r) => isRoleVisibleWith(r, keys));
1547
+ if (visible.length === 0)
1548
+ return undefined;
1549
+ // Un `revoke` por slug quita los hechos de TODOS los homónimos del
1550
+ // scope, así que el evento los lleva todos; un `grant` ambiguo no
1551
+ // llega a escribir (422), y el evento no elige por él.
1552
+ return operation === 'revoke' || visible.length === 1 ? visible : undefined;
1553
+ }
1554
+ catch {
1555
+ return undefined;
1556
+ }
1557
+ }
1558
+ /** Permisos DENEGADOS al holder en algún scope de la cadena: `listDenies` en UNA lectura (500 `E_AUTHZ_UNSUPPORTED` sin él). */
1559
+ async #deniedAlong(driver, subject, chain, primitive) {
1560
+ const listDenies = this.#optional(driver, 'listDenies', primitive);
1561
+ const key = _a.#scopeKey;
1562
+ const chainKeys = new Set(chain.map(key));
1563
+ const denied = new Set();
1564
+ for (const deny of await listDenies(subject)) {
1565
+ if (chainKeys.has(key(deny.scope)))
1566
+ denied.add(deny.permission);
1567
+ }
1568
+ return denied;
1569
+ }
1570
+ // ── Roles locales a un scope (3B · B3): la API de DELEGACIÓN ─────────
1571
+ // Un administrador de un scope (el actor) define roles que solo existen
1572
+ // dentro de ese scope (owner) con permisos que él mismo tiene efectivos
1573
+ // ahí y que la plataforma declaró delegables, por debajo de su rank. Es
1574
+ // policy de ESCRITURA (composición): `authorize` no cambia (invariantes
1575
+ // 1, 2, 8). Todo se resuelve en FRESCO (auditor C3): una vista de
1576
+ // `forRequest` con la cadena vieja no puede delegar en una unit que ya
1577
+ // cambió de tenant. Escribe con `withAuthzCatalogWrite`: la versión
1578
+ // compartida sube como última sentencia y los demás procesos ven el rol
1579
+ // en su siguiente pregunta (B7).
1580
+ /**
1581
+ * Define un rol LOCAL a `ownerScope`. Policy, en este orden y antes de
1582
+ * escribir nada: `actor` obligatorio y bien formado; `ownerScope` válido,
1583
+ * no la raíz (los roles de la raíz son globales: config + sync) y conocido
1584
+ * por el árbol (fresco); `spec` bien formado (slug, nivel ≠ `app`, rank
1585
+ * entero, permisos); cada permiso en `config.delegablePermissions`, en el
1586
+ * catálogo, componible en ese nivel (`assignableAt`, B5) y EFECTIVO para
1587
+ * el actor en el owner (lo concede un rol suyo de la cadena y no lo tiene
1588
+ * denegado en ella — C2); `0 < rank < min(rank del actor, rank máximo
1589
+ * global)`; y ningún rol `(slug, scopeType)` visible en el owner (global,
1590
+ * o local a un ancestro) ni local a un descendiente (colisión, 422
1591
+ * `E_AUTHZ_CATALOG_CONFLICT`, re-comprobada dentro de la transacción
1592
+ * serializada — 3D · M2). `options.within` contiene la escritura contra el
1593
+ * OWNER y `requireWithin` la exige, como en las otras ocho (3D · M3).
1594
+ * Devuelve el rol y notifica `role_defined`.
1595
+ */
1596
+ async defineScopedRole(actor, ownerScope, spec, options) {
1597
+ const who = await this.#requireActor(actor, 'defineScopedRole');
1598
+ this.#assertOwnerScope(ownerScope, 'defineScopedRole');
1599
+ const parsed = this.#parseScopedRoleSpec(spec);
1600
+ const driver = await this.driver();
1601
+ // 3E · P4 (code-review): un rol local que este driver no sabrá purgar es
1602
+ // estado que NADA puede borrar — `deleteScopedRole` responde 500 y
1603
+ // `scopes.detached` de ese scope (y de cualquier ancestro que lo
1604
+ // arrastre) queda muerto para siempre, hechos incluidos. Se dice ANTES
1605
+ // de crear nada, no al intentar deshacerlo.
1606
+ this.#optional(driver, 'purgeRole', 'defineScopedRole', 'Los roles locales a un scope necesitan poder purgarse; en el driver openfga llegan con el modo `facts` (fase 3b).');
1607
+ const chain = await assertKnownScope(this.#freshResolver(), ownerScope, 'defineScopedRole');
1608
+ // Contención (3D · M3, auditor V4): es la SÉPTIMA escritura y hasta 3C no
1609
+ // la cubría `requireWithin`, así que un holder con un rol en la RAÍZ
1610
+ // creaba roles dentro de cualquier tenant (squatting de slugs incluido)
1611
+ // con el `ownerScope` que le llegara en el cuerpo de la petición. La
1612
+ // cadena ya está resuelta en fresco: se contrasta contra ella.
1613
+ this.#assertWithinChain(ownerScope, chain, options, 'defineScopedRole');
1614
+ const owner = chain[0];
1615
+ const ownerKey = scopeKey(owner);
1616
+ const catalog = await this.#catalogFor(driver).view();
1617
+ // Composición y lista blanca antes de leer hechos: lo barato primero.
1618
+ this.#assertComposable(parsed.permissions, parsed, catalog);
1619
+ await this.#assertLevelUnderOwner(parsed.scopeType, chain, 'defineScopedRole');
1620
+ const { granted, rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
1621
+ const denied = await this.#deniedAlong(driver, who, chain, 'defineScopedRole');
1622
+ this.#assertDelegable(parsed.permissions, granted, denied, owner);
1623
+ this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
1624
+ const shadowedByAncestor = await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, catalog.rolesNamed(parsed.slug, parsed.scopeType));
1625
+ this.#assertAboveShadowed(actorRank, shadowedByAncestor, 'defineScopedRole');
1626
+ const uuid = uuidv7();
1627
+ const permissionUuids = parsed.permissions.map((slug) => catalog.permission(slug).uuid);
1628
+ await this.#writeCatalog(async (trx) => {
1629
+ // La colisión, OTRA VEZ, dentro de la transacción SERIALIZADA (M2) y
1630
+ // contra la BASE: entre el chequeo de arriba y este hubo un
1631
+ // `resolveChain` y dos lecturas al driver —cientos de ms con un árbol
1632
+ // SQL— y el memo no ve lo que otro proceso confirmó en esa ventana.
1633
+ const rows = await trx
1634
+ .from('authz_roles')
1635
+ .where('slug', parsed.slug)
1636
+ .where('scope_type', parsed.scopeType)
1637
+ .select('owner_scope_key');
1638
+ const known = new Set(catalog.rolesNamed(parsed.slug, parsed.scopeType).map((r) => r.owner));
1639
+ const fresh = [...new Set(rows.map((r) => String(r.owner_scope_key)).filter((o) => !known.has(o)))];
1640
+ if (fresh.length) {
1641
+ // Los homónimos que el memo no tenía son de una escritura confirmada
1642
+ // mientras validábamos. Los que se pueden juzgar SIN salir de la
1643
+ // transacción (global, o un ancestro-o-igual del owner: la cadena ya
1644
+ // está resuelta) dan el 422 preciso; el resto se rechaza igual —
1645
+ // resolver su cadena aquí dentro pediría otra conexión mientras se
1646
+ // sostiene el cerrojo del catálogo (con un pool de 1, un abrazo
1647
+ // mortal). Reintentar es correcto: el memo ya está invalidado y el
1648
+ // segundo intento valida con la foto buena.
1649
+ await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, fresh.map((o) => ({ owner: o })), 'sin-árbol');
1650
+ throw new CatalogConflictError(`El catálogo cambió mientras se validaba '${parsed.slug}@${parsed.scopeType}': apareció un rol con ese nombre ` +
1651
+ `(owner ${fresh.join(', ')}) que no estaba en la foto con la que se comprobaron las colisiones. ` +
1652
+ `No se escribe a ciegas; reintenta la operación.`);
1653
+ }
1654
+ const now = systemClock();
1655
+ await trx.table('authz_roles').insert({
1656
+ uuid,
1657
+ slug: parsed.slug,
1658
+ name: parsed.name,
1659
+ description: parsed.description,
1660
+ scope_type: parsed.scopeType,
1661
+ rank: parsed.rank,
1662
+ owner_scope_key: ownerKey,
1663
+ created_at: now,
1664
+ updated_at: now,
1665
+ });
1666
+ for (const permissionUuid of permissionUuids) {
1667
+ await trx.table('authz_role_permissions').insert({ uuid: uuidv7(), role_uuid: uuid, permission_uuid: permissionUuid, created_at: now });
1668
+ }
1669
+ });
1670
+ const role = Object.freeze({ uuid, slug: parsed.slug, scopeType: parsed.scopeType, owner: ownerKey, rank: parsed.rank });
1671
+ // La proyección derivada del driver, si la tiene (3b-2e · E4): en el modo
1672
+ // `facts` lo que un rol concede son TUPLAS, así que un rol definido sin
1673
+ // proyectar no concedería nada — un no-op silencioso. Va después del
1674
+ // commit del catálogo y antes de notificar.
1675
+ await driver.projectCatalogRole?.(uuid);
1676
+ await this.#notifyCatalog({
1677
+ action: 'role_defined',
1678
+ actor: who,
58
1679
  role,
59
- expiresAt: options?.expiresAt ?? null,
1680
+ owner,
1681
+ permissions: [...parsed.permissions].sort(),
1682
+ ...(shadowedByAncestor.length ? { shadowedByAncestor } : {}),
60
1683
  });
1684
+ return role;
1685
+ }
1686
+ /**
1687
+ * Cambia `name`/`description`/`rank`/`permissions` de un rol LOCAL (nunca
1688
+ * su slug, nivel ni owner). Un global es 422 `E_AUTHZ_ROLE_IMMUTABLE`. El
1689
+ * actor tiene que tener, en el owner del rol, rank MAYOR que el del rol
1690
+ * (no se toca un rol de rango ≥ al propio) y la misma policy que al
1691
+ * definir para lo que cambia: los permisos nuevos delegables/efectivos y
1692
+ * componibles, el rank nuevo por debajo del suyo. Sin cambios reales no
1693
+ * escribe ni notifica (idempotente). Notifica `role_updated`.
1694
+ */
1695
+ async updateScopedRole(actor, roleUuid, changes, options) {
1696
+ const who = await this.#requireActor(actor, 'updateScopedRole');
1697
+ assertCatalogUuid('rol', roleUuid);
1698
+ const parsed = this.#parseScopedRoleChanges(changes);
1699
+ const driver = await this.driver();
1700
+ const catalog = await this.#catalogFor(driver).view();
1701
+ const role = this.#localRoleOrFail(catalog, roleUuid);
1702
+ const owner = this.#ownerOf(role);
1703
+ const chain = await assertKnownScope(this.#freshResolver(), owner, 'updateScopedRole');
1704
+ // El scope contrastado es el OWNER del rol (3D · M3): editar un rol es
1705
+ // escribir dentro de su contenedor.
1706
+ this.#assertWithinChain(owner, chain, options, 'updateScopedRole');
1707
+ const current = [...catalog.rolePermissionsOf(role.uuid)].sort();
1708
+ const next = {
1709
+ name: parsed.name ?? null,
1710
+ description: parsed.description,
1711
+ rank: parsed.rank ?? role.rank,
1712
+ permissions: parsed.permissions ?? current,
1713
+ };
1714
+ if (parsed.permissions)
1715
+ this.#assertComposable(parsed.permissions, role, catalog);
1716
+ // 3E · P1: el nivel no cambia por esta API, pero un rol cuyo nivel está
1717
+ // POR ENCIMA de su owner (creado antes de 3E o a mano) no se perpetúa:
1718
+ // es una mina de slug y lo que toca es purgarlo, no editarlo.
1719
+ await this.#assertLevelUnderOwner(role.scopeType, chain, 'updateScopedRole');
1720
+ const { granted, rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
1721
+ this.#assertAboveRole(actorRank, role);
1722
+ // 3G · W3: este rol puede estar ENSOMBRECIENDO al homónimo de un
1723
+ // descendiente (3F · S3). Cambiarle rank o permisos es seguir ejerciendo
1724
+ // esa autoridad, así que exige lo mismo que crearlo: superarlo en rango.
1725
+ this.#assertAboveShadowed(actorRank, await this.#shadowedBelow(scopeKey(owner), new Set(chain.map(scopeKey)), catalog.rolesNamed(role.slug, role.scopeType).filter((other) => other.uuid !== role.uuid), 'updateScopedRole'), 'updateScopedRole');
1726
+ if (parsed.permissions) {
1727
+ const denied = await this.#deniedAlong(driver, who, chain, 'updateScopedRole');
1728
+ this.#assertDelegable(parsed.permissions, granted, denied, owner);
1729
+ }
1730
+ if (parsed.rank !== undefined)
1731
+ this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
1732
+ const nextPermissions = [...next.permissions].sort();
1733
+ const permissionsChanged = nextPermissions.join('\u001f') !== current.join('\u001f');
1734
+ const wanted = new Set(nextPermissions.map((slug) => catalog.permission(slug).uuid));
1735
+ const changed = await this.#writeCatalog(async (trx) => {
1736
+ const row = (await trx.from('authz_roles').where('uuid', role.uuid).select('name', 'description', 'rank'))[0];
1737
+ if (!row)
1738
+ throw new UnknownRoleError(role.uuid);
1739
+ const patch = {};
1740
+ if (next.name !== null && next.name !== row.name)
1741
+ patch.name = next.name;
1742
+ if (next.description !== undefined && (next.description ?? null) !== (row.description ?? null))
1743
+ patch.description = next.description;
1744
+ if (next.rank !== Number(row.rank))
1745
+ patch.rank = next.rank;
1746
+ let touched = false;
1747
+ if (Object.keys(patch).length) {
1748
+ await trx.from('authz_roles').where('uuid', role.uuid).update({ ...patch, updated_at: systemClock() });
1749
+ touched = true;
1750
+ }
1751
+ if (permissionsChanged) {
1752
+ const links = await trx.from('authz_role_permissions').where('role_uuid', role.uuid).select('uuid', 'permission_uuid');
1753
+ const linked = new Set(links.map((l) => l.permission_uuid));
1754
+ const stale = links.filter((l) => !wanted.has(l.permission_uuid));
1755
+ if (stale.length) {
1756
+ await trx
1757
+ .from('authz_role_permissions')
1758
+ .whereIn('uuid', stale.map((l) => l.uuid))
1759
+ .delete();
1760
+ }
1761
+ for (const permissionUuid of wanted) {
1762
+ if (linked.has(permissionUuid))
1763
+ continue;
1764
+ await trx.table('authz_role_permissions').insert({ uuid: uuidv7(), role_uuid: role.uuid, permission_uuid: permissionUuid, created_at: systemClock() });
1765
+ }
1766
+ touched = true;
1767
+ }
1768
+ return touched;
1769
+ }, { skipIfNoop: true });
1770
+ const updated = Object.freeze({ ...role, rank: next.rank });
1771
+ // 3b-2e · E4: quitarle un permiso a un rol tiene que dejar de conceder
1772
+ // también en el driver que proyecta el catálogo como tuplas. Sin esto la
1773
+ // tupla `permits_<P>` sobrevive al vínculo y el rol sigue concediendo lo
1774
+ // que ya no vincula: fail-open.
1775
+ if (changed && permissionsChanged)
1776
+ await driver.projectCatalogRole?.(role.uuid);
1777
+ if (changed)
1778
+ await this.#notifyCatalog({ action: 'role_updated', actor: who, role: updated, owner, permissions: nextPermissions });
1779
+ return updated;
1780
+ }
1781
+ /**
1782
+ * Purga un rol LOCAL: sus asignaciones en todos los scopes, sus vínculos y
1783
+ * el rol (`driver.purgeRole`, B4; 500 `E_AUTHZ_UNSUPPORTED` en un driver
1784
+ * que no lo trae, sin tocar nada). Un global es 422
1785
+ * `E_AUTHZ_ROLE_IMMUTABLE`; el actor necesita rank MAYOR que el del rol en
1786
+ * su owner. Notifica `role_purged`. No necesita `listDenies`.
1787
+ */
1788
+ async deleteScopedRole(actor, roleUuid, options) {
1789
+ const who = await this.#requireActor(actor, 'deleteScopedRole');
1790
+ assertCatalogUuid('rol', roleUuid);
1791
+ const driver = await this.driver();
1792
+ const purgeRole = this.#optional(driver, 'purgeRole', 'deleteScopedRole');
1793
+ const catalog = await this.#catalogFor(driver).view();
1794
+ const role = this.#localRoleOrFail(catalog, roleUuid);
1795
+ const owner = this.#ownerOf(role);
1796
+ const chain = await assertKnownScope(this.#freshResolver(), owner, 'deleteScopedRole');
1797
+ this.#assertWithinChain(owner, chain, options, 'deleteScopedRole');
1798
+ const { rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
1799
+ this.#assertAboveRole(actorRank, role);
1800
+ const permissions = [...catalog.rolePermissionsOf(role.uuid)].sort();
1801
+ try {
1802
+ await purgeRole(role.uuid);
1803
+ }
1804
+ finally {
1805
+ invalidateAuthzCatalog();
1806
+ }
1807
+ await this.#notifyCatalog({ action: 'role_purged', actor: who, role, owner, permissions });
1808
+ }
1809
+ /**
1810
+ * Los roles LOCALES cuyo owner el árbol YA NO conoce, y —con `force`— su
1811
+ * purga. Es el motor de `authz:catalog:prune-orphans` (3b-0 · Z2).
1812
+ *
1813
+ * Un rol así está DORMIDO, y «dormido» significa **exactamente** esto
1814
+ * (3b-0b · AA1, auditor 3b-0): no es visible desde ningún scope vivo cuya
1815
+ * cadena NO pase por su owner. No significa que no conceda. La regla única
1816
+ * de visibilidad (invariante 18) pide que el owner esté en la cadena del
1817
+ * scope preguntado, y **un descendiente vivo cuya ruta materializada sigue
1818
+ * pasando por el owner la cumple**: ahí el rol concede, es membresía por
1819
+ * los seis caminos de lectura y se puede ASIGNAR, por slug y por uuid.
1820
+ * Ocurre en cuanto el consumidor borra la fila del owner sin borrar (o sin
1821
+ * notificar) la de sus descendientes — el borrado en dos pasos y las rutas
1822
+ * materializadas son lo normal. Por eso este barrido es destructivo de
1823
+ * verdad y por eso `--dry-run` es el default: puede estar revocando
1824
+ * permisos VIVOS, no recogiendo basura inerte. Lo que sí es seguro decir:
1825
+ * un rol huérfano SIN asignaciones vigentes no concede nada, y ninguno
1826
+ * concede en un scope cuya cadena no pase por el owner.
1827
+ *
1828
+ * Cada huérfano viene con `assignments` (hechos vigentes) y
1829
+ * `stillGranting` (`assignments > 0`), que es la marca CONSERVADORA de
1830
+ * «esto no es basura inerte»: cuenta hechos, no comprueba si el scope de
1831
+ * cada uno sigue resolviendo. Falso ⇒ no concede seguro; verdadero ⇒
1832
+ * míralo antes de `--force`.
1833
+ *
1834
+ * **Y esos hechos se los cuenta el DRIVER** (3b-2j, decisión del dueño del
1835
+ * 2026-08-31 (3)), con `countRoleAssignments` del puerto. Hasta aquí los
1836
+ * contaba el propio barrido en `authz_assignments` —la tabla del driver
1837
+ * `database`—, así que con `openfga` en modo `facts`, donde los hechos
1838
+ * viven en el store, `stillGranting` era SIEMPRE `false`: el barrido
1839
+ * declaraba basura inerte, justo antes de un borrado destructivo, un rol
1840
+ * que estaba concediendo (medido en el lote 2i). Un driver que no traiga
1841
+ * el método deja los DOS campos en **`undefined`**, nunca en `false`: «no
1842
+ * lo sé» no puede degradar a «no concede», que es exactamente el bug. Con
1843
+ * `undefined` el rol no es demostrablemente inerte y el comando lo lista
1844
+ * APARTE, igual que a los que sí conceden.
1845
+ *
1846
+ * Lo que el rol dormido sí hace en todo caso es ocupar su `(slug, nivel)`
1847
+ * dentro del subárbol donde todavía se le vea, y `deleteScopedRole` no lo
1848
+ * alcanza (resuelve el owner en fresco y responde 422
1849
+ * `E_AUTHZ_UNKNOWN_SCOPE`). Hasta 3G esa limpieza la arrastraba
1850
+ * `scopes.detached`, y ahí es donde nacieron tres de las cuatro
1851
+ * regresiones de la Fase 3: la operación la dispara un TENANT, sobre un
1852
+ * scope que ya no resuelve, así que hubo que inventarle una policy de
1853
+ * rango sin cadena donde medirla, una enumeración del subárbol y una
1854
+ * degradación — tres piezas que compuestas destruían roles de
1855
+ * descendientes VIVOS. Aquí no hay nada de eso: es una operación de
1856
+ * PLATAFORMA (una tarea de mantenimiento con acceso al catálogo, como
1857
+ * `authz:catalog:sync`), no lleva actor y no mide rangos, exactamente como
1858
+ * el `purgeRole` de último recurso que el README ya prometía. Es, junto a
1859
+ * `driver()`, **API de plataforma**: se salta `requireActor` y
1860
+ * `requireWithin` a propósito, así que no se expone a un controlador.
1861
+ *
1862
+ * `force: false` (el default, y el del comando: `--dry-run`) NO escribe:
1863
+ * devuelve la lista para que un humano la mire. Con `force: true` cada rol
1864
+ * se purga con `purgeRole` —atómico: asignaciones + vínculos + fila +
1865
+ * versión del catálogo— y se notifica `role_purged` (sin `actor`). El
1866
+ * conjunto no es atómico, y por eso el reporte dice QUÉ se purgó
1867
+ * (`purged: CatalogRoleRef[]`, 3b-0b · AB3) y no cuántos: si un
1868
+ * `purgeRole` falla a mitad, lo anterior ya está borrado —con el hallazgo
1869
+ * de AA1 eso puede ser revocación parcial de permisos vivos— y quien
1870
+ * recoge el 503 necesita la lista, no un contador. Una pasada
1871
+ * interrumpida la recoge la siguiente (el orden es estable por uuid).
1872
+ *
1873
+ * **Dos seguros contra el barrido a ciegas**, que es el riesgo real
1874
+ * (auditor 3b-0):
1875
+ *
1876
+ * - **Cota de purga masiva** (AA2): si TODOS los owners distintos
1877
+ * resultan huérfanos, o si los huérfanos superan el 50 % de los roles
1878
+ * locales, `force` es 500 `E_AUTHZ_MASS_PURGE_REFUSED` **antes de
1879
+ * borrar nada**. Esa es la firma de un `resolveChain` filtrado por el
1880
+ * tenant de la petición o corriendo sin contexto (comando, réplica
1881
+ * atrasada): devuelve `null` para todo y la pasada se lleva el catálogo
1882
+ * local de TODOS los tenants (medido: 2 de 2 roles vivos). Una poda
1883
+ * grande de verdad pasa con `allowMassPurge: true`
1884
+ * (`--allow-mass-purge`), que es una decisión humana. El `--dry-run` no
1885
+ * lanza —es justo el diagnóstico que hay que poder mirar— pero lo
1886
+ * marca en `massPurge`.
1887
+ * - **Re-resolución justo antes de cada purga** (AA3): entre la lectura y
1888
+ * el borrado cabe un `scopes.attached`/restore concurrente, y la
1889
+ * ventana es TODA la pasada (N roles + N `resolveChain`), no un
1890
+ * instante. Cada owner se vuelve a resolver en FRESCO inmediatamente
1891
+ * antes de su `purgeRole`; si ha vuelto, el rol se salta y se cuenta en
1892
+ * `skipped` con `reason: 'owner-came-back'`.
1893
+ *
1894
+ * Coste: una lectura del catálogo local + un `resolveChain` por OWNER
1895
+ * DISTINTO (memoizado) + UNA llamada a `countRoleAssignments` con los
1896
+ * uuids de los huérfanos (ninguna si no hay) + un `resolveChain` más por
1897
+ * rol purgado (el de AA3). Es O(owners con roles locales) para mirar y
1898
+ * O(roles purgados) para borrar, y corre en un comando, no en el camino de
1899
+ * una petición.
1900
+ */
1901
+ async pruneOrphanRoles(options = {}) {
1902
+ const force = options.force === true;
1903
+ this.#resolver('authz:catalog:prune-orphans');
1904
+ const driver = await this.driver();
1905
+ // Antes de leer nada: un driver que no sabe purgar lo dice, no se
1906
+ // descubre a mitad de la pasada (3E · P4). Y antes que la barrera del
1907
+ // freeze (3b-7): «no sé purgar» es permanente y se dice sin consultar
1908
+ // NADA (la promesa medida en 3b-1); «estás congelado» es transitorio.
1909
+ const purgeRole = this.#optional(driver, 'purgeRole', 'pruneOrphanRoles');
1910
+ if (force)
1911
+ await this.#assertNotFrozen('authz:catalog:prune-orphans');
1912
+ const resolver = this.#freshResolver();
1913
+ const locals = await readLocalRoles({ driver: this.#config.default });
1914
+ const resolved = new Map();
1915
+ const orphans = [];
1916
+ for (const { role, permissions } of locals) {
1917
+ const owner = this.#ownerOf(role);
1918
+ if (!resolved.has(role.owner)) {
1919
+ resolved.set(role.owner, (await resolveChain(resolver, owner, 'pruneOrphanRoles')) !== null);
1920
+ }
1921
+ if (resolved.get(role.owner))
1922
+ continue;
1923
+ orphans.push({ role, owner, permissions, assignments: undefined, stillGranting: undefined });
1924
+ }
1925
+ // Los hechos son del DRIVER, no de una tabla (3b-2j). Sin el método del
1926
+ // puerto los dos campos se quedan en `undefined`: el barrido no lo sabe y
1927
+ // lo dice, en vez de degradar a «no concede».
1928
+ if (orphans.length > 0 && typeof driver.countRoleAssignments === 'function') {
1929
+ const counts = await driver.countRoleAssignments(orphans.map(({ role }) => role.uuid));
1930
+ if (!Array.isArray(counts) || counts.length !== orphans.length) {
1931
+ throw new AuthorizationInternalError(`countRoleAssignments: el driver '${this.#config.default}' respondió ${Array.isArray(counts) ? counts.length : typeof counts} ` +
1932
+ `valor(es) para ${orphans.length} rol(es). La respuesta es POR POSICIÓN y esto se lee antes de borrar: no se ` +
1933
+ `adivina cuál era de quién.`);
1934
+ }
1935
+ counts.forEach((total, i) => {
1936
+ if (!Number.isInteger(total) || total < 0) {
1937
+ throw new AuthorizationInternalError(`countRoleAssignments: el driver '${this.#config.default}' respondió '${total}' para el rol ` +
1938
+ `'${orphans[i].role.slug}' (${orphans[i].role.uuid}); se espera un entero ≥ 0.`);
1939
+ }
1940
+ orphans[i].assignments = total;
1941
+ orphans[i].stillGranting = total > 0;
1942
+ });
1943
+ }
1944
+ const owners = new Set(locals.map(({ role }) => role.owner));
1945
+ const orphanOwners = new Set(orphans.map(({ role }) => role.owner));
1946
+ const massPurge = orphans.length > 0 && (orphanOwners.size === owners.size || orphans.length * 2 > locals.length);
1947
+ if (!force)
1948
+ return { orphans, purged: [], skipped: [], massPurge, dryRun: true };
1949
+ if (massPurge && options.allowMassPurge !== true) {
1950
+ throw new MassPurgeRefusedError(`pruneOrphanRoles: ${orphans.length} de ${locals.length} roles locales (${orphanOwners.size} de ${owners.size} ` +
1951
+ `owners distintos) tienen el owner fuera del árbol. Esa es la firma de un 'scopes.resolveChain' ciego —filtrado ` +
1952
+ `por el tenant de la petición, o sin contexto— que devuelve null para todo: una pasada así borra el catálogo ` +
1953
+ `local de todos los tenants. No se ha borrado nada. Comprueba el resolutor y, si la poda es real, repite con ` +
1954
+ `allowMassPurge: true (--allow-mass-purge).`);
1955
+ }
1956
+ const purged = [];
1957
+ const skipped = [];
1958
+ for (const { role, owner, permissions } of orphans) {
1959
+ // AA3: la ventana entre leer y borrar es toda la pasada. El owner se
1960
+ // vuelve a resolver EN FRESCO aquí mismo; si ha vuelto (un
1961
+ // `scopes.attached`, un restore, una réplica que se pone al día) este
1962
+ // rol ya no es huérfano y no se toca.
1963
+ if ((await resolveChain(this.#freshResolver(), owner, 'pruneOrphanRoles')) !== null) {
1964
+ skipped.push({ role, reason: 'owner-came-back' });
1965
+ continue;
1966
+ }
1967
+ try {
1968
+ // 3b-8 · B3 (mismo patrón que el relay): la ventana de la pasada es
1969
+ // larga (N roles × resolveChain) y la mirada única de la entrada
1970
+ // dejaba purgas destructivas DESPUÉS de un freeze adquirido a mitad.
1971
+ // Se re-afirma por rol, ANTES de cada borrado; el 503 sale envuelto
1972
+ // en PruneInterruptedError para que viaje la lista de lo YA purgado.
1973
+ await this.#assertNotFrozen('authz:catalog:prune-orphans');
1974
+ await purgeRole(role.uuid);
1975
+ }
1976
+ catch (error) {
1977
+ // La purga no es transaccional ENTRE roles: lo ya borrado está
1978
+ // borrado. El valor de retorno no llega a producirse, así que la
1979
+ // lista viaja en el error (tester 3b-1 §6.2) y el del driver va como
1980
+ // `cause`: la abstracción no filtra.
1981
+ throw new PruneInterruptedError(`pruneOrphanRoles: '${role.slug}' (nivel '${role.scopeType}') no se pudo purgar. ` +
1982
+ `Los ${purged.length} rol(es) anteriores YA están borrados y no se deshacen; el resto sigue vivo. ` +
1983
+ `La lista de lo purgado va en 'error.purged' y también en los eventos 'role_purged' ya emitidos; ` +
1984
+ `la siguiente pasada recoge lo que queda.`, purged, skipped, { cause: error });
1985
+ }
1986
+ finally {
1987
+ invalidateAuthzCatalog();
1988
+ }
1989
+ purged.push(role);
1990
+ await this.#notifyCatalog({ action: 'role_purged', role, owner, permissions });
1991
+ }
1992
+ return { orphans, purged, skipped, massPurge, dryRun: false };
1993
+ }
1994
+ /** El actor de la API de delegación: obligatorio SIEMPRE (sin él no hay policy que evaluar) y bien formado. */
1995
+ async #requireActor(actor, operation) {
1996
+ await this.#assertNotFrozen(operation);
1997
+ if (actor === undefined || actor === null) {
1998
+ throw new ActorRequiredError(`${operation}: el actor es obligatorio (es quien delega; sin él no hay policy que evaluar).`);
1999
+ }
2000
+ assertSubject(actor);
2001
+ return actor;
2002
+ }
2003
+ /** El owner de un rol local: un scope válido que no sea la raíz (sus roles son globales y se declaran en el config). */
2004
+ #assertOwnerScope(ownerScope, operation) {
2005
+ assertScope(ownerScope);
2006
+ if (ownerScope.type === APP_SCOPE_TYPE) {
2007
+ throw new InvalidIdentityError(`${operation}: la raíz 'app' no puede ser owner de un rol local; los roles de la raíz son globales y se declaran ` +
2008
+ `en el catálogo del config (syncAuthzCatalog).`);
2009
+ }
2010
+ }
2011
+ #parseScopedRoleSpec(spec) {
2012
+ if (!spec || typeof spec !== 'object')
2013
+ throw new InvalidIdentityError(`Spec de rol local inválido: llegó ${spec === null ? 'null' : typeof spec}`);
2014
+ assertValidSlug('rol', spec.slug);
2015
+ assertScopeType(spec.scopeType);
2016
+ if (spec.scopeType === APP_SCOPE_TYPE) {
2017
+ throw new InvalidIdentityError(`Spec de rol local inválido: un rol local no puede ser de nivel 'app' (la raíz no está dentro de ningún owner).`);
2018
+ }
2019
+ const rank = this.#parseRank(spec.rank);
2020
+ if (rank === undefined)
2021
+ throw new InvalidIdentityError(`Spec de rol local inválido: 'rank' es obligatorio (entero).`);
2022
+ const name = this.#parseName(spec.name) ?? spec.slug;
2023
+ const description = this.#parseDescription(spec.description) ?? null;
2024
+ const permissions = this.#parsePermissions(spec.permissions);
2025
+ // 3D · N3 (auditor V7): un rol sin permisos no concede nada y ocupa el
2026
+ // `(slug, nivel)` del owner —y del subárbol— para siempre. Es squatting
2027
+ // con forma de spec: 422.
2028
+ if (permissions.length === 0) {
2029
+ throw new InvalidIdentityError(`Spec de rol local inválido: 'permissions' está vacío. Un rol que no concede nada solo ocupa el ` +
2030
+ `(slug, nivel) de su owner; si lo que quieres es reservarlo, hazlo con un permiso real.`);
2031
+ }
2032
+ return { slug: spec.slug, scopeType: spec.scopeType, name, description, rank, permissions };
2033
+ }
2034
+ #parseScopedRoleChanges(changes) {
2035
+ if (!changes || typeof changes !== 'object')
2036
+ throw new InvalidIdentityError(`Cambios de rol local inválidos: llegó ${changes === null ? 'null' : typeof changes}`);
2037
+ // 3D · N2 (tester H6): `slug`, `scopeType` y `owner` NO se cambian por
2038
+ // esta API —el README lo promete— y hasta aquí se ignoraban EN SILENCIO:
2039
+ // quien pasaba `{ slug: 'otro' }` creía haber renombrado el rol. Lo que
2040
+ // no se puede hacer se dice.
2041
+ const allowed = new Set(['name', 'description', 'rank', 'permissions']);
2042
+ const unknown = Object.keys(changes).filter((key) => !allowed.has(key));
2043
+ if (unknown.length) {
2044
+ throw new InvalidIdentityError(`Cambios de rol local inválidos: '${unknown.join("', '")}' no se puede${unknown.length > 1 ? 'n' : ''} cambiar ` +
2045
+ `(un rol local no cambia de slug, nivel ni owner: purga y define otro). Campos admitidos: ${[...allowed].join(', ')}.`);
2046
+ }
2047
+ return {
2048
+ name: this.#parseName(changes.name),
2049
+ description: this.#parseDescription(changes.description),
2050
+ rank: this.#parseRank(changes.rank),
2051
+ permissions: changes.permissions === undefined ? undefined : this.#parsePermissions(changes.permissions),
2052
+ };
2053
+ }
2054
+ #parseRank(rank) {
2055
+ if (rank === undefined)
2056
+ return undefined;
2057
+ if (typeof rank !== 'number' || !Number.isInteger(rank)) {
2058
+ throw new InvalidIdentityError(`rank inválido: se esperaba un entero y llegó ${typeof rank === 'number' ? rank : typeof rank}`);
2059
+ }
2060
+ return rank;
2061
+ }
2062
+ #parseName(name) {
2063
+ if (name === undefined)
2064
+ return undefined;
2065
+ if (typeof name !== 'string' || name.length === 0 || name.length > ROLE_NAME_MAX) {
2066
+ throw new InvalidIdentityError(`name inválido: se esperaba una cadena de 1 a ${ROLE_NAME_MAX} caracteres`);
2067
+ }
2068
+ return name;
2069
+ }
2070
+ #parseDescription(description) {
2071
+ if (description === undefined)
2072
+ return undefined;
2073
+ if (description === null)
2074
+ return null;
2075
+ if (typeof description !== 'string' || description.length > ROLE_DESCRIPTION_MAX) {
2076
+ throw new InvalidIdentityError(`description inválida: se esperaba una cadena de hasta ${ROLE_DESCRIPTION_MAX} caracteres, o null`);
2077
+ }
2078
+ return description;
2079
+ }
2080
+ #parsePermissions(permissions) {
2081
+ if (!Array.isArray(permissions))
2082
+ throw new InvalidIdentityError(`permissions inválido: se esperaba una lista de slugs y llegó ${typeof permissions}`);
2083
+ for (const slug of permissions)
2084
+ assertValidSlug('permiso', slug);
2085
+ return [...new Set(permissions)];
2086
+ }
2087
+ /** Lista blanca, existencia en el catálogo y composición por nivel (B5), en ese orden. */
2088
+ #assertComposable(permissions, role, catalog) {
2089
+ const delegable = new Set(this.#config.delegablePermissions ?? []);
2090
+ for (const slug of permissions) {
2091
+ if (!delegable.has(slug)) {
2092
+ throw new PermissionNotDelegableError(`'${slug}' no se puede delegar: no está en config.delegablePermissions ` +
2093
+ `(${delegable.size ? [...delegable].join(', ') : 'vacía: nadie delega nada hasta declararla'}).`);
2094
+ }
2095
+ const permission = catalog.permission(slug);
2096
+ if (!permission)
2097
+ throw new UnknownPermissionError(slug);
2098
+ assertAssignableAt(role, slug, permission.assignableAt);
2099
+ }
2100
+ }
2101
+ /** Cada permiso tiene que ser EFECTIVO para el actor en el owner: concedido por un rol suyo de la cadena y no denegado en ella (C2). */
2102
+ #assertDelegable(permissions, granted, denied, owner) {
2103
+ for (const slug of permissions) {
2104
+ if (denied.has(slug)) {
2105
+ throw new PermissionNotDelegableError(`'${slug}' no se puede delegar: el actor lo tiene DENEGADO en ${owner.type}:${owner.uuid ?? ''} (o en un ancestro); ` +
2106
+ `un deny no se lava componiendo un rol para otro.`);
2107
+ }
2108
+ if (!granted.has(slug)) {
2109
+ throw new PermissionNotDelegableError(`'${slug}' no se puede delegar: el actor no lo tiene efectivo en ${owner.type}:${owner.uuid ?? ''} ` +
2110
+ `(ningún rol vigente suyo en esa cadena lo concede).`);
2111
+ }
2112
+ }
2113
+ }
2114
+ /** `0 < rank < min(rank del actor, rank máximo global)`: policy de escritura, no de evaluación (invariante 8). */
2115
+ #assertRank(rank, actorRank, topGlobalRank) {
2116
+ const ceiling = Math.min(actorRank, topGlobalRank);
2117
+ if (!(rank > 0 && rank < ceiling)) {
2118
+ throw new RankExceededError(`rank ${rank} fuera de la policy: tiene que cumplir 0 < rank < ${ceiling} (min(rank del actor = ${actorRank}, rank máximo global = ${topGlobalRank})).`);
2119
+ }
2120
+ }
2121
+ /**
2122
+ * El nivel de un rol local nunca está POR ENCIMA de su owner (3E · P1,
2123
+ * auditor A1). La regla es mínima a propósito y se decide con la cadena
2124
+ * que ya está resuelta, sin pedirle nada más al consumidor:
2125
+ *
2126
+ * - `scopeType === owner.type` ⇒ vale (el caso propio).
2127
+ * - `scopeType` es el nivel de un ANCESTRO del owner (`app` incluida,
2128
+ * que está en toda cadena) ⇒ 422 `E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER`. Es
2129
+ * la mina: un `operador@organization` cuyo owner es una unit jamás es
2130
+ * visible —no concede, no es membresía, nadie lo puede asignar— y lo
2131
+ * único que hace es OCUPAR ese `(slug, nivel)` para el dueño del árbol
2132
+ * y para el catálogo GLOBAL; el actor de menor privilegio del sistema
2133
+ * bloqueando a la plataforma (y, hasta 3E, su deploy entero).
2134
+ * - Cualquier otro tipo se presume DESCENDIENTE y vale: es el caso común
2135
+ * (`lead@unit` con owner una organization) y exigir `descendantsOf`
2136
+ * para él rompía a todo consumidor con el stub publicado, que no lo
2137
+ * declara.
2138
+ * - Con `scopes.descendantsOf` declarado se ENDURECE: el tipo tiene que
2139
+ * aparecer de verdad bajo el owner en el árbol de HOY; si no, 422.
2140
+ *
2141
+ * **Lo que cuesta la degradación, dicho** (3G · X1, auditor P4): si el
2142
+ * subárbol no se puede enumerar (cota superada o `descendantsOf` caído) la
2143
+ * regla vuelve a ser la MÍNIMA — y el propio actor puede provocarlo
2144
+ * creando más hijos de su scope que `maxDescendants`, porque crear scopes
2145
+ * es una función normal del producto. Es un control que el vigilado apaga.
2146
+ * Se acepta a sabiendas: la regla mínima no concede NADA (es la que corre
2147
+ * en todo consumidor con el stub publicado), y el daño residual —ocupar un
2148
+ * `(slug, nivel)`— es reparable por AUTORIDAD + RANGO: un ancestro define
2149
+ * el suyo y lo ensombrece (3F · S3 + 3G · W3) **si supera en rango al
2150
+ * squatter** — `rank` es metadata del consumidor (invariante 8) y nada
2151
+ * obliga a que decrezca con la profundidad, así que con un reparto no
2152
+ * monótono (rank 60 en una unit bajo el org-admin rank 50 que es dueño de
2153
+ * ese árbol) el dueño se lleva 422 por las dos puertas y el recurso es la
2154
+ * PLATAFORMA (3b-1 · D1): el techo global acota todo rank local, y
2155
+ * `purgeRole` no mide rango. Quien no acepte ese trato deja
2156
+ * `maxDescendants` por encima de su subárbol mayor (3b-0b · AB1: la
2157
+ * degradación ya no se anuncia en ningún retorno —`truncated` se borró con
2158
+ * `ScopeDetachOutcome` en 3b-0 · Z1—, así que la cota es lo único que hay
2159
+ * que vigilar; `authz:catalog:diff --fail-on-shadows` es el gate de CI).
2160
+ *
2161
+ * (Un `scopeType` de nivel `app` muere antes, en `#parseScopedRoleSpec`:
2162
+ * la raíz no cuelga de ningún owner. Si llegara aquí sería un ancestro.)
2163
+ */
2164
+ async #assertLevelUnderOwner(scopeType, chain, operation) {
2165
+ const owner = chain[0];
2166
+ if (scopeType === owner.type)
2167
+ return;
2168
+ const above = chain.slice(1).find((scope) => scope.type === scopeType);
2169
+ if (above) {
2170
+ throw new RoleLevelAboveOwnerError(`${operation}: un rol local de nivel '${scopeType}' está POR ENCIMA de su owner ${owner.type}:${owner.uuid ?? ''} ` +
2171
+ `('${scopeType}' es el nivel de ${above.type}:${above.uuid ?? ''}, un ancestro suyo en la cadena). Un rol así no ` +
2172
+ `sería visible en ninguna parte: no concedería nada y solo ocuparía ese (slug, nivel) para el resto del árbol y ` +
2173
+ `para el catálogo global. Defínelo en el nivel del owner o en uno por debajo.`);
2174
+ }
2175
+ // Lo demás se presume por debajo; con el árbol del consumidor a mano, se comprueba.
2176
+ const { below, enumerated } = await this.#descendantsOrDegrade(owner, operation);
2177
+ if (!enumerated)
2178
+ return;
2179
+ if (below.some((scope) => scope.type === scopeType))
2180
+ return;
2181
+ const levels = [...new Set(below.map((scope) => scope.type))].sort();
2182
+ throw new RoleLevelAboveOwnerError(`${operation}: un rol local de nivel '${scopeType}' no cuelga de ${owner.type}:${owner.uuid ?? ''} ` +
2183
+ `(bajo él hoy hay ${levels.length ? `niveles ${levels.join(', ')}` : 'ningún scope'}), así que no sería visible en ` +
2184
+ `ninguna parte: no concedería nada y solo ocuparía ese (slug, nivel) para el resto del árbol y para el catálogo global. ` +
2185
+ `Define el rol en el nivel del owner o en uno que cuelgue de él.`);
2186
+ }
2187
+ /**
2188
+ * Solo se toca un rol de rango MENOR que el propio.
2189
+ *
2190
+ * El mensaje solo nombra el rol cuando el actor tiene ALGO en esa cadena
2191
+ * (rank > 0): con rank 0 no tiene ningún rol vigente ahí, así que el rol
2192
+ * pertenece a un árbol que no es suyo y decirle su slug y su rank
2193
+ * convertía el 422 en una sonda de catálogo ajeno —`scopes.detached` de la
2194
+ * unit de otro tenant, sin `within`, enumeraba sus roles locales sin
2195
+ * escribir nada (3G · X5, auditor P7)—. Es la misma regla que ya se aplicó
2196
+ * a `E_AUTHZ_AMBIGUOUS_ROLE` (3E · Q2): se nombra lo que el llamante ya
2197
+ * puede ver, nada más.
2198
+ */
2199
+ #assertAboveRole(actorRank, role) {
2200
+ if (actorRank > role.rank)
2201
+ return;
2202
+ if (actorRank === 0) {
2203
+ throw new RankExceededError(`El actor no tiene ningún rol vigente en la cadena de ese scope (rank 0), así que no puede tocar los roles ` +
2204
+ `locales que hay ahí: hace falta rank mayor que el del rol.`);
2205
+ }
2206
+ throw new RankExceededError(`El actor (rank ${actorRank} en el owner del rol) no puede tocar '${role.slug}' (rank ${role.rank}): hace falta rank mayor que el del rol.`);
2207
+ }
2208
+ /** El rol por uuid, y LOCAL: un global es inmutable por esta API. */
2209
+ #localRoleOrFail(catalog, roleUuid) {
2210
+ const role = catalog.roleByUuid(roleUuid);
2211
+ if (!role)
2212
+ throw new UnknownRoleError(roleUuid);
2213
+ if (role.owner === GLOBAL_OWNER_KEY) {
2214
+ throw new RoleImmutableError(`El rol '${role.slug}@${role.scopeType}' es GLOBAL (catálogo del config): se cambia en el config y se sincroniza; ` +
2215
+ `por la API de delegación es inmutable.`);
2216
+ }
2217
+ return role;
2218
+ }
2219
+ /** El scope owner de un rol local (de su clave). Una clave que no es un scope es catálogo corrupto (500). */
2220
+ #ownerOf(role) {
2221
+ const owner = scopeFromKey(role.owner);
2222
+ if (!owner || owner.type === APP_SCOPE_TYPE) {
2223
+ throw new AuthorizationInternalError(`El owner del rol '${role.slug}' (${role.uuid}) no es una clave de scope: '${role.owner}'`);
2224
+ }
2225
+ return owner;
2226
+ }
2227
+ /**
2228
+ * La colisión se decide por AUTORIDAD (3F · S3, auditor N1): *una
2229
+ * definición más autorizada gana y ensombrece a la menos autorizada* —
2230
+ * global > local de un ancestro > local de un descendiente—, que es la
2231
+ * regla que 3E ya tomó para los globales frente a los locales.
2232
+ *
2233
+ * - Un homónimo GLOBAL, o local a un ancestro-o-igual del owner, es 422
2234
+ * `E_AUTHZ_CATALOG_CONFLICT`: hacia ARRIBA no se ensombrece a nadie.
2235
+ * - Un homónimo local a un DESCENDIENTE del owner ya NO colisiona: el
2236
+ * nuevo se crea —si el actor SUPERA EN RANGO al que va a ensombrecer
2237
+ * (3G · W3, `#assertAboveShadowed`: la autoridad no es solo posición)—
2238
+ * y el del descendiente queda ENSOMBRECIDO (se devuelve para el evento
2239
+ * `role_defined` y el diff lo lista como `shadowedByAncestor`). Hasta 3E era 422, y con eso el actor de menor
2240
+ * privilegio del sistema le ocupaba el nombre al DUEÑO del árbol —para
2241
+ * siempre, salvo purga rol a rol— y dejaba `authz:catalog:diff` en rojo,
2242
+ * que es el gate de CI del deploy. Ahora la mina solo se ensombrece a sí
2243
+ * misma: dentro de SU subárbol el slug pasa a 422 `E_AUTHZ_AMBIGUOUS_ROLE`
2244
+ * (M1, fail-closed) y se opera por `{ uuid }`, exactamente como con un
2245
+ * global. No concede nada de más: el hecho apunta al uuid del rol.
2246
+ *
2247
+ * Los owners de los homónimos se resuelven en fresco; uno que el árbol ya
2248
+ * no conoce no colisiona ni se ensombrece (no es visible en ningún sitio).
2249
+ *
2250
+ * `others` son los homónimos a contrastar: la foto del memo en el chequeo
2251
+ * BARATO (antes de abrir la transacción, para no pagar una transacción por
2252
+ * una colisión evidente) y las filas leídas de la BASE dentro de la
2253
+ * transacción serializada (3D · M2), que es el que manda. Con solo el
2254
+ * primero, dos `define` en paralelo —o un `define` contra un `sync`—
2255
+ * insertaban los dos homónimos y el estado era permanente (auditor V2).
2256
+ * En modo `sin-árbol` (dentro de la transacción, con el cerrojo sostenido)
2257
+ * no se resuelve ninguna cadena: el llamante rechaza igual lo que no puede
2258
+ * juzgar y pide reintentar.
2259
+ */
2260
+ async #assertNoRoleCollision(slug, scopeType, owner, ownerChain, others, mode = 'con-árbol') {
2261
+ const ownerKey = scopeKey(owner);
2262
+ const ancestors = new Set(ownerChain.map(scopeKey));
2263
+ // Lo que se juzga SIN tocar el árbol, primero: un 422 evidente no paga
2264
+ // un `resolveChain` por homónimo.
2265
+ for (const other of others) {
2266
+ let where = null;
2267
+ if (other.owner === GLOBAL_OWNER_KEY)
2268
+ where = 'global (catálogo del config)';
2269
+ else if (ancestors.has(other.owner))
2270
+ where = other.owner === ownerKey ? 'este mismo scope' : `un ancestro (${other.owner})`;
2271
+ if (where) {
2272
+ throw new CatalogConflictError(`Ya existe un rol '${slug}' de nivel '${scopeType}' visible desde ${owner.type}:${owner.uuid ?? ''}: es ${where}. ` +
2273
+ `Dentro de un scope un (slug, nivel) identifica un solo rol; elige otro slug.`);
2274
+ }
2275
+ }
2276
+ if (mode === 'sin-árbol')
2277
+ return [];
2278
+ return this.#shadowedBelow(ownerKey, ancestors, others, 'defineScopedRole');
2279
+ }
2280
+ /**
2281
+ * Los homónimos LOCALES a un DESCENDIENTE del owner: los que una
2282
+ * definición en `ownerKey` ENSOMBRECE (3F · S3). Los owners se resuelven
2283
+ * en fresco; uno que el árbol ya no conoce no ensombrece a nadie. Lo usan
2284
+ * `defineScopedRole` (la colisión) y `updateScopedRole` (que no crea
2285
+ * sombras nuevas, pero tampoco deja tocar un rol que ya ensombrece a otro
2286
+ * de más rango — 3G · W3).
2287
+ *
2288
+ * **La VENTANA, dicha** (3b-1 · D2, auditor 3G): `chain === null` es «no
2289
+ * demostrable», y aquí se trata como «no hay sombra». Mientras el árbol no
2290
+ * responda por el owner de la víctima —soft-delete, réplica atrasada, un
2291
+ * scope en «pending»: los mismos estados que el resto del paquete admite
2292
+ * como normales— un actor de rank bajo en un ancestro crea el homónimo sin
2293
+ * pasar por `#assertAboveShadowed`, y al volver el árbol la sombra es real
2294
+ * y permanente. **No se rechaza, a propósito**: desde 3b-0 · Z1 un rol cuyo
2295
+ * owner no resuelve está DORMIDO y la salida es `prune-orphans`, así que
2296
+ * rechazar aquí convertiría un rol dormido en un BLOQUEO de `(slug, nivel)`
2297
+ * —exactamente la mina que Z1 quitó— y lo haría por una condición que el
2298
+ * llamante no puede ni ver ni corregir. Lo que acota el daño: (a) el mismo
2299
+ * atacante consigue la misma denegación **yendo primero**, sin trampa
2300
+ * ninguna (W3 solo protege a los roles que YA existen; ocupar el nombre
2301
+ * antes siempre fue gratis); (b) nadie pierde permisos —`authorize` no
2302
+ * direcciona por slug— y la sombra sale en `authz:catalog:diff` como
2303
+ * `shadowedByAncestor`; (c) el dueño del árbol con rango la borra, y la
2304
+ * plataforma siempre (3b-1 · D1).
2305
+ */
2306
+ async #shadowedBelow(ownerKey, ancestors, others, operation) {
2307
+ const shadowed = [];
2308
+ for (const other of others) {
2309
+ if (other.owner === GLOBAL_OWNER_KEY || ancestors.has(other.owner))
2310
+ continue;
2311
+ if (!('uuid' in other) || !('rank' in other))
2312
+ continue;
2313
+ const otherOwner = scopeFromKey(other.owner);
2314
+ const chain = otherOwner ? await resolveChain(this.#freshResolver(), otherOwner, operation) : null;
2315
+ if (chain && chain.some((s) => scopeKey(s) === ownerKey))
2316
+ shadowed.push(other);
2317
+ }
2318
+ return shadowed;
2319
+ }
2320
+ /**
2321
+ * Sobre un rol solo actúa quien lo SUPERA EN RANGO — también para
2322
+ * ensombrecerlo (3G · W3, auditor P3′). **Es una comprobación de
2323
+ * ESCRITURA, no un invariante** (3b-1 · D3): quién ensombrece a quién es
2324
+ * función del árbol de HOY y el árbol se mueve sin preguntar aquí
2325
+ * (`scopes.moved` crea sombras sin juzgar ningún rango), y el propio
2326
+ * chequeo tiene su ventana (`#shadowedBelow` con `chain === null`, D2) y
2327
+ * su límite honesto: solo protege a los roles que YA existen —ocupar el
2328
+ * nombre primero siempre fue gratis—. Ensombrecer es tan destructivo
2329
+ * como borrar: dentro del subárbol del ensombrecido toda ruta por slug
2330
+ * pasa a 422 `E_AUTHZ_AMBIGUOUS_ROLE` para TODOS, y la víctima no puede
2331
+ * repararlo (su rango se mide en la cadena del owner del rol que
2332
+ * ensombrece, donde no vale nada). Hasta 3F la autoridad era solo POSICIÓN
2333
+ * y un actor de rank 3 en la organization inutilizaba por slug un rol de
2334
+ * rank 40 de una unit, en toda su cadena y para siempre.
2335
+ *
2336
+ * El mensaje NO nombra el rank ni el owner del ensombrecido: un ancestro
2337
+ * no ve los roles de sus descendientes (la visibilidad solo baja), así que
2338
+ * el 422 no puede ser una sonda del catálogo de abajo (misma regla que
2339
+ * `E_AUTHZ_AMBIGUOUS_ROLE`, 3E · Q2).
2340
+ */
2341
+ #assertAboveShadowed(actorRank, shadowed, operation) {
2342
+ for (const role of shadowed) {
2343
+ if (actorRank > role.rank)
2344
+ continue;
2345
+ throw new RankExceededError(`${operation}: por debajo de este owner ya hay un rol local '${role.slug}' de nivel '${role.scopeType}' con rank ` +
2346
+ `MAYOR O IGUAL al tuyo (tu rank aquí es ${actorRank}). Definir el tuyo lo ensombrecería —dentro de su subárbol ` +
2347
+ `ese slug pasaría a ser ambiguo para todos y su dueño no podría repararlo—, y sobre un rol solo actúa quien lo ` +
2348
+ `supera en rango, igual que en deleteScopedRole. Elige otro slug.`);
2349
+ }
2350
+ }
2351
+ /**
2352
+ * LA escritura del catálogo por el manager: `withAuthzCatalogWrite` (la
2353
+ * versión compartida sube como última sentencia, dentro) y, al salir
2354
+ * —bien o mal—, se invalidan los memos de este proceso (como el sync). Con
2355
+ * `skipIfNoop`, un `fn` que devuelve `false` no ha escrito nada y la
2356
+ * versión no se toca (se revierte la transacción vacía).
2357
+ */
2358
+ async #writeCatalog(fn, options = {}) {
2359
+ const noop = Symbol('noop');
2360
+ try {
2361
+ return await withAuthzCatalogWrite(async (trx) => {
2362
+ const result = await fn(trx);
2363
+ if (options.skipIfNoop && result === false)
2364
+ throw noop;
2365
+ return result;
2366
+ }, { driver: this.#config.default });
2367
+ }
2368
+ catch (error) {
2369
+ if (error === noop)
2370
+ return false;
2371
+ throw error;
2372
+ }
2373
+ finally {
2374
+ invalidateAuthzCatalog();
2375
+ }
2376
+ }
2377
+ async #notifyCatalog(event) {
2378
+ try {
2379
+ await this.#config.hooks?.onCatalogWrite?.(event);
2380
+ }
2381
+ catch (error) {
2382
+ const context = `authz: el hook onCatalogWrite falló tras '${event.action}' (la escritura sí se aplicó)`;
2383
+ try {
2384
+ const { default: logger } = await import('@adonisjs/core/services/logger');
2385
+ logger.error({ err: error, event }, context);
2386
+ }
2387
+ catch {
2388
+ console.error(context, error);
2389
+ }
2390
+ }
2391
+ }
2392
+ /**
2393
+ * Asigna un rol al holder en un scope. `role` es un `RoleQuery` (3D · M1):
2394
+ * un slug, `{ slug, scopeType }` o `{ uuid }` — esta última es la forma
2395
+ * exacta, la única que responde cuando dos roles locales homónimos son
2396
+ * visibles en la misma cadena (las otras dos son 422
2397
+ * `E_AUTHZ_AMBIGUOUS_ROLE`, nunca «el más cercano gana»).
2398
+ */
2399
+ async grant(subject, role, scope, options) {
2400
+ const actor = await this.#writeOptions(options, 'grant');
2401
+ assertIdentity({ subject, role, scope, expiresAt: options?.expiresAt });
2402
+ await this.#assertWithin(scope, options, 'grant');
2403
+ // 3E · Q7: el evento lleva el rol RESUELTO (uuid + slug + nivel + owner),
2404
+ // no la pregunta cruda. Solo se resuelve si hay hook que lo vaya a leer.
2405
+ const roles = await this.#resolvedRoles(role, scope, 'grant');
2406
+ const outcome = (await this.#write({ action: 'granted', subject, scope, roles, expiresAt: options?.expiresAt ?? null, ...actor }, async () => (await this.driver()).grant(subject, role, scope, options))) ??
2407
+ // Un driver de terceros que aún devuelva `void`: la firma promete un
2408
+ // `GrantOutcome` y no miente (E1). Sin lectura previa no hay caducidad
2409
+ // anterior que contar: es lo que pidió el llamante, y `existed: false`.
2410
+ { existed: false, expiresAt: options?.expiresAt ?? null };
2411
+ // Un re-grant que cambia la caducidad de una asignación existente es un
2412
+ // evento distinto (L0.4): quien audita necesita ver de cuál a cuál.
2413
+ if (expiryChanged(outcome)) {
2414
+ await this.#notify({
2415
+ action: 'extended',
2416
+ subject,
2417
+ scope,
2418
+ roles,
2419
+ expiresAt: outcome.expiresAt,
2420
+ previousExpiresAt: outcome.previousExpiresAt,
2421
+ ...actor,
2422
+ });
2423
+ }
2424
+ else {
2425
+ await this.#notify({
2426
+ action: 'granted',
2427
+ subject,
2428
+ scope,
2429
+ roles,
2430
+ expiresAt: outcome.expiresAt,
2431
+ ...actor,
2432
+ });
2433
+ }
2434
+ return outcome;
2435
+ }
2436
+ /**
2437
+ * Quita la asignación del rol en ese scope exacto. Por slug se quitan las
2438
+ * de TODOS los homónimos `(slug, nivel)` (3B; quitar nunca concede, y el
2439
+ * scope puede no existir ya para el árbol); por `{ uuid }`, solo la de ese
2440
+ * rol.
2441
+ */
2442
+ async revoke(subject, role, scope, options) {
2443
+ const actor = await this.#writeOptions(options, 'revoke');
2444
+ assertIdentity({ subject, role, scope });
2445
+ await this.#assertWithin(scope, options, 'revoke');
2446
+ const event = { action: 'revoked', subject, scope, roles: await this.#resolvedRoles(role, scope, 'revoke'), ...actor };
2447
+ await this.#write(event, async () => (await this.driver()).revoke(subject, role, scope));
2448
+ await this.#notify(event);
2449
+ }
2450
+ async deny(subject, permission, scope, options) {
2451
+ const actor = await this.#writeOptions(options, 'deny');
2452
+ assertIdentity({ subject, permission, scope });
2453
+ await this.#assertWithin(scope, options, 'deny');
2454
+ const event = { action: 'denied', subject, scope, permission, ...actor };
2455
+ await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope));
2456
+ await this.#notify(event);
61
2457
  }
62
- async revoke(subject, role, scope) {
63
- await (await this.driver()).revoke(subject, role, scope);
64
- await this.#notify({ action: 'revoked', subject, scope, role });
2458
+ async removeDeny(subject, permission, scope, options) {
2459
+ const actor = await this.#writeOptions(options, 'removeDeny');
2460
+ assertIdentity({ subject, permission, scope });
2461
+ await this.#assertWithin(scope, options, 'removeDeny');
2462
+ const event = { action: 'deny_removed', subject, scope, permission, ...actor };
2463
+ await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope));
2464
+ await this.#notify(event);
65
2465
  }
66
- async deny(subject, permission, scope) {
67
- await (await this.driver()).deny(subject, permission, scope);
68
- await this.#notify({ action: 'denied', subject, scope, permission });
2466
+ /**
2467
+ * Scopes de un tipo donde el holder tiene el permiso (2.1, B3). La ÚNICA
2468
+ * API del paquete que enumera descendientes — excepción explícita al
2469
+ * invariante 7 (`list*` siguen siendo directos) — y lo hace con el
2470
+ * `descendantsOf` del consumidor, nunca con N+1 `resolveChain` a ciegas.
2471
+ *
2472
+ * Regla:
2473
+ * 1. `listScopes(subject, permission)`: los scopes DIRECTOS que conceden,
2474
+ * ya sin los bloqueados por un deny en su cadena y sin los que el árbol
2475
+ * no conoce. Vacío ⇒ `none`.
2476
+ * 2. Si la raíz está entre ellos ⇒ `all`, con `excludedSubtrees` = todos
2477
+ * los scopes con deny vivo del permiso (`listDenies`), como subárboles
2478
+ * (F10). Nunca `all` sin esa lista (juez cruce 5): un deny vivo tiene
2479
+ * que verse.
2480
+ * 3. Si no: candidatos = directos ∪ sus descendientes (`descendantsOf`).
2481
+ * Cada candidato se contrasta con `resolveChain` (memoizado por
2482
+ * request, F3): su cadena tiene que contener el scope concedente —si
2483
+ * no, los dos resolutores del consumidor describen árboles distintos y
2484
+ * se lanza 503 `E_AUTHZ_RESOLVER_FAILED`, nunca una lista con cruces—
2485
+ * y no puede contener un scope denegado (es EXACTAMENTE la regla de
2486
+ * `authorize`: deny en la cadena ⇒ false). Se filtran por `scopeType`.
2487
+ * 4. Más de `maxScopes` ⇒ 422 `E_AUTHZ_TOO_MANY_SCOPES`, nunca parcial, y
2488
+ * se corta en cuanto se sabe (F8): los directos del tipo antes de bajar
2489
+ * y el conteo del tipo dentro del bucle. `options.maxScopes` solo puede
2490
+ * BAJAR la cota del config.
2491
+ * Sin `scopes.descendantsOf` ⇒ 500 `E_AUTHZ_NO_DESCENDANTS_RESOLVER`
2492
+ * (antes de mirar nada: un `none` sin árbol sería mentira).
2493
+ */
2494
+ async authorizedScopes(subject, permission, scopeType, options = {}) {
2495
+ assertIdentity({ subject, permission, scopeType });
2496
+ const descendantsOf = this.#descendantsResolver('authorizedScopes');
2497
+ const { maxScopes, maxNodes } = this.#scopeBounds('authorizedScopes', options);
2498
+ // Una vista propia para la llamada: los scopes que se resuelvan se
2499
+ // resuelven una vez.
2500
+ const view = this.#readResolver ? this : this.forRequest();
2501
+ const driver = await view.#reader();
2502
+ const listDenies = this.#optional(driver, 'listDenies', 'authorizedScopes');
2503
+ const key = _a.#scopeKey;
2504
+ const direct = await driver.listScopes(subject, permission);
2505
+ if (direct.length === 0)
2506
+ return { kind: 'none' };
2507
+ const denied = (await listDenies(subject)).filter((d) => d.permission === permission).map((d) => d.scope);
2508
+ if (direct.some((s) => s.type === APP_SCOPE_TYPE)) {
2509
+ if (denied.length > maxScopes) {
2510
+ throw new TooManyScopesError(`authorizedScopes: ${denied.length} subárboles excluidos superan maxScopes=${maxScopes}; no se devuelve una lista parcial.`);
2511
+ }
2512
+ return { kind: 'all', excludedSubtrees: denied.map((scope) => ({ scope, includesDescendants: true })) };
2513
+ }
2514
+ const tooMany = (count) => new TooManyScopesError(`authorizedScopes: más de ${maxScopes} scopes de tipo '${scopeType}' (${count} ya contados, maxScopes); ` +
2515
+ `acota la pregunta o sube la cota. No se devuelve una lista parcial.`);
2516
+ const deniedKeys = new Set(denied.map(key));
2517
+ const result = new Map();
2518
+ // Los directos del tipo (ya sin denies por encima: `listScopes`) cuentan
2519
+ // antes de bajar a ningún subárbol (F8).
2520
+ for (const granted of direct) {
2521
+ if (granted.type === scopeType)
2522
+ result.set(key(granted), granted);
2523
+ }
2524
+ if (result.size > maxScopes)
2525
+ throw tooMany(result.size);
2526
+ const resolver = view.#readResolverOrFresh();
2527
+ for (const granted of direct) {
2528
+ const grantedKey = key(granted);
2529
+ for (const candidate of await view.#descendants(descendantsOf, granted, maxNodes)) {
2530
+ const candidateKey = key(candidate);
2531
+ if (result.has(candidateKey))
2532
+ continue;
2533
+ // Pertenencia (F3): la cadena del candidato, según `resolveChain`,
2534
+ // tiene que pasar por el scope concedente. Si no (o si el árbol de
2535
+ // ancestros no lo conoce), los dos resolutores discrepan: 503.
2536
+ const chain = await resolveChain(resolver, candidate, 'authorizedScopes');
2537
+ if (!chain || !chain.some((s) => key(s) === grantedKey)) {
2538
+ throw new ScopeResolverError('authorizedScopes', new Error(`descendantsOf(${grantedKey.replace('\u001f', ':')}) devolvió ${candidateKey.replace('\u001f', ':')} pero ` +
2539
+ `resolveChain no lo cuelga de ahí: los dos resolutores describen árboles distintos y no se puede responder.`));
2540
+ }
2541
+ // Deny en la cadena ⇒ no concede, como en `authorize`.
2542
+ if (chain.some((s) => deniedKeys.has(key(s))))
2543
+ continue;
2544
+ if (candidate.type !== scopeType)
2545
+ continue;
2546
+ result.set(candidateKey, candidate);
2547
+ if (result.size > maxScopes)
2548
+ throw tooMany(result.size);
2549
+ }
2550
+ }
2551
+ const scopes = [...result.values()];
2552
+ return scopes.length ? { kind: 'some', scopes } : { kind: 'none' };
2553
+ }
2554
+ /**
2555
+ * Los `excludedSubtrees` de un `all` (F10) expandidos: cada scope denegado
2556
+ * y todos sus descendientes, con el `descendantsOf` del config. Un scope
2557
+ * que `descendantsOf` no conoce (`null`) es 503: restarlo a medias
2558
+ * dejaría su subárbol dentro (fail-open). Cotas: `maxDescendants` por
2559
+ * subárbol y `maxScopes` (config o por llamada, nunca por encima del
2560
+ * config) sobre el total ⇒ 422, nunca parcial.
2561
+ */
2562
+ async expandExcludedSubtrees(excluded, options = {}) {
2563
+ if (!Array.isArray(excluded)) {
2564
+ throw new InvalidIdentityError(`expandExcludedSubtrees: se esperaba un array y llegó ${typeof excluded}`);
2565
+ }
2566
+ for (const item of excluded)
2567
+ assertScope(item?.scope);
2568
+ // Es una lectura de la vista como las demás (I2, auditor 10): caduca con ella.
2569
+ this.#assertReadable();
2570
+ const descendantsOf = this.#descendantsResolver('expandExcludedSubtrees');
2571
+ const { maxScopes, maxNodes } = this.#scopeBounds('expandExcludedSubtrees', options);
2572
+ const key = _a.#scopeKey;
2573
+ const result = new Map();
2574
+ for (const { scope } of excluded) {
2575
+ result.set(key(scope), scope);
2576
+ const below = await this.#descendants(descendantsOf, scope, maxNodes, 'strict');
2577
+ for (const d of below)
2578
+ result.set(key(d), d);
2579
+ if (result.size > maxScopes) {
2580
+ throw new TooManyScopesError(`expandExcludedSubtrees: más de ${maxScopes} scopes excluidos (maxScopes); no se devuelve una lista parcial.`);
2581
+ }
2582
+ }
2583
+ return [...result.values()];
2584
+ }
2585
+ /**
2586
+ * Clave LAXA de un scope, solo para agrupar/deduplicar candidatos dentro
2587
+ * de una operación (`authorizedScopes`, el anti-ciclo de `#assertEdge`).
2588
+ * NO es `scopeKey` de `identity.ts` —esa valida la gramática y es la que
2589
+ * identifica hechos, owners e ids de binding, y es la que usa
2590
+ * `#rolesAlong` (3D · N5)—: aquí los scopes vienen del `descendantsOf` del
2591
+ * consumidor y no se les exige gramática para compararlos entre sí.
2592
+ */
2593
+ static #scopeKey(s) {
2594
+ return `${s.type}\u001f${s.uuid ?? ''}`;
2595
+ }
2596
+ #descendantsResolver(operation) {
2597
+ const descendantsOf = this.#config.scopes?.descendantsOf;
2598
+ if (!descendantsOf) {
2599
+ throw new NoDescendantsResolverError(`${operation} necesita 'scopes.descendantsOf' en config/authorization.ts (p. ej. sqlDescendantsOf(...)): ` +
2600
+ `sin el árbol de descendientes no se puede enumerar sin mentir.`);
2601
+ }
2602
+ return descendantsOf;
2603
+ }
2604
+ /**
2605
+ * Cotas de una enumeración: `maxScopes` del config (default 1000), que
2606
+ * una llamada solo puede BAJAR (F8: subirla por llamada era una escalada
2607
+ * silenciosa de la cota global), y `maxDescendants` del config.
2608
+ */
2609
+ #scopeBounds(operation, options) {
2610
+ const configured = this.#config.scopes?.maxScopes ?? DEFAULT_MAX_SCOPES;
2611
+ const maxNodes = this.#config.scopes?.maxDescendants ?? DEFAULT_MAX_DESCENDANTS;
2612
+ for (const [name, value] of [
2613
+ ['maxScopes', configured],
2614
+ ['maxDescendants', maxNodes],
2615
+ ['maxScopes (por llamada)', options.maxScopes ?? configured],
2616
+ ]) {
2617
+ if (!Number.isInteger(value) || value < 1 || value > MAX_SCOPE_BOUND) {
2618
+ throw new AuthorizationConfigError(`${operation}: ${name} debe ser un entero entre 1 y ${MAX_SCOPE_BOUND} (llegó ${String(value)})`);
2619
+ }
2620
+ }
2621
+ return { maxScopes: Math.min(options.maxScopes ?? configured, configured), maxNodes };
69
2622
  }
70
- async removeDeny(subject, permission, scope) {
71
- await (await this.driver()).removeDeny(subject, permission, scope);
72
- await this.#notify({ action: 'deny_removed', subject, scope, permission });
2623
+ /**
2624
+ * El subárbol del consumidor para la pieza que lo camina por SEGURIDAD y
2625
+ * no por enumeración —la regla de nivel de `defineScopedRole`/
2626
+ * `updateScopedRole`—, DEGRADANDO en vez de tumbar la operación (3F · S2,
2627
+ * auditor N3).
2628
+ *
2629
+ * Regla: *declarar `scopes.descendantsOf` nunca puede dejarte peor que no
2630
+ * declararlo*. Hasta 3E, una org con más units que `maxDescendants` —la
2631
+ * cota sale del config y una llamada no la puede subir (F8)— dejaba al
2632
+ * tenant grande sin poder delegar hacia abajo: la configuración que el
2633
+ * invariante 18 recomienda EMPEORABA el caso grande. Ahora, si el subárbol
2634
+ * no se puede enumerar (más nodos que la cota, o un `descendantsOf` que
2635
+ * falla), se sigue con `enumerated: false` y la regla de nivel cae a la
2636
+ * MÍNIMA (rechazar solo los tipos de un ancestro), que es la que corre en
2637
+ * todo consumidor con el stub publicado y no concede nada.
2638
+ * Pero no es gratis y está escrito donde toca (3G · X1, auditor P4): es un
2639
+ * control que el propio vigilado puede apagar creando hijos. Lo que NO
2640
+ * degrada es ensombrecer, que sigue pidiendo rango aunque la regla de
2641
+ * nivel haya caído a la mínima (3G · W3).
2642
+ *
2643
+ * (Desde 3b-0 · Z1 `scopes.detached` ya no llama aquí: purga los hechos
2644
+ * del scope EXACTO y no toca el catálogo, así que no tiene subárbol que
2645
+ * enumerar ni degradación que declarar.)
2646
+ *
2647
+ * Lo que NO se degrada es un error de CONFIG (`maxDescendants` fuera de
2648
+ * rango): eso es un bug del consumidor y sigue siendo 500.
2649
+ */
2650
+ async #descendantsOrDegrade(scope, operation) {
2651
+ const descendantsOf = this.#config.scopes?.descendantsOf;
2652
+ if (!descendantsOf)
2653
+ return { below: [], enumerated: false };
2654
+ const { maxNodes } = this.#scopeBounds(operation, {});
2655
+ try {
2656
+ return { below: await this.#descendants(descendantsOf, scope, maxNodes), enumerated: true };
2657
+ }
2658
+ catch (error) {
2659
+ if (error instanceof TooManyScopesError || error instanceof ScopeResolverError) {
2660
+ return { below: [], enumerated: false };
2661
+ }
2662
+ throw error;
2663
+ }
2664
+ }
2665
+ /**
2666
+ * `descendantsOf` del consumidor, clasificado como `resolveChain` clasifica
2667
+ * `resolveChain`: lanza ⇒ 503 `E_AUTHZ_RESOLVER_FAILED`; no-array o
2668
+ * scope mal formado ⇒ 503; más de `maxNodes` ⇒ 422 `E_AUTHZ_TOO_MANY_SCOPES`;
2669
+ * `null` (desconocido para ese árbol) ⇒ nada debajo para un scope
2670
+ * CONCEDENTE (conservador: no se lista lo que no se puede enumerar) y 503
2671
+ * en modo `strict` (un subárbol EXCLUIDO que no se puede enumerar no se
2672
+ * puede restar: fail-open, F3/F10).
2673
+ */
2674
+ async #descendants(descendantsOf, scope, maxNodes, unknown = 'empty') {
2675
+ let result;
2676
+ try {
2677
+ result = await descendantsOf(scope, { maxNodes });
2678
+ }
2679
+ catch (error) {
2680
+ if (isAuthzError(error))
2681
+ throw error;
2682
+ throw new ScopeResolverError('descendantsOf', error);
2683
+ }
2684
+ if (result === null || result === undefined) {
2685
+ if (unknown === 'strict') {
2686
+ throw new ScopeResolverError('descendantsOf', new Error(`descendantsOf no conoce ${scope.type}:${scope.uuid ?? ''}: su subárbol no se puede restar.`));
2687
+ }
2688
+ return [];
2689
+ }
2690
+ if (!Array.isArray(result)) {
2691
+ throw new ScopeResolverError('descendantsOf', new TypeError(`descendantsOf devolvió ${typeof result} en vez de ScopeRef[] | null`));
2692
+ }
2693
+ for (const s of result) {
2694
+ try {
2695
+ assertScope(s);
2696
+ }
2697
+ catch (error) {
2698
+ throw new ScopeResolverError('descendantsOf', error);
2699
+ }
2700
+ }
2701
+ if (result.length > maxNodes) {
2702
+ throw new TooManyScopesError(`descendantsOf(${scope.type}:${scope.uuid ?? ''}) devolvió ${result.length} nodos, más que maxDescendants=${maxNodes}.`);
2703
+ }
2704
+ return result;
2705
+ }
2706
+ /**
2707
+ * Ejecuta una escritura del driver. Si vence el deadline (503
2708
+ * `E_AUTHZ_BACKEND_TIMEOUT`) el resultado es DESCONOCIDO: el SDK o el
2709
+ * servidor pueden aplicarla después de que el llamante reciba el error
2710
+ * (D2, auditor H1). Antes de propagar se notifica el mismo evento con
2711
+ * `indeterminate: true`, para que quien audita registre "puede haber
2712
+ * ocurrido" en vez de nada. Cualquier otro fallo (422, conexión rechazada)
2713
+ * significa que la escritura no ocurrió y se propaga sin evento.
2714
+ */
2715
+ async #write(event, fn) {
2716
+ try {
2717
+ return await fn();
2718
+ }
2719
+ catch (error) {
2720
+ if (error instanceof AuthorizationBackendTimeoutError) {
2721
+ await this.#notify({ ...event, indeterminate: true });
2722
+ }
2723
+ throw error;
2724
+ }
73
2725
  }
74
2726
  /**
75
2727
  * Notifica al consumidor. El hook es un side-effect (auditar, emitir un
@@ -99,4 +2751,5 @@ export class AuthorizationManager {
99
2751
  }
100
2752
  }
101
2753
  }
2754
+ _a = AuthorizationManager;
102
2755
  //# sourceMappingURL=manager.js.map