@jantstack/adonis-authz 2.5.0-alpha.4 → 2.5.0-rc.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.
@@ -775,11 +775,15 @@ export class AuthorizationManager {
775
775
  // se contrasta es la de origen, resuelta en fresco.
776
776
  attached: async (child, parent, options) => {
777
777
  const actor = await this.#writeOptions(options, 'scopes.attached');
778
- const edge = await this.#assertEdge(child, parent, 'scopes.attached');
778
+ // RC2-B (rotación 2 · cierre 2, firmado: REORDENAR): `within` —destino Y origen, en fresco— se decide ANTES que el
779
+ // anti-ciclos y el techo de profundidad. Al revés, `SCOPE_CYCLE`/`SCOPE_DEPTH_LIMIT` sobre un scope AJENO decían
780
+ // DÓNDE está (de qué otro scope ajeno desciende, a cuántos saltos de la raíz) antes del `NOT_WITHIN`.
781
+ const edge = await this.#resolveEdge(child, parent, 'scopes.attached');
779
782
  this.#assertWithinChain(parent, edge.chain, options, 'scopes.attached');
780
783
  // Un hijo que el árbol ya conoce se está MOVIENDO (el `attach` de un
781
784
  // nodo existente es un `move`): su origen también tiene que estar dentro.
782
785
  await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
786
+ this.#judgeEdge(edge, parent, 'scopes.attached');
783
787
  const outbox = this.#outbox();
784
788
  if (outbox) {
785
789
  const receipt = await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
@@ -803,9 +807,11 @@ export class AuthorizationManager {
803
807
  */
804
808
  moved: async (child, newParent, options) => {
805
809
  const actor = await this.#writeOptions(options, 'scopes.moved');
806
- const edge = await this.#assertEdge(child, newParent, 'scopes.moved');
810
+ // RC2-B: `within` (destino y origen) antes que el anti-ciclos y el techo (ver `attached`).
811
+ const edge = await this.#resolveEdge(child, newParent, 'scopes.moved');
807
812
  this.#assertWithinChain(newParent, edge.chain, options, 'scopes.moved');
808
813
  await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
814
+ this.#judgeEdge(edge, newParent, 'scopes.moved');
809
815
  const outbox = this.#outbox();
810
816
  if (outbox) {
811
817
  const receipt = await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
@@ -2075,7 +2081,7 @@ export class AuthorizationManager {
2075
2081
  *
2076
2082
  * **La raíz `app` NO es clave** (2.4.0-alpha.8; panel relay 2, cuestión 1;
2077
2083
  * decisión del dueño 2026-09-10 (4)). La raíz no se mueve, no se purga y
2078
- * nunca es hijo (los tres 422 de `#assertEdge`/`scopes.detached`), así que
2084
+ * nunca es hijo (los tres 422 de `#resolveEdge`/`#judgeEdge`/`scopes.detached`), así que
2079
2085
  * dos cambios cuyo ÚNICO cruce sea `app` conmutan: ningún orden que
2080
2086
  * conservar (el auditor lo buscó y no lo encontró; `m2-orden.ts`). Con
2081
2087
  * `[hijo, app]` toda alta de tenant compartía clave con todas las demás:
@@ -2398,16 +2404,35 @@ export class AuthorizationManager {
2398
2404
  return;
2399
2405
  this.#assertWithinChain(child, chain, options, operation);
2400
2406
  }
2401
- /** Lo mismo con una cadena ya resuelta (en fresco) por el llamante. */
2402
- #assertWithinChain(scope, chain, options, operation) {
2407
+ /**
2408
+ * Lo mismo con una cadena ya resuelta (en fresco) por el llamante. `asked` es cómo nombra el `message` lo que el
2409
+ * llamante PASÓ cuando el scope contrastado NO lo pasó él (RC2-A, rotación 2 · cierre 2: en `updateScopedRole`/
2410
+ * `deleteScopedRole` se contrasta el OWNER del rol, que sale del catálogo — el llamante solo pasó el uuid del rol).
2411
+ */
2412
+ #assertWithinChain(scope, chain, options, operation, asked) {
2403
2413
  const within = this.#requiredWithin(scope, options, operation);
2404
2414
  if (!within)
2405
2415
  return;
2406
2416
  if (!chain.some((s) => _a.#sameScope(s, within))) {
2407
- throw new NotWithinError(`${operation}: ${scope.type}:${scope.uuid ?? ''} no está dentro de ` +
2408
- `${within.type}:${within.uuid ?? ''} (la cadena es ${chain.map((s) => `${s.type}:${s.uuid ?? ''}`).join(' → ')}); ` +
2409
- `no se escribe fuera del scope declarado.`);
2417
+ // R2-03: el `message` llega al tenant tal cual — solo nombra lo que el llamante PASÓ (el scope, o el rol, y su
2418
+ // `within`); la cadena (identificadores de OTRO tenant) va a `error.details`, no enumerable, para el log del servidor.
2419
+ throw new NotWithinError(`${operation}: ${asked ?? `${scope.type}:${scope.uuid ?? ''}`} no está dentro de ${within.type}:${within.uuid ?? ''}; ` +
2420
+ `no se escribe fuera del scope declarado.`, { chain: chain.map((s) => ({ type: s.type, uuid: s.uuid ?? null })) });
2421
+ }
2422
+ }
2423
+ /**
2424
+ * La cadena (fresca) del OWNER de un rol local para `updateScopedRole`/`deleteScopedRole`, o 422
2425
+ * `E_AUTHZ_UNKNOWN_SCOPE` si el owner ya no resuelve (rol DORMIDO). El `message` nombra el ROL tal como lo pasó el
2426
+ * llamante, nunca el owner: el owner es un identificador que el llamante no aportó y puede ser de otro tenant (RC2-A).
2427
+ * El owner va a `error.details.owner`, no enumerable. El CÓDIGO no cambia.
2428
+ */
2429
+ async #ownerChainOf(owner, roleUuid, operation) {
2430
+ const chain = await resolveChain(this.#freshResolver(), owner, operation);
2431
+ if (!chain) {
2432
+ throw new UnknownScopeError(`El owner del rol ${roleUuid} no existe para el resolutor de la cadena (${operation}): el rol está dormido. ` +
2433
+ `No se escribe sobre un scope que el árbol no reconoce; lo retira la plataforma (authz:catalog:prune-orphans).`, { owner: { type: owner.type, uuid: owner.uuid ?? null } });
2410
2434
  }
2435
+ return chain;
2411
2436
  }
2412
2437
  /** El `within` a contrastar, validado y exigido según `requireWithin`; `null` si no hay que contrastar nada. */
2413
2438
  #requiredWithin(scope, options, operation) {
@@ -2456,13 +2481,13 @@ export class AuthorizationManager {
2456
2481
  return resolver;
2457
2482
  }
2458
2483
  /**
2459
- * Valida la arista y devuelve la cadena (fresca) del padre y el HIJO
2460
- * CANÓNICO (invariante 17). El hijo canónico se devuelve desde 3b-2d
2484
+ * Resuelve la arista —identidad, padre conocido— y devuelve la cadena (fresca) del padre y el HIJO
2485
+ * CANÓNICO (invariante 17); el anti-ciclos y el techo los juzga `#judgeEdge`, DESPUÉS de `within` (RC2-B). El hijo canónico se devuelve desde 3b-2d
2461
2486
  * porque la outbox lo encola: lo que se guarda en la cola es la fila del
2462
2487
  * árbol, no lo que escribió el llamante — si no, el relay abriría días
2463
2488
  * después una rama nueva en el store por un alias del uuid.
2464
2489
  */
2465
- async #assertEdge(child, parent, operation) {
2490
+ async #resolveEdge(child, parent, operation) {
2466
2491
  const resolver = this.#resolver(operation);
2467
2492
  assertScope(child);
2468
2493
  assertScope(parent);
@@ -2474,14 +2499,22 @@ export class AuthorizationManager {
2474
2499
  // El hijo, si el árbol ya lo conoce, con su identidad canónica (K1): un
2475
2500
  // alias del uuid no puede colarse por debajo de la comprobación de ciclo.
2476
2501
  const known = await resolveChain(resolver, child, operation);
2477
- const canonicalChild = known ? known[0] : child;
2478
- const childKey = _a.#scopeKey(canonicalChild);
2479
- if (chain.some((s) => _a.#scopeKey(s) === childKey)) {
2502
+ return { chain, child: known ? known[0] : child };
2503
+ }
2504
+ /**
2505
+ * La otra mitad de la arista: anti-ciclos y techo de profundidad sobre lo que `#resolveEdge` resolvió. **Va DESPUÉS de
2506
+ * `within`** (rotación 2 · cierre 2 · RC2-B, firmado: REORDENAR): los dos errores hablan de la cadena del PADRE —«desciende
2507
+ * de …», «lo dejaría a N saltos»— y con un padre de otro tenant eran un oráculo de ESTRUCTURA ajena (de qué org cuelga
2508
+ * una unit ajena, a qué profundidad está) que salía antes del `NOT_WITHIN`. Sin `within`, o con uno que pasa, el orden
2509
+ * de los demás errores no cambia (identidad → padre desconocido → ciclo → profundidad).
2510
+ */
2511
+ #judgeEdge(edge, parent, operation) {
2512
+ const childKey = _a.#scopeKey(edge.child);
2513
+ if (edge.chain.some((s) => _a.#scopeKey(s) === childKey)) {
2480
2514
  throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
2481
2515
  `colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
2482
2516
  }
2483
- this.#assertDepth(chain, canonicalChild, parent, operation);
2484
- return { chain, child: canonicalChild };
2517
+ this.#assertDepth(edge.chain, edge.child, parent, operation);
2485
2518
  }
2486
2519
  /**
2487
2520
  * **El gate de profundidad, opt-in** (2.4.0-alpha.5 · R0; `scopes.maxDepth`).
@@ -2596,8 +2629,10 @@ export class AuthorizationManager {
2596
2629
  if (!chain)
2597
2630
  return [];
2598
2631
  const catalog = await this.#catalogFor(driver).view();
2599
- const { granted } = await this.#rolesAlong(driver, subject, chain, catalog);
2600
- const denied = await this.#deniedAlong(driver, subject, chain, 'effectivePermissions');
2632
+ // Roles y denies en UNA foto (rotación 2 · cierre 3, L12): ver `#decisionFacts`.
2633
+ const facts = await this.#decisionFacts(driver, subject, { chain, catalog }, 'effectivePermissions');
2634
+ const { granted } = this.#grantedFrom(facts.roles, chain, catalog);
2635
+ const denied = this.#deniedIn(facts.denies, chain);
2601
2636
  return [...granted].filter((permission) => !denied.has(permission));
2602
2637
  }
2603
2638
  /**
@@ -2621,11 +2656,18 @@ export class AuthorizationManager {
2621
2656
  * `E_AUTHZ_AMBIGUOUS_ROLE`) si hay homónimos visibles. Nunca elige uno.
2622
2657
  */
2623
2658
  async #rolesAlong(driver, subject, chain, catalog) {
2659
+ return this.#grantedFrom(await this.#readRoles(driver, subject, chain, catalog), chain, catalog);
2660
+ }
2661
+ /** Los roles del holder en la cadena por el PUERTO: `rolesInChain` si el driver lo trae; si no, la composición por slug. */
2662
+ async #readRoles(driver, subject, chain, catalog) {
2663
+ return typeof driver.rolesInChain === 'function'
2664
+ ? await driver.rolesInChain(subject, chain)
2665
+ : await this.#rolesFromSlugs(driver, subject, chain, chainKeysFrom(chain), catalog);
2666
+ }
2667
+ /** Lo que conceden unos roles YA LEÍDOS a lo largo de la cadena, y el rank más alto (puro: el catálogo es el memo). */
2668
+ #grantedFrom(roles, chain, catalog) {
2624
2669
  const keysFrom = chainKeysFrom(chain);
2625
2670
  const levelIndex = new Map(chain.map((s, i) => [scopeKey(s), i]));
2626
- const roles = typeof driver.rolesInChain === 'function'
2627
- ? await driver.rolesInChain(subject, chain)
2628
- : await this.#rolesFromSlugs(driver, subject, chain, keysFrom, catalog);
2629
2671
  const granted = new Set();
2630
2672
  let rank = 0;
2631
2673
  for (const { scope: level, role } of roles) {
@@ -2724,18 +2766,91 @@ export class AuthorizationManager {
2724
2766
  return undefined;
2725
2767
  }
2726
2768
  }
2727
- /** Permisos DENEGADOS al holder en algún scope de la cadena: `listDenies` en UNA lectura (500 `E_AUTHZ_UNSUPPORTED` sin él). */
2728
- async #deniedAlong(driver, subject, chain, primitive) {
2729
- const listDenies = this.#optional(driver, 'listDenies', primitive);
2769
+ /** Permisos DENEGADOS al holder en algún scope de la cadena, sobre denies YA LEÍDOS (los de `listDenies(subject)`). */
2770
+ #deniedIn(denies, chain) {
2730
2771
  const key = _a.#scopeKey;
2731
2772
  const chainKeys = new Set(chain.map(key));
2732
2773
  const denied = new Set();
2733
- for (const deny of await listDenies(subject)) {
2774
+ for (const deny of denies) {
2734
2775
  if (chainKeys.has(key(deny.scope)))
2735
2776
  denied.add(deny.permission);
2736
2777
  }
2737
2778
  return denied;
2738
2779
  }
2780
+ /**
2781
+ * **El punto de paso ÚNICO de las decisiones que el manager COMPONE con concesión y deny** (rotación 2 · cierre 3;
2782
+ * 4.ª aparición del linaje L12 «lector rasgado»). `effectivePermissions`, la policy de delegación de
2783
+ * `defineScopedRole`/`updateScopedRole` (`#assertDelegable`) y `authorizedScopes` combinan lo que el holder TIENE
2784
+ * (`rolesInChain`, o `listScopes` de un permiso) con lo que tiene DENEGADO (`listDenies`). Hasta aquí eran dos métodos
2785
+ * del puerto, dos lecturas sin foto común: un offboarding suelto (revoke y luego removeDeny) entre las dos hacía que
2786
+ * `effectivePermissions` listara un permiso, `authorizedScopes` una unit y `defineScopedRole` delegara algo que el
2787
+ * holder no tenía en NINGÚN estado real (repro `.claude/reproducciones/database-authorize-rasgado.spec.ts`, caso 5).
2788
+ *
2789
+ * Regla: con un driver que DECLARA la capacidad `snapshotDecisions: true` (opcional por D15) las DOS mitades salen de
2790
+ * UNA llamada a `readSnapshot`, que el driver responde desde una sola foto de sus hechos; la respuesta se contrasta
2791
+ * con lo pedido (una clave que falta o que no es un array es un bug del driver: 500 nombrándolo, como
2792
+ * `authorizeMany`). Decide la CAPACIDAD, no la presencia del método (rotación 2 · cierre 3b, H3 del auditor; el
2793
+ * patrón de `membersOfNative`): declarar `true` sin traer `readSnapshot` es 500 `E_AUTHZ_CONFIG` antes de leer nada,
2794
+ * y un driver que declara `false` (o nada) compone como siempre aunque tenga un método con ese nombre —el hueco
2795
+ * DECLARADO de un driver sin la capacidad (`openfga`, terceros)—. En los dos caminos, sin `listDenies` es 500
2796
+ * `E_AUTHZ_UNSUPPORTED` antes de leer nada: nunca un «sin denies».
2797
+ *
2798
+ * `catalog` va con `chain` porque la composición por slug lo necesita; el catálogo y la cadena se leen FUERA de la
2799
+ * foto (el memo contrasta la versión compartida; la cadena es del consumidor: «cadena frente a hechos», declarado).
2800
+ *
2801
+ * **Cuándo sale el 500 de «`true` sin `readSnapshot`»** (rotación 2 · cierre 3c, J3 del auditor): en la PRIMERA
2802
+ * decisión compuesta (`effectivePermissions`, `authorizedScopes`, la policy de `defineScopedRole`/`updateScopedRole`),
2803
+ * no al resolver el driver — `authorize` y los `list*` no lo necesitan y siguen respondiendo. Fail-closed y ruidoso,
2804
+ * pero tarde; el patrón es el de `#optional` (una operación que falta se dice cuando se pide). El mensaje nombra el
2805
+ * driver que lo EMITIÓ —la clave de `config.drivers` y la clase del objeto que la factory devolvió—, no solo
2806
+ * `config.default`.
2807
+ */
2808
+ async #decisionFacts(driver, subject, request, primitive) {
2809
+ const listDenies = this.#optional(driver, 'listDenies', primitive);
2810
+ if (driver.capabilities?.snapshotDecisions === true) {
2811
+ if (typeof driver.readSnapshot !== 'function') {
2812
+ throw new AuthorizationConfigError(`${primitive}: el driver ${this.#emitter(driver)} declara snapshotDecisions: true y no trae readSnapshot. ` +
2813
+ 'La capacidad promete leer en UNA foto lo que el manager compone; sin el método no hay foto que leer. ' +
2814
+ 'Implementa readSnapshot o declara snapshotDecisions: false (el manager compone con dos lecturas, el hueco declarado).');
2815
+ }
2816
+ const answer = await driver.readSnapshot(subject, {
2817
+ ...(request.chain ? { rolesInChain: request.chain } : {}),
2818
+ ...(request.permission !== undefined ? { listScopes: request.permission } : {}),
2819
+ listDenies: true,
2820
+ });
2821
+ const got = (answer ?? {});
2822
+ const missing = [
2823
+ ...(request.chain ? ['rolesInChain'] : []),
2824
+ ...(request.permission !== undefined ? ['listScopes'] : []),
2825
+ 'listDenies',
2826
+ ].filter((key) => !Array.isArray(got[key]));
2827
+ if (answer === null || typeof answer !== 'object' || missing.length) {
2828
+ throw new AuthorizationInternalError(`${primitive}: el driver ${this.#emitter(driver)} respondió a readSnapshot sin ${missing.join(', ') || 'un objeto'}; ` +
2829
+ 'el puerto exige un array por cada lectura pedida, todas de la misma foto.');
2830
+ }
2831
+ return {
2832
+ roles: got.rolesInChain,
2833
+ scopes: got.listScopes,
2834
+ denies: got.listDenies,
2835
+ };
2836
+ }
2837
+ // Fallback DECLARADO (driver sin `snapshotDecisions: true`): dos lecturas del puerto sin foto común. Un método
2838
+ // `readSnapshot` de un driver que no declara la capacidad NO se llama (puede ser otra cosa con el mismo nombre).
2839
+ const roles = request.chain ? await this.#readRoles(driver, subject, request.chain, request.catalog) : undefined;
2840
+ const scopes = request.permission !== undefined ? await driver.listScopes(subject, request.permission) : undefined;
2841
+ return { roles, scopes, denies: await listDenies(subject) };
2842
+ }
2843
+ /**
2844
+ * Cómo se nombra en un error el driver que lo EMITIÓ (cierre 3c, J3): la clave de `config.drivers` que lo resolvió y la
2845
+ * clase del objeto que devolvió la factory (una vista `Object.create`/`withClock` hereda la del driver). La clave sola
2846
+ * puede no decir qué objeto respondió (una factory que elige entre varios, una vista).
2847
+ */
2848
+ #emitter(driver) {
2849
+ const kind = driver?.constructor?.name;
2850
+ return typeof kind === 'string' && kind !== '' && kind !== 'Object'
2851
+ ? `'${this.#config.default}' (${kind})`
2852
+ : `'${this.#config.default}'`;
2853
+ }
2739
2854
  // ── Roles locales a un scope (3B · B3): la API de DELEGACIÓN ─────────
2740
2855
  // Un administrador de un scope (el actor) define roles que solo existen
2741
2856
  // dentro de ese scope (owner) con permisos que él mismo tiene efectivos
@@ -2787,8 +2902,10 @@ export class AuthorizationManager {
2787
2902
  // Composición y lista blanca antes de leer hechos: lo barato primero.
2788
2903
  this.#assertComposable(parsed.permissions, parsed, catalog);
2789
2904
  await this.#assertLevelUnderOwner(parsed.scopeType, chain, 'defineScopedRole');
2790
- const { granted, rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
2791
- const denied = await this.#deniedAlong(driver, who, chain, 'defineScopedRole');
2905
+ // Lo que el actor tiene y lo que tiene denegado, en UNA foto (rotación 2 · cierre 3, L12: `#decisionFacts`).
2906
+ const facts = await this.#decisionFacts(driver, who, { chain, catalog }, 'defineScopedRole');
2907
+ const { granted, rank: actorRank } = this.#grantedFrom(facts.roles, chain, catalog);
2908
+ const denied = this.#deniedIn(facts.denies, chain);
2792
2909
  this.#assertDelegable(parsed.permissions, granted, denied, owner);
2793
2910
  this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
2794
2911
  const shadowedByAncestor = await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, catalog.rolesNamed(parsed.slug, parsed.scopeType));
@@ -2808,18 +2925,20 @@ export class AuthorizationManager {
2808
2925
  const known = new Set(catalog.rolesNamed(parsed.slug, parsed.scopeType).map((r) => r.owner));
2809
2926
  const fresh = [...new Set(rows.map((r) => String(r.owner_scope_key)).filter((o) => !known.has(o)))];
2810
2927
  if (fresh.length) {
2811
- // Los homónimos que el memo no tenía son de una escritura confirmada
2812
- // mientras validábamos. Los que se pueden juzgar SIN salir de la
2813
- // transacción (global, o un ancestro-o-igual del owner: la cadena ya
2814
- // está resuelta) dan el 422 preciso; el resto se rechaza igual —
2815
- // resolver su cadena aquí dentro pediría otra conexión mientras se
2816
- // sostiene el cerrojo del catálogo (con un pool de 1, un abrazo
2817
- // mortal). Reintentar es correcto: el memo ya está invalidado y el
2818
- // segundo intento valida con la foto buena.
2928
+ // Los homónimos que el memo no tenía son de una escritura confirmada mientras validábamos. Se juzgan con la
2929
+ // MISMA regla de colisión por AUTORIDAD que la comprobación previa (3F · S3, invariante 18), en lo que se puede
2930
+ // juzgar SIN salir de la transacción —la cadena del owner ya está resuelta—: global o ancestro-o-igual ⇒ 422
2931
+ // preciso (eso mantiene M2: dos `define` del mismo owner acaban en uno). **Cualquier otro owner NO es colisión**
2932
+ // (rotación 2 · R2-05, decisión del dueño 2026-10-22): un hermano —otro tenant— puede tener el mismo slug, y
2933
+ // un DESCENDIENTE queda ensombrecido, que es el segundo final legal de la carrera ancestro↔descendiente
2934
+ // (3G · Y1). Hasta este cierre los dos se rechazaban con un 422 que NOMBRABA el owner ajeno («owner
2935
+ // organization|<uuid de A>»): un oráculo de qué tenants definen qué slug, y una colisión falsa (reintentar
2936
+ // entraba). Lo que NO se hace aquí, a propósito: resolver la cadena del owner nuevo dentro de la transacción
2937
+ // (pediría otra conexión con el cerrojo del catálogo sostenido: abrazo mortal con pool 1), así que el rango
2938
+ // sobre un descendiente confirmado EN la ventana no se mide (W3 protege a los roles que YA existían al validar;
2939
+ // el mismo actor obtiene lo mismo yendo primero) y no sale en `shadowedByAncestor` del evento — sí en
2940
+ // `authz:catalog:diff`. Declarado en el CHANGELOG.
2819
2941
  await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, fresh.map((o) => ({ owner: o })), 'sin-árbol');
2820
- throw new CatalogConflictError(`El catálogo cambió mientras se validaba '${parsed.slug}@${parsed.scopeType}': apareció un rol con ese nombre ` +
2821
- `(owner ${fresh.join(', ')}) que no estaba en la foto con la que se comprobaron las colisiones. ` +
2822
- `No se escribe a ciegas; reintenta la operación.`);
2823
2942
  }
2824
2943
  const now = systemClock();
2825
2944
  await trx.table('authz_roles').insert({
@@ -2871,10 +2990,10 @@ export class AuthorizationManager {
2871
2990
  const catalog = await this.#catalogFor(driver).view();
2872
2991
  const role = this.#localRoleOrFail(catalog, roleUuid);
2873
2992
  const owner = this.#ownerOf(role);
2874
- const chain = await assertKnownScope(this.#freshResolver(), owner, 'updateScopedRole');
2993
+ const chain = await this.#ownerChainOf(owner, role.uuid, 'updateScopedRole');
2875
2994
  // El scope contrastado es el OWNER del rol (3D · M3): editar un rol es
2876
2995
  // escribir dentro de su contenedor.
2877
- this.#assertWithinChain(owner, chain, options, 'updateScopedRole');
2996
+ this.#assertWithinChain(owner, chain, options, 'updateScopedRole', `el rol ${role.uuid}`);
2878
2997
  const current = [...catalog.rolePermissionsOf(role.uuid)].sort();
2879
2998
  const next = {
2880
2999
  name: parsed.name ?? null,
@@ -2888,15 +3007,24 @@ export class AuthorizationManager {
2888
3007
  // POR ENCIMA de su owner (creado antes de 3E o a mano) no se perpetúa:
2889
3008
  // es una mina de slug y lo que toca es purgarlo, no editarlo.
2890
3009
  await this.#assertLevelUnderOwner(role.scopeType, chain, 'updateScopedRole');
2891
- const { granted, rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
3010
+ // Con permisos nuevos la policy combina lo que el actor tiene con lo que tiene denegado: UNA foto (rotación 2 ·
3011
+ // cierre 3, L12: `#decisionFacts`). Sin permisos solo hace falta el rank (una mitad: no rasga). Un driver SIN
3012
+ // `listDenies` no tiene denies que leer: se leen solo los roles y el 500 `E_AUTHZ_UNSUPPORTED` sale DESPUÉS del 422
3013
+ // de rango, en el orden de siempre (rotación 2 · cierre 3b, H4 del auditor).
3014
+ const withDenies = Boolean(parsed.permissions) && typeof driver.listDenies === 'function';
3015
+ const facts = withDenies
3016
+ ? await this.#decisionFacts(driver, who, { chain, catalog }, 'updateScopedRole')
3017
+ : { roles: await this.#readRoles(driver, who, chain, catalog), denies: [] };
3018
+ const { granted, rank: actorRank } = this.#grantedFrom(facts.roles, chain, catalog);
2892
3019
  this.#assertAboveRole(actorRank, role);
2893
3020
  // 3G · W3: este rol puede estar ENSOMBRECIENDO al homónimo de un
2894
3021
  // descendiente (3F · S3). Cambiarle rank o permisos es seguir ejerciendo
2895
3022
  // esa autoridad, así que exige lo mismo que crearlo: superarlo en rango.
2896
3023
  this.#assertAboveShadowed(actorRank, await this.#shadowedBelow(scopeKey(owner), new Set(chain.map(scopeKey)), catalog.rolesNamed(role.slug, role.scopeType).filter((other) => other.uuid !== role.uuid), 'updateScopedRole'), 'updateScopedRole');
2897
3024
  if (parsed.permissions) {
2898
- const denied = await this.#deniedAlong(driver, who, chain, 'updateScopedRole');
2899
- this.#assertDelegable(parsed.permissions, granted, denied, owner);
3025
+ if (!withDenies)
3026
+ this.#optional(driver, 'listDenies', 'updateScopedRole');
3027
+ this.#assertDelegable(parsed.permissions, granted, this.#deniedIn(facts.denies, chain), owner);
2900
3028
  }
2901
3029
  if (parsed.rank !== undefined)
2902
3030
  this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
@@ -2965,8 +3093,8 @@ export class AuthorizationManager {
2965
3093
  const catalog = await this.#catalogFor(driver).view();
2966
3094
  const role = this.#localRoleOrFail(catalog, roleUuid);
2967
3095
  const owner = this.#ownerOf(role);
2968
- const chain = await assertKnownScope(this.#freshResolver(), owner, 'deleteScopedRole');
2969
- this.#assertWithinChain(owner, chain, options, 'deleteScopedRole');
3096
+ const chain = await this.#ownerChainOf(owner, role.uuid, 'deleteScopedRole');
3097
+ this.#assertWithinChain(owner, chain, options, 'deleteScopedRole', `el rol ${role.uuid}`);
2970
3098
  const { rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
2971
3099
  this.#assertAboveRole(actorRank, role);
2972
3100
  const permissions = [...catalog.rolePermissionsOf(role.uuid)].sort();
@@ -3063,6 +3191,21 @@ export class AuthorizationManager {
3063
3191
  * antes de su `purgeRole`; si ha vuelto, el rol se salta y se cuenta en
3064
3192
  * `skipped` con `reason: 'owner-came-back'`.
3065
3193
  *
3194
+ * **Tercer disparador del seguro** (rotación 2 de M4 · R2-02, decisión del dueño 2026-10-22 (2)): la
3195
+ * proporción la controla un TENANT —con la API de delegación legítima infla su catálogo local hasta que los
3196
+ * huérfanos de los demás bajan del 50 %—, y la variante realista del resolutor ciego no es «`null` para todo»
3197
+ * sino un contexto de tenant COLADO en el job de plataforma, que solo deja vivo a ese tenant. Así que, con
3198
+ * `force`, si los huérfanos son de MÁS DE UN owner y ALGUNO todavía concede (`stillGranting === true`) o no se
3199
+ * sabe (`undefined`: el driver no cuenta), también es 500 `E_AUTHZ_MASS_PURGE_REFUSED` antes de borrar nada,
3200
+ * salvo `allowMassPurge`. Un solo owner huérfano con asignaciones vivas sigue pasando (es el caso normal de un
3201
+ * tenant que se va, y el listado lo marca aparte). El `--dry-run` no lanza: lo marca en `massPurge` y dice por
3202
+ * qué en `massPurgeReasons` (`'all-owners' | 'proportion' | 'granting-across-owners'`).
3203
+ * - **Cota de lectura** (R2-04): `maxLocalRoles` (default `DEFAULT_MAX_LOCAL_ROLES`, `--max-local-roles` en el
3204
+ * comando) acota la lectura del catálogo local; pasarla es 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, nunca una lista
3205
+ * parcial. Hasta este cierre la opción no existía y el mensaje pedía «subir la cota» sin mando: un tenant con
3206
+ * 10 001 roles locales dejaba `prune-orphans` muerto para todos. Un valor que no es un entero ≥ 1 es 500
3207
+ * `E_AUTHZ_CONFIG` antes de leer nada.
3208
+ *
3066
3209
  * Coste: una lectura del catálogo local + un `resolveChain` por OWNER
3067
3210
  * DISTINTO (memoizado) + UNA llamada a `countRoleAssignments` con los
3068
3211
  * uuids de los huérfanos (ninguna si no hay) + un `resolveChain` más por
@@ -3071,6 +3214,12 @@ export class AuthorizationManager {
3071
3214
  * una petición.
3072
3215
  */
3073
3216
  async pruneOrphanRoles(options = {}) {
3217
+ // R2-04: la cota es una entrada del operador y se valida antes de leer nada (L5: entrada inerte o rechazada).
3218
+ const maxLocalRoles = options.maxLocalRoles;
3219
+ if (maxLocalRoles !== undefined && !(Number.isInteger(maxLocalRoles) && maxLocalRoles >= 1)) {
3220
+ throw new AuthorizationConfigError(`pruneOrphanRoles: maxLocalRoles (--max-local-roles) debe ser un entero >= 1 (llegó ${String(maxLocalRoles)}). ` +
3221
+ `No se ha leído nada.`);
3222
+ }
3074
3223
  const force = options.force === true;
3075
3224
  this.#resolver('authz:catalog:prune-orphans');
3076
3225
  const driver = await this.driver();
@@ -3082,7 +3231,7 @@ export class AuthorizationManager {
3082
3231
  if (force)
3083
3232
  await this.#assertNotFrozen('authz:catalog:prune-orphans');
3084
3233
  const resolver = this.#freshResolver();
3085
- const locals = await readLocalRoles({ driver: this.#config.default });
3234
+ const locals = await readLocalRoles({ driver: this.#config.default, maxLocalRoles });
3086
3235
  const resolved = new Map();
3087
3236
  const orphans = [];
3088
3237
  for (const { role, permissions } of locals) {
@@ -3115,15 +3264,33 @@ export class AuthorizationManager {
3115
3264
  }
3116
3265
  const owners = new Set(locals.map(({ role }) => role.owner));
3117
3266
  const orphanOwners = new Set(orphans.map(({ role }) => role.owner));
3118
- const massPurge = orphans.length > 0 && (orphanOwners.size === owners.size || orphans.length * 2 > locals.length);
3267
+ // Los TRES disparadores del seguro (AA2 + R2-02). Cada uno se reporta: el `--dry-run` dice POR QUÉ lo marca.
3268
+ const granting = orphans.filter(({ stillGranting }) => stillGranting !== false);
3269
+ const massPurgeReasons = [];
3270
+ if (orphans.length > 0) {
3271
+ if (orphanOwners.size === owners.size)
3272
+ massPurgeReasons.push('all-owners');
3273
+ if (orphans.length * 2 > locals.length)
3274
+ massPurgeReasons.push('proportion');
3275
+ if (orphanOwners.size > 1 && granting.length > 0)
3276
+ massPurgeReasons.push('granting-across-owners');
3277
+ }
3278
+ const massPurge = massPurgeReasons.length > 0;
3119
3279
  if (!force)
3120
- return { orphans, purged: [], skipped: [], massPurge, dryRun: true };
3280
+ return { orphans, purged: [], skipped: [], massPurge, massPurgeReasons, dryRun: true };
3121
3281
  if (massPurge && options.allowMassPurge !== true) {
3282
+ // Solo CUENTAS: ni uuids ni slugs de owners (la lista ya la ve el operador en el `--dry-run`).
3283
+ const motivos = massPurgeReasons.map((reason) => reason === 'all-owners'
3284
+ ? `todos los owners distintos (${orphanOwners.size} de ${owners.size}) salen huérfanos`
3285
+ : reason === 'proportion'
3286
+ ? `los huérfanos son más del 50 % de los roles locales (${orphans.length} de ${locals.length})`
3287
+ : `los huérfanos son de ${orphanOwners.size} owners distintos y ${granting.length} de ellos TODAVÍA conceden ` +
3288
+ `o no se sabe (stillGranting true/undefined)`);
3122
3289
  throw new MassPurgeRefusedError(`pruneOrphanRoles: ${orphans.length} de ${locals.length} roles locales (${orphanOwners.size} de ${owners.size} ` +
3123
- `owners distintos) tienen el owner fuera del árbol. Esa es la firma de un 'scopes.resolveChain' ciego —filtrado ` +
3124
- `por el tenant de la petición, o sin contexto— que devuelve null para todo: una pasada así borra el catálogo ` +
3125
- `local de todos los tenants. No se ha borrado nada. Comprueba el resolutor y, si la poda es real, repite con ` +
3126
- `allowMassPurge: true (--allow-mass-purge).`);
3290
+ `owners distintos) tienen el owner fuera del árbol, y ${motivos.join('; ')}. Esa es la firma de un ` +
3291
+ `'scopes.resolveChain' ciego —filtrado por el tenant de la petición, con el contexto de UN tenant colado en el ` +
3292
+ `job, o sin contexto—: una pasada así borra el catálogo local, y las asignaciones vivas, de otros tenants. No se ` +
3293
+ `ha borrado nada. Comprueba el resolutor y, si la poda es real, repite con allowMassPurge: true (--allow-mass-purge).`);
3127
3294
  }
3128
3295
  const purged = [];
3129
3296
  const skipped = [];
@@ -3161,7 +3328,7 @@ export class AuthorizationManager {
3161
3328
  purged.push(role);
3162
3329
  await this.#notifyCatalog({ action: 'role_purged', role, owner, permissions });
3163
3330
  }
3164
- return { orphans, purged, skipped, massPurge, dryRun: false };
3331
+ return { orphans, purged, skipped, massPurge, massPurgeReasons, dryRun: false };
3165
3332
  }
3166
3333
  /** El actor de la API de delegación: obligatorio SIEMPRE (sin él no hay policy que evaluar) y bien formado. */
3167
3334
  async #requireActor(actor, operation) {
@@ -3686,12 +3853,13 @@ export class AuthorizationManager {
3686
3853
  // resuelven una vez.
3687
3854
  const view = this.#readResolver ? this : this.forRequest();
3688
3855
  const driver = await view.#reader();
3689
- const listDenies = this.#optional(driver, 'listDenies', 'authorizedScopes');
3690
3856
  const key = _a.#scopeKey;
3691
- const direct = await driver.listScopes(subject, permission);
3857
+ // Los scopes directos que conceden y los denies, en UNA foto (rotación 2 · cierre 3, L12: `#decisionFacts`).
3858
+ const facts = await view.#decisionFacts(driver, subject, { permission }, 'authorizedScopes');
3859
+ const direct = facts.scopes;
3692
3860
  if (direct.length === 0)
3693
3861
  return { kind: 'none' };
3694
- const denied = (await listDenies(subject)).filter((d) => d.permission === permission).map((d) => d.scope);
3862
+ const denied = facts.denies.filter((d) => d.permission === permission).map((d) => d.scope);
3695
3863
  if (direct.some((s) => s.type === APP_SCOPE_TYPE)) {
3696
3864
  if (denied.length > maxScopes) {
3697
3865
  throw new TooManyScopesError(`authorizedScopes: ${denied.length} subárboles excluidos superan maxScopes=${maxScopes}; no se devuelve una lista parcial.`);
@@ -3771,7 +3939,7 @@ export class AuthorizationManager {
3771
3939
  }
3772
3940
  /**
3773
3941
  * Clave LAXA de un scope, solo para agrupar/deduplicar candidatos dentro
3774
- * de una operación (`authorizedScopes`, el anti-ciclo de `#assertEdge`).
3942
+ * de una operación (`authorizedScopes`, el anti-ciclo de `#judgeEdge`).
3775
3943
  * NO es `scopeKey` de `identity.ts` —esa valida la gramática y es la que
3776
3944
  * identifica hechos, owners e ids de binding, y es la que usa
3777
3945
  * `#rolesAlong` (3D · N5)—: aquí los scopes vienen del `descendantsOf` del
@@ -89,7 +89,9 @@ interface DatabaseLike {
89
89
  * coincidieran en los primeros 64 COMPARTÍAN lease y se excluían para siempre
90
90
  * — y el modo de fallo era `busy: true`, que no lanza: el relay de uno de los
91
91
  * dos no corría nunca, en silencio. Desde alpha.5 el nombre es un hash
92
- * ESTABLE del nombre completo (`current`, 59 caracteres), que no colisiona.
92
+ * ESTABLE del nombre completo (`current`, 59 caracteres), que no colisiona entre
93
+ * TABLAS — entre BASES del mismo servidor sí: el «nombre completo» no llevaba la
94
+ * base (rotación 2 · R2-01, abajo).
93
95
  *
94
96
  * **Transición**: `sqlScopeOutbox.acquire()` toma el lock viejo (`legacy`, el
95
97
  * recortado) Y el nuevo durante alpha.5, porque en un rolling deploy un
@@ -118,9 +120,41 @@ export declare const SCOPE_OUTBOX_LEGACY_LOCK_UNTIL = "2.4.0-alpha.6";
118
120
  * desde ≤ alpha.4 en MySQL exige parada completa del relay, no despliegue rodante.
119
121
  */
120
122
  export declare const SCOPE_OUTBOX_TAKES_LEGACY_LOCK = false;
121
- export declare function scopeOutboxLockNames(connection: string | undefined, table: string): {
123
+ /**
124
+ * **La identidad de la BASE en el nombre del lease** (rotación 2 de M4 · R2-01, decisión del dueño 2026-10-22 (1)).
125
+ * `GET_LOCK` es un cerrojo de SERVIDOR, y hasta 2.5.0-alpha.4 el nombre era `<conexión>:<tabla>`: dos despliegues en
126
+ * BASES distintas del mismo MySQL con la conexión por defecto de Adonis y la tabla del stub calculaban el MISMO nombre y
127
+ * se excluían (`busy`, exit 0) — el síntoma que R6 de alpha.5 daba por cerrado nunca lo estuvo, porque el «nombre
128
+ * completo» no llevaba la base. Desde este cierre `current` lleva la base que la PROPIA conexión que sostiene el lease
129
+ * lee con `DATABASE()` (no la del config: es la base contra la que esa sesión resuelve la tabla sin cualificar, o sea la
130
+ * identidad real de la cola; un alias de host o una URL distinta que apunte a la misma base dan el mismo nombre).
131
+ *
132
+ * En MINÚSCULAS a propósito: los dos errores posibles no cuestan lo mismo. Fundir `App` y `app` (dos bases distintas en
133
+ * un servidor sensible a mayúsculas) solo hace que esos dos despliegues compartan lease —vivacidad, `busy`—; separar la
134
+ * misma base escrita de dos formas por dos procesos del MISMO despliegue serían dos escritores —seguridad—. Se elige el
135
+ * error barato. `DATABASE()` `NULL` (sesión sin base por defecto: la tabla tiene que ir cualificada) entra como cadena
136
+ * vacía: los procesos de ese despliegue siguen calculando el mismo nombre.
137
+ *
138
+ * Los tres nombres: `legacy` (el recortado a 64 de ≤ alpha.4, retirado en alpha.7, se conserva para el caso que lo
139
+ * fija), `unscoped` (el sha1 sin base de 2.4.0-alpha.5..2.5.0-alpha.4) y `current` (el sha1 con base). Sin `database`
140
+ * —quien no ha abierto conexión: los casos de identidad pura— `current` es `unscoped`.
141
+ */
142
+ export declare function scopeOutboxLockNames(connection: string | undefined, table: string, database?: string | null): {
122
143
  legacy: string;
144
+ unscoped: string;
123
145
  current: string;
124
146
  };
147
+ /**
148
+ * **La transición del nombre sin base** (rotación 2 · R2-01, firmada: «como alpha.5»). En 2.5.x `acquire()` toma el
149
+ * nombre VIEJO (`unscoped`, el de 2.4.0-alpha.5..2.5.0-alpha.4) y DESPUÉS el nuevo (`current`, con la base), y suelta
150
+ * los dos: en un despliegue rodante un proceso de alpha.4 solo conoce el viejo, y los dos tienen que seguir
151
+ * excluyéndose. **El precio, declarado**: mientras se tome el viejo, dos despliegues en bases distintas del mismo
152
+ * servidor SIGUEN compartiéndolo — R2-01 queda abierto en 2.5.x y se cierra al retirarlo en 2.6 (README «Limits»,
153
+ * `relay.mysql-unscoped-lease`; backlog 2.6). Atado a la versión como en alpha.6: `tests/rotacion2_cierre.spec.ts` exige
154
+ * que valga exactamente «`package.json` < `SCOPE_OUTBOX_UNSCOPED_LOCK_RETIRED_IN`» (`2.6.0-alpha.0`: toda 2.6, sus
155
+ * prereleases incluidas), así que el bump a 2.6 pone la suite en rojo hasta retirarlo.
156
+ */
157
+ export declare const SCOPE_OUTBOX_UNSCOPED_LOCK_RETIRED_IN = "2.6.0-alpha.0";
158
+ export declare const SCOPE_OUTBOX_TAKES_UNSCOPED_LOCK = true;
125
159
  export declare function sqlScopeOutbox(options?: SqlScopeOutboxOptions, database?: DatabaseLike): ScopeOutbox;
126
160
  export {};