@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,859 @@
1
+ import db from '@adonisjs/lucid/services/db';
2
+ import { v7 as uuidv7 } from 'uuid';
3
+ import { assertCatalogUuid, assertNoSlugCollisions, assertScopeType, assertValidSlug, scopeFromKey, scopeKey } from '../identity.js';
4
+ import { CatalogConflictError, InvalidIdentityError, RoleNotAssignableAtError, UnknownPermissionError } from '../errors.js';
5
+ import { guardSql } from '../shared/backend_guard.js';
6
+ import { GLOBAL_OWNER_KEY, invalidateAuthzCatalog, parseAssignableAt, withAuthzCatalogWrite } from './catalog_cache.js';
7
+ import { systemClock } from '../clock.js';
8
+ /**
9
+ * `assignableAt` de un permiso tal como se GUARDA (`authz_permissions.assignable_at`):
10
+ * JSON ordenado y sin duplicados, o `null` = cualquier nivel. Ordenado para
11
+ * que dos specs equivalentes sean la misma cadena (el sync compara texto).
12
+ */
13
+ export function encodeAssignableAt(levels) {
14
+ if (levels === undefined)
15
+ return null;
16
+ return JSON.stringify([...new Set(levels)].sort());
17
+ }
18
+ /**
19
+ * `assignable_at` tal como está EN LA BASE, normalizado a la forma en la que
20
+ * el spec lo codifica (3D · N5): es lo que permite que el sync y el diff
21
+ * comparen lo mismo. Hasta aquí el sync comparaba la cadena cruda y el diff
22
+ * la lista parseada, así que un valor desordenado a mano (`["unit","app"]`)
23
+ * salía «en sync» y el sync lo reescribía. Un valor CORRUPTO no lanza aquí:
24
+ * cuenta como distinto, para que el sync lo repare en vez de atragantarse
25
+ * (leerlo sí es 500, `parseAssignableAt`).
26
+ */
27
+ export function storedAssignableAt(slug, raw) {
28
+ try {
29
+ const parsed = parseAssignableAt(slug, raw);
30
+ return encodeAssignableAt(parsed ? [...parsed] : undefined);
31
+ }
32
+ catch {
33
+ return CORRUPT_ASSIGNABLE_AT;
34
+ }
35
+ }
36
+ /** Marca de `storedAssignableAt` para una fila que no se puede leer (3D · N5). */
37
+ export const CORRUPT_ASSIGNABLE_AT = '\u0000corrupto';
38
+ /**
39
+ * `assignable_at` de la base para el DIFF (3E · P5): una fila corrupta vale
40
+ * `null` (cualquier nivel) en vez de 500. El diff EXISTE para reportar filas
41
+ * así —`storedAssignableAt` ya las cuenta como distintas— y hasta 3E se
42
+ * atragantaba con `parseAssignableAt`: el comando moría con 500 justo en el
43
+ * despliegue que iba a repararlo. Leerlo para DECIDIR sigue siendo 500
44
+ * (`parseAssignableAt`, el memo).
45
+ */
46
+ function tolerantAssignableAt(slug, raw) {
47
+ try {
48
+ return parseAssignableAt(slug, raw);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** Un rol de nivel `scopeType` que lleva `permission` con esos `levels` (o `null`): legal, o 422 `E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT`. */
55
+ export function assertAssignableAt(role, permission, levels) {
56
+ if (levels && !levels.includes(role.scopeType)) {
57
+ throw new RoleNotAssignableAtError(`El rol '${role.slug}' (nivel '${role.scopeType}') no puede llevar '${permission}': solo pueden llevarlo roles de ` +
58
+ `${levels.join(', ')} (assignableAt). Es un control de composición del catálogo, no de evaluación.`);
59
+ }
60
+ }
61
+ /**
62
+ * Longitud de `authz_permissions.assignable_at` (`varchar(500)` en el stub y
63
+ * en el espejo). El JSON codificado tiene que caber (3D · N3).
64
+ */
65
+ export const ASSIGNABLE_AT_MAX = 500;
66
+ /** Deadline de cada consulta del catálogo (D15); configurable por `timeoutMs`. */
67
+ export const DEFAULT_CATALOG_TIMEOUT_MS = 5_000;
68
+ /**
69
+ * Valida la gramática del catálogo entero ANTES de tocar la base: formato,
70
+ * longitud publicable en FGA, reservados, familias de prefijos y colisiones
71
+ * tras codificar (`docs:write` / `docs_write`). Un catálogo inválido no
72
+ * escribe nada.
73
+ */
74
+ function assertCatalogGrammar(catalog) {
75
+ const levelsBySlug = new Map();
76
+ for (const perm of catalog.permissions) {
77
+ assertValidSlug('permiso', perm.slug);
78
+ // `assignableAt` (3B · B5): omitido = cualquier nivel; si viene, una
79
+ // lista no vacía de tipos de scope válidos (mismo formato que un
80
+ // `scope_type`). `[]` sería «ningún rol puede llevarlo»: un permiso
81
+ // muerto disfrazado de restricción, se rechaza.
82
+ if (perm.assignableAt !== undefined) {
83
+ if (!Array.isArray(perm.assignableAt) || perm.assignableAt.length === 0) {
84
+ throw new InvalidIdentityError(`assignableAt del permiso '${perm.slug}' inválido: se esperaba una lista no vacía de tipos de scope (u omitirlo) y llegó ` +
85
+ `${Array.isArray(perm.assignableAt) ? '[]' : typeof perm.assignableAt}`);
86
+ }
87
+ for (const level of perm.assignableAt)
88
+ assertScopeType(level);
89
+ // 3D · N3 (auditor V7): `authz_permissions.assignable_at` es
90
+ // `varchar(500)`. Un JSON que no cabe se trunca en un MySQL no
91
+ // estricto y `parseAssignableAt` respondería 500 en CADA `view()` —un
92
+ // corte de servicio total por un dato de config—. Se rechaza al
93
+ // escribir, que es donde el operador puede arreglarlo.
94
+ const encoded = encodeAssignableAt([...perm.assignableAt]);
95
+ if (encoded.length > ASSIGNABLE_AT_MAX) {
96
+ throw new InvalidIdentityError(`assignableAt del permiso '${perm.slug}' no cabe en authz_permissions.assignable_at ` +
97
+ `(${encoded.length} caracteres, máximo ${ASSIGNABLE_AT_MAX}): declara menos niveles o nombres más cortos.`);
98
+ }
99
+ }
100
+ levelsBySlug.set(perm.slug, perm.assignableAt ? Object.freeze([...perm.assignableAt]) : null);
101
+ }
102
+ for (const role of catalog.roles) {
103
+ assertValidSlug('rol', role.slug);
104
+ // El nivel del rol es identidad de scope (minúsculas, ≤ 20, sin
105
+ // separadores): lo que no pase aquí no puede llegar a `scope_type` (E4).
106
+ assertScopeType(role.scopeType);
107
+ // El uuid fijo del spec es la identidad del rol en ambos drivers y viaja
108
+ // en los ids de binding de FGA (3A · A1): canónico y en minúsculas, o 422.
109
+ if (role.uuid !== undefined)
110
+ assertCatalogUuid(`rol '${role.slug}'`, role.uuid);
111
+ for (const slug of role.permissions) {
112
+ assertValidSlug('permiso', slug);
113
+ // Composición dentro del spec (B5); los permisos de OTRO catálogo se
114
+ // contrastan con la base dentro de la transacción.
115
+ assertAssignableAt(role, slug, levelsBySlug.get(slug) ?? null);
116
+ }
117
+ }
118
+ assertNoSlugCollisions('permiso', catalog.permissions.map((p) => p.slug));
119
+ assertNoSlugCollisions('rol', catalog.roles.map((r) => r.slug));
120
+ }
121
+ /**
122
+ * Sincroniza un catálogo (roles + permisos + vínculos) a las tablas `authz_*`.
123
+ *
124
+ * Idempotente y transaccional. Permisos y roles se crean si faltan (nunca se
125
+ * borran: la limpieza de un rol o permiso retirado es una decisión explícita
126
+ * del consumidor, porque arrastra asignaciones). Los vínculos rol→permiso
127
+ * son la excepción: con `prune: 'links'` (default) el spec MANDA sobre los
128
+ * roles que declara. Antes el sync era solo aditivo y quitar un permiso de
129
+ * un rol en el config no lo quitaba de ningún entorno — la única fuente de
130
+ * verdad visible mentía sobre los permisos efectivos (L0.9, N1).
131
+ *
132
+ * Un rol que concede un permiso que no existe ni en este spec ni en la base
133
+ * (sincronizado por otro catálogo) es 422 `E_AUTHZ_UNKNOWN_PERMISSION`:
134
+ * antes se saltaba en silencio.
135
+ *
136
+ * Lo usan el seeder del nivel app, `authz:catalog:sync` y el harness de la
137
+ * suite de contrato. El catálogo es metadata compartida entre drivers: un
138
+ * driver externo (openfga) materializa aparte solo los hechos.
139
+ */
140
+ export async function syncAuthzCatalog(catalog, options = {}) {
141
+ assertCatalogGrammar(catalog);
142
+ const prune = options.prune ?? 'links';
143
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
144
+ // Cada consulta con deadline y fallo clasificado (D15). `fn` devuelve el
145
+ // builder sin ejecutar (nada de `.first()`, que ejecuta al instante).
146
+ const sql = (operation, fn) => guardSql('catalog', operation, timeoutMs, fn);
147
+ const one = async (operation, fn) => (await sql(operation, () => fn().limit(1)))[0] ?? null;
148
+ // Todo o nada. Un fallo a mitad (una constraint, la conexión) dejaba el
149
+ // catálogo escrito a medias: roles sin sus permisos, es decir holders que
150
+ // "tienen" un rol que no concede nada. El guard exterior clasifica lo que
151
+ // falle al abrir o confirmar la transacción.
152
+ //
153
+ // La versión compartida (`authz_catalog_version`) sube DENTRO de la
154
+ // transacción y como su ÚLTIMA sentencia (2D · F1, 2E · H2), por el mismo
155
+ // camino que cualquier escritura a mano: `withAuthzCatalogWrite`. Los memos
156
+ // de todos los procesos la contrastan antes de servir y recargan. Al salir
157
+ // —bien o mal— se invalida además el memo de este proceso (2A): un sync
158
+ // que confirmó tiene que verse en la siguiente pregunta también con
159
+ // `everyMs`, y uno que falló al confirmar no se sabe si confirmó. Recargar
160
+ // de más es gratis; servir un catálogo viejo no.
161
+ let report;
162
+ let snapshot = null;
163
+ try {
164
+ const done = await syncInTransaction(catalog, prune, sql, one, timeoutMs, options.projection);
165
+ report = done.report;
166
+ snapshot = done.snapshot;
167
+ }
168
+ finally {
169
+ invalidateAuthzCatalog();
170
+ }
171
+ // La proyección se rehace con el catálogo YA CONFIRMADO y fuera de la
172
+ // transacción: el store no participa del commit de SQL (no hay 2PC), así
173
+ // que la fuente de verdad se escribe primero y lo derivado después. Un
174
+ // fallo aquí deja deriva —reconstruible con `authz:reconcile`—, nunca una
175
+ // concesión que las tablas no respalden.
176
+ if (options.projection && snapshot) {
177
+ report.projection = await options.projection.project(snapshot);
178
+ }
179
+ return report;
180
+ }
181
+ async function syncInTransaction(catalog, prune, sql, one, timeoutMs, projection) {
182
+ const report = { shadowedByGlobal: [], assignableAtViolations: [] };
183
+ let snapshot = null;
184
+ await sql('sync', () => withAuthzCatalogWrite(async (trx) => {
185
+ // 0. Colisión tras codificar también contra lo que YA hay en la base:
186
+ // `docs:write` de otro catálogo y `docs_write` de este serían UNA
187
+ // relación FGA (D3). Dentro de la transacción, para verlo consistente.
188
+ const stored = await sql('sync.permissions', () => trx.from('authz_permissions').select('slug'));
189
+ assertNoSlugCollisions('permiso', [
190
+ ...catalog.permissions.map((p) => p.slug),
191
+ ...stored.map((p) => p.slug),
192
+ ]);
193
+ // 0.5 ¿El catálogo que va a quedar es PUBLICABLE en el backend del
194
+ // driver? (3b-2a · A3/A4). ANTES de escribir nada: si se escribiera
195
+ // primero, un catálogo que rebasa el techo del modelo quedaría en la
196
+ // base sin poder proyectarse nunca y con el store sin poder
197
+ // regenerarse. Los permisos que van a existir son los de la base más
198
+ // los del spec (el sync no borra permisos).
199
+ if (projection) {
200
+ const willExist = new Set([
201
+ ...stored.map((p) => String(p.slug)),
202
+ ...catalog.permissions.map((p) => p.slug),
203
+ ]);
204
+ projection.assertPublishable([...willExist].sort());
205
+ }
206
+ // 1. Permisos: upsert por slug; `assignable_at` manda el config (B5).
207
+ for (const perm of catalog.permissions) {
208
+ const existing = await one('sync.permission', () => trx.from('authz_permissions').where('slug', perm.slug).select('uuid', 'assignable_at'));
209
+ const assignableAt = encodeAssignableAt(perm.assignableAt);
210
+ if (existing) {
211
+ if (storedAssignableAt(perm.slug, existing.assignable_at) !== assignableAt) {
212
+ await sql('sync.permission.levels', () => trx.from('authz_permissions').where('uuid', existing.uuid).update({ assignable_at: assignableAt, updated_at: systemClock() }));
213
+ }
214
+ continue;
215
+ }
216
+ await sql('sync.permission.insert', () => trx.table('authz_permissions').insert({
217
+ uuid: uuidv7(),
218
+ slug: perm.slug,
219
+ description: perm.description ?? null,
220
+ assignable_at: assignableAt,
221
+ created_at: systemClock(),
222
+ updated_at: systemClock(),
223
+ }));
224
+ }
225
+ // Los permisos que los roles referencian, estén en este spec o vengan de
226
+ // otro catálogo ya sincronizado — con sus niveles (B5): un rol de este
227
+ // spec tampoco puede llevar un permiso ajeno fuera de su nivel.
228
+ const referenced = new Set(catalog.permissions.map((p) => p.slug));
229
+ for (const role of catalog.roles)
230
+ for (const slug of role.permissions)
231
+ referenced.add(slug);
232
+ const dbPerms = await sql('sync.referenced', () => trx
233
+ .from('authz_permissions')
234
+ .whereIn('slug', [...referenced])
235
+ .select('uuid', 'slug', 'assignable_at'));
236
+ const permUuidBySlug = new Map(dbPerms.map((p) => [p.slug, p.uuid]));
237
+ const levelsBySlug = new Map(dbPerms.map((p) => [p.slug, parseAssignableAt(p.slug, p.assignable_at)]));
238
+ for (const role of catalog.roles) {
239
+ for (const slug of role.permissions) {
240
+ if (!permUuidBySlug.has(slug))
241
+ throw new UnknownPermissionError(slug);
242
+ assertAssignableAt(role, slug, levelsBySlug.get(slug) ?? null);
243
+ }
244
+ }
245
+ // 2. Roles: upsert por (slug, scope_type) entre los GLOBALES (3B · B6).
246
+ // Un rol LOCAL con ese (slug, scope_type) es una colisión: el spec
247
+ // no puede ocupar un nombre que un tenant ya usa (dentro de ese
248
+ // tenant habría dos roles con el mismo nombre) — 422, nada escrito.
249
+ // 3E · P1 b (auditor A1): un rol LOCAL homónimo ya NO aborta el catálogo
250
+ // entero. Un tenant con rank 2 tumbaba el despliegue de la plataforma
251
+ // para siempre —y con él roles nuevos que no tenían nada que ver—. Los
252
+ // globales GANAN: se escribe el global, el local afectado se REPORTA
253
+ // (`shadowedByGlobal`) y el perjuicio queda en quien ocupó el nombre:
254
+ // con M1 la ambigüedad es fail-closed, así que sus rutas por slug pasan
255
+ // a 422 y le queda `{ uuid }` o pedir la purga. `defineScopedRole`
256
+ // sigue rechazando las colisiones hacia ARRIBA (3F · S3), y hacia
257
+ // ABAJO ensombrecer exige superar en RANGO al ensombrecido (3G · W3):
258
+ // la autoridad no es solo posición en el árbol.
259
+ //
260
+ // En UNA consulta, no una por rol (3F · T2, auditor N6): esto corre con
261
+ // el cerrojo de `authz_catalog_version` sostenido, y un deploy con
262
+ // cientos de roles alargaba la sección crítica hasta hacer probable el
263
+ // 503 `E_AUTHZ_BACKEND_TIMEOUT` de los `defineScopedRole` concurrentes.
264
+ const localesHomonimas = catalog.roles.length
265
+ ? await sql('sync.role.locals', () => trx
266
+ .from('authz_roles')
267
+ .whereIn('slug', [...new Set(catalog.roles.map((r) => r.slug))])
268
+ .whereNot('owner_scope_key', GLOBAL_OWNER_KEY)
269
+ .select('uuid', 'slug', 'scope_type', 'owner_scope_key'))
270
+ : [];
271
+ for (const role of catalog.roles) {
272
+ const locals = localesHomonimas.filter((row) => row.slug === role.slug && String(row.scope_type) === role.scopeType);
273
+ for (const local of locals) {
274
+ report.shadowedByGlobal.push({
275
+ uuid: String(local.uuid),
276
+ slug: role.slug,
277
+ scopeType: role.scopeType,
278
+ owner: String(local.owner_scope_key),
279
+ });
280
+ }
281
+ const existing = await one('sync.role', () => trx
282
+ .from('authz_roles')
283
+ .where('slug', role.slug)
284
+ .where('scope_type', role.scopeType)
285
+ .where('owner_scope_key', GLOBAL_OWNER_KEY)
286
+ .select('uuid', 'rank'));
287
+ const roleUuid = existing?.uuid ?? role.uuid ?? uuidv7();
288
+ if (!existing) {
289
+ await sql('sync.role.insert', () => trx.table('authz_roles').insert({
290
+ uuid: roleUuid,
291
+ slug: role.slug,
292
+ name: role.name ?? role.slug,
293
+ description: role.description ?? null,
294
+ scope_type: role.scopeType,
295
+ rank: role.rank ?? 0,
296
+ owner_scope_key: GLOBAL_OWNER_KEY,
297
+ created_at: systemClock(),
298
+ updated_at: systemClock(),
299
+ }));
300
+ }
301
+ else if (role.rank !== undefined && existing.rank !== role.rank) {
302
+ // El rank es metadata de policy: el config manda.
303
+ await sql('sync.role.rank', () => trx.from('authz_roles').where('uuid', roleUuid).update({ rank: role.rank }));
304
+ }
305
+ // 3. Vínculos rol→permiso: el spec manda para ESTE rol.
306
+ const wanted = new Set(role.permissions.map((slug) => permUuidBySlug.get(slug)));
307
+ const current = await sql('sync.links', () => trx.from('authz_role_permissions').where('role_uuid', roleUuid).select('uuid', 'permission_uuid'));
308
+ const linked = new Set(current.map((l) => l.permission_uuid));
309
+ for (const permUuid of wanted) {
310
+ if (linked.has(permUuid))
311
+ continue;
312
+ await sql('sync.link.insert', () => trx.table('authz_role_permissions').insert({
313
+ uuid: uuidv7(),
314
+ role_uuid: roleUuid,
315
+ permission_uuid: permUuid,
316
+ created_at: systemClock(),
317
+ }));
318
+ }
319
+ if (prune === 'links') {
320
+ const stale = current.filter((l) => !wanted.has(l.permission_uuid));
321
+ if (stale.length) {
322
+ await sql('sync.link.prune', () => trx
323
+ .from('authz_role_permissions')
324
+ .whereIn('uuid', stale.map((l) => l.uuid))
325
+ .delete());
326
+ }
327
+ }
328
+ }
329
+ // 3.5 Revalidación de composición contra el `assignableAt` que acaba
330
+ // de mandar el config (3E · P6): los roles DEL SPEC ya se validan
331
+ // arriba (un spec incoherente consigo mismo es 422 y no escribe
332
+ // nada), pero los que ya estaban —LOCALES de los tenants y
333
+ // globales de otro catálogo— llevaban el permiso desde antes y
334
+ // nadie los miraba: estrechar `assignableAt` entraba a medias y en
335
+ // silencio. No se les quita el vínculo (lo asignado sigue
336
+ // concediendo, invariante 1): se REPORTAN, como `shadowedByGlobal`.
337
+ const limited = catalog.permissions.filter((p) => p.assignableAt);
338
+ if (limited.length) {
339
+ const levelsOf = new Map(limited.map((p) => [p.slug, new Set(p.assignableAt)]));
340
+ const links = await sql('sync.revalidate', () => trx
341
+ .from('authz_role_permissions')
342
+ .join('authz_roles', 'authz_roles.uuid', 'authz_role_permissions.role_uuid')
343
+ .join('authz_permissions', 'authz_permissions.uuid', 'authz_role_permissions.permission_uuid')
344
+ .whereIn('authz_permissions.slug', limited.map((p) => p.slug))
345
+ .select('authz_roles.uuid as role_uuid', 'authz_roles.slug as role_slug', 'authz_roles.scope_type as scope_type', 'authz_roles.owner_scope_key as owner_scope_key', 'authz_permissions.slug as permission_slug'));
346
+ for (const link of links) {
347
+ const levels = levelsOf.get(String(link.permission_slug));
348
+ if (!levels || levels.has(String(link.scope_type)))
349
+ continue;
350
+ report.assignableAtViolations.push({
351
+ role: {
352
+ uuid: String(link.role_uuid),
353
+ slug: String(link.role_slug),
354
+ scopeType: String(link.scope_type),
355
+ owner: String(link.owner_scope_key),
356
+ },
357
+ permission: String(link.permission_slug),
358
+ });
359
+ }
360
+ }
361
+ // 3.9 Foto del catálogo para la proyección del driver (3b-2a · A5).
362
+ // Se lee DENTRO de la transacción para que sea coherente, y se
363
+ // proyecta fuera, con el commit hecho. Es el catálogo ENTERO, no el
364
+ // spec: la proyección es un espejo, y lo que sobra en el store solo
365
+ // se sabe comparando contra todo.
366
+ if (projection) {
367
+ snapshot = await readCatalogProjectionSnapshot(trx, (operation, fn) => sql(`sync.projection.${operation}`, fn));
368
+ }
369
+ // 4. La versión compartida la sube `withAuthzCatalogWrite` al salir de
370
+ // aquí, como última sentencia: o se confirma todo (catálogo nuevo +
371
+ // versión nueva) o nada.
372
+ }, { driver: 'catalog', timeoutMs }));
373
+ return { report, snapshot };
374
+ }
375
+ /**
376
+ * **La foto del catálogo que se PROYECTA** (3b-2a · A5), leída de `authz_*`.
377
+ *
378
+ * Es el catálogo ENTERO, no el spec que se está sincronizando: la proyección
379
+ * es un espejo y lo que sobra en el backend solo se sabe comparando contra
380
+ * todo. `syncAuthzCatalog` la lee DENTRO de su transacción (coherente con lo
381
+ * que acaba de escribir) y `authz:reconcile` fuera, contra la base: las dos
382
+ * tienen que construir exactamente la misma foto, o reconcile "arreglaría" en
383
+ * cada pasada lo que el sync acaba de dejar bien. Por eso está aquí y no
384
+ * duplicada en el driver.
385
+ *
386
+ * `source` es cualquier cosa con `from(tabla)` (el `db` de Lucid o una
387
+ * transacción) y `sql` envuelve cada consulta con el deadline de quien llama.
388
+ */
389
+ export async function readCatalogProjectionSnapshot(source, sql = (_operation, fn) => fn()) {
390
+ const permissions = await sql('permissions', () => source.from('authz_permissions').select('uuid', 'slug'));
391
+ const roles = await sql('roles', () => source.from('authz_roles').select('uuid'));
392
+ const links = await sql('links', () => source.from('authz_role_permissions').select('role_uuid', 'permission_uuid'));
393
+ const slugByUuid = new Map(permissions.map((p) => [String(p.uuid), String(p.slug)]));
394
+ const permissionsByRole = new Map(roles.map((r) => [String(r.uuid), []]));
395
+ for (const link of links) {
396
+ const slug = slugByUuid.get(String(link.permission_uuid));
397
+ const list = permissionsByRole.get(String(link.role_uuid));
398
+ // Un vínculo sin rol o sin permiso no existe (FK): si apareciera,
399
+ // proyectarlo sería inventar catálogo.
400
+ if (slug && list)
401
+ list.push(slug);
402
+ }
403
+ return {
404
+ permissions: [...slugByUuid.values()].sort(),
405
+ roles: [...permissionsByRole.entries()]
406
+ .map(([uuid, perms]) => ({ uuid, permissions: perms.sort() }))
407
+ .sort((a, b) => (a.uuid < b.uuid ? -1 : a.uuid > b.uuid ? 1 : 0)),
408
+ };
409
+ }
410
+ /**
411
+ * Compara un spec con lo que hay en `authz_*`, sin escribir. Igual que el
412
+ * sync, solo mira los roles del spec: lo ajeno (otro catálogo) no es una
413
+ * diferencia. Un diff limpio significa que `syncAuthzCatalog(spec)` sería un
414
+ * no-op.
415
+ */
416
+ export async function diffAuthzCatalog(catalog, options = {}) {
417
+ assertCatalogGrammar(catalog);
418
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
419
+ const sql = (operation, fn) => guardSql('catalog', operation, timeoutMs, fn);
420
+ const diff = {
421
+ missingPermissions: [],
422
+ missingRoles: [],
423
+ missingLinks: [],
424
+ extraLinks: [],
425
+ rankMismatches: [],
426
+ assignableAtMismatches: [],
427
+ scopedRoles: [],
428
+ ambiguousRoles: [],
429
+ shadowedByGlobal: [],
430
+ shadowedByAncestor: [],
431
+ assignableAtViolations: [],
432
+ };
433
+ const dbPerms = await sql('diff.permissions', () => db.from('authz_permissions').select('uuid', 'slug', 'assignable_at'));
434
+ // Lo que el sync rechazaría, el diff lo dice antes (D3).
435
+ assertNoSlugCollisions('permiso', [
436
+ ...catalog.permissions.map((p) => p.slug),
437
+ ...dbPerms.map((p) => p.slug),
438
+ ]);
439
+ const permUuidBySlug = new Map(dbPerms.map((p) => [p.slug, p.uuid]));
440
+ const permSlugByUuid = new Map(dbPerms.map((p) => [p.uuid, p.slug]));
441
+ // Tolerante con la fila corrupta (3E · P5): el diff la REPORTA, no muere.
442
+ const levelsBySlug = new Map(dbPerms.map((p) => [p.slug, tolerantAssignableAt(p.slug, p.assignable_at)]));
443
+ const storedBySlug = new Map(dbPerms.map((p) => [p.slug, storedAssignableAt(p.slug, p.assignable_at)]));
444
+ for (const perm of catalog.permissions) {
445
+ if (!permUuidBySlug.has(perm.slug)) {
446
+ diff.missingPermissions.push(perm.slug);
447
+ continue;
448
+ }
449
+ const expected = encodeAssignableAt(perm.assignableAt);
450
+ const actual = levelsBySlug.get(perm.slug) ?? null;
451
+ // La MISMA normalización que el sync (3D · N5): lo que el diff dice «en
452
+ // sync» tiene que ser exactamente lo que el sync dejaría sin tocar.
453
+ const stored = storedBySlug.get(perm.slug) ?? null;
454
+ if (expected !== stored) {
455
+ const corrupt = stored === CORRUPT_ASSIGNABLE_AT;
456
+ diff.assignableAtMismatches.push({
457
+ permission: perm.slug,
458
+ expected: expected ? JSON.parse(expected) : null,
459
+ actual: corrupt ? null : actual ? [...actual].sort() : null,
460
+ ...(corrupt ? { corrupt: true } : {}),
461
+ });
462
+ }
463
+ }
464
+ // Un rol del spec que lleve un permiso de OTRO catálogo fuera de su nivel:
465
+ // el sync lo rechazaría (B5), el diff lo dice antes.
466
+ for (const role of catalog.roles) {
467
+ for (const slug of role.permissions) {
468
+ if (!catalog.permissions.some((p) => p.slug === slug))
469
+ assertAssignableAt(role, slug, levelsBySlug.get(slug) ?? null);
470
+ }
471
+ }
472
+ // Roles locales (3B): se listan como propios de un scope; y un global del
473
+ // spec con el nombre de uno de ellos es la misma colisión que en el sync.
474
+ const locals = await sql('diff.locals', () => db.from('authz_roles').whereNot('owner_scope_key', GLOBAL_OWNER_KEY).select('slug', 'scope_type', 'owner_scope_key'));
475
+ for (const local of locals) {
476
+ diff.scopedRoles.push({ slug: local.slug, scopeType: local.scope_type, owner: local.owner_scope_key });
477
+ // 3E · P1 b: ya no es un 422 (el sync escribe el global y ensombrece al
478
+ // local). Es deriva que hay que ver ANTES del deploy, no una excepción
479
+ // que impide ver el resto del informe.
480
+ const clash = catalog.roles.find((r) => r.slug === local.slug && r.scopeType === local.scope_type);
481
+ if (clash) {
482
+ diff.shadowedByGlobal.push({ slug: local.slug, scopeType: local.scope_type, owner: local.owner_scope_key });
483
+ }
484
+ }
485
+ diff.assignableAtViolations = await findAssignableAtViolations(sql, catalog);
486
+ // 3F · S3: los homónimos visibles en una misma cadena se clasifican por
487
+ // AUTORIDAD; solo lo que la autoridad no ordena es deriva.
488
+ const homonimos = await classifyHomonyms(sql, options.resolveChain);
489
+ diff.ambiguousRoles = homonimos.ambiguousRoles;
490
+ diff.shadowedByAncestor = homonimos.shadowedByAncestor;
491
+ for (const shadow of homonimos.shadowedByGlobal) {
492
+ if (diff.shadowedByGlobal.some((s) => s.slug === shadow.slug && s.scopeType === shadow.scopeType && s.owner === shadow.owner))
493
+ continue;
494
+ diff.shadowedByGlobal.push(shadow);
495
+ }
496
+ for (const role of catalog.roles) {
497
+ const existing = (await sql('diff.role', () => db
498
+ .from('authz_roles')
499
+ .where('slug', role.slug)
500
+ .where('scope_type', role.scopeType)
501
+ .where('owner_scope_key', GLOBAL_OWNER_KEY)
502
+ .select('uuid', 'rank')
503
+ .limit(1)))[0];
504
+ if (!existing) {
505
+ diff.missingRoles.push({ slug: role.slug, scopeType: role.scopeType });
506
+ for (const permission of role.permissions) {
507
+ diff.missingLinks.push({ role: role.slug, scopeType: role.scopeType, permission });
508
+ }
509
+ continue;
510
+ }
511
+ if (role.rank !== undefined && existing.rank !== role.rank) {
512
+ diff.rankMismatches.push({
513
+ role: role.slug,
514
+ scopeType: role.scopeType,
515
+ expected: role.rank,
516
+ actual: existing.rank,
517
+ });
518
+ }
519
+ const current = await sql('diff.links', () => db.from('authz_role_permissions').where('role_uuid', existing.uuid).select('permission_uuid'));
520
+ const linked = new Set(current.map((l) => permSlugByUuid.get(l.permission_uuid) ?? l.permission_uuid));
521
+ for (const permission of role.permissions) {
522
+ if (!linked.has(permission)) {
523
+ diff.missingLinks.push({ role: role.slug, scopeType: role.scopeType, permission });
524
+ }
525
+ }
526
+ for (const permission of [...linked].sort()) {
527
+ if (!role.permissions.includes(permission)) {
528
+ diff.extraLinks.push({ role: role.slug, scopeType: role.scopeType, permission });
529
+ }
530
+ }
531
+ }
532
+ return diff;
533
+ }
534
+ export function catalogInSync(diff) {
535
+ return (diff.missingPermissions.length === 0 &&
536
+ diff.missingRoles.length === 0 &&
537
+ diff.missingLinks.length === 0 &&
538
+ diff.extraLinks.length === 0 &&
539
+ diff.rankMismatches.length === 0 &&
540
+ diff.assignableAtMismatches.length === 0 &&
541
+ diff.ambiguousRoles.length === 0 &&
542
+ diff.assignableAtViolations.length === 0);
543
+ }
544
+ /**
545
+ * Vínculos rol→permiso vivos que el `assignableAt` del spec ya no admite
546
+ * (3E · P6). Mira TODOS los roles de la base (los del spec los rechaza
547
+ * `assertAssignableAt` antes, con 422): los que quedan son locales de los
548
+ * tenants y globales de otro catálogo, justo los que el sync no revalidaba.
549
+ */
550
+ async function findAssignableAtViolations(sql, catalog) {
551
+ const limited = catalog.permissions.filter((p) => p.assignableAt);
552
+ if (!limited.length)
553
+ return [];
554
+ const levelsOf = new Map(limited.map((p) => [p.slug, new Set(p.assignableAt)]));
555
+ const links = await sql('diff.assignableAt', () => db
556
+ .from('authz_role_permissions')
557
+ .join('authz_roles', 'authz_roles.uuid', 'authz_role_permissions.role_uuid')
558
+ .join('authz_permissions', 'authz_permissions.uuid', 'authz_role_permissions.permission_uuid')
559
+ .whereIn('authz_permissions.slug', limited.map((p) => p.slug))
560
+ .select('authz_roles.slug as role_slug', 'authz_roles.scope_type as scope_type', 'authz_roles.owner_scope_key as owner_scope_key', 'authz_permissions.slug as permission_slug'));
561
+ const found = [];
562
+ for (const link of links) {
563
+ const levels = levelsOf.get(String(link.permission_slug));
564
+ if (!levels || levels.has(String(link.scope_type)))
565
+ continue;
566
+ found.push({
567
+ role: String(link.role_slug),
568
+ scopeType: String(link.scope_type),
569
+ owner: String(link.owner_scope_key),
570
+ permission: String(link.permission_slug),
571
+ });
572
+ }
573
+ return found;
574
+ }
575
+ /**
576
+ * `(slug, nivel)` con dos o más roles visibles a la vez desde una misma
577
+ * cadena (3D · M2 d), clasificados por AUTORIDAD (3F · S3): global > local
578
+ * de un ancestro > local de un descendiente. Un global + cualquier local
579
+ * siempre conviven (el global se ve en todas partes) y el global gana; dos
580
+ * locales solo conviven si un owner está en la cadena del otro —eso exige el
581
+ * árbol del consumidor, así que sin `resolveChain` la pareja no se juzga (y
582
+ * el informe lo dice)— y gana el ancestro. Lo que queda en `ambiguousRoles`
583
+ * es lo que ninguna regla ordena: dos owners que se declaran ancestro el uno
584
+ * del otro (un `resolveChain` con un ciclo o contradictorio). Eso es deriva.
585
+ */
586
+ async function classifyHomonyms(sql, resolveChain) {
587
+ const rows = await sql('diff.ambiguous', () => db.from('authz_roles').select('slug', 'scope_type', 'owner_scope_key'));
588
+ const byName = new Map();
589
+ for (const row of rows) {
590
+ const key = `${row.slug}\u001f${row.scope_type}`;
591
+ if (!byName.has(key))
592
+ byName.set(key, []);
593
+ byName.get(key).push(String(row.owner_scope_key));
594
+ }
595
+ const chains = new Map();
596
+ const chainOf = async (ownerKey) => {
597
+ if (chains.has(ownerKey))
598
+ return chains.get(ownerKey);
599
+ const owner = scopeFromKey(ownerKey);
600
+ let keys = null;
601
+ if (owner && resolveChain) {
602
+ const chain = await resolveChain(owner);
603
+ keys = chain ? chain.map(scopeKey) : null;
604
+ }
605
+ chains.set(ownerKey, keys);
606
+ return keys;
607
+ };
608
+ const result = {
609
+ ambiguousRoles: [],
610
+ shadowedByGlobal: [],
611
+ shadowedByAncestor: [],
612
+ };
613
+ for (const [key, owners] of byName) {
614
+ if (owners.length < 2)
615
+ continue;
616
+ const [slug, scopeType] = key.split('\u001f');
617
+ const locals = owners.filter((o) => o !== GLOBAL_OWNER_KEY);
618
+ if (owners.length > locals.length) {
619
+ // Hay un global: convive con TODOS los locales homónimos y GANA.
620
+ for (const owner of locals)
621
+ result.shadowedByGlobal.push({ slug, scopeType, owner });
622
+ }
623
+ // 3G · X3 (auditor P5 b): la pareja de LOCALES se clasifica igual haya
624
+ // global o no. Hasta aquí un global en el grupo hacía `continue` y con
625
+ // eso dejaba de detectarse una pareja de locales CONTRADICTORIA (dos
626
+ // owners que se declaran ancestro el uno del otro), que es la única
627
+ // deriva de verdad de esta clasificación: un caso ciego nuevo.
628
+ // 3b-1 · T-3b 3 (tester 3F · §6.3): UNA línea por rol ENSOMBRECIDO, no
629
+ // una por pareja. Con owners anidados a > b > c salían tres (a→b, a→c,
630
+ // b→c) para tres roles, y la tercera no añadía nada: lo que el operador
631
+ // necesita saber es qué `(slug, nivel)` está muerto y quién manda ahí.
632
+ // El ensombrecedor que se nombra es el MÁS AUTORIZADO —el ancestro más
633
+ // alto de la cadena del ensombrecido—, que es el orden que 3F · S3 fijó.
634
+ for (const b of locals) {
635
+ const chainB = await chainOf(b);
636
+ if (!chainB)
637
+ continue;
638
+ const shadowers = [];
639
+ for (const a of locals) {
640
+ if (a === b)
641
+ continue;
642
+ // `a` está en la cadena de `b`: `a` es su ancestro y lo ensombrece.
643
+ if (!chainB.includes(a))
644
+ continue;
645
+ if ((await chainOf(a))?.includes(b)) {
646
+ // Y `b` en la de `a`: el árbol se contradice y nadie manda.
647
+ const owners2 = [a, b].sort();
648
+ if (!result.ambiguousRoles.some((r) => r.slug === slug && r.scopeType === scopeType && r.owners.join() === owners2.join())) {
649
+ result.ambiguousRoles.push({ slug, scopeType, owners: owners2 });
650
+ }
651
+ continue;
652
+ }
653
+ shadowers.push(a);
654
+ }
655
+ if (!shadowers.length)
656
+ continue;
657
+ // Más lejos en la cadena de `b` = más arriba en el árbol = más autoridad.
658
+ shadowers.sort((x, y) => chainB.indexOf(y) - chainB.indexOf(x));
659
+ result.shadowedByAncestor.push({ slug, scopeType, owner: b, shadowedBy: shadowers[0] });
660
+ }
661
+ }
662
+ return result;
663
+ }
664
+ /** Líneas legibles del diff (vacío si está en sync). */
665
+ export function formatCatalogDiff(diff) {
666
+ const lines = [];
667
+ const link = (l) => `${l.role}@${l.scopeType} → ${l.permission}`;
668
+ for (const slug of diff.missingPermissions)
669
+ lines.push(`permiso ausente en la base: ${slug}`);
670
+ for (const role of diff.missingRoles)
671
+ lines.push(`rol ausente en la base: ${role.slug}@${role.scopeType}`);
672
+ for (const l of diff.missingLinks)
673
+ lines.push(`vínculo ausente en la base: ${link(l)}`);
674
+ for (const l of diff.extraLinks)
675
+ lines.push(`vínculo SOBRANTE en la base (privilegio zombi): ${link(l)}`);
676
+ for (const r of diff.rankMismatches) {
677
+ lines.push(`rank distinto: ${r.role}@${r.scopeType} spec=${r.expected} base=${r.actual}`);
678
+ }
679
+ const levels = (l) => (l ? l.join(',') : 'cualquiera');
680
+ for (const a of diff.assignableAtMismatches) {
681
+ if (a.corrupt) {
682
+ lines.push(`assignableAt CORRUPTO en la base: ${a.permission} (spec=${levels(a.expected)}); authz_permissions.assignable_at ` +
683
+ `no es una lista JSON de niveles y el memo lo lee con 500: sincroniza para repararlo`);
684
+ continue;
685
+ }
686
+ lines.push(`assignableAt distinto: ${a.permission} spec=${levels(a.expected)} base=${levels(a.actual)}`);
687
+ }
688
+ for (const v of diff.assignableAtViolations) {
689
+ lines.push(`vínculo fuera de assignableAt: ${v.role}@${v.scopeType} (owner ${v.owner}) → ${v.permission} — ` +
690
+ `el config ya no admite ese nivel; el sync no lo borra (lo asignado sigue concediendo): arréglalo tú`);
691
+ }
692
+ for (const a of diff.ambiguousRoles) {
693
+ lines.push(`rol AMBIGUO que la autoridad no ordena (los dos owners se declaran ancestro del otro): ${a.slug}@${a.scopeType} ` +
694
+ `owners=${a.owners.join(', ')} — toda ruta por slug ahí responde 422 E_AUTHZ_AMBIGUOUS_ROLE; se opera por ` +
695
+ `{ uuid } y se deshace PURGANDO uno (un rol local no se renombra) o arreglando el árbol`);
696
+ }
697
+ return lines;
698
+ }
699
+ /** Líneas informativas del diff (no son diferencias): los roles locales, propios de un scope. */
700
+ export function formatScopedRoles(diff) {
701
+ return diff.scopedRoles.map((r) => `rol local (propio de ${r.owner}): ${r.slug}@${r.scopeType}`);
702
+ }
703
+ /**
704
+ * Líneas informativas del diff (3F · S3): los roles locales ENSOMBRECIDOS
705
+ * por una definición más autorizada —un global, o un local de un ancestro—.
706
+ * No son deriva: es el orden de autoridad funcionando. Se listan porque
707
+ * mientras duren, ese slug es 422 `E_AUTHZ_AMBIGUOUS_ROLE` en la cadena de
708
+ * su owner y hay que operar por `{ uuid }` (o purgar uno de los dos).
709
+ */
710
+ export function formatShadowedRoles(diff) {
711
+ const lines = [];
712
+ for (const s of diff.shadowedByGlobal) {
713
+ lines.push(`rol local ENSOMBRECIDO por un global homónimo: ${s.slug}@${s.scopeType} (owner ${s.owner}) — el global gana; ` +
714
+ `en la cadena de ese owner el slug pasa a 422 E_AUTHZ_AMBIGUOUS_ROLE (para TODOS, la plataforma incluida: ` +
715
+ `se direcciona por { uuid }) hasta que se purgue uno`);
716
+ }
717
+ for (const s of diff.shadowedByAncestor) {
718
+ lines.push(`rol local ENSOMBRECIDO por el de un ancestro: ${s.slug}@${s.scopeType} (owner ${s.owner}, ensombrecido por ` +
719
+ `${s.shadowedBy}) — el ancestro gana; en la cadena de ${s.owner} el slug pasa a 422 E_AUTHZ_AMBIGUOUS_ROLE ` +
720
+ `(se direcciona por { uuid }) hasta que se purgue uno`);
721
+ }
722
+ return lines;
723
+ }
724
+ /**
725
+ * Resuelve todos los catálogos y comprueba que ningún rol `(slug, scopeType)`
726
+ * ni permiso aparezca en dos de ellos (422 `E_AUTHZ_CATALOG_CONFLICT`). Se
727
+ * hace ANTES de tocar la base: el sync de un catálogo poda los vínculos de
728
+ * los roles que declara, así que dos catálogos con el mismo rol se pisarían
729
+ * y el último en el orden ganaría en silencio (D3). Un rol pertenece a
730
+ * exactamente un catálogo. También valida la gramática de cada uno.
731
+ */
732
+ async function resolveDisjointCatalogs(catalogs) {
733
+ const specs = [];
734
+ for (const source of catalogs)
735
+ specs.push(await source());
736
+ for (const spec of specs)
737
+ assertCatalogGrammar(spec);
738
+ const roleOwner = new Map();
739
+ const permissionOwner = new Map();
740
+ const conflicts = [];
741
+ for (const [index, spec] of specs.entries()) {
742
+ for (const role of spec.roles) {
743
+ const id = `${role.slug}@${role.scopeType}`;
744
+ const owner = roleOwner.get(id);
745
+ if (owner !== undefined)
746
+ conflicts.push(`rol ${id} (catálogos #${owner + 1} y #${index + 1})`);
747
+ else
748
+ roleOwner.set(id, index);
749
+ }
750
+ for (const perm of spec.permissions) {
751
+ const owner = permissionOwner.get(perm.slug);
752
+ if (owner !== undefined) {
753
+ conflicts.push(`permiso ${perm.slug} (catálogos #${owner + 1} y #${index + 1})`);
754
+ }
755
+ else
756
+ permissionOwner.set(perm.slug, index);
757
+ }
758
+ }
759
+ if (conflicts.length) {
760
+ throw new CatalogConflictError(`Catálogos en conflicto: ${conflicts.join('; ')}. Un rol (slug + scopeType) y un permiso ` +
761
+ `pertenecen a exactamente un catálogo: el sync del segundo podaría los vínculos del primero.`);
762
+ }
763
+ return specs;
764
+ }
765
+ /**
766
+ * El diff de TODOS los catálogos del config. `inSync: false` ⇒ el comando
767
+ * sale con código ≠ 0 (para CI). Separado del comando para poder probarlo
768
+ * sin un kernel de ace.
769
+ */
770
+ export async function runCatalogDiff(catalogs, options = {}) {
771
+ const lines = [];
772
+ let inSync = true;
773
+ const specs = await resolveDisjointCatalogs(catalogs);
774
+ // Los roles locales (3B) no son deriva del config y no dependen del spec:
775
+ // se toman del PRIMER diff que ya se calcula (3E · Q6; hasta aquí el
776
+ // comando repetía el diff entero —todas sus consultas— solo para
777
+ // extraerlos, y sus líneas salían indentadas DENTRO del bloque de
778
+ // diferencias del último catálogo, como si fueran suyas).
779
+ let scoped = [];
780
+ // 3b-1 · T-3b 1 (tester 3F · §6.1): las sombras se ACUMULAN sobre todos los
781
+ // catálogos, con deduplicación. `diff.shadowedByGlobal` tiene una fuente
782
+ // DEPENDIENTE del spec (un rol del spec homónimo de un local), así que
783
+ // tomarlas del índice 0 perdía por completo las que causaba un rol del
784
+ // catálogo #2: no salían como línea de sombras ni como diferencia de ese
785
+ // catálogo. `scopedRoles` sí sale del índice 0: lee la BASE y no depende
786
+ // del spec, así que repetir el diff entero solo para él sería gratuito
787
+ // (3E · Q6).
788
+ const sombras = {
789
+ shadowedByGlobal: [],
790
+ shadowedByAncestor: [],
791
+ };
792
+ for (const [index, spec] of specs.entries()) {
793
+ const diff = await diffAuthzCatalog(spec, options);
794
+ for (const s of diff.shadowedByGlobal) {
795
+ if (sombras.shadowedByGlobal.some((x) => x.slug === s.slug && x.scopeType === s.scopeType && x.owner === s.owner))
796
+ continue;
797
+ sombras.shadowedByGlobal.push(s);
798
+ }
799
+ for (const s of diff.shadowedByAncestor) {
800
+ if (sombras.shadowedByAncestor.some((x) => x.slug === s.slug && x.scopeType === s.scopeType && x.owner === s.owner && x.shadowedBy === s.shadowedBy)) {
801
+ continue;
802
+ }
803
+ sombras.shadowedByAncestor.push(s);
804
+ }
805
+ if (index === 0)
806
+ scoped = formatScopedRoles(diff);
807
+ if (catalogInSync(diff)) {
808
+ lines.push(`catálogo #${index + 1}: en sync`);
809
+ continue;
810
+ }
811
+ inSync = false;
812
+ lines.push(`catálogo #${index + 1}: DIFERENCIAS`);
813
+ for (const line of formatCatalogDiff(diff))
814
+ lines.push(` ${line}`);
815
+ }
816
+ const shadowed = formatShadowedRoles(sombras);
817
+ if (shadowed.length) {
818
+ lines.push('roles locales ENSOMBRECIDOS por una definición más autorizada (no son deriva: 3F · S3):');
819
+ for (const line of shadowed)
820
+ lines.push(` ${line}`);
821
+ // 3G · X3 (auditor P5): que un tenant no pueda dejar en rojo el gate de
822
+ // CI de la plataforma es la decisión de 3F · S3, pero el efecto es que
823
+ // NADIE se entera por CI de que las rutas por slug de un subárbol están
824
+ // muertas. `--fail-on-shadows` es el opt-in de quien sí quiere enterarse.
825
+ if (options.failOnShadows) {
826
+ inSync = false;
827
+ lines.push(' (--fail-on-shadows: los ensombrecidos cuentan como deriva en ESTA ejecución)');
828
+ }
829
+ }
830
+ if (scoped.length) {
831
+ lines.push('roles locales (no son deriva del config):');
832
+ for (const line of scoped)
833
+ lines.push(` ${line}`);
834
+ if (!options.resolveChain) {
835
+ lines.push(' (sin scopes.resolveChain no se puede juzgar si dos roles locales homónimos son visibles en la misma cadena; ' +
836
+ 'la pareja global+local sí se detecta)');
837
+ }
838
+ }
839
+ return { inSync, lines };
840
+ }
841
+ /**
842
+ * Sincroniza todos los catálogos del config, en orden (lo que hace
843
+ * `authz:catalog:sync`). Devuelve cuántos y lo que hay que DECIR (3E · P1 b
844
+ * / P6): el comando lo imprime como aviso — reportar y seguir, nunca romper
845
+ * en silencio.
846
+ */
847
+ export async function syncCatalogs(catalogs, options = {}) {
848
+ let count = 0;
849
+ const shadowedByGlobal = [];
850
+ const assignableAtViolations = [];
851
+ for (const spec of await resolveDisjointCatalogs(catalogs)) {
852
+ const report = await syncAuthzCatalog(spec, options);
853
+ shadowedByGlobal.push(...report.shadowedByGlobal);
854
+ assignableAtViolations.push(...report.assignableAtViolations);
855
+ count += 1;
856
+ }
857
+ return { count, shadowedByGlobal, assignableAtViolations };
858
+ }
859
+ //# sourceMappingURL=catalog.js.map