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

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 (108) hide show
  1. package/README.md +299 -19
  2. package/build/commands/authz_relations_reconcile.d.ts +25 -1
  3. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  4. package/build/commands/authz_relations_reconcile.js +94 -6
  5. package/build/commands/authz_relations_reconcile.js.map +1 -1
  6. package/build/index.d.ts +13 -2
  7. package/build/index.d.ts.map +1 -1
  8. package/build/index.js +15 -1
  9. package/build/index.js.map +1 -1
  10. package/build/providers/authz_provider.d.ts +6 -1
  11. package/build/providers/authz_provider.d.ts.map +1 -1
  12. package/build/providers/authz_provider.js +18 -2
  13. package/build/providers/authz_provider.js.map +1 -1
  14. package/build/src/define_config.d.ts +73 -1
  15. package/build/src/define_config.d.ts.map +1 -1
  16. package/build/src/define_config.js.map +1 -1
  17. package/build/src/drivers/database_driver.d.ts +53 -14
  18. package/build/src/drivers/database_driver.d.ts.map +1 -1
  19. package/build/src/drivers/database_driver.js +104 -32
  20. package/build/src/drivers/database_driver.js.map +1 -1
  21. package/build/src/drivers/database_relations_driver.d.ts +57 -9
  22. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  23. package/build/src/drivers/database_relations_driver.js +344 -89
  24. package/build/src/drivers/database_relations_driver.js.map +1 -1
  25. package/build/src/drivers/openfga_driver.d.ts +17 -4
  26. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  27. package/build/src/drivers/openfga_driver.js +38 -3
  28. package/build/src/drivers/openfga_driver.js.map +1 -1
  29. package/build/src/drivers/openfga_facts.d.ts +15 -0
  30. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  31. package/build/src/drivers/openfga_facts.js +25 -2
  32. package/build/src/drivers/openfga_facts.js.map +1 -1
  33. package/build/src/drivers/openfga_relations_driver.d.ts +40 -6
  34. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  35. package/build/src/drivers/openfga_relations_driver.js +244 -44
  36. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  37. package/build/src/errors.d.ts +83 -4
  38. package/build/src/errors.d.ts.map +1 -1
  39. package/build/src/errors.js +139 -3
  40. package/build/src/errors.js.map +1 -1
  41. package/build/src/freeze.d.ts +37 -16
  42. package/build/src/freeze.d.ts.map +1 -1
  43. package/build/src/freeze.js +53 -8
  44. package/build/src/freeze.js.map +1 -1
  45. package/build/src/manager.d.ts +16 -1
  46. package/build/src/manager.d.ts.map +1 -1
  47. package/build/src/manager.js +116 -25
  48. package/build/src/manager.js.map +1 -1
  49. package/build/src/models/authz_assignment.d.ts +2 -2
  50. package/build/src/models/authz_assignment.d.ts.map +1 -1
  51. package/build/src/models/authz_deny.d.ts +2 -2
  52. package/build/src/models/authz_deny.d.ts.map +1 -1
  53. package/build/src/models/authz_permission.d.ts +2 -2
  54. package/build/src/models/authz_permission.d.ts.map +1 -1
  55. package/build/src/models/authz_role.d.ts +2 -2
  56. package/build/src/models/authz_role.d.ts.map +1 -1
  57. package/build/src/models/authz_role_permission.d.ts +2 -2
  58. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  59. package/build/src/relation_partition_trigger.d.ts.map +1 -1
  60. package/build/src/relation_partition_trigger.js +11 -3
  61. package/build/src/relation_partition_trigger.js.map +1 -1
  62. package/build/src/relations/define_relations_config.d.ts +51 -0
  63. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  64. package/build/src/relations/define_relations_config.js +68 -1
  65. package/build/src/relations/define_relations_config.js.map +1 -1
  66. package/build/src/relations/manager.d.ts +71 -6
  67. package/build/src/relations/manager.d.ts.map +1 -1
  68. package/build/src/relations/manager.js +301 -29
  69. package/build/src/relations/manager.js.map +1 -1
  70. package/build/src/relations/reconcile.d.ts +53 -4
  71. package/build/src/relations/reconcile.d.ts.map +1 -1
  72. package/build/src/relations/reconcile.js +92 -30
  73. package/build/src/relations/reconcile.js.map +1 -1
  74. package/build/src/scope_outbox.d.ts.map +1 -1
  75. package/build/src/scope_outbox.js +17 -10
  76. package/build/src/scope_outbox.js.map +1 -1
  77. package/build/src/shared/backend_guard.d.ts +28 -0
  78. package/build/src/shared/backend_guard.d.ts.map +1 -1
  79. package/build/src/shared/backend_guard.js +52 -0
  80. package/build/src/shared/backend_guard.js.map +1 -1
  81. package/build/src/shared/transaction_guard.d.ts +50 -0
  82. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  83. package/build/src/shared/transaction_guard.js +60 -0
  84. package/build/src/shared/transaction_guard.js.map +1 -0
  85. package/build/src/testing/contract.d.ts +55 -4
  86. package/build/src/testing/contract.d.ts.map +1 -1
  87. package/build/src/testing/contract.js +240 -8
  88. package/build/src/testing/contract.js.map +1 -1
  89. package/build/src/testing/main.d.ts +4 -4
  90. package/build/src/testing/main.d.ts.map +1 -1
  91. package/build/src/testing/main.js +2 -2
  92. package/build/src/testing/main.js.map +1 -1
  93. package/build/src/testing/relations_contract.d.ts +23 -1
  94. package/build/src/testing/relations_contract.d.ts.map +1 -1
  95. package/build/src/testing/relations_contract.js +603 -19
  96. package/build/src/testing/relations_contract.js.map +1 -1
  97. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  98. package/build/src/testing/relations_reconcile_contract.js +49 -1
  99. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  100. package/build/src/traits/has_uuid.d.ts +2 -2
  101. package/build/src/traits/has_uuid.d.ts.map +1 -1
  102. package/build/src/types.d.ts +284 -16
  103. package/build/src/types.d.ts.map +1 -1
  104. package/build/src/types.js +4 -0
  105. package/build/src/types.js.map +1 -1
  106. package/build/stubs/config/authorization.stub +45 -1
  107. package/build/stubs/migration.stub +20 -10
  108. package/package.json +1 -1
@@ -19,8 +19,12 @@
19
19
  */
20
20
  import { v7 as uuidv7 } from 'uuid';
21
21
  import { test as japaTest } from '@japa/runner';
22
+ import db from '@adonisjs/lucid/services/db';
23
+ import { scopeKey } from '../identity.js';
22
24
  import { RelationsManager } from '../relations/manager.js';
23
25
  import { defineRelationsConfig } from '../relations/define_relations_config.js';
26
+ import { systemClock } from '../clock.js';
27
+ import { resolveGrantExpiry } from '../expiry.js';
24
28
  import { isRelUserset } from '../types.js';
25
29
  /* ── El vocabulario del contrato ─────────────────────────────────────────── */
26
30
  /** Los holder types del contrato de relaciones. */
@@ -45,6 +49,19 @@ export function contractRelationsConfig() {
45
49
  database: { membersOf: true },
46
50
  });
47
51
  }
52
+ /** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
53
+ export function lucidRelationsContractTransactions() {
54
+ return {
55
+ async begin() {
56
+ const trx = await db.transaction();
57
+ return { client: trx, commit: () => trx.commit(), rollback: () => trx.rollback() };
58
+ },
59
+ async census(partition) {
60
+ const rows = await db.from('authz_relations').where('partition_key', scopeKey(partition)).count('* as n');
61
+ return Number(rows[0]?.n ?? 0);
62
+ },
63
+ };
64
+ }
48
65
  function scopeKeyOf(scope) {
49
66
  return scope.type === 'app' ? 'app' : `${scope.type}|${scope.uuid}`;
50
67
  }
@@ -59,8 +76,19 @@ function subjectKeyOf(subject) {
59
76
  * que prueba el CONTRATO —no el MODELO fusionado (eso es el `:8101` en 4-4)—.
60
77
  */
61
78
  export function makeRelationsDriver(options) {
79
+ return buildRelationsDouble(options, [], systemClock);
80
+ }
81
+ /**
82
+ * El doble, sobre un conjunto de tuplas COMPARTIDO y un reloj: `withClock`
83
+ * (solo con `injectableClock: true`) devuelve otra vista sobre las MISMAS
84
+ * tuplas con otro `now`, como los drivers reales (R-15).
85
+ */
86
+ function buildRelationsDouble(options, tuples, now) {
62
87
  const { config, capabilities, limits } = options;
63
- const tuples = [];
88
+ /** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. */
89
+ function active(t) {
90
+ return t.expiresAt === null || t.expiresAt.getTime() > now().getTime();
91
+ }
64
92
  /**
65
93
  * `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
66
94
  * satisface R. `viewer includes editor` ⇒ un `editor` es también `viewer`,
@@ -84,11 +112,13 @@ export function makeRelationsDriver(options) {
84
112
  }
85
113
  return out;
86
114
  }
115
+ /** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
87
116
  function directSubjects(partition, objectType, objectId, relation) {
88
117
  return tuples.filter((t) => t.partition === partition &&
89
118
  t.objectType === objectType &&
90
119
  t.objectId === objectId &&
91
- t.relation === relation);
120
+ t.relation === relation &&
121
+ active(t));
92
122
  }
93
123
  function resolves(subject, relation, object, partition, seen = new Set()) {
94
124
  const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
@@ -142,24 +172,30 @@ export function makeRelationsDriver(options) {
142
172
  }
143
173
  const driver = {
144
174
  capabilities,
145
- async relate(subject, relation, object, partition) {
175
+ async relate(subject, relation, object, partition, options) {
146
176
  const partitionKey = scopeKeyOf(partition);
147
177
  const subjectKey = subjectKeyOf(subject);
148
- const exists = tuples.some((t) => t.partition === partitionKey &&
178
+ const existing = tuples.find((t) => t.partition === partitionKey &&
149
179
  t.objectType === object.type &&
150
180
  t.objectId === object.id &&
151
181
  t.relation === relation &&
152
182
  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
- });
183
+ // R-15, los tres estados (invariante 10): omitido preserva la vigente
184
+ // (una caducada revive sin caducidad), null la quita, Date la fija.
185
+ const expiresAt = resolveGrantExpiry(existing?.expiresAt ?? null, options?.expiresAt, now());
186
+ if (existing) {
187
+ existing.expiresAt = expiresAt;
188
+ return;
162
189
  }
190
+ tuples.push({
191
+ partition: partitionKey,
192
+ objectType: object.type,
193
+ objectId: object.id,
194
+ relation,
195
+ subject,
196
+ subjectKey,
197
+ expiresAt,
198
+ });
163
199
  },
164
200
  async unrelate(subject, relation, object, partition) {
165
201
  const partitionKey = scopeKeyOf(partition);
@@ -182,7 +218,7 @@ export function makeRelationsDriver(options) {
182
218
  const partitionKey = scopeKeyOf(partition);
183
219
  const ids = new Set();
184
220
  for (const t of tuples) {
185
- if (t.partition === partitionKey && t.objectType === objectType)
221
+ if (t.partition === partitionKey && t.objectType === objectType && active(t))
186
222
  ids.add(t.objectId);
187
223
  }
188
224
  const matches = [];
@@ -238,6 +274,8 @@ export function makeRelationsDriver(options) {
238
274
  if (capabilities.enumerateRelations) {
239
275
  driver.enumerateRelations = async (partition) => {
240
276
  const partitionKey = scopeKeyOf(partition);
277
+ // SIN filtrar la caducada (R-15): llega con su `expiresAt` para que el
278
+ // destino de reconcile la cuente en `skipped`, no para que desaparezca.
241
279
  const out = tuples
242
280
  .filter((t) => t.partition === partitionKey)
243
281
  .map((t) => ({
@@ -245,12 +283,30 @@ export function makeRelationsDriver(options) {
245
283
  relation: t.relation,
246
284
  object: { type: t.objectType, id: t.objectId },
247
285
  partition,
286
+ expiresAt: t.expiresAt,
248
287
  }));
249
288
  return { tuples: out };
250
289
  };
251
290
  }
291
+ if (capabilities.injectableClock) {
292
+ // La vista con otro reloj comparte las tuplas (como `Object.create(this)`
293
+ // en los drivers reales). Solo existe si se declara: la cara `false` del
294
+ // juez exige que un driver sin la capacidad NO traiga `withClock`.
295
+ driver.withClock = (next) => buildRelationsDouble(options, tuples, next);
296
+ }
252
297
  return driver;
253
298
  }
299
+ /** El reloj fijo del juez (el mismo patrón que `fixedClock` del runner de roles). */
300
+ function fixedClock(start) {
301
+ let current = new Date(start.getTime());
302
+ return {
303
+ now: () => new Date(current.getTime()),
304
+ set: (at) => {
305
+ current = new Date(at.getTime());
306
+ },
307
+ };
308
+ }
309
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
254
310
  /* ── El runner ───────────────────────────────────────────────────────────── */
255
311
  function holder() {
256
312
  return { type: 'user', uuid: uuidv7() };
@@ -271,11 +327,22 @@ function partition() {
271
327
  */
272
328
  function spy(driver) {
273
329
  let writes = 0;
330
+ let calls = 0;
274
331
  const wrapped = new Proxy(driver, {
275
332
  get(target, prop, receiver) {
276
- if (prop === 'relate' || prop === 'unrelate') {
333
+ // L-2: las CUATRO escrituras (también `purge*`, que reciben `{ transaction }`).
334
+ if (prop === 'relate' || prop === 'unrelate' || prop === 'purgeObject' || prop === 'purgeSubject') {
277
335
  return async (...args) => {
278
336
  writes++;
337
+ calls++;
338
+ return target[prop](...args);
339
+ };
340
+ }
341
+ // alpha.3 (cierre) · las TRES lecturas también cuentan: F-05 tiene que
342
+ // cortarlas ANTES del driver («cero llamadas» = ni una lectura).
343
+ if (prop === 'check' || prop === 'listObjects' || prop === 'listSubjects') {
344
+ return async (...args) => {
345
+ calls++;
279
346
  return target[prop](...args);
280
347
  };
281
348
  }
@@ -283,7 +350,7 @@ function spy(driver) {
283
350
  return typeof value === 'function' ? value.bind(target) : value;
284
351
  },
285
352
  });
286
- return { driver: wrapped, writes: () => writes };
353
+ return { driver: wrapped, writes: () => writes, calls: () => calls };
287
354
  }
288
355
  export function runRelationsDriverContract(harness) {
289
356
  // eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -316,18 +383,30 @@ export function registerRelationsDriverContract(harness, api) {
316
383
  async function freshManager(options) {
317
384
  const base = await harness.makeDriver(contractRelationsConfig());
318
385
  const s = spy(base);
319
- return { manager: new RelationsManager(s.driver, contractRelationsConfig(), options), writes: s.writes, base };
386
+ // L-2: el manager nombra al driver en las dos puertas de `{ transaction }`
387
+ // como lo haría el provider (la clave en `relations.drivers`): aquí, el harness.
388
+ return {
389
+ manager: new RelationsManager(s.driver, contractRelationsConfig(), { driverName: harness.name, ...options }),
390
+ writes: s.writes,
391
+ calls: s.calls,
392
+ base,
393
+ };
320
394
  }
321
395
  api.group(`relations contract [${harness.name}]`, (group) => {
322
396
  group.each.setup(async () => {
323
- const f = await freshManager();
397
+ // alpha.3: el manager de cada caso captura `onRelationWrite` — el par
398
+ // `transactionalWrites` juzga también el EVENTO (la otra cara observable
399
+ // de la capacidad) y R-07/R-08 juzgan el evento de la purga.
400
+ state.events = [];
401
+ const f = await freshManager({ onRelationWrite: (e) => void state.events.push(e) });
324
402
  state.manager = f.manager;
325
403
  state.base = f.base;
326
404
  state.writes = f.writes;
405
+ state.calls = f.calls;
327
406
  });
328
407
  const test = (title, fn) => {
329
408
  registered++;
330
- api.test(title, fn);
409
+ return api.test(title, fn);
331
410
  };
332
411
  function caseFor(capability, pair) {
333
412
  // MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
@@ -472,6 +551,80 @@ export function registerRelationsDriverContract(harness, api) {
472
551
  assert.equal(caught.code, 'E_AUTHZ_RELATION_UNKNOWN');
473
552
  assert.equal(state.writes(), 0);
474
553
  });
554
+ /**
555
+ * **F-05 en las PURGAS y las LECTURAS** (2.4.0-alpha.3, cierre del 🔴 1 /
556
+ * 🟠 2 del auditor). Hasta el cierre F-05 solo cubría `relate`/`unrelate`,
557
+ * y en el store COMPARTIDO de `openfga` `purgeObject({ type:
558
+ * 'role_binding', id: R }, S)` borraba el binding REAL de un rol
559
+ * (`authorize` de `true` a `false`, evento de auditoría limpio) y
560
+ * `listSubjects('assignee', role_binding)` enumeraba sus asignados sin
561
+ * catálogo ni `within`. Un driver de terceros que no lo corte por el
562
+ * manager NO pasa: cero llamadas al driver (ni escrituras ni lecturas),
563
+ * cero eventos. Una purga o un `listObjects` validan el TIPO; `check`/
564
+ * `listSubjects` el par; `purgeSubject` el userset del sujeto.
565
+ */
566
+ test('F-05 · purgeObject/purgeSubject(userset)/check/listObjects/listSubjects/membersOf contra un tipo NO declarado (role_binding) ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN con CERO llamadas al driver y CERO eventos; check/listObjects/listSubjects/membersOf con una relación no declarada, y relate/unrelate/check/listSubjects/listObjects/membersOf SIN relación (undefined) ⇒ 422 E_AUTHZ_RELATION_UNKNOWN', async ({ assert, }) => {
567
+ const u = holder();
568
+ const roleUuid = uuidv7();
569
+ const S = partition();
570
+ const binding = { type: 'role_binding', id: roleUuid };
571
+ const attempts = [
572
+ ['purgeObject', () => state.manager.purgeObject(binding, S)],
573
+ [
574
+ 'purgeSubject(userset)',
575
+ () => state.manager.purgeSubject({ object: binding, relation: 'role' }, S),
576
+ ],
577
+ ['check', () => state.manager.check(u, 'assignee', binding, S)],
578
+ ['listObjects', () => state.manager.listObjects(u, 'assignee', 'role_binding', S)],
579
+ ['listSubjects', () => state.manager.listSubjects('assignee', binding, S)],
580
+ // Cierre-2 · 🟡 3: `membersOf` es la octava operación (F-05 va ANTES de la capacidad).
581
+ ['membersOf', () => state.manager.membersOf(binding, 'assignee', S)],
582
+ ];
583
+ for (const [label, run] of attempts) {
584
+ const caught = await run().then(() => null, (e) => e);
585
+ assert.isNotNull(caught, `ROJO (${label}): entró contra un tipo NO declarado (role_binding)`);
586
+ assert.equal(caught.status, 422, `${label}: ${caught.message}`);
587
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN', label);
588
+ }
589
+ // Una relación no declarada del tipo declarado, en las lecturas que la nombran.
590
+ const doc = { type: 'document', id: docId() };
591
+ for (const [label, run] of [
592
+ ['check', () => state.manager.check(u, 'assignee', doc, S)],
593
+ ['listObjects', () => state.manager.listObjects(u, 'assignee', 'document', S)],
594
+ ['listSubjects', () => state.manager.listSubjects('assignee', doc, S)],
595
+ ['membersOf', () => state.manager.membersOf(doc, 'assignee', S)],
596
+ ]) {
597
+ const caught = await run().then(() => null, (e) => e);
598
+ assert.equal(caught?.status, 422, `${label}: ${caught?.message}`);
599
+ assert.equal(caught?.code, 'E_AUTHZ_RELATION_UNKNOWN', label);
600
+ }
601
+ // Cierre-2 · 🟠 1: SIN relación (`request.input('relation')` ausente) es
602
+ // 422, no una tupla escrita, no un 503 del backend y no la unión de
603
+ // `listSubjects`. Un driver de terceros lo hereda del manager.
604
+ for (const [label, run] of [
605
+ ['relate', () => state.manager.relate(u, undefined, doc, S)],
606
+ ['unrelate', () => state.manager.unrelate(u, undefined, doc, S)],
607
+ ['check', () => state.manager.check(u, undefined, doc, S)],
608
+ ['listSubjects', () => state.manager.listSubjects(undefined, doc, S)],
609
+ ['listObjects', () => state.manager.listObjects(u, undefined, 'document', S)],
610
+ ['membersOf', () => state.manager.membersOf(doc, undefined, S)],
611
+ ]) {
612
+ const caught = await run().then(() => null, (e) => e);
613
+ assert.isNotNull(caught, `ROJO (${label}): ENTRÓ sin relación`);
614
+ assert.equal(caught.status, 422, `${label}: ${caught.message}`);
615
+ assert.equal(caught.code, 'E_AUTHZ_RELATION_UNKNOWN', label);
616
+ }
617
+ assert.equal(state.calls(), 0, 'CERO llamadas al driver: ni una escritura ni una lectura');
618
+ assert.lengthOf(state.events, 0, 'CERO eventos: no hubo purga que auditar');
619
+ // CONTROL: lo declarado sigue entrando por los mismos cinco caminos.
620
+ await state.manager.relate(u, 'viewer', doc, S);
621
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, S));
622
+ assert.lengthOf((await state.manager.listObjects(u, 'viewer', 'document', S)).objects, 1);
623
+ assert.lengthOf((await state.manager.listSubjects('viewer', doc, S)).subjects, 1);
624
+ await state.manager.purgeObject(doc, S);
625
+ await state.manager.purgeSubject({ object: { type: 'group', id: docId() }, relation: 'member' }, S);
626
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, S));
627
+ });
475
628
  /* ── R-13: assertWrite PURO + actor en onRelationWrite ── */
476
629
  test('R-13 · assertWrite rechaza ⇒ nada toca el driver; actor viaja en onRelationWrite', async ({ assert, }) => {
477
630
  const events = [];
@@ -503,6 +656,102 @@ export function registerRelationsDriverContract(harness, api) {
503
656
  assert.deepEqual(events[0].actor, actor);
504
657
  assert.equal(events[0].operation, 'relate');
505
658
  });
659
+ /* ── R-17 · las purgas notifican (2.4.0-alpha.3 · E7) ── */
660
+ test('R-17 · purgeObject/purgeSubject notifican UN evento por llamada con su forma (sin relation; purgeObject sin subject, purgeSubject sin object) y el actor, también cuando no borran nada; requireActor las alcanza (422 sin actor, cero llamadas, cero eventos)', async ({ assert, }) => {
661
+ const u = holder();
662
+ const v = holder();
663
+ const doc = { type: 'document', id: docId() };
664
+ const doc2 = { type: 'document', id: docId() };
665
+ const p = partition();
666
+ const actor = holder();
667
+ await state.manager.relate(u, 'viewer', doc, p);
668
+ await state.manager.relate(v, 'editor', doc, p);
669
+ await state.manager.relate(u, 'owner', doc2, p);
670
+ const before = state.events.length;
671
+ // purgeObject: dos tuplas borradas, UN evento, con la forma decidida.
672
+ await state.manager.purgeObject(doc, p, { actor });
673
+ assert.lengthOf(state.events, before + 1, 'ROJO: la purga no notifica exactamente UN evento por llamada');
674
+ const purgedObject = state.events[before];
675
+ assert.equal(purgedObject.operation, 'purgeObject');
676
+ assert.deepEqual(purgedObject.object, doc);
677
+ assert.deepEqual(purgedObject.partition, p);
678
+ assert.deepEqual(purgedObject.actor, actor);
679
+ assert.notProperty(purgedObject, 'subject');
680
+ assert.notProperty(purgedObject, 'relation');
681
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p));
682
+ // purgeSubject: UN evento, sin object.
683
+ await state.manager.purgeSubject(u, p, { actor });
684
+ assert.lengthOf(state.events, before + 2);
685
+ const purgedSubject = state.events[before + 1];
686
+ assert.equal(purgedSubject.operation, 'purgeSubject');
687
+ assert.deepEqual(purgedSubject.subject, u);
688
+ assert.deepEqual(purgedSubject.partition, p);
689
+ assert.deepEqual(purgedSubject.actor, actor);
690
+ assert.notProperty(purgedSubject, 'object');
691
+ assert.notProperty(purgedSubject, 'relation');
692
+ assert.isFalse(await state.manager.check(u, 'owner', doc2, p));
693
+ // Una purga que no borra nada notifica igual (sin actor, el evento no lo inventa).
694
+ await state.manager.purgeObject({ type: 'document', id: docId() }, p);
695
+ assert.lengthOf(state.events, before + 3);
696
+ assert.equal(state.events[before + 2].operation, 'purgeObject');
697
+ assert.notProperty(state.events[before + 2], 'actor');
698
+ // requireActor alcanza a las purgas (D-3): 422 ANTES del driver.
699
+ const strictEvents = [];
700
+ const strict = await freshManager({ requireActor: true, onRelationWrite: (e) => void strictEvents.push(e) });
701
+ for (const [operation, run] of [
702
+ ['purgeObject', () => strict.manager.purgeObject(doc, p)],
703
+ ['purgeSubject', () => strict.manager.purgeSubject(u, p)],
704
+ ]) {
705
+ let caught;
706
+ try {
707
+ await run();
708
+ assert.fail(`${operation}: requireActor: true tenía que exigir actor a la purga`);
709
+ }
710
+ catch (error) {
711
+ caught = error;
712
+ }
713
+ assert.equal(caught.status, 422, `${operation}: ${caught.message}`);
714
+ assert.equal(caught.code, 'E_AUTHZ_ACTOR_REQUIRED', operation);
715
+ }
716
+ assert.equal(strict.writes(), 0, 'cero llamadas al driver');
717
+ assert.lengthOf(strictEvents, 0);
718
+ await strict.manager.purgeObject(doc, p, { actor });
719
+ assert.equal(strict.writes(), 1);
720
+ assert.lengthOf(strictEvents, 1);
721
+ assert.deepEqual(strictEvents[0].actor, actor);
722
+ });
723
+ /* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
724
+ 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
+ const u = holder();
726
+ const doc = { type: 'document', id: docId() };
727
+ const p = partition();
728
+ // Caducada al escribirse: no concede y no se enumera (caducidad
729
+ // estricta; sin reloj inyectado, un instante PASADO ya lo observa).
730
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
731
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p), 'caducada ⇒ check false');
732
+ const objects = await state.manager.listObjects(u, 'viewer', 'document', p);
733
+ assert.isFalse(objects.objects.some((o) => o.id === doc.id), 'caducada ⇒ listObjects no la lista');
734
+ const subjects = await state.manager.listSubjects('viewer', doc, p);
735
+ assert.isFalse(subjects.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid), 'caducada ⇒ listSubjects no la lista');
736
+ // Con caducidad futura concede (y `Date` sobre la caducada la FIJA: tercer estado).
737
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() + 3_600_000) });
738
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p), 'futura ⇒ concede');
739
+ // Un `expiresAt` que no es Date válida/null/omitido es 422 ANTES del driver.
740
+ const writes = state.writes();
741
+ for (const bad of ['mañana', 12345, new Date('no-es-fecha')]) {
742
+ let caught;
743
+ try {
744
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: bad });
745
+ assert.fail(`debería haber lanzado con ${String(bad)}`);
746
+ }
747
+ catch (error) {
748
+ caught = error;
749
+ }
750
+ assert.equal(caught.status, 422);
751
+ assert.equal(caught.code, 'E_AUTHZ_INVALID_IDENTITY');
752
+ }
753
+ assert.equal(state.writes(), writes, 'el driver no se tocó con un expiresAt inválido');
754
+ });
506
755
  /* ── Capacidades, cada una con sus DOS caras ── */
507
756
  caseFor('singleCheckRelations', {
508
757
  // whenTrue: la derivación (includes + userset anidado) resuelve — la
@@ -641,6 +890,341 @@ export function registerRelationsDriverContract(harness, api) {
641
890
  assert.isUndefined(page.truncated);
642
891
  }),
643
892
  });
893
+ /* ── R-15 · el par `injectableClock` (paridad con roles, 2.5 · J1) ── */
894
+ caseFor('injectableClock', {
895
+ whenTrue: () => test('injectableClock:true · expiresAt en tres estados con el reloj inyectado, y la caducidad EXACTA (T−1 ms concede, T no, T+1 no) en check/listObjects/listSubjects y a través de un userset; renovar y revivir sin dormir', async ({ assert, }) => {
896
+ assert.typeOf(state.base.withClock, 'function', 'injectableClock: true exige withClock en el puerto');
897
+ // `T` lleva milisegundos a propósito (2.5 · J3): un motor que trunque
898
+ // a segundos falla aquí (DATETIME(3) en MySQL).
899
+ const T = new Date('2030-06-15T12:34:56.789Z');
900
+ const clock = fixedClock(new Date(T.getTime() - 60_000));
901
+ const clocked = new RelationsManager(state.base.withClock(clock.now), contractRelationsConfig());
902
+ const u = holder();
903
+ const doc = { type: 'document', id: docId() };
904
+ const viaGroup = { type: 'document', id: docId() };
905
+ const g = { type: 'group', id: docId() };
906
+ const p = partition();
907
+ await clocked.relate(u, 'viewer', doc, p, { expiresAt: T });
908
+ // La MEMBRESÍA caduca: g#member es editor de `viaGroup` sin plazo, pero
909
+ // u es member de g solo hasta T ⇒ en T deja de ser viewer de viaGroup.
910
+ await clocked.relate(u, 'member', g, p, { expiresAt: T });
911
+ await clocked.relate({ object: g, relation: 'member' }, 'editor', viaGroup, p);
912
+ const observe = async () => ({
913
+ check: await clocked.check(u, 'viewer', doc, p),
914
+ listObjects: (await clocked.listObjects(u, 'viewer', 'document', p)).objects.some((o) => o.id === doc.id),
915
+ listSubjects: (await clocked.listSubjects('viewer', doc, p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
916
+ viaGroup: await clocked.check(u, 'viewer', viaGroup, p),
917
+ ...(harness.capabilities.membersOfNative
918
+ ? {
919
+ membersOf: (await clocked.membersOf(g, 'member', p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
920
+ }
921
+ : {}),
922
+ });
923
+ const alive = {
924
+ check: true,
925
+ listObjects: true,
926
+ listSubjects: true,
927
+ viaGroup: true,
928
+ ...(harness.capabilities.membersOfNative ? { membersOf: true } : {}),
929
+ };
930
+ const gone = {
931
+ check: false,
932
+ listObjects: false,
933
+ listSubjects: false,
934
+ viaGroup: false,
935
+ ...(harness.capabilities.membersOfNative ? { membersOf: false } : {}),
936
+ };
937
+ clock.set(new Date(T.getTime() - 1));
938
+ assert.deepEqual(await observe(), alive, 'T−1 ms');
939
+ clock.set(T);
940
+ assert.deepEqual(await observe(), gone, 'T: la que vence ahora ya no cuenta (estricta)');
941
+ clock.set(new Date(T.getTime() + 1));
942
+ assert.deepEqual(await observe(), gone, 'T+1 ms');
943
+ // Renovación: `Date` posterior vuelve a conceder hasta ESE instante.
944
+ const T2 = new Date(T.getTime() + 1_000);
945
+ await clocked.relate(u, 'viewer', doc, p, { expiresAt: T2 });
946
+ await clocked.relate(u, 'member', g, p, { expiresAt: T2 });
947
+ assert.deepEqual(await observe(), alive, 'renovada en T+1 ms');
948
+ clock.set(new Date(T2.getTime() - 1));
949
+ assert.deepEqual(await observe(), alive, 'T2−1 ms');
950
+ clock.set(T2);
951
+ assert.deepEqual(await observe(), gone, 'T2');
952
+ // Los tres estados (invariante 10), con el reloj en T2 (u ya caducado):
953
+ // omitido sobre una CADUCADA revive sin caducidad;
954
+ await clocked.relate(u, 'viewer', doc, p);
955
+ await clocked.relate(u, 'member', g, p);
956
+ clock.set(new Date('2099-12-31T23:59:59.999Z'));
957
+ assert.deepEqual(await observe(), alive, 'revivida sin caducidad: vigente en 2099');
958
+ // omitido sobre una VIGENTE la preserva (no la convierte en permanente);
959
+ const keep = holder();
960
+ const soon = new Date(clock.now().getTime() + 1_500);
961
+ await clocked.relate(keep, 'viewer', doc, p, { expiresAt: soon });
962
+ await clocked.relate(keep, 'viewer', doc, p);
963
+ // null la quita.
964
+ const lift = holder();
965
+ await clocked.relate(lift, 'viewer', doc, p, { expiresAt: soon });
966
+ await clocked.relate(lift, 'viewer', doc, p, { expiresAt: null });
967
+ clock.set(new Date(soon.getTime() - 1));
968
+ assert.isTrue(await clocked.check(keep, 'viewer', doc, p), 'preservada: vigente en soon−1');
969
+ clock.set(soon);
970
+ assert.isFalse(await clocked.check(keep, 'viewer', doc, p), 'preservada: vence en soon (omitido NO la hizo permanente)');
971
+ assert.isTrue(await clocked.check(lift, 'viewer', doc, p), 'null la quitó: sigue vigente');
972
+ // El driver del harness (sin reloj inyectado) no se ha tocado: para él
973
+ // (hoy) T2 —2030— es futuro y la relación renovada concede.
974
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
975
+ }),
976
+ 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
+ assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');
978
+ const doc = { type: 'document', id: docId() };
979
+ const p = partition();
980
+ const keep = holder();
981
+ const lift = holder();
982
+ const revive = holder();
983
+ const soon = new Date(Date.now() + 1_500);
984
+ await state.manager.relate(keep, 'viewer', doc, p, { expiresAt: soon });
985
+ await state.manager.relate(keep, 'viewer', doc, p); // omitido: preserva `soon`
986
+ await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: soon });
987
+ await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: null }); // la quita
988
+ await state.manager.relate(revive, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
989
+ assert.isFalse(await state.manager.check(revive, 'viewer', doc, p), 'caducada no concede');
990
+ await state.manager.relate(revive, 'viewer', doc, p); // omitido sobre caducada: revive sin caducidad
991
+ assert.isTrue(await state.manager.check(keep, 'viewer', doc, p));
992
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
993
+ assert.isTrue(await state.manager.check(revive, 'viewer', doc, p));
994
+ await sleep(1_700);
995
+ assert.isFalse(await state.manager.check(keep, 'viewer', doc, p), 'preservada: venció');
996
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p), 'sin caducidad: sigue');
997
+ assert.isTrue(await state.manager.check(revive, 'viewer', doc, p), 'revivida sin caducidad: sigue');
998
+ }),
999
+ });
1000
+ /* ── L-2 · el par `transactionalWrites` (panel `{trx}`, (C); mismo nombre que en roles) ── */
1001
+ caseFor('transactionalWrites', {
1002
+ // L-4: la cara `true`. Lo que se juzga es «los dos o ninguno con TU
1003
+ // transacción» POR CENSO (paridad con la cara `true` de roles, L-3): el
1004
+ // rollback deja CERO tuplas nuevas en la partición (no solo `check`
1005
+ // false — una respuesta puede salir bien con la fila viva), el commit
1006
+ // las deja escritas, mientras la transacción está abierta la autoridad
1007
+ // (que lee por la conexión del motor) no ve nada, y `purgeObject`/
1008
+ // `purgeSubject` borran y REVIERTEN juntos (tras el rollback todo lo
1009
+ // purgado está de vuelta). Y la otra mitad: una transacción que NO es
1010
+ // la del driver no recibe ni una sentencia. Necesita un motor con
1011
+ // transacciones reales y pool ≥ 2: el harness `database` lo declara
1012
+ // `true` en `sqlite-file`/PG/MySQL y `false` en `:memory:`.
1013
+ whenTrue: () => test('transactionalWrites:true · relate/unrelate/purgeObject/purgeSubject con { transaction } + rollback ⇒ check sin cambio Y CERO tuplas nuevas (censo); + commit ⇒ aplicado; purge* borran y revierten JUNTOS; el EVENTO de las cuatro lleva transactional: true (y sin { transaction }, no); una transacción ajena, un cliente sin transacción o el `db` entero ⇒ 500 E_AUTHZ_CONFIG sin UNA sentencia por ella', async ({ assert, }) => {
1014
+ assert.equal(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: true y el driver no: declara lo observable');
1015
+ const tx = harness.transactions ?? lucidRelationsContractTransactions();
1016
+ const u = holder();
1017
+ const v = holder();
1018
+ const doc = { type: 'document', id: docId() };
1019
+ const other = { type: 'document', id: docId() };
1020
+ const p = partition();
1021
+ const can = () => state.manager.check(u, 'viewer', doc, p);
1022
+ const census = () => tx.census(p);
1023
+ // Una transacción que se queda abierta tras una aserción fallida
1024
+ // retiene una conexión del pool y arrastra a los casos siguientes:
1025
+ // el rollback va en `finally`, y el commit solo si nada falló.
1026
+ const rolledBack = async (fn) => {
1027
+ const t = await tx.begin();
1028
+ try {
1029
+ await fn(t.client);
1030
+ }
1031
+ finally {
1032
+ await t.rollback();
1033
+ }
1034
+ };
1035
+ const committed = async (fn) => {
1036
+ const t = await tx.begin();
1037
+ try {
1038
+ await fn(t.client);
1039
+ }
1040
+ catch (error) {
1041
+ await t.rollback();
1042
+ throw error;
1043
+ }
1044
+ await t.commit();
1045
+ };
1046
+ // relate: dentro no se ve desde fuera; rollback ⇒ nada; commit ⇒ concede.
1047
+ let seenInside;
1048
+ await rolledBack(async (transaction) => {
1049
+ await state.manager.relate(u, 'viewer', doc, p, { transaction });
1050
+ seenInside = await can();
1051
+ });
1052
+ assert.equal(await census(), 0, 'ROJO: la relación sobrevivió al rollback (la escritura NO fue por la transacción del llamante)');
1053
+ assert.isFalse(await can(), 'relate + rollback ⇒ no concede');
1054
+ assert.isFalse(seenInside, 'la autoridad lee por la conexión del motor: el relate sin confirmar no concede');
1055
+ await committed(async (transaction) => {
1056
+ await state.manager.relate(u, 'viewer', doc, p, { transaction });
1057
+ });
1058
+ assert.isTrue(await can(), 'relate + commit ⇒ concede');
1059
+ assert.equal(await census(), 1);
1060
+ // unrelate: rollback ⇒ la tupla sigue; commit ⇒ se va.
1061
+ await rolledBack(async (transaction) => {
1062
+ await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
1063
+ seenInside = await can();
1064
+ });
1065
+ assert.equal(await census(), 1, 'ROJO: el unrelate sobrevivió al rollback');
1066
+ assert.isTrue(await can(), 'unrelate + rollback ⇒ sigue concediendo');
1067
+ assert.isTrue(seenInside, 'el unrelate sin confirmar no se ve desde la conexión del motor');
1068
+ await committed(async (transaction) => {
1069
+ await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
1070
+ });
1071
+ assert.isFalse(await can(), 'unrelate + commit ⇒ no concede');
1072
+ assert.equal(await census(), 0);
1073
+ // purgeObject: dos sujetos sobre `doc` + uno sobre `other`; rollback ⇒
1074
+ // los tres siguen (borran y revierten JUNTOS); commit ⇒ quedan solo
1075
+ // los de `other`.
1076
+ await state.manager.relate(u, 'viewer', doc, p);
1077
+ await state.manager.relate(v, 'editor', doc, p);
1078
+ await state.manager.relate(u, 'owner', other, p);
1079
+ assert.equal(await census(), 3);
1080
+ await rolledBack(async (transaction) => {
1081
+ await state.manager.purgeObject(doc, p, { transaction });
1082
+ seenInside = await can();
1083
+ });
1084
+ assert.equal(await census(), 3, 'ROJO: purgeObject sobrevivió al rollback (algo de lo purgado no volvió)');
1085
+ assert.isTrue(await can(), 'purgeObject + rollback ⇒ sigue concediendo');
1086
+ assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
1087
+ assert.isTrue(seenInside, 'el purgeObject sin confirmar no se ve desde la conexión del motor');
1088
+ await committed(async (transaction) => {
1089
+ await state.manager.purgeObject(doc, p, { transaction });
1090
+ });
1091
+ assert.equal(await census(), 1, 'purgeObject + commit ⇒ solo queda la tupla de `other`');
1092
+ assert.isFalse(await can());
1093
+ assert.isFalse(await state.manager.check(v, 'viewer', doc, p));
1094
+ assert.isTrue(await state.manager.check(u, 'owner', other, p));
1095
+ // purgeSubject: `u` es owner de `other` y viewer de `doc` de nuevo;
1096
+ // rollback ⇒ las dos siguen; commit ⇒ las dos se van y `v` no se toca.
1097
+ await state.manager.relate(u, 'viewer', doc, p);
1098
+ await state.manager.relate(v, 'viewer', doc, p);
1099
+ assert.equal(await census(), 3);
1100
+ await rolledBack(async (transaction) => {
1101
+ await state.manager.purgeSubject(u, p, { transaction });
1102
+ seenInside = await can();
1103
+ });
1104
+ assert.equal(await census(), 3, 'ROJO: purgeSubject sobrevivió al rollback');
1105
+ assert.isTrue(await can(), 'purgeSubject + rollback ⇒ sigue concediendo');
1106
+ assert.isTrue(await state.manager.check(u, 'owner', other, p));
1107
+ assert.isTrue(seenInside, 'el purgeSubject sin confirmar no se ve desde la conexión del motor');
1108
+ await committed(async (transaction) => {
1109
+ await state.manager.purgeSubject(u, p, { transaction });
1110
+ });
1111
+ assert.equal(await census(), 1, 'purgeSubject + commit ⇒ solo queda la tupla de `v`');
1112
+ assert.isFalse(await can());
1113
+ assert.isFalse(await state.manager.check(u, 'owner', other, p));
1114
+ assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
1115
+ // alpha.3 · B3: la OTRA cara observable de la capacidad — el evento.
1116
+ // Cada escritura inscrita en `{ transaction }` (rollback o commit,
1117
+ // da igual: el paquete no ve el desenlace) publica `transactional:
1118
+ // true`; las que fueron SIN transacción, no (ausente, no `false`).
1119
+ const inTrx = state.events.filter((e) => e.transactional === true).map((e) => e.operation);
1120
+ assert.deepEqual(inTrx, ['relate', 'relate', 'unrelate', 'unrelate', 'purgeObject', 'purgeObject', 'purgeSubject', 'purgeSubject'], 'ROJO: el evento de una escritura inscrita en tu transacción no lleva transactional: true (las ocho: 2 por operación, rollback + commit)');
1121
+ const outside = state.events.filter((e) => e.transactional !== true);
1122
+ assert.isAbove(outside.length, 0, 'las escrituras sin transacción también notifican');
1123
+ for (const e of outside)
1124
+ assert.notProperty(e, 'transactional', `${e.operation} sin { transaction }: la marca está AUSENTE`);
1125
+ // Una transacción que NO es la del driver (`assertCallerTransaction`):
1126
+ // 500 `E_AUTHZ_CONFIG` y ni una sentencia por ella (espía).
1127
+ const statements = [];
1128
+ const spyTrx = (shape) => new Proxy(shape, {
1129
+ get(target, prop) {
1130
+ if (prop === 'from' || prop === 'table' || prop === 'raw' || prop === 'query' || prop === 'knexQuery' || prop === 'rawQuery' || prop === 'transaction') {
1131
+ return (...args) => {
1132
+ statements.push(`${String(prop)}(${String(args[0] ?? '')})`);
1133
+ return { where: () => ({}), select: async () => [], insert: async () => [], delete: async () => 0 };
1134
+ };
1135
+ }
1136
+ return Reflect.get(target, prop);
1137
+ },
1138
+ });
1139
+ const foreign = spyTrx({ isTransaction: true, connectionName: `not-${harness.name}-${uuidv7()}` });
1140
+ const notATransaction = spyTrx({ isTransaction: false, connectionName: 'primary' });
1141
+ const wholeDb = spyTrx({ connection() { }, primaryConnectionName: 'primary' });
1142
+ const w = holder();
1143
+ const writesBefore = state.writes();
1144
+ for (const [label, transaction] of [
1145
+ ['otra conexión', foreign],
1146
+ ['un cliente sin transacción', notATransaction],
1147
+ ['el db entero', wholeDb],
1148
+ ]) {
1149
+ for (const [operation, run] of [
1150
+ ['relate', () => state.manager.relate(w, 'viewer', doc, p, { transaction })],
1151
+ ['unrelate', () => state.manager.unrelate(w, 'viewer', doc, p, { transaction })],
1152
+ ['purgeObject', () => state.manager.purgeObject(doc, p, { transaction })],
1153
+ ['purgeSubject', () => state.manager.purgeSubject(w, p, { transaction })],
1154
+ ]) {
1155
+ let caught;
1156
+ try {
1157
+ await run();
1158
+ assert.fail(`${label}/${operation}: debería haber lanzado`);
1159
+ }
1160
+ catch (error) {
1161
+ caught = error;
1162
+ }
1163
+ assert.equal(caught.status, 500, `${label}/${operation}: ${caught.message}`);
1164
+ assert.equal(caught.code, 'E_AUTHZ_CONFIG', `${label}/${operation}`);
1165
+ assert.include(caught.message, operation, `${label}/${operation}: nombra la operación`);
1166
+ }
1167
+ }
1168
+ assert.deepEqual(statements, [], 'ni una sentencia por una transacción que no es la del driver');
1169
+ assert.isAbove(state.writes(), writesBefore, 'la trx ajena la juzga el DRIVER (assertCallerTransaction), no la puerta 1');
1170
+ assert.equal(await census(), 1, 'la trx ajena no escribió nada');
1171
+ assert.isFalse(await state.manager.check(w, 'viewer', doc, p));
1172
+ // Y la trx ajena (500 del driver) tampoco notifica: no hubo escritura.
1173
+ assert.equal(state.events.length, inTrx.length + outside.length, 'una escritura rechazada por assertCallerTransaction no notifica');
1174
+ })?.timeout(30_000),
1175
+ whenFalse: () => test('transactionalWrites:false · { transaction } en relate/unrelate/purgeObject/purgeSubject ⇒ 500 E_AUTHZ_UNSUPPORTED nombrando driver y operación, con CERO llamadas al driver (espía) y CERO eventos; y requireTransactionalWrites: true ⇒ 500 E_AUTHZ_CONFIG al construir el manager (= al resolver el driver)', async ({ assert, }) => {
1176
+ assert.notEqual(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: false y el driver declara true: declara lo observable');
1177
+ const trx = { from() { }, table() { }, isTransaction: true, connectionName: 'primary' };
1178
+ const u = holder();
1179
+ const doc = { type: 'document', id: docId() };
1180
+ const p = partition();
1181
+ const writes = [
1182
+ ['relate', () => state.manager.relate(u, 'viewer', doc, p, { transaction: trx })],
1183
+ ['unrelate', () => state.manager.unrelate(u, 'viewer', doc, p, { transaction: trx })],
1184
+ ['purgeObject', () => state.manager.purgeObject(doc, p, { transaction: trx })],
1185
+ ['purgeSubject', () => state.manager.purgeSubject(u, p, { transaction: trx })],
1186
+ ];
1187
+ for (const [operation, run] of writes) {
1188
+ let caught;
1189
+ try {
1190
+ await run();
1191
+ assert.fail(`${operation}: debería haber lanzado`);
1192
+ }
1193
+ catch (error) {
1194
+ caught = error;
1195
+ }
1196
+ assert.equal(caught.status, 500, `${operation}: ${caught.message}`);
1197
+ assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED', operation);
1198
+ assert.include(caught.message, `'${harness.name}'`, `${operation}: nombra el driver`);
1199
+ assert.include(caught.message, operation, `${operation}: nombra la operación`);
1200
+ assert.include(caught.message, 'requireTransactionalWrites', `${operation}: la letra lleva la salida`);
1201
+ }
1202
+ assert.equal(state.writes(), 0, 'cero llamadas al driver: el rechazo va ANTES del backend');
1203
+ assert.deepEqual(state.events, [], 'alpha.3 · B3: una escritura que no ocurre no notifica (cero eventos)');
1204
+ // Sin `{ transaction }` la MISMA escritura entra, y notifica SIN la marca.
1205
+ await state.manager.relate(u, 'viewer', doc, p);
1206
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
1207
+ assert.equal(state.writes(), 1);
1208
+ assert.lengthOf(state.events, 1);
1209
+ assert.notProperty(state.events[0], 'transactional', 'con transactionalWrites: false la marca NUNCA aparece');
1210
+ // Puerta 2, opt-in: al construir el manager (= resolver el driver).
1211
+ let atResolve;
1212
+ try {
1213
+ new RelationsManager(state.base, contractRelationsConfig(), {
1214
+ requireTransactionalWrites: true,
1215
+ driverName: harness.name,
1216
+ });
1217
+ assert.fail('requireTransactionalWrites: true sobre un driver que declara false tenía que lanzar al construir');
1218
+ }
1219
+ catch (error) {
1220
+ atResolve = error;
1221
+ }
1222
+ assert.equal(atResolve.status, 500, String(atResolve?.message));
1223
+ assert.equal(atResolve.code, 'E_AUTHZ_CONFIG');
1224
+ assert.include(atResolve.message, `'${harness.name}'`);
1225
+ assert.include(atResolve.message, 'transactionalWrites');
1226
+ }),
1227
+ });
644
1228
  // Al cerrar: toda capacidad declarada tiene que haber registrado su cara.
645
1229
  group.teardown(async () => {
646
1230
  const uncovered = Object.keys(harness.capabilities).filter((c) => !covered.has(c));