@jantstack/adonis-authz 2.0.0-alpha.1 → 2.4.0-alpha.2

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 (214) hide show
  1. package/README.md +693 -36
  2. package/build/commands/authz_catalog_diff.js +1 -1
  3. package/build/commands/authz_catalog_diff.js.map +1 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  6. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  8. package/build/commands/authz_catalog_sync.d.ts +17 -0
  9. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  10. package/build/commands/authz_catalog_sync.js +27 -4
  11. package/build/commands/authz_catalog_sync.js.map +1 -1
  12. package/build/commands/authz_freeze.d.ts +44 -0
  13. package/build/commands/authz_freeze.d.ts.map +1 -0
  14. package/build/commands/authz_freeze.js +95 -0
  15. package/build/commands/authz_freeze.js.map +1 -0
  16. package/build/commands/authz_reconcile.d.ts +102 -0
  17. package/build/commands/authz_reconcile.d.ts.map +1 -0
  18. package/build/commands/authz_reconcile.js +294 -0
  19. package/build/commands/authz_reconcile.js.map +1 -0
  20. package/build/commands/authz_relations_reconcile.d.ts +97 -0
  21. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  22. package/build/commands/authz_relations_reconcile.js +313 -0
  23. package/build/commands/authz_relations_reconcile.js.map +1 -0
  24. package/build/commands/authz_scopes_relay.d.ts +47 -0
  25. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  26. package/build/commands/authz_scopes_relay.js +141 -0
  27. package/build/commands/authz_scopes_relay.js.map +1 -0
  28. package/build/commands/authz_unfreeze.d.ts +37 -0
  29. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  30. package/build/commands/authz_unfreeze.js +92 -0
  31. package/build/commands/authz_unfreeze.js.map +1 -0
  32. package/build/commands/main.d.ts +6 -1
  33. package/build/commands/main.d.ts.map +1 -1
  34. package/build/commands/main.js +6 -1
  35. package/build/commands/main.js.map +1 -1
  36. package/build/commands/openfga_provision.d.ts +46 -4
  37. package/build/commands/openfga_provision.d.ts.map +1 -1
  38. package/build/commands/openfga_provision.js +90 -7
  39. package/build/commands/openfga_provision.js.map +1 -1
  40. package/build/configure.d.ts +11 -0
  41. package/build/configure.d.ts.map +1 -1
  42. package/build/configure.js +37 -1
  43. package/build/configure.js.map +1 -1
  44. package/build/index.d.ts +50 -10
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +45 -6
  47. package/build/index.js.map +1 -1
  48. package/build/providers/authz_provider.d.ts +26 -2
  49. package/build/providers/authz_provider.d.ts.map +1 -1
  50. package/build/providers/authz_provider.js +55 -2
  51. package/build/providers/authz_provider.js.map +1 -1
  52. package/build/services/relations.d.ts +14 -0
  53. package/build/services/relations.d.ts.map +1 -0
  54. package/build/services/relations.js +17 -0
  55. package/build/services/relations.js.map +1 -0
  56. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  57. package/build/src/catalog/catalog.d.ts.map +1 -0
  58. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  59. package/build/src/catalog/catalog.js.map +1 -0
  60. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  61. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  62. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  63. package/build/src/catalog/catalog_cache.js.map +1 -0
  64. package/build/src/define_config.d.ts +137 -3
  65. package/build/src/define_config.d.ts.map +1 -1
  66. package/build/src/define_config.js.map +1 -1
  67. package/build/src/drivers/database_driver.d.ts +128 -7
  68. package/build/src/drivers/database_driver.d.ts.map +1 -1
  69. package/build/src/drivers/database_driver.js +510 -24
  70. package/build/src/drivers/database_driver.js.map +1 -1
  71. package/build/src/drivers/database_relations_driver.d.ts +113 -0
  72. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  73. package/build/src/drivers/database_relations_driver.js +679 -0
  74. package/build/src/drivers/database_relations_driver.js.map +1 -0
  75. package/build/src/drivers/openfga_driver.d.ts +729 -122
  76. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  77. package/build/src/drivers/openfga_driver.js +2083 -476
  78. package/build/src/drivers/openfga_driver.js.map +1 -1
  79. package/build/src/drivers/openfga_facts.d.ts +384 -0
  80. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  81. package/build/src/drivers/openfga_facts.js +836 -0
  82. package/build/src/drivers/openfga_facts.js.map +1 -0
  83. package/build/src/drivers/openfga_relations_driver.d.ts +141 -0
  84. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  85. package/build/src/drivers/openfga_relations_driver.js +590 -0
  86. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  87. package/build/src/errors.d.ts +290 -5
  88. package/build/src/errors.d.ts.map +1 -1
  89. package/build/src/errors.js +296 -7
  90. package/build/src/errors.js.map +1 -1
  91. package/build/src/freeze.d.ts +141 -0
  92. package/build/src/freeze.d.ts.map +1 -0
  93. package/build/src/freeze.js +217 -0
  94. package/build/src/freeze.js.map +1 -0
  95. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  96. package/build/src/http/app_access_middleware.js.map +1 -0
  97. package/build/src/http/resource_access_middleware.d.ts +105 -0
  98. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  99. package/build/src/http/resource_access_middleware.js +81 -0
  100. package/build/src/http/resource_access_middleware.js.map +1 -0
  101. package/build/src/identity.d.ts +74 -1
  102. package/build/src/identity.d.ts.map +1 -1
  103. package/build/src/identity.js +100 -2
  104. package/build/src/identity.js.map +1 -1
  105. package/build/src/manager.d.ts +330 -4
  106. package/build/src/manager.d.ts.map +1 -1
  107. package/build/src/manager.js +1285 -188
  108. package/build/src/manager.js.map +1 -1
  109. package/build/src/models/authz_assignment.d.ts +7 -7
  110. package/build/src/models/authz_assignment.d.ts.map +1 -1
  111. package/build/src/models/authz_deny.d.ts +7 -7
  112. package/build/src/models/authz_deny.d.ts.map +1 -1
  113. package/build/src/models/authz_permission.d.ts +7 -7
  114. package/build/src/models/authz_permission.d.ts.map +1 -1
  115. package/build/src/models/authz_role.d.ts +7 -7
  116. package/build/src/models/authz_role.d.ts.map +1 -1
  117. package/build/src/models/authz_role_permission.d.ts +7 -7
  118. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  119. package/build/src/openfga.d.ts +18 -2
  120. package/build/src/openfga.d.ts.map +1 -1
  121. package/build/src/openfga.js +15 -1
  122. package/build/src/openfga.js.map +1 -1
  123. package/build/src/reconcile.d.ts +37 -0
  124. package/build/src/reconcile.d.ts.map +1 -0
  125. package/build/src/reconcile.js +69 -0
  126. package/build/src/reconcile.js.map +1 -0
  127. package/build/src/relation_partition_trigger.d.ts +8 -0
  128. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  129. package/build/src/relation_partition_trigger.js +93 -0
  130. package/build/src/relation_partition_trigger.js.map +1 -0
  131. package/build/src/relations/define_relations_config.d.ts +75 -0
  132. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  133. package/build/src/relations/define_relations_config.js +173 -0
  134. package/build/src/relations/define_relations_config.js.map +1 -0
  135. package/build/src/relations/manager.d.ts +60 -0
  136. package/build/src/relations/manager.d.ts.map +1 -0
  137. package/build/src/relations/manager.js +213 -0
  138. package/build/src/relations/manager.js.map +1 -0
  139. package/build/src/relations/reconcile.d.ts +111 -0
  140. package/build/src/relations/reconcile.d.ts.map +1 -0
  141. package/build/src/relations/reconcile.js +200 -0
  142. package/build/src/relations/reconcile.js.map +1 -0
  143. package/build/src/relations_config_store.d.ts +22 -0
  144. package/build/src/relations_config_store.d.ts.map +1 -0
  145. package/build/src/relations_config_store.js +74 -0
  146. package/build/src/relations_config_store.js.map +1 -0
  147. package/build/src/scope_outbox.d.ts +69 -0
  148. package/build/src/scope_outbox.d.ts.map +1 -0
  149. package/build/src/scope_outbox.js +298 -0
  150. package/build/src/scope_outbox.js.map +1 -0
  151. package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
  152. package/build/src/shared/backend_guard.d.ts.map +1 -0
  153. package/build/src/{drivers → shared}/backend_guard.js +78 -1
  154. package/build/src/shared/backend_guard.js.map +1 -0
  155. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  156. package/build/src/shared/sql_expiry.js.map +1 -0
  157. package/build/src/shared/transaction_guard.d.ts +50 -0
  158. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  159. package/build/src/shared/transaction_guard.js +60 -0
  160. package/build/src/shared/transaction_guard.js.map +1 -0
  161. package/build/src/sql_descendants.d.ts +47 -1
  162. package/build/src/sql_descendants.d.ts.map +1 -1
  163. package/build/src/sql_descendants.js +75 -1
  164. package/build/src/sql_descendants.js.map +1 -1
  165. package/build/src/testing/contract.d.ts +129 -4
  166. package/build/src/testing/contract.d.ts.map +1 -1
  167. package/build/src/testing/contract.js +912 -177
  168. package/build/src/testing/contract.js.map +1 -1
  169. package/build/src/testing/main.d.ts +8 -2
  170. package/build/src/testing/main.d.ts.map +1 -1
  171. package/build/src/testing/main.js +4 -1
  172. package/build/src/testing/main.js.map +1 -1
  173. package/build/src/testing/migration_contract.d.ts +284 -0
  174. package/build/src/testing/migration_contract.d.ts.map +1 -0
  175. package/build/src/testing/migration_contract.js +586 -0
  176. package/build/src/testing/migration_contract.js.map +1 -0
  177. package/build/src/testing/relations_contract.d.ts +73 -0
  178. package/build/src/testing/relations_contract.d.ts.map +1 -0
  179. package/build/src/testing/relations_contract.js +1069 -0
  180. package/build/src/testing/relations_contract.js.map +1 -0
  181. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  182. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  183. package/build/src/testing/relations_reconcile_contract.js +220 -0
  184. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  185. package/build/src/traits/authz_scopes.js +1 -1
  186. package/build/src/traits/authz_scopes.js.map +1 -1
  187. package/build/src/traits/has_uuid.d.ts +8 -8
  188. package/build/src/traits/has_uuid.d.ts.map +1 -1
  189. package/build/src/types.d.ts +1053 -83
  190. package/build/src/types.d.ts.map +1 -1
  191. package/build/src/types.js +10 -0
  192. package/build/src/types.js.map +1 -1
  193. package/build/stubs/config/authorization.stub +104 -4
  194. package/build/stubs/migration.stub +136 -0
  195. package/build/stubs/scopes_outbox_migration.stub +57 -0
  196. package/package.json +4 -2
  197. package/build/commands/openfga_import.d.ts +0 -34
  198. package/build/commands/openfga_import.d.ts.map +0 -1
  199. package/build/commands/openfga_import.js +0 -97
  200. package/build/commands/openfga_import.js.map +0 -1
  201. package/build/src/catalog.d.ts.map +0 -1
  202. package/build/src/catalog.js.map +0 -1
  203. package/build/src/catalog_cache.d.ts.map +0 -1
  204. package/build/src/catalog_cache.js.map +0 -1
  205. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  206. package/build/src/drivers/backend_guard.js.map +0 -1
  207. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  208. package/build/src/drivers/sql_expiry.js.map +0 -1
  209. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  210. package/build/src/middleware/app_access_middleware.js.map +0 -1
  211. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  212. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
  213. /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
  214. /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
@@ -76,6 +76,41 @@ export interface WriteOptions {
76
76
  * quién puede conceder qué es policy del consumidor (invariante 8).
77
77
  */
78
78
  actor?: SubjectRef;
79
+ /**
80
+ * **La transacción ABIERTA del consumidor** (`TransactionClientContract` de
81
+ * Lucid: el `trx` de `db.transaction()`), para que la escritura del paquete
82
+ * confirme o revierta CON la tuya — L-2, panel `{trx}` (C).
83
+ *
84
+ * **Encolar ≠ escribir.** Son dos promesas distintas y este campo hace UNA
85
+ * u OTRA según la operación:
86
+ *
87
+ * - En `grant`/`revoke`/`deny`/`removeDeny` (los HECHOS) significa
88
+ * **ESCRIBIR en tu transacción**: «los dos o ninguno» entre el hecho y
89
+ * tus filas, en el mismo motor transaccional. Solo lo cumple un driver
90
+ * que declare `capabilities.transactionalWrites: true` (`database`, con el
91
+ * `trx` de SU conexión: otra conexión, un `QueryClient` o el `db` entero
92
+ * son 500 `E_AUTHZ_CONFIG`, `assertCallerTransaction`). Con un driver
93
+ * que declare `false` —`openfga`: una tupla no entra en una transacción
94
+ * SQL, no hay 2PC— la llamada es **500 `E_AUTHZ_UNSUPPORTED`** nombrando
95
+ * driver y operación, **antes de tocar el driver** (cero llamadas):
96
+ * nunca se ignora, nunca un aviso. Quien quiera fallar al ARRANCAR en
97
+ * vez de en una ruta poco transitada declara `requireTransactionalWrites:
98
+ * true` en el config (500 `E_AUTHZ_CONFIG` al resolver el driver).
99
+ * - En `scopes.attached/moved/detached` significa **ENCOLAR en tu
100
+ * transacción** (3b-2d, `ScopeTreeWriteOptions.transaction`): el INSERT
101
+ * de la outbox cae dentro de ella; el backend NO se toca dentro de tu
102
+ * transacción y no pasa por la puerta de la capacidad.
103
+ * - En la API de delegación (`defineScopedRole`/`updateScopedRole`/
104
+ * `deleteScopedRole`) **no se admite** (500 `E_AUTHZ_UNSUPPORTED`): esas
105
+ * escriben el catálogo por `withAuthzCatalogWrite`, que ES el
106
+ * serializador entre procesos (cerrojo + bump como última sentencia,
107
+ * invariante 14); moverlas al commit del consumidor lo anularía.
108
+ *
109
+ * Lo que NUNCA viaja por ella, en ninguna de las tres: **la autoridad**
110
+ * (L-1 · 🟠 8) — la barrera del freeze, el catálogo y `resolveChain` se leen
111
+ * por la conexión del motor, así que `{ transaction }` exige **pool ≥ 2**.
112
+ */
113
+ transaction?: unknown;
79
114
  }
80
115
  /**
81
116
  * Opciones de las NUEVE escrituras del manager (`grant`, `revoke`, `deny`,
@@ -105,6 +140,38 @@ export interface ScopedWriteOptions extends WriteOptions {
105
140
  */
106
141
  within?: ScopeRef;
107
142
  }
143
+ /**
144
+ * Opciones de las TRES notificaciones del árbol (`scopes.attached/moved/
145
+ * detached`) — 3b-2d. Añaden la transacción del consumidor, que solo se usa
146
+ * cuando hay `scopes.outbox` declarada: es lo que hace que el cambio del
147
+ * árbol y su encolado confirmen (o se vayan) juntos.
148
+ */
149
+ export interface ScopeTreeWriteOptions extends ScopedWriteOptions {
150
+ /**
151
+ * La transacción ABIERTA del consumidor (`TransactionClientContract` de
152
+ * Lucid, o lo que use su outbox). El paquete no la interpreta: se la pasa
153
+ * tal cual a `scopes.outbox.enqueue` para que el INSERT del encolado caiga
154
+ * dentro de ella — **ENCOLAR, no escribir**: el backend no se toca dentro
155
+ * de tu transacción. Sin outbox declarada no hace nada; con outbox declarada
156
+ * y sin transacción, el encolado se confirma solo y vuelve a haber dos
157
+ * confirmaciones distintas (la outbox lo avisa si puede).
158
+ *
159
+ * **Lo que NUNCA viaja por ella: la autoridad** (L-1 · 🟠 8). La barrera
160
+ * del freeze se lee por la conexión del motor, jamás por esta transacción
161
+ * (su snapshot puede ser anterior al freeze). Por eso exige **pool ≥ 2**:
162
+ * con pool 1 (SQLite `:memory:`) la barrera no consigue conexión mientras
163
+ * tú sostienes la única y la notificación sale 503 `E_AUTHZ_BACKEND_TIMEOUT`
164
+ * (`freezeTimeoutMs`) — fail-closed, nunca un bypass. Y `sqlScopeOutbox`
165
+ * exige que sea una transacción ABIERTA de SU conexión: otra conexión, un
166
+ * `QueryClient` o el `db` entero son 500 `E_AUTHZ_CONFIG` (🟠 9).
167
+ *
168
+ * **Encolar ≠ escribir** (L-2): aquí la transacción ENCOLA; en
169
+ * `grant`/`revoke`/`deny`/`removeDeny` (`WriteOptions.transaction`) ESCRIBE
170
+ * el hecho dentro de ella y pasa por la puerta de `transactionalWrites`.
171
+ * Esta notificación no pasa por esa puerta: un driver `openfga` la acepta.
172
+ */
173
+ transaction?: unknown;
174
+ }
108
175
  export interface GrantOptions extends ScopedWriteOptions {
109
176
  /**
110
177
  * Caducidad de la asignación, en TRES estados (L0.4):
@@ -162,7 +229,115 @@ export type NormalizedRoleQuery = {
162
229
  slug?: undefined;
163
230
  scopeType?: undefined;
164
231
  };
232
+ /**
233
+ * **Lo que un driver DECLARA de sí mismo** (3b-2e · E2). No es documentación:
234
+ * el manager lo LEE (el gate de deriva del árbol, E3) y la suite de contrato
235
+ * exige a cada capacidad su caso —el del valor declarado, nunca un `skip`—.
236
+ *
237
+ * Todas son opcionales de declarar (un driver de 2.x que no traiga
238
+ * `capabilities` se trata como todo `false`), pero declarar `true` lo que no
239
+ * se cumple es una promesa sin juez: el contrato lanza al registrarse.
240
+ */
241
+ export interface AuthorizationDriverCapabilities {
242
+ /**
243
+ * El ÁRBOL de scopes vive como hechos del backend y el backend es el PDP
244
+ * (`openfga` con `hierarchy: 'facts'`). Con `true` el manager exige la
245
+ * mitigación de la deriva (`scopes.outbox` o la firma explícita): el árbol
246
+ * está en dos sitios y un `rollback` del consumidor deja al backend
247
+ * adelantado (cruce 4 · S5).
248
+ */
249
+ hierarchyFacts: boolean;
250
+ /**
251
+ * `authorize` es UNA sola llamada al backend: no consulta el árbol del
252
+ * consumidor (`resolveChain`) y el catálogo solo a través del memo.
253
+ */
254
+ singleCheckAuthorize: boolean;
255
+ /**
256
+ * El backend resuelve la MEMBRESÍA por sí mismo. **`false` en los dos
257
+ * drivers del paquete, también en `facts`** (panel 2, cruce 6): `hasRole`,
258
+ * `listRoles`, `listRoleScopes`, `listSubjects` y `listScopes` siguen
259
+ * usando `resolveChain`. Por eso el titular «sin SQL en el camino caliente»
260
+ * está PROHIBIDO a secas: lo cierto es «sin SQL por request en `authorize`».
261
+ */
262
+ roleInheritanceNative: boolean;
263
+ /**
264
+ * Los `list*` enumeran también lo HEREDADO. **`false` siempre en este
265
+ * paquete** (invariante 7): enumerar descendientes sería abierto, y en
266
+ * `openfga` además obligaría a `ListObjects`, que trunca al tope del
267
+ * servidor sin ninguna señal (S16). Los `list*` devuelven hechos DIRECTOS.
268
+ */
269
+ listObjectsInherited: boolean;
270
+ /** El driver implementa `purgeRole` de verdad (sin él no hay roles locales). */
271
+ purgeRole: boolean;
272
+ /**
273
+ * El driver sabe CONTAR los hechos vigentes de un rol
274
+ * (`countRoleAssignments`, 3b-2j). Es lo que hace verdadero el
275
+ * `stillGranting` de `pruneOrphanRoles`, que se lee justo antes de un
276
+ * borrado destructivo. Con `false` el barrido no lo sabe y lo dice
277
+ * (`undefined`), nunca `false`: «no lo sé» no puede degradar a «no
278
+ * concede».
279
+ */
280
+ countRoleAssignments: boolean;
281
+ /**
282
+ * Las LECTURAS canonizan la ortografía del scope contra el árbol del
283
+ * consumidor antes de buscar los hechos (3b-2k · K1 · R2 (c)). Con `true`
284
+ * (driver `database`) `authorize` resuelve la cadena y usa `chain[0]`, la
285
+ * identidad canónica (invariante 17), así que un alias del uuid que TU
286
+ * tabla funde con la fila real —una columna `uuid` de PostgreSQL, una
287
+ * collation `*_ci` de MySQL— encuentra los mismos hechos. Con `false`
288
+ * (`openfga` en modo `facts`) la decisión no pasa por el árbol —es la
289
+ * contrapartida de `singleCheckAuthorize`— y el objeto del store se compone
290
+ * con la ortografía del LLAMANTE: un alias responde `false` donde la forma
291
+ * canónica concede. Es fail-CLOSED y no evade ningún deny, pero no es la
292
+ * misma respuesta: **pasa los uuids exactamente como los guarda tu tabla**.
293
+ * La ESCRITURA canoniza en los dos (3b-2h · 🟠 3).
294
+ */
295
+ canonicalScopeReads: boolean;
296
+ /**
297
+ * El driver sabe ser el **ORIGEN** de una migración: implementa
298
+ * `enumerateFacts` y entrega sus hechos paginados, sin filtrar y con su
299
+ * caducidad (3b-3b). Con `true` es lo que `authz:reconcile --to=<otro>`
300
+ * pasa como `source.facts`. Con `false` el driver no puede ser origen por
301
+ * el puerto y `authz:reconcile` lo DICE (500 `E_AUTHZ_UNSUPPORTED`
302
+ * nombrando `enumerateFacts`), nunca una migración vacía en silencio — que
303
+ * es exactamente el fail-dangerous que se evita: un origen que devuelve
304
+ * cero hechos y un `--prune` detrás borran el destino entero.
305
+ *
306
+ * **`false` en el driver `database` a propósito**: sus hechos son
307
+ * `authz_assignments`/`authz_denies`, el esquema publicado del paquete, y
308
+ * el destino los lee de ahí directamente (`openfga.reconcile`).
309
+ */
310
+ enumerateFacts: boolean;
311
+ /**
312
+ * El driver puede inscribir sus escrituras en la transacción del consumidor
313
+ * (`{ transaction }` en `grant`/`revoke`/`deny`/`removeDeny`). `true`
314
+ * significa EXACTAMENTE «los dos o ninguno con TU transacción», nunca «no
315
+ * se pierde». `database` = true (con el `trx` de SU conexión; L-3).
316
+ * `openfga` = false, y no puede ser otra cosa: una tupla no entra en una
317
+ * transacción SQL — el store es otro servicio y no hay 2PC. No hay valor
318
+ * intermedio y no se publica ninguno (panel `{trx}`, veredicto (C)).
319
+ *
320
+ * Dos puertas la hacen verdad: con `false`, `{ transaction }` es 500
321
+ * `E_AUTHZ_UNSUPPORTED` por llamada, con cero llamadas al driver; y con
322
+ * `requireTransactionalWrites: true` en el config un driver `false` es 500
323
+ * `E_AUTHZ_CONFIG` al RESOLVER (el despliegue no arranca). **Mismo nombre
324
+ * en `RelationsDriverCapabilities`**: un driver de terceros no aprende dos.
325
+ *
326
+ * `database` la cumple desde L-3: la ESCRITURA (y la lectura «¿ya existe?»
327
+ * que forma parte de ella) va por la transacción ABIERTA del llamante
328
+ * (`assertCallerTransaction` contra la conexión primaria de Lucid); la
329
+ * AUTORIDAD (barrera del freeze, catálogo, `resolveChain`) nunca — por eso
330
+ * exige pool ≥ 2, y un despliegue con pool 1 declara `false` en las opciones
331
+ * del driver. Un choque del UNIQUE dentro de la transacción del llamante es
332
+ * 409 `E_AUTHZ_WRITE_CONFLICT` («envenena tu transacción»); un deadline
333
+ * vencido ahí sigue siendo `indeterminate: true` y el evento lleva
334
+ * `transactional: true`.
335
+ */
336
+ transactionalWrites: boolean;
337
+ }
165
338
  export interface AuthorizationDriver {
339
+ /** Lo que este driver declara poder hacer (3b-2e · E2). Ver `AuthorizationDriverCapabilities`. */
340
+ readonly capabilities?: AuthorizationDriverCapabilities;
166
341
  /**
167
342
  * ¿El holder tiene el permiso en el scope? Evalúa la cadena completa:
168
343
  * sin deny en la cadena Y alguna asignación vigente cuyo rol concede el
@@ -181,7 +356,7 @@ export interface AuthorizationDriver {
181
356
  * el catálogo para `scope.type` (422 si no, como `grant`); la asignación
182
357
  * puede no existir (no-op).
183
358
  */
184
- revoke(subject: SubjectRef, role: RoleQuery, scope: ScopeRef): Promise<void>;
359
+ revoke(subject: SubjectRef, role: RoleQuery, scope: ScopeRef, options?: WriteOptions): Promise<void>;
185
360
  /**
186
361
  * ¿El holder tiene el rol (vigente) en el scope o en un ancestro?
187
362
  * Misma regla de herencia hacia abajo que `authorize`. Es MEMBRESÍA: el
@@ -192,12 +367,12 @@ export interface AuthorizationDriver {
192
367
  * Deny explícito de UN permiso al holder en un scope (y sus descendientes).
193
368
  * El permiso debe existir en el catálogo (throw si no). Idempotente.
194
369
  */
195
- deny(subject: SubjectRef, permission: string, scope: ScopeRef): Promise<void>;
370
+ deny(subject: SubjectRef, permission: string, scope: ScopeRef, options?: WriteOptions): Promise<void>;
196
371
  /**
197
372
  * Levanta el deny en ese scope exacto. El permiso debe existir en el
198
373
  * catálogo (422 si no, como `deny`); el deny puede no existir (no-op).
199
374
  */
200
- removeDeny(subject: SubjectRef, permission: string, scope: ScopeRef): Promise<void>;
375
+ removeDeny(subject: SubjectRef, permission: string, scope: ScopeRef, options?: WriteOptions): Promise<void>;
201
376
  /** Holders con asignación VIGENTE del rol en ese scope exacto (sin herencia). */
202
377
  listSubjects(role: RoleQuery, scope: ScopeRef): Promise<SubjectRef[]>;
203
378
  /**
@@ -303,6 +478,102 @@ export interface AuthorizationDriver {
303
478
  * `defineScopedRole` lo dice antes de escribir nada (3E · P4).
304
479
  */
305
480
  purgeRole?(roleUuid: string): Promise<void>;
481
+ /**
482
+ * Cuántos hechos VIGENTES tiene cada rol, en TODOS los scopes (3b-2j,
483
+ * decisión del dueño del 2026-08-31 (3)). Un hecho es una asignación del
484
+ * rol a un holder que no ha caducado (`expiresAt` nulo o futuro, con el
485
+ * reloj del driver); el rol se identifica por su uuid y la respuesta va
486
+ * POR POSICIÓN, como `authorizeMany`. Un rol sin hechos —o que el backend
487
+ * no conoce— es `0`; `uuid` mal formado ⇒ 422 `E_AUTHZ_INVALID_IDENTITY`.
488
+ *
489
+ * Es lo que `pruneOrphanRoles` (`authz:catalog:prune-orphans`) necesita
490
+ * para decir si un rol huérfano TODAVÍA CONCEDE, y es una pregunta del
491
+ * PUERTO porque los hechos son del driver: hasta 3b-2j el barrido contaba
492
+ * filas de `authz_assignments` —la tabla del driver `database`— y con
493
+ * `openfga` en modo `facts`, donde viven en el store, decía siempre que
494
+ * no. El campo se lee justo antes de un borrado destructivo y su contrato
495
+ * publicado es «falso ⇒ este rol seguro que no concede», así que ese
496
+ * `false` era fail-dangerous.
497
+ *
498
+ * Es CONSERVADOR a propósito: cuenta hechos, no comprueba si el scope de
499
+ * cada uno sigue resolviendo. Cero ⇒ no concede seguro; más de cero ⇒
500
+ * míralo antes de purgar.
501
+ *
502
+ * OPCIONAL en el puerto (**breaking para un driver de 2.2 que no lo
503
+ * traiga**, y por eso opcional y no obligatorio): sin él
504
+ * `pruneOrphanRoles` deja `assignments` y `stillGranting` en `undefined`
505
+ * —jamás en `false`— y el comando lista esos roles APARTE, como los que sí
506
+ * conceden. Capacidad `countRoleAssignments`.
507
+ */
508
+ countRoleAssignments?(roleUuids: string[]): Promise<number[]>;
509
+ /**
510
+ * Rehace la **proyección derivada** del catálogo para UN rol (3b-2e · E4).
511
+ * Opcional: solo la implementa un driver que mantenga esa proyección (el
512
+ * `openfga` en modo `facts`, donde lo que un rol concede son tuplas y no
513
+ * el catálogo local). El manager la llama después de `defineScopedRole` y
514
+ * `updateScopedRole` —las dos escrituras de catálogo que cambian los
515
+ * vínculos de un rol fuera de `syncAuthzCatalog`—, porque si no un rol
516
+ * recién definido no concedería NADA y un rol al que se le quita un permiso
517
+ * lo seguiría concediendo (fail-open). En `database` no existe: el catálogo
518
+ * es la fuente y no hay espejo que rehacer.
519
+ */
520
+ projectCatalogRole?(roleUuid: string): Promise<void>;
521
+ /**
522
+ * La **proyección derivada del catálogo entero** de este driver (3b-2a ·
523
+ * A5), para inyectarla en `syncAuthzCatalog`/`syncCatalogs`. Opcional por
524
+ * el mismo motivo que `projectCatalogRole`: solo la trae un driver que
525
+ * mantenga un espejo del catálogo en su backend (el `openfga` en modo
526
+ * `facts`).
527
+ *
528
+ * Está en el PUERTO porque el camino de recuperación documentado —«un
529
+ * `authz:catalog:sync` reescribe la proyección»— lo ejecuta un comando que
530
+ * solo ve `AuthorizationDriver` (3b-8 · A1): sin esto, el CLI sincronizaba
531
+ * `authz_*` y dejaba el espejo del store SIN TOCAR, o sea que en `facts`
532
+ * un permiso quitado del catálogo seguía concediendo y un rol nuevo no
533
+ * concedía nada.
534
+ */
535
+ catalogProjection?(): CatalogProjection;
536
+ /**
537
+ * **Reconstruye el estado de ESTE driver desde `authz_*` + el árbol del
538
+ * consumidor** (3b-3a). Es lo que hay detrás de `authz:reconcile --to=<este
539
+ * driver>`: hechos, árbol y proyección del catálogo, idempotente
540
+ * (la segunda pasada escribe cero), reanudable por lotes con cursor y
541
+ * **nunca silenciosa** (el reporte cuenta lo escrito, lo actualizado, lo
542
+ * igual, lo que sobra, lo borrado y lo que NO se migró con su motivo).
543
+ *
544
+ * `dryRun` es el VERIFICADOR: mismo recorrido, cero escrituras. Es
545
+ * **read-only por contrato** (panel 2, cruce 4 · S18) — un `--fix` sería un
546
+ * mecanismo de concesión y queda PROHIBIDO.
547
+ *
548
+ * Opcional en el puerto: un driver que no lo trae dice «no sé
549
+ * reconstruirme» y el manager responde 500 `E_AUTHZ_UNSUPPORTED` nombrando
550
+ * el método, nunca una migración a medias en silencio. El driver
551
+ * `database` NO lo implementa: sus tablas SON el origen, y llenarlas desde
552
+ * un store es la otra dirección (3b-3b).
553
+ */
554
+ reconcile?(source: ReconcileSource, options: ReconcileOptions): Promise<ReconcileReport>;
555
+ /**
556
+ * **Los hechos de ESTE driver, paginados, para que otro se reconstruya
557
+ * desde ellos** (3b-3b). Es la otra mitad de `reconcile`: `reconcile` es
558
+ * ser el DESTINO de `authz:reconcile`, `enumerateFacts` es ser el ORIGEN.
559
+ *
560
+ * Contrato: como mucho `limit` hechos por página (más ⇒ 500), orden total
561
+ * y estable, cursor opaco que tiene que AVANZAR (repetirlo ⇒ 500, jamás un
562
+ * bucle), y **nada se filtra**: una asignación caducada sale con su
563
+ * `expiresAt` para que el destino la cuente en `skipped` con su motivo. Lo
564
+ * que el origen no sabe expresar como hecho del puerto sale en `skipped`
565
+ * de la página, nunca descartado en silencio.
566
+ *
567
+ * Opcional: capacidad `enumerateFacts`. El driver `database` **no lo
568
+ * trae** a propósito — sus hechos son `authz_assignments`/`authz_denies`,
569
+ * el esquema publicado del paquete, y el destino los lee de ahí (es lo que
570
+ * hace `openfga.reconcile`). Un driver de terceros que quiera migrar
571
+ * DESDE otro sitio sí lo necesita.
572
+ */
573
+ enumerateFacts?(page: {
574
+ limit: number;
575
+ after?: string;
576
+ }): Promise<ReconcileFactPage>;
306
577
  /**
307
578
  * Roles DIRECTOS vigentes del holder en cada scope de `chain` (2D · G5),
308
579
  * como pares `{ scope, role }`; solo roles que EXISTEN en ese scope (D5 +
@@ -330,6 +601,224 @@ export interface AuthorizationDriver {
330
601
  * `authorization.expandExcludedSubtrees(excluded)` (usa tu `descendantsOf`)
331
602
  * o resta el subárbol en tu propia consulta (CTE recursiva, `path LIKE`…).
332
603
  */
604
+ /**
605
+ * Lo que el manager le presta al driver para reconciliar: el árbol del
606
+ * consumidor (entero y paginado) y su resolutor. El driver pone lo suyo —qué
607
+ * hechos guarda y cómo—; el paquete no le dice cómo migrar, le da la FUENTE.
608
+ */
609
+ export interface ReconcileSource {
610
+ enumerateEdges: ScopeEdgesEnumerator;
611
+ resolveChain: ScopeChainResolver;
612
+ /**
613
+ * Los HECHOS del origen, paginados (3b-3b). Solo hace falta en la
614
+ * dirección en la que el origen NO es `authz_*`: `--to=database` los lee
615
+ * del store con este enumerador, mientras que `--to=openfga` lee las
616
+ * tablas del paquete directamente (son su propio esquema publicado, no el
617
+ * secreto de un driver).
618
+ *
619
+ * Es **perezoso a propósito**: el manager solo resuelve el driver de
620
+ * ORIGEN cuando el destino lo pide, así que una migración que no necesita
621
+ * hechos del puerto no construye nada. Sin origen que lo implemente, la
622
+ * primera llamada es 500 `E_AUTHZ_UNSUPPORTED` nombrando `enumerateFacts`.
623
+ */
624
+ facts?: ReconcileFactsEnumerator;
625
+ /**
626
+ * **Quién es la FUENTE DE VERDAD de los hechos en esta pasada** (3b-5, los
627
+ * dos 🔴 del auditor final). Lo decide el MANAGER, que es el único que sabe
628
+ * qué driver está sirviendo (`config.default`) y qué declara cada uno
629
+ * (`capabilities.hierarchyFacts`), y el destino lo OBEDECE.
630
+ *
631
+ * Sin esto, `--to=openfga` leía siempre `authz_assignments`/`authz_denies`,
632
+ * y en un despliegue `hierarchy: 'facts'` esas tablas **no son** la fuente
633
+ * de verdad de los hechos —lo son las tuplas del store—: la pasada
634
+ * reescribía lo revocado después del cutover, `--prune` borraba los denies
635
+ * vivos y el barrido de visibilidad del invariante 18 no se aplicaba nunca
636
+ * (`forbidden` salía vacío porque `wanted.facts` salía vacío).
637
+ *
638
+ * - `authzTables: true` ⇒ los hechos son las tablas del paquete y el
639
+ * destino las lee él mismo (es la MIGRACIÓN `database` → `openfga`);
640
+ * - `authzTables: false` ⇒ los hechos llegan por el PUERTO (`facts`,
641
+ * `enumerateFacts`) del origen `name`, que puede ser **el propio
642
+ * destino** cuando el destino es el driver ACTIVO y sus hechos son
643
+ * suyos (la pasada de MANTENIMIENTO: rehace lo derivado —marcador,
644
+ * catálogo, árbol y visibilidad— y no inventa ni borra un solo hecho).
645
+ *
646
+ * Ausente = `{ name: 'authz_*', authzTables: true }`: el comportamiento de
647
+ * 3b-3a, que es el que vale cuando el origen es el esquema publicado.
648
+ */
649
+ factsOrigin?: ReconcileFactsOrigin;
650
+ }
651
+ /** Ver `ReconcileSource.factsOrigin` (3b-5). */
652
+ export interface ReconcileFactsOrigin {
653
+ /** Cómo se NOMBRA el origen en el reporte (clave de `drivers`, o `authz_*`). */
654
+ name: string;
655
+ /** `true` ⇒ los hechos son `authz_assignments`/`authz_denies` y los lee el destino. */
656
+ authzTables: boolean;
657
+ }
658
+ /**
659
+ * Un hecho del ORIGEN en el vocabulario del PUERTO, no en el del backend
660
+ * (3b-3b). Es lo que un driver entrega cuando le toca ser el origen de una
661
+ * migración: el destino no sabe si detrás hay tuplas, filas o un fichero.
662
+ *
663
+ * La identidad del rol es el **uuid** (3D · M1), nunca el slug: dos owners
664
+ * definen `lead@unit` y el slug no identifica nada. La del permiso es el
665
+ * **slug**, que es lo que el catálogo local sabe traducir a uuid.
666
+ */
667
+ export interface ReconcileFact {
668
+ kind: 'assignment' | 'deny';
669
+ holder: SubjectRef;
670
+ /** El scope tal como lo guarda el ORIGEN; el destino lo canoniza con SU árbol. */
671
+ scope: ScopeRef;
672
+ /** `assignment`: uuid del rol. */
673
+ roleUuid?: string;
674
+ /** `deny`: slug del permiso. */
675
+ permission?: string;
676
+ /**
677
+ * `assignment`: la caducidad tal como está guardada, **sin filtrar**. Una
678
+ * caducada tiene que LLEGAR para poder contarse en `skipped` con su motivo;
679
+ * un origen que la filtre por su cuenta la haría desaparecer en silencio,
680
+ * que es justo lo que la migración no puede hacer.
681
+ */
682
+ expiresAt?: Date | null;
683
+ /** Cómo lo nombra el origen (para `details`): un motivo sin la fila no se arregla. */
684
+ detail: string;
685
+ }
686
+ /**
687
+ * Una página de hechos del origen. `cursor` es opaco y tiene que AVANZAR
688
+ * (repetirlo ⇒ 500, nunca un bucle); `skipped` es lo que el ORIGEN no supo
689
+ * expresar como hecho del puerto (basura de otra versión, un holder type que
690
+ * el config no declara…) y que el destino suma a su reporte.
691
+ */
692
+ export interface ReconcileFactPage {
693
+ facts: ReconcileFact[];
694
+ skipped?: ReconcileSkip[];
695
+ cursor?: string;
696
+ }
697
+ export type ReconcileFactsEnumerator = (page: {
698
+ limit: number;
699
+ after?: string;
700
+ }) => Promise<ReconcileFactPage>;
701
+ export interface ReconcileOptions {
702
+ /** Mismo recorrido, CERO escrituras. Es el verificador (read-only por contrato). */
703
+ dryRun?: boolean;
704
+ /**
705
+ * Borra del destino los HECHOS que el origen no respalda: los de un scope
706
+ * que ya no resuelve (3b-0b · AA4, «resurrección») y los que sobran (un
707
+ * store escrito por una versión anterior). Sin él se REPORTAN y no se
708
+ * borran. Lo derivado —marcador de raíz, proyección del catálogo y árbol—
709
+ * se rehace siempre: es un espejo de datos locales que nadie más escribe.
710
+ */
711
+ prune?: boolean;
712
+ /** La salida humana de `E_AUTHZ_MASS_RECONCILE_REFUSED`. */
713
+ allowMassDelete?: boolean;
714
+ /** Filas por lote en las lecturas del origen y por `Write` en el destino (default 100). */
715
+ batchSize?: number;
716
+ /**
717
+ * **La cota del volcado del destino** (3b-3b · B5). Reconciliar exige
718
+ * comparar contra el estado ENTERO del destino, y ese volcado entra en
719
+ * memoria: el ORIGEN se lee por lotes con cursor, el destino no. En vez de
720
+ * dejarlo como una sorpresa (un OOM en producción), se declara: pasar de
721
+ * `maxTuples` es 500 `E_AUTHZ_RECONCILE_TOO_LARGE` **antes de escribir
722
+ * nada**, nombrando la cota y cómo subirla. Default
723
+ * `DEFAULT_RECONCILE_MAX_TUPLES`.
724
+ */
725
+ maxTuples?: number;
726
+ }
727
+ /**
728
+ * Cuántas tuplas/filas del destino caben en una pasada de `authz:reconcile`
729
+ * (3b-3b · B5). No es una garantía de memoria: es la cota DECLARADA por
730
+ * encima de la cual la pasada se niega en vez de intentarlo.
731
+ */
732
+ export declare const DEFAULT_RECONCILE_MAX_TUPLES = 1000000;
733
+ /**
734
+ * Algo que la pasada NO migró (una fila del origen) o NO tocó (una tupla del
735
+ * destino), con su motivo. Nunca un contador a secas: un motivo sin la fila
736
+ * no se puede arreglar.
737
+ */
738
+ export interface ReconcileSkip {
739
+ kind: 'assignment' | 'deny' | 'edge' | 'tuple';
740
+ reason: string;
741
+ detail: string;
742
+ }
743
+ /** Los cinco números de una fase (o del total). */
744
+ export interface ReconcileCounts {
745
+ /** Tuplas nuevas en el destino. */
746
+ written: number;
747
+ /** Tuplas que estaban con OTRA caducidad y se han rehecho (delete + write). */
748
+ updated: number;
749
+ /** Tuplas que ya estaban exactamente igual. */
750
+ unchanged: number;
751
+ /** Tuplas del destino que el origen NO respalda. */
752
+ extra: number;
753
+ /** De las anteriores, las que la pasada borra (las que sobran de lo derivado, y con `prune` también los hechos). */
754
+ deleted: number;
755
+ }
756
+ /**
757
+ * Lo que movió una pasada de `authz:reconcile`. Los contadores describen el
758
+ * PLAN: con `dryRun` son exactamente los mismos números y no se escribe nada
759
+ * (lo dice `dryRun: true`), que es lo que hace del verificador un simulacro
760
+ * fiel y no una segunda implementación.
761
+ */
762
+ export interface ReconcileReport extends ReconcileCounts {
763
+ /** El driver de destino (`--to`). */
764
+ to: string;
765
+ /**
766
+ * **De dónde salieron los HECHOS de esta pasada** (3b-5): el nombre del
767
+ * driver ORIGEN, o `authz_*` si fueron las tablas del paquete. No es
768
+ * decoración: es la diferencia entre una migración y una pasada de
769
+ * mantenimiento contra el driver activo, y el comando la imprime — una
770
+ * pasada que lee los hechos del sitio equivocado no puede ser silenciosa.
771
+ */
772
+ factsFrom?: string;
773
+ /**
774
+ * **La garantía del freeze, publicada en vez de supuesta** (3b-7, juez C4).
775
+ * Solo en la pasada que ESCRIBE (el `--dry-run` no congela). `lapsed: true`
776
+ * significa que el lease se perdió a mitad —una pausa más larga que el
777
+ * lease, la base caída, otro dueño— y hubo una ventana en la que otros
778
+ * procesos pudieron escribir: la pasada NO se certifica y el comando sale
779
+ * distinto de cero. `leaseMs: null` = ventana sin renovación (el freeze de
780
+ * OPERADOR dentro del que corrió la pasada, o un lease infinito). Lo pone
781
+ * el MANAGER: el driver no sabe de ventanas.
782
+ */
783
+ frozen?: {
784
+ durable: boolean;
785
+ lapsed: boolean;
786
+ leaseMs: number | null;
787
+ fence: number;
788
+ };
789
+ dryRun: boolean;
790
+ prune: boolean;
791
+ /** Los mismos números por fase: qué es catálogo, qué es árbol y qué son hechos. */
792
+ phases: Record<'root' | 'catalog' | 'tree' | 'facts', ReconcileCounts>;
793
+ /**
794
+ * Motivo → cuántas cosas se quedaron fuera: filas del origen que no se
795
+ * migraron y tuplas del destino que esta pasada no tocó (`extra-fact`, las
796
+ * que solo se van con `--prune`).
797
+ */
798
+ skipped: Record<string, number>;
799
+ /** Y cuáles (acotado por `maxSkipDetails`): un contador no permite arreglar nada. */
800
+ details: ReconcileSkip[];
801
+ /** Ciclos del árbol del ORIGEN: sus aristas NO se escriben (FGA los evalúa y son fail-open). */
802
+ cycles: string[][];
803
+ drift: {
804
+ /** Faltaba el marcador de raíz: sin él el store entero DENIEGA (3b-2i). */
805
+ rootMarker: boolean;
806
+ /** Scopes con más de un padre en el destino (3b-2h · 🟠 4): cruce de tenants. */
807
+ multiParent: string[];
808
+ /**
809
+ * Aristas `scope#binding` que el destino tenía mal (invariante 18): la
810
+ * escritura de visibilidad que `scopes.moved`/`projectCatalogRole`
811
+ * pudieron perder si el relay no pasó.
812
+ */
813
+ roleVisibility: number;
814
+ /** Cambios del árbol encolados y sin relevar: la VENTANA del relay, medida. */
815
+ pendingRelay: number;
816
+ /** Entradas APARCADAS de la outbox: divergencia permanente, no una ventana. */
817
+ deadRelay: number;
818
+ };
819
+ /** La pasada tiene la firma de un origen ciego (ver `E_AUTHZ_MASS_RECONCILE_REFUSED`). */
820
+ massDelete: boolean;
821
+ }
333
822
  export interface ExcludedSubtree {
334
823
  scope: ScopeRef;
335
824
  /** Siempre `true`: recuerda que lo excluido es el subárbol entero. */
@@ -395,35 +884,256 @@ export type AuthorizationDriverFactory = () => AuthorizationDriver | Promise<Aut
395
884
  */
396
885
  export type ScopeChainResolver = (scope: ScopeRef) => Promise<ScopeRef[] | null>;
397
886
  /**
398
- * Resolutor de DESCENDIENTES de un scope (2.1, B2): todos los nodos del
399
- * subárbol (cualquier tipo, cualquier profundidad), sin el propio scope y
400
- * sin orden exigido. Lo implementa el consumidor (o `sqlDescendantsOf`, el
401
- * helper opt-in del paquete): el paquete NO lo suple con N+1 llamadas a
402
- * `resolveChain`. `null` = scope desconocido. Más de `maxNodes` nodos ⇒
403
- * el consumidor lanza; si devuelve de más, lanza el manager (422
404
- * `E_AUTHZ_TOO_MANY_SCOPES`). Nunca se llama desde `authorize`/`hasRole`/
405
- * `list*` (test de arquitectura): solo desde `authorizedScopes`.
887
+ * Una arista del árbol del consumidor: «`child` cuelga de `parent`» (3b-3a).
888
+ * `parent` puede ser `APP_SCOPE`; `child` nunca es la raíz.
406
889
  */
890
+ export interface ScopeEdge {
891
+ child: ScopeRef;
892
+ parent: ScopeRef;
893
+ }
894
+ /** Una página de `scopes.enumerateEdges`. Sin `cursor` = no queda nada más. */
895
+ export interface ScopeEdgePage {
896
+ edges: ScopeEdge[];
897
+ /**
898
+ * Continuación OPACA para la siguiente llamada (`after`). Ausente o
899
+ * `undefined` significa «se acabó»: devolver siempre un cursor es un bucle
900
+ * infinito, y el llamante lo denuncia (500) si el cursor no avanza.
901
+ */
902
+ cursor?: string;
903
+ }
407
904
  /**
408
- * El árbol del consumidor hacia ABAJO (2.1): todos los descendientes de
409
- * `scope`, en cualquier orden y sin incluirlo. `null` = «este árbol no conoce
410
- * ese scope».
905
+ * **El árbol ENTERO, paginado** (3b-3a). Es la otra mitad de
906
+ * `resolveChain`: aquel responde «¿de qué cuelga ESTE scope?» y este
907
+ * «¿cuáles son todas las aristas?», que es lo que hace falta para
908
+ * reconstruir el árbol de un backend que lo guarda como hechos propios
909
+ * (`authz:reconcile --to=openfga`) y para ver las que sobran (las que el
910
+ * consumidor ya no respalda).
411
911
  *
412
- * Contrato con un scope que `resolveChain` YA NO conoce (3G · W2, auditor
413
- * pregunta 2): **el consumidor es la autoridad sobre su tabla y puede
414
- * devolver los hijos** (una ruta materializada, o un `where parent_id = X`,
415
- * no necesitan la fila del padre) **o `null`**; el paquete no asume ninguna
416
- * de las dos. La consecuencia está en `scopes.detached`: si el scope no
417
- * resuelve y por debajo no llega nada, la purga NO se puede declarar
418
- * completa (`ScopeDetachOutcome.truncated: true`). Y lo que se devuelva se
419
- * trata como el subárbol real: los roles de esos owners se purgan con la
420
- * policy de rango medida en la cadena de CADA owner (3G · W1), nunca en la
421
- * del scope notificado.
912
+ * Contrato:
913
+ * - devuelve **como mucho `limit`** aristas por página (más 500: el
914
+ * llamante no puede paginar lo que no cabe en su lote);
915
+ * - el orden tiene que ser TOTAL y ESTABLE entre llamadas (la clave
916
+ * primaria vale): si no, una pasada reanudada se salta nodos;
917
+ * - `cursor` es opaco para el paquete y vuelve tal cual en `after`; que no
918
+ * avance es 500, nunca un bucle;
919
+ * - una arista cuyo padre no existe en la tabla NO se emite (es un nodo que
920
+ * `resolveChain` tampoco resuelve): el destino la ve como sobrante y
921
+ * `authz:reconcile` la cuenta y la reporta.
922
+ *
923
+ * Sin él, `authz:reconcile --to=openfga` no puede migrar el árbol y lo dice
924
+ * (500 `E_AUTHZ_CONFIG`): NO se inventa un árbol plano.
925
+ * `sqlScopeEdges(...)` lo implementa sobre una tabla con columna padre.
926
+ */
927
+ export type ScopeEdgesEnumerator = (options: {
928
+ limit: number;
929
+ after?: string;
930
+ }) => Promise<ScopeEdgePage>;
931
+ /**
932
+ * Un cambio del ÁRBOL, tal como lo encola la outbox (3b-2d). Es exactamente
933
+ * lo que el consumidor notifica por `manager.scopes.*`, con la identidad ya
934
+ * CANÓNICA (invariante 17): se resuelve al encolar, mientras la fila del
935
+ * consumidor todavía existe, no al relevarla.
936
+ */
937
+ export type ScopeTreeChange = {
938
+ op: 'attached';
939
+ child: ScopeRef;
940
+ parent: ScopeRef;
941
+ } | {
942
+ op: 'moved';
943
+ child: ScopeRef;
944
+ parent: ScopeRef;
945
+ } | {
946
+ op: 'detached';
947
+ child: ScopeRef;
948
+ };
949
+ /** Un cambio pendiente en la outbox, con la identidad de su registro. */
950
+ export interface PendingScopeTreeChange {
951
+ /** Identificador estable del registro; el relay lo devuelve al marcarlo. */
952
+ id: string | number;
953
+ change: ScopeTreeChange;
954
+ /** Intentos fallidos previos, si la outbox los lleva (el reporte los muestra). */
955
+ attempts?: number;
956
+ /** La última causa de fallo, si la outbox la guarda (`dead()` la enseña). */
957
+ lastError?: string;
958
+ /**
959
+ * Quién ordenó el cambio, si el call-site lo declaró y la outbox lo
960
+ * guarda. El relay lo pone en el `AuthzWriteEvent` del `scope_purged` que
961
+ * dispara un `detached`: la auditoría no debe perder al autor por pasar
962
+ * por una cola.
963
+ */
964
+ actor?: SubjectRef;
965
+ }
966
+ /** Lo que el relay aplicó (o aplicaría), pieza a pieza. */
967
+ export interface RelayedScopeChange {
968
+ id: string | number;
969
+ change: ScopeTreeChange;
970
+ attempts?: number;
971
+ /** La causa, en lo que FALLÓ, se APARCÓ o se APLAZÓ (nunca en lo aplicado). */
972
+ error?: string;
973
+ }
974
+ /**
975
+ * Reporte de `authz:scopes:relay` (3b-2d; 3b-2h · 🔴 2). Dice QUÉ se aplicó,
976
+ * no un contador: la pasada no es atómica y un número no permite retomar nada.
977
+ */
978
+ export interface ScopeRelayReport {
979
+ /** Aplicados en esta pasada, en orden. Vacío en `dryRun`. */
980
+ applied: RelayedScopeChange[];
981
+ /**
982
+ * El PRIMER cambio que falló, con la causa (`failures[0]`). Se conserva
983
+ * porque es lo que mira un supervisor; la lista completa está en
984
+ * `failures`.
985
+ */
986
+ failed: {
987
+ id: string | number;
988
+ change: ScopeTreeChange;
989
+ error: string;
990
+ } | null;
991
+ /**
992
+ * TODO lo que falló en esta pasada (3b-2h · 🔴 2). Un fallo ya no para la
993
+ * pasada entera: para lo que DEPENDE de él —los cambios que nombran alguno
994
+ * de sus scopes, que salen en `deferred`— y el resto sigue.
995
+ */
996
+ failures: Array<{
997
+ id: string | number;
998
+ change: ScopeTreeChange;
999
+ error: string;
1000
+ }>;
1001
+ /**
1002
+ * Lo que NO se intentó porque toca un scope contaminado por un fallo o por
1003
+ * otro aplazado de esta misma pasada. Es lo que mantiene el ORDEN del árbol
1004
+ * (aplicar un `moved` antes que el `attached` de su padre da un árbol que
1005
+ * nunca existió) sin dejar que una fila envenenada bloquee a los demás.
1006
+ */
1007
+ deferred: RelayedScopeChange[];
1008
+ /**
1009
+ * Entradas APARCADAS por la outbox tras agotar sus intentos (`dead()`), si
1010
+ * la implementación lo soporta. No se van a aplicar solas: el árbol del
1011
+ * backend está permanentemente divergente en esos nodos y hay que mirarlas.
1012
+ */
1013
+ dead: RelayedScopeChange[];
1014
+ /**
1015
+ * Otra pasada tenía el lease de la cola y esta no ha hecho NADA (3b-2h ·
1016
+ * 🟠 4). No es un error: el relay es escritor ÚNICO.
1017
+ */
1018
+ busy: boolean;
1019
+ /** Quedan cambios sin aplicar tras la pasada (vuelve a ejecutar). */
1020
+ remaining: boolean;
1021
+ dryRun: boolean;
1022
+ /** Solo con `dryRun`: lo que se aplicaría, en orden. */
1023
+ wouldApply: RelayedScopeChange[];
1024
+ }
1025
+ /**
1026
+ * El lease de una pasada del relay (3b-2h · 🟠 4). Lo devuelve
1027
+ * `ScopeOutbox.acquire()` y lo suelta el manager en un `finally`.
1028
+ */
1029
+ export interface ScopeOutboxLease {
1030
+ release(): Promise<void>;
1031
+ }
1032
+ /** Contexto del encolado: la transacción del consumidor y quién lo ordena. */
1033
+ export interface ScopeOutboxContext {
1034
+ /**
1035
+ * Lo que el llamante pasó en `ScopeTreeWriteOptions.transaction`: para
1036
+ * Lucid, el `TransactionClientContract` de la transacción en curso. El
1037
+ * manager no lo interpreta —no conoce la BD del consumidor—: lo pasea.
1038
+ * `sqlScopeOutbox` SÍ lo juzga (L-1 · 🟠 9, `assertCallerTransaction`):
1039
+ * tiene que ser una transacción ABIERTA de la conexión de la cola, o 500
1040
+ * `E_AUTHZ_CONFIG` antes del INSERT. Una outbox propia hereda el deber.
1041
+ */
1042
+ transaction?: unknown;
1043
+ actor?: SubjectRef;
1044
+ }
1045
+ /**
1046
+ * **El puerto de la outbox del árbol** (3b-2d, panel 2 cruce 4 · S5).
1047
+ *
1048
+ * Sin él, `manager.scopes.attached/moved/detached` escribe en el backend
1049
+ * DENTRO de la transacción del consumidor y un `rollback` posterior deja el
1050
+ * árbol de FGA diciendo una cosa y la BD del consumidor otra —una escalada
1051
+ * persistente e invisible, porque la aplicación lista y audita contra SQL—.
1052
+ * Con él, el manager no toca el driver: ENCOLA el cambio con la transacción
1053
+ * del consumidor, así que el cambio del árbol y su intención de propagación
1054
+ * confirman o se van juntos. Lo aplica después `authz:scopes:relay`.
1055
+ *
1056
+ * El paquete no impone tabla: define este puerto y publica un stub de
1057
+ * migración (`stubs/scopes_outbox_migration.stub`) y una implementación
1058
+ * sobre Lucid (`sqlScopeOutbox`) para quien no quiera escribir la suya.
1059
+ *
1060
+ * Lo que NO arregla, y hay que leerlo así: durante el lag del relay
1061
+ * (segundos) FGA decide con el árbol VIEJO. Es un fail-open temporal — el
1062
+ * tenant antiguo conserva acceso tras un `moved`, y los denies heredados no
1063
+ * aplican tras un `attached`—. No hay 2PC; es el precio de tener el árbol en
1064
+ * dos sitios.
1065
+ */
1066
+ export interface ScopeOutbox {
1067
+ /**
1068
+ * Encola el cambio en la transacción del consumidor. Debe escribir y
1069
+ * volver: nada de aplicarlo aquí. Si lanza, la escritura del manager falla
1070
+ * (y la transacción del consumidor se lleva las dos cosas).
1071
+ */
1072
+ enqueue(change: ScopeTreeChange, context: ScopeOutboxContext): Promise<void>;
1073
+ /**
1074
+ * Los pendientes MÁS ANTIGUOS primero: el orden del árbol es el del
1075
+ * encolado. `after` (3b-2h · 🔴 2) es el id del último registro que el
1076
+ * relay ya vio en ESTA pasada: como una entrada que falla ya no para la
1077
+ * pasada, se queda pendiente y volvería a salir la primera para siempre.
1078
+ * Una implementación que lo ignore sigue siendo válida —el relay detecta
1079
+ * que no avanza y termina la pasada—, pero solo drenará hasta el primer
1080
+ * lote atascado.
1081
+ */
1082
+ pending(limit: number, after?: string | number): Promise<PendingScopeTreeChange[]>;
1083
+ /** Aplicado en el backend: no se vuelve a relevar. */
1084
+ markApplied(id: string | number): Promise<void>;
1085
+ /** Falló al aplicarse: se queda pendiente, con la causa a la vista. */
1086
+ markFailed(id: string | number, error: string): Promise<void>;
1087
+ /**
1088
+ * **Las entradas APARCADAS** (3b-2h · 🔴 2), opcional. Una entrada que ya
1089
+ * no se puede aplicar —su scope padre se borró antes de la pasada— no se
1090
+ * arregla sola: la outbox puede dejar de ofrecerla en `pending()` tras N
1091
+ * intentos y enseñarla aquí. El relay las REPORTA en cada pasada y el
1092
+ * comando sale ≠ 0 mientras haya alguna: un aparcado es una divergencia
1093
+ * permanente del árbol del backend, no un incidente resuelto.
1094
+ */
1095
+ dead?(limit: number): Promise<PendingScopeTreeChange[]>;
1096
+ /**
1097
+ * **El lease del escritor ÚNICO** (3b-2h · 🟠 4), opcional. `pending()` no
1098
+ * reserva nada, así que dos pasadas a la vez (un `CronJob` con
1099
+ * `concurrencyPolicy: Allow`, dos réplicas, una pasada más larga que su
1100
+ * intervalo) trabajan sobre el MISMO lote: la rezagada re-aplica un
1101
+ * `attached` viejo después de que la otra aplicara el `moved` nuevo y deja
1102
+ * el árbol del store REVERTIDO —con un solo padre, así que nada lo
1103
+ * delata— (medido). Con `acquire`, la segunda pasada no hace nada y lo
1104
+ * dice (`busy`). `null` = otra pasada lo tiene.
1105
+ *
1106
+ * CONTRATO: el lease se toma UNA vez al inicio de la pasada y se sostiene
1107
+ * hasta el `finally`; el relay NO lo re-verifica ni lo renueva dentro del
1108
+ * bucle (a diferencia del freeze durable, que sí se re-afirma por lote).
1109
+ * Por eso la implementación DEBE ser un cerrojo SOSTENIDO mientras dura la
1110
+ * pasada, no un TTL que pueda vencer a mitad: los que trae el paquete lo
1111
+ * cumplen (`pg_try_advisory_xact_lock` vive con la transacción; `get_lock`
1112
+ * de MySQL con la sesión; SQLite en proceso). Un `acquire` con TTL
1113
+ * reabriría la ventana del doble escritor que este lease cierra.
1114
+ */
1115
+ acquire?(): Promise<ScopeOutboxLease | null>;
1116
+ }
1117
+ /**
1118
+ * El árbol del consumidor hacia ABAJO (2.1, B2): todos los descendientes de
1119
+ * `scope` (cualquier tipo, cualquier profundidad), en cualquier orden y sin
1120
+ * incluirlo. Lo implementa el consumidor (o `sqlDescendantsOf`, el helper
1121
+ * opt-in del paquete): el paquete NO lo suple con N+1 llamadas a
1122
+ * `resolveChain`. `null` = «este árbol no conoce ese scope».
422
1123
  *
423
1124
  * Más de `maxNodes` nodos ⇒ el resolutor puede devolver la lista larga (el
424
1125
  * paquete la caza con 422 `E_AUTHZ_TOO_MANY_SCOPES`) o lanzar; en
425
- * `authorizedScopes` eso es un 422 y en `scopes.detached`/`defineScopedRole`
426
- * DEGRADA (3F · S2, y ver el aviso de `#assertLevelUnderOwner`).
1126
+ * `authorizedScopes` eso es un 422 y en `defineScopedRole`/`updateScopedRole`
1127
+ * DEGRADA a la regla de nivel mínima (3F · S2, y ver el aviso de
1128
+ * `#assertLevelUnderOwner`).
1129
+ *
1130
+ * Solo se llama desde `authorizedScopes`/`expandExcludedSubtrees` y desde la
1131
+ * regla de nivel de la delegación; NUNCA desde `authorize`/`hasRole`/`list*`
1132
+ * (test de arquitectura) ni desde `scopes.detached`, que purga hechos del
1133
+ * scope EXACTO y no baja por el árbol (invariante 11; 3b-0 · Z1).
1134
+ *
1135
+ * (D7: hasta 3G había DOS docblocks seguidos aquí y el viejo contradecía al
1136
+ * nuevo sobre qué se espera al pasarse de `maxNodes`. Queda uno.)
427
1137
  */
428
1138
  export type ScopeDescendantsResolver = (scope: ScopeRef, options: {
429
1139
  maxNodes: number;
@@ -479,63 +1189,24 @@ export interface AuthzWriteEvent {
479
1189
  * propagar el 503 para que la auditoría registre un resultado desconocido
480
1190
  * en vez de un silencio (que se lee como "no pasó nada"). Un 503 que no es
481
1191
  * timeout (conexión rechazada) no lo lleva: esa escritura no ocurrió.
482
- */
483
- indeterminate?: boolean;
484
- /**
485
- * Solo en `scope_purged`: el árbol ya NO conoce el scope notificado —el
486
- * consumidor borró su fila y avisa después, que es el orden que el paquete
487
- * admite— o alguno de los roles purgados tenía un owner que tampoco
488
- * resuelve (3F · S1; 3G · W1/W2). `'owner-detached-unknown'` significa dos
489
- * cosas a la vez, y las dos importan a quien audita: (a) la purga procede
490
- * igual —bloquearla dejaba vivos el rol, sus asignaciones y los denies de
491
- * un scope borrado (auditor N2), sin ninguna salida con `requireActor:
492
- * true`— y (b) para ESOS roles —los que no tienen dónde medir el rango— la
493
- * policy de 3E · P3 no se pudo evaluar. Para los demás sí se evalúa: el
494
- * rango se mide en la cadena del OWNER de cada rol (3G · W1), así que un
495
- * `detached` de un ancestro desconocido ya NO destruye los roles de
496
- * descendientes vivos. Sale también con `purgedRoles: 0`.
497
- */
498
- reason?: 'owner-detached-unknown';
499
- /**
500
- * Solo en `scope_purged`: la purga de roles se acotó al scope EXACTO
501
- * porque el subárbol no se pudo enumerar (3F · S2). Ver
502
- * `ScopeDetachOutcome.truncated`.
503
- */
504
- truncated?: true;
505
- }
506
- /**
507
- * Lo que devuelve `scopes.detached` (3F · S1/S2). Hasta 3E era `void` y no
508
- * había forma de saber si la purga alcanzó a todo el subárbol ni si la
509
- * policy de rango se llegó a evaluar.
510
- */
511
- export interface ScopeDetachOutcome {
512
- /** Roles LOCALES purgados (los del scope y, con `descendantsOf`, los del subárbol). */
513
- purgedRoles: number;
514
- /**
515
- * `true` cuando el subárbol NO se pudo enumerar (más de `maxDescendants`,
516
- * o un `descendantsOf` que falló) y la purga se acotó al scope EXACTO
517
- * (3F · S2). Degradar en vez de tumbar la operación es la regla: declarar
518
- * `scopes.descendantsOf` nunca puede dejarte peor que no declararlo, y
519
- * hasta 3E un subárbol grande dejaba el `detached` en 503 sin purgar ni
520
- * los roles ni los hechos (auditor N3). Los roles que quedan abajo no son
521
- * visibles en ninguna parte —su owner ya no cuelga del árbol—, pero siguen
522
- * ocupando su `(slug, nivel)`: hay que volver a notificar nodo a nodo o
523
- * subir la cota.
524
1192
  *
525
- * También es `true` cuando el árbol ya NO conoce el scope y `descendantsOf`
526
- * no devolvió nada debajo (3G · W2, auditor P2): el puerto no le exige
527
- * responder por un scope que `resolveChain` desconoce —puede devolver sus
528
- * hijos o `null`, ver `ScopeDescendantsResolver`—, así que un vacío ahí no
529
- * demuestra que debajo no quedara nada. Decir `truncated: false` era
530
- * afirmar «purga completa» con el rol de la unit hija vivo y concediendo.
1193
+ * **Con `{ transaction }` (L-3) sigue siendo `true` en el deadline**, junto
1194
+ * a `transactional: true`: la sentencia puede haber aterrizado DENTRO de la
1195
+ * transacción del llamante (SQLite no cancela; MySQL la mata y la
1196
+ * transacción sigue; PostgreSQL deja la transacción abortada) y confirmar
1197
+ * o no es del llamante, que el paquete no ve. Invariante 13 intacto.
531
1198
  */
532
- truncated: boolean;
1199
+ indeterminate?: boolean;
533
1200
  /**
534
- * Igual que en `AuthzWriteEvent`: el scope notificado (o el owner de algún
535
- * rol purgado) ya no está en el árbol, así que para esos roles la policy
536
- * de rango no se pudo evaluar. Presente aunque `purgedRoles` sea 0.
1201
+ * `true` cuando la escritura se inscribió en la transacción del llamante
1202
+ * (`{ transaction }`, L-3): en el momento del evento la fila existe SOLO
1203
+ * dentro de esa transacción, y es un hecho si y solo si el llamante
1204
+ * confirma — cosa que el paquete no ve. Un sink que registre esto como
1205
+ * firme registra algo que un rollback deshace; si necesita la última
1206
+ * palabra, que se cuelgue del commit (`trx.after('commit', …)` en Lucid).
1207
+ * Ausente en el resto (encolar en `scopes.*` no es escribir y no lo lleva).
537
1208
  */
538
- reason?: 'owner-detached-unknown';
1209
+ transactional?: boolean;
539
1210
  }
540
1211
  /** Un rol del catálogo tal como lo ve el motor (3A · A2/A3, 3B · B2). */
541
1212
  export interface CatalogRole {
@@ -628,10 +1299,9 @@ export interface ScopedRoleChanges {
628
1299
  export interface AuthzCatalogWriteEvent {
629
1300
  action: 'role_defined' | 'role_updated' | 'role_purged';
630
1301
  /**
631
- * Quién lo ordenó. La API de delegación lo exige siempre; ausente solo en
632
- * los `role_purged` que arrastra `scopes.detached` (3D · M4), donde el
633
- * actor es el `WriteOptions.actor` de esa notificación del árbol y puede
634
- * no venir.
1302
+ * Quién lo ordenó. La API de delegación lo exige siempre; ausente en los
1303
+ * `role_purged` de `authz:catalog:prune-orphans` (3b-0 · Z2), que es una
1304
+ * operación de PLATAFORMA y no de un actor del árbol.
635
1305
  */
636
1306
  actor?: SubjectRef;
637
1307
  role: CatalogRole;
@@ -652,4 +1322,304 @@ export interface AuthzCatalogWriteEvent {
652
1322
  */
653
1323
  shadowedByAncestor?: CatalogRoleRef[];
654
1324
  }
1325
+ /**
1326
+ * Un rol del catálogo tal como lo ve la PROYECCIÓN: su uuid (la identidad, 3A
1327
+ * · A1) y los slugs de los permisos que vincula.
1328
+ */
1329
+ export interface CatalogProjectionRole {
1330
+ uuid: string;
1331
+ permissions: string[];
1332
+ }
1333
+ /**
1334
+ * Foto del catálogo confirmado que un driver puede materializar en su
1335
+ * backend. Se lee de `authz_*` dentro de la transacción del sync: es
1336
+ * DERIVADA, y por eso se puede reconstruir entera (`authz:reconcile`).
1337
+ */
1338
+ export interface CatalogProjectionSnapshot {
1339
+ /** Todos los slugs de permiso del catálogo (no solo los del spec que se sincroniza). */
1340
+ permissions: string[];
1341
+ /** Todos los roles con sus vínculos rol→permiso. */
1342
+ roles: CatalogProjectionRole[];
1343
+ }
1344
+ /** Lo que una pasada de proyección movió. Nunca un booleano: una proyección silenciosa no se vigila. */
1345
+ export interface CatalogProjectionReport {
1346
+ /** Tuplas nuevas escritas. */
1347
+ written: number;
1348
+ /** Tuplas que sobraban (el catálogo ya no las respalda) y se han borrado. */
1349
+ deleted: number;
1350
+ /** Tuplas que ya estaban exactamente igual. */
1351
+ unchanged: number;
1352
+ }
1353
+ /**
1354
+ * **Proyección derivada del catálogo en el backend de un driver** (regla del
1355
+ * catálogo reescrita — panel 2, cruce 7; decisión del dueño 2026-08-28).
1356
+ *
1357
+ * El catálogo es propiedad LOCAL siempre: roles y permisos viven en `authz_*`
1358
+ * y ningún driver es su fuente de verdad. Un driver PUEDE mantener una
1359
+ * proyección (el modo `facts` de openfga: permisos como relaciones del modelo
1360
+ * + vínculos rol→permiso como tuplas `role:<uuid>#permits_<P>@<holder>:*`) si
1361
+ * y solo si: (a) es reconstruible desde `authz_*`, (b) `authz:reconcile` la
1362
+ * vigila y (c) NUNCA se lee como catálogo.
1363
+ *
1364
+ * Se inyecta en `syncAuthzCatalog` en vez de importarse: `src/catalog.ts` es
1365
+ * la ruta de un consumidor solo-database y no puede tirar del SDK de OpenFGA
1366
+ * (regla 3 de `check_purity.mjs`).
1367
+ */
1368
+ export interface CatalogProjection {
1369
+ /**
1370
+ * ¿El catálogo que va a quedar es publicable en este backend? Se llama
1371
+ * ANTES de escribir nada (cotas de nombre y techo del modelo, A3/A4): un
1372
+ * catálogo que no se puede proyectar no se escribe a medias.
1373
+ */
1374
+ assertPublishable(permissions: readonly string[]): void;
1375
+ /** Rehace la proyección del catálogo ya confirmado: escribe lo que falta y BORRA lo que sobra. */
1376
+ project(snapshot: CatalogProjectionSnapshot): Promise<CatalogProjectionReport>;
1377
+ }
1378
+ /** Un objeto de relaciones: `document:<id>`, `folder:<id>`, `group:<id>`… */
1379
+ export interface RelObject {
1380
+ /** El tipo FGA del objeto (`document`, `folder`, `space`…), declarado en `defineRelationsConfig`. */
1381
+ type: string;
1382
+ /** El id del objeto dentro de su partición. Sin `|`/`#`/`:` (los pone el driver al componer). */
1383
+ id: string;
1384
+ }
1385
+ /**
1386
+ * Un userset como sujeto: `group:eng#member` (todos los miembros del grupo).
1387
+ * Es lo que hace que un `relate(group:g#member, viewer, doc)` conceda `viewer`
1388
+ * a todo el que sea `member` de `g` (`usersetsOf`, un nivel).
1389
+ */
1390
+ export interface RelUserset {
1391
+ object: RelObject;
1392
+ relation: string;
1393
+ }
1394
+ /**
1395
+ * El sujeto de una relación: un HOLDER (`{type,uuid}`, como `SubjectRef`) o un
1396
+ * USERSET (`{object, relation}`). El puerto lo tipa explícito porque los dos
1397
+ * viajan por `relate`/`listSubjects`/`check`; un userset de OTRA partición se
1398
+ * corta por comparación de string en el driver (nunca cruza).
1399
+ */
1400
+ export type RelSubject = SubjectRef | RelUserset;
1401
+ /** Discriminador: ¿este sujeto es un userset (`group:g#member`) y no un holder? */
1402
+ export declare function isRelUserset(subject: RelSubject): subject is RelUserset;
1403
+ /**
1404
+ * La referencia COMPLETA a una tupla de relación, tal como la ve `assertWrite`
1405
+ * (R-13) y `onRelationWrite`: sujeto + relación + objeto + partición, más la
1406
+ * operación. Es puro dato: quien lo recibe decide (auditar, rechazar), nunca
1407
+ * muta el store.
1408
+ */
1409
+ export interface RelationRef {
1410
+ operation: 'relate' | 'unrelate';
1411
+ subject: RelSubject;
1412
+ relation: string;
1413
+ object: RelObject;
1414
+ partition: ScopeRef;
1415
+ /**
1416
+ * La caducidad pedida en `relate` (R-15), en sus tres estados: omitida
1417
+ * (preserva la vigente), `null` (la quita) o `Date` (la fija). Solo viaja en
1418
+ * `relate`; `assertWrite` puede rechazar una compartición sin plazo.
1419
+ */
1420
+ expiresAt?: Date | null;
1421
+ }
1422
+ /** El evento de escritura de relaciones (auditoría del consumidor, sin `AsyncLocalStorage`). */
1423
+ export interface RelationWriteEvent extends RelationRef {
1424
+ /** Quién ordenó la escritura (`RelationWriteOptions.actor`), ya validado. Ausente si no lo pasó. */
1425
+ actor?: SubjectRef;
1426
+ }
1427
+ /**
1428
+ * Opciones de `purgeObject`/`purgeSubject` (L-2): solo la transacción del
1429
+ * consumidor. Ver `RelationTransactionOptions.transaction`.
1430
+ */
1431
+ export interface RelationTransactionOptions {
1432
+ /**
1433
+ * **La transacción ABIERTA del consumidor** (`TransactionClientContract` de
1434
+ * Lucid), para que la escritura de la tupla confirme o revierta CON la tuya
1435
+ * — L-2, panel `{trx}` (C). Aquí significa **ESCRIBIR en tu transacción**
1436
+ * («los dos o ninguno» en el mismo motor transaccional) — **encolar ≠
1437
+ * escribir**: no es la outbox de `scopes.*`, que solo ENCOLA. Solo lo
1438
+ * cumple un driver con `capabilities.transactionalWrites: true` (`database`,
1439
+ * con el `trx` de SU conexión, L-4); con `false` (`openfga`: una tupla no
1440
+ * entra en una transacción SQL) la llamada es **500 `E_AUTHZ_UNSUPPORTED`**
1441
+ * nombrando driver y operación, antes de tocar el driver. Con
1442
+ * `requireTransactionalWrites: true` (`config.relations`, o heredado del
1443
+ * raíz) un driver `false` es 500 `E_AUTHZ_CONFIG` al resolver. La
1444
+ * AUTORIDAD (barrera del freeze) nunca viaja por ella: pool ≥ 2.
1445
+ */
1446
+ transaction?: unknown;
1447
+ }
1448
+ /** Opciones comunes a `relate`/`unrelate`. */
1449
+ export interface RelationWriteOptions extends RelationTransactionOptions {
1450
+ /** Quién ordena la escritura; viaja en `RelationWriteEvent.actor`. */
1451
+ actor?: SubjectRef;
1452
+ /**
1453
+ * **Caducidad de la tupla de relación** (R-15, 2.4.0-alpha.2) — los MISMOS
1454
+ * tres estados que `grant` (invariante 10): omitida ⇒ preserva una caducidad
1455
+ * VIGENTE (una ya caducada revive sin caducidad: es una relación nueva);
1456
+ * `null` ⇒ la quita; `Date` ⇒ la fija (también a un instante pasado: caduca).
1457
+ * Caducidad ESTRICTA: lo que vence AHORA ya no cuenta (`expires_at > now`;
1458
+ * `current_time < valid_until`). Solo la lee `relate`; `unrelate` la ignora.
1459
+ * Cualquier otro valor ⇒ 422 `E_AUTHZ_INVALID_IDENTITY` antes del driver.
1460
+ */
1461
+ expiresAt?: Date | null;
1462
+ }
1463
+ /** Una página de una enumeración de relaciones (cursor opaco que AVANZA, no filtra herencia). */
1464
+ export interface RelationPage {
1465
+ limit?: number;
1466
+ after?: string;
1467
+ }
1468
+ /**
1469
+ * Lo que un `RelationsDriver` DECLARA que puede hacer. Cada valor lleva su par
1470
+ * de casos `{ whenTrue, whenFalse }` en `runRelationsDriverContract` — nunca
1471
+ * un `skip` (3b-2e · E2). El runner FALLA si una capacidad declarada no tiene
1472
+ * poblada la cara que corresponde a su valor.
1473
+ */
1474
+ export interface RelationsDriverCapabilities {
1475
+ /** `check` es UNA sola llamada al backend (`openfga`: un `Check`). */
1476
+ singleCheckRelations: boolean;
1477
+ /**
1478
+ * Los `listObjects` enumeran también lo HEREDADO. **`false` siempre** en
1479
+ * este paquete (invariante 7): en `openfga` obligaría a `ListObjects`, que
1480
+ * trunca al tope del servidor. Devuelven hechos DIRECTOS + lo derivado.
1481
+ */
1482
+ listObjectsInherited: boolean;
1483
+ /** `listSubjects` devuelve también sujetos USERSET (`group:g#member`), no solo holders. */
1484
+ usersetSubjects: boolean;
1485
+ /**
1486
+ * El driver implementa `membersOf` (membresía TRANSITIVA a través de
1487
+ * usersets). Solo `database` (CTE recursiva); `openfga` es `false` (la
1488
+ * transitiva sería `ListUsers`, que trunca) ⇒ `membersOf` es 500
1489
+ * `E_AUTHZ_UNSUPPORTED`.
1490
+ */
1491
+ membersOfNative: boolean;
1492
+ /** El driver sabe ser ORIGEN de `authz:reconcile` de relaciones (`enumerateRelations`). */
1493
+ enumerateRelations: boolean;
1494
+ /**
1495
+ * `listObjects` SEÑALA el truncamiento cuando el backend corta al tope
1496
+ * (`openfga` con `ListObjects`): la página devuelve `truncated: true`, nunca
1497
+ * una lista parcial muda (S16). Capacidad NUEVA, distinta del
1498
+ * `truncationSignal` de los `list*` de roles.
1499
+ */
1500
+ listObjectsTruncation: boolean;
1501
+ /**
1502
+ * El driver acepta un reloj inyectado (R-15, paridad con el par
1503
+ * `injectableClock` de roles, 2.5 · J1): `withClock(now)` devuelve una
1504
+ * vista del driver cuyo `now()` decide la caducidad de las tuplas. Con
1505
+ * `true` el juez observa la caducidad EXACTA (T−1 ms concede, T no) y los
1506
+ * tres estados de `expiresAt` sin dormir; con `false` solo puede observarlos
1507
+ * en tiempo real (y el driver NO debe traer `withClock`: declara lo que se
1508
+ * observa).
1509
+ */
1510
+ injectableClock: boolean;
1511
+ /**
1512
+ * El driver puede inscribir sus escrituras en la transacción del consumidor
1513
+ * (`{ transaction }` en `relate`/`unrelate`/`purgeObject`/`purgeSubject`).
1514
+ * `true` significa EXACTAMENTE «los dos o ninguno con TU transacción»,
1515
+ * nunca «no se pierde». `database` = **`true`** desde L-4 (la escritura va
1516
+ * por el `trx` ABIERTO de SU conexión, `assertCallerTransaction`; la
1517
+ * AUTORIDAD —barrera del freeze, F-05— nunca; exige pool ≥ 2, y un
1518
+ * despliegue con pool 1 declara `false` con la opción
1519
+ * `DatabaseRelationsDriverOptions.transactionalWrites`). `openfga` = false,
1520
+ * y no puede ser otra cosa: una tupla no entra en una transacción SQL — el
1521
+ * store es otro servicio y no hay 2PC. **Mismo nombre que en
1522
+ * `AuthorizationDriverCapabilities`**. Con `false`, `{ transaction }` es 500
1523
+ * `E_AUTHZ_UNSUPPORTED` por llamada (cero llamadas al driver); con
1524
+ * `requireTransactionalWrites: true` un driver `false` es 500
1525
+ * `E_AUTHZ_CONFIG` al resolver. La cara `true` del runner juzga el rollback
1526
+ * POR CENSO (cero tuplas nuevas para las cuatro escrituras; `purge*`
1527
+ * revierten juntos) y que una transacción ajena no recibe ni una sentencia.
1528
+ */
1529
+ transactionalWrites: boolean;
1530
+ }
1531
+ /**
1532
+ * Una página de `listObjects`/`listSubjects`/`enumerateRelations`. `truncated`
1533
+ * dice si el backend cortó al tope (solo con `listObjectsTruncation`): un
1534
+ * consumidor que lo ve sabe que hay MÁS y no toma la lista por completa.
1535
+ */
1536
+ export interface RelationObjectsPage {
1537
+ objects: RelObject[];
1538
+ cursor?: string;
1539
+ truncated?: boolean;
1540
+ }
1541
+ export interface RelationSubjectsPage {
1542
+ subjects: RelSubject[];
1543
+ cursor?: string;
1544
+ truncated?: boolean;
1545
+ }
1546
+ /** Una tupla de relación tal como la enumera `enumerateRelations` (origen de reconcile). */
1547
+ export interface RelationTuple {
1548
+ subject: RelSubject;
1549
+ relation: string;
1550
+ object: RelObject;
1551
+ partition: ScopeRef;
1552
+ /**
1553
+ * La caducidad de la tupla tal como está ESCRITA (R-15): `null`/ausente = no
1554
+ * caduca. `enumerateRelations` NO filtra la caducada: tiene que LLEGAR al
1555
+ * destino de `reconcile` con su `expiresAt` para contarse en `skipped`
1556
+ * (la lección de la 3b); filtrarla en el origen la haría desaparecer sin rastro.
1557
+ */
1558
+ expiresAt?: Date | null;
1559
+ }
1560
+ export interface RelationTuplePage {
1561
+ tuples: RelationTuple[];
1562
+ cursor?: string;
1563
+ }
1564
+ /**
1565
+ * El puerto de ReBAC. `partition: ScopeRef` es OBLIGATORIA en TODA operación
1566
+ * (`APP_SCOPE` es válida para mono-tenant): el aislamiento de tenant se corta
1567
+ * por la partición, y el driver la serializa en el id del objeto y del
1568
+ * userset. La whitelist de tipo/relación (F-05) la aplica el manager ANTES de
1569
+ * llamar al driver, pero el driver la re-valida por defensa en profundidad:
1570
+ * `relate`/`unrelate` de `database` y de `openfga` rechazan 422
1571
+ * (`E_AUTHZ_RELATION_TYPE_UNKNOWN` / `E_AUTHZ_RELATION_UNKNOWN`, la MISMA
1572
+ * función y el mismo `code` que el manager, `assertRelationDeclared`) un
1573
+ * `object.type` no declarado o una `relation` no declarada para ese tipo,
1574
+ * ANTES de tocar el backend (cero `Write`, cero INSERT). Es la red para quien
1575
+ * entra por `manager.driver()` o por `reconcileRelations` (L-0): hasta
1576
+ * entonces esta frase era falsa en los dos drivers y, en el store compartido,
1577
+ * `driver.relate(evil, 'assignee', {type:'role_binding', id:<roleUuid>}, S)`
1578
+ * escalaba a `roles.authorize` (medido).
1579
+ */
1580
+ export interface RelationsDriver {
1581
+ readonly capabilities?: RelationsDriverCapabilities;
1582
+ /** Crea la relación `subject —relation→ object` en `partition`. Idempotente. */
1583
+ relate(subject: RelSubject, relation: string, object: RelObject, partition: ScopeRef, options?: RelationWriteOptions): Promise<void>;
1584
+ /** Retira la relación. No-op seguro si no existe (invariante 6). */
1585
+ unrelate(subject: RelSubject, relation: string, object: RelObject, partition: ScopeRef, options?: RelationWriteOptions): Promise<void>;
1586
+ /** ¿`subject` tiene `relation` sobre `object` en `partition` (directo o derivado por includes/userset)? */
1587
+ check(subject: RelSubject, relation: string, object: RelObject, partition: ScopeRef): Promise<boolean>;
1588
+ /** Los objetos de tipo `objectType` sobre los que `subject` tiene `relation`. Directos + derivados, sin herencia abierta. */
1589
+ listObjects(subject: RelSubject, relation: string, objectType: string, partition: ScopeRef, page?: RelationPage): Promise<RelationObjectsPage>;
1590
+ /** Los sujetos DIRECTOS de `relation` sobre `object` (holders y usersets). Nunca la membresía transitiva (eso es `membersOf`). */
1591
+ listSubjects(relation: string, object: RelObject, partition: ScopeRef, page?: RelationPage): Promise<RelationSubjectsPage>;
1592
+ /** Borra todas las tuplas cuyo OBJETO es `object` y demuestra cero, o lanza 500 `E_AUTHZ_PURGE_INCOMPLETE` (invariante 11). */
1593
+ purgeObject(object: RelObject, partition: ScopeRef, options?: RelationTransactionOptions): Promise<void>;
1594
+ /** Borra todas las tuplas cuyo SUJETO es `subject` y demuestra cero, o lanza 500. */
1595
+ purgeSubject(subject: RelSubject, partition: ScopeRef, options?: RelationTransactionOptions): Promise<void>;
1596
+ /**
1597
+ * La membresía TRANSITIVA de un objeto-grupo: todos los holders que son
1598
+ * `member` directa o a través de grupos anidados. DISTINTO de
1599
+ * `listSubjects(member, group)`, que devuelve solo los hechos DIRECTOS. Solo
1600
+ * lo trae el driver con `membersOfNative: true`.
1601
+ */
1602
+ membersOf?(object: RelObject, relation: string, partition: ScopeRef, page?: RelationPage): Promise<RelationSubjectsPage>;
1603
+ /** ORIGEN de `authz:reconcile` de relaciones: las tuplas paginadas, sin filtrar (la caducada LLEGA con su `expiresAt`). Solo con `enumerateRelations: true`. */
1604
+ enumerateRelations?(partition: ScopeRef, page?: RelationPage): Promise<RelationTuplePage>;
1605
+ /**
1606
+ * Vista de este driver con OTRO reloj de pared (R-15, paridad con
1607
+ * `AuthorizationDriver.withClock`, 2.5 · J1): mismo backend, solo cambia el
1608
+ * `now()` que decide la caducidad (`expires_at > now` en SQL; el
1609
+ * `current_time` del `Check` en FGA; el filtro en cliente de `listSubjects`).
1610
+ * Opcional: el `RelationsManager` lo aplica si recibe `clock` (500
1611
+ * `E_AUTHZ_CONFIG` si el driver no lo trae) y el juez lo usa con
1612
+ * `injectableClock: true`.
1613
+ */
1614
+ withClock?(now: () => Date): RelationsDriver;
1615
+ }
1616
+ /**
1617
+ * Factory de un `RelationsDriver` (Fase 4, lote 4-6) — el análogo de
1618
+ * `AuthorizationDriverFactory` para el puerto de relaciones. El consumidor la
1619
+ * declara en `config.relations.drivers`, y `authz:relations:reconcile` la
1620
+ * invoca para construir el ORIGEN y el DESTINO de una migración de tuplas. El
1621
+ * driver `openfga` de relaciones entra por el subpath `/openfga` DENTRO de la
1622
+ * factory (como el de roles), así que el comando nunca toca el SDK (pureza).
1623
+ */
1624
+ export type RelationsDriverFactory = () => RelationsDriver | Promise<RelationsDriver>;
655
1625
  //# sourceMappingURL=types.d.ts.map