@jantstack/adonis-authz 2.4.0-alpha.9 → 2.5.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 (109) hide show
  1. package/README.md +296 -15
  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 +66 -19
  9. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  10. package/build/commands/authz_relations_reconcile.js +212 -39
  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 +2 -1
  26. package/build/providers/authz_provider.js.map +1 -1
  27. package/build/src/define_config.d.ts +9 -0
  28. package/build/src/define_config.d.ts.map +1 -1
  29. package/build/src/define_config.js +11 -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 +91 -44
  37. package/build/src/drivers/database_relations_driver.js.map +1 -1
  38. package/build/src/drivers/openfga_driver.d.ts +16 -1
  39. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  40. package/build/src/drivers/openfga_driver.js +66 -20
  41. package/build/src/drivers/openfga_driver.js.map +1 -1
  42. package/build/src/drivers/openfga_relations_driver.d.ts +16 -4
  43. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  44. package/build/src/drivers/openfga_relations_driver.js +71 -34
  45. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  46. package/build/src/errors.d.ts +50 -0
  47. package/build/src/errors.d.ts.map +1 -1
  48. package/build/src/errors.js +50 -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 +262 -35
  61. package/build/src/manager.js.map +1 -1
  62. package/build/src/openfga.d.ts +1 -1
  63. package/build/src/openfga.d.ts.map +1 -1
  64. package/build/src/openfga.js +1 -1
  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/manager.d.ts +20 -0
  71. package/build/src/relations/manager.d.ts.map +1 -1
  72. package/build/src/relations/manager.js +84 -13
  73. package/build/src/relations/manager.js.map +1 -1
  74. package/build/src/relations/reconcile.d.ts +67 -0
  75. package/build/src/relations/reconcile.d.ts.map +1 -1
  76. package/build/src/relations/reconcile.js +123 -30
  77. package/build/src/relations/reconcile.js.map +1 -1
  78. package/build/src/relations/tuple_key.d.ts +25 -0
  79. package/build/src/relations/tuple_key.d.ts.map +1 -0
  80. package/build/src/relations/tuple_key.js +60 -0
  81. package/build/src/relations/tuple_key.js.map +1 -0
  82. package/build/src/shared/backend_guard.d.ts +30 -11
  83. package/build/src/shared/backend_guard.d.ts.map +1 -1
  84. package/build/src/shared/backend_guard.js +45 -15
  85. package/build/src/shared/backend_guard.js.map +1 -1
  86. package/build/src/shared/sql_expiry.d.ts +2 -0
  87. package/build/src/shared/sql_expiry.d.ts.map +1 -1
  88. package/build/src/shared/sql_expiry.js +4 -0
  89. package/build/src/shared/sql_expiry.js.map +1 -1
  90. package/build/src/testing/main.d.ts +3 -1
  91. package/build/src/testing/main.d.ts.map +1 -1
  92. package/build/src/testing/main.js +2 -1
  93. package/build/src/testing/main.js.map +1 -1
  94. package/build/src/testing/migration_contract.d.ts +9 -0
  95. package/build/src/testing/migration_contract.d.ts.map +1 -1
  96. package/build/src/testing/migration_contract.js +5 -2
  97. package/build/src/testing/migration_contract.js.map +1 -1
  98. package/build/src/testing/relations_contract.d.ts +14 -0
  99. package/build/src/testing/relations_contract.d.ts.map +1 -1
  100. package/build/src/testing/relations_contract.js +297 -21
  101. package/build/src/testing/relations_contract.js.map +1 -1
  102. package/build/src/testing/test_clock.d.ts +15 -0
  103. package/build/src/testing/test_clock.d.ts.map +1 -0
  104. package/build/src/testing/test_clock.js +65 -0
  105. package/build/src/testing/test_clock.js.map +1 -0
  106. package/build/src/types.d.ts +108 -9
  107. package/build/src/types.d.ts.map +1 -1
  108. package/build/src/types.js.map +1 -1
  109. package/package.json +5 -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 } 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,21 @@ 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
+ if (isRelUserset(subject))
114
+ assertRelationDeclared(config, subject.object, subject.relation);
115
+ }
116
+ /** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. `at` fija el instante (un `now` por lote en `checkMany`). */
117
+ function active(t, at = now) {
118
+ return t.expiresAt === null || t.expiresAt.getTime() > at().getTime();
91
119
  }
92
120
  /**
93
121
  * `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
@@ -113,27 +141,27 @@ function buildRelationsDouble(options, tuples, now) {
113
141
  return out;
114
142
  }
115
143
  /** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
116
- function directSubjects(partition, objectType, objectId, relation) {
144
+ function directSubjects(partition, objectType, objectId, relation, at = now) {
117
145
  return tuples.filter((t) => t.partition === partition &&
118
146
  t.objectType === objectType &&
119
147
  t.objectId === objectId &&
120
148
  t.relation === relation &&
121
- active(t));
149
+ active(t, at));
122
150
  }
123
- function resolves(subject, relation, object, partition, seen = new Set()) {
151
+ function resolves(subject, relation, object, partition, seen = new Set(), at = now) {
124
152
  const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
125
153
  if (seen.has(guardKey))
126
154
  return false;
127
155
  seen.add(guardKey);
128
156
  const wantKey = subjectKeyOf(subject);
129
157
  for (const rel of expandDown(object.type, relation)) {
130
- for (const t of directSubjects(partition, object.type, object.id, rel)) {
158
+ for (const t of directSubjects(partition, object.type, object.id, rel, at)) {
131
159
  if (t.subjectKey === wantKey)
132
160
  return true;
133
161
  // El sujeto directo es un userset (`group:g#member`): un HOLDER lo
134
162
  // satisface si es MIEMBRO del userset (transitivo, grupos anidan).
135
163
  if (isRelUserset(t.subject) && !isRelUserset(subject)) {
136
- if (resolves(subject, t.subject.relation, t.subject.object, partition, seen))
164
+ if (resolves(subject, t.subject.relation, t.subject.object, partition, seen, at))
137
165
  return true;
138
166
  }
139
167
  }
@@ -173,6 +201,8 @@ function buildRelationsDouble(options, tuples, now) {
173
201
  const driver = {
174
202
  capabilities,
175
203
  async relate(subject, relation, object, partition, options) {
204
+ assertRelationDeclared(config, object, relation);
205
+ assertSubjectDeclared(subject);
176
206
  const partitionKey = scopeKeyOf(partition);
177
207
  const subjectKey = subjectKeyOf(subject);
178
208
  const existing = tuples.find((t) => t.partition === partitionKey &&
@@ -198,6 +228,8 @@ function buildRelationsDouble(options, tuples, now) {
198
228
  });
199
229
  },
200
230
  async unrelate(subject, relation, object, partition) {
231
+ assertRelationDeclared(config, object, relation);
232
+ assertSubjectDeclared(subject);
201
233
  const partitionKey = scopeKeyOf(partition);
202
234
  const subjectKey = subjectKeyOf(subject);
203
235
  for (let i = tuples.length - 1; i >= 0; i--) {
@@ -212,9 +244,13 @@ function buildRelationsDouble(options, tuples, now) {
212
244
  }
213
245
  },
214
246
  async check(subject, relation, object, partition) {
247
+ assertRelationDeclared(config, object, relation);
248
+ assertSubjectDeclared(subject);
215
249
  return resolves(subject, relation, object, scopeKeyOf(partition));
216
250
  },
217
251
  async listObjects(subject, relation, objectType, partition, page) {
252
+ assertRelationDeclared(config, { type: objectType }, relation);
253
+ assertSubjectDeclared(subject);
218
254
  const partitionKey = scopeKeyOf(partition);
219
255
  const ids = new Set();
220
256
  for (const t of tuples) {
@@ -237,6 +273,7 @@ function buildRelationsDouble(options, tuples, now) {
237
273
  return { objects: matches };
238
274
  },
239
275
  async listSubjects(relation, object, partition, page) {
276
+ assertRelationDeclared(config, object, relation);
240
277
  const partitionKey = scopeKeyOf(partition);
241
278
  // DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
242
279
  // `membersOf`) ni derivado. `usersetSubjects` decide si los usersets
@@ -248,6 +285,7 @@ function buildRelationsDouble(options, tuples, now) {
248
285
  return { subjects: dedupeSubjects(subjects) };
249
286
  },
250
287
  async purgeObject(object, partition) {
288
+ assertObjectTypeDeclared(config, object);
251
289
  const partitionKey = scopeKeyOf(partition);
252
290
  for (let i = tuples.length - 1; i >= 0; i--) {
253
291
  const t = tuples[i];
@@ -257,6 +295,7 @@ function buildRelationsDouble(options, tuples, now) {
257
295
  }
258
296
  },
259
297
  async purgeSubject(subject, partition) {
298
+ assertSubjectDeclared(subject);
260
299
  const partitionKey = scopeKeyOf(partition);
261
300
  const subjectKey = subjectKeyOf(subject);
262
301
  for (let i = tuples.length - 1; i >= 0; i--) {
@@ -266,8 +305,27 @@ function buildRelationsDouble(options, tuples, now) {
266
305
  }
267
306
  },
268
307
  };
308
+ // `checkMany` (plan 2.5 · lote A): el doble lo trae solo cuando declara
309
+ // `singleCheckRelations` —el driver que resuelve en UNA llamada es el que
310
+ // tiene un lote nativo—; el MÍNIMO no lo trae y así el runner ejerce el
311
+ // respaldo del manager (N `check`, P4-9) sin una capacidad nueva.
312
+ if (capabilities.singleCheckRelations) {
313
+ driver.checkMany = async (subject, relation, objects, partition) => {
314
+ assertCheckManyObjects(objects);
315
+ for (const object of objects)
316
+ assertRelationDeclared(config, object, relation);
317
+ assertSubjectDeclared(subject);
318
+ const key = scopeKeyOf(partition);
319
+ const { distinct, index } = distinctObjects(objects);
320
+ // UN solo `now` para todo el lote (A-18): se fija aquí y `resolves` lo lee.
321
+ const at = now();
322
+ const answers = distinct.map((object) => resolves(subject, relation, object, key, new Set(), () => at));
323
+ return expandByPosition(index, answers);
324
+ };
325
+ }
269
326
  if (capabilities.membersOfNative) {
270
327
  driver.membersOf = async (object, relation, partition) => {
328
+ assertRelationDeclared(config, object, relation);
271
329
  return { subjects: collectMembers(object, relation, scopeKeyOf(partition)) };
272
330
  };
273
331
  }
@@ -720,6 +778,198 @@ export function registerRelationsDriverContract(harness, api) {
720
778
  assert.lengthOf(strictEvents, 1);
721
779
  assert.deepEqual(strictEvents[0].actor, actor);
722
780
  });
781
+ /* ── P4 · checkMany (plan 2.5 · lote A; A-9…A-14 del tester) ────────── */
782
+ /**
783
+ * **`checkMany` ≡ N `check`, posición a posición** (A-9, P4-1): allow directo,
784
+ * includes (`editor` ⊆ `viewer`), userset ANIDADO, caducado en el pasado y
785
+ * «nada». Y el orden de la RESPUESTA es el de la PETICIÓN (A-10, P4-2): los
786
+ * ids del lote van en orden inverso al de escritura y el patrón esperado
787
+ * es F,T,F,T — un driver que mapease por orden de fila de SQL o por el
788
+ * orden en que llegan los resultados del backend se pone rojo.
789
+ */
790
+ 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, }) => {
791
+ const u = holder();
792
+ const p = partition();
793
+ const direct = { type: 'document', id: docId() };
794
+ const viaInclude = { type: 'document', id: docId() };
795
+ const viaNested = { type: 'document', id: docId() };
796
+ const expired = { type: 'document', id: docId() };
797
+ const nothing = { type: 'document', id: docId() };
798
+ const g1 = { type: 'group', id: docId() };
799
+ const g2 = { type: 'group', id: docId() };
800
+ await state.manager.relate(u, 'viewer', direct, p);
801
+ await state.manager.relate(u, 'editor', viaInclude, p);
802
+ await state.manager.relate(u, 'member', g1, p);
803
+ await state.manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
804
+ await state.manager.relate({ object: g2, relation: 'member' }, 'viewer', viaNested, p);
805
+ await state.manager.relate(u, 'viewer', expired, p, { expiresAt: new Date(Date.now() - 60_000) });
806
+ const objects = [direct, viaInclude, viaNested, expired, nothing];
807
+ const one = [];
808
+ for (const object of objects)
809
+ one.push(await state.manager.check(u, 'viewer', object, p));
810
+ assert.deepEqual(one, [true, true, true, false, false], 'precondición: N check');
811
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', objects, p), one, 'checkMany ≡ N check, posición a posición');
812
+ // P4-2: ids en orden INVERSO al de escritura y respuesta F,T,F,T.
813
+ const a = { type: 'document', id: docId() };
814
+ const b = { type: 'document', id: docId() };
815
+ const c = { type: 'document', id: docId() };
816
+ const d = { type: 'document', id: docId() };
817
+ await state.manager.relate(u, 'viewer', c, p);
818
+ await state.manager.relate(u, 'viewer', a, p);
819
+ const sorted = [a, b, c, d].sort((x, y) => (x.id < y.id ? -1 : 1));
820
+ // [d', c', b', a'] con T solo en los que se escribieron (c y a): el patrón
821
+ // depende del orden de ids, así que se calcula con N check y se exige igual.
822
+ const request = [sorted[3], sorted[2], sorted[1], sorted[0]];
823
+ const expected = [];
824
+ for (const object of request)
825
+ expected.push(await state.manager.check(u, 'viewer', object, p));
826
+ assert.sameMembers(expected, [true, true, false, false]);
827
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', request, p), expected, 'el orden de la respuesta es el de la petición');
828
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', [...request].reverse(), p), [...expected].reverse());
829
+ });
830
+ /** A-11 (P4-4a): duplicados permitidos y respondidos por POSICIÓN. */
831
+ test('P4-4a · checkMany con duplicados: [a, a, b] ⇒ [x, x, y] (por posición, sin deduplicar la respuesta)', async ({ assert }) => {
832
+ const u = holder();
833
+ const p = partition();
834
+ const a = { type: 'document', id: docId() };
835
+ const b = { type: 'document', id: docId() };
836
+ await state.manager.relate(u, 'viewer', a, p);
837
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', [a, a, b], p), [true, true, false]);
838
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', [b, a, b, a], p), [false, true, false, true]);
839
+ });
840
+ /** A-12 (P4-5): el tope 50 responde; 51 y [] son 422 con CERO llamadas al driver. */
841
+ 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, }) => {
842
+ const u = holder();
843
+ const p = partition();
844
+ const fifty = [];
845
+ for (let i = 0; i < RELATIONS_CHECK_MANY_MAX; i++)
846
+ fifty.push({ type: 'document', id: docId() });
847
+ await state.manager.relate(u, 'viewer', fifty[RELATIONS_CHECK_MANY_MAX - 1], p);
848
+ const answers = await state.manager.checkMany(u, 'viewer', fifty, p);
849
+ assert.lengthOf(answers, RELATIONS_CHECK_MANY_MAX);
850
+ assert.isTrue(answers[RELATIONS_CHECK_MANY_MAX - 1]);
851
+ assert.isFalse(answers[0]);
852
+ const before = state.calls();
853
+ for (const [label, objects] of [
854
+ ['51', [...fifty, { type: 'document', id: docId() }]],
855
+ ['[]', []],
856
+ ]) {
857
+ const caught = await state.manager.checkMany(u, 'viewer', objects, p).then(() => null, (e) => e);
858
+ assert.isNotNull(caught, `ROJO (${label}): checkMany respondió en vez de rechazar`);
859
+ assert.equal(caught.status, 422, `${label}: ${caught.message}`);
860
+ assert.equal(caught.code, 'E_AUTHZ_CHECK_MANY_BATCH', label);
861
+ }
862
+ assert.equal(state.calls(), before, 'CERO llamadas al driver: el tope y el lote vacío se juzgan antes');
863
+ });
864
+ /** A-13 (P4-6): F-05 por ÍTEM — un `role_binding` en la ÚLTIMA posición es 422 con cero llamadas. */
865
+ 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, }) => {
866
+ const u = holder();
867
+ const p = partition();
868
+ const twenty = [];
869
+ for (let i = 0; i < 20; i++)
870
+ twenty.push({ type: 'document', id: docId() });
871
+ const before = state.calls();
872
+ const evil = await state.manager.checkMany(u, 'viewer', [...twenty, { type: 'role_binding', id: docId() }], p).then(() => null, (e) => e);
873
+ assert.isNotNull(evil, 'ROJO: un role_binding en la posición 21 entró');
874
+ assert.equal(evil.status, 422, String(evil?.message));
875
+ assert.equal(evil.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN');
876
+ const bad = await state.manager.checkMany(u, 'viewer', [...twenty, { type: 'document', id: 'A#B' }], p).then(() => null, (e) => e);
877
+ assert.equal(bad?.status, 422, String(bad?.message));
878
+ assert.equal(bad?.code, 'E_AUTHZ_INVALID_IDENTITY');
879
+ const undeclared = await state.manager.checkMany(u, 'assignee', twenty, p).then(() => null, (e) => e);
880
+ assert.equal(undeclared?.code, 'E_AUTHZ_RELATION_UNKNOWN', 'la relación no declarada también es 422');
881
+ assert.equal(state.calls(), before, 'CERO llamadas al driver');
882
+ });
883
+ /** A-14 (P4-7, H10): tipos MEZCLADOS con la misma relación responden por ítem (`document` y `workspace`). */
884
+ 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, }) => {
885
+ const u = holder();
886
+ const p = partition();
887
+ const doc = { type: 'document', id: docId() };
888
+ const ws = { type: 'workspace', id: docId() };
889
+ const otherWs = { type: 'workspace', id: docId() };
890
+ const otherDoc = { type: 'document', id: docId() };
891
+ await state.manager.relate(u, 'viewer', doc, p);
892
+ await state.manager.relate(u, 'editor', ws, p); // includes del tipo workspace: editor ⊆ viewer
893
+ // El mismo id en los DOS tipos: el tipo forma parte de la identidad (no se cruzan).
894
+ const twin = { type: 'workspace', id: doc.id };
895
+ assert.deepEqual(await state.manager.checkMany(u, 'viewer', [doc, ws, otherWs, otherDoc, twin, ws, doc], p), [true, true, false, false, false, true, true]);
896
+ assert.deepEqual(await state.manager.checkMany(u, 'owner', [doc, ws], p), [false, false], 'owner no se deriva de editor/viewer');
897
+ });
898
+ /* ── D13 · el DRIVER llamado directamente también corta F-05 (plan 2.5 · lote A) ── */
899
+ /**
900
+ * **F-05 vive también en el driver** (D13, decisión del dueño 2026-10-14;
901
+ * tester H8): hasta el lote A el runner solo exigía el corte en el MANAGER,
902
+ * así que un driver de terceros podía no re-validar y pasar el contrato —
903
+ * y por `manager.driver()` o por `reconcileRelations` (que escribe por el
904
+ * driver del destino) componer el id de un binding real en un store
905
+ * compartido. Ahora se llama al driver del harness EN DIRECTO, sin manager:
906
+ * las ocho operaciones (y `checkMany`, y `membersOf` cuando el driver lo
907
+ * trae nativo: 🟡 A7) rechazan un tipo no declarado con el MISMO 422 que
908
+ * el manager, y el `checkMany` directo corta el tope y el lote vacío (⚪ A12). **Ruptura declarada** para un harness de
909
+ * terceros que sembrara tipos no declarados por el driver.
910
+ */
911
+ 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, }) => {
912
+ const driver = state.base;
913
+ const u = holder();
914
+ const S = partition();
915
+ const binding = { type: 'role_binding', id: docId() };
916
+ const doc = { type: 'document', id: docId() };
917
+ const typeAttempts = [
918
+ ['relate', () => driver.relate(u, 'assignee', binding, S)],
919
+ ['unrelate', () => driver.unrelate(u, 'assignee', binding, S)],
920
+ ['check', () => driver.check(u, 'assignee', binding, S)],
921
+ // `viewer` está declarada para `doc` ([0]): lo ÚNICO que falla es el role_binding de [1] (F-05 por ítem).
922
+ ['checkMany', () => (typeof driver.checkMany === 'function' ? driver.checkMany(u, 'viewer', [doc, binding], S) : driver.check(u, 'assignee', binding, S))],
923
+ ['listObjects', () => driver.listObjects(u, 'assignee', 'role_binding', S)],
924
+ ['listSubjects', () => driver.listSubjects('assignee', binding, S)],
925
+ ['purgeObject', () => driver.purgeObject(binding, S)],
926
+ ['purgeSubject(userset)', () => driver.purgeSubject({ object: binding, relation: 'role' }, S)],
927
+ // 🟡 A7 (auditor del lote A): la NOVENA operación, cuando el driver la trae nativa — un `membersOf`
928
+ // sin F-05 enumera los asignados de un rol del store compartido (la clase del 🟠 2 de alpha.3).
929
+ ...(harness.capabilities.membersOfNative && typeof driver.membersOf === 'function'
930
+ ? [['membersOf', () => driver.membersOf(binding, 'assignee', S)]]
931
+ : []),
932
+ ];
933
+ assert.equal(typeAttempts.length, harness.capabilities.membersOfNative && typeof driver.membersOf === 'function' ? 9 : 8, 'las nueve operaciones del puerto (ocho sin membersOf nativo)');
934
+ for (const [label, run] of typeAttempts) {
935
+ const caught = await run().then(() => null, (e) => e);
936
+ assert.isNotNull(caught, `ROJO (D13 · driver.${label}): el driver ENTRÓ contra role_binding sin el manager delante`);
937
+ assert.equal(caught.status, 422, `driver.${label}: ${caught.message}`);
938
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN', `driver.${label}`);
939
+ }
940
+ for (const [label, run] of [
941
+ ['relate', () => driver.relate(u, 'assignee', doc, S)],
942
+ ['check', () => driver.check(u, 'assignee', doc, S)],
943
+ ['listSubjects', () => driver.listSubjects('assignee', doc, S)],
944
+ ]) {
945
+ const caught = await run().then(() => null, (e) => e);
946
+ assert.equal(caught?.status, 422, `driver.${label}: ${caught?.message}`);
947
+ assert.equal(caught?.code, 'E_AUTHZ_RELATION_UNKNOWN', `driver.${label}`);
948
+ }
949
+ // ⚪ A12 (auditor del lote A): el tope y el lote vacío también se cortan en el DRIVER directo.
950
+ if (typeof driver.checkMany === 'function') {
951
+ const many = [];
952
+ for (let i = 0; i <= RELATIONS_CHECK_MANY_MAX; i++)
953
+ many.push({ type: 'document', id: docId() });
954
+ for (const [label, objects] of [
955
+ [`${RELATIONS_CHECK_MANY_MAX + 1}`, many],
956
+ ['[]', []],
957
+ ]) {
958
+ const caught = await driver.checkMany(u, 'viewer', objects, S).then(() => null, (e) => e);
959
+ assert.isNotNull(caught, `ROJO (A12 · driver.checkMany ${label}): el driver directo no corta el lote`);
960
+ assert.equal(caught.status, 422, `driver.checkMany ${label}: ${caught.message}`);
961
+ assert.equal(caught.code, 'E_AUTHZ_CHECK_MANY_BATCH', `driver.checkMany ${label}`);
962
+ }
963
+ assert.lengthOf(await driver.checkMany(u, 'viewer', many.slice(0, RELATIONS_CHECK_MANY_MAX), S), RELATIONS_CHECK_MANY_MAX, 'el tope exacto responde');
964
+ }
965
+ // Control: lo declarado entra por el driver y el store no tiene ni rastro del binding.
966
+ await driver.relate(u, 'viewer', doc, S);
967
+ assert.isTrue(await driver.check(u, 'viewer', doc, S));
968
+ if (typeof driver.enumerateRelations === 'function' && harness.capabilities.enumerateRelations) {
969
+ const page = await driver.enumerateRelations(S);
970
+ assert.isFalse(page.tuples.some((t) => t.object.type === 'role_binding'), 'cero tuplas role_binding');
971
+ }
972
+ });
723
973
  /* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
724
974
  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
975
  const u = holder();
@@ -972,6 +1222,32 @@ export function registerRelationsDriverContract(harness, api) {
972
1222
  // El driver del harness (sin reloj inyectado) no se ha tocado: para él
973
1223
  // (hoy) T2 —2030— es futuro y la relación renovada concede.
974
1224
  assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
1225
+ // **A-18 · un solo `now` por lote** (plan 2.5 · lote A): un reloj que
1226
+ // AVANZA 1 ms por lectura y tres tuplas que caducan en T3+1, T3+2 y
1227
+ // T3+3. `checkMany` lee el reloj UNA vez (T3) y las tres conceden;
1228
+ // un driver que leyera `now()` por ítem vería caducar la segunda o
1229
+ // la tercera entre ítems (mutante: `now()` por ítem ⇒ rojo). Solo
1230
+ // donde el driver trae `checkMany`: el respaldo del manager son N
1231
+ // `check`, cada uno con su instante (y así va escrito en el README).
1232
+ if (typeof state.base.checkMany === 'function') {
1233
+ const T3 = new Date('2031-01-01T00:00:00.000Z');
1234
+ const tickClock = fixedClock(T3);
1235
+ const batch = holder();
1236
+ const b1 = { type: 'document', id: docId() };
1237
+ const b2 = { type: 'document', id: docId() };
1238
+ const b3 = { type: 'document', id: docId() };
1239
+ const ticking = new RelationsManager(state.base.withClock(tickClock.now), contractRelationsConfig());
1240
+ await ticking.relate(batch, 'viewer', b1, p, { expiresAt: new Date(T3.getTime() + 1) });
1241
+ await ticking.relate(batch, 'viewer', b2, p, { expiresAt: new Date(T3.getTime() + 2) });
1242
+ await ticking.relate(batch, 'viewer', b3, p, { expiresAt: new Date(T3.getTime() + 3) });
1243
+ let reads = 0;
1244
+ const advancing = new RelationsManager(state.base.withClock(() => new Date(T3.getTime() + reads++)), contractRelationsConfig());
1245
+ 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');
1246
+ assert.equal(reads, 1, 'checkMany leyó el reloj EXACTAMENTE una vez para todo el lote');
1247
+ // Y con el reloj ya en T3+3 ninguna concede (estricta): el lote decide con UN instante, no con el primero cacheado.
1248
+ reads = 3;
1249
+ assert.deepEqual(await advancing.checkMany(batch, 'viewer', [b1, b2, b3], p), [false, false, false]);
1250
+ }
975
1251
  }),
976
1252
  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
1253
  assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');