@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
@@ -1,15 +1,16 @@
1
1
  var _a;
2
2
  import { Exception } from '@adonisjs/core/exceptions';
3
3
  import { v7 as uuidv7 } from 'uuid';
4
- import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, scopeSpellings, } from './identity.js';
4
+ import { activeRelationsDriverKey } from './define_config.js';
5
+ import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, } from './identity.js';
5
6
  import { expiryChanged } from './expiry.js';
6
7
  import { randomBytes } from 'node:crypto';
7
- import { DEFAULT_FREEZE_LEASE_MS, acquireFreeze, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
8
- import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
8
+ import { DEFAULT_FREEZE_LEASE_MS, acquireFreezeOutcome, UNKNOWN_HOLDER, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
9
+ import { assertKnownScope, canonicalScopeTargets, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
9
10
  import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
10
11
  import { assertAssignableAt } from './catalog/catalog.js';
11
12
  import { systemClock } from './clock.js';
12
- import { ActorRequiredError, AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, ScopeDepthLimitError, ScopeRequeueRefusedError, } from './errors.js';
13
+ import { ActorRequiredError, AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, ScopeDepthLimitError, ScopeRequeueRefusedError, StaleSourceRefusedError, OperatorWindowExpiredError, } from './errors.js';
13
14
  import { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
14
15
  import { memoizeAncestors } from './memoize_ancestors.js';
15
16
  import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
@@ -20,12 +21,26 @@ const ROLE_DESCRIPTION_MAX = 500;
20
21
  function describeAnswer(answer) {
21
22
  if (!Array.isArray(answer))
22
23
  return `${answer === null ? 'null' : typeof answer} (no es un array)`;
23
- const offending = answer.find((b) => typeof b !== 'boolean');
24
- if (offending !== undefined || answer.some((b) => typeof b !== 'boolean')) {
25
- return `un array de ${answer.length} con un elemento que no es boolean (${typeof offending})`;
24
+ for (let i = 0; i < answer.length; i++) {
25
+ if (!(i in answer))
26
+ return `un array de ${answer.length} con un HUECO en la posición ${i}`;
27
+ if (typeof answer[i] !== 'boolean')
28
+ return `un array de ${answer.length} con un elemento que no es boolean en la posición ${i} (${typeof answer[i]})`;
26
29
  }
27
30
  return `un array de ${answer.length}`;
28
31
  }
32
+ /**
33
+ * ¿Cada POSICIÓN del array es un boolean? Por índice, nunca con `some`/`every`
34
+ * (que saltan los huecos de un array disperso: 🟡 A3 del auditor del lote A;
35
+ * gemelo de `checkMany` en `src/relations/manager.ts`).
36
+ */
37
+ function everyPositionBoolean(answers) {
38
+ for (let i = 0; i < answers.length; i++) {
39
+ if (typeof answers[i] !== 'boolean')
40
+ return false;
41
+ }
42
+ return true;
43
+ }
29
44
  /**
30
45
  * Configs que ya recibieron el aviso de seguridad opt-in (B7/B1): una vez
31
46
  * por config, no por instancia — `forRequest()` construye managers hijos con
@@ -406,12 +421,30 @@ export class AuthorizationManager {
406
421
  const finalReason = reason ?? 'una operación de plataforma';
407
422
  const holder = `${kind}:${process.pid}:${randomBytes(4).toString('hex')}`;
408
423
  const nowMs = root.#wallMs();
409
- const token = await acquireFreeze({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
410
- if (token === null) {
411
- const row = await readFreezeRow({ driver: this.#config.default });
424
+ // alpha.10 · S2 (+ cierre del auditor · R2): una ventana de OPERADOR
425
+ // caducada no se pisa en silencio. Una fila con el lease vencido se toma
426
+ // encima (así la flota vuelve sola tras un SIGKILL de una pasada), pero la
427
+ // del operador es EVIDENCIA: pisarla y soltarla después la deja a NULL y
428
+ // borra el único rastro de que las escrituras volvieron a entrar a mitad
429
+ // del cutover. La exclusión vive DENTRO del UPDATE de `acquireFreezeOutcome`
430
+ // (atómica): aquí solo se lee el veredicto y la fila para redactarlo — un
431
+ // «compruebo y luego tomo» con dos lecturas del reloj era un TOCTOU.
432
+ const outcome = await acquireFreezeOutcome({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
433
+ if (outcome.status === 'expired-operator') {
434
+ const row = outcome.row;
435
+ const when = row.untilMs === null ? '?' : new Date(row.untilMs).toISOString();
436
+ throw new OperatorWindowExpiredError(`freeze (${reason ?? kind}): la ventana del OPERADOR (${row.holder ?? '?'}, ${row.reason}, fence ` +
437
+ `${row.fence}) CADUCÓ el ${when} y nadie la cerró: desde entonces las escrituras volvieron a entrar en ` +
438
+ `toda la flota. No se toma una ventana propia encima —borraría ese rastro al soltarla y la pasada se ` +
439
+ `certificaría como si hubiera corrido dentro del cutover—. Reconócela con authz:unfreeze (dice cuánto ` +
440
+ `tiempo estuvo abierta y limpia la fila) y reabre la ventana con authz:freeze antes de repetir.`);
441
+ }
442
+ if (outcome.status === 'held') {
443
+ const row = outcome.row;
412
444
  throw new FreezeHeldError(`freeze: ya hay un freeze VIVO de otro dueño (${row.holder ?? '?'}, fence ${row.fence}, motivo: ${row.reason ?? '?'}). ` +
413
445
  `Espera a que termine, o levántalo con authz:unfreeze si su proceso murió sin lease.`);
414
446
  }
447
+ const token = outcome.token;
415
448
  const held = { token, reason: finalReason, kind, leaseMs, timer: null, lapsed: false };
416
449
  if (leaseMs !== null) {
417
450
  const interval = Math.max(250, Math.floor(leaseMs / 3));
@@ -472,6 +505,39 @@ export class AuthorizationManager {
472
505
  get frozen() {
473
506
  return this.#root().#heldFreeze !== null;
474
507
  }
508
+ /**
509
+ * La clave (en `relations.drivers`) del driver de RELACIONES activo:
510
+ * `relations.default ?? default` (2.4.0-alpha.10). La lee el guard de la
511
+ * dirección rancia de `runRelationsReconcile`; desde el cierre del auditor
512
+ * (R4) el llamante ya no puede contradecirla con un `activeKey` propio.
513
+ */
514
+ relationsDriverKey() {
515
+ return activeRelationsDriverKey(this.#root().#config);
516
+ }
517
+ /** Las claves registradas en `relations.drivers` del config de ESTE manager (vacío si no hay relaciones). */
518
+ relationsDriverKeys() {
519
+ return Object.keys(this.#root().#config.relations?.drivers ?? {});
520
+ }
521
+ /**
522
+ * La factory registrada bajo `key` en `relations.drivers` de ESTE manager, o
523
+ * `undefined` (cierre del auditor de alpha.10 · R4). `runRelationsReconcile`
524
+ * resuelve el origen y el destino por aquí —del MISMO config que decide el
525
+ * activo— y juzga la identidad por la factory resuelta, no por la etiqueta:
526
+ * dos claves con la misma factory son el mismo driver (y dos factories DISTINTAS
527
+ * sobre el mismo backend NO: límite declarado, cierre-2 · C1).
528
+ *
529
+ * **API de PLATAFORMA, junto a `driver()`** (cierre-2 · C6): con esta factory
530
+ * cualquier código que tenga el manager —vistas de `forRequest()` incluidas—
531
+ * construye un driver de relaciones NO activo y escribe en él sin `assertWrite`,
532
+ * `requireActor`, freeze ni `onRelationWrite`. No añade capacidad (el config ya
533
+ * da las factories), pero es otra cosa que buscar en una revisión.
534
+ */
535
+ relationsDriverFactory(key) {
536
+ const drivers = this.#root().#config.relations?.drivers;
537
+ if (drivers === undefined || !Object.prototype.hasOwnProperty.call(drivers, key))
538
+ return undefined;
539
+ return drivers[key];
540
+ }
475
541
  /** El freeze VIVO de la fila compartida, o `null`. Lo lee cualquiera; solo el token lo levanta. */
476
542
  async freezeStatus() {
477
543
  const row = await readFreezeRow({ driver: this.#config.default });
@@ -479,12 +545,42 @@ export class AuthorizationManager {
479
545
  return null;
480
546
  return {
481
547
  reason: row.reason,
482
- holder: row.holder ?? '?',
548
+ holder: row.holder ?? UNKNOWN_HOLDER,
483
549
  kind: freezeKindOf(row.holder),
484
550
  untilMs: row.untilMs,
485
551
  fence: row.fence,
486
552
  };
487
553
  }
554
+ /**
555
+ * **La ÚLTIMA ventana de la fila, también la CADUCADA** (2.4.0-alpha.10, 🟠 2
556
+ * del sexto informe de COGNITIV). `freezeStatus()` responde solo lo VIVO —y
557
+ * así lo siguen usando la barrera y los comandos—, de modo que una ventana
558
+ * cuyo lease venció era indistinguible de «nunca hubo»: `authz:unfreeze`
559
+ * decía éxito y el último paso del cutover no delataba que las escrituras
560
+ * habían vuelto a entrar a mitad. Se distingue por la fila: `releaseFreeze`
561
+ * pone el motivo a `null` al levantar, así que motivo puesto + `untilMs` en
562
+ * el pasado es una ventana que CADUCÓ sin que nadie la cerrase.
563
+ *
564
+ * `null` = nunca hubo o ya se levantó; `expired: false` = viva (lo mismo que
565
+ * `freezeStatus()`); `expired: true` = caducada. `checkedAtMs` es el instante
566
+ * del reloj del config con el que se decidió.
567
+ */
568
+ async lastFreezeStatus() {
569
+ const row = await readFreezeRow({ driver: this.#config.default });
570
+ if (row.reason === null)
571
+ return null;
572
+ const nowMs = this.#root().#wallMs();
573
+ return {
574
+ reason: row.reason,
575
+ holder: row.holder ?? UNKNOWN_HOLDER,
576
+ kind: freezeKindOf(row.holder),
577
+ untilMs: row.untilMs,
578
+ fence: row.fence,
579
+ expired: !freezeIsLive(row, nowMs),
580
+ // El instante (reloj del config) con el que se decidió `expired`.
581
+ checkedAtMs: nowMs,
582
+ };
583
+ }
488
584
  /**
489
585
  * `freeze()` + `finally unfreeze(token)`. El `finally` es la parte que
490
586
  * importa: una migración que revienta a la mitad no puede dejar la
@@ -502,7 +598,13 @@ export class AuthorizationManager {
502
598
  operatorAsContext: options.operatorAsContext === true,
503
599
  });
504
600
  try {
505
- return await fn({ fence: context.fence, leaseMs: context.leaseMs, lapsed: context.lapsed });
601
+ return await fn({
602
+ fence: context.fence,
603
+ leaseMs: context.leaseMs,
604
+ lapsed: context.lapsed,
605
+ ...(context.untilMs === undefined ? {} : { untilMs: context.untilMs }),
606
+ ...(context.window === undefined ? {} : { window: context.window }),
607
+ });
506
608
  }
507
609
  finally {
508
610
  await context.release();
@@ -528,6 +630,7 @@ export class AuthorizationManager {
528
630
  return {
529
631
  fence: outer.token.fence,
530
632
  leaseMs: outer.leaseMs,
633
+ window: 'enclosing',
531
634
  release: async () => { },
532
635
  lapsed: () => root.#tokenLapsed(outer.token, outer.lapsed),
533
636
  };
@@ -536,9 +639,14 @@ export class AuthorizationManager {
536
639
  const status = await this.freezeStatus();
537
640
  if (status !== null && status.kind === 'operator') {
538
641
  const token = { fence: status.fence, holder: status.holder };
642
+ // alpha.10 · 3: la caducidad REAL de la ventana adoptada. Hasta alpha.9
643
+ // `leaseMs: null` fijo — el reporte decía «sin caducidad» de una ventana
644
+ // abierta con `--lease-ms` (la certificación sí leía la fila).
539
645
  return {
540
646
  fence: status.fence,
541
- leaseMs: null,
647
+ leaseMs: status.untilMs === null ? null : Math.max(0, status.untilMs - root.#wallMs()),
648
+ untilMs: status.untilMs,
649
+ window: 'operator',
542
650
  release: async () => { },
543
651
  lapsed: () => root.#tokenLapsed(token, false),
544
652
  };
@@ -549,6 +657,7 @@ export class AuthorizationManager {
549
657
  return {
550
658
  fence: token.fence,
551
659
  leaseMs: held.leaseMs,
660
+ window: 'own',
552
661
  release: async () => {
553
662
  try {
554
663
  await this.unfreeze(token);
@@ -731,14 +840,19 @@ export class AuthorizationManager {
731
840
  //
732
841
  // Y cuando NO hay cadena —la fila ya no existe, que es el orden
733
842
  // soportado de `detached` (3F · S1)— no hay con qué canonizar: se
734
- // purgan TODAS las ortografías de las que el uuid del llamante puede
735
- // ser alias (`scopeSpellings`, 3b-2h · 🟠 3). Con la fila viva esto es
736
- // exactamente una, la de la tabla; sin ella, la del llamante y la
737
- // canónica que un motor pudo fundir con la suya. Antes se usaba la del
738
- // llamante a secas: `purgeScope` demostraba cero sobre un objeto que no
739
- // existe, devolvía OK, y el scope real seguía concediendo para siempre.
740
- const chain = await resolveChain(this.#freshResolver(), child, 'scopes.detached');
741
- const targets = chain ? [chain[0]] : scopeSpellings(child);
843
+ // purgan las ortografías de las que el uuid del llamante puede ser
844
+ // alias (`scopeSpellings`, 3b-2h · 🟠 3: lo que PG funde,
845
+ // `pgFusedSpellings`) MENOS las que el resolutor reconozca como un
846
+ // scope VIVO distinto del pedido (cierre-3 del lote A · 🟡 C2-2: con
847
+ // ids de texto, la canónica de un alias puede ser la unit viva de otro
848
+ // tenant, y `purgeScope` en `database` le borraría los denies). Con la
849
+ // fila viva esto es exactamente una, la de la tabla. El punto de paso
850
+ // es ÚNICO y compartido con `revoke`/`removeDeny` de los dos drivers
851
+ // (`canonicalScopeTargets`); un resolutor que falla al contrastar es
852
+ // 503, nunca «purgar por si acaso». Antes se usaba la del llamante a
853
+ // secas: `purgeScope` demostraba cero sobre un objeto que no existe,
854
+ // devolvía OK, y el scope real seguía concediendo para siempre.
855
+ const targets = await canonicalScopeTargets(this.#freshResolver(), child, 'scopes.detached');
742
856
  const outbox = this.#outbox();
743
857
  if (outbox) {
744
858
  // La identidad se resuelve AQUÍ, con la fila del consumidor todavía
@@ -857,12 +971,13 @@ export class AuthorizationManager {
857
971
  throw new AuthorizationConfigError(`authz:scopes:relay: deadDetail solo admite 'full' (llegó ${JSON.stringify(options.deadDetail)}); sin él, bajo only, dead[] va sin error.`);
858
972
  }
859
973
  const inputs = await _a.#relayInputs(outbox, options.only, batchSize, options.deadDetail);
860
- const { only, filter, dead, deadCount, deadEntries, missingOf } = inputs;
974
+ const { only, filter, dead, deadCount, deadEntries, unseenOf } = inputs;
861
975
  if (dryRun) {
862
976
  const batch = await outbox.pending(limit, undefined, filter);
863
977
  _a.#assertOnlyHonoured(batch, only);
864
978
  const extra = batch.length >= limit ? true : false;
865
979
  const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
980
+ const unseen = await unseenOf(seen);
866
981
  // La edad es GLOBAL: sin filtro la cabeza es `batch[0]`; con `only` hay que mirar la cola entera.
867
982
  const queue = filter === undefined ? this.#queueOf(batch[0]) : (await this.#queueStatus(outbox)).queue;
868
983
  return {
@@ -878,7 +993,7 @@ export class AuthorizationManager {
878
993
  dryRun: true,
879
994
  wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
880
995
  entries: deadEntries,
881
- missing: missingOf(seen),
996
+ ...unseen,
882
997
  };
883
998
  }
884
999
  // El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
@@ -919,8 +1034,14 @@ export class AuthorizationManager {
919
1034
  // llegar a `batchSize`). Una sola lectura de más, y solo si no cabía.
920
1035
  const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
921
1036
  const deadCount = census.length;
922
- /** Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`. */
923
- const deadEntries = read
1037
+ /**
1038
+ * Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`.
1039
+ * Cierre-2 · C2 (gemelo): con `only` sale del CENSO completo si la lectura se
1040
+ * cortó en `batchSize` — si no, un id pedido aparcado más allá del primer lote
1041
+ * era `missing` («no estaba ni pendiente ni aparcado») estando aparcado.
1042
+ */
1043
+ const deadSource = only !== undefined && read.length >= batchSize ? census : read;
1044
+ const deadEntries = deadSource
924
1045
  .filter((item) => only === undefined || only.has(String(item.id)))
925
1046
  .map((item) => ({
926
1047
  id: item.id,
@@ -929,8 +1050,29 @@ export class AuthorizationManager {
929
1050
  attempts: item.attempts,
930
1051
  ...(item.error === undefined ? {} : { reason: item.error }),
931
1052
  }));
932
- const missingOf = (seen) => only === undefined ? [] : [...only.values()].filter((id) => !seen.has(String(id))).map((id) => _a.#rawId(onlyIds, id));
933
- return { only, filter, dead, census, deadCount, deadEntries, missingOf };
1053
+ /**
1054
+ * **Qué fue de los ids pedidos que la pasada NO vio** (cierre-2 de alpha.10 ·
1055
+ * C2). Hasta el cierre-2 todos eran `missing`; pero un id fuera del lote porque
1056
+ * la pasada alcanzó `limit` sigue PENDIENTE. Se pregunta a la cola —filtrada por
1057
+ * esos ids, sin límite propio, UNA lectura y solo si hay alguno sin ver—: lo que
1058
+ * sigue pendiente es `notEvaluated`; el resto, `missing` (no está en la cola: lo
1059
+ * aparcado ya está en `seen`).
1060
+ */
1061
+ const unseenOf = async (seen) => {
1062
+ if (only === undefined)
1063
+ return { missing: [], notEvaluated: [] };
1064
+ const unseen = [...only.values()].filter((id) => !seen.has(id)).map((id) => _a.#rawId(onlyIds, id));
1065
+ if (unseen.length === 0)
1066
+ return { missing: [], notEvaluated: [] };
1067
+ const stillPending = await outbox.pending(unseen.length, undefined, { ids: unseen });
1068
+ _a.#assertOnlyHonoured(stillPending, new Set(unseen.map(String)));
1069
+ const pendingIds = new Set(stillPending.map((item) => String(item.id)));
1070
+ return {
1071
+ missing: unseen.filter((id) => !pendingIds.has(String(id))),
1072
+ notEvaluated: unseen.filter((id) => pendingIds.has(String(id))),
1073
+ };
1074
+ };
1075
+ return { only, filter, dead, census, deadCount, deadEntries, unseenOf };
934
1076
  }
935
1077
  /** El reporte de una pasada que NO entró: otra tiene el lease. Nada aplicado, nada tocado. */
936
1078
  async #busyReport(outbox, inputs) {
@@ -949,6 +1091,7 @@ export class AuthorizationManager {
949
1091
  wouldApply: [],
950
1092
  entries: inputs.deadEntries,
951
1093
  missing: [],
1094
+ notEvaluated: [],
952
1095
  };
953
1096
  }
954
1097
  /**
@@ -1020,7 +1163,7 @@ export class AuthorizationManager {
1020
1163
  * (auditor 🟠 5: tomar el lease dos veces era `busy` en verde).
1021
1164
  */
1022
1165
  async #drainScopeChanges(outbox, input) {
1023
- const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, missingOf } = input;
1166
+ const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, unseenOf } = input;
1024
1167
  const driver = await this.driver();
1025
1168
  const applied = [];
1026
1169
  const deferred = [];
@@ -1188,7 +1331,7 @@ export class AuthorizationManager {
1188
1331
  dryRun: false,
1189
1332
  wouldApply: [],
1190
1333
  entries,
1191
- missing: missingOf(new Set(entries.map((e) => String(e.id)))),
1334
+ ...(await unseenOf(new Set(entries.map((e) => String(e.id))))),
1192
1335
  };
1193
1336
  }
1194
1337
  /**
@@ -1586,6 +1729,49 @@ export class AuthorizationManager {
1586
1729
  throw new UnsupportedOperationError('reconcile', `authz:reconcile --to=${name}`, name, `El driver '${name}' no sabe reconstruirse desde 'authz_*' + el árbol del consumidor. ` +
1587
1730
  `El driver 'database' es ese caso a propósito: sus tablas son el ORIGEN.`);
1588
1731
  }
1732
+ // **La dirección RANCIA** (2.4.0-alpha.10; 🔴 4 del sexto informe de
1733
+ // COGNITIV y cuarta iteración del LINAJE «origen rancio del reconcile»:
1734
+ // 3b-5 cerró la pasada por defecto, el primer guard de alpha.10 cerró
1735
+ // `--from`, y quedaba abierto el origen elegido POR DESCARTE). Regla del
1736
+ // dueño: el tercer defecto de un linaje se arregla estructuralmente, así
1737
+ // que el guard NO mira la bandera, mira el ORIGEN RESUELTO — con la MISMA
1738
+ // decisión que `#factsOrigin` (`#isMaintenancePass`), no con una copia:
1739
+ // si `to` es el driver ACTIVO, el único origen legítimo es `to` mismo (la
1740
+ // pasada de MANTENIMIENTO); cualquier otro —`--from`, el driver elegido por
1741
+ // descarte o las tablas `authz_*`— dejó de ver los hechos en el cutover y la
1742
+ // pasada re-escribiría lo revocado después (con o sin --prune; el seguro de
1743
+ // borrado masivo no salta: el origen no está vacío). Se decide por `to`,
1744
+ // nunca por `from`: el cutover inicial y la vuelta atrás ANTES de cambiar
1745
+ // `default` tienen `to` ≠ activo y pasan. Antes de resolver el origen y de
1746
+ // congelar. `--dry-run` no lanza (es el verificador): lo marca y
1747
+ // `reconcileLines` lo hace deriva. `from === to` sigue siendo el 500
1748
+ // E_AUTHZ_CONFIG de 3b-3b.
1749
+ const stale = this.#sameDriver(name, this.#config.default) &&
1750
+ (options.from === undefined || !this.#sameDriver(options.from, name)) &&
1751
+ !this.#isMaintenancePass(name, target, options.from);
1752
+ const allowStale = options.allowStaleSource === true;
1753
+ if (stale && !allowStale && options.dryRun !== true) {
1754
+ const how = options.from !== undefined ? `--from=${options.from}` : 'sin --from';
1755
+ // Cierre del auditor (⚪ 8): con UN solo driver registrado no hay descarte que
1756
+ // hacer; la letra no puede hablar de un origen que no existe.
1757
+ const others = Object.keys(this.#config.drivers ?? {}).filter((key) => !this.#sameDriver(key, name));
1758
+ const originLabel = options.from !== undefined
1759
+ ? `el origen '${options.from}' no lo es`
1760
+ : others.length === 0
1761
+ ? `no hay ningún otro driver registrado ni pasada de mantenimiento para él (no guarda sus hechos en su ` +
1762
+ `backend: no declara hierarchyFacts), así que no hay nada de dónde reconciliar hacia '${name}'`
1763
+ : `sin --from el origen se elegiría POR DESCARTE (las tablas authz_* u otro driver registrado: ` +
1764
+ `${others.join(', ')}), y no es '${name}'`;
1765
+ throw new StaleSourceRefusedError(`authz:reconcile --to=${name} ${how}: '${name}' es el driver ACTIVO (config.default` +
1766
+ `${name === this.#config.default ? '' : `: la clave resuelve la MISMA factory que '${this.#config.default}'`}) y ${originLabel}. ` +
1767
+ `Esa dirección re-escribiría en el driver activo hechos que el origen ya no refleja: resucitaría las ` +
1768
+ `revocaciones hechas desde el cutover (y con --prune borraría lo concedido después). No se ha leído, ` +
1769
+ `congelado ni escrito nada. Hacia el driver activo el único origen es él mismo: la pasada de mantenimiento ` +
1770
+ `(authz:reconcile --to=${name} [--dry-run], sin --from) si su driver guarda sus hechos; y si lo que quieres ` +
1771
+ `es volver atrás, migra en la otra dirección ANTES de cambiar config.default. Si de verdad el origen sigue ` +
1772
+ `siendo la fuente de verdad (el motor aún no ha escrito desde '${name}'), la decisión humana es ` +
1773
+ `--allow-stale-source (allowStaleSource: true).`);
1774
+ }
1589
1775
  // **De dónde salen los HECHOS** (3b-5): la decisión que faltaba, y la que
1590
1776
  // el destino no puede tomar por su cuenta. Ver `#factsOrigin`.
1591
1777
  const origin = await this.#factsOrigin(name, target, options.from);
@@ -1596,7 +1782,7 @@ export class AuthorizationManager {
1596
1782
  // `authz_*` no construye ningún driver de más. Y el origen se resuelve
1597
1783
  // UNA vez.
1598
1784
  facts: origin.enumerate,
1599
- factsOrigin: { name: origin.name, authzTables: origin.authzTables },
1785
+ factsOrigin: { name: origin.name, authzTables: origin.authzTables, ...(origin.self ? { self: true } : {}) },
1600
1786
  // El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
1601
1787
  // el mismo mando que el gate de `scopes.*`.
1602
1788
  ...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
@@ -1618,6 +1804,8 @@ export class AuthorizationManager {
1618
1804
  // Quién fue el origen se DICE, siempre: es la diferencia entre una
1619
1805
  // migración y una pasada de mantenimiento contra el driver activo.
1620
1806
  report.factsFrom = origin.resolved();
1807
+ if (stale)
1808
+ report.staleSource = { to: name, from: origin.resolved(), allowed: allowStale };
1621
1809
  return report;
1622
1810
  };
1623
1811
  // La pasada que escribe congela; el verificador NO (ver el docblock).
@@ -1641,6 +1829,8 @@ export class AuthorizationManager {
1641
1829
  lapsed: await window.lapsed(),
1642
1830
  leaseMs: window.leaseMs,
1643
1831
  fence: window.fence,
1832
+ ...(window.untilMs === undefined ? {} : { untilMs: window.untilMs }),
1833
+ ...(window.window === undefined ? {} : { window: window.window }),
1644
1834
  };
1645
1835
  return report;
1646
1836
  }
@@ -1668,7 +1858,9 @@ export class AuthorizationManager {
1668
1858
  * MANTENIMIENTO: los hechos se leen del propio destino por el puerto
1669
1859
  * (`enumerateFacts`), se rehace lo DERIVADO (marcador, catálogo, árbol)
1670
1860
  * y se aplica el barrido de visibilidad del invariante 18 con el árbol
1671
- * y el catálogo de HOY. No se inventa ni se borra un solo hecho. Un
1861
+ * y el catálogo de HOY. No se inventa ningún hecho; con `--prune` se
1862
+ * borran los que ya no respaldan nada (scopes que ya no resuelven,
1863
+ * caducados, roles/permisos retirados, y los que sobran). Un
1672
1864
  * destino activo con `hierarchyFacts` que no sepa enumerar sus hechos
1673
1865
  * es 500 `E_AUTHZ_UNSUPPORTED`: leerle `authz_*` sería justo el defecto.
1674
1866
  * 2. **`--from=<nombre>` manda**, y por eso se resuelve YA: de la
@@ -1682,7 +1874,7 @@ export class AuthorizationManager {
1682
1874
  * de 3b-3b (`#factsEnumerator`).
1683
1875
  */
1684
1876
  async #factsOrigin(to, target, from) {
1685
- if (from === undefined && to === this.#config.default && target.capabilities?.hierarchyFacts === true) {
1877
+ if (this.#isMaintenancePass(to, target, from)) {
1686
1878
  if (typeof target.enumerateFacts !== 'function') {
1687
1879
  throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `El motor SIRVE desde '${to}' y ese driver declara que el árbol y los hechos viven en su backend ` +
1688
1880
  `(hierarchyFacts), así que 'authz_assignments'/'authz_denies' NO son la fuente de verdad de sus ` +
@@ -1692,6 +1884,7 @@ export class AuthorizationManager {
1692
1884
  return {
1693
1885
  name: to,
1694
1886
  authzTables: false,
1887
+ self: true,
1695
1888
  enumerate: (page) => target.enumerateFacts(page),
1696
1889
  resolved: () => to,
1697
1890
  };
@@ -1717,10 +1910,44 @@ export class AuthorizationManager {
1717
1910
  });
1718
1911
  return { name: AUTHZ_TABLES_ORIGIN, authzTables: true, enumerate, resolved: () => resolvedName };
1719
1912
  }
1913
+ /**
1914
+ * **¿Es la pasada de MANTENIMIENTO?** (3b-5; punto ÚNICO desde alpha.10): el
1915
+ * destino es el driver ACTIVO, sus hechos viven en su backend
1916
+ * (`hierarchyFacts`) y nadie nombró otro origen. Es la ÚNICA forma en la que
1917
+ * el origen resuelto de una pasada hacia el driver activo es el propio
1918
+ * destino, y la usan los dos que la necesitan: `#factsOrigin` (de dónde se
1919
+ * leen los hechos) y el guard de la dirección rancia de `reconcile` (si ese
1920
+ * origen es legítimo). Dos copias de esta condición eran dos respuestas.
1921
+ */
1922
+ #isMaintenancePass(to, target, from) {
1923
+ return from === undefined && this.#sameDriver(to, this.#config.default) && target.capabilities?.hierarchyFacts === true;
1924
+ }
1925
+ /**
1926
+ * **¿Dos claves de `drivers` son el MISMO driver?** (cierre del auditor de
1927
+ * alpha.10 · R4, gemelo de roles): misma clave o la MISMA factory registrada
1928
+ * bajo dos claves. El guard de la dirección rancia, el mantenimiento, el
1929
+ * `from === to` y el descarte juzgan la identidad RESUELTA, no la etiqueta:
1930
+ * con `drivers: { openfga: f, fga: f }` y `default: 'openfga'`, `--to=fga
1931
+ * --from=database` es la misma dirección rancia que `--to=openfga`.
1932
+ *
1933
+ * **Límite DECLARADO (cierre-2 · C1)**: la identidad es la REFERENCIA de la
1934
+ * factory, no el backend. Dos factories distintas que construyen el mismo
1935
+ * store (`openfga: () => new OpenFga…(…)`, `fga: () => new OpenFga…(…)`) NO son
1936
+ * el mismo driver para este predicado y el alias pasa el guard. Identidad de
1937
+ * backend declarada por el driver ⇒ roadmap 2.5.
1938
+ */
1939
+ #sameDriver(a, b) {
1940
+ if (a === b)
1941
+ return true;
1942
+ const drivers = this.#config.drivers ?? {};
1943
+ const own = (key) => (Object.prototype.hasOwnProperty.call(drivers, key) ? drivers[key] : undefined);
1944
+ const fa = own(a);
1945
+ return fa !== undefined && fa === own(b);
1946
+ }
1720
1947
  /** El driver que `--from` nombra, con los dos errores de 3b-3b intactos. */
1721
1948
  async #originDriver(from, to) {
1722
1949
  const registered = Object.keys(this.#config.drivers ?? {});
1723
- if (from === to) {
1950
+ if (this.#sameDriver(from, to)) {
1724
1951
  throw new AuthorizationConfigError(`authz:reconcile --from=${from} --to=${to}: el origen y el destino son el mismo driver. ` +
1725
1952
  `Si lo que quieres es VERIFICAR y reparar lo derivado del driver activo, no lo digas con --from: ` +
1726
1953
  `la pasada ya lee sus hechos de él cuando es el driver por defecto.`);
@@ -1756,7 +1983,7 @@ export class AuthorizationManager {
1756
1983
  const registered = Object.keys(this.#config.drivers ?? {});
1757
1984
  const candidates = [];
1758
1985
  for (const candidate of registered) {
1759
- if (candidate === to)
1986
+ if (this.#sameDriver(candidate, to))
1760
1987
  continue;
1761
1988
  const driver = await this.#config.drivers[candidate]();
1762
1989
  if (typeof driver.enumerateFacts === 'function')
@@ -2188,7 +2415,7 @@ export class AuthorizationManager {
2188
2415
  const answer = await driver.authorizeMany(subject, permission, scopes);
2189
2416
  // Un `boolean[]` desalineado se leería por posición (F5, CR3): es un
2190
2417
  // bug del driver, no una decisión. 500 nombrando al culpable.
2191
- if (!Array.isArray(answer) || answer.length !== scopes.length || answer.some((b) => typeof b !== 'boolean')) {
2418
+ if (!Array.isArray(answer) || answer.length !== scopes.length || !everyPositionBoolean(answer)) {
2192
2419
  throw new AuthorizationInternalError(`authorizeMany: el driver '${this.#config.default}' devolvió ${describeAnswer(answer)} para ${scopes.length} scopes; ` +
2193
2420
  `el puerto exige un boolean[] con exactamente una posición por scope.`);
2194
2421
  }