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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +248 -18
  2. package/build/commands/authz_relations_reconcile.d.ts +25 -1
  3. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  4. package/build/commands/authz_relations_reconcile.js +94 -6
  5. package/build/commands/authz_relations_reconcile.js.map +1 -1
  6. package/build/index.d.ts +12 -1
  7. package/build/index.d.ts.map +1 -1
  8. package/build/index.js +10 -0
  9. package/build/index.js.map +1 -1
  10. package/build/providers/authz_provider.d.ts.map +1 -1
  11. package/build/providers/authz_provider.js +7 -0
  12. package/build/providers/authz_provider.js.map +1 -1
  13. package/build/src/define_config.d.ts +36 -0
  14. package/build/src/define_config.d.ts.map +1 -1
  15. package/build/src/define_config.js.map +1 -1
  16. package/build/src/drivers/database_driver.d.ts +53 -14
  17. package/build/src/drivers/database_driver.d.ts.map +1 -1
  18. package/build/src/drivers/database_driver.js +104 -32
  19. package/build/src/drivers/database_driver.js.map +1 -1
  20. package/build/src/drivers/database_relations_driver.d.ts +47 -9
  21. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  22. package/build/src/drivers/database_relations_driver.js +318 -89
  23. package/build/src/drivers/database_relations_driver.js.map +1 -1
  24. package/build/src/drivers/openfga_driver.d.ts +17 -4
  25. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  26. package/build/src/drivers/openfga_driver.js +38 -3
  27. package/build/src/drivers/openfga_driver.js.map +1 -1
  28. package/build/src/drivers/openfga_facts.d.ts +15 -0
  29. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  30. package/build/src/drivers/openfga_facts.js +25 -2
  31. package/build/src/drivers/openfga_facts.js.map +1 -1
  32. package/build/src/drivers/openfga_relations_driver.d.ts +27 -6
  33. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  34. package/build/src/drivers/openfga_relations_driver.js +140 -16
  35. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  36. package/build/src/errors.d.ts +35 -3
  37. package/build/src/errors.d.ts.map +1 -1
  38. package/build/src/errors.js +61 -3
  39. package/build/src/errors.js.map +1 -1
  40. package/build/src/freeze.d.ts +37 -16
  41. package/build/src/freeze.d.ts.map +1 -1
  42. package/build/src/freeze.js +53 -8
  43. package/build/src/freeze.js.map +1 -1
  44. package/build/src/manager.d.ts +16 -1
  45. package/build/src/manager.d.ts.map +1 -1
  46. package/build/src/manager.js +116 -25
  47. package/build/src/manager.js.map +1 -1
  48. package/build/src/models/authz_assignment.d.ts +2 -2
  49. package/build/src/models/authz_assignment.d.ts.map +1 -1
  50. package/build/src/models/authz_deny.d.ts +2 -2
  51. package/build/src/models/authz_deny.d.ts.map +1 -1
  52. package/build/src/models/authz_permission.d.ts +2 -2
  53. package/build/src/models/authz_permission.d.ts.map +1 -1
  54. package/build/src/models/authz_role.d.ts +2 -2
  55. package/build/src/models/authz_role.d.ts.map +1 -1
  56. package/build/src/models/authz_role_permission.d.ts +2 -2
  57. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  58. package/build/src/relation_partition_trigger.d.ts.map +1 -1
  59. package/build/src/relation_partition_trigger.js +11 -3
  60. package/build/src/relation_partition_trigger.js.map +1 -1
  61. package/build/src/relations/define_relations_config.d.ts +17 -0
  62. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  63. package/build/src/relations/define_relations_config.js +30 -1
  64. package/build/src/relations/define_relations_config.js.map +1 -1
  65. package/build/src/relations/manager.d.ts +25 -3
  66. package/build/src/relations/manager.d.ts.map +1 -1
  67. package/build/src/relations/manager.js +76 -19
  68. package/build/src/relations/manager.js.map +1 -1
  69. package/build/src/relations/reconcile.d.ts +53 -4
  70. package/build/src/relations/reconcile.d.ts.map +1 -1
  71. package/build/src/relations/reconcile.js +92 -30
  72. package/build/src/relations/reconcile.js.map +1 -1
  73. package/build/src/scope_outbox.d.ts.map +1 -1
  74. package/build/src/scope_outbox.js +17 -10
  75. package/build/src/scope_outbox.js.map +1 -1
  76. package/build/src/shared/backend_guard.d.ts +28 -0
  77. package/build/src/shared/backend_guard.d.ts.map +1 -1
  78. package/build/src/shared/backend_guard.js +52 -0
  79. package/build/src/shared/backend_guard.js.map +1 -1
  80. package/build/src/shared/transaction_guard.d.ts +50 -0
  81. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  82. package/build/src/shared/transaction_guard.js +60 -0
  83. package/build/src/shared/transaction_guard.js.map +1 -0
  84. package/build/src/testing/contract.d.ts +55 -4
  85. package/build/src/testing/contract.d.ts.map +1 -1
  86. package/build/src/testing/contract.js +240 -8
  87. package/build/src/testing/contract.js.map +1 -1
  88. package/build/src/testing/main.d.ts +4 -4
  89. package/build/src/testing/main.d.ts.map +1 -1
  90. package/build/src/testing/main.js +2 -2
  91. package/build/src/testing/main.js.map +1 -1
  92. package/build/src/testing/relations_contract.d.ts +23 -1
  93. package/build/src/testing/relations_contract.d.ts.map +1 -1
  94. package/build/src/testing/relations_contract.js +432 -17
  95. package/build/src/testing/relations_contract.js.map +1 -1
  96. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  97. package/build/src/testing/relations_reconcile_contract.js +49 -1
  98. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  99. package/build/src/traits/has_uuid.d.ts +2 -2
  100. package/build/src/traits/has_uuid.d.ts.map +1 -1
  101. package/build/src/types.d.ts +197 -10
  102. package/build/src/types.d.ts.map +1 -1
  103. package/build/src/types.js.map +1 -1
  104. package/build/stubs/migration.stub +20 -10
  105. 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 } 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 `relate`/`unrelate`, L-0). */
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,7 +252,11 @@ 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)) {
131
262
  this.#assertObject(subject.object);
@@ -135,7 +266,21 @@ export class DatabaseRelationsDriver {
135
266
  return { type: subject.object.type, uuid: subject.object.id, relation: subject.relation, partition: partitionKey };
136
267
  }
137
268
  assertSubject(subject);
138
- return { type: subject.type, uuid: subject.uuid, relation: null, partition: null };
269
+ return { type: subject.type, uuid: subject.uuid, relation: HOLDER_RELATION, partition: null };
270
+ }
271
+ /**
272
+ * El WHERE por `subject_relation`. Para un userset, su relación exacta. Para
273
+ * un HOLDER (`''`), **tolerante a NULL**: una fila vieja con NULL (alpha.1
274
+ * sin el backfill de L-4b) sigue siendo el mismo hecho, y si `unrelate`/
275
+ * `purgeSubject` no la vieran seguiría concediendo tras retirarla
276
+ * (fail-open); el «¿ya existe?» de `relate` también la encuentra, así que
277
+ * renovar su caducidad la sustituye por una fila con `''`.
278
+ */
279
+ #whereSubjectRelation(b, relation) {
280
+ if (relation === HOLDER_RELATION)
281
+ b.where('subject_relation', HOLDER_RELATION).orWhereNull('subject_relation');
282
+ else
283
+ b.where('subject_relation', relation);
139
284
  }
140
285
  /* ── Expansión de includes (un nivel, hacia ABAJO desde la relación) ──── */
141
286
  #expandDown(objectType, relation) {
@@ -155,17 +300,33 @@ export class DatabaseRelationsDriver {
155
300
  return [...out];
156
301
  }
157
302
  /* ── Escrituras ───────────────────────────────────────────────────────── */
158
- async relate(subject, relation, object, partition, _options) {
303
+ async relate(subject, relation, object, partition, options) {
304
+ // L-0 · F-05 en el DRIVER (la MISMA función que el manager): la red para
305
+ // quien entra por `manager.driver()` o por `reconcileRelations`. Corta
306
+ // ANTES de pedir la conexión. En `database` la tabla es propia (inocuo),
307
+ // pero el 422 es el MISMO que en `openfga`, donde el store es compartido.
308
+ assertRelationDeclared(this.#config, object, relation);
159
309
  assertScope(partition);
160
310
  this.#assertObject(object);
161
311
  this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
312
+ // R-15 (defensa en profundidad; el manager ya lo validó): los tres estados.
313
+ assertExpiresAt(options?.expiresAt);
314
+ const requested = options?.expiresAt;
162
315
  const partitionKey = scopeKey(partition);
163
316
  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
317
+ const codec = this.#expiry;
318
+ const now = this.#now();
319
+ // L-4: por la transacción del llamante si llegó (`#writer`, juzgada AQUÍ,
320
+ // después de F-05 y la gramática y antes de la primera sentencia).
321
+ const { client, external } = this.#writer('relate', options);
322
+ // El check-then-delete-insert idempotente. Con transacción EXTERNA corre
323
+ // tal cual sobre ella (el trigger de partición dispara en ESE INSERT,
324
+ // dentro de la trx del consumidor; NUNCA se abre una interna: la fila
325
+ // confirmaría sola y sobreviviría al rollback del llamante). Sin ella, en
326
+ // una transacción INTERNA de la conexión del driver (la atomicidad
327
+ // trigger+insert de 4-3). Cada sentencia lleva su deadline (`#sql`).
328
+ const write = async (trx) => {
329
+ const existing = await this.#sql('relate.select', () => trx
169
330
  .from(RELATIONS_TABLE)
170
331
  .where('partition_key', partitionKey)
171
332
  .where('object_type', object.type)
@@ -173,11 +334,25 @@ export class DatabaseRelationsDriver {
173
334
  .where('relation', relation)
174
335
  .where('subject_type', s.type)
175
336
  .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({
337
+ .where((b) => this.#whereSubjectRelation(b, s.relation))
338
+ .select('uuid', codec.select('expires_at'))
339
+ .limit(1));
340
+ const current = existing[0];
341
+ const previous = current ? codec.fromDb(current.expires_at) : null;
342
+ // Los tres estados (invariante 10): omitido preserva la VIGENTE (una
343
+ // caducada revive sin caducidad), null la quita, Date la fija.
344
+ const expiresAt = resolveGrantExpiry(previous, requested, now);
345
+ if (current) {
346
+ // Idempotente (invariante 6): la misma caducidad no reescribe nada.
347
+ if (sameInstant(previous, expiresAt))
348
+ return;
349
+ // **INSERT/DELETE-ONLY (decisión (c))**: cambiar la caducidad es
350
+ // BORRAR la fila e INSERTAR otra —nunca un UPDATE—; la fila nueva
351
+ // tiene otro `uuid`, que es lo que lo hace observable. Dentro de la
352
+ // trx del llamante, el rollback devuelve la fila (y la caducidad) VIEJA.
353
+ await this.#sql('relate.delete', () => trx.from(RELATIONS_TABLE).where('uuid', current.uuid).delete());
354
+ }
355
+ await this.#sql('relate.insert', () => trx.table(RELATIONS_TABLE).insert({
181
356
  uuid: uuidv7(),
182
357
  partition_key: partitionKey,
183
358
  object_type: object.type,
@@ -187,26 +362,49 @@ export class DatabaseRelationsDriver {
187
362
  subject_uuid: s.uuid,
188
363
  subject_relation: s.relation,
189
364
  subject_partition: s.partition,
190
- created_at: this.#now(),
191
- });
192
- }));
365
+ expires_at: codec.toDb(expiresAt),
366
+ // Sello de auditoría, no decisión (2.5-B · K5): reloj del SISTEMA
367
+ // (con el reloj inyectado en 2099 un TIMESTAMP de MySQL reventaría).
368
+ created_at: systemClock(),
369
+ }));
370
+ };
371
+ if (external) {
372
+ try {
373
+ await write(client);
374
+ }
375
+ catch (error) {
376
+ this.#poisoned('relate', error);
377
+ }
378
+ return;
379
+ }
380
+ await this.#sql('relate', () => client.transaction(write));
193
381
  }
194
- async unrelate(subject, relation, object, partition, _options) {
382
+ async unrelate(subject, relation, object, partition, options) {
383
+ // L-0 · F-05 también al RETIRAR (paridad con `openfga`).
384
+ assertRelationDeclared(this.#config, object, relation);
195
385
  assertScope(partition);
196
386
  this.#assertObject(object);
197
387
  this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
198
388
  const partitionKey = scopeKey(partition);
199
389
  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());
390
+ const { client, external } = this.#writer('unrelate', options);
391
+ try {
392
+ await this.#sql('unrelate', () => client
393
+ .from(RELATIONS_TABLE)
394
+ .where('partition_key', partitionKey)
395
+ .where('object_type', object.type)
396
+ .where('object_uuid', object.id)
397
+ .where('relation', relation)
398
+ .where('subject_type', s.type)
399
+ .where('subject_uuid', s.uuid)
400
+ .where((b) => this.#whereSubjectRelation(b, s.relation))
401
+ .delete());
402
+ }
403
+ catch (error) {
404
+ if (external)
405
+ this.#poisoned('unrelate', error);
406
+ throw error;
407
+ }
210
408
  }
211
409
  /* ── Lecturas ─────────────────────────────────────────────────────────── */
212
410
  async check(subject, relation, object, partition) {
@@ -217,15 +415,16 @@ export class DatabaseRelationsDriver {
217
415
  const relations = this.#expandDown(object.type, relation);
218
416
  const meta = this.#dialectMeta();
219
417
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
220
- const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey);
418
+ const at = this.#expiry.bind(this.#now());
419
+ const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
221
420
  const relPlaceholders = relations.map(() => '?').join(', ');
222
421
  const sql = cte +
223
422
  ` SELECT ${meta.hint}1 FROM ${q(RELATIONS_TABLE)} r ` +
224
423
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
225
424
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
226
425
  `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];
426
+ `AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')} LIMIT 1`;
427
+ const bindings = [...cteBindings, partitionKey, object.type, object.id, ...relations, at];
229
428
  const rows = rowsOf(await this.#raw('check', sql, bindings));
230
429
  return rows.length > 0;
231
430
  }
@@ -237,15 +436,16 @@ export class DatabaseRelationsDriver {
237
436
  const relations = this.#expandDown(objectType, relation);
238
437
  const meta = this.#dialectMeta();
239
438
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
240
- const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey);
439
+ const at = this.#expiry.bind(this.#now());
440
+ const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
241
441
  const relPlaceholders = relations.map(() => '?').join(', ');
242
442
  const sql = cte +
243
443
  ` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
244
444
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
245
445
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
246
446
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? ` +
247
- `AND r.${q('relation')} IN (${relPlaceholders})`;
248
- const bindings = [...cteBindings, partitionKey, objectType, ...relations];
447
+ `AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
448
+ const bindings = [...cteBindings, partitionKey, objectType, ...relations, at];
249
449
  const rows = rowsOf(await this.#raw('listObjects', sql, bindings));
250
450
  const objects = rows
251
451
  .map((row) => ({ type: objectType, id: String(row.object_uuid) }))
@@ -258,14 +458,14 @@ export class DatabaseRelationsDriver {
258
458
  const partitionKey = scopeKey(partition);
259
459
  // DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
260
460
  // `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()
461
+ // usersets (`group#member`) salen junto con los holders. Solo VIGENTES (R-15).
462
+ const now = this.#now();
463
+ const rows = await this.#sql('listSubjects', () => this.#whereActive(this.#connection()
263
464
  .from(RELATIONS_TABLE)
264
465
  .where('partition_key', partitionKey)
265
466
  .where('object_type', object.type)
266
467
  .where('object_uuid', object.id)
267
- .where('relation', relation)
268
- .select('subject_type', 'subject_uuid', 'subject_relation'));
468
+ .where('relation', relation), now).select('subject_type', 'subject_uuid', 'subject_relation'));
269
469
  const subjects = rows.map((row) => this.#rowToSubject(row));
270
470
  return { subjects };
271
471
  }
@@ -284,68 +484,90 @@ export class DatabaseRelationsDriver {
284
484
  const meta = this.#dialectMeta();
285
485
  const q = (name) => `${meta.quote}${name}${meta.quote}`;
286
486
  const relPlaceholders = relations.map(() => '?').join(', ');
487
+ const at = this.#expiry.bind(this.#now());
488
+ const active = this.#activeSql(q, 'r');
287
489
  // `grp`: los grupos alcanzables por usersets desde (object, RS) —directos y
288
490
  // anidados—. Ancla de UN solo SELECT (MySQL no mezcla UNION ALL con la
289
491
  // recursión): los usersets directos del objeto para cualquier rel de RS.
492
+ // Solo hechos VIGENTES en los cuatro tramos (R-15): una membresía caducada
493
+ // no lleva a nadie dentro.
290
494
  const sql = `WITH RECURSIVE grp(g_uuid) AS ( ` +
291
495
  `SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
292
496
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
293
497
  `AND r.${q('relation')} IN (${relPlaceholders}) AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
294
- `AND r.${q('subject_relation')} = '${GROUP_MEMBER}' ` +
498
+ `AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
295
499
  `UNION ` +
296
500
  `SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
297
501
  `JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
298
502
  `WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
299
503
  `AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
300
- `AND r.${q('subject_relation')} = '${GROUP_MEMBER}' ` +
504
+ `AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
301
505
  `) ` +
302
506
  // Holders directos del objeto para RS, UNION holders directos de cualquier grupo de `grp`.
303
507
  `SELECT ${meta.hint}${q('subject_type')} AS ${q('subject_type')}, ${q('subject_uuid')} AS ${q('subject_uuid')} ` +
304
508
  `FROM ${q(RELATIONS_TABLE)} r ` +
305
509
  `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 ` +
510
+ `AND r.${q('relation')} IN (${relPlaceholders}) AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active} ` +
307
511
  `UNION ` +
308
512
  `SELECT r.${q('subject_type')}, r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
309
513
  `JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
310
514
  `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`;
515
+ `AND r.${q('relation')} = '${GROUP_MEMBER}' AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active}`;
312
516
  const bindings = [
313
517
  // grp ancla
314
- partitionKey, object.type, object.id, ...relations,
518
+ partitionKey, object.type, object.id, ...relations, at,
315
519
  // grp recursiva
316
- partitionKey,
520
+ partitionKey, at,
317
521
  // holders directos del objeto
318
- partitionKey, object.type, object.id, ...relations,
522
+ partitionKey, object.type, object.id, ...relations, at,
319
523
  // holders de los grupos
320
- partitionKey,
524
+ partitionKey, at,
321
525
  ];
322
526
  const rows = rowsOf(await this.#raw('membersOf', sql, bindings));
323
527
  const subjects = rows.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
324
528
  return { subjects };
325
529
  }
326
530
  /* ── Purga (invariante 11): el DELETE demuestra el cero ───────────────── */
327
- async purgeObject(object, partition) {
531
+ async purgeObject(object, partition, options) {
328
532
  assertScope(partition);
329
533
  this.#assertObject(object);
330
534
  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());
535
+ // L-4: por la transacción del llamante si llegó — la purga borra y
536
+ // REVIERTE con ella (tras un rollback todo lo purgado está de vuelta).
537
+ const { client, external } = this.#writer('purgeObject', options);
538
+ try {
539
+ await this.#sql('purgeObject', () => client
540
+ .from(RELATIONS_TABLE)
541
+ .whereIn('partition_key', partitionKeys)
542
+ .where('object_type', object.type)
543
+ .where('object_uuid', object.id)
544
+ .delete());
545
+ }
546
+ catch (error) {
547
+ if (external)
548
+ this.#poisoned('purgeObject', error);
549
+ throw error;
550
+ }
337
551
  }
338
- async purgeSubject(subject, partition) {
552
+ async purgeSubject(subject, partition, options) {
339
553
  assertScope(partition);
340
554
  const partitionKeys = this.#partitionSpellings(partition);
341
555
  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());
556
+ const { client, external } = this.#writer('purgeSubject', options);
557
+ try {
558
+ await this.#sql('purgeSubject', () => client
559
+ .from(RELATIONS_TABLE)
560
+ .whereIn('partition_key', partitionKeys)
561
+ .where('subject_type', s.type)
562
+ .where('subject_uuid', s.uuid)
563
+ .where((b) => this.#whereSubjectRelation(b, s.relation))
564
+ .delete());
565
+ }
566
+ catch (error) {
567
+ if (external)
568
+ this.#poisoned('purgeSubject', error);
569
+ throw error;
570
+ }
349
571
  }
350
572
  /* ── ORIGEN de reconcile (4-5): enumera los hechos directos ───────────── */
351
573
  /**
@@ -354,8 +576,9 @@ export class DatabaseRelationsDriver {
354
576
  * paginados por la PK (`uuid`, cursor que AVANZA), sin derivar por
355
577
  * includes/usersets (el modelo del destino lo recompone): el destino recibe
356
578
  * 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,
579
+ * reconcile — **la caducada LLEGA con su `expiresAt`**, R-15, para contarse
580
+ * en `skipped`; filtrarla aquí la haría desaparecer sin rastro). Barre las
581
+ * DOS ortografías del uuid de partición (🟡2,
359
582
  * coherente con `purge*`): un origen que las funde no puede dejar hechos del
360
583
  * alias sin migrar.
361
584
  *
@@ -383,7 +606,7 @@ export class DatabaseRelationsDriver {
383
606
  .limit(limit + 1);
384
607
  if (after)
385
608
  q = q.where('uuid', '>', after);
386
- return q.select('uuid', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation');
609
+ return q.select('uuid', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation', this.#expiry.select('expires_at'));
387
610
  });
388
611
  const hasMore = rows.length > limit;
389
612
  const pageRows = hasMore ? rows.slice(0, limit) : rows;
@@ -393,6 +616,7 @@ export class DatabaseRelationsDriver {
393
616
  object: { type: String(row.object_type), id: String(row.object_uuid) },
394
617
  // La partición canónica pedida: el barrido de ortografías ya la unificó.
395
618
  partition,
619
+ expiresAt: this.#expiry.fromDb(row.expires_at),
396
620
  }));
397
621
  const cursor = hasMore ? String(pageRows[pageRows.length - 1].uuid) : undefined;
398
622
  return cursor ? { tuples, cursor } : { tuples };
@@ -417,8 +641,12 @@ export class DatabaseRelationsDriver {
417
641
  * de esas identidades. Devuelve el `WITH RECURSIVE …` (sin el SELECT final) y
418
642
  * sus bindings.
419
643
  */
420
- #principalCte(meta, q, s, partitionKey) {
421
- const baseRel = s.relation ?? '';
644
+ #principalCte(meta, q, s, partitionKey, at) {
645
+ // L-4b: el holder ya viene con `''`; el `COALESCE(subject_relation, '')`
646
+ // del JOIN es la tolerancia a una fila vieja con NULL.
647
+ const baseRel = s.relation;
648
+ // Solo membresías VIGENTES suben por la recursión (R-15): una `member`
649
+ // caducada no convierte al sujeto en miembro de nada.
422
650
  const cte = `WITH RECURSIVE principal(p_type, p_uuid, p_rel) AS ( ` +
423
651
  `SELECT ${meta.text('?')}, ${meta.text('?')}, ${meta.text('?')} ` +
424
652
  `UNION ` +
@@ -427,11 +655,12 @@ export class DatabaseRelationsDriver {
427
655
  `JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
428
656
  `AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
429
657
  `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] };
658
+ `AND r.${q('relation')} = '${GROUP_MEMBER}' AND ${this.#activeSql(q, 'r')} )`;
659
+ return { cte, bindings: [s.type, s.uuid, baseRel, partitionKey, at] };
432
660
  }
661
+ /** `''` (L-4b) o NULL (fila vieja sin backfill) ⇒ holder; con valor ⇒ userset. */
433
662
  #rowToSubject(row) {
434
- if (row.subject_relation === null || row.subject_relation === undefined) {
663
+ if (row.subject_relation === null || row.subject_relation === undefined || row.subject_relation === HOLDER_RELATION) {
435
664
  return { type: String(row.subject_type), uuid: String(row.subject_uuid) };
436
665
  }
437
666
  return { object: { type: String(row.subject_type), id: String(row.subject_uuid) }, relation: String(row.subject_relation) };