@podosoft/podokit 1.4.2 → 1.5.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/dist/deploy-compose-profile.js +2 -2
- package/dist/deploy-compose-render.js +1 -0
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +2 -0
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.js +3 -1
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +18 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/dot-env.docker.example +2 -0
- package/dist/templates/fullstack/dot-env.example +17 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +7 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +8 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { APP_BODY_LIMITS } from "./body-limits";
|
|
2
2
|
import { APP_FORWARDED_REQUEST_HEADERS, APP_RELAYED_RESPONSE_HEADERS } from "./proxy-headers";
|
|
3
|
+
import { APP_PROXY_TIMEOUTS } from "./proxy-timeouts";
|
|
3
4
|
|
|
4
5
|
// Server-side proxy boundary. The browser never talks to the API directly:
|
|
5
6
|
// requests go through SvelteKit server routes, which forward an allowlist of
|
|
@@ -118,46 +119,115 @@ export interface BodyLimit {
|
|
|
118
119
|
maxBytes: number;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
export
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
/** A literal path segment, or null for a `:name` parameter. */
|
|
123
|
+
type PathSegment = string | null;
|
|
124
|
+
|
|
125
|
+
/** An application path rule with its prefix split into segments. */
|
|
126
|
+
export type PathRule<T extends { pathPrefix: string }> = T & { readonly segments: readonly PathSegment[] };
|
|
127
|
+
|
|
128
|
+
const LITERAL_SEGMENT = /^[A-Za-z0-9._~!$&'()+,;=:@-]+$/;
|
|
129
|
+
const PARAMETER_SEGMENT = /^:[A-Za-z_][A-Za-z0-9_]*$/;
|
|
130
|
+
|
|
131
|
+
/** The segments of a prefix under /api/, or undefined when it is not one. */
|
|
132
|
+
function parsePathPrefix(pathPrefix: string): PathSegment[] | undefined {
|
|
133
|
+
const base = pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix;
|
|
134
|
+
const [root, ...segments] = base.split("/");
|
|
135
|
+
if (root !== "" || segments[0] !== "api") return undefined;
|
|
136
|
+
const parsed: PathSegment[] = [];
|
|
137
|
+
for (const segment of segments) {
|
|
138
|
+
if (PARAMETER_SEGMENT.test(segment)) {
|
|
139
|
+
parsed.push(null);
|
|
140
|
+
} else if (LITERAL_SEGMENT.test(segment) && !segment.startsWith(":") && segment !== "." && segment !== "..") {
|
|
141
|
+
parsed.push(segment);
|
|
142
|
+
} else {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return parsed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Below zero when `a` is more specific: more segments, then, from the left, a
|
|
151
|
+
* literal segment before a parameter.
|
|
152
|
+
*/
|
|
153
|
+
function compareSpecificity(a: readonly PathSegment[], b: readonly PathSegment[]): number {
|
|
154
|
+
if (a.length !== b.length) return b.length - a.length;
|
|
155
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
156
|
+
const literal = a[index] !== null;
|
|
157
|
+
if (literal !== (b[index] !== null)) return literal ? -1 : 1;
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Validate an application list of /api path prefixes and order it so the
|
|
163
|
+
* most specific prefix matches first. Prefixes are stored without a trailing
|
|
164
|
+
* slash. */
|
|
165
|
+
function sortedByPrefix<T extends { pathPrefix: string }>(
|
|
166
|
+
entries: ReadonlyArray<T>,
|
|
167
|
+
listName: string,
|
|
168
|
+
example: string,
|
|
169
|
+
check: (entry: T) => void,
|
|
170
|
+
): PathRule<T>[] {
|
|
171
|
+
const seen = new Map<string, string>();
|
|
172
|
+
const rules = entries.map((entry) => {
|
|
173
|
+
const { pathPrefix } = entry;
|
|
174
|
+
const segments = parsePathPrefix(pathPrefix);
|
|
175
|
+
if (!segments) {
|
|
132
176
|
throw new Error(
|
|
133
|
-
|
|
177
|
+
`${listName} pathPrefix must be a path under /api/ made of literal segments and ":name" parameters, ` +
|
|
178
|
+
`such as "${example}": ${JSON.stringify(pathPrefix)}`,
|
|
134
179
|
);
|
|
135
180
|
}
|
|
136
|
-
|
|
137
|
-
|
|
181
|
+
check(entry);
|
|
182
|
+
// Parameter names do not change which paths a prefix covers.
|
|
183
|
+
const key = segments.map((segment) => segment ?? ":").join("/");
|
|
184
|
+
const listed = seen.get(key);
|
|
185
|
+
if (listed !== undefined) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`${listName} lists the same paths twice: ${JSON.stringify(listed)} and ${JSON.stringify(pathPrefix)}.`,
|
|
188
|
+
);
|
|
138
189
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
seen.add(base);
|
|
142
|
-
return { pathPrefix: base, maxBytes };
|
|
190
|
+
seen.set(key, pathPrefix);
|
|
191
|
+
return { ...entry, pathPrefix: pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix, segments };
|
|
143
192
|
});
|
|
144
|
-
return
|
|
193
|
+
return rules.sort((left, right) => compareSpecificity(left.segments, right.segments));
|
|
145
194
|
}
|
|
146
195
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
196
|
+
/** The most specific rule for a request path. Prefixes match whole segments of
|
|
197
|
+
* the decoded path, so an encoded segment cannot step around them, and a
|
|
198
|
+
* parameter matches exactly one non-empty segment. */
|
|
199
|
+
function entryFor<T extends { pathPrefix: string }>(
|
|
200
|
+
pathname: string,
|
|
201
|
+
rules: readonly PathRule<T>[],
|
|
202
|
+
): PathRule<T> | undefined {
|
|
152
203
|
let path = pathname;
|
|
153
204
|
try {
|
|
154
205
|
path = decodeURIComponent(pathname);
|
|
155
206
|
} catch {
|
|
156
207
|
// Keep the raw path when it holds a malformed escape.
|
|
157
208
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
209
|
+
const segments = path.split("/").slice(1);
|
|
210
|
+
return rules.find(
|
|
211
|
+
(rule) =>
|
|
212
|
+
rule.segments.length <= segments.length &&
|
|
213
|
+
rule.segments.every((segment, index) =>
|
|
214
|
+
segment === null ? segments[index] !== "" : segment === segments[index],
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function sortedBodyLimits(limits: ReadonlyArray<BodyLimit>): PathRule<BodyLimit>[] {
|
|
220
|
+
return sortedByPrefix(limits, "APP_BODY_LIMITS", "/api/feedback/", ({ pathPrefix, maxBytes }) => {
|
|
221
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
222
|
+
throw new Error(`APP_BODY_LIMITS maxBytes for ${pathPrefix} must be a positive whole number of bytes.`);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const bodyLimits = sortedBodyLimits(APP_BODY_LIMITS);
|
|
228
|
+
|
|
229
|
+
export function bodyLimitFor(pathname: string, limits: readonly PathRule<BodyLimit>[] = bodyLimits): number | undefined {
|
|
230
|
+
return entryFor(pathname, limits)?.maxBytes;
|
|
161
231
|
}
|
|
162
232
|
|
|
163
233
|
/** Read a request body, or return null as soon as it exceeds maxBytes. The rest
|
|
@@ -220,13 +290,15 @@ export function backendBaseUrl(): string {
|
|
|
220
290
|
|
|
221
291
|
/**
|
|
222
292
|
* Milliseconds one server-side API call may take, response body included, and
|
|
223
|
-
* how
|
|
224
|
-
*
|
|
293
|
+
* how often the proxy checks whether the API still answers at all while it waits
|
|
294
|
+
* for response headers.
|
|
225
295
|
*/
|
|
226
296
|
const DEFAULT_BACKEND_TIMEOUT_MS = 2_000;
|
|
227
297
|
/**
|
|
228
|
-
* Milliseconds the proxy waits for the API's response headers,
|
|
229
|
-
* API's default idle timeout
|
|
298
|
+
* Milliseconds the proxy waits for the API's response headers, unless
|
|
299
|
+
* APP_PROXY_TIMEOUTS lists the path. The API's default idle timeout of 35
|
|
300
|
+
* seconds outlasts it.
|
|
301
|
+
* Streamed bodies are not cut off once they start.
|
|
230
302
|
*/
|
|
231
303
|
const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
|
|
232
304
|
/** Seconds between checks while the API is unreachable, also sent as Retry-After. */
|
|
@@ -259,10 +331,84 @@ function backendProxyTimeoutMs(): number {
|
|
|
259
331
|
backendTimeoutMs();
|
|
260
332
|
backendProxyTimeoutMs();
|
|
261
333
|
|
|
334
|
+
export interface ProxyTimeout {
|
|
335
|
+
pathPrefix: string;
|
|
336
|
+
/** Milliseconds to wait for response headers, or null for no deadline. */
|
|
337
|
+
timeoutMs: number | null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function sortedProxyTimeouts(timeouts: ReadonlyArray<ProxyTimeout>): PathRule<ProxyTimeout>[] {
|
|
341
|
+
return sortedByPrefix(timeouts, "APP_PROXY_TIMEOUTS", "/api/exports/", ({ pathPrefix, timeoutMs }) => {
|
|
342
|
+
if (timeoutMs !== null && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMER_MS)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`APP_PROXY_TIMEOUTS timeoutMs for ${pathPrefix} must be null or a whole number of milliseconds from 1 to ${MAX_TIMER_MS}.`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const proxyTimeouts = sortedProxyTimeouts(APP_PROXY_TIMEOUTS);
|
|
351
|
+
|
|
352
|
+
/** How long the proxy waits for response headers on a request path, or null for no deadline. */
|
|
353
|
+
export function proxyTimeoutFor(
|
|
354
|
+
pathname: string,
|
|
355
|
+
timeouts: readonly PathRule<ProxyTimeout>[] = proxyTimeouts,
|
|
356
|
+
): number | null {
|
|
357
|
+
const entry = entryFor(pathname, timeouts);
|
|
358
|
+
return entry ? entry.timeoutMs : backendProxyTimeoutMs();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Bun closes silent connections on a 4-second tick, up to 4 seconds before their idle timeout. */
|
|
362
|
+
const IDLE_TICK_SECONDS = 4;
|
|
363
|
+
/** The longest idle timeout Bun accepts; 0 turns it off. */
|
|
364
|
+
const MAX_IDLE_TIMEOUT_SECONDS = 255;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Throw unless the web server keeps a silent connection open longer than the
|
|
368
|
+
* proxy waits for response headers. adapter-bun takes its idle timeout from
|
|
369
|
+
* CONNECTION_IDLE_TIMEOUT (Bun's 10 seconds when unset) and closes a connection
|
|
370
|
+
* that sends nothing for that long without a response, so neither the API's late
|
|
371
|
+
* answer nor the proxy's 504 would reach the browser.
|
|
372
|
+
*/
|
|
373
|
+
export function checkIdleTimeout(
|
|
374
|
+
idleTimeout: string | undefined,
|
|
375
|
+
defaultTimeoutMs: number,
|
|
376
|
+
timeouts: readonly ProxyTimeout[],
|
|
377
|
+
): void {
|
|
378
|
+
// adapter-bun rejects a malformed value itself when it starts.
|
|
379
|
+
if (idleTimeout === undefined || !/^\d+$/.test(idleTimeout) || Number(idleTimeout) === 0) return;
|
|
380
|
+
let longest: { timeoutMs: number | null; source: string } = {
|
|
381
|
+
timeoutMs: defaultTimeoutMs,
|
|
382
|
+
source: "BACKEND_PROXY_TIMEOUT_MS",
|
|
383
|
+
};
|
|
384
|
+
for (const { pathPrefix, timeoutMs } of timeouts) {
|
|
385
|
+
if (longest.timeoutMs !== null && (timeoutMs === null || timeoutMs > longest.timeoutMs)) {
|
|
386
|
+
longest = { timeoutMs, source: `APP_PROXY_TIMEOUTS for ${pathPrefix}` };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const seconds = Number(idleTimeout);
|
|
390
|
+
if (longest.timeoutMs !== null && (seconds - IDLE_TICK_SECONDS) * 1000 > longest.timeoutMs) return;
|
|
391
|
+
const minimum =
|
|
392
|
+
longest.timeoutMs === null ? Infinity : Math.floor(longest.timeoutMs / 1000) + IDLE_TICK_SECONDS + 1;
|
|
393
|
+
const wait = longest.timeoutMs === null ? "no deadline" : `${longest.timeoutMs} ms`;
|
|
394
|
+
const fix =
|
|
395
|
+
minimum <= MAX_IDLE_TIMEOUT_SECONDS
|
|
396
|
+
? `at least ${minimum}, or to 0 to turn the idle timeout off`
|
|
397
|
+
: `0 to turn the idle timeout off, since Bun allows at most ${MAX_IDLE_TIMEOUT_SECONDS} seconds`;
|
|
398
|
+
throw new Error(
|
|
399
|
+
`CONNECTION_IDLE_TIMEOUT=${seconds} lets the web server close a silent connection before the /api proxy ` +
|
|
400
|
+
`stops waiting for response headers (${wait}, ${longest.source}); Bun closes idle connections up to ` +
|
|
401
|
+
`${IDLE_TICK_SECONDS} seconds early. Set CONNECTION_IDLE_TIMEOUT to ${fix}.`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
checkIdleTimeout(process.env.CONNECTION_IDLE_TIMEOUT, backendProxyTimeoutMs(), proxyTimeouts);
|
|
406
|
+
|
|
262
407
|
// The API counts as unreachable only when GET /health gets no HTTP answer
|
|
263
|
-
// within BACKEND_TIMEOUT_MS. It is checked when a request gets no answer,
|
|
264
|
-
//
|
|
265
|
-
// endpoint does not fail every request in the process
|
|
408
|
+
// within BACKEND_TIMEOUT_MS. It is checked when a request gets no answer, and
|
|
409
|
+
// every BACKEND_TIMEOUT_MS while the proxy waits for response headers, so one
|
|
410
|
+
// slow endpoint does not fail every request in the process, while an API that
|
|
411
|
+
// stops answering still ends a long or unbounded wait. Once the API is found
|
|
266
412
|
// unreachable, requests still waiting for it fail, new ones fail at once instead
|
|
267
413
|
// of each waiting for a refused connection, a DNS lookup, or a hung server, and
|
|
268
414
|
// a background check runs at most every BACKEND_RETRY_AFTER_SECONDS. Any HTTP
|
|
@@ -303,6 +449,11 @@ export function checkBackend(): Promise<void> {
|
|
|
303
449
|
return pendingCheck;
|
|
304
450
|
}
|
|
305
451
|
|
|
452
|
+
/** Check the API unless a check is running or one ended within BACKEND_TIMEOUT_MS. */
|
|
453
|
+
function recheckBackend(now = Date.now()): void {
|
|
454
|
+
if (!pendingCheck && now - lastCheckEnded >= backendTimeoutMs()) void checkBackend();
|
|
455
|
+
}
|
|
456
|
+
|
|
306
457
|
/** Record an HTTP answer from the API, whatever its status. */
|
|
307
458
|
export function reportBackendAnswer(): void {
|
|
308
459
|
answers += 1;
|
|
@@ -400,15 +551,19 @@ export async function proxyRequest(
|
|
|
400
551
|
body = limited;
|
|
401
552
|
}
|
|
402
553
|
}
|
|
403
|
-
// The deadline, the health
|
|
554
|
+
// The deadline, the health checks, and the outage signal cover only the wait
|
|
404
555
|
// for response headers, so streamed bodies such as server-sent events are not
|
|
405
556
|
// cut off.
|
|
406
557
|
const waiting = new AbortController();
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
558
|
+
const timeoutMs = proxyTimeoutFor(pathname);
|
|
559
|
+
const deadline =
|
|
560
|
+
timeoutMs === null
|
|
561
|
+
? undefined
|
|
562
|
+
: setTimeout(
|
|
563
|
+
() => waiting.abort(new DOMException("The API did not respond in time", "TimeoutError")),
|
|
564
|
+
timeoutMs,
|
|
565
|
+
);
|
|
566
|
+
const healthChecks = setInterval(() => recheckBackend(), backendTimeoutMs());
|
|
412
567
|
const outage = backendOutageSignal();
|
|
413
568
|
const failOnOutage = (): void => waiting.abort(outage.reason);
|
|
414
569
|
outage.addEventListener("abort", failOnOutage);
|
|
@@ -432,7 +587,7 @@ export async function proxyRequest(
|
|
|
432
587
|
return backendFailure(pathname, waiting.signal.aborted ? 504 : 503);
|
|
433
588
|
} finally {
|
|
434
589
|
clearTimeout(deadline);
|
|
435
|
-
|
|
590
|
+
clearInterval(healthChecks);
|
|
436
591
|
outage.removeEventListener("abort", failOnOutage);
|
|
437
592
|
}
|
|
438
593
|
reportBackendAnswer();
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* proxies to the API. An entry caps the body of every request whose path is
|
|
4
4
|
* pathPrefix or below it, for example
|
|
5
5
|
* { pathPrefix: "/api/feedback/", maxBytes: 64 * 1024 }
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* { pathPrefix: "/api/hosts/:hostId/uploads", maxBytes: 1024 * 1024 }
|
|
7
|
+
* where a :name segment matches exactly one non-empty path segment. The most
|
|
8
|
+
* specific entry wins: more segments, then a literal segment over a parameter
|
|
9
|
+
* from the left. Above the limit the proxy stops reading and answers 413
|
|
10
|
+
* PAYLOAD_TOO_LARGE without contacting the API.
|
|
8
11
|
*
|
|
9
12
|
* BODY_SIZE_LIMIT remains the ceiling for every web route: Bun rejects a larger
|
|
10
13
|
* body before this code runs, so a maxBytes above it has no effect. Routes that
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-owned deadlines for /api requests that the web server proxies to
|
|
3
|
+
* the API. An entry replaces BACKEND_PROXY_TIMEOUT_MS for every request whose
|
|
4
|
+
* path is pathPrefix or below it, for example
|
|
5
|
+
* { pathPrefix: "/api/exports/", timeoutMs: 120_000 }
|
|
6
|
+
* { pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }
|
|
7
|
+
* where a :name segment matches exactly one non-empty path segment. The most
|
|
8
|
+
* specific entry wins: more segments, then a literal segment over a parameter
|
|
9
|
+
* from the left. timeoutMs is how long the proxy waits for the API's response
|
|
10
|
+
* headers before it answers 504 BACKEND_TIMEOUT; null waits as long as the API
|
|
11
|
+
* takes. Either way the proxy answers 503 BACKEND_UNAVAILABLE once the API's
|
|
12
|
+
* GET /health stops answering, and the deadline ends when the response headers
|
|
13
|
+
* arrive.
|
|
14
|
+
*
|
|
15
|
+
* Bun closes a silent connection up to 4 seconds before its idle timeout, so a
|
|
16
|
+
* longer wait also needs the API's API_IDLE_TIMEOUT and the web server's
|
|
17
|
+
* CONNECTION_IDLE_TIMEOUT (35 seconds each by default) more than 4 seconds above
|
|
18
|
+
* the deadline, or 0 for null or anything above 250 seconds. The proxy refuses
|
|
19
|
+
* to load when CONNECTION_IDLE_TIMEOUT cannot cover an entry here.
|
|
20
|
+
* PodoKit creates this file once and never overwrites it.
|
|
21
|
+
*/
|
|
22
|
+
export const APP_PROXY_TIMEOUTS: ReadonlyArray<{ pathPrefix: string; timeoutMs: number | null }> = [];
|
|
@@ -12,6 +12,8 @@ POSTGRES_USER=podokit
|
|
|
12
12
|
POSTGRES_PASSWORD=podokit
|
|
13
13
|
POSTGRES_DB=podokit
|
|
14
14
|
BACKEND_INTERNAL_URL=http://api:5002
|
|
15
|
+
# API: the web container and the gateway are the one proxy in front of it.
|
|
16
|
+
TRUSTED_PROXY_HOPS=1
|
|
15
17
|
# Web: milliseconds per server-side API call, and the /api proxy's wait for
|
|
16
18
|
# response headers.
|
|
17
19
|
# BACKEND_TIMEOUT_MS=2000
|
|
@@ -27,6 +27,18 @@ POSTGRES_DB=podokit
|
|
|
27
27
|
# Largest API request body in bytes (Bun's default is 128 MiB). Requests through
|
|
28
28
|
# the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
|
|
29
29
|
# API_MAX_REQUEST_BODY_SIZE=134217728
|
|
30
|
+
# Seconds the API keeps a silent connection open (0 to 255, 0 turns it off). Keep
|
|
31
|
+
# it, like CONNECTION_IDLE_TIMEOUT below, more than 4 above the longest /api proxy
|
|
32
|
+
# deadline in seconds.
|
|
33
|
+
# API_IDLE_TIMEOUT=35
|
|
34
|
+
|
|
35
|
+
# Reverse proxies in front of the API whose X-Forwarded-For names the caller. The
|
|
36
|
+
# web app's /api proxy is one, so 1 records the browser's address for sessions,
|
|
37
|
+
# audit entries, and rate limits; 0 uses the socket peer. Count only proxies that
|
|
38
|
+
# every request passes: with 1, a caller that reaches the API directly chooses
|
|
39
|
+
# its own recorded address.
|
|
40
|
+
TRUSTED_PROXY_HOPS=1
|
|
41
|
+
# TRUSTED_PROXY_HEADER=x-forwarded-for
|
|
30
42
|
|
|
31
43
|
# Readiness (GET /health/ready): the longest one dependency check may run, and how
|
|
32
44
|
# long a result is reused across probes (0 checks on every request).
|
|
@@ -39,13 +51,17 @@ BACKEND_INTERNAL_URL=http://localhost:5002
|
|
|
39
51
|
# waits for response headers (504 BACKEND_TIMEOUT after the second value). When
|
|
40
52
|
# the API's GET /health does not answer within BACKEND_TIMEOUT_MS either, API
|
|
41
53
|
# calls fail at once until it does. Raise BACKEND_PROXY_TIMEOUT_MS together with
|
|
42
|
-
#
|
|
54
|
+
# API_IDLE_TIMEOUT above and CONNECTION_IDLE_TIMEOUT below.
|
|
43
55
|
# BACKEND_TIMEOUT_MS=2000
|
|
44
56
|
# BACKEND_PROXY_TIMEOUT_MS=30000
|
|
45
57
|
|
|
46
58
|
CORS_ORIGIN=http://localhost:5001
|
|
47
59
|
# Web request limit; leave room for a 2 MiB multipart upload
|
|
48
60
|
BODY_SIZE_LIMIT=3M
|
|
61
|
+
# Seconds the built web server keeps a silent connection open (0 to 255, 0 turns
|
|
62
|
+
# it off). Keep it more than 4 above the longest /api proxy deadline in seconds,
|
|
63
|
+
# BACKEND_PROXY_TIMEOUT_MS or APP_PROXY_TIMEOUTS; the proxy refuses to load otherwise.
|
|
64
|
+
CONNECTION_IDLE_TIMEOUT=35
|
|
49
65
|
|
|
50
66
|
# ── Optional / development ───────────────────────────────────────────
|
|
51
67
|
# Redis — only needed by cache/queue modules (redis, bullmq, rate-limit,
|
|
@@ -9,6 +9,9 @@ data:
|
|
|
9
9
|
CORS_ORIGIN: "https://example.com"
|
|
10
10
|
BACKEND_INTERNAL_URL: "http://api:3000"
|
|
11
11
|
BODY_SIZE_LIMIT: "3M"
|
|
12
|
+
CONNECTION_IDLE_TIMEOUT: "35"
|
|
13
|
+
API_IDLE_TIMEOUT: "35"
|
|
14
|
+
TRUSTED_PROXY_HOPS: "1"
|
|
12
15
|
PROTOCOL_HEADER: "x-forwarded-proto"
|
|
13
16
|
HOST_HEADER: "x-forwarded-host"
|
|
14
17
|
ADDRESS_HEADER: "x-forwarded-for"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
4
|
+
import { Elysia } from "elysia";
|
|
5
|
+
import type { AuthService, AuthSession } from "../auth/auth.service";
|
|
6
|
+
import { AdminSessionsService } from "../admin-sessions/admin-sessions.service";
|
|
7
|
+
import {
|
|
8
|
+
accountSessionRoutes,
|
|
9
|
+
type AccountSessionPage,
|
|
10
|
+
type OwnSessionRevoker,
|
|
11
|
+
} from "./account-sessions.routes";
|
|
12
|
+
|
|
13
|
+
// More sessions than Better Auth's /list-sessions returns.
|
|
14
|
+
const OTHER_SESSIONS = 150;
|
|
15
|
+
const DAY_MS = 86_400_000;
|
|
16
|
+
|
|
17
|
+
// Callers name their session with a test header; its row says whose it is.
|
|
18
|
+
function authFor(db: SQL): Pick<AuthService, "requireSession"> {
|
|
19
|
+
return {
|
|
20
|
+
requireSession: async (request: Request): Promise<AuthSession> => {
|
|
21
|
+
const id = request.headers.get("x-test-session");
|
|
22
|
+
const [row] = id
|
|
23
|
+
? await db<Array<{ userId: string; createdAt: string }>>`
|
|
24
|
+
SELECT "userId", "createdAt" FROM "session" WHERE "id" = ${id}
|
|
25
|
+
`
|
|
26
|
+
: [];
|
|
27
|
+
if (!row) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
28
|
+
return { user: { id: row.userId }, session: { id, createdAt: row.createdAt } };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("account session routes", () => {
|
|
34
|
+
let db: SQL;
|
|
35
|
+
let revoke: Mock<OwnSessionRevoker>;
|
|
36
|
+
let freshAge: number;
|
|
37
|
+
let handle: (request: Request) => Promise<Response>;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
db = new SQL(":memory:");
|
|
42
|
+
await db`CREATE TABLE "user" ("id" TEXT PRIMARY KEY, "email" TEXT NOT NULL, "name" TEXT NOT NULL)`;
|
|
43
|
+
await db`
|
|
44
|
+
CREATE TABLE "session" (
|
|
45
|
+
"id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, "token" TEXT NOT NULL,
|
|
46
|
+
"ipAddress" TEXT, "userAgent" TEXT, "impersonatedBy" TEXT,
|
|
47
|
+
"createdAt" TEXT NOT NULL, "expiresAt" TEXT NOT NULL
|
|
48
|
+
)
|
|
49
|
+
`;
|
|
50
|
+
await db`INSERT INTO "user" VALUES ('user-1', 'one@example.com', 'One'), ('user-2', 'two@example.com', 'Two')`;
|
|
51
|
+
const expires = new Date(now + DAY_MS).toISOString();
|
|
52
|
+
// The current session is the oldest, so newest-first paging would put it last.
|
|
53
|
+
await db`
|
|
54
|
+
INSERT INTO "session" VALUES ('current', 'user-1', 'secret-current', '203.0.113.7', 'Agent',
|
|
55
|
+
NULL, ${new Date(now - 60_000).toISOString()}, ${expires})
|
|
56
|
+
`;
|
|
57
|
+
for (let index = 0; index < OTHER_SESSIONS; index += 1) {
|
|
58
|
+
await db`
|
|
59
|
+
INSERT INTO "session" VALUES (${`other-${index}`}, 'user-1', ${`secret-${index}`}, NULL, NULL, NULL,
|
|
60
|
+
${new Date(now - 30_000 + index).toISOString()}, ${expires})
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
await db`
|
|
64
|
+
INSERT INTO "session" VALUES ('foreign', 'user-2', 'secret-foreign', NULL, NULL, NULL,
|
|
65
|
+
${new Date(now).toISOString()}, ${expires})
|
|
66
|
+
`;
|
|
67
|
+
revoke = mock<OwnSessionRevoker>(async () => undefined);
|
|
68
|
+
freshAge = 86_400;
|
|
69
|
+
const app = new Elysia()
|
|
70
|
+
.onError(({ code, error, set }) => {
|
|
71
|
+
if (error instanceof AppException) {
|
|
72
|
+
set.status = error.statusCode;
|
|
73
|
+
return { code: error.code };
|
|
74
|
+
}
|
|
75
|
+
if (code === "VALIDATION") {
|
|
76
|
+
set.status = 400;
|
|
77
|
+
return { code: "VALIDATION_ERROR" };
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
})
|
|
81
|
+
.use(accountSessionRoutes({
|
|
82
|
+
auth: authFor(db),
|
|
83
|
+
sessions: new AdminSessionsService(db, "sqlite"),
|
|
84
|
+
revoke,
|
|
85
|
+
freshAgeSeconds: async () => freshAge,
|
|
86
|
+
}));
|
|
87
|
+
handle = (request) => app.handle(request);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(async () => {
|
|
91
|
+
await db.close();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function send(path: string, init: RequestInit = {}, session: string | null = "current"): Promise<Response> {
|
|
95
|
+
const headers = new Headers(init.headers);
|
|
96
|
+
if (session) headers.set("x-test-session", session);
|
|
97
|
+
return handle(new Request(`http://localhost${path}`, { ...init, headers }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("returns the current session on every page and pages through every other one", async () => {
|
|
101
|
+
const seen: string[] = [];
|
|
102
|
+
let cursor: string | null = null;
|
|
103
|
+
let pages = 0;
|
|
104
|
+
do {
|
|
105
|
+
const response = await send(`/account/sessions?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
|
|
106
|
+
expect(response.status).toBe(200);
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
expect(text).not.toContain("secret-");
|
|
109
|
+
const page = JSON.parse(text) as AccountSessionPage;
|
|
110
|
+
expect(page.current).toMatchObject({ id: "current", ipAddress: "203.0.113.7", userAgent: "Agent" });
|
|
111
|
+
expect(page.total).toBe(OTHER_SESSIONS + 1);
|
|
112
|
+
seen.push(...page.items.map((item) => item.id));
|
|
113
|
+
cursor = page.nextCursor;
|
|
114
|
+
pages += 1;
|
|
115
|
+
} while (cursor);
|
|
116
|
+
|
|
117
|
+
expect(pages).toBe(2);
|
|
118
|
+
expect(seen).toHaveLength(OTHER_SESSIONS);
|
|
119
|
+
expect(new Set(seen).size).toBe(OTHER_SESSIONS);
|
|
120
|
+
expect(seen).not.toContain("current");
|
|
121
|
+
expect(seen).not.toContain("foreign");
|
|
122
|
+
expect(seen[0]).toBe(`other-${OTHER_SESSIONS - 1}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("refuses anonymous callers and a session too old to list sessions", async () => {
|
|
126
|
+
expect((await send("/account/sessions", {}, null)).status).toBe(401);
|
|
127
|
+
await db`UPDATE "session" SET "createdAt" = ${new Date(now - 2 * DAY_MS).toISOString()} WHERE "id" = 'current'`;
|
|
128
|
+
const stale = await send("/account/sessions");
|
|
129
|
+
expect(stale.status).toBe(403);
|
|
130
|
+
expect(await stale.json()).toEqual({ code: "SESSION_NOT_FRESH" });
|
|
131
|
+
freshAge = 0;
|
|
132
|
+
expect((await send("/account/sessions")).status).toBe(200);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("revokes one of the caller's sessions by id with the token looked up on the server", async () => {
|
|
136
|
+
const response = await send("/account/sessions/other-3", { method: "DELETE", headers: { cookie: "session=1" } });
|
|
137
|
+
|
|
138
|
+
expect(response.status).toBe(204);
|
|
139
|
+
expect(revoke).toHaveBeenCalledTimes(1);
|
|
140
|
+
const [token, headers] = revoke.mock.calls[0] ?? [];
|
|
141
|
+
expect(token).toBe("secret-3");
|
|
142
|
+
expect(headers?.get("cookie")).toBe("session=1");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("does not reveal or revoke another user's session", async () => {
|
|
146
|
+
const response = await send("/account/sessions/foreign", { method: "DELETE" });
|
|
147
|
+
|
|
148
|
+
expect(response.status).toBe(404);
|
|
149
|
+
expect(await response.json()).toEqual({ code: "ACCOUNT_SESSION_NOT_FOUND" });
|
|
150
|
+
expect(revoke).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { Elysia, t } from "elysia";
|
|
3
|
+
import type { AuthService, AuthSession } from "../auth/auth.service";
|
|
4
|
+
import type { ElysiaPlugin } from "../core/services";
|
|
5
|
+
import {
|
|
6
|
+
ADMIN_SESSIONS_MAX_LIMIT,
|
|
7
|
+
type AdminSessionsService,
|
|
8
|
+
type AdminSessionView,
|
|
9
|
+
} from "../admin-sessions/admin-sessions.service";
|
|
10
|
+
|
|
11
|
+
export const ACCOUNT_SESSION_NOT_FOUND = "ACCOUNT_SESSION_NOT_FOUND";
|
|
12
|
+
/** Better Auth's code for a session older than `session.freshAge`. */
|
|
13
|
+
export const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
|
|
14
|
+
|
|
15
|
+
/** One of the caller's own sessions. The bearer token never leaves the server. */
|
|
16
|
+
export interface AccountSessionView {
|
|
17
|
+
id: string;
|
|
18
|
+
ipAddress: string | null;
|
|
19
|
+
userAgent: string | null;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
expiresAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AccountSessionPage {
|
|
25
|
+
/** The session making the request, on every page. */
|
|
26
|
+
current: AccountSessionView | null;
|
|
27
|
+
/** The caller's other active sessions, newest first. */
|
|
28
|
+
items: AccountSessionView[];
|
|
29
|
+
nextCursor: string | null;
|
|
30
|
+
/** Every active session of the caller, the current one included. */
|
|
31
|
+
total: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Revokes one of the caller's sessions by its bearer token. */
|
|
35
|
+
export type OwnSessionRevoker = (sessionToken: string, headers: Headers) => Promise<void>;
|
|
36
|
+
|
|
37
|
+
export interface AccountSessionRoutesOptions {
|
|
38
|
+
auth: Pick<AuthService, "requireSession">;
|
|
39
|
+
sessions: AdminSessionsService;
|
|
40
|
+
revoke: OwnSessionRevoker;
|
|
41
|
+
/** Better Auth's `session.freshAge` in seconds; 0 turns the check off. */
|
|
42
|
+
freshAgeSeconds: () => Promise<number>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function accountView(session: AdminSessionView): AccountSessionView {
|
|
46
|
+
return {
|
|
47
|
+
id: session.id,
|
|
48
|
+
ipAddress: session.ipAddress,
|
|
49
|
+
userAgent: session.userAgent,
|
|
50
|
+
createdAt: session.createdAt,
|
|
51
|
+
expiresAt: session.expiresAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Better Auth lists sessions only for a fresh session; this list keeps that rule.
|
|
56
|
+
function requireFresh(session: AuthSession, freshAgeSeconds: number): void {
|
|
57
|
+
if (freshAgeSeconds === 0) return;
|
|
58
|
+
const createdAt = new Date(session.session.createdAt as string | Date).getTime();
|
|
59
|
+
if (Date.now() - createdAt >= freshAgeSeconds * 1000) {
|
|
60
|
+
throw new AppException(SESSION_NOT_FRESH, "Session is not fresh", 403);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The signed-in user's own sessions, paged on the server. Better Auth's
|
|
66
|
+
* `/list-sessions` returns at most 100 sessions, which could leave out the
|
|
67
|
+
* current one.
|
|
68
|
+
*/
|
|
69
|
+
export function accountSessionRoutes({
|
|
70
|
+
auth,
|
|
71
|
+
sessions,
|
|
72
|
+
revoke,
|
|
73
|
+
freshAgeSeconds,
|
|
74
|
+
}: AccountSessionRoutesOptions): ElysiaPlugin {
|
|
75
|
+
return new Elysia({ name: "podokit.account.sessions" })
|
|
76
|
+
.get("/account/sessions", async ({ request, query }) => {
|
|
77
|
+
const session = await auth.requireSession(request);
|
|
78
|
+
requireFresh(session, await freshAgeSeconds());
|
|
79
|
+
const currentId = String(session.session.id);
|
|
80
|
+
const [page, current] = await Promise.all([
|
|
81
|
+
sessions.list({ ...query, userId: session.user.id, excludeId: currentId }),
|
|
82
|
+
sessions.find(currentId),
|
|
83
|
+
]);
|
|
84
|
+
const result: AccountSessionPage = {
|
|
85
|
+
current: current ? accountView(current) : null,
|
|
86
|
+
items: page.items.map(accountView),
|
|
87
|
+
nextCursor: page.nextCursor,
|
|
88
|
+
total: page.total + (current ? 1 : 0),
|
|
89
|
+
};
|
|
90
|
+
return result;
|
|
91
|
+
}, {
|
|
92
|
+
query: t.Object({
|
|
93
|
+
cursor: t.Optional(t.String({ minLength: 1, maxLength: 512 })),
|
|
94
|
+
limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_SESSIONS_MAX_LIMIT })),
|
|
95
|
+
}),
|
|
96
|
+
detail: {
|
|
97
|
+
tags: ["account"],
|
|
98
|
+
summary: "List the current user's active sessions",
|
|
99
|
+
description: "`current` is the session making the request; `items` are the other sessions, newest first, keyset-paged with `cursor`. Session tokens are never returned. A session older than Better Auth's `session.freshAge` gets 403 `SESSION_NOT_FRESH`, as with `/api/auth/list-sessions`.",
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
.delete("/account/sessions/:id", async ({ request, params, set }) => {
|
|
103
|
+
const session = await auth.requireSession(request);
|
|
104
|
+
const token = await sessions.tokenOf(params.id, session.user.id);
|
|
105
|
+
if (!token) throw new AppException(ACCOUNT_SESSION_NOT_FOUND, "Session not found.", 404);
|
|
106
|
+
await revoke(token, request.headers);
|
|
107
|
+
set.status = 204;
|
|
108
|
+
}, {
|
|
109
|
+
params: t.Object({ id: t.String({ minLength: 1, maxLength: 255 }) }),
|
|
110
|
+
detail: { tags: ["account"], summary: "Revoke one of the current user's sessions by id" },
|
|
111
|
+
});
|
|
112
|
+
}
|