@jantstack/adonis-authz 2.5.0-alpha.2 → 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 (80) hide show
  1. package/README.md +343 -7
  2. package/build/commands/authz_relations_reconcile.d.ts +6 -0
  3. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  4. package/build/commands/authz_relations_reconcile.js +113 -3
  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/src/drivers/database_relations_driver.d.ts +36 -1
  15. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  16. package/build/src/drivers/database_relations_driver.js +211 -45
  17. package/build/src/drivers/database_relations_driver.js.map +1 -1
  18. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  19. package/build/src/drivers/openfga_driver.js +102 -36
  20. package/build/src/drivers/openfga_driver.js.map +1 -1
  21. package/build/src/drivers/openfga_facts.d.ts +21 -0
  22. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  23. package/build/src/drivers/openfga_facts.js +74 -8
  24. package/build/src/drivers/openfga_facts.js.map +1 -1
  25. package/build/src/drivers/openfga_relations_driver.d.ts +74 -1
  26. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  27. package/build/src/drivers/openfga_relations_driver.js +599 -29
  28. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  29. package/build/src/errors.d.ts +112 -0
  30. package/build/src/errors.d.ts.map +1 -1
  31. package/build/src/errors.js +130 -0
  32. package/build/src/errors.js.map +1 -1
  33. package/build/src/manager.d.ts.map +1 -1
  34. package/build/src/manager.js +9 -4
  35. package/build/src/manager.js.map +1 -1
  36. package/build/src/openfga.d.ts +1 -1
  37. package/build/src/openfga.d.ts.map +1 -1
  38. package/build/src/openfga.js +1 -1
  39. package/build/src/openfga.js.map +1 -1
  40. package/build/src/relations/check_many.d.ts +1 -1
  41. package/build/src/relations/check_many.d.ts.map +1 -1
  42. package/build/src/relations/check_many.js +6 -4
  43. package/build/src/relations/check_many.js.map +1 -1
  44. package/build/src/relations/define_relations_config.d.ts +115 -2
  45. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  46. package/build/src/relations/define_relations_config.js +197 -3
  47. package/build/src/relations/define_relations_config.js.map +1 -1
  48. package/build/src/relations/manager.d.ts +33 -2
  49. package/build/src/relations/manager.d.ts.map +1 -1
  50. package/build/src/relations/manager.js +120 -3
  51. package/build/src/relations/manager.js.map +1 -1
  52. package/build/src/relations/reconcile.d.ts +219 -1
  53. package/build/src/relations/reconcile.d.ts.map +1 -1
  54. package/build/src/relations/reconcile.js +745 -155
  55. package/build/src/relations/reconcile.js.map +1 -1
  56. package/build/src/relations/tuple_order.d.ts +126 -0
  57. package/build/src/relations/tuple_order.d.ts.map +1 -0
  58. package/build/src/relations/tuple_order.js +598 -0
  59. package/build/src/relations/tuple_order.js.map +1 -0
  60. package/build/src/shared/backend_guard.d.ts +8 -0
  61. package/build/src/shared/backend_guard.d.ts.map +1 -1
  62. package/build/src/shared/backend_guard.js +15 -0
  63. package/build/src/shared/backend_guard.js.map +1 -1
  64. package/build/src/testing/main.d.ts +1 -1
  65. package/build/src/testing/main.d.ts.map +1 -1
  66. package/build/src/testing/main.js +1 -1
  67. package/build/src/testing/main.js.map +1 -1
  68. package/build/src/testing/relations_contract.d.ts +18 -0
  69. package/build/src/testing/relations_contract.d.ts.map +1 -1
  70. package/build/src/testing/relations_contract.js +824 -44
  71. package/build/src/testing/relations_contract.js.map +1 -1
  72. package/build/src/testing/relations_reconcile_contract.d.ts +9 -0
  73. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  74. package/build/src/testing/relations_reconcile_contract.js +207 -0
  75. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  76. package/build/src/types.d.ts +83 -3
  77. package/build/src/types.d.ts.map +1 -1
  78. package/build/src/types.js +2 -0
  79. package/build/src/types.js.map +1 -1
  80. package/package.json +1 -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, assertUsersetSubject } 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
  /**
@@ -146,6 +161,13 @@ export class OpenFgaRelationsDriver {
146
161
  enumerateRelations: true,
147
162
  listObjectsTruncation: true,
148
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,
149
171
  // L-2/L-5 (panel `{trx}`, (C)): `false` EXPLÍCITO, y no puede ser otra
150
172
  // cosa — una tupla no entra en una transacción SQL: el store es otro
151
173
  // servicio y no hay 2PC, así que «los dos o ninguno» con la transacción
@@ -174,6 +196,12 @@ export class OpenFgaRelationsDriver {
174
196
  #listObjectsDeadlineMs;
175
197
  #monotonic;
176
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 = {};
177
205
  diagnostics = { unparseableTuples: 0 };
178
206
  constructor(config, options) {
179
207
  this.#config = config;
@@ -228,6 +256,18 @@ export class OpenFgaRelationsDriver {
228
256
  }
229
257
  this.#monotonic = monotonic ?? (() => performance.now());
230
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
+ }
231
271
  this.client = new OpenFgaClient({
232
272
  apiUrl: options.apiUrl,
233
273
  storeId: options.storeId,
@@ -249,6 +289,8 @@ export class OpenFgaRelationsDriver {
249
289
  }
250
290
  const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
251
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;
252
294
  return view;
253
295
  }
254
296
  /** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
@@ -294,6 +336,146 @@ export class OpenFgaRelationsDriver {
294
336
  throw fail(error);
295
337
  }), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
296
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
+ }
297
479
  /* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
298
480
  /** `<type>:<scopeKey(partition)>|<objectUuid>`. */
299
481
  #objectId(objectType, partitionKey, objectUuid) {
@@ -380,8 +562,12 @@ export class OpenFgaRelationsDriver {
380
562
  * - presente con la caducidad que toca ⇒ no-op (idempotente, invariante 6);
381
563
  * - presente con OTRA ⇒ delete + write en DOS llamadas (FGA no admite
382
564
  * delete+write de la misma clave en un `Write`, ni reescribir su
383
- * condición): entre ambas hay un instante en el que `check` responde
384
- * `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`).
385
571
  */
386
572
  /**
387
573
  * **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
@@ -413,6 +599,7 @@ export class OpenFgaRelationsDriver {
413
599
  const user = this.#subjectUser(subject, partitionKey);
414
600
  const key = { user, relation, object: objectId };
415
601
  const now = this.#now();
602
+ await this.#assertModelDeny([object.type]);
416
603
  const current = await this.#readTuple('relate', key);
417
604
  const previous = current ? current.validUntil : null;
418
605
  const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
@@ -422,10 +609,99 @@ export class OpenFgaRelationsDriver {
422
609
  ? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
423
610
  : key;
424
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
+ }
425
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;
426
654
  }
427
655
  await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
428
656
  }
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
+ }
429
705
  /**
430
706
  * La tupla EXACTA de una clave (un `Read` por clave completa), con su caducidad si la lleva.
431
707
  * ⚪ N5 (cierre 2 del lote B): un 200 sin `tuples` NO es «no existe» — hasta el cierre `?? []` lo
@@ -451,10 +727,67 @@ export class OpenFgaRelationsDriver {
451
727
  const partitionKey = this.#keyOf(partition);
452
728
  const objectId = this.#objectId(object.type, partitionKey, object.id);
453
729
  const user = this.#subjectUser(subject, partitionKey);
730
+ await this.#assertModelDeny([object.type]);
454
731
  await this.#guard('unrelate', () =>
455
732
  // Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
456
733
  this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
457
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
+ }
458
791
  /* ── Lecturas ───────────────────────────────────────────────────────────── */
459
792
  /**
460
793
  * **F-05 en las LECTURAS y las PURGAS del driver** (2.4.0-alpha.3, cierre
@@ -477,6 +810,7 @@ export class OpenFgaRelationsDriver {
477
810
  const objectId = this.#objectId(object.type, partitionKey, object.id);
478
811
  const user = this.#subjectUser(subject, partitionKey);
479
812
  const context = this.#checkContext(this.#now());
813
+ await this.#assertModelDeny([object.type]);
480
814
  const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
481
815
  return response.allowed === true;
482
816
  }
@@ -500,6 +834,7 @@ export class OpenFgaRelationsDriver {
500
834
  const user = this.#subjectUser(subject, partitionKey);
501
835
  const { distinct, index } = distinctObjects(objects);
502
836
  const context = this.#checkContext(this.#now());
837
+ await this.#assertModelDeny(distinct.map((object) => object.type));
503
838
  const checks = distinct.map((object, i) => ({
504
839
  user,
505
840
  relation,
@@ -519,6 +854,115 @@ export class OpenFgaRelationsDriver {
519
854
  }
520
855
  return expandByPosition(index, answers);
521
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
+ }
522
966
  /**
523
967
  * **El envoltorio ÚNICO de `ListObjects`** (plan 2.5 · lote B · B4; linaje L8,
524
968
  * 5.ª iteración ⇒ M8: propiedad universal «toda enumeración que se puede
@@ -568,6 +1012,7 @@ export class OpenFgaRelationsDriver {
568
1012
  const partitionKey = this.#keyOf(partition);
569
1013
  const user = this.#subjectUser(subject, partitionKey);
570
1014
  const context = this.#checkContext(this.#now());
1015
+ await this.#assertModelDeny([objectType]);
571
1016
  const { raw, truncated } = await this.#listObjectsPage({ user, relation, type: objectType, context });
572
1017
  const objects = [];
573
1018
  for (const full of raw) {
@@ -713,7 +1158,8 @@ export class OpenFgaRelationsDriver {
713
1158
  }
714
1159
  tuples.push({
715
1160
  subject,
716
- relation: key.relation,
1161
+ // Lote D · D2: `denied_<rel>` ⇒ `{ relation: <rel>, kind: 'deny' }` (`storedRelationKind`, punto de paso único).
1162
+ ...storedRelationKind(key.relation),
717
1163
  object: { type: parsed.type, id: parsed.objectUuid },
718
1164
  partition,
719
1165
  expiresAt: key.validUntil,
@@ -738,7 +1184,7 @@ export class OpenFgaRelationsDriver {
738
1184
  const objectId = this.#objectId(object.type, this.#keyOf(partition), object.id);
739
1185
  const progress = { deleted: 0 };
740
1186
  try {
741
- await this.#purgeByFilter('purgeObject', { object: objectId }, `objeto ${objectId}`, progress);
1187
+ await this.#purgeByFilters('purgeObject', [[{ object: objectId }, `objeto ${objectId}`]], progress);
742
1188
  }
743
1189
  catch (error) {
744
1190
  throw this.#partialIfDeleted(error, progress);
@@ -763,9 +1209,7 @@ export class OpenFgaRelationsDriver {
763
1209
  const types = [FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
764
1210
  const progress = { deleted: 0 };
765
1211
  try {
766
- for (const type of types) {
767
- await this.#purgeByFilter('purgeSubject', { user, object: `${type}:` }, `sujeto ${user} en '${type}'`, progress);
768
- }
1212
+ await this.#purgeByFilters('purgeSubject', types.map((type) => [{ user, object: `${type}:` }, `sujeto ${user} en '${type}'`]), progress);
769
1213
  }
770
1214
  catch (error) {
771
1215
  throw this.#partialIfDeleted(error, progress);
@@ -775,7 +1219,7 @@ export class OpenFgaRelationsDriver {
775
1219
  * **Una purga que muere a MEDIAS es indeterminada** (invariante 13,
776
1220
  * 2.4.0-alpha.3, cierre del 🟡 6 del auditor). La purga NO es un `Write`:
777
1221
  * son N `Read`+`deleteTuples` (ortografías × tipos, y lotes de
778
- * `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
779
1223
  * `E_AUTHZ_PURGE_INCOMPLETE` de la demostración de cero— puede llegar
780
1224
  * después de haber borrado parte. Si YA se borró al menos una tupla, el
781
1225
  * error se marca (`markPartialWrite`, `src/errors.ts`: una propiedad, la
@@ -790,22 +1234,60 @@ export class OpenFgaRelationsDriver {
790
1234
  return markPartialWrite(error);
791
1235
  return error;
792
1236
  }
793
- /** 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). */
794
- async #purgeByFilter(operation, filter, what, progress) {
795
- const keys = await this.#readAll(operation, filter);
796
- for (let i = 0; i < keys.length; i += WRITE_BATCH_SIZE) {
797
- const batch = keys.slice(i, i + WRITE_BATCH_SIZE);
798
- 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
+ }
799
1279
  progress.deleted += batch.length;
800
1280
  }
801
- if (keys.length === 0)
802
- return;
803
- // Demostración de cero: re-lee tras borrar (invariante 11).
804
- const residue = await this.#readAll(operation, filter);
805
- if (residue.length > 0) {
806
- throw new PurgeIncompleteError(`${operation}: quedan ${residue.length} tuplas de ${what} tras el borrado ` +
807
- `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}). ` +
808
- `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
+ }
809
1291
  }
810
1292
  }
811
1293
  /* ── Helpers ────────────────────────────────────────────────────────────── */
@@ -882,4 +1364,92 @@ export class OpenFgaRelationsDriver {
882
1364
  // de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
883
1365
  // la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
884
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
+ }
885
1455
  //# sourceMappingURL=openfga_relations_driver.js.map