@jantstack/adonis-authz 2.4.0-alpha.9 → 2.5.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 (128) hide show
  1. package/README.md +468 -30
  2. package/build/commands/authz_freeze.js +1 -1
  3. package/build/commands/authz_freeze.js.map +1 -1
  4. package/build/commands/authz_reconcile.d.ts +6 -2
  5. package/build/commands/authz_reconcile.d.ts.map +1 -1
  6. package/build/commands/authz_reconcile.js +129 -13
  7. package/build/commands/authz_reconcile.js.map +1 -1
  8. package/build/commands/authz_relations_reconcile.d.ts +16 -24
  9. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  10. package/build/commands/authz_relations_reconcile.js +198 -49
  11. package/build/commands/authz_relations_reconcile.js.map +1 -1
  12. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  13. package/build/commands/authz_scopes_relay.js +81 -1
  14. package/build/commands/authz_scopes_relay.js.map +1 -1
  15. package/build/commands/authz_unfreeze.d.ts +12 -4
  16. package/build/commands/authz_unfreeze.d.ts.map +1 -1
  17. package/build/commands/authz_unfreeze.js +74 -9
  18. package/build/commands/authz_unfreeze.js.map +1 -1
  19. package/build/index.d.ts +6 -4
  20. package/build/index.d.ts.map +1 -1
  21. package/build/index.js +5 -3
  22. package/build/index.js.map +1 -1
  23. package/build/providers/authz_provider.d.ts +1 -1
  24. package/build/providers/authz_provider.d.ts.map +1 -1
  25. package/build/providers/authz_provider.js +5 -1
  26. package/build/providers/authz_provider.js.map +1 -1
  27. package/build/src/define_config.d.ts +41 -0
  28. package/build/src/define_config.d.ts.map +1 -1
  29. package/build/src/define_config.js +120 -0
  30. package/build/src/define_config.js.map +1 -1
  31. package/build/src/drivers/database_driver.d.ts.map +1 -1
  32. package/build/src/drivers/database_driver.js +1 -0
  33. package/build/src/drivers/database_driver.js.map +1 -1
  34. package/build/src/drivers/database_relations_driver.d.ts +18 -6
  35. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  36. package/build/src/drivers/database_relations_driver.js +94 -46
  37. package/build/src/drivers/database_relations_driver.js.map +1 -1
  38. package/build/src/drivers/openfga_driver.d.ts +46 -5
  39. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  40. package/build/src/drivers/openfga_driver.js +115 -29
  41. package/build/src/drivers/openfga_driver.js.map +1 -1
  42. package/build/src/drivers/openfga_relations_driver.d.ts +82 -13
  43. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  44. package/build/src/drivers/openfga_relations_driver.js +216 -55
  45. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  46. package/build/src/errors.d.ts +86 -0
  47. package/build/src/errors.d.ts.map +1 -1
  48. package/build/src/errors.js +86 -0
  49. package/build/src/errors.js.map +1 -1
  50. package/build/src/freeze.d.ts +63 -4
  51. package/build/src/freeze.d.ts.map +1 -1
  52. package/build/src/freeze.js +107 -18
  53. package/build/src/freeze.js.map +1 -1
  54. package/build/src/identity.d.ts +120 -9
  55. package/build/src/identity.d.ts.map +1 -1
  56. package/build/src/identity.js +164 -12
  57. package/build/src/identity.js.map +1 -1
  58. package/build/src/manager.d.ts +64 -2
  59. package/build/src/manager.d.ts.map +1 -1
  60. package/build/src/manager.js +267 -35
  61. package/build/src/manager.js.map +1 -1
  62. package/build/src/openfga.d.ts +2 -2
  63. package/build/src/openfga.d.ts.map +1 -1
  64. package/build/src/openfga.js +2 -2
  65. package/build/src/openfga.js.map +1 -1
  66. package/build/src/relations/check_many.d.ts +17 -0
  67. package/build/src/relations/check_many.d.ts.map +1 -0
  68. package/build/src/relations/check_many.js +57 -0
  69. package/build/src/relations/check_many.js.map +1 -0
  70. package/build/src/relations/define_relations_config.d.ts +74 -1
  71. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  72. package/build/src/relations/define_relations_config.js +201 -7
  73. package/build/src/relations/define_relations_config.js.map +1 -1
  74. package/build/src/relations/manager.d.ts +20 -0
  75. package/build/src/relations/manager.d.ts.map +1 -1
  76. package/build/src/relations/manager.js +88 -15
  77. package/build/src/relations/manager.js.map +1 -1
  78. package/build/src/relations/reconcile.d.ts +134 -3
  79. package/build/src/relations/reconcile.d.ts.map +1 -1
  80. package/build/src/relations/reconcile.js +340 -36
  81. package/build/src/relations/reconcile.js.map +1 -1
  82. package/build/src/relations/tuple_key.d.ts +25 -0
  83. package/build/src/relations/tuple_key.d.ts.map +1 -0
  84. package/build/src/relations/tuple_key.js +60 -0
  85. package/build/src/relations/tuple_key.js.map +1 -0
  86. package/build/src/relations_config_store.d.ts.map +1 -1
  87. package/build/src/relations_config_store.js +29 -7
  88. package/build/src/relations_config_store.js.map +1 -1
  89. package/build/src/relations_reconcile.d.ts +79 -0
  90. package/build/src/relations_reconcile.d.ts.map +1 -0
  91. package/build/src/relations_reconcile.js +85 -0
  92. package/build/src/relations_reconcile.js.map +1 -0
  93. package/build/src/scope_outbox.d.ts +9 -5
  94. package/build/src/scope_outbox.d.ts.map +1 -1
  95. package/build/src/scope_outbox.js +7 -4
  96. package/build/src/scope_outbox.js.map +1 -1
  97. package/build/src/shared/backend_guard.d.ts +30 -11
  98. package/build/src/shared/backend_guard.d.ts.map +1 -1
  99. package/build/src/shared/backend_guard.js +45 -15
  100. package/build/src/shared/backend_guard.js.map +1 -1
  101. package/build/src/shared/sql_expiry.d.ts +2 -0
  102. package/build/src/shared/sql_expiry.d.ts.map +1 -1
  103. package/build/src/shared/sql_expiry.js +4 -0
  104. package/build/src/shared/sql_expiry.js.map +1 -1
  105. package/build/src/testing/main.d.ts +3 -1
  106. package/build/src/testing/main.d.ts.map +1 -1
  107. package/build/src/testing/main.js +2 -1
  108. package/build/src/testing/main.js.map +1 -1
  109. package/build/src/testing/migration_contract.d.ts +9 -0
  110. package/build/src/testing/migration_contract.d.ts.map +1 -1
  111. package/build/src/testing/migration_contract.js +5 -2
  112. package/build/src/testing/migration_contract.js.map +1 -1
  113. package/build/src/testing/relations_contract.d.ts +14 -0
  114. package/build/src/testing/relations_contract.d.ts.map +1 -1
  115. package/build/src/testing/relations_contract.js +367 -21
  116. package/build/src/testing/relations_contract.js.map +1 -1
  117. package/build/src/testing/scope_outbox_contract.d.ts +14 -1
  118. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
  119. package/build/src/testing/scope_outbox_contract.js +126 -48
  120. package/build/src/testing/scope_outbox_contract.js.map +1 -1
  121. package/build/src/testing/test_clock.d.ts +15 -0
  122. package/build/src/testing/test_clock.d.ts.map +1 -0
  123. package/build/src/testing/test_clock.js +65 -0
  124. package/build/src/testing/test_clock.js.map +1 -0
  125. package/build/src/types.d.ts +115 -12
  126. package/build/src/types.d.ts.map +1 -1
  127. package/build/src/types.js.map +1 -1
  128. package/package.json +6 -1
@@ -20,9 +20,10 @@
20
20
  import { v7 as uuidv7 } from 'uuid';
21
21
  import { test as japaTest } from '@japa/runner';
22
22
  import db from '@adonisjs/lucid/services/db';
23
- import { scopeKey } from '../identity.js';
23
+ import { partitionKey, scopeKey } from '../identity.js';
24
24
  import { RelationsManager } from '../relations/manager.js';
25
- import { defineRelationsConfig } from '../relations/define_relations_config.js';
25
+ import { defineRelationsConfig, assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject } from '../relations/define_relations_config.js';
26
+ import { assertCheckManyObjects, distinctObjects, expandByPosition, RELATIONS_CHECK_MANY_MAX } from '../relations/check_many.js';
26
27
  import { systemClock } from '../clock.js';
27
28
  import { resolveGrantExpiry } from '../expiry.js';
28
29
  import { isRelUserset } from '../types.js';
@@ -33,18 +34,26 @@ export const RELATIONS_HOLDER_TYPES = ['user', 'admin', 'integration'];
33
34
  * La config FIJA del contrato: un tipo `document` con `owner ⊆ editor ⊆
34
35
  * viewer` (includes de un nivel) y `membersOf` habilitado en `database`.
35
36
  */
37
+ /**
38
+ * **Los dos tipos del contrato** (plan 2.5 · lote A, H10): `document` y un
39
+ * SEGUNDO tipo con las MISMAS relaciones, `workspace`, para que «tipos
40
+ * mezclados con la misma relación responden por ítem» (`checkMany`, P4-7)
41
+ * sea observable con la config fija —solo con `document` y el `group`
42
+ * built-in no comparten nombre de relación—. Cambio anunciado del export
43
+ * publicado: un harness de terceros que afirme sobre el modelo publicado de
44
+ * esta config ve un tipo más.
45
+ */
46
+ export const RELATIONS_CONTRACT_OBJECT_TYPES = ['document', 'workspace'];
47
+ /**
48
+ * La config FIJA del contrato: `document` y `workspace`, cada uno con `owner ⊆
49
+ * editor ⊆ viewer` (includes de un nivel) y `membersOf` habilitado en `database`.
50
+ */
36
51
  export function contractRelationsConfig() {
37
52
  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
- ],
53
+ objectTypes: RELATIONS_CONTRACT_OBJECT_TYPES.map((type) => ({
54
+ type,
55
+ relations: [{ name: 'owner' }, { name: 'editor', includes: ['owner'] }, { name: 'viewer', includes: ['editor'] }],
56
+ })),
48
57
  holderTypes: [...RELATIONS_HOLDER_TYPES],
49
58
  database: { membersOf: true },
50
59
  });
@@ -62,8 +71,15 @@ export function lucidRelationsContractTransactions() {
62
71
  },
63
72
  };
64
73
  }
74
+ /**
75
+ * La clave de partición del doble pasa por `partitionKey` (`src/identity.ts`), la MISMA puerta que los dos
76
+ * drivers reales (plan 2.5 · lote 0, cierre ⚪ 11; lote A, D4): una partición mal formada (`A#B`, mayúsculas) es
77
+ * 422 `E_AUTHZ_INVALID_IDENTITY` también en el fixture, y un uuid de 32 hex SIN guiones también (D4). Hasta el
78
+ * cierre del lote 0 componía la clave por plantilla y aceptaba cualquier cosa, así que la columna `partition`
79
+ * de la guardia de superficie no podía decir nada del doble.
80
+ */
65
81
  function scopeKeyOf(scope) {
66
- return scope.type === 'app' ? 'app' : `${scope.type}|${scope.uuid}`;
82
+ return partitionKey(scope);
67
83
  }
68
84
  function subjectKeyOf(subject) {
69
85
  if (isRelUserset(subject))
@@ -85,9 +101,22 @@ export function makeRelationsDriver(options) {
85
101
  */
86
102
  function buildRelationsDouble(options, tuples, now) {
87
103
  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();
104
+ /**
105
+ * **F-05 en el DOBLE** (plan 2.5 · lote 0, D13; hallazgo H8 del tester): hasta el lote 0 el doble
106
+ * publicado no re-validaba tipo/relación —solo el manager cortaba— y un driver de terceros podía
107
+ * calcar esa forma y pasar el contrato. El doble aplica las MISMAS dos funciones que los dos drivers
108
+ * reales (`assertRelationDeclared` estricta y `assertObjectTypeDeclared` de tipo), en las ocho
109
+ * operaciones y en el userset del sujeto. La guardia de superficie (`tests/guard_surface.spec.ts`,
110
+ * filas `double`) lo sondea en el doble y en su vista `withClock`.
111
+ */
112
+ function assertSubjectDeclared(subject) {
113
+ // B3 (lote B): F-05 del par y solo `group#member`, la MISMA función que las tres capas reales.
114
+ if (isRelUserset(subject))
115
+ assertUsersetSubject(config, subject);
116
+ }
117
+ /** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. `at` fija el instante (un `now` por lote en `checkMany`). */
118
+ function active(t, at = now) {
119
+ return t.expiresAt === null || t.expiresAt.getTime() > at().getTime();
91
120
  }
92
121
  /**
93
122
  * `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
@@ -113,27 +142,27 @@ function buildRelationsDouble(options, tuples, now) {
113
142
  return out;
114
143
  }
115
144
  /** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
116
- function directSubjects(partition, objectType, objectId, relation) {
145
+ function directSubjects(partition, objectType, objectId, relation, at = now) {
117
146
  return tuples.filter((t) => t.partition === partition &&
118
147
  t.objectType === objectType &&
119
148
  t.objectId === objectId &&
120
149
  t.relation === relation &&
121
- active(t));
150
+ active(t, at));
122
151
  }
123
- function resolves(subject, relation, object, partition, seen = new Set()) {
152
+ function resolves(subject, relation, object, partition, seen = new Set(), at = now) {
124
153
  const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
125
154
  if (seen.has(guardKey))
126
155
  return false;
127
156
  seen.add(guardKey);
128
157
  const wantKey = subjectKeyOf(subject);
129
158
  for (const rel of expandDown(object.type, relation)) {
130
- for (const t of directSubjects(partition, object.type, object.id, rel)) {
159
+ for (const t of directSubjects(partition, object.type, object.id, rel, at)) {
131
160
  if (t.subjectKey === wantKey)
132
161
  return true;
133
162
  // El sujeto directo es un userset (`group:g#member`): un HOLDER lo
134
163
  // satisface si es MIEMBRO del userset (transitivo, grupos anidan).
135
164
  if (isRelUserset(t.subject) && !isRelUserset(subject)) {
136
- if (resolves(subject, t.subject.relation, t.subject.object, partition, seen))
165
+ if (resolves(subject, t.subject.relation, t.subject.object, partition, seen, at))
137
166
  return true;
138
167
  }
139
168
  }
@@ -173,6 +202,8 @@ function buildRelationsDouble(options, tuples, now) {
173
202
  const driver = {
174
203
  capabilities,
175
204
  async relate(subject, relation, object, partition, options) {
205
+ assertRelationDeclared(config, object, relation);
206
+ assertSubjectDeclared(subject);
176
207
  const partitionKey = scopeKeyOf(partition);
177
208
  const subjectKey = subjectKeyOf(subject);
178
209
  const existing = tuples.find((t) => t.partition === partitionKey &&
@@ -198,6 +229,8 @@ function buildRelationsDouble(options, tuples, now) {
198
229
  });
199
230
  },
200
231
  async unrelate(subject, relation, object, partition) {
232
+ assertRelationDeclared(config, object, relation);
233
+ assertSubjectDeclared(subject);
201
234
  const partitionKey = scopeKeyOf(partition);
202
235
  const subjectKey = subjectKeyOf(subject);
203
236
  for (let i = tuples.length - 1; i >= 0; i--) {
@@ -212,9 +245,13 @@ function buildRelationsDouble(options, tuples, now) {
212
245
  }
213
246
  },
214
247
  async check(subject, relation, object, partition) {
248
+ assertRelationDeclared(config, object, relation);
249
+ assertSubjectDeclared(subject);
215
250
  return resolves(subject, relation, object, scopeKeyOf(partition));
216
251
  },
217
252
  async listObjects(subject, relation, objectType, partition, page) {
253
+ assertRelationDeclared(config, { type: objectType }, relation);
254
+ assertSubjectDeclared(subject);
218
255
  const partitionKey = scopeKeyOf(partition);
219
256
  const ids = new Set();
220
257
  for (const t of tuples) {
@@ -237,6 +274,7 @@ function buildRelationsDouble(options, tuples, now) {
237
274
  return { objects: matches };
238
275
  },
239
276
  async listSubjects(relation, object, partition, page) {
277
+ assertRelationDeclared(config, object, relation);
240
278
  const partitionKey = scopeKeyOf(partition);
241
279
  // DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
242
280
  // `membersOf`) ni derivado. `usersetSubjects` decide si los usersets
@@ -248,6 +286,7 @@ function buildRelationsDouble(options, tuples, now) {
248
286
  return { subjects: dedupeSubjects(subjects) };
249
287
  },
250
288
  async purgeObject(object, partition) {
289
+ assertObjectTypeDeclared(config, object);
251
290
  const partitionKey = scopeKeyOf(partition);
252
291
  for (let i = tuples.length - 1; i >= 0; i--) {
253
292
  const t = tuples[i];
@@ -257,6 +296,7 @@ function buildRelationsDouble(options, tuples, now) {
257
296
  }
258
297
  },
259
298
  async purgeSubject(subject, partition) {
299
+ assertSubjectDeclared(subject);
260
300
  const partitionKey = scopeKeyOf(partition);
261
301
  const subjectKey = subjectKeyOf(subject);
262
302
  for (let i = tuples.length - 1; i >= 0; i--) {
@@ -266,8 +306,27 @@ function buildRelationsDouble(options, tuples, now) {
266
306
  }
267
307
  },
268
308
  };
309
+ // `checkMany` (plan 2.5 · lote A): el doble lo trae solo cuando declara
310
+ // `singleCheckRelations` —el driver que resuelve en UNA llamada es el que
311
+ // tiene un lote nativo—; el MÍNIMO no lo trae y así el runner ejerce el
312
+ // respaldo del manager (N `check`, P4-9) sin una capacidad nueva.
313
+ if (capabilities.singleCheckRelations) {
314
+ driver.checkMany = async (subject, relation, objects, partition) => {
315
+ assertCheckManyObjects(objects);
316
+ for (const object of objects)
317
+ assertRelationDeclared(config, object, relation);
318
+ assertSubjectDeclared(subject);
319
+ const key = scopeKeyOf(partition);
320
+ const { distinct, index } = distinctObjects(objects);
321
+ // UN solo `now` para todo el lote (A-18): se fija aquí y `resolves` lo lee.
322
+ const at = now();
323
+ const answers = distinct.map((object) => resolves(subject, relation, object, key, new Set(), () => at));
324
+ return expandByPosition(index, answers);
325
+ };
326
+ }
269
327
  if (capabilities.membersOfNative) {
270
328
  driver.membersOf = async (object, relation, partition) => {
329
+ assertRelationDeclared(config, object, relation);
271
330
  return { subjects: collectMembers(object, relation, scopeKeyOf(partition)) };
272
331
  };
273
332
  }
@@ -720,6 +779,267 @@ export function registerRelationsDriverContract(harness, api) {
720
779
  assert.lengthOf(strictEvents, 1);
721
780
  assert.deepEqual(strictEvents[0].actor, actor);
722
781
  });
782
+ /* ── P4 · checkMany (plan 2.5 · lote A; A-9…A-14 del tester) ────────── */
783
+ /**
784
+ * **`checkMany` ≡ N `check`, posición a posición** (A-9, P4-1): allow directo,
785
+ * includes (`editor` ⊆ `viewer`), userset ANIDADO, caducado en el pasado y
786
+ * «nada». Y el orden de la RESPUESTA es el de la PETICIÓN (A-10, P4-2): los
787
+ * ids del lote van en orden inverso al de escritura y el patrón esperado
788
+ * es F,T,F,T — un driver que mapease por orden de fila de SQL o por el
789
+ * orden en que llegan los resultados del backend se pone rojo.
790
+ */
791
+ test('P4-1/P4-2 · checkMany ≡ N check posición a posición (allow, includes, userset anidado, caducado, nada) y responde en el ORDEN de la petición (F,T,F,T con ids en orden inverso)', async ({ assert, }) => {
792
+ const u = holder();
793
+ const p = partition();
794
+ const direct = { type: 'document', id: docId() };
795
+ const viaInclude = { type: 'document', id: docId() };
796
+ const viaNested = { type: 'document', id: docId() };
797
+ const expired = { type: 'document', id: docId() };
798
+ const nothing = { type: 'document', id: docId() };
799
+ const g1 = { type: 'group', id: docId() };
800
+ const g2 = { type: 'group', id: docId() };
801
+ await state.manager.relate(u, 'viewer', direct, p);
802
+ await state.manager.relate(u, 'editor', viaInclude, p);
803
+ await state.manager.relate(u, 'member', g1, p);
804
+ await state.manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
805
+ await state.manager.relate({ object: g2, relation: 'member' }, 'viewer', viaNested, p);
806
+ await state.manager.relate(u, 'viewer', expired, p, { expiresAt: new Date(Date.now() - 60_000) });
807
+ const objects = [direct, viaInclude, viaNested, expired, nothing];
808
+ const one = [];
809
+ for (const object of objects)
810
+ one.push(await state.manager.check(u, 'viewer', object, p));
811
+ assert.deepEqual(one, [true, true, true, false, false], 'precondición: N check');
812
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', objects, p), one, 'checkMany ≡ N check, posición a posición');
813
+ // P4-2: ids en orden INVERSO al de escritura y respuesta F,T,F,T.
814
+ const a = { type: 'document', id: docId() };
815
+ const b = { type: 'document', id: docId() };
816
+ const c = { type: 'document', id: docId() };
817
+ const d = { type: 'document', id: docId() };
818
+ await state.manager.relate(u, 'viewer', c, p);
819
+ await state.manager.relate(u, 'viewer', a, p);
820
+ const sorted = [a, b, c, d].sort((x, y) => (x.id < y.id ? -1 : 1));
821
+ // [d', c', b', a'] con T solo en los que se escribieron (c y a): el patrón
822
+ // depende del orden de ids, así que se calcula con N check y se exige igual.
823
+ const request = [sorted[3], sorted[2], sorted[1], sorted[0]];
824
+ const expected = [];
825
+ for (const object of request)
826
+ expected.push(await state.manager.check(u, 'viewer', object, p));
827
+ assert.sameMembers(expected, [true, true, false, false]);
828
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', request, p), expected, 'el orden de la respuesta es el de la petición');
829
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', [...request].reverse(), p), [...expected].reverse());
830
+ });
831
+ /** A-11 (P4-4a): duplicados permitidos y respondidos por POSICIÓN. */
832
+ test('P4-4a · checkMany con duplicados: [a, a, b] ⇒ [x, x, y] (por posición, sin deduplicar la respuesta)', async ({ assert }) => {
833
+ const u = holder();
834
+ const p = partition();
835
+ const a = { type: 'document', id: docId() };
836
+ const b = { type: 'document', id: docId() };
837
+ await state.manager.relate(u, 'viewer', a, p);
838
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', [a, a, b], p), [true, true, false]);
839
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', [b, a, b, a], p), [false, true, false, true]);
840
+ });
841
+ /** A-12 (P4-5): el tope 50 responde; 51 y [] son 422 con CERO llamadas al driver. */
842
+ test(`P4-5 · checkMany con ${RELATIONS_CHECK_MANY_MAX} objetos responde; ${RELATIONS_CHECK_MANY_MAX + 1} ⇒ 422 E_AUTHZ_CHECK_MANY_BATCH y [] ⇒ 422, ambos con CERO llamadas al driver`, async ({ assert, }) => {
843
+ const u = holder();
844
+ const p = partition();
845
+ const fifty = [];
846
+ for (let i = 0; i < RELATIONS_CHECK_MANY_MAX; i++)
847
+ fifty.push({ type: 'document', id: docId() });
848
+ await state.manager.relate(u, 'viewer', fifty[RELATIONS_CHECK_MANY_MAX - 1], p);
849
+ const answers = await state.manager.checkMany(u, 'viewer', fifty, p);
850
+ assert.lengthOf(answers, RELATIONS_CHECK_MANY_MAX);
851
+ assert.isTrue(answers[RELATIONS_CHECK_MANY_MAX - 1]);
852
+ assert.isFalse(answers[0]);
853
+ const before = state.calls();
854
+ for (const [label, objects] of [
855
+ ['51', [...fifty, { type: 'document', id: docId() }]],
856
+ ['[]', []],
857
+ ]) {
858
+ const caught = await state.manager.checkMany(u, 'viewer', objects, p).then(() => null, (e) => e);
859
+ assert.isNotNull(caught, `ROJO (${label}): checkMany respondió en vez de rechazar`);
860
+ assert.equal(caught.status, 422, `${label}: ${caught.message}`);
861
+ assert.equal(caught.code, 'E_AUTHZ_CHECK_MANY_BATCH', label);
862
+ }
863
+ assert.equal(state.calls(), before, 'CERO llamadas al driver: el tope y el lote vacío se juzgan antes');
864
+ });
865
+ /** A-13 (P4-6): F-05 por ÍTEM — un `role_binding` en la ÚLTIMA posición es 422 con cero llamadas. */
866
+ test('P4-6 · checkMany · F-05 por ítem: un role_binding en la posición 21 de 21 ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN con CERO llamadas al driver; un id inválido en la última posición ⇒ 422 E_AUTHZ_INVALID_IDENTITY', async ({ assert, }) => {
867
+ const u = holder();
868
+ const p = partition();
869
+ const twenty = [];
870
+ for (let i = 0; i < 20; i++)
871
+ twenty.push({ type: 'document', id: docId() });
872
+ const before = state.calls();
873
+ const evil = await state.manager.checkMany(u, 'viewer', [...twenty, { type: 'role_binding', id: docId() }], p).then(() => null, (e) => e);
874
+ assert.isNotNull(evil, 'ROJO: un role_binding en la posición 21 entró');
875
+ assert.equal(evil.status, 422, String(evil?.message));
876
+ assert.equal(evil.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN');
877
+ const bad = await state.manager.checkMany(u, 'viewer', [...twenty, { type: 'document', id: 'A#B' }], p).then(() => null, (e) => e);
878
+ assert.equal(bad?.status, 422, String(bad?.message));
879
+ assert.equal(bad?.code, 'E_AUTHZ_INVALID_IDENTITY');
880
+ const undeclared = await state.manager.checkMany(u, 'assignee', twenty, p).then(() => null, (e) => e);
881
+ assert.equal(undeclared?.code, 'E_AUTHZ_RELATION_UNKNOWN', 'la relación no declarada también es 422');
882
+ assert.equal(state.calls(), before, 'CERO llamadas al driver');
883
+ });
884
+ /** A-14 (P4-7, H10): tipos MEZCLADOS con la misma relación responden por ítem (`document` y `workspace`). */
885
+ test('P4-7 · checkMany con tipos mezclados (document y workspace) y la misma relación responde por ítem, con los includes de CADA tipo', async ({ assert, }) => {
886
+ const u = holder();
887
+ const p = partition();
888
+ const doc = { type: 'document', id: docId() };
889
+ const ws = { type: 'workspace', id: docId() };
890
+ const otherWs = { type: 'workspace', id: docId() };
891
+ const otherDoc = { type: 'document', id: docId() };
892
+ await state.manager.relate(u, 'viewer', doc, p);
893
+ await state.manager.relate(u, 'editor', ws, p); // includes del tipo workspace: editor ⊆ viewer
894
+ // El mismo id en los DOS tipos: el tipo forma parte de la identidad (no se cruzan).
895
+ const twin = { type: 'workspace', id: doc.id };
896
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', [doc, ws, otherWs, otherDoc, twin, ws, doc], p), [true, true, false, false, false, true, true]);
897
+ assert.deepEqual(await state.manager.checkMany(u, 'owner', [doc, ws], p), [false, false], 'owner no se deriva de editor/viewer');
898
+ });
899
+ /* ── D13 · el DRIVER llamado directamente también corta F-05 (plan 2.5 · lote A) ── */
900
+ /**
901
+ * **F-05 vive también en el driver** (D13, decisión del dueño 2026-10-14;
902
+ * tester H8): hasta el lote A el runner solo exigía el corte en el MANAGER,
903
+ * así que un driver de terceros podía no re-validar y pasar el contrato —
904
+ * y por `manager.driver()` o por `reconcileRelations` (que escribe por el
905
+ * driver del destino) componer el id de un binding real en un store
906
+ * compartido. Ahora se llama al driver del harness EN DIRECTO, sin manager:
907
+ * las ocho operaciones (y `checkMany`, y `membersOf` cuando el driver lo
908
+ * trae nativo: 🟡 A7) rechazan un tipo no declarado con el MISMO 422 que
909
+ * el manager, y el `checkMany` directo corta el tope y el lote vacío (⚪ A12). **Ruptura declarada** para un harness de
910
+ * terceros que sembrara tipos no declarados por el driver.
911
+ */
912
+ test('D13 · F-05 en el DRIVER llamado directamente (sin manager): relate/unrelate/check/checkMany/listObjects/listSubjects/purgeObject/purgeSubject(userset) —y membersOf si es nativo (A7)— contra role_binding ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN, una relación no declarada ⇒ 422 E_AUTHZ_RELATION_UNKNOWN, y checkMany directo corta 51 y [] (A12)', async ({ assert, }) => {
913
+ const driver = state.base;
914
+ const u = holder();
915
+ const S = partition();
916
+ const binding = { type: 'role_binding', id: docId() };
917
+ const doc = { type: 'document', id: docId() };
918
+ const typeAttempts = [
919
+ ['relate', () => driver.relate(u, 'assignee', binding, S)],
920
+ ['unrelate', () => driver.unrelate(u, 'assignee', binding, S)],
921
+ ['check', () => driver.check(u, 'assignee', binding, S)],
922
+ // `viewer` está declarada para `doc` ([0]): lo ÚNICO que falla es el role_binding de [1] (F-05 por ítem).
923
+ ['checkMany', () => (typeof driver.checkMany === 'function' ? driver.checkMany(u, 'viewer', [doc, binding], S) : driver.check(u, 'assignee', binding, S))],
924
+ ['listObjects', () => driver.listObjects(u, 'assignee', 'role_binding', S)],
925
+ ['listSubjects', () => driver.listSubjects('assignee', binding, S)],
926
+ ['purgeObject', () => driver.purgeObject(binding, S)],
927
+ ['purgeSubject(userset)', () => driver.purgeSubject({ object: binding, relation: 'role' }, S)],
928
+ // 🟡 A7 (auditor del lote A): la NOVENA operación, cuando el driver la trae nativa — un `membersOf`
929
+ // sin F-05 enumera los asignados de un rol del store compartido (la clase del 🟠 2 de alpha.3).
930
+ ...(harness.capabilities.membersOfNative && typeof driver.membersOf === 'function'
931
+ ? [['membersOf', () => driver.membersOf(binding, 'assignee', S)]]
932
+ : []),
933
+ ];
934
+ assert.equal(typeAttempts.length, harness.capabilities.membersOfNative && typeof driver.membersOf === 'function' ? 9 : 8, 'las nueve operaciones del puerto (ocho sin membersOf nativo)');
935
+ for (const [label, run] of typeAttempts) {
936
+ const caught = await run().then(() => null, (e) => e);
937
+ assert.isNotNull(caught, `ROJO (D13 · driver.${label}): el driver ENTRÓ contra role_binding sin el manager delante`);
938
+ assert.equal(caught.status, 422, `driver.${label}: ${caught.message}`);
939
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN', `driver.${label}`);
940
+ }
941
+ for (const [label, run] of [
942
+ ['relate', () => driver.relate(u, 'assignee', doc, S)],
943
+ ['check', () => driver.check(u, 'assignee', doc, S)],
944
+ ['listSubjects', () => driver.listSubjects('assignee', doc, S)],
945
+ ]) {
946
+ const caught = await run().then(() => null, (e) => e);
947
+ assert.equal(caught?.status, 422, `driver.${label}: ${caught?.message}`);
948
+ assert.equal(caught?.code, 'E_AUTHZ_RELATION_UNKNOWN', `driver.${label}`);
949
+ }
950
+ // ⚪ A12 (auditor del lote A): el tope y el lote vacío también se cortan en el DRIVER directo.
951
+ if (typeof driver.checkMany === 'function') {
952
+ const many = [];
953
+ for (let i = 0; i <= RELATIONS_CHECK_MANY_MAX; i++)
954
+ many.push({ type: 'document', id: docId() });
955
+ for (const [label, objects] of [
956
+ [`${RELATIONS_CHECK_MANY_MAX + 1}`, many],
957
+ ['[]', []],
958
+ ]) {
959
+ const caught = await driver.checkMany(u, 'viewer', objects, S).then(() => null, (e) => e);
960
+ assert.isNotNull(caught, `ROJO (A12 · driver.checkMany ${label}): el driver directo no corta el lote`);
961
+ assert.equal(caught.status, 422, `driver.checkMany ${label}: ${caught.message}`);
962
+ assert.equal(caught.code, 'E_AUTHZ_CHECK_MANY_BATCH', `driver.checkMany ${label}`);
963
+ }
964
+ assert.lengthOf(await driver.checkMany(u, 'viewer', many.slice(0, RELATIONS_CHECK_MANY_MAX), S), RELATIONS_CHECK_MANY_MAX, 'el tope exacto responde');
965
+ }
966
+ // Control: lo declarado entra por el driver y el store no tiene ni rastro del binding.
967
+ await driver.relate(u, 'viewer', doc, S);
968
+ assert.isTrue(await driver.check(u, 'viewer', doc, S));
969
+ if (typeof driver.enumerateRelations === 'function' && harness.capabilities.enumerateRelations) {
970
+ const page = await driver.enumerateRelations(S);
971
+ assert.isFalse(page.tuples.some((t) => t.object.type === 'role_binding'), 'cero tuplas role_binding');
972
+ }
973
+ });
974
+ /* ── B3 · el userset del SUJETO: `group#member` y solo `group#member` (plan 2.5 · lote B; cierre 1 · 🟡 5) ── */
975
+ /**
976
+ * **B3 en el runner PUBLICADO** (cierre 1 del lote B · 🟡 5 del auditor):
977
+ * hasta el cierre la variante `usersetUnsupported` solo vivía en la guardia
978
+ * de superficie del paquete, así que un driver de terceros que guardara la
979
+ * fila inerte (`document#viewer` como sujeto) —o respondiera 503— pasaba
980
+ * `runRelationsDriverContract` entero, y el reconcile hacia él y
981
+ * `manager.driver()` repetían los 🟠 1/🟠 2. Es «variante de F-05» (D13):
982
+ * se exige por el MANAGER y por el DRIVER llamado en directo, en las seis
983
+ * operaciones con sujeto, con el `code` propio; por el manager con CERO
984
+ * llamadas al driver (espía) y CERO eventos. El userset legítimo
985
+ * (`group#member`) sigue entrando por los mismos caminos (control).
986
+ */
987
+ test('B3 · un userset ≠ group#member como SUJETO (document#viewer, declarado) ⇒ 422 E_AUTHZ_RELATION_USERSET_UNSUPPORTED en relate/unrelate/check/checkMany/listObjects/purgeSubject, por el MANAGER (CERO llamadas al driver, CERO eventos) y por el DRIVER en directo; group#member entra (control)', async ({ assert, }) => {
988
+ const S = partition();
989
+ const doc = { type: 'document', id: docId() };
990
+ const other = { type: 'document', id: docId() };
991
+ const stale = { object: other, relation: 'viewer' };
992
+ const attemptsOn = (driver) => [
993
+ ['relate', () => driver.relate(stale, 'viewer', doc, S)],
994
+ ['unrelate', () => driver.unrelate(stale, 'viewer', doc, S)],
995
+ ['check', () => driver.check(stale, 'viewer', doc, S)],
996
+ ['checkMany', () => (typeof driver.checkMany === 'function' ? driver.checkMany(stale, 'viewer', [doc], S) : driver.check(stale, 'viewer', doc, S))],
997
+ ['listObjects', () => driver.listObjects(stale, 'viewer', 'document', S)],
998
+ ['purgeSubject', () => driver.purgeSubject(stale, S)],
999
+ ];
1000
+ for (const [path, target] of [
1001
+ ['manager', state.manager],
1002
+ ['driver', state.base],
1003
+ ]) {
1004
+ for (const [label, run] of attemptsOn(target)) {
1005
+ const caught = await run().then(() => null, (e) => e);
1006
+ assert.isNotNull(caught, `ROJO (B3 · ${path}.${label}): el userset document#viewer ENTRÓ como sujeto`);
1007
+ assert.equal(caught.status, 422, `${path}.${label}: ${caught.message}`);
1008
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_USERSET_UNSUPPORTED', `${path}.${label}`);
1009
+ }
1010
+ }
1011
+ // Por el manager, «cero llamadas» se observa con el espía.
1012
+ assert.equal(state.calls(), 0, 'por el manager: CERO llamadas al driver');
1013
+ assert.lengthOf(state.events, 0, 'CERO eventos');
1014
+ // Cierre 2 del lote B · 🟡 N4: por el driver en directo el runner SÍ ve su backend — por el PUERTO. Un
1015
+ // tercero que valida DESPUÉS de escribir (`relate` guarda la fila inerte y luego lanza el 422) pasaba el
1016
+ // contrato entero (medido: 58/58 verdes con ese mutante sobre el doble). Se afirma por `listSubjects`
1017
+ // (invariante 7: hechos directos; un userset guardado saldría ahí con `usersetSubjects: true`) y, con la
1018
+ // capacidad, por `enumerateRelations` (la partición entera): la fila NO quedó.
1019
+ // **Límite declarado (cierre 3 · ⚪ N11)**: el runner solo ve el backend por los puertos que el harness
1020
+ // DECLARA. Con `usersetSubjects: false` (`listSubjects` no devuelve usersets) y `enumerateRelations: false`
1021
+ // no hay puerto por el que observar la fila inerte, así que un tercero con ese harness mínimo que guarde y
1022
+ // DESPUÉS lance pasa este caso (medido con el mutante «guarda y luego lanza» sobre el doble mínimo). Declara
1023
+ // las capacidades que tu driver tiene; el runner no puede probar lo que no se le deja ver (README «Third-party
1024
+ // drivers»).
1025
+ const staleKey = `${other.type}:${other.id}#viewer`;
1026
+ const describeSubject = (s) => (isRelUserset(s) ? `${s.object.type}:${s.object.id}#${s.relation}` : `${s.type}:${s.uuid}`);
1027
+ const listed = await state.base.listSubjects('viewer', doc, S);
1028
+ assert.notInclude(listed.subjects.map(describeSubject), staleKey, 'ROJO (N4): el driver GUARDÓ la fila inerte antes de lanzar el 422 — listSubjects la enumera');
1029
+ if (typeof state.base.enumerateRelations === 'function' && harness.capabilities.enumerateRelations) {
1030
+ const page = await state.base.enumerateRelations(S);
1031
+ assert.isFalse(page.tuples.some((t) => describeSubject(t.subject) === staleKey), 'ROJO (N4): la partición contiene la fila inerte que el 422 decía no haber escrito');
1032
+ }
1033
+ // Control: el userset legítimo entra por los dos caminos y concede.
1034
+ const u = holder();
1035
+ const g = { type: 'group', id: docId() };
1036
+ await state.manager.relate(u, 'member', g, S);
1037
+ await state.manager.relate({ object: g, relation: 'member' }, 'viewer', doc, S);
1038
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, S));
1039
+ assert.isTrue(await state.base.check({ object: g, relation: 'member' }, 'viewer', doc, S));
1040
+ await state.base.purgeSubject({ object: g, relation: 'member' }, S);
1041
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, S));
1042
+ });
723
1043
  /* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
724
1044
  test('R-15 · relate con expiresAt pasado NO concede (check/listObjects/listSubjects); futuro sí; expiresAt inválido ⇒ 422 sin tocar el driver', async ({ assert, }) => {
725
1045
  const u = holder();
@@ -972,6 +1292,32 @@ export function registerRelationsDriverContract(harness, api) {
972
1292
  // El driver del harness (sin reloj inyectado) no se ha tocado: para él
973
1293
  // (hoy) T2 —2030— es futuro y la relación renovada concede.
974
1294
  assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
1295
+ // **A-18 · un solo `now` por lote** (plan 2.5 · lote A): un reloj que
1296
+ // AVANZA 1 ms por lectura y tres tuplas que caducan en T3+1, T3+2 y
1297
+ // T3+3. `checkMany` lee el reloj UNA vez (T3) y las tres conceden;
1298
+ // un driver que leyera `now()` por ítem vería caducar la segunda o
1299
+ // la tercera entre ítems (mutante: `now()` por ítem ⇒ rojo). Solo
1300
+ // donde el driver trae `checkMany`: el respaldo del manager son N
1301
+ // `check`, cada uno con su instante (y así va escrito en el README).
1302
+ if (typeof state.base.checkMany === 'function') {
1303
+ const T3 = new Date('2031-01-01T00:00:00.000Z');
1304
+ const tickClock = fixedClock(T3);
1305
+ const batch = holder();
1306
+ const b1 = { type: 'document', id: docId() };
1307
+ const b2 = { type: 'document', id: docId() };
1308
+ const b3 = { type: 'document', id: docId() };
1309
+ const ticking = new RelationsManager(state.base.withClock(tickClock.now), contractRelationsConfig());
1310
+ await ticking.relate(batch, 'viewer', b1, p, { expiresAt: new Date(T3.getTime() + 1) });
1311
+ await ticking.relate(batch, 'viewer', b2, p, { expiresAt: new Date(T3.getTime() + 2) });
1312
+ await ticking.relate(batch, 'viewer', b3, p, { expiresAt: new Date(T3.getTime() + 3) });
1313
+ let reads = 0;
1314
+ const advancing = new RelationsManager(state.base.withClock(() => new Date(T3.getTime() + reads++)), contractRelationsConfig());
1315
+ assert.deepEqual(await advancing.checkMany(batch, 'viewer', [b1, b2, b3, b1, b2, b3], p), [true, true, true, true, true, true], 'un solo now por lote: las tres conceden en T3');
1316
+ assert.equal(reads, 1, 'checkMany leyó el reloj EXACTAMENTE una vez para todo el lote');
1317
+ // Y con el reloj ya en T3+3 ninguna concede (estricta): el lote decide con UN instante, no con el primero cacheado.
1318
+ reads = 3;
1319
+ assert.deepEqual(await advancing.checkMany(batch, 'viewer', [b1, b2, b3], p), [false, false, false]);
1320
+ }
975
1321
  }),
976
1322
  whenFalse: () => test('injectableClock:false · expiresAt en tres estados en tiempo real (1,5 s): omitido preserva, null quita, caducada revive; sin withClock', async ({ assert, }) => {
977
1323
  assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');