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