@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.
- package/README.md +24 -14
- package/build/commands/authz_catalog_prune_orphans.d.ts +17 -2
- package/build/commands/authz_catalog_prune_orphans.js +28 -5
- package/build/commands/authz_scopes_relay.js +5 -1
- package/build/commands/authz_scopes_requeue.js +2 -1
- package/build/index.d.ts +2 -2
- package/build/src/catalog/catalog_cache.js +2 -1
- package/build/src/drivers/database_driver.d.ts +54 -2
- package/build/src/drivers/database_driver.js +138 -23
- package/build/src/drivers/database_relations_driver.js +25 -15
- package/build/src/errors.d.ts +39 -0
- package/build/src/errors.js +8 -0
- package/build/src/identity.d.ts +7 -1
- package/build/src/identity.js +29 -0
- package/build/src/manager.d.ts +24 -0
- package/build/src/manager.js +225 -57
- package/build/src/scope_outbox.d.ts +36 -2
- package/build/src/scope_outbox.js +78 -17
- package/build/src/testing/contract.d.ts +19 -2
- package/build/src/testing/contract.js +181 -19
- package/build/src/types.d.ts +49 -0
- package/build/src/types.js +2 -0
- package/package.json +1 -1
package/build/src/manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 `#
|
|
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
|
-
/**
|
|
2402
|
-
|
|
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
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
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
|
-
*
|
|
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 #
|
|
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
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
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,
|
|
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
|
-
|
|
2600
|
-
const
|
|
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
|
|
2728
|
-
|
|
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
|
|
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
|
-
|
|
2791
|
-
const
|
|
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
|
-
//
|
|
2813
|
-
// transacción
|
|
2814
|
-
//
|
|
2815
|
-
//
|
|
2816
|
-
//
|
|
2817
|
-
//
|
|
2818
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2899
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
3124
|
-
`por el tenant de la petición,
|
|
3125
|
-
`
|
|
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
|
-
|
|
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 =
|
|
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 `#
|
|
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
|
-
|
|
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 {};
|