@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
@@ -21,11 +21,13 @@ import { v7 as uuidv7 } from 'uuid';
21
21
  import { test as japaTest } from '@japa/runner';
22
22
  import db from '@adonisjs/lucid/services/db';
23
23
  import { partitionKey, scopeKey } from '../identity.js';
24
+ import { RenewalUnsafeError } from '../errors.js';
24
25
  import { RelationsManager } from '../relations/manager.js';
25
- import { defineRelationsConfig, assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject } from '../relations/define_relations_config.js';
26
+ import { defineRelationsConfig, assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, cutPathAllows, decideFrom, deniedRelationOf, denyPhaseOf, storedRelationKind, relationsDown, RELATION_DENY_PREFIX, } from '../relations/define_relations_config.js';
26
27
  import { assertCheckManyObjects, distinctObjects, expandByPosition, RELATIONS_CHECK_MANY_MAX } from '../relations/check_many.js';
28
+ import { assertEnumeratePage, assertNoListPage } from '../relations/page.js';
27
29
  import { systemClock } from '../clock.js';
28
- import { resolveGrantExpiry } from '../expiry.js';
30
+ import { resolveGrantExpiry, sameInstant } from '../expiry.js';
29
31
  import { isRelUserset } from '../types.js';
30
32
  /* ── El vocabulario del contrato ─────────────────────────────────────────── */
31
33
  /** Los holder types del contrato de relaciones. */
@@ -58,6 +60,47 @@ export function contractRelationsConfig() {
58
60
  database: { membersOf: true },
59
61
  });
60
62
  }
63
+ /**
64
+ * **La config del contrato CON techo de anidación de grupos** (lote «antes de RC» · R1, cierre: el runner publicado no
65
+ * tenía ningún caso del techo y un driver de terceros no tenía juez): la MISMA config que `contractRelationsConfig` con
66
+ * `maxGroupDepth` (por defecto 3) y el opt-in de `database` (`database.enforceGroupDepth`). Un driver que declare
67
+ * `groupDepthLimit()` tiene que responder con un techo ≤ ese número (la config solo BAJA el del driver) y aplicarlo.
68
+ */
69
+ export function contractGroupDepthRelationsConfig(maxGroupDepth = 3) {
70
+ return defineRelationsConfig({
71
+ objectTypes: RELATIONS_CONTRACT_OBJECT_TYPES.map((type) => ({
72
+ type,
73
+ relations: [{ name: 'owner' }, { name: 'editor', includes: ['owner'] }, { name: 'viewer', includes: ['editor'] }],
74
+ })),
75
+ holderTypes: [...RELATIONS_HOLDER_TYPES],
76
+ database: { membersOf: true, enforceGroupDepth: true },
77
+ maxGroupDepth,
78
+ });
79
+ }
80
+ /**
81
+ * **La config del contrato CON deny** (plan 2.5 · lote D · D1): los MISMOS dos tipos con el mismo retículo y, además,
82
+ * un tercero sin opt-in —para que «deny sobre un tipo sin deny ⇒ 422» y «D8: un tipo sin opt-in también honra»
83
+ * se juzguen con la config fija—:
84
+ *
85
+ * - `document`: `deny: 'write'` (fase 2: se escriben denies);
86
+ * - `workspace`: `deny: 'read'` (fase 1: el modelo y las lecturas honran; `deny()` ⇒ 422);
87
+ * - `note`: sin opt-in.
88
+ *
89
+ * Es una config APARTE (no se tocó `contractRelationsConfig`): un tipo sin `deny` genera el modelo de 2.4.0-alpha.10
90
+ * byte a byte (D-29) y los casos de antes no cambian de config.
91
+ */
92
+ export const RELATIONS_CONTRACT_DENY_TYPES = { document: 'write', workspace: 'read', note: undefined };
93
+ export function contractDenyRelationsConfig() {
94
+ return defineRelationsConfig({
95
+ objectTypes: Object.keys(RELATIONS_CONTRACT_DENY_TYPES).map((type) => {
96
+ const relations = [{ name: 'owner' }, { name: 'editor', includes: ['owner'] }, { name: 'viewer', includes: ['editor'] }];
97
+ const deny = RELATIONS_CONTRACT_DENY_TYPES[type];
98
+ return deny === undefined ? { type, relations } : { type, relations, deny };
99
+ }),
100
+ holderTypes: [...RELATIONS_HOLDER_TYPES],
101
+ database: { membersOf: true },
102
+ });
103
+ }
61
104
  /** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
62
105
  export function lucidRelationsContractTransactions() {
63
106
  return {
@@ -118,28 +161,9 @@ function buildRelationsDouble(options, tuples, now) {
118
161
  function active(t, at = now) {
119
162
  return t.expiresAt === null || t.expiresAt.getTime() > at().getTime();
120
163
  }
121
- /**
122
- * `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
123
- * satisface R. `viewer includes editor` ⇒ un `editor` es también `viewer`,
124
- * así que para resolver `viewer` se aceptan los sujetos de `viewer`, de lo
125
- * que `viewer` incluye (`editor`) y transitivamente (`owner`). Se sigue la
126
- * cadena de `includes` hacia ABAJO desde R.
127
- */
164
+ /** `down*(R)` — la función ÚNICA de la config (lote D), la misma que los dos drivers reales. */
128
165
  function expandDown(objectType, relation) {
129
- const out = new Set([relation]);
130
- const type = config.objectTypes.find((t) => t.type === objectType);
131
- const queue = [relation];
132
- while (queue.length) {
133
- const current = queue.pop();
134
- const def = type?.relations.find((r) => r.name === current);
135
- for (const inc of def?.includes ?? []) {
136
- if (!out.has(inc)) {
137
- out.add(inc);
138
- queue.push(inc);
139
- }
140
- }
141
- }
142
- return out;
166
+ return new Set(relationsDown(config, objectType, relation));
143
167
  }
144
168
  /** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
145
169
  function directSubjects(partition, objectType, objectId, relation, at = now) {
@@ -149,25 +173,49 @@ function buildRelationsDouble(options, tuples, now) {
149
173
  t.relation === relation &&
150
174
  active(t, at));
151
175
  }
152
- function resolves(subject, relation, object, partition, seen = new Set(), at = now) {
153
- const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
154
- if (seen.has(guardKey))
155
- return false;
156
- seen.add(guardKey);
157
- const wantKey = subjectKeyOf(subject);
158
- for (const rel of expandDown(object.type, relation)) {
159
- for (const t of directSubjects(partition, object.type, object.id, rel, at)) {
160
- if (t.subjectKey === wantKey)
161
- return true;
162
- // El sujeto directo es un userset (`group:g#member`): un HOLDER lo
163
- // satisface si es MIEMBRO del userset (transitivo, grupos anidan).
164
- if (isRelUserset(t.subject) && !isRelUserset(subject)) {
165
- if (resolves(subject, t.subject.relation, t.subject.object, partition, seen, at))
166
- return true;
176
+ /**
177
+ * **El PRINCIPAL del sujeto** (lote D, la misma CTE `principal` del driver `database`): él mismo y cada
178
+ * `group:g#member` del que es miembro VIGENTE, transitivamente (grupos anidados). Un grant o un deny alcanzan al
179
+ * sujeto si su sujeto directo está aquí — por eso un deny a `g#member` lo transporta una membresía, y deja de
180
+ * transportarlo cuando esa membresía caduca (M6, caso C).
181
+ */
182
+ function principalOf(subject, partition, at = now) {
183
+ const out = new Set([subjectKeyOf(subject)]);
184
+ let grew = true;
185
+ while (grew) {
186
+ grew = false;
187
+ for (const t of tuples) {
188
+ if (t.partition !== partition || t.objectType !== 'group' || t.relation !== 'member' || !active(t, at))
189
+ continue;
190
+ const key = `group:${t.objectId}#member`;
191
+ if (out.has(t.subjectKey) && !out.has(key)) {
192
+ out.add(key);
193
+ grew = true;
167
194
  }
168
195
  }
169
196
  }
170
- return false;
197
+ return out;
198
+ }
199
+ /** Las relaciones con grant (`allowed`) y con deny (`denied`, nombre PÚBLICO) que alcanzan al principal en el objeto. */
200
+ function factsOn(principal, object, partition, at = now) {
201
+ const allowed = new Set();
202
+ const denied = new Set();
203
+ for (const t of tuples) {
204
+ if (t.partition !== partition || t.objectType !== object.type || t.objectId !== object.id || !active(t, at))
205
+ continue;
206
+ if (!principal.has(t.subjectKey))
207
+ continue;
208
+ if (t.relation.startsWith(RELATION_DENY_PREFIX))
209
+ denied.add(t.relation.slice(RELATION_DENY_PREFIX.length));
210
+ else
211
+ allowed.add(t.relation);
212
+ }
213
+ return { allowed, denied };
214
+ }
215
+ /** `check` con CORTE DE CAMINO (D5): `cutPathAllows`, la regla ÚNICA (la misma que `database` y el oráculo). */
216
+ function resolves(subject, relation, object, partition, at = now) {
217
+ const { allowed, denied } = factsOn(principalOf(subject, partition, at), object, partition, at);
218
+ return cutPathAllows(config, object.type, relation, allowed, denied);
171
219
  }
172
220
  function collectMembers(object, relation, partition, seen = new Set()) {
173
221
  const key = `${object.type}:${object.id}#${relation}`;
@@ -199,6 +247,26 @@ function buildRelationsDouble(options, tuples, now) {
199
247
  }
200
248
  return out;
201
249
  }
250
+ /** ¿El grupo (o uno que lo contiene) es sujeto de un deny vivo de la partición? (cierre 2 de D2 · 🟠 N1). */
251
+ function carriedDeny(partition, group) {
252
+ const seen = new Set([group]);
253
+ const queue = [group];
254
+ while (queue.length > 0) {
255
+ const userset = `${queue.shift()}#member`;
256
+ for (const t of tuples) {
257
+ if (t.partition !== partition || t.subjectKey !== userset)
258
+ continue;
259
+ if (t.relation.startsWith(RELATION_DENY_PREFIX))
260
+ return `${t.objectType}:${t.objectId}#${t.relation}@${userset}`;
261
+ // Cierre 3 de D2 · ⚪ C2-4: una arista de anidación VENCIDA no transporta nada.
262
+ if (t.objectType === 'group' && t.relation === 'member' && active(t) && !seen.has(`group:${t.objectId}`)) {
263
+ seen.add(`group:${t.objectId}`);
264
+ queue.push(`group:${t.objectId}`);
265
+ }
266
+ }
267
+ }
268
+ return null;
269
+ }
202
270
  const driver = {
203
271
  capabilities,
204
272
  async relate(subject, relation, object, partition, options) {
@@ -215,6 +283,15 @@ function buildRelationsDouble(options, tuples, now) {
215
283
  // (una caducada revive sin caducidad), null la quita, Date la fija.
216
284
  const expiresAt = resolveGrantExpiry(existing?.expiresAt ?? null, options?.expiresAt, now());
217
285
  if (existing) {
286
+ // Cierre 2 de D2 · 🟠 N1: el doble DECLARA lo que hace — con `atomicRenewal` ausente/`false` se porta como un
287
+ // driver que renueva con dos escrituras y NIEGA renovar una membresía que transporta un deny vivo (409, cero
288
+ // escrituras); con `true` renueva en el sitio (atómico).
289
+ // Cierre 3 de D2 · ⚪ C2-4: una membresía YA VENCIDA no conecta nada: reescribirla no abre ninguna ventana.
290
+ if (capabilities.atomicRenewal !== true && object.type === 'group' && active(existing) && !sameInstant(existing.expiresAt, expiresAt)) {
291
+ const carried = carriedDeny(partitionKey, `${object.type}:${object.id}`);
292
+ if (carried !== null)
293
+ throw RenewalUnsafeError.carrier(`${object.type}:${object.id}#${relation}@${subjectKey}`, carried);
294
+ }
218
295
  existing.expiresAt = expiresAt;
219
296
  return;
220
297
  }
@@ -249,10 +326,12 @@ function buildRelationsDouble(options, tuples, now) {
249
326
  assertSubjectDeclared(subject);
250
327
  return resolves(subject, relation, object, scopeKeyOf(partition));
251
328
  },
252
- async listObjects(subject, relation, objectType, partition, page) {
329
+ async listObjects(subject, relation, objectType, partition) {
253
330
  assertRelationDeclared(config, { type: objectType }, relation);
254
331
  assertSubjectDeclared(subject);
255
332
  const partitionKey = scopeKeyOf(partition);
333
+ // 7.4: el doble tampoco pagina (un quinto argumento ≠ undefined ⇒ 500), después de F-05 y la partición.
334
+ assertNoListPage('listObjects', arguments[4], 'double');
256
335
  const ids = new Set();
257
336
  for (const t of tuples) {
258
337
  if (t.partition === partitionKey && t.objectType === objectType && active(t))
@@ -273,9 +352,10 @@ function buildRelationsDouble(options, tuples, now) {
273
352
  }
274
353
  return { objects: matches };
275
354
  },
276
- async listSubjects(relation, object, partition, page) {
355
+ async listSubjects(relation, object, partition) {
277
356
  assertRelationDeclared(config, object, relation);
278
357
  const partitionKey = scopeKeyOf(partition);
358
+ assertNoListPage('listSubjects', arguments[3], 'double');
279
359
  // DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
280
360
  // `membersOf`) ni derivado. `usersetSubjects` decide si los usersets
281
361
  // (`group:g#member`) salen o solo los holders.
@@ -320,26 +400,94 @@ function buildRelationsDouble(options, tuples, now) {
320
400
  const { distinct, index } = distinctObjects(objects);
321
401
  // UN solo `now` para todo el lote (A-18): se fija aquí y `resolves` lo lee.
322
402
  const at = now();
323
- const answers = distinct.map((object) => resolves(subject, relation, object, key, new Set(), () => at));
403
+ const answers = distinct.map((object) => resolves(subject, relation, object, key, () => at));
324
404
  return expandByPosition(index, answers);
325
405
  };
326
406
  }
407
+ // **El deny en el doble** (lote D · D1): solo con `relationDenies: true` —el MÍNIMO no lo trae y el runner juzga
408
+ // la cara `false` (D-28)—. Las MISMAS guardas que los drivers reales (F-05, opt-in en fase 'write', sin caducidad,
409
+ // ni `*` ni `group`) y la MISMA regla (`cutPathAllows`/`decideFrom`).
410
+ if (capabilities.relationDenies) {
411
+ driver.deny = async (subject, relation, object, partition, options) => {
412
+ assertRelationDeclared(config, object, relation);
413
+ assertRelationDenyable(config, object, relation);
414
+ assertDenyNoExpiry(options);
415
+ assertDenySubject(subject);
416
+ assertSubjectDeclared(subject);
417
+ const partitionKey = scopeKeyOf(partition);
418
+ const subjectKey = subjectKeyOf(subject);
419
+ const stored = deniedRelationOf(relation);
420
+ const existing = tuples.find((t) => t.partition === partitionKey && t.objectType === object.type && t.objectId === object.id && t.relation === stored && t.subjectKey === subjectKey);
421
+ if (existing) {
422
+ existing.expiresAt = null;
423
+ return;
424
+ }
425
+ tuples.push({ partition: partitionKey, objectType: object.type, objectId: object.id, relation: stored, subject, subjectKey, expiresAt: null });
426
+ };
427
+ driver.removeDeny = async (subject, relation, object, partition, options) => {
428
+ assertRelationDeclared(config, object, relation);
429
+ assertDenyNoExpiry(options);
430
+ assertSubjectDeclared(subject);
431
+ const partitionKey = scopeKeyOf(partition);
432
+ const subjectKey = subjectKeyOf(subject);
433
+ const stored = deniedRelationOf(relation);
434
+ for (let i = tuples.length - 1; i >= 0; i--) {
435
+ const t = tuples[i];
436
+ if (t.partition === partitionKey && t.objectType === object.type && t.objectId === object.id && t.relation === stored && t.subjectKey === subjectKey) {
437
+ tuples.splice(i, 1);
438
+ }
439
+ }
440
+ };
441
+ // Lote D · D2: la fase del opt-in en la config de ESTE driver (R2-5), con F-05 del par; la pregunta del plan del reconcile.
442
+ driver.denyPhase = async (object, relation) => {
443
+ assertRelationDeclared(config, object, relation);
444
+ return denyPhaseOf(config, object.type) ?? null;
445
+ };
446
+ driver.listDenies = async function (relation, object, partition) {
447
+ assertRelationDeclared(config, object, relation);
448
+ const key = scopeKeyOf(partition);
449
+ assertNoListPage('listDenies', arguments[3], 'double');
450
+ const direct = directSubjects(key, object.type, object.id, deniedRelationOf(relation));
451
+ return { subjects: dedupeSubjects(direct.map((t) => t.subject)) };
452
+ };
453
+ driver.decideMany = async (subject, relation, objects, partition) => {
454
+ assertCheckManyObjects(objects, 'decideMany');
455
+ for (const object of objects)
456
+ assertRelationDeclared(config, object, relation);
457
+ assertSubjectDeclared(subject);
458
+ const key = scopeKeyOf(partition);
459
+ const { distinct, index } = distinctObjects(objects);
460
+ const at = now();
461
+ const principal = principalOf(subject, key, () => at);
462
+ const answers = distinct.map((object) => {
463
+ const { allowed, denied } = factsOn(principal, object, key, () => at);
464
+ return decideFrom(config, object.type, relation, allowed, denied);
465
+ });
466
+ return index.map((i) => answers[i]);
467
+ };
468
+ }
327
469
  if (capabilities.membersOfNative) {
328
- driver.membersOf = async (object, relation, partition) => {
470
+ driver.membersOf = async function (object, relation, partition) {
329
471
  assertRelationDeclared(config, object, relation);
330
- return { subjects: collectMembers(object, relation, scopeKeyOf(partition)) };
472
+ // Lote D: `membersOf` RESTA (corte de camino por candidato, como `database`).
473
+ const key = scopeKeyOf(partition);
474
+ assertNoListPage('membersOf', arguments[3], 'double');
475
+ return { subjects: collectMembers(object, relation, key).filter((s) => resolves(s, relation, object, key)) };
331
476
  };
332
477
  }
333
478
  if (capabilities.enumerateRelations) {
334
- driver.enumerateRelations = async (partition) => {
479
+ driver.enumerateRelations = async (partition, page) => {
335
480
  const partitionKey = scopeKeyOf(partition);
481
+ // 7.4: la misma forma que los drivers reales; el doble no emite cursor (todo en una página) ⇒ after es 500.
482
+ assertEnumeratePage('enumerateRelations', page, 'none', 'double');
336
483
  // SIN filtrar la caducada (R-15): llega con su `expiresAt` para que el
337
484
  // destino de reconcile la cuente en `skipped`, no para que desaparezca.
338
485
  const out = tuples
339
486
  .filter((t) => t.partition === partitionKey)
340
487
  .map((t) => ({
341
488
  subject: t.subject,
342
- relation: t.relation,
489
+ // Lote D · D2: el deny se enumera con su relación pública y `kind: 'deny'` (la misma función que los drivers).
490
+ ...storedRelationKind(t.relation),
343
491
  object: { type: t.objectType, id: t.objectId },
344
492
  partition,
345
493
  expiresAt: t.expiresAt,
@@ -366,6 +514,63 @@ function fixedClock(start) {
366
514
  };
367
515
  }
368
516
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
517
+ /* ── Lote D · T1: la tabla de 64 estados con CORTE DE CAMINO (tester ronda 2, §2.2) ─────────────────────────── */
518
+ /** Las tres preguntas de T1, en el orden de la tabla (v / e / o). */
519
+ const T1_QUESTIONS = ['viewer', 'editor', 'owner'];
520
+ /** Orden de los subconjuntos de la tabla del tester: ∅, {v}, {e}, {e,v}, {o}, {o,v}, {o,e}, {o,e,v} (bits v=1, e=2, o=4). */
521
+ function t1Subset(mask) {
522
+ const out = [];
523
+ if (mask & 4)
524
+ out.push('owner');
525
+ if (mask & 2)
526
+ out.push('editor');
527
+ if (mask & 1)
528
+ out.push('viewer');
529
+ return out;
530
+ }
531
+ /** La fila `row` (0..63) de T1: allows = subconjunto `row >> 3`, denies = subconjunto `row & 7`. */
532
+ function t1State(row) {
533
+ return { allows: t1Subset(row >> 3), denies: t1Subset(row & 7) };
534
+ }
535
+ /**
536
+ * **La tabla LITERAL del tester** (`panel-tenencia-2-tester.md` §2.2, 64 filas × v/e/o), copiada byte a byte: el
537
+ * oráculo de abajo tiene que reproducirla (si no, el caso es rojo antes de preguntar nada al driver), y el driver
538
+ * tiene que reproducir las dos. NO se calcula con `cutPathAllows` del paquete: un mutante de la regla cambiaría a la
539
+ * vez la respuesta y la expectativa.
540
+ */
541
+ const T1_EXPECTED = ('FFF FFF FFF FFF FFF FFF FFF FFF ' +
542
+ 'TFF FFF TFF FFF TFF FFF TFF FFF ' +
543
+ 'TTF FFF FFF FFF TTF FFF FFF FFF ' +
544
+ 'TTF FFF TFF FFF TTF FFF TFF FFF ' +
545
+ 'TTT FFF FFF FFF FFF FFF FFF FFF ' +
546
+ 'TTT FFF TFF FFF TFF FFF TFF FFF ' +
547
+ 'TTT FFF FFF FFF TTF FFF FFF FFF ' +
548
+ 'TTT FFF TFF FFF TTF FFF TFF FFF').split(' ');
549
+ /** Las 37 filas PAR del tester (alguna semántica plausible en UN solo driver da otra respuesta): las del BRIDGE. */
550
+ const T1_PAR = new Set([
551
+ 11, 13, 15, 18, 19, 21, 22, 23, 26, 27, 29, 30, 31, 34, 35, 36, 37, 38, 39, 42, 43, 44, 45, 46, 47, 50, 51, 52, 53,
552
+ 54, 55, 58, 59, 60, 61, 62, 63,
553
+ ]);
554
+ /** El retículo del contrato, escrito a mano (owner ⇒ editor ⇒ viewer). */
555
+ const T1_DOWN = { viewer: ['viewer', 'editor', 'owner'], editor: ['editor', 'owner'], owner: ['owner'] };
556
+ const T1_UP = { owner: ['owner', 'editor', 'viewer'], editor: ['editor', 'viewer'], viewer: ['viewer'] };
557
+ /** CORTE (D5), la fórmula del tester sobre el retículo a mano: ∃ T ∈ A ∩ down*(Q) con up*(T) ∩ D = ∅. */
558
+ function t1Oracle(allows, denies, q) {
559
+ return T1_DOWN[q].some((t) => allows.includes(t) && T1_UP[t].every((above) => !denies.includes(above)));
560
+ }
561
+ /** D9 (AMPLIO): ¿algún deny en up*(down*(Q))? */
562
+ function t1DeniedAbove(denies, q) {
563
+ return T1_DOWN[q].some((t) => T1_UP[t].some((above) => denies.includes(above)));
564
+ }
565
+ /** Las seis filas con NOMBRE (tester §2.2): cada una se lee sola cuando falla. */
566
+ const T1_NAMED = [
567
+ ['D-06 · un deny en viewer bloquea al owner: F/F/F', 34, 'FFF'],
568
+ ['D-07 · un deny en editor anula el grant de owner ENTERO: F/F/F (la fila del juez: mata a las siete semánticas)', 35, 'FFF'],
569
+ ['D-08 · …salvo un viewer directo: owner + viewer + deny(editor) ⇒ T/F/F', 43, 'TFF'],
570
+ ['D-09 · deny en owner sin otro grant: F/F/F (no sobrevive ni el viewer derivado)', 37, 'FFF'],
571
+ ['D-10 · owner + viewer + deny(owner) ⇒ T/F/F', 45, 'TFF'],
572
+ ['D-11 · un deny en una relación MÁS FUERTE no toca un grant más débil: viewer + deny(editor) ⇒ T/F/F', 11, 'TFF'],
573
+ ];
369
574
  /* ── El runner ───────────────────────────────────────────────────────────── */
370
575
  function holder() {
371
576
  return { type: 'user', uuid: uuidv7() };
@@ -389,8 +594,13 @@ function spy(driver) {
389
594
  let calls = 0;
390
595
  const wrapped = new Proxy(driver, {
391
596
  get(target, prop, receiver) {
597
+ // Un método OPCIONAL que el driver no trae se queda ausente (el manager decide por `typeof`: `checkMany`
598
+ // compone, `decideMany`/`deny` son 500): envolverlo lo haría aparecer.
599
+ if (typeof target[prop] !== 'function')
600
+ return Reflect.get(target, prop, receiver);
392
601
  // L-2: las CUATRO escrituras (también `purge*`, que reciben `{ transaction }`).
393
- if (prop === 'relate' || prop === 'unrelate' || prop === 'purgeObject' || prop === 'purgeSubject') {
602
+ // Lote D: también `deny`/`removeDeny` (escrituras del deny).
603
+ if (prop === 'relate' || prop === 'unrelate' || prop === 'purgeObject' || prop === 'purgeSubject' || prop === 'deny' || prop === 'removeDeny') {
394
604
  return async (...args) => {
395
605
  writes++;
396
606
  calls++;
@@ -399,7 +609,14 @@ function spy(driver) {
399
609
  }
400
610
  // alpha.3 (cierre) · las TRES lecturas también cuentan: F-05 tiene que
401
611
  // cortarlas ANTES del driver («cero llamadas» = ni una lectura).
402
- if (prop === 'check' || prop === 'listObjects' || prop === 'listSubjects') {
612
+ // Lote D: y `checkMany`/`decideMany`/`listDenies`/`membersOf` (las lecturas del lote, «cero llamadas» = ninguna).
613
+ if (prop === 'check' ||
614
+ prop === 'listObjects' ||
615
+ prop === 'listSubjects' ||
616
+ prop === 'checkMany' ||
617
+ prop === 'decideMany' ||
618
+ prop === 'listDenies' ||
619
+ prop === 'membersOf') {
403
620
  return async (...args) => {
404
621
  calls++;
405
622
  return target[prop](...args);
@@ -512,6 +729,46 @@ export function registerRelationsDriverContract(harness, api) {
512
729
  await state.manager.relate({ object: g2, relation: 'member' }, 'viewer', doc, p);
513
730
  assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
514
731
  });
732
+ /* ── R1 (lote «antes de RC»): el techo de anidación de grupos, si el driver lo declara ── */
733
+ test('R1 · groupDepthLimit (si el driver lo declara): con maxGroupDepth 3 + database.enforceGroupDepth el techo es ≤ 3 y se APLICA — la arista que deja una cadena más larga, por ARRIBA o por ABAJO, o que cierra un ciclo ⇒ 422 E_AUTHZ_GROUP_DEPTH_LIMIT sin escribir (check sigue F) y sin evento por el manager; la membresía de un holder y una relación de dominio no son anidación', async ({ assert, }) => {
734
+ const config = contractGroupDepthRelationsConfig(3);
735
+ const driver = await harness.makeDriver(config);
736
+ // `groupDepthLimit` es OPCIONAL en el puerto: un driver sin él no declara techo (el plan del reconcile no tiene
737
+ // nada que negar) y no hay gate que juzgar. Lo mismo un driver que responde `null` (sin techo con esta config).
738
+ const limit = typeof driver.groupDepthLimit === 'function' ? await driver.groupDepthLimit() : null;
739
+ if (limit === null)
740
+ return;
741
+ assert.isTrue(Number.isInteger(limit) && limit >= 1 && limit <= 3, `el techo con maxGroupDepth 3 tiene que estar en 1..3 (llegó ${String(limit)})`);
742
+ const events = [];
743
+ const manager = new RelationsManager(driver, config, { driverName: harness.name, onRelationWrite: (e) => void events.push(e) });
744
+ const p = partition();
745
+ const g = Array.from({ length: limit + 2 }, () => ({ type: 'group', id: docId() }));
746
+ const member = (o) => ({ object: o, relation: 'member' });
747
+ for (let i = 0; i < limit; i++)
748
+ await manager.relate(member(g[i]), 'member', g[i + 1], p);
749
+ const u = holder();
750
+ await manager.relate(u, 'member', g[0], p);
751
+ assert.isTrue(await manager.check(u, 'member', g[limit], p), `una cadena de ${limit} saltos (el techo) entra y resuelve`);
752
+ const before = events.length;
753
+ const refused = async (label, run) => {
754
+ const error = await run().then(() => null, (e) => e);
755
+ assert.equal(error?.status, 422, `${label}: ${error?.message ?? 'entró'}`);
756
+ assert.equal(error?.code, 'E_AUTHZ_GROUP_DEPTH_LIMIT', label);
757
+ return error;
758
+ };
759
+ await refused('por ARRIBA (la cima de la cadena dentro de otro grupo)', () => manager.relate(member(g[limit]), 'member', g[limit + 1], p));
760
+ assert.isFalse(await manager.check(u, 'member', g[limit + 1], p), 'la arista rechazada no se escribió');
761
+ const x = { type: 'group', id: docId() };
762
+ await refused('por ABAJO (otro grupo dentro de la base de la cadena)', () => manager.relate(member(x), 'member', g[0], p));
763
+ const v = holder();
764
+ await manager.relate(v, 'member', x, p);
765
+ assert.isFalse(await manager.check(v, 'member', g[0], p), 'x ⊂ g0 no se escribió');
766
+ await refused('el CICLO', () => manager.relate(member(g[limit]), 'member', g[0], p));
767
+ assert.equal(events.length, before + 1, '422 ⇒ esa escritura no ocurrió y no hay evento (invariante 13): solo el del holder v');
768
+ // No es anidación: la membresía de un holder y una relación de dominio sobre la cima.
769
+ await manager.relate(holder(), 'member', g[limit], p);
770
+ await manager.relate(member(g[limit]), 'viewer', { type: 'document', id: docId() }, p);
771
+ });
515
772
  /* ── R-05: la partición aísla ── */
516
773
  test('R-05 · partición: relate en A no concede en B', async ({ assert }) => {
517
774
  const u = holder();
@@ -559,6 +816,93 @@ export function registerRelationsDriverContract(harness, api) {
559
816
  const page = await state.manager.listObjects(u, 'viewer', 'document', p);
560
817
  assert.sameMembers(page.objects.map((o) => o.id).sort(), [d1.id, d2.id].sort());
561
818
  });
819
+ /* ── 7.4 (lote «antes de RC», opción 4 del juez): los listados NO paginan ── */
820
+ /**
821
+ * **P13 · «sin página ⇒ lista completa y sin cursor»** (injerto 1 del juez; auditor R1-2, tester P13). N = 150:
822
+ * por encima del default de 100 de `enumerate*` de `database` y del `page_size` 100 de `Read` en OpenFGA, así que
823
+ * un driver que pagina por dentro y devuelve la PRIMERA página (o un cursor que nadie lee) es rojo aquí. El tipo ya
824
+ * no tiene `cursor` (protege a quien compila); este caso protege en EJECUCIÓN frente a un driver de terceros y a un
825
+ * default de `limit` que alguien meta en 2.6. `listObjects` puede venir `truncated` SOLO si el driver declara
826
+ * `listObjectsTruncation` (y entonces lo que trae ⊆ lo escrito); los demás, el conjunto exacto.
827
+ */
828
+ test('7.4 · P13 · sin page los listados son EXHAUSTIVOS y sin cursor: N=150 (> 100 de enumerate y del page_size de Read) en listSubjects y listObjects —y listDenies con relationDenies, membersOf con membersOfNative—; listObjects truncated solo con listObjectsTruncation', async ({ assert, }) => {
829
+ const N = 150;
830
+ const p = partition();
831
+ const users = Array.from({ length: N }, () => holder());
832
+ const ids = (subjects) => subjects.map((s) => s.uuid).sort();
833
+ const expected = users.map((u) => u.uuid).sort();
834
+ const doc = { type: 'document', id: docId() };
835
+ for (const u of users)
836
+ await state.manager.relate(u, 'viewer', doc, p);
837
+ const subjects = await state.manager.listSubjects('viewer', doc, p);
838
+ assert.notProperty(subjects, 'cursor', 'listSubjects sin page no devuelve cursor');
839
+ assert.notEqual(subjects.truncated, true, 'listSubjects no corta (Read se agota o lanza)');
840
+ assert.deepEqual(ids(subjects.subjects), expected, `listSubjects devuelve los ${N}, no una página`);
841
+ const reader = holder();
842
+ const docs = Array.from({ length: N }, () => docId());
843
+ for (const id of docs)
844
+ await state.manager.relate(reader, 'viewer', { type: 'document', id }, p);
845
+ const objects = await state.manager.listObjects(reader, 'viewer', 'document', p);
846
+ assert.notProperty(objects, 'cursor', 'listObjects sin page no devuelve cursor');
847
+ const got = objects.objects.map((o) => o.id).sort();
848
+ if (objects.truncated === true) {
849
+ assert.isTrue(harness.capabilities.listObjectsTruncation, 'truncated sin declarar listObjectsTruncation');
850
+ assert.includeMembers(docs, got, 'lo que trae una lista truncada es de lo escrito');
851
+ }
852
+ else {
853
+ assert.deepEqual(got, [...docs].sort(), `listObjects devuelve los ${N}, no una página`);
854
+ }
855
+ if (harness.capabilities.membersOfNative) {
856
+ const group = { type: 'group', id: docId() };
857
+ for (const u of users)
858
+ await state.manager.relate(u, 'member', group, p);
859
+ const members = await state.manager.membersOf(group, 'member', p);
860
+ assert.notProperty(members, 'cursor', 'membersOf sin page no devuelve cursor');
861
+ assert.deepEqual(ids(members.subjects), expected, `membersOf devuelve los ${N}`);
862
+ }
863
+ if (harness.capabilities.relationDenies) {
864
+ const { manager } = await denyManager();
865
+ const denied = { type: 'document', id: docId() };
866
+ for (const u of users)
867
+ await manager.deny(u, 'viewer', denied, p);
868
+ const denies = await manager.listDenies('viewer', denied, p);
869
+ assert.notProperty(denies, 'cursor', 'listDenies sin page no devuelve cursor');
870
+ assert.deepEqual(ids(denies.subjects), expected, `listDenies devuelve los ${N}`);
871
+ }
872
+ });
873
+ /**
874
+ * **RC74-1 · página en los cuatro listados ⇒ 500, cero llamadas** (capa MANAGER, sobre el driver del harness). En
875
+ * 2.6 este caso pasa a ser la cara `whenFalse` de cada capacidad de paginación. `undefined` EXPLÍCITO entra
876
+ * (RC74-3). No se exige al driver en directo: obligaría a un tercero a un 500 por un parámetro que ya no está en
877
+ * el tipo (lo cubren la guardia y los specs de los drivers del paquete).
878
+ */
879
+ test('7.4 · RC74-1 · listObjects/listSubjects/listDenies/membersOf con page ({ limit: 1 }, null, {}) ⇒ 500 E_AUTHZ_UNSUPPORTED nombrando la operación, con CERO llamadas al driver; undefined explícito entra (RC74-3)', async ({ assert, }) => {
880
+ const u = holder();
881
+ const doc = { type: 'document', id: docId() };
882
+ const group = { type: 'group', id: docId() };
883
+ const p = partition();
884
+ const m = state.manager;
885
+ const calls = [
886
+ ['listObjects', (page) => m.listObjects(u, 'viewer', 'document', p, page)],
887
+ ['listSubjects', (page) => m.listSubjects('viewer', doc, p, page)],
888
+ ['listDenies', (page) => m.listDenies('viewer', doc, p, page)],
889
+ ['membersOf', (page) => m.membersOf(group, 'member', p, page)],
890
+ ];
891
+ for (const [operation, call] of calls) {
892
+ for (const page of [{ limit: 1 }, null, {}]) {
893
+ const before = state.calls();
894
+ const caught = await call(page).then(() => null, (e) => e);
895
+ assert.isNotNull(caught, `${operation}(…, ${JSON.stringify(page)}) entró: una página que no hace nada es una lista entera que parece una página`);
896
+ assert.equal(caught.status, 500, `${operation}: ${caught.message}`);
897
+ assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED', `${operation}: ${caught.message}`);
898
+ assert.include(caught.message, operation);
899
+ assert.equal(state.calls(), before, `${operation} con page: CERO llamadas al driver`);
900
+ }
901
+ }
902
+ // RC74-3: el argumento `undefined` explícito es «sin página» (quien reenvía un opcional vacío no se rompe).
903
+ assert.isArray((await m.listObjects(u, 'viewer', 'document', p, undefined)).objects);
904
+ assert.isArray((await m.listSubjects('viewer', doc, p, undefined)).subjects);
905
+ });
562
906
  /* ── R-12: identidad mal formada ⇒ 422 ── */
563
907
  test('R-12 · partición mal formada ⇒ 422 con code', async ({ assert }) => {
564
908
  const u = holder();
@@ -1571,6 +1915,610 @@ export function registerRelationsDriverContract(harness, api) {
1571
1915
  assert.include(atResolve.message, 'transactionalWrites');
1572
1916
  }),
1573
1917
  });
1918
+ /* ── Lote D · D1 · el par `relationDenies` (D5 corte de camino, D8, D9, D10, D3, S8–S10, S12–S15, P-2) ── */
1919
+ /** Un manager sobre la config del contrato CON deny (`document` 'write', `workspace` 'read', `note` sin opt-in). */
1920
+ async function denyManager(options) {
1921
+ const config = contractDenyRelationsConfig();
1922
+ const base = await harness.makeDriver(config);
1923
+ const s = spy(base);
1924
+ return {
1925
+ manager: new RelationsManager(s.driver, config, { driverName: harness.name, ...options }),
1926
+ base,
1927
+ config,
1928
+ writes: s.writes,
1929
+ calls: s.calls,
1930
+ };
1931
+ }
1932
+ /** Siembra un estado de T1 sobre un objeto: allows `A` y denies `D` (subconjuntos de owner/editor/viewer) para `u`. */
1933
+ async function seedState(m, u, object, p, allows, denies) {
1934
+ for (const r of allows)
1935
+ await m.relate(u, r, object, p);
1936
+ for (const r of denies)
1937
+ await m.deny(u, r, object, p);
1938
+ }
1939
+ /* ── Estrategia 2.5.0 · firma 4 · el par `experimental` (regla D15; mismo nombre que en roles) ── */
1940
+ // Dice lo que el consumidor tiene que saber («fuera de la garantía de la versión») y no apaga casos: el runner corre
1941
+ // ENTERO contra un driver experimental. Se observa la declaración del driver y la copia normalizada del manager.
1942
+ caseFor('experimental', {
1943
+ whenTrue: () => test('experimental:true · el driver se declara EXPERIMENTAL (capabilities.experimental === true) y `relations.capabilities()` lo publica tal cual; el runner corre igual contra él', async ({ assert }) => {
1944
+ assert.strictEqual(state.base.capabilities?.experimental, true, 'el harness declara experimental: true y el driver no: declara lo observable');
1945
+ const published = state.manager.capabilities();
1946
+ assert.strictEqual(published.experimental, true);
1947
+ assert.isTrue(Object.isFrozen(published));
1948
+ }),
1949
+ whenFalse: () => test('experimental:false · el driver NO se declara experimental (ausente o false) y `relations.capabilities().experimental` es false, nunca undefined (D15)', async ({ assert }) => {
1950
+ assert.notStrictEqual(state.base.capabilities?.experimental, true, 'el harness declara experimental: false y el driver declara true: declara lo observable');
1951
+ assert.strictEqual(state.manager.capabilities().experimental, false);
1952
+ }),
1953
+ });
1954
+ /* ── Cierre 2 de D2 · 🟠 N1 · el par `atomicRenewal` (renovar una membresía que TRANSPORTA un deny) ── */
1955
+ /**
1956
+ * Siembra: U ∈ g con caducidad (+1 h), un allow DIRECTO de U sobre X y —si el driver deniega— el deny de `g#member`
1957
+ * sobre X; además U ∈ h (+1 h) con h ⊂ g (portadora ANIDADA) y U ∈ k (+1 h) sin deny. Devuelve la caducidad sembrada.
1958
+ */
1959
+ async function seedRenewal(m, denies) {
1960
+ const u = holder();
1961
+ const p = partition();
1962
+ const x = { type: 'document', id: docId() };
1963
+ const g = { type: 'group', id: docId() };
1964
+ const h = { type: 'group', id: docId() };
1965
+ const k = { type: 'group', id: docId() };
1966
+ const hour = new Date(Date.now() + 3_600_000);
1967
+ await m.relate(u, 'member', g, p, { expiresAt: hour });
1968
+ if (harness.capabilities.usersetSubjects)
1969
+ await m.relate({ object: h, relation: 'member' }, 'member', g, p);
1970
+ await m.relate(u, 'member', h, p, { expiresAt: hour });
1971
+ await m.relate(u, 'member', k, p, { expiresAt: hour });
1972
+ await m.relate(u, 'viewer', x, p);
1973
+ if (denies && harness.capabilities.usersetSubjects)
1974
+ await m.deny({ object: g, relation: 'member' }, 'viewer', x, p);
1975
+ return { u, p, x, g, h, k, hour };
1976
+ }
1977
+ /** La caducidad de la membresía de U en `group` según la enumeración del driver (si la trae); `undefined` si no. */
1978
+ async function membershipExpiry(base, u, group, p) {
1979
+ if (!harness.capabilities.enumerateRelations || typeof base.enumerateRelations !== 'function')
1980
+ return undefined;
1981
+ const page = await base.enumerateRelations(p);
1982
+ const tuple = page.tuples.find((t) => t.object.type === 'group' && t.object.id === group.id && !isRelUserset(t.subject) && t.subject.uuid === u.uuid);
1983
+ return tuple ? (tuple.expiresAt ?? null) : undefined;
1984
+ }
1985
+ caseFor('atomicRenewal', {
1986
+ whenTrue: () => test('atomicRenewal:true · renovar (otra caducidad) una membresía que TRANSPORTA un deny —directa y ANIDADA— entra en UNA escritura: la caducidad cambia y el deny sigue negando', async ({ assert }) => {
1987
+ const { manager, base } = harness.capabilities.relationDenies ? await denyManager() : { manager: state.manager, base: state.base };
1988
+ const { u, p, x, g, h, k } = await seedRenewal(manager, harness.capabilities.relationDenies === true);
1989
+ for (const group of [g, h, k]) {
1990
+ await manager.relate(u, 'member', group, p, { expiresAt: null });
1991
+ const expiry = await membershipExpiry(base, u, group, p);
1992
+ if (expiry !== undefined)
1993
+ assert.isNull(expiry, `la membresía de ${group.id} quedó SIN caducidad`);
1994
+ }
1995
+ const carried = harness.capabilities.relationDenies === true && harness.capabilities.usersetSubjects === true;
1996
+ assert.equal(await manager.check(u, 'viewer', x, p), !carried, 'el deny transportado sigue negando tras renovar');
1997
+ }),
1998
+ whenFalse: () => test('atomicRenewal:false · renovar una membresía que TRANSPORTA un deny vivo —directa o por un grupo que la contiene— ⇒ 409 E_AUTHZ_RENEWAL_UNSAFE con la receta y CERO escrituras (la caducidad no cambia, el deny sigue negando); una membresía sin deny se renueva', async ({ assert }) => {
1999
+ const denies = harness.capabilities.relationDenies === true && harness.capabilities.usersetSubjects === true;
2000
+ const { manager, base } = harness.capabilities.relationDenies ? await denyManager() : { manager: state.manager, base: state.base };
2001
+ const { u, p, x, g, h, k, hour } = await seedRenewal(manager, denies);
2002
+ if (denies) {
2003
+ for (const group of [g, h]) {
2004
+ let caught;
2005
+ try {
2006
+ await manager.relate(u, 'member', group, p, { expiresAt: null });
2007
+ }
2008
+ catch (error) {
2009
+ caught = error;
2010
+ }
2011
+ assert.equal(caught?.status, 409, `renovar ${group.id === g.id ? 'la portadora' : 'la portadora ANIDADA'}: ${caught?.message}`);
2012
+ assert.equal(caught?.code, 'E_AUTHZ_RENEWAL_UNSAFE');
2013
+ assert.include(caught?.message, 'unrelate + relate');
2014
+ assert.include(caught?.message, 'No se ha escrito nada');
2015
+ const expiry = await membershipExpiry(base, u, group, p);
2016
+ if (expiry !== undefined)
2017
+ assert.equal(expiry?.getTime(), hour.getTime(), 'CERO escrituras: la caducidad no cambió');
2018
+ assert.isFalse(await manager.check(u, 'viewer', x, p), 'el deny sigue negando');
2019
+ }
2020
+ // Cierre 3 de D2 · ⚪ C2-4: una membresía portadora YA VENCIDA no conecta nada (no hay ventana que proteger) ⇒
2021
+ // reescribirla NO es 409; tampoco la de un grupo cuyo ÚNICO camino al deny es una arista de anidación vencida.
2022
+ const past = new Date(Date.now() - 3_600_000);
2023
+ const late = holder();
2024
+ await manager.relate(late, 'viewer', x, p);
2025
+ await manager.relate(late, 'member', g, p, { expiresAt: past });
2026
+ assert.isTrue(await manager.check(late, 'viewer', x, p), 'la membresía vencida no lleva el deny hasta el sujeto');
2027
+ await manager.relate(late, 'member', g, p, { expiresAt: null });
2028
+ assert.isFalse(await manager.check(late, 'viewer', x, p), 'reescrita: el deny de g vuelve a alcanzarle');
2029
+ const viaExpired = { type: 'group', id: docId() };
2030
+ await manager.relate({ object: viaExpired, relation: 'member' }, 'member', g, p, { expiresAt: past });
2031
+ const nested = holder();
2032
+ await manager.relate(nested, 'member', viaExpired, p, { expiresAt: hour });
2033
+ await manager.relate(nested, 'member', viaExpired, p, { expiresAt: null });
2034
+ const nestedExpiry = await membershipExpiry(base, nested, viaExpired, p);
2035
+ if (nestedExpiry !== undefined)
2036
+ assert.isNull(nestedExpiry, 'la arista vencida hacia g no transporta su deny: se renueva');
2037
+ }
2038
+ await manager.relate(u, 'member', k, p, { expiresAt: null });
2039
+ const expiry = await membershipExpiry(base, u, k, p);
2040
+ if (expiry !== undefined)
2041
+ assert.isNull(expiry, 'la membresía SIN deny se renueva');
2042
+ }),
2043
+ });
2044
+ caseFor('relationDenies', {
2045
+ whenTrue: () => {
2046
+ test('D-05 · corte de camino: las 192 respuestas de T1 (64 estados × viewer/editor/owner) por check; checkMany, decideMany (D9) y listObjects (D-15) dicen lo MISMO sobre los 64 objetos; P1-M monotonía (D-12)', async ({ assert, }) => {
2047
+ // El oráculo es INDEPENDIENTE del código del paquete (la tabla literal del tester §2.2 y su fórmula con el
2048
+ // retículo escrito a mano): si fuera `cutPathAllows`, un mutante de la regla cambiaría a la vez el driver y
2049
+ // la expectativa y viviría.
2050
+ for (let row = 0; row < 64; row++) {
2051
+ const { allows, denies } = t1State(row);
2052
+ const oracle = T1_QUESTIONS.map((q) => (t1Oracle(allows, denies, q) ? 'T' : 'F')).join('');
2053
+ assert.equal(oracle, T1_EXPECTED[row], `T1 fila ${row + 1}: el oráculo y la tabla del tester divergen`);
2054
+ }
2055
+ const { manager } = await denyManager();
2056
+ const u = holder();
2057
+ const p = partition();
2058
+ const objects = [];
2059
+ for (let row = 0; row < 64; row++) {
2060
+ const object = { type: 'document', id: docId() };
2061
+ objects.push(object);
2062
+ const { allows, denies } = t1State(row);
2063
+ await seedState(manager, u, object, p, allows, denies);
2064
+ }
2065
+ const observed = [];
2066
+ for (let row = 0; row < 64; row++) {
2067
+ let answer = '';
2068
+ for (const q of T1_QUESTIONS)
2069
+ answer += (await manager.check(u, q, objects[row], p)) ? 'T' : 'F';
2070
+ observed.push(answer);
2071
+ }
2072
+ const wrong = observed
2073
+ .map((got, row) => [row, got])
2074
+ .filter(([row, got]) => got !== T1_EXPECTED[row])
2075
+ .map(([row, got]) => `fila ${row + 1} (A=${t1State(row).allows.join('+') || '∅'} D=${t1State(row).denies.join('+') || '∅'}${T1_PAR.has(row + 1) ? ', PAR' : ''}): esperado ${T1_EXPECTED[row]} (v/e/o), obtenido ${got}`);
2076
+ assert.deepEqual(wrong, [], 'T1 · check con corte de camino');
2077
+ // checkMany (sin cambio de contrato) y decideMany (D9 AMPLIO), por lotes de ≤ 50, y listObjects (resta).
2078
+ for (const [qi, q] of T1_QUESTIONS.entries()) {
2079
+ const many = [];
2080
+ const decided = [];
2081
+ for (let start = 0; start < 64; start += RELATIONS_CHECK_MANY_MAX) {
2082
+ const batch = objects.slice(start, start + RELATIONS_CHECK_MANY_MAX);
2083
+ many.push(...(await manager.checkMany(u, q, batch, p)));
2084
+ decided.push(...(await manager.decideMany(u, q, batch, p)));
2085
+ }
2086
+ assert.deepEqual(many.map((b) => (b ? 'T' : 'F')).join(''), T1_EXPECTED.map((x) => x[qi]).join(''), `checkMany(${q}) honra el deny igual que check (sin cambio de contrato)`);
2087
+ const expectedDecisions = T1_EXPECTED.map((x, row) => (x[qi] === 'T' ? 'allow' : t1DeniedAbove(t1State(row).denies, q) ? 'deny' : 'none'));
2088
+ assert.deepEqual(decided, expectedDecisions, `decideMany(${q}) · D9 amplio sobre los 64 estados`);
2089
+ const page = await manager.listObjects(u, q, 'document', p);
2090
+ const listed = page.objects.map((o) => o.id);
2091
+ const expectedIds = objects.filter((_, row) => T1_EXPECTED[row][qi] === 'T').map((o) => o.id);
2092
+ const intruders = listed.filter((id) => !expectedIds.includes(id));
2093
+ assert.deepEqual(intruders, [], `listObjects(${q}) RESTA (D-15): ni un objeto denegado (S16: tampoco con el tope)`);
2094
+ if (page.truncated === true) {
2095
+ // S16 (tope bajo, :8103 o el doble con tope): la página cortada es un SUBCONJUNTO de lo concedido, y el
2096
+ // truncado solo se señala si hay concedidos de más — nunca porque los denegados llenen el tope.
2097
+ assert.isAbove(expectedIds.length, listed.length, `listObjects(${q}) · truncated solo si hay allows de más`);
2098
+ }
2099
+ else {
2100
+ assert.sameMembers(listed, expectedIds, `listObjects(${q}) RESTA (D-15) y es exhaustiva sin truncated`);
2101
+ }
2102
+ }
2103
+ // D-12 · P1-M: más allows o menos denies nunca quita acceso (sobre lo OBSERVADO).
2104
+ const nonMonotone = [];
2105
+ for (let a = 0; a < 64; a++) {
2106
+ for (let b = 0; b < 64; b++) {
2107
+ const A = t1State(a);
2108
+ const B = t1State(b);
2109
+ const moreOrEqual = A.allows.every((r) => B.allows.includes(r)) && B.denies.every((r) => A.denies.includes(r));
2110
+ if (!moreOrEqual)
2111
+ continue;
2112
+ for (let qi = 0; qi < 3; qi++) {
2113
+ if (observed[a][qi] === 'T' && observed[b][qi] === 'F')
2114
+ nonMonotone.push(`${a + 1}→${b + 1} (${T1_QUESTIONS[qi]})`);
2115
+ }
2116
+ }
2117
+ }
2118
+ assert.deepEqual(nonMonotone, [], 'D-12 · P1-M: monotonía en los 64 estados');
2119
+ });
2120
+ for (const [name, row, expected] of T1_NAMED) {
2121
+ test(`${name} (T1 fila ${row})`, async ({ assert }) => {
2122
+ const { manager } = await denyManager();
2123
+ const u = holder();
2124
+ const p = partition();
2125
+ const object = { type: 'document', id: docId() };
2126
+ const { allows, denies } = t1State(row - 1);
2127
+ await seedState(manager, u, object, p, allows, denies);
2128
+ const got = [];
2129
+ for (const q of T1_QUESTIONS)
2130
+ got.push((await manager.check(u, q, object, p)) ? 'T' : 'F');
2131
+ assert.equal(got.join(''), expected, `${name}: v/e/o`);
2132
+ });
2133
+ }
2134
+ test('D-01/D-02/D-03 · deny gana sobre el allow directo y removeDeny lo restaura; el ORDEN de escritura no importa; deny y removeDeny idempotentes (re-deny no duplica, un removeDeny levanta un deny repetido, re-removeDeny es no-op)', async ({ assert }) => {
2135
+ const { manager } = await denyManager();
2136
+ const p = partition();
2137
+ const u = holder();
2138
+ const a = { type: 'document', id: docId() };
2139
+ await manager.relate(u, 'viewer', a, p);
2140
+ assert.isTrue(await manager.check(u, 'viewer', a, p));
2141
+ await manager.deny(u, 'viewer', a, p);
2142
+ assert.isFalse(await manager.check(u, 'viewer', a, p), 'D-01: deny gana');
2143
+ await manager.removeDeny(u, 'viewer', a, p);
2144
+ assert.isTrue(await manager.check(u, 'viewer', a, p), 'D-01: removeDeny restaura');
2145
+ // D-02: deny ANTES que el allow, mismo resultado.
2146
+ const b = { type: 'document', id: docId() };
2147
+ await manager.deny(u, 'viewer', b, p);
2148
+ await manager.relate(u, 'viewer', b, p);
2149
+ assert.isFalse(await manager.check(u, 'viewer', b, p), 'D-02: deny→allow = allow→deny');
2150
+ // D-03: idempotencia.
2151
+ await manager.deny(u, 'viewer', b, p);
2152
+ await manager.deny(u, 'viewer', b, p);
2153
+ assert.lengthOf((await manager.listDenies('viewer', b, p)).subjects, 1, 're-deny no duplica');
2154
+ await manager.removeDeny(u, 'viewer', b, p);
2155
+ assert.isTrue(await manager.check(u, 'viewer', b, p), 'un removeDeny levanta un deny repetido');
2156
+ await manager.removeDeny(u, 'viewer', b, p);
2157
+ assert.isTrue(await manager.check(u, 'viewer', b, p), 're-removeDeny es no-op');
2158
+ assert.deepEqual((await manager.listDenies('viewer', b, p)).subjects, []);
2159
+ });
2160
+ test('D-04 · allow y deny del MISMO hecho conviven: unrelate no levanta el deny, removeDeny no quita el allow; listSubjects NO resta (D-15) y listDenies da los hechos directos', async ({ assert }) => {
2161
+ const { manager } = await denyManager();
2162
+ const p = partition();
2163
+ const u = holder();
2164
+ const v = holder();
2165
+ const a = { type: 'document', id: docId() };
2166
+ await manager.relate(u, 'viewer', a, p);
2167
+ await manager.relate(v, 'viewer', a, p);
2168
+ await manager.deny(u, 'viewer', a, p);
2169
+ const subjects = (await manager.listSubjects('viewer', a, p)).subjects.map((s) => s.uuid);
2170
+ assert.sameMembers(subjects, [u.uuid, v.uuid], 'listSubjects da HECHOS: no resta el deny');
2171
+ assert.deepEqual((await manager.listDenies('viewer', a, p)).subjects.map((s) => s.uuid), [u.uuid]);
2172
+ await manager.unrelate(u, 'viewer', a, p);
2173
+ await manager.relate(u, 'viewer', a, p);
2174
+ assert.isFalse(await manager.check(u, 'viewer', a, p), 'unrelate no tocó el deny');
2175
+ assert.lengthOf((await manager.listDenies('viewer', a, p)).subjects, 1);
2176
+ await manager.removeDeny(u, 'viewer', a, p);
2177
+ assert.isTrue(await manager.check(u, 'viewer', a, p), 'removeDeny no quitó el allow');
2178
+ assert.isTrue(await manager.check(v, 'viewer', a, p), 'el deny de u nunca tocó a v');
2179
+ });
2180
+ test('D-13 · deny a g#member bloquea a sus miembros, también ANIDADOS (g1 ⊂ g2, deny a g2#member); un deny DIRECTO bloquea aunque el allow llegue por grupo', async ({ assert }) => {
2181
+ const { manager } = await denyManager();
2182
+ const p = partition();
2183
+ const u = holder();
2184
+ const g1 = { type: 'group', id: docId() };
2185
+ const g2 = { type: 'group', id: docId() };
2186
+ await manager.relate(u, 'member', g1, p);
2187
+ await manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
2188
+ const a = { type: 'document', id: docId() };
2189
+ await manager.relate(u, 'owner', a, p);
2190
+ await manager.deny({ object: g2, relation: 'member' }, 'editor', a, p);
2191
+ assert.isFalse(await manager.check(u, 'viewer', a, p), 'deny(editor) a g2#member alcanza a u por g1 ⊂ g2 (fila 35)');
2192
+ const b = { type: 'document', id: docId() };
2193
+ await manager.relate({ object: g1, relation: 'member' }, 'viewer', b, p);
2194
+ assert.isTrue(await manager.check(u, 'viewer', b, p));
2195
+ await manager.deny(u, 'viewer', b, p);
2196
+ assert.isFalse(await manager.check(u, 'viewer', b, p), 'deny directo gana al allow que llega por el grupo');
2197
+ });
2198
+ test('D-14 · check de un USERSET: el deny de un miembro NO sube al grupo (check(g#member) sigue siendo la fila de g)', async ({ assert }) => {
2199
+ const { manager } = await denyManager();
2200
+ const p = partition();
2201
+ const u = holder();
2202
+ const g = { type: 'group', id: docId() };
2203
+ const a = { type: 'document', id: docId() };
2204
+ await manager.relate(u, 'member', g, p);
2205
+ await manager.relate({ object: g, relation: 'member' }, 'viewer', a, p);
2206
+ await manager.deny(u, 'viewer', a, p);
2207
+ assert.isFalse(await manager.check(u, 'viewer', a, p));
2208
+ assert.isTrue(await manager.check({ object: g, relation: 'member' }, 'viewer', a, p), 'el grupo conserva su viewer');
2209
+ });
2210
+ test('D-16 · purgeObject y purgeSubject borran también los denies (después del objeto, nada del sujeto sobrevive; re-relate concede)', async ({ assert }) => {
2211
+ const { manager } = await denyManager();
2212
+ const p = partition();
2213
+ const u = holder();
2214
+ const a = { type: 'document', id: docId() };
2215
+ await manager.relate(u, 'viewer', a, p);
2216
+ await manager.deny(u, 'viewer', a, p);
2217
+ await manager.purgeObject(a, p);
2218
+ assert.deepEqual((await manager.listDenies('viewer', a, p)).subjects, [], 'purgeObject borró el deny');
2219
+ await manager.relate(u, 'viewer', a, p);
2220
+ assert.isTrue(await manager.check(u, 'viewer', a, p));
2221
+ await manager.deny(u, 'viewer', a, p);
2222
+ await manager.purgeSubject(u, p);
2223
+ assert.deepEqual((await manager.listDenies('viewer', a, p)).subjects, [], 'purgeSubject borró el deny');
2224
+ await manager.relate(u, 'viewer', a, p);
2225
+ assert.isTrue(await manager.check(u, 'viewer', a, p), 'ni allow ni deny viejos: el re-relate concede');
2226
+ });
2227
+ test('D-15b · membersOf RESTA (si el driver es nativo): un miembro TRANSITIVO con deny directo, o alcanzado por un deny al grupo, no sale; sin deny, la lista de siempre', async ({ assert }) => {
2228
+ if (!harness.capabilities.membersOfNative) {
2229
+ // Cara sin membersOf nativo: la operación es 500 E_AUTHZ_UNSUPPORTED (su par lo juzga); aquí se afirma que el deny no la abre.
2230
+ const { manager } = await denyManager();
2231
+ const caught = await manager.membersOf({ type: 'document', id: docId() }, 'viewer', partition()).then(() => null, (e) => e);
2232
+ assert.equal(caught?.code, 'E_AUTHZ_UNSUPPORTED');
2233
+ return;
2234
+ }
2235
+ const { manager } = await denyManager();
2236
+ const p = partition();
2237
+ const a = holder();
2238
+ const b = holder();
2239
+ const c = holder();
2240
+ const g = { type: 'group', id: docId() };
2241
+ const doc = { type: 'document', id: docId() };
2242
+ await manager.relate(a, 'member', g, p);
2243
+ await manager.relate(b, 'member', g, p);
2244
+ await manager.relate({ object: g, relation: 'member' }, 'viewer', doc, p);
2245
+ await manager.relate(c, 'owner', doc, p);
2246
+ const before = (await manager.membersOf(doc, 'viewer', p)).subjects.map((s) => s.uuid);
2247
+ assert.sameMembers(before, [a.uuid, b.uuid, c.uuid]);
2248
+ await manager.deny(b, 'viewer', doc, p);
2249
+ await manager.deny(c, 'editor', doc, p);
2250
+ const after = (await manager.membersOf(doc, 'viewer', p)).subjects.map((s) => s.uuid);
2251
+ assert.sameMembers(after, [a.uuid], 'b (deny directo) y c (owner cortado por deny(editor), fila 35) no salen');
2252
+ });
2253
+ test('S8–S10 · FILAS DECLARADAS (quitar acceso que DA acceso): unrelate(u, member, g) con deny a g#member ⇒ u recupera su allow directo (S8); purgeObject(group:g) ⇒ igual (S9); purgeSubject(g#member) borra los denies del grupo (S10)', async ({ assert }) => {
2254
+ const { manager } = await denyManager();
2255
+ const run = async (lift, label) => {
2256
+ const p = partition();
2257
+ const u = holder();
2258
+ const g = { type: 'group', id: docId() };
2259
+ const a = { type: 'document', id: docId() };
2260
+ await manager.relate(u, 'viewer', a, p);
2261
+ await manager.relate(u, 'member', g, p);
2262
+ await manager.deny({ object: g, relation: 'member' }, 'viewer', a, p);
2263
+ assert.isFalse(await manager.check(u, 'viewer', a, p), `${label}: antes, el deny del grupo bloquea`);
2264
+ await lift(u, g, p);
2265
+ assert.isTrue(await manager.check(u, 'viewer', a, p), `${label}: FILA DECLARADA — la escritura que «quita» devuelve el acceso`);
2266
+ };
2267
+ await run((u, g, p) => manager.unrelate(u, 'member', g, p), 'S8 · salir del grupo denegado');
2268
+ await run((_u, g, p) => manager.purgeObject(g, p), 'S9 · purgar el grupo');
2269
+ await run((_u, g, p) => manager.purgeSubject({ object: g, relation: 'member' }, p), 'S10 · purgar el userset del grupo');
2270
+ });
2271
+ test('D-17 · deny sobre un tipo SIN opt-in (note), en FASE 1 (workspace, deny: read), sobre group, con `*` o con un userset ≠ group#member ⇒ 422 con CERO llamadas al driver y CERO eventos; removeDeny y listDenies SÍ aceptan esos tipos declarados (D8: retirar el opt-in es reversible)', async ({ assert }) => {
2272
+ const events = [];
2273
+ const f = await denyManager({ onRelationWrite: (e) => void events.push(e) });
2274
+ const p = partition();
2275
+ const u = holder();
2276
+ const cases = [
2277
+ ['tipo sin opt-in', () => f.manager.deny(u, 'viewer', { type: 'note', id: docId() }, p), 'E_AUTHZ_RELATION_NOT_DENYABLE'],
2278
+ ["fase 1 (deny: 'read')", () => f.manager.deny(u, 'viewer', { type: 'workspace', id: docId() }, p), 'E_AUTHZ_RELATION_NOT_DENYABLE'],
2279
+ ['group', () => f.manager.deny(u, 'member', { type: 'group', id: docId() }, p), 'E_AUTHZ_RELATION_NOT_DENYABLE'],
2280
+ ["comodín '*'", () => f.manager.deny({ type: 'user', uuid: '*' }, 'viewer', { type: 'document', id: docId() }, p), 'E_AUTHZ_RELATION_NOT_DENYABLE'],
2281
+ ['userset ≠ group#member', () => f.manager.deny({ object: { type: 'document', id: docId() }, relation: 'viewer' }, 'viewer', { type: 'document', id: docId() }, p), 'E_AUTHZ_RELATION_USERSET_UNSUPPORTED'],
2282
+ ];
2283
+ for (const [label, run, code] of cases) {
2284
+ const caught = await run().then(() => null, (e) => e);
2285
+ assert.isNotNull(caught, `${label}: entró`);
2286
+ assert.equal(caught?.status, 422, `${label}: ${caught?.message}`);
2287
+ assert.equal(caught?.code, code, `${label}: ${caught?.message}`);
2288
+ }
2289
+ assert.equal(f.calls(), 0, 'CERO llamadas al driver');
2290
+ assert.lengthOf(events, 0, 'CERO eventos');
2291
+ for (const type of ['note', 'workspace']) {
2292
+ await f.manager.removeDeny(u, 'viewer', { type, id: docId() }, p);
2293
+ assert.deepEqual((await f.manager.listDenies('viewer', { type, id: docId() }, p)).subjects, [], `${type}: listDenies responde`);
2294
+ }
2295
+ });
2296
+ test('D-19 (reescrito por D3) · expiresAt en deny/removeDeny ⇒ 422 E_AUTHZ_DENY_EXPIRY_UNSUPPORTED con la razón medida (Date y null), CERO llamadas', async ({ assert }) => {
2297
+ const f = await denyManager();
2298
+ const p = partition();
2299
+ const a = { type: 'document', id: docId() };
2300
+ for (const expiresAt of [new Date(Date.now() + 60_000), null]) {
2301
+ for (const op of ['deny', 'removeDeny']) {
2302
+ const caught = await f.manager[op](holder(), 'viewer', a, p, { expiresAt }).then(() => null, (e) => e);
2303
+ assert.equal(caught?.status, 422, `${op}(${String(expiresAt)}): ${caught?.message}`);
2304
+ assert.equal(caught?.code, 'E_AUTHZ_DENY_EXPIRY_UNSUPPORTED');
2305
+ assert.include(String(caught?.message), 'M5');
2306
+ }
2307
+ }
2308
+ assert.equal(f.calls(), 0);
2309
+ });
2310
+ test('D-20 · eventos: deny/removeDeny notifican con operation y el nombre PÚBLICO de la relación, el actor y sin expiresAt; requireActor los alcanza; assertWrite ve operation deny/removeDeny y si lanza no hay escritura ni evento', async ({ assert }) => {
2311
+ const events = [];
2312
+ const seen = [];
2313
+ const actor = holder();
2314
+ const f = await denyManager({
2315
+ onRelationWrite: (e) => void events.push(e),
2316
+ assertWrite: (ref) => {
2317
+ seen.push(ref.operation);
2318
+ if (ref.object.id === 'vetado')
2319
+ throw new Error('policy');
2320
+ },
2321
+ });
2322
+ const p = partition();
2323
+ const u = holder();
2324
+ const a = { type: 'document', id: docId() };
2325
+ await f.manager.deny(u, 'viewer', a, p, { actor });
2326
+ await f.manager.removeDeny(u, 'viewer', a, p, { actor });
2327
+ assert.deepEqual(events.map((e) => e.operation), ['deny', 'removeDeny']);
2328
+ assert.equal(events[0].relation, 'viewer', 'el nombre PÚBLICO, nunca denied_viewer');
2329
+ assert.deepEqual(events[0].actor, actor);
2330
+ assert.notProperty(events[0], 'expiresAt');
2331
+ assert.deepEqual(seen, ['deny', 'removeDeny']);
2332
+ const vetoed = await f.manager.deny(u, 'viewer', { type: 'document', id: 'vetado' }, p).then(() => null, (e) => e);
2333
+ assert.equal(vetoed?.message, 'policy');
2334
+ assert.lengthOf(events, 2, 'assertWrite que lanza: sin evento');
2335
+ const strict = await denyManager({ requireActor: true });
2336
+ const missing = await strict.manager.deny(u, 'viewer', a, p).then(() => null, (e) => e);
2337
+ assert.equal(missing?.code, 'E_AUTHZ_ACTOR_REQUIRED');
2338
+ const missing2 = await strict.manager.removeDeny(u, 'viewer', a, p).then(() => null, (e) => e);
2339
+ assert.equal(missing2?.code, 'E_AUTHZ_ACTOR_REQUIRED');
2340
+ assert.equal(strict.calls(), 0);
2341
+ });
2342
+ test('D9 · decideMany: fila 35 preguntada en viewer ⇒ deny (amplio); fila 43 ⇒ allow; nada ⇒ none; el caso Nóminas (deny en Nóminas con allow heredado de Finanzas ⇒ [none, deny, allow] recorriendo hoja → raíz); orden y duplicados por posición', async ({ assert }) => {
2343
+ const { manager } = await denyManager();
2344
+ const p = partition();
2345
+ const u = holder();
2346
+ const row35 = { type: 'document', id: docId() };
2347
+ const row43 = { type: 'document', id: docId() };
2348
+ const nothing = { type: 'document', id: docId() };
2349
+ await seedState(manager, u, row35, p, ['owner'], ['editor']);
2350
+ await seedState(manager, u, row43, p, ['owner', 'viewer'], ['editor']);
2351
+ assert.deepEqual(await manager.decideMany(u, 'viewer', [row35, row43, nothing, row35], p), ['deny', 'allow', 'none', 'deny']);
2352
+ // Nóminas (juez ronda 2 §4; auditor R2-1): la cadena la recorre el LLAMANTE.
2353
+ const informe = { type: 'document', id: docId() };
2354
+ const nominas = { type: 'document', id: docId() };
2355
+ const finanzas = { type: 'document', id: docId() };
2356
+ await manager.relate(u, 'viewer', finanzas, p);
2357
+ await manager.deny(u, 'viewer', nominas, p);
2358
+ assert.deepEqual(await manager.decideMany(u, 'viewer', [informe, nominas, finanzas], p), ['none', 'deny', 'allow'], 'el primer no-none desde la hoja es deny');
2359
+ assert.deepEqual(await manager.checkMany(u, 'viewer', [informe, nominas, finanzas], p), [false, false, true], 'con checkMany el mismo escenario es INDISTINGUIBLE (limitación declarada)');
2360
+ });
2361
+ test(`D9 · decideMany con las MISMAS garantías que checkMany: ${RELATIONS_CHECK_MANY_MAX} responde, ${RELATIONS_CHECK_MANY_MAX + 1} y [] ⇒ 422 E_AUTHZ_CHECK_MANY_BATCH; F-05 por ÍTEM (role_binding en la última posición) y un id inválido ⇒ 422; todo con CERO llamadas`, async ({ assert }) => {
2362
+ const f = await denyManager();
2363
+ const p = partition();
2364
+ const u = holder();
2365
+ const ids = Array.from({ length: RELATIONS_CHECK_MANY_MAX }, () => ({ type: 'document', id: docId() }));
2366
+ assert.lengthOf(await f.manager.decideMany(u, 'viewer', ids, p), RELATIONS_CHECK_MANY_MAX);
2367
+ const before = f.calls();
2368
+ for (const objects of [[...ids, { type: 'document', id: docId() }], []]) {
2369
+ const caught = await f.manager.decideMany(u, 'viewer', objects, p).then(() => null, (e) => e);
2370
+ assert.equal(caught?.code, 'E_AUTHZ_CHECK_MANY_BATCH');
2371
+ assert.include(String(caught?.message), 'decideMany');
2372
+ }
2373
+ const typed = await f.manager.decideMany(u, 'viewer', [...ids.slice(0, 20), { type: 'role_binding', id: docId() }], p).then(() => null, (e) => e);
2374
+ assert.equal(typed?.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN');
2375
+ const badId = await f.manager.decideMany(u, 'viewer', [...ids.slice(0, 3), { type: 'document', id: 'A#B' }], p).then(() => null, (e) => e);
2376
+ assert.equal(badId?.code, 'E_AUTHZ_INVALID_IDENTITY');
2377
+ assert.equal(f.calls(), before, 'CERO llamadas: el lote se juzga antes');
2378
+ });
2379
+ test('P-2 (COGNITIV) · decideMany para un holder de OTRA organización responde por la partición del OBJETO — y su gemelo en check/checkMany: el mismo holder con allow en A y deny en B sobre el mismo id', async ({ assert }) => {
2380
+ const { manager } = await denyManager();
2381
+ const orgA = partition();
2382
+ const orgB = partition();
2383
+ const guest = holder();
2384
+ const shared = { type: 'document', id: docId() };
2385
+ await manager.relate(guest, 'viewer', shared, orgA);
2386
+ await manager.relate(guest, 'viewer', shared, orgB);
2387
+ await manager.deny(guest, 'viewer', shared, orgB);
2388
+ assert.deepEqual(await manager.decideMany(guest, 'viewer', [shared], orgA), ['allow'], 'decideMany responde por la partición del objeto (A)');
2389
+ assert.deepEqual(await manager.decideMany(guest, 'viewer', [shared], orgB), ['deny'], 'y en B, por B');
2390
+ assert.isTrue(await manager.check(guest, 'viewer', shared, orgA), 'gemelo check en A');
2391
+ assert.isFalse(await manager.check(guest, 'viewer', shared, orgB), 'gemelo check en B');
2392
+ assert.deepEqual(await manager.checkMany(guest, 'viewer', [shared], orgA), [true], 'gemelo checkMany en A');
2393
+ assert.deepEqual(await manager.checkMany(guest, 'viewer', [shared], orgB), [false], 'gemelo checkMany en B');
2394
+ });
2395
+ test('D-30 · un alias de la partición jamás evade un deny: con el deny en la canónica, la ortografía sin guiones es 422 en check/checkMany/decideMany/listObjects/deny/removeDeny/listDenies (nunca true)', async ({ assert }) => {
2396
+ const { manager } = await denyManager();
2397
+ const canonical = { type: 'unit', uuid: uuidv7() };
2398
+ const alias = { type: 'unit', uuid: canonical.uuid.replace(/-/g, '') };
2399
+ const u = holder();
2400
+ const a = { type: 'document', id: docId() };
2401
+ await manager.relate(u, 'viewer', a, canonical);
2402
+ await manager.deny(u, 'viewer', a, canonical);
2403
+ const runs = [
2404
+ ['check', () => manager.check(u, 'viewer', a, alias)],
2405
+ ['checkMany', () => manager.checkMany(u, 'viewer', [a], alias)],
2406
+ ['decideMany', () => manager.decideMany(u, 'viewer', [a], alias)],
2407
+ ['listObjects', () => manager.listObjects(u, 'viewer', 'document', alias)],
2408
+ ['deny', () => manager.deny(u, 'viewer', a, alias)],
2409
+ ['removeDeny', () => manager.removeDeny(u, 'viewer', a, alias)],
2410
+ ['listDenies', () => manager.listDenies('viewer', a, alias)],
2411
+ ];
2412
+ for (const [op, run] of runs) {
2413
+ const caught = await run().then((r) => ({ answered: r }), (e) => e);
2414
+ assert.equal(caught?.code, 'E_AUTHZ_INVALID_IDENTITY', `${op} por el alias: ${JSON.stringify(caught?.answered ?? caught?.message)}`);
2415
+ }
2416
+ assert.isFalse(await manager.check(u, 'viewer', a, canonical), 'el deny sigue en la canónica');
2417
+ });
2418
+ test("denyPhase · (cierre 1 de D2 · ⚪ 9) el DRIVER responde la fase de SU config por tipo (document 'write', workspace 'read', note null) y es la verdad sobre deny(): 'write' ⇒ entra y check F; 'read' y null ⇒ 422 NOT_DENYABLE sin escribir; F-05 (tipo/relación no declarados, relación ausente) ⇒ 422 — la puerta del DESTINO de authz:relations:reconcile", async ({ assert }) => {
2419
+ const config = contractDenyRelationsConfig();
2420
+ const driver = await harness.makeDriver(config);
2421
+ assert.isFunction(driver.denyPhase, 'un driver que declara relationDenies trae denyPhase (el reconcile lo exige: motivo no-deny-phase)');
2422
+ const p = partition();
2423
+ for (const [type, expected] of Object.entries(RELATIONS_CONTRACT_DENY_TYPES)) {
2424
+ const phase = await driver.denyPhase({ type }, 'viewer');
2425
+ assert.equal(phase, expected ?? null, `denyPhase(${type}) = la fase de la config del DRIVER`);
2426
+ const u = holder();
2427
+ const object = { type, id: docId() };
2428
+ await driver.relate(u, 'viewer', object, p);
2429
+ const wrote = await driver.deny(u, 'viewer', object, p).then(() => null, (e) => e);
2430
+ if (phase === 'write') {
2431
+ assert.isNull(wrote, `${type}: 'write' ⇒ deny() entra (${wrote?.message})`);
2432
+ assert.isFalse(await driver.check(u, 'viewer', object, p), `${type}: el deny escrito niega`);
2433
+ }
2434
+ else {
2435
+ assert.equal(wrote?.status, 422, `${type}: '${phase}' ⇒ deny() 422 (${wrote?.message})`);
2436
+ assert.equal(wrote?.code, 'E_AUTHZ_RELATION_NOT_DENYABLE');
2437
+ assert.isTrue(await driver.check(u, 'viewer', object, p), `${type}: la negativa no escribió nada`);
2438
+ }
2439
+ }
2440
+ for (const [label, run, code] of [
2441
+ ['tipo no declarado', () => driver.denyPhase({ type: 'role_binding' }, 'assignee'), 'E_AUTHZ_RELATION_TYPE_UNKNOWN'],
2442
+ ['relación no declarada', () => driver.denyPhase({ type: 'document' }, 'approver'), 'E_AUTHZ_RELATION_UNKNOWN'],
2443
+ ['relación ausente', () => driver.denyPhase({ type: 'document' }, undefined), 'E_AUTHZ_RELATION_UNKNOWN'],
2444
+ ]) {
2445
+ const caught = await run().then((answer) => ({ answer }), (e) => e);
2446
+ assert.equal(caught?.code, code, `F-05 · ${label}: ${JSON.stringify(caught?.answer ?? caught?.message)}`);
2447
+ }
2448
+ });
2449
+ test('M6 · el deny TRANSPORTADO por una membresía de grupo con caducidad: viva ⇒ deny (check F, decideMany deny); caducada ⇒ el allow vuelve (check T, decideMany allow); S13: un solo now por lote en decideMany', async ({ assert }) => {
2450
+ const config = contractDenyRelationsConfig();
2451
+ const base = await harness.makeDriver(config);
2452
+ const u = holder();
2453
+ const g = { type: 'group', id: docId() };
2454
+ const a = { type: 'document', id: docId() };
2455
+ const p = partition();
2456
+ if (harness.capabilities.injectableClock) {
2457
+ const T = new Date('2031-03-03T03:03:03.303Z');
2458
+ const clock = fixedClock(new Date(T.getTime() - 60_000));
2459
+ const m = new RelationsManager(base.withClock(clock.now), config);
2460
+ await m.relate(u, 'viewer', a, p);
2461
+ await m.relate(u, 'member', g, p, { expiresAt: T });
2462
+ await m.deny({ object: g, relation: 'member' }, 'viewer', a, p);
2463
+ clock.set(new Date(T.getTime() - 1));
2464
+ assert.isFalse(await m.check(u, 'viewer', a, p), 'T−1 ms: la membresía viva transporta el deny');
2465
+ assert.deepEqual(await m.decideMany(u, 'viewer', [a], p), ['deny']);
2466
+ clock.set(T);
2467
+ assert.isTrue(await m.check(u, 'viewer', a, p), 'T: la membresía caducó y el allow vuelve');
2468
+ assert.deepEqual(await m.decideMany(u, 'viewer', [a], p), ['allow']);
2469
+ // S13 · UN solo `now` por lote también en decideMany (el patrón A-18): un reloj que AVANZA 1 ms por lectura
2470
+ // y tres allows que caducan en T3+1, T3+2, T3+3 ⇒ las tres `allow` y el reloj se lee UNA vez.
2471
+ const T3 = new Date('2031-04-04T00:00:00.000Z');
2472
+ const setup = new RelationsManager(base.withClock(fixedClock(T3).now), config);
2473
+ const batch = holder();
2474
+ const docs = [docId(), docId(), docId()].map((id) => ({ type: 'document', id }));
2475
+ for (const [i, d] of docs.entries())
2476
+ await setup.relate(batch, 'viewer', d, p, { expiresAt: new Date(T3.getTime() + i + 1) });
2477
+ let reads = 0;
2478
+ const ticking = new RelationsManager(base.withClock(() => new Date(T3.getTime() + reads++)), config);
2479
+ assert.deepEqual(await ticking.decideMany(batch, 'viewer', [...docs, ...docs], p), ['allow', 'allow', 'allow', 'allow', 'allow', 'allow']);
2480
+ assert.equal(reads, 1, 'S13: decideMany leyó el reloj EXACTAMENTE una vez para todo el lote');
2481
+ return;
2482
+ }
2483
+ const m = new RelationsManager(base, config);
2484
+ await m.relate(u, 'viewer', a, p);
2485
+ await m.relate(u, 'member', g, p, { expiresAt: new Date(Date.now() + 1_500) });
2486
+ await m.deny({ object: g, relation: 'member' }, 'viewer', a, p);
2487
+ assert.isFalse(await m.check(u, 'viewer', a, p), 'viva: el deny viaja por la membresía');
2488
+ await sleep(1_700);
2489
+ assert.isTrue(await m.check(u, 'viewer', a, p), 'caducada: el allow vuelve');
2490
+ });
2491
+ },
2492
+ whenFalse: () => test('relationDenies:false · D-28 · deny/removeDeny/listDenies/decideMany ⇒ 500 E_AUTHZ_UNSUPPORTED nombrándolos, con CERO llamadas al driver y CERO eventos; F-05 va antes (un role_binding sigue siendo 422)', async ({ assert }) => {
2493
+ const p = partition();
2494
+ const u = holder();
2495
+ const a = { type: 'document', id: docId() };
2496
+ const runs = [
2497
+ ['deny', () => state.manager.deny(u, 'viewer', a, p)],
2498
+ ['removeDeny', () => state.manager.removeDeny(u, 'viewer', a, p)],
2499
+ ['listDenies', () => state.manager.listDenies('viewer', a, p)],
2500
+ ['decideMany', () => state.manager.decideMany(u, 'viewer', [a], p)],
2501
+ ];
2502
+ for (const [op, run] of runs) {
2503
+ const caught = await run().then(() => null, (e) => e);
2504
+ assert.equal(caught?.status, 500, `${op}: ${caught?.message}`);
2505
+ assert.equal(caught?.code, 'E_AUTHZ_UNSUPPORTED');
2506
+ assert.include(String(caught?.message), op);
2507
+ }
2508
+ const typed = await state.manager.deny(u, 'assignee', { type: 'role_binding', id: docId() }, p).then(() => null, (e) => e);
2509
+ assert.equal(typed?.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN', 'F-05 antes que la capacidad');
2510
+ assert.equal(state.calls(), 0, 'CERO llamadas al driver');
2511
+ assert.lengthOf(state.events, 0, 'CERO eventos');
2512
+ // Cierre 1 de D2 · ⚪ 9 (el par de denyPhase): sin relationDenies el driver no AFIRMA una fase — o no trae
2513
+ // denyPhase, o responde null para todo tipo (la puerta del reconcile lo trata como no-capability igualmente).
2514
+ const bare = await harness.makeDriver(contractDenyRelationsConfig());
2515
+ if (typeof bare.denyPhase === 'function') {
2516
+ for (const type of Object.keys(RELATIONS_CONTRACT_DENY_TYPES)) {
2517
+ assert.isNull(await bare.denyPhase({ type }, 'viewer'), `relationDenies:false · denyPhase(${type}) no afirma una fase`);
2518
+ }
2519
+ }
2520
+ }),
2521
+ });
1574
2522
  // Al cerrar: toda capacidad declarada tiene que haber registrado su cara.
1575
2523
  group.teardown(async () => {
1576
2524
  const uncovered = Object.keys(harness.capabilities).filter((c) => !covered.has(c));
@@ -1581,4 +2529,3 @@ export function registerRelationsDriverContract(harness, api) {
1581
2529
  });
1582
2530
  return { registered, covered };
1583
2531
  }
1584
- //# sourceMappingURL=relations_contract.js.map