fix(api): return the terminated count from terminate sessions (#2539)

This commit is contained in:
Hampus
2026-09-06 18:38:46 +02:00
committed by GitHub
parent 2f159852a7
commit d9f983b08e
4 changed files with 14 additions and 27 deletions
@@ -315,7 +315,7 @@ export class AdminUserSecurityService {
if (!user) {
throw new UnknownUserError();
}
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
const terminatedCount = await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -324,6 +324,7 @@ export class AdminUserSecurityService {
auditLogReason,
metadata: new Map(),
});
return {terminated_count: terminatedCount};
}
async setUserAcls(
+3 -2
View File
@@ -178,18 +178,19 @@ export async function logoutAuthSessions(
});
}
export async function terminateAllUserSessions(ctx: ApiContext, userId: UserID): Promise<void> {
export async function terminateAllUserSessions(ctx: ApiContext, userId: UserID): Promise<number> {
const {users, gateway} = ctx.services;
const authSessions = await users.listAuthSessions(userId);
await users.deleteAllPushSubscriptions(userId);
await gateway.invalidatePushSubscriptions({userId});
if (authSessions.length === 0) return;
if (authSessions.length === 0) return 0;
const hashes = authSessions.map((s) => s.sessionIdHash);
await users.deleteAuthSessions(userId, hashes);
await gateway.terminateSession({
userId,
sessionIdHashes: authSessions.map((s) => Buffer.from(s.sessionIdHash).toString('base64url')),
});
return authSessions.length;
}
export async function replaceCurrentAuthSession(
+2 -21
View File
@@ -41,8 +41,6 @@ const STREAMED_RESPONSE_ROUTES = new Map([
['POST /admin/system/heap-snapshots', "'Content-Type': 'application/octet-stream'"],
]);
const EMPTY_RESPONSE_ROUTES = new Map([['DELETE /admin/users/{}/sessions', 'terminated_count']]);
const MERGED_SCHEMA_ROUTES = new Map([
['POST /auth/sessions/logout', "Validator('json', LogoutAuthSessionsRequest.merge(SudoVerificationSchema))"],
[
@@ -513,7 +511,7 @@ for (const file of await walk(DOCS_ROOT)) {
const resolvedTarget = resolveRef(spec, target);
const responseIsUnion =
resolvedTarget != null && ((resolvedTarget.oneOf ?? []).length > 0 || (resolvedTarget.anyOf ?? []).length > 0);
if (!STREAMED_RESPONSE_ROUTES.has(key) && !EMPTY_RESPONSE_ROUTES.has(key)) {
if (!STREAMED_RESPONSE_ROUTES.has(key)) {
const referencedTypes = new Map<string, string>();
for (const anchor of referenced) {
for (const [field, type] of anchorTypes.get(anchor) ?? []) {
@@ -542,12 +540,7 @@ for (const file of await walk(DOCS_ROOT)) {
});
}
}
if (
responseProperties.size > 0 &&
!responseIsUnion &&
!STREAMED_RESPONSE_ROUTES.has(key) &&
!EMPTY_RESPONSE_ROUTES.has(key)
) {
if (responseProperties.size > 0 && !responseIsUnion && !STREAMED_RESPONSE_ROUTES.has(key)) {
responsesChecked += 1;
for (const field of responseProperties) {
if (pageFields.has(field) || referencedFields.has(field)) {
@@ -707,15 +700,6 @@ for (const [route, anchor] of STREAMED_RESPONSE_ROUTES) {
staleExemptions.push(`${route}: no longer streams a file, drop this exemption`);
}
}
const adminUserSecuritySource = await readFile(
path.join(REPO_ROOT, 'fluxer_api/src/api/admin/services/AdminUserSecurityService.ts'),
'utf8',
);
for (const [route, marker] of EMPTY_RESPONSE_ROUTES) {
if (adminUserSecuritySource.includes(marker)) {
staleExemptions.push(`${route}: the handler now emits ${marker}, drop this exemption`);
}
}
for (const [route, anchor] of MERGED_SCHEMA_ROUTES) {
const present = authSource.includes(anchor) || guildSource.includes(anchor);
if (!present) {
@@ -743,9 +727,6 @@ console.log(`success response schemas checked: ${responsesChecked.toString()}`);
console.log(
`streamed-response exemptions active: ${STREAMED_RESPONSE_ROUTES.size.toString()} (the spec declares JSON, the implementation streams a file)`,
);
console.log(
`empty-response exemptions active: ${EMPTY_RESPONSE_ROUTES.size.toString()} (the spec declares a body, the handler returns none)`,
);
console.log(`response fields found documented on the page: ${responseFieldsFound.toString()}`);
console.log(`request body field types compared: ${typesCompared.toString()}`);
console.log(`request body optionality compared: ${optionalityCompared.toString()}`);
@@ -1838,12 +1838,16 @@ There is no operation that revokes one session. The account's Admin API keys, bo
| Status | Body | Condition |
| --- | --- | --- |
| 200<sup>1</sup> | empty<sup>2</sup> | Active sessions were terminated |
| 200<sup>1</sup> | [terminate sessions response](#terminate-sessions-response) | Active sessions were terminated |
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
<sup>1</sup> An account with no live session is accepted and still records the audit entry
<sup>1</sup> An account with no live session is accepted, returns `terminated_count` 0, and still records the audit entry
<sup>2</sup> The handler emits no body at all, so a client MUST NOT read the `terminated_count` member the spec declares
#### Terminate sessions response
| Field | Type | Description |
| --- | --- | --- |
| terminated_count | integer | The number of sessions that were terminated |
:::caution[A terminated session cannot be restored]
The tombstone stays visible through [List user sessions](#list-user-sessions), but the session itself is gone.