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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +248 -18
  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 +12 -1
  7. package/build/index.d.ts.map +1 -1
  8. package/build/index.js +10 -0
  9. package/build/index.js.map +1 -1
  10. package/build/providers/authz_provider.d.ts.map +1 -1
  11. package/build/providers/authz_provider.js +7 -0
  12. package/build/providers/authz_provider.js.map +1 -1
  13. package/build/src/define_config.d.ts +36 -0
  14. package/build/src/define_config.d.ts.map +1 -1
  15. package/build/src/define_config.js.map +1 -1
  16. package/build/src/drivers/database_driver.d.ts +53 -14
  17. package/build/src/drivers/database_driver.d.ts.map +1 -1
  18. package/build/src/drivers/database_driver.js +104 -32
  19. package/build/src/drivers/database_driver.js.map +1 -1
  20. package/build/src/drivers/database_relations_driver.d.ts +47 -9
  21. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  22. package/build/src/drivers/database_relations_driver.js +318 -89
  23. package/build/src/drivers/database_relations_driver.js.map +1 -1
  24. package/build/src/drivers/openfga_driver.d.ts +17 -4
  25. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  26. package/build/src/drivers/openfga_driver.js +38 -3
  27. package/build/src/drivers/openfga_driver.js.map +1 -1
  28. package/build/src/drivers/openfga_facts.d.ts +15 -0
  29. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  30. package/build/src/drivers/openfga_facts.js +25 -2
  31. package/build/src/drivers/openfga_facts.js.map +1 -1
  32. package/build/src/drivers/openfga_relations_driver.d.ts +27 -6
  33. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  34. package/build/src/drivers/openfga_relations_driver.js +140 -16
  35. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  36. package/build/src/errors.d.ts +35 -3
  37. package/build/src/errors.d.ts.map +1 -1
  38. package/build/src/errors.js +61 -3
  39. package/build/src/errors.js.map +1 -1
  40. package/build/src/freeze.d.ts +37 -16
  41. package/build/src/freeze.d.ts.map +1 -1
  42. package/build/src/freeze.js +53 -8
  43. package/build/src/freeze.js.map +1 -1
  44. package/build/src/manager.d.ts +16 -1
  45. package/build/src/manager.d.ts.map +1 -1
  46. package/build/src/manager.js +116 -25
  47. package/build/src/manager.js.map +1 -1
  48. package/build/src/models/authz_assignment.d.ts +2 -2
  49. package/build/src/models/authz_assignment.d.ts.map +1 -1
  50. package/build/src/models/authz_deny.d.ts +2 -2
  51. package/build/src/models/authz_deny.d.ts.map +1 -1
  52. package/build/src/models/authz_permission.d.ts +2 -2
  53. package/build/src/models/authz_permission.d.ts.map +1 -1
  54. package/build/src/models/authz_role.d.ts +2 -2
  55. package/build/src/models/authz_role.d.ts.map +1 -1
  56. package/build/src/models/authz_role_permission.d.ts +2 -2
  57. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  58. package/build/src/relation_partition_trigger.d.ts.map +1 -1
  59. package/build/src/relation_partition_trigger.js +11 -3
  60. package/build/src/relation_partition_trigger.js.map +1 -1
  61. package/build/src/relations/define_relations_config.d.ts +17 -0
  62. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  63. package/build/src/relations/define_relations_config.js +30 -1
  64. package/build/src/relations/define_relations_config.js.map +1 -1
  65. package/build/src/relations/manager.d.ts +25 -3
  66. package/build/src/relations/manager.d.ts.map +1 -1
  67. package/build/src/relations/manager.js +76 -19
  68. package/build/src/relations/manager.js.map +1 -1
  69. package/build/src/relations/reconcile.d.ts +53 -4
  70. package/build/src/relations/reconcile.d.ts.map +1 -1
  71. package/build/src/relations/reconcile.js +92 -30
  72. package/build/src/relations/reconcile.js.map +1 -1
  73. package/build/src/scope_outbox.d.ts.map +1 -1
  74. package/build/src/scope_outbox.js +17 -10
  75. package/build/src/scope_outbox.js.map +1 -1
  76. package/build/src/shared/backend_guard.d.ts +28 -0
  77. package/build/src/shared/backend_guard.d.ts.map +1 -1
  78. package/build/src/shared/backend_guard.js +52 -0
  79. package/build/src/shared/backend_guard.js.map +1 -1
  80. package/build/src/shared/transaction_guard.d.ts +50 -0
  81. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  82. package/build/src/shared/transaction_guard.js +60 -0
  83. package/build/src/shared/transaction_guard.js.map +1 -0
  84. package/build/src/testing/contract.d.ts +55 -4
  85. package/build/src/testing/contract.d.ts.map +1 -1
  86. package/build/src/testing/contract.js +240 -8
  87. package/build/src/testing/contract.js.map +1 -1
  88. package/build/src/testing/main.d.ts +4 -4
  89. package/build/src/testing/main.d.ts.map +1 -1
  90. package/build/src/testing/main.js +2 -2
  91. package/build/src/testing/main.js.map +1 -1
  92. package/build/src/testing/relations_contract.d.ts +23 -1
  93. package/build/src/testing/relations_contract.d.ts.map +1 -1
  94. package/build/src/testing/relations_contract.js +432 -17
  95. package/build/src/testing/relations_contract.js.map +1 -1
  96. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  97. package/build/src/testing/relations_reconcile_contract.js +49 -1
  98. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  99. package/build/src/traits/has_uuid.d.ts +2 -2
  100. package/build/src/traits/has_uuid.d.ts.map +1 -1
  101. package/build/src/types.d.ts +197 -10
  102. package/build/src/types.d.ts.map +1 -1
  103. package/build/src/types.js.map +1 -1
  104. package/build/stubs/migration.stub +20 -10
  105. 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() };
@@ -273,7 +329,8 @@ function spy(driver) {
273
329
  let writes = 0;
274
330
  const wrapped = new Proxy(driver, {
275
331
  get(target, prop, receiver) {
276
- if (prop === 'relate' || prop === 'unrelate') {
332
+ // L-2: las CUATRO escrituras (también `purge*`, que reciben `{ transaction }`).
333
+ if (prop === 'relate' || prop === 'unrelate' || prop === 'purgeObject' || prop === 'purgeSubject') {
277
334
  return async (...args) => {
278
335
  writes++;
279
336
  return target[prop](...args);
@@ -316,7 +373,13 @@ export function registerRelationsDriverContract(harness, api) {
316
373
  async function freshManager(options) {
317
374
  const base = await harness.makeDriver(contractRelationsConfig());
318
375
  const s = spy(base);
319
- return { manager: new RelationsManager(s.driver, contractRelationsConfig(), options), writes: s.writes, base };
376
+ // L-2: el manager nombra al driver en las dos puertas de `{ transaction }`
377
+ // como lo haría el provider (la clave en `relations.drivers`): aquí, el harness.
378
+ return {
379
+ manager: new RelationsManager(s.driver, contractRelationsConfig(), { driverName: harness.name, ...options }),
380
+ writes: s.writes,
381
+ base,
382
+ };
320
383
  }
321
384
  api.group(`relations contract [${harness.name}]`, (group) => {
322
385
  group.each.setup(async () => {
@@ -327,7 +390,7 @@ export function registerRelationsDriverContract(harness, api) {
327
390
  });
328
391
  const test = (title, fn) => {
329
392
  registered++;
330
- api.test(title, fn);
393
+ return api.test(title, fn);
331
394
  };
332
395
  function caseFor(capability, pair) {
333
396
  // MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
@@ -503,6 +566,38 @@ export function registerRelationsDriverContract(harness, api) {
503
566
  assert.deepEqual(events[0].actor, actor);
504
567
  assert.equal(events[0].operation, 'relate');
505
568
  });
569
+ /* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
570
+ test('R-15 · relate con expiresAt pasado NO concede (check/listObjects/listSubjects); futuro sí; expiresAt inválido ⇒ 422 sin tocar el driver', async ({ assert, }) => {
571
+ const u = holder();
572
+ const doc = { type: 'document', id: docId() };
573
+ const p = partition();
574
+ // Caducada al escribirse: no concede y no se enumera (caducidad
575
+ // estricta; sin reloj inyectado, un instante PASADO ya lo observa).
576
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
577
+ assert.isFalse(await state.manager.check(u, 'viewer', doc, p), 'caducada ⇒ check false');
578
+ const objects = await state.manager.listObjects(u, 'viewer', 'document', p);
579
+ assert.isFalse(objects.objects.some((o) => o.id === doc.id), 'caducada ⇒ listObjects no la lista');
580
+ const subjects = await state.manager.listSubjects('viewer', doc, p);
581
+ assert.isFalse(subjects.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid), 'caducada ⇒ listSubjects no la lista');
582
+ // Con caducidad futura concede (y `Date` sobre la caducada la FIJA: tercer estado).
583
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() + 3_600_000) });
584
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p), 'futura ⇒ concede');
585
+ // Un `expiresAt` que no es Date válida/null/omitido es 422 ANTES del driver.
586
+ const writes = state.writes();
587
+ for (const bad of ['mañana', 12345, new Date('no-es-fecha')]) {
588
+ let caught;
589
+ try {
590
+ await state.manager.relate(u, 'viewer', doc, p, { expiresAt: bad });
591
+ assert.fail(`debería haber lanzado con ${String(bad)}`);
592
+ }
593
+ catch (error) {
594
+ caught = error;
595
+ }
596
+ assert.equal(caught.status, 422);
597
+ assert.equal(caught.code, 'E_AUTHZ_INVALID_IDENTITY');
598
+ }
599
+ assert.equal(state.writes(), writes, 'el driver no se tocó con un expiresAt inválido');
600
+ });
506
601
  /* ── Capacidades, cada una con sus DOS caras ── */
507
602
  caseFor('singleCheckRelations', {
508
603
  // whenTrue: la derivación (includes + userset anidado) resuelve — la
@@ -641,6 +736,326 @@ export function registerRelationsDriverContract(harness, api) {
641
736
  assert.isUndefined(page.truncated);
642
737
  }),
643
738
  });
739
+ /* ── R-15 · el par `injectableClock` (paridad con roles, 2.5 · J1) ── */
740
+ caseFor('injectableClock', {
741
+ whenTrue: () => test('injectableClock:true · expiresAt en tres estados con el reloj inyectado, y la caducidad EXACTA (T−1 ms concede, T no, T+1 no) en check/listObjects/listSubjects y a través de un userset; renovar y revivir sin dormir', async ({ assert, }) => {
742
+ assert.typeOf(state.base.withClock, 'function', 'injectableClock: true exige withClock en el puerto');
743
+ // `T` lleva milisegundos a propósito (2.5 · J3): un motor que trunque
744
+ // a segundos falla aquí (DATETIME(3) en MySQL).
745
+ const T = new Date('2030-06-15T12:34:56.789Z');
746
+ const clock = fixedClock(new Date(T.getTime() - 60_000));
747
+ const clocked = new RelationsManager(state.base.withClock(clock.now), contractRelationsConfig());
748
+ const u = holder();
749
+ const doc = { type: 'document', id: docId() };
750
+ const viaGroup = { type: 'document', id: docId() };
751
+ const g = { type: 'group', id: docId() };
752
+ const p = partition();
753
+ await clocked.relate(u, 'viewer', doc, p, { expiresAt: T });
754
+ // La MEMBRESÍA caduca: g#member es editor de `viaGroup` sin plazo, pero
755
+ // u es member de g solo hasta T ⇒ en T deja de ser viewer de viaGroup.
756
+ await clocked.relate(u, 'member', g, p, { expiresAt: T });
757
+ await clocked.relate({ object: g, relation: 'member' }, 'editor', viaGroup, p);
758
+ const observe = async () => ({
759
+ check: await clocked.check(u, 'viewer', doc, p),
760
+ listObjects: (await clocked.listObjects(u, 'viewer', 'document', p)).objects.some((o) => o.id === doc.id),
761
+ listSubjects: (await clocked.listSubjects('viewer', doc, p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
762
+ viaGroup: await clocked.check(u, 'viewer', viaGroup, p),
763
+ ...(harness.capabilities.membersOfNative
764
+ ? {
765
+ membersOf: (await clocked.membersOf(g, 'member', p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
766
+ }
767
+ : {}),
768
+ });
769
+ const alive = {
770
+ check: true,
771
+ listObjects: true,
772
+ listSubjects: true,
773
+ viaGroup: true,
774
+ ...(harness.capabilities.membersOfNative ? { membersOf: true } : {}),
775
+ };
776
+ const gone = {
777
+ check: false,
778
+ listObjects: false,
779
+ listSubjects: false,
780
+ viaGroup: false,
781
+ ...(harness.capabilities.membersOfNative ? { membersOf: false } : {}),
782
+ };
783
+ clock.set(new Date(T.getTime() - 1));
784
+ assert.deepEqual(await observe(), alive, 'T−1 ms');
785
+ clock.set(T);
786
+ assert.deepEqual(await observe(), gone, 'T: la que vence ahora ya no cuenta (estricta)');
787
+ clock.set(new Date(T.getTime() + 1));
788
+ assert.deepEqual(await observe(), gone, 'T+1 ms');
789
+ // Renovación: `Date` posterior vuelve a conceder hasta ESE instante.
790
+ const T2 = new Date(T.getTime() + 1_000);
791
+ await clocked.relate(u, 'viewer', doc, p, { expiresAt: T2 });
792
+ await clocked.relate(u, 'member', g, p, { expiresAt: T2 });
793
+ assert.deepEqual(await observe(), alive, 'renovada en T+1 ms');
794
+ clock.set(new Date(T2.getTime() - 1));
795
+ assert.deepEqual(await observe(), alive, 'T2−1 ms');
796
+ clock.set(T2);
797
+ assert.deepEqual(await observe(), gone, 'T2');
798
+ // Los tres estados (invariante 10), con el reloj en T2 (u ya caducado):
799
+ // omitido sobre una CADUCADA revive sin caducidad;
800
+ await clocked.relate(u, 'viewer', doc, p);
801
+ await clocked.relate(u, 'member', g, p);
802
+ clock.set(new Date('2099-12-31T23:59:59.999Z'));
803
+ assert.deepEqual(await observe(), alive, 'revivida sin caducidad: vigente en 2099');
804
+ // omitido sobre una VIGENTE la preserva (no la convierte en permanente);
805
+ const keep = holder();
806
+ const soon = new Date(clock.now().getTime() + 1_500);
807
+ await clocked.relate(keep, 'viewer', doc, p, { expiresAt: soon });
808
+ await clocked.relate(keep, 'viewer', doc, p);
809
+ // null la quita.
810
+ const lift = holder();
811
+ await clocked.relate(lift, 'viewer', doc, p, { expiresAt: soon });
812
+ await clocked.relate(lift, 'viewer', doc, p, { expiresAt: null });
813
+ clock.set(new Date(soon.getTime() - 1));
814
+ assert.isTrue(await clocked.check(keep, 'viewer', doc, p), 'preservada: vigente en soon−1');
815
+ clock.set(soon);
816
+ assert.isFalse(await clocked.check(keep, 'viewer', doc, p), 'preservada: vence en soon (omitido NO la hizo permanente)');
817
+ assert.isTrue(await clocked.check(lift, 'viewer', doc, p), 'null la quitó: sigue vigente');
818
+ // El driver del harness (sin reloj inyectado) no se ha tocado: para él
819
+ // (hoy) T2 —2030— es futuro y la relación renovada concede.
820
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
821
+ }),
822
+ whenFalse: () => test('injectableClock:false · expiresAt en tres estados en tiempo real (1,5 s): omitido preserva, null quita, caducada revive; sin withClock', async ({ assert, }) => {
823
+ assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');
824
+ const doc = { type: 'document', id: docId() };
825
+ const p = partition();
826
+ const keep = holder();
827
+ const lift = holder();
828
+ const revive = holder();
829
+ const soon = new Date(Date.now() + 1_500);
830
+ await state.manager.relate(keep, 'viewer', doc, p, { expiresAt: soon });
831
+ await state.manager.relate(keep, 'viewer', doc, p); // omitido: preserva `soon`
832
+ await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: soon });
833
+ await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: null }); // la quita
834
+ await state.manager.relate(revive, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
835
+ assert.isFalse(await state.manager.check(revive, 'viewer', doc, p), 'caducada no concede');
836
+ await state.manager.relate(revive, 'viewer', doc, p); // omitido sobre caducada: revive sin caducidad
837
+ assert.isTrue(await state.manager.check(keep, 'viewer', doc, p));
838
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
839
+ assert.isTrue(await state.manager.check(revive, 'viewer', doc, p));
840
+ await sleep(1_700);
841
+ assert.isFalse(await state.manager.check(keep, 'viewer', doc, p), 'preservada: venció');
842
+ assert.isTrue(await state.manager.check(lift, 'viewer', doc, p), 'sin caducidad: sigue');
843
+ assert.isTrue(await state.manager.check(revive, 'viewer', doc, p), 'revivida sin caducidad: sigue');
844
+ }),
845
+ });
846
+ /* ── L-2 · el par `transactionalWrites` (panel `{trx}`, (C); mismo nombre que en roles) ── */
847
+ caseFor('transactionalWrites', {
848
+ // L-4: la cara `true`. Lo que se juzga es «los dos o ninguno con TU
849
+ // transacción» POR CENSO (paridad con la cara `true` de roles, L-3): el
850
+ // rollback deja CERO tuplas nuevas en la partición (no solo `check`
851
+ // false — una respuesta puede salir bien con la fila viva), el commit
852
+ // las deja escritas, mientras la transacción está abierta la autoridad
853
+ // (que lee por la conexión del motor) no ve nada, y `purgeObject`/
854
+ // `purgeSubject` borran y REVIERTEN juntos (tras el rollback todo lo
855
+ // purgado está de vuelta). Y la otra mitad: una transacción que NO es
856
+ // la del driver no recibe ni una sentencia. Necesita un motor con
857
+ // transacciones reales y pool ≥ 2: el harness `database` lo declara
858
+ // `true` en `sqlite-file`/PG/MySQL y `false` en `:memory:`.
859
+ whenTrue: () => test('transactionalWrites:true · relate/unrelate/purgeObject/purgeSubject con { transaction } + rollback ⇒ check sin cambio Y CERO tuplas nuevas (censo); + commit ⇒ aplicado; purge* borran y revierten JUNTOS; una transacción ajena, un cliente sin transacción o el `db` entero ⇒ 500 E_AUTHZ_CONFIG sin UNA sentencia por ella', async ({ assert, }) => {
860
+ assert.equal(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: true y el driver no: declara lo observable');
861
+ const tx = harness.transactions ?? lucidRelationsContractTransactions();
862
+ const u = holder();
863
+ const v = holder();
864
+ const doc = { type: 'document', id: docId() };
865
+ const other = { type: 'document', id: docId() };
866
+ const p = partition();
867
+ const can = () => state.manager.check(u, 'viewer', doc, p);
868
+ const census = () => tx.census(p);
869
+ // Una transacción que se queda abierta tras una aserción fallida
870
+ // retiene una conexión del pool y arrastra a los casos siguientes:
871
+ // el rollback va en `finally`, y el commit solo si nada falló.
872
+ const rolledBack = async (fn) => {
873
+ const t = await tx.begin();
874
+ try {
875
+ await fn(t.client);
876
+ }
877
+ finally {
878
+ await t.rollback();
879
+ }
880
+ };
881
+ const committed = async (fn) => {
882
+ const t = await tx.begin();
883
+ try {
884
+ await fn(t.client);
885
+ }
886
+ catch (error) {
887
+ await t.rollback();
888
+ throw error;
889
+ }
890
+ await t.commit();
891
+ };
892
+ // relate: dentro no se ve desde fuera; rollback ⇒ nada; commit ⇒ concede.
893
+ let seenInside;
894
+ await rolledBack(async (transaction) => {
895
+ await state.manager.relate(u, 'viewer', doc, p, { transaction });
896
+ seenInside = await can();
897
+ });
898
+ assert.equal(await census(), 0, 'ROJO: la relación sobrevivió al rollback (la escritura NO fue por la transacción del llamante)');
899
+ assert.isFalse(await can(), 'relate + rollback ⇒ no concede');
900
+ assert.isFalse(seenInside, 'la autoridad lee por la conexión del motor: el relate sin confirmar no concede');
901
+ await committed(async (transaction) => {
902
+ await state.manager.relate(u, 'viewer', doc, p, { transaction });
903
+ });
904
+ assert.isTrue(await can(), 'relate + commit ⇒ concede');
905
+ assert.equal(await census(), 1);
906
+ // unrelate: rollback ⇒ la tupla sigue; commit ⇒ se va.
907
+ await rolledBack(async (transaction) => {
908
+ await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
909
+ seenInside = await can();
910
+ });
911
+ assert.equal(await census(), 1, 'ROJO: el unrelate sobrevivió al rollback');
912
+ assert.isTrue(await can(), 'unrelate + rollback ⇒ sigue concediendo');
913
+ assert.isTrue(seenInside, 'el unrelate sin confirmar no se ve desde la conexión del motor');
914
+ await committed(async (transaction) => {
915
+ await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
916
+ });
917
+ assert.isFalse(await can(), 'unrelate + commit ⇒ no concede');
918
+ assert.equal(await census(), 0);
919
+ // purgeObject: dos sujetos sobre `doc` + uno sobre `other`; rollback ⇒
920
+ // los tres siguen (borran y revierten JUNTOS); commit ⇒ quedan solo
921
+ // los de `other`.
922
+ await state.manager.relate(u, 'viewer', doc, p);
923
+ await state.manager.relate(v, 'editor', doc, p);
924
+ await state.manager.relate(u, 'owner', other, p);
925
+ assert.equal(await census(), 3);
926
+ await rolledBack(async (transaction) => {
927
+ await state.manager.purgeObject(doc, p, { transaction });
928
+ seenInside = await can();
929
+ });
930
+ assert.equal(await census(), 3, 'ROJO: purgeObject sobrevivió al rollback (algo de lo purgado no volvió)');
931
+ assert.isTrue(await can(), 'purgeObject + rollback ⇒ sigue concediendo');
932
+ assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
933
+ assert.isTrue(seenInside, 'el purgeObject sin confirmar no se ve desde la conexión del motor');
934
+ await committed(async (transaction) => {
935
+ await state.manager.purgeObject(doc, p, { transaction });
936
+ });
937
+ assert.equal(await census(), 1, 'purgeObject + commit ⇒ solo queda la tupla de `other`');
938
+ assert.isFalse(await can());
939
+ assert.isFalse(await state.manager.check(v, 'viewer', doc, p));
940
+ assert.isTrue(await state.manager.check(u, 'owner', other, p));
941
+ // purgeSubject: `u` es owner de `other` y viewer de `doc` de nuevo;
942
+ // rollback ⇒ las dos siguen; commit ⇒ las dos se van y `v` no se toca.
943
+ await state.manager.relate(u, 'viewer', doc, p);
944
+ await state.manager.relate(v, 'viewer', doc, p);
945
+ assert.equal(await census(), 3);
946
+ await rolledBack(async (transaction) => {
947
+ await state.manager.purgeSubject(u, p, { transaction });
948
+ seenInside = await can();
949
+ });
950
+ assert.equal(await census(), 3, 'ROJO: purgeSubject sobrevivió al rollback');
951
+ assert.isTrue(await can(), 'purgeSubject + rollback ⇒ sigue concediendo');
952
+ assert.isTrue(await state.manager.check(u, 'owner', other, p));
953
+ assert.isTrue(seenInside, 'el purgeSubject sin confirmar no se ve desde la conexión del motor');
954
+ await committed(async (transaction) => {
955
+ await state.manager.purgeSubject(u, p, { transaction });
956
+ });
957
+ assert.equal(await census(), 1, 'purgeSubject + commit ⇒ solo queda la tupla de `v`');
958
+ assert.isFalse(await can());
959
+ assert.isFalse(await state.manager.check(u, 'owner', other, p));
960
+ assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
961
+ // Una transacción que NO es la del driver (`assertCallerTransaction`):
962
+ // 500 `E_AUTHZ_CONFIG` y ni una sentencia por ella (espía).
963
+ const statements = [];
964
+ const spyTrx = (shape) => new Proxy(shape, {
965
+ get(target, prop) {
966
+ if (prop === 'from' || prop === 'table' || prop === 'raw' || prop === 'query' || prop === 'knexQuery' || prop === 'rawQuery' || prop === 'transaction') {
967
+ return (...args) => {
968
+ statements.push(`${String(prop)}(${String(args[0] ?? '')})`);
969
+ return { where: () => ({}), select: async () => [], insert: async () => [], delete: async () => 0 };
970
+ };
971
+ }
972
+ return Reflect.get(target, prop);
973
+ },
974
+ });
975
+ const foreign = spyTrx({ isTransaction: true, connectionName: `not-${harness.name}-${uuidv7()}` });
976
+ const notATransaction = spyTrx({ isTransaction: false, connectionName: 'primary' });
977
+ const wholeDb = spyTrx({ connection() { }, primaryConnectionName: 'primary' });
978
+ const w = holder();
979
+ const writesBefore = state.writes();
980
+ for (const [label, transaction] of [
981
+ ['otra conexión', foreign],
982
+ ['un cliente sin transacción', notATransaction],
983
+ ['el db entero', wholeDb],
984
+ ]) {
985
+ for (const [operation, run] of [
986
+ ['relate', () => state.manager.relate(w, 'viewer', doc, p, { transaction })],
987
+ ['unrelate', () => state.manager.unrelate(w, 'viewer', doc, p, { transaction })],
988
+ ['purgeObject', () => state.manager.purgeObject(doc, p, { transaction })],
989
+ ['purgeSubject', () => state.manager.purgeSubject(w, p, { transaction })],
990
+ ]) {
991
+ let caught;
992
+ try {
993
+ await run();
994
+ assert.fail(`${label}/${operation}: debería haber lanzado`);
995
+ }
996
+ catch (error) {
997
+ caught = error;
998
+ }
999
+ assert.equal(caught.status, 500, `${label}/${operation}: ${caught.message}`);
1000
+ assert.equal(caught.code, 'E_AUTHZ_CONFIG', `${label}/${operation}`);
1001
+ assert.include(caught.message, operation, `${label}/${operation}: nombra la operación`);
1002
+ }
1003
+ }
1004
+ assert.deepEqual(statements, [], 'ni una sentencia por una transacción que no es la del driver');
1005
+ assert.isAbove(state.writes(), writesBefore, 'la trx ajena la juzga el DRIVER (assertCallerTransaction), no la puerta 1');
1006
+ assert.equal(await census(), 1, 'la trx ajena no escribió nada');
1007
+ assert.isFalse(await state.manager.check(w, 'viewer', doc, p));
1008
+ })?.timeout(30_000),
1009
+ whenFalse: () => test('transactionalWrites:false · { transaction } en relate/unrelate/purgeObject/purgeSubject ⇒ 500 E_AUTHZ_UNSUPPORTED nombrando driver y operación, con CERO llamadas al driver (espía); y requireTransactionalWrites: true ⇒ 500 E_AUTHZ_CONFIG al construir el manager (= al resolver el driver)', async ({ assert, }) => {
1010
+ assert.notEqual(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: false y el driver declara true: declara lo observable');
1011
+ const trx = { from() { }, table() { }, isTransaction: true, connectionName: 'primary' };
1012
+ const u = holder();
1013
+ const doc = { type: 'document', id: docId() };
1014
+ const p = partition();
1015
+ const writes = [
1016
+ ['relate', () => state.manager.relate(u, 'viewer', doc, p, { transaction: trx })],
1017
+ ['unrelate', () => state.manager.unrelate(u, 'viewer', doc, p, { transaction: trx })],
1018
+ ['purgeObject', () => state.manager.purgeObject(doc, p, { transaction: trx })],
1019
+ ['purgeSubject', () => state.manager.purgeSubject(u, p, { transaction: trx })],
1020
+ ];
1021
+ for (const [operation, run] of writes) {
1022
+ let caught;
1023
+ try {
1024
+ await run();
1025
+ assert.fail(`${operation}: debería haber lanzado`);
1026
+ }
1027
+ catch (error) {
1028
+ caught = error;
1029
+ }
1030
+ assert.equal(caught.status, 500, `${operation}: ${caught.message}`);
1031
+ assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED', operation);
1032
+ assert.include(caught.message, `'${harness.name}'`, `${operation}: nombra el driver`);
1033
+ assert.include(caught.message, operation, `${operation}: nombra la operación`);
1034
+ assert.include(caught.message, 'requireTransactionalWrites', `${operation}: la letra lleva la salida`);
1035
+ }
1036
+ assert.equal(state.writes(), 0, 'cero llamadas al driver: el rechazo va ANTES del backend');
1037
+ // Sin `{ transaction }` la MISMA escritura entra.
1038
+ await state.manager.relate(u, 'viewer', doc, p);
1039
+ assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
1040
+ assert.equal(state.writes(), 1);
1041
+ // Puerta 2, opt-in: al construir el manager (= resolver el driver).
1042
+ let atResolve;
1043
+ try {
1044
+ new RelationsManager(state.base, contractRelationsConfig(), {
1045
+ requireTransactionalWrites: true,
1046
+ driverName: harness.name,
1047
+ });
1048
+ assert.fail('requireTransactionalWrites: true sobre un driver que declara false tenía que lanzar al construir');
1049
+ }
1050
+ catch (error) {
1051
+ atResolve = error;
1052
+ }
1053
+ assert.equal(atResolve.status, 500, String(atResolve?.message));
1054
+ assert.equal(atResolve.code, 'E_AUTHZ_CONFIG');
1055
+ assert.include(atResolve.message, `'${harness.name}'`);
1056
+ assert.include(atResolve.message, 'transactionalWrites');
1057
+ }),
1058
+ });
644
1059
  // Al cerrar: toda capacidad declarada tiene que haber registrado su cara.
645
1060
  group.teardown(async () => {
646
1061
  const uncovered = Object.keys(harness.capabilities).filter((c) => !covered.has(c));