@jantstack/adonis-authz 2.4.0-alpha.9 → 2.5.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +468 -30
  2. package/build/commands/authz_freeze.js +1 -1
  3. package/build/commands/authz_freeze.js.map +1 -1
  4. package/build/commands/authz_reconcile.d.ts +6 -2
  5. package/build/commands/authz_reconcile.d.ts.map +1 -1
  6. package/build/commands/authz_reconcile.js +129 -13
  7. package/build/commands/authz_reconcile.js.map +1 -1
  8. package/build/commands/authz_relations_reconcile.d.ts +16 -24
  9. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  10. package/build/commands/authz_relations_reconcile.js +198 -49
  11. package/build/commands/authz_relations_reconcile.js.map +1 -1
  12. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  13. package/build/commands/authz_scopes_relay.js +81 -1
  14. package/build/commands/authz_scopes_relay.js.map +1 -1
  15. package/build/commands/authz_unfreeze.d.ts +12 -4
  16. package/build/commands/authz_unfreeze.d.ts.map +1 -1
  17. package/build/commands/authz_unfreeze.js +74 -9
  18. package/build/commands/authz_unfreeze.js.map +1 -1
  19. package/build/index.d.ts +6 -4
  20. package/build/index.d.ts.map +1 -1
  21. package/build/index.js +5 -3
  22. package/build/index.js.map +1 -1
  23. package/build/providers/authz_provider.d.ts +1 -1
  24. package/build/providers/authz_provider.d.ts.map +1 -1
  25. package/build/providers/authz_provider.js +5 -1
  26. package/build/providers/authz_provider.js.map +1 -1
  27. package/build/src/define_config.d.ts +41 -0
  28. package/build/src/define_config.d.ts.map +1 -1
  29. package/build/src/define_config.js +120 -0
  30. package/build/src/define_config.js.map +1 -1
  31. package/build/src/drivers/database_driver.d.ts.map +1 -1
  32. package/build/src/drivers/database_driver.js +1 -0
  33. package/build/src/drivers/database_driver.js.map +1 -1
  34. package/build/src/drivers/database_relations_driver.d.ts +18 -6
  35. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  36. package/build/src/drivers/database_relations_driver.js +94 -46
  37. package/build/src/drivers/database_relations_driver.js.map +1 -1
  38. package/build/src/drivers/openfga_driver.d.ts +46 -5
  39. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  40. package/build/src/drivers/openfga_driver.js +115 -29
  41. package/build/src/drivers/openfga_driver.js.map +1 -1
  42. package/build/src/drivers/openfga_relations_driver.d.ts +82 -13
  43. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  44. package/build/src/drivers/openfga_relations_driver.js +216 -55
  45. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  46. package/build/src/errors.d.ts +86 -0
  47. package/build/src/errors.d.ts.map +1 -1
  48. package/build/src/errors.js +86 -0
  49. package/build/src/errors.js.map +1 -1
  50. package/build/src/freeze.d.ts +63 -4
  51. package/build/src/freeze.d.ts.map +1 -1
  52. package/build/src/freeze.js +107 -18
  53. package/build/src/freeze.js.map +1 -1
  54. package/build/src/identity.d.ts +120 -9
  55. package/build/src/identity.d.ts.map +1 -1
  56. package/build/src/identity.js +164 -12
  57. package/build/src/identity.js.map +1 -1
  58. package/build/src/manager.d.ts +64 -2
  59. package/build/src/manager.d.ts.map +1 -1
  60. package/build/src/manager.js +267 -35
  61. package/build/src/manager.js.map +1 -1
  62. package/build/src/openfga.d.ts +2 -2
  63. package/build/src/openfga.d.ts.map +1 -1
  64. package/build/src/openfga.js +2 -2
  65. package/build/src/openfga.js.map +1 -1
  66. package/build/src/relations/check_many.d.ts +17 -0
  67. package/build/src/relations/check_many.d.ts.map +1 -0
  68. package/build/src/relations/check_many.js +57 -0
  69. package/build/src/relations/check_many.js.map +1 -0
  70. package/build/src/relations/define_relations_config.d.ts +74 -1
  71. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  72. package/build/src/relations/define_relations_config.js +201 -7
  73. package/build/src/relations/define_relations_config.js.map +1 -1
  74. package/build/src/relations/manager.d.ts +20 -0
  75. package/build/src/relations/manager.d.ts.map +1 -1
  76. package/build/src/relations/manager.js +88 -15
  77. package/build/src/relations/manager.js.map +1 -1
  78. package/build/src/relations/reconcile.d.ts +134 -3
  79. package/build/src/relations/reconcile.d.ts.map +1 -1
  80. package/build/src/relations/reconcile.js +340 -36
  81. package/build/src/relations/reconcile.js.map +1 -1
  82. package/build/src/relations/tuple_key.d.ts +25 -0
  83. package/build/src/relations/tuple_key.d.ts.map +1 -0
  84. package/build/src/relations/tuple_key.js +60 -0
  85. package/build/src/relations/tuple_key.js.map +1 -0
  86. package/build/src/relations_config_store.d.ts.map +1 -1
  87. package/build/src/relations_config_store.js +29 -7
  88. package/build/src/relations_config_store.js.map +1 -1
  89. package/build/src/relations_reconcile.d.ts +79 -0
  90. package/build/src/relations_reconcile.d.ts.map +1 -0
  91. package/build/src/relations_reconcile.js +85 -0
  92. package/build/src/relations_reconcile.js.map +1 -0
  93. package/build/src/scope_outbox.d.ts +9 -5
  94. package/build/src/scope_outbox.d.ts.map +1 -1
  95. package/build/src/scope_outbox.js +7 -4
  96. package/build/src/scope_outbox.js.map +1 -1
  97. package/build/src/shared/backend_guard.d.ts +30 -11
  98. package/build/src/shared/backend_guard.d.ts.map +1 -1
  99. package/build/src/shared/backend_guard.js +45 -15
  100. package/build/src/shared/backend_guard.js.map +1 -1
  101. package/build/src/shared/sql_expiry.d.ts +2 -0
  102. package/build/src/shared/sql_expiry.d.ts.map +1 -1
  103. package/build/src/shared/sql_expiry.js +4 -0
  104. package/build/src/shared/sql_expiry.js.map +1 -1
  105. package/build/src/testing/main.d.ts +3 -1
  106. package/build/src/testing/main.d.ts.map +1 -1
  107. package/build/src/testing/main.js +2 -1
  108. package/build/src/testing/main.js.map +1 -1
  109. package/build/src/testing/migration_contract.d.ts +9 -0
  110. package/build/src/testing/migration_contract.d.ts.map +1 -1
  111. package/build/src/testing/migration_contract.js +5 -2
  112. package/build/src/testing/migration_contract.js.map +1 -1
  113. package/build/src/testing/relations_contract.d.ts +14 -0
  114. package/build/src/testing/relations_contract.d.ts.map +1 -1
  115. package/build/src/testing/relations_contract.js +367 -21
  116. package/build/src/testing/relations_contract.js.map +1 -1
  117. package/build/src/testing/scope_outbox_contract.d.ts +14 -1
  118. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
  119. package/build/src/testing/scope_outbox_contract.js +126 -48
  120. package/build/src/testing/scope_outbox_contract.js.map +1 -1
  121. package/build/src/testing/test_clock.d.ts +15 -0
  122. package/build/src/testing/test_clock.d.ts.map +1 -0
  123. package/build/src/testing/test_clock.js +65 -0
  124. package/build/src/testing/test_clock.js.map +1 -0
  125. package/build/src/types.d.ts +115 -12
  126. package/build/src/types.d.ts.map +1 -1
  127. package/build/src/types.js.map +1 -1
  128. package/package.json +6 -1
@@ -1,15 +1,16 @@
1
1
  var _a;
2
2
  import { Exception } from '@adonisjs/core/exceptions';
3
3
  import { v7 as uuidv7 } from 'uuid';
4
- import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, scopeSpellings, } from './identity.js';
4
+ import { activeRelationsDriverKey, assertAuthorizationConfigShape } from './define_config.js';
5
+ import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, } from './identity.js';
5
6
  import { expiryChanged } from './expiry.js';
6
7
  import { randomBytes } from 'node:crypto';
7
- import { DEFAULT_FREEZE_LEASE_MS, acquireFreeze, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
8
- import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
8
+ import { DEFAULT_FREEZE_LEASE_MS, acquireFreezeOutcome, UNKNOWN_HOLDER, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
9
+ import { assertKnownScope, canonicalScopeTargets, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
9
10
  import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
10
11
  import { assertAssignableAt } from './catalog/catalog.js';
11
12
  import { systemClock } from './clock.js';
12
- import { ActorRequiredError, AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, ScopeDepthLimitError, ScopeRequeueRefusedError, } from './errors.js';
13
+ import { 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, StaleSourceRefusedError, OperatorWindowExpiredError, } from './errors.js';
13
14
  import { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
14
15
  import { memoizeAncestors } from './memoize_ancestors.js';
15
16
  import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
@@ -20,12 +21,26 @@ const ROLE_DESCRIPTION_MAX = 500;
20
21
  function describeAnswer(answer) {
21
22
  if (!Array.isArray(answer))
22
23
  return `${answer === null ? 'null' : typeof answer} (no es un array)`;
23
- const offending = answer.find((b) => typeof b !== 'boolean');
24
- if (offending !== undefined || answer.some((b) => typeof b !== 'boolean')) {
25
- return `un array de ${answer.length} con un elemento que no es boolean (${typeof offending})`;
24
+ for (let i = 0; i < answer.length; i++) {
25
+ if (!(i in answer))
26
+ return `un array de ${answer.length} con un HUECO en la posición ${i}`;
27
+ if (typeof answer[i] !== 'boolean')
28
+ return `un array de ${answer.length} con un elemento que no es boolean en la posición ${i} (${typeof answer[i]})`;
26
29
  }
27
30
  return `un array de ${answer.length}`;
28
31
  }
32
+ /**
33
+ * ¿Cada POSICIÓN del array es un boolean? Por índice, nunca con `some`/`every`
34
+ * (que saltan los huecos de un array disperso: 🟡 A3 del auditor del lote A;
35
+ * gemelo de `checkMany` en `src/relations/manager.ts`).
36
+ */
37
+ function everyPositionBoolean(answers) {
38
+ for (let i = 0; i < answers.length; i++) {
39
+ if (typeof answers[i] !== 'boolean')
40
+ return false;
41
+ }
42
+ return true;
43
+ }
29
44
  /**
30
45
  * Configs que ya recibieron el aviso de seguridad opt-in (B7/B1): una vez
31
46
  * por config, no por instancia — `forRequest()` construye managers hijos con
@@ -140,6 +155,11 @@ export class AuthorizationManager {
140
155
  */
141
156
  #heldFreeze = null;
142
157
  constructor(config) {
158
+ // Cierre 1 del lote B · 🟡 6 (L5): la FORMA del config se valida aquí también —
159
+ // el provider construye con `new AuthorizationManager(config)` y un consumidor
160
+ // puede saltarse `defineConfig`; un typo (`requireWithing`) no puede entrar en
161
+ // silencio por ninguna puerta.
162
+ assertAuthorizationConfigShape(config);
143
163
  this.#config = config;
144
164
  if (config.clock !== undefined && typeof config.clock !== 'function') {
145
165
  throw new AuthorizationConfigError(`AuthorizationManager: config.clock debe ser una función () => Date (llegó ${typeof config.clock})`);
@@ -406,12 +426,30 @@ export class AuthorizationManager {
406
426
  const finalReason = reason ?? 'una operación de plataforma';
407
427
  const holder = `${kind}:${process.pid}:${randomBytes(4).toString('hex')}`;
408
428
  const nowMs = root.#wallMs();
409
- const token = await acquireFreeze({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
410
- if (token === null) {
411
- const row = await readFreezeRow({ driver: this.#config.default });
429
+ // alpha.10 · S2 (+ cierre del auditor · R2): una ventana de OPERADOR
430
+ // caducada no se pisa en silencio. Una fila con el lease vencido se toma
431
+ // encima (así la flota vuelve sola tras un SIGKILL de una pasada), pero la
432
+ // del operador es EVIDENCIA: pisarla y soltarla después la deja a NULL y
433
+ // borra el único rastro de que las escrituras volvieron a entrar a mitad
434
+ // del cutover. La exclusión vive DENTRO del UPDATE de `acquireFreezeOutcome`
435
+ // (atómica): aquí solo se lee el veredicto y la fila para redactarlo — un
436
+ // «compruebo y luego tomo» con dos lecturas del reloj era un TOCTOU.
437
+ const outcome = await acquireFreezeOutcome({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
438
+ if (outcome.status === 'expired-operator') {
439
+ const row = outcome.row;
440
+ const when = row.untilMs === null ? '?' : new Date(row.untilMs).toISOString();
441
+ throw new OperatorWindowExpiredError(`freeze (${reason ?? kind}): la ventana del OPERADOR (${row.holder ?? '?'}, ${row.reason}, fence ` +
442
+ `${row.fence}) CADUCÓ el ${when} y nadie la cerró: desde entonces las escrituras volvieron a entrar en ` +
443
+ `toda la flota. No se toma una ventana propia encima —borraría ese rastro al soltarla y la pasada se ` +
444
+ `certificaría como si hubiera corrido dentro del cutover—. Reconócela con authz:unfreeze (dice cuánto ` +
445
+ `tiempo estuvo abierta y limpia la fila) y reabre la ventana con authz:freeze antes de repetir.`);
446
+ }
447
+ if (outcome.status === 'held') {
448
+ const row = outcome.row;
412
449
  throw new FreezeHeldError(`freeze: ya hay un freeze VIVO de otro dueño (${row.holder ?? '?'}, fence ${row.fence}, motivo: ${row.reason ?? '?'}). ` +
413
450
  `Espera a que termine, o levántalo con authz:unfreeze si su proceso murió sin lease.`);
414
451
  }
452
+ const token = outcome.token;
415
453
  const held = { token, reason: finalReason, kind, leaseMs, timer: null, lapsed: false };
416
454
  if (leaseMs !== null) {
417
455
  const interval = Math.max(250, Math.floor(leaseMs / 3));
@@ -472,6 +510,39 @@ export class AuthorizationManager {
472
510
  get frozen() {
473
511
  return this.#root().#heldFreeze !== null;
474
512
  }
513
+ /**
514
+ * La clave (en `relations.drivers`) del driver de RELACIONES activo:
515
+ * `relations.default ?? default` (2.4.0-alpha.10). La lee el guard de la
516
+ * dirección rancia de `runRelationsReconcile`; desde el cierre del auditor
517
+ * (R4) el llamante ya no puede contradecirla con un `activeKey` propio.
518
+ */
519
+ relationsDriverKey() {
520
+ return activeRelationsDriverKey(this.#root().#config);
521
+ }
522
+ /** Las claves registradas en `relations.drivers` del config de ESTE manager (vacío si no hay relaciones). */
523
+ relationsDriverKeys() {
524
+ return Object.keys(this.#root().#config.relations?.drivers ?? {});
525
+ }
526
+ /**
527
+ * La factory registrada bajo `key` en `relations.drivers` de ESTE manager, o
528
+ * `undefined` (cierre del auditor de alpha.10 · R4). `runRelationsReconcile`
529
+ * resuelve el origen y el destino por aquí —del MISMO config que decide el
530
+ * activo— y juzga la identidad por la factory resuelta, no por la etiqueta:
531
+ * dos claves con la misma factory son el mismo driver (y dos factories DISTINTAS
532
+ * sobre el mismo backend NO: límite declarado, cierre-2 · C1).
533
+ *
534
+ * **API de PLATAFORMA, junto a `driver()`** (cierre-2 · C6): con esta factory
535
+ * cualquier código que tenga el manager —vistas de `forRequest()` incluidas—
536
+ * construye un driver de relaciones NO activo y escribe en él sin `assertWrite`,
537
+ * `requireActor`, freeze ni `onRelationWrite`. No añade capacidad (el config ya
538
+ * da las factories), pero es otra cosa que buscar en una revisión.
539
+ */
540
+ relationsDriverFactory(key) {
541
+ const drivers = this.#root().#config.relations?.drivers;
542
+ if (drivers === undefined || !Object.prototype.hasOwnProperty.call(drivers, key))
543
+ return undefined;
544
+ return drivers[key];
545
+ }
475
546
  /** El freeze VIVO de la fila compartida, o `null`. Lo lee cualquiera; solo el token lo levanta. */
476
547
  async freezeStatus() {
477
548
  const row = await readFreezeRow({ driver: this.#config.default });
@@ -479,12 +550,42 @@ export class AuthorizationManager {
479
550
  return null;
480
551
  return {
481
552
  reason: row.reason,
482
- holder: row.holder ?? '?',
553
+ holder: row.holder ?? UNKNOWN_HOLDER,
483
554
  kind: freezeKindOf(row.holder),
484
555
  untilMs: row.untilMs,
485
556
  fence: row.fence,
486
557
  };
487
558
  }
559
+ /**
560
+ * **La ÚLTIMA ventana de la fila, también la CADUCADA** (2.4.0-alpha.10, 🟠 2
561
+ * del sexto informe de COGNITIV). `freezeStatus()` responde solo lo VIVO —y
562
+ * así lo siguen usando la barrera y los comandos—, de modo que una ventana
563
+ * cuyo lease venció era indistinguible de «nunca hubo»: `authz:unfreeze`
564
+ * decía éxito y el último paso del cutover no delataba que las escrituras
565
+ * habían vuelto a entrar a mitad. Se distingue por la fila: `releaseFreeze`
566
+ * pone el motivo a `null` al levantar, así que motivo puesto + `untilMs` en
567
+ * el pasado es una ventana que CADUCÓ sin que nadie la cerrase.
568
+ *
569
+ * `null` = nunca hubo o ya se levantó; `expired: false` = viva (lo mismo que
570
+ * `freezeStatus()`); `expired: true` = caducada. `checkedAtMs` es el instante
571
+ * del reloj del config con el que se decidió.
572
+ */
573
+ async lastFreezeStatus() {
574
+ const row = await readFreezeRow({ driver: this.#config.default });
575
+ if (row.reason === null)
576
+ return null;
577
+ const nowMs = this.#root().#wallMs();
578
+ return {
579
+ reason: row.reason,
580
+ holder: row.holder ?? UNKNOWN_HOLDER,
581
+ kind: freezeKindOf(row.holder),
582
+ untilMs: row.untilMs,
583
+ fence: row.fence,
584
+ expired: !freezeIsLive(row, nowMs),
585
+ // El instante (reloj del config) con el que se decidió `expired`.
586
+ checkedAtMs: nowMs,
587
+ };
588
+ }
488
589
  /**
489
590
  * `freeze()` + `finally unfreeze(token)`. El `finally` es la parte que
490
591
  * importa: una migración que revienta a la mitad no puede dejar la
@@ -502,7 +603,13 @@ export class AuthorizationManager {
502
603
  operatorAsContext: options.operatorAsContext === true,
503
604
  });
504
605
  try {
505
- return await fn({ fence: context.fence, leaseMs: context.leaseMs, lapsed: context.lapsed });
606
+ return await fn({
607
+ fence: context.fence,
608
+ leaseMs: context.leaseMs,
609
+ lapsed: context.lapsed,
610
+ ...(context.untilMs === undefined ? {} : { untilMs: context.untilMs }),
611
+ ...(context.window === undefined ? {} : { window: context.window }),
612
+ });
506
613
  }
507
614
  finally {
508
615
  await context.release();
@@ -528,6 +635,7 @@ export class AuthorizationManager {
528
635
  return {
529
636
  fence: outer.token.fence,
530
637
  leaseMs: outer.leaseMs,
638
+ window: 'enclosing',
531
639
  release: async () => { },
532
640
  lapsed: () => root.#tokenLapsed(outer.token, outer.lapsed),
533
641
  };
@@ -536,9 +644,14 @@ export class AuthorizationManager {
536
644
  const status = await this.freezeStatus();
537
645
  if (status !== null && status.kind === 'operator') {
538
646
  const token = { fence: status.fence, holder: status.holder };
647
+ // alpha.10 · 3: la caducidad REAL de la ventana adoptada. Hasta alpha.9
648
+ // `leaseMs: null` fijo — el reporte decía «sin caducidad» de una ventana
649
+ // abierta con `--lease-ms` (la certificación sí leía la fila).
539
650
  return {
540
651
  fence: status.fence,
541
- leaseMs: null,
652
+ leaseMs: status.untilMs === null ? null : Math.max(0, status.untilMs - root.#wallMs()),
653
+ untilMs: status.untilMs,
654
+ window: 'operator',
542
655
  release: async () => { },
543
656
  lapsed: () => root.#tokenLapsed(token, false),
544
657
  };
@@ -549,6 +662,7 @@ export class AuthorizationManager {
549
662
  return {
550
663
  fence: token.fence,
551
664
  leaseMs: held.leaseMs,
665
+ window: 'own',
552
666
  release: async () => {
553
667
  try {
554
668
  await this.unfreeze(token);
@@ -731,14 +845,19 @@ export class AuthorizationManager {
731
845
  //
732
846
  // Y cuando NO hay cadena —la fila ya no existe, que es el orden
733
847
  // soportado de `detached` (3F · S1)— no hay con qué canonizar: se
734
- // purgan TODAS las ortografías de las que el uuid del llamante puede
735
- // ser alias (`scopeSpellings`, 3b-2h · 🟠 3). Con la fila viva esto es
736
- // exactamente una, la de la tabla; sin ella, la del llamante y la
737
- // canónica que un motor pudo fundir con la suya. Antes se usaba la del
738
- // llamante a secas: `purgeScope` demostraba cero sobre un objeto que no
739
- // existe, devolvía OK, y el scope real seguía concediendo para siempre.
740
- const chain = await resolveChain(this.#freshResolver(), child, 'scopes.detached');
741
- const targets = chain ? [chain[0]] : scopeSpellings(child);
848
+ // purgan las ortografías de las que el uuid del llamante puede ser
849
+ // alias (`scopeSpellings`, 3b-2h · 🟠 3: lo que PG funde,
850
+ // `pgFusedSpellings`) MENOS las que el resolutor reconozca como un
851
+ // scope VIVO distinto del pedido (cierre-3 del lote A · 🟡 C2-2: con
852
+ // ids de texto, la canónica de un alias puede ser la unit viva de otro
853
+ // tenant, y `purgeScope` en `database` le borraría los denies). Con la
854
+ // fila viva esto es exactamente una, la de la tabla. El punto de paso
855
+ // es ÚNICO y compartido con `revoke`/`removeDeny` de los dos drivers
856
+ // (`canonicalScopeTargets`); un resolutor que falla al contrastar es
857
+ // 503, nunca «purgar por si acaso». Antes se usaba la del llamante a
858
+ // secas: `purgeScope` demostraba cero sobre un objeto que no existe,
859
+ // devolvía OK, y el scope real seguía concediendo para siempre.
860
+ const targets = await canonicalScopeTargets(this.#freshResolver(), child, 'scopes.detached');
742
861
  const outbox = this.#outbox();
743
862
  if (outbox) {
744
863
  // La identidad se resuelve AQUÍ, con la fila del consumidor todavía
@@ -857,12 +976,13 @@ export class AuthorizationManager {
857
976
  throw new AuthorizationConfigError(`authz:scopes:relay: deadDetail solo admite 'full' (llegó ${JSON.stringify(options.deadDetail)}); sin él, bajo only, dead[] va sin error.`);
858
977
  }
859
978
  const inputs = await _a.#relayInputs(outbox, options.only, batchSize, options.deadDetail);
860
- const { only, filter, dead, deadCount, deadEntries, missingOf } = inputs;
979
+ const { only, filter, dead, deadCount, deadEntries, unseenOf } = inputs;
861
980
  if (dryRun) {
862
981
  const batch = await outbox.pending(limit, undefined, filter);
863
982
  _a.#assertOnlyHonoured(batch, only);
864
983
  const extra = batch.length >= limit ? true : false;
865
984
  const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
985
+ const unseen = await unseenOf(seen);
866
986
  // La edad es GLOBAL: sin filtro la cabeza es `batch[0]`; con `only` hay que mirar la cola entera.
867
987
  const queue = filter === undefined ? this.#queueOf(batch[0]) : (await this.#queueStatus(outbox)).queue;
868
988
  return {
@@ -878,7 +998,7 @@ export class AuthorizationManager {
878
998
  dryRun: true,
879
999
  wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
880
1000
  entries: deadEntries,
881
- missing: missingOf(seen),
1001
+ ...unseen,
882
1002
  };
883
1003
  }
884
1004
  // El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
@@ -919,8 +1039,14 @@ export class AuthorizationManager {
919
1039
  // llegar a `batchSize`). Una sola lectura de más, y solo si no cabía.
920
1040
  const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
921
1041
  const deadCount = census.length;
922
- /** Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`. */
923
- const deadEntries = read
1042
+ /**
1043
+ * Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`.
1044
+ * Cierre-2 · C2 (gemelo): con `only` sale del CENSO completo si la lectura se
1045
+ * cortó en `batchSize` — si no, un id pedido aparcado más allá del primer lote
1046
+ * era `missing` («no estaba ni pendiente ni aparcado») estando aparcado.
1047
+ */
1048
+ const deadSource = only !== undefined && read.length >= batchSize ? census : read;
1049
+ const deadEntries = deadSource
924
1050
  .filter((item) => only === undefined || only.has(String(item.id)))
925
1051
  .map((item) => ({
926
1052
  id: item.id,
@@ -929,8 +1055,29 @@ export class AuthorizationManager {
929
1055
  attempts: item.attempts,
930
1056
  ...(item.error === undefined ? {} : { reason: item.error }),
931
1057
  }));
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 };
1058
+ /**
1059
+ * **Qué fue de los ids pedidos que la pasada NO vio** (cierre-2 de alpha.10 ·
1060
+ * C2). Hasta el cierre-2 todos eran `missing`; pero un id fuera del lote porque
1061
+ * la pasada alcanzó `limit` sigue PENDIENTE. Se pregunta a la cola —filtrada por
1062
+ * esos ids, sin límite propio, UNA lectura y solo si hay alguno sin ver—: lo que
1063
+ * sigue pendiente es `notEvaluated`; el resto, `missing` (no está en la cola: lo
1064
+ * aparcado ya está en `seen`).
1065
+ */
1066
+ const unseenOf = async (seen) => {
1067
+ if (only === undefined)
1068
+ return { missing: [], notEvaluated: [] };
1069
+ const unseen = [...only.values()].filter((id) => !seen.has(id)).map((id) => _a.#rawId(onlyIds, id));
1070
+ if (unseen.length === 0)
1071
+ return { missing: [], notEvaluated: [] };
1072
+ const stillPending = await outbox.pending(unseen.length, undefined, { ids: unseen });
1073
+ _a.#assertOnlyHonoured(stillPending, new Set(unseen.map(String)));
1074
+ const pendingIds = new Set(stillPending.map((item) => String(item.id)));
1075
+ return {
1076
+ missing: unseen.filter((id) => !pendingIds.has(String(id))),
1077
+ notEvaluated: unseen.filter((id) => pendingIds.has(String(id))),
1078
+ };
1079
+ };
1080
+ return { only, filter, dead, census, deadCount, deadEntries, unseenOf };
934
1081
  }
935
1082
  /** El reporte de una pasada que NO entró: otra tiene el lease. Nada aplicado, nada tocado. */
936
1083
  async #busyReport(outbox, inputs) {
@@ -949,6 +1096,7 @@ export class AuthorizationManager {
949
1096
  wouldApply: [],
950
1097
  entries: inputs.deadEntries,
951
1098
  missing: [],
1099
+ notEvaluated: [],
952
1100
  };
953
1101
  }
954
1102
  /**
@@ -1020,7 +1168,7 @@ export class AuthorizationManager {
1020
1168
  * (auditor 🟠 5: tomar el lease dos veces era `busy` en verde).
1021
1169
  */
1022
1170
  async #drainScopeChanges(outbox, input) {
1023
- const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, missingOf } = input;
1171
+ const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, unseenOf } = input;
1024
1172
  const driver = await this.driver();
1025
1173
  const applied = [];
1026
1174
  const deferred = [];
@@ -1188,7 +1336,7 @@ export class AuthorizationManager {
1188
1336
  dryRun: false,
1189
1337
  wouldApply: [],
1190
1338
  entries,
1191
- missing: missingOf(new Set(entries.map((e) => String(e.id)))),
1339
+ ...(await unseenOf(new Set(entries.map((e) => String(e.id))))),
1192
1340
  };
1193
1341
  }
1194
1342
  /**
@@ -1586,6 +1734,49 @@ export class AuthorizationManager {
1586
1734
  throw new UnsupportedOperationError('reconcile', `authz:reconcile --to=${name}`, name, `El driver '${name}' no sabe reconstruirse desde 'authz_*' + el árbol del consumidor. ` +
1587
1735
  `El driver 'database' es ese caso a propósito: sus tablas son el ORIGEN.`);
1588
1736
  }
1737
+ // **La dirección RANCIA** (2.4.0-alpha.10; 🔴 4 del sexto informe de
1738
+ // COGNITIV y cuarta iteración del LINAJE «origen rancio del reconcile»:
1739
+ // 3b-5 cerró la pasada por defecto, el primer guard de alpha.10 cerró
1740
+ // `--from`, y quedaba abierto el origen elegido POR DESCARTE). Regla del
1741
+ // dueño: el tercer defecto de un linaje se arregla estructuralmente, así
1742
+ // que el guard NO mira la bandera, mira el ORIGEN RESUELTO — con la MISMA
1743
+ // decisión que `#factsOrigin` (`#isMaintenancePass`), no con una copia:
1744
+ // si `to` es el driver ACTIVO, el único origen legítimo es `to` mismo (la
1745
+ // pasada de MANTENIMIENTO); cualquier otro —`--from`, el driver elegido por
1746
+ // descarte o las tablas `authz_*`— dejó de ver los hechos en el cutover y la
1747
+ // pasada re-escribiría lo revocado después (con o sin --prune; el seguro de
1748
+ // borrado masivo no salta: el origen no está vacío). Se decide por `to`,
1749
+ // nunca por `from`: el cutover inicial y la vuelta atrás ANTES de cambiar
1750
+ // `default` tienen `to` ≠ activo y pasan. Antes de resolver el origen y de
1751
+ // congelar. `--dry-run` no lanza (es el verificador): lo marca y
1752
+ // `reconcileLines` lo hace deriva. `from === to` sigue siendo el 500
1753
+ // E_AUTHZ_CONFIG de 3b-3b.
1754
+ const stale = this.#sameDriver(name, this.#config.default) &&
1755
+ (options.from === undefined || !this.#sameDriver(options.from, name)) &&
1756
+ !this.#isMaintenancePass(name, target, options.from);
1757
+ const allowStale = options.allowStaleSource === true;
1758
+ if (stale && !allowStale && options.dryRun !== true) {
1759
+ const how = options.from !== undefined ? `--from=${options.from}` : 'sin --from';
1760
+ // Cierre del auditor (⚪ 8): con UN solo driver registrado no hay descarte que
1761
+ // hacer; la letra no puede hablar de un origen que no existe.
1762
+ const others = Object.keys(this.#config.drivers ?? {}).filter((key) => !this.#sameDriver(key, name));
1763
+ const originLabel = options.from !== undefined
1764
+ ? `el origen '${options.from}' no lo es`
1765
+ : others.length === 0
1766
+ ? `no hay ningún otro driver registrado ni pasada de mantenimiento para él (no guarda sus hechos en su ` +
1767
+ `backend: no declara hierarchyFacts), así que no hay nada de dónde reconciliar hacia '${name}'`
1768
+ : `sin --from el origen se elegiría POR DESCARTE (las tablas authz_* u otro driver registrado: ` +
1769
+ `${others.join(', ')}), y no es '${name}'`;
1770
+ throw new StaleSourceRefusedError(`authz:reconcile --to=${name} ${how}: '${name}' es el driver ACTIVO (config.default` +
1771
+ `${name === this.#config.default ? '' : `: la clave resuelve la MISMA factory que '${this.#config.default}'`}) y ${originLabel}. ` +
1772
+ `Esa dirección re-escribiría en el driver activo hechos que el origen ya no refleja: resucitaría las ` +
1773
+ `revocaciones hechas desde el cutover (y con --prune borraría lo concedido después). No se ha leído, ` +
1774
+ `congelado ni escrito nada. Hacia el driver activo el único origen es él mismo: la pasada de mantenimiento ` +
1775
+ `(authz:reconcile --to=${name} [--dry-run], sin --from) si su driver guarda sus hechos; y si lo que quieres ` +
1776
+ `es volver atrás, migra en la otra dirección ANTES de cambiar config.default. Si de verdad el origen sigue ` +
1777
+ `siendo la fuente de verdad (el motor aún no ha escrito desde '${name}'), la decisión humana es ` +
1778
+ `--allow-stale-source (allowStaleSource: true).`);
1779
+ }
1589
1780
  // **De dónde salen los HECHOS** (3b-5): la decisión que faltaba, y la que
1590
1781
  // el destino no puede tomar por su cuenta. Ver `#factsOrigin`.
1591
1782
  const origin = await this.#factsOrigin(name, target, options.from);
@@ -1596,7 +1787,7 @@ export class AuthorizationManager {
1596
1787
  // `authz_*` no construye ningún driver de más. Y el origen se resuelve
1597
1788
  // UNA vez.
1598
1789
  facts: origin.enumerate,
1599
- factsOrigin: { name: origin.name, authzTables: origin.authzTables },
1790
+ factsOrigin: { name: origin.name, authzTables: origin.authzTables, ...(origin.self ? { self: true } : {}) },
1600
1791
  // El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
1601
1792
  // el mismo mando que el gate de `scopes.*`.
1602
1793
  ...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
@@ -1618,6 +1809,8 @@ export class AuthorizationManager {
1618
1809
  // Quién fue el origen se DICE, siempre: es la diferencia entre una
1619
1810
  // migración y una pasada de mantenimiento contra el driver activo.
1620
1811
  report.factsFrom = origin.resolved();
1812
+ if (stale)
1813
+ report.staleSource = { to: name, from: origin.resolved(), allowed: allowStale };
1621
1814
  return report;
1622
1815
  };
1623
1816
  // La pasada que escribe congela; el verificador NO (ver el docblock).
@@ -1641,6 +1834,8 @@ export class AuthorizationManager {
1641
1834
  lapsed: await window.lapsed(),
1642
1835
  leaseMs: window.leaseMs,
1643
1836
  fence: window.fence,
1837
+ ...(window.untilMs === undefined ? {} : { untilMs: window.untilMs }),
1838
+ ...(window.window === undefined ? {} : { window: window.window }),
1644
1839
  };
1645
1840
  return report;
1646
1841
  }
@@ -1668,7 +1863,9 @@ export class AuthorizationManager {
1668
1863
  * MANTENIMIENTO: los hechos se leen del propio destino por el puerto
1669
1864
  * (`enumerateFacts`), se rehace lo DERIVADO (marcador, catálogo, árbol)
1670
1865
  * y se aplica el barrido de visibilidad del invariante 18 con el árbol
1671
- * y el catálogo de HOY. No se inventa ni se borra un solo hecho. Un
1866
+ * y el catálogo de HOY. No se inventa ningún hecho; con `--prune` se
1867
+ * borran los que ya no respaldan nada (scopes que ya no resuelven,
1868
+ * caducados, roles/permisos retirados, y los que sobran). Un
1672
1869
  * destino activo con `hierarchyFacts` que no sepa enumerar sus hechos
1673
1870
  * es 500 `E_AUTHZ_UNSUPPORTED`: leerle `authz_*` sería justo el defecto.
1674
1871
  * 2. **`--from=<nombre>` manda**, y por eso se resuelve YA: de la
@@ -1682,7 +1879,7 @@ export class AuthorizationManager {
1682
1879
  * de 3b-3b (`#factsEnumerator`).
1683
1880
  */
1684
1881
  async #factsOrigin(to, target, from) {
1685
- if (from === undefined && to === this.#config.default && target.capabilities?.hierarchyFacts === true) {
1882
+ if (this.#isMaintenancePass(to, target, from)) {
1686
1883
  if (typeof target.enumerateFacts !== 'function') {
1687
1884
  throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `El motor SIRVE desde '${to}' y ese driver declara que el árbol y los hechos viven en su backend ` +
1688
1885
  `(hierarchyFacts), así que 'authz_assignments'/'authz_denies' NO son la fuente de verdad de sus ` +
@@ -1692,6 +1889,7 @@ export class AuthorizationManager {
1692
1889
  return {
1693
1890
  name: to,
1694
1891
  authzTables: false,
1892
+ self: true,
1695
1893
  enumerate: (page) => target.enumerateFacts(page),
1696
1894
  resolved: () => to,
1697
1895
  };
@@ -1717,10 +1915,44 @@ export class AuthorizationManager {
1717
1915
  });
1718
1916
  return { name: AUTHZ_TABLES_ORIGIN, authzTables: true, enumerate, resolved: () => resolvedName };
1719
1917
  }
1918
+ /**
1919
+ * **¿Es la pasada de MANTENIMIENTO?** (3b-5; punto ÚNICO desde alpha.10): el
1920
+ * destino es el driver ACTIVO, sus hechos viven en su backend
1921
+ * (`hierarchyFacts`) y nadie nombró otro origen. Es la ÚNICA forma en la que
1922
+ * el origen resuelto de una pasada hacia el driver activo es el propio
1923
+ * destino, y la usan los dos que la necesitan: `#factsOrigin` (de dónde se
1924
+ * leen los hechos) y el guard de la dirección rancia de `reconcile` (si ese
1925
+ * origen es legítimo). Dos copias de esta condición eran dos respuestas.
1926
+ */
1927
+ #isMaintenancePass(to, target, from) {
1928
+ return from === undefined && this.#sameDriver(to, this.#config.default) && target.capabilities?.hierarchyFacts === true;
1929
+ }
1930
+ /**
1931
+ * **¿Dos claves de `drivers` son el MISMO driver?** (cierre del auditor de
1932
+ * alpha.10 · R4, gemelo de roles): misma clave o la MISMA factory registrada
1933
+ * bajo dos claves. El guard de la dirección rancia, el mantenimiento, el
1934
+ * `from === to` y el descarte juzgan la identidad RESUELTA, no la etiqueta:
1935
+ * con `drivers: { openfga: f, fga: f }` y `default: 'openfga'`, `--to=fga
1936
+ * --from=database` es la misma dirección rancia que `--to=openfga`.
1937
+ *
1938
+ * **Límite DECLARADO (cierre-2 · C1)**: la identidad es la REFERENCIA de la
1939
+ * factory, no el backend. Dos factories distintas que construyen el mismo
1940
+ * store (`openfga: () => new OpenFga…(…)`, `fga: () => new OpenFga…(…)`) NO son
1941
+ * el mismo driver para este predicado y el alias pasa el guard. Identidad de
1942
+ * backend declarada por el driver ⇒ roadmap 2.5.
1943
+ */
1944
+ #sameDriver(a, b) {
1945
+ if (a === b)
1946
+ return true;
1947
+ const drivers = this.#config.drivers ?? {};
1948
+ const own = (key) => (Object.prototype.hasOwnProperty.call(drivers, key) ? drivers[key] : undefined);
1949
+ const fa = own(a);
1950
+ return fa !== undefined && fa === own(b);
1951
+ }
1720
1952
  /** El driver que `--from` nombra, con los dos errores de 3b-3b intactos. */
1721
1953
  async #originDriver(from, to) {
1722
1954
  const registered = Object.keys(this.#config.drivers ?? {});
1723
- if (from === to) {
1955
+ if (this.#sameDriver(from, to)) {
1724
1956
  throw new AuthorizationConfigError(`authz:reconcile --from=${from} --to=${to}: el origen y el destino son el mismo driver. ` +
1725
1957
  `Si lo que quieres es VERIFICAR y reparar lo derivado del driver activo, no lo digas con --from: ` +
1726
1958
  `la pasada ya lee sus hechos de él cuando es el driver por defecto.`);
@@ -1756,7 +1988,7 @@ export class AuthorizationManager {
1756
1988
  const registered = Object.keys(this.#config.drivers ?? {});
1757
1989
  const candidates = [];
1758
1990
  for (const candidate of registered) {
1759
- if (candidate === to)
1991
+ if (this.#sameDriver(candidate, to))
1760
1992
  continue;
1761
1993
  const driver = await this.#config.drivers[candidate]();
1762
1994
  if (typeof driver.enumerateFacts === 'function')
@@ -2188,7 +2420,7 @@ export class AuthorizationManager {
2188
2420
  const answer = await driver.authorizeMany(subject, permission, scopes);
2189
2421
  // Un `boolean[]` desalineado se leería por posición (F5, CR3): es un
2190
2422
  // bug del driver, no una decisión. 500 nombrando al culpable.
2191
- if (!Array.isArray(answer) || answer.length !== scopes.length || answer.some((b) => typeof b !== 'boolean')) {
2423
+ if (!Array.isArray(answer) || answer.length !== scopes.length || !everyPositionBoolean(answer)) {
2192
2424
  throw new AuthorizationInternalError(`authorizeMany: el driver '${this.#config.default}' devolvió ${describeAnswer(answer)} para ${scopes.length} scopes; ` +
2193
2425
  `el puerto exige un boolean[] con exactamente una posición por scope.`);
2194
2426
  }