@jantstack/adonis-authz 2.5.0-alpha.2 → 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 (275) hide show
  1. package/README.md +743 -28
  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 +6 -1
  13. package/build/commands/authz_relations_reconcile.js +163 -5
  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 +72 -1
  23. package/build/commands/openfga_provision.js +236 -4
  24. package/build/configure.d.ts +0 -1
  25. package/build/configure.js +0 -1
  26. package/build/index.d.ts +11 -9
  27. package/build/index.js +12 -5
  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 +46 -5
  45. package/build/src/drivers/database_relations_driver.js +297 -59
  46. package/build/src/drivers/openfga_driver.d.ts +159 -26
  47. package/build/src/drivers/openfga_driver.js +980 -231
  48. package/build/src/drivers/openfga_facts.d.ts +112 -1
  49. package/build/src/drivers/openfga_facts.js +351 -11
  50. package/build/src/drivers/openfga_relations_driver.d.ts +109 -8
  51. package/build/src/drivers/openfga_relations_driver.js +817 -53
  52. package/build/src/errors.d.ts +233 -1
  53. package/build/src/errors.js +283 -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 +164 -31
  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 +3 -4
  81. package/build/src/openfga.js +5 -3
  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 +1 -2
  87. package/build/src/relations/check_many.js +6 -5
  88. package/build/src/relations/define_relations_config.d.ts +140 -5
  89. package/build/src/relations/define_relations_config.js +205 -6
  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 +46 -8
  95. package/build/src/relations/manager.js +178 -16
  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 +274 -2
  99. package/build/src/relations/reconcile.js +1006 -162
  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 +127 -0
  103. package/build/src/relations/tuple_order.js +599 -0
  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 +60 -1
  111. package/build/src/shared/backend_guard.js +179 -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 +1 -2
  121. package/build/src/testing/main.js +1 -2
  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 +25 -1
  125. package/build/src/testing/relations_contract.js +996 -49
  126. package/build/src/testing/relations_reconcile_contract.d.ts +9 -1
  127. package/build/src/testing/relations_reconcile_contract.js +207 -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 +170 -15
  139. package/build/src/types.js +6 -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_config_store.d.ts.map +0 -1
  239. package/build/src/relations_config_store.js.map +0 -1
  240. package/build/src/relations_reconcile.d.ts.map +0 -1
  241. package/build/src/relations_reconcile.js.map +0 -1
  242. package/build/src/scope_outbox.d.ts.map +0 -1
  243. package/build/src/scope_outbox.js.map +0 -1
  244. package/build/src/shared/backend_guard.d.ts.map +0 -1
  245. package/build/src/shared/backend_guard.js.map +0 -1
  246. package/build/src/shared/sql_expiry.d.ts.map +0 -1
  247. package/build/src/shared/sql_expiry.js.map +0 -1
  248. package/build/src/shared/transaction_guard.d.ts.map +0 -1
  249. package/build/src/shared/transaction_guard.js.map +0 -1
  250. package/build/src/sql_descendants.d.ts.map +0 -1
  251. package/build/src/sql_descendants.js.map +0 -1
  252. package/build/src/testing/contract.d.ts.map +0 -1
  253. package/build/src/testing/contract.js.map +0 -1
  254. package/build/src/testing/main.d.ts.map +0 -1
  255. package/build/src/testing/main.js.map +0 -1
  256. package/build/src/testing/migration_contract.d.ts.map +0 -1
  257. package/build/src/testing/migration_contract.js.map +0 -1
  258. package/build/src/testing/relations_contract.d.ts.map +0 -1
  259. package/build/src/testing/relations_contract.js.map +0 -1
  260. package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
  261. package/build/src/testing/relations_reconcile_contract.js.map +0 -1
  262. package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
  263. package/build/src/testing/scope_outbox_contract.js.map +0 -1
  264. package/build/src/testing/scope_tree.d.ts.map +0 -1
  265. package/build/src/testing/scope_tree.js.map +0 -1
  266. package/build/src/testing/test_clock.d.ts.map +0 -1
  267. package/build/src/testing/test_clock.js.map +0 -1
  268. package/build/src/traits/authz_scopes.d.ts.map +0 -1
  269. package/build/src/traits/authz_scopes.js.map +0 -1
  270. package/build/src/traits/has_uuid.d.ts.map +0 -1
  271. package/build/src/traits/has_uuid.js.map +0 -1
  272. package/build/src/types.d.ts.map +0 -1
  273. package/build/src/types.js.map +0 -1
  274. package/build/stubs/main.d.ts.map +0 -1
  275. package/build/stubs/main.js.map +0 -1
@@ -25,18 +25,26 @@
25
25
  */
26
26
  import db from '@adonisjs/lucid/services/db';
27
27
  import { v7 as uuidv7 } from 'uuid';
28
- import { AuthorizationConfigError, InvalidIdentityError, UnsupportedDialectError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
28
+ import { AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, MembersOfDenyLimitError, UnsupportedDialectError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
29
29
  import { assertPartition, assertSubject, assertExpiresAt, partitionKey } from '../identity.js';
30
30
  import { relationPartitionTrigger, relationPartitionTriggerDrops } from '../relation_partition_trigger.js';
31
31
  import { isClock, systemClock } from '../clock.js';
32
- import { resolveGrantExpiry, sameInstant } from '../expiry.js';
32
+ import { isActiveExpiry, resolveGrantExpiry, sameInstant } from '../expiry.js';
33
+ import { assertGroupDepthSettings, groupDepthLimitError, groupNestingVerdict, isGroupNesting, plannedByReconcile } from '../relations/group_depth.js';
33
34
  import { sqlExpiryCodec } from '../shared/sql_expiry.js';
34
- import { guardSql, isDeadlock, isUniqueViolation } from '../shared/backend_guard.js';
35
+ import { guardSql, isDeadlock, isUniqueViolation, withDeadline } from '../shared/backend_guard.js';
35
36
  import { assertCallerTransaction } from '../shared/transaction_guard.js';
36
37
  import { isRelUserset } from '../types.js';
37
- import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject } from '../relations/define_relations_config.js';
38
+ import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, cutPathAllows, decideFrom, deniableAbove, deniedRelationOf, storedRelationKind, denyPhaseOf, relationsDown, RELATION_DENY_PREFIX, } from '../relations/define_relations_config.js';
38
39
  import { assertCheckManyObjects, distinctObjects, expandByPosition } from '../relations/check_many.js';
40
+ import { assertEnumeratePage, assertNoListPage, enumerateLimit } from '../relations/page.js';
39
41
  const DEFAULT_TIMEOUT_MS = 5_000;
42
+ /**
43
+ * **Cota de candidatos de `membersOf` cuando el objeto tiene un deny vigente** (lote D · cierre 1 de D1, 🟡 4): con deny
44
+ * cada candidato se juzga con un `check` (una CTE por candidato). Pasada ⇒ 500 `E_AUTHZ_MEMBERS_DENY_LIMIT`, nunca una
45
+ * lista parcial. En «Limits» del README, atada a esta constante (`scope_depth.spec`).
46
+ */
47
+ export const RELATIONS_MEMBERS_DENY_CHECK_MAX = 1_000;
40
48
  const RELATIONS_TABLE = 'authz_relations';
41
49
  /** Longitud máxima del id de un objeto/holder de relación (columna `varchar(64)`). */
42
50
  const RELATION_ID_MAX = 64;
@@ -52,9 +60,6 @@ const GROUP_MEMBER = 'member';
52
60
  * userset puede colisionar con ella.
53
61
  */
54
62
  const HOLDER_RELATION = '';
55
- /** Tamaño de página por defecto y tope de `enumerateRelations` (origen de reconcile). */
56
- const DEFAULT_ENUMERATE_LIMIT = 100;
57
- const MAX_ENUMERATE_LIMIT = 1_000;
58
63
  /** Formato de id de relación: minúsculas, dígitos y `._-`; sin separadores del store. */
59
64
  const RELATION_ID_FORMAT = /^[a-z0-9_.-]+$/;
60
65
  const DIALECTS = {
@@ -110,6 +115,9 @@ export class DatabaseRelationsDriver {
110
115
  #database;
111
116
  #expiryCodec;
112
117
  constructor(config, options = {}, database = db) {
118
+ // Cierre de R1 · 🟡 4 del auditor: el driver RE-VALIDA el techo de la config (una config extendida con spread no pasó
119
+ // por `defineRelationsConfig`; `NaN` o `enforceGroupDepth: true` sin número apagaban el gate en silencio). 500.
120
+ assertGroupDepthSettings(config.maxGroupDepth, config.enforceGroupDepth, 'DatabaseRelationsDriver');
113
121
  this.#config = config;
114
122
  this.#connectionName = options.connection;
115
123
  this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -125,6 +133,14 @@ export class DatabaseRelationsDriver {
125
133
  listObjectsTruncation: false,
126
134
  injectableClock: true,
127
135
  transactionalWrites: this.#transactionalWrites,
136
+ // Lote D · D1: el deny vive en la MISMA tabla (`relation = 'denied_<rel>'`) y TODA lectura lo honra SIEMPRE
137
+ // (D8: también en un tipo sin opt-in —una fila deny es un hecho—).
138
+ relationDenies: true,
139
+ // Cierre 2 de D2 · 🟠 N1: renovar (otra caducidad) es delete + insert en UNA transacción (`#upsert`): ningún
140
+ // instante sin la tupla, así que una membresía que transporta un deny se renueva sin ventana.
141
+ atomicRenewal: true,
142
+ // Estrategia 2.5.0 · firma 4 (D15): `false` EXPLÍCITO — `database` es el driver ESTABLE de 2.5.0.
143
+ experimental: false,
128
144
  });
129
145
  }
130
146
  /**
@@ -293,21 +309,30 @@ export class DatabaseRelationsDriver {
293
309
  b.where('subject_relation', relation);
294
310
  }
295
311
  /* ── Expansión de includes (un nivel, hacia ABAJO desde la relación) ──── */
312
+ /** `down*(R)` — desde el lote D, la función ÚNICA de la config (`relationsDown`), la misma que el doble y el oráculo. */
296
313
  #expandDown(objectType, relation) {
297
- const out = new Set([relation]);
298
- const type = this.#config.objectTypes.find((t) => t.type === objectType);
299
- const queue = [relation];
300
- while (queue.length) {
301
- const current = queue.pop();
302
- const def = type?.relations.find((r) => r.name === current);
303
- for (const inc of def?.includes ?? []) {
304
- if (!out.has(inc)) {
305
- out.add(inc);
306
- queue.push(inc);
307
- }
308
- }
314
+ return relationsDown(this.#config, objectType, relation);
315
+ }
316
+ /**
317
+ * **Lo que una lectura con deny tiene que traer para `R`** (lote D · D5/D8): los grants de `down*(R)` y los denies de
318
+ * `up*(down*(R))` (almacenados como `denied_<rel>`). SIEMPRE, también en un tipo sin opt-in (D8: una fila deny es un
319
+ * hecho; si el tipo retiró el opt-in con denies vivos, `database` sigue denegando). Sin filas `denied_*` la respuesta
320
+ * es la de 2.4.0-alpha.10 (el corte no tiene nada que cortar).
321
+ */
322
+ #readRelations(objectType, relation) {
323
+ return [...this.#expandDown(objectType, relation), ...deniableAbove(this.#config, objectType, relation).map(deniedRelationOf)];
324
+ }
325
+ /** Reparte las relaciones leídas de un objeto en grants y denies (nombre PÚBLICO). */
326
+ #split(relations) {
327
+ const allowed = new Set();
328
+ const denied = new Set();
329
+ for (const r of relations) {
330
+ if (r.startsWith(RELATION_DENY_PREFIX))
331
+ denied.add(r.slice(RELATION_DENY_PREFIX.length));
332
+ else
333
+ allowed.add(r);
309
334
  }
310
- return [...out];
335
+ return { allowed, denied };
311
336
  }
312
337
  /* ── Escrituras ───────────────────────────────────────────────────────── */
313
338
  async relate(subject, relation, object, partition, options) {
@@ -321,14 +346,88 @@ export class DatabaseRelationsDriver {
321
346
  this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
322
347
  // R-15 (defensa en profundidad; el manager ya lo validó): los tres estados.
323
348
  assertExpiresAt(options?.expiresAt);
324
- const requested = options?.expiresAt;
349
+ await this.#upsert('relate', subject, relation, object, partition, options?.expiresAt, options);
350
+ }
351
+ /**
352
+ * **`deny` = la tupla `denied_<rel>`, por el MISMO camino idempotente que `relate`** (lote D · D1): F-05 sobre el
353
+ * nombre PÚBLICO, la puerta de escritura del opt-in (fase `'write'`, D10), ni `group` ni `*`, y SIN caducidad (D3:
354
+ * `expiresAt` ⇒ 422; un deny escrito a mano con caducidad se rehace permanente). Por la transacción del llamante si
355
+ * llega (L-4, como `relate`).
356
+ */
357
+ async deny(subject, relation, object, partition, options) {
358
+ assertRelationDeclared(this.#config, object, relation);
359
+ assertRelationDenyable(this.#config, object, relation);
360
+ assertDenyNoExpiry(options);
361
+ assertPartition(partition);
362
+ this.#assertObject(object);
363
+ assertDenySubject(subject);
364
+ await this.#upsert('deny', subject, deniedRelationOf(relation), object, partition, null, options);
365
+ }
366
+ /** `removeDeny`: borra la tupla `denied_<rel>`. Sobre CUALQUIER tipo declarado (D8/R2-4 b). No-op si no está. */
367
+ async removeDeny(subject, relation, object, partition, options) {
368
+ assertRelationDeclared(this.#config, object, relation);
369
+ assertDenyNoExpiry(options);
370
+ assertPartition(partition);
371
+ this.#assertObject(object);
372
+ await this.#deleteTuple('removeDeny', subject, deniedRelationOf(relation), object, partition, options);
373
+ }
374
+ /**
375
+ * **El techo de anidación de grupos de ESTE driver** (lote «antes de RC» · R1, decisión del dueño 2026-10-17 (5)):
376
+ * `database` no tiene techo técnico (la CTE deduplica y termina con ciclos), así que es `null` SALVO el opt-in
377
+ * explícito `database.enforceGroupDepth: true`, que aplica el `maxGroupDepth` de la config (obligatorio y numérico
378
+ * con el opt-in: lo valida `defineRelationsConfig` al arrancar).
379
+ */
380
+ async groupDepthLimit() {
381
+ return this.#groupDepthLimit();
382
+ }
383
+ #groupDepthLimit() {
384
+ const { maxGroupDepth, enforceGroupDepth } = assertGroupDepthSettings(this.#config.maxGroupDepth, this.#config.enforceGroupDepth, 'DatabaseRelationsDriver');
385
+ return enforceGroupDepth && typeof maxGroupDepth === 'number' ? maxGroupDepth : null;
386
+ }
387
+ /**
388
+ * **El gate de R1 en `database`** (opt-in): la arista `group:<parent>#member@group:<child>#member` que se va a escribir
389
+ * VIVA no puede cerrar un ciclo ni dejar un camino de grupos anidados más largo que el techo. El grafo se LEE POR EL
390
+ * MISMO CLIENTE QUE ESCRIBE (`trx`: la transacción del llamante con `{ transaction }`, o la interna de la escritura) —
391
+ * T-6 del tester: leído por la conexión del motor, un llamante que en UNA transacción escribe `A⊂B` y luego `B⊂C`
392
+ * pasaría las dos (la segunda no ve la primera, sin confirmar). Lo que esto NO cierra, declarado: dos transacciones
393
+ * CONCURRENTES que escriben cada una media cadena (cada una ve su foto, sin cerrojo) — la carrera entre procesos.
394
+ * Una sentencia por nivel del BFS (y por cada 500 grupos del nivel), con la caducidad ESTRICTA de la lectura.
395
+ */
396
+ async #assertGroupNesting(trx, operation, partitionKey, edge, now, limit) {
397
+ const neighbours = async (groups, column) => {
398
+ const out = [];
399
+ for (let i = 0; i < groups.length; i += 500) {
400
+ const chunk = groups.slice(i, i + 500);
401
+ const rows = await this.#sql(`${operation}.groupDepth`, () => this.#whereActive(trx
402
+ .from(RELATIONS_TABLE)
403
+ .where('partition_key', partitionKey)
404
+ .where('object_type', GROUP_TYPE)
405
+ .where('relation', GROUP_MEMBER)
406
+ .where('subject_type', GROUP_TYPE)
407
+ .where('subject_relation', GROUP_MEMBER)
408
+ .whereIn(column, chunk), now).select({ child: 'subject_uuid', parent: 'object_uuid' }));
409
+ for (const row of rows)
410
+ out.push({ child: row.child, parent: row.parent });
411
+ }
412
+ return out;
413
+ };
414
+ const verdict = await groupNestingVerdict(edge, limit, {
415
+ parentsOf: (groups) => neighbours(groups, 'subject_uuid'),
416
+ childrenOf: (groups) => neighbours(groups, 'object_uuid'),
417
+ });
418
+ if (verdict.kind !== 'ok') {
419
+ throw groupDepthLimitError(verdict, `group:${edge.parent}#member@group:${edge.child}#member`, limit);
420
+ }
421
+ }
422
+ /** El check-then-delete-insert idempotente de `relate`/`deny` sobre la relación ALMACENADA. */
423
+ async #upsert(operation, subject, relation, object, partition, requested, options) {
325
424
  const partitionKey = this.#keyOf(partition);
326
425
  const s = this.#subjectColumns(subject, partitionKey);
327
426
  const codec = this.#expiry;
328
427
  const now = this.#now();
329
428
  // L-4: por la transacción del llamante si llegó (`#writer`, juzgada AQUÍ,
330
429
  // después de F-05 y la gramática y antes de la primera sentencia).
331
- const { client, external } = this.#writer('relate', options);
430
+ const { client, external } = this.#writer(operation, options);
332
431
  // El check-then-delete-insert idempotente. Con transacción EXTERNA corre
333
432
  // tal cual sobre ella (el trigger de partición dispara en ESE INSERT,
334
433
  // dentro de la trx del consumidor; NUNCA se abre una interna: la fila
@@ -336,7 +435,7 @@ export class DatabaseRelationsDriver {
336
435
  // una transacción INTERNA de la conexión del driver (la atomicidad
337
436
  // trigger+insert de 4-3). Cada sentencia lleva su deadline (`#sql`).
338
437
  const write = async (trx) => {
339
- const existing = await this.#sql('relate.select', () => trx
438
+ const existing = await this.#sql(`${operation}.select`, () => trx
340
439
  .from(RELATIONS_TABLE)
341
440
  .where('partition_key', partitionKey)
342
441
  .where('object_type', object.type)
@@ -352,17 +451,28 @@ export class DatabaseRelationsDriver {
352
451
  // Los tres estados (invariante 10): omitido preserva la VIGENTE (una
353
452
  // caducada revive sin caducidad), null la quita, Date la fija.
354
453
  const expiresAt = resolveGrantExpiry(previous, requested, now);
454
+ // Idempotente (invariante 6): la misma caducidad no reescribe nada — y va ANTES del gate de R1: un `relate`
455
+ // repetido de una arista preexistente (sembrada antes de alpha.4 o con el gate apagado) sigue siendo un no-op.
456
+ if (current && sameInstant(previous, expiresAt))
457
+ return;
458
+ // R1 (opt-in en `database`): una arista de anidación que queda VIVA —nueva, renovada o revivida— pasa el gate,
459
+ // leyendo el grafo por ESTE `trx` (T-6). Una vencida no conecta nada (la misma regla que la lectura). Antes del
460
+ // DELETE: un 422 no deja la arista vieja borrada.
461
+ const limit = this.#groupDepthLimit();
462
+ if (limit !== null &&
463
+ isGroupNesting(subject, relation, object) &&
464
+ isActiveExpiry(expiresAt, now) &&
465
+ !plannedByReconcile(options, partition, { child: subject.object.id, parent: object.id })) {
466
+ await this.#assertGroupNesting(trx, operation, partitionKey, { child: subject.object.id, parent: object.id }, now, limit);
467
+ }
355
468
  if (current) {
356
- // Idempotente (invariante 6): la misma caducidad no reescribe nada.
357
- if (sameInstant(previous, expiresAt))
358
- return;
359
469
  // **INSERT/DELETE-ONLY (decisión (c))**: cambiar la caducidad es
360
470
  // BORRAR la fila e INSERTAR otra —nunca un UPDATE—; la fila nueva
361
471
  // tiene otro `uuid`, que es lo que lo hace observable. Dentro de la
362
472
  // trx del llamante, el rollback devuelve la fila (y la caducidad) VIEJA.
363
- await this.#sql('relate.delete', () => trx.from(RELATIONS_TABLE).where('uuid', current.uuid).delete());
473
+ await this.#sql(`${operation}.delete`, () => trx.from(RELATIONS_TABLE).where('uuid', current.uuid).delete());
364
474
  }
365
- await this.#sql('relate.insert', () => trx.table(RELATIONS_TABLE).insert({
475
+ await this.#sql(`${operation}.insert`, () => trx.table(RELATIONS_TABLE).insert({
366
476
  uuid: uuidv7(),
367
477
  partition_key: partitionKey,
368
478
  object_type: object.type,
@@ -383,11 +493,11 @@ export class DatabaseRelationsDriver {
383
493
  await write(client);
384
494
  }
385
495
  catch (error) {
386
- this.#poisoned('relate', error);
496
+ this.#poisoned(operation, error);
387
497
  }
388
498
  return;
389
499
  }
390
- await this.#sql('relate', () => client.transaction(write));
500
+ await this.#sql(operation, () => client.transaction(write));
391
501
  }
392
502
  async unrelate(subject, relation, object, partition, options) {
393
503
  // L-0 · F-05 también al RETIRAR (paridad con `openfga`).
@@ -395,11 +505,15 @@ export class DatabaseRelationsDriver {
395
505
  assertPartition(partition);
396
506
  this.#assertObject(object);
397
507
  this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
508
+ await this.#deleteTuple('unrelate', subject, relation, object, partition, options);
509
+ }
510
+ /** El DELETE de UNA tupla (relación ALMACENADA): `unrelate` y `removeDeny`. Un `unrelate` nunca toca `denied_*` (D-04). */
511
+ async #deleteTuple(operation, subject, relation, object, partition, options) {
398
512
  const partitionKey = this.#keyOf(partition);
399
513
  const s = this.#subjectColumns(subject, partitionKey);
400
- const { client, external } = this.#writer('unrelate', options);
514
+ const { client, external } = this.#writer(operation, options);
401
515
  try {
402
- await this.#sql('unrelate', () => client
516
+ await this.#sql(operation, () => client
403
517
  .from(RELATIONS_TABLE)
404
518
  .where('partition_key', partitionKey)
405
519
  .where('object_type', object.type)
@@ -412,7 +526,7 @@ export class DatabaseRelationsDriver {
412
526
  }
413
527
  catch (error) {
414
528
  if (external)
415
- this.#poisoned('unrelate', error);
529
+ this.#poisoned(operation, error);
416
530
  throw error;
417
531
  }
418
532
  }
@@ -433,21 +547,25 @@ export class DatabaseRelationsDriver {
433
547
  this.#assertObject(object);
434
548
  const partitionKey = this.#keyOf(partition);
435
549
  const s = this.#subjectColumns(subject, partitionKey);
436
- const relations = this.#expandDown(object.type, relation);
550
+ // Lote D · D5: los grants de `down*(R)` Y los denies de `up*(down*(R))`, en UNA consulta sobre la misma CTE
551
+ // `principal` (el deny también viaja por `group#member`, anidado, y una membresía caducada no lo transporta);
552
+ // el CORTE se decide en `cutPathAllows`, la función ÚNICA de la config.
553
+ const relations = this.#readRelations(object.type, relation);
437
554
  const meta = this.#dialectMeta();
438
555
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
439
556
  const at = this.#expiry.bind(this.#now());
440
557
  const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
441
558
  const relPlaceholders = relations.map(() => '?').join(', ');
442
559
  const sql = cte +
443
- ` SELECT ${meta.hint}1 FROM ${q(RELATIONS_TABLE)} r ` +
560
+ ` SELECT ${meta.hint}DISTINCT r.${q('relation')} AS ${q('relation')} FROM ${q(RELATIONS_TABLE)} r ` +
444
561
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
445
562
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
446
563
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
447
- `AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')} LIMIT 1`;
564
+ `AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
448
565
  const bindings = [...cteBindings, partitionKey, object.type, object.id, ...relations, at];
449
566
  const rows = rowsOf(await this.#raw('check', sql, bindings));
450
- return rows.length > 0;
567
+ const { allowed, denied } = this.#split(rows.map((row) => String(row.relation)));
568
+ return cutPathAllows(this.#config, object.type, relation, allowed, denied);
451
569
  }
452
570
  /**
453
571
  * **`checkMany` = UNA consulta** (plan 2.5 · lote A, P4-8): la MISMA CTE
@@ -460,7 +578,35 @@ export class DatabaseRelationsDriver {
460
578
  * de la consulta (defensa en profundidad: el manager ya lo hizo).
461
579
  */
462
580
  async checkMany(subject, relation, objects, partition) {
463
- assertCheckManyObjects(objects);
581
+ const { distinct, index, facts } = await this.#readMany('checkMany', subject, relation, objects, partition);
582
+ const answers = distinct.map((object) => {
583
+ const { allowed, denied } = this.#split(facts.get(`${object.type}:${object.id}`) ?? []);
584
+ return cutPathAllows(this.#config, object.type, relation, allowed, denied);
585
+ });
586
+ return expandByPosition(index, answers);
587
+ }
588
+ /**
589
+ * **`decideMany` = la consulta de `checkMany`** (lote D · D9): los grants de `down*(R)` y los denies de
590
+ * `up*(down*(R))` de cada objeto distinto, con UN `now` y en UNA consulta, y la regla AMPLIA de `decideFrom` (la
591
+ * misma función que el doble y `openfga` cuando alinea su `batchCheck`). Tope, `[]` y F-05 por ítem como `checkMany`.
592
+ */
593
+ async decideMany(subject, relation, objects, partition) {
594
+ const { distinct, index, facts } = await this.#readMany('decideMany', subject, relation, objects, partition);
595
+ const answers = distinct.map((object) => {
596
+ const { allowed, denied } = this.#split(facts.get(`${object.type}:${object.id}`) ?? []);
597
+ return decideFrom(this.#config, object.type, relation, allowed, denied);
598
+ });
599
+ return index.map((at) => answers[at]);
600
+ }
601
+ /**
602
+ * **`checkMany`/`decideMany` = UNA consulta** (plan 2.5 · lote A, P4-8; lote D): la MISMA CTE `principal` de
603
+ * `check` (el sujeto y sus grupos, transitivo, solo vigentes) y un `SELECT DISTINCT object_type, object_uuid,
604
+ * relation` sobre los hechos de la partición que casan con CUALQUIER objeto distinto del lote —agrupados por TIPO,
605
+ * porque los `includes` se expanden por tipo— con grants Y denies (`#readRelations`), con UN solo `now` para todo
606
+ * el lote. F-05 y la gramática del id, POR ÍTEM, antes de la consulta (defensa en profundidad).
607
+ */
608
+ async #readMany(operation, subject, relation, objects, partition) {
609
+ assertCheckManyObjects(objects, operation);
464
610
  for (const object of objects) {
465
611
  assertRelationDeclared(this.#config, object, relation);
466
612
  this.#assertObject(object);
@@ -473,7 +619,7 @@ export class DatabaseRelationsDriver {
473
619
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
474
620
  const at = this.#expiry.bind(this.#now());
475
621
  const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
476
- // Un grupo por TIPO de objeto: `(object_type = ? AND object_uuid IN (…) AND relation IN (<expandDown(tipo)>))`.
622
+ // Un grupo por TIPO de objeto: `(object_type = ? AND object_uuid IN (…) AND relation IN (<grants y denies del tipo>))`.
477
623
  const byType = new Map();
478
624
  for (const object of distinct) {
479
625
  const ids = byType.get(object.type) ?? [];
@@ -483,7 +629,7 @@ export class DatabaseRelationsDriver {
483
629
  const groups = [];
484
630
  const groupBindings = [];
485
631
  for (const [type, ids] of byType) {
486
- const relations = this.#expandDown(type, relation);
632
+ const relations = this.#readRelations(type, relation);
487
633
  groups.push(`(r.${q('object_type')} = ? AND r.${q('object_uuid')} IN (${ids.map(() => '?').join(', ')}) ` +
488
634
  `AND r.${q('relation')} IN (${relations.map(() => '?').join(', ')}))`);
489
635
  groupBindings.push(type, ...ids, ...relations);
@@ -492,52 +638,78 @@ export class DatabaseRelationsDriver {
492
638
  // ⚪ A8 (auditor del lote A): el hint de MySQL va ANTES de DISTINCT (como en
493
639
  // `listObjects`); detrás, el optimizador lo ignora y a > 1000 niveles de
494
640
  // anidación `checkMany` era `ERROR 3636` (503) donde `check` respondía.
495
- ` SELECT ${meta.hint}DISTINCT r.${q('object_type')} AS object_type, r.${q('object_uuid')} AS object_uuid ` +
641
+ ` SELECT ${meta.hint}DISTINCT r.${q('object_type')} AS object_type, r.${q('object_uuid')} AS object_uuid, ` +
642
+ `r.${q('relation')} AS relation ` +
496
643
  `FROM ${q(RELATIONS_TABLE)} r ` +
497
644
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
498
645
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
499
646
  `WHERE r.${q('partition_key')} = ? AND (${groups.join(' OR ')}) AND ${this.#activeSql(q, 'r')}`;
500
647
  const bindings = [...cteBindings, partitionKey, ...groupBindings, at];
501
- const rows = rowsOf(await this.#raw('checkMany', sql, bindings));
502
- const granted = new Set(rows.map((row) => `${row.object_type}:${row.object_uuid}`));
503
- const answers = distinct.map((object) => granted.has(`${object.type}:${object.id}`));
504
- return expandByPosition(index, answers);
648
+ const rows = rowsOf(await this.#raw(operation, sql, bindings));
649
+ const facts = new Map();
650
+ for (const row of rows) {
651
+ const key = `${row.object_type}:${row.object_uuid}`;
652
+ const list = facts.get(key) ?? [];
653
+ list.push(String(row.relation));
654
+ facts.set(key, list);
655
+ }
656
+ return { distinct, index, facts };
505
657
  }
506
- async listObjects(subject, relation, objectType, partition, _page) {
658
+ async listObjects(subject, relation, objectType, partition) {
507
659
  assertRelationDeclared(this.#config, { type: objectType }, relation);
508
660
  assertPartition(partition);
509
661
  this.#assertId('el tipo de objeto', objectType, RELATION_ID_MAX);
662
+ // 7.4: sin página (un quinto argumento ≠ undefined ⇒ 500 con cero sentencias), después de F-05 y la partición.
663
+ assertNoListPage('listObjects', arguments[4], 'database');
510
664
  const partitionKey = this.#keyOf(partition);
511
665
  const s = this.#subjectColumns(subject, partitionKey);
512
- const relations = this.#expandDown(objectType, relation);
666
+ // Lote D · D-15: `listObjects` RESTA (la misma regla de corte que `check`, objeto a objeto).
667
+ const relations = this.#readRelations(objectType, relation);
513
668
  const meta = this.#dialectMeta();
514
669
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
515
670
  const at = this.#expiry.bind(this.#now());
516
671
  const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
517
672
  const relPlaceholders = relations.map(() => '?').join(', ');
518
673
  const sql = cte +
519
- ` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
674
+ ` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')}, r.${q('relation')} AS ${q('relation')} ` +
675
+ `FROM ${q(RELATIONS_TABLE)} r ` +
520
676
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
521
677
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
522
678
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? ` +
523
679
  `AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
524
680
  const bindings = [...cteBindings, partitionKey, objectType, ...relations, at];
525
681
  const rows = rowsOf(await this.#raw('listObjects', sql, bindings));
526
- const objects = rows
527
- .map((row) => ({ type: objectType, id: String(row.object_uuid) }))
682
+ const byObject = new Map();
683
+ for (const row of rows) {
684
+ const id = String(row.object_uuid);
685
+ const list = byObject.get(id) ?? [];
686
+ list.push(String(row.relation));
687
+ byObject.set(id, list);
688
+ }
689
+ const objects = [...byObject]
690
+ .filter(([, rels]) => {
691
+ const { allowed, denied } = this.#split(rels);
692
+ return cutPathAllows(this.#config, objectType, relation, allowed, denied);
693
+ })
694
+ .map(([id]) => ({ type: objectType, id }))
528
695
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
529
696
  return { objects };
530
697
  }
531
- async listSubjects(relation, object, partition, _page) {
698
+ async listSubjects(relation, object, partition) {
532
699
  assertRelationDeclared(this.#config, object, relation);
533
700
  assertPartition(partition);
534
701
  this.#assertObject(object);
702
+ assertNoListPage('listSubjects', arguments[3], 'database');
703
+ return this.#directSubjects('listSubjects', relation, object, partition);
704
+ }
705
+ /** Los sujetos DIRECTOS vigentes de UNA relación almacenada (`listSubjects`; `listDenies` con `denied_<rel>`). */
706
+ async #directSubjects(operation, relation, object, partition) {
535
707
  const partitionKey = this.#keyOf(partition);
536
708
  // DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
537
709
  // `membersOf`) ni derivado por includes. `usersetSubjects: true` ⇒ los
538
710
  // usersets (`group#member`) salen junto con los holders. Solo VIGENTES (R-15).
539
711
  const now = this.#now();
540
- const rows = await this.#sql('listSubjects', () => this.#whereActive(this.#connection()
712
+ const rows = await this.#sql(operation, () => this.#whereActive(this.#connection()
541
713
  .from(RELATIONS_TABLE)
542
714
  .where('partition_key', partitionKey)
543
715
  .where('object_type', object.type)
@@ -546,6 +718,27 @@ export class DatabaseRelationsDriver {
546
718
  const subjects = rows.map((row) => this.#rowToSubject(row));
547
719
  return { subjects };
548
720
  }
721
+ /**
722
+ * **`listDenies`** (lote D · D1): los sujetos con deny DIRECTO de `relation` sobre `object` —hechos, como
723
+ * `listSubjects` sobre la relación almacenada `denied_<rel>`; no resta ni deriva—. Sobre CUALQUIER tipo declarado
724
+ * (D8/R2-4 b: con el opt-in retirado, los denies vivos se siguen pudiendo listar y borrar).
725
+ */
726
+ async listDenies(relation, object, partition) {
727
+ assertRelationDeclared(this.#config, object, relation);
728
+ assertPartition(partition);
729
+ this.#assertObject(object);
730
+ assertNoListPage('listDenies', arguments[3], 'database');
731
+ return this.#directSubjects('listDenies', deniedRelationOf(relation), object, partition);
732
+ }
733
+ /**
734
+ * **`denyPhase` — ¿admite este driver escrituras de deny en el tipo?** (lote D · D2, R2-5): la fase del opt-in EN
735
+ * LA CONFIG DE ESTE DRIVER (`'write'`, `'read'` o `null`). F-05 estricto del par (422), cero sentencias: es
736
+ * configuración. La llama `reconcileRelations` en la fase de plan cuando el origen trae denies.
737
+ */
738
+ async denyPhase(object, relation) {
739
+ assertRelationDeclared(this.#config, object, relation);
740
+ return denyPhaseOf(this.#config, object.type) ?? null;
741
+ }
549
742
  /**
550
743
  * Membresía TRANSITIVA de un objeto-grupo: todos los HOLDERS que son `member`
551
744
  * directa o a través de grupos anidados (usersets `group#member`). CTE
@@ -553,12 +746,13 @@ export class DatabaseRelationsDriver {
553
746
  * directos): en un grupo con `member(g1#member, g2)`, `membersOf(g2)` trae a
554
747
  * los holders de g1 y `listSubjects(member, g2)` solo el userset `g1#member`.
555
748
  */
556
- async membersOf(object, relation, partition, _page) {
749
+ async membersOf(object, relation, partition) {
557
750
  // Cierre-2 · 🟡 3: F-05 (par, ESTRICTA) también en `membersOf`, la octava
558
751
  // operación del puerto; antes solo `#assertObject` (gramática).
559
752
  assertRelationDeclared(this.#config, object, relation);
560
753
  assertPartition(partition);
561
754
  this.#assertObject(object);
755
+ assertNoListPage('membersOf', arguments[3], 'database');
562
756
  const partitionKey = this.#keyOf(partition);
563
757
  const relations = this.#expandDown(object.type, relation);
564
758
  const meta = this.#dialectMeta();
@@ -604,7 +798,45 @@ export class DatabaseRelationsDriver {
604
798
  partitionKey, at,
605
799
  ];
606
800
  const rows = rowsOf(await this.#raw('membersOf', sql, bindings));
607
- const subjects = rows.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
801
+ const candidates = rows.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
802
+ // **Lote D · `membersOf` RESTA** (juez ronda 2: «listObjects y membersOf restan»; D8: siempre, con o sin opt-in).
803
+ // Si el objeto tiene alguna fila `denied_*` vigente, cada candidato se juzga con `check` —el MISMO corte de camino
804
+ // que el resto de lecturas, un `check` por candidato—; sin filas deny, cero consultas de más (la respuesta y el coste
805
+ // de 2.4.0-alpha.10). Coste declarado: O(miembros) consultas cuando el objeto tiene denies.
806
+ const denied = await this.#sql('membersOf.denies', () => this.#whereActive(this.#connection()
807
+ .from(RELATIONS_TABLE)
808
+ .where('partition_key', partitionKey)
809
+ .where('object_type', object.type)
810
+ .where('object_uuid', object.id)
811
+ .whereIn('relation', deniableAbove(this.#config, object.type, relation).map(deniedRelationOf)), this.#now())
812
+ .select('uuid')
813
+ .limit(1));
814
+ if (denied.length === 0)
815
+ return { subjects: candidates };
816
+ // **Cierre 1 de D1 · 🟡 4 del auditor**: con deny, N `check` en serie sin cota ni deadline total era una
817
+ // amplificación barata para quien puede escribir un deny (disponibilidad, no concesión). Cota DECLARADA
818
+ // (`RELATIONS_MEMBERS_DENY_CHECK_MAX`, «Limits» del README) ⇒ 500 ANTES del primer `check`, nunca una lista parcial;
819
+ // y deadline TOTAL sobre el bucle (`timeoutMs` del driver) ⇒ 503 `E_AUTHZ_BACKEND_TIMEOUT`, el bucle se para.
820
+ if (candidates.length > RELATIONS_MEMBERS_DENY_CHECK_MAX) {
821
+ throw new MembersOfDenyLimitError(`membersOf(${object.type}, '${relation}'): el objeto tiene un deny vigente y ${candidates.length} candidatos; con deny ` +
822
+ `cada candidato se juzga con un check y la cota es ${RELATIONS_MEMBERS_DENY_CHECK_MAX} ` +
823
+ `(RELATIONS_MEMBERS_DENY_CHECK_MAX). No se devuelve una lista parcial: pregunta check por sujeto, o retira el deny.`);
824
+ }
825
+ let expired = false;
826
+ const loop = (async () => {
827
+ const subjects = [];
828
+ for (const candidate of candidates) {
829
+ if (expired)
830
+ break;
831
+ if (await this.check(candidate, relation, object, partition))
832
+ subjects.push(candidate);
833
+ }
834
+ return subjects;
835
+ })();
836
+ const subjects = await withDeadline(loop, this.#timeoutMs, () => {
837
+ expired = true;
838
+ return new AuthorizationBackendTimeoutError('database-relations', 'membersOf', this.#timeoutMs);
839
+ });
608
840
  return { subjects };
609
841
  }
610
842
  /* ── Purga (invariante 11): el DELETE demuestra el cero ───────────────── */
@@ -680,9 +912,12 @@ export class DatabaseRelationsDriver {
680
912
  */
681
913
  async enumerateRelations(partition, page) {
682
914
  assertPartition(partition);
915
+ // 7.4 (injerto 4, H2/H3): limit entero ≥ 1 y after = un uuid canónico (el cursor que ESTE driver emite) o 422,
916
+ // con cero sentencias; un limit por encima del techo se recorta en #enumerateRows.
917
+ const checked = assertEnumeratePage('enumerateRelations', page, 'uuid', 'database');
683
918
  const wanted = new Map();
684
919
  wanted.set(partitionKey(partition), partition);
685
- return this.#enumerateRows('enumerateRelations', wanted, page);
920
+ return this.#enumerateRows('enumerateRelations', wanted, checked);
686
921
  }
687
922
  /**
688
923
  * **`enumerateRelationsMany` — K particiones en UNA consulta** (2.4.0-alpha.8
@@ -711,7 +946,8 @@ export class DatabaseRelationsDriver {
711
946
  if (!wanted.has(key))
712
947
  wanted.set(key, partition);
713
948
  }
714
- return this.#enumerateRows('enumerateRelationsMany', wanted, page);
949
+ const checked = assertEnumeratePage('enumerateRelationsMany', page, 'uuid', 'database');
950
+ return this.#enumerateRows('enumerateRelationsMany', wanted, checked);
715
951
  }
716
952
  /**
717
953
  * La consulta de `enumerateRelations`/`enumerateRelationsMany`: los hechos
@@ -723,7 +959,8 @@ export class DatabaseRelationsDriver {
723
959
  async #enumerateRows(operation, wanted, page) {
724
960
  const partitionKeys = [...wanted.keys()];
725
961
  const declaredTypes = [GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
726
- const limit = Math.max(1, Math.min(page?.limit ?? DEFAULT_ENUMERATE_LIMIT, MAX_ENUMERATE_LIMIT));
962
+ // La COPIA validada en la entrada (assertEnumeratePage), no la página del llamante: aquí solo el default y el RECORTE al techo, declarado en «Limits».
963
+ const limit = enumerateLimit(page);
727
964
  const after = page?.after;
728
965
  const rows = await this.#sql(operation, () => {
729
966
  let q = this.#connection()
@@ -740,7 +977,9 @@ export class DatabaseRelationsDriver {
740
977
  const pageRows = hasMore ? rows.slice(0, limit) : rows;
741
978
  const tuples = pageRows.map((row) => ({
742
979
  subject: this.#rowToSubject(row),
743
- relation: String(row.relation),
980
+ // Lote D · D2: `denied_<rel>` sale como `{ relation: <rel>, kind: 'deny' }` (punto de paso único,
981
+ // `storedRelationKind`); hasta D2 salía la relación almacenada y el reconcile la contaba como no declarada.
982
+ ...storedRelationKind(String(row.relation)),
744
983
  object: { type: String(row.object_type), id: String(row.object_uuid) },
745
984
  // La partición canónica PEDIDA: el barrido de ortografías ya la unificó.
746
985
  partition: wanted.get(String(row.partition_key)),
@@ -793,4 +1032,3 @@ function rowsOf(result) {
793
1032
  }
794
1033
  return result?.rows ?? [];
795
1034
  }
796
- //# sourceMappingURL=database_relations_driver.js.map