@jantstack/adonis-authz 2.5.0-alpha.1 → 2.5.0-alpha.3

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 (103) hide show
  1. package/README.md +515 -22
  2. package/build/commands/authz_relations_reconcile.d.ts +13 -62
  3. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  4. package/build/commands/authz_relations_reconcile.js +202 -116
  5. package/build/commands/authz_relations_reconcile.js.map +1 -1
  6. package/build/commands/openfga_provision.d.ts +62 -0
  7. package/build/commands/openfga_provision.d.ts.map +1 -1
  8. package/build/commands/openfga_provision.js +193 -1
  9. package/build/commands/openfga_provision.js.map +1 -1
  10. package/build/index.d.ts +6 -6
  11. package/build/index.d.ts.map +1 -1
  12. package/build/index.js +3 -3
  13. package/build/index.js.map +1 -1
  14. package/build/providers/authz_provider.d.ts.map +1 -1
  15. package/build/providers/authz_provider.js +4 -1
  16. package/build/providers/authz_provider.js.map +1 -1
  17. package/build/src/define_config.d.ts +32 -0
  18. package/build/src/define_config.d.ts.map +1 -1
  19. package/build/src/define_config.js +109 -0
  20. package/build/src/define_config.js.map +1 -1
  21. package/build/src/drivers/database_relations_driver.d.ts +36 -1
  22. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  23. package/build/src/drivers/database_relations_driver.js +213 -46
  24. package/build/src/drivers/database_relations_driver.js.map +1 -1
  25. package/build/src/drivers/openfga_driver.d.ts +30 -4
  26. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  27. package/build/src/drivers/openfga_driver.js +151 -45
  28. package/build/src/drivers/openfga_driver.js.map +1 -1
  29. package/build/src/drivers/openfga_facts.d.ts +21 -0
  30. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  31. package/build/src/drivers/openfga_facts.js +74 -8
  32. package/build/src/drivers/openfga_facts.js.map +1 -1
  33. package/build/src/drivers/openfga_relations_driver.d.ts +140 -10
  34. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  35. package/build/src/drivers/openfga_relations_driver.js +743 -49
  36. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  37. package/build/src/errors.d.ts +148 -0
  38. package/build/src/errors.d.ts.map +1 -1
  39. package/build/src/errors.js +166 -0
  40. package/build/src/errors.js.map +1 -1
  41. package/build/src/manager.d.ts.map +1 -1
  42. package/build/src/manager.js +15 -5
  43. package/build/src/manager.js.map +1 -1
  44. package/build/src/openfga.d.ts +1 -1
  45. package/build/src/openfga.d.ts.map +1 -1
  46. package/build/src/openfga.js +1 -1
  47. package/build/src/openfga.js.map +1 -1
  48. package/build/src/relations/check_many.d.ts +1 -1
  49. package/build/src/relations/check_many.d.ts.map +1 -1
  50. package/build/src/relations/check_many.js +6 -4
  51. package/build/src/relations/check_many.js.map +1 -1
  52. package/build/src/relations/define_relations_config.d.ts +187 -1
  53. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  54. package/build/src/relations/define_relations_config.js +395 -7
  55. package/build/src/relations/define_relations_config.js.map +1 -1
  56. package/build/src/relations/manager.d.ts +33 -2
  57. package/build/src/relations/manager.d.ts.map +1 -1
  58. package/build/src/relations/manager.js +123 -4
  59. package/build/src/relations/manager.js.map +1 -1
  60. package/build/src/relations/reconcile.d.ts +295 -13
  61. package/build/src/relations/reconcile.d.ts.map +1 -1
  62. package/build/src/relations/reconcile.js +886 -85
  63. package/build/src/relations/reconcile.js.map +1 -1
  64. package/build/src/relations/tuple_order.d.ts +126 -0
  65. package/build/src/relations/tuple_order.d.ts.map +1 -0
  66. package/build/src/relations/tuple_order.js +598 -0
  67. package/build/src/relations/tuple_order.js.map +1 -0
  68. package/build/src/relations_config_store.d.ts.map +1 -1
  69. package/build/src/relations_config_store.js +29 -7
  70. package/build/src/relations_config_store.js.map +1 -1
  71. package/build/src/relations_reconcile.d.ts +79 -0
  72. package/build/src/relations_reconcile.d.ts.map +1 -0
  73. package/build/src/relations_reconcile.js +85 -0
  74. package/build/src/relations_reconcile.js.map +1 -0
  75. package/build/src/scope_outbox.d.ts +9 -5
  76. package/build/src/scope_outbox.d.ts.map +1 -1
  77. package/build/src/scope_outbox.js +7 -4
  78. package/build/src/scope_outbox.js.map +1 -1
  79. package/build/src/shared/backend_guard.d.ts +8 -0
  80. package/build/src/shared/backend_guard.d.ts.map +1 -1
  81. package/build/src/shared/backend_guard.js +15 -0
  82. package/build/src/shared/backend_guard.js.map +1 -1
  83. package/build/src/testing/main.d.ts +1 -1
  84. package/build/src/testing/main.d.ts.map +1 -1
  85. package/build/src/testing/main.js +1 -1
  86. package/build/src/testing/main.js.map +1 -1
  87. package/build/src/testing/relations_contract.d.ts +18 -0
  88. package/build/src/testing/relations_contract.d.ts.map +1 -1
  89. package/build/src/testing/relations_contract.js +895 -45
  90. package/build/src/testing/relations_contract.js.map +1 -1
  91. package/build/src/testing/relations_reconcile_contract.d.ts +9 -0
  92. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  93. package/build/src/testing/relations_reconcile_contract.js +207 -0
  94. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  95. package/build/src/testing/scope_outbox_contract.d.ts +14 -1
  96. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
  97. package/build/src/testing/scope_outbox_contract.js +126 -48
  98. package/build/src/testing/scope_outbox_contract.js.map +1 -1
  99. package/build/src/types.d.ts +90 -6
  100. package/build/src/types.d.ts.map +1 -1
  101. package/build/src/types.js +2 -0
  102. package/build/src/types.js.map +1 -1
  103. package/package.json +2 -1
@@ -32,20 +32,35 @@
32
32
  * del SDK escapa (todo va por `#guard`, que clasifica a 503/timeout).
33
33
  */
34
34
  import { OpenFgaClient, ConsistencyPreference, ClientWriteRequestOnMissingDeletes, ClientWriteRequestOnDuplicateWrites, } from '@openfga/sdk';
35
- import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, UnsupportedOperationError, markPartialWrite, } from '../errors.js';
35
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, RenewalRacedError, RenewalUnsafeError, UnsupportedOperationError, markPartialWrite, } from '../errors.js';
36
36
  import { assertPartition, assertSubject, assertRelationId, assertExpiresAt, partitionKey } from '../identity.js';
37
37
  import { isClock, systemClock } from '../clock.js';
38
- import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
38
+ import { isActiveExpiry, resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
39
39
  import { isRelUserset } from '../types.js';
40
40
  import { alignBatchResults } from './openfga_driver.js';
41
- import { FGA_MAX_BATCH_CHECK } from './openfga_facts.js';
41
+ import { FGA_MAX_BATCH_CHECK, canonicalRewrite, factsRelationRewrites } from './openfga_facts.js';
42
42
  import { assertCheckManyObjects, distinctObjects, expandByPosition } from '../relations/check_many.js';
43
43
  import { assertFgaObjectId, FACTS_EXPIRY_CONDITION, FACTS_GROUP_TYPE, } from './openfga_facts.js';
44
- import { assertRelationDeclared, assertObjectTypeDeclared } from '../relations/define_relations_config.js';
45
- import { isTimeoutLike, withDeadline } from '../shared/backend_guard.js';
44
+ import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, denyPhaseOf, deniedRelationOf, storedRelationKind, hasDenyTypes, relationsDown, RELATION_DENY_PREFIX, RELATION_USERSET_RELATION, RELATION_USERSET_TYPE, } from '../relations/define_relations_config.js';
45
+ import { relationDeleteOrder, relationOrderClass } from '../relations/tuple_order.js';
46
+ import { isServerWriteLimit, isTimeoutLike, withDeadline } from '../shared/backend_guard.js';
46
47
  export const DEFAULT_TIMEOUT_MS = 5_000;
47
- /** Tope de operaciones por `Write` en FGA (`exceeded_entity_limit` a partir de 100). */
48
- const WRITE_BATCH_SIZE = 100;
48
+ /**
49
+ * **Tope de operaciones por `Write` en FGA** — `--max-tuples-per-write` del servidor, default 100. MEDIDO en v1.19.0
50
+ * (`openfga-dev`, lote D · cierre 1 de D1): 100 deletes en un `Write` entran; 101 —o 50 writes + 51 deletes— es 400
51
+ * «The number of write operations exceeds the allowed limit of 100» y NO se aplica nada (el `Write` es transaccional).
52
+ * Es también la frontera de la purga atómica (`#purgeByFilters`): una purga que cabe aquí es UN `Write`, sin estado
53
+ * intermedio; con más membresías de grupo que esto, el residuo es una fila DECLARADA (README, «Deny on relations»).
54
+ * **Requisito del despliegue: `--max-tuples-per-write` ≥ 100** (README, «Limits»). Un servidor con el tope MÁS BAJO
55
+ * responde 400 `exceeded_entity_limit` al primer `Write` que lo pase, y ése puede no ser el primero de la purga (con
56
+ * tope 50 y `[3 allows][60 membresías][40 denies]` el `Write` de 3 ya se aplicó): el driver lo dice como **500
57
+ * `E_AUTHZ_CONFIG`** nombrando el requisito —no como un 503 que parece transitorio: repetida, la purga vuelve a pedir
58
+ * un `Write` de más de lo que el servidor admite y no termina nunca— y, si ya borró algo, **marcado como escritura
59
+ * parcial** (`indeterminate: true`, invariante 13). Cierre 2 de D1 · ⚪ N3 del auditor (la letra anterior, «503 sin
60
+ * haber borrado nada», era falsa con varios `Write`). Uno con el tope más alto no cambia nada (el driver sigue partiendo
61
+ * en 100). Sin opción para bajarlo: la cota es del servidor y el remedio es su arranque.
62
+ */
63
+ export const RELATIONS_PURGE_WRITE_MAX = 100;
49
64
  /** Tamaño de página de `Read` (máximo del servidor). */
50
65
  const READ_PAGE_SIZE = 100;
51
66
  /**
@@ -65,6 +80,40 @@ const MAX_READ_PAGES = 10_000;
65
80
  * truncado FALSO (el servidor devolvió todo); por encima, se perdería el corte.
66
81
  */
67
82
  const DEFAULT_LIST_OBJECTS_MAX = 1_000;
83
+ /**
84
+ * **B4 (plan 2.5 · lote B): el corte EFECTIVO de `ListObjects` del servidor**.
85
+ * Pasado, el servidor devuelve lo que llevaba resuelto con 200 y SIN señal
86
+ * (analista M4, medido): la segunda forma —además del tope— de una lista
87
+ * parcial muda. El driver mide cada llamada con un reloj MONÓTONO y, si duró
88
+ * ≥ `LIST_OBJECTS_DEADLINE_RATIO` × el corte configurado, señala
89
+ * `truncated: true` (la MISMA señal que el tope). **El corte efectivo es el
90
+ * MENOR de dos** (cierre 2 del lote B · 🟠 N1, medido por el auditor contra
91
+ * OpenFGA v1.19.0): `--listObjects-deadline` (3 s por defecto) si ≠ 0 y, si es
92
+ * 0, `--request-timeout` (3 s por defecto) — `list_objects.go` solo pone su
93
+ * propio timeout cuando el deadline ≠ 0 y, si no, hereda el ctx del
94
+ * interceptor de `--request-timeout`, que corta igual con un 200 mudo (con
95
+ * `--request-timeout=30ms`: `200 objects 0 of 40000`). «Sin deadline» no
96
+ * existe: el servidor siempre corta en alguno. **El corte efectivo es el MENOR
97
+ * de `--listObjects-deadline` (si ≠ 0), `--request-timeout` y, por HTTP,
98
+ * `--http-upstream-timeout` (5 s por defecto en el gateway)** (⚪ N10, cierre 3
99
+ * del lote B: el tercer corte, no medido para `ListObjects`, también cuenta);
100
+ * declara ESE menor en `listObjectsDeadlineMs` — y nunca por encima del
101
+ * `timeoutMs` del cliente: la señal tiene que poder saltar antes de que el
102
+ * cliente corte (500 al construir si no).
103
+ */
104
+ export const DEFAULT_LIST_OBJECTS_DEADLINE_MS = 3_000;
105
+ export const LIST_OBJECTS_DEADLINE_RATIO = 0.9;
106
+ /**
107
+ * **SOLO TESTS** (⚪ 9 del cierre 1 del lote B): la clave —un símbolo— bajo la que
108
+ * un test inyecta la fuente del reloj MONÓTONO en ms con el que `#listObjectsPage`
109
+ * mide cada `ListObjects` (default `performance.now`). Fuera del tipo publicado
110
+ * `OpenFgaRelationsDriverOptions` y NO re-exportada por `/openfga`: un
111
+ * `monotonic: () => 0` en producción apagaría la señal del deadline, y una opción
112
+ * con ese poder no viaja en la superficie pública. No es el reloj de pared
113
+ * (`now`, que decide la caducidad): una ventana se mide con un reloj que no
114
+ * salta (2E · H3), y así el caso del deadline corre sin `sleep`.
115
+ */
116
+ export const MONOTONIC_CLOCK = Symbol('OpenFgaRelationsDriver.monotonic');
68
117
  /**
69
118
  * Ignora duplicados en el `Write` de una tupla que NO estaba (idempotencia de
70
119
  * dos `relate` concurrentes idénticos). Cambiar la caducidad de una que SÍ
@@ -98,7 +147,9 @@ export class OpenFgaRelationsDriver {
98
147
  * y devuelve SOLO las tuplas de relación de la partición (tipo declarado o
99
148
  * `group`), descartando las de `facts` (scope/role/role_binding).
100
149
  * - `listObjectsTruncation: true`: `ListObjects` trunca al tope del servidor
101
- * y este driver lo SEÑALA (medido contra el `:8103` de tope 3).
150
+ * y este driver lo SEÑALA (medido contra el `:8103` de tope 3) — y desde
151
+ * B4 (plan 2.5) también cuando la llamada roza el DEADLINE del servidor
152
+ * (≥ 0,9 × `listObjectsDeadlineMs`), la otra forma de lista parcial muda.
102
153
  * - `injectableClock: true` (R-15): `withClock(now)` fija el reloj que viaja
103
154
  * como `current_time` y filtra `listSubjects`, como el driver de roles.
104
155
  */
@@ -110,6 +161,13 @@ export class OpenFgaRelationsDriver {
110
161
  enumerateRelations: true,
111
162
  listObjectsTruncation: true,
112
163
  injectableClock: true,
164
+ // Lote D · D1: el deny es la relación `denied_<rel>` del modelo fusionado (`X = … but not denied_X`); el corte lo
165
+ // evalúa el SERVIDOR en `check`/`batchCheck`/`ListObjects` (una lista cortada es un subconjunto: fail-closed).
166
+ relationDenies: true,
167
+ // Cierre 2 de D2 · 🟠 N1: `false` EXPLÍCITO — FGA no reemplaza la misma clave en un `Write` (M5): `relate` renueva
168
+ // con DOS (delete, write). Por eso NIEGA renovar una membresía que transporta un deny (409
169
+ // `E_AUTHZ_RENEWAL_UNSAFE`, cero escrituras) y marca parcial la renovación permitida cuyo 2.º `Write` falla.
170
+ atomicRenewal: false,
113
171
  // L-2/L-5 (panel `{trx}`, (C)): `false` EXPLÍCITO, y no puede ser otra
114
172
  // cosa — una tupla no entra en una transacción SQL: el store es otro
115
173
  // servicio y no hay 2PC, así que «los dos o ninguno» con la transacción
@@ -135,7 +193,15 @@ export class OpenFgaRelationsDriver {
135
193
  #timeoutMs;
136
194
  #consistency;
137
195
  #listObjectsMax;
196
+ #listObjectsDeadlineMs;
197
+ #monotonic;
138
198
  #logger;
199
+ /**
200
+ * **Las relaciones `denied_*` que declara el modelo que sirve este driver** (lote D · D-31): se lee UNA vez por
201
+ * proceso (`readAuthorizationModel` del `modelId` fijado —D14 lo exige con un tipo con deny—) y se comparte con
202
+ * las vistas `withClock`. Un fallo al leerla no se memoriza (503 y se reintenta en la siguiente).
203
+ */
204
+ #modelMemo = {};
139
205
  diagnostics = { unparseableTuples: 0 };
140
206
  constructor(config, options) {
141
207
  this.#config = config;
@@ -149,7 +215,59 @@ export class OpenFgaRelationsDriver {
149
215
  ? ConsistencyPreference.MinimizeLatency
150
216
  : ConsistencyPreference.HigherConsistency;
151
217
  this.#listObjectsMax = options.listObjectsMaxResults ?? DEFAULT_LIST_OBJECTS_MAX;
218
+ const deadline = options.listObjectsDeadlineMs ?? DEFAULT_LIST_OBJECTS_DEADLINE_MS;
219
+ // 🟠 N1 (cierre 2 del lote B): `Infinity` se RETIRA. Con `--listObjects-deadline=0`
220
+ // el `--request-timeout` del servidor sigue cortando con un 200 mudo
221
+ // (medido, v1.19.0), así que «sin deadline» era una receta para la lista
222
+ // parcial muda que B4 impide. Solo un entero > 0: el corte efectivo.
223
+ if (!(Number.isInteger(deadline) && deadline > 0)) {
224
+ throw new AuthorizationConfigError(`OpenFgaRelationsDriver: listObjectsDeadlineMs debe ser un entero > 0 en ms: el corte EFECTIVO de ListObjects ` +
225
+ `de tu servidor, que es --listObjects-deadline si no es 0 y, si es 0, --request-timeout (3 s por defecto; ` +
226
+ `medido en OpenFGA v1.19.0: con el deadline a 0 el request-timeout corta igual con un 200 mudo). El servidor ` +
227
+ `siempre corta en alguno de los dos; declara el menor. No hay «sin deadline» (Infinity ya no se admite) ` +
228
+ `(llegó ${String(options.listObjectsDeadlineMs)}); default ${DEFAULT_LIST_OBJECTS_DEADLINE_MS}.`);
229
+ }
230
+ // ⚪ N10 (cierre 3 del lote B): la señal tiene que poder SALTAR antes de que
231
+ // el cliente corte. Un deadline declarado por encima del `timeoutMs` del
232
+ // cliente (5 s por defecto) nunca alcanza su umbral 0,9 × D —el cliente
233
+ // aborta con 503 antes— y, si el servidor corta ANTES que D, la lista muda
234
+ // pasa como completa (`Number.MAX_SAFE_INTEGER` era «sin deadline» con otro
235
+ // nombre y pasaba `Number.isInteger`). Guarda por construcción, sin tope
236
+ // arbitrario: D ≤ timeoutMs del cliente, o 500.
237
+ if (deadline > this.#timeoutMs) {
238
+ throw new AuthorizationConfigError(`OpenFgaRelationsDriver: listObjectsDeadlineMs (${deadline}) no puede ser mayor que el timeoutMs del cliente ` +
239
+ `(${this.#timeoutMs}): la señal tiene que poder saltar antes de que el cliente corte. Con un deadline por encima ` +
240
+ `del timeout el umbral 0,9 × listObjectsDeadlineMs nunca se alcanza (el cliente aborta con 503 antes) y, si el ` +
241
+ `servidor corta antes, la lista parcial muda pasa como completa. Declara el corte EFECTIVO del servidor —el MENOR ` +
242
+ `de --listObjects-deadline (si ≠ 0), --request-timeout y, por HTTP, --http-upstream-timeout— y un timeoutMs ≥ a él.`);
243
+ }
244
+ this.#listObjectsDeadlineMs = deadline;
245
+ // ⚪ 9 (cierre 1 del lote B): el reloj monótono inyectable SOLO entra por el
246
+ // símbolo (no está en el tipo publicado); la clave de texto `monotonic` de
247
+ // alpha.2 ya no gobierna nada y se rechaza (L5: una opción inerte no cae en
248
+ // silencio en el default).
249
+ if (options.monotonic !== undefined) {
250
+ throw new AuthorizationConfigError(`OpenFgaRelationsDriver: 'monotonic' no es una opción pública (era SOLO TESTS); el reloj monótono se inyecta ` +
251
+ `por el símbolo MONOTONIC_CLOCK del módulo del driver, que no se exporta por '/openfga'.`);
252
+ }
253
+ const monotonic = options[MONOTONIC_CLOCK];
254
+ if (monotonic !== undefined && typeof monotonic !== 'function') {
255
+ throw new AuthorizationConfigError(`OpenFgaRelationsDriver: [MONOTONIC_CLOCK] debe ser una función () => number (llegó ${typeof monotonic})`);
256
+ }
257
+ this.#monotonic = monotonic ?? (() => performance.now());
152
258
  this.#logger = options.logger ?? console;
259
+ // **D14** (decisión del dueño 2026-10-14; auditor R2-2): con algún tipo con opt-in del deny (en cualquier fase), el
260
+ // `modelId` FIJADO es obligatorio. Sin él cada `Check` evalúa contra el ÚLTIMO modelo del store, y un
261
+ // `openfga:provision` desde un proceso viejo (sin `denied_*`) haría que este pod concediera sobre objetos denegados
262
+ // sin enterarse; con él, la guarda D-31 lee ESE modelo una vez y es 500 si no declara el deny.
263
+ if (hasDenyTypes(config) && !options.modelId) {
264
+ throw new AuthorizationConfigError(`OpenFgaRelationsDriver: la config de relaciones declara deny en ${config.objectTypes
265
+ .filter((t) => t.deny !== undefined)
266
+ .map((t) => `'${t.type}' (${t.deny})`)
267
+ .join(', ')} y el driver no tiene modelId fijado (D14). Con deny activo cada pod tiene que evaluar contra un modelo ` +
268
+ `CONOCIDO que declare denied_<rel>: publica el modelo (openfga:provision), fija OPENFGA_MODEL_ID en TODA la flota ` +
269
+ `y despliega. Sin el pin, un modelo republicado sin denied_* haría conceder sobre objetos denegados.`);
270
+ }
153
271
  this.client = new OpenFgaClient({
154
272
  apiUrl: options.apiUrl,
155
273
  storeId: options.storeId,
@@ -171,6 +289,8 @@ export class OpenFgaRelationsDriver {
171
289
  }
172
290
  const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
173
291
  view.client = this.client;
292
+ // La guarda D-31 es del PROCESO: la vista comparte la lectura del modelo.
293
+ view.#modelMemo = this.#modelMemo;
174
294
  return view;
175
295
  }
176
296
  /** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
@@ -216,6 +336,146 @@ export class OpenFgaRelationsDriver {
216
336
  throw fail(error);
217
337
  }), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
218
338
  }
339
+ /* ── D-31: el modelo FIJADO tiene que declarar el deny de los tipos con opt-in ─────────────────────────── */
340
+ /** Las relaciones `<tipo>#denied_<rel>` que declara el modelo que sirve este driver (memo por proceso). */
341
+ #modelDenied() {
342
+ this.#readModel();
343
+ return this.#modelMemo.denied;
344
+ }
345
+ /**
346
+ * **R2 del auditor del panel «óptimo» (cierre 5 de D2): la DERIVA modelo ↔ config en TODOS los tipos**, no solo en los
347
+ * que tienen deny: por tipo declarado en la config (y `group`), las relaciones cuya regla en el modelo FIJADO no es la
348
+ * que genera el paquete (`factsRelationRewrites`, por igualdad tras `canonicalRewrite`) o que el modelo no trae. La
349
+ * MISMA lectura del modelo (memo por proceso) que D-31.
350
+ */
351
+ #modelDrift() {
352
+ this.#readModel();
353
+ return this.#modelMemo.drift;
354
+ }
355
+ /** UNA lectura del modelo por proceso (memo compartido con las vistas `withClock`); un fallo no se memoriza. */
356
+ #readModel() {
357
+ if (this.#modelMemo.denied)
358
+ return;
359
+ const memo = this.#modelMemo;
360
+ const served = this.#guard('readAuthorizationModel', async () => {
361
+ const response = this.#options.modelId
362
+ ? await this.client.readAuthorizationModel()
363
+ : await this.client.readLatestAuthorizationModel();
364
+ const definitions = response?.authorization_model?.type_definitions;
365
+ if (!Array.isArray(definitions)) {
366
+ throw new Error('la respuesta de ReadAuthorizationModel no trae type_definitions; no se toma por «sin deny»');
367
+ }
368
+ // R2 (cierre 5 de D2): la deriva de TODOS los tipos de la config (y `group`) contra lo que genera el paquete.
369
+ // En un tipo SIN opt-in también vale la forma CON deny (`<rel> = base but not denied_<rel>`): es D8 —el opt-in
370
+ // retirado con el modelo aún publicado—, más restrictivo, nunca una concesión por una regla vieja.
371
+ const drift = new Map();
372
+ const expectedTypes = [
373
+ { type: RELATION_USERSET_TYPE, relations: { member: { this: {} } } },
374
+ ...this.#config.objectTypes.map((spec) => ({
375
+ type: spec.type,
376
+ relations: factsRelationRewrites(spec),
377
+ alternative: spec.deny === undefined ? factsRelationRewrites({ ...spec, deny: 'read' }) : undefined,
378
+ })),
379
+ ];
380
+ for (const { type, relations: expectedRelations, alternative } of expectedTypes) {
381
+ const servedRelations = definitions.find((definition) => definition?.type === type)?.relations ?? {};
382
+ // Cierre 6 de D2 · 🟡 D5-2: la excepción D8 vale solo si el modelo trae la forma CON deny ENTERA para esa
383
+ // relación: `<rel>` Y `denied_<rel>` (el cierre del corte de camino). Con `denied_editor = this` (sin `or
384
+ // denied_viewer`) un modelo a mano concedía `editor` a quien tiene deny en `viewer`, donde `database` niega.
385
+ const denyFormMatches = (name) => {
386
+ if (!alternative)
387
+ return false;
388
+ const denied = deniedRelationOf(name);
389
+ return sameRewrite(servedRelations[name], alternative[name]) && sameRewrite(servedRelations[denied], alternative[denied]);
390
+ };
391
+ const differing = Object.keys(expectedRelations).filter((name) => !sameRewrite(servedRelations[name], expectedRelations[name]) && !denyFormMatches(name));
392
+ if (differing.length > 0)
393
+ drift.set(type, differing);
394
+ }
395
+ // Cierre 1 de D1 · ⚪ 7: no basta con que EXISTA `denied_<rel>`: `<rel>` tiene que RESTARLO.
396
+ // Cierre 2 de D1 · ⚪ N4: tampoco basta con la FORMA. En un tipo CON opt-in, `<rel>` y `denied_<rel>` tienen que
397
+ // ser EXACTAMENTE las reglas que genera el paquete para ese tipo (`factsRelationRewrites`, la fuente única del
398
+ // generador), por igualdad estructural tras `canonicalRewrite`: un modelo a mano con `denied_editor: [user]` (sin
399
+ // `or denied_viewer`, el cierre del corte de camino) restaba bien y CONCEDÍA `editor` a un owner con deny en
400
+ // `viewer`, donde `database` niega. En un tipo SIN opt-in (D8: opt-in retirado con el modelo aún publicado) no hay
401
+ // regla del paquete con la que comparar: cuenta la forma de ⚪ 7 (`difference` con `subtract` = `denied_<rel>`),
402
+ // que es lo que `decideMany` necesita para saber qué deny preguntar.
403
+ const out = new Set();
404
+ for (const definition of definitions) {
405
+ const relations = definition?.relations ?? {};
406
+ const spec = this.#config.objectTypes.find((t) => t.type === definition?.type);
407
+ const expected = spec && spec.deny !== undefined ? factsRelationRewrites(spec) : undefined;
408
+ for (const name of Object.keys(relations)) {
409
+ if (!name.startsWith(RELATION_DENY_PREFIX))
410
+ continue;
411
+ const relation = name.slice(RELATION_DENY_PREFIX.length);
412
+ const base = relations[relation];
413
+ const ok = expected
414
+ ? sameRewrite(base, expected[relation]) && sameRewrite(relations[name], expected[name])
415
+ : base?.difference?.subtract?.computedUserset?.relation === name;
416
+ if (ok)
417
+ out.add(`${definition.type}#${name}`);
418
+ }
419
+ }
420
+ return { denied: out, drift };
421
+ }).catch((error) => {
422
+ // Un fallo (503) NO se memoriza: la siguiente lo vuelve a intentar.
423
+ if (memo.denied === denied) {
424
+ memo.denied = undefined;
425
+ memo.drift = undefined;
426
+ }
427
+ throw error;
428
+ });
429
+ const denied = served.then((x) => x.denied);
430
+ memo.denied = denied;
431
+ memo.drift = served.then((x) => x.drift);
432
+ // Las dos promesas derivan de UNA: si nadie espera una de ellas, su rechazo no puede quedar sin capturar.
433
+ memo.denied.catch(() => { });
434
+ memo.drift.catch(() => { });
435
+ }
436
+ /**
437
+ * **D-31 · un tipo con opt-in y un modelo FIJADO sin `denied_<rel>` ⇒ 500 al USARLO** (juez ronda 2 §2, H3 b):
438
+ * un pod con la config nueva y el pin a un modelo viejo (o republicado por un proceso viejo) concedería sobre un
439
+ * objeto denegado —la resta la hace el servidor con el modelo que el pod fija—. Antes de la primera request de
440
+ * cualquier operación sobre esos tipos se comprueba (una lectura del modelo por proceso) y, si falta, 500
441
+ * `E_AUTHZ_CONFIG` nombrando las relaciones: ruidoso en vez de conceder. Tipos sin opt-in: sin coste.
442
+ */
443
+ async #assertModelDeny(types) {
444
+ const wanted = [];
445
+ const distinct = [...new Set(types)];
446
+ for (const type of distinct) {
447
+ if (denyPhaseOf(this.#config, type) === undefined)
448
+ continue;
449
+ const spec = this.#config.objectTypes.find((t) => t.type === type);
450
+ for (const relation of spec?.relations ?? [])
451
+ wanted.push(`${type}#${deniedRelationOf(relation.name)}`);
452
+ }
453
+ if (wanted.length > 0) {
454
+ const declared = await this.#modelDenied();
455
+ const missing = wanted.filter((name) => !declared.has(name));
456
+ if (missing.length > 0) {
457
+ throw new AuthorizationConfigError(`OpenFgaRelationsDriver: la config declara deny y el modelo que sirve este driver (modelId ` +
458
+ `${this.#options.modelId ?? '(último)'}) NO declara ${missing.join(', ')} —o la declara pero <rel> o denied_<rel> no son las reglas que genera el paquete (but not y el cierre del corte de camino)— (D-31). Con ese modelo el servidor no ` +
459
+ `resta el deny: un pod así concedería sobre objetos denegados. Republica el modelo desde la config actual ` +
460
+ `(openfga:provision) y fija el modelId nuevo en TODA la flota antes de desplegar.`);
461
+ }
462
+ }
463
+ // R2 del auditor del panel «óptimo» (cierre 5 de D2): la MISMA comparación en los tipos SIN deny. Un pod con una config
464
+ // que ESTRECHA una regla (`viewer` deja de incluir `editor`) y el pin a un modelo viejo concedía por la regla vieja,
465
+ // en silencio, donde `database` con la misma config niega. Misma lectura del modelo, ya memorizada.
466
+ // Cierre 6 de D2 · ⚪ (D5-2): `group` se compara en TODA operación, no solo en las suyas — un `group#member` del
467
+ // modelo que no es `this` cambia quién es miembro y, con ello, qué concede un `check` de `document`.
468
+ const drift = await this.#modelDrift();
469
+ const compared = [...new Set([...distinct, RELATION_USERSET_TYPE])];
470
+ const drifted = compared.flatMap((type) => (drift.get(type) ?? []).map((relation) => `${type}#${relation}`));
471
+ if (drifted.length > 0) {
472
+ throw new AuthorizationConfigError(`OpenFgaRelationsDriver: el modelo que sirve este driver (modelId ${this.#options.modelId ?? '(último)'}) declara ` +
473
+ `${drifted.join(', ')} con una regla DISTINTA de la que genera la config de este proceso (includes / deny), o no la ` +
474
+ `declara (D-31 en todos los tipos, R2). Con ese modelo el servidor decide por la regla del modelo: un pod así ` +
475
+ `concedería lo que la config —y database con la misma config— niega. Republica el modelo desde la config actual ` +
476
+ `(openfga:provision) y fija el modelId nuevo en TODA la flota antes de desplegar.`);
477
+ }
478
+ }
219
479
  /* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
220
480
  /** `<type>:<scopeKey(partition)>|<objectUuid>`. */
221
481
  #objectId(objectType, partitionKey, objectUuid) {
@@ -238,8 +498,9 @@ export class OpenFgaRelationsDriver {
238
498
  if (isRelUserset(subject)) {
239
499
  // alpha.3 · F-05 también en el USERSET del sujeto (la misma función que
240
500
  // el manager): `role_binding:<key>|<R>#role` como `user` de un `Read`/
241
- // `Check`/`Write` no se compone nunca.
242
- assertRelationDeclared(this.#config, subject.object, subject.relation);
501
+ // `Check`/`Write` no se compone nunca. B3 (lote B): y solo `group#member`
502
+ // — otro userset era un 400 del servidor (503 mal clasificado).
503
+ assertUsersetSubject(this.#config, subject);
243
504
  const object = this.#objectId(subject.object.type, partitionKey, subject.object.id);
244
505
  return `${object}#${subject.relation}`;
245
506
  }
@@ -301,8 +562,12 @@ export class OpenFgaRelationsDriver {
301
562
  * - presente con la caducidad que toca ⇒ no-op (idempotente, invariante 6);
302
563
  * - presente con OTRA ⇒ delete + write en DOS llamadas (FGA no admite
303
564
  * delete+write de la misma clave en un `Write`, ni reescribir su
304
- * condición): entre ambas hay un instante en el que `check` responde
305
- * `false` —fail-closed, la misma ventana que el `grant` de roles—.
565
+ * condición): entre ambas la tupla NO está. Para un allow eso solo
566
+ * quita (fail-closed, la misma ventana que el `grant` de roles); para una
567
+ * membresía `group:*#member` que TRANSPORTA un deny, CONCEDERÍA — desde el
568
+ * cierre 2 de D2 (🟠 N1) esa renovación es 409 `E_AUTHZ_RENEWAL_UNSAFE`
569
+ * con cero escrituras (`#carriedDeny`), y si el 2.º `Write` de una
570
+ * renovación permitida falla el error va marcado PARCIAL (`markPartialWrite`).
306
571
  */
307
572
  /**
308
573
  * **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
@@ -334,6 +599,7 @@ export class OpenFgaRelationsDriver {
334
599
  const user = this.#subjectUser(subject, partitionKey);
335
600
  const key = { user, relation, object: objectId };
336
601
  const now = this.#now();
602
+ await this.#assertModelDeny([object.type]);
337
603
  const current = await this.#readTuple('relate', key);
338
604
  const previous = current ? current.validUntil : null;
339
605
  const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
@@ -343,14 +609,111 @@ export class OpenFgaRelationsDriver {
343
609
  ? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
344
610
  : key;
345
611
  if (current) {
612
+ // Cierre 2 de D2 · 🟠 N1 (decisión del dueño 2026-10-15 (9)): una membresía que TRANSPORTA un deny no se renueva
613
+ // con dos `Write` — entre el delete y el write el sujeto quedaría sin el deny y concedería. 409, CERO escrituras.
614
+ // Cierre 3 de D2 · ⚪ C2-4: una membresía YA VENCIDA no conecta nada (el deny ya no alcanza al sujeto por ella), así
615
+ // que no hay ventana que proteger: se reescribe sin comprobar portadora (hasta el cierre 3 era un 409 espurio).
616
+ const guarded = object.type === RELATION_USERSET_TYPE && isActiveExpiry(previous, now);
617
+ const membership = `${objectId}#${relation}@${user}`;
618
+ if (guarded) {
619
+ const carried = await this.#carriedDeny(objectId, partitionKey, now);
620
+ if (carried !== null)
621
+ throw RenewalUnsafeError.carrier(membership, carried);
622
+ }
346
623
  await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES));
624
+ // Cierre 2 de D2 · 🟠 N1 (b): el delete YA entró — si el write falla, la escritura es PARCIAL (invariante 13: el
625
+ // manager notifica `indeterminate: true`); hasta el cierre era un 503 «sin escribir» con la tupla borrada.
626
+ try {
627
+ await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
628
+ }
629
+ catch (error) {
630
+ // Cierre 3 de D2 · 🟡 C2-3: la membresía quedó BORRADA. Si entre la comprobación y el delete entró un deny en el
631
+ // grupo (TOCTOU), ese deny ya no alcanza al sujeto —y no lo alcanzará hasta que la membresía se reescriba—: el
632
+ // error parcial lo NOMBRA (`carriedDeny`) con la receta. Mejor esfuerzo: si la re-lectura también falla, se dice.
633
+ if (guarded && error !== null && typeof error === 'object') {
634
+ annotateRacedDeny(error, membership, await this.#recheckCarried(objectId, partitionKey, now));
635
+ }
636
+ throw error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
637
+ }
638
+ // Cierre 3 de D2 · 🟡 C2-3: la RE-COMPROBACIÓN. Un deny que entró entre `#carriedDeny` y los dos `Write` no alcanzó
639
+ // al sujeto durante la ventana; la renovación se aplicó (el estado final es correcto) y el error lo dice, marcado
640
+ // parcial (el manager notifica `indeterminate: true`). La ventana NO se cierra (FGA no tiene una primitiva para
641
+ // serializar `deny` contra la renovación): deja de ser muda. README «Every other renewal stays two `Write`s».
642
+ if (guarded) {
643
+ const recheck = await this.#recheckCarried(objectId, partitionKey, now);
644
+ if (recheck.deny !== null)
645
+ throw markPartialWrite(RenewalRacedError.raced(membership, recheck.deny));
646
+ if (recheck.error !== undefined) {
647
+ const error = recheck.error;
648
+ throw error !== null && typeof error === 'object'
649
+ ? markPartialWrite(annotateRacedDeny(error, membership, recheck, true))
650
+ : error;
651
+ }
652
+ }
653
+ return;
347
654
  }
348
655
  await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
349
656
  }
350
- /** La tupla EXACTA de una clave (un `Read` por clave completa), con su caducidad si la lleva. */
657
+ /**
658
+ * **¿Transporta el grupo un deny vivo?** (cierre 2 de D2 · 🟠 N1): el `#member` de `group` —o el de un grupo que lo
659
+ * contiene, transitivamente (`group:G#member@group:H#member`: los miembros de H heredan los denies de G)— es sujeto de
660
+ * una tupla `denied_*` de la partición, en algún tipo con opt-in del deny. Devuelve la primera que encuentra (para la
661
+ * letra) o `null`. Coste: por cada grupo del cierre hacia arriba, un `Read` por `user` + tipo (el filtro que FGA admite:
662
+ * `user` y `object` tipo) por cada tipo con deny, y uno de sus padres. Solo corre en una RENOVACIÓN de membresía.
663
+ */
664
+ async #carriedDeny(groupObjectId, partitionKey, now) {
665
+ const denyTypes = this.#config.objectTypes.filter((t) => denyPhaseOf(this.#config, t.type) !== undefined).map((t) => t.type);
666
+ if (denyTypes.length === 0)
667
+ return null;
668
+ const seen = new Set([groupObjectId]);
669
+ const queue = [groupObjectId];
670
+ while (queue.length > 0) {
671
+ const userset = `${queue.shift()}#${RELATION_USERSET_RELATION}`;
672
+ for (const type of denyTypes) {
673
+ for (const t of await this.#readAll('relate', { user: userset, object: `${type}:` })) {
674
+ // Cierre 6 de D2 · 🟡 D5-1: un deny VENCIDO no transporta nada (la MISMA regla que C2-4 para la arista y que el
675
+ // plan para toda tupla del destino: vencida = ausente). Contarlo hacía de la pasada REAL un 409 que el dry-run
676
+ // no anunciaba, y las particiones siguientes no migraban.
677
+ if (t.relation.startsWith(RELATION_DENY_PREFIX) &&
678
+ t.object.startsWith(`${type}:${partitionKey}|`) &&
679
+ isActiveExpiry(t.validUntil ?? null, now)) {
680
+ return `${t.object}#${t.relation}@${t.user}`;
681
+ }
682
+ }
683
+ }
684
+ for (const parent of await this.#readAll('relate', { user: userset, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` })) {
685
+ // Cierre 3 de D2 · ⚪ C2-4: una arista de anidación VENCIDA no transporta nada (no conecta en FGA).
686
+ if (!isActiveExpiry(parent.validUntil ?? null, now))
687
+ continue;
688
+ if (!seen.has(parent.object)) {
689
+ seen.add(parent.object);
690
+ queue.push(parent.object);
691
+ }
692
+ }
693
+ }
694
+ return null;
695
+ }
696
+ /** `#carriedDeny` tras los dos `Write` (🟡 C2-3): nunca lanza; un fallo de la re-lectura viaja en `error`. */
697
+ async #recheckCarried(groupObjectId, partitionKey, now) {
698
+ try {
699
+ return { deny: await this.#carriedDeny(groupObjectId, partitionKey, now) };
700
+ }
701
+ catch (error) {
702
+ return { deny: null, error };
703
+ }
704
+ }
705
+ /**
706
+ * La tupla EXACTA de una clave (un `Read` por clave completa), con su caducidad si la lleva.
707
+ * ⚪ N5 (cierre 2 del lote B): un 200 sin `tuples` NO es «no existe» — hasta el cierre `?? []` lo
708
+ * vendía así y `relate` escribía a ciegas (una caducidad vigente que no se ve no se preserva).
709
+ * Es 503, como `#readAll`: exhaustivo-o-ruidoso también en la lectura de UNA clave.
710
+ */
351
711
  async #readTuple(operation, key) {
352
712
  const response = await this.#guard(operation, () => this.client.read(key, { consistency: this.#consistency }));
353
- const found = (response.tuples ?? []).map((t) => t.key).find((k) => k?.user === key.user && k?.relation === key.relation && k?.object === key.object);
713
+ if (!Array.isArray(response?.tuples)) {
714
+ throw new AuthorizationBackendError('openfga-relations', operation, new Error(`la respuesta del Read de la clave no trae 'tuples'; no se toma por «no existe»`));
715
+ }
716
+ const found = response.tuples.map((t) => t.key).find((k) => k?.user === key.user && k?.relation === key.relation && k?.object === key.object);
354
717
  if (!found)
355
718
  return null;
356
719
  return { ...key, validUntil: toExpiryDate(found.condition?.context?.valid_until) };
@@ -364,10 +727,67 @@ export class OpenFgaRelationsDriver {
364
727
  const partitionKey = this.#keyOf(partition);
365
728
  const objectId = this.#objectId(object.type, partitionKey, object.id);
366
729
  const user = this.#subjectUser(subject, partitionKey);
730
+ await this.#assertModelDeny([object.type]);
367
731
  await this.#guard('unrelate', () =>
368
732
  // Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
369
733
  this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
370
734
  }
735
+ /**
736
+ * **`deny` = UN `Write` de la tupla `denied_<rel>`** (lote D · D1): sin caducidad (D3), así que no hay renovación
737
+ * ni ventana —un re-deny idéntico lo ignora el servidor (`on_duplicate: ignore`)—; una tupla de deny escrita a mano
738
+ * CON condición devuelve 409 y, desde el cierre 1 de D2 (🟡 4), sale como 500 `E_AUTHZ_CONFIG` con la receta
739
+ * (`AuthorizationConfigError.conditionalDeny`; hasta entonces era un 503 que se repetía para siempre). Antes del `Write`: la
740
+ * puerta de `{ transaction }` (L-5), F-05 sobre el nombre PÚBLICO, la puerta del opt-in (fase `'write'`), sin
741
+ * caducidad, la partición, `*`, el sujeto y la guarda del modelo (D-31). CERO llamadas si algo de eso falla.
742
+ */
743
+ async deny(subject, relation, object, partition, options) {
744
+ this.#rejectTransaction('deny', options);
745
+ assertRelationDeclared(this.#config, object, relation);
746
+ assertRelationDenyable(this.#config, object, relation);
747
+ assertDenyNoExpiry(options);
748
+ assertPartition(partition);
749
+ assertDenySubject(subject);
750
+ const partitionKey = this.#keyOf(partition);
751
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
752
+ const user = this.#subjectUser(subject, partitionKey);
753
+ await this.#assertModelDeny([object.type]);
754
+ const key = { user, relation: deniedRelationOf(relation), object: objectId };
755
+ try {
756
+ await this.#guard('deny', () => this.client.write({ writes: [key] }, IGNORE_DUPLICATE_WRITES));
757
+ }
758
+ catch (error) {
759
+ // Cierre 1 de D2 · 🟡 4: el 409 de una clave que ya existe CON condición (medido en v1.19.0: «already exists with
760
+ // a different condition») no es una caída. Se confirma RELEYENDO la tupla (no por el texto del mensaje): si está y
761
+ // lleva condición, 500 con la receta; si no, el 503 de siempre.
762
+ if (error?.status === 503 && error?.cause?.statusCode === 409) {
763
+ const found = await this.#guard('deny', () => this.client.read(key, { consistency: this.#consistency }));
764
+ if (Array.isArray(found?.tuples) && found.tuples.some((t) => t?.key?.condition)) {
765
+ throw AuthorizationConfigError.conditionalDeny('deny', `${key.object}#${key.relation}@${key.user}`);
766
+ }
767
+ }
768
+ throw error;
769
+ }
770
+ }
771
+ /**
772
+ * **`removeDeny` = UN delete de `denied_<rel>`** (no-op si no está). Sobre CUALQUIER tipo declarado (D8/R2-4 b):
773
+ * `Write` con delete no valida la relación contra el modelo (medido en v1.19.0), así que un deny vivo de un tipo
774
+ * que retiró el opt-in se puede borrar. Ojo: quitar un deny CONCEDE (filas declaradas S8–S10).
775
+ * **La guarda D-31 SÍ corre aquí para los tipos CON opt-in** (letra corregida en el cierre 1 de D1, ⚪ 5 del auditor:
776
+ * la decisión 4 del informe decía que no): con el opt-in vivo y un modelo fijado sin `denied_*` ⇒ 500 antes del
777
+ * delete. Con el opt-in RETIRADO no hay guarda (el tipo no está en la config con `deny`) y el delete borra de verdad
778
+ * (medido por el auditor: `listDenies` de 1 a 0 y `check` vuelve a `true` con el modelo viejo) — retirar es reversible.
779
+ */
780
+ async removeDeny(subject, relation, object, partition, options) {
781
+ this.#rejectTransaction('removeDeny', options);
782
+ assertRelationDeclared(this.#config, object, relation);
783
+ assertDenyNoExpiry(options);
784
+ assertPartition(partition);
785
+ const partitionKey = this.#keyOf(partition);
786
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
787
+ const user = this.#subjectUser(subject, partitionKey);
788
+ await this.#assertModelDeny([object.type]);
789
+ await this.#guard('removeDeny', () => this.client.write({ deletes: [{ user, relation: deniedRelationOf(relation), object: objectId }] }, IGNORE_MISSING_DELETES));
790
+ }
371
791
  /* ── Lecturas ───────────────────────────────────────────────────────────── */
372
792
  /**
373
793
  * **F-05 en las LECTURAS y las PURGAS del driver** (2.4.0-alpha.3, cierre
@@ -390,6 +810,7 @@ export class OpenFgaRelationsDriver {
390
810
  const objectId = this.#objectId(object.type, partitionKey, object.id);
391
811
  const user = this.#subjectUser(subject, partitionKey);
392
812
  const context = this.#checkContext(this.#now());
813
+ await this.#assertModelDeny([object.type]);
393
814
  const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
394
815
  return response.allowed === true;
395
816
  }
@@ -413,6 +834,7 @@ export class OpenFgaRelationsDriver {
413
834
  const user = this.#subjectUser(subject, partitionKey);
414
835
  const { distinct, index } = distinctObjects(objects);
415
836
  const context = this.#checkContext(this.#now());
837
+ await this.#assertModelDeny(distinct.map((object) => object.type));
416
838
  const checks = distinct.map((object, i) => ({
417
839
  user,
418
840
  relation,
@@ -432,16 +854,157 @@ export class OpenFgaRelationsDriver {
432
854
  }
433
855
  return expandByPosition(index, answers);
434
856
  }
857
+ /**
858
+ * **`decideMany` = un `batchCheck` con `R` y los `denied_<m>` de las relaciones FUENTE de `down*(R)`** (lote D ·
859
+ * D9, juez ronda 2 §4): por el cierre ascendente del modelo (`denied_X = this ∨ denied_P`), `denied_<fuente>`
860
+ * cubre todo `up*(down*(R))` — en el retículo del contrato, 2 ítems por objeto (`R` y `denied_owner`). Mismo
861
+ * `current_time` en TODOS los ítems (un `now` por lote), alineado por `correlationId` (`alignBatchResults`, la
862
+ * misma función que `checkMany` y roles), troceado por `FGA_MAX_BATCH_CHECK`, y un `error` en UN ítem ⇒ 503 para
863
+ * todo el lote (nunca `none`: S15). La decisión sale de `decideFrom`, la regla ÚNICA (la misma que `database` y el
864
+ * doble). En un tipo sin opt-in se pregunta el deny solo si el modelo que sirve el driver lo declara (opt-in
865
+ * retirado con el modelo aún publicado, D8); si no, cada posición es `allow` o `none` (no hay deny posible).
866
+ */
867
+ async decideMany(subject, relation, objects, partition) {
868
+ assertCheckManyObjects(objects, 'decideMany');
869
+ for (const object of objects)
870
+ assertRelationDeclared(this.#config, object, relation);
871
+ assertPartition(partition);
872
+ this.#assertPinnedForDecideMany();
873
+ const partitionKey = this.#keyOf(partition);
874
+ const user = this.#subjectUser(subject, partitionKey);
875
+ const { distinct, index } = distinctObjects(objects);
876
+ const context = this.#checkContext(this.#now());
877
+ await this.#assertModelDeny(distinct.map((object) => object.type));
878
+ const declared = await this.#modelDenied();
879
+ const checks = [];
880
+ const plan = [];
881
+ distinct.forEach((object, i) => {
882
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
883
+ const down = relationsDown(this.#config, object.type, relation);
884
+ const type = this.#config.objectTypes.find((t) => t.type === object.type);
885
+ const sources = down.filter((name) => {
886
+ const includes = type?.relations.find((r) => r.name === name)?.includes ?? [];
887
+ return includes.length === 0 && declared.has(`${object.type}#${deniedRelationOf(name)}`);
888
+ });
889
+ plan.push({ object, sources });
890
+ // `correlationId` del servidor: `^[\w\d-]{1,36}$` (sin `:`): `a<i>` para R, `d<i>-<k>` para la fuente k.
891
+ checks.push({ user, relation, object: objectId, context, correlationId: `a${i}` });
892
+ sources.forEach((source, k) => {
893
+ checks.push({ user, relation: deniedRelationOf(source), object: objectId, context, correlationId: `d${i}-${k}` });
894
+ });
895
+ });
896
+ const allowed = new Map();
897
+ for (let start = 0; start < checks.length; start += FGA_MAX_BATCH_CHECK) {
898
+ const chunk = checks.slice(start, start + FGA_MAX_BATCH_CHECK);
899
+ const response = await this.#guard('decideMany', () => this.client.batchCheck({ checks: chunk }, { consistency: this.#consistency }));
900
+ const results = alignBatchResults('openfga-relations', 'decideMany', chunk, response.result);
901
+ results.forEach((r, i) => allowed.set(chunk[i].correlationId, r.allowed === true));
902
+ }
903
+ // D9, por posición: `allow` si el `Check` de R concede (el servidor ya aplicó el corte); si no, `deny` si alguno
904
+ // de los `denied_<fuente>` alcanza al sujeto (su cierre cubre up*(down*(R))); si no, `none`. Es `decideFrom` con el
905
+ // corte ya evaluado en el servidor (los dos drivers y el doble responden la misma tabla: casos D9 de la COMÚN).
906
+ const answers = plan.map(({ sources }, i) => {
907
+ if (allowed.get(`a${i}`))
908
+ return 'allow';
909
+ return sources.some((_, k) => allowed.get(`d${i}-${k}`)) ? 'deny' : 'none';
910
+ });
911
+ return index.map((at) => answers[at]);
912
+ }
913
+ /**
914
+ * **`decideMany` exige `modelId` FIJADO, con o sin tipos con deny** (lote D · cierre 1 de D1, 🟡 3 del auditor; D14
915
+ * extendido a la primitiva de cadena). `decideMany` decide qué `denied_*` preguntar con el memo del modelo
916
+ * (`#modelDenied`), y `check` lo resta el SERVIDOR con el modelo que evalúa: sin pin, el memo es el «último» del
917
+ * momento de la primera lectura y el servidor evalúa con el último de AHORA. Republicado un modelo con deny (flota con
918
+ * dos configs, reactivación del opt-in), `check` daba `false` por el deny y `decideMany` respondía `none` —la cadena
919
+ * del llamante seguía subiendo y concedía por el padre: fail-open en la primitiva—. Con el pin, memo y `Check` son
920
+ * del MISMO modelo. 500 `E_AUTHZ_CONFIG` con cero llamadas, ANTES de leer nada.
921
+ */
922
+ #assertPinnedForDecideMany() {
923
+ if (this.#options.modelId)
924
+ return;
925
+ throw new AuthorizationConfigError(`OpenFgaRelationsDriver.decideMany: exige modelId FIJADO (D14, también sin tipos con deny). decideMany decide qué ` +
926
+ `denied_<rel> preguntar con el modelo que leyó una vez, y sin pin el servidor evalúa check con el ÚLTIMO modelo del ` +
927
+ `store: tras republicar un modelo con deny respondería 'none' donde check responde false, y la cadena del llamante ` +
928
+ `concedería por el padre. Receta: provision (openfga:provision) → fijar modelId (OPENFGA_MODEL_ID) en TODA la flota ` +
929
+ `→ desplegar. No se ha llamado al servidor.`);
930
+ }
931
+ /**
932
+ * **`denyPhase` — ¿admite este driver escrituras de deny en el tipo?** (lote D · D2, R2-5). La fase del opt-in EN
933
+ * LA CONFIG DE ESTE DRIVER y, con opt-in, el modelo FIJADO comprobado con la MISMA guarda D-31 que `deny` (500 si
934
+ * no declara `denied_<rel>`). F-05 estricto del par, antes de nada (422, cero llamadas). La llama
935
+ * `reconcileRelations` en la fase de plan cuando el origen trae denies: un destino `openfga` sin `'write'` en el
936
+ * tipo, o con un pin a un modelo sin el deny, NIEGA la pasada antes de la primera escritura (jamás `skipped`).
937
+ */
938
+ async denyPhase(object, relation) {
939
+ assertRelationDeclared(this.#config, object, relation);
940
+ const phase = denyPhaseOf(this.#config, object.type);
941
+ if (phase === undefined)
942
+ return null;
943
+ await this.#assertModelDeny([object.type]);
944
+ return phase;
945
+ }
946
+ /** `listDenies` = `Read` por `(object, denied_<rel>)`: los denies DIRECTOS (hechos). Cualquier tipo declarado (D8). */
947
+ async listDenies(relation, object, partition, _page) {
948
+ assertRelationDeclared(this.#config, object, relation);
949
+ assertPartition(partition);
950
+ const partitionKey = this.#keyOf(partition);
951
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
952
+ const now = this.#now();
953
+ const tuples = await this.#readAll('listDenies', { object: objectId, relation: deniedRelationOf(relation) });
954
+ const subjects = [];
955
+ for (const t of tuples) {
956
+ if (t.validUntil && t.validUntil.getTime() <= now.getTime())
957
+ continue;
958
+ const subject = this.#userToSubject(t.user);
959
+ if (subject)
960
+ subjects.push(subject);
961
+ else
962
+ this.#warnUnparseable('listDenies', t.user);
963
+ }
964
+ return { subjects };
965
+ }
966
+ /**
967
+ * **El envoltorio ÚNICO de `ListObjects`** (plan 2.5 · lote B · B4; linaje L8,
968
+ * 5.ª iteración ⇒ M8: propiedad universal «toda enumeración que se puede
969
+ * cortar devuelve `truncated` o lanza»). `ListObjects` corta de DOS formas,
970
+ * las dos con 200 y sin `continuation_token` ni bandera: (1) al tope del
971
+ * servidor (`listObjectsMaxResults`): `>= tope` resultados CRUDOS ⇒ no se
972
+ * puede prometer la lista completa; (2) al DEADLINE del servidor
973
+ * (`--listObjects-deadline`, 3 s por defecto): pasado, devuelve lo que
974
+ * llevaba resuelto (analista M4, medido). La (2) se detecta midiendo la
975
+ * llamada con el reloj MONÓTONO (`monotonic`, `performance.now`): si duró
976
+ * ≥ `LIST_OBJECTS_DEADLINE_RATIO` × `listObjectsDeadlineMs`, `truncated`. Es
977
+ * una señal conservadora a propósito (una llamada lenta pero completa sale
978
+ * como truncada; una cortada JAMÁS como completa). Todo `ListObjects` del
979
+ * driver pasa por aquí — la guardia de superficie (columna `truncation`) lo
980
+ * sondea con las dos causas y un mutante por cada una. Las enumeraciones por
981
+ * `Read` (`listSubjects`, `enumerateRelations`, las purgas) no se cortan:
982
+ * `#readAll` agota el token o lanza 503 («exhaustivo o ruidoso»).
983
+ */
984
+ async #listObjectsPage(request) {
985
+ const started = this.#monotonic();
986
+ const response = await this.#guard('listObjects', () => this.client.listObjects(request, { consistency: this.#consistency }));
987
+ const elapsedMs = this.#monotonic() - started;
988
+ // 🟡 4 (cierre 1 del lote B): un 200 sin `objects` (un proxy, una caché, un
989
+ // cuerpo vacío) NO es una lista completa vacía: es una enumeración que no
990
+ // se puede prometer ⇒ 503, como `#readAll` con una respuesta sin `tuples`.
991
+ // Hasta el cierre `response.objects ?? []` la vendía como `{ objects: [] }`
992
+ // sin `truncated` — la lista «completa» muda que M8 prohíbe.
993
+ if (!Array.isArray(response?.objects)) {
994
+ throw new AuthorizationBackendError('openfga-relations', 'listObjects', new Error(`la respuesta de ListObjects no trae 'objects'; no se toma por vacía (sería una lista completa muda)`));
995
+ }
996
+ const raw = response.objects;
997
+ const byCap = raw.length >= this.#listObjectsMax;
998
+ const byDeadline = elapsedMs >= LIST_OBJECTS_DEADLINE_RATIO * this.#listObjectsDeadlineMs;
999
+ return { raw, truncated: byCap || byDeadline, elapsedMs };
1000
+ }
435
1001
  /**
436
1002
  * `listObjects` = `ListObjects` (resuelve includes + usersets del lado del
437
- * servidor) + FILTRO por partición + señal de TRUNCADO. `ListObjects` corta
438
- * al tope del servidor sin `continuation_token` ni bandera, así que el
439
- * truncado se detecta comparando el número de resultados CRUDOS con el tope
440
- * configurado (`listObjectsMaxResults`): `>= tope` ⇒ no se puede prometer la
441
- * lista completa ⇒ `truncated: true`. El filtro por partición se aplica
442
- * DESPUÉS (los ids llevan la partición), así que en un store con varias
443
- * particiones el tope se consume entre todas — otra razón para señalar el
444
- * truncado y no callar (documentado en el README).
1003
+ * servidor) + FILTRO por partición + señal de TRUNCADO (`#listObjectsPage`:
1004
+ * tope o deadline, B4). El filtro por partición se aplica DESPUÉS (los ids
1005
+ * llevan la partición), así que en un store con varias particiones el tope
1006
+ * se consume entre todas — otra razón para señalar el truncado y no callar
1007
+ * (documentado en el README).
445
1008
  */
446
1009
  async listObjects(subject, relation, objectType, partition, _page) {
447
1010
  assertRelationDeclared(this.#config, { type: objectType }, relation);
@@ -449,9 +1012,8 @@ export class OpenFgaRelationsDriver {
449
1012
  const partitionKey = this.#keyOf(partition);
450
1013
  const user = this.#subjectUser(subject, partitionKey);
451
1014
  const context = this.#checkContext(this.#now());
452
- const response = await this.#guard('listObjects', () => this.client.listObjects({ user, relation, type: objectType, context }, { consistency: this.#consistency }));
453
- const raw = response.objects ?? [];
454
- const truncated = raw.length >= this.#listObjectsMax;
1015
+ await this.#assertModelDeny([objectType]);
1016
+ const { raw, truncated } = await this.#listObjectsPage({ user, relation, type: objectType, context });
455
1017
  const objects = [];
456
1018
  for (const full of raw) {
457
1019
  const parsed = this.#parseObjectId(full);
@@ -596,7 +1158,8 @@ export class OpenFgaRelationsDriver {
596
1158
  }
597
1159
  tuples.push({
598
1160
  subject,
599
- relation: key.relation,
1161
+ // Lote D · D2: `denied_<rel>` ⇒ `{ relation: <rel>, kind: 'deny' }` (`storedRelationKind`, punto de paso único).
1162
+ ...storedRelationKind(key.relation),
600
1163
  object: { type: parsed.type, id: parsed.objectUuid },
601
1164
  partition,
602
1165
  expiresAt: key.validUntil,
@@ -621,7 +1184,7 @@ export class OpenFgaRelationsDriver {
621
1184
  const objectId = this.#objectId(object.type, this.#keyOf(partition), object.id);
622
1185
  const progress = { deleted: 0 };
623
1186
  try {
624
- await this.#purgeByFilter('purgeObject', { object: objectId }, `objeto ${objectId}`, progress);
1187
+ await this.#purgeByFilters('purgeObject', [[{ object: objectId }, `objeto ${objectId}`]], progress);
625
1188
  }
626
1189
  catch (error) {
627
1190
  throw this.#partialIfDeleted(error, progress);
@@ -646,9 +1209,7 @@ export class OpenFgaRelationsDriver {
646
1209
  const types = [FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
647
1210
  const progress = { deleted: 0 };
648
1211
  try {
649
- for (const type of types) {
650
- await this.#purgeByFilter('purgeSubject', { user, object: `${type}:` }, `sujeto ${user} en '${type}'`, progress);
651
- }
1212
+ await this.#purgeByFilters('purgeSubject', types.map((type) => [{ user, object: `${type}:` }, `sujeto ${user} en '${type}'`]), progress);
652
1213
  }
653
1214
  catch (error) {
654
1215
  throw this.#partialIfDeleted(error, progress);
@@ -658,7 +1219,7 @@ export class OpenFgaRelationsDriver {
658
1219
  * **Una purga que muere a MEDIAS es indeterminada** (invariante 13,
659
1220
  * 2.4.0-alpha.3, cierre del 🟡 6 del auditor). La purga NO es un `Write`:
660
1221
  * son N `Read`+`deleteTuples` (ortografías × tipos, y lotes de
661
- * `WRITE_BATCH_SIZE`), y un 503 que no es timeout —o el 500
1222
+ * `RELATIONS_PURGE_WRITE_MAX`), y un 503 que no es timeout —o el 500
662
1223
  * `E_AUTHZ_PURGE_INCOMPLETE` de la demostración de cero— puede llegar
663
1224
  * después de haber borrado parte. Si YA se borró al menos una tupla, el
664
1225
  * error se marca (`markPartialWrite`, `src/errors.ts`: una propiedad, la
@@ -673,30 +1234,75 @@ export class OpenFgaRelationsDriver {
673
1234
  return markPartialWrite(error);
674
1235
  return error;
675
1236
  }
676
- /** Lee por filtro, borra en lotes, y RE-LEE para demostrar cero (o lanza). `progress.deleted` cuenta lo que YA se borró (un lote que falla no borró nada: el `Write` de FGA es transaccional). */
677
- async #purgeByFilter(operation, filter, what, progress) {
678
- const keys = await this.#readAll(operation, filter);
679
- for (let i = 0; i < keys.length; i += WRITE_BATCH_SIZE) {
680
- const batch = keys.slice(i, i + WRITE_BATCH_SIZE);
681
- await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES));
1237
+ /**
1238
+ * **Lee TODOS los filtros, borra en un `Write` si cabe —y si no, en ORDEN GLOBAL: allows → membresías → denies— y
1239
+ * RE-LEE cada filtro para demostrar cero (o lanza)** (lote D · D-16/D-26/S3; cierre 1 de D1, 🟠 1 del auditor; el
1240
+ * orden S6 de roles, invariante 6). La propiedad: **una purga cortada a medias no concede nada que no concedieran el
1241
+ * estado inicial o el final** —con la única excepción DECLARADA de abajo—.
1242
+ *
1243
+ * - **Cabe en `RELATIONS_PURGE_WRITE_MAX` (100) ⇒ UN solo `Write`**: FGA lo aplica de forma transaccional (medido:
1244
+ * 101 es 400 y no se aplica nada), así que NO hay estado intermedio que cortar.
1245
+ * - **No cabe ⇒ tres clases, cada una en sus propios `Write`, en el orden de BORRADO del punto de paso ÚNICO
1246
+ * `relationDeleteOrder`** (`src/relations/tuple_order.ts`; cierre 1 de D2 · 🟠 1: el mismo que el reconcile): (1) los allows sobre objetos que no son
1247
+ * `group` —quitar un allow solo resta (D-12, monotonía)—; (2) las MEMBRESÍAS `group:*#member@<sujeto>` —una membresía
1248
+ * TRANSPORTA allows y denies a la vez: borrarla antes que un allow directo deja ese allow sin el deny que lo cortaba
1249
+ * (🟠 1: con el orden «solo prefijo» de alpha.3-dev, `group:g#member@U` caía en el lote 1 y `doc:V#viewer@U` en el 2;
1250
+ * cortada entre los dos, U CONCEDÍA sobre V; la gemela `purgeSubject(g#member)` con la arista anidada
1251
+ * `group:h#member@group:g#member`)—, en UN `Write` PROPIO si caben (ordenadas por objeto, las portadoras de deny al
1252
+ * final: el reparto no depende del `Read`); (3) los `denied_*`, al final —quedan denies de MÁS, nunca de menos—.
1253
+ * - **Residuo DECLARADO**: con MÁS de 100 membresías del sujeto no caben en un `Write`, y una que transporta un deny
1254
+ * puede caer en un lote anterior a otra que transporta un allow (fila con caso en `relations_openfga.spec`, frase en
1255
+ * el README). No se niega la purga antes de borrar (invariante 11: una purga no espera; un 500 dejaría al sujeto de
1256
+ * baja con TODO su acceso): sale marcada como parcial (`indeterminate: true`) y se repite.
1257
+ *
1258
+ * `purgeObject` pasa por aquí igual: sobre un objeto que no es `group` no hay membresías (allows → denies); sobre
1259
+ * `group:g` todo son membresías de g, y cada miembro queda dentro o fuera de g —su estado inicial o el final—.
1260
+ * `progress.deleted` cuenta lo que YA se borró (un lote que falla no borró nada) para la marca de escritura parcial
1261
+ * (invariante 13).
1262
+ */
1263
+ async #purgeByFilters(operation, filters, progress) {
1264
+ const read = [];
1265
+ for (const [filter, what] of filters)
1266
+ read.push({ filter, what, keys: await this.#readAll(operation, filter) });
1267
+ for (const batch of purgeWrites(read.flatMap((r) => r.keys))) {
1268
+ try {
1269
+ await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES));
1270
+ }
1271
+ catch (error) {
1272
+ // Cierre 2 de D1 · ⚪ N3: el 400 del tope de operaciones por `Write` del SERVIDOR no es una caída (503
1273
+ // reintentable): es un despliegue con `--max-tuples-per-write` < `RELATIONS_PURGE_WRITE_MAX`. 500 con la letra;
1274
+ // la marca de escritura parcial la pone `#partialIfDeleted` si ya se aplicó un `Write` anterior.
1275
+ if (isServerWriteLimit(error))
1276
+ throw purgeWriteLimitError(operation, batch.length, progress.deleted, error);
1277
+ throw error;
1278
+ }
682
1279
  progress.deleted += batch.length;
683
1280
  }
684
- if (keys.length === 0)
685
- return;
686
- // Demostración de cero: re-lee tras borrar (invariante 11).
687
- const residue = await this.#readAll(operation, filter);
688
- if (residue.length > 0) {
689
- throw new PurgeIncompleteError(`${operation}: quedan ${residue.length} tuplas de ${what} tras el borrado ` +
690
- `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}). ` +
691
- `No se confirma la purga.`);
1281
+ // Demostración de cero: re-lee tras borrar (invariante 11), solo los filtros que tenían algo.
1282
+ for (const { filter, what, keys } of read) {
1283
+ if (keys.length === 0)
1284
+ continue;
1285
+ const residue = await this.#readAll(operation, filter);
1286
+ if (residue.length > 0) {
1287
+ throw new PurgeIncompleteError(`${operation}: quedan ${residue.length} tuplas de ${what} tras el borrado ` +
1288
+ `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}). ` +
1289
+ `No se confirma la purga.`);
1290
+ }
692
1291
  }
693
1292
  }
694
1293
  /* ── Helpers ────────────────────────────────────────────────────────────── */
695
1294
  /**
696
- * TODAS las tuplas que casan con el filtro, paginando `Read` hasta agotar el
697
- * `continuation_token`. `Read` no tiene tope de resultados (a diferencia de
698
- * `ListObjects`), así que es exhaustivo — por eso las purgas y `listSubjects`
699
- * van por aquí, no por `ListObjects`.
1295
+ * **El envoltorio ÚNICO de `Read`** (B4, la otra mitad de la propiedad
1296
+ * universal): TODAS las tuplas que casan con el filtro, paginando `Read`
1297
+ * hasta agotar el `continuation_token`. `Read` no tiene tope de resultados
1298
+ * (a diferencia de `ListObjects`), así que es exhaustivo — por eso las purgas,
1299
+ * `listSubjects` y `enumerateRelations(Many)` van por aquí, no por
1300
+ * `ListObjects` — y cuando NO puede serlo, lanza 503 en vez de devolver una
1301
+ * página corta: una respuesta sin `tuples`, un token que se repite (el
1302
+ * servidor no avanza; una página vacía con token cae aquí) o más de
1303
+ * `MAX_READ_PAGES` páginas. La guardia de superficie (columna `truncation`)
1304
+ * sondea las cinco operaciones que entran por aquí con un mutante que
1305
+ * «termina» en la página vacía.
700
1306
  */
701
1307
  async #readAll(operation, filter) {
702
1308
  const keys = [];
@@ -758,4 +1364,92 @@ export class OpenFgaRelationsDriver {
758
1364
  // de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
759
1365
  // la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
760
1366
  // sin haber borrado nada (medido por el auditor: `alpha3-cierre-parcial.ts` B).
1367
+ /** Igualdad ESTRUCTURAL de dos reglas de reescritura (D-31, cierre 2 de D1 · ⚪ N4); una ausente nunca es igual. */
1368
+ function sameRewrite(served, generated) {
1369
+ if (served === undefined || generated === undefined)
1370
+ return false;
1371
+ return JSON.stringify(canonicalRewrite(served)) === JSON.stringify(canonicalRewrite(generated));
1372
+ }
1373
+ /** La LETRA del 500 de N3 (fijada por `relations_openfga.spec`, «⚪ N3 · …»). */
1374
+ function purgeWriteLimitError(operation, size, deleted, cause) {
1375
+ const sdk = cause?.cause?.apiErrorMessage;
1376
+ return new AuthorizationConfigError(`OpenFgaRelationsDriver: ${operation}: el servidor OpenFGA rechazó un Write de ${size} tuplas por su tope de ` +
1377
+ `operaciones por Write (exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). El driver parte las ` +
1378
+ `purgas en RELATIONS_PURGE_WRITE_MAX = ${RELATIONS_PURGE_WRITE_MAX} y exige --max-tuples-per-write ≥ ` +
1379
+ `${RELATIONS_PURGE_WRITE_MAX} en el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${RELATIONS_PURGE_WRITE_MAX}). ` +
1380
+ `No es transitorio: repetida, la purga vuelve a fallar igual. ` +
1381
+ (deleted > 0
1382
+ ? `Ya se habían borrado ${deleted} tuplas en Write anteriores: la purga quedó A MEDIAS (escritura parcial).`
1383
+ : 'No se borró nada.'), { cause });
1384
+ }
1385
+ /**
1386
+ * **Los `Write` de una purga, en orden** (lote D · cierre 1 de D1, 🟠 1; ver `#purgeByFilters`): uno solo si todo cabe
1387
+ * en `RELATIONS_PURGE_WRITE_MAX`; si no, en el orden de BORRADO del punto de paso ÚNICO `relationDeleteOrder`
1388
+ * (`src/relations/tuple_order.ts`, cierre 1 de D2 · 🟠 1: el MISMO que usa el reconcile) — allows sobre objetos que no
1389
+ * son `group`, luego las membresías `group:*#member` (las portadoras de deny al final, ordenadas por objeto y sujeto) y
1390
+ * al final los `denied_*` —, con CADA CLASE en sus PROPIOS `Write` (con ≤ 100 membresías, uno). Pura: la tabla de verdad
1391
+ * del caso la juzga contra el servidor, y la PROPIEDAD la juzga sin servidor la guardia de superficie
1392
+ * (`GS · P · openfga.purge.order`, cierre 2 de D1 · ⚪ N2), con los mutantes de orden en el corredor publicado
1393
+ * (`npm run check:guard-mutants`). Exportada solo para eso: no sale por `/openfga`.
1394
+ */
1395
+ export function purgeWrites(keys) {
1396
+ if (keys.length === 0)
1397
+ return [];
1398
+ if (keys.length <= RELATIONS_PURGE_WRITE_MAX)
1399
+ return [[...keys]];
1400
+ const view = (k) => ({ deny: k.relation.startsWith(RELATION_DENY_PREFIX), object: k.object, user: k.user });
1401
+ const ordered = relationDeleteOrder(keys, view);
1402
+ const chunks = (list) => {
1403
+ const out = [];
1404
+ for (let i = 0; i < list.length; i += RELATIONS_PURGE_WRITE_MAX)
1405
+ out.push(list.slice(i, i + RELATIONS_PURGE_WRITE_MAX));
1406
+ return out;
1407
+ };
1408
+ // Cada CLASE en sus propios `Write`: una membresía nunca comparte lote con el último allow ni con el primer deny.
1409
+ const runs = [];
1410
+ let previous;
1411
+ for (const k of ordered) {
1412
+ const cls = relationOrderClass(view(k));
1413
+ if (cls !== previous)
1414
+ runs.push([]);
1415
+ runs[runs.length - 1].push(k);
1416
+ previous = cls;
1417
+ }
1418
+ return runs.flatMap(chunks);
1419
+ }
1420
+ /**
1421
+ * **El error de una renovación en dos `Write` NOMBRA el deny que entró en la ventana** (cierre 3 de D2 · 🟡 C2-3). Sobre
1422
+ * el error que se propaga (su clase y su `code` no cambian): `carriedDeny` = el deny que la re-comprobación encontró, y la
1423
+ * letra con la receta; si la re-comprobación también falló, lo dice (`carriedDenyUnknown: true`). `applied` = la
1424
+ * renovación SÍ se aplicó y lo que falló es solo la re-comprobación. Un error congelado se propaga tal cual.
1425
+ */
1426
+ function annotateRacedDeny(error, membership, recheck, applied = false) {
1427
+ let note = null;
1428
+ const target = error;
1429
+ try {
1430
+ if (recheck.deny !== null) {
1431
+ target.carriedDeny = recheck.deny;
1432
+ note =
1433
+ ` — Cierre 3 de D2 (C2-3): la membresía ${membership} quedó BORRADA y un deny concurrente (${recheck.deny}) que ` +
1434
+ 'entró en el grupo durante la renovación ya NO alcanza al sujeto por ella, y no lo alcanzará hasta que la ' +
1435
+ 'membresía se reescriba. Receta: vuelve a escribirla (relate: ya no existe, así que es UN Write) o retira y ' +
1436
+ 'reescribe ese deny después.';
1437
+ }
1438
+ else if (recheck.error !== undefined) {
1439
+ target.carriedDenyUnknown = true;
1440
+ note = applied
1441
+ ? ` — Cierre 3 de D2 (C2-3): la renovación de ${membership} SE APLICÓ, pero la re-comprobación de un deny ` +
1442
+ 'concurrente en su grupo falló: no se sabe si un deny entró en la ventana entre los dos Write. Receta: lista los ' +
1443
+ 'denies del grupo (listDenies) y revisa las decisiones de ese sujeto en esa ventana.'
1444
+ : ` — Cierre 3 de D2 (C2-3): la membresía ${membership} quedó BORRADA y la re-comprobación de un deny concurrente ` +
1445
+ 'en su grupo también falló. Receta: vuelve a escribir la membresía (relate) y lista los denies del grupo.';
1446
+ }
1447
+ if (note !== null && typeof target.message === 'string')
1448
+ target.message += note;
1449
+ }
1450
+ catch {
1451
+ // Un error congelado no admite la nota: se propaga el original (la clase y el `code` no cambian).
1452
+ }
1453
+ return error;
1454
+ }
761
1455
  //# sourceMappingURL=openfga_relations_driver.js.map