@adonis-agora/authkit-server 0.81.2 → 0.82.0
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 +1 -1
- package/build/database/migrations/make_authkit_oidc_table.js +3 -0
- package/build/src/adapters/adapter_contract.d.ts +12 -0
- package/build/src/adapters/database_adapter.d.ts +10 -0
- package/build/src/adapters/database_adapter.js +172 -7
- package/build/src/adapters/redis_adapter.d.ts +1 -1
- package/build/src/adapters/redis_adapter.js +9 -6
- package/build/src/host/admin_sessions_service.js +46 -34
- package/build/src/host/durable/account_deletion_workflow.d.ts +5 -1
- package/build/src/host/durable/account_deletion_workflow.js +3 -2
- package/build/src/host/durable/account_export_workflow.d.ts +4 -0
- package/build/src/host/durable/account_export_workflow.js +2 -1
- package/build/src/host/durable/index.d.ts +2 -2
- package/build/src/host/durable/index.js +5 -4
- package/build/src/host/durable/package_origin.d.ts +7 -0
- package/build/src/host/durable/package_origin.js +7 -0
- package/build/src/host/runtime_settings.d.ts +5 -3
- package/build/src/host/runtime_settings.js +34 -7
- package/build/src/host/ui-dist/assets/index-d00IbXWz.js +155 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/pat/lucid_pat_store.js +9 -3
- package/build/src/pat/pat_store.d.ts +1 -0
- package/build/src/schema/ensure.d.ts +5 -0
- package/build/src/schema/ensure.js +130 -4
- package/package.json +13 -10
- package/skills/authkit-idp-setup/SKILL.md +6 -2
- package/build/src/host/ui-dist/assets/index-CIv26Mfg.js +0 -155
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;
|
|
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() {
|
|
@@ -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
|
-
|
|
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,6 +113,12 @@ export class DatabaseAdapter {
|
|
|
22
113
|
uid: payload.uid ?? null,
|
|
23
114
|
expires_at: expiresAt,
|
|
24
115
|
};
|
|
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
|
+
}
|
|
25
122
|
const existing = await this.#query().where('id', id).first();
|
|
26
123
|
if (existing) {
|
|
27
124
|
await this.#query().where('id', id).update(row);
|
|
@@ -29,6 +126,7 @@ export class DatabaseAdapter {
|
|
|
29
126
|
else {
|
|
30
127
|
await this.db.table(TABLE).insert(row);
|
|
31
128
|
}
|
|
129
|
+
this.#maybePrune();
|
|
32
130
|
}
|
|
33
131
|
async #parse(record) {
|
|
34
132
|
if (!record)
|
|
@@ -53,10 +151,10 @@ export class DatabaseAdapter {
|
|
|
53
151
|
const changed = await this.#query()
|
|
54
152
|
.where('id', id)
|
|
55
153
|
.where('payload', row.payload)
|
|
56
|
-
.where('expires_at', '>',
|
|
154
|
+
.where('expires_at', '>', this.#timestamp(now))
|
|
57
155
|
.update({
|
|
58
156
|
payload: JSON.stringify({ ...payload, exp: Math.floor(now / 1000) + expiresIn }),
|
|
59
|
-
expires_at:
|
|
157
|
+
expires_at: this.#timestamp(now + expiresIn * 1000),
|
|
60
158
|
});
|
|
61
159
|
if (Number(changed) > 0)
|
|
62
160
|
return true;
|
|
@@ -94,14 +192,81 @@ export class DatabaseAdapter {
|
|
|
94
192
|
* console admin para listar `Client` (CRUD) e `Session`/`Grant`/tokens.
|
|
95
193
|
*/
|
|
96
194
|
async list() {
|
|
97
|
-
|
|
195
|
+
return this.#toArtifacts(await this.#liveQuery().orderBy('id', 'asc'));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Artefatos vivos da conta, pelo índice `(model_name, account_id)`. As linhas com
|
|
199
|
+
* `account_id` NULL (gravadas antes da coluna existir, ou por uma versão anterior
|
|
200
|
+
* da lib durante um deploy) entram na leitura; o filtro final é sempre o
|
|
201
|
+
* `payload.accountId`, e as NULL que têm conta no payload são classificadas aqui,
|
|
202
|
+
* para não serem relidas na próxima busca.
|
|
203
|
+
*/
|
|
204
|
+
async listByAccount(accountId) {
|
|
205
|
+
if (!(await this.#hasAccountColumn())) {
|
|
206
|
+
return (await this.list()).filter((r) => r.payload.accountId === accountId);
|
|
207
|
+
}
|
|
208
|
+
const rows = await this.#liveQuery()
|
|
209
|
+
.where((q) => q.where('account_id', accountId).orWhereNull('account_id'))
|
|
210
|
+
.orderBy('id', 'asc');
|
|
211
|
+
const unclassified = new Map();
|
|
212
|
+
const result = [];
|
|
213
|
+
for (const artifact of this.#parseRows(rows)) {
|
|
214
|
+
const owner = artifact.payload.accountId;
|
|
215
|
+
if (artifact.unclassified && typeof owner === 'string' && owner) {
|
|
216
|
+
const ids = unclassified.get(owner) ?? [];
|
|
217
|
+
ids.push(artifact.id);
|
|
218
|
+
unclassified.set(owner, ids);
|
|
219
|
+
}
|
|
220
|
+
if (owner === accountId)
|
|
221
|
+
result.push({ id: artifact.id, payload: artifact.payload });
|
|
222
|
+
}
|
|
223
|
+
await this.#classify(unclassified);
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
/** Artefatos vivos que referenciam algum dos grants, pela coluna indexada `grant_id`. */
|
|
227
|
+
async listByGrantIds(grantIds) {
|
|
228
|
+
const wanted = new Set(grantIds);
|
|
229
|
+
const result = [];
|
|
230
|
+
for (const chunk of chunks([...wanted])) {
|
|
231
|
+
const rows = await this.#liveQuery().whereIn('grant_id', chunk).orderBy('id', 'asc');
|
|
232
|
+
for (const artifact of this.#toArtifacts(rows)) {
|
|
233
|
+
if (wanted.has(artifact.payload.grantId))
|
|
234
|
+
result.push(artifact);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
async #classify(byAccount) {
|
|
240
|
+
for (const [accountId, ids] of byAccount) {
|
|
241
|
+
for (const chunk of chunks(ids)) {
|
|
242
|
+
try {
|
|
243
|
+
await this.#query()
|
|
244
|
+
.whereNull('account_id')
|
|
245
|
+
.whereIn('id', chunk)
|
|
246
|
+
.update({ account_id: accountId });
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Classificar é só otimização: a linha segue NULL e é lida de novo.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** Linhas → artefatos, descartando as expiradas; `unclassified` = `account_id` NULL. */
|
|
255
|
+
#parseRows(rows) {
|
|
98
256
|
const now = Date.now();
|
|
99
257
|
const result = [];
|
|
100
258
|
for (const row of rows) {
|
|
101
259
|
if (row.expires_at && new Date(row.expires_at).getTime() <= now)
|
|
102
260
|
continue;
|
|
103
|
-
result.push({
|
|
261
|
+
result.push({
|
|
262
|
+
id: row.id,
|
|
263
|
+
payload: JSON.parse(row.payload),
|
|
264
|
+
unclassified: row.account_id == null,
|
|
265
|
+
});
|
|
104
266
|
}
|
|
105
267
|
return result;
|
|
106
268
|
}
|
|
269
|
+
#toArtifacts(rows) {
|
|
270
|
+
return this.#parseRows(rows).map(({ id, payload }) => ({ id, payload }));
|
|
271
|
+
}
|
|
107
272
|
}
|
|
@@ -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
|
}
|
|
@@ -128,24 +128,27 @@ export class RedisAdapter {
|
|
|
128
128
|
* prefixo de chave do model (`<prefix>:<name>:*`). É limpo porque cada artefato
|
|
129
129
|
* é uma chave única já namespaceada por `prefix` + `name`; SCAN é não-bloqueante
|
|
130
130
|
* (cursor) ao contrário de KEYS. Usado pelo console admin (`Client`, `Session`,
|
|
131
|
-
* `Grant`, tokens).
|
|
131
|
+
* `Grant`, tokens). Cada página do SCAN é lida num único MGET.
|
|
132
132
|
*/
|
|
133
133
|
async list() {
|
|
134
134
|
const prefix = `${this.prefix}:${this.name}:`;
|
|
135
135
|
const result = [];
|
|
136
136
|
let cursor = '0';
|
|
137
137
|
do {
|
|
138
|
-
const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT',
|
|
138
|
+
const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 1000);
|
|
139
139
|
cursor = next;
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
if (keys.length === 0)
|
|
141
|
+
continue;
|
|
142
|
+
const values = await this.redis.mget(...keys);
|
|
143
|
+
keys.forEach((key, i) => {
|
|
144
|
+
const data = values[i];
|
|
142
145
|
if (!data)
|
|
143
|
-
|
|
146
|
+
return;
|
|
144
147
|
result.push({
|
|
145
148
|
id: key.slice(prefix.length),
|
|
146
149
|
payload: JSON.parse(data),
|
|
147
150
|
});
|
|
148
|
-
}
|
|
151
|
+
});
|
|
149
152
|
} while (cursor !== '0');
|
|
150
153
|
result.sort((a, b) => a.id.localeCompare(b.id));
|
|
151
154
|
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.#
|
|
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
|
|
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
|
|
193
|
-
const
|
|
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.#
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
}
|
|
@@ -33,7 +33,7 @@ export interface AccountDeletionWorkflowDeps {
|
|
|
33
33
|
* ```ts
|
|
34
34
|
* import { defineAccountDeletionWorkflow } from '@adonis-agora/authkit-server/durable'
|
|
35
35
|
* const wf = defineAccountDeletionWorkflow({ oidc: () => app.container.make('authkit.server') })
|
|
36
|
-
* engine.register(wf.name, wf.version, wf.body)
|
|
36
|
+
* engine.register(wf.name, wf.version, wf.body, wf.options)
|
|
37
37
|
* ```
|
|
38
38
|
*
|
|
39
39
|
* O corpo é FORWARD-ONLY (sem compensação — nunca des-deleta): cada etapa do
|
|
@@ -47,4 +47,8 @@ export declare function defineAccountDeletionWorkflow(deps: AccountDeletionWorkf
|
|
|
47
47
|
name: string;
|
|
48
48
|
version: string;
|
|
49
49
|
body: WorkflowBody;
|
|
50
|
+
/** Opções de `engine.register` — carimba `origin` com o nome deste pacote. */
|
|
51
|
+
options: {
|
|
52
|
+
origin: string;
|
|
53
|
+
};
|
|
50
54
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { anonymizeAudit, auditDeleted, deleteAccountAvatar, deleteAccountRow, disableMfa, removeFromOrgs, removePasskeys, revokePats, revokeSessions, snapshotAccount, unlinkProviders, } from '../account_deletion_ops.js';
|
|
2
|
+
import { PACKAGE_ORIGIN } from './package_origin.js';
|
|
2
3
|
/** Nome canônico do workflow durável de deleção de conta. */
|
|
3
4
|
export const ACCOUNT_DELETE_WORKFLOW = 'authkit.account.delete';
|
|
4
5
|
/** Estado inicial vazio do {@link DeletionResult} (acumulado etapa a etapa). */
|
|
@@ -26,7 +27,7 @@ function emptyResult() {
|
|
|
26
27
|
* ```ts
|
|
27
28
|
* import { defineAccountDeletionWorkflow } from '@adonis-agora/authkit-server/durable'
|
|
28
29
|
* const wf = defineAccountDeletionWorkflow({ oidc: () => app.container.make('authkit.server') })
|
|
29
|
-
* engine.register(wf.name, wf.version, wf.body)
|
|
30
|
+
* engine.register(wf.name, wf.version, wf.body, wf.options)
|
|
30
31
|
* ```
|
|
31
32
|
*
|
|
32
33
|
* O corpo é FORWARD-ONLY (sem compensação — nunca des-deleta): cada etapa do
|
|
@@ -82,5 +83,5 @@ export function defineAccountDeletionWorkflow(deps) {
|
|
|
82
83
|
result.ok = (await ctx.localStep('delete.account', async () => deleteAccountRow((await deps.oidc()).config, accountId))).ok;
|
|
83
84
|
return result;
|
|
84
85
|
};
|
|
85
|
-
return { name: ACCOUNT_DELETE_WORKFLOW, version: '1', body };
|
|
86
|
+
return { name: ACCOUNT_DELETE_WORKFLOW, version: '1', body, options: { origin: PACKAGE_ORIGIN } };
|
|
86
87
|
}
|
|
@@ -65,4 +65,8 @@ export declare function defineAccountExportWorkflow(deps: AccountExportWorkflowD
|
|
|
65
65
|
body: (ctx: DurableStepCtx & {
|
|
66
66
|
runId?: string;
|
|
67
67
|
}, input: AccountExportWorkflowInput) => Promise<unknown>;
|
|
68
|
+
/** Opções de `engine.register` — carimba `origin` com o nome deste pacote. */
|
|
69
|
+
options: {
|
|
70
|
+
origin: string;
|
|
71
|
+
};
|
|
68
72
|
};
|
|
@@ -7,6 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
import { AccountExportService } from '../account_export_service.js';
|
|
10
|
+
import { PACKAGE_ORIGIN } from './package_origin.js';
|
|
10
11
|
/** Nome canônico do workflow durável de export de dados de conta. */
|
|
11
12
|
export const ACCOUNT_EXPORT_WORKFLOW = 'authkit.account.export';
|
|
12
13
|
/** Persistência default: grava o JSON no disk do drive do app (best-effort). */
|
|
@@ -89,5 +90,5 @@ export function defineAccountExportWorkflow(deps) {
|
|
|
89
90
|
});
|
|
90
91
|
return { ok: true, artifactKey, bytes: json.length };
|
|
91
92
|
};
|
|
92
|
-
return { name: ACCOUNT_EXPORT_WORKFLOW, version: '1', body };
|
|
93
|
+
return { name: ACCOUNT_EXPORT_WORKFLOW, version: '1', body, options: { origin: PACKAGE_ORIGIN } };
|
|
93
94
|
}
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* const oidc = () => app.container.make('authkit.server')
|
|
22
22
|
* const del = defineAccountDeletionWorkflow({ oidc })
|
|
23
23
|
* const exp = defineAccountExportWorkflow({ oidc })
|
|
24
|
-
* engine.register(del.name, del.version, del.body)
|
|
25
|
-
* engine.register(exp.name, exp.version, exp.body)
|
|
24
|
+
* engine.register(del.name, del.version, del.body, del.options)
|
|
25
|
+
* engine.register(exp.name, exp.version, exp.body, exp.options)
|
|
26
26
|
* // ...e rode um worker (engine.runPending / runOne) p/ o grupo do authkit.
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
@@ -21,13 +21,14 @@
|
|
|
21
21
|
* const oidc = () => app.container.make('authkit.server')
|
|
22
22
|
* const del = defineAccountDeletionWorkflow({ oidc })
|
|
23
23
|
* const exp = defineAccountExportWorkflow({ oidc })
|
|
24
|
-
* engine.register(del.name, del.version, del.body)
|
|
25
|
-
* engine.register(exp.name, exp.version, exp.body)
|
|
24
|
+
* engine.register(del.name, del.version, del.body, del.options)
|
|
25
|
+
* engine.register(exp.name, exp.version, exp.body, exp.options)
|
|
26
26
|
* // ...e rode um worker (engine.runPending / runOne) p/ o grupo do authkit.
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
import { ACCOUNT_DELETE_WORKFLOW, } from './account_deletion_workflow.js';
|
|
30
30
|
import { ACCOUNT_EXPORT_WORKFLOW, } from './account_export_workflow.js';
|
|
31
|
+
import { PACKAGE_ORIGIN } from './package_origin.js';
|
|
31
32
|
export { ACCOUNT_DELETE_WORKFLOW, defineAccountDeletionWorkflow, } from './account_deletion_workflow.js';
|
|
32
33
|
export { ACCOUNT_EXPORT_WORKFLOW, defineAccountExportWorkflow, } from './account_export_workflow.js';
|
|
33
34
|
/**
|
|
@@ -53,7 +54,7 @@ export async function resolveWorkflowEngine(resolver) {
|
|
|
53
54
|
*/
|
|
54
55
|
export async function enqueueAccountDeletion(engine, input) {
|
|
55
56
|
const runId = `${ACCOUNT_DELETE_WORKFLOW}:${input.accountId}`;
|
|
56
|
-
await engine.start(ACCOUNT_DELETE_WORKFLOW, input, runId);
|
|
57
|
+
await engine.start(ACCOUNT_DELETE_WORKFLOW, input, runId, { origin: PACKAGE_ORIGIN });
|
|
57
58
|
return runId;
|
|
58
59
|
}
|
|
59
60
|
/**
|
|
@@ -62,7 +63,7 @@ export async function enqueueAccountDeletion(engine, input) {
|
|
|
62
63
|
*/
|
|
63
64
|
export async function enqueueAccountExport(engine, input) {
|
|
64
65
|
const runId = `${ACCOUNT_EXPORT_WORKFLOW}:${input.accountId}`;
|
|
65
|
-
await engine.start(ACCOUNT_EXPORT_WORKFLOW, input, runId);
|
|
66
|
+
await engine.start(ACCOUNT_EXPORT_WORKFLOW, input, runId, { origin: PACKAGE_ORIGIN });
|
|
66
67
|
return runId;
|
|
67
68
|
}
|
|
68
69
|
/**
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O nome npm deste pacote, carimbado como `origin` nos runs duráveis do authkit (ver
|
|
3
|
+
* `WorkflowRun.origin` do `@adonis-agora/durable`) — a faceta ORIGIN do console separa
|
|
4
|
+
* os runs da lib dos runs do próprio app. Requer durable >= 0.36 (engines mais antigos
|
|
5
|
+
* ignoram a opção).
|
|
6
|
+
*/
|
|
7
|
+
export declare const PACKAGE_ORIGIN = "@adonis-agora/authkit-server";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O nome npm deste pacote, carimbado como `origin` nos runs duráveis do authkit (ver
|
|
3
|
+
* `WorkflowRun.origin` do `@adonis-agora/durable`) — a faceta ORIGIN do console separa
|
|
4
|
+
* os runs da lib dos runs do próprio app. Requer durable >= 0.36 (engines mais antigos
|
|
5
|
+
* ignoram a opção).
|
|
6
|
+
*/
|
|
7
|
+
export const PACKAGE_ORIGIN = '@adonis-agora/authkit-server';
|
|
@@ -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
|
|
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>;
|