@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.0-alpha.10

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 (158) hide show
  1. package/README.md +496 -29
  2. package/build/commands/authz_catalog_diff.d.ts.map +1 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_diff.js.map +1 -1
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -1
  6. package/build/commands/authz_catalog_prune_orphans.js +0 -1
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -1
  8. package/build/commands/authz_freeze.d.ts.map +1 -1
  9. package/build/commands/authz_freeze.js +1 -2
  10. package/build/commands/authz_freeze.js.map +1 -1
  11. package/build/commands/authz_reconcile.d.ts +15 -6
  12. package/build/commands/authz_reconcile.d.ts.map +1 -1
  13. package/build/commands/authz_reconcile.js +143 -20
  14. package/build/commands/authz_reconcile.js.map +1 -1
  15. package/build/commands/authz_relations_reconcile.d.ts +72 -1
  16. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  17. package/build/commands/authz_relations_reconcile.js +234 -21
  18. package/build/commands/authz_relations_reconcile.js.map +1 -1
  19. package/build/commands/authz_scopes_relay.d.ts +50 -0
  20. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  21. package/build/commands/authz_scopes_relay.js +213 -10
  22. package/build/commands/authz_scopes_relay.js.map +1 -1
  23. package/build/commands/authz_scopes_requeue.d.ts +80 -0
  24. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  25. package/build/commands/authz_scopes_requeue.js +230 -0
  26. package/build/commands/authz_scopes_requeue.js.map +1 -0
  27. package/build/commands/authz_unfreeze.d.ts +12 -4
  28. package/build/commands/authz_unfreeze.d.ts.map +1 -1
  29. package/build/commands/authz_unfreeze.js +74 -9
  30. package/build/commands/authz_unfreeze.js.map +1 -1
  31. package/build/commands/main.d.ts +16 -13
  32. package/build/commands/main.d.ts.map +1 -1
  33. package/build/commands/main.js +59 -11
  34. package/build/commands/main.js.map +1 -1
  35. package/build/commands/openfga_provision.d.ts +49 -3
  36. package/build/commands/openfga_provision.d.ts.map +1 -1
  37. package/build/commands/openfga_provision.js +72 -7
  38. package/build/commands/openfga_provision.js.map +1 -1
  39. package/build/index.d.ts +15 -5
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +16 -3
  42. package/build/index.js.map +1 -1
  43. package/build/providers/authz_provider.d.ts +7 -2
  44. package/build/providers/authz_provider.d.ts.map +1 -1
  45. package/build/providers/authz_provider.js +20 -3
  46. package/build/providers/authz_provider.js.map +1 -1
  47. package/build/src/define_config.d.ts +106 -1
  48. package/build/src/define_config.d.ts.map +1 -1
  49. package/build/src/define_config.js +11 -0
  50. package/build/src/define_config.js.map +1 -1
  51. package/build/src/drivers/database_driver.d.ts +53 -14
  52. package/build/src/drivers/database_driver.d.ts.map +1 -1
  53. package/build/src/drivers/database_driver.js +104 -32
  54. package/build/src/drivers/database_driver.js.map +1 -1
  55. package/build/src/drivers/database_relations_driver.d.ts +74 -9
  56. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  57. package/build/src/drivers/database_relations_driver.js +391 -93
  58. package/build/src/drivers/database_relations_driver.js.map +1 -1
  59. package/build/src/drivers/openfga_driver.d.ts +71 -10
  60. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  61. package/build/src/drivers/openfga_driver.js +195 -16
  62. package/build/src/drivers/openfga_driver.js.map +1 -1
  63. package/build/src/drivers/openfga_facts.d.ts +15 -0
  64. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  65. package/build/src/drivers/openfga_facts.js +25 -2
  66. package/build/src/drivers/openfga_facts.js.map +1 -1
  67. package/build/src/drivers/openfga_relations_driver.d.ts +72 -6
  68. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  69. package/build/src/drivers/openfga_relations_driver.js +308 -50
  70. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  71. package/build/src/errors.d.ts +175 -4
  72. package/build/src/errors.d.ts.map +1 -1
  73. package/build/src/errors.js +219 -3
  74. package/build/src/errors.js.map +1 -1
  75. package/build/src/freeze.d.ts +100 -20
  76. package/build/src/freeze.d.ts.map +1 -1
  77. package/build/src/freeze.js +159 -25
  78. package/build/src/freeze.js.map +1 -1
  79. package/build/src/manager.d.ts +239 -7
  80. package/build/src/manager.d.ts.map +1 -1
  81. package/build/src/manager.js +1192 -148
  82. package/build/src/manager.js.map +1 -1
  83. package/build/src/models/authz_assignment.d.ts +2 -2
  84. package/build/src/models/authz_assignment.d.ts.map +1 -1
  85. package/build/src/models/authz_deny.d.ts +2 -2
  86. package/build/src/models/authz_deny.d.ts.map +1 -1
  87. package/build/src/models/authz_permission.d.ts +2 -2
  88. package/build/src/models/authz_permission.d.ts.map +1 -1
  89. package/build/src/models/authz_role.d.ts +2 -2
  90. package/build/src/models/authz_role.d.ts.map +1 -1
  91. package/build/src/models/authz_role_permission.d.ts +2 -2
  92. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  93. package/build/src/relation_partition_trigger.d.ts.map +1 -1
  94. package/build/src/relation_partition_trigger.js +11 -3
  95. package/build/src/relation_partition_trigger.js.map +1 -1
  96. package/build/src/relations/define_relations_config.d.ts +51 -0
  97. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  98. package/build/src/relations/define_relations_config.js +68 -1
  99. package/build/src/relations/define_relations_config.js.map +1 -1
  100. package/build/src/relations/manager.d.ts +106 -6
  101. package/build/src/relations/manager.d.ts.map +1 -1
  102. package/build/src/relations/manager.js +368 -30
  103. package/build/src/relations/manager.js.map +1 -1
  104. package/build/src/relations/reconcile.d.ts +81 -6
  105. package/build/src/relations/reconcile.d.ts.map +1 -1
  106. package/build/src/relations/reconcile.js +233 -77
  107. package/build/src/relations/reconcile.js.map +1 -1
  108. package/build/src/scope_outbox.d.ts +54 -0
  109. package/build/src/scope_outbox.d.ts.map +1 -1
  110. package/build/src/scope_outbox.js +274 -33
  111. package/build/src/scope_outbox.js.map +1 -1
  112. package/build/src/shared/backend_guard.d.ts +28 -0
  113. package/build/src/shared/backend_guard.d.ts.map +1 -1
  114. package/build/src/shared/backend_guard.js +52 -0
  115. package/build/src/shared/backend_guard.js.map +1 -1
  116. package/build/src/shared/sql_expiry.d.ts +2 -0
  117. package/build/src/shared/sql_expiry.d.ts.map +1 -1
  118. package/build/src/shared/sql_expiry.js +4 -0
  119. package/build/src/shared/sql_expiry.js.map +1 -1
  120. package/build/src/shared/transaction_guard.d.ts +53 -0
  121. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  122. package/build/src/shared/transaction_guard.js +74 -0
  123. package/build/src/shared/transaction_guard.js.map +1 -0
  124. package/build/src/sql_descendants.d.ts +8 -0
  125. package/build/src/sql_descendants.d.ts.map +1 -1
  126. package/build/src/sql_descendants.js.map +1 -1
  127. package/build/src/testing/contract.d.ts +55 -4
  128. package/build/src/testing/contract.d.ts.map +1 -1
  129. package/build/src/testing/contract.js +244 -9
  130. package/build/src/testing/contract.js.map +1 -1
  131. package/build/src/testing/main.d.ts +6 -4
  132. package/build/src/testing/main.d.ts.map +1 -1
  133. package/build/src/testing/main.js +3 -2
  134. package/build/src/testing/main.js.map +1 -1
  135. package/build/src/testing/migration_contract.d.ts +9 -0
  136. package/build/src/testing/migration_contract.d.ts.map +1 -1
  137. package/build/src/testing/migration_contract.js +5 -2
  138. package/build/src/testing/migration_contract.js.map +1 -1
  139. package/build/src/testing/relations_contract.d.ts +23 -1
  140. package/build/src/testing/relations_contract.d.ts.map +1 -1
  141. package/build/src/testing/relations_contract.js +603 -19
  142. package/build/src/testing/relations_contract.js.map +1 -1
  143. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  144. package/build/src/testing/relations_reconcile_contract.js +49 -1
  145. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  146. package/build/src/testing/scope_outbox_contract.d.ts +98 -0
  147. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  148. package/build/src/testing/scope_outbox_contract.js +677 -0
  149. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  150. package/build/src/traits/has_uuid.d.ts +2 -2
  151. package/build/src/traits/has_uuid.d.ts.map +1 -1
  152. package/build/src/types.d.ts +708 -38
  153. package/build/src/types.d.ts.map +1 -1
  154. package/build/src/types.js +55 -0
  155. package/build/src/types.js.map +1 -1
  156. package/build/stubs/config/authorization.stub +45 -1
  157. package/build/stubs/migration.stub +20 -10
  158. package/package.json +1 -1
@@ -3,14 +3,20 @@
3
3
  * (Fase 4, lote 4-3).
4
4
  *
5
5
  * Los hechos de relación viven en la tabla `authz_relations` (una fila por
6
- * tupla, INSERT/DELETE-ONLY, decisión (c) del dueño): NO hay `expires_at` y no
7
- * hay «renovar = delete+insert» (la caducidad de relaciones —R-15— quedó FUERA
8
- * de la 2.4, default del dueño). La resolución de `check`/`membersOf`/
9
- * `listObjects` —includes de un nivel y usersets de grupos anidados— es una
10
- * **CTE recursiva por dialecto** (mismo patrón que `sql_descendants.ts`), y el
11
- * cruce de particiones lo defiende, además, el trigger `relationPartitionTrigger`
12
- * (defensa en profundidad para el escritor «a mano»; el corte primario es la
13
- * columna `partition_key` en cada consulta).
6
+ * tupla, INSERT/DELETE-ONLY, decisión (c) del dueño). **La caducidad (R-15,
7
+ * 2.4.0-alpha.2)** es la columna `expires_at` (NULL = no caduca), con los
8
+ * MISMOS tres estados de `expiresAt` que `grant` (invariante 10), caducidad
9
+ * ESTRICTA (`expires_at > now`, la que vence ahora ya no cuenta), el MISMO
10
+ * codec por dialecto que `authz_assignments` (`sqlExpiryCodec`, 2.5-B · K2) y
11
+ * el reloj inyectable `withClock` (2.5 · J1). **Renovar la caducidad es
12
+ * delete+insert, nunca UPDATE** (decisión (c) del juez: un solo trigger por
13
+ * evento, menos superficie de divergencia de motor; observable: la fila cambia
14
+ * de `uuid`). La resolución de `check`/`membersOf`/`listObjects` —includes de
15
+ * un nivel y usersets de grupos anidados— es una **CTE recursiva por dialecto**
16
+ * (mismo patrón que `sql_descendants.ts`) que solo recorre hechos VIGENTES, y
17
+ * el cruce de particiones lo defiende, además, el trigger
18
+ * `relationPartitionTrigger` (defensa en profundidad para el escritor «a mano»;
19
+ * el corte primario es la columna `partition_key` en cada consulta).
14
20
  *
15
21
  * Pureza: este módulo vive en `drivers/` y NO importa `openfga` (regla 3 de
16
22
  * `check_purity.mjs`) ni el `manager`. Consume solo el puerto (`../types.js`),
@@ -19,12 +25,16 @@
19
25
  */
20
26
  import db from '@adonisjs/lucid/services/db';
21
27
  import { v7 as uuidv7 } from 'uuid';
22
- import { InvalidIdentityError, UnsupportedDialectError } from '../errors.js';
23
- import { assertScope, assertSubject, scopeKey, scopeSpellings } from '../identity.js';
28
+ import { AuthorizationConfigError, InvalidIdentityError, UnsupportedDialectError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
29
+ import { assertScope, assertSubject, assertExpiresAt, scopeKey, scopeSpellings } from '../identity.js';
24
30
  import { relationPartitionTrigger, relationPartitionTriggerDrops } from '../relation_partition_trigger.js';
25
- import { systemClock } from '../clock.js';
26
- import { guardSql } from '../shared/backend_guard.js';
31
+ import { isClock, systemClock } from '../clock.js';
32
+ import { resolveGrantExpiry, sameInstant } from '../expiry.js';
33
+ import { sqlExpiryCodec } from '../shared/sql_expiry.js';
34
+ import { guardSql, isDeadlock, isUniqueViolation } from '../shared/backend_guard.js';
35
+ import { assertCallerTransaction } from '../shared/transaction_guard.js';
27
36
  import { isRelUserset } from '../types.js';
37
+ import { assertRelationDeclared, assertObjectTypeDeclared } from '../relations/define_relations_config.js';
28
38
  const DEFAULT_TIMEOUT_MS = 5_000;
29
39
  const RELATIONS_TABLE = 'authz_relations';
30
40
  /** Longitud máxima del id de un objeto/holder de relación (columna `varchar(64)`). */
@@ -32,6 +42,15 @@ const RELATION_ID_MAX = 64;
32
42
  /** El tipo BUILT-IN portador de usersets (declarado por el generador, no por el consumidor). */
33
43
  const GROUP_TYPE = 'group';
34
44
  const GROUP_MEMBER = 'member';
45
+ /**
46
+ * L-4b: lo que lleva `subject_relation` un HOLDER. Cadena vacía y no NULL
47
+ * porque el UNIQUE `authz_rel_tuple_uq` incluye la columna y NULL ≠ NULL en
48
+ * los tres motores (con NULL, dos `relate` concurrentes del mismo holder
49
+ * confirmaban DOS filas). Cadena vacía y no un centinela (`'-'`) porque la
50
+ * gramática de una relación (`[a-z0-9._-]{1,50}`) no admite la vacía: ningún
51
+ * userset puede colisionar con ella.
52
+ */
53
+ const HOLDER_RELATION = '';
35
54
  /** Tamaño de página por defecto y tope de `enumerateRelations` (origen de reconcile). */
36
55
  const DEFAULT_ENUMERATE_LIMIT = 100;
37
56
  const MAX_ENUMERATE_LIMIT = 1_000;
@@ -67,32 +86,81 @@ export class DatabaseRelationsDriver {
67
86
  * paginados por la PK, sin filtrar ni derivar (invariante 7 + higiene de
68
87
  * reconcile): la caña la ve el destino tal cual la escribió el origen.
69
88
  * - `listObjectsTruncation: false`: sin tope de servidor, `listObjects` es exhaustiva.
89
+ * - `injectableClock: true` (R-15): `withClock(now)` fija el reloj que decide
90
+ * la caducidad (`expires_at > now`), como el driver `database` de roles.
91
+ * - `transactionalWrites: true` (L-4, default): las cuatro escrituras van
92
+ * por la transacción ABIERTA del llamante (`#writer()`,
93
+ * `assertCallerTransaction` contra la conexión de este driver). Lo que
94
+ * NO viaja por ella: la barrera del freeze y F-05 (autoridad). Un
95
+ * despliegue con pool 1 lo declara `false` (ver las opciones).
70
96
  */
71
- capabilities = Object.freeze({
72
- singleCheckRelations: true,
73
- listObjectsInherited: false,
74
- usersetSubjects: true,
75
- membersOfNative: true,
76
- enumerateRelations: true,
77
- listObjectsTruncation: false,
78
- });
97
+ capabilities;
98
+ /**
99
+ * Con `transactionalWrites: false` declarado por el despliegue, un
100
+ * `{ transaction }` que llegara igual (un llamante que se salta el manager)
101
+ * se rechaza aquí también: no se ignora en silencio.
102
+ */
103
+ #transactionalWrites;
79
104
  #config;
80
105
  #connectionName;
81
106
  #timeoutMs;
82
107
  #now;
83
108
  /** Inyectable para probar el dialecto ajeno sin servidor. */
84
109
  #database;
110
+ #expiryCodec;
85
111
  constructor(config, options = {}, database = db) {
86
112
  this.#config = config;
87
113
  this.#connectionName = options.connection;
88
114
  this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
89
115
  this.#now = options.now ?? systemClock;
90
116
  this.#database = database;
117
+ this.#transactionalWrites = options.transactionalWrites ?? true;
118
+ this.capabilities = Object.freeze({
119
+ singleCheckRelations: true,
120
+ listObjectsInherited: false,
121
+ usersetSubjects: true,
122
+ membersOfNative: true,
123
+ enumerateRelations: true,
124
+ listObjectsTruncation: false,
125
+ injectableClock: true,
126
+ transactionalWrites: this.#transactionalWrites,
127
+ });
128
+ }
129
+ /**
130
+ * Vista de este driver con OTRO reloj de pared (R-15, paridad con
131
+ * `AuthorizationDriver.withClock`): misma conexión, config y deadline; solo
132
+ * cambia el `now` que decide la caducidad. El driver no tiene estado propio
133
+ * (la conexión es del servicio `db`), así que la vista es una instancia nueva.
134
+ */
135
+ withClock(now) {
136
+ if (!isClock(now)) {
137
+ throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
138
+ }
139
+ return new DatabaseRelationsDriver(this.#config, { connection: this.#connectionName, timeoutMs: this.#timeoutMs, now, transactionalWrites: this.#transactionalWrites }, this.#database);
91
140
  }
92
141
  /* ── Infraestructura ──────────────────────────────────────────────────── */
93
142
  #connection() {
94
143
  return this.#database.connection(this.#connectionName);
95
144
  }
145
+ /** El codec de `expires_at` por dialecto (K2): se decide una vez por driver, sin consulta. */
146
+ get #expiry() {
147
+ this.#expiryCodec ??= sqlExpiryCodec(this.#connection());
148
+ return this.#expiryCodec;
149
+ }
150
+ /**
151
+ * El predicado SQL de VIGENCIA (R-15, caducidad ESTRICTA): sin caducidad o
152
+ * con caducidad FUTURA — la que vence en `now` ya no cuenta. Para el SQL
153
+ * crudo de las CTEs (`q` cita el identificador; la binding es `bind(now)`
154
+ * del codec, con UN solo `now` por operación, 2.5-B · K9).
155
+ */
156
+ #activeSql(q, alias) {
157
+ return `(${alias}.${q('expires_at')} IS NULL OR ${alias}.${q('expires_at')} > ?)`;
158
+ }
159
+ /** El mismo predicado, para el query builder. */
160
+ #whereActive(query, at) {
161
+ const bound = this.#expiry.bind(at);
162
+ return query.where((b) => b.whereNull('expires_at').orWhere('expires_at', '>', bound));
163
+ }
96
164
  #dialectMeta() {
97
165
  const connection = this.#connection();
98
166
  const name = connection?.dialect?.name ?? connection?.client?.driverName ?? 'desconocido';
@@ -110,8 +178,67 @@ export class DatabaseRelationsDriver {
110
178
  #raw(operation, text, bindings) {
111
179
  return this.#sql(operation, () => this.#connection().rawQuery(text, bindings));
112
180
  }
181
+ /** La conexión de quien ESCRIBE: la declarada, o la primaria de Lucid (`undefined` con un `db` doble sin ella). */
182
+ #ownerConnection() {
183
+ return this.#connectionName ?? this.#database.primaryConnectionName;
184
+ }
185
+ /**
186
+ * Por dónde escribe `operation` (L-4, el MISMO patrón que `writer()` del
187
+ * driver de roles, L-3): la transacción ABIERTA del llamante si llegó en
188
+ * `options.transaction` —validada contra la conexión de ESTE driver por
189
+ * `assertCallerTransaction`: otra conexión, un `QueryClient` o el `db`
190
+ * entero son 500 `E_AUTHZ_CONFIG` ANTES de tocar nada—, o la conexión del
191
+ * driver. `external` dice cuál de los dos.
192
+ *
193
+ * Solo la ESCRITURA va por ella (y la lectura «¿ya existe?» de `relate`,
194
+ * que forma parte de la misma escritura: un `relate` y su renovación en la
195
+ * misma transacción tienen que verse). La AUTORIDAD no: la barrera del
196
+ * freeze la lee el `RelationsManager` por la conexión del motor, y F-05 y la
197
+ * gramática cortan antes de llegar aquí.
198
+ */
199
+ #writer(operation, options) {
200
+ if (options?.transaction === undefined || options.transaction === null) {
201
+ return { client: this.#connection(), external: false };
202
+ }
203
+ if (!this.#transactionalWrites) {
204
+ throw new UnsupportedOperationError('transactionalWrites', operation, 'database', 'Este despliegue declara transactionalWrites: false en las opciones del driver de relaciones (pool 1): ' +
205
+ '{ transaction } no se admite. Quita la opción (exige pool ≥ 2) o no pases transaction.');
206
+ }
207
+ const trx = assertCallerTransaction(`database-relations.${operation}`, options.transaction, {
208
+ connection: this.#ownerConnection(),
209
+ });
210
+ return { client: trx, external: true };
211
+ }
212
+ /**
213
+ * Una sentencia que falla DENTRO de la transacción del llamante no se
214
+ * absorbe (L-4, paridad con `poisoned()` de roles): en PostgreSQL la
215
+ * transacción ya está ABORTADA (toda sentencia posterior es `25P02` hasta
216
+ * el rollback) y en REPEATABLE READ una relectura no vería al ganador. Así
217
+ * que un choque del UNIQUE de `authz_relations` (dos `relate` del mismo
218
+ * hecho en dos transacciones abiertas) y un **deadlock** (dos transacciones
219
+ * que escriben dos relaciones en orden cruzado: el motor elige una víctima y
220
+ * la deshace —PG `40P01`, MySQL `1213`—) salen como **409
221
+ * `E_AUTHZ_WRITE_CONFLICT`** —«envenena tu transacción: haz rollback y
222
+ * reintenta»—; un deadline vencido sigue siendo el 503
223
+ * `E_AUTHZ_BACKEND_TIMEOUT` que ya clasificó `#sql`, y cualquier otro fallo,
224
+ * su 503. Nunca una segunda sentencia sobre una transacción que puede estar
225
+ * abortada. (Medido por motor en `tests/relations_database.spec.ts`.)
226
+ */
227
+ #poisoned(operation, error) {
228
+ if (isUniqueViolation(error)) {
229
+ throw new WriteConflictError(`database-relations.${operation}: la tupla la escribió otra transacción mientras la tuya estaba abierta ` +
230
+ '(choque del UNIQUE dentro de tu transacción). No se puede absorber ahí dentro: la transacción queda ' +
231
+ 'envenenada (en PostgreSQL, abortada). Haz rollback y reintenta.', { cause: error });
232
+ }
233
+ if (isDeadlock(error)) {
234
+ throw new WriteConflictError(`database-relations.${operation}: el motor detectó un DEADLOCK con otra transacción y eligió la tuya ` +
235
+ 'como víctima (dos transacciones escribiendo las mismas tuplas en orden cruzado). La transacción queda ' +
236
+ 'envenenada (PostgreSQL la aborta; MySQL la deshace entera). Haz rollback y reintenta.', { cause: error });
237
+ }
238
+ throw error;
239
+ }
113
240
  /* ── Validación de identidad (defensa en profundidad) ─────────────────── */
114
- /** El objeto es `{ type, id }` bien formado (el `type` ya pasó F-05 en el manager). */
241
+ /** El objeto es `{ type, id }` bien formado (F-05 —tipo/relación declarados— la aplica `assertRelationDeclared` en las SIETE operaciones: L-0 en `relate`/`unrelate`, alpha.3 en purgas y lecturas). */
115
242
  #assertObject(object) {
116
243
  if (!object || typeof object !== 'object') {
117
244
  throw new InvalidIdentityError(`Objeto de relación inválido: llegó ${typeof object}`);
@@ -125,9 +252,17 @@ export class DatabaseRelationsDriver {
125
252
  `(llegó ${JSON.stringify(value)})`);
126
253
  }
127
254
  }
128
- /** Valida el sujeto (holder o userset) y devuelve sus columnas. */
255
+ /**
256
+ * Valida el sujeto (holder o userset) y devuelve sus columnas. L-4b: el
257
+ * holder lleva `relation: ''` (NUNCA NULL): es lo que hace que el UNIQUE
258
+ * `authz_rel_tuple_uq` lo defienda (`'' = ''`, mientras que NULL ≠ NULL).
259
+ */
129
260
  #subjectColumns(subject, partitionKey) {
130
261
  if (isRelUserset(subject)) {
262
+ // alpha.3 · F-05 también en el USERSET del sujeto (la misma función que
263
+ // el manager): `{ object: { type: 'role_binding' }, relation: 'role' }`
264
+ // como sujeto de una purga/lectura no toca ni nombra un binding real.
265
+ assertRelationDeclared(this.#config, subject.object, subject.relation);
131
266
  this.#assertObject(subject.object);
132
267
  this.#assertId(`la relación del userset '${subject.object.type}'`, subject.relation, RELATION_ID_MAX);
133
268
  // El userset comparte la partición de la tupla (el manager así lo exige);
@@ -135,7 +270,21 @@ export class DatabaseRelationsDriver {
135
270
  return { type: subject.object.type, uuid: subject.object.id, relation: subject.relation, partition: partitionKey };
136
271
  }
137
272
  assertSubject(subject);
138
- return { type: subject.type, uuid: subject.uuid, relation: null, partition: null };
273
+ return { type: subject.type, uuid: subject.uuid, relation: HOLDER_RELATION, partition: null };
274
+ }
275
+ /**
276
+ * El WHERE por `subject_relation`. Para un userset, su relación exacta. Para
277
+ * un HOLDER (`''`), **tolerante a NULL**: una fila vieja con NULL (alpha.1
278
+ * sin el backfill de L-4b) sigue siendo el mismo hecho, y si `unrelate`/
279
+ * `purgeSubject` no la vieran seguiría concediendo tras retirarla
280
+ * (fail-open); el «¿ya existe?» de `relate` también la encuentra, así que
281
+ * renovar su caducidad la sustituye por una fila con `''`.
282
+ */
283
+ #whereSubjectRelation(b, relation) {
284
+ if (relation === HOLDER_RELATION)
285
+ b.where('subject_relation', HOLDER_RELATION).orWhereNull('subject_relation');
286
+ else
287
+ b.where('subject_relation', relation);
139
288
  }
140
289
  /* ── Expansión de includes (un nivel, hacia ABAJO desde la relación) ──── */
141
290
  #expandDown(objectType, relation) {
@@ -155,17 +304,33 @@ export class DatabaseRelationsDriver {
155
304
  return [...out];
156
305
  }
157
306
  /* ── Escrituras ───────────────────────────────────────────────────────── */
158
- async relate(subject, relation, object, partition, _options) {
307
+ async relate(subject, relation, object, partition, options) {
308
+ // L-0 · F-05 en el DRIVER (la MISMA función que el manager): la red para
309
+ // quien entra por `manager.driver()` o por `reconcileRelations`. Corta
310
+ // ANTES de pedir la conexión. En `database` la tabla es propia (inocuo),
311
+ // pero el 422 es el MISMO que en `openfga`, donde el store es compartido.
312
+ assertRelationDeclared(this.#config, object, relation);
159
313
  assertScope(partition);
160
314
  this.#assertObject(object);
161
315
  this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
316
+ // R-15 (defensa en profundidad; el manager ya lo validó): los tres estados.
317
+ assertExpiresAt(options?.expiresAt);
318
+ const requested = options?.expiresAt;
162
319
  const partitionKey = scopeKey(partition);
163
320
  const s = this.#subjectColumns(subject, partitionKey);
164
- // Transacción INTERNA: la atomicidad trigger+insert (el trigger corre en el
165
- // mismo INSERT; el check-then-insert idempotente va dentro de la misma
166
- // transacción). `{trx}` NO se expone en el puerto (decisión (b), diferido).
167
- await this.#sql('relate', () => this.#connection().transaction(async (trx) => {
168
- const existing = await trx
321
+ const codec = this.#expiry;
322
+ const now = this.#now();
323
+ // L-4: por la transacción del llamante si llegó (`#writer`, juzgada AQUÍ,
324
+ // después de F-05 y la gramática y antes de la primera sentencia).
325
+ const { client, external } = this.#writer('relate', options);
326
+ // El check-then-delete-insert idempotente. Con transacción EXTERNA corre
327
+ // tal cual sobre ella (el trigger de partición dispara en ESE INSERT,
328
+ // dentro de la trx del consumidor; NUNCA se abre una interna: la fila
329
+ // confirmaría sola y sobreviviría al rollback del llamante). Sin ella, en
330
+ // una transacción INTERNA de la conexión del driver (la atomicidad
331
+ // trigger+insert de 4-3). Cada sentencia lleva su deadline (`#sql`).
332
+ const write = async (trx) => {
333
+ const existing = await this.#sql('relate.select', () => trx
169
334
  .from(RELATIONS_TABLE)
170
335
  .where('partition_key', partitionKey)
171
336
  .where('object_type', object.type)
@@ -173,11 +338,25 @@ export class DatabaseRelationsDriver {
173
338
  .where('relation', relation)
174
339
  .where('subject_type', s.type)
175
340
  .where('subject_uuid', s.uuid)
176
- .where((b) => (s.relation === null ? b.whereNull('subject_relation') : b.where('subject_relation', s.relation)))
177
- .limit(1);
178
- if (existing.length > 0)
179
- return; // idempotente (invariante 6): no duplica.
180
- await trx.table(RELATIONS_TABLE).insert({
341
+ .where((b) => this.#whereSubjectRelation(b, s.relation))
342
+ .select('uuid', codec.select('expires_at'))
343
+ .limit(1));
344
+ const current = existing[0];
345
+ const previous = current ? codec.fromDb(current.expires_at) : null;
346
+ // Los tres estados (invariante 10): omitido preserva la VIGENTE (una
347
+ // caducada revive sin caducidad), null la quita, Date la fija.
348
+ const expiresAt = resolveGrantExpiry(previous, requested, now);
349
+ if (current) {
350
+ // Idempotente (invariante 6): la misma caducidad no reescribe nada.
351
+ if (sameInstant(previous, expiresAt))
352
+ return;
353
+ // **INSERT/DELETE-ONLY (decisión (c))**: cambiar la caducidad es
354
+ // BORRAR la fila e INSERTAR otra —nunca un UPDATE—; la fila nueva
355
+ // tiene otro `uuid`, que es lo que lo hace observable. Dentro de la
356
+ // trx del llamante, el rollback devuelve la fila (y la caducidad) VIEJA.
357
+ await this.#sql('relate.delete', () => trx.from(RELATIONS_TABLE).where('uuid', current.uuid).delete());
358
+ }
359
+ await this.#sql('relate.insert', () => trx.table(RELATIONS_TABLE).insert({
181
360
  uuid: uuidv7(),
182
361
  partition_key: partitionKey,
183
362
  object_type: object.type,
@@ -187,29 +366,63 @@ export class DatabaseRelationsDriver {
187
366
  subject_uuid: s.uuid,
188
367
  subject_relation: s.relation,
189
368
  subject_partition: s.partition,
190
- created_at: this.#now(),
191
- });
192
- }));
369
+ expires_at: codec.toDb(expiresAt),
370
+ // Sello de auditoría, no decisión (2.5-B · K5): reloj del SISTEMA
371
+ // (con el reloj inyectado en 2099 un TIMESTAMP de MySQL reventaría).
372
+ created_at: systemClock(),
373
+ }));
374
+ };
375
+ if (external) {
376
+ try {
377
+ await write(client);
378
+ }
379
+ catch (error) {
380
+ this.#poisoned('relate', error);
381
+ }
382
+ return;
383
+ }
384
+ await this.#sql('relate', () => client.transaction(write));
193
385
  }
194
- async unrelate(subject, relation, object, partition, _options) {
386
+ async unrelate(subject, relation, object, partition, options) {
387
+ // L-0 · F-05 también al RETIRAR (paridad con `openfga`).
388
+ assertRelationDeclared(this.#config, object, relation);
195
389
  assertScope(partition);
196
390
  this.#assertObject(object);
197
391
  this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
198
392
  const partitionKey = scopeKey(partition);
199
393
  const s = this.#subjectColumns(subject, partitionKey);
200
- await this.#sql('unrelate', () => this.#connection()
201
- .from(RELATIONS_TABLE)
202
- .where('partition_key', partitionKey)
203
- .where('object_type', object.type)
204
- .where('object_uuid', object.id)
205
- .where('relation', relation)
206
- .where('subject_type', s.type)
207
- .where('subject_uuid', s.uuid)
208
- .where((b) => (s.relation === null ? b.whereNull('subject_relation') : b.where('subject_relation', s.relation)))
209
- .delete());
394
+ const { client, external } = this.#writer('unrelate', options);
395
+ try {
396
+ await this.#sql('unrelate', () => client
397
+ .from(RELATIONS_TABLE)
398
+ .where('partition_key', partitionKey)
399
+ .where('object_type', object.type)
400
+ .where('object_uuid', object.id)
401
+ .where('relation', relation)
402
+ .where('subject_type', s.type)
403
+ .where('subject_uuid', s.uuid)
404
+ .where((b) => this.#whereSubjectRelation(b, s.relation))
405
+ .delete());
406
+ }
407
+ catch (error) {
408
+ if (external)
409
+ this.#poisoned('unrelate', error);
410
+ throw error;
411
+ }
210
412
  }
211
413
  /* ── Lecturas ─────────────────────────────────────────────────────────── */
414
+ /**
415
+ * **F-05 en las LECTURAS y las PURGAS del driver** (2.4.0-alpha.3, cierre
416
+ * del 🔴 1 / 🟠 2 del auditor; la MISMA función y el mismo orden que L-0 en
417
+ * `relate`/`unrelate`): PRIMERA línea, antes de pedir la conexión. En
418
+ * `database` la tabla es propia (inocuo), pero el 422 es el MISMO que en
419
+ * `openfga`, donde `purgeObject(role_binding)` borraba el binding real y
420
+ * `listSubjects(assignee, role_binding)` enumeraba sus asignados por el
421
+ * camino de `manager.driver()`. `check`/`listSubjects` validan el par;
422
+ * `listObjects`/`purgeObject` el tipo; `purgeSubject` el userset.
423
+ */
212
424
  async check(subject, relation, object, partition) {
425
+ assertRelationDeclared(this.#config, object, relation);
213
426
  assertScope(partition);
214
427
  this.#assertObject(object);
215
428
  const partitionKey = scopeKey(partition);
@@ -217,19 +430,21 @@ export class DatabaseRelationsDriver {
217
430
  const relations = this.#expandDown(object.type, relation);
218
431
  const meta = this.#dialectMeta();
219
432
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
220
- const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey);
433
+ const at = this.#expiry.bind(this.#now());
434
+ const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
221
435
  const relPlaceholders = relations.map(() => '?').join(', ');
222
436
  const sql = cte +
223
437
  ` SELECT ${meta.hint}1 FROM ${q(RELATIONS_TABLE)} r ` +
224
438
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
225
439
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
226
440
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
227
- `AND r.${q('relation')} IN (${relPlaceholders}) LIMIT 1`;
228
- const bindings = [...cteBindings, partitionKey, object.type, object.id, ...relations];
441
+ `AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')} LIMIT 1`;
442
+ const bindings = [...cteBindings, partitionKey, object.type, object.id, ...relations, at];
229
443
  const rows = rowsOf(await this.#raw('check', sql, bindings));
230
444
  return rows.length > 0;
231
445
  }
232
446
  async listObjects(subject, relation, objectType, partition, _page) {
447
+ assertRelationDeclared(this.#config, { type: objectType }, relation);
233
448
  assertScope(partition);
234
449
  this.#assertId('el tipo de objeto', objectType, RELATION_ID_MAX);
235
450
  const partitionKey = scopeKey(partition);
@@ -237,15 +452,16 @@ export class DatabaseRelationsDriver {
237
452
  const relations = this.#expandDown(objectType, relation);
238
453
  const meta = this.#dialectMeta();
239
454
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
240
- const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey);
455
+ const at = this.#expiry.bind(this.#now());
456
+ const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
241
457
  const relPlaceholders = relations.map(() => '?').join(', ');
242
458
  const sql = cte +
243
459
  ` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
244
460
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
245
461
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
246
462
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? ` +
247
- `AND r.${q('relation')} IN (${relPlaceholders})`;
248
- const bindings = [...cteBindings, partitionKey, objectType, ...relations];
463
+ `AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
464
+ const bindings = [...cteBindings, partitionKey, objectType, ...relations, at];
249
465
  const rows = rowsOf(await this.#raw('listObjects', sql, bindings));
250
466
  const objects = rows
251
467
  .map((row) => ({ type: objectType, id: String(row.object_uuid) }))
@@ -253,19 +469,20 @@ export class DatabaseRelationsDriver {
253
469
  return { objects };
254
470
  }
255
471
  async listSubjects(relation, object, partition, _page) {
472
+ assertRelationDeclared(this.#config, object, relation);
256
473
  assertScope(partition);
257
474
  this.#assertObject(object);
258
475
  const partitionKey = scopeKey(partition);
259
476
  // DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
260
477
  // `membersOf`) ni derivado por includes. `usersetSubjects: true` ⇒ los
261
- // usersets (`group#member`) salen junto con los holders.
262
- const rows = await this.#sql('listSubjects', () => this.#connection()
478
+ // usersets (`group#member`) salen junto con los holders. Solo VIGENTES (R-15).
479
+ const now = this.#now();
480
+ const rows = await this.#sql('listSubjects', () => this.#whereActive(this.#connection()
263
481
  .from(RELATIONS_TABLE)
264
482
  .where('partition_key', partitionKey)
265
483
  .where('object_type', object.type)
266
484
  .where('object_uuid', object.id)
267
- .where('relation', relation)
268
- .select('subject_type', 'subject_uuid', 'subject_relation'));
485
+ .where('relation', relation), now).select('subject_type', 'subject_uuid', 'subject_relation'));
269
486
  const subjects = rows.map((row) => this.#rowToSubject(row));
270
487
  return { subjects };
271
488
  }
@@ -277,6 +494,9 @@ export class DatabaseRelationsDriver {
277
494
  * los holders de g1 y `listSubjects(member, g2)` solo el userset `g1#member`.
278
495
  */
279
496
  async membersOf(object, relation, partition, _page) {
497
+ // Cierre-2 · 🟡 3: F-05 (par, ESTRICTA) también en `membersOf`, la octava
498
+ // operación del puerto; antes solo `#assertObject` (gramática).
499
+ assertRelationDeclared(this.#config, object, relation);
280
500
  assertScope(partition);
281
501
  this.#assertObject(object);
282
502
  const partitionKey = scopeKey(partition);
@@ -284,68 +504,96 @@ export class DatabaseRelationsDriver {
284
504
  const meta = this.#dialectMeta();
285
505
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
286
506
  const relPlaceholders = relations.map(() => '?').join(', ');
507
+ const at = this.#expiry.bind(this.#now());
508
+ const active = this.#activeSql(q, 'r');
287
509
  // `grp`: los grupos alcanzables por usersets desde (object, RS) —directos y
288
510
  // anidados—. Ancla de UN solo SELECT (MySQL no mezcla UNION ALL con la
289
511
  // recursión): los usersets directos del objeto para cualquier rel de RS.
512
+ // Solo hechos VIGENTES en los cuatro tramos (R-15): una membresía caducada
513
+ // no lleva a nadie dentro.
290
514
  const sql = `WITH RECURSIVE grp(g_uuid) AS ( ` +
291
515
  `SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
292
516
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
293
517
  `AND r.${q('relation')} IN (${relPlaceholders}) AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
294
- `AND r.${q('subject_relation')} = '${GROUP_MEMBER}' ` +
518
+ `AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
295
519
  `UNION ` +
296
520
  `SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
297
521
  `JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
298
522
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
299
523
  `AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
300
- `AND r.${q('subject_relation')} = '${GROUP_MEMBER}' ` +
524
+ `AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
301
525
  `) ` +
302
526
  // Holders directos del objeto para RS, UNION holders directos de cualquier grupo de `grp`.
303
527
  `SELECT ${meta.hint}${q('subject_type')} AS ${q('subject_type')}, ${q('subject_uuid')} AS ${q('subject_uuid')} ` +
304
528
  `FROM ${q(RELATIONS_TABLE)} r ` +
305
529
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
306
- `AND r.${q('relation')} IN (${relPlaceholders}) AND r.${q('subject_relation')} IS NULL ` +
530
+ `AND r.${q('relation')} IN (${relPlaceholders}) AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active} ` +
307
531
  `UNION ` +
308
532
  `SELECT r.${q('subject_type')}, r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
309
533
  `JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
310
534
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
311
- `AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_relation')} IS NULL`;
535
+ `AND r.${q('relation')} = '${GROUP_MEMBER}' AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active}`;
312
536
  const bindings = [
313
537
  // grp ancla
314
- partitionKey, object.type, object.id, ...relations,
538
+ partitionKey, object.type, object.id, ...relations, at,
315
539
  // grp recursiva
316
- partitionKey,
540
+ partitionKey, at,
317
541
  // holders directos del objeto
318
- partitionKey, object.type, object.id, ...relations,
542
+ partitionKey, object.type, object.id, ...relations, at,
319
543
  // holders de los grupos
320
- partitionKey,
544
+ partitionKey, at,
321
545
  ];
322
546
  const rows = rowsOf(await this.#raw('membersOf', sql, bindings));
323
547
  const subjects = rows.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
324
548
  return { subjects };
325
549
  }
326
550
  /* ── Purga (invariante 11): el DELETE demuestra el cero ───────────────── */
327
- async purgeObject(object, partition) {
551
+ async purgeObject(object, partition, options) {
552
+ // alpha.3 · F-05 (solo el TIPO: una purga no nombra relación), PRIMERA línea.
553
+ // Cierre-2: la función de TIPO, no la estricta con relación opcional.
554
+ assertObjectTypeDeclared(this.#config, object);
328
555
  assertScope(partition);
329
556
  this.#assertObject(object);
330
557
  const partitionKeys = this.#partitionSpellings(partition);
331
- await this.#sql('purgeObject', () => this.#connection()
332
- .from(RELATIONS_TABLE)
333
- .whereIn('partition_key', partitionKeys)
334
- .where('object_type', object.type)
335
- .where('object_uuid', object.id)
336
- .delete());
558
+ // L-4: por la transacción del llamante si llegó — la purga borra y
559
+ // REVIERTE con ella (tras un rollback todo lo purgado está de vuelta).
560
+ const { client, external } = this.#writer('purgeObject', options);
561
+ try {
562
+ await this.#sql('purgeObject', () => client
563
+ .from(RELATIONS_TABLE)
564
+ .whereIn('partition_key', partitionKeys)
565
+ .where('object_type', object.type)
566
+ .where('object_uuid', object.id)
567
+ .delete());
568
+ }
569
+ catch (error) {
570
+ if (external)
571
+ this.#poisoned('purgeObject', error);
572
+ throw error;
573
+ }
337
574
  }
338
- async purgeSubject(subject, partition) {
575
+ async purgeSubject(subject, partition, options) {
576
+ // alpha.3 · F-05 del USERSET (una purga por sujeto no nombra objeto), PRIMERA línea.
577
+ if (isRelUserset(subject))
578
+ assertRelationDeclared(this.#config, subject.object, subject.relation);
339
579
  assertScope(partition);
340
580
  const partitionKeys = this.#partitionSpellings(partition);
341
581
  const s = this.#subjectColumns(subject, scopeKey(partition));
342
- await this.#sql('purgeSubject', () => this.#connection()
343
- .from(RELATIONS_TABLE)
344
- .whereIn('partition_key', partitionKeys)
345
- .where('subject_type', s.type)
346
- .where('subject_uuid', s.uuid)
347
- .where((b) => (s.relation === null ? b.whereNull('subject_relation') : b.where('subject_relation', s.relation)))
348
- .delete());
582
+ const { client, external } = this.#writer('purgeSubject', options);
583
+ try {
584
+ await this.#sql('purgeSubject', () => client
585
+ .from(RELATIONS_TABLE)
586
+ .whereIn('partition_key', partitionKeys)
587
+ .where('subject_type', s.type)
588
+ .where('subject_uuid', s.uuid)
589
+ .where((b) => this.#whereSubjectRelation(b, s.relation))
590
+ .delete());
591
+ }
592
+ catch (error) {
593
+ if (external)
594
+ this.#poisoned('purgeSubject', error);
595
+ throw error;
596
+ }
349
597
  }
350
598
  /* ── ORIGEN de reconcile (4-5): enumera los hechos directos ───────────── */
351
599
  /**
@@ -354,8 +602,9 @@ export class DatabaseRelationsDriver {
354
602
  * paginados por la PK (`uuid`, cursor que AVANZA), sin derivar por
355
603
  * includes/usersets (el modelo del destino lo recompone): el destino recibe
356
604
  * los hechos tal cual y decide qué escribe (invariante 7 + la higiene de
357
- * reconcile — una caducada tendría que LLEGAR; aquí no hay caducidad, R-15
358
- * quedó fuera). Barre las DOS ortografías del uuid de partición (🟡2,
605
+ * reconcile — **la caducada LLEGA con su `expiresAt`**, R-15, para contarse
606
+ * en `skipped`; filtrarla aquí la haría desaparecer sin rastro). Barre las
607
+ * DOS ortografías del uuid de partición (🟡2,
359
608
  * coherente con `purge*`): un origen que las funde no puede dejar hechos del
360
609
  * alias sin migrar.
361
610
  *
@@ -370,11 +619,54 @@ export class DatabaseRelationsDriver {
370
619
  */
371
620
  async enumerateRelations(partition, page) {
372
621
  assertScope(partition);
373
- const partitionKeys = this.#partitionSpellings(partition);
622
+ const wanted = new Map();
623
+ for (const key of this.#partitionSpellings(partition))
624
+ wanted.set(key, partition);
625
+ return this.#enumerateRows('enumerateRelations', wanted, page);
626
+ }
627
+ /**
628
+ * **`enumerateRelationsMany` — K particiones en UNA consulta** (2.4.0-alpha.8
629
+ * · C1; panel relay 2, analista §8, juez 7.1). El mismo SELECT que
630
+ * `enumerateRelations` con `partition_key IN (todas las ortografías de
631
+ * todas las particiones)`, orden total por `uuid` y el mismo cursor
632
+ * (estable: reanudar con él ve cada tupla exactamente una vez, sea cual
633
+ * sea K). Cada tupla viene con la partición PEDIDA a la que pertenece: la
634
+ * primera referencia de cada clave es la que viaja en `tuple.partition`
635
+ * (dos ortografías del mismo uuid son UNA partición aquí, como en
636
+ * `enumerateRelations`, que ya barre las dos). Sin particiones es 500 con
637
+ * cero consultas (paridad con `openfga`: un barrido para nadie no es un
638
+ * éxito vacío). Hasta alpha.7 el manager caía a su respaldo —K consultas y
639
+ * un cursor que perdía tuplas al reanudar (C2)—; sin capacidad nueva: con
640
+ * los dos drivers publicados nativos y el respaldo ruidoso, no hay nada que
641
+ * declarar.
642
+ */
643
+ async enumerateRelationsMany(partitions, page) {
644
+ if (!Array.isArray(partitions) || partitions.length === 0) {
645
+ throw new AuthorizationConfigError('enumerateRelationsMany: sin particiones no hay nada que enumerar (y una consulta para nadie no es un éxito vacío). ' +
646
+ 'Pasa al menos una partición.');
647
+ }
648
+ const wanted = new Map();
649
+ for (const partition of partitions) {
650
+ assertScope(partition);
651
+ for (const key of this.#partitionSpellings(partition))
652
+ if (!wanted.has(key))
653
+ wanted.set(key, partition);
654
+ }
655
+ return this.#enumerateRows('enumerateRelationsMany', wanted, page);
656
+ }
657
+ /**
658
+ * La consulta de `enumerateRelations`/`enumerateRelationsMany`: los hechos
659
+ * directos de las claves de partición pedidas, filtrados por tipos
660
+ * declarados, ordenados por `uuid` y paginados (`limit + 1` para saber si
661
+ * hay más; el cursor es el último `uuid` de la página). `wanted` mapea cada
662
+ * clave (ortografía) a la partición PEDIDA que viaja en la tupla.
663
+ */
664
+ async #enumerateRows(operation, wanted, page) {
665
+ const partitionKeys = [...wanted.keys()];
374
666
  const declaredTypes = [GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
375
667
  const limit = Math.max(1, Math.min(page?.limit ?? DEFAULT_ENUMERATE_LIMIT, MAX_ENUMERATE_LIMIT));
376
668
  const after = page?.after;
377
- const rows = await this.#sql('enumerateRelations', () => {
669
+ const rows = await this.#sql(operation, () => {
378
670
  let q = this.#connection()
379
671
  .from(RELATIONS_TABLE)
380
672
  .whereIn('partition_key', partitionKeys)
@@ -383,7 +675,7 @@ export class DatabaseRelationsDriver {
383
675
  .limit(limit + 1);
384
676
  if (after)
385
677
  q = q.where('uuid', '>', after);
386
- return q.select('uuid', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation');
678
+ return q.select('uuid', 'partition_key', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation', this.#expiry.select('expires_at'));
387
679
  });
388
680
  const hasMore = rows.length > limit;
389
681
  const pageRows = hasMore ? rows.slice(0, limit) : rows;
@@ -391,8 +683,9 @@ export class DatabaseRelationsDriver {
391
683
  subject: this.#rowToSubject(row),
392
684
  relation: String(row.relation),
393
685
  object: { type: String(row.object_type), id: String(row.object_uuid) },
394
- // La partición canónica pedida: el barrido de ortografías ya la unificó.
395
- partition,
686
+ // La partición canónica PEDIDA: el barrido de ortografías ya la unificó.
687
+ partition: wanted.get(String(row.partition_key)),
688
+ expiresAt: this.#expiry.fromDb(row.expires_at),
396
689
  }));
397
690
  const cursor = hasMore ? String(pageRows[pageRows.length - 1].uuid) : undefined;
398
691
  return cursor ? { tuples, cursor } : { tuples };
@@ -417,8 +710,12 @@ export class DatabaseRelationsDriver {
417
710
  * de esas identidades. Devuelve el `WITH RECURSIVE …` (sin el SELECT final) y
418
711
  * sus bindings.
419
712
  */
420
- #principalCte(meta, q, s, partitionKey) {
421
- const baseRel = s.relation ?? '';
713
+ #principalCte(meta, q, s, partitionKey, at) {
714
+ // L-4b: el holder ya viene con `''`; el `COALESCE(subject_relation, '')`
715
+ // del JOIN es la tolerancia a una fila vieja con NULL.
716
+ const baseRel = s.relation;
717
+ // Solo membresías VIGENTES suben por la recursión (R-15): una `member`
718
+ // caducada no convierte al sujeto en miembro de nada.
422
719
  const cte = `WITH RECURSIVE principal(p_type, p_uuid, p_rel) AS ( ` +
423
720
  `SELECT ${meta.text('?')}, ${meta.text('?')}, ${meta.text('?')} ` +
424
721
  `UNION ` +
@@ -427,11 +724,12 @@ export class DatabaseRelationsDriver {
427
724
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
428
725
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
429
726
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
430
- `AND r.${q('relation')} = '${GROUP_MEMBER}' )`;
431
- return { cte, bindings: [s.type, s.uuid, baseRel, partitionKey] };
727
+ `AND r.${q('relation')} = '${GROUP_MEMBER}' AND ${this.#activeSql(q, 'r')} )`;
728
+ return { cte, bindings: [s.type, s.uuid, baseRel, partitionKey, at] };
432
729
  }
730
+ /** `''` (L-4b) o NULL (fila vieja sin backfill) ⇒ holder; con valor ⇒ userset. */
433
731
  #rowToSubject(row) {
434
- if (row.subject_relation === null || row.subject_relation === undefined) {
732
+ if (row.subject_relation === null || row.subject_relation === undefined || row.subject_relation === HOLDER_RELATION) {
435
733
  return { type: String(row.subject_type), uuid: String(row.subject_uuid) };
436
734
  }
437
735
  return { object: { type: String(row.subject_type), id: String(row.subject_uuid) }, relation: String(row.subject_relation) };