@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
@@ -0,0 +1,666 @@
1
+ import db from '@adonisjs/lucid/services/db';
2
+ import { guardSql, isAuthzError, isSqlDriverError, isTimeoutLike } from '../shared/backend_guard.js';
3
+ import { AmbiguousRoleError, AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, TooManyLocalRolesError, } from '../errors.js';
4
+ import { isValidScopeType, scopeFromKey } from '../identity.js';
5
+ import { systemClock } from '../clock.js';
6
+ import { isSqliteDialect } from '../shared/sql_expiry.js';
7
+ /**
8
+ * Memo del CATÁLOGO (roles, permisos y vínculos rol→permiso), y de nada más.
9
+ *
10
+ * El camino caliente de `authorize` consultaba `authz_*` en cada pregunta
11
+ * (`findPermission` en ambos drivers; `rolesGranting` además en openfga)
12
+ * para leer algo que solo cambia cuando el consumidor sincroniza su config.
13
+ * Este memo carga las tres tablas una vez, perezosamente, y las sirve desde
14
+ * memoria mientras la BASE diga que siguen vigentes. Lo que NUNCA cachea:
15
+ * hechos (asignaciones, denies) ni decisiones — un `authorize` siempre
16
+ * pregunta al backend de hechos; lo que se ahorra es el "¿qué uuid tiene
17
+ * `docs:read`?".
18
+ *
19
+ * Invariante (2D · F1): **el catálogo que decide es el de la BD.** La tabla
20
+ * `authz_catalog_version` (una fila, `id = 1`) lleva un entero que
21
+ * `syncAuthzCatalog` incrementa DENTRO de su transacción. Cada foto recuerda
22
+ * con qué versión se cargó y, antes de servirse, la contrasta con la fila
23
+ * (un SELECT por clave primaria, con deadline y clasificado 503): si difiere,
24
+ * recarga. Así un sync en OTRO proceso (otro worker, `node ace
25
+ * authz:catalog:sync` en un despliegue) se ve en la siguiente pregunta de
26
+ * todos los procesos, sin pub/sub y sin reiniciar. El memo nunca sirve una
27
+ * decisión con una versión distinta de la de la base.
28
+ *
29
+ * Contrato de invalidación (README, "Performance"):
30
+ * 1. `syncAuthzCatalog` (y por tanto `authz:catalog:sync`) sube la fila en
31
+ * su transacción (lo ven todos los procesos) y además el contador de
32
+ * este módulo (lo ve este proceso al instante, también con `everyMs`).
33
+ * 2. `invalidateAuthzCatalog()` invalida los memos de ESTE proceso; la fila
34
+ * la sube `bumpAuthzCatalogVersion(trx)` para TODOS, y SOLO como última
35
+ * sentencia de la transacción que escribe `authz_*` (2E · H2): un bump
36
+ * que se confirma antes que su escritura hace que otro proceso recargue
37
+ * los datos viejos etiquetados con la versión nueva y no vuelva a
38
+ * revalidar jamás. Quien escribe `authz_*` por fuera del sync (un seeder,
39
+ * una migración de datos) lo hace con `withAuthzCatalogWrite(async (trx)
40
+ * => …)`, que abre la transacción y sube la versión al final, dentro; sin
41
+ * eso los demás procesos no se enteran (caso negativo fijado por test).
42
+ * 3. `revalidate: 'always'` (default) contrasta la fila en cada `view()`.
43
+ * `{ everyMs }` (opt-in) la contrasta como mucho una vez por ventana: es
44
+ * una ventana ACOTADA de catálogo viejo —de revocación fail-open— que el
45
+ * consumidor acepta a sabiendas a cambio de ahorrarse ese SELECT.
46
+ *
47
+ * Es composición: cada driver del paquete construye el suyo (o recibe uno
48
+ * compartido en `catalog`); un driver de terceros no necesita saber que existe.
49
+ */
50
+ /** Tabla de la versión compartida del catálogo (una fila, `id = 1`). */
51
+ export const CATALOG_VERSION_TABLE = 'authz_catalog_version';
52
+ const CATALOG_VERSION_ROW_ID = 1;
53
+ /**
54
+ * Clave de owner de los roles GLOBALES (`authz_roles.owner_scope_key`, 3B ·
55
+ * B1): los del catálogo del config (`syncAuthzCatalog`). Reservada: ningún
56
+ * `scopeKey()` la produce (la raíz da `app`; el resto lleva `|`).
57
+ */
58
+ export const GLOBAL_OWNER_KEY = 'global';
59
+ /** ¿El rol cuenta en un scope cuya cadena tiene esas claves (owner global o en la cadena)? */
60
+ export function isRoleVisibleWith(role, chainKeys) {
61
+ if (role.owner === GLOBAL_OWNER_KEY)
62
+ return true;
63
+ return Array.isArray(chainKeys) ? chainKeys.includes(role.owner) : chainKeys.has(role.owner);
64
+ }
65
+ /**
66
+ * Versión del catálogo en ESTE proceso. La sube `syncAuthzCatalog` al
67
+ * terminar e `invalidateAuthzCatalog()`; cada memo recuerda con qué versión
68
+ * cargó y se recarga si difiere. Es la señal intra-proceso (inmediata, sin
69
+ * SQL); la señal entre procesos es la fila `authz_catalog_version`.
70
+ */
71
+ let catalogVersion = 0;
72
+ /**
73
+ * Invalida todos los memos del catálogo de este proceso. En otro proceso no
74
+ * hace nada: para eso está la fila compartida (`bumpAuthzCatalogVersion`).
75
+ */
76
+ export function invalidateAuthzCatalog() {
77
+ catalogVersion += 1;
78
+ }
79
+ /** Deadline de cada consulta de carga (el mismo default que el catálogo). */
80
+ export const DEFAULT_CATALOG_CACHE_TIMEOUT_MS = 5_000;
81
+ /**
82
+ * Cota por defecto de `readLocalRoles` (3b-0b · AB2): cuántos roles LOCALES
83
+ * puede devolver una pasada de `prune-orphans` antes de rendirse con 500.
84
+ * Es del mismo orden que `maxScopes`/`maxDescendants`: una lectura sin
85
+ * `LIMIT` sobre una tabla que el consumidor hace crecer.
86
+ */
87
+ export const DEFAULT_MAX_LOCAL_ROLES = 10_000;
88
+ function describeClient(client) {
89
+ if (client === null)
90
+ return 'null';
91
+ if (client === undefined)
92
+ return 'nada';
93
+ if (typeof client !== 'object' && typeof client !== 'function')
94
+ return `un ${typeof client}`;
95
+ const c = client;
96
+ if (c.isTransaction === false)
97
+ return `un cliente que NO es una transacción (${c.constructor?.name ?? 'objeto'})`;
98
+ return `un ${c.constructor?.name ?? 'objeto'} sin isTransaction`;
99
+ }
100
+ /**
101
+ * `bumpAuthzCatalogVersion` exige la transacción que escribe `authz_*` (2E ·
102
+ * H2, auditor 2): con el `db` global la subida se confirmaba ANTES que la
103
+ * escritura (fuera de la transacción del consumidor) y el memo de otro
104
+ * proceso recargaba los datos viejos con la etiqueta nueva — un fail-open
105
+ * permanente. 500: es un error de programación, no una pregunta.
106
+ */
107
+ function assertTransactionClient(client, operation) {
108
+ const c = client;
109
+ if (!c || typeof c.from !== 'function' || typeof c.table !== 'function' || c.isTransaction !== true) {
110
+ throw new AuthorizationConfigError(`${operation} exige el cliente de la TRANSACCIÓN que escribe authz_* (el trx de db.transaction) y llegó ` +
111
+ `${describeClient(client)}. Un bump fuera de esa transacción se confirma antes que la escritura y deja a los ` +
112
+ `demás procesos con el catálogo viejo etiquetado como nuevo, para siempre. Escribe authz_* con ` +
113
+ `withAuthzCatalogWrite(async (trx) => { … }): abre la transacción, ejecuta tu escritura y sube la versión ` +
114
+ `como última sentencia, dentro.`);
115
+ }
116
+ return c;
117
+ }
118
+ /**
119
+ * `catalog` (memo compartido) y `catalogRevalidate` juntos se contradicen
120
+ * (2E · I3, auditor 11): la política de revalidación es la del memo y se fija
121
+ * al construir el `CatalogCache`; el `catalogRevalidate` del driver se
122
+ * ignoraba en silencio. 500 al construir: config rota, no una pregunta. Lo
123
+ * llaman los dos drivers del paquete desde su constructor.
124
+ */
125
+ export function assertCatalogOptions(driver, options) {
126
+ if (options.catalog !== undefined && options.catalogRevalidate !== undefined) {
127
+ throw new AuthorizationConfigError(`${driver}: 'catalog' (memo compartido) y 'catalogRevalidate' no pueden ir juntos: la política de ` +
128
+ `revalidación es la del memo que se comparte (new CatalogCache({ revalidate })) y la del driver se ` +
129
+ `ignoraría. Quita 'catalogRevalidate' o construye el driver sin 'catalog'.`);
130
+ }
131
+ }
132
+ /**
133
+ * La fila de la versión no está o no se puede leer como número (2E · I1,
134
+ * auditor 7): es una base sin la migración 2.0 (que la siembra) o con la fila
135
+ * borrada. Fail-closed: sin versión legible no se sirve ningún catálogo — ni
136
+ * el memo viejo ni una carga en frío etiquetada como «versión 0».
137
+ */
138
+ function unreadableVersionRow(driver, why) {
139
+ const error = new AuthorizationBackendError(driver, 'catalog.version', new Error(why));
140
+ error.message =
141
+ `El backend de autorización '${driver}' no tiene una versión legible del catálogo (${CATALOG_VERSION_TABLE}, ` +
142
+ `id = ${CATALOG_VERSION_ROW_ID}): ${why}. Probablemente la migración 2.0 no está aplicada (siembra la fila); ` +
143
+ `sin ella no se sirve ningún catálogo.`;
144
+ return error;
145
+ }
146
+ /**
147
+ * Versión compartida del catálogo: la fila `authz_catalog_version`. Sin fila
148
+ * legible (semilla ausente, fila borrada, valor no numérico) es 503
149
+ * `E_AUTHZ_BACKEND_UNAVAILABLE` con «migración 2.0 no aplicada» (I1); sin
150
+ * TABLA se clasifica como el resto de fallos SQL (503).
151
+ */
152
+ export async function readAuthzCatalogVersion(options = {}) {
153
+ const client = options.client ?? db;
154
+ const driver = options.driver ?? 'catalog';
155
+ const rows = await guardSql(driver, 'catalog.version', options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS, () => client.from(CATALOG_VERSION_TABLE).where('id', CATALOG_VERSION_ROW_ID).select('version'));
156
+ if (rows.length === 0)
157
+ throw unreadableVersionRow(driver, 'la fila no existe');
158
+ const raw = rows[0].version;
159
+ const version = typeof raw === 'number' ? raw : typeof raw === 'string' || typeof raw === 'bigint' ? Number(raw) : Number.NaN;
160
+ if (!Number.isFinite(version))
161
+ throw unreadableVersionRow(driver, `la columna version vale ${String(raw)}`);
162
+ return version;
163
+ }
164
+ /**
165
+ * Sube la versión compartida del catálogo: TODOS los memos de TODOS los
166
+ * procesos recargan en su siguiente pregunta (o al cerrar su ventana
167
+ * `everyMs`). Exige el cliente de la transacción que escribe `authz_*` (500
168
+ * `E_AUTHZ_CONFIG` sin él, 2E · H2) y tiene que ser su ÚLTIMA sentencia: lo
169
+ * garantiza `withAuthzCatalogWrite`, que es por donde escriben
170
+ * `syncAuthzCatalog` y cualquier seeder o migración de datos. Sin fila
171
+ * (semilla ausente) la crea; la carrera de dos procesos que la crean a la vez
172
+ * la resuelve la clave primaria: el perdedor vuelve a hacer UPDATE. Es SOLO el
173
+ * canal entre procesos: no toca el contador de este (con `everyMs`, hasta este
174
+ * proceso tarda una ventana en verlo; con `'always'`, la siguiente pregunta lo
175
+ * ve).
176
+ */
177
+ export async function bumpAuthzCatalogVersion(trx, options = {}) {
178
+ const client = assertTransactionClient(trx, 'bumpAuthzCatalogVersion');
179
+ const driver = options.driver ?? 'catalog';
180
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
181
+ const update = () => guardSql(driver, 'catalog.version.bump', timeoutMs, () => client
182
+ .from(CATALOG_VERSION_TABLE)
183
+ .where('id', CATALOG_VERSION_ROW_ID)
184
+ .increment('version', 1));
185
+ const updated = Number(await update());
186
+ if (updated > 0)
187
+ return;
188
+ try {
189
+ await guardSql(driver, 'catalog.version.seed', timeoutMs, () => client.table(CATALOG_VERSION_TABLE).insert({ id: CATALOG_VERSION_ROW_ID, version: 1, updated_at: systemClock() }));
190
+ }
191
+ catch (error) {
192
+ // Otro proceso sembró la fila entre el UPDATE y el INSERT: se sube la suya.
193
+ if (Number(await update()) === 0)
194
+ throw error;
195
+ }
196
+ }
197
+ /**
198
+ * TODOS los roles LOCALES (owner ≠ `global`) leídos de la BASE, en fresco y
199
+ * con sus permisos.
200
+ *
201
+ * Lo usa `AuthorizationManager.pruneOrphanRoles` —el motor de
202
+ * `authz:catalog:prune-orphans` (3b-0 · Z2)—, que necesita mirar el catálogo
203
+ * ENTERO: los roles cuyo owner ya no resuelve son precisamente los que nadie
204
+ * enumera desde el árbol (no cuelgan de ningún scope vivo), así que no hay
205
+ * lista de owners con la que preguntar.
206
+ *
207
+ * De la BASE y no del memo: con `catalogRevalidate: { everyMs }` —config
208
+ * legal y documentada— la foto puede no tener lo que otro proceso acaba de
209
+ * confirmar (auditor A2 bis), y aquí se decide qué se BORRA.
210
+ *
211
+ * El orden es ESTABLE por `uuid` (3F · U5, tester 3E · §4.3): sin `ORDER BY`
212
+ * lo pone el motor, así que la lista del `--dry-run`, la secuencia de
213
+ * `role_purged` que ve el hook de auditoría y el rol por el que seguiría una
214
+ * pasada interrumpida cambiaban entre PostgreSQL, MySQL y SQLite. Con uuid
215
+ * v7 es además el orden de creación.
216
+ *
217
+ * **Lo que NO devuelve son los HECHOS del rol** (3b-2j). Hasta aquí contaba
218
+ * también sus asignaciones vigentes (3b-0b · AA1) leyendo
219
+ * `authz_assignments`, que es la tabla del driver `database`: con `openfga`
220
+ * los hechos viven en el store y ese conteo era siempre cero, así que el
221
+ * `stillGranting` del barrido —«falso ⇒ no concede SEGURO», leído justo
222
+ * antes de un borrado destructivo— mentía. Contarlos es ahora una pregunta
223
+ * del PUERTO (`AuthorizationDriver.countRoleAssignments`), que es de quien
224
+ * son los hechos.
225
+ *
226
+ * Cota `maxLocalRoles` (3b-0b · AB2, default 10 000): la lectura es una sola
227
+ * consulta y un catálogo local enorme la convierte en amplificación. Se pide
228
+ * una fila de más y, si aparece, 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES` — nunca
229
+ * una lista parcial, que aquí sería purgar a ciegas la mitad del catálogo.
230
+ */
231
+ export async function readLocalRoles(options = {}) {
232
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
233
+ const driver = options.driver ?? 'catalog';
234
+ const maxLocalRoles = options.maxLocalRoles ?? DEFAULT_MAX_LOCAL_ROLES;
235
+ const rows = await guardSql(driver, 'catalog.localRoles', timeoutMs, () => db
236
+ .from('authz_roles')
237
+ .whereNot('owner_scope_key', GLOBAL_OWNER_KEY)
238
+ .orderBy('uuid', 'asc')
239
+ .limit(maxLocalRoles + 1)
240
+ .select('uuid', 'slug', 'scope_type', 'rank', 'owner_scope_key'));
241
+ if (rows.length > maxLocalRoles) {
242
+ throw new TooManyLocalRolesError(`El catálogo tiene más de ${maxLocalRoles} roles locales (cota 'maxLocalRoles'). No se devuelve una lista ` +
243
+ `parcial: quien lee esto decide qué se BORRA. Sube la cota a sabiendas si tu catálogo local es así de grande.`);
244
+ }
245
+ if (rows.length === 0)
246
+ return [];
247
+ const links = await guardSql(driver, 'catalog.localRolePermissions', timeoutMs, () => db
248
+ .from('authz_role_permissions')
249
+ .join('authz_permissions', 'authz_permissions.uuid', 'authz_role_permissions.permission_uuid')
250
+ .whereIn('authz_role_permissions.role_uuid', rows.map((row) => String(row.uuid)))
251
+ .select('authz_role_permissions.role_uuid as role_uuid', 'authz_permissions.slug as permission_slug'));
252
+ const permissionsOf = new Map();
253
+ for (const link of links) {
254
+ const list = permissionsOf.get(String(link.role_uuid)) ?? [];
255
+ list.push(String(link.permission_slug));
256
+ permissionsOf.set(String(link.role_uuid), list);
257
+ }
258
+ return rows.map((row) => ({
259
+ role: Object.freeze({
260
+ uuid: String(row.uuid),
261
+ slug: String(row.slug),
262
+ scopeType: String(row.scope_type),
263
+ owner: String(row.owner_scope_key),
264
+ rank: Number(row.rank),
265
+ }),
266
+ permissions: (permissionsOf.get(String(row.uuid)) ?? []).sort(),
267
+ }));
268
+ }
269
+ /**
270
+ * Serializa las escrituras del CATÁLOGO bloqueando la fila de
271
+ * `authz_catalog_version` (3D · M2, auditor V2 🔴).
272
+ *
273
+ * La unicidad de la que depende todo el modelo de roles locales —«dentro de
274
+ * una cadena un `(slug, nivel)` identifica un solo rol»— era un
275
+ * *read-then-write*: cada escritor comprobaba la colisión contra SU foto del
276
+ * memo y el unique de la base es `(slug, scope_type, owner_scope_key)`, así
277
+ * que dos `define` con owners distintos (o un `define` contra un `sync`)
278
+ * insertaban los dos y dejaban dos homónimos PERMANENTES. Un `SELECT … FOR
279
+ * UPDATE` sobre `authz_roles WHERE slug=? AND scope_type=?` no lo cierra: no
280
+ * hay filas que bloquear (en PostgreSQL no hay gap locks), que es justo el
281
+ * caso. La fila de la versión sí existe siempre y TODA escritura de `authz_*`
282
+ * pasa por aquí, así que bloquearla es la barrera real: los escritores del
283
+ * catálogo van en serie y el re-chequeo de colisión dentro de la transacción
284
+ * (leyendo la BASE, no el memo) ve lo que el anterior confirmó.
285
+ *
286
+ * Coste: las escrituras del catálogo son raras (un sync por despliegue, la
287
+ * API de delegación). Los HECHOS (`grant`/`deny`/…) no pasan por aquí.
288
+ *
289
+ * SQLite no tiene `FOR UPDATE` y no lo necesita: sus escrituras ya se
290
+ * serializan a nivel de base (una transacción de escritura a la vez).
291
+ */
292
+ async function lockCatalogForWrite(trx, options) {
293
+ // 3E · Q5: Lucid llama `better-sqlite3` a su dialecto de SQLite.
294
+ if (isSqliteDialect(trx))
295
+ return;
296
+ await guardSql(options.driver, 'catalog.lock', options.timeoutMs, () => trx.from(CATALOG_VERSION_TABLE).where('id', CATALOG_VERSION_ROW_ID).forUpdate().select('version'));
297
+ }
298
+ /**
299
+ * LA forma de escribir `authz_*` por fuera de `syncAuthzCatalog` (2E · H2):
300
+ * abre una transacción, ejecuta `fn(trx)` —tu escritura, con ESE cliente— y
301
+ * sube `authz_catalog_version` como última sentencia, dentro. O se confirma
302
+ * todo (datos nuevos + versión nueva) o nada: un memo de otro proceso nunca
303
+ * puede leer la versión nueva con los datos viejos. Devuelve lo que devuelva
304
+ * `fn`. Si `fn` lanza, la transacción se revierte, la versión no sube y su
305
+ * error sale tal cual (es tuyo) — salvo que sea un error del cliente SQL
306
+ * (2.5-B · K12), que se clasifica como 503 igual que un fallo al abrir o
307
+ * confirmar la transacción. NO te tragues errores de SQL dentro de `fn`: en
308
+ * PostgreSQL la transacción queda abortada y todo lo que sigue falla; en
309
+ * MySQL y SQLite el motor no la aborta y lo que sigue SE CONFIRMA.
310
+ *
311
+ * Es solo el canal entre procesos (la fila): en este proceso la siguiente
312
+ * pregunta lo ve con `'always'` y, con `{ everyMs }`, al cerrar la ventana —
313
+ * `syncAuthzCatalog` además invalida en memoria; hazlo tú con
314
+ * `invalidateAuthzCatalog()` si usas `everyMs` y lo necesitas al instante.
315
+ *
316
+ * await withAuthzCatalogWrite(async (trx) => {
317
+ * await trx.from('authz_role_permissions').where('role_uuid', role).delete()
318
+ * })
319
+ */
320
+ export async function withAuthzCatalogWrite(fn, options = {}) {
321
+ if (typeof fn !== 'function') {
322
+ throw new AuthorizationConfigError(`withAuthzCatalogWrite espera la función que escribe authz_* (async (trx) => …) y llegó ${typeof fn}`);
323
+ }
324
+ const driver = options.driver ?? 'catalog';
325
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
326
+ const connection = options.connection ? db.connection(options.connection) : db;
327
+ // Lo que lance `fn` es del consumidor y sale intacto; lo que falle al abrir
328
+ // o confirmar la transacción es la base y se clasifica (503).
329
+ let consumerError = null;
330
+ try {
331
+ return await connection.transaction(async (trx) => {
332
+ // Primero el cerrojo del catálogo (M2), después la escritura y, como
333
+ // última sentencia, el bump (H2).
334
+ await lockCatalogForWrite(trx, { driver, timeoutMs });
335
+ let result;
336
+ try {
337
+ result = await fn(trx);
338
+ }
339
+ catch (error) {
340
+ consumerError = { error };
341
+ throw error;
342
+ }
343
+ await bumpAuthzCatalogVersion(trx, { driver, timeoutMs });
344
+ return result;
345
+ });
346
+ }
347
+ catch (error) {
348
+ // Lo que `fn` lanzó y es SUYO (su `Error`, un 422 del paquete) sale
349
+ // intacto. Lo que `fn` dejó escapar del CLIENTE SQL (2.5-B · K12: en
350
+ // PostgreSQL, tras tragarse un fallo, la transacción está abortada y el
351
+ // siguiente UPDATE lanza `25P02` con el SQL dentro) se clasifica igual
352
+ // que un fallo al abrir o confirmar: 503, causa conservada, sin SQL en el
353
+ // mensaje. `fn` NO debe tragarse errores de SQL: en MySQL y SQLite el
354
+ // motor no aborta la transacción y lo que sigue SE CONFIRMA.
355
+ const fromConsumer = consumerError !== null && consumerError.error === error;
356
+ if (fromConsumer && !isSqlDriverError(error))
357
+ throw error;
358
+ if (isAuthzError(error))
359
+ throw error;
360
+ if (isTimeoutLike(error))
361
+ throw new AuthorizationBackendTimeoutError(driver, 'catalog.write', timeoutMs, error);
362
+ throw new AuthorizationBackendError(driver, 'catalog.write', error);
363
+ }
364
+ }
365
+ export class CatalogCache {
366
+ #view = null;
367
+ #version = -1;
368
+ /** Generación de ESTA instancia: `invalidate()` la sube; una carga captura la suya antes de leer (F4). */
369
+ #generation = 0;
370
+ #loadedGeneration = -1;
371
+ #loading = null;
372
+ #checking = null;
373
+ /** Último instante (reloj monótono) en que la foto se contrastó con la fila (base de `everyMs`). */
374
+ #checkedAt = 0;
375
+ #everyMs;
376
+ #timeoutMs;
377
+ #driver;
378
+ /** Reloj monótono: nunca `Date.now()` para medir una ventana (H3). */
379
+ #now;
380
+ constructor(options = {}) {
381
+ const revalidate = options.revalidate ?? 'always';
382
+ if (revalidate !== 'always') {
383
+ const everyMs = revalidate?.everyMs;
384
+ if (typeof everyMs !== 'number' || !(Number.isFinite(everyMs) && everyMs > 0)) {
385
+ throw new TypeError(`CatalogCache: revalidate debe ser 'always' o { everyMs: número > 0 } (llegó ${JSON.stringify(revalidate)})`);
386
+ }
387
+ this.#everyMs = everyMs;
388
+ }
389
+ else {
390
+ this.#everyMs = null;
391
+ }
392
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
393
+ this.#driver = options.driver ?? 'catalog';
394
+ if (options.now !== undefined && typeof options.now !== 'function') {
395
+ throw new TypeError(`CatalogCache: now debe ser una función (llegó ${typeof options.now})`);
396
+ }
397
+ this.#now = options.now ?? (() => performance.now());
398
+ }
399
+ /**
400
+ * La foto vigente. Sin foto, o invalidada en este proceso ⇒ carga (las
401
+ * llamadas concurrentes comparten la misma promesa: un arranque con cien
402
+ * requests no dispara cien cargas). Con foto ⇒ se contrasta su versión
403
+ * con la fila compartida (según `revalidate`; las comprobaciones
404
+ * concurrentes también comparten promesa) y, si la base va por delante,
405
+ * recarga. Una carga o comprobación que falla (503, clasificado por
406
+ * `guardSql`) no deja nada cacheado ni servido: la siguiente pregunta
407
+ * vuelve a intentarlo.
408
+ */
409
+ async view() {
410
+ const current = this.#view;
411
+ if (current && this.#isFresh()) {
412
+ if (!this.#needsCheck())
413
+ return current;
414
+ if (!this.#checking) {
415
+ this.#checking = this.#revalidate(current).finally(() => {
416
+ this.#checking = null;
417
+ });
418
+ }
419
+ return this.#checking;
420
+ }
421
+ return this.#reload();
422
+ }
423
+ /** Una carga compartida; `knownVersion` es la fila recién leída por una revalidación (se ahorra releerla). */
424
+ #reload(knownVersion) {
425
+ if (!this.#loading) {
426
+ this.#loading = this.#load(knownVersion).finally(() => {
427
+ this.#loading = null;
428
+ });
429
+ }
430
+ return this.#loading;
431
+ }
432
+ /**
433
+ * Olvida la foto de ESTE memo. La siguiente pregunta recarga. Es un bump
434
+ * de generación, no un `#view = null`: una carga en vuelo capturó la
435
+ * generación anterior y su foto aterriza ya vieja (F4, CR2).
436
+ */
437
+ invalidate() {
438
+ this.#generation += 1;
439
+ }
440
+ /** ¿Hay una foto cargada y vigente para este proceso? (Observabilidad para tests y diagnóstico; no consulta la base.) */
441
+ get loaded() {
442
+ return this.#view !== null && this.#isFresh();
443
+ }
444
+ #isFresh() {
445
+ return this.#version === catalogVersion && this.#loadedGeneration === this.#generation;
446
+ }
447
+ #needsCheck() {
448
+ if (this.#everyMs === null)
449
+ return true;
450
+ return this.#now() - this.#checkedAt >= this.#everyMs;
451
+ }
452
+ #sql(operation, fn) {
453
+ return guardSql(this.#driver, operation, this.#timeoutMs, fn);
454
+ }
455
+ #readVersion() {
456
+ return readAuthzCatalogVersion({ driver: this.#driver, timeoutMs: this.#timeoutMs });
457
+ }
458
+ /**
459
+ * Contrasta la foto con la fila: misma versión ⇒ se sirve; distinta ⇒
460
+ * recarga. Si la foto cambió mientras se leía la fila (otra carga terminó
461
+ * antes) se sirve lo que haya ahora, que ya es más nuevo.
462
+ */
463
+ async #revalidate(current) {
464
+ const dbVersion = await this.#readVersion();
465
+ if (this.#view !== current)
466
+ return this.view();
467
+ if (dbVersion === current.version && this.#isFresh()) {
468
+ this.#checkedAt = this.#now();
469
+ return current;
470
+ }
471
+ return this.#reload(dbVersion);
472
+ }
473
+ async #load(knownVersion) {
474
+ // Las versiones (proceso y fila) se toman ANTES de leer las tablas: si un
475
+ // sync aterriza durante la carga, esta foto queda marcada como vieja y
476
+ // la siguiente pregunta recarga. Una foto mixta solo puede ser más
477
+ // restrictiva (permisos → roles → vínculos) y dura una pregunta.
478
+ const version = catalogVersion;
479
+ const generation = this.#generation;
480
+ const dbVersion = knownVersion ?? (await this.#readVersion());
481
+ const permissions = await this.#sql('catalog.permissions', () => db.from('authz_permissions').select('uuid', 'slug', 'assignable_at'));
482
+ const roles = await this.#sql('catalog.roles', () => db.from('authz_roles').select('uuid', 'slug', 'scope_type', 'owner_scope_key', 'rank'));
483
+ const links = await this.#sql('catalog.links', () => db.from('authz_role_permissions').select('role_uuid', 'permission_uuid'));
484
+ const view = buildCatalogView(permissions, roles, links, systemClock().getTime(), dbVersion);
485
+ this.#view = view;
486
+ this.#version = version;
487
+ this.#loadedGeneration = generation;
488
+ // La ventana se mide con el reloj monótono; `loadedAt` es de pared (informativo).
489
+ this.#checkedAt = this.#now();
490
+ return view;
491
+ }
492
+ }
493
+ /**
494
+ * Clave `(slug, scopeType)` de un rol con un separador NO imprimible
495
+ * (`\u001f`, escrito como escape a propósito: un carácter invisible en el
496
+ * código ya costó un bug): `a:b`+`c` y `a`+`b:c` no pueden colisionar.
497
+ */
498
+ function roleKey(slug, scopeType) {
499
+ return `${slug}\u001f${scopeType}`;
500
+ }
501
+ /**
502
+ * `assignable_at` tal como viene de la base: `NULL` = cualquier nivel; si
503
+ * no, un JSON con una lista no vacía de tipos de scope válidos. Otra cosa
504
+ * (una edición a mano) es catálogo corrupto: 500 `E_AUTHZ_INTERNAL`, nunca
505
+ * «cualquiera» (sería relajar una restricción en silencio) ni «ninguno»
506
+ * disfrazado de dato.
507
+ */
508
+ export function parseAssignableAt(slug, raw) {
509
+ if (raw === null || raw === undefined)
510
+ return null;
511
+ let parsed = raw;
512
+ if (typeof raw === 'string') {
513
+ try {
514
+ parsed = JSON.parse(raw);
515
+ }
516
+ catch {
517
+ parsed = undefined;
518
+ }
519
+ }
520
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every((t) => typeof t === 'string' && isValidScopeType(t))) {
521
+ throw new AuthorizationInternalError(`authz_permissions.assignable_at del permiso '${slug}' no es una lista JSON no vacía de tipos de scope válidos ` +
522
+ `(llegó ${typeof raw === 'string' ? raw : typeof raw}); corrige la fila o vuelve a sincronizar el catálogo.`);
523
+ }
524
+ return Object.freeze([...new Set(parsed)]);
525
+ }
526
+ /**
527
+ * `authz_roles.owner_scope_key` tal como viene de la base: `global` o la
528
+ * clave de un scope que NO es la raíz. Cualquier otra cosa es catálogo
529
+ * corrupto (500 `E_AUTHZ_INTERNAL`), nunca un default silencioso.
530
+ *
531
+ * `'app'` en particular (3D · N3, auditor V7): la raíz está SIEMPRE en toda
532
+ * cadena, así que un rol con ese owner sería visible en todas partes — un
533
+ * global disfrazado que `syncAuthzCatalog` no gobierna. La API lo impide
534
+ * (`#assertOwnerScope`); aquí se cierra la fila escrita a mano.
535
+ */
536
+ function ownerOf(row) {
537
+ const owner = row.owner_scope_key;
538
+ const valid = typeof owner === 'string' && (owner === GLOBAL_OWNER_KEY || scopeFromKey(owner)?.uuid != null);
539
+ if (!valid) {
540
+ throw new AuthorizationInternalError(`authz_roles.owner_scope_key del rol '${row.slug}@${row.scope_type}' (${row.uuid}) no es una clave de owner ` +
541
+ `(llegó ${typeof owner === 'string' ? `'${owner}'` : owner === null ? 'null' : typeof owner}); se espera ` +
542
+ `'${GLOBAL_OWNER_KEY}' o '<tipo>|<uuid>' de un scope que no sea la raíz. Corrige la fila o aplica la migración 2.2.`);
543
+ }
544
+ return owner;
545
+ }
546
+ function buildCatalogView(permissions, roles, links, loadedAt, version) {
547
+ const permissionBySlug = new Map();
548
+ const slugByPermissionUuid = new Map();
549
+ for (const p of permissions) {
550
+ permissionBySlug.set(p.slug, Object.freeze({ uuid: p.uuid, assignableAt: parseAssignableAt(p.slug, p.assignable_at) }));
551
+ slugByPermissionUuid.set(p.uuid, p.slug);
552
+ }
553
+ // Por `(slug, scopeType)` puede haber VARIOS roles (owners distintos, 3B):
554
+ // la lista guarda primero el global (si lo hay) y luego los locales.
555
+ const rolesByKey = new Map();
556
+ const roleByUuid = new Map();
557
+ const rolesByLevel = new Map();
558
+ const rolesByOwner = new Map();
559
+ let topGlobalRank = 0;
560
+ for (const r of roles) {
561
+ const owner = ownerOf(r);
562
+ const rank = Number(r.rank ?? 0);
563
+ const role = Object.freeze({
564
+ uuid: r.uuid,
565
+ slug: r.slug,
566
+ scopeType: r.scope_type,
567
+ owner,
568
+ rank: Number.isFinite(rank) ? rank : 0,
569
+ });
570
+ const key = roleKey(r.slug, r.scope_type);
571
+ if (!rolesByKey.has(key))
572
+ rolesByKey.set(key, []);
573
+ if (owner === GLOBAL_OWNER_KEY) {
574
+ rolesByKey.get(key).unshift(role);
575
+ if (role.rank > topGlobalRank)
576
+ topGlobalRank = role.rank;
577
+ }
578
+ else {
579
+ rolesByKey.get(key).push(role);
580
+ }
581
+ roleByUuid.set(r.uuid, role);
582
+ if (!rolesByLevel.has(r.scope_type))
583
+ rolesByLevel.set(r.scope_type, []);
584
+ rolesByLevel.get(r.scope_type).push(role);
585
+ if (owner !== GLOBAL_OWNER_KEY) {
586
+ if (!rolesByOwner.has(owner))
587
+ rolesByOwner.set(owner, []);
588
+ rolesByOwner.get(owner).push(role);
589
+ }
590
+ }
591
+ // Un vínculo cuyo rol o permiso no existe (FK rota fuera del sync) no
592
+ // concede nada: se ignora, igual que lo ignoraría el join SQL.
593
+ const grantingByPermission = new Map();
594
+ const permissionsByRole = new Map();
595
+ for (const link of links) {
596
+ const role = roleByUuid.get(link.role_uuid);
597
+ if (!role)
598
+ continue;
599
+ const slug = slugByPermissionUuid.get(link.permission_uuid);
600
+ if (slug !== undefined) {
601
+ if (!permissionsByRole.has(link.role_uuid))
602
+ permissionsByRole.set(link.role_uuid, new Set());
603
+ permissionsByRole.get(link.role_uuid).add(slug);
604
+ }
605
+ let byLevel = grantingByPermission.get(link.permission_uuid);
606
+ if (!byLevel) {
607
+ byLevel = new Map();
608
+ grantingByPermission.set(link.permission_uuid, byLevel);
609
+ }
610
+ let list = byLevel.get(role.scopeType);
611
+ if (!list) {
612
+ list = [];
613
+ byLevel.set(role.scopeType, list);
614
+ }
615
+ list.push({ slug: role.slug, uuid: link.role_uuid, scopeType: role.scopeType, owner: role.owner });
616
+ }
617
+ const EMPTY_SET = new Set();
618
+ const permissionSlugs = Object.freeze([...permissionBySlug.keys()]);
619
+ return {
620
+ permission: (slug) => permissionBySlug.get(slug) ?? null,
621
+ role: (slug, scopeType) => (rolesByKey.get(roleKey(slug, scopeType)) ?? []).find((r) => r.owner === GLOBAL_OWNER_KEY) ?? null,
622
+ roleVisible: (slug, scopeType, chainKeys) => {
623
+ const named = rolesByKey.get(roleKey(slug, scopeType));
624
+ if (!named)
625
+ return null;
626
+ const owners = new Set(chainKeys);
627
+ const visible = named.filter((role) => isRoleVisibleWith(role, owners));
628
+ if (visible.length === 0)
629
+ return null;
630
+ if (visible.length > 1) {
631
+ // 3D · M1: fail-closed. Se nombran uuid y owner de los que SON
632
+ // visibles en esta cadena —y solo esos (3E · Q2): el llamante ya
633
+ // puede verlos, así que no hay fuga de otro árbol—, que es lo que le
634
+ // permite direccionar por `{ uuid }`.
635
+ //
636
+ // 3E · Q1 (auditor A5): el mensaje aconsejaba «renombra uno de
637
+ // ellos» y la API PROHÍBE renombrar (`updateScopedRole` solo cambia
638
+ // name/description/rank/permissions). La salida real es `{ uuid }`
639
+ // para seguir operando y purgar uno para deshacer la ambigüedad.
640
+ throw new AmbiguousRoleError(`'${slug}' (nivel '${scopeType}') es AMBIGUO aquí: hay ${visible.length} roles visibles en esta cadena ` +
641
+ `(${visible.map((r) => `${r.uuid} owner=${r.owner}`).join('; ')}). Un slug ya no identifica un rol: ` +
642
+ `pregunta por { uuid }, que sigue funcionando. Un rol local no se renombra: para deshacer la ambigüedad ` +
643
+ `hay que PURGAR uno (deleteScopedRole con rank suficiente, o la plataforma con driver().purgeRole). ` +
644
+ `authz:catalog:diff los lista (3F · S3: los ensombrecidos por autoridad NO son deriva y salen con exit 0).`);
645
+ }
646
+ return visible[0];
647
+ },
648
+ rolesNamed: (slug, scopeType) => [...(rolesByKey.get(roleKey(slug, scopeType)) ?? [])],
649
+ rolesOwnedBy: (ownerKey) => [...(rolesByOwner.get(ownerKey) ?? [])],
650
+ localRoles: () => [...rolesByOwner.values()].flat(),
651
+ roleByUuid: (uuid) => roleByUuid.get(uuid) ?? null,
652
+ rolesFor: (scopeType, ownerKeys) => {
653
+ const owners = new Set(ownerKeys);
654
+ return (rolesByLevel.get(scopeType) ?? []).filter((r) => isRoleVisibleWith(r, owners));
655
+ },
656
+ // Copia por llamada: el llamante puede mutar lo que recibe sin tocar la foto.
657
+ rolesGranting: (permissionUuid) => new Map([...(grantingByPermission.get(permissionUuid) ?? new Map())].map(([k, v]) => [k, [...v]])),
658
+ rolePermissionsOf: (roleUuid) => new Set(permissionsByRole.get(roleUuid) ?? EMPTY_SET),
659
+ permissionSlug: (uuid) => slugByPermissionUuid.get(uuid) ?? null,
660
+ permissionSlugs,
661
+ topGlobalRank,
662
+ loadedAt,
663
+ version,
664
+ };
665
+ }
666
+ //# sourceMappingURL=catalog_cache.js.map