@jantstack/adonis-authz 2.5.0-alpha.3 → 2.5.0-alpha.4

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 (277) hide show
  1. package/README.md +414 -35
  2. package/build/commands/authz_catalog_diff.d.ts +0 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +0 -1
  5. package/build/commands/authz_catalog_prune_orphans.js +0 -1
  6. package/build/commands/authz_catalog_sync.d.ts +0 -1
  7. package/build/commands/authz_catalog_sync.js +0 -1
  8. package/build/commands/authz_freeze.d.ts +0 -1
  9. package/build/commands/authz_freeze.js +0 -1
  10. package/build/commands/authz_reconcile.d.ts +0 -1
  11. package/build/commands/authz_reconcile.js +0 -1
  12. package/build/commands/authz_relations_reconcile.d.ts +0 -1
  13. package/build/commands/authz_relations_reconcile.js +56 -8
  14. package/build/commands/authz_scopes_relay.d.ts +0 -1
  15. package/build/commands/authz_scopes_relay.js +49 -5
  16. package/build/commands/authz_scopes_requeue.d.ts +0 -1
  17. package/build/commands/authz_scopes_requeue.js +14 -2
  18. package/build/commands/authz_unfreeze.d.ts +0 -1
  19. package/build/commands/authz_unfreeze.js +0 -1
  20. package/build/commands/main.d.ts +0 -1
  21. package/build/commands/main.js +0 -1
  22. package/build/commands/openfga_provision.d.ts +10 -1
  23. package/build/commands/openfga_provision.js +43 -3
  24. package/build/configure.d.ts +0 -1
  25. package/build/configure.js +0 -1
  26. package/build/index.d.ts +7 -5
  27. package/build/index.js +10 -3
  28. package/build/providers/authz_provider.d.ts +0 -1
  29. package/build/providers/authz_provider.js +0 -1
  30. package/build/services/main.d.ts +0 -1
  31. package/build/services/main.js +0 -1
  32. package/build/services/relations.d.ts +0 -1
  33. package/build/services/relations.js +0 -1
  34. package/build/src/catalog/catalog.d.ts +0 -1
  35. package/build/src/catalog/catalog.js +0 -1
  36. package/build/src/catalog/catalog_cache.d.ts +13 -1
  37. package/build/src/catalog/catalog_cache.js +25 -1
  38. package/build/src/clock.d.ts +0 -1
  39. package/build/src/clock.js +0 -1
  40. package/build/src/define_config.d.ts +0 -1
  41. package/build/src/define_config.js +24 -1
  42. package/build/src/drivers/database_driver.d.ts +6 -1
  43. package/build/src/drivers/database_driver.js +22 -2
  44. package/build/src/drivers/database_relations_driver.d.ts +11 -5
  45. package/build/src/drivers/database_relations_driver.js +90 -18
  46. package/build/src/drivers/openfga_driver.d.ts +159 -26
  47. package/build/src/drivers/openfga_driver.js +882 -199
  48. package/build/src/drivers/openfga_facts.d.ts +91 -1
  49. package/build/src/drivers/openfga_facts.js +277 -3
  50. package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
  51. package/build/src/drivers/openfga_relations_driver.js +422 -228
  52. package/build/src/errors.d.ts +121 -1
  53. package/build/src/errors.js +153 -1
  54. package/build/src/expiry.d.ts +51 -2
  55. package/build/src/expiry.js +96 -2
  56. package/build/src/freeze.d.ts +0 -1
  57. package/build/src/freeze.js +0 -1
  58. package/build/src/hierarchical_resolver.d.ts +0 -1
  59. package/build/src/hierarchical_resolver.js +0 -1
  60. package/build/src/http/app_access_middleware.d.ts +0 -1
  61. package/build/src/http/app_access_middleware.js +0 -1
  62. package/build/src/http/resource_access_middleware.d.ts +0 -1
  63. package/build/src/http/resource_access_middleware.js +0 -1
  64. package/build/src/identity.d.ts +2 -1
  65. package/build/src/identity.js +4 -1
  66. package/build/src/manager.d.ts +0 -1
  67. package/build/src/manager.js +156 -28
  68. package/build/src/memoize_ancestors.d.ts +0 -1
  69. package/build/src/memoize_ancestors.js +0 -1
  70. package/build/src/models/authz_assignment.d.ts +5 -6
  71. package/build/src/models/authz_assignment.js +0 -1
  72. package/build/src/models/authz_deny.d.ts +5 -6
  73. package/build/src/models/authz_deny.js +0 -1
  74. package/build/src/models/authz_permission.d.ts +5 -6
  75. package/build/src/models/authz_permission.js +0 -1
  76. package/build/src/models/authz_role.d.ts +5 -6
  77. package/build/src/models/authz_role.js +0 -1
  78. package/build/src/models/authz_role_permission.d.ts +5 -6
  79. package/build/src/models/authz_role_permission.js +0 -1
  80. package/build/src/openfga.d.ts +2 -3
  81. package/build/src/openfga.js +4 -2
  82. package/build/src/reconcile.d.ts +0 -1
  83. package/build/src/reconcile.js +0 -1
  84. package/build/src/relation_partition_trigger.d.ts +0 -1
  85. package/build/src/relation_partition_trigger.js +0 -1
  86. package/build/src/relations/check_many.d.ts +0 -1
  87. package/build/src/relations/check_many.js +0 -1
  88. package/build/src/relations/define_relations_config.d.ts +25 -3
  89. package/build/src/relations/define_relations_config.js +8 -3
  90. package/build/src/relations/deny_reach.d.ts +102 -0
  91. package/build/src/relations/deny_reach.js +230 -0
  92. package/build/src/relations/group_depth.d.ts +215 -0
  93. package/build/src/relations/group_depth.js +453 -0
  94. package/build/src/relations/manager.d.ts +14 -7
  95. package/build/src/relations/manager.js +60 -15
  96. package/build/src/relations/page.d.ts +19 -0
  97. package/build/src/relations/page.js +74 -0
  98. package/build/src/relations/reconcile.d.ts +58 -4
  99. package/build/src/relations/reconcile.js +266 -12
  100. package/build/src/relations/tuple_key.d.ts +0 -1
  101. package/build/src/relations/tuple_key.js +0 -1
  102. package/build/src/relations/tuple_order.d.ts +5 -4
  103. package/build/src/relations/tuple_order.js +5 -4
  104. package/build/src/relations_config_store.d.ts +57 -2
  105. package/build/src/relations_config_store.js +155 -5
  106. package/build/src/relations_reconcile.d.ts +0 -1
  107. package/build/src/relations_reconcile.js +0 -1
  108. package/build/src/scope_outbox.d.ts +0 -1
  109. package/build/src/scope_outbox.js +0 -1
  110. package/build/src/shared/backend_guard.d.ts +52 -1
  111. package/build/src/shared/backend_guard.js +164 -2
  112. package/build/src/shared/sql_expiry.d.ts +0 -1
  113. package/build/src/shared/sql_expiry.js +0 -1
  114. package/build/src/shared/transaction_guard.d.ts +0 -1
  115. package/build/src/shared/transaction_guard.js +0 -1
  116. package/build/src/sql_descendants.d.ts +0 -1
  117. package/build/src/sql_descendants.js +0 -1
  118. package/build/src/testing/contract.d.ts +7 -1
  119. package/build/src/testing/contract.js +77 -1
  120. package/build/src/testing/main.d.ts +0 -1
  121. package/build/src/testing/main.js +0 -1
  122. package/build/src/testing/migration_contract.d.ts +0 -1
  123. package/build/src/testing/migration_contract.js +0 -1
  124. package/build/src/testing/relations_contract.d.ts +7 -1
  125. package/build/src/testing/relations_contract.js +174 -7
  126. package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
  127. package/build/src/testing/relations_reconcile_contract.js +0 -1
  128. package/build/src/testing/scope_outbox_contract.d.ts +0 -1
  129. package/build/src/testing/scope_outbox_contract.js +0 -1
  130. package/build/src/testing/scope_tree.d.ts +0 -1
  131. package/build/src/testing/scope_tree.js +0 -1
  132. package/build/src/testing/test_clock.d.ts +0 -1
  133. package/build/src/testing/test_clock.js +0 -1
  134. package/build/src/traits/authz_scopes.d.ts +0 -1
  135. package/build/src/traits/authz_scopes.js +0 -1
  136. package/build/src/traits/has_uuid.d.ts +5 -6
  137. package/build/src/traits/has_uuid.js +0 -1
  138. package/build/src/types.d.ts +89 -14
  139. package/build/src/types.js +4 -1
  140. package/build/stubs/config/authorization.stub +22 -6
  141. package/build/stubs/main.d.ts +0 -1
  142. package/build/stubs/main.js +0 -1
  143. package/package.json +4 -3
  144. package/build/commands/authz_catalog_diff.d.ts.map +0 -1
  145. package/build/commands/authz_catalog_diff.js.map +0 -1
  146. package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
  147. package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
  148. package/build/commands/authz_catalog_sync.d.ts.map +0 -1
  149. package/build/commands/authz_catalog_sync.js.map +0 -1
  150. package/build/commands/authz_freeze.d.ts.map +0 -1
  151. package/build/commands/authz_freeze.js.map +0 -1
  152. package/build/commands/authz_reconcile.d.ts.map +0 -1
  153. package/build/commands/authz_reconcile.js.map +0 -1
  154. package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
  155. package/build/commands/authz_relations_reconcile.js.map +0 -1
  156. package/build/commands/authz_scopes_relay.d.ts.map +0 -1
  157. package/build/commands/authz_scopes_relay.js.map +0 -1
  158. package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
  159. package/build/commands/authz_scopes_requeue.js.map +0 -1
  160. package/build/commands/authz_unfreeze.d.ts.map +0 -1
  161. package/build/commands/authz_unfreeze.js.map +0 -1
  162. package/build/commands/main.d.ts.map +0 -1
  163. package/build/commands/main.js.map +0 -1
  164. package/build/commands/openfga_provision.d.ts.map +0 -1
  165. package/build/commands/openfga_provision.js.map +0 -1
  166. package/build/configure.d.ts.map +0 -1
  167. package/build/configure.js.map +0 -1
  168. package/build/index.d.ts.map +0 -1
  169. package/build/index.js.map +0 -1
  170. package/build/providers/authz_provider.d.ts.map +0 -1
  171. package/build/providers/authz_provider.js.map +0 -1
  172. package/build/services/main.d.ts.map +0 -1
  173. package/build/services/main.js.map +0 -1
  174. package/build/services/relations.d.ts.map +0 -1
  175. package/build/services/relations.js.map +0 -1
  176. package/build/src/catalog/catalog.d.ts.map +0 -1
  177. package/build/src/catalog/catalog.js.map +0 -1
  178. package/build/src/catalog/catalog_cache.d.ts.map +0 -1
  179. package/build/src/catalog/catalog_cache.js.map +0 -1
  180. package/build/src/clock.d.ts.map +0 -1
  181. package/build/src/clock.js.map +0 -1
  182. package/build/src/define_config.d.ts.map +0 -1
  183. package/build/src/define_config.js.map +0 -1
  184. package/build/src/drivers/database_driver.d.ts.map +0 -1
  185. package/build/src/drivers/database_driver.js.map +0 -1
  186. package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
  187. package/build/src/drivers/database_relations_driver.js.map +0 -1
  188. package/build/src/drivers/openfga_driver.d.ts.map +0 -1
  189. package/build/src/drivers/openfga_driver.js.map +0 -1
  190. package/build/src/drivers/openfga_facts.d.ts.map +0 -1
  191. package/build/src/drivers/openfga_facts.js.map +0 -1
  192. package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
  193. package/build/src/drivers/openfga_relations_driver.js.map +0 -1
  194. package/build/src/errors.d.ts.map +0 -1
  195. package/build/src/errors.js.map +0 -1
  196. package/build/src/expiry.d.ts.map +0 -1
  197. package/build/src/expiry.js.map +0 -1
  198. package/build/src/freeze.d.ts.map +0 -1
  199. package/build/src/freeze.js.map +0 -1
  200. package/build/src/hierarchical_resolver.d.ts.map +0 -1
  201. package/build/src/hierarchical_resolver.js.map +0 -1
  202. package/build/src/http/app_access_middleware.d.ts.map +0 -1
  203. package/build/src/http/app_access_middleware.js.map +0 -1
  204. package/build/src/http/resource_access_middleware.d.ts.map +0 -1
  205. package/build/src/http/resource_access_middleware.js.map +0 -1
  206. package/build/src/identity.d.ts.map +0 -1
  207. package/build/src/identity.js.map +0 -1
  208. package/build/src/manager.d.ts.map +0 -1
  209. package/build/src/manager.js.map +0 -1
  210. package/build/src/memoize_ancestors.d.ts.map +0 -1
  211. package/build/src/memoize_ancestors.js.map +0 -1
  212. package/build/src/models/authz_assignment.d.ts.map +0 -1
  213. package/build/src/models/authz_assignment.js.map +0 -1
  214. package/build/src/models/authz_deny.d.ts.map +0 -1
  215. package/build/src/models/authz_deny.js.map +0 -1
  216. package/build/src/models/authz_permission.d.ts.map +0 -1
  217. package/build/src/models/authz_permission.js.map +0 -1
  218. package/build/src/models/authz_role.d.ts.map +0 -1
  219. package/build/src/models/authz_role.js.map +0 -1
  220. package/build/src/models/authz_role_permission.d.ts.map +0 -1
  221. package/build/src/models/authz_role_permission.js.map +0 -1
  222. package/build/src/openfga.d.ts.map +0 -1
  223. package/build/src/openfga.js.map +0 -1
  224. package/build/src/reconcile.d.ts.map +0 -1
  225. package/build/src/reconcile.js.map +0 -1
  226. package/build/src/relation_partition_trigger.d.ts.map +0 -1
  227. package/build/src/relation_partition_trigger.js.map +0 -1
  228. package/build/src/relations/check_many.d.ts.map +0 -1
  229. package/build/src/relations/check_many.js.map +0 -1
  230. package/build/src/relations/define_relations_config.d.ts.map +0 -1
  231. package/build/src/relations/define_relations_config.js.map +0 -1
  232. package/build/src/relations/manager.d.ts.map +0 -1
  233. package/build/src/relations/manager.js.map +0 -1
  234. package/build/src/relations/reconcile.d.ts.map +0 -1
  235. package/build/src/relations/reconcile.js.map +0 -1
  236. package/build/src/relations/tuple_key.d.ts.map +0 -1
  237. package/build/src/relations/tuple_key.js.map +0 -1
  238. package/build/src/relations/tuple_order.d.ts.map +0 -1
  239. package/build/src/relations/tuple_order.js.map +0 -1
  240. package/build/src/relations_config_store.d.ts.map +0 -1
  241. package/build/src/relations_config_store.js.map +0 -1
  242. package/build/src/relations_reconcile.d.ts.map +0 -1
  243. package/build/src/relations_reconcile.js.map +0 -1
  244. package/build/src/scope_outbox.d.ts.map +0 -1
  245. package/build/src/scope_outbox.js.map +0 -1
  246. package/build/src/shared/backend_guard.d.ts.map +0 -1
  247. package/build/src/shared/backend_guard.js.map +0 -1
  248. package/build/src/shared/sql_expiry.d.ts.map +0 -1
  249. package/build/src/shared/sql_expiry.js.map +0 -1
  250. package/build/src/shared/transaction_guard.d.ts.map +0 -1
  251. package/build/src/shared/transaction_guard.js.map +0 -1
  252. package/build/src/sql_descendants.d.ts.map +0 -1
  253. package/build/src/sql_descendants.js.map +0 -1
  254. package/build/src/testing/contract.d.ts.map +0 -1
  255. package/build/src/testing/contract.js.map +0 -1
  256. package/build/src/testing/main.d.ts.map +0 -1
  257. package/build/src/testing/main.js.map +0 -1
  258. package/build/src/testing/migration_contract.d.ts.map +0 -1
  259. package/build/src/testing/migration_contract.js.map +0 -1
  260. package/build/src/testing/relations_contract.d.ts.map +0 -1
  261. package/build/src/testing/relations_contract.js.map +0 -1
  262. package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
  263. package/build/src/testing/relations_reconcile_contract.js.map +0 -1
  264. package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
  265. package/build/src/testing/scope_outbox_contract.js.map +0 -1
  266. package/build/src/testing/scope_tree.d.ts.map +0 -1
  267. package/build/src/testing/scope_tree.js.map +0 -1
  268. package/build/src/testing/test_clock.d.ts.map +0 -1
  269. package/build/src/testing/test_clock.js.map +0 -1
  270. package/build/src/traits/authz_scopes.d.ts.map +0 -1
  271. package/build/src/traits/authz_scopes.js.map +0 -1
  272. package/build/src/traits/has_uuid.d.ts.map +0 -1
  273. package/build/src/traits/has_uuid.js.map +0 -1
  274. package/build/src/types.d.ts.map +0 -1
  275. package/build/src/types.js.map +0 -1
  276. package/build/stubs/main.d.ts.map +0 -1
  277. package/build/stubs/main.js.map +0 -1
@@ -29,28 +29,32 @@
29
29
  * Pureza: vive detrás del subpath `@jantstack/adonis-authz/openfga`
30
30
  * (`src/openfga.ts`), como el driver `facts` de roles: es lo único que puede
31
31
  * tocar `@openfga/sdk` (peer opcional). La abstracción no filtra: ningún error
32
- * del SDK escapa (todo va por `#guard`, que clasifica a 503/timeout).
32
+ * del SDK escapa (todo va por `#guard`, que clasifica con `classifyFgaFailure`, R3).
33
33
  */
34
34
  import { OpenFgaClient, ConsistencyPreference, ClientWriteRequestOnMissingDeletes, ClientWriteRequestOnDuplicateWrites, } from '@openfga/sdk';
35
- import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, RenewalRacedError, RenewalUnsafeError, UnsupportedOperationError, markPartialWrite, } from '../errors.js';
35
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, RenewalRacedError, RenewalUnsafeError, ResolutionLimitError, UnsupportedOperationError, markDeploymentFailure, markPartialWrite, } from '../errors.js';
36
36
  import { assertPartition, assertSubject, assertRelationId, assertExpiresAt, partitionKey } from '../identity.js';
37
37
  import { isClock, systemClock } from '../clock.js';
38
- import { isActiveExpiry, resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
38
+ import { fgaTupleExpiry, isActiveExpiry, resolveGrantExpiry, sameInstant } from '../expiry.js';
39
39
  import { isRelUserset } from '../types.js';
40
40
  import { alignBatchResults } from './openfga_driver.js';
41
- import { FGA_MAX_BATCH_CHECK, canonicalRewrite, factsRelationRewrites } from './openfga_facts.js';
41
+ import { FGA_MAX_BATCH_CHECK, assertExperimentalOptIn, assertPinnedModel, factsModelDifferences, factsModelDriftError } from './openfga_facts.js';
42
42
  import { assertCheckManyObjects, distinctObjects, expandByPosition } from '../relations/check_many.js';
43
+ import { assertEnumeratePage, assertNoListPage } from '../relations/page.js';
43
44
  import { assertFgaObjectId, FACTS_EXPIRY_CONDITION, FACTS_GROUP_TYPE, } from './openfga_facts.js';
44
- import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, denyPhaseOf, deniedRelationOf, storedRelationKind, hasDenyTypes, relationsDown, RELATION_DENY_PREFIX, RELATION_USERSET_RELATION, RELATION_USERSET_TYPE, } from '../relations/define_relations_config.js';
45
+ import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, denyPhaseOf, deniedRelationOf, storedRelationKind, relationsDown, RELATION_DENY_PREFIX, RELATION_USERSET_RELATION, RELATION_USERSET_TYPE, } from '../relations/define_relations_config.js';
45
46
  import { relationDeleteOrder, relationOrderClass } from '../relations/tuple_order.js';
46
- import { isServerWriteLimit, isTimeoutLike, withDeadline } from '../shared/backend_guard.js';
47
+ import { GROUP_DEPTH_CALIBRATION_BACKOFF_MS, GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS, GroupDepthBudget, assertGroupDepthSettings, calibrateGroupDepth, groupNestingError, groupNestingVerdict, isGroupNesting, plannedByReconcile, } from '../relations/group_depth.js';
48
+ import { fgaFailureError, isServerWriteLimit, withDeadline } from '../shared/backend_guard.js';
47
49
  export const DEFAULT_TIMEOUT_MS = 5_000;
48
50
  /**
49
51
  * **Tope de operaciones por `Write` en FGA** — `--max-tuples-per-write` del servidor, default 100. MEDIDO en v1.19.0
50
52
  * (`openfga-dev`, lote D · cierre 1 de D1): 100 deletes en un `Write` entran; 101 —o 50 writes + 51 deletes— es 400
51
53
  * «The number of write operations exceeds the allowed limit of 100» y NO se aplica nada (el `Write` es transaccional).
52
- * Es también la frontera de la purga atómica (`#purgeByFilters`): una purga que cabe aquí es UN `Write`, sin estado
53
- * intermedio; con más membresías de grupo que esto, el residuo es una fila DECLARADA (README, «Deny on relations»).
54
+ * Es también la frontera de la purga atómica PARA EL ESCRITOR (`#purgeByFilters`): una purga que cabe aquí es UN
55
+ * `Write`, sin PREFIJO que cortar —un `Check` concurrente SÍ puede observar ese `Write` a medias: lector rasgado, fila
56
+ * «torn read» de Limits, decisión (6a) del dueño—; con más membresías de grupo que esto, el residuo es una fila DECLARADA
57
+ * (README, «Deny on relations»).
54
58
  * **Requisito del despliegue: `--max-tuples-per-write` ≥ 100** (README, «Limits»). Un servidor con el tope MÁS BAJO
55
59
  * responde 400 `exceeded_entity_limit` al primer `Write` que lo pase, y ése puede no ser el primero de la purga (con
56
60
  * tope 50 y `[3 allows][60 membresías][40 denies]` el `Write` de 3 ya se aplicó): el driver lo dice como **500
@@ -127,6 +131,42 @@ const IGNORE_DUPLICATE_WRITES = {
127
131
  const IGNORE_MISSING_DELETES = {
128
132
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
129
133
  };
134
+ /** La condición de una tupla leída, en la forma de `StoredRelationTuple` (el ÚNICO paso por `fgaTupleExpiry`). */
135
+ function storedCondition(condition) {
136
+ const expiry = fgaTupleExpiry(condition);
137
+ if (expiry.kind === 'expiry')
138
+ return { validUntil: expiry.at };
139
+ if (expiry.kind === 'none')
140
+ return { validUntil: null };
141
+ return { validUntil: null, invalid: `${expiry.kind}: ${expiry.detail}` };
142
+ }
143
+ /**
144
+ * **El error de una llamada DENTRO de la espera tras una calibración fallida** (cierre-3 de R1 · R-5): una instancia NUEVA
145
+ * por llamada, con `cause` = el error memorizado. Misma clase, `status`, `code` y `details` que el memorizado, salvo un
146
+ * TIMEOUT (`AuthorizationBackendTimeoutError`), que pasa a 503 `AuthorizationBackendError` no-timeout: lo que venció fue
147
+ * la sonda de OTRA llamada; esta no tocó la red y su escritura no ocurrió, así que el manager no debe notificar
148
+ * `indeterminate` (invariante 13).
149
+ */
150
+ function backoffError(memorized, remainingMs) {
151
+ const suffix = ` (la calibración del techo de anidación falló hace poco; en espera ${Math.max(0, Math.ceil(remainingMs))} ms más, sin sondear)`;
152
+ if (memorized instanceof AuthorizationBackendTimeoutError) {
153
+ const error = new AuthorizationBackendError('openfga-relations', 'groupDepthLimit', memorized);
154
+ error.message = `${memorized.message}${suffix}`;
155
+ return error;
156
+ }
157
+ if (!(memorized instanceof Error))
158
+ return memorized;
159
+ const copy = Object.create(Object.getPrototypeOf(memorized));
160
+ for (const key of Reflect.ownKeys(memorized)) {
161
+ if (key === 'partialWrite' || key === 'stack')
162
+ continue;
163
+ Object.defineProperty(copy, key, Object.getOwnPropertyDescriptor(memorized, key));
164
+ }
165
+ Object.defineProperty(copy, 'message', { value: `${memorized.message}${suffix}`, writable: true, configurable: true, enumerable: false });
166
+ Object.defineProperty(copy, 'cause', { value: memorized, writable: true, configurable: true, enumerable: false });
167
+ Error.captureStackTrace?.(copy, backoffError);
168
+ return copy;
169
+ }
130
170
  export class OpenFgaRelationsDriver {
131
171
  /**
132
172
  * Lo que declara (cada valor tiene su cara en `runRelationsDriverContract`):
@@ -183,6 +223,8 @@ export class OpenFgaRelationsDriver {
183
223
  // ajusta (usa `database` para lo que tenga que ir en su transacción, o
184
224
  // `requireTransactionalWrites` para fallar al arrancar).
185
225
  transactionalWrites: false,
226
+ // Estrategia 2.5.0 · firma 4 (regla D15: opcional, normalizada a `false`): EXPERIMENTAL en 2.5.x, como el de roles.
227
+ experimental: true,
186
228
  });
187
229
  client;
188
230
  #config;
@@ -197,13 +239,29 @@ export class OpenFgaRelationsDriver {
197
239
  #monotonic;
198
240
  #logger;
199
241
  /**
200
- * **Las relaciones `denied_*` que declara el modelo que sirve este driver** (lote D · D-31): se lee UNA vez por
201
- * proceso (`readAuthorizationModel` del `modelId` fijado —D14 lo exige con un tipo con deny—) y se comparte con
202
- * las vistas `withClock`. Un fallo al leerla no se memoriza (503 y se reintenta en la siguiente).
242
+ * **La comparación modelo ↔ config de L11** (`#modelGate`): se lee UNA vez por proceso (`readAuthorizationModel` del
243
+ * `modelId` fijado, obligatorio) y se comparte con las vistas `withClock` (objeto que se muta). Un fallo transitorio
244
+ * al leerla no se memoriza (503 y se reintenta en la siguiente).
203
245
  */
204
246
  #modelMemo = {};
247
+ /**
248
+ * **La calibración del techo de anidación de grupos** (R1, decisión 5 + 5-bis): UNA por proceso, compartida con las
249
+ * vistas `withClock` (objeto que se muta, como `#modelMemo`). Se memoriza SOLO el éxito: un fallo (503, deadline, el
250
+ * 500 de una sonda ciega) no se queda — la siguiente operación vuelve a calibrar (C13a), pero no antes de
251
+ * `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` (`failure`: hasta entonces se relanza ese mismo error sin sondear; cierre-2 de
252
+ * R1 · D5). Y un 503 `E_AUTHZ_RESOLUTION_LIMIT` de una lectura invalida el éxito memorizado, como mucho una vez cada
253
+ * `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS` (`invalidatedAt`; P2 del juez): el límite BAJADO en el servidor se ve
254
+ * sin reiniciar la app.
255
+ */
256
+ #groupDepthMemo = {};
205
257
  diagnostics = { unparseableTuples: 0 };
206
258
  constructor(config, options) {
259
+ // Estrategia 2.5.0 · firma 4: `openfga` es EXPERIMENTAL en 2.5.x. PRIMERA línea: sin el token, 500 sin crear el cliente
260
+ // (la vista `withClock` reconstruye con las MISMAS opciones, así que hereda el token y vuelve a pasar por aquí).
261
+ assertExperimentalOptIn('OpenFgaRelationsDriver', options?.experimental);
262
+ // Cierre de R1 · 🟡 4 del auditor: el driver RE-VALIDA el techo de la config (una config extendida con spread no pasó
263
+ // por `defineRelationsConfig`; `NaN` apagaba el gate en silencio). 500 al construir.
264
+ assertGroupDepthSettings(config.maxGroupDepth, config.enforceGroupDepth, 'OpenFgaRelationsDriver');
207
265
  this.#config = config;
208
266
  this.#options = options;
209
267
  this.#now = options.now ?? systemClock;
@@ -256,18 +314,10 @@ export class OpenFgaRelationsDriver {
256
314
  }
257
315
  this.#monotonic = monotonic ?? (() => performance.now());
258
316
  this.#logger = options.logger ?? console;
259
- // **D14** (decisión del dueño 2026-10-14; auditor R2-2): con algún tipo con opt-in del deny (en cualquier fase), el
260
- // `modelId` FIJADO es obligatorio. Sin él cada `Check` evalúa contra el ÚLTIMO modelo del store, y un
261
- // `openfga:provision` desde un proceso viejo (sin `denied_*`) haría que este pod concediera sobre objetos denegados
262
- // sin enterarse; con él, la guarda D-31 lee ESE modelo una vez y es 500 si no declara el deny.
263
- if (hasDenyTypes(config) && !options.modelId) {
264
- throw new AuthorizationConfigError(`OpenFgaRelationsDriver: la config de relaciones declara deny en ${config.objectTypes
265
- .filter((t) => t.deny !== undefined)
266
- .map((t) => `'${t.type}' (${t.deny})`)
267
- .join(', ')} y el driver no tiene modelId fijado (D14). Con deny activo cada pod tiene que evaluar contra un modelo ` +
268
- `CONOCIDO que declare denied_<rel>: publica el modelo (openfga:provision), fija OPENFGA_MODEL_ID en TODA la flota ` +
269
- `y despliega. Sin el pin, un modelo republicado sin denied_* haría conceder sobre objetos denegados.`);
270
- }
317
+ // **L11 · `modelId` OBLIGATORIO, siempre** (decisión del dueño 2026-10-17 (2); sustituye a D14 —«solo con deny»— y a
318
+ // `#assertPinnedForDecideMany`, P5/P6): sin el pin, la puerta L11 compara el modelo del arranque y el servidor
319
+ // evalúa con el último de AHORA, así que la igualdad no protegería nada.
320
+ assertPinnedModel('OpenFgaRelationsDriver', options.modelId);
271
321
  this.client = new OpenFgaClient({
272
322
  apiUrl: options.apiUrl,
273
323
  storeId: options.storeId,
@@ -289,8 +339,9 @@ export class OpenFgaRelationsDriver {
289
339
  }
290
340
  const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
291
341
  view.client = this.client;
292
- // La guarda D-31 es del PROCESO: la vista comparte la lectura del modelo.
342
+ // La puerta L11 es del PROCESO: la vista comparte la lectura del modelo (el MISMO objeto, que se muta).
293
343
  view.#modelMemo = this.#modelMemo;
344
+ view.#groupDepthMemo = this.#groupDepthMemo;
294
345
  return view;
295
346
  }
296
347
  /** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
@@ -309,21 +360,28 @@ export class OpenFgaRelationsDriver {
309
360
  * que dice «venció el deadline» —503 `E_AUTHZ_BACKEND_TIMEOUT`, indeterminado—,
310
361
  * y para lo que el cliente lanza por su cuenta el `isTimeoutLike` ESTRICTO
311
362
  * de `backend_guard.ts` (`ECONNABORTED`/`ETIMEDOUT`/`.timeout` numérico/
312
- * `KnexTimeoutError`, por la cadena de `cause`). Hasta el cierre-2 este
363
+ * `KnexTimeoutError` y, desde R3, el `FgaError` sin respuesta, por la cadena de `cause`); el RESTO lo clasifica
364
+ * `classifyFgaFailure` (R3: transitorio 503, `E_AUTHZ_RESOLUTION_LIMIT` 503, despliegue 500, carrera 409, 4xx
365
+ * desconocido 500). Hasta el cierre-2 este
313
366
  * driver duplicaba la función con una rama por SUBSTRING del mensaje
314
367
  * (`/timeout/i`): un error que decía «no es timeout» salía como timeout con
315
368
  * `indeterminate: true` sin haber borrado nada, y el docblock afirmaba en
316
369
  * falso «mismo criterio que roles».
317
370
  */
318
- async #guard(operation, fn) {
371
+ async #guard(operation, fn, write = false) {
319
372
  const fail = (error) => {
320
373
  // Un error del paquete (422/500 ya clasificado) se propaga tal cual.
321
374
  if (error instanceof InvalidIdentityError || error instanceof PurgeIncompleteError)
322
375
  return error;
323
- if (isTimeoutLike(error)) {
324
- return new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs, error);
325
- }
326
- return new AuthorizationBackendError('openfga-relations', operation, error);
376
+ // R3 (decisión del dueño 2026-10-17 (3)): la clase la decide `classifyFgaFailure`, el MISMO punto de paso que el
377
+ // driver de roles (por `statusCode` + `apiErrorCode`, jamás por el mensaje).
378
+ // Cierre de R3 (🟡 R3-A2): `write` ⇒ el 504 / `deadline_exceeded` del `Write` es indeterminado (timeout).
379
+ const failure = fgaFailureError('openfga-relations', operation, error, { timeoutMs: this.#timeoutMs, write });
380
+ // Cierre-2 de R1 · D5 (P2 del juez): un 503 de profundidad FUERA de la calibración dice que el servidor ya no
381
+ // resuelve lo que el techo memorizado prometía (límite bajado sin reiniciar la app) ⇒ re-calibrar, acotado.
382
+ if (failure instanceof ResolutionLimitError && operation !== 'groupDepthLimit')
383
+ this.#invalidateGroupDepth();
384
+ return failure;
327
385
  };
328
386
  let pending;
329
387
  try {
@@ -336,145 +394,68 @@ export class OpenFgaRelationsDriver {
336
394
  throw fail(error);
337
395
  }), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
338
396
  }
339
- /* ── D-31: el modelo FIJADO tiene que declarar el deny de los tipos con opt-in ─────────────────────────── */
340
- /** Las relaciones `<tipo>#denied_<rel>` que declara el modelo que sirve este driver (memo por proceso). */
341
- #modelDenied() {
342
- this.#readModel();
343
- return this.#modelMemo.denied;
344
- }
397
+ /* ── L11: el modelo FIJADO es el que genera la config (una puerta, por tipo) ─────────────────────────── */
345
398
  /**
346
- * **R2 del auditor del panel «óptimo» (cierre 5 de D2): la DERIVA modelo ↔ config en TODOS los tipos**, no solo en los
347
- * que tienen deny: por tipo declarado en la config (y `group`), las relaciones cuya regla en el modelo FIJADO no es la
348
- * que genera el paquete (`factsRelationRewrites`, por igualdad tras `canonicalRewrite`) o que el modelo no trae. La
349
- * MISMA lectura del modelo (memo por proceso) que D-31.
399
+ * **La lectura del modelo, UNA por proceso** (L11; memo compartido con las vistas `withClock` — es un OBJETO que se
400
+ * MUTA, nunca se reasigna en la vista, H11 b): `readAuthorizationModel` del `modelId` FIJADO (obligatorio desde L11) y
401
+ * `factsModelDifferences` contra la config de este proceso, con el MISMO mapa de holders que usa `openfga:provision`
402
+ * (H11 a). Se memoriza el ÉXITO de la lectura —también si hay diferencias: el modelo fijado no cambia (C13 b)— y un
403
+ * generador que no puede generar (500, H11 d); **un fallo al LEER el modelo NO se memoriza, sea de la clase que sea**
404
+ * (C13 a; cierre de L11): la siguiente operación vuelve a leer.
350
405
  */
351
- #modelDrift() {
352
- this.#readModel();
353
- return this.#modelMemo.drift;
354
- }
355
- /** UNA lectura del modelo por proceso (memo compartido con las vistas `withClock`); un fallo no se memoriza. */
356
- #readModel() {
357
- if (this.#modelMemo.denied)
358
- return;
406
+ #modelGate() {
359
407
  const memo = this.#modelMemo;
360
- const served = this.#guard('readAuthorizationModel', async () => {
361
- const response = this.#options.modelId
362
- ? await this.client.readAuthorizationModel()
363
- : await this.client.readLatestAuthorizationModel();
364
- const definitions = response?.authorization_model?.type_definitions;
365
- if (!Array.isArray(definitions)) {
366
- throw new Error('la respuesta de ReadAuthorizationModel no trae type_definitions; no se toma por «sin deny»');
367
- }
368
- // R2 (cierre 5 de D2): la deriva de TODOS los tipos de la config (y `group`) contra lo que genera el paquete.
369
- // En un tipo SIN opt-in también vale la forma CON deny (`<rel> = base but not denied_<rel>`): es D8 —el opt-in
370
- // retirado con el modelo aún publicado—, más restrictivo, nunca una concesión por una regla vieja.
371
- const drift = new Map();
372
- const expectedTypes = [
373
- { type: RELATION_USERSET_TYPE, relations: { member: { this: {} } } },
374
- ...this.#config.objectTypes.map((spec) => ({
375
- type: spec.type,
376
- relations: factsRelationRewrites(spec),
377
- alternative: spec.deny === undefined ? factsRelationRewrites({ ...spec, deny: 'read' }) : undefined,
378
- })),
379
- ];
380
- for (const { type, relations: expectedRelations, alternative } of expectedTypes) {
381
- const servedRelations = definitions.find((definition) => definition?.type === type)?.relations ?? {};
382
- // Cierre 6 de D2 · 🟡 D5-2: la excepción D8 vale solo si el modelo trae la forma CON deny ENTERA para esa
383
- // relación: `<rel>` Y `denied_<rel>` (el cierre del corte de camino). Con `denied_editor = this` (sin `or
384
- // denied_viewer`) un modelo a mano concedía `editor` a quien tiene deny en `viewer`, donde `database` niega.
385
- const denyFormMatches = (name) => {
386
- if (!alternative)
387
- return false;
388
- const denied = deniedRelationOf(name);
389
- return sameRewrite(servedRelations[name], alternative[name]) && sameRewrite(servedRelations[denied], alternative[denied]);
390
- };
391
- const differing = Object.keys(expectedRelations).filter((name) => !sameRewrite(servedRelations[name], expectedRelations[name]) && !denyFormMatches(name));
392
- if (differing.length > 0)
393
- drift.set(type, differing);
394
- }
395
- // Cierre 1 de D1 · ⚪ 7: no basta con que EXISTA `denied_<rel>`: `<rel>` tiene que RESTARLO.
396
- // Cierre 2 de D1 · ⚪ N4: tampoco basta con la FORMA. En un tipo CON opt-in, `<rel>` y `denied_<rel>` tienen que
397
- // ser EXACTAMENTE las reglas que genera el paquete para ese tipo (`factsRelationRewrites`, la fuente única del
398
- // generador), por igualdad estructural tras `canonicalRewrite`: un modelo a mano con `denied_editor: [user]` (sin
399
- // `or denied_viewer`, el cierre del corte de camino) restaba bien y CONCEDÍA `editor` a un owner con deny en
400
- // `viewer`, donde `database` niega. En un tipo SIN opt-in (D8: opt-in retirado con el modelo aún publicado) no hay
401
- // regla del paquete con la que comparar: cuenta la forma de ⚪ 7 (`difference` con `subtract` = `denied_<rel>`),
402
- // que es lo que `decideMany` necesita para saber qué deny preguntar.
403
- const out = new Set();
404
- for (const definition of definitions) {
405
- const relations = definition?.relations ?? {};
406
- const spec = this.#config.objectTypes.find((t) => t.type === definition?.type);
407
- const expected = spec && spec.deny !== undefined ? factsRelationRewrites(spec) : undefined;
408
- for (const name of Object.keys(relations)) {
409
- if (!name.startsWith(RELATION_DENY_PREFIX))
410
- continue;
411
- const relation = name.slice(RELATION_DENY_PREFIX.length);
412
- const base = relations[relation];
413
- const ok = expected
414
- ? sameRewrite(base, expected[relation]) && sameRewrite(relations[name], expected[name])
415
- : base?.difference?.subtract?.computedUserset?.relation === name;
416
- if (ok)
417
- out.add(`${definition.type}#${name}`);
418
- }
419
- }
420
- return { denied: out, drift };
421
- }).catch((error) => {
422
- // Un fallo (503) NO se memoriza: la siguiente lo vuelve a intentar.
423
- if (memo.denied === denied) {
424
- memo.denied = undefined;
425
- memo.drift = undefined;
408
+ if (memo.gate)
409
+ return memo.gate;
410
+ // Cierre de L11 (🟡-6 del tester): «comparación hecha ⇒ memo» y «fallo al leer el modelo ⇒ no memo», por la FASE y no
411
+ // por la clase del error — con R3 un 401/403 o un modelo inexistente serán 500 de despliegue y no pueden quedarse
412
+ // memorizados de por vida en el proceso.
413
+ let compared = false;
414
+ const gate = this.#guard('readAuthorizationModel', async () => {
415
+ const response = await this.client.readAuthorizationModel();
416
+ const served = response?.authorization_model;
417
+ if (!Array.isArray(served?.type_definitions)) {
418
+ throw new Error('la respuesta de ReadAuthorizationModel no trae type_definitions; no se toma por un modelo igual');
426
419
  }
420
+ return served;
421
+ }).then((served) => {
422
+ compared = true;
423
+ return factsModelDifferences(served, { holderTypes: this.#holderTypes, relations: this.#config, slice: 'relations' });
424
+ });
425
+ const pending = gate.catch((error) => {
426
+ if (!compared && memo.gate === pending)
427
+ memo.gate = undefined;
427
428
  throw error;
428
429
  });
429
- const denied = served.then((x) => x.denied);
430
- memo.denied = denied;
431
- memo.drift = served.then((x) => x.drift);
432
- // Las dos promesas derivan de UNA: si nadie espera una de ellas, su rechazo no puede quedar sin capturar.
433
- memo.denied.catch(() => { });
434
- memo.drift.catch(() => { });
430
+ memo.gate = pending;
431
+ pending.catch(() => { });
432
+ return pending;
435
433
  }
436
434
  /**
437
- * **D-31 · un tipo con opt-in y un modelo FIJADO sin `denied_<rel>` ⇒ 500 al USARLO** (juez ronda 2 §2, H3 b):
438
- * un pod con la config nueva y el pin a un modelo viejo (o republicado por un proceso viejo) concedería sobre un
439
- * objeto denegado —la resta la hace el servidor con el modelo que el pod fija—. Antes de la primera request de
440
- * cualquier operación sobre esos tipos se comprueba (una lectura del modelo por proceso) y, si falta, 500
441
- * `E_AUTHZ_CONFIG` nombrando las relaciones: ruidoso en vez de conceder. Tipos sin opt-in: sin coste.
435
+ * **La puerta L11** (lote «antes de RC», decisión del dueño 2026-10-17 (1); sustituye a P1–P4 —D-31, R2, la excepción
436
+ * D8 por relación y `#modelDenied`— y a P6 —`#assertPinnedForDecideMany`—): antes de la PRIMERA llamada al servidor
437
+ * de la operación, el modelo que sirve el driver tiene que ser IGUAL al que genera la config en los tipos que la
438
+ * operación toca, más `group` (un `group#member` distinto cambia quién es miembro y, con ello, qué concede un `check`
439
+ * de cualquier tipo), más los holders y las CONDICIONES (deciden la vigencia de toda tupla). Si no, 500
440
+ * `E_AUTHZ_CONFIG` nombrando las diferencias por ruta y CERO llamadas que evalúen o escriban.
441
+ *
442
+ * Sin exenciones (injerto 4 del juez): las purgas y los `list*` pasan la puerta ENTERA —el orden de la purga (L10) se
443
+ * demostró sobre la semántica de la CONFIG y la vigencia la decide la condición del modelo—; `enumerateRelations(Many)`,
444
+ * que es el ORIGEN de una migración y tiene que poder rescatar un store con deriva, pasa SOLO la parte `conditions`
445
+ * (`part: 'conditions'`). El orden en cada operación: la puerta de `{ transaction }`, F-05 y la gramática (422, cero
446
+ * llamadas) van ANTES; la puerta va justo antes de la primera llamada (H1-5).
442
447
  */
443
- async #assertModelDeny(types) {
444
- const wanted = [];
445
- const distinct = [...new Set(types)];
446
- for (const type of distinct) {
447
- if (denyPhaseOf(this.#config, type) === undefined)
448
- continue;
449
- const spec = this.#config.objectTypes.find((t) => t.type === type);
450
- for (const relation of spec?.relations ?? [])
451
- wanted.push(`${type}#${deniedRelationOf(relation.name)}`);
452
- }
453
- if (wanted.length > 0) {
454
- const declared = await this.#modelDenied();
455
- const missing = wanted.filter((name) => !declared.has(name));
456
- if (missing.length > 0) {
457
- throw new AuthorizationConfigError(`OpenFgaRelationsDriver: la config declara deny y el modelo que sirve este driver (modelId ` +
458
- `${this.#options.modelId ?? '(último)'}) NO declara ${missing.join(', ')} —o la declara pero <rel> o denied_<rel> no son las reglas que genera el paquete (but not y el cierre del corte de camino)— (D-31). Con ese modelo el servidor no ` +
459
- `resta el deny: un pod así concedería sobre objetos denegados. Republica el modelo desde la config actual ` +
460
- `(openfga:provision) y fija el modelId nuevo en TODA la flota antes de desplegar.`);
461
- }
462
- }
463
- // R2 del auditor del panel «óptimo» (cierre 5 de D2): la MISMA comparación en los tipos SIN deny. Un pod con una config
464
- // que ESTRECHA una regla (`viewer` deja de incluir `editor`) y el pin a un modelo viejo concedía por la regla vieja,
465
- // en silencio, donde `database` con la misma config niega. Misma lectura del modelo, ya memorizada.
466
- // Cierre 6 de D2 · ⚪ (D5-2): `group` se compara en TODA operación, no solo en las suyas — un `group#member` del
467
- // modelo que no es `this` cambia quién es miembro y, con ello, qué concede un `check` de `document`.
468
- const drift = await this.#modelDrift();
469
- const compared = [...new Set([...distinct, RELATION_USERSET_TYPE])];
470
- const drifted = compared.flatMap((type) => (drift.get(type) ?? []).map((relation) => `${type}#${relation}`));
471
- if (drifted.length > 0) {
472
- throw new AuthorizationConfigError(`OpenFgaRelationsDriver: el modelo que sirve este driver (modelId ${this.#options.modelId ?? '(último)'}) declara ` +
473
- `${drifted.join(', ')} con una regla DISTINTA de la que genera la config de este proceso (includes / deny), o no la ` +
474
- `declara (D-31 en todos los tipos, R2). Con ese modelo el servidor decide por la regla del modelo: un pod así ` +
475
- `concedería lo que la config —y database con la misma config— niega. Republica el modelo desde la config actual ` +
476
- `(openfga:provision) y fija el modelId nuevo en TODA la flota antes de desplegar.`);
477
- }
448
+ async #assertModel(operation, types, part = 'full') {
449
+ const comparison = await this.#modelGate();
450
+ const touched = [...types];
451
+ // Una operación sobre `group` (una membresía) cambia lo que concede o niega CUALQUIER tipo —y su renovación lee los
452
+ // `denied_*` de los tipos con deny (`#carriedDeny`)—: compara todos los tipos de la config.
453
+ const groupWide = touched.includes(RELATION_USERSET_TYPE) ? this.#config.objectTypes.map((t) => t.type) : [];
454
+ const relevant = new Set([...touched, ...groupWide, RELATION_USERSET_TYPE, ...Object.values(this.#holderTypes)]);
455
+ const hits = comparison.differences.filter((d) => part === 'conditions' ? d.type === null && d.path.startsWith('conditions.') : d.type === null || relevant.has(d.type));
456
+ if (hits.length > 0)
457
+ throw factsModelDriftError('OpenFgaRelationsDriver', operation, this.#options.modelId, hits);
458
+ return comparison;
478
459
  }
479
460
  /* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
480
461
  /** `<type>:<scopeKey(partition)>|<objectUuid>`. */
@@ -599,12 +580,31 @@ export class OpenFgaRelationsDriver {
599
580
  const user = this.#subjectUser(subject, partitionKey);
600
581
  const key = { user, relation, object: objectId };
601
582
  const now = this.#now();
602
- await this.#assertModelDeny([object.type]);
583
+ await this.#assertModel('relate', [object.type]);
603
584
  const current = await this.#readTuple('relate', key);
585
+ // L11 · H8: la clave YA existe con una condición que el paquete no escribe (`foreign`) o no sabe leer
586
+ // (`unparseable`). Tratarla como «sin caducidad» haría de este `relate` un no-op mudo (y la tupla inerte para el
587
+ // servidor, M-a); como «vencida», una renovación en dos `Write` sin mirar portadoras. 409, cero escrituras, receta.
588
+ if (current?.invalid !== undefined) {
589
+ throw RenewalUnsafeError.foreignCondition('relate', `${objectId}#${relation}@${user}`, current.invalid);
590
+ }
604
591
  const previous = current ? current.validUntil : null;
605
592
  const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
606
593
  if (current && sameInstant(previous, expiresAt))
607
594
  return;
595
+ // R1 (decisión del dueño 2026-10-17 (5)): una arista de anidación que queda VIVA —nueva, renovada o revivida— pasa el
596
+ // gate del techo (calibrado, o el `maxGroupDepth` menor de la config) ANTES de cualquier `Write`: 422 y cero escrituras.
597
+ // El no-op idéntico de ARRIBA va ANTES (invariante 6: un `relate` repetido de una arista preexistente no se vuelve 422).
598
+ if (isGroupNesting(subject, relation, object) &&
599
+ isActiveExpiry(expiresAt, now) &&
600
+ !plannedByReconcile(options, partition, { child: subject.object.id, parent: object.id })) {
601
+ const limit = await this.groupDepthLimit();
602
+ // El presupuesto (y su deadline TOTAL) empieza DESPUÉS de la calibración: el primer `relate` de un proceso en frío
603
+ // paga la calibración aparte y no puede dar un 422 espurio por ella (cierre de R1, panel).
604
+ const budget = new GroupDepthBudget();
605
+ if (limit !== null)
606
+ await this.#assertGroupNesting(partitionKey, { child: subject.object.id, parent: object.id }, now, limit, budget);
607
+ }
608
608
  const tuple = expiresAt
609
609
  ? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
610
610
  : key;
@@ -620,11 +620,11 @@ export class OpenFgaRelationsDriver {
620
620
  if (carried !== null)
621
621
  throw RenewalUnsafeError.carrier(membership, carried);
622
622
  }
623
- await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES));
623
+ await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES), true);
624
624
  // Cierre 2 de D2 · 🟠 N1 (b): el delete YA entró — si el write falla, la escritura es PARCIAL (invariante 13: el
625
625
  // manager notifica `indeterminate: true`); hasta el cierre era un 503 «sin escribir» con la tupla borrada.
626
626
  try {
627
- await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
627
+ await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES), true);
628
628
  }
629
629
  catch (error) {
630
630
  // Cierre 3 de D2 · 🟡 C2-3: la membresía quedó BORRADA. Si entre la comprobación y el delete entró un deny en el
@@ -652,7 +652,181 @@ export class OpenFgaRelationsDriver {
652
652
  }
653
653
  return;
654
654
  }
655
- await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
655
+ await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES), true);
656
+ }
657
+ /**
658
+ * **El techo de anidación de grupos de ESTE driver** (lote «antes de RC» · R1, decisiones del dueño 2026-10-17 (5) y
659
+ * (5-bis), opción (a)): ACTIVO por defecto. Se CALIBRA contra el servidor (`#calibratedGroupDepth`) y es el mínimo de
660
+ * la sonda positiva y la negativa (22 en OpenFGA v1.19 con `--resolve-node-limit` por defecto, medido en F0 · M-c); el
661
+ * `maxGroupDepth` de la config solo puede BAJARLO — uno mayor que el calibrado es 500 `E_AUTHZ_CONFIG` con las dos
662
+ * cifras (el despliegue promete un techo que el servidor no resuelve) —, y `false` lo apaga de forma explícita (sin
663
+ * calibrar). Solo lectura: lo usa `relate` antes de escribir una arista `group#member` y el plan de
664
+ * `reconcileRelations` sobre el destino.
665
+ */
666
+ async groupDepthLimit() {
667
+ const configured = assertGroupDepthSettings(this.#config.maxGroupDepth, this.#config.enforceGroupDepth, 'OpenFgaRelationsDriver').maxGroupDepth;
668
+ if (configured === false)
669
+ return null;
670
+ const calibrated = await this.#calibratedGroupDepth();
671
+ if (configured !== undefined && configured > calibrated) {
672
+ throw Object.assign(new AuthorizationConfigError(`OpenFgaRelationsDriver: maxGroupDepth = ${configured} en la config de relaciones y el servidor solo resuelve ` +
673
+ `${calibrated} saltos de grupos anidados (calibrado con tuplas contextuales: el mínimo de un miembro y de un ` +
674
+ 'no-miembro con membresía). Un techo por encima del calibrado admitiría cadenas que darán 503 ' +
675
+ `E_AUTHZ_RESOLUTION_LIMIT a los no-miembros. Baja maxGroupDepth a ≤ ${calibrated}, o sube ` +
676
+ 'OPENFGA_RESOLVE_NODE_LIMIT (--resolve-node-limit) en el servidor y reinicia la app: SUBIR el límite no lo ' +
677
+ 'detecta nada (la calibración se memoriza por proceso); BAJARLO sí, sin reinicio — el 503 de profundidad de ' +
678
+ 'una lectura la re-calibra, como mucho una vez por minuto.'), { details: { maxGroupDepth: configured, calibrated } });
679
+ }
680
+ return configured ?? calibrated;
681
+ }
682
+ /**
683
+ * La calibración memorizada (solo el éxito; una en vuelo se comparte). Tras un fallo, durante
684
+ * `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` se relanza la MISMA CLASE de error sin sondear (cierre-2 de R1 · D5: el backoff);
685
+ * después se vuelve a calibrar. Cada llamada de la ventana recibe un error NUEVO con `cause` = el memorizado (cierre-3 ·
686
+ * R-5: compartir la instancia compartía cualquier anotación de un llamador), y un TIMEOUT de la calibración se relanza
687
+ * como 503 no-timeout: la escritura de la ventana no tocó la red, así que no es indeterminada (invariante 13).
688
+ */
689
+ #calibratedGroupDepth() {
690
+ const memo = this.#groupDepthMemo;
691
+ if (memo.calibrated)
692
+ return memo.calibrated;
693
+ if (memo.failure && this.#monotonic() - memo.failure.at < GROUP_DEPTH_CALIBRATION_BACKOFF_MS) {
694
+ return Promise.reject(backoffError(memo.failure.error, GROUP_DEPTH_CALIBRATION_BACKOFF_MS - (this.#monotonic() - memo.failure.at)));
695
+ }
696
+ const pending = this.#calibrate().then((calibrated) => {
697
+ if (memo.calibrated === pending)
698
+ memo.failure = undefined;
699
+ return calibrated;
700
+ }, (error) => {
701
+ if (memo.calibrated === pending) {
702
+ memo.calibrated = undefined;
703
+ memo.failure = { at: this.#monotonic(), error };
704
+ }
705
+ throw error;
706
+ });
707
+ memo.calibrated = pending;
708
+ pending.catch(() => { });
709
+ return pending;
710
+ }
711
+ /**
712
+ * **Re-calibración tras un 503 de profundidad** (cierre-2 de R1 · D5 (a), P2 del juez): olvida el techo memorizado para
713
+ * que la siguiente escritura de anidación lo vuelva a medir, como mucho UNA vez cada
714
+ * `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS` por proceso (reloj monótono). Es lo que hace honesta (5-bis) —«nadie
715
+ * recibe 503 dentro del límite»— cuando el operador BAJA el límite del servidor sin reiniciar; subirlo no da 503 y
716
+ * sigue pidiendo reinicio. Sin techo memorizado (nunca calibró, o falló) no hay nada que olvidar.
717
+ */
718
+ #invalidateGroupDepth() {
719
+ const memo = this.#groupDepthMemo;
720
+ if (memo.calibrated === undefined)
721
+ return;
722
+ const now = this.#monotonic();
723
+ if (memo.invalidatedAt !== undefined && now - memo.invalidatedAt < GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS)
724
+ return;
725
+ memo.calibrated = undefined;
726
+ memo.invalidatedAt = now;
727
+ }
728
+ /**
729
+ * **La calibración** (decisión 5: «con tuplas contextuales, solo lectura, forma de dominio»; 5-bis: el MÍNIMO de las
730
+ * dos sondas): un `Check` con TODO el grafo en `contextual_tuples` sobre ids que no existen en el store —
731
+ * `<holder>:authz-calibration-member ∈ g0 ⊂ … ⊂ gK` y la relación de DOMINIO del primer tipo declarado sobre
732
+ * `gK#member` (positiva: T), y `<holder>:authz-calibration-outsider ∈ h0 ⊂ h1` (negativa CON membresía: F; sin
733
+ * membresías la sonda es CIEGA, M-c) —, siempre con `HIGHER_CONSISTENCY`. Pasa antes la puerta L11 (el modelo fijado es
734
+ * el de la config). Sin ningún tipo con relación (que acepte `group#member`) calibra con `group#member` y resta 1
735
+ * (T-4: la forma `member` resuelve un salto más que la de dominio).
736
+ */
737
+ async #calibrate() {
738
+ const domainType = this.#config.objectTypes.find((t) => t.relations.length > 0);
739
+ const domain = domainType ? { type: domainType.type, relation: domainType.relations[0].name } : null;
740
+ await this.#assertModel('groupDepthLimit', [domain?.type ?? RELATION_USERSET_TYPE]);
741
+ const holder = Object.values(this.#holderTypes)[0];
742
+ if (!holder) {
743
+ throw new AuthorizationConfigError('OpenFgaRelationsDriver: sin holderTypes no se puede calibrar el techo de anidación de grupos (la sonda necesita un holder).');
744
+ }
745
+ const ns = 'authz-calibration';
746
+ const group = (name) => `${RELATION_USERSET_TYPE}:${ns}|${name}`;
747
+ const member = `${holder}:${ns}-member`;
748
+ const outsider = `${holder}:${ns}-outsider`;
749
+ const context = this.#checkContext(this.#now());
750
+ const probe = async (depth, side) => {
751
+ const tuples = [
752
+ { user: member, relation: RELATION_USERSET_RELATION, object: group('g0') },
753
+ ];
754
+ for (let i = 0; i < depth; i++) {
755
+ tuples.push({ user: `${group(`g${i}`)}#${RELATION_USERSET_RELATION}`, relation: RELATION_USERSET_RELATION, object: group(`g${i + 1}`) });
756
+ }
757
+ tuples.push({ user: outsider, relation: RELATION_USERSET_RELATION, object: group('h0') });
758
+ tuples.push({ user: `${group('h0')}#${RELATION_USERSET_RELATION}`, relation: RELATION_USERSET_RELATION, object: group('h1') });
759
+ const top = `${group(`g${depth}`)}#${RELATION_USERSET_RELATION}`;
760
+ const target = domain
761
+ ? { relation: domain.relation, object: `${domain.type}:${ns}|x` }
762
+ : { relation: RELATION_USERSET_RELATION, object: group(`g${depth}`) };
763
+ if (domain)
764
+ tuples.push({ user: top, relation: domain.relation, object: target.object });
765
+ try {
766
+ const response = await this.#guard('groupDepthLimit', () => this.client.check({ user: side === 'positive' ? member : outsider, relation: target.relation, object: target.object, contextualTuples: tuples, context }, { consistency: ConsistencyPreference.HigherConsistency }));
767
+ return (response?.allowed === true) === (side === 'positive') ? 'ok' : 'blind';
768
+ }
769
+ catch (error) {
770
+ if (error instanceof ResolutionLimitError)
771
+ return 'too-complex';
772
+ throw error;
773
+ }
774
+ };
775
+ const calibrated = await calibrateGroupDepth(probe);
776
+ return domain ? calibrated : Math.max(0, calibrated - 1);
777
+ }
778
+ /**
779
+ * **El gate de R1 en `openfga`**: lee el grafo de anidación de la partición hacia ARRIBA desde el contenedor (un `Read`
780
+ * por `user` = `group:<g>#member` y tipo `group:`, por grupo) y hacia ABAJO desde el contenido (un `Read` por objeto
781
+ * `group:<g>` y relación `member`, que trae también los holders: O(miembros) en una escritura rara, declarado), con la
782
+ * vigencia del instante de la escritura (una arista vencida no conecta; una con condición `foreign`/`unparseable`
783
+ * CUENTA: más 422, nunca menos). Concurrencia: dos `relate` que juntas exceden pasan las dos (cada una lee el grafo
784
+ * sin la otra) — declarado parcial, como `scopes.maxDepth`.
785
+ *
786
+ * **Presupuesto** (cierre de R1 · 🟠 1 del auditor): cada página de `Read` pasa por `GroupDepthBudget` —requests,
787
+ * tuplas leídas (los holders que el `Read` hacia abajo no puede filtrar cuentan) y deadline TOTAL del veredicto—; al
788
+ * pasarlo, 422 `too-many-groups` sin escribir.
789
+ */
790
+ async #assertGroupNesting(partitionKey, edge, now, limit, budget) {
791
+ const pages = { before: () => budget.beforeRead(), after: (n) => budget.charge(n) };
792
+ const prefix = `${RELATION_USERSET_TYPE}:${partitionKey}|`;
793
+ const live = (t) => t.invalid !== undefined || isActiveExpiry(t.validUntil ?? null, now);
794
+ const idOf = (fullObject) => {
795
+ const parsed = this.#parseObjectId(fullObject);
796
+ return parsed && parsed.type === RELATION_USERSET_TYPE && parsed.partitionKey === partitionKey ? parsed.objectUuid : null;
797
+ };
798
+ const verdict = await groupNestingVerdict(edge, limit, {
799
+ parentsOf: async (groups) => {
800
+ const out = [];
801
+ for (const child of groups) {
802
+ const user = `${prefix}${child}#${RELATION_USERSET_RELATION}`;
803
+ for (const t of await this.#readAll('relate', { user, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` }, pages)) {
804
+ const parent = t.user === user && t.relation === RELATION_USERSET_RELATION && live(t) ? idOf(t.object) : null;
805
+ if (parent !== null)
806
+ out.push({ child, parent });
807
+ }
808
+ }
809
+ return out;
810
+ },
811
+ childrenOf: async (groups) => {
812
+ const out = [];
813
+ for (const parent of groups) {
814
+ const object = `${prefix}${parent}`;
815
+ for (const t of await this.#readAll('relate', { object, relation: RELATION_USERSET_RELATION }, pages)) {
816
+ const suffix = `#${RELATION_USERSET_RELATION}`;
817
+ if (t.object !== object || t.relation !== RELATION_USERSET_RELATION || !t.user.endsWith(suffix) || !live(t))
818
+ continue;
819
+ const child = idOf(t.user.slice(0, -suffix.length));
820
+ if (child !== null)
821
+ out.push({ child, parent });
822
+ }
823
+ }
824
+ return out;
825
+ },
826
+ });
827
+ if (verdict.kind !== 'ok') {
828
+ throw groupNestingError('openfga-relations', verdict, `group:${edge.parent}#member@group:${edge.child}#member`, limit);
829
+ }
656
830
  }
657
831
  /**
658
832
  * **¿Transporta el grupo un deny vivo?** (cierre 2 de D2 · 🟠 N1): el `#member` de `group` —o el de un grupo que lo
@@ -676,14 +850,16 @@ export class OpenFgaRelationsDriver {
676
850
  // no anunciaba, y las particiones siguientes no migraban.
677
851
  if (t.relation.startsWith(RELATION_DENY_PREFIX) &&
678
852
  t.object.startsWith(`${type}:${partitionKey}|`) &&
679
- isActiveExpiry(t.validUntil ?? null, now)) {
853
+ // L11: un deny con una condición `foreign`/`unparseable` cuenta como VIVO (fail-closed: más 409, nunca menos).
854
+ (t.invalid !== undefined || isActiveExpiry(t.validUntil ?? null, now))) {
680
855
  return `${t.object}#${t.relation}@${t.user}`;
681
856
  }
682
857
  }
683
858
  }
684
859
  for (const parent of await this.#readAll('relate', { user: userset, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` })) {
685
860
  // Cierre 3 de D2 · ⚪ C2-4: una arista de anidación VENCIDA no transporta nada (no conecta en FGA).
686
- if (!isActiveExpiry(parent.validUntil ?? null, now))
861
+ // L11: una arista con condición `foreign`/`unparseable` se sigue (más grupos que mirar: más 409, nunca menos).
862
+ if (parent.invalid === undefined && !isActiveExpiry(parent.validUntil ?? null, now))
687
863
  continue;
688
864
  if (!seen.has(parent.object)) {
689
865
  seen.add(parent.object);
@@ -716,7 +892,7 @@ export class OpenFgaRelationsDriver {
716
892
  const found = response.tuples.map((t) => t.key).find((k) => k?.user === key.user && k?.relation === key.relation && k?.object === key.object);
717
893
  if (!found)
718
894
  return null;
719
- return { ...key, validUntil: toExpiryDate(found.condition?.context?.valid_until) };
895
+ return { ...key, ...storedCondition(found.condition) };
720
896
  }
721
897
  async unrelate(subject, relation, object, partition, options) {
722
898
  this.#rejectTransaction('unrelate', options);
@@ -727,10 +903,10 @@ export class OpenFgaRelationsDriver {
727
903
  const partitionKey = this.#keyOf(partition);
728
904
  const objectId = this.#objectId(object.type, partitionKey, object.id);
729
905
  const user = this.#subjectUser(subject, partitionKey);
730
- await this.#assertModelDeny([object.type]);
906
+ await this.#assertModel('unrelate', [object.type]);
731
907
  await this.#guard('unrelate', () =>
732
908
  // Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
733
- this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
909
+ this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES), true);
734
910
  }
735
911
  /**
736
912
  * **`deny` = UN `Write` de la tupla `denied_<rel>`** (lote D · D1): sin caducidad (D3), así que no hay renovación
@@ -738,7 +914,7 @@ export class OpenFgaRelationsDriver {
738
914
  * CON condición devuelve 409 y, desde el cierre 1 de D2 (🟡 4), sale como 500 `E_AUTHZ_CONFIG` con la receta
739
915
  * (`AuthorizationConfigError.conditionalDeny`; hasta entonces era un 503 que se repetía para siempre). Antes del `Write`: la
740
916
  * puerta de `{ transaction }` (L-5), F-05 sobre el nombre PÚBLICO, la puerta del opt-in (fase `'write'`), sin
741
- * caducidad, la partición, `*`, el sujeto y la guarda del modelo (D-31). CERO llamadas si algo de eso falla.
917
+ * caducidad, la partición, `*`, el sujeto y la puerta del modelo (L11). CERO llamadas si algo de eso falla.
742
918
  */
743
919
  async deny(subject, relation, object, partition, options) {
744
920
  this.#rejectTransaction('deny', options);
@@ -750,16 +926,17 @@ export class OpenFgaRelationsDriver {
750
926
  const partitionKey = this.#keyOf(partition);
751
927
  const objectId = this.#objectId(object.type, partitionKey, object.id);
752
928
  const user = this.#subjectUser(subject, partitionKey);
753
- await this.#assertModelDeny([object.type]);
929
+ await this.#assertModel('deny', [object.type]);
754
930
  const key = { user, relation: deniedRelationOf(relation), object: objectId };
755
931
  try {
756
- await this.#guard('deny', () => this.client.write({ writes: [key] }, IGNORE_DUPLICATE_WRITES));
932
+ await this.#guard('deny', () => this.client.write({ writes: [key] }, IGNORE_DUPLICATE_WRITES), true);
757
933
  }
758
934
  catch (error) {
759
935
  // Cierre 1 de D2 · 🟡 4: el 409 de una clave que ya existe CON condición (medido en v1.19.0: «already exists with
760
936
  // a different condition») no es una caída. Se confirma RELEYENDO la tupla (no por el texto del mensaje): si está y
761
- // lleva condición, 500 con la receta; si no, el 503 de siempre.
762
- if (error?.status === 503 && error?.cause?.statusCode === 409) {
937
+ // lleva condición, 500 con la receta; si no, el 409 de la carrera (R3; hasta R3, 503).
938
+ // R3: el 409 es la clase «carrera» (409 `E_AUTHZ_WRITE_CONFLICT`, antes 503); se mira la CAUSA del SDK.
939
+ if (error?.cause?.statusCode === 409) {
763
940
  const found = await this.#guard('deny', () => this.client.read(key, { consistency: this.#consistency }));
764
941
  if (Array.isArray(found?.tuples) && found.tuples.some((t) => t?.key?.condition)) {
765
942
  throw AuthorizationConfigError.conditionalDeny('deny', `${key.object}#${key.relation}@${key.user}`);
@@ -772,7 +949,7 @@ export class OpenFgaRelationsDriver {
772
949
  * **`removeDeny` = UN delete de `denied_<rel>`** (no-op si no está). Sobre CUALQUIER tipo declarado (D8/R2-4 b):
773
950
  * `Write` con delete no valida la relación contra el modelo (medido en v1.19.0), así que un deny vivo de un tipo
774
951
  * que retiró el opt-in se puede borrar. Ojo: quitar un deny CONCEDE (filas declaradas S8–S10).
775
- * **La guarda D-31 SÍ corre aquí para los tipos CON opt-in** (letra corregida en el cierre 1 de D1, ⚪ 5 del auditor:
952
+ * **La puerta del modelo (L11, antes D-31) SÍ corre aquí** (letra corregida en el cierre 1 de D1, ⚪ 5 del auditor:
776
953
  * la decisión 4 del informe decía que no): con el opt-in vivo y un modelo fijado sin `denied_*` ⇒ 500 antes del
777
954
  * delete. Con el opt-in RETIRADO no hay guarda (el tipo no está en la config con `deny`) y el delete borra de verdad
778
955
  * (medido por el auditor: `listDenies` de 1 a 0 y `check` vuelve a `true` con el modelo viejo) — retirar es reversible.
@@ -785,8 +962,8 @@ export class OpenFgaRelationsDriver {
785
962
  const partitionKey = this.#keyOf(partition);
786
963
  const objectId = this.#objectId(object.type, partitionKey, object.id);
787
964
  const user = this.#subjectUser(subject, partitionKey);
788
- await this.#assertModelDeny([object.type]);
789
- await this.#guard('removeDeny', () => this.client.write({ deletes: [{ user, relation: deniedRelationOf(relation), object: objectId }] }, IGNORE_MISSING_DELETES));
965
+ await this.#assertModel('removeDeny', [object.type]);
966
+ await this.#guard('removeDeny', () => this.client.write({ deletes: [{ user, relation: deniedRelationOf(relation), object: objectId }] }, IGNORE_MISSING_DELETES), true);
790
967
  }
791
968
  /* ── Lecturas ───────────────────────────────────────────────────────────── */
792
969
  /**
@@ -810,7 +987,7 @@ export class OpenFgaRelationsDriver {
810
987
  const objectId = this.#objectId(object.type, partitionKey, object.id);
811
988
  const user = this.#subjectUser(subject, partitionKey);
812
989
  const context = this.#checkContext(this.#now());
813
- await this.#assertModelDeny([object.type]);
990
+ await this.#assertModel('check', [object.type]);
814
991
  const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
815
992
  return response.allowed === true;
816
993
  }
@@ -834,7 +1011,7 @@ export class OpenFgaRelationsDriver {
834
1011
  const user = this.#subjectUser(subject, partitionKey);
835
1012
  const { distinct, index } = distinctObjects(objects);
836
1013
  const context = this.#checkContext(this.#now());
837
- await this.#assertModelDeny(distinct.map((object) => object.type));
1014
+ await this.#assertModel('checkMany', distinct.map((object) => object.type));
838
1015
  const checks = distinct.map((object, i) => ({
839
1016
  user,
840
1017
  relation,
@@ -869,13 +1046,13 @@ export class OpenFgaRelationsDriver {
869
1046
  for (const object of objects)
870
1047
  assertRelationDeclared(this.#config, object, relation);
871
1048
  assertPartition(partition);
872
- this.#assertPinnedForDecideMany();
873
1049
  const partitionKey = this.#keyOf(partition);
874
1050
  const user = this.#subjectUser(subject, partitionKey);
875
1051
  const { distinct, index } = distinctObjects(objects);
876
1052
  const context = this.#checkContext(this.#now());
877
- await this.#assertModelDeny(distinct.map((object) => object.type));
878
- const declared = await this.#modelDenied();
1053
+ // L11: con la puerta, el modelo ES el que genera la config (con D8 por TIPO ENTERO): un tipo en forma `deny` declara
1054
+ // `denied_<rel>` para TODAS sus relaciones, uno en forma `plain` para ninguna.
1055
+ const { denyForm } = await this.#assertModel('decideMany', distinct.map((object) => object.type));
879
1056
  const checks = [];
880
1057
  const plan = [];
881
1058
  distinct.forEach((object, i) => {
@@ -884,7 +1061,7 @@ export class OpenFgaRelationsDriver {
884
1061
  const type = this.#config.objectTypes.find((t) => t.type === object.type);
885
1062
  const sources = down.filter((name) => {
886
1063
  const includes = type?.relations.find((r) => r.name === name)?.includes ?? [];
887
- return includes.length === 0 && declared.has(`${object.type}#${deniedRelationOf(name)}`);
1064
+ return includes.length === 0 && denyForm.get(object.type) === 'deny';
888
1065
  });
889
1066
  plan.push({ object, sources });
890
1067
  // `correlationId` del servidor: `^[\w\d-]{1,36}$` (sin `:`): `a<i>` para R, `d<i>-<k>` para la fuente k.
@@ -910,27 +1087,9 @@ export class OpenFgaRelationsDriver {
910
1087
  });
911
1088
  return index.map((at) => answers[at]);
912
1089
  }
913
- /**
914
- * **`decideMany` exige `modelId` FIJADO, con o sin tipos con deny** (lote D · cierre 1 de D1, 🟡 3 del auditor; D14
915
- * extendido a la primitiva de cadena). `decideMany` decide qué `denied_*` preguntar con el memo del modelo
916
- * (`#modelDenied`), y `check` lo resta el SERVIDOR con el modelo que evalúa: sin pin, el memo es el «último» del
917
- * momento de la primera lectura y el servidor evalúa con el último de AHORA. Republicado un modelo con deny (flota con
918
- * dos configs, reactivación del opt-in), `check` daba `false` por el deny y `decideMany` respondía `none` —la cadena
919
- * del llamante seguía subiendo y concedía por el padre: fail-open en la primitiva—. Con el pin, memo y `Check` son
920
- * del MISMO modelo. 500 `E_AUTHZ_CONFIG` con cero llamadas, ANTES de leer nada.
921
- */
922
- #assertPinnedForDecideMany() {
923
- if (this.#options.modelId)
924
- return;
925
- throw new AuthorizationConfigError(`OpenFgaRelationsDriver.decideMany: exige modelId FIJADO (D14, también sin tipos con deny). decideMany decide qué ` +
926
- `denied_<rel> preguntar con el modelo que leyó una vez, y sin pin el servidor evalúa check con el ÚLTIMO modelo del ` +
927
- `store: tras republicar un modelo con deny respondería 'none' donde check responde false, y la cadena del llamante ` +
928
- `concedería por el padre. Receta: provision (openfga:provision) → fijar modelId (OPENFGA_MODEL_ID) en TODA la flota ` +
929
- `→ desplegar. No se ha llamado al servidor.`);
930
- }
931
1090
  /**
932
1091
  * **`denyPhase` — ¿admite este driver escrituras de deny en el tipo?** (lote D · D2, R2-5). La fase del opt-in EN
933
- * LA CONFIG DE ESTE DRIVER y, con opt-in, el modelo FIJADO comprobado con la MISMA guarda D-31 que `deny` (500 si
1092
+ * LA CONFIG DE ESTE DRIVER y, con opt-in, el modelo FIJADO comprobado con la MISMA puerta L11 que `deny` (500 si
934
1093
  * no declara `denied_<rel>`). F-05 estricto del par, antes de nada (422, cero llamadas). La llama
935
1094
  * `reconcileRelations` en la fase de plan cuando el origen trae denies: un destino `openfga` sin `'write'` en el
936
1095
  * tipo, o con un pin a un modelo sin el deny, NIEGA la pasada antes de la primera escritura (jamás `skipped`).
@@ -940,19 +1099,27 @@ export class OpenFgaRelationsDriver {
940
1099
  const phase = denyPhaseOf(this.#config, object.type);
941
1100
  if (phase === undefined)
942
1101
  return null;
943
- await this.#assertModelDeny([object.type]);
1102
+ await this.#assertModel('denyPhase', [object.type]);
944
1103
  return phase;
945
1104
  }
946
1105
  /** `listDenies` = `Read` por `(object, denied_<rel>)`: los denies DIRECTOS (hechos). Cualquier tipo declarado (D8). */
947
- async listDenies(relation, object, partition, _page) {
1106
+ async listDenies(relation, object, partition) {
948
1107
  assertRelationDeclared(this.#config, object, relation);
949
1108
  assertPartition(partition);
1109
+ // 7.4: sin página (≠ undefined ⇒ 500 con cero llamadas al cliente), después de F-05 y la partición.
1110
+ assertNoListPage('listDenies', arguments[3], 'openfga');
950
1111
  const partitionKey = this.#keyOf(partition);
951
1112
  const objectId = this.#objectId(object.type, partitionKey, object.id);
952
1113
  const now = this.#now();
1114
+ await this.#assertModel('listDenies', [object.type]);
953
1115
  const tuples = await this.#readAll('listDenies', { object: objectId, relation: deniedRelationOf(relation) });
954
1116
  const subjects = [];
955
1117
  for (const t of tuples) {
1118
+ // L11: una condición `foreign`/`unparseable` no se lista (se avisa y se cuenta): el paquete no sabe si está viva.
1119
+ if (t.invalid !== undefined) {
1120
+ this.#warnUnparseable('listDenies', `${t.object}#${t.relation}@${t.user} (${t.invalid})`);
1121
+ continue;
1122
+ }
956
1123
  if (t.validUntil && t.validUntil.getTime() <= now.getTime())
957
1124
  continue;
958
1125
  const subject = this.#userToSubject(t.user);
@@ -1006,13 +1173,14 @@ export class OpenFgaRelationsDriver {
1006
1173
  * se consume entre todas — otra razón para señalar el truncado y no callar
1007
1174
  * (documentado en el README).
1008
1175
  */
1009
- async listObjects(subject, relation, objectType, partition, _page) {
1176
+ async listObjects(subject, relation, objectType, partition) {
1010
1177
  assertRelationDeclared(this.#config, { type: objectType }, relation);
1011
1178
  assertPartition(partition);
1179
+ assertNoListPage('listObjects', arguments[4], 'openfga');
1012
1180
  const partitionKey = this.#keyOf(partition);
1013
1181
  const user = this.#subjectUser(subject, partitionKey);
1014
1182
  const context = this.#checkContext(this.#now());
1015
- await this.#assertModelDeny([objectType]);
1183
+ await this.#assertModel('listObjects', [objectType]);
1016
1184
  const { raw, truncated } = await this.#listObjectsPage({ user, relation, type: objectType, context });
1017
1185
  const objects = [];
1018
1186
  for (const full of raw) {
@@ -1034,18 +1202,26 @@ export class OpenFgaRelationsDriver {
1034
1202
  * derivado por includes. `usersetSubjects: true` ⇒ los usersets
1035
1203
  * (`group#member`) salen junto con los holders.
1036
1204
  */
1037
- async listSubjects(relation, object, partition, _page) {
1205
+ async listSubjects(relation, object, partition) {
1038
1206
  assertRelationDeclared(this.#config, object, relation);
1039
1207
  assertPartition(partition);
1208
+ assertNoListPage('listSubjects', arguments[3], 'openfga');
1040
1209
  const partitionKey = this.#keyOf(partition);
1041
1210
  const objectId = this.#objectId(object.type, partitionKey, object.id);
1042
1211
  // `Read` devuelve tuplas ESCRITAS, no evaluadas: la caducidad se filtra en
1043
1212
  // cliente con el MISMO reloj del driver (R-15, estricta: la que vence en
1044
1213
  // `now` ya no cuenta), como las enumeraciones del driver de roles.
1045
1214
  const now = this.#now();
1215
+ await this.#assertModel('listSubjects', [object.type]);
1046
1216
  const tuples = await this.#readAll('listSubjects', { object: objectId, relation });
1047
1217
  const subjects = [];
1048
1218
  for (const t of tuples) {
1219
+ // L11: una condición `foreign`/`unparseable` NO es un hecho vigente que listar (sería «permanente» por defecto);
1220
+ // se avisa y se cuenta en `diagnostics.unparseableTuples`.
1221
+ if (t.invalid !== undefined) {
1222
+ this.#warnUnparseable('listSubjects', `${t.object}#${t.relation}@${t.user} (${t.invalid})`);
1223
+ continue;
1224
+ }
1049
1225
  if (t.validUntil && t.validUntil.getTime() <= now.getTime())
1050
1226
  continue;
1051
1227
  const subject = this.#userToSubject(t.user);
@@ -1085,13 +1261,26 @@ export class OpenFgaRelationsDriver {
1085
1261
  * la CADUCADA también llega, con su `expiresAt` (R-15), para que el destino
1086
1262
  * la cuente en `skipped`.
1087
1263
  *
1264
+ * **Una tupla con condición AJENA llega marcada** (`invalidCondition`, L11) y
1265
+ * con `expiresAt` = la ÉPOCA. Eso es conservador SOLO para los allows: un
1266
+ * lector que ignore la marca lee el deny (o la membresía de `group` que lo
1267
+ * transporta) como VENCIDO y deja de negar —fail-OPEN— (cierre-2 · ⚪ C3).
1268
+ * Ninguna fecha sirve para los dos tipos de tupla: el lector tiene que mirar
1269
+ * `invalidCondition` ANTES que `expiresAt` (el plan del paquete lo hace).
1270
+ *
1088
1271
  * **Coste: O(tuplas del store) por PASADA** (2.4.0-alpha.6): es un barrido
1089
1272
  * entero por llamada, sea cual sea el tamaño de la partición (medido: 5 000
1090
1273
  * tuplas ⇒ 50 páginas ≈ 170 ms; 15 000 ⇒ 151 páginas ≈ 0,5 s). Para K
1091
1274
  * particiones usa `enumerateRelationsMany`: UN barrido en vez de K.
1092
1275
  */
1093
- async enumerateRelations(partition, _page) {
1276
+ async enumerateRelations(partition, page) {
1094
1277
  assertPartition(partition);
1278
+ // 7.4 (injerto 4): limit basura ⇒ 422; after ⇒ 500 (este driver no emite cursor: devuelve TODO en una página).
1279
+ // Un limit válido se IGNORA (una página, O(store)): declarado en «Limits».
1280
+ assertEnumeratePage('enumerateRelations', page, 'none', 'openfga');
1281
+ // L11: el ORIGEN de una migración pasa SOLO la parte `conditions` de la puerta (tiene que poder rescatar un store
1282
+ // con deriva de reglas; la vigencia de lo que emite sí depende de la condición del modelo).
1283
+ await this.#assertModel('enumerateRelations', [], 'conditions');
1095
1284
  return this.#sweep([partition], 'enumerateRelations');
1096
1285
  }
1097
1286
  /**
@@ -1120,13 +1309,15 @@ export class OpenFgaRelationsDriver {
1120
1309
  * se cuenta en `diagnostics.unparseableTuples` y se avisa. Sin particiones
1121
1310
  * es 500 `E_AUTHZ_CONFIG` con cero lecturas: no hay «éxito vacío».
1122
1311
  */
1123
- async enumerateRelationsMany(partitions, _page) {
1312
+ async enumerateRelationsMany(partitions, page) {
1124
1313
  if (!Array.isArray(partitions) || partitions.length === 0) {
1125
1314
  throw new AuthorizationConfigError('enumerateRelationsMany: sin particiones no hay nada que enumerar (y un barrido del store para nadie no es un éxito vacío). ' +
1126
1315
  'Pasa al menos una partición.');
1127
1316
  }
1128
1317
  for (const partition of partitions)
1129
1318
  assertPartition(partition);
1319
+ assertEnumeratePage('enumerateRelationsMany', page, 'none', 'openfga');
1320
+ await this.#assertModel('enumerateRelationsMany', [], 'conditions');
1130
1321
  return this.#sweep(partitions, 'enumerateRelationsMany');
1131
1322
  }
1132
1323
  /** El barrido: UN `Read({})` paginado del store entero, repartido por la clave ENTERA de partición. */
@@ -1162,7 +1353,13 @@ export class OpenFgaRelationsDriver {
1162
1353
  ...storedRelationKind(key.relation),
1163
1354
  object: { type: parsed.type, id: parsed.objectUuid },
1164
1355
  partition,
1165
- expiresAt: key.validUntil,
1356
+ // Cierre de L11 (⚪ A3.3 del auditor): con una condición que el paquete no escribe (o no sabe leer) `expiresAt` NO
1357
+ // es `null` sino la ÉPOCA. Cierre-2 (⚪ C3): eso es fail-CLOSED para un allow que lea un tercero sin mirar
1358
+ // `invalidCondition` (lo lee VENCIDO), pero fail-OPEN para un deny o una membresía que transporta uno (deja de negar);
1359
+ // con `null` era al revés. Ninguna fecha vale para los dos: la marca es lo que manda, y VIAJA al plan, que la
1360
+ // clasifica antes de mirar la fecha (`invalid-condition`, L9): nunca se migra ni se poda como sobrante.
1361
+ expiresAt: key.invalid !== undefined ? new Date(0) : key.validUntil,
1362
+ ...(key.invalid !== undefined ? { invalidCondition: key.invalid } : {}),
1166
1363
  });
1167
1364
  }
1168
1365
  return { tuples };
@@ -1182,6 +1379,7 @@ export class OpenFgaRelationsDriver {
1182
1379
  assertObjectTypeDeclared(this.#config, object);
1183
1380
  assertPartition(partition);
1184
1381
  const objectId = this.#objectId(object.type, this.#keyOf(partition), object.id);
1382
+ await this.#assertModel('purgeObject', [object.type]);
1185
1383
  const progress = { deleted: 0 };
1186
1384
  try {
1187
1385
  await this.#purgeByFilters('purgeObject', [[{ object: objectId }, `objeto ${objectId}`]], progress);
@@ -1207,6 +1405,8 @@ export class OpenFgaRelationsDriver {
1207
1405
  assertSubject(subject);
1208
1406
  const user = this.#subjectUser(subject, this.#keyOf(partition));
1209
1407
  const types = [FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
1408
+ // Toca TODOS los tipos declarados: la puerta los compara todos.
1409
+ await this.#assertModel('purgeSubject', types);
1210
1410
  const progress = { deleted: 0 };
1211
1411
  try {
1212
1412
  await this.#purgeByFilters('purgeSubject', types.map((type) => [{ user, object: `${type}:` }, `sujeto ${user} en '${type}'`]), progress);
@@ -1241,7 +1441,8 @@ export class OpenFgaRelationsDriver {
1241
1441
  * estado inicial o el final** —con la única excepción DECLARADA de abajo—.
1242
1442
  *
1243
1443
  * - **Cabe en `RELATIONS_PURGE_WRITE_MAX` (100) ⇒ UN solo `Write`**: FGA lo aplica de forma transaccional (medido:
1244
- * 101 es 400 y no se aplica nada), así que NO hay estado intermedio que cortar.
1444
+ * 101 es 400 y no se aplica nada), así que NO hay PREFIJO que cortar (atómico para el ESCRITOR: un `Check`
1445
+ * concurrente puede observar el `Write` a medias —lector rasgado, fila «torn read» de Limits, decisión (6a)—).
1245
1446
  * - **No cabe ⇒ tres clases, cada una en sus propios `Write`, en el orden de BORRADO del punto de paso ÚNICO
1246
1447
  * `relationDeleteOrder`** (`src/relations/tuple_order.ts`; cierre 1 de D2 · 🟠 1: el mismo que el reconcile): (1) los allows sobre objetos que no son
1247
1448
  * `group` —quitar un allow solo resta (D-12, monotonía)—; (2) las MEMBRESÍAS `group:*#member@<sujeto>` —una membresía
@@ -1266,7 +1467,7 @@ export class OpenFgaRelationsDriver {
1266
1467
  read.push({ filter, what, keys: await this.#readAll(operation, filter) });
1267
1468
  for (const batch of purgeWrites(read.flatMap((r) => r.keys))) {
1268
1469
  try {
1269
- await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES));
1470
+ await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES), true);
1270
1471
  }
1271
1472
  catch (error) {
1272
1473
  // Cierre 2 de D1 · ⚪ N3: el 400 del tope de operaciones por `Write` del SERVIDOR no es una caída (503
@@ -1304,12 +1505,15 @@ export class OpenFgaRelationsDriver {
1304
1505
  * sondea las cinco operaciones que entran por aquí con un mutante que
1305
1506
  * «termina» en la página vacía.
1306
1507
  */
1307
- async #readAll(operation, filter) {
1508
+ async #readAll(operation, filter,
1509
+ /** R1: el presupuesto del gate de anidación (antes de cada página y con las tuplas de cada una). */
1510
+ pageHooks) {
1308
1511
  const keys = [];
1309
1512
  let continuationToken;
1310
1513
  const seenTokens = new Set();
1311
1514
  let pages = 0;
1312
1515
  do {
1516
+ pageHooks?.before();
1313
1517
  const response = await this.#guard(operation, () => this.client.read(filter, {
1314
1518
  pageSize: READ_PAGE_SIZE,
1315
1519
  continuationToken,
@@ -1322,6 +1526,7 @@ export class OpenFgaRelationsDriver {
1322
1526
  if (!Array.isArray(response?.tuples)) {
1323
1527
  throw new AuthorizationBackendError('openfga-relations', operation, new Error(`la respuesta del Read (página ${pages}) no trae 'tuples'; no se toma por vacía`));
1324
1528
  }
1529
+ pageHooks?.after(response.tuples.length);
1325
1530
  for (const tuple of response.tuples) {
1326
1531
  const key = tuple?.key;
1327
1532
  if (!key?.user || !key?.relation || !key?.object) {
@@ -1330,12 +1535,7 @@ export class OpenFgaRelationsDriver {
1330
1535
  }
1331
1536
  // Con su caducidad (R-15): quien la lee decide si filtra (`listSubjects`),
1332
1537
  // la emite (`enumerateRelations`) o la ignora (`purge*` borra TODO).
1333
- keys.push({
1334
- user: key.user,
1335
- relation: key.relation,
1336
- object: key.object,
1337
- validUntil: toExpiryDate(key.condition?.context?.valid_until),
1338
- });
1538
+ keys.push({ user: key.user, relation: key.relation, object: key.object, ...storedCondition(key.condition) });
1339
1539
  }
1340
1540
  continuationToken = response.continuation_token || undefined;
1341
1541
  if (continuationToken) {
@@ -1364,23 +1564,18 @@ export class OpenFgaRelationsDriver {
1364
1564
  // de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
1365
1565
  // la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
1366
1566
  // sin haber borrado nada (medido por el auditor: `alpha3-cierre-parcial.ts` B).
1367
- /** Igualdad ESTRUCTURAL de dos reglas de reescritura (D-31, cierre 2 de D1 · ⚪ N4); una ausente nunca es igual. */
1368
- function sameRewrite(served, generated) {
1369
- if (served === undefined || generated === undefined)
1370
- return false;
1371
- return JSON.stringify(canonicalRewrite(served)) === JSON.stringify(canonicalRewrite(generated));
1372
- }
1373
1567
  /** La LETRA del 500 de N3 (fijada por `relations_openfga.spec`, «⚪ N3 · …»). */
1374
1568
  function purgeWriteLimitError(operation, size, deleted, cause) {
1375
1569
  const sdk = cause?.cause?.apiErrorMessage;
1376
- return new AuthorizationConfigError(`OpenFgaRelationsDriver: ${operation}: el servidor OpenFGA rechazó un Write de ${size} tuplas por su tope de ` +
1570
+ // R3: es un fallo de DESPLIEGUE (transitorio para el relay), como el que ya clasificó `#guard`.
1571
+ return markDeploymentFailure(new AuthorizationConfigError(`OpenFgaRelationsDriver: ${operation}: el servidor OpenFGA rechazó un Write de ${size} tuplas por su tope de ` +
1377
1572
  `operaciones por Write (exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). El driver parte las ` +
1378
1573
  `purgas en RELATIONS_PURGE_WRITE_MAX = ${RELATIONS_PURGE_WRITE_MAX} y exige --max-tuples-per-write ≥ ` +
1379
1574
  `${RELATIONS_PURGE_WRITE_MAX} en el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${RELATIONS_PURGE_WRITE_MAX}). ` +
1380
1575
  `No es transitorio: repetida, la purga vuelve a fallar igual. ` +
1381
1576
  (deleted > 0
1382
1577
  ? `Ya se habían borrado ${deleted} tuplas en Write anteriores: la purga quedó A MEDIAS (escritura parcial).`
1383
- : 'No se borró nada.'), { cause });
1578
+ : 'No se borró nada.'), { cause }));
1384
1579
  }
1385
1580
  /**
1386
1581
  * **Los `Write` de una purga, en orden** (lote D · cierre 1 de D1, 🟠 1; ver `#purgeByFilters`): uno solo si todo cabe
@@ -1452,4 +1647,3 @@ function annotateRacedDeny(error, membership, recheck, applied = false) {
1452
1647
  }
1453
1648
  return error;
1454
1649
  }
1455
- //# sourceMappingURL=openfga_relations_driver.js.map