@jantstack/adonis-authz 1.1.0 → 2.4.0-alpha.1

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 (236) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +837 -51
  3. package/build/commands/authz_catalog_diff.d.ts +28 -0
  4. package/build/commands/authz_catalog_diff.d.ts.map +1 -0
  5. package/build/commands/authz_catalog_diff.js +67 -0
  6. package/build/commands/authz_catalog_diff.js.map +1 -0
  7. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  8. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  9. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  10. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  11. package/build/commands/authz_catalog_sync.d.ts +37 -0
  12. package/build/commands/authz_catalog_sync.d.ts.map +1 -0
  13. package/build/commands/authz_catalog_sync.js +81 -0
  14. package/build/commands/authz_catalog_sync.js.map +1 -0
  15. package/build/commands/authz_freeze.d.ts +44 -0
  16. package/build/commands/authz_freeze.d.ts.map +1 -0
  17. package/build/commands/authz_freeze.js +95 -0
  18. package/build/commands/authz_freeze.js.map +1 -0
  19. package/build/commands/authz_reconcile.d.ts +102 -0
  20. package/build/commands/authz_reconcile.d.ts.map +1 -0
  21. package/build/commands/authz_reconcile.js +294 -0
  22. package/build/commands/authz_reconcile.js.map +1 -0
  23. package/build/commands/authz_relations_reconcile.d.ts +73 -0
  24. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  25. package/build/commands/authz_relations_reconcile.js +225 -0
  26. package/build/commands/authz_relations_reconcile.js.map +1 -0
  27. package/build/commands/authz_scopes_relay.d.ts +47 -0
  28. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  29. package/build/commands/authz_scopes_relay.js +141 -0
  30. package/build/commands/authz_scopes_relay.js.map +1 -0
  31. package/build/commands/authz_unfreeze.d.ts +37 -0
  32. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  33. package/build/commands/authz_unfreeze.js +92 -0
  34. package/build/commands/authz_unfreeze.js.map +1 -0
  35. package/build/commands/main.d.ts +8 -1
  36. package/build/commands/main.d.ts.map +1 -1
  37. package/build/commands/main.js +8 -1
  38. package/build/commands/main.js.map +1 -1
  39. package/build/commands/openfga_provision.d.ts +46 -4
  40. package/build/commands/openfga_provision.d.ts.map +1 -1
  41. package/build/commands/openfga_provision.js +91 -7
  42. package/build/commands/openfga_provision.js.map +1 -1
  43. package/build/configure.d.ts +11 -0
  44. package/build/configure.d.ts.map +1 -1
  45. package/build/configure.js +37 -1
  46. package/build/configure.js.map +1 -1
  47. package/build/index.d.ts +75 -7
  48. package/build/index.d.ts.map +1 -1
  49. package/build/index.js +67 -5
  50. package/build/index.js.map +1 -1
  51. package/build/providers/authz_provider.d.ts +26 -2
  52. package/build/providers/authz_provider.d.ts.map +1 -1
  53. package/build/providers/authz_provider.js +48 -2
  54. package/build/providers/authz_provider.js.map +1 -1
  55. package/build/services/relations.d.ts +14 -0
  56. package/build/services/relations.d.ts.map +1 -0
  57. package/build/services/relations.js +17 -0
  58. package/build/services/relations.js.map +1 -0
  59. package/build/src/catalog/catalog.d.ts +289 -0
  60. package/build/src/catalog/catalog.d.ts.map +1 -0
  61. package/build/src/catalog/catalog.js +859 -0
  62. package/build/src/catalog/catalog.js.map +1 -0
  63. package/build/src/catalog/catalog_cache.d.ts +324 -0
  64. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  65. package/build/src/catalog/catalog_cache.js +666 -0
  66. package/build/src/catalog/catalog_cache.js.map +1 -0
  67. package/build/src/clock.d.ts +24 -0
  68. package/build/src/clock.d.ts.map +1 -0
  69. package/build/src/clock.js +7 -0
  70. package/build/src/clock.js.map +1 -0
  71. package/build/src/define_config.d.ts +201 -7
  72. package/build/src/define_config.d.ts.map +1 -1
  73. package/build/src/define_config.js.map +1 -1
  74. package/build/src/drivers/database_driver.d.ts +324 -15
  75. package/build/src/drivers/database_driver.d.ts.map +1 -1
  76. package/build/src/drivers/database_driver.js +1107 -128
  77. package/build/src/drivers/database_driver.js.map +1 -1
  78. package/build/src/drivers/database_relations_driver.d.ts +75 -0
  79. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  80. package/build/src/drivers/database_relations_driver.js +450 -0
  81. package/build/src/drivers/database_relations_driver.js.map +1 -0
  82. package/build/src/drivers/openfga_driver.d.ts +963 -55
  83. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  84. package/build/src/drivers/openfga_driver.js +2693 -372
  85. package/build/src/drivers/openfga_driver.js.map +1 -1
  86. package/build/src/drivers/openfga_facts.d.ts +369 -0
  87. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  88. package/build/src/drivers/openfga_facts.js +813 -0
  89. package/build/src/drivers/openfga_facts.js.map +1 -0
  90. package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
  91. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  92. package/build/src/drivers/openfga_relations_driver.js +466 -0
  93. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  94. package/build/src/errors.d.ts +586 -0
  95. package/build/src/errors.d.ts.map +1 -1
  96. package/build/src/errors.js +583 -0
  97. package/build/src/errors.js.map +1 -1
  98. package/build/src/expiry.d.ts +27 -0
  99. package/build/src/expiry.d.ts.map +1 -0
  100. package/build/src/expiry.js +50 -0
  101. package/build/src/expiry.js.map +1 -0
  102. package/build/src/freeze.d.ts +120 -0
  103. package/build/src/freeze.d.ts.map +1 -0
  104. package/build/src/freeze.js +172 -0
  105. package/build/src/freeze.js.map +1 -0
  106. package/build/src/hierarchical_resolver.d.ts +56 -0
  107. package/build/src/hierarchical_resolver.d.ts.map +1 -0
  108. package/build/src/hierarchical_resolver.js +87 -0
  109. package/build/src/hierarchical_resolver.js.map +1 -0
  110. package/build/src/{middleware → http}/app_access_middleware.d.ts +8 -6
  111. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  112. package/build/src/{middleware → http}/app_access_middleware.js +10 -26
  113. package/build/src/http/app_access_middleware.js.map +1 -0
  114. package/build/src/http/resource_access_middleware.d.ts +105 -0
  115. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  116. package/build/src/http/resource_access_middleware.js +81 -0
  117. package/build/src/http/resource_access_middleware.js.map +1 -0
  118. package/build/src/identity.d.ts +228 -0
  119. package/build/src/identity.d.ts.map +1 -0
  120. package/build/src/identity.js +457 -0
  121. package/build/src/identity.js.map +1 -0
  122. package/build/src/manager.d.ts +536 -7
  123. package/build/src/manager.d.ts.map +1 -1
  124. package/build/src/manager.js +2676 -23
  125. package/build/src/manager.js.map +1 -1
  126. package/build/src/memoize_ancestors.d.ts +23 -0
  127. package/build/src/memoize_ancestors.d.ts.map +1 -0
  128. package/build/src/memoize_ancestors.js +42 -0
  129. package/build/src/memoize_ancestors.js.map +1 -0
  130. package/build/src/models/authz_assignment.d.ts +11 -11
  131. package/build/src/models/authz_assignment.d.ts.map +1 -1
  132. package/build/src/models/authz_deny.d.ts +11 -11
  133. package/build/src/models/authz_deny.d.ts.map +1 -1
  134. package/build/src/models/authz_permission.d.ts +17 -11
  135. package/build/src/models/authz_permission.d.ts.map +1 -1
  136. package/build/src/models/authz_permission.js +4 -0
  137. package/build/src/models/authz_permission.js.map +1 -1
  138. package/build/src/models/authz_role.d.ts +19 -12
  139. package/build/src/models/authz_role.d.ts.map +1 -1
  140. package/build/src/models/authz_role.js +6 -1
  141. package/build/src/models/authz_role.js.map +1 -1
  142. package/build/src/models/authz_role_permission.d.ts +11 -11
  143. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  144. package/build/src/openfga.d.ts +29 -0
  145. package/build/src/openfga.d.ts.map +1 -0
  146. package/build/src/openfga.js +26 -0
  147. package/build/src/openfga.js.map +1 -0
  148. package/build/src/reconcile.d.ts +37 -0
  149. package/build/src/reconcile.d.ts.map +1 -0
  150. package/build/src/reconcile.js +69 -0
  151. package/build/src/reconcile.js.map +1 -0
  152. package/build/src/relation_partition_trigger.d.ts +8 -0
  153. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  154. package/build/src/relation_partition_trigger.js +85 -0
  155. package/build/src/relation_partition_trigger.js.map +1 -0
  156. package/build/src/relations/define_relations_config.d.ts +58 -0
  157. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  158. package/build/src/relations/define_relations_config.js +144 -0
  159. package/build/src/relations/define_relations_config.js.map +1 -0
  160. package/build/src/relations/manager.d.ts +38 -0
  161. package/build/src/relations/manager.d.ts.map +1 -0
  162. package/build/src/relations/manager.js +156 -0
  163. package/build/src/relations/manager.js.map +1 -0
  164. package/build/src/relations/reconcile.d.ts +62 -0
  165. package/build/src/relations/reconcile.d.ts.map +1 -0
  166. package/build/src/relations/reconcile.js +138 -0
  167. package/build/src/relations/reconcile.js.map +1 -0
  168. package/build/src/relations_config_store.d.ts +22 -0
  169. package/build/src/relations_config_store.d.ts.map +1 -0
  170. package/build/src/relations_config_store.js +74 -0
  171. package/build/src/relations_config_store.js.map +1 -0
  172. package/build/src/scope_outbox.d.ts +69 -0
  173. package/build/src/scope_outbox.d.ts.map +1 -0
  174. package/build/src/scope_outbox.js +291 -0
  175. package/build/src/scope_outbox.js.map +1 -0
  176. package/build/src/shared/backend_guard.d.ts +106 -0
  177. package/build/src/shared/backend_guard.d.ts.map +1 -0
  178. package/build/src/shared/backend_guard.js +246 -0
  179. package/build/src/shared/backend_guard.js.map +1 -0
  180. package/build/src/shared/sql_expiry.d.ts +53 -0
  181. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  182. package/build/src/shared/sql_expiry.js +66 -0
  183. package/build/src/shared/sql_expiry.js.map +1 -0
  184. package/build/src/sql_descendants.d.ts +97 -0
  185. package/build/src/sql_descendants.d.ts.map +1 -0
  186. package/build/src/sql_descendants.js +203 -0
  187. package/build/src/sql_descendants.js.map +1 -0
  188. package/build/src/testing/contract.d.ts +212 -7
  189. package/build/src/testing/contract.d.ts.map +1 -1
  190. package/build/src/testing/contract.js +3449 -24
  191. package/build/src/testing/contract.js.map +1 -1
  192. package/build/src/testing/main.d.ts +10 -2
  193. package/build/src/testing/main.d.ts.map +1 -1
  194. package/build/src/testing/main.js +5 -1
  195. package/build/src/testing/main.js.map +1 -1
  196. package/build/src/testing/migration_contract.d.ts +284 -0
  197. package/build/src/testing/migration_contract.d.ts.map +1 -0
  198. package/build/src/testing/migration_contract.js +586 -0
  199. package/build/src/testing/migration_contract.js.map +1 -0
  200. package/build/src/testing/relations_contract.d.ts +51 -0
  201. package/build/src/testing/relations_contract.d.ts.map +1 -0
  202. package/build/src/testing/relations_contract.js +654 -0
  203. package/build/src/testing/relations_contract.js.map +1 -0
  204. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  205. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  206. package/build/src/testing/relations_reconcile_contract.js +172 -0
  207. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  208. package/build/src/testing/scope_tree.d.ts +65 -0
  209. package/build/src/testing/scope_tree.d.ts.map +1 -0
  210. package/build/src/testing/scope_tree.js +145 -0
  211. package/build/src/testing/scope_tree.js.map +1 -0
  212. package/build/src/traits/authz_scopes.d.ts +30 -6
  213. package/build/src/traits/authz_scopes.d.ts.map +1 -1
  214. package/build/src/traits/authz_scopes.js +30 -18
  215. package/build/src/traits/authz_scopes.js.map +1 -1
  216. package/build/src/traits/has_uuid.d.ts +12 -12
  217. package/build/src/traits/has_uuid.d.ts.map +1 -1
  218. package/build/src/types.d.ts +1313 -28
  219. package/build/src/types.d.ts.map +1 -1
  220. package/build/src/types.js +20 -4
  221. package/build/src/types.js.map +1 -1
  222. package/build/stubs/config/app_acl.stub +4 -2
  223. package/build/stubs/config/authorization.stub +168 -14
  224. package/build/stubs/migration.stub +183 -13
  225. package/build/stubs/scopes_outbox_migration.stub +57 -0
  226. package/package.json +14 -7
  227. package/build/commands/openfga_import.d.ts +0 -28
  228. package/build/commands/openfga_import.d.ts.map +0 -1
  229. package/build/commands/openfga_import.js +0 -74
  230. package/build/commands/openfga_import.js.map +0 -1
  231. package/build/src/catalog.d.ts +0 -3
  232. package/build/src/catalog.d.ts.map +0 -1
  233. package/build/src/catalog.js +0 -103
  234. package/build/src/catalog.js.map +0 -1
  235. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  236. package/build/src/middleware/app_access_middleware.js.map +0 -1
@@ -0,0 +1,654 @@
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 { RelationsManager } from '../relations/manager.js';
23
+ import { defineRelationsConfig } from '../relations/define_relations_config.js';
24
+ import { isRelUserset } from '../types.js';
25
+ /* ── El vocabulario del contrato ─────────────────────────────────────────── */
26
+ /** Los holder types del contrato de relaciones. */
27
+ export const RELATIONS_HOLDER_TYPES = ['user', 'admin', 'integration'];
28
+ /**
29
+ * La config FIJA del contrato: un tipo `document` con `owner ⊆ editor ⊆
30
+ * viewer` (includes de un nivel) y `membersOf` habilitado en `database`.
31
+ */
32
+ export function contractRelationsConfig() {
33
+ return defineRelationsConfig({
34
+ objectTypes: [
35
+ {
36
+ type: 'document',
37
+ relations: [
38
+ { name: 'owner' },
39
+ { name: 'editor', includes: ['owner'] },
40
+ { name: 'viewer', includes: ['editor'] },
41
+ ],
42
+ },
43
+ ],
44
+ holderTypes: [...RELATIONS_HOLDER_TYPES],
45
+ database: { membersOf: true },
46
+ });
47
+ }
48
+ function scopeKeyOf(scope) {
49
+ return scope.type === 'app' ? 'app' : `${scope.type}|${scope.uuid}`;
50
+ }
51
+ function subjectKeyOf(subject) {
52
+ if (isRelUserset(subject))
53
+ return `${subject.object.type}:${subject.object.id}#${subject.relation}`;
54
+ return `${subject.type}:${subject.uuid}`;
55
+ }
56
+ /**
57
+ * Un `RelationsDriver` en memoria que satisface el puerto y HONRA las
58
+ * capacidades declaradas (para poblar las dos caras del contrato). Es el doble
59
+ * que prueba el CONTRATO —no el MODELO fusionado (eso es el `:8101` en 4-4)—.
60
+ */
61
+ export function makeRelationsDriver(options) {
62
+ const { config, capabilities, limits } = options;
63
+ const tuples = [];
64
+ /**
65
+ * `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
66
+ * satisface R. `viewer includes editor` ⇒ un `editor` es también `viewer`,
67
+ * así que para resolver `viewer` se aceptan los sujetos de `viewer`, de lo
68
+ * que `viewer` incluye (`editor`) y transitivamente (`owner`). Se sigue la
69
+ * cadena de `includes` hacia ABAJO desde R.
70
+ */
71
+ function expandDown(objectType, relation) {
72
+ const out = new Set([relation]);
73
+ const type = config.objectTypes.find((t) => t.type === objectType);
74
+ const queue = [relation];
75
+ while (queue.length) {
76
+ const current = queue.pop();
77
+ const def = type?.relations.find((r) => r.name === current);
78
+ for (const inc of def?.includes ?? []) {
79
+ if (!out.has(inc)) {
80
+ out.add(inc);
81
+ queue.push(inc);
82
+ }
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+ function directSubjects(partition, objectType, objectId, relation) {
88
+ return tuples.filter((t) => t.partition === partition &&
89
+ t.objectType === objectType &&
90
+ t.objectId === objectId &&
91
+ t.relation === relation);
92
+ }
93
+ function resolves(subject, relation, object, partition, seen = new Set()) {
94
+ const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
95
+ if (seen.has(guardKey))
96
+ return false;
97
+ seen.add(guardKey);
98
+ const wantKey = subjectKeyOf(subject);
99
+ for (const rel of expandDown(object.type, relation)) {
100
+ for (const t of directSubjects(partition, object.type, object.id, rel)) {
101
+ if (t.subjectKey === wantKey)
102
+ return true;
103
+ // El sujeto directo es un userset (`group:g#member`): un HOLDER lo
104
+ // satisface si es MIEMBRO del userset (transitivo, grupos anidan).
105
+ if (isRelUserset(t.subject) && !isRelUserset(subject)) {
106
+ if (resolves(subject, t.subject.relation, t.subject.object, partition, seen))
107
+ return true;
108
+ }
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+ function collectMembers(object, relation, partition, seen = new Set()) {
114
+ const key = `${object.type}:${object.id}#${relation}`;
115
+ if (seen.has(key))
116
+ return [];
117
+ seen.add(key);
118
+ const out = [];
119
+ for (const rel of expandDown(object.type, relation)) {
120
+ for (const t of directSubjects(partition, object.type, object.id, rel)) {
121
+ if (isRelUserset(t.subject)) {
122
+ out.push(...collectMembers(t.subject.object, t.subject.relation, partition, seen));
123
+ }
124
+ else {
125
+ out.push(t.subject);
126
+ }
127
+ }
128
+ }
129
+ return dedupeSubjects(out);
130
+ }
131
+ function dedupeSubjects(subjects) {
132
+ const seen = new Set();
133
+ const out = [];
134
+ for (const s of subjects) {
135
+ const k = subjectKeyOf(s);
136
+ if (seen.has(k))
137
+ continue;
138
+ seen.add(k);
139
+ out.push(s);
140
+ }
141
+ return out;
142
+ }
143
+ const driver = {
144
+ capabilities,
145
+ async relate(subject, relation, object, partition) {
146
+ const partitionKey = scopeKeyOf(partition);
147
+ const subjectKey = subjectKeyOf(subject);
148
+ const exists = tuples.some((t) => t.partition === partitionKey &&
149
+ t.objectType === object.type &&
150
+ t.objectId === object.id &&
151
+ t.relation === relation &&
152
+ t.subjectKey === subjectKey);
153
+ if (!exists) {
154
+ tuples.push({
155
+ partition: partitionKey,
156
+ objectType: object.type,
157
+ objectId: object.id,
158
+ relation,
159
+ subject,
160
+ subjectKey,
161
+ });
162
+ }
163
+ },
164
+ async unrelate(subject, relation, object, partition) {
165
+ const partitionKey = scopeKeyOf(partition);
166
+ const subjectKey = subjectKeyOf(subject);
167
+ for (let i = tuples.length - 1; i >= 0; i--) {
168
+ const t = tuples[i];
169
+ if (t.partition === partitionKey &&
170
+ t.objectType === object.type &&
171
+ t.objectId === object.id &&
172
+ t.relation === relation &&
173
+ t.subjectKey === subjectKey) {
174
+ tuples.splice(i, 1);
175
+ }
176
+ }
177
+ },
178
+ async check(subject, relation, object, partition) {
179
+ return resolves(subject, relation, object, scopeKeyOf(partition));
180
+ },
181
+ async listObjects(subject, relation, objectType, partition, page) {
182
+ const partitionKey = scopeKeyOf(partition);
183
+ const ids = new Set();
184
+ for (const t of tuples) {
185
+ if (t.partition === partitionKey && t.objectType === objectType)
186
+ ids.add(t.objectId);
187
+ }
188
+ const matches = [];
189
+ for (const id of ids) {
190
+ if (resolves(subject, relation, { type: objectType, id }, partitionKey)) {
191
+ matches.push({ type: objectType, id });
192
+ }
193
+ }
194
+ matches.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
195
+ // `listObjectsTruncation`: si el backend tiene tope y el driver lo
196
+ // señala, la página trunca al tope y avisa. Si no, es exhaustiva.
197
+ const cap = limits?.listMaxResults;
198
+ if (capabilities.listObjectsTruncation && cap && matches.length > cap) {
199
+ return { objects: matches.slice(0, cap), truncated: true };
200
+ }
201
+ return { objects: matches };
202
+ },
203
+ async listSubjects(relation, object, partition, page) {
204
+ const partitionKey = scopeKeyOf(partition);
205
+ // DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
206
+ // `membersOf`) ni derivado. `usersetSubjects` decide si los usersets
207
+ // (`group:g#member`) salen o solo los holders.
208
+ const direct = directSubjects(partitionKey, object.type, object.id, relation);
209
+ const subjects = direct
210
+ .map((t) => t.subject)
211
+ .filter((s) => capabilities.usersetSubjects || !isRelUserset(s));
212
+ return { subjects: dedupeSubjects(subjects) };
213
+ },
214
+ async purgeObject(object, partition) {
215
+ const partitionKey = scopeKeyOf(partition);
216
+ for (let i = tuples.length - 1; i >= 0; i--) {
217
+ const t = tuples[i];
218
+ if (t.partition === partitionKey && t.objectType === object.type && t.objectId === object.id) {
219
+ tuples.splice(i, 1);
220
+ }
221
+ }
222
+ },
223
+ async purgeSubject(subject, partition) {
224
+ const partitionKey = scopeKeyOf(partition);
225
+ const subjectKey = subjectKeyOf(subject);
226
+ for (let i = tuples.length - 1; i >= 0; i--) {
227
+ const t = tuples[i];
228
+ if (t.partition === partitionKey && t.subjectKey === subjectKey)
229
+ tuples.splice(i, 1);
230
+ }
231
+ },
232
+ };
233
+ if (capabilities.membersOfNative) {
234
+ driver.membersOf = async (object, relation, partition) => {
235
+ return { subjects: collectMembers(object, relation, scopeKeyOf(partition)) };
236
+ };
237
+ }
238
+ if (capabilities.enumerateRelations) {
239
+ driver.enumerateRelations = async (partition) => {
240
+ const partitionKey = scopeKeyOf(partition);
241
+ const out = tuples
242
+ .filter((t) => t.partition === partitionKey)
243
+ .map((t) => ({
244
+ subject: t.subject,
245
+ relation: t.relation,
246
+ object: { type: t.objectType, id: t.objectId },
247
+ partition,
248
+ }));
249
+ return { tuples: out };
250
+ };
251
+ }
252
+ return driver;
253
+ }
254
+ /* ── El runner ───────────────────────────────────────────────────────────── */
255
+ function holder() {
256
+ return { type: 'user', uuid: uuidv7() };
257
+ }
258
+ function docId() {
259
+ return uuidv7();
260
+ }
261
+ function partition() {
262
+ return { type: 'unit', uuid: uuidv7() };
263
+ }
264
+ /**
265
+ * Un driver que cuenta las llamadas a `relate`/`unrelate` (para probar «no tocó
266
+ * el driver»). Es un `Proxy` —no un `{...driver}`— para no perder los métodos
267
+ * de PROTOTIPO de un driver de CLASE (el `database` de 4-3): el spread solo
268
+ * copia propiedades propias, así que un `check`/`membersOf` en el prototipo
269
+ * desaparecía. Los métodos delegados se enlazan al `target` para que sus
270
+ * campos privados (`#…`) sigan funcionando a través del proxy.
271
+ */
272
+ function spy(driver) {
273
+ let writes = 0;
274
+ const wrapped = new Proxy(driver, {
275
+ get(target, prop, receiver) {
276
+ if (prop === 'relate' || prop === 'unrelate') {
277
+ return async (...args) => {
278
+ writes++;
279
+ return target[prop](...args);
280
+ };
281
+ }
282
+ const value = Reflect.get(target, prop, receiver);
283
+ return typeof value === 'function' ? value.bind(target) : value;
284
+ },
285
+ });
286
+ return { driver: wrapped, writes: () => writes };
287
+ }
288
+ export function runRelationsDriverContract(harness) {
289
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
290
+ return registerRelationsDriverContract(harness, {
291
+ group: (title, define) => japaTest.group(title, define),
292
+ test: (title, fn) => japaTest(title, fn),
293
+ });
294
+ }
295
+ /**
296
+ * El MOTOR del runner (registro de casos), separado para que el propio paquete
297
+ * pueda contar los casos (`relations_harness.spec.ts`). Devuelve el nº de
298
+ * casos registrados y las capacidades cubiertas.
299
+ */
300
+ export function registerRelationsDriverContract(harness, api) {
301
+ // **«Una capacidad `true` sin su soporte se rechaza»** (paridad con el guard
302
+ // de roles, `contract_harness.spec.ts`): `listObjectsTruncation: true` no se
303
+ // puede JUZGAR sin conocer el tope del backend —el caso `whenTrue` siembra
304
+ // `tope + 2` objetos y exige que la página trunque a `tope`—, así que sin
305
+ // `limits.listMaxResults` es un skip disfrazado y se rechaza AL REGISTRAR, no
306
+ // a mitad del caso. (Las demás capacidades sí tienen su whenTrue observable
307
+ // con el doble; una capacidad `true` cuyo DRIVER no traiga el método —una
308
+ // mentira— la corta el `RelationsManager` con 500, nunca un skip.)
309
+ if (harness.capabilities.listObjectsTruncation && harness.limits?.listMaxResults === undefined) {
310
+ throw new Error(`[contrato ${harness.name}] declara 'listObjectsTruncation: true' sin 'limits.listMaxResults': ` +
311
+ `sin el tope del backend no hay forma de observar el truncado (un skip disfrazado).`);
312
+ }
313
+ const covered = new Set();
314
+ let registered = 0;
315
+ const state = {};
316
+ async function freshManager(options) {
317
+ const base = await harness.makeDriver(contractRelationsConfig());
318
+ const s = spy(base);
319
+ return { manager: new RelationsManager(s.driver, contractRelationsConfig(), options), writes: s.writes, base };
320
+ }
321
+ api.group(`relations contract [${harness.name}]`, (group) => {
322
+ group.each.setup(async () => {
323
+ const f = await freshManager();
324
+ state.manager = f.manager;
325
+ state.base = f.base;
326
+ state.writes = f.writes;
327
+ });
328
+ const test = (title, fn) => {
329
+ registered++;
330
+ api.test(title, fn);
331
+ };
332
+ function caseFor(capability, pair) {
333
+ // MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
334
+ // en el código del runner, o es un skip disfrazado (exigencia del tester).
335
+ if (typeof pair.whenTrue !== 'function' || typeof pair.whenFalse !== 'function') {
336
+ throw new Error(`[contrato ${harness.name}] la capacidad '${capability}' no declara sus dos caras ` +
337
+ `(whenTrue Y whenFalse): una cara ausente es un skip disfrazado.`);
338
+ }
339
+ const before = registered;
340
+ (harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse)();
341
+ if (registered > before)
342
+ covered.add(capability);
343
+ }
344
+ /* ── R-01: denegación por defecto y concesión directa ── */
345
+ test('R-01 · relate concede; sin relate deniega (denegación por defecto)', async ({ assert }) => {
346
+ const u = holder();
347
+ const doc = { type: 'document', id: docId() };
348
+ const p = partition();
349
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p));
350
+ await state.manager.relate(u, 'viewer', doc, p);
351
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
352
+ });
353
+ /* ── R-02: includes sin `from` (editor ⊆ viewer) ── */
354
+ test('R-02 · includes: relate(editor) ⇒ check(viewer) true; el inverso NO', async ({ assert }) => {
355
+ const u = holder();
356
+ const doc = { type: 'document', id: docId() };
357
+ const p = partition();
358
+ await state.manager.relate(u, 'editor', doc, p);
359
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
360
+ const v = holder();
361
+ await state.manager.relate(v, 'viewer', doc, p);
362
+ assert.isFalse(await state.manager.check(v, 'editor', doc, p));
363
+ });
364
+ /* ── R-03/R-04: usersets, grupos anidados ── */
365
+ test('R-03/R-04 · userset: relate(group#member, viewer) + membresía anidada ⇒ check true', async ({ assert, }) => {
366
+ const u = holder();
367
+ const doc = { type: 'document', id: docId() };
368
+ const g1 = { type: 'group', id: docId() };
369
+ const g2 = { type: 'group', id: docId() };
370
+ const p = partition();
371
+ // g1 miembro de g2; u miembro de g1; g2#member es viewer del doc.
372
+ await state.manager.relate(u, 'member', g1, p);
373
+ await state.manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
374
+ await state.manager.relate({ object: g2, relation: 'member' }, 'viewer', doc, p);
375
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
376
+ });
377
+ /* ── R-05: la partición aísla ── */
378
+ test('R-05 · partición: relate en A no concede en B', async ({ assert }) => {
379
+ const u = holder();
380
+ const doc = { type: 'document', id: docId() };
381
+ const a = partition();
382
+ const b = partition();
383
+ await state.manager.relate(u, 'viewer', doc, a);
384
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, a));
385
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, b));
386
+ });
387
+ /* ── R-06: idempotencia ── */
388
+ test('R-06 · unrelate retira; re-relate no duplica; re-unrelate no-op', async ({ assert }) => {
389
+ const u = holder();
390
+ const doc = { type: 'document', id: docId() };
391
+ const p = partition();
392
+ await state.manager.relate(u, 'viewer', doc, p);
393
+ await state.manager.relate(u, 'viewer', doc, p); // idempotente
394
+ const before = await state.manager.listSubjects('viewer', doc, p);
395
+ assert.lengthOf(before.subjects, 1);
396
+ await state.manager.unrelate(u, 'viewer', doc, p);
397
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p));
398
+ await state.manager.unrelate(u, 'viewer', doc, p); // no-op seguro
399
+ });
400
+ /* ── R-07/R-08: purga con demostración de cero ── */
401
+ test('R-07/R-08 · purgeObject y purgeSubject retiran sus tuplas', async ({ assert }) => {
402
+ const u = holder();
403
+ const doc = { type: 'document', id: docId() };
404
+ const p = partition();
405
+ await state.manager.relate(u, 'viewer', doc, p);
406
+ await state.manager.purgeObject(doc, p);
407
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p));
408
+ const doc2 = { type: 'document', id: docId() };
409
+ await state.manager.relate(u, 'owner', doc2, p);
410
+ await state.manager.purgeSubject(u, p);
411
+ assert.isFalse(await state.manager.check(u, 'owner', doc2, p));
412
+ });
413
+ /* ── R-09: listObjects/listSubjects directos ── */
414
+ test('R-09 · listObjects devuelve los objetos del sujeto (directos + derivados)', async ({ assert }) => {
415
+ const u = holder();
416
+ const d1 = { type: 'document', id: docId() };
417
+ const d2 = { type: 'document', id: docId() };
418
+ const p = partition();
419
+ await state.manager.relate(u, 'editor', d1, p);
420
+ await state.manager.relate(u, 'viewer', d2, p);
421
+ const page = await state.manager.listObjects(u, 'viewer', 'document', p);
422
+ assert.sameMembers(page.objects.map((o) => o.id).sort(), [d1.id, d2.id].sort());
423
+ });
424
+ /* ── R-12: identidad mal formada ⇒ 422 ── */
425
+ test('R-12 · partición mal formada ⇒ 422 con code', async ({ assert }) => {
426
+ const u = holder();
427
+ const doc = { type: 'document', id: docId() };
428
+ let caught;
429
+ try {
430
+ await state.manager.relate(u, 'viewer', doc, { type: 'unit', uuid: 'NO-ES-UUID' });
431
+ assert.fail('debería haber lanzado');
432
+ }
433
+ catch (error) {
434
+ caught = error;
435
+ }
436
+ assert.equal(caught.status, 422);
437
+ assert.equal(caught.code, 'E_AUTHZ_INVALID_IDENTITY');
438
+ assert.equal(state.writes(), 0);
439
+ });
440
+ /* ── F-05 · el cierre del 🔴: tipo no declarado (role_binding) ⇒ 422 ANTES del driver ── */
441
+ test('F-05 · relate(evil, assignee, role_binding, S) ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN, driver intacto', async ({ assert, }) => {
442
+ const evil = holder();
443
+ const roleUuid = uuidv7();
444
+ const S = partition();
445
+ let caught;
446
+ try {
447
+ await state.manager.relate(evil, 'assignee', { type: 'role_binding', id: roleUuid }, S);
448
+ assert.fail('debería haber lanzado');
449
+ }
450
+ catch (error) {
451
+ caught = error;
452
+ }
453
+ assert.equal(caught.status, 422);
454
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN');
455
+ // El cierre estructural: el driver NUNCA se llamó, así que no pudo
456
+ // componer el id del binding ni escribir la tupla `assignee`.
457
+ assert.equal(state.writes(), 0);
458
+ });
459
+ test('F-05 · relate con una relación no declarada del tipo ⇒ 422 E_AUTHZ_RELATION_UNKNOWN, driver intacto', async ({ assert, }) => {
460
+ const u = holder();
461
+ const doc = { type: 'document', id: docId() };
462
+ const p = partition();
463
+ let caught;
464
+ try {
465
+ await state.manager.relate(u, 'assignee', doc, p); // 'assignee' no es relación de 'document'
466
+ assert.fail('debería haber lanzado');
467
+ }
468
+ catch (error) {
469
+ caught = error;
470
+ }
471
+ assert.equal(caught.status, 422);
472
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_UNKNOWN');
473
+ assert.equal(state.writes(), 0);
474
+ });
475
+ /* ── R-13: assertWrite PURO + actor en onRelationWrite ── */
476
+ test('R-13 · assertWrite rechaza ⇒ nada toca el driver; actor viaja en onRelationWrite', async ({ assert, }) => {
477
+ const events = [];
478
+ const rejected = await freshManager({
479
+ assertWrite: (ref) => {
480
+ if (ref.relation === 'owner')
481
+ throw new Error('no se permite conceder owner');
482
+ },
483
+ onRelationWrite: (e) => events.push(e),
484
+ });
485
+ const u = holder();
486
+ const doc = { type: 'document', id: docId() };
487
+ const p = partition();
488
+ const actor = holder();
489
+ let threw = false;
490
+ try {
491
+ await rejected.manager.relate(u, 'owner', doc, p, { actor });
492
+ }
493
+ catch {
494
+ threw = true;
495
+ }
496
+ assert.isTrue(threw);
497
+ assert.equal(rejected.writes(), 0); // assertWrite cortó ANTES del driver
498
+ assert.lengthOf(events, 0);
499
+ // Una escritura permitida sí llega y notifica con el actor.
500
+ await rejected.manager.relate(u, 'viewer', doc, p, { actor });
501
+ assert.equal(rejected.writes(), 1);
502
+ assert.lengthOf(events, 1);
503
+ assert.deepEqual(events[0].actor, actor);
504
+ assert.equal(events[0].operation, 'relate');
505
+ });
506
+ /* ── Capacidades, cada una con sus DOS caras ── */
507
+ caseFor('singleCheckRelations', {
508
+ // whenTrue: la derivación (includes + userset anidado) resuelve — la
509
+ // propiedad de «un Check» se mide contra el `:8101` en 4-4 (espía).
510
+ whenTrue: () => test('singleCheckRelations · check resuelve includes + userset anidado', async ({ assert }) => {
511
+ const u = holder();
512
+ const doc = { type: 'document', id: docId() };
513
+ const g = { type: 'group', id: docId() };
514
+ const p = partition();
515
+ await state.manager.relate(u, 'member', g, p);
516
+ await state.manager.relate({ object: g, relation: 'member' }, 'editor', doc, p);
517
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p)); // editor⊆viewer vía grupo
518
+ }),
519
+ whenFalse: () => test('singleCheckRelations:false · check sigue dando la respuesta correcta', async ({ assert }) => {
520
+ const u = holder();
521
+ const doc = { type: 'document', id: docId() };
522
+ const p = partition();
523
+ await state.manager.relate(u, 'viewer', doc, p);
524
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
525
+ }),
526
+ });
527
+ caseFor('listObjectsInherited', {
528
+ // Siempre `false` en este paquete (invariante 7). whenTrue existe para
529
+ // un driver hipotético que enumere lo heredado; no corre con los dobles.
530
+ whenTrue: () => test('listObjectsInherited:true · enumera lo heredado', async ({ assert }) => {
531
+ assert.isTrue(true);
532
+ }),
533
+ whenFalse: () => test('listObjectsInherited:false · listObjects NO abre un subárbol, da directos+derivados', async ({ assert, }) => {
534
+ const u = holder();
535
+ const d1 = { type: 'document', id: docId() };
536
+ const p = partition();
537
+ await state.manager.relate(u, 'owner', d1, p);
538
+ const page = await state.manager.listObjects(u, 'viewer', 'document', p);
539
+ assert.deepEqual(page.objects.map((o) => o.id), [d1.id]);
540
+ assert.isUndefined(page.truncated);
541
+ }),
542
+ });
543
+ caseFor('usersetSubjects', {
544
+ whenTrue: () => test('usersetSubjects:true · listSubjects incluye los usersets (group#member)', async ({ assert }) => {
545
+ const doc = { type: 'document', id: docId() };
546
+ const g = { type: 'group', id: docId() };
547
+ const p = partition();
548
+ await state.manager.relate({ object: g, relation: 'member' }, 'viewer', doc, p);
549
+ const page = await state.manager.listSubjects('viewer', doc, p);
550
+ assert.isTrue(page.subjects.some((s) => isRelUserset(s)));
551
+ }),
552
+ whenFalse: () => test('usersetSubjects:false · listSubjects devuelve solo holders', async ({ assert }) => {
553
+ const doc = { type: 'document', id: docId() };
554
+ const g = { type: 'group', id: docId() };
555
+ const u = holder();
556
+ const p = partition();
557
+ await state.manager.relate(u, 'viewer', doc, p);
558
+ await state.manager.relate({ object: g, relation: 'member' }, 'viewer', doc, p);
559
+ const page = await state.manager.listSubjects('viewer', doc, p);
560
+ assert.isFalse(page.subjects.some((s) => isRelUserset(s)));
561
+ }),
562
+ });
563
+ caseFor('membersOfNative', {
564
+ whenTrue: () => test('membersOfNative:true · membersOf da la membresía TRANSITIVA (distinta de listSubjects)', async ({ assert, }) => {
565
+ const u = holder();
566
+ const g1 = { type: 'group', id: docId() };
567
+ const g2 = { type: 'group', id: docId() };
568
+ const p = partition();
569
+ await state.manager.relate(u, 'member', g1, p);
570
+ await state.manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
571
+ const transitive = await state.manager.membersOf(g2, 'member', p);
572
+ assert.isTrue(transitive.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid));
573
+ // listSubjects DIRECTO de g2 no lo trae (solo el userset g1#member).
574
+ const direct = await state.manager.listSubjects('member', g2, p);
575
+ assert.isFalse(direct.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid));
576
+ }),
577
+ whenFalse: () => test('membersOfNative:false · membersOf ⇒ 500 E_AUTHZ_UNSUPPORTED nombrándolo', async ({ assert }) => {
578
+ const g = { type: 'group', id: docId() };
579
+ const p = partition();
580
+ let caught;
581
+ try {
582
+ await state.manager.membersOf(g, 'member', p);
583
+ assert.fail('debería haber lanzado');
584
+ }
585
+ catch (error) {
586
+ caught = error;
587
+ }
588
+ assert.equal(caught.status, 500);
589
+ assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED');
590
+ assert.include(caught.message, 'membersOf');
591
+ }),
592
+ });
593
+ caseFor('enumerateRelations', {
594
+ whenTrue: () => test('enumerateRelations:true · enumera las tuplas de la partición', async ({ assert }) => {
595
+ const u = holder();
596
+ const doc = { type: 'document', id: docId() };
597
+ const p = partition();
598
+ await state.manager.relate(u, 'viewer', doc, p);
599
+ const page = await state.manager.enumerateRelations(p);
600
+ assert.lengthOf(page.tuples, 1);
601
+ assert.equal(page.tuples[0].relation, 'viewer');
602
+ }),
603
+ whenFalse: () => test('enumerateRelations:false · enumerateRelations ⇒ 500 E_AUTHZ_UNSUPPORTED', async ({ assert }) => {
604
+ const p = partition();
605
+ let caught;
606
+ try {
607
+ await state.manager.enumerateRelations(p);
608
+ assert.fail('debería haber lanzado');
609
+ }
610
+ catch (error) {
611
+ caught = error;
612
+ }
613
+ assert.equal(caught.status, 500);
614
+ assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED');
615
+ assert.include(caught.message, 'enumerateRelations');
616
+ }),
617
+ });
618
+ caseFor('listObjectsTruncation', {
619
+ whenTrue: () => test('listObjectsTruncation:true · listObjects trunca al tope y SEÑALA truncated', async ({ assert }) => {
620
+ const limit = harness.limits?.listMaxResults;
621
+ if (!limit) {
622
+ throw new Error(`[contrato ${harness.name}] declara 'listObjectsTruncation: true' sin 'limits.listMaxResults'.`);
623
+ }
624
+ const u = holder();
625
+ const p = partition();
626
+ for (let i = 0; i < limit + 2; i++) {
627
+ await state.manager.relate(u, 'viewer', { type: 'document', id: docId() }, p);
628
+ }
629
+ const page = await state.manager.listObjects(u, 'viewer', 'document', p);
630
+ assert.lengthOf(page.objects, limit);
631
+ assert.isTrue(page.truncated);
632
+ }),
633
+ whenFalse: () => test('listObjectsTruncation:false · listObjects es exhaustiva (sin señal de truncado)', async ({ assert, }) => {
634
+ const u = holder();
635
+ const p = partition();
636
+ for (let i = 0; i < 5; i++) {
637
+ await state.manager.relate(u, 'viewer', { type: 'document', id: docId() }, p);
638
+ }
639
+ const page = await state.manager.listObjects(u, 'viewer', 'document', p);
640
+ assert.lengthOf(page.objects, 5);
641
+ assert.isUndefined(page.truncated);
642
+ }),
643
+ });
644
+ // Al cerrar: toda capacidad declarada tiene que haber registrado su cara.
645
+ group.teardown(async () => {
646
+ const uncovered = Object.keys(harness.capabilities).filter((c) => !covered.has(c));
647
+ if (uncovered.length) {
648
+ throw new Error(`[contrato ${harness.name}] no cubrió ${uncovered.join(', ')}: cada capacidad necesita su caso.`);
649
+ }
650
+ });
651
+ });
652
+ return { registered, covered };
653
+ }
654
+ //# sourceMappingURL=relations_contract.js.map