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

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 (67) hide show
  1. package/README.md +92 -3
  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 +16 -13
  15. package/build/commands/main.d.ts.map +1 -1
  16. package/build/commands/main.js +59 -11
  17. package/build/commands/main.js.map +1 -1
  18. package/build/commands/openfga_provision.d.ts +49 -3
  19. package/build/commands/openfga_provision.d.ts.map +1 -1
  20. package/build/commands/openfga_provision.js +72 -7
  21. package/build/commands/openfga_provision.js.map +1 -1
  22. package/build/index.d.ts +3 -3
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +2 -2
  25. package/build/index.js.map +1 -1
  26. package/build/src/define_config.d.ts +24 -0
  27. package/build/src/define_config.d.ts.map +1 -1
  28. package/build/src/define_config.js.map +1 -1
  29. package/build/src/drivers/openfga_driver.d.ts +4 -2
  30. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  31. package/build/src/drivers/openfga_driver.js +38 -5
  32. package/build/src/drivers/openfga_driver.js.map +1 -1
  33. package/build/src/errors.d.ts +30 -0
  34. package/build/src/errors.d.ts.map +1 -1
  35. package/build/src/errors.js +32 -0
  36. package/build/src/errors.js.map +1 -1
  37. package/build/src/manager.d.ts +106 -5
  38. package/build/src/manager.d.ts.map +1 -1
  39. package/build/src/manager.js +425 -26
  40. package/build/src/manager.js.map +1 -1
  41. package/build/src/relations/manager.d.ts +13 -1
  42. package/build/src/relations/manager.d.ts.map +1 -1
  43. package/build/src/relations/manager.js +15 -1
  44. package/build/src/relations/manager.js.map +1 -1
  45. package/build/src/scope_outbox.d.ts +21 -0
  46. package/build/src/scope_outbox.d.ts.map +1 -1
  47. package/build/src/scope_outbox.js +211 -24
  48. package/build/src/scope_outbox.js.map +1 -1
  49. package/build/src/sql_descendants.d.ts +8 -0
  50. package/build/src/sql_descendants.d.ts.map +1 -1
  51. package/build/src/sql_descendants.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 +255 -18
  64. package/build/src/types.d.ts.map +1 -1
  65. package/build/src/types.js +51 -0
  66. package/build/src/types.js.map +1 -1
  67. package/package.json +1 -1
@@ -9,8 +9,8 @@ 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';
13
- import { APP_SCOPE_TYPE } from './types.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 { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
14
14
  import { memoizeAncestors } from './memoize_ancestors.js';
15
15
  import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
16
16
  /** Longitudes de `authz_roles.name`/`description` (el esquema publicado). */
@@ -62,6 +62,17 @@ 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
+ * **Cota PROPIA del censo de aparcadas** (alpha.5 · cierre, auditor 🟠 2). Las
67
+ * claves de `dead()` que taponan `attached`/`moved` se leen COMPLETAS en cada
68
+ * pasada, sean cuales sean `limit` y `batchSize` (con `--limit 50` o la receta
69
+ * inline `{ only, limit: 1 }` un censo acotado por `limit` dejaba a la aparcada
70
+ * 101 sin taponar y el `attached` colgaba del padre VIEJO — y la sonda de
71
+ * `only` no la ve porque `earlierPending` excluye lo aparcado). Si hay más
72
+ * aparcadas que esto, la pasada se NIEGA (500) en vez de aplicar con un tapón
73
+ * incompleto: una cola así es un incidente, no un lote.
74
+ */
75
+ export const PARKED_CENSUS_MAX = 50_000;
65
76
  /** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
66
77
  export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
67
78
  /** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
@@ -201,6 +212,37 @@ export class AuthorizationManager {
201
212
  this.#driver = driver;
202
213
  return driver;
203
214
  }
215
+ /**
216
+ * **Lo que el driver ACTIVO declara, de solo lectura** (2.4.0-alpha.4;
217
+ * pregunta 1 de COGNITIV, panel alpha.4). Para que el código de tenant
218
+ * pueda ramificar —`if ((await authorization.capabilities()).transactionalWrites)
219
+ * grant(…, { transaction }) else grant(…)`— sin llamar a `driver()`, que
220
+ * sigue siendo API de PLATAFORMA (el grep del README sigue significando lo
221
+ * que significa).
222
+ *
223
+ * Es `(await this.driver()).capabilities` y NADA más, a propósito:
224
+ * - las capacidades son de la INSTANCIA (`database` decide
225
+ * `transactionalWrites` desde sus opciones), así que hay que resolver;
226
+ * - y se resuelve POR `driver()`, con sus dos puertas
227
+ * (`requireTransactionalWrites` y el gate de deriva del árbol) y su
228
+ * caché. Resolverlo por otro camino «para que la lectura no lance» y
229
+ * guardarlo en `#driver` dejaría `driver()` sirviendo un driver que
230
+ * nunca pasó por las puertas: un bypass permanente del gate de deriva
231
+ * (🔴 1 del auditor). Por eso hereda los 500 de las puertas
232
+ * (`E_AUTHZ_CONFIG`, `E_AUTHZ_SCOPE_DRIFT_UNGUARDED`) y la factory del
233
+ * consumidor: llámalo al arrancar (un hook `booted`/`ready`), no por
234
+ * request en un endpoint de metadatos.
235
+ *
236
+ * Devuelve una copia CONGELADA y NORMALIZADA: todas las claves de
237
+ * `AuthorizationDriverCapabilities` presentes, solo `true` es `true`
238
+ * (la misma lectura que la puerta 1), nunca `undefined`; mutarla no toca
239
+ * el objeto que leen las puertas. La vista de `forRequest()` delega en el
240
+ * padre (por `driver()`).
241
+ */
242
+ async capabilities() {
243
+ const driver = await this.driver();
244
+ return normalizeAuthorizationCapabilities(driver.capabilities);
245
+ }
204
246
  /**
205
247
  * **Puerta 2 de `{ transaction }`** (L-2, opt-in): con
206
248
  * `requireTransactionalWrites: true` el driver activo tiene que declarar
@@ -588,10 +630,11 @@ export class AuthorizationManager {
588
630
  await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
589
631
  const outbox = this.#outbox();
590
632
  if (outbox) {
591
- await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
592
- return;
633
+ const receipt = await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
634
+ return _a.#receiptOf([receipt]);
593
635
  }
594
636
  await (await this.driver()).onScopeAttached?.(child, parent);
637
+ return { queued: [] };
595
638
  },
596
639
  /**
597
640
  * `moved` NO vuelve a juzgar el catálogo, y no tiene por qué (3b-1 · D3,
@@ -613,10 +656,11 @@ export class AuthorizationManager {
613
656
  await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
614
657
  const outbox = this.#outbox();
615
658
  if (outbox) {
616
- await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
617
- return;
659
+ const receipt = await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
660
+ return _a.#receiptOf([receipt]);
618
661
  }
619
662
  await (await this.driver()).onScopeMoved?.(child, newParent);
663
+ return { queued: [] };
620
664
  },
621
665
  /**
622
666
  * Hechos primero (el driver demuestra cero o lanza), arista después
@@ -670,10 +714,12 @@ export class AuthorizationManager {
670
714
  // La identidad se resuelve AQUÍ, con la fila del consumidor todavía
671
715
  // viva si la hay: al relevar el cambio ya no resolvería. Y no se
672
716
  // audita `scope_purged` todavía, porque todavía no ha pasado nada.
717
+ // El recibo es PLURAL por esto mismo: una fila por ortografía.
718
+ const receipts = [];
673
719
  for (const target of targets) {
674
- await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor });
720
+ receipts.push(await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor }));
675
721
  }
676
- return;
722
+ return _a.#receiptOf(receipts);
677
723
  }
678
724
  const driver = await this.driver();
679
725
  for (const purged of targets) {
@@ -682,8 +728,23 @@ export class AuthorizationManager {
682
728
  await driver.onScopeDetached?.(purged);
683
729
  await this.#notify(event);
684
730
  }
731
+ return { queued: [] };
685
732
  },
686
733
  };
734
+ /**
735
+ * El recibo del encolado (alpha.5 · R1): los ids que la outbox devolvió,
736
+ * en orden. Una outbox que devuelve `void` (terceros anteriores a alpha.5)
737
+ * no deja id, y el recibo sale vacío: nunca se inventa uno.
738
+ */
739
+ static #receiptOf(receipts) {
740
+ const queued = [];
741
+ for (const receipt of receipts) {
742
+ if (receipt && typeof receipt === 'object' && (typeof receipt.id === 'number' || typeof receipt.id === 'string')) {
743
+ queued.push(receipt.id);
744
+ }
745
+ }
746
+ return { queued };
747
+ }
687
748
  /**
688
749
  * **Drena la outbox del árbol y aplica los cambios al driver** (3b-2d).
689
750
  * Es lo que hay detrás de `node ace authz:scopes:relay`.
@@ -727,6 +788,26 @@ export class AuthorizationManager {
727
788
  * **fail-open temporal** —el tenant antiguo conserva acceso tras un
728
789
  * `moved`, los denies heredados no aplican tras un `attached`—. No hay
729
790
  * 2PC; es el precio de tener el árbol en dos sitios.
791
+ *
792
+ * **`only` (2.4.0-alpha.5 · R1)**: relevar SOLO estas entradas —los ids del
793
+ * recibo de `scopes.*` que ESTE proceso acaba de recibir, jamás del cuerpo
794
+ * de una petición— sin leer la cola de los demás (medido: 4 507 ms → 19 ms
795
+ * con 500 entradas ajenas por delante). El orden por clave NO se relaja:
796
+ * antes de aplicar cada entrada pedida se sondea la cola ENTERA
797
+ * (`earlierPending`, o paginando `pending()` si la outbox no lo tiene) y,
798
+ * si hay una pendiente ANTERIOR que comparte clave de scope con ella, sale
799
+ * `deferred` con `depends-on:<id>` — nunca aplicada fuera de orden (el 🔴 1
800
+ * del panel relay, medido: un `attached(child, unit)` aplicado antes que su
801
+ * `moved(unit → orgB)` dejaba al editor de la org VIEJA autorizando sobre el
802
+ * hijo). `remaining` y `dead` siguen siendo GLOBALES. `only: []` es 500
803
+ * (un `filter` vacío del consumidor no puede acabar en drenaje ni en un
804
+ * reporte vacío en verde), y una outbox que no honre el filtro también.
805
+ *
806
+ * **Lo aparcado tapona por clave (alpha.5 · R2a)**: las claves de `dead()`
807
+ * siembran el bloqueo al inicio de la pasada para lo que CONCEDE o recuelga
808
+ * (`attached`/`moved`, `deferred` con `parked:<id>`) y dejan pasar lo que
809
+ * RETIRA (`detached`: una purga nunca queda bloqueada, invariante 11). Y un
810
+ * 503 del backend NO consume intento (`markFailed(..., { transient })`).
730
811
  */
731
812
  async relayScopeChanges(options = {}) {
732
813
  // El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
@@ -740,11 +821,28 @@ export class AuthorizationManager {
740
821
  const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
741
822
  const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
742
823
  const dryRun = options.dryRun === true;
743
- /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE. */
824
+ const only = _a.#onlyOf(options.only);
825
+ // Al puerto van los ids TAL CUAL los dio el llamante (el recibo); el
826
+ // conjunto de cadenas es solo para comparar sin depender del tipo.
827
+ const filter = only === undefined ? undefined : { ids: [...options.only] };
828
+ /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE, y GLOBAL. */
744
829
  const dead = await _a.#deadLetters(outbox, batchSize);
830
+ /** Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`. */
831
+ const deadEntries = dead
832
+ .filter((item) => only === undefined || only.has(String(item.id)))
833
+ .map((item) => ({
834
+ id: item.id,
835
+ change: item.change,
836
+ status: 'dead',
837
+ attempts: item.attempts,
838
+ ...(item.error === undefined ? {} : { reason: item.error }),
839
+ }));
840
+ const missingOf = (seen) => only === undefined ? [] : [...only.values()].filter((id) => !seen.has(String(id))).map((id) => _a.#rawId(options.only, id));
745
841
  if (dryRun) {
746
- const batch = await outbox.pending(limit);
842
+ const batch = await outbox.pending(limit, undefined, filter);
843
+ _a.#assertOnlyHonoured(batch, only);
747
844
  const extra = batch.length >= limit ? true : false;
845
+ const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
748
846
  return {
749
847
  applied: [],
750
848
  failed: null,
@@ -755,6 +853,8 @@ export class AuthorizationManager {
755
853
  remaining: batch.length > 0 || extra,
756
854
  dryRun: true,
757
855
  wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
856
+ entries: deadEntries,
857
+ missing: missingOf(seen),
758
858
  };
759
859
  }
760
860
  // El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
@@ -772,6 +872,8 @@ export class AuthorizationManager {
772
872
  remaining: (await outbox.pending(1)).length > 0,
773
873
  dryRun: false,
774
874
  wouldApply: [],
875
+ entries: deadEntries,
876
+ missing: [],
775
877
  };
776
878
  }
777
879
  try {
@@ -779,8 +881,33 @@ export class AuthorizationManager {
779
881
  const applied = [];
780
882
  const deferred = [];
781
883
  const failures = [];
782
- /** Claves de scope contaminadas: lo que las toque se aplaza. */
783
- const blocked = new Set();
884
+ const entries = [];
885
+ /**
886
+ * Claves de scope contaminadas ⇒ el id que las contaminó y el ORIGEN del
887
+ * bloqueo (alpha.5 · cierre, auditor 🟠 1). `parked: true` = la causa
888
+ * raíz (transitiva) es una entrada APARCADA: tapona solo lo que concede
889
+ * o recuelga (`attached`/`moved`), NUNCA un `detached` — si no, un
890
+ * `attached` aplazado por aparcado congelaba la purga del nodo que el
891
+ * tenant ya borró, para siempre (medido: el scope conservaba sus hechos
892
+ * y su arista, y seguía concediendo; invariante 11). `parked: false` =
893
+ * un FALLO real de esta pasada (o una pendiente anterior no pedida):
894
+ * contamina todo, `detached` incluido — el orden entre un `attached`
895
+ * que falló y la purga de ese mismo nodo sigue importando (3b-2h).
896
+ */
897
+ const blocked = new Map();
898
+ const block = (key, id, parked) => {
899
+ const current = blocked.get(key);
900
+ // Un fallo real pisa a un bloqueo por aparcado; nunca al revés.
901
+ if (current === undefined || (current.parked && !parked))
902
+ blocked.set(key, { id, parked });
903
+ };
904
+ /**
905
+ * **Las claves de lo APARCADO** (alpha.5 · R2a): taponan lo que concede
906
+ * o recuelga y dejan pasar lo que retira. El censo es COMPLETO —no
907
+ * depende de `limit` ni de `batchSize` (cierre, 🟠 2)— con su propia
908
+ * cota; el reporte sigue enseñando las primeras `batchSize`.
909
+ */
910
+ const parkedKeys = await _a.#parkedKeys(outbox, dead, batchSize);
784
911
  // Una outbox que no marca lo aplicado devolvería el mismo pendiente
785
912
  // para siempre: el relay no puede quedarse dando vueltas ni
786
913
  // "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
@@ -790,6 +917,13 @@ export class AuthorizationManager {
790
917
  const parked = new Set();
791
918
  /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
792
919
  let after;
920
+ const defer = (item, keys, reason, error, byParked) => {
921
+ for (const key of keys)
922
+ block(key, item.id, byParked);
923
+ parked.add(String(item.id));
924
+ deferred.push({ id: item.id, change: item.change, attempts: item.attempts, error });
925
+ entries.push({ id: item.id, change: item.change, status: 'deferred', reason, attempts: item.attempts });
926
+ };
793
927
  outer: while (applied.length < limit) {
794
928
  // **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
795
929
  // mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
@@ -804,9 +938,10 @@ export class AuthorizationManager {
804
938
  // lo ya aplicado está marcado en la outbox (la pasada es reanudable)
805
939
  // y el resto sigue pendiente para después de la ventana.
806
940
  await this.#assertNotFrozen('authz:scopes:relay');
807
- const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after);
941
+ const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after, filter);
808
942
  if (batch.length === 0)
809
943
  break;
944
+ _a.#assertOnlyHonoured(batch, only);
810
945
  let progress = false;
811
946
  for (const item of batch) {
812
947
  const id = String(item.id);
@@ -821,34 +956,55 @@ export class AuthorizationManager {
821
956
  if (applied.length >= limit)
822
957
  break outer;
823
958
  const keys = _a.#changeKeys(item.change);
824
- const collision = keys.find((key) => blocked.has(key));
959
+ // Una purga solo la frena un bloqueo cuyo origen sea un FALLO real;
960
+ // el que nace de un aparcado la deja pasar (invariante 11).
961
+ const collision = keys.find((key) => {
962
+ const hit = blocked.get(key);
963
+ return hit !== undefined && !(item.change.op === 'detached' && hit.parked);
964
+ });
825
965
  if (collision !== undefined) {
826
- for (const key of keys)
827
- blocked.add(key);
828
- parked.add(id);
829
- deferred.push({
830
- id: item.id,
831
- change: item.change,
832
- attempts: item.attempts,
833
- error: `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada`,
834
- });
966
+ const by = blocked.get(collision);
967
+ defer(item, keys, `depends-on:${by.id}`, `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada (#${by.id})`, by.parked);
835
968
  continue;
836
969
  }
970
+ // Lo aparcado tapona lo que CONCEDE o recuelga; una purga pasa.
971
+ if (item.change.op !== 'detached') {
972
+ const parkedKey = keys.find((key) => parkedKeys.has(key));
973
+ if (parkedKey !== undefined) {
974
+ const by = parkedKeys.get(parkedKey);
975
+ 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);
976
+ continue;
977
+ }
978
+ }
979
+ // Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
980
+ // que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
981
+ if (only !== undefined) {
982
+ const earlier = await this.#dependencyOf(outbox, item, batchSize);
983
+ if (earlier !== null) {
984
+ const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
985
+ 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}); ` +
986
+ 'corre el relay completo', false);
987
+ continue;
988
+ }
989
+ }
837
990
  try {
838
991
  await this.#applyScopeChange(driver, item);
839
992
  }
840
993
  catch (error) {
841
994
  const message = error instanceof Error ? error.message : String(error);
842
- await outbox.markFailed(item.id, message);
995
+ const transient = _a.#isTransientRelayFailure(error);
996
+ await outbox.markFailed(item.id, message, { transient });
843
997
  for (const key of keys)
844
- blocked.add(key);
998
+ block(key, item.id, false);
845
999
  parked.add(id);
846
- failures.push({ id: item.id, change: item.change, error: message });
1000
+ failures.push({ id: item.id, change: item.change, error: message, transient });
1001
+ entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
847
1002
  continue;
848
1003
  }
849
1004
  await outbox.markApplied(item.id);
850
1005
  done.add(id);
851
1006
  applied.push({ id: item.id, change: item.change, attempts: item.attempts });
1007
+ entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
852
1008
  }
853
1009
  // Una outbox que ignora `after` devuelve el mismo lote atascado: la
854
1010
  // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
@@ -856,6 +1012,7 @@ export class AuthorizationManager {
856
1012
  if (!progress)
857
1013
  break;
858
1014
  }
1015
+ entries.push(...deadEntries);
859
1016
  return {
860
1017
  applied,
861
1018
  failed: failures[0] ?? null,
@@ -866,12 +1023,229 @@ export class AuthorizationManager {
866
1023
  remaining: (await outbox.pending(1)).length > 0,
867
1024
  dryRun: false,
868
1025
  wouldApply: [],
1026
+ entries,
1027
+ missing: missingOf(new Set(entries.map((e) => String(e.id)))),
869
1028
  };
870
1029
  }
871
1030
  finally {
872
1031
  await lease?.release();
873
1032
  }
874
1033
  }
1034
+ /**
1035
+ * `only` normalizado (alpha.5 · R1): un conjunto de ids como cadena (el id
1036
+ * de `sqlScopeOutbox` es un entero que según el motor y el driver llega
1037
+ * como `number` o como `string`; comparar con `===` sería un no-op
1038
+ * silencioso). `undefined` = sin filtro. **`[]` es 500**, nunca «sin
1039
+ * filtro»: un `filter` vacío del consumidor —o una outbox de terceros sin
1040
+ * recibo, que deja `queued: []`— no puede acabar drenando la cola entera
1041
+ * bajo el lease de una petición HTTP, ni en un reporte vacío en verde.
1042
+ */
1043
+ static #onlyOf(only) {
1044
+ if (only === undefined)
1045
+ return undefined;
1046
+ if (!Array.isArray(only) || only.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
1047
+ throw new AuthorizationConfigError(`authz:scopes:relay: 'only' debe ser una lista de ids (number | string); llegó ${JSON.stringify(only)}`);
1048
+ }
1049
+ if (only.length === 0) {
1050
+ 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 " +
1051
+ 'se aplicó en línea; con una outbox de terceros sin recibo no hay `only`): relevar «nada» no es relevar «todo». ' +
1052
+ 'Quita `only` para drenar la cola entera.');
1053
+ }
1054
+ return new Set(only.map(String));
1055
+ }
1056
+ /** El id tal como lo pasó el llamante en `only` (para `missing`). */
1057
+ static #rawId(only, key) {
1058
+ return only.find((id) => String(id) === key) ?? key;
1059
+ }
1060
+ /**
1061
+ * Una outbox que IGNORA `filter.ids` devuelve lo que no se pidió: el
1062
+ * backlog ajeno se aplicaría dentro de la petición del consumidor, en
1063
+ * silencio. Se detecta y se para (500), como el `markApplied` no honrado.
1064
+ */
1065
+ static #assertOnlyHonoured(batch, only) {
1066
+ if (only === undefined)
1067
+ return;
1068
+ const stranger = batch.find((item) => !only.has(String(item.id)));
1069
+ if (stranger !== undefined) {
1070
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox devolvió el cambio ${String(stranger.id)}, que no está en 'only'. ` +
1071
+ 'Tu implementación de ScopeOutbox no honra `pending(limit, after, { ids })`: el relay para antes de aplicar ' +
1072
+ 'lo que no se pidió (filtra en el puerto, no en memoria).');
1073
+ }
1074
+ }
1075
+ /**
1076
+ * La caída del BACKEND no dice nada de la entrada (alpha.5 · R2a): un
1077
+ * `AuthorizationBackendError` (503; el timeout es uno), y en general
1078
+ * cualquier 503 (el freeze, el resolutor caído). Todo lo demás —422, 500,
1079
+ * `E_AUTHZ_SCOPE_TREE_DRIFT`, el padre inexistente, un error sin status de
1080
+ * un driver de terceros— es de la entrada y cuenta hacia el aparcado.
1081
+ */
1082
+ static #isTransientRelayFailure(error) {
1083
+ if (error instanceof AuthorizationBackendError)
1084
+ return true;
1085
+ return typeof error === 'object' && error !== null && error.status === 503;
1086
+ }
1087
+ /**
1088
+ * Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
1089
+ * (alpha.5 · R2a). El censo es el `dead` ya leído si cabe entero en un
1090
+ * lote (menos de `batchSize` filas = no hay más); si no, se relee ENTERO
1091
+ * hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit` (cierre, 🟠 2:
1092
+ * con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no taponaba y el
1093
+ * `attached` colgaba del padre viejo)—, y si la cota se alcanza la pasada
1094
+ * se niega (500): un tapón incompleto no es un tapón.
1095
+ */
1096
+ static async #parkedKeys(outbox, dead, batchSize) {
1097
+ const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
1098
+ if (census.length >= PARKED_CENSUS_MAX) {
1099
+ 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 ` +
1100
+ `(PARKED_CENSUS_MAX): sin el censo entero el tapón por clave sería ciego y un attached podría colgarse del padre ` +
1101
+ `VIEJO. No se ha aplicado nada. Mira dead(), reencola o reconcilia antes de volver a relevar.`);
1102
+ }
1103
+ const keys = new Map();
1104
+ for (const item of census) {
1105
+ for (const key of _a.#changeKeys(item.change))
1106
+ if (!keys.has(key))
1107
+ keys.set(key, item.id);
1108
+ }
1109
+ return keys;
1110
+ }
1111
+ /**
1112
+ * **La sonda de dependencia de `only`** (alpha.5 · R1): la pendiente más
1113
+ * antigua ANTERIOR a `item` que comparta clave de scope con ella, o `null`.
1114
+ * Con `earlierPending` es una consulta de existencia (0,69 ms medidos);
1115
+ * sin ella se pagina `pending()` desde el principio hasta `item.id` —
1116
+ * correcto, O(cola)— y si la cola no se deja recorrer en
1117
+ * `RELAY_WINDOW_MAX_PAGES` lotes se para (500 nombrando el método), nunca
1118
+ * se aplica sin saber.
1119
+ */
1120
+ async #dependencyOf(outbox, item, batchSize) {
1121
+ const keys = _a.#changeKeys(item.change);
1122
+ if (typeof outbox.earlierPending === 'function') {
1123
+ const scopes = item.change.op === 'detached' ? [item.change.child] : [item.change.child, item.change.parent];
1124
+ return outbox.earlierPending(item.id, scopes);
1125
+ }
1126
+ const mine = String(item.id);
1127
+ let after;
1128
+ for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
1129
+ const batch = await outbox.pending(batchSize, after);
1130
+ for (const earlier of batch) {
1131
+ if (String(earlier.id) === mine)
1132
+ return null;
1133
+ if (_a.#changeKeys(earlier.change).some((key) => keys.includes(key)))
1134
+ return earlier;
1135
+ }
1136
+ if (batch.length < batchSize)
1137
+ return null;
1138
+ after = batch[batch.length - 1].id;
1139
+ }
1140
+ 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 ` +
1141
+ `${RELAY_WINDOW_MAX_PAGES * batchSize} pendientes por delante y tu ScopeOutbox no implementa earlierPending(). ` +
1142
+ 'Implementa la sonda, o releva sin `only`.');
1143
+ }
1144
+ /**
1145
+ * **Devuelve entradas APARCADAS a la cola** (2.4.0-alpha.5 · R2b; panel
1146
+ * relay, cuestión 2). Es lo que hay detrás de `node ace authz:scopes:requeue
1147
+ * --id`. Operación de PLATAFORMA como `relayScopeChanges` y `driver()`: sin
1148
+ * actor, sin `within`, **no se expone por HTTP**.
1149
+ *
1150
+ * **La sonda anti-reversión vive AQUÍ, no en la outbox**: reaplicar una
1151
+ * entrada ANTERIOR a otra ya APLICADA sobre la misma clave de scope
1152
+ * reescribe el árbol del store en un orden que nunca existió — el `moved`
1153
+ * viejo tras el nuevo deja el padre VIEJO con UN solo padre, así que
1154
+ * `assertOneParent` no lo delata y el tenant antiguo recupera el acceso
1155
+ * (auditor 🔴 2, `relay-auditor-5.ts`, medido). Por eso, antes de tocar la
1156
+ * cola, cada aparcada pedida se compara contra las aplicadas posteriores
1157
+ * que compartan clave (`outbox.laterApplied`, espejo de `earlierPending`)
1158
+ * y si hay alguna se NIEGA con 500 `E_AUTHZ_REQUEUE_REFUSED` nombrándola —
1159
+ * todo o nada, cero filas tocadas—. El remedio correcto es
1160
+ * `authz:reconcile --to=<driver>`, que recalcula con el árbol de HOY. La
1161
+ * sonda es del manager porque necesita las claves del cambio
1162
+ * (`#changeKeys`); `outbox.requeue` queda mecánico y una outbox de
1163
+ * terceros la hereda gratis.
1164
+ *
1165
+ * **`force`** salta la sonda y devuelve el diff en `overrides`. Existe para
1166
+ * el comando ace —que lo imprime y exige el flag explícito (decisión del
1167
+ * dueño del 2026-09-09 (2))— y para nada más: llamarlo con `force: true`
1168
+ * desde un endpoint es policy en una API sin policy.
1169
+ *
1170
+ * `attempts` queda en `maxAttempts − 1` (lo hace la outbox): si la causa
1171
+ * sigue rota la entrada vuelve a aparcarse en UNA pasada y contamina su
1172
+ * clave solo en esa pasada —un requeue automatizado de lo irreparable no
1173
+ * es un grifo de aplazamiento permanente (auditor 🔴 2, segunda mitad)—; si
1174
+ * el operador la arregló, entra a la primera. No hay bucle: lo aparcado no
1175
+ * vuelve solo a `pending()`.
1176
+ *
1177
+ * Exige `dead`, `requeue` y `laterApplied` en el puerto (500
1178
+ * `E_AUTHZ_UNSUPPORTED` nombrando el que falte): sin censo no se sabe qué
1179
+ * se reencola y sin sonda no hay requeue. No pasa por la barrera del freeze:
1180
+ * no escribe en el backend; la pasada que aplique la entrada sí la pasa.
1181
+ */
1182
+ async requeueScopeChanges(ids, options = {}) {
1183
+ const outbox = this.#outbox();
1184
+ if (!outbox) {
1185
+ throw new AuthorizationConfigError("authz:scopes:requeue necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada aparcado que reencolar.");
1186
+ }
1187
+ const wanted = _a.#requeueIds(ids);
1188
+ for (const method of ['dead', 'requeue', 'laterApplied']) {
1189
+ if (typeof outbox[method] !== 'function') {
1190
+ throw new UnsupportedOperationError(method, 'authz:scopes:requeue', 'scopes.outbox', method === 'laterApplied'
1191
+ ? 'Sin la sonda anti-reversión (laterApplied: las entradas APLICADAS posteriores que comparten clave) no hay ' +
1192
+ 'requeue: reaplicar una entrada vieja por encima de una nueva revierte el árbol del store en silencio.'
1193
+ : method === 'dead'
1194
+ ? 'Sin dead() no se sabe qué está aparcado, y solo lo aparcado se reencola.'
1195
+ : 'Devuelve la entrada aparcada a pending() con attempts = maxAttempts − 1, todo o nada.');
1196
+ }
1197
+ }
1198
+ // El censo de lo aparcado (acotado como el del relay): solo eso se reencola.
1199
+ const parked = await outbox.dead(DEFAULT_RELAY_LIMIT);
1200
+ const byId = new Map();
1201
+ for (const item of parked)
1202
+ byId.set(String(item.id), item);
1203
+ const unknown = wanted.filter((id) => !byId.has(String(id)));
1204
+ if (unknown.length > 0) {
1205
+ throw new AuthorizationConfigError(`authz:scopes:requeue: ${unknown.map(String).join(', ')} no está(n) aparcada(s) —no existe, ya se aplicó, o sigue ` +
1206
+ `pendiente con intentos por gastar—: no se ha reencolado NADA (todo o nada). Solo se reencola lo que sale por ` +
1207
+ `dead(); una entrada aplicada no revive jamás.`);
1208
+ }
1209
+ const conflicts = [];
1210
+ for (const id of wanted) {
1211
+ const item = byId.get(String(id));
1212
+ const scopes = item.change.op === 'detached' ? [item.change.child] : [item.change.child, item.change.parent];
1213
+ // Se pide UNA de más para saber si el diff que va a leer el operador está cortado (cierre, ⚪ 6).
1214
+ const later = await outbox.laterApplied(item.id, scopes, DEFAULT_RELAY_BATCH + 1);
1215
+ const applied = later.slice(0, DEFAULT_RELAY_BATCH);
1216
+ if (applied.length > 0) {
1217
+ conflicts.push({ parked: item, applied, ...(later.length > DEFAULT_RELAY_BATCH ? { truncated: true } : {}) });
1218
+ }
1219
+ }
1220
+ const force = options.force === true;
1221
+ if (conflicts.length > 0 && !force) {
1222
+ const detail = conflicts
1223
+ .map((conflict) => {
1224
+ const keys = _a.#changeKeys(conflict.parked.change);
1225
+ const shared = conflict.applied.map((entry) => {
1226
+ const key = _a.#changeKeys(entry.change).find((k) => keys.includes(k)) ?? keys[0];
1227
+ return `#${entry.id} (${entry.change.op}, clave ${key})`;
1228
+ });
1229
+ return `#${conflict.parked.id} (${conflict.parked.change.op}) revertiría ${shared.join(', ')}`;
1230
+ })
1231
+ .join('; ');
1232
+ throw new ScopeRequeueRefusedError(`authz:scopes:requeue: reencolar reaplicaría un cambio ANTERIOR a otro ya APLICADO sobre la misma clave de scope, ` +
1233
+ `y el árbol del store quedaría en un orden que nunca existió: ${detail}. No se ha reencolado nada. ` +
1234
+ `Para recalcular con el árbol de HOY: authz:reconcile --to=<driver>. Si de verdad quieres revertir, el comando ` +
1235
+ `ace admite --force (imprime este diff y entra igual).`, conflicts);
1236
+ }
1237
+ await outbox.requeue([...wanted]);
1238
+ return { requeued: wanted.map((id) => byId.get(String(id))), overrides: conflicts, forced: force };
1239
+ }
1240
+ /** Los ids de `requeue`: lista NO vacía de números o cadenas, sin repetidos (500 si no). */
1241
+ static #requeueIds(ids) {
1242
+ if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
1243
+ throw new AuthorizationConfigError(`authz:scopes:requeue: 'ids' debe ser una lista NO vacía de ids (number | string); llegó ${JSON.stringify(ids)}. ` +
1244
+ 'Sin ids no se reencola nada: «nada» no es «todo».');
1245
+ }
1246
+ const seen = new Set();
1247
+ return ids.filter((id) => (seen.has(String(id)) ? false : (seen.add(String(id)), true)));
1248
+ }
875
1249
  /**
876
1250
  * **`authz:reconcile --to=<driver>`** (3b-3a): la ÚNICA primitiva de
877
1251
  * migración y verificación del paquete, y el motivo de la fase entera —
@@ -944,6 +1318,9 @@ export class AuthorizationManager {
944
1318
  // UNA vez.
945
1319
  facts: origin.enumerate,
946
1320
  factsOrigin: { name: origin.name, authzTables: origin.authzTables },
1321
+ // El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
1322
+ // el mismo mando que el gate de `scopes.*`.
1323
+ ...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
947
1324
  };
948
1325
  const pass = async () => {
949
1326
  const report = await target.reconcile(source, options);
@@ -1401,8 +1778,30 @@ export class AuthorizationManager {
1401
1778
  throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
1402
1779
  `colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
1403
1780
  }
1781
+ this.#assertDepth(chain, canonicalChild, parent, operation);
1404
1782
  return { chain, child: canonicalChild };
1405
1783
  }
1784
+ /**
1785
+ * **El gate de profundidad, opt-in** (2.4.0-alpha.5 · R0; `scopes.maxDepth`).
1786
+ * La cadena del padre incluye `app`, así que su longitud ES la profundidad
1787
+ * a la que quedaría el hijo (saltos `parent` hasta la raíz): cero I/O de
1788
+ * más. Se juzga ANTES de escribir o encolar. PARCIAL a propósito: no ve la
1789
+ * altura del subárbol que un `moved` arrastra (ver `define_config.ts`).
1790
+ */
1791
+ #assertDepth(chain, child, parent, operation) {
1792
+ const maxDepth = this.#config.scopes?.maxDepth;
1793
+ if (maxDepth === undefined)
1794
+ return;
1795
+ if (typeof maxDepth !== 'number' || !Number.isInteger(maxDepth) || maxDepth < 1) {
1796
+ throw new AuthorizationConfigError(`${operation}: scopes.maxDepth debe ser un entero >= 1 (llegó ${String(maxDepth)}); quítalo si no quieres techo.`);
1797
+ }
1798
+ if (chain.length > maxDepth) {
1799
+ throw new ScopeDepthLimitError(`${operation}: colgar ${child.type}:${child.uuid} de ${parent.type}:${parent.uuid ?? ''} lo dejaría a ${chain.length} ` +
1800
+ `saltos de app y scopes.maxDepth=${maxDepth}. No se ha escrito ni encolado nada. El techo existe porque el modelo ` +
1801
+ `facts de openfga no resuelve de forma fiable más allá de FACTS_MAX_RESOLVE_DEPTH (22): pasado él, authorize es ` +
1802
+ `503 para todo ese subárbol. Aplana el árbol, o sube el techo (y OPENFGA_RESOLVE_NODE_LIMIT en el servidor).`);
1803
+ }
1804
+ }
1406
1805
  // La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
1407
1806
  // pregunta mal formada (uuid ausente, `{app, uuid}`, slug con `~`) es 422
1408
1807
  // sin tocar catálogo, árbol ni backend, y sin que el hook `onWrite` audite