@jantstack/adonis-authz 2.4.0-alpha.7 → 2.4.0-alpha.8
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 +22 -4
- package/build/commands/authz_scopes_relay.d.ts +32 -0
- package/build/commands/authz_scopes_relay.d.ts.map +1 -1
- package/build/commands/authz_scopes_relay.js +79 -0
- package/build/commands/authz_scopes_relay.js.map +1 -1
- package/build/commands/authz_scopes_requeue.d.ts +17 -0
- package/build/commands/authz_scopes_requeue.d.ts.map +1 -1
- package/build/commands/authz_scopes_requeue.js +83 -5
- package/build/commands/authz_scopes_requeue.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -1
- package/build/index.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +17 -0
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +48 -5
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +21 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +7 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/manager.d.ts +47 -2
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +500 -226
- package/build/src/manager.js.map +1 -1
- package/build/src/relations/manager.d.ts +19 -3
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +33 -8
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/scope_outbox.d.ts +14 -0
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +28 -0
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/transaction_guard.d.ts +5 -2
- package/build/src/shared/transaction_guard.d.ts.map +1 -1
- package/build/src/shared/transaction_guard.js +16 -2
- package/build/src/shared/transaction_guard.js.map +1 -1
- package/build/src/testing/scope_outbox_contract.d.ts +10 -0
- package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
- package/build/src/testing/scope_outbox_contract.js +71 -1
- package/build/src/testing/scope_outbox_contract.js.map +1 -1
- package/build/src/types.d.ts +66 -1
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js.map +1 -1
- package/package.json +1 -1
package/build/src/manager.js
CHANGED
|
@@ -84,6 +84,27 @@ export const RELAY_ORIGIN_MAX_HOPS = 100;
|
|
|
84
84
|
export const PARKED_CENSUS_MAX = 50_000;
|
|
85
85
|
/** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
|
|
86
86
|
export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
|
|
87
|
+
/**
|
|
88
|
+
* Cada cuánto reintenta `acquire()` una pasada con `waitForLeaseMs` (2.4.0-alpha.8;
|
|
89
|
+
* panel relay 2, cuestión 7). Medido en PG: 0,29 ms por sondeo ocupado, y el
|
|
90
|
+
* sondeo no retiene conexión entre intentos (la transacción del intento fallido
|
|
91
|
+
* se revierte). El default de `waitForLeaseMs` es 0: el `busy` inmediato de
|
|
92
|
+
* siempre.
|
|
93
|
+
*/
|
|
94
|
+
export const RELAY_LEASE_POLL_MS = 25;
|
|
95
|
+
/**
|
|
96
|
+
* Tope de `waitForLeaseMs` (cierre alpha.8, 🟡 1 del auditor): una espera sin tope
|
|
97
|
+
* cuelga la petición de un tenant; por encima es 500 `E_AUTHZ_CONFIG`. Y la espera
|
|
98
|
+
* se cuenta en SONDEOS (`waitForLeaseMs / RELAY_LEASE_POLL_MS`), no solo con el
|
|
99
|
+
* reloj de pared: con `config.clock` fijo el sondeo por reloj no terminaba nunca.
|
|
100
|
+
*/
|
|
101
|
+
export const RELAY_WAIT_LEASE_MAX_MS = 60_000;
|
|
102
|
+
/** Las opciones que `relayScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
|
|
103
|
+
const RELAY_OPTIONS = ['limit', 'batchSize', 'dryRun', 'only', 'waitForLeaseMs', 'deadDetail'];
|
|
104
|
+
/** Precedencia de los orígenes de un bloqueo por clave (2.4.0-alpha.8): un fallo real pisa todo; una pendiente pisa a un aparcado. */
|
|
105
|
+
const BLOCK_RANK = { parked: 0, pending: 1, failed: 2 };
|
|
106
|
+
/** Las opciones que `requeueScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
|
|
107
|
+
const REQUEUE_OPTIONS = ['force', 'apply', 'waitForLeaseMs'];
|
|
87
108
|
/** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
|
|
88
109
|
const monotonicNow = () => performance.now();
|
|
89
110
|
/**
|
|
@@ -819,6 +840,7 @@ export class AuthorizationManager {
|
|
|
819
840
|
* 503 del backend NO consume intento (`markFailed(..., { transient })`).
|
|
820
841
|
*/
|
|
821
842
|
async relayScopeChanges(options = {}) {
|
|
843
|
+
_a.#assertKnownOptions('relayScopeChanges', options, RELAY_OPTIONS);
|
|
822
844
|
// El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
|
|
823
845
|
// como cualquier otra escritura (lo que quede en la cola sigue ahí).
|
|
824
846
|
await this.#assertNotFrozen('authz:scopes:relay');
|
|
@@ -830,34 +852,27 @@ export class AuthorizationManager {
|
|
|
830
852
|
const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
|
|
831
853
|
const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
|
|
832
854
|
const dryRun = options.dryRun === true;
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
/** Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`. */
|
|
840
|
-
const deadEntries = dead
|
|
841
|
-
.filter((item) => only === undefined || only.has(String(item.id)))
|
|
842
|
-
.map((item) => ({
|
|
843
|
-
id: item.id,
|
|
844
|
-
change: item.change,
|
|
845
|
-
status: 'dead',
|
|
846
|
-
attempts: item.attempts,
|
|
847
|
-
...(item.error === undefined ? {} : { reason: item.error }),
|
|
848
|
-
}));
|
|
849
|
-
const missingOf = (seen) => only === undefined ? [] : [...only.values()].filter((id) => !seen.has(String(id))).map((id) => _a.#rawId(options.only, id));
|
|
855
|
+
const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
|
|
856
|
+
if (options.deadDetail !== undefined && options.deadDetail !== 'full') {
|
|
857
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: deadDetail solo admite 'full' (llegó ${JSON.stringify(options.deadDetail)}); sin él, bajo only, dead[] va sin error.`);
|
|
858
|
+
}
|
|
859
|
+
const inputs = await _a.#relayInputs(outbox, options.only, batchSize, options.deadDetail);
|
|
860
|
+
const { only, filter, dead, deadCount, deadEntries, missingOf } = inputs;
|
|
850
861
|
if (dryRun) {
|
|
851
862
|
const batch = await outbox.pending(limit, undefined, filter);
|
|
852
863
|
_a.#assertOnlyHonoured(batch, only);
|
|
853
864
|
const extra = batch.length >= limit ? true : false;
|
|
854
865
|
const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
|
|
866
|
+
// La edad es GLOBAL: sin filtro la cabeza es `batch[0]`; con `only` hay que mirar la cola entera.
|
|
867
|
+
const queue = filter === undefined ? this.#queueOf(batch[0]) : (await this.#queueStatus(outbox)).queue;
|
|
855
868
|
return {
|
|
856
869
|
applied: [],
|
|
857
870
|
failed: null,
|
|
858
871
|
failures: [],
|
|
859
872
|
deferred: [],
|
|
860
873
|
dead,
|
|
874
|
+
deadCount,
|
|
875
|
+
queue,
|
|
861
876
|
busy: false,
|
|
862
877
|
remaining: batch.length > 0 || extra,
|
|
863
878
|
dryRun: true,
|
|
@@ -868,191 +883,313 @@ export class AuthorizationManager {
|
|
|
868
883
|
}
|
|
869
884
|
// El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
|
|
870
885
|
// como hasta ahora (y el README dice que entonces el relay tiene que
|
|
871
|
-
// correr de uno en uno).
|
|
872
|
-
const lease =
|
|
873
|
-
if (outbox.acquire && lease === null)
|
|
874
|
-
return
|
|
875
|
-
applied: [],
|
|
876
|
-
failed: null,
|
|
877
|
-
failures: [],
|
|
878
|
-
deferred: [],
|
|
879
|
-
dead,
|
|
880
|
-
busy: true,
|
|
881
|
-
remaining: (await outbox.pending(1)).length > 0,
|
|
882
|
-
dryRun: false,
|
|
883
|
-
wouldApply: [],
|
|
884
|
-
entries: deadEntries,
|
|
885
|
-
missing: [],
|
|
886
|
-
};
|
|
887
|
-
}
|
|
886
|
+
// correr de uno en uno). Con `waitForLeaseMs` se reintenta hasta el tope.
|
|
887
|
+
const lease = await this.#acquireLease(outbox, waitForLeaseMs);
|
|
888
|
+
if (outbox.acquire && lease === null)
|
|
889
|
+
return this.#busyReport(outbox, inputs);
|
|
888
890
|
try {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
891
|
+
return await this.#drainScopeChanges(outbox, { limit, batchSize, ...inputs });
|
|
892
|
+
}
|
|
893
|
+
finally {
|
|
894
|
+
await lease?.release();
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Lo que una pasada necesita saber de la cola antes de tomar el lease
|
|
899
|
+
* (`only` normalizado, el filtro para el puerto, lo APARCADO —siempre
|
|
900
|
+
* global— y sus entradas para el reporte). Compartido por `relayScopeChanges`
|
|
901
|
+
* y por `requeueScopeChanges({ apply })` (2.4.0-alpha.8), que releva bajo
|
|
902
|
+
* el MISMO lease y necesita releer lo aparcado DESPUÉS de reencolar.
|
|
903
|
+
*/
|
|
904
|
+
static async #relayInputs(outbox, onlyIds, batchSize, deadDetail) {
|
|
905
|
+
const only = _a.#onlyOf(onlyIds);
|
|
906
|
+
// Al puerto van los ids TAL CUAL los dio el llamante (el recibo); el
|
|
907
|
+
// conjunto de cadenas es solo para comparar sin depender del tipo.
|
|
908
|
+
const filter = only === undefined ? undefined : { ids: [...onlyIds] };
|
|
909
|
+
/** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE, y GLOBAL. */
|
|
910
|
+
const read = await _a.#deadLetters(outbox, batchSize);
|
|
911
|
+
// Bajo `only` (2.4.0-alpha.8, coordinador): la lista sigue siendo global
|
|
912
|
+
// —ids, `change`, `attempts`— pero SIN `error` salvo `deadDetail: 'full'`:
|
|
913
|
+
// la causa es texto del backend sobre el árbol de otro tenant, y la receta
|
|
914
|
+
// inline corre dentro de una petición. Lo pedido (`deadEntries`) sí la
|
|
915
|
+
// lleva: es lo suyo.
|
|
916
|
+
const dead = only === undefined || deadDetail === 'full' ? read : read.map(({ error: _error, ...safe }) => safe);
|
|
917
|
+
// El censo COMPLETO (cierre alpha.5, 🟠 2): lo que `#parkedKeys` siembra y,
|
|
918
|
+
// desde 2.4.0-alpha.8, lo que cuenta `deadCount` (`dead.length` miente al
|
|
919
|
+
// llegar a `batchSize`). Una sola lectura de más, y solo si no cabía.
|
|
920
|
+
const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
|
|
921
|
+
const deadCount = census.length;
|
|
922
|
+
/** Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`. */
|
|
923
|
+
const deadEntries = read
|
|
924
|
+
.filter((item) => only === undefined || only.has(String(item.id)))
|
|
925
|
+
.map((item) => ({
|
|
926
|
+
id: item.id,
|
|
927
|
+
change: item.change,
|
|
928
|
+
status: 'dead',
|
|
929
|
+
attempts: item.attempts,
|
|
930
|
+
...(item.error === undefined ? {} : { reason: item.error }),
|
|
931
|
+
}));
|
|
932
|
+
const missingOf = (seen) => only === undefined ? [] : [...only.values()].filter((id) => !seen.has(String(id))).map((id) => _a.#rawId(onlyIds, id));
|
|
933
|
+
return { only, filter, dead, census, deadCount, deadEntries, missingOf };
|
|
934
|
+
}
|
|
935
|
+
/** El reporte de una pasada que NO entró: otra tiene el lease. Nada aplicado, nada tocado. */
|
|
936
|
+
async #busyReport(outbox, inputs) {
|
|
937
|
+
const { remaining, queue } = await this.#queueStatus(outbox);
|
|
938
|
+
return {
|
|
939
|
+
applied: [],
|
|
940
|
+
failed: null,
|
|
941
|
+
failures: [],
|
|
942
|
+
deferred: [],
|
|
943
|
+
dead: inputs.dead,
|
|
944
|
+
deadCount: inputs.deadCount,
|
|
945
|
+
queue,
|
|
946
|
+
busy: true,
|
|
947
|
+
remaining,
|
|
948
|
+
dryRun: false,
|
|
949
|
+
wouldApply: [],
|
|
950
|
+
entries: inputs.deadEntries,
|
|
951
|
+
missing: [],
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* **La cabeza GLOBAL de la cola y su edad** (2.4.0-alpha.8; panel relay 2,
|
|
956
|
+
* cuestión 5). Es el `pending(1)` que la pasada ya hacía al final para
|
|
957
|
+
* `remaining` —cero consultas nuevas—, leído SIN filtro aunque haya `only`:
|
|
958
|
+
* la edad, como `remaining` y `dead`, es de la cola entera. La edad la mide
|
|
959
|
+
* el reloj INYECTABLE del manager (`#wallMs`), nunca `Date.now()`.
|
|
960
|
+
*/
|
|
961
|
+
async #queueStatus(outbox) {
|
|
962
|
+
const head = await outbox.pending(1);
|
|
963
|
+
return { remaining: head.length > 0, queue: this.#queueOf(head[0]) };
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* La edad de una cabeza: `Math.max(0, ahora − createdAt)` —nunca negativa:
|
|
967
|
+
* el sello lo puso el reloj de OTRA réplica, con su skew—; sin cabeza o sin
|
|
968
|
+
* `createdAt` (una outbox de terceros que no lo trae) ⇒ `null` en los dos
|
|
969
|
+
* campos, jamás `0` ni «ahora».
|
|
970
|
+
*/
|
|
971
|
+
#queueOf(head) {
|
|
972
|
+
const createdAt = head?.createdAt;
|
|
973
|
+
if (!(createdAt instanceof Date) || Number.isNaN(createdAt.getTime()))
|
|
974
|
+
return { oldestPendingAt: null, pendingAgeMs: null };
|
|
975
|
+
return { oldestPendingAt: createdAt, pendingAgeMs: Math.max(0, this.#root().#wallMs() - createdAt.getTime()) };
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* **El lease del escritor único, con espera acotada** (2.4.0-alpha.8;
|
|
979
|
+
* `waitForLeaseMs`, panel relay 2, cuestión 7). Con `0` (el default) es
|
|
980
|
+
* UN intento y `null` = `busy`, como siempre — el caso «dos pasadas a la vez
|
|
981
|
+
* ⇒ busy» no cambia. Con un tope, reintenta `acquire()` cada
|
|
982
|
+
* `RELAY_LEASE_POLL_MS` hasta vencerlo: `busy` al vencer, jamás un cuelgue.
|
|
983
|
+
* El reloj es el inyectable del manager (nunca `Date.now()` en `src/`).
|
|
984
|
+
* Exige pool ≥ 2 (README): con pool 1 el que espera puede estar reteniendo
|
|
985
|
+
* la única conexión que necesita el que tiene el lease.
|
|
986
|
+
*/
|
|
987
|
+
async #acquireLease(outbox, waitForLeaseMs) {
|
|
988
|
+
if (!outbox.acquire)
|
|
989
|
+
return null;
|
|
990
|
+
let lease = await outbox.acquire();
|
|
991
|
+
if (lease !== null || waitForLeaseMs <= 0)
|
|
992
|
+
return lease;
|
|
993
|
+
const deadline = this.#root().#wallMs() + waitForLeaseMs;
|
|
994
|
+
// Dos topes a la vez: el reloj de pared (inyectable) y el NÚMERO de sondeos —
|
|
995
|
+
// con un reloj fijo el primero no vence jamás (medido por el auditor de alpha.8).
|
|
996
|
+
const maxPolls = Math.ceil(waitForLeaseMs / RELAY_LEASE_POLL_MS);
|
|
997
|
+
for (let polls = 0; lease === null && polls < maxPolls && this.#root().#wallMs() < deadline; polls++) {
|
|
998
|
+
await new Promise((resolve) => setTimeout(resolve, RELAY_LEASE_POLL_MS));
|
|
999
|
+
lease = await outbox.acquire();
|
|
1000
|
+
}
|
|
1001
|
+
return lease;
|
|
1002
|
+
}
|
|
1003
|
+
/** `waitForLeaseMs` normalizado: entero >= 0 (500 si no); ausente = 0. */
|
|
1004
|
+
static #waitMs(value) {
|
|
1005
|
+
if (value === undefined)
|
|
1006
|
+
return 0;
|
|
1007
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
1008
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs debe ser un entero >= 0 en milisegundos (llegó ${String(value)})`);
|
|
1009
|
+
}
|
|
1010
|
+
if (value > RELAY_WAIT_LEASE_MAX_MS) {
|
|
1011
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs = ${value} supera el tope RELAY_WAIT_LEASE_MAX_MS (${RELAY_WAIT_LEASE_MAX_MS} ms): ` +
|
|
1012
|
+
`una espera sin tope cuelga la petición del tenant; acorta la pasada (only/limit) en vez de esperar más.`);
|
|
1013
|
+
}
|
|
1014
|
+
return value;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* **La pasada, ya con el lease tomado**: lo que hasta 2.4.0-alpha.7 era el
|
|
1018
|
+
* cuerpo de `relayScopeChanges`. Compartida con `requeueScopeChanges({
|
|
1019
|
+
* apply })`, que la llama bajo SU lease con `only` = los ids reencolados
|
|
1020
|
+
* (auditor 🟠 5: tomar el lease dos veces era `busy` en verde).
|
|
1021
|
+
*/
|
|
1022
|
+
async #drainScopeChanges(outbox, input) {
|
|
1023
|
+
const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, missingOf } = input;
|
|
1024
|
+
const driver = await this.driver();
|
|
1025
|
+
const applied = [];
|
|
1026
|
+
const deferred = [];
|
|
1027
|
+
const failures = [];
|
|
1028
|
+
const entries = [];
|
|
1029
|
+
/**
|
|
1030
|
+
* Claves de scope contaminadas ⇒ el id que las contaminó y el ORIGEN del
|
|
1031
|
+
* bloqueo (alpha.5 · cierre, auditor 🟠 1). `parked: true` = la causa
|
|
1032
|
+
* raíz (transitiva) es una entrada APARCADA: tapona solo lo que concede
|
|
1033
|
+
* o recuelga (`attached`/`moved`), NUNCA un `detached` — si no, un
|
|
1034
|
+
* `attached` aplazado por aparcado congelaba la purga del nodo que el
|
|
1035
|
+
* tenant ya borró, para siempre (medido: el scope conservaba sus hechos
|
|
1036
|
+
* y su arista, y seguía concediendo; invariante 11). `parked: false` =
|
|
1037
|
+
* un FALLO real de esta pasada (o una pendiente anterior no pedida):
|
|
1038
|
+
* contamina todo, `detached` incluido — el orden entre un `attached`
|
|
1039
|
+
* que falló y la purga de ese mismo nodo sigue importando (3b-2h).
|
|
1040
|
+
*/
|
|
1041
|
+
const blocked = new Map();
|
|
1042
|
+
/** Origen ya sondeado de entradas NO pedidas (alpha.6): `parked` = retenida solo por un aparcado. Se olvida en cuanto la pasada registra un bloqueo que no es de aparcado (podría contaminar una clave ya sondeada). */
|
|
1043
|
+
const origins = new Map();
|
|
1044
|
+
const block = (key, id, origin) => {
|
|
1045
|
+
const current = blocked.get(key);
|
|
1046
|
+
// Precedencia `failed > pending > parked` (2.4.0-alpha.8; desde el
|
|
1047
|
+
// cierre de alpha.5: un fallo real pisa a un aparcado, nunca al revés).
|
|
1048
|
+
if (current === undefined || BLOCK_RANK[origin] > BLOCK_RANK[current.origin])
|
|
1049
|
+
blocked.set(key, { id, origin });
|
|
1050
|
+
if (origin !== 'parked')
|
|
1051
|
+
origins.clear();
|
|
1052
|
+
};
|
|
1053
|
+
/**
|
|
1054
|
+
* **Las claves de lo APARCADO** (alpha.5 · R2a): taponan lo que concede
|
|
1055
|
+
* o recuelga y dejan pasar lo que retira. El censo es COMPLETO —no
|
|
1056
|
+
* depende de `limit` ni de `batchSize` (cierre, 🟠 2)— con su propia
|
|
1057
|
+
* cota; el reporte sigue enseñando las primeras `batchSize`.
|
|
1058
|
+
*/
|
|
1059
|
+
const parkedKeys = _a.#parkedKeys(census);
|
|
1060
|
+
// Una outbox que no marca lo aplicado devolvería el mismo pendiente
|
|
1061
|
+
// para siempre: el relay no puede quedarse dando vueltas ni
|
|
1062
|
+
// "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
|
|
1063
|
+
// vuelve a ver un id que YA aplicó.
|
|
1064
|
+
const done = new Set();
|
|
1065
|
+
/** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
|
|
1066
|
+
const parked = new Set();
|
|
1067
|
+
/** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
|
|
1068
|
+
let after;
|
|
1069
|
+
const defer = (item, keys, reason, error, origin) => {
|
|
1070
|
+
for (const key of keys)
|
|
1071
|
+
block(key, item.id, origin);
|
|
1072
|
+
parked.add(String(item.id));
|
|
1073
|
+
deferred.push({ id: item.id, change: item.change, attempts: item.attempts, error });
|
|
1074
|
+
// `origin` se DERIVA del bloqueo (2.4.0-alpha.8), jamás del prefijo de `reason`.
|
|
1075
|
+
entries.push({ id: item.id, change: item.change, status: 'deferred', reason, attempts: item.attempts, origin });
|
|
1076
|
+
};
|
|
1077
|
+
outer: while (applied.length < limit) {
|
|
1078
|
+
// **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
|
|
1079
|
+
// mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
|
|
1080
|
+
// (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
|
|
1081
|
+
// adquiriera el freeze durable: escrituras que no salen en ningún
|
|
1082
|
+
// contador de la pasada certificada y que pueden invalidar su
|
|
1083
|
+
// resultado. El trade-off documentado en freeze.ts cubre «una
|
|
1084
|
+
// escritura que ya pasó su barrera», no una pasada entera. El coste
|
|
1085
|
+
// (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
|
|
1086
|
+
// medidos y aceptados) va fuera del camino caliente. Un freeze
|
|
1087
|
+
// adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
|
|
1088
|
+
// lo ya aplicado está marcado en la outbox (la pasada es reanudable)
|
|
1089
|
+
// y el resto sigue pendiente para después de la ventana.
|
|
1090
|
+
await this.#assertNotFrozen('authz:scopes:relay');
|
|
1091
|
+
const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after, filter);
|
|
1092
|
+
if (batch.length === 0)
|
|
1093
|
+
break;
|
|
1094
|
+
_a.#assertOnlyHonoured(batch, only);
|
|
1095
|
+
let progress = false;
|
|
1096
|
+
for (const item of batch) {
|
|
1097
|
+
const id = String(item.id);
|
|
1098
|
+
if (done.has(id)) {
|
|
1099
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
|
|
1100
|
+
'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
|
|
1101
|
+
}
|
|
1102
|
+
if (parked.has(id))
|
|
1103
|
+
continue;
|
|
1104
|
+
progress = true;
|
|
1105
|
+
after = item.id;
|
|
1106
|
+
if (applied.length >= limit)
|
|
1107
|
+
break outer;
|
|
1108
|
+
const keys = _a.#changeKeys(item.change);
|
|
1109
|
+
// Una purga solo la frena un bloqueo cuyo origen sea un FALLO real;
|
|
1110
|
+
// el que nace de un aparcado la deja pasar (invariante 11).
|
|
1111
|
+
const collision = keys.find((key) => {
|
|
1112
|
+
const hit = blocked.get(key);
|
|
1113
|
+
return hit !== undefined && !(item.change.op === 'detached' && hit.origin === 'parked');
|
|
1114
|
+
});
|
|
1115
|
+
if (collision !== undefined) {
|
|
1116
|
+
const by = blocked.get(collision);
|
|
1117
|
+
defer(item, keys, `depends-on:${by.id}`, `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada (#${by.id})`, by.origin);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
// Lo aparcado tapona lo que CONCEDE o recuelga; una purga pasa.
|
|
1121
|
+
if (item.change.op !== 'detached') {
|
|
1122
|
+
const parkedKey = keys.find((key) => parkedKeys.has(key));
|
|
1123
|
+
if (parkedKey !== undefined) {
|
|
1124
|
+
const by = parkedKeys.get(parkedKey);
|
|
1125
|
+
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`, 'parked');
|
|
981
1126
|
continue;
|
|
982
1127
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1128
|
+
}
|
|
1129
|
+
// Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
|
|
1130
|
+
// que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
|
|
1131
|
+
// **Y la sonda hereda el ORIGEN de esa anterior** (alpha.6, auditor 🟡 A
|
|
1132
|
+
// de alpha.5): si la anterior está —transitivamente— retenida solo por
|
|
1133
|
+
// un APARCADO, es el mismo bloqueo `parked:` que en la pasada completa
|
|
1134
|
+
// deja pasar la purga (invariante 11): el `detached` SE APLICA. Si la
|
|
1135
|
+
// anterior es un `attached`/`moved` pendiente de verdad (aplicable, o
|
|
1136
|
+
// retenido por un FALLO real), el `detached` sigue `deferred`: aplicarlo
|
|
1137
|
+
// antes que un `moved` pendiente del mismo scope dejaría deriva.
|
|
1138
|
+
if (only !== undefined) {
|
|
1139
|
+
const earlier = await this.#dependencyOf(outbox, item, batchSize);
|
|
1140
|
+
if (earlier !== null) {
|
|
1141
|
+
const origin = await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, origins);
|
|
1142
|
+
if (!(item.change.op === 'detached' && origin === 'parked')) {
|
|
1143
|
+
const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
|
|
1144
|
+
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}); ` +
|
|
1145
|
+
(origin === 'parked' ? 'espera a una entrada APARCADA; mira dead()' : 'corre el relay completo'), origin);
|
|
989
1146
|
continue;
|
|
990
1147
|
}
|
|
991
1148
|
}
|
|
992
|
-
// Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
|
|
993
|
-
// que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
|
|
994
|
-
// **Y la sonda hereda el ORIGEN de esa anterior** (alpha.6, auditor 🟡 A
|
|
995
|
-
// de alpha.5): si la anterior está —transitivamente— retenida solo por
|
|
996
|
-
// un APARCADO, es el mismo bloqueo `parked:` que en la pasada completa
|
|
997
|
-
// deja pasar la purga (invariante 11): el `detached` SE APLICA. Si la
|
|
998
|
-
// anterior es un `attached`/`moved` pendiente de verdad (aplicable, o
|
|
999
|
-
// retenido por un FALLO real), el `detached` sigue `deferred`: aplicarlo
|
|
1000
|
-
// antes que un `moved` pendiente del mismo scope dejaría deriva.
|
|
1001
|
-
if (only !== undefined) {
|
|
1002
|
-
const earlier = await this.#dependencyOf(outbox, item, batchSize);
|
|
1003
|
-
if (earlier !== null) {
|
|
1004
|
-
const byParked = await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, origins);
|
|
1005
|
-
if (!(item.change.op === 'detached' && byParked)) {
|
|
1006
|
-
const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
|
|
1007
|
-
defer(item, keys, `depends-on:${earlier.id}`, `aplazado: depende de ${shared}, que nombra la entrada anterior #${earlier.id} todavía sin aplicar (depends-on:${earlier.id}); ` +
|
|
1008
|
-
(byParked ? 'espera a una entrada APARCADA; mira dead()' : 'corre el relay completo'), byParked);
|
|
1009
|
-
continue;
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
try {
|
|
1014
|
-
await this.#applyScopeChange(driver, item);
|
|
1015
|
-
}
|
|
1016
|
-
catch (error) {
|
|
1017
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1018
|
-
const transient = _a.#isTransientRelayFailure(error);
|
|
1019
|
-
await outbox.markFailed(item.id, message, { transient });
|
|
1020
|
-
for (const key of keys)
|
|
1021
|
-
block(key, item.id, false);
|
|
1022
|
-
parked.add(id);
|
|
1023
|
-
failures.push({ id: item.id, change: item.change, error: message, transient });
|
|
1024
|
-
entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
|
|
1025
|
-
continue;
|
|
1026
|
-
}
|
|
1027
|
-
await outbox.markApplied(item.id);
|
|
1028
|
-
done.add(id);
|
|
1029
|
-
applied.push({ id: item.id, change: item.change, attempts: item.attempts });
|
|
1030
|
-
entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
|
|
1031
1149
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1150
|
+
try {
|
|
1151
|
+
await this.#applyScopeChange(driver, item);
|
|
1152
|
+
}
|
|
1153
|
+
catch (error) {
|
|
1154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1155
|
+
const transient = _a.#isTransientRelayFailure(error);
|
|
1156
|
+
await outbox.markFailed(item.id, message, { transient });
|
|
1157
|
+
for (const key of keys)
|
|
1158
|
+
block(key, item.id, 'failed');
|
|
1159
|
+
parked.add(id);
|
|
1160
|
+
failures.push({ id: item.id, change: item.change, error: message, transient });
|
|
1161
|
+
entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
await outbox.markApplied(item.id);
|
|
1165
|
+
done.add(id);
|
|
1166
|
+
applied.push({ id: item.id, change: item.change, attempts: item.attempts });
|
|
1167
|
+
entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
|
|
1037
1168
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
deferred,
|
|
1044
|
-
dead,
|
|
1045
|
-
busy: false,
|
|
1046
|
-
remaining: (await outbox.pending(1)).length > 0,
|
|
1047
|
-
dryRun: false,
|
|
1048
|
-
wouldApply: [],
|
|
1049
|
-
entries,
|
|
1050
|
-
missing: missingOf(new Set(entries.map((e) => String(e.id)))),
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
finally {
|
|
1054
|
-
await lease?.release();
|
|
1169
|
+
// Una outbox que ignora `after` devuelve el mismo lote atascado: la
|
|
1170
|
+
// pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
|
|
1171
|
+
// haya detrás, sigue pendiente para la siguiente).
|
|
1172
|
+
if (!progress)
|
|
1173
|
+
break;
|
|
1055
1174
|
}
|
|
1175
|
+
entries.push(...deadEntries);
|
|
1176
|
+
// La cabeza global de la cola: `remaining` y, desde alpha.8, su EDAD.
|
|
1177
|
+
const { remaining, queue } = await this.#queueStatus(outbox);
|
|
1178
|
+
return {
|
|
1179
|
+
applied,
|
|
1180
|
+
failed: failures[0] ?? null,
|
|
1181
|
+
failures,
|
|
1182
|
+
deferred,
|
|
1183
|
+
dead,
|
|
1184
|
+
deadCount,
|
|
1185
|
+
queue,
|
|
1186
|
+
busy: false,
|
|
1187
|
+
remaining,
|
|
1188
|
+
dryRun: false,
|
|
1189
|
+
wouldApply: [],
|
|
1190
|
+
entries,
|
|
1191
|
+
missing: missingOf(new Set(entries.map((e) => String(e.id)))),
|
|
1192
|
+
};
|
|
1056
1193
|
}
|
|
1057
1194
|
/**
|
|
1058
1195
|
* `only` normalizado (alpha.5 · R1): un conjunto de ids como cadena (el id
|
|
@@ -1109,15 +1246,14 @@ export class AuthorizationManager {
|
|
|
1109
1246
|
}
|
|
1110
1247
|
/**
|
|
1111
1248
|
* Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
|
|
1112
|
-
* (alpha.5 · R2a). El censo
|
|
1113
|
-
* lote (menos de `batchSize` filas = no hay más); si no,
|
|
1114
|
-
* hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit`
|
|
1115
|
-
* con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no
|
|
1116
|
-
* `attached` colgaba del padre viejo)—, y si la cota se
|
|
1117
|
-
* se niega (500): un tapón incompleto no es un tapón.
|
|
1118
|
-
*/
|
|
1119
|
-
static
|
|
1120
|
-
const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
|
|
1249
|
+
* (alpha.5 · R2a). El censo lo lee `#relayInputs`: el `dead` ya leído si
|
|
1250
|
+
* cabe entero en un lote (menos de `batchSize` filas = no hay más); si no,
|
|
1251
|
+
* la cola ENTERA hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit`
|
|
1252
|
+
* (cierre, 🟠 2: con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no
|
|
1253
|
+
* taponaba y el `attached` colgaba del padre viejo)—, y si la cota se
|
|
1254
|
+
* alcanza la pasada se niega (500): un tapón incompleto no es un tapón.
|
|
1255
|
+
*/
|
|
1256
|
+
static #parkedKeys(census) {
|
|
1121
1257
|
if (census.length >= PARKED_CENSUS_MAX) {
|
|
1122
1258
|
throw new AuthorizationConfigError(`authz:scopes:relay: la outbox tiene ${PARKED_CENSUS_MAX} o más entradas APARCADAS y el censo de sus claves no cabe ` +
|
|
1123
1259
|
`(PARKED_CENSUS_MAX): sin el censo entero el tapón por clave sería ciego y un attached podría colgarse del padre ` +
|
|
@@ -1125,9 +1261,19 @@ export class AuthorizationManager {
|
|
|
1125
1261
|
}
|
|
1126
1262
|
const keys = new Map();
|
|
1127
1263
|
for (const item of census) {
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1264
|
+
// **Solo la clave del HIJO** (2.4.0-alpha.8 · variante B; dueño: sí).
|
|
1265
|
+
// Una aparcada dice que la POSICIÓN DE SU HIJO en el store es
|
|
1266
|
+
// incorrecta; no dice nada de su padre nuevo: un `attached(otraUnit,
|
|
1267
|
+
// orgA)` detrás de un `moved(unit → orgA)` aparcado escribe otra arista
|
|
1268
|
+
// y conmuta (medido, `m7-tapon.ts`: se aplazaba sin motivo y el store
|
|
1269
|
+
// conservaba el padre VIEJO de todo lo que colgara de orgA). Lo que SÍ
|
|
1270
|
+
// nombra al hijo —`attached(child, unit)`, `moved(unit → orgC)`— sigue
|
|
1271
|
+
// taponado. Vive SOLO aquí, en la siembra: al COMPARAR (`#changeKeys`
|
|
1272
|
+
// en `blocked`, el respaldo de la sonda) van siempre las dos claves, o
|
|
1273
|
+
// el caso 1136 se pierde (trampa medida por el juez).
|
|
1274
|
+
const key = scopeKey(item.change.child);
|
|
1275
|
+
if (!keys.has(key))
|
|
1276
|
+
keys.set(key, item.id);
|
|
1131
1277
|
}
|
|
1132
1278
|
return keys;
|
|
1133
1279
|
}
|
|
@@ -1143,8 +1289,9 @@ export class AuthorizationManager {
|
|
|
1143
1289
|
async #dependencyOf(outbox, item, batchSize) {
|
|
1144
1290
|
const keys = _a.#changeKeys(item.change);
|
|
1145
1291
|
if (typeof outbox.earlierPending === 'function') {
|
|
1146
|
-
|
|
1147
|
-
|
|
1292
|
+
// La misma lista que `#changeKeys` (la raíz exenta, alpha.8): el SQL del
|
|
1293
|
+
// puerto sí sabe casar `parent_type = 'app'`; el manager no se lo pide.
|
|
1294
|
+
return outbox.earlierPending(item.id, _a.#changeScopes(item.change));
|
|
1148
1295
|
}
|
|
1149
1296
|
const mine = String(item.id);
|
|
1150
1297
|
let after;
|
|
@@ -1186,23 +1333,31 @@ export class AuthorizationManager {
|
|
|
1186
1333
|
const known = memo.get(id);
|
|
1187
1334
|
if (known !== undefined)
|
|
1188
1335
|
return known;
|
|
1189
|
-
const decide = (
|
|
1190
|
-
memo.set(id,
|
|
1191
|
-
return
|
|
1336
|
+
const decide = (origin) => {
|
|
1337
|
+
memo.set(id, origin);
|
|
1338
|
+
return origin;
|
|
1192
1339
|
};
|
|
1340
|
+
// Pasada la cota, hacia lo seguro: como un fallo real.
|
|
1193
1341
|
if (hops >= RELAY_ORIGIN_MAX_HOPS)
|
|
1194
|
-
return decide(
|
|
1342
|
+
return decide('failed');
|
|
1195
1343
|
const keys = _a.#changeKeys(entry.change);
|
|
1196
1344
|
const hits = keys.map((key) => blocked.get(key)).filter((hit) => hit !== undefined);
|
|
1197
|
-
|
|
1198
|
-
|
|
1345
|
+
// Desde 2.4.0-alpha.8 el origen es TRI-estado (`failed > pending > parked`):
|
|
1346
|
+
// la decisión aplicar/aplazar no cambia (solo `parked` exime a la purga);
|
|
1347
|
+
// lo que cambia es que el reporte distingue «espera a una pendiente de
|
|
1348
|
+
// verdad» de «bloqueada por un fallo».
|
|
1349
|
+
if (hits.some((hit) => hit.origin === 'failed'))
|
|
1350
|
+
return decide('failed');
|
|
1351
|
+
if (hits.some((hit) => hit.origin === 'pending'))
|
|
1352
|
+
return decide('pending');
|
|
1199
1353
|
if (entry.change.op !== 'detached') {
|
|
1200
1354
|
if (hits.length > 0 || keys.some((key) => parkedKeys.has(key)))
|
|
1201
|
-
return decide(
|
|
1355
|
+
return decide('parked');
|
|
1202
1356
|
}
|
|
1203
1357
|
const earlier = await this.#dependencyOf(outbox, entry, batchSize);
|
|
1358
|
+
// Ninguna anterior: se aplicaría en la pasada completa ⇒ la pedida espera a una PENDIENTE de verdad.
|
|
1204
1359
|
if (earlier === null)
|
|
1205
|
-
return decide(
|
|
1360
|
+
return decide('pending');
|
|
1206
1361
|
return decide(await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, memo, hops + 1));
|
|
1207
1362
|
}
|
|
1208
1363
|
/**
|
|
@@ -1240,10 +1395,45 @@ export class AuthorizationManager {
|
|
|
1240
1395
|
*
|
|
1241
1396
|
* Exige `dead`, `requeue` y `laterApplied` en el puerto (500
|
|
1242
1397
|
* `E_AUTHZ_UNSUPPORTED` nombrando el que falte): sin censo no se sabe qué
|
|
1243
|
-
* se reencola y sin sonda no hay requeue.
|
|
1244
|
-
* no escribe en el backend; la pasada que aplique la
|
|
1398
|
+
* se reencola y sin sonda no hay requeue. Sin `apply` no pasa por la
|
|
1399
|
+
* barrera del freeze: no escribe en el backend; la pasada que aplique la
|
|
1400
|
+
* entrada sí la pasa.
|
|
1401
|
+
*
|
|
1402
|
+
* **`apply: true` — reencolar Y relevar en la misma llamada** (2.4.0-alpha.8;
|
|
1403
|
+
* panel relay 2, cuestión 4; decisión del dueño 2026-09-10 (4)). Medido
|
|
1404
|
+
* (tester P14): la reencolada vuelve con su id ORIGINAL (menor) y la
|
|
1405
|
+
* siguiente pasada `only` del tenant salía `deferred · depends-on:` — el
|
|
1406
|
+
* incidente no se cerraba con un comando. Con `apply`, tras reencolar se
|
|
1407
|
+
* releva SOLO eso (`only` = exactamente los ids reencolados: los
|
|
1408
|
+
* dependientes NO se arrastran, quedan `deferred`/pendientes y el reporte lo
|
|
1409
|
+
* dice —«`only` es mi recibo»—) y el reporte del relay viaja en
|
|
1410
|
+
* `report.relay`. Cuatro condiciones, todas fijadas con caso: (1) **un solo
|
|
1411
|
+
* lease** para toda la llamada (tomarlo dos veces era `busy` en verde,
|
|
1412
|
+
* auditor 🟠 5) — se toma PRIMERO, y si está ocupado es `relay.busy` con
|
|
1413
|
+
* CERO filas reencoladas (reencolar y decir `busy` dejaba `attempts = max −
|
|
1414
|
+
* 1` y la entrada se volvía a aparcar contaminando su clave); (2) **pasa la
|
|
1415
|
+
* barrera del freeze** ANTES de tocar nada (con `apply` escribe el árbol);
|
|
1416
|
+
* (3) **excluyente con `force`** (500 juntos: con las dos, revertir el árbol
|
|
1417
|
+
* pasaba de dos llamadas de plataforma a una, auditor 🟠 6); (4) la sonda
|
|
1418
|
+
* anti-reversión corre ANTES de reencolar, como siempre. `waitForLeaseMs`
|
|
1419
|
+
* (solo con `apply`) es la misma espera acotada que en el relay, para que
|
|
1420
|
+
* el comando que cierra el incidente no pierda siempre contra el cron.
|
|
1245
1421
|
*/
|
|
1246
1422
|
async requeueScopeChanges(ids, options = {}) {
|
|
1423
|
+
_a.#assertKnownOptions('requeueScopeChanges', options, REQUEUE_OPTIONS);
|
|
1424
|
+
const apply = options.apply === true;
|
|
1425
|
+
if (apply && options.force === true) {
|
|
1426
|
+
throw new AuthorizationConfigError('authz:scopes:requeue: apply y force no se combinan: con las dos, revertir el árbol del store por encima de lo ya ' +
|
|
1427
|
+
'aplicado sería UNA sola llamada. Reencola con --force y releva después a sabiendas, o usa --apply sin --force. ' +
|
|
1428
|
+
'No se ha hecho nada.');
|
|
1429
|
+
}
|
|
1430
|
+
if (!apply && options.waitForLeaseMs !== undefined) {
|
|
1431
|
+
throw new AuthorizationConfigError('authz:scopes:requeue: waitForLeaseMs solo tiene sentido con apply (sin apply no se toma ningún lease). No se ha hecho nada.');
|
|
1432
|
+
}
|
|
1433
|
+
const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
|
|
1434
|
+
// Con `apply` se ESCRIBE el árbol: la barrera del freeze va antes de nada.
|
|
1435
|
+
if (apply)
|
|
1436
|
+
await this.#assertNotFrozen('authz:scopes:requeue --apply');
|
|
1247
1437
|
const outbox = this.#outbox();
|
|
1248
1438
|
if (!outbox) {
|
|
1249
1439
|
throw new AuthorizationConfigError("authz:scopes:requeue necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada aparcado que reencolar.");
|
|
@@ -1259,6 +1449,22 @@ export class AuthorizationManager {
|
|
|
1259
1449
|
: 'Devuelve la entrada aparcada a pending() con attempts = maxAttempts − 1, todo o nada.');
|
|
1260
1450
|
}
|
|
1261
1451
|
}
|
|
1452
|
+
// Con `apply`, el lease PRIMERO (una vez para toda la llamada): ocupado ⇒
|
|
1453
|
+
// `busy` con cero filas tocadas. Sin `apply` no hay lease que tomar.
|
|
1454
|
+
const lease = apply ? await this.#acquireLease(outbox, waitForLeaseMs) : null;
|
|
1455
|
+
if (apply && outbox.acquire && lease === null) {
|
|
1456
|
+
const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
|
|
1457
|
+
return { requeued: [], overrides: [], forced: false, relay: await this.#busyReport(outbox, inputs) };
|
|
1458
|
+
}
|
|
1459
|
+
try {
|
|
1460
|
+
return await this.#requeueHeld(outbox, wanted, options.force === true, apply);
|
|
1461
|
+
}
|
|
1462
|
+
finally {
|
|
1463
|
+
await lease?.release();
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/** `requeueScopeChanges` con el lease (si `apply`) ya tomado: censo, sonda anti-reversión, requeue y —con `apply`— el relay de esos ids. */
|
|
1467
|
+
async #requeueHeld(outbox, wanted, force, apply) {
|
|
1262
1468
|
// El censo de lo aparcado (acotado como el del relay): solo eso se reencola.
|
|
1263
1469
|
const parked = await outbox.dead(DEFAULT_RELAY_LIMIT);
|
|
1264
1470
|
const byId = new Map();
|
|
@@ -1273,7 +1479,9 @@ export class AuthorizationManager {
|
|
|
1273
1479
|
const conflicts = [];
|
|
1274
1480
|
for (const id of wanted) {
|
|
1275
1481
|
const item = byId.get(String(id));
|
|
1276
|
-
|
|
1482
|
+
// La misma lista que `#changeKeys` (la raíz exenta, alpha.8): dos altas de
|
|
1483
|
+
// tenant no se revierten entre sí, y el conflicto que salía era falso.
|
|
1484
|
+
const scopes = _a.#changeScopes(item.change);
|
|
1277
1485
|
// Se pide UNA de más para saber si el diff que va a leer el operador está cortado (cierre, ⚪ 6).
|
|
1278
1486
|
const later = await outbox.laterApplied(item.id, scopes, DEFAULT_RELAY_BATCH + 1);
|
|
1279
1487
|
const applied = later.slice(0, DEFAULT_RELAY_BATCH);
|
|
@@ -1281,7 +1489,6 @@ export class AuthorizationManager {
|
|
|
1281
1489
|
conflicts.push({ parked: item, applied, ...(later.length > DEFAULT_RELAY_BATCH ? { truncated: true } : {}) });
|
|
1282
1490
|
}
|
|
1283
1491
|
}
|
|
1284
|
-
const force = options.force === true;
|
|
1285
1492
|
if (conflicts.length > 0 && !force) {
|
|
1286
1493
|
const detail = conflicts
|
|
1287
1494
|
.map((conflict) => {
|
|
@@ -1299,7 +1506,15 @@ export class AuthorizationManager {
|
|
|
1299
1506
|
`ace admite --force (imprime este diff y entra igual).`, conflicts);
|
|
1300
1507
|
}
|
|
1301
1508
|
await outbox.requeue([...wanted]);
|
|
1302
|
-
|
|
1509
|
+
const report = { requeued: wanted.map((id) => byId.get(String(id))), overrides: conflicts, forced: force };
|
|
1510
|
+
if (!apply)
|
|
1511
|
+
return report;
|
|
1512
|
+
// Bajo el MISMO lease: solo los ids reencolados, con lo aparcado releído
|
|
1513
|
+
// (las reencoladas ya no salen por `dead()`); la barrera del freeze se
|
|
1514
|
+
// re-afirma por lote dentro, como en toda pasada.
|
|
1515
|
+
const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
|
|
1516
|
+
report.relay = await this.#drainScopeChanges(outbox, { limit: DEFAULT_RELAY_LIMIT, batchSize: DEFAULT_RELAY_BATCH, ...inputs });
|
|
1517
|
+
return report;
|
|
1303
1518
|
}
|
|
1304
1519
|
/** Los ids de `requeue`: lista NO vacía de números o cadenas, sin repetidos (500 si no). */
|
|
1305
1520
|
static #requeueIds(ids) {
|
|
@@ -1601,11 +1816,42 @@ export class AuthorizationManager {
|
|
|
1601
1816
|
* comparten ninguna no pueden interactuar en el árbol —toda dependencia
|
|
1602
1817
|
* (recolgar, cerrar un ciclo, purgar) viaja por un nodo nombrado—, así que
|
|
1603
1818
|
* el orden RELATIVO que hay que conservar es exactamente este.
|
|
1819
|
+
*
|
|
1820
|
+
* **La raíz `app` NO es clave** (2.4.0-alpha.8; panel relay 2, cuestión 1;
|
|
1821
|
+
* decisión del dueño 2026-09-10 (4)). La raíz no se mueve, no se purga y
|
|
1822
|
+
* nunca es hijo (los tres 422 de `#assertEdge`/`scopes.detached`), así que
|
|
1823
|
+
* dos cambios cuyo ÚNICO cruce sea `app` conmutan: ningún orden que
|
|
1824
|
+
* conservar (el auditor lo buscó y no lo encontró; `m2-orden.ts`). Con
|
|
1825
|
+
* `[hijo, app]` toda alta de tenant compartía clave con todas las demás:
|
|
1826
|
+
* una aparcada bajo la raíz congelaba el registro entero (5 pasadas con
|
|
1827
|
+
* `applied = []`, medido en un consumidor), dos altas concurrentes con la
|
|
1828
|
+
* receta inline dejaban la segunda `deferred` SIN nada aparcado, y
|
|
1829
|
+
* `requeue` se negaba con un conflicto falso. Se decide por REF
|
|
1830
|
+
* (`#isRootRef`), nunca por la cadena `'app'`, y en los TRES sitios que
|
|
1831
|
+
* construyen la lista: aquí, `#dependencyOf` y `requeueScopeChanges` (los
|
|
1832
|
+
* dos por `#changeScopes`); también para el bloqueo por FALLO (una sola
|
|
1833
|
+
* regla). La lista nunca queda vacía: el hijo siempre va.
|
|
1604
1834
|
*/
|
|
1605
1835
|
static #changeKeys(change) {
|
|
1606
|
-
return change.
|
|
1607
|
-
|
|
1608
|
-
|
|
1836
|
+
return _a.#changeScopes(change).map((scope) => scopeKey(scope));
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Los scopes de `#changeKeys` como REFERENCIAS: la lista que va al puerto
|
|
1840
|
+
* (`earlierPending`/`laterApplied`). Es la misma decisión que
|
|
1841
|
+
* `#changeKeys` y vive en un solo sitio a propósito (auditor 🟠 3 del panel
|
|
1842
|
+
* relay 2: exentar la raíz solo en las claves dejaba el mutex vivo bajo
|
|
1843
|
+
* `only`, porque el SQL de la sonda sí casa `parent_type = 'app'`).
|
|
1844
|
+
*/
|
|
1845
|
+
static #changeScopes(change) {
|
|
1846
|
+
if (change.op === 'detached')
|
|
1847
|
+
return [change.child];
|
|
1848
|
+
if (_a.#isRootRef(change.parent))
|
|
1849
|
+
return [change.child];
|
|
1850
|
+
return [change.child, change.parent];
|
|
1851
|
+
}
|
|
1852
|
+
/** ¿Es la raíz? Por REF (`type === 'app' && uuid === null`), jamás comparando la cadena `'app'`. */
|
|
1853
|
+
static #isRootRef(scope) {
|
|
1854
|
+
return scope.type === APP_SCOPE_TYPE && (scope.uuid ?? null) === null;
|
|
1609
1855
|
}
|
|
1610
1856
|
/** Lo aparcado por la outbox (si sabe aparcar), listo para el reporte. */
|
|
1611
1857
|
static async #deadLetters(outbox, limit) {
|
|
@@ -1648,6 +1894,28 @@ export class AuthorizationManager {
|
|
|
1648
1894
|
await this.#notify(event);
|
|
1649
1895
|
}
|
|
1650
1896
|
/** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
|
|
1897
|
+
/**
|
|
1898
|
+
* **Una opción desconocida es 500, nunca silencio** (2.4.0-alpha.8 · X-1;
|
|
1899
|
+
* tester del panel relay 2, §10-d). Medido en alpha.7: `requeueScopeChanges(ids,
|
|
1900
|
+
* { apply: true })` y `relayScopeChanges({ waitForLeaseMs })` se IGNORABAN
|
|
1901
|
+
* —el objeto de opciones no se validaba— y un consumidor en JS (o con
|
|
1902
|
+
* `as any`) creía que aplicaba o esperaba cuando no; el síntoma era un
|
|
1903
|
+
* `busy` o un `deferred` achacado a otra cosa. Se comprueban las CLAVES
|
|
1904
|
+
* (un valor `undefined` en una clave conocida vale: es lo que pasan los
|
|
1905
|
+
* comandos ace), y el objeto tiene que serlo.
|
|
1906
|
+
*/
|
|
1907
|
+
static #assertKnownOptions(operation, options, known) {
|
|
1908
|
+
if (options === undefined)
|
|
1909
|
+
return;
|
|
1910
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
1911
|
+
throw new AuthorizationConfigError(`${operation}: las opciones deben ser un objeto { ${known.join(', ')} }; llegó ${Array.isArray(options) ? 'una lista' : String(options)}.`);
|
|
1912
|
+
}
|
|
1913
|
+
const unknown = Object.keys(options).filter((key) => !known.includes(key));
|
|
1914
|
+
if (unknown.length > 0) {
|
|
1915
|
+
throw new AuthorizationConfigError(`${operation}: opción(es) desconocida(s): ${unknown.join(', ')}. Las conocidas son ${known.join(', ')}. ` +
|
|
1916
|
+
'Una opción que no existe no se ignora: en JS creerías que aplica (o espera) cuando no. No se ha hecho nada.');
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1651
1919
|
static #positive(value, fallback, name) {
|
|
1652
1920
|
if (value === undefined)
|
|
1653
1921
|
return fallback;
|
|
@@ -1860,10 +2128,16 @@ export class AuthorizationManager {
|
|
|
1860
2128
|
throw new AuthorizationConfigError(`${operation}: scopes.maxDepth debe ser un entero >= 1 (llegó ${String(maxDepth)}); quítalo si no quieres techo.`);
|
|
1861
2129
|
}
|
|
1862
2130
|
if (chain.length > maxDepth) {
|
|
2131
|
+
// El `message` es para el TENANT (2.4.0-alpha.8, cuestión 7.2): sin el
|
|
2132
|
+
// nombre del backend ni sus constantes; eso va en `details`, para el log.
|
|
1863
2133
|
throw new ScopeDepthLimitError(`${operation}: colgar ${child.type}:${child.uuid} de ${parent.type}:${parent.uuid ?? ''} lo dejaría a ${chain.length} ` +
|
|
1864
|
-
`saltos de
|
|
1865
|
-
`
|
|
1866
|
-
|
|
2134
|
+
`saltos de la raíz y el techo es scopes.maxDepth=${maxDepth}. No se ha escrito ni encolado nada. Aplana el árbol ` +
|
|
2135
|
+
`(o pide al operador que suba el techo).`, {
|
|
2136
|
+
depth: chain.length,
|
|
2137
|
+
maxDepth,
|
|
2138
|
+
why: 'el modelo facts del driver openfga no resuelve de forma fiable más allá de FACTS_MAX_RESOLVE_DEPTH: pasado él, authorize es 503 para todo ese subárbol',
|
|
2139
|
+
server: 'para subir el techo hay que subir también OPENFGA_RESOLVE_NODE_LIMIT en el servidor',
|
|
2140
|
+
});
|
|
1867
2141
|
}
|
|
1868
2142
|
}
|
|
1869
2143
|
// La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
|