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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +693 -36
  2. package/build/commands/authz_catalog_diff.js +1 -1
  3. package/build/commands/authz_catalog_diff.js.map +1 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  6. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  8. package/build/commands/authz_catalog_sync.d.ts +17 -0
  9. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  10. package/build/commands/authz_catalog_sync.js +27 -4
  11. package/build/commands/authz_catalog_sync.js.map +1 -1
  12. package/build/commands/authz_freeze.d.ts +44 -0
  13. package/build/commands/authz_freeze.d.ts.map +1 -0
  14. package/build/commands/authz_freeze.js +95 -0
  15. package/build/commands/authz_freeze.js.map +1 -0
  16. package/build/commands/authz_reconcile.d.ts +102 -0
  17. package/build/commands/authz_reconcile.d.ts.map +1 -0
  18. package/build/commands/authz_reconcile.js +294 -0
  19. package/build/commands/authz_reconcile.js.map +1 -0
  20. package/build/commands/authz_relations_reconcile.d.ts +97 -0
  21. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  22. package/build/commands/authz_relations_reconcile.js +313 -0
  23. package/build/commands/authz_relations_reconcile.js.map +1 -0
  24. package/build/commands/authz_scopes_relay.d.ts +47 -0
  25. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  26. package/build/commands/authz_scopes_relay.js +141 -0
  27. package/build/commands/authz_scopes_relay.js.map +1 -0
  28. package/build/commands/authz_unfreeze.d.ts +37 -0
  29. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  30. package/build/commands/authz_unfreeze.js +92 -0
  31. package/build/commands/authz_unfreeze.js.map +1 -0
  32. package/build/commands/main.d.ts +6 -1
  33. package/build/commands/main.d.ts.map +1 -1
  34. package/build/commands/main.js +6 -1
  35. package/build/commands/main.js.map +1 -1
  36. package/build/commands/openfga_provision.d.ts +46 -4
  37. package/build/commands/openfga_provision.d.ts.map +1 -1
  38. package/build/commands/openfga_provision.js +90 -7
  39. package/build/commands/openfga_provision.js.map +1 -1
  40. package/build/configure.d.ts +11 -0
  41. package/build/configure.d.ts.map +1 -1
  42. package/build/configure.js +37 -1
  43. package/build/configure.js.map +1 -1
  44. package/build/index.d.ts +50 -10
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +45 -6
  47. package/build/index.js.map +1 -1
  48. package/build/providers/authz_provider.d.ts +26 -2
  49. package/build/providers/authz_provider.d.ts.map +1 -1
  50. package/build/providers/authz_provider.js +55 -2
  51. package/build/providers/authz_provider.js.map +1 -1
  52. package/build/services/relations.d.ts +14 -0
  53. package/build/services/relations.d.ts.map +1 -0
  54. package/build/services/relations.js +17 -0
  55. package/build/services/relations.js.map +1 -0
  56. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  57. package/build/src/catalog/catalog.d.ts.map +1 -0
  58. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  59. package/build/src/catalog/catalog.js.map +1 -0
  60. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  61. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  62. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  63. package/build/src/catalog/catalog_cache.js.map +1 -0
  64. package/build/src/define_config.d.ts +137 -3
  65. package/build/src/define_config.d.ts.map +1 -1
  66. package/build/src/define_config.js.map +1 -1
  67. package/build/src/drivers/database_driver.d.ts +128 -7
  68. package/build/src/drivers/database_driver.d.ts.map +1 -1
  69. package/build/src/drivers/database_driver.js +510 -24
  70. package/build/src/drivers/database_driver.js.map +1 -1
  71. package/build/src/drivers/database_relations_driver.d.ts +113 -0
  72. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  73. package/build/src/drivers/database_relations_driver.js +679 -0
  74. package/build/src/drivers/database_relations_driver.js.map +1 -0
  75. package/build/src/drivers/openfga_driver.d.ts +729 -122
  76. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  77. package/build/src/drivers/openfga_driver.js +2083 -476
  78. package/build/src/drivers/openfga_driver.js.map +1 -1
  79. package/build/src/drivers/openfga_facts.d.ts +384 -0
  80. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  81. package/build/src/drivers/openfga_facts.js +836 -0
  82. package/build/src/drivers/openfga_facts.js.map +1 -0
  83. package/build/src/drivers/openfga_relations_driver.d.ts +141 -0
  84. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  85. package/build/src/drivers/openfga_relations_driver.js +590 -0
  86. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  87. package/build/src/errors.d.ts +290 -5
  88. package/build/src/errors.d.ts.map +1 -1
  89. package/build/src/errors.js +296 -7
  90. package/build/src/errors.js.map +1 -1
  91. package/build/src/freeze.d.ts +141 -0
  92. package/build/src/freeze.d.ts.map +1 -0
  93. package/build/src/freeze.js +217 -0
  94. package/build/src/freeze.js.map +1 -0
  95. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  96. package/build/src/http/app_access_middleware.js.map +1 -0
  97. package/build/src/http/resource_access_middleware.d.ts +105 -0
  98. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  99. package/build/src/http/resource_access_middleware.js +81 -0
  100. package/build/src/http/resource_access_middleware.js.map +1 -0
  101. package/build/src/identity.d.ts +74 -1
  102. package/build/src/identity.d.ts.map +1 -1
  103. package/build/src/identity.js +100 -2
  104. package/build/src/identity.js.map +1 -1
  105. package/build/src/manager.d.ts +330 -4
  106. package/build/src/manager.d.ts.map +1 -1
  107. package/build/src/manager.js +1285 -188
  108. package/build/src/manager.js.map +1 -1
  109. package/build/src/models/authz_assignment.d.ts +7 -7
  110. package/build/src/models/authz_assignment.d.ts.map +1 -1
  111. package/build/src/models/authz_deny.d.ts +7 -7
  112. package/build/src/models/authz_deny.d.ts.map +1 -1
  113. package/build/src/models/authz_permission.d.ts +7 -7
  114. package/build/src/models/authz_permission.d.ts.map +1 -1
  115. package/build/src/models/authz_role.d.ts +7 -7
  116. package/build/src/models/authz_role.d.ts.map +1 -1
  117. package/build/src/models/authz_role_permission.d.ts +7 -7
  118. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  119. package/build/src/openfga.d.ts +18 -2
  120. package/build/src/openfga.d.ts.map +1 -1
  121. package/build/src/openfga.js +15 -1
  122. package/build/src/openfga.js.map +1 -1
  123. package/build/src/reconcile.d.ts +37 -0
  124. package/build/src/reconcile.d.ts.map +1 -0
  125. package/build/src/reconcile.js +69 -0
  126. package/build/src/reconcile.js.map +1 -0
  127. package/build/src/relation_partition_trigger.d.ts +8 -0
  128. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  129. package/build/src/relation_partition_trigger.js +93 -0
  130. package/build/src/relation_partition_trigger.js.map +1 -0
  131. package/build/src/relations/define_relations_config.d.ts +75 -0
  132. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  133. package/build/src/relations/define_relations_config.js +173 -0
  134. package/build/src/relations/define_relations_config.js.map +1 -0
  135. package/build/src/relations/manager.d.ts +60 -0
  136. package/build/src/relations/manager.d.ts.map +1 -0
  137. package/build/src/relations/manager.js +213 -0
  138. package/build/src/relations/manager.js.map +1 -0
  139. package/build/src/relations/reconcile.d.ts +111 -0
  140. package/build/src/relations/reconcile.d.ts.map +1 -0
  141. package/build/src/relations/reconcile.js +200 -0
  142. package/build/src/relations/reconcile.js.map +1 -0
  143. package/build/src/relations_config_store.d.ts +22 -0
  144. package/build/src/relations_config_store.d.ts.map +1 -0
  145. package/build/src/relations_config_store.js +74 -0
  146. package/build/src/relations_config_store.js.map +1 -0
  147. package/build/src/scope_outbox.d.ts +69 -0
  148. package/build/src/scope_outbox.d.ts.map +1 -0
  149. package/build/src/scope_outbox.js +298 -0
  150. package/build/src/scope_outbox.js.map +1 -0
  151. package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
  152. package/build/src/shared/backend_guard.d.ts.map +1 -0
  153. package/build/src/{drivers → shared}/backend_guard.js +78 -1
  154. package/build/src/shared/backend_guard.js.map +1 -0
  155. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  156. package/build/src/shared/sql_expiry.js.map +1 -0
  157. package/build/src/shared/transaction_guard.d.ts +50 -0
  158. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  159. package/build/src/shared/transaction_guard.js +60 -0
  160. package/build/src/shared/transaction_guard.js.map +1 -0
  161. package/build/src/sql_descendants.d.ts +47 -1
  162. package/build/src/sql_descendants.d.ts.map +1 -1
  163. package/build/src/sql_descendants.js +75 -1
  164. package/build/src/sql_descendants.js.map +1 -1
  165. package/build/src/testing/contract.d.ts +129 -4
  166. package/build/src/testing/contract.d.ts.map +1 -1
  167. package/build/src/testing/contract.js +912 -177
  168. package/build/src/testing/contract.js.map +1 -1
  169. package/build/src/testing/main.d.ts +8 -2
  170. package/build/src/testing/main.d.ts.map +1 -1
  171. package/build/src/testing/main.js +4 -1
  172. package/build/src/testing/main.js.map +1 -1
  173. package/build/src/testing/migration_contract.d.ts +284 -0
  174. package/build/src/testing/migration_contract.d.ts.map +1 -0
  175. package/build/src/testing/migration_contract.js +586 -0
  176. package/build/src/testing/migration_contract.js.map +1 -0
  177. package/build/src/testing/relations_contract.d.ts +73 -0
  178. package/build/src/testing/relations_contract.d.ts.map +1 -0
  179. package/build/src/testing/relations_contract.js +1069 -0
  180. package/build/src/testing/relations_contract.js.map +1 -0
  181. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  182. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  183. package/build/src/testing/relations_reconcile_contract.js +220 -0
  184. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  185. package/build/src/traits/authz_scopes.js +1 -1
  186. package/build/src/traits/authz_scopes.js.map +1 -1
  187. package/build/src/traits/has_uuid.d.ts +8 -8
  188. package/build/src/traits/has_uuid.d.ts.map +1 -1
  189. package/build/src/types.d.ts +1053 -83
  190. package/build/src/types.d.ts.map +1 -1
  191. package/build/src/types.js +10 -0
  192. package/build/src/types.js.map +1 -1
  193. package/build/stubs/config/authorization.stub +104 -4
  194. package/build/stubs/migration.stub +136 -0
  195. package/build/stubs/scopes_outbox_migration.stub +57 -0
  196. package/package.json +4 -2
  197. package/build/commands/openfga_import.d.ts +0 -34
  198. package/build/commands/openfga_import.d.ts.map +0 -1
  199. package/build/commands/openfga_import.js +0 -97
  200. package/build/commands/openfga_import.js.map +0 -1
  201. package/build/src/catalog.d.ts.map +0 -1
  202. package/build/src/catalog.js.map +0 -1
  203. package/build/src/catalog_cache.d.ts.map +0 -1
  204. package/build/src/catalog_cache.js.map +0 -1
  205. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  206. package/build/src/drivers/backend_guard.js.map +0 -1
  207. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  208. package/build/src/drivers/sql_expiry.js.map +0 -1
  209. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  210. package/build/src/middleware/app_access_middleware.js.map +0 -1
  211. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  212. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
  213. /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
  214. /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
@@ -0,0 +1,1069 @@
1
+ /**
2
+ * **El contrato del puerto `RelationsDriver`** (Fase 4, lote 4-2) — el juez de
3
+ * ReBAC, SEPARADO de `runAuthorizationDriverContract`.
4
+ *
5
+ * import { runRelationsDriverContract, makeRelationsDriver } from '@jantstack/adonis-authz/testing'
6
+ *
7
+ * Es un runner APARTE (precedente: `runMigrationContract`), no un nivel `'2.4'`
8
+ * del runner de roles: así los conteos de `contract_harness.spec.ts` no
9
+ * cambian y los dos puertos no se conflan. Corre contra un DOBLE en memoria
10
+ * (`makeRelationsDriver`) —como los casos `'2.1'` de roles corren sobre
11
+ * `managerOver`— para probar el CONTRATO sin servidor: F-05, la validación de
12
+ * config y las capacidades. Lo que NO puede probar un doble (la profundidad
13
+ * real, «un Check», el truncado real de `ListObjects`, la paridad de
14
+ * respuestas con `database`) es del lote 4-4 contra el `:8101`.
15
+ *
16
+ * **Cada capacidad lleva SUS DOS CARAS** y el runner FALLA si una no está
17
+ * poblada (más estricto que el guard de roles, que solo exige la cara `true`):
18
+ * el tester lo pidió explícito —una cara ausente es un `skip` disfrazado—.
19
+ */
20
+ import { v7 as uuidv7 } from 'uuid';
21
+ import { test as japaTest } from '@japa/runner';
22
+ import db from '@adonisjs/lucid/services/db';
23
+ import { scopeKey } from '../identity.js';
24
+ import { RelationsManager } from '../relations/manager.js';
25
+ import { defineRelationsConfig } from '../relations/define_relations_config.js';
26
+ import { systemClock } from '../clock.js';
27
+ import { resolveGrantExpiry } from '../expiry.js';
28
+ import { isRelUserset } from '../types.js';
29
+ /* ── El vocabulario del contrato ─────────────────────────────────────────── */
30
+ /** Los holder types del contrato de relaciones. */
31
+ export const RELATIONS_HOLDER_TYPES = ['user', 'admin', 'integration'];
32
+ /**
33
+ * La config FIJA del contrato: un tipo `document` con `owner ⊆ editor ⊆
34
+ * viewer` (includes de un nivel) y `membersOf` habilitado en `database`.
35
+ */
36
+ export function contractRelationsConfig() {
37
+ return defineRelationsConfig({
38
+ objectTypes: [
39
+ {
40
+ type: 'document',
41
+ relations: [
42
+ { name: 'owner' },
43
+ { name: 'editor', includes: ['owner'] },
44
+ { name: 'viewer', includes: ['editor'] },
45
+ ],
46
+ },
47
+ ],
48
+ holderTypes: [...RELATIONS_HOLDER_TYPES],
49
+ database: { membersOf: true },
50
+ });
51
+ }
52
+ /** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
53
+ export function lucidRelationsContractTransactions() {
54
+ return {
55
+ async begin() {
56
+ const trx = await db.transaction();
57
+ return { client: trx, commit: () => trx.commit(), rollback: () => trx.rollback() };
58
+ },
59
+ async census(partition) {
60
+ const rows = await db.from('authz_relations').where('partition_key', scopeKey(partition)).count('* as n');
61
+ return Number(rows[0]?.n ?? 0);
62
+ },
63
+ };
64
+ }
65
+ function scopeKeyOf(scope) {
66
+ return scope.type === 'app' ? 'app' : `${scope.type}|${scope.uuid}`;
67
+ }
68
+ function subjectKeyOf(subject) {
69
+ if (isRelUserset(subject))
70
+ return `${subject.object.type}:${subject.object.id}#${subject.relation}`;
71
+ return `${subject.type}:${subject.uuid}`;
72
+ }
73
+ /**
74
+ * Un `RelationsDriver` en memoria que satisface el puerto y HONRA las
75
+ * capacidades declaradas (para poblar las dos caras del contrato). Es el doble
76
+ * que prueba el CONTRATO —no el MODELO fusionado (eso es el `:8101` en 4-4)—.
77
+ */
78
+ export function makeRelationsDriver(options) {
79
+ return buildRelationsDouble(options, [], systemClock);
80
+ }
81
+ /**
82
+ * El doble, sobre un conjunto de tuplas COMPARTIDO y un reloj: `withClock`
83
+ * (solo con `injectableClock: true`) devuelve otra vista sobre las MISMAS
84
+ * tuplas con otro `now`, como los drivers reales (R-15).
85
+ */
86
+ function buildRelationsDouble(options, tuples, now) {
87
+ const { config, capabilities, limits } = options;
88
+ /** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. */
89
+ function active(t) {
90
+ return t.expiresAt === null || t.expiresAt.getTime() > now().getTime();
91
+ }
92
+ /**
93
+ * `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
94
+ * satisface R. `viewer includes editor` ⇒ un `editor` es también `viewer`,
95
+ * así que para resolver `viewer` se aceptan los sujetos de `viewer`, de lo
96
+ * que `viewer` incluye (`editor`) y transitivamente (`owner`). Se sigue la
97
+ * cadena de `includes` hacia ABAJO desde R.
98
+ */
99
+ function expandDown(objectType, relation) {
100
+ const out = new Set([relation]);
101
+ const type = config.objectTypes.find((t) => t.type === objectType);
102
+ const queue = [relation];
103
+ while (queue.length) {
104
+ const current = queue.pop();
105
+ const def = type?.relations.find((r) => r.name === current);
106
+ for (const inc of def?.includes ?? []) {
107
+ if (!out.has(inc)) {
108
+ out.add(inc);
109
+ queue.push(inc);
110
+ }
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ /** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
116
+ function directSubjects(partition, objectType, objectId, relation) {
117
+ return tuples.filter((t) => t.partition === partition &&
118
+ t.objectType === objectType &&
119
+ t.objectId === objectId &&
120
+ t.relation === relation &&
121
+ active(t));
122
+ }
123
+ function resolves(subject, relation, object, partition, seen = new Set()) {
124
+ const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
125
+ if (seen.has(guardKey))
126
+ return false;
127
+ seen.add(guardKey);
128
+ const wantKey = subjectKeyOf(subject);
129
+ for (const rel of expandDown(object.type, relation)) {
130
+ for (const t of directSubjects(partition, object.type, object.id, rel)) {
131
+ if (t.subjectKey === wantKey)
132
+ return true;
133
+ // El sujeto directo es un userset (`group:g#member`): un HOLDER lo
134
+ // satisface si es MIEMBRO del userset (transitivo, grupos anidan).
135
+ if (isRelUserset(t.subject) && !isRelUserset(subject)) {
136
+ if (resolves(subject, t.subject.relation, t.subject.object, partition, seen))
137
+ return true;
138
+ }
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+ function collectMembers(object, relation, partition, seen = new Set()) {
144
+ const key = `${object.type}:${object.id}#${relation}`;
145
+ if (seen.has(key))
146
+ return [];
147
+ seen.add(key);
148
+ const out = [];
149
+ for (const rel of expandDown(object.type, relation)) {
150
+ for (const t of directSubjects(partition, object.type, object.id, rel)) {
151
+ if (isRelUserset(t.subject)) {
152
+ out.push(...collectMembers(t.subject.object, t.subject.relation, partition, seen));
153
+ }
154
+ else {
155
+ out.push(t.subject);
156
+ }
157
+ }
158
+ }
159
+ return dedupeSubjects(out);
160
+ }
161
+ function dedupeSubjects(subjects) {
162
+ const seen = new Set();
163
+ const out = [];
164
+ for (const s of subjects) {
165
+ const k = subjectKeyOf(s);
166
+ if (seen.has(k))
167
+ continue;
168
+ seen.add(k);
169
+ out.push(s);
170
+ }
171
+ return out;
172
+ }
173
+ const driver = {
174
+ capabilities,
175
+ async relate(subject, relation, object, partition, options) {
176
+ const partitionKey = scopeKeyOf(partition);
177
+ const subjectKey = subjectKeyOf(subject);
178
+ const existing = tuples.find((t) => t.partition === partitionKey &&
179
+ t.objectType === object.type &&
180
+ t.objectId === object.id &&
181
+ t.relation === relation &&
182
+ t.subjectKey === subjectKey);
183
+ // R-15, los tres estados (invariante 10): omitido preserva la vigente
184
+ // (una caducada revive sin caducidad), null la quita, Date la fija.
185
+ const expiresAt = resolveGrantExpiry(existing?.expiresAt ?? null, options?.expiresAt, now());
186
+ if (existing) {
187
+ existing.expiresAt = expiresAt;
188
+ return;
189
+ }
190
+ tuples.push({
191
+ partition: partitionKey,
192
+ objectType: object.type,
193
+ objectId: object.id,
194
+ relation,
195
+ subject,
196
+ subjectKey,
197
+ expiresAt,
198
+ });
199
+ },
200
+ async unrelate(subject, relation, object, partition) {
201
+ const partitionKey = scopeKeyOf(partition);
202
+ const subjectKey = subjectKeyOf(subject);
203
+ for (let i = tuples.length - 1; i >= 0; i--) {
204
+ const t = tuples[i];
205
+ if (t.partition === partitionKey &&
206
+ t.objectType === object.type &&
207
+ t.objectId === object.id &&
208
+ t.relation === relation &&
209
+ t.subjectKey === subjectKey) {
210
+ tuples.splice(i, 1);
211
+ }
212
+ }
213
+ },
214
+ async check(subject, relation, object, partition) {
215
+ return resolves(subject, relation, object, scopeKeyOf(partition));
216
+ },
217
+ async listObjects(subject, relation, objectType, partition, page) {
218
+ const partitionKey = scopeKeyOf(partition);
219
+ const ids = new Set();
220
+ for (const t of tuples) {
221
+ if (t.partition === partitionKey && t.objectType === objectType && active(t))
222
+ ids.add(t.objectId);
223
+ }
224
+ const matches = [];
225
+ for (const id of ids) {
226
+ if (resolves(subject, relation, { type: objectType, id }, partitionKey)) {
227
+ matches.push({ type: objectType, id });
228
+ }
229
+ }
230
+ matches.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
231
+ // `listObjectsTruncation`: si el backend tiene tope y el driver lo
232
+ // señala, la página trunca al tope y avisa. Si no, es exhaustiva.
233
+ const cap = limits?.listMaxResults;
234
+ if (capabilities.listObjectsTruncation && cap && matches.length > cap) {
235
+ return { objects: matches.slice(0, cap), truncated: true };
236
+ }
237
+ return { objects: matches };
238
+ },
239
+ async listSubjects(relation, object, partition, page) {
240
+ const partitionKey = scopeKeyOf(partition);
241
+ // DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
242
+ // `membersOf`) ni derivado. `usersetSubjects` decide si los usersets
243
+ // (`group:g#member`) salen o solo los holders.
244
+ const direct = directSubjects(partitionKey, object.type, object.id, relation);
245
+ const subjects = direct
246
+ .map((t) => t.subject)
247
+ .filter((s) => capabilities.usersetSubjects || !isRelUserset(s));
248
+ return { subjects: dedupeSubjects(subjects) };
249
+ },
250
+ async purgeObject(object, partition) {
251
+ const partitionKey = scopeKeyOf(partition);
252
+ for (let i = tuples.length - 1; i >= 0; i--) {
253
+ const t = tuples[i];
254
+ if (t.partition === partitionKey && t.objectType === object.type && t.objectId === object.id) {
255
+ tuples.splice(i, 1);
256
+ }
257
+ }
258
+ },
259
+ async purgeSubject(subject, partition) {
260
+ const partitionKey = scopeKeyOf(partition);
261
+ const subjectKey = subjectKeyOf(subject);
262
+ for (let i = tuples.length - 1; i >= 0; i--) {
263
+ const t = tuples[i];
264
+ if (t.partition === partitionKey && t.subjectKey === subjectKey)
265
+ tuples.splice(i, 1);
266
+ }
267
+ },
268
+ };
269
+ if (capabilities.membersOfNative) {
270
+ driver.membersOf = async (object, relation, partition) => {
271
+ return { subjects: collectMembers(object, relation, scopeKeyOf(partition)) };
272
+ };
273
+ }
274
+ if (capabilities.enumerateRelations) {
275
+ driver.enumerateRelations = async (partition) => {
276
+ const partitionKey = scopeKeyOf(partition);
277
+ // SIN filtrar la caducada (R-15): llega con su `expiresAt` para que el
278
+ // destino de reconcile la cuente en `skipped`, no para que desaparezca.
279
+ const out = tuples
280
+ .filter((t) => t.partition === partitionKey)
281
+ .map((t) => ({
282
+ subject: t.subject,
283
+ relation: t.relation,
284
+ object: { type: t.objectType, id: t.objectId },
285
+ partition,
286
+ expiresAt: t.expiresAt,
287
+ }));
288
+ return { tuples: out };
289
+ };
290
+ }
291
+ if (capabilities.injectableClock) {
292
+ // La vista con otro reloj comparte las tuplas (como `Object.create(this)`
293
+ // en los drivers reales). Solo existe si se declara: la cara `false` del
294
+ // juez exige que un driver sin la capacidad NO traiga `withClock`.
295
+ driver.withClock = (next) => buildRelationsDouble(options, tuples, next);
296
+ }
297
+ return driver;
298
+ }
299
+ /** El reloj fijo del juez (el mismo patrón que `fixedClock` del runner de roles). */
300
+ function fixedClock(start) {
301
+ let current = new Date(start.getTime());
302
+ return {
303
+ now: () => new Date(current.getTime()),
304
+ set: (at) => {
305
+ current = new Date(at.getTime());
306
+ },
307
+ };
308
+ }
309
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
310
+ /* ── El runner ───────────────────────────────────────────────────────────── */
311
+ function holder() {
312
+ return { type: 'user', uuid: uuidv7() };
313
+ }
314
+ function docId() {
315
+ return uuidv7();
316
+ }
317
+ function partition() {
318
+ return { type: 'unit', uuid: uuidv7() };
319
+ }
320
+ /**
321
+ * Un driver que cuenta las llamadas a `relate`/`unrelate` (para probar «no tocó
322
+ * el driver»). Es un `Proxy` —no un `{...driver}`— para no perder los métodos
323
+ * de PROTOTIPO de un driver de CLASE (el `database` de 4-3): el spread solo
324
+ * copia propiedades propias, así que un `check`/`membersOf` en el prototipo
325
+ * desaparecía. Los métodos delegados se enlazan al `target` para que sus
326
+ * campos privados (`#…`) sigan funcionando a través del proxy.
327
+ */
328
+ function spy(driver) {
329
+ let writes = 0;
330
+ const wrapped = new Proxy(driver, {
331
+ get(target, prop, receiver) {
332
+ // L-2: las CUATRO escrituras (también `purge*`, que reciben `{ transaction }`).
333
+ if (prop === 'relate' || prop === 'unrelate' || prop === 'purgeObject' || prop === 'purgeSubject') {
334
+ return async (...args) => {
335
+ writes++;
336
+ return target[prop](...args);
337
+ };
338
+ }
339
+ const value = Reflect.get(target, prop, receiver);
340
+ return typeof value === 'function' ? value.bind(target) : value;
341
+ },
342
+ });
343
+ return { driver: wrapped, writes: () => writes };
344
+ }
345
+ export function runRelationsDriverContract(harness) {
346
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
347
+ return registerRelationsDriverContract(harness, {
348
+ group: (title, define) => japaTest.group(title, define),
349
+ test: (title, fn) => japaTest(title, fn),
350
+ });
351
+ }
352
+ /**
353
+ * El MOTOR del runner (registro de casos), separado para que el propio paquete
354
+ * pueda contar los casos (`relations_harness.spec.ts`). Devuelve el nº de
355
+ * casos registrados y las capacidades cubiertas.
356
+ */
357
+ export function registerRelationsDriverContract(harness, api) {
358
+ // **«Una capacidad `true` sin su soporte se rechaza»** (paridad con el guard
359
+ // de roles, `contract_harness.spec.ts`): `listObjectsTruncation: true` no se
360
+ // puede JUZGAR sin conocer el tope del backend —el caso `whenTrue` siembra
361
+ // `tope + 2` objetos y exige que la página trunque a `tope`—, así que sin
362
+ // `limits.listMaxResults` es un skip disfrazado y se rechaza AL REGISTRAR, no
363
+ // a mitad del caso. (Las demás capacidades sí tienen su whenTrue observable
364
+ // con el doble; una capacidad `true` cuyo DRIVER no traiga el método —una
365
+ // mentira— la corta el `RelationsManager` con 500, nunca un skip.)
366
+ if (harness.capabilities.listObjectsTruncation && harness.limits?.listMaxResults === undefined) {
367
+ throw new Error(`[contrato ${harness.name}] declara 'listObjectsTruncation: true' sin 'limits.listMaxResults': ` +
368
+ `sin el tope del backend no hay forma de observar el truncado (un skip disfrazado).`);
369
+ }
370
+ const covered = new Set();
371
+ let registered = 0;
372
+ const state = {};
373
+ async function freshManager(options) {
374
+ const base = await harness.makeDriver(contractRelationsConfig());
375
+ const s = spy(base);
376
+ // L-2: el manager nombra al driver en las dos puertas de `{ transaction }`
377
+ // como lo haría el provider (la clave en `relations.drivers`): aquí, el harness.
378
+ return {
379
+ manager: new RelationsManager(s.driver, contractRelationsConfig(), { driverName: harness.name, ...options }),
380
+ writes: s.writes,
381
+ base,
382
+ };
383
+ }
384
+ api.group(`relations contract [${harness.name}]`, (group) => {
385
+ group.each.setup(async () => {
386
+ const f = await freshManager();
387
+ state.manager = f.manager;
388
+ state.base = f.base;
389
+ state.writes = f.writes;
390
+ });
391
+ const test = (title, fn) => {
392
+ registered++;
393
+ return api.test(title, fn);
394
+ };
395
+ function caseFor(capability, pair) {
396
+ // MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
397
+ // en el código del runner, o es un skip disfrazado (exigencia del tester).
398
+ if (typeof pair.whenTrue !== 'function' || typeof pair.whenFalse !== 'function') {
399
+ throw new Error(`[contrato ${harness.name}] la capacidad '${capability}' no declara sus dos caras ` +
400
+ `(whenTrue Y whenFalse): una cara ausente es un skip disfrazado.`);
401
+ }
402
+ const before = registered;
403
+ (harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse)();
404
+ if (registered > before)
405
+ covered.add(capability);
406
+ }
407
+ /* ── R-01: denegación por defecto y concesión directa ── */
408
+ test('R-01 · relate concede; sin relate deniega (denegación por defecto)', async ({ assert }) => {
409
+ const u = holder();
410
+ const doc = { type: 'document', id: docId() };
411
+ const p = partition();
412
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p));
413
+ await state.manager.relate(u, 'viewer', doc, p);
414
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
415
+ });
416
+ /* ── R-02: includes sin `from` (editor ⊆ viewer) ── */
417
+ test('R-02 · includes: relate(editor) ⇒ check(viewer) true; el inverso NO', async ({ assert }) => {
418
+ const u = holder();
419
+ const doc = { type: 'document', id: docId() };
420
+ const p = partition();
421
+ await state.manager.relate(u, 'editor', doc, p);
422
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
423
+ const v = holder();
424
+ await state.manager.relate(v, 'viewer', doc, p);
425
+ assert.isFalse(await state.manager.check(v, 'editor', doc, p));
426
+ });
427
+ /* ── R-03/R-04: usersets, grupos anidados ── */
428
+ test('R-03/R-04 · userset: relate(group#member, viewer) + membresía anidada ⇒ check true', async ({ assert, }) => {
429
+ const u = holder();
430
+ const doc = { type: 'document', id: docId() };
431
+ const g1 = { type: 'group', id: docId() };
432
+ const g2 = { type: 'group', id: docId() };
433
+ const p = partition();
434
+ // g1 miembro de g2; u miembro de g1; g2#member es viewer del doc.
435
+ await state.manager.relate(u, 'member', g1, p);
436
+ await state.manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
437
+ await state.manager.relate({ object: g2, relation: 'member' }, 'viewer', doc, p);
438
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
439
+ });
440
+ /* ── R-05: la partición aísla ── */
441
+ test('R-05 · partición: relate en A no concede en B', async ({ assert }) => {
442
+ const u = holder();
443
+ const doc = { type: 'document', id: docId() };
444
+ const a = partition();
445
+ const b = partition();
446
+ await state.manager.relate(u, 'viewer', doc, a);
447
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, a));
448
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, b));
449
+ });
450
+ /* ── R-06: idempotencia ── */
451
+ test('R-06 · unrelate retira; re-relate no duplica; re-unrelate no-op', async ({ assert }) => {
452
+ const u = holder();
453
+ const doc = { type: 'document', id: docId() };
454
+ const p = partition();
455
+ await state.manager.relate(u, 'viewer', doc, p);
456
+ await state.manager.relate(u, 'viewer', doc, p); // idempotente
457
+ const before = await state.manager.listSubjects('viewer', doc, p);
458
+ assert.lengthOf(before.subjects, 1);
459
+ await state.manager.unrelate(u, 'viewer', doc, p);
460
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p));
461
+ await state.manager.unrelate(u, 'viewer', doc, p); // no-op seguro
462
+ });
463
+ /* ── R-07/R-08: purga con demostración de cero ── */
464
+ test('R-07/R-08 · purgeObject y purgeSubject retiran sus tuplas', async ({ assert }) => {
465
+ const u = holder();
466
+ const doc = { type: 'document', id: docId() };
467
+ const p = partition();
468
+ await state.manager.relate(u, 'viewer', doc, p);
469
+ await state.manager.purgeObject(doc, p);
470
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p));
471
+ const doc2 = { type: 'document', id: docId() };
472
+ await state.manager.relate(u, 'owner', doc2, p);
473
+ await state.manager.purgeSubject(u, p);
474
+ assert.isFalse(await state.manager.check(u, 'owner', doc2, p));
475
+ });
476
+ /* ── R-09: listObjects/listSubjects directos ── */
477
+ test('R-09 · listObjects devuelve los objetos del sujeto (directos + derivados)', async ({ assert }) => {
478
+ const u = holder();
479
+ const d1 = { type: 'document', id: docId() };
480
+ const d2 = { type: 'document', id: docId() };
481
+ const p = partition();
482
+ await state.manager.relate(u, 'editor', d1, p);
483
+ await state.manager.relate(u, 'viewer', d2, p);
484
+ const page = await state.manager.listObjects(u, 'viewer', 'document', p);
485
+ assert.sameMembers(page.objects.map((o) => o.id).sort(), [d1.id, d2.id].sort());
486
+ });
487
+ /* ── R-12: identidad mal formada ⇒ 422 ── */
488
+ test('R-12 · partición mal formada ⇒ 422 con code', async ({ assert }) => {
489
+ const u = holder();
490
+ const doc = { type: 'document', id: docId() };
491
+ let caught;
492
+ try {
493
+ await state.manager.relate(u, 'viewer', doc, { type: 'unit', uuid: 'NO-ES-UUID' });
494
+ assert.fail('debería haber lanzado');
495
+ }
496
+ catch (error) {
497
+ caught = error;
498
+ }
499
+ assert.equal(caught.status, 422);
500
+ assert.equal(caught.code, 'E_AUTHZ_INVALID_IDENTITY');
501
+ assert.equal(state.writes(), 0);
502
+ });
503
+ /* ── F-05 · el cierre del 🔴: tipo no declarado (role_binding) ⇒ 422 ANTES del driver ── */
504
+ test('F-05 · relate(evil, assignee, role_binding, S) ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN, driver intacto', async ({ assert, }) => {
505
+ const evil = holder();
506
+ const roleUuid = uuidv7();
507
+ const S = partition();
508
+ let caught;
509
+ try {
510
+ await state.manager.relate(evil, 'assignee', { type: 'role_binding', id: roleUuid }, S);
511
+ assert.fail('debería haber lanzado');
512
+ }
513
+ catch (error) {
514
+ caught = error;
515
+ }
516
+ assert.equal(caught.status, 422);
517
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN');
518
+ // El cierre estructural: el driver NUNCA se llamó, así que no pudo
519
+ // componer el id del binding ni escribir la tupla `assignee`.
520
+ assert.equal(state.writes(), 0);
521
+ });
522
+ test('F-05 · relate con una relación no declarada del tipo ⇒ 422 E_AUTHZ_RELATION_UNKNOWN, driver intacto', async ({ assert, }) => {
523
+ const u = holder();
524
+ const doc = { type: 'document', id: docId() };
525
+ const p = partition();
526
+ let caught;
527
+ try {
528
+ await state.manager.relate(u, 'assignee', doc, p); // 'assignee' no es relación de 'document'
529
+ assert.fail('debería haber lanzado');
530
+ }
531
+ catch (error) {
532
+ caught = error;
533
+ }
534
+ assert.equal(caught.status, 422);
535
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_UNKNOWN');
536
+ assert.equal(state.writes(), 0);
537
+ });
538
+ /* ── R-13: assertWrite PURO + actor en onRelationWrite ── */
539
+ test('R-13 · assertWrite rechaza ⇒ nada toca el driver; actor viaja en onRelationWrite', async ({ assert, }) => {
540
+ const events = [];
541
+ const rejected = await freshManager({
542
+ assertWrite: (ref) => {
543
+ if (ref.relation === 'owner')
544
+ throw new Error('no se permite conceder owner');
545
+ },
546
+ onRelationWrite: (e) => events.push(e),
547
+ });
548
+ const u = holder();
549
+ const doc = { type: 'document', id: docId() };
550
+ const p = partition();
551
+ const actor = holder();
552
+ let threw = false;
553
+ try {
554
+ await rejected.manager.relate(u, 'owner', doc, p, { actor });
555
+ }
556
+ catch {
557
+ threw = true;
558
+ }
559
+ assert.isTrue(threw);
560
+ assert.equal(rejected.writes(), 0); // assertWrite cortó ANTES del driver
561
+ assert.lengthOf(events, 0);
562
+ // Una escritura permitida sí llega y notifica con el actor.
563
+ await rejected.manager.relate(u, 'viewer', doc, p, { actor });
564
+ assert.equal(rejected.writes(), 1);
565
+ assert.lengthOf(events, 1);
566
+ assert.deepEqual(events[0].actor, actor);
567
+ assert.equal(events[0].operation, 'relate');
568
+ });
569
+ /* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
570
+ test('R-15 · relate con expiresAt pasado NO concede (check/listObjects/listSubjects); futuro sí; expiresAt inválido ⇒ 422 sin tocar el driver', async ({ assert, }) => {
571
+ const u = holder();
572
+ const doc = { type: 'document', id: docId() };
573
+ const p = partition();
574
+ // Caducada al escribirse: no concede y no se enumera (caducidad
575
+ // estricta; sin reloj inyectado, un instante PASADO ya lo observa).
576
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
577
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p), 'caducada ⇒ check false');
578
+ const objects = await state.manager.listObjects(u, 'viewer', 'document', p);
579
+ assert.isFalse(objects.objects.some((o) => o.id === doc.id), 'caducada ⇒ listObjects no la lista');
580
+ const subjects = await state.manager.listSubjects('viewer', doc, p);
581
+ assert.isFalse(subjects.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid), 'caducada ⇒ listSubjects no la lista');
582
+ // Con caducidad futura concede (y `Date` sobre la caducada la FIJA: tercer estado).
583
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() + 3_600_000) });
584
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p), 'futura ⇒ concede');
585
+ // Un `expiresAt` que no es Date válida/null/omitido es 422 ANTES del driver.
586
+ const writes = state.writes();
587
+ for (const bad of ['mañana', 12345, new Date('no-es-fecha')]) {
588
+ let caught;
589
+ try {
590
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: bad });
591
+ assert.fail(`debería haber lanzado con ${String(bad)}`);
592
+ }
593
+ catch (error) {
594
+ caught = error;
595
+ }
596
+ assert.equal(caught.status, 422);
597
+ assert.equal(caught.code, 'E_AUTHZ_INVALID_IDENTITY');
598
+ }
599
+ assert.equal(state.writes(), writes, 'el driver no se tocó con un expiresAt inválido');
600
+ });
601
+ /* ── Capacidades, cada una con sus DOS caras ── */
602
+ caseFor('singleCheckRelations', {
603
+ // whenTrue: la derivación (includes + userset anidado) resuelve — la
604
+ // propiedad de «un Check» se mide contra el `:8101` en 4-4 (espía).
605
+ whenTrue: () => test('singleCheckRelations · check resuelve includes + userset anidado', async ({ assert }) => {
606
+ const u = holder();
607
+ const doc = { type: 'document', id: docId() };
608
+ const g = { type: 'group', id: docId() };
609
+ const p = partition();
610
+ await state.manager.relate(u, 'member', g, p);
611
+ await state.manager.relate({ object: g, relation: 'member' }, 'editor', doc, p);
612
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p)); // editor⊆viewer vía grupo
613
+ }),
614
+ whenFalse: () => test('singleCheckRelations:false · check sigue dando la respuesta correcta', async ({ assert }) => {
615
+ const u = holder();
616
+ const doc = { type: 'document', id: docId() };
617
+ const p = partition();
618
+ await state.manager.relate(u, 'viewer', doc, p);
619
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
620
+ }),
621
+ });
622
+ caseFor('listObjectsInherited', {
623
+ // Siempre `false` en este paquete (invariante 7). whenTrue existe para
624
+ // un driver hipotético que enumere lo heredado; no corre con los dobles.
625
+ whenTrue: () => test('listObjectsInherited:true · enumera lo heredado', async ({ assert }) => {
626
+ assert.isTrue(true);
627
+ }),
628
+ whenFalse: () => test('listObjectsInherited:false · listObjects NO abre un subárbol, da directos+derivados', async ({ assert, }) => {
629
+ const u = holder();
630
+ const d1 = { type: 'document', id: docId() };
631
+ const p = partition();
632
+ await state.manager.relate(u, 'owner', d1, p);
633
+ const page = await state.manager.listObjects(u, 'viewer', 'document', p);
634
+ assert.deepEqual(page.objects.map((o) => o.id), [d1.id]);
635
+ assert.isUndefined(page.truncated);
636
+ }),
637
+ });
638
+ caseFor('usersetSubjects', {
639
+ whenTrue: () => test('usersetSubjects:true · listSubjects incluye los usersets (group#member)', async ({ assert }) => {
640
+ const doc = { type: 'document', id: docId() };
641
+ const g = { type: 'group', id: docId() };
642
+ const p = partition();
643
+ await state.manager.relate({ object: g, relation: 'member' }, 'viewer', doc, p);
644
+ const page = await state.manager.listSubjects('viewer', doc, p);
645
+ assert.isTrue(page.subjects.some((s) => isRelUserset(s)));
646
+ }),
647
+ whenFalse: () => test('usersetSubjects:false · listSubjects devuelve solo holders', async ({ assert }) => {
648
+ const doc = { type: 'document', id: docId() };
649
+ const g = { type: 'group', id: docId() };
650
+ const u = holder();
651
+ const p = partition();
652
+ await state.manager.relate(u, 'viewer', doc, p);
653
+ await state.manager.relate({ object: g, relation: 'member' }, 'viewer', doc, p);
654
+ const page = await state.manager.listSubjects('viewer', doc, p);
655
+ assert.isFalse(page.subjects.some((s) => isRelUserset(s)));
656
+ }),
657
+ });
658
+ caseFor('membersOfNative', {
659
+ whenTrue: () => test('membersOfNative:true · membersOf da la membresía TRANSITIVA (distinta de listSubjects)', async ({ assert, }) => {
660
+ const u = holder();
661
+ const g1 = { type: 'group', id: docId() };
662
+ const g2 = { type: 'group', id: docId() };
663
+ const p = partition();
664
+ await state.manager.relate(u, 'member', g1, p);
665
+ await state.manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
666
+ const transitive = await state.manager.membersOf(g2, 'member', p);
667
+ assert.isTrue(transitive.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid));
668
+ // listSubjects DIRECTO de g2 no lo trae (solo el userset g1#member).
669
+ const direct = await state.manager.listSubjects('member', g2, p);
670
+ assert.isFalse(direct.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid));
671
+ }),
672
+ whenFalse: () => test('membersOfNative:false · membersOf ⇒ 500 E_AUTHZ_UNSUPPORTED nombrándolo', async ({ assert }) => {
673
+ const g = { type: 'group', id: docId() };
674
+ const p = partition();
675
+ let caught;
676
+ try {
677
+ await state.manager.membersOf(g, 'member', p);
678
+ assert.fail('debería haber lanzado');
679
+ }
680
+ catch (error) {
681
+ caught = error;
682
+ }
683
+ assert.equal(caught.status, 500);
684
+ assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED');
685
+ assert.include(caught.message, 'membersOf');
686
+ }),
687
+ });
688
+ caseFor('enumerateRelations', {
689
+ whenTrue: () => test('enumerateRelations:true · enumera las tuplas de la partición', async ({ assert }) => {
690
+ const u = holder();
691
+ const doc = { type: 'document', id: docId() };
692
+ const p = partition();
693
+ await state.manager.relate(u, 'viewer', doc, p);
694
+ const page = await state.manager.enumerateRelations(p);
695
+ assert.lengthOf(page.tuples, 1);
696
+ assert.equal(page.tuples[0].relation, 'viewer');
697
+ }),
698
+ whenFalse: () => test('enumerateRelations:false · enumerateRelations ⇒ 500 E_AUTHZ_UNSUPPORTED', async ({ assert }) => {
699
+ const p = partition();
700
+ let caught;
701
+ try {
702
+ await state.manager.enumerateRelations(p);
703
+ assert.fail('debería haber lanzado');
704
+ }
705
+ catch (error) {
706
+ caught = error;
707
+ }
708
+ assert.equal(caught.status, 500);
709
+ assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED');
710
+ assert.include(caught.message, 'enumerateRelations');
711
+ }),
712
+ });
713
+ caseFor('listObjectsTruncation', {
714
+ whenTrue: () => test('listObjectsTruncation:true · listObjects trunca al tope y SEÑALA truncated', async ({ assert }) => {
715
+ const limit = harness.limits?.listMaxResults;
716
+ if (!limit) {
717
+ throw new Error(`[contrato ${harness.name}] declara 'listObjectsTruncation: true' sin 'limits.listMaxResults'.`);
718
+ }
719
+ const u = holder();
720
+ const p = partition();
721
+ for (let i = 0; i < limit + 2; i++) {
722
+ await state.manager.relate(u, 'viewer', { type: 'document', id: docId() }, p);
723
+ }
724
+ const page = await state.manager.listObjects(u, 'viewer', 'document', p);
725
+ assert.lengthOf(page.objects, limit);
726
+ assert.isTrue(page.truncated);
727
+ }),
728
+ whenFalse: () => test('listObjectsTruncation:false · listObjects es exhaustiva (sin señal de truncado)', async ({ assert, }) => {
729
+ const u = holder();
730
+ const p = partition();
731
+ for (let i = 0; i < 5; i++) {
732
+ await state.manager.relate(u, 'viewer', { type: 'document', id: docId() }, p);
733
+ }
734
+ const page = await state.manager.listObjects(u, 'viewer', 'document', p);
735
+ assert.lengthOf(page.objects, 5);
736
+ assert.isUndefined(page.truncated);
737
+ }),
738
+ });
739
+ /* ── R-15 · el par `injectableClock` (paridad con roles, 2.5 · J1) ── */
740
+ caseFor('injectableClock', {
741
+ whenTrue: () => test('injectableClock:true · expiresAt en tres estados con el reloj inyectado, y la caducidad EXACTA (T−1 ms concede, T no, T+1 no) en check/listObjects/listSubjects y a través de un userset; renovar y revivir sin dormir', async ({ assert, }) => {
742
+ assert.typeOf(state.base.withClock, 'function', 'injectableClock: true exige withClock en el puerto');
743
+ // `T` lleva milisegundos a propósito (2.5 · J3): un motor que trunque
744
+ // a segundos falla aquí (DATETIME(3) en MySQL).
745
+ const T = new Date('2030-06-15T12:34:56.789Z');
746
+ const clock = fixedClock(new Date(T.getTime() - 60_000));
747
+ const clocked = new RelationsManager(state.base.withClock(clock.now), contractRelationsConfig());
748
+ const u = holder();
749
+ const doc = { type: 'document', id: docId() };
750
+ const viaGroup = { type: 'document', id: docId() };
751
+ const g = { type: 'group', id: docId() };
752
+ const p = partition();
753
+ await clocked.relate(u, 'viewer', doc, p, { expiresAt: T });
754
+ // La MEMBRESÍA caduca: g#member es editor de `viaGroup` sin plazo, pero
755
+ // u es member de g solo hasta T ⇒ en T deja de ser viewer de viaGroup.
756
+ await clocked.relate(u, 'member', g, p, { expiresAt: T });
757
+ await clocked.relate({ object: g, relation: 'member' }, 'editor', viaGroup, p);
758
+ const observe = async () => ({
759
+ check: await clocked.check(u, 'viewer', doc, p),
760
+ listObjects: (await clocked.listObjects(u, 'viewer', 'document', p)).objects.some((o) => o.id === doc.id),
761
+ listSubjects: (await clocked.listSubjects('viewer', doc, p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
762
+ viaGroup: await clocked.check(u, 'viewer', viaGroup, p),
763
+ ...(harness.capabilities.membersOfNative
764
+ ? {
765
+ membersOf: (await clocked.membersOf(g, 'member', p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
766
+ }
767
+ : {}),
768
+ });
769
+ const alive = {
770
+ check: true,
771
+ listObjects: true,
772
+ listSubjects: true,
773
+ viaGroup: true,
774
+ ...(harness.capabilities.membersOfNative ? { membersOf: true } : {}),
775
+ };
776
+ const gone = {
777
+ check: false,
778
+ listObjects: false,
779
+ listSubjects: false,
780
+ viaGroup: false,
781
+ ...(harness.capabilities.membersOfNative ? { membersOf: false } : {}),
782
+ };
783
+ clock.set(new Date(T.getTime() - 1));
784
+ assert.deepEqual(await observe(), alive, 'T−1 ms');
785
+ clock.set(T);
786
+ assert.deepEqual(await observe(), gone, 'T: la que vence ahora ya no cuenta (estricta)');
787
+ clock.set(new Date(T.getTime() + 1));
788
+ assert.deepEqual(await observe(), gone, 'T+1 ms');
789
+ // Renovación: `Date` posterior vuelve a conceder hasta ESE instante.
790
+ const T2 = new Date(T.getTime() + 1_000);
791
+ await clocked.relate(u, 'viewer', doc, p, { expiresAt: T2 });
792
+ await clocked.relate(u, 'member', g, p, { expiresAt: T2 });
793
+ assert.deepEqual(await observe(), alive, 'renovada en T+1 ms');
794
+ clock.set(new Date(T2.getTime() - 1));
795
+ assert.deepEqual(await observe(), alive, 'T2−1 ms');
796
+ clock.set(T2);
797
+ assert.deepEqual(await observe(), gone, 'T2');
798
+ // Los tres estados (invariante 10), con el reloj en T2 (u ya caducado):
799
+ // omitido sobre una CADUCADA revive sin caducidad;
800
+ await clocked.relate(u, 'viewer', doc, p);
801
+ await clocked.relate(u, 'member', g, p);
802
+ clock.set(new Date('2099-12-31T23:59:59.999Z'));
803
+ assert.deepEqual(await observe(), alive, 'revivida sin caducidad: vigente en 2099');
804
+ // omitido sobre una VIGENTE la preserva (no la convierte en permanente);
805
+ const keep = holder();
806
+ const soon = new Date(clock.now().getTime() + 1_500);
807
+ await clocked.relate(keep, 'viewer', doc, p, { expiresAt: soon });
808
+ await clocked.relate(keep, 'viewer', doc, p);
809
+ // null la quita.
810
+ const lift = holder();
811
+ await clocked.relate(lift, 'viewer', doc, p, { expiresAt: soon });
812
+ await clocked.relate(lift, 'viewer', doc, p, { expiresAt: null });
813
+ clock.set(new Date(soon.getTime() - 1));
814
+ assert.isTrue(await clocked.check(keep, 'viewer', doc, p), 'preservada: vigente en soon−1');
815
+ clock.set(soon);
816
+ assert.isFalse(await clocked.check(keep, 'viewer', doc, p), 'preservada: vence en soon (omitido NO la hizo permanente)');
817
+ assert.isTrue(await clocked.check(lift, 'viewer', doc, p), 'null la quitó: sigue vigente');
818
+ // El driver del harness (sin reloj inyectado) no se ha tocado: para él
819
+ // (hoy) T2 —2030— es futuro y la relación renovada concede.
820
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
821
+ }),
822
+ whenFalse: () => test('injectableClock:false · expiresAt en tres estados en tiempo real (1,5 s): omitido preserva, null quita, caducada revive; sin withClock', async ({ assert, }) => {
823
+ assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');
824
+ const doc = { type: 'document', id: docId() };
825
+ const p = partition();
826
+ const keep = holder();
827
+ const lift = holder();
828
+ const revive = holder();
829
+ const soon = new Date(Date.now() + 1_500);
830
+ await state.manager.relate(keep, 'viewer', doc, p, { expiresAt: soon });
831
+ await state.manager.relate(keep, 'viewer', doc, p); // omitido: preserva `soon`
832
+ await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: soon });
833
+ await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: null }); // la quita
834
+ await state.manager.relate(revive, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
835
+ assert.isFalse(await state.manager.check(revive, 'viewer', doc, p), 'caducada no concede');
836
+ await state.manager.relate(revive, 'viewer', doc, p); // omitido sobre caducada: revive sin caducidad
837
+ assert.isTrue(await state.manager.check(keep, 'viewer', doc, p));
838
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
839
+ assert.isTrue(await state.manager.check(revive, 'viewer', doc, p));
840
+ await sleep(1_700);
841
+ assert.isFalse(await state.manager.check(keep, 'viewer', doc, p), 'preservada: venció');
842
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p), 'sin caducidad: sigue');
843
+ assert.isTrue(await state.manager.check(revive, 'viewer', doc, p), 'revivida sin caducidad: sigue');
844
+ }),
845
+ });
846
+ /* ── L-2 · el par `transactionalWrites` (panel `{trx}`, (C); mismo nombre que en roles) ── */
847
+ caseFor('transactionalWrites', {
848
+ // L-4: la cara `true`. Lo que se juzga es «los dos o ninguno con TU
849
+ // transacción» POR CENSO (paridad con la cara `true` de roles, L-3): el
850
+ // rollback deja CERO tuplas nuevas en la partición (no solo `check`
851
+ // false — una respuesta puede salir bien con la fila viva), el commit
852
+ // las deja escritas, mientras la transacción está abierta la autoridad
853
+ // (que lee por la conexión del motor) no ve nada, y `purgeObject`/
854
+ // `purgeSubject` borran y REVIERTEN juntos (tras el rollback todo lo
855
+ // purgado está de vuelta). Y la otra mitad: una transacción que NO es
856
+ // la del driver no recibe ni una sentencia. Necesita un motor con
857
+ // transacciones reales y pool ≥ 2: el harness `database` lo declara
858
+ // `true` en `sqlite-file`/PG/MySQL y `false` en `:memory:`.
859
+ whenTrue: () => test('transactionalWrites:true · relate/unrelate/purgeObject/purgeSubject con { transaction } + rollback ⇒ check sin cambio Y CERO tuplas nuevas (censo); + commit ⇒ aplicado; purge* borran y revierten JUNTOS; una transacción ajena, un cliente sin transacción o el `db` entero ⇒ 500 E_AUTHZ_CONFIG sin UNA sentencia por ella', async ({ assert, }) => {
860
+ assert.equal(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: true y el driver no: declara lo observable');
861
+ const tx = harness.transactions ?? lucidRelationsContractTransactions();
862
+ const u = holder();
863
+ const v = holder();
864
+ const doc = { type: 'document', id: docId() };
865
+ const other = { type: 'document', id: docId() };
866
+ const p = partition();
867
+ const can = () => state.manager.check(u, 'viewer', doc, p);
868
+ const census = () => tx.census(p);
869
+ // Una transacción que se queda abierta tras una aserción fallida
870
+ // retiene una conexión del pool y arrastra a los casos siguientes:
871
+ // el rollback va en `finally`, y el commit solo si nada falló.
872
+ const rolledBack = async (fn) => {
873
+ const t = await tx.begin();
874
+ try {
875
+ await fn(t.client);
876
+ }
877
+ finally {
878
+ await t.rollback();
879
+ }
880
+ };
881
+ const committed = async (fn) => {
882
+ const t = await tx.begin();
883
+ try {
884
+ await fn(t.client);
885
+ }
886
+ catch (error) {
887
+ await t.rollback();
888
+ throw error;
889
+ }
890
+ await t.commit();
891
+ };
892
+ // relate: dentro no se ve desde fuera; rollback ⇒ nada; commit ⇒ concede.
893
+ let seenInside;
894
+ await rolledBack(async (transaction) => {
895
+ await state.manager.relate(u, 'viewer', doc, p, { transaction });
896
+ seenInside = await can();
897
+ });
898
+ assert.equal(await census(), 0, 'ROJO: la relación sobrevivió al rollback (la escritura NO fue por la transacción del llamante)');
899
+ assert.isFalse(await can(), 'relate + rollback ⇒ no concede');
900
+ assert.isFalse(seenInside, 'la autoridad lee por la conexión del motor: el relate sin confirmar no concede');
901
+ await committed(async (transaction) => {
902
+ await state.manager.relate(u, 'viewer', doc, p, { transaction });
903
+ });
904
+ assert.isTrue(await can(), 'relate + commit ⇒ concede');
905
+ assert.equal(await census(), 1);
906
+ // unrelate: rollback ⇒ la tupla sigue; commit ⇒ se va.
907
+ await rolledBack(async (transaction) => {
908
+ await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
909
+ seenInside = await can();
910
+ });
911
+ assert.equal(await census(), 1, 'ROJO: el unrelate sobrevivió al rollback');
912
+ assert.isTrue(await can(), 'unrelate + rollback ⇒ sigue concediendo');
913
+ assert.isTrue(seenInside, 'el unrelate sin confirmar no se ve desde la conexión del motor');
914
+ await committed(async (transaction) => {
915
+ await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
916
+ });
917
+ assert.isFalse(await can(), 'unrelate + commit ⇒ no concede');
918
+ assert.equal(await census(), 0);
919
+ // purgeObject: dos sujetos sobre `doc` + uno sobre `other`; rollback ⇒
920
+ // los tres siguen (borran y revierten JUNTOS); commit ⇒ quedan solo
921
+ // los de `other`.
922
+ await state.manager.relate(u, 'viewer', doc, p);
923
+ await state.manager.relate(v, 'editor', doc, p);
924
+ await state.manager.relate(u, 'owner', other, p);
925
+ assert.equal(await census(), 3);
926
+ await rolledBack(async (transaction) => {
927
+ await state.manager.purgeObject(doc, p, { transaction });
928
+ seenInside = await can();
929
+ });
930
+ assert.equal(await census(), 3, 'ROJO: purgeObject sobrevivió al rollback (algo de lo purgado no volvió)');
931
+ assert.isTrue(await can(), 'purgeObject + rollback ⇒ sigue concediendo');
932
+ assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
933
+ assert.isTrue(seenInside, 'el purgeObject sin confirmar no se ve desde la conexión del motor');
934
+ await committed(async (transaction) => {
935
+ await state.manager.purgeObject(doc, p, { transaction });
936
+ });
937
+ assert.equal(await census(), 1, 'purgeObject + commit ⇒ solo queda la tupla de `other`');
938
+ assert.isFalse(await can());
939
+ assert.isFalse(await state.manager.check(v, 'viewer', doc, p));
940
+ assert.isTrue(await state.manager.check(u, 'owner', other, p));
941
+ // purgeSubject: `u` es owner de `other` y viewer de `doc` de nuevo;
942
+ // rollback ⇒ las dos siguen; commit ⇒ las dos se van y `v` no se toca.
943
+ await state.manager.relate(u, 'viewer', doc, p);
944
+ await state.manager.relate(v, 'viewer', doc, p);
945
+ assert.equal(await census(), 3);
946
+ await rolledBack(async (transaction) => {
947
+ await state.manager.purgeSubject(u, p, { transaction });
948
+ seenInside = await can();
949
+ });
950
+ assert.equal(await census(), 3, 'ROJO: purgeSubject sobrevivió al rollback');
951
+ assert.isTrue(await can(), 'purgeSubject + rollback ⇒ sigue concediendo');
952
+ assert.isTrue(await state.manager.check(u, 'owner', other, p));
953
+ assert.isTrue(seenInside, 'el purgeSubject sin confirmar no se ve desde la conexión del motor');
954
+ await committed(async (transaction) => {
955
+ await state.manager.purgeSubject(u, p, { transaction });
956
+ });
957
+ assert.equal(await census(), 1, 'purgeSubject + commit ⇒ solo queda la tupla de `v`');
958
+ assert.isFalse(await can());
959
+ assert.isFalse(await state.manager.check(u, 'owner', other, p));
960
+ assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
961
+ // Una transacción que NO es la del driver (`assertCallerTransaction`):
962
+ // 500 `E_AUTHZ_CONFIG` y ni una sentencia por ella (espía).
963
+ const statements = [];
964
+ const spyTrx = (shape) => new Proxy(shape, {
965
+ get(target, prop) {
966
+ if (prop === 'from' || prop === 'table' || prop === 'raw' || prop === 'query' || prop === 'knexQuery' || prop === 'rawQuery' || prop === 'transaction') {
967
+ return (...args) => {
968
+ statements.push(`${String(prop)}(${String(args[0] ?? '')})`);
969
+ return { where: () => ({}), select: async () => [], insert: async () => [], delete: async () => 0 };
970
+ };
971
+ }
972
+ return Reflect.get(target, prop);
973
+ },
974
+ });
975
+ const foreign = spyTrx({ isTransaction: true, connectionName: `not-${harness.name}-${uuidv7()}` });
976
+ const notATransaction = spyTrx({ isTransaction: false, connectionName: 'primary' });
977
+ const wholeDb = spyTrx({ connection() { }, primaryConnectionName: 'primary' });
978
+ const w = holder();
979
+ const writesBefore = state.writes();
980
+ for (const [label, transaction] of [
981
+ ['otra conexión', foreign],
982
+ ['un cliente sin transacción', notATransaction],
983
+ ['el db entero', wholeDb],
984
+ ]) {
985
+ for (const [operation, run] of [
986
+ ['relate', () => state.manager.relate(w, 'viewer', doc, p, { transaction })],
987
+ ['unrelate', () => state.manager.unrelate(w, 'viewer', doc, p, { transaction })],
988
+ ['purgeObject', () => state.manager.purgeObject(doc, p, { transaction })],
989
+ ['purgeSubject', () => state.manager.purgeSubject(w, p, { transaction })],
990
+ ]) {
991
+ let caught;
992
+ try {
993
+ await run();
994
+ assert.fail(`${label}/${operation}: debería haber lanzado`);
995
+ }
996
+ catch (error) {
997
+ caught = error;
998
+ }
999
+ assert.equal(caught.status, 500, `${label}/${operation}: ${caught.message}`);
1000
+ assert.equal(caught.code, 'E_AUTHZ_CONFIG', `${label}/${operation}`);
1001
+ assert.include(caught.message, operation, `${label}/${operation}: nombra la operación`);
1002
+ }
1003
+ }
1004
+ assert.deepEqual(statements, [], 'ni una sentencia por una transacción que no es la del driver');
1005
+ assert.isAbove(state.writes(), writesBefore, 'la trx ajena la juzga el DRIVER (assertCallerTransaction), no la puerta 1');
1006
+ assert.equal(await census(), 1, 'la trx ajena no escribió nada');
1007
+ assert.isFalse(await state.manager.check(w, 'viewer', doc, p));
1008
+ })?.timeout(30_000),
1009
+ whenFalse: () => test('transactionalWrites:false · { transaction } en relate/unrelate/purgeObject/purgeSubject ⇒ 500 E_AUTHZ_UNSUPPORTED nombrando driver y operación, con CERO llamadas al driver (espía); y requireTransactionalWrites: true ⇒ 500 E_AUTHZ_CONFIG al construir el manager (= al resolver el driver)', async ({ assert, }) => {
1010
+ assert.notEqual(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: false y el driver declara true: declara lo observable');
1011
+ const trx = { from() { }, table() { }, isTransaction: true, connectionName: 'primary' };
1012
+ const u = holder();
1013
+ const doc = { type: 'document', id: docId() };
1014
+ const p = partition();
1015
+ const writes = [
1016
+ ['relate', () => state.manager.relate(u, 'viewer', doc, p, { transaction: trx })],
1017
+ ['unrelate', () => state.manager.unrelate(u, 'viewer', doc, p, { transaction: trx })],
1018
+ ['purgeObject', () => state.manager.purgeObject(doc, p, { transaction: trx })],
1019
+ ['purgeSubject', () => state.manager.purgeSubject(u, p, { transaction: trx })],
1020
+ ];
1021
+ for (const [operation, run] of writes) {
1022
+ let caught;
1023
+ try {
1024
+ await run();
1025
+ assert.fail(`${operation}: debería haber lanzado`);
1026
+ }
1027
+ catch (error) {
1028
+ caught = error;
1029
+ }
1030
+ assert.equal(caught.status, 500, `${operation}: ${caught.message}`);
1031
+ assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED', operation);
1032
+ assert.include(caught.message, `'${harness.name}'`, `${operation}: nombra el driver`);
1033
+ assert.include(caught.message, operation, `${operation}: nombra la operación`);
1034
+ assert.include(caught.message, 'requireTransactionalWrites', `${operation}: la letra lleva la salida`);
1035
+ }
1036
+ assert.equal(state.writes(), 0, 'cero llamadas al driver: el rechazo va ANTES del backend');
1037
+ // Sin `{ transaction }` la MISMA escritura entra.
1038
+ await state.manager.relate(u, 'viewer', doc, p);
1039
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
1040
+ assert.equal(state.writes(), 1);
1041
+ // Puerta 2, opt-in: al construir el manager (= resolver el driver).
1042
+ let atResolve;
1043
+ try {
1044
+ new RelationsManager(state.base, contractRelationsConfig(), {
1045
+ requireTransactionalWrites: true,
1046
+ driverName: harness.name,
1047
+ });
1048
+ assert.fail('requireTransactionalWrites: true sobre un driver que declara false tenía que lanzar al construir');
1049
+ }
1050
+ catch (error) {
1051
+ atResolve = error;
1052
+ }
1053
+ assert.equal(atResolve.status, 500, String(atResolve?.message));
1054
+ assert.equal(atResolve.code, 'E_AUTHZ_CONFIG');
1055
+ assert.include(atResolve.message, `'${harness.name}'`);
1056
+ assert.include(atResolve.message, 'transactionalWrites');
1057
+ }),
1058
+ });
1059
+ // Al cerrar: toda capacidad declarada tiene que haber registrado su cara.
1060
+ group.teardown(async () => {
1061
+ const uncovered = Object.keys(harness.capabilities).filter((c) => !covered.has(c));
1062
+ if (uncovered.length) {
1063
+ throw new Error(`[contrato ${harness.name}] no cubrió ${uncovered.join(', ')}: cada capacidad necesita su caso.`);
1064
+ }
1065
+ });
1066
+ });
1067
+ return { registered, covered };
1068
+ }
1069
+ //# sourceMappingURL=relations_contract.js.map