@adonis-agora/authkit-server 0.81.3 → 0.82.1

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.
package/README.md CHANGED
@@ -120,7 +120,7 @@ router.post('/auth/interaction/:uid/consent', [AuthInteractionController, 'conse
120
120
 
121
121
  Escolha o backend no `config/authkit.ts`:
122
122
  - `adapters.redis({ connection })` — requer `@adonisjs/redis` configurado.
123
- - `adapters.database({ connection? })` — Lucid; rode a migração `authkit_oidc_payloads`.
123
+ - `adapters.database({ connection? })` — Lucid; tabela `authkit_oidc_payloads`, criada pelo `ensureAuthkitSchema` no boot (com `schema: { autoManage: false }`, chame-o numa migração). A coluna indexada `account_id` mantém as buscas e revogações por conta em SQL; linhas antigas com `account_id` NULL continuam sendo encontradas. Postgres, MySQL e SQLite.
124
124
 
125
125
  ## Observabilidade
126
126
 
@@ -11,6 +11,9 @@ export default class extends BaseSchema {
11
11
  table.string('uid', 255).nullable().index();
12
12
  table.timestamp('expires_at', { useTz: true }).nullable();
13
13
  table.primary(['model_name', 'id']);
14
+ table.string('account_id', 255).nullable();
15
+ table.index(['model_name', 'account_id'], 'authkit_oidc_payloads_account_idx');
16
+ table.index(['expires_at'], 'authkit_oidc_payloads_expires_idx');
14
17
  });
15
18
  }
16
19
  async down() {
@@ -79,9 +79,17 @@ export function buildOrganizations(ctx) {
79
79
  },
80
80
  async listOrgsForAccount(accountId) {
81
81
  const memberships = await MemberModel.query().where('account_id', accountId);
82
+ if (memberships.length === 0)
83
+ return [];
84
+ // One query for every org (by primary key, like `find`), instead of one per
85
+ // membership. The result keeps the memberships' order.
86
+ const ids = [...new Set(memberships.map((m) => m.organizationId))];
87
+ const orgs = new Map();
88
+ for (const org of await OrgModel.findMany(ids))
89
+ orgs.set(String(org.id), org);
82
90
  const result = [];
83
91
  for (const m of memberships) {
84
- const org = await OrgModel.find(m.organizationId);
92
+ const org = orgs.get(String(m.organizationId));
85
93
  if (org)
86
94
  result.push({ ...toOrgSummary(org), role: m.role });
87
95
  }
@@ -36,4 +36,16 @@ export interface OidcAdapter {
36
36
  * (`new AdapterClass(model)`), então não recebe parâmetro.
37
37
  */
38
38
  list?(): Promise<EnumeratedArtifact[]>;
39
+ /**
40
+ * Artefatos vivos deste model cujo `payload.accountId` é `accountId` — o mesmo
41
+ * resultado de `list()` filtrado pela conta, sem enumerar as outras contas.
42
+ * OPCIONAL: ausente, os consumidores caem em `list()` + filtro. Tem de ser completo
43
+ * (a revogação confia nele): um artefato que fique de fora continua valendo.
44
+ */
45
+ listByAccount?(accountId: string): Promise<EnumeratedArtifact[]>;
46
+ /**
47
+ * Artefatos vivos deste model cujo `payload.grantId` está em `grantIds` — o
48
+ * mesmo resultado de `list()` filtrado pelos grants. OPCIONAL, como `listByAccount`.
49
+ */
50
+ listByGrantIds?(grantIds: string[]): Promise<EnumeratedArtifact[]>;
39
51
  }
@@ -19,4 +19,14 @@ export declare class DatabaseAdapter implements OidcAdapter {
19
19
  * console admin para listar `Client` (CRUD) e `Session`/`Grant`/tokens.
20
20
  */
21
21
  list(): Promise<EnumeratedArtifact[]>;
22
+ /**
23
+ * Artefatos vivos da conta, pelo índice `(model_name, account_id)`. As linhas com
24
+ * `account_id` NULL (gravadas antes da coluna existir, ou por uma versão anterior
25
+ * da lib durante um deploy) entram na leitura; o filtro final é sempre o
26
+ * `payload.accountId`, e as NULL que têm conta no payload são classificadas aqui,
27
+ * para não serem relidas na próxima busca.
28
+ */
29
+ listByAccount(accountId: string): Promise<EnumeratedArtifact[]>;
30
+ /** Artefatos vivos que referenciam algum dos grants, pela coluna indexada `grant_id`. */
31
+ listByGrantIds(grantIds: string[]): Promise<EnumeratedArtifact[]>;
22
32
  }
@@ -1,4 +1,31 @@
1
1
  const TABLE = 'authkit_oidc_payloads';
2
+ /** Intervalo mínimo, por conexão, entre duas limpezas das linhas expiradas. */
3
+ const PRUNE_INTERVAL_MS = 10 * 60_000;
4
+ /** Quanto tempo a ausência da coluna `account_id` vale antes de re-checar. */
5
+ const ACCOUNT_COLUMN_RECHECK_MS = 60_000;
6
+ /** Tamanho máximo de uma lista `IN (...)`. */
7
+ const IN_CHUNK = 500;
8
+ const connectionStates = new WeakMap();
9
+ function connectionState(db) {
10
+ let state = connectionStates.get(db);
11
+ if (!state) {
12
+ state = { accountColumn: null, accountColumnRecheckAt: 0, nextPruneAt: 0 };
13
+ connectionStates.set(db, state);
14
+ }
15
+ return state;
16
+ }
17
+ /** Dialetos que gravam `expires_at` como texto ISO-8601 (comparado lexicograficamente). */
18
+ const TEXT_TIMESTAMP_DIALECTS = new Set([
19
+ 'sqlite3',
20
+ 'better-sqlite3',
21
+ 'libsql',
22
+ ]);
23
+ function chunks(items) {
24
+ const out = [];
25
+ for (let i = 0; i < items.length; i += IN_CHUNK)
26
+ out.push(items.slice(i, i + IN_CHUNK));
27
+ return out;
28
+ }
2
29
  export class DatabaseAdapter {
3
30
  name;
4
31
  db;
@@ -9,10 +36,74 @@ export class DatabaseAdapter {
9
36
  #query() {
10
37
  return this.db.query().from(TABLE).where('model_name', this.name);
11
38
  }
39
+ /**
40
+ * Valor de `expires_at` (gravação e comparação) para o dialeto da conexão. No
41
+ * SQLite a coluna guarda texto ISO-8601 (o driver gravaria um `Date` como número,
42
+ * que não se compara com o texto já gravado); nos demais vai um `Date`, que o
43
+ * driver serializa no formato do banco — o MySQL recusa o ISO com `Z`.
44
+ */
45
+ #timestamp(ms) {
46
+ const state = connectionState(this.db);
47
+ if (state.dialect === undefined) {
48
+ // O factory passa o manager (`Database`) ou um `QueryClient` de conexão nomeada.
49
+ const db = this.db;
50
+ const client = typeof db.connection === 'function' ? db.connection() : db;
51
+ state.dialect = client?.dialect?.name ?? null;
52
+ }
53
+ const dialect = state.dialect;
54
+ return !dialect || TEXT_TIMESTAMP_DIALECTS.has(dialect)
55
+ ? new Date(ms).toISOString()
56
+ : new Date(ms);
57
+ }
58
+ /** `#query()` restrito às linhas não expiradas. */
59
+ #liveQuery() {
60
+ const now = this.#timestamp(Date.now());
61
+ return this.#query().where((q) => q.whereNull('expires_at').orWhere('expires_at', '>', now));
62
+ }
63
+ /**
64
+ * A coluna `account_id` existe? Probe por SELECT (searchPath-aware, como o
65
+ * `ensureAuthkitSchema`). "Presente" vale pela vida do processo; "ausente" é
66
+ * re-checado, para pegar o ALTER do boot que rodou depois do primeiro request.
67
+ */
68
+ async #hasAccountColumn() {
69
+ const state = connectionState(this.db);
70
+ if (state.accountColumn === true)
71
+ return true;
72
+ if (state.accountColumn === false && state.accountColumnRecheckAt > Date.now())
73
+ return false;
74
+ try {
75
+ await this.db.query().from(TABLE).select('account_id').limit(1);
76
+ state.accountColumn = true;
77
+ }
78
+ catch {
79
+ state.accountColumn = false;
80
+ state.accountColumnRecheckAt = Date.now() + ACCOUNT_COLUMN_RECHECK_MS;
81
+ }
82
+ return state.accountColumn;
83
+ }
84
+ /**
85
+ * Apaga as linhas expiradas de todos os models (no máximo uma vez por
86
+ * {@link PRUNE_INTERVAL_MS} por conexão). Linha expirada já é invisível para
87
+ * `find`/`list`; a limpeza só impede a tabela de crescer sem limite. Roda fora
88
+ * do caminho do request e nunca lança.
89
+ */
90
+ #maybePrune() {
91
+ const state = connectionState(this.db);
92
+ const now = Date.now();
93
+ if (state.nextPruneAt > now)
94
+ return;
95
+ state.nextPruneAt = now + PRUNE_INTERVAL_MS;
96
+ Promise.resolve()
97
+ .then(() => this.db
98
+ .query()
99
+ .from(TABLE)
100
+ .whereNotNull('expires_at')
101
+ .where('expires_at', '<=', this.#timestamp(now))
102
+ .delete())
103
+ .catch(() => { });
104
+ }
12
105
  async upsert(id, payload, expiresIn) {
13
- // Armazenamos como ISO string para que o parse de expiração seja determinístico
14
- // independente do backend (SQLite/Postgres) e do formato nativo de timestamp.
15
- const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : null;
106
+ const expiresAt = expiresIn ? this.#timestamp(Date.now() + expiresIn * 1000) : null;
16
107
  const row = {
17
108
  id,
18
109
  model_name: this.name,
@@ -22,13 +113,21 @@ export class DatabaseAdapter {
22
113
  uid: payload.uid ?? null,
23
114
  expires_at: expiresAt,
24
115
  };
25
- const existing = await this.#query().where('id', id).first();
116
+ if (await this.#hasAccountColumn()) {
117
+ // NULL = "sem conta conhecida": as buscas por conta também leem essas linhas
118
+ // (e as classificam), então uma linha escrita sem a coluna nunca fica de fora.
119
+ const accountId = payload.accountId;
120
+ row.account_id = typeof accountId === 'string' && accountId ? accountId : null;
121
+ }
122
+ // Only whether the row exists matters: don't read the (possibly large) payload back.
123
+ const existing = await this.#query().where('id', id).select('id').first();
26
124
  if (existing) {
27
125
  await this.#query().where('id', id).update(row);
28
126
  }
29
127
  else {
30
128
  await this.db.table(TABLE).insert(row);
31
129
  }
130
+ this.#maybePrune();
32
131
  }
33
132
  async #parse(record) {
34
133
  if (!record)
@@ -53,10 +152,10 @@ export class DatabaseAdapter {
53
152
  const changed = await this.#query()
54
153
  .where('id', id)
55
154
  .where('payload', row.payload)
56
- .where('expires_at', '>', new Date(now).toISOString())
155
+ .where('expires_at', '>', this.#timestamp(now))
57
156
  .update({
58
157
  payload: JSON.stringify({ ...payload, exp: Math.floor(now / 1000) + expiresIn }),
59
- expires_at: new Date(now + expiresIn * 1000).toISOString(),
158
+ expires_at: this.#timestamp(now + expiresIn * 1000),
60
159
  });
61
160
  if (Number(changed) > 0)
62
161
  return true;
@@ -94,14 +193,81 @@ export class DatabaseAdapter {
94
193
  * console admin para listar `Client` (CRUD) e `Session`/`Grant`/tokens.
95
194
  */
96
195
  async list() {
97
- const rows = await this.#query().orderBy('id', 'asc');
196
+ return this.#toArtifacts(await this.#liveQuery().orderBy('id', 'asc'));
197
+ }
198
+ /**
199
+ * Artefatos vivos da conta, pelo índice `(model_name, account_id)`. As linhas com
200
+ * `account_id` NULL (gravadas antes da coluna existir, ou por uma versão anterior
201
+ * da lib durante um deploy) entram na leitura; o filtro final é sempre o
202
+ * `payload.accountId`, e as NULL que têm conta no payload são classificadas aqui,
203
+ * para não serem relidas na próxima busca.
204
+ */
205
+ async listByAccount(accountId) {
206
+ if (!(await this.#hasAccountColumn())) {
207
+ return (await this.list()).filter((r) => r.payload.accountId === accountId);
208
+ }
209
+ const rows = await this.#liveQuery()
210
+ .where((q) => q.where('account_id', accountId).orWhereNull('account_id'))
211
+ .orderBy('id', 'asc');
212
+ const unclassified = new Map();
213
+ const result = [];
214
+ for (const artifact of this.#parseRows(rows)) {
215
+ const owner = artifact.payload.accountId;
216
+ if (artifact.unclassified && typeof owner === 'string' && owner) {
217
+ const ids = unclassified.get(owner) ?? [];
218
+ ids.push(artifact.id);
219
+ unclassified.set(owner, ids);
220
+ }
221
+ if (owner === accountId)
222
+ result.push({ id: artifact.id, payload: artifact.payload });
223
+ }
224
+ await this.#classify(unclassified);
225
+ return result;
226
+ }
227
+ /** Artefatos vivos que referenciam algum dos grants, pela coluna indexada `grant_id`. */
228
+ async listByGrantIds(grantIds) {
229
+ const wanted = new Set(grantIds);
230
+ const result = [];
231
+ for (const chunk of chunks([...wanted])) {
232
+ const rows = await this.#liveQuery().whereIn('grant_id', chunk).orderBy('id', 'asc');
233
+ for (const artifact of this.#toArtifacts(rows)) {
234
+ if (wanted.has(artifact.payload.grantId))
235
+ result.push(artifact);
236
+ }
237
+ }
238
+ return result;
239
+ }
240
+ async #classify(byAccount) {
241
+ for (const [accountId, ids] of byAccount) {
242
+ for (const chunk of chunks(ids)) {
243
+ try {
244
+ await this.#query()
245
+ .whereNull('account_id')
246
+ .whereIn('id', chunk)
247
+ .update({ account_id: accountId });
248
+ }
249
+ catch {
250
+ // Classificar é só otimização: a linha segue NULL e é lida de novo.
251
+ }
252
+ }
253
+ }
254
+ }
255
+ /** Linhas → artefatos, descartando as expiradas; `unclassified` = `account_id` NULL. */
256
+ #parseRows(rows) {
98
257
  const now = Date.now();
99
258
  const result = [];
100
259
  for (const row of rows) {
101
260
  if (row.expires_at && new Date(row.expires_at).getTime() <= now)
102
261
  continue;
103
- result.push({ id: row.id, payload: JSON.parse(row.payload) });
262
+ result.push({
263
+ id: row.id,
264
+ payload: JSON.parse(row.payload),
265
+ unclassified: row.account_id == null,
266
+ });
104
267
  }
105
268
  return result;
106
269
  }
270
+ #toArtifacts(rows) {
271
+ return this.#parseRows(rows).map(({ id, payload }) => ({ id, payload }));
272
+ }
107
273
  }
@@ -19,7 +19,7 @@ export declare class RedisAdapter implements OidcAdapter {
19
19
  * prefixo de chave do model (`<prefix>:<name>:*`). É limpo porque cada artefato
20
20
  * é uma chave única já namespaceada por `prefix` + `name`; SCAN é não-bloqueante
21
21
  * (cursor) ao contrário de KEYS. Usado pelo console admin (`Client`, `Session`,
22
- * `Grant`, tokens).
22
+ * `Grant`, tokens). Cada página do SCAN é lida num único MGET.
23
23
  */
24
24
  list(): Promise<EnumeratedArtifact[]>;
25
25
  }
@@ -101,15 +101,23 @@ export class RedisAdapter {
101
101
  return this.find(id);
102
102
  }
103
103
  async consume(id) {
104
- const found = await this.find(id);
105
- if (!found)
104
+ const key = this.#key(id);
105
+ // GET + TTL in one round trip (a pipeline, read-only: nothing changes before the SET).
106
+ const results = await this.redis.pipeline().get(key).ttl(key).exec();
107
+ const [getError, data] = results?.[0] ?? [null, null];
108
+ if (getError)
109
+ throw getError;
110
+ if (!data)
106
111
  return;
112
+ const [ttlError, ttl] = results?.[1] ?? [null, -1];
113
+ if (ttlError)
114
+ throw ttlError;
115
+ const found = JSON.parse(data);
107
116
  found.consumed = Math.floor(Date.now() / 1000);
108
- const ttl = await this.redis.ttl(this.#key(id));
109
117
  if (ttl > 0)
110
- await this.redis.set(this.#key(id), JSON.stringify(found), 'EX', ttl);
118
+ await this.redis.set(key, JSON.stringify(found), 'EX', ttl);
111
119
  else
112
- await this.redis.set(this.#key(id), JSON.stringify(found));
120
+ await this.redis.set(key, JSON.stringify(found));
113
121
  }
114
122
  async destroy(id) {
115
123
  await this.redis.del(this.#key(id));
@@ -128,24 +136,27 @@ export class RedisAdapter {
128
136
  * prefixo de chave do model (`<prefix>:<name>:*`). É limpo porque cada artefato
129
137
  * é uma chave única já namespaceada por `prefix` + `name`; SCAN é não-bloqueante
130
138
  * (cursor) ao contrário de KEYS. Usado pelo console admin (`Client`, `Session`,
131
- * `Grant`, tokens).
139
+ * `Grant`, tokens). Cada página do SCAN é lida num único MGET.
132
140
  */
133
141
  async list() {
134
142
  const prefix = `${this.prefix}:${this.name}:`;
135
143
  const result = [];
136
144
  let cursor = '0';
137
145
  do {
138
- const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 100);
146
+ const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 1000);
139
147
  cursor = next;
140
- for (const key of keys) {
141
- const data = await this.redis.get(key);
148
+ if (keys.length === 0)
149
+ continue;
150
+ const values = await this.redis.mget(...keys);
151
+ keys.forEach((key, i) => {
152
+ const data = values[i];
142
153
  if (!data)
143
- continue;
154
+ return;
144
155
  result.push({
145
156
  id: key.slice(prefix.length),
146
157
  payload: JSON.parse(data),
147
158
  });
148
- }
159
+ });
149
160
  } while (cursor !== '0');
150
161
  result.sort((a, b) => a.id.localeCompare(b.id));
151
162
  return result;
@@ -122,6 +122,32 @@ export class AdminSessionsService {
122
122
  return [];
123
123
  return adapter.list();
124
124
  }
125
+ /** Artefatos do model da conta — pela busca por conta do adapter, quando existe. */
126
+ async #listModelOfAccount(model, accountId) {
127
+ const adapter = this.#adapter(model);
128
+ if (adapter.listByAccount)
129
+ return adapter.listByAccount(accountId);
130
+ if (!adapter.list)
131
+ return [];
132
+ return (await adapter.list()).filter((r) => r.payload.accountId === accountId);
133
+ }
134
+ /** Artefatos de um model token que referenciam algum dos grants. */
135
+ async #listTokensOfGrants(model, grantIds) {
136
+ if (grantIds.size === 0)
137
+ return [];
138
+ const adapter = this.#adapter(model);
139
+ if (adapter.listByGrantIds)
140
+ return adapter.listByGrantIds([...grantIds]);
141
+ if (!adapter.list)
142
+ return [];
143
+ return (await adapter.list()).filter((r) => grantIds.has(r.payload.grantId));
144
+ }
145
+ /** Grants da conta, exceto os espelhos `pgrant_` de Account Tokens do Poppy. */
146
+ async #accountGrants(accountId) {
147
+ // Os grants `pgrant_` são o espelho de um Account Token do Poppy (Bearer de MCP) — aparecem e
148
+ // são revogados como personal agent, não como app OIDC.
149
+ return (await this.#listModelOfAccount('Grant', accountId)).filter((r) => !r.id.startsWith('pgrant_'));
150
+ }
125
151
  /**
126
152
  * Conta TODAS as sessões ativas do IdP (todas as contas). 0 quando o adapter não
127
153
  * enumera. Usado pelo dashboard de métricas.
@@ -132,10 +158,8 @@ export class AdminSessionsService {
132
158
  }
133
159
  /** Lista as sessões ativas da conta (vazio quando o adapter não enumera). */
134
160
  async listSessions(accountId) {
135
- const rows = await this.#listModel('Session');
136
- return rows
137
- .filter((r) => r.payload.accountId === accountId)
138
- .map((r) => ({
161
+ const rows = await this.#listModelOfAccount('Session', accountId);
162
+ return rows.map((r) => ({
139
163
  id: r.id,
140
164
  accountId,
141
165
  loginTs: r.payload.loginTs,
@@ -183,14 +207,12 @@ export class AdminSessionsService {
183
207
  * enumeração de cada model token), feitas só quando o adapter enumera.
184
208
  */
185
209
  async listGrants(accountId) {
186
- const rows = await this.#listModel('Grant');
187
- // Os grants `pgrant_` são o espelho de um Account Token do Poppy (Bearer de MCP) — aparecem e
188
- // são revogados como personal agent, não como app OIDC.
189
- const grants = rows.filter((r) => r.payload.accountId === accountId && !r.id.startsWith('pgrant_'));
210
+ const grants = await this.#accountGrants(accountId);
190
211
  if (grants.length === 0)
191
212
  return [];
192
- const atByGrant = await this.#countByGrant('AccessToken');
193
- const rtByGrant = await this.#countByGrant('RefreshToken');
213
+ const grantIds = new Set(grants.map((g) => g.id));
214
+ const atByGrant = await this.#countByGrant('AccessToken', grantIds);
215
+ const rtByGrant = await this.#countByGrant('RefreshToken', grantIds);
194
216
  return grants.map((g) => ({
195
217
  id: g.id,
196
218
  accountId,
@@ -199,9 +221,9 @@ export class AdminSessionsService {
199
221
  refreshTokens: rtByGrant.get(g.id) ?? 0,
200
222
  }));
201
223
  }
202
- /** Conta artefatos de um model token agrupados por `grantId`. */
203
- async #countByGrant(model) {
204
- const rows = await this.#listModel(model);
224
+ /** Conta os artefatos de um model token dos grants, agrupados por `grantId`. */
225
+ async #countByGrant(model, grantIds) {
226
+ const rows = await this.#listTokensOfGrants(model, grantIds);
205
227
  const map = new Map();
206
228
  for (const r of rows) {
207
229
  const gid = r.payload.grantId;
@@ -231,7 +253,7 @@ export class AdminSessionsService {
231
253
  // Revogamos todos os grants da conta (que pertencem à conta, não à sessão
232
254
  // específica). A sessão corrente gerará novos grants no próximo authorize.
233
255
  // Isso garante que sessões antigas não mantenham tokens vivos.
234
- const grants = await this.listGrants(accountId);
256
+ const grants = await this.#accountGrants(accountId);
235
257
  const grantIds = new Set(grants.map((g) => g.id));
236
258
  let accessTokens = 0;
237
259
  let refreshTokens = 0;
@@ -279,7 +301,7 @@ export class AdminSessionsService {
279
301
  for (const s of sessions) {
280
302
  await sessionAdapter.destroy(s.id);
281
303
  }
282
- const grants = await this.listGrants(accountId);
304
+ const grants = await this.#accountGrants(accountId);
283
305
  const grantIds = new Set(grants.map((g) => g.id));
284
306
  let accessTokens = 0;
285
307
  let refreshTokens = 0;
@@ -314,7 +336,7 @@ export class AdminSessionsService {
314
336
  */
315
337
  async revokeClientGrants(accountId, clientId) {
316
338
  const grantAdapter = this.#adapter('Grant');
317
- const grants = (await this.listGrants(accountId)).filter((g) => g.clientId === clientId);
339
+ const grants = (await this.#accountGrants(accountId)).filter((g) => g.payload.clientId === clientId);
318
340
  const grantIds = new Set(grants.map((g) => g.id));
319
341
  const accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
320
342
  const refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
@@ -346,12 +368,10 @@ export class AdminSessionsService {
346
368
  */
347
369
  async revokeOrgGrants(orgId, accountId) {
348
370
  const grantAdapter = this.#adapter('Grant');
349
- const rows = await this.#listModel('Grant');
350
- const grants = rows.filter((r) => {
351
- if (normalizeActiveOrg(r.payload.activeOrg)?.orgId !== orgId)
352
- return false;
353
- return accountId === undefined || r.payload.accountId === accountId;
354
- });
371
+ const rows = accountId === undefined
372
+ ? await this.#listModel('Grant')
373
+ : await this.#listModelOfAccount('Grant', accountId);
374
+ const grants = rows.filter((r) => normalizeActiveOrg(r.payload.activeOrg)?.orgId === orgId);
355
375
  if (grants.length === 0) {
356
376
  return { sessions: 0, grants: 0, accessTokens: 0, refreshTokens: 0 };
357
377
  }
@@ -375,17 +395,9 @@ export class AdminSessionsService {
375
395
  /** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
376
396
  async #destroyTokensOfGrants(model, grantIds) {
377
397
  const adapter = this.#adapter(model);
378
- if (!adapter.list)
379
- return 0;
380
- const rows = await adapter.list();
381
- let count = 0;
382
- for (const r of rows) {
383
- const gid = r.payload.grantId;
384
- if (gid && grantIds.has(gid)) {
385
- await adapter.destroy(r.id);
386
- count++;
387
- }
388
- }
389
- return count;
398
+ const rows = await this.#listTokensOfGrants(model, grantIds);
399
+ for (const r of rows)
400
+ await adapter.destroy(r.id);
401
+ return rows.length;
390
402
  }
391
403
  }
@@ -1,6 +1,25 @@
1
1
  import { ACCOUNT_IDP_SESSION_KEY, readIdpSession } from './idp_session_bridge.js';
2
2
  import { impersonationState } from './impersonation_session.js';
3
3
  import { assertAccountEnabled } from './login_attempt.js';
4
+ /**
5
+ * How long (seconds) a renewal stays good enough: a linked RP whose IdP session was
6
+ * renewed less than this ago, with the current TTL, is not renewed again. Without
7
+ * it every authenticated request wrote the IdP session row and re-sent its cookie.
8
+ */
9
+ const RENEW_RESOLUTION_SEC = 60;
10
+ /**
11
+ * The linked RP's IdP session already expires between `ttl - RENEW_RESOLUTION_SEC`
12
+ * and `ttl` from now, i.e. it was renewed with this TTL moments ago. Renewing again
13
+ * would only move the expiry by those few seconds. A session that expires LATER
14
+ * than `now + ttl` (the TTL was lowered) is still renewed, which shortens it.
15
+ * Restoration (no local login yet) always renews.
16
+ */
17
+ function renewedRecently(idp, ttl, current, linkedUid) {
18
+ if (!current || linkedUid !== idp.uid || typeof idp.exp !== 'number')
19
+ return false;
20
+ const remaining = idp.exp - Date.now() / 1000;
21
+ return remaining <= ttl && remaining > ttl - RENEW_RESOLUTION_SEC;
22
+ }
4
23
  /** Same-host RP restoration: the signed IdP cookie is the persistent credential. */
5
24
  export async function ensureRpSession(ctx, sessionKey) {
6
25
  const service = await ctx.containerResolver.make('authkit.server');
@@ -39,7 +58,7 @@ export async function ensureRpSession(ctx, sessionKey) {
39
58
  }
40
59
  const adapter = service.provider.Session.adapter;
41
60
  const ttl = service.sessionTtlHolder.rememberSec;
42
- if (adapter.renewSession) {
61
+ if (adapter.renewSession && !renewedRecently(idp, ttl, current, linkedUid)) {
43
62
  if (!(await adapter.renewSession(idp.id, ttl))) {
44
63
  if (linkedUid)
45
64
  ctx.session.clear();
@@ -108,10 +108,9 @@ export declare class RuntimeSettings implements SettingsCapability {
108
108
  private readonly db;
109
109
  private readonly ttlMs;
110
110
  private readonly connectionName;
111
- private cache;
112
- /** null = não foi verificado ainda; false = tabela ausente; true = presente */
113
- private tablePresent;
111
+ private readonly shared;
114
112
  constructor(db: any, opts?: RuntimeSettingsOptions);
113
+ private get cache();
115
114
  /**
116
115
  * Retorna o objeto de conexão Lucid correto.
117
116
  * Quando `connectionName` está definido, usa `db.connection(name)`;
@@ -128,6 +127,9 @@ export declare class RuntimeSettings implements SettingsCapability {
128
127
  * detecta a tabela corretamente independente do schema.
129
128
  *
130
129
  * Fail-safe: qualquer erro → false (tabela considerada ausente, sem lançar).
130
+ * "Presente" vale pela vida do processo; "ausente" é re-verificado após o TTL,
131
+ * para que uma migração rodada com o app no ar (ou um erro transitório) não
132
+ * desligue as settings até o restart.
131
133
  */
132
134
  private hasTable;
133
135
  getSetting(key: string, orgId?: string | null): Promise<unknown | null>;
@@ -57,6 +57,24 @@ import { isSettingLocked, SettingLockedError } from './config_locks.js';
57
57
  export function supportsSettings(obj) {
58
58
  return !!obj && typeof obj.getSetting === 'function';
59
59
  }
60
+ const sharedStates = new WeakMap();
61
+ function sharedStateFor(db, connection) {
62
+ const fresh = () => ({ cache: new Map(), tablePresent: null, tableAbsentUntil: 0 });
63
+ if (!db || (typeof db !== 'object' && typeof db !== 'function'))
64
+ return fresh();
65
+ let byConnection = sharedStates.get(db);
66
+ if (!byConnection) {
67
+ byConnection = new Map();
68
+ sharedStates.set(db, byConnection);
69
+ }
70
+ const name = connection ?? '';
71
+ let state = byConnection.get(name);
72
+ if (!state) {
73
+ state = fresh();
74
+ byConnection.set(name, state);
75
+ }
76
+ return state;
77
+ }
60
78
  /** Chave de cache: combina key + escopo de org (null = global). */
61
79
  function cacheKey(key, orgId) {
62
80
  return orgId ? `${key}\x00${orgId}` : key;
@@ -70,13 +88,15 @@ export class RuntimeSettings {
70
88
  db;
71
89
  ttlMs;
72
90
  connectionName;
73
- cache = new Map();
74
- /** null = não foi verificado ainda; false = tabela ausente; true = presente */
75
- tablePresent = null;
91
+ shared;
76
92
  constructor(db, opts = {}) {
77
93
  this.db = db;
78
94
  this.ttlMs = opts.ttlMs ?? 15_000;
79
95
  this.connectionName = opts.connection;
96
+ this.shared = sharedStateFor(db, opts.connection);
97
+ }
98
+ get cache() {
99
+ return this.shared.cache;
80
100
  }
81
101
  /**
82
102
  * Retorna o objeto de conexão Lucid correto.
@@ -96,17 +116,24 @@ export class RuntimeSettings {
96
116
  * detecta a tabela corretamente independente do schema.
97
117
  *
98
118
  * Fail-safe: qualquer erro → false (tabela considerada ausente, sem lançar).
119
+ * "Presente" vale pela vida do processo; "ausente" é re-verificado após o TTL,
120
+ * para que uma migração rodada com o app no ar (ou um erro transitório) não
121
+ * desligue as settings até o restart.
99
122
  */
100
123
  async hasTable() {
101
- if (this.tablePresent !== null)
102
- return this.tablePresent;
124
+ const shared = this.shared;
125
+ if (shared.tablePresent === true)
126
+ return true;
127
+ if (shared.tablePresent === false && shared.tableAbsentUntil > Date.now())
128
+ return false;
103
129
  try {
104
130
  await this.conn().from('auth_settings').select('key').limit(1);
105
- this.tablePresent = true;
131
+ shared.tablePresent = true;
106
132
  return true;
107
133
  }
108
134
  catch {
109
- this.tablePresent = false;
135
+ shared.tablePresent = false;
136
+ shared.tableAbsentUntil = Date.now() + this.ttlMs;
110
137
  return false;
111
138
  }
112
139
  }