@jantstack/adonis-authz 2.4.0-alpha.4 → 2.4.0-alpha.6

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 (66) hide show
  1. package/README.md +102 -4
  2. package/build/commands/authz_reconcile.d.ts +5 -3
  3. package/build/commands/authz_reconcile.d.ts.map +1 -1
  4. package/build/commands/authz_reconcile.js +15 -3
  5. package/build/commands/authz_reconcile.js.map +1 -1
  6. package/build/commands/authz_scopes_relay.d.ts +18 -0
  7. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  8. package/build/commands/authz_scopes_relay.js +55 -7
  9. package/build/commands/authz_scopes_relay.js.map +1 -1
  10. package/build/commands/authz_scopes_requeue.d.ts +63 -0
  11. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  12. package/build/commands/authz_scopes_requeue.js +154 -0
  13. package/build/commands/authz_scopes_requeue.js.map +1 -0
  14. package/build/commands/main.d.ts +2 -1
  15. package/build/commands/main.d.ts.map +1 -1
  16. package/build/commands/main.js +5 -3
  17. package/build/commands/main.js.map +1 -1
  18. package/build/index.d.ts +3 -3
  19. package/build/index.d.ts.map +1 -1
  20. package/build/index.js +2 -2
  21. package/build/index.js.map +1 -1
  22. package/build/src/define_config.d.ts +24 -0
  23. package/build/src/define_config.d.ts.map +1 -1
  24. package/build/src/define_config.js.map +1 -1
  25. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  26. package/build/src/drivers/openfga_driver.js +24 -1
  27. package/build/src/drivers/openfga_driver.js.map +1 -1
  28. package/build/src/drivers/openfga_relations_driver.d.ts +32 -0
  29. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  30. package/build/src/drivers/openfga_relations_driver.js +64 -6
  31. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  32. package/build/src/errors.d.ts +30 -0
  33. package/build/src/errors.d.ts.map +1 -1
  34. package/build/src/errors.js +32 -0
  35. package/build/src/errors.js.map +1 -1
  36. package/build/src/manager.d.ts +86 -4
  37. package/build/src/manager.d.ts.map +1 -1
  38. package/build/src/manager.js +457 -25
  39. package/build/src/manager.js.map +1 -1
  40. package/build/src/relations/manager.d.ts +7 -0
  41. package/build/src/relations/manager.d.ts.map +1 -1
  42. package/build/src/relations/manager.js +27 -0
  43. package/build/src/relations/manager.js.map +1 -1
  44. package/build/src/relations/reconcile.d.ts +14 -2
  45. package/build/src/relations/reconcile.d.ts.map +1 -1
  46. package/build/src/relations/reconcile.js +155 -77
  47. package/build/src/relations/reconcile.js.map +1 -1
  48. package/build/src/scope_outbox.d.ts +21 -0
  49. package/build/src/scope_outbox.d.ts.map +1 -1
  50. package/build/src/scope_outbox.js +211 -24
  51. package/build/src/scope_outbox.js.map +1 -1
  52. package/build/src/testing/contract.d.ts.map +1 -1
  53. package/build/src/testing/contract.js +4 -1
  54. package/build/src/testing/contract.js.map +1 -1
  55. package/build/src/testing/main.d.ts +2 -0
  56. package/build/src/testing/main.d.ts.map +1 -1
  57. package/build/src/testing/main.js +1 -0
  58. package/build/src/testing/main.js.map +1 -1
  59. package/build/src/testing/scope_outbox_contract.d.ts +88 -0
  60. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  61. package/build/src/testing/scope_outbox_contract.js +607 -0
  62. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  63. package/build/src/types.d.ts +240 -17
  64. package/build/src/types.d.ts.map +1 -1
  65. package/build/src/types.js.map +1 -1
  66. package/package.json +1 -1
@@ -9,7 +9,7 @@ import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from '
9
9
  import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
10
10
  import { assertAssignableAt } from './catalog/catalog.js';
11
11
  import { systemClock } from './clock.js';
12
- import { ActorRequiredError, 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, } from './errors.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
13
  import { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
14
14
  import { memoizeAncestors } from './memoize_ancestors.js';
15
15
  import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
@@ -62,6 +62,26 @@ export const DEFAULT_RELAY_BATCH = 100;
62
62
  export const DEFAULT_RELAY_LIMIT = 10_000;
63
63
  /** Páginas que `authz:reconcile` pasea para MEDIR la ventana del relay (3b-3a). */
64
64
  const RELAY_WINDOW_MAX_PAGES = 1_000;
65
+ /**
66
+ * **Saltos que la sonda de `only` sigue hacia atrás para heredar el ORIGEN de
67
+ * un bloqueo** (2.4.0-alpha.6). Cada salto es una consulta `earlierPending`
68
+ * (0,69 ms) sobre una entrada NO pedida; la cadena es de ids estrictamente
69
+ * decrecientes, así que termina sola — la cota solo acota el coste dentro de
70
+ * una petición inline. Pasada la cota el origen se toma por FALLO (la purga se
71
+ * aplaza a la pasada completa): degrada hacia lo seguro, nunca aplica sin saber.
72
+ */
73
+ export const RELAY_ORIGIN_MAX_HOPS = 100;
74
+ /**
75
+ * **Cota PROPIA del censo de aparcadas** (alpha.5 · cierre, auditor 🟠 2). Las
76
+ * claves de `dead()` que taponan `attached`/`moved` se leen COMPLETAS en cada
77
+ * pasada, sean cuales sean `limit` y `batchSize` (con `--limit 50` o la receta
78
+ * inline `{ only, limit: 1 }` un censo acotado por `limit` dejaba a la aparcada
79
+ * 101 sin taponar y el `attached` colgaba del padre VIEJO — y la sonda de
80
+ * `only` no la ve porque `earlierPending` excluye lo aparcado). Si hay más
81
+ * aparcadas que esto, la pasada se NIEGA (500) en vez de aplicar con un tapón
82
+ * incompleto: una cola así es un incidente, no un lote.
83
+ */
84
+ export const PARKED_CENSUS_MAX = 50_000;
65
85
  /** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
66
86
  export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
67
87
  /** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
@@ -619,10 +639,11 @@ export class AuthorizationManager {
619
639
  await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
620
640
  const outbox = this.#outbox();
621
641
  if (outbox) {
622
- await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
623
- return;
642
+ const receipt = await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
643
+ return _a.#receiptOf([receipt]);
624
644
  }
625
645
  await (await this.driver()).onScopeAttached?.(child, parent);
646
+ return { queued: [] };
626
647
  },
627
648
  /**
628
649
  * `moved` NO vuelve a juzgar el catálogo, y no tiene por qué (3b-1 · D3,
@@ -644,10 +665,11 @@ export class AuthorizationManager {
644
665
  await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
645
666
  const outbox = this.#outbox();
646
667
  if (outbox) {
647
- await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
648
- return;
668
+ const receipt = await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
669
+ return _a.#receiptOf([receipt]);
649
670
  }
650
671
  await (await this.driver()).onScopeMoved?.(child, newParent);
672
+ return { queued: [] };
651
673
  },
652
674
  /**
653
675
  * Hechos primero (el driver demuestra cero o lanza), arista después
@@ -701,10 +723,12 @@ export class AuthorizationManager {
701
723
  // La identidad se resuelve AQUÍ, con la fila del consumidor todavía
702
724
  // viva si la hay: al relevar el cambio ya no resolvería. Y no se
703
725
  // audita `scope_purged` todavía, porque todavía no ha pasado nada.
726
+ // El recibo es PLURAL por esto mismo: una fila por ortografía.
727
+ const receipts = [];
704
728
  for (const target of targets) {
705
- await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor });
729
+ receipts.push(await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor }));
706
730
  }
707
- return;
731
+ return _a.#receiptOf(receipts);
708
732
  }
709
733
  const driver = await this.driver();
710
734
  for (const purged of targets) {
@@ -713,8 +737,23 @@ export class AuthorizationManager {
713
737
  await driver.onScopeDetached?.(purged);
714
738
  await this.#notify(event);
715
739
  }
740
+ return { queued: [] };
716
741
  },
717
742
  };
743
+ /**
744
+ * El recibo del encolado (alpha.5 · R1): los ids que la outbox devolvió,
745
+ * en orden. Una outbox que devuelve `void` (terceros anteriores a alpha.5)
746
+ * no deja id, y el recibo sale vacío: nunca se inventa uno.
747
+ */
748
+ static #receiptOf(receipts) {
749
+ const queued = [];
750
+ for (const receipt of receipts) {
751
+ if (receipt && typeof receipt === 'object' && (typeof receipt.id === 'number' || typeof receipt.id === 'string')) {
752
+ queued.push(receipt.id);
753
+ }
754
+ }
755
+ return { queued };
756
+ }
718
757
  /**
719
758
  * **Drena la outbox del árbol y aplica los cambios al driver** (3b-2d).
720
759
  * Es lo que hay detrás de `node ace authz:scopes:relay`.
@@ -758,6 +797,26 @@ export class AuthorizationManager {
758
797
  * **fail-open temporal** —el tenant antiguo conserva acceso tras un
759
798
  * `moved`, los denies heredados no aplican tras un `attached`—. No hay
760
799
  * 2PC; es el precio de tener el árbol en dos sitios.
800
+ *
801
+ * **`only` (2.4.0-alpha.5 · R1)**: relevar SOLO estas entradas —los ids del
802
+ * recibo de `scopes.*` que ESTE proceso acaba de recibir, jamás del cuerpo
803
+ * de una petición— sin leer la cola de los demás (medido: 4 507 ms → 19 ms
804
+ * con 500 entradas ajenas por delante). El orden por clave NO se relaja:
805
+ * antes de aplicar cada entrada pedida se sondea la cola ENTERA
806
+ * (`earlierPending`, o paginando `pending()` si la outbox no lo tiene) y,
807
+ * si hay una pendiente ANTERIOR que comparte clave de scope con ella, sale
808
+ * `deferred` con `depends-on:<id>` — nunca aplicada fuera de orden (el 🔴 1
809
+ * del panel relay, medido: un `attached(child, unit)` aplicado antes que su
810
+ * `moved(unit → orgB)` dejaba al editor de la org VIEJA autorizando sobre el
811
+ * hijo). `remaining` y `dead` siguen siendo GLOBALES. `only: []` es 500
812
+ * (un `filter` vacío del consumidor no puede acabar en drenaje ni en un
813
+ * reporte vacío en verde), y una outbox que no honre el filtro también.
814
+ *
815
+ * **Lo aparcado tapona por clave (alpha.5 · R2a)**: las claves de `dead()`
816
+ * siembran el bloqueo al inicio de la pasada para lo que CONCEDE o recuelga
817
+ * (`attached`/`moved`, `deferred` con `parked:<id>`) y dejan pasar lo que
818
+ * RETIRA (`detached`: una purga nunca queda bloqueada, invariante 11). Y un
819
+ * 503 del backend NO consume intento (`markFailed(..., { transient })`).
761
820
  */
762
821
  async relayScopeChanges(options = {}) {
763
822
  // El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
@@ -771,11 +830,28 @@ export class AuthorizationManager {
771
830
  const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
772
831
  const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
773
832
  const dryRun = options.dryRun === true;
774
- /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE. */
833
+ const only = _a.#onlyOf(options.only);
834
+ // Al puerto van los ids TAL CUAL los dio el llamante (el recibo); el
835
+ // conjunto de cadenas es solo para comparar sin depender del tipo.
836
+ const filter = only === undefined ? undefined : { ids: [...options.only] };
837
+ /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE, y GLOBAL. */
775
838
  const dead = await _a.#deadLetters(outbox, batchSize);
839
+ /** Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`. */
840
+ const deadEntries = dead
841
+ .filter((item) => only === undefined || only.has(String(item.id)))
842
+ .map((item) => ({
843
+ id: item.id,
844
+ change: item.change,
845
+ status: 'dead',
846
+ attempts: item.attempts,
847
+ ...(item.error === undefined ? {} : { reason: item.error }),
848
+ }));
849
+ const missingOf = (seen) => only === undefined ? [] : [...only.values()].filter((id) => !seen.has(String(id))).map((id) => _a.#rawId(options.only, id));
776
850
  if (dryRun) {
777
- const batch = await outbox.pending(limit);
851
+ const batch = await outbox.pending(limit, undefined, filter);
852
+ _a.#assertOnlyHonoured(batch, only);
778
853
  const extra = batch.length >= limit ? true : false;
854
+ const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
779
855
  return {
780
856
  applied: [],
781
857
  failed: null,
@@ -786,6 +862,8 @@ export class AuthorizationManager {
786
862
  remaining: batch.length > 0 || extra,
787
863
  dryRun: true,
788
864
  wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
865
+ entries: deadEntries,
866
+ missing: missingOf(seen),
789
867
  };
790
868
  }
791
869
  // El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
@@ -803,6 +881,8 @@ export class AuthorizationManager {
803
881
  remaining: (await outbox.pending(1)).length > 0,
804
882
  dryRun: false,
805
883
  wouldApply: [],
884
+ entries: deadEntries,
885
+ missing: [],
806
886
  };
807
887
  }
808
888
  try {
@@ -810,8 +890,37 @@ export class AuthorizationManager {
810
890
  const applied = [];
811
891
  const deferred = [];
812
892
  const failures = [];
813
- /** Claves de scope contaminadas: lo que las toque se aplaza. */
814
- const blocked = new Set();
893
+ const entries = [];
894
+ /**
895
+ * Claves de scope contaminadas ⇒ el id que las contaminó y el ORIGEN del
896
+ * bloqueo (alpha.5 · cierre, auditor 🟠 1). `parked: true` = la causa
897
+ * raíz (transitiva) es una entrada APARCADA: tapona solo lo que concede
898
+ * o recuelga (`attached`/`moved`), NUNCA un `detached` — si no, un
899
+ * `attached` aplazado por aparcado congelaba la purga del nodo que el
900
+ * tenant ya borró, para siempre (medido: el scope conservaba sus hechos
901
+ * y su arista, y seguía concediendo; invariante 11). `parked: false` =
902
+ * un FALLO real de esta pasada (o una pendiente anterior no pedida):
903
+ * contamina todo, `detached` incluido — el orden entre un `attached`
904
+ * que falló y la purga de ese mismo nodo sigue importando (3b-2h).
905
+ */
906
+ const blocked = new Map();
907
+ /** Origen ya sondeado de entradas NO pedidas (alpha.6): `true` = retenida solo por un aparcado. Se olvida en cuanto la pasada registra un FALLO real (podría contaminar una clave ya sondeada). */
908
+ const origins = new Map();
909
+ const block = (key, id, parked) => {
910
+ const current = blocked.get(key);
911
+ // Un fallo real pisa a un bloqueo por aparcado; nunca al revés.
912
+ if (current === undefined || (current.parked && !parked))
913
+ blocked.set(key, { id, parked });
914
+ if (!parked)
915
+ origins.clear();
916
+ };
917
+ /**
918
+ * **Las claves de lo APARCADO** (alpha.5 · R2a): taponan lo que concede
919
+ * o recuelga y dejan pasar lo que retira. El censo es COMPLETO —no
920
+ * depende de `limit` ni de `batchSize` (cierre, 🟠 2)— con su propia
921
+ * cota; el reporte sigue enseñando las primeras `batchSize`.
922
+ */
923
+ const parkedKeys = await _a.#parkedKeys(outbox, dead, batchSize);
815
924
  // Una outbox que no marca lo aplicado devolvería el mismo pendiente
816
925
  // para siempre: el relay no puede quedarse dando vueltas ni
817
926
  // "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
@@ -821,6 +930,13 @@ export class AuthorizationManager {
821
930
  const parked = new Set();
822
931
  /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
823
932
  let after;
933
+ const defer = (item, keys, reason, error, byParked) => {
934
+ for (const key of keys)
935
+ block(key, item.id, byParked);
936
+ parked.add(String(item.id));
937
+ deferred.push({ id: item.id, change: item.change, attempts: item.attempts, error });
938
+ entries.push({ id: item.id, change: item.change, status: 'deferred', reason, attempts: item.attempts });
939
+ };
824
940
  outer: while (applied.length < limit) {
825
941
  // **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
826
942
  // mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
@@ -835,9 +951,10 @@ export class AuthorizationManager {
835
951
  // lo ya aplicado está marcado en la outbox (la pasada es reanudable)
836
952
  // y el resto sigue pendiente para después de la ventana.
837
953
  await this.#assertNotFrozen('authz:scopes:relay');
838
- const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after);
954
+ const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after, filter);
839
955
  if (batch.length === 0)
840
956
  break;
957
+ _a.#assertOnlyHonoured(batch, only);
841
958
  let progress = false;
842
959
  for (const item of batch) {
843
960
  const id = String(item.id);
@@ -852,34 +969,65 @@ export class AuthorizationManager {
852
969
  if (applied.length >= limit)
853
970
  break outer;
854
971
  const keys = _a.#changeKeys(item.change);
855
- const collision = keys.find((key) => blocked.has(key));
972
+ // Una purga solo la frena un bloqueo cuyo origen sea un FALLO real;
973
+ // el que nace de un aparcado la deja pasar (invariante 11).
974
+ const collision = keys.find((key) => {
975
+ const hit = blocked.get(key);
976
+ return hit !== undefined && !(item.change.op === 'detached' && hit.parked);
977
+ });
856
978
  if (collision !== undefined) {
857
- for (const key of keys)
858
- blocked.add(key);
859
- parked.add(id);
860
- deferred.push({
861
- id: item.id,
862
- change: item.change,
863
- attempts: item.attempts,
864
- error: `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada`,
865
- });
979
+ const by = blocked.get(collision);
980
+ defer(item, keys, `depends-on:${by.id}`, `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada (#${by.id})`, by.parked);
866
981
  continue;
867
982
  }
983
+ // Lo aparcado tapona lo que CONCEDE o recuelga; una purga pasa.
984
+ if (item.change.op !== 'detached') {
985
+ const parkedKey = keys.find((key) => parkedKeys.has(key));
986
+ if (parkedKey !== undefined) {
987
+ const by = parkedKeys.get(parkedKey);
988
+ defer(item, keys, `parked:${by}`, `aplazado: depende de ${parkedKey}, que nombra la entrada APARCADA #${by} (parked:${by}); no se aplica hasta que alguien la mire`, true);
989
+ continue;
990
+ }
991
+ }
992
+ // Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
993
+ // que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
994
+ // **Y la sonda hereda el ORIGEN de esa anterior** (alpha.6, auditor 🟡 A
995
+ // de alpha.5): si la anterior está —transitivamente— retenida solo por
996
+ // un APARCADO, es el mismo bloqueo `parked:` que en la pasada completa
997
+ // deja pasar la purga (invariante 11): el `detached` SE APLICA. Si la
998
+ // anterior es un `attached`/`moved` pendiente de verdad (aplicable, o
999
+ // retenido por un FALLO real), el `detached` sigue `deferred`: aplicarlo
1000
+ // antes que un `moved` pendiente del mismo scope dejaría deriva.
1001
+ if (only !== undefined) {
1002
+ const earlier = await this.#dependencyOf(outbox, item, batchSize);
1003
+ if (earlier !== null) {
1004
+ const byParked = await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, origins);
1005
+ if (!(item.change.op === 'detached' && byParked)) {
1006
+ const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
1007
+ defer(item, keys, `depends-on:${earlier.id}`, `aplazado: depende de ${shared}, que nombra la entrada anterior #${earlier.id} todavía sin aplicar (depends-on:${earlier.id}); ` +
1008
+ (byParked ? 'espera a una entrada APARCADA; mira dead()' : 'corre el relay completo'), byParked);
1009
+ continue;
1010
+ }
1011
+ }
1012
+ }
868
1013
  try {
869
1014
  await this.#applyScopeChange(driver, item);
870
1015
  }
871
1016
  catch (error) {
872
1017
  const message = error instanceof Error ? error.message : String(error);
873
- await outbox.markFailed(item.id, message);
1018
+ const transient = _a.#isTransientRelayFailure(error);
1019
+ await outbox.markFailed(item.id, message, { transient });
874
1020
  for (const key of keys)
875
- blocked.add(key);
1021
+ block(key, item.id, false);
876
1022
  parked.add(id);
877
- failures.push({ id: item.id, change: item.change, error: message });
1023
+ failures.push({ id: item.id, change: item.change, error: message, transient });
1024
+ entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
878
1025
  continue;
879
1026
  }
880
1027
  await outbox.markApplied(item.id);
881
1028
  done.add(id);
882
1029
  applied.push({ id: item.id, change: item.change, attempts: item.attempts });
1030
+ entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
883
1031
  }
884
1032
  // Una outbox que ignora `after` devuelve el mismo lote atascado: la
885
1033
  // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
@@ -887,6 +1035,7 @@ export class AuthorizationManager {
887
1035
  if (!progress)
888
1036
  break;
889
1037
  }
1038
+ entries.push(...deadEntries);
890
1039
  return {
891
1040
  applied,
892
1041
  failed: failures[0] ?? null,
@@ -897,12 +1046,270 @@ export class AuthorizationManager {
897
1046
  remaining: (await outbox.pending(1)).length > 0,
898
1047
  dryRun: false,
899
1048
  wouldApply: [],
1049
+ entries,
1050
+ missing: missingOf(new Set(entries.map((e) => String(e.id)))),
900
1051
  };
901
1052
  }
902
1053
  finally {
903
1054
  await lease?.release();
904
1055
  }
905
1056
  }
1057
+ /**
1058
+ * `only` normalizado (alpha.5 · R1): un conjunto de ids como cadena (el id
1059
+ * de `sqlScopeOutbox` es un entero que según el motor y el driver llega
1060
+ * como `number` o como `string`; comparar con `===` sería un no-op
1061
+ * silencioso). `undefined` = sin filtro. **`[]` es 500**, nunca «sin
1062
+ * filtro»: un `filter` vacío del consumidor —o una outbox de terceros sin
1063
+ * recibo, que deja `queued: []`— no puede acabar drenando la cola entera
1064
+ * bajo el lease de una petición HTTP, ni en un reporte vacío en verde.
1065
+ */
1066
+ static #onlyOf(only) {
1067
+ if (only === undefined)
1068
+ return undefined;
1069
+ if (!Array.isArray(only) || only.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
1070
+ throw new AuthorizationConfigError(`authz:scopes:relay: 'only' debe ser una lista de ids (number | string); llegó ${JSON.stringify(only)}`);
1071
+ }
1072
+ if (only.length === 0) {
1073
+ throw new AuthorizationConfigError("authz:scopes:relay: 'only' está vacío. Un recibo vacío significa que no hay nada que relevar (sin outbox el cambio ya " +
1074
+ 'se aplicó en línea; con una outbox de terceros sin recibo no hay `only`): relevar «nada» no es relevar «todo». ' +
1075
+ 'Quita `only` para drenar la cola entera.');
1076
+ }
1077
+ return new Set(only.map(String));
1078
+ }
1079
+ /** El id tal como lo pasó el llamante en `only` (para `missing`). */
1080
+ static #rawId(only, key) {
1081
+ return only.find((id) => String(id) === key) ?? key;
1082
+ }
1083
+ /**
1084
+ * Una outbox que IGNORA `filter.ids` devuelve lo que no se pidió: el
1085
+ * backlog ajeno se aplicaría dentro de la petición del consumidor, en
1086
+ * silencio. Se detecta y se para (500), como el `markApplied` no honrado.
1087
+ */
1088
+ static #assertOnlyHonoured(batch, only) {
1089
+ if (only === undefined)
1090
+ return;
1091
+ const stranger = batch.find((item) => !only.has(String(item.id)));
1092
+ if (stranger !== undefined) {
1093
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox devolvió el cambio ${String(stranger.id)}, que no está en 'only'. ` +
1094
+ 'Tu implementación de ScopeOutbox no honra `pending(limit, after, { ids })`: el relay para antes de aplicar ' +
1095
+ 'lo que no se pidió (filtra en el puerto, no en memoria).');
1096
+ }
1097
+ }
1098
+ /**
1099
+ * La caída del BACKEND no dice nada de la entrada (alpha.5 · R2a): un
1100
+ * `AuthorizationBackendError` (503; el timeout es uno), y en general
1101
+ * cualquier 503 (el freeze, el resolutor caído). Todo lo demás —422, 500,
1102
+ * `E_AUTHZ_SCOPE_TREE_DRIFT`, el padre inexistente, un error sin status de
1103
+ * un driver de terceros— es de la entrada y cuenta hacia el aparcado.
1104
+ */
1105
+ static #isTransientRelayFailure(error) {
1106
+ if (error instanceof AuthorizationBackendError)
1107
+ return true;
1108
+ return typeof error === 'object' && error !== null && error.status === 503;
1109
+ }
1110
+ /**
1111
+ * Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
1112
+ * (alpha.5 · R2a). El censo es el `dead` ya leído si cabe entero en un
1113
+ * lote (menos de `batchSize` filas = no hay más); si no, se relee ENTERO
1114
+ * hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit` (cierre, 🟠 2:
1115
+ * con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no taponaba y el
1116
+ * `attached` colgaba del padre viejo)—, y si la cota se alcanza la pasada
1117
+ * se niega (500): un tapón incompleto no es un tapón.
1118
+ */
1119
+ static async #parkedKeys(outbox, dead, batchSize) {
1120
+ const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
1121
+ if (census.length >= PARKED_CENSUS_MAX) {
1122
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox tiene ${PARKED_CENSUS_MAX} o más entradas APARCADAS y el censo de sus claves no cabe ` +
1123
+ `(PARKED_CENSUS_MAX): sin el censo entero el tapón por clave sería ciego y un attached podría colgarse del padre ` +
1124
+ `VIEJO. No se ha aplicado nada. Mira dead(), reencola o reconcilia antes de volver a relevar.`);
1125
+ }
1126
+ const keys = new Map();
1127
+ for (const item of census) {
1128
+ for (const key of _a.#changeKeys(item.change))
1129
+ if (!keys.has(key))
1130
+ keys.set(key, item.id);
1131
+ }
1132
+ return keys;
1133
+ }
1134
+ /**
1135
+ * **La sonda de dependencia de `only`** (alpha.5 · R1): la pendiente más
1136
+ * antigua ANTERIOR a `item` que comparta clave de scope con ella, o `null`.
1137
+ * Con `earlierPending` es una consulta de existencia (0,69 ms medidos);
1138
+ * sin ella se pagina `pending()` desde el principio hasta `item.id` —
1139
+ * correcto, O(cola)— y si la cola no se deja recorrer en
1140
+ * `RELAY_WINDOW_MAX_PAGES` lotes se para (500 nombrando el método), nunca
1141
+ * se aplica sin saber.
1142
+ */
1143
+ async #dependencyOf(outbox, item, batchSize) {
1144
+ const keys = _a.#changeKeys(item.change);
1145
+ if (typeof outbox.earlierPending === 'function') {
1146
+ const scopes = item.change.op === 'detached' ? [item.change.child] : [item.change.child, item.change.parent];
1147
+ return outbox.earlierPending(item.id, scopes);
1148
+ }
1149
+ const mine = String(item.id);
1150
+ let after;
1151
+ for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
1152
+ const batch = await outbox.pending(batchSize, after);
1153
+ for (const earlier of batch) {
1154
+ if (String(earlier.id) === mine)
1155
+ return null;
1156
+ if (_a.#changeKeys(earlier.change).some((key) => keys.includes(key)))
1157
+ return earlier;
1158
+ }
1159
+ if (batch.length < batchSize)
1160
+ return null;
1161
+ after = batch[batch.length - 1].id;
1162
+ }
1163
+ throw new AuthorizationConfigError(`authz:scopes:relay: no se pudo sondear si el cambio ${mine} depende de una entrada anterior: la cola tiene más de ` +
1164
+ `${RELAY_WINDOW_MAX_PAGES * batchSize} pendientes por delante y tu ScopeOutbox no implementa earlierPending(). ` +
1165
+ 'Implementa la sonda, o releva sin `only`.');
1166
+ }
1167
+ /**
1168
+ * **El ORIGEN del bloqueo de una entrada NO pedida** (2.4.0-alpha.6): ¿en la
1169
+ * pasada COMPLETA saldría `deferred` por un APARCADO —directo (`parked:`) o
1170
+ * heredado (`depends-on:` de otra que lo está)— y no por un fallo real ni
1171
+ * porque simplemente se aplicaría? `true` = retenida solo por un aparcado.
1172
+ * Es la misma regla que aplica `block`/`defer` a lo que la pasada SÍ procesa,
1173
+ * simulada hacia atrás sobre lo que no procesa:
1174
+ * 1. una clave suya en `blocked` (esta pasada) con origen FALLO ⇒ fallo;
1175
+ * 2. `attached`/`moved` con una clave en `parkedKeys`, o en `blocked` con
1176
+ * origen aparcado ⇒ aparcado;
1177
+ * 3. si no, su propia anterior con clave compartida (`earlierPending`):
1178
+ * ninguna ⇒ se aplicaría ⇒ NO es aparcado (la pedida espera su turno);
1179
+ * alguna ⇒ el origen de ESA (recursivo, ids decrecientes, memoizado,
1180
+ * `RELAY_ORIGIN_MAX_HOPS`; pasada la cota ⇒ fallo, hacia lo seguro).
1181
+ * Un `detached` anterior nunca es «aparcado» (una purga pasa; si sigue
1182
+ * pendiente es porque se aplicará): la pedida espera detrás.
1183
+ */
1184
+ async #blockOriginOf(outbox, entry, blocked, parkedKeys, batchSize, memo, hops = 0) {
1185
+ const id = String(entry.id);
1186
+ const known = memo.get(id);
1187
+ if (known !== undefined)
1188
+ return known;
1189
+ const decide = (byParked) => {
1190
+ memo.set(id, byParked);
1191
+ return byParked;
1192
+ };
1193
+ if (hops >= RELAY_ORIGIN_MAX_HOPS)
1194
+ return decide(false);
1195
+ const keys = _a.#changeKeys(entry.change);
1196
+ const hits = keys.map((key) => blocked.get(key)).filter((hit) => hit !== undefined);
1197
+ if (hits.some((hit) => !hit.parked))
1198
+ return decide(false);
1199
+ if (entry.change.op !== 'detached') {
1200
+ if (hits.length > 0 || keys.some((key) => parkedKeys.has(key)))
1201
+ return decide(true);
1202
+ }
1203
+ const earlier = await this.#dependencyOf(outbox, entry, batchSize);
1204
+ if (earlier === null)
1205
+ return decide(false);
1206
+ return decide(await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, memo, hops + 1));
1207
+ }
1208
+ /**
1209
+ * **Devuelve entradas APARCADAS a la cola** (2.4.0-alpha.5 · R2b; panel
1210
+ * relay, cuestión 2). Es lo que hay detrás de `node ace authz:scopes:requeue
1211
+ * --id`. Operación de PLATAFORMA como `relayScopeChanges` y `driver()`: sin
1212
+ * actor, sin `within`, **no se expone por HTTP**.
1213
+ *
1214
+ * **La sonda anti-reversión vive AQUÍ, no en la outbox**: reaplicar una
1215
+ * entrada ANTERIOR a otra ya APLICADA sobre la misma clave de scope
1216
+ * reescribe el árbol del store en un orden que nunca existió — el `moved`
1217
+ * viejo tras el nuevo deja el padre VIEJO con UN solo padre, así que
1218
+ * `assertOneParent` no lo delata y el tenant antiguo recupera el acceso
1219
+ * (auditor 🔴 2, `relay-auditor-5.ts`, medido). Por eso, antes de tocar la
1220
+ * cola, cada aparcada pedida se compara contra las aplicadas posteriores
1221
+ * que compartan clave (`outbox.laterApplied`, espejo de `earlierPending`)
1222
+ * y si hay alguna se NIEGA con 500 `E_AUTHZ_REQUEUE_REFUSED` nombrándola —
1223
+ * todo o nada, cero filas tocadas—. El remedio correcto es
1224
+ * `authz:reconcile --to=<driver>`, que recalcula con el árbol de HOY. La
1225
+ * sonda es del manager porque necesita las claves del cambio
1226
+ * (`#changeKeys`); `outbox.requeue` queda mecánico y una outbox de
1227
+ * terceros la hereda gratis.
1228
+ *
1229
+ * **`force`** salta la sonda y devuelve el diff en `overrides`. Existe para
1230
+ * el comando ace —que lo imprime y exige el flag explícito (decisión del
1231
+ * dueño del 2026-09-09 (2))— y para nada más: llamarlo con `force: true`
1232
+ * desde un endpoint es policy en una API sin policy.
1233
+ *
1234
+ * `attempts` queda en `maxAttempts − 1` (lo hace la outbox): si la causa
1235
+ * sigue rota la entrada vuelve a aparcarse en UNA pasada y contamina su
1236
+ * clave solo en esa pasada —un requeue automatizado de lo irreparable no
1237
+ * es un grifo de aplazamiento permanente (auditor 🔴 2, segunda mitad)—; si
1238
+ * el operador la arregló, entra a la primera. No hay bucle: lo aparcado no
1239
+ * vuelve solo a `pending()`.
1240
+ *
1241
+ * Exige `dead`, `requeue` y `laterApplied` en el puerto (500
1242
+ * `E_AUTHZ_UNSUPPORTED` nombrando el que falte): sin censo no se sabe qué
1243
+ * se reencola y sin sonda no hay requeue. No pasa por la barrera del freeze:
1244
+ * no escribe en el backend; la pasada que aplique la entrada sí la pasa.
1245
+ */
1246
+ async requeueScopeChanges(ids, options = {}) {
1247
+ const outbox = this.#outbox();
1248
+ if (!outbox) {
1249
+ throw new AuthorizationConfigError("authz:scopes:requeue necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada aparcado que reencolar.");
1250
+ }
1251
+ const wanted = _a.#requeueIds(ids);
1252
+ for (const method of ['dead', 'requeue', 'laterApplied']) {
1253
+ if (typeof outbox[method] !== 'function') {
1254
+ throw new UnsupportedOperationError(method, 'authz:scopes:requeue', 'scopes.outbox', method === 'laterApplied'
1255
+ ? 'Sin la sonda anti-reversión (laterApplied: las entradas APLICADAS posteriores que comparten clave) no hay ' +
1256
+ 'requeue: reaplicar una entrada vieja por encima de una nueva revierte el árbol del store en silencio.'
1257
+ : method === 'dead'
1258
+ ? 'Sin dead() no se sabe qué está aparcado, y solo lo aparcado se reencola.'
1259
+ : 'Devuelve la entrada aparcada a pending() con attempts = maxAttempts − 1, todo o nada.');
1260
+ }
1261
+ }
1262
+ // El censo de lo aparcado (acotado como el del relay): solo eso se reencola.
1263
+ const parked = await outbox.dead(DEFAULT_RELAY_LIMIT);
1264
+ const byId = new Map();
1265
+ for (const item of parked)
1266
+ byId.set(String(item.id), item);
1267
+ const unknown = wanted.filter((id) => !byId.has(String(id)));
1268
+ if (unknown.length > 0) {
1269
+ throw new AuthorizationConfigError(`authz:scopes:requeue: ${unknown.map(String).join(', ')} no está(n) aparcada(s) —no existe, ya se aplicó, o sigue ` +
1270
+ `pendiente con intentos por gastar—: no se ha reencolado NADA (todo o nada). Solo se reencola lo que sale por ` +
1271
+ `dead(); una entrada aplicada no revive jamás.`);
1272
+ }
1273
+ const conflicts = [];
1274
+ for (const id of wanted) {
1275
+ const item = byId.get(String(id));
1276
+ const scopes = item.change.op === 'detached' ? [item.change.child] : [item.change.child, item.change.parent];
1277
+ // Se pide UNA de más para saber si el diff que va a leer el operador está cortado (cierre, ⚪ 6).
1278
+ const later = await outbox.laterApplied(item.id, scopes, DEFAULT_RELAY_BATCH + 1);
1279
+ const applied = later.slice(0, DEFAULT_RELAY_BATCH);
1280
+ if (applied.length > 0) {
1281
+ conflicts.push({ parked: item, applied, ...(later.length > DEFAULT_RELAY_BATCH ? { truncated: true } : {}) });
1282
+ }
1283
+ }
1284
+ const force = options.force === true;
1285
+ if (conflicts.length > 0 && !force) {
1286
+ const detail = conflicts
1287
+ .map((conflict) => {
1288
+ const keys = _a.#changeKeys(conflict.parked.change);
1289
+ const shared = conflict.applied.map((entry) => {
1290
+ const key = _a.#changeKeys(entry.change).find((k) => keys.includes(k)) ?? keys[0];
1291
+ return `#${entry.id} (${entry.change.op}, clave ${key})`;
1292
+ });
1293
+ return `#${conflict.parked.id} (${conflict.parked.change.op}) revertiría ${shared.join(', ')}`;
1294
+ })
1295
+ .join('; ');
1296
+ throw new ScopeRequeueRefusedError(`authz:scopes:requeue: reencolar reaplicaría un cambio ANTERIOR a otro ya APLICADO sobre la misma clave de scope, ` +
1297
+ `y el árbol del store quedaría en un orden que nunca existió: ${detail}. No se ha reencolado nada. ` +
1298
+ `Para recalcular con el árbol de HOY: authz:reconcile --to=<driver>. Si de verdad quieres revertir, el comando ` +
1299
+ `ace admite --force (imprime este diff y entra igual).`, conflicts);
1300
+ }
1301
+ await outbox.requeue([...wanted]);
1302
+ return { requeued: wanted.map((id) => byId.get(String(id))), overrides: conflicts, forced: force };
1303
+ }
1304
+ /** Los ids de `requeue`: lista NO vacía de números o cadenas, sin repetidos (500 si no). */
1305
+ static #requeueIds(ids) {
1306
+ if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
1307
+ throw new AuthorizationConfigError(`authz:scopes:requeue: 'ids' debe ser una lista NO vacía de ids (number | string); llegó ${JSON.stringify(ids)}. ` +
1308
+ 'Sin ids no se reencola nada: «nada» no es «todo».');
1309
+ }
1310
+ const seen = new Set();
1311
+ return ids.filter((id) => (seen.has(String(id)) ? false : (seen.add(String(id)), true)));
1312
+ }
906
1313
  /**
907
1314
  * **`authz:reconcile --to=<driver>`** (3b-3a): la ÚNICA primitiva de
908
1315
  * migración y verificación del paquete, y el motivo de la fase entera —
@@ -975,6 +1382,9 @@ export class AuthorizationManager {
975
1382
  // UNA vez.
976
1383
  facts: origin.enumerate,
977
1384
  factsOrigin: { name: origin.name, authzTables: origin.authzTables },
1385
+ // El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
1386
+ // el mismo mando que el gate de `scopes.*`.
1387
+ ...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
978
1388
  };
979
1389
  const pass = async () => {
980
1390
  const report = await target.reconcile(source, options);
@@ -1432,8 +1842,30 @@ export class AuthorizationManager {
1432
1842
  throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
1433
1843
  `colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
1434
1844
  }
1845
+ this.#assertDepth(chain, canonicalChild, parent, operation);
1435
1846
  return { chain, child: canonicalChild };
1436
1847
  }
1848
+ /**
1849
+ * **El gate de profundidad, opt-in** (2.4.0-alpha.5 · R0; `scopes.maxDepth`).
1850
+ * La cadena del padre incluye `app`, así que su longitud ES la profundidad
1851
+ * a la que quedaría el hijo (saltos `parent` hasta la raíz): cero I/O de
1852
+ * más. Se juzga ANTES de escribir o encolar. PARCIAL a propósito: no ve la
1853
+ * altura del subárbol que un `moved` arrastra (ver `define_config.ts`).
1854
+ */
1855
+ #assertDepth(chain, child, parent, operation) {
1856
+ const maxDepth = this.#config.scopes?.maxDepth;
1857
+ if (maxDepth === undefined)
1858
+ return;
1859
+ if (typeof maxDepth !== 'number' || !Number.isInteger(maxDepth) || maxDepth < 1) {
1860
+ throw new AuthorizationConfigError(`${operation}: scopes.maxDepth debe ser un entero >= 1 (llegó ${String(maxDepth)}); quítalo si no quieres techo.`);
1861
+ }
1862
+ if (chain.length > maxDepth) {
1863
+ throw new ScopeDepthLimitError(`${operation}: colgar ${child.type}:${child.uuid} de ${parent.type}:${parent.uuid ?? ''} lo dejaría a ${chain.length} ` +
1864
+ `saltos de app y scopes.maxDepth=${maxDepth}. No se ha escrito ni encolado nada. El techo existe porque el modelo ` +
1865
+ `facts de openfga no resuelve de forma fiable más allá de FACTS_MAX_RESOLVE_DEPTH (22): pasado él, authorize es ` +
1866
+ `503 para todo ese subárbol. Aplana el árbol, o sube el techo (y OPENFGA_RESOLVE_NODE_LIMIT en el servidor).`);
1867
+ }
1868
+ }
1437
1869
  // La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
1438
1870
  // pregunta mal formada (uuid ausente, `{app, uuid}`, slug con `~`) es 422
1439
1871
  // sin tocar catálogo, árbol ni backend, y sin que el hook `onWrite` audite