운영 보안을 보존하여 빌드 개선 배포본 통합
This commit is contained in:
@@ -14,6 +14,10 @@
|
||||
"types": "./dist/auth/gameToken.d.ts",
|
||||
"default": "./dist/auth/gameToken.js"
|
||||
},
|
||||
"./auth/sessionRevocation": {
|
||||
"types": "./dist/auth/sessionRevocation.d.ts",
|
||||
"default": "./dist/auth/sessionRevocation.js"
|
||||
},
|
||||
"./auth/sanctions": {
|
||||
"types": "./dist/auth/sanctions.d.ts",
|
||||
"default": "./dist/auth/sanctions.js"
|
||||
@@ -57,6 +61,10 @@
|
||||
"./util/JosaUtil": {
|
||||
"types": "./dist/util/JosaUtil.d.ts",
|
||||
"default": "./dist/util/JosaUtil.js"
|
||||
},
|
||||
"./images/imageUpload": {
|
||||
"types": "./dist/images/imageUpload.d.ts",
|
||||
"default": "./dist/images/imageUpload.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Gateway/game API는 같은 Redis namespace의 회수 계약을 공유한다.
|
||||
// 최초 전환 cutoff와 사용자 watermark는 token TTL 설정 변화에도 유효해야 하므로
|
||||
// TTL로 지우지 않는다. 사용자당 한 값이며 삭제는 모든 기존 token 만료 확인 뒤 운영 작업이다.
|
||||
export const buildSessionRevocationBaselineKey = (channel: string): string => `${channel}:revocation:v1:baseline`;
|
||||
export const buildUserSessionRevocationKey = (channel: string, userId: string): string =>
|
||||
`${channel}:revocation:v1:user:${userId}`;
|
||||
|
||||
export const parseSessionRevocationWatermark = (raw: string | null): Date | null => {
|
||||
if (raw === null) return null;
|
||||
if (!/^\d{1,16}$/.test(raw)) throw new Error('Invalid session revocation state.');
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value > 8_640_000_000_000_000)
|
||||
throw new Error('Invalid session revocation state.');
|
||||
return new Date(value);
|
||||
};
|
||||
|
||||
/** A caller timeout does not prove a command already sent to Redis has settled. */
|
||||
export class SessionRevocationCommandBudget {
|
||||
private pending = 0;
|
||||
constructor(private readonly maxPending = 64) {}
|
||||
|
||||
async run<T>(start: () => Promise<T>): Promise<T> {
|
||||
if (this.pending >= this.maxPending) throw new Error('Session revocation storage is unavailable.');
|
||||
this.pending += 1;
|
||||
let command: Promise<T>;
|
||||
try {
|
||||
command = start();
|
||||
} catch {
|
||||
this.pending -= 1;
|
||||
throw new Error('Session revocation storage is unavailable.');
|
||||
}
|
||||
void command.then(
|
||||
() => {
|
||||
this.pending -= 1;
|
||||
},
|
||||
() => {
|
||||
this.pending -= 1;
|
||||
}
|
||||
);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
command,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Session revocation storage is unavailable.')), 2000);
|
||||
timeout.unref();
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
throw new Error('Session revocation storage is unavailable.');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const MAX_ORIGINS = 32;
|
||||
const MAX_ORIGIN_LENGTH = 2048;
|
||||
|
||||
const parsePublicUrl = (value: string): URL => {
|
||||
if (value.length > MAX_ORIGIN_LENGTH) throw new Error('API origin configuration is too long.');
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error('API origins require a public HTTP(S) URL without credentials.');
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/** Public application URLs supply the default origins; extra origins must be explicit. */
|
||||
export const resolveApiAllowedOrigins = (
|
||||
extraOrigins: string | undefined,
|
||||
publicUrls: readonly (string | undefined)[]
|
||||
): string[] => {
|
||||
const origins = new Set<string>();
|
||||
for (const value of publicUrls) {
|
||||
if (value?.trim()) origins.add(parsePublicUrl(value.trim()).origin);
|
||||
}
|
||||
if (extraOrigins?.trim()) {
|
||||
const entries = extraOrigins.split(',');
|
||||
if (entries.length > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
|
||||
for (const value of entries) {
|
||||
const url = parsePublicUrl(value.trim());
|
||||
if (url.pathname !== '/' || url.search || url.hash) {
|
||||
throw new Error('API_ALLOWED_ORIGINS entries must be origins, without paths or queries.');
|
||||
}
|
||||
origins.add(url.origin);
|
||||
}
|
||||
}
|
||||
if (origins.size > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
|
||||
return [...origins];
|
||||
};
|
||||
|
||||
class ApiOriginForbiddenError extends Error {
|
||||
readonly statusCode = 403;
|
||||
|
||||
constructor() {
|
||||
super('Request origin is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject browser requests before parsing bodies or dispatching mutations, not just CORS responses. */
|
||||
export const createApiOriginGuard = (allowedOrigins: readonly string[]) => {
|
||||
const allowed = new Set(allowedOrigins);
|
||||
return async (request: { headers: { origin?: string | string[] } }): Promise<void> => {
|
||||
const origin = request.headers.origin;
|
||||
// Internal machine clients have no Origin and still require their ordinary auth/HMAC.
|
||||
if (origin === undefined) return;
|
||||
if (typeof origin !== 'string' || origin.length > MAX_ORIGIN_LENGTH || !allowed.has(origin)) {
|
||||
throw new ApiOriginForbiddenError();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Preserve public validation/permission messages while hiding internal causes and stacks. */
|
||||
export const safeTrpcErrorFormatter = <T extends { message: string; data: { code: string; stack?: string } }>({
|
||||
shape,
|
||||
}: {
|
||||
shape: T;
|
||||
}): T => ({
|
||||
...shape,
|
||||
message: shape.data.code === 'INTERNAL_SERVER_ERROR' ? 'Internal server error' : shape.message,
|
||||
data: { ...shape.data, stack: undefined },
|
||||
});
|
||||
|
||||
export const safeHttpErrorHandler = (
|
||||
error: unknown,
|
||||
request: { log: { error: (context: object, message: string) => void } },
|
||||
reply: { code: (status: number) => { send: (body: object) => unknown } }
|
||||
): void => {
|
||||
const value = error && typeof error === 'object' ? (error as { statusCode?: number; message?: string }) : {};
|
||||
const status =
|
||||
typeof value.statusCode === 'number' &&
|
||||
Number.isInteger(value.statusCode) &&
|
||||
value.statusCode >= 400 &&
|
||||
value.statusCode < 500
|
||||
? value.statusCode
|
||||
: 500;
|
||||
request.log.error({ err: error }, 'Request handling failed');
|
||||
reply.code(status).send({
|
||||
statusCode: status,
|
||||
error: status >= 500 ? 'Internal Server Error' : 'Request Error',
|
||||
message: status >= 500 ? 'Internal server error' : (value.message ?? 'Request failed'),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
const errorCodes = /^(?:P\d{4}|FST_ERR_[A-Z_]+|ERR_[A-Z_]+|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|INTERNAL_SERVER_ERROR)$/;
|
||||
|
||||
const errorSummary = (
|
||||
error: unknown
|
||||
): { type: string; message: string; stack: string; code?: string; statusCode?: number } => {
|
||||
const value = error && typeof error === 'object' ? (error as Record<string, unknown>) : {};
|
||||
const code =
|
||||
typeof value.code === 'string' && value.code.length < 80 && errorCodes.test(value.code)
|
||||
? value.code
|
||||
: undefined;
|
||||
const statusCode =
|
||||
typeof value.statusCode === 'number' && value.statusCode >= 400 && value.statusCode <= 599
|
||||
? value.statusCode
|
||||
: undefined;
|
||||
return {
|
||||
type: 'RequestError',
|
||||
message: 'Request handling failed',
|
||||
stack: '',
|
||||
...(code ? { code } : {}),
|
||||
...(statusCode ? { statusCode } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/** HTTP diagnostics carry static route/status/code, never credentials or error payloads. */
|
||||
export const safeHttpLoggerOptions = {
|
||||
serializers: {
|
||||
req: (request: { method?: string; routeOptions?: { url?: string } }) => ({
|
||||
method: request.method,
|
||||
// URLs can carry OAuth/session/input values, including unmatched path segments.
|
||||
url: request.routeOptions?.url ?? '[unmatched]',
|
||||
}),
|
||||
res: (response: { statusCode?: number }) => ({ statusCode: response.statusCode }),
|
||||
err: errorSummary,
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'password',
|
||||
'sessionToken',
|
||||
'accessToken',
|
||||
'token',
|
||||
'secret',
|
||||
'body',
|
||||
'headers',
|
||||
'req.headers',
|
||||
'req.body',
|
||||
'res.headers',
|
||||
],
|
||||
remove: true,
|
||||
},
|
||||
hooks: {
|
||||
logMethod(args: unknown[], method: (...values: unknown[]) => void): void {
|
||||
const first = args[0];
|
||||
const error =
|
||||
first instanceof Error
|
||||
? first
|
||||
: first && typeof first === 'object' && 'err' in first
|
||||
? first.err
|
||||
: undefined;
|
||||
if (error !== undefined) {
|
||||
// Fastify also copies err.message into msg; serializers alone do not protect it.
|
||||
method.apply(this, [{ err: errorSummary(error) }, 'Request handling failed']);
|
||||
} else {
|
||||
// Fastify's default 404 handler also embeds the raw URL in a message.
|
||||
method.apply(
|
||||
this,
|
||||
args.map((value) =>
|
||||
typeof value === 'string' && /^Route .+ not found$/.test(value) ? 'Route not found' : value
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
|
||||
export class ImageWorkBusyError extends Error {
|
||||
constructor() {
|
||||
super('Image service is busy.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Admission lasts until actual work settles, including work that ignores abort. No queue. */
|
||||
export class ImageWorkBudget {
|
||||
private pending = 0;
|
||||
constructor(private readonly limit = 4) {}
|
||||
async run<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (this.pending >= this.limit) throw new ImageWorkBusyError();
|
||||
this.pending++;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
this.pending--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageUploadError extends Error {}
|
||||
const uploadBudget = new ImageWorkBudget();
|
||||
export const MAX_IMAGE_RESPONSE_BYTES = 16 * 1024;
|
||||
export const IMAGE_UPLOAD_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const isRasterImage = (body: Buffer): boolean =>
|
||||
body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
|
||||
(body[0] === 255 && body[1] === 216 && body[2] === 255) ||
|
||||
['GIF87a', 'GIF89a'].includes(body.toString('ascii', 0, 6)) ||
|
||||
(body.toString('ascii', 0, 4) === 'RIFF' && body.toString('ascii', 8, 12) === 'WEBP') ||
|
||||
body.toString('ascii', 0, 2) === 'BM' ||
|
||||
['49492a00', '4d4d002a', '49492b00', '4d4d002b'].includes(body.subarray(0, 4).toString('hex')) ||
|
||||
(body.length >= 12 && body.toString('ascii', 4, 8) === 'ftyp');
|
||||
|
||||
export const performSignedImageUpload = async (
|
||||
input: {
|
||||
kind: 'content' | 'user-icons';
|
||||
baseUrl: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
secret: string;
|
||||
},
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
now: () => number = Date.now,
|
||||
options: { timeoutMs?: number; budget?: ImageWorkBudget } = {}
|
||||
): Promise<void> => {
|
||||
const base = new URL(input.baseUrl);
|
||||
if (
|
||||
!['http:', 'https:'].includes(base.protocol) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
base.search ||
|
||||
base.hash ||
|
||||
!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename) ||
|
||||
input.body.length > 16 * 1024 * 1024
|
||||
) {
|
||||
throw new ImageUploadError('Invalid image upload.');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const work = (options.budget ?? uploadBudget).run(async () => {
|
||||
const pathname = `/v1/uploads/${input.kind}/core2026/${input.filename}`;
|
||||
const expires = String(Math.floor(now() / 1000) + 60);
|
||||
const requestId = randomUUID();
|
||||
const digest = createHash('sha256').update(input.body).digest('hex');
|
||||
const signature = createHmac('sha256', input.secret)
|
||||
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
|
||||
.digest('hex');
|
||||
const response = await fetchImpl(`${input.baseUrl.replace(/\/$/, '')}${pathname}`, {
|
||||
method: 'PUT',
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
'x-image-client': 'core2026',
|
||||
'x-image-expires': expires,
|
||||
'x-image-request-id': requestId,
|
||||
'x-image-signature': signature,
|
||||
},
|
||||
body: new Uint8Array(input.body),
|
||||
});
|
||||
if (controller.signal.aborted || !response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new ImageUploadError(
|
||||
controller.signal.aborted
|
||||
? 'Image upload timed out.'
|
||||
: `Image repository upload failed with HTTP ${response.status}.`
|
||||
);
|
||||
}
|
||||
const declared = Number(response.headers.get('content-length') ?? 0);
|
||||
if (declared > MAX_IMAGE_RESPONSE_BYTES) {
|
||||
await response.body?.cancel();
|
||||
throw new ImageUploadError('Image response exceeds its size limit.');
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new ImageUploadError('Image repository returned an invalid response.');
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (controller.signal.aborted) throw new ImageUploadError('Image upload timed out.');
|
||||
if (chunk.done) break;
|
||||
total += chunk.value.byteLength;
|
||||
if (total > MAX_IMAGE_RESPONSE_BYTES)
|
||||
throw new ImageUploadError('Image response exceeds its size limit.');
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel();
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
const expected =
|
||||
input.kind === 'content' ? `uploads/core2026/${input.filename}` : `icons/users/core2026/${input.filename}`;
|
||||
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expected) {
|
||||
throw new ImageUploadError('Image repository returned an unexpected upload path.');
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
work,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new ImageUploadError('Image upload timed out.'));
|
||||
}, options.timeoutMs ?? IMAGE_UPLOAD_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (error instanceof ImageWorkBusyError || error instanceof ImageUploadError) throw error;
|
||||
// Request errors can contain credential-bearing URLs and signatures.
|
||||
throw new ImageUploadError('Image repository upload failed.');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
@@ -31,6 +31,9 @@ export * from './gateway/profileStatus.js';
|
||||
export * from './gateway/runtimeDiagnostics.js';
|
||||
export * from './game/accessPenalty.js';
|
||||
export * from './http/trpcTransport.js';
|
||||
export * from './http/safeLogger.js';
|
||||
export * from './http/errorPrivacy.js';
|
||||
export * from './http/apiOrigin.js';
|
||||
export * from './webPush/types.js';
|
||||
export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js';
|
||||
|
||||
|
||||
@@ -307,6 +307,21 @@ export type TurnDaemonCommand =
|
||||
| { kind: 'nationPriority'; priority: string[] }
|
||||
| { kind: 'generalPriority'; priority: string[] };
|
||||
}
|
||||
| {
|
||||
type: 'tournamentAdjustGeneral';
|
||||
requestId?: string;
|
||||
reason: 'tournamentJoin' | 'tournamentJoinRollback' | 'tournamentBet' | 'tournamentBetRollback';
|
||||
userId: string;
|
||||
generalId: number;
|
||||
goldDelta: number;
|
||||
betGoldDelta?: number;
|
||||
minGoldAfter: number;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentSeedNpcBets';
|
||||
requestId?: string;
|
||||
bets: Array<{ generalId: number; amount: number }>;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
requestId?: string;
|
||||
@@ -701,6 +716,28 @@ export type TurnDaemonCommandResult =
|
||||
nationId?: number;
|
||||
currentUpdatedAt?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentAdjustGeneral';
|
||||
ok: true;
|
||||
generalId: number;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentAdjustGeneral';
|
||||
ok: false;
|
||||
code: 'FORBIDDEN' | 'BAD_REQUEST';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentSeedNpcBets';
|
||||
ok: true;
|
||||
processedGeneralIds: number[];
|
||||
skippedGeneralIds: number[];
|
||||
}
|
||||
| {
|
||||
type: 'tournamentSeedNpcBets';
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
ok: true;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createApiOriginGuard, resolveApiAllowedOrigins } from '../src/http/apiOrigin.js';
|
||||
|
||||
describe('public API origin policy', () => {
|
||||
it('derives exact origins from application URLs and explicit extra origins', async () => {
|
||||
const origins = resolveApiAllowedOrigins('http://localhost:5173,https://sam.hided.net', [
|
||||
'https://sam.hided.net/gateway/',
|
||||
'https://sam.hided.net/che/',
|
||||
]);
|
||||
expect(origins).toEqual(['https://sam.hided.net', 'http://localhost:5173']);
|
||||
const guard = createApiOriginGuard(origins);
|
||||
await expect(guard({ headers: {} })).resolves.toBeUndefined();
|
||||
for (const origin of origins) await expect(guard({ headers: { origin } })).resolves.toBeUndefined();
|
||||
for (const origin of [
|
||||
'https://untrusted.example',
|
||||
'https://sam.hided.net.untrusted.example',
|
||||
'http://sam.hided.net',
|
||||
'https://sam.hided.net:8443',
|
||||
'null',
|
||||
'',
|
||||
'https://sam.hided.net,https://untrusted.example',
|
||||
'x'.repeat(2049),
|
||||
['https://sam.hided.net', 'https://untrusted.example'],
|
||||
]) {
|
||||
await expect(guard({ headers: { origin } })).rejects.toMatchObject({ statusCode: 403 });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'*',
|
||||
'null',
|
||||
'https://user:password@example.com',
|
||||
'file:///tmp/example',
|
||||
'https://x.test/path',
|
||||
'https://x.test?query',
|
||||
'https://x.test#fragment',
|
||||
'https://x.test,',
|
||||
])('rejects unsafe configuration %s', (value) => {
|
||||
expect(() => resolveApiAllowedOrigins(value, [])).toThrow();
|
||||
});
|
||||
|
||||
it('bounds configuration and fails closed when no public origin is configured', async () => {
|
||||
expect(() =>
|
||||
resolveApiAllowedOrigins(Array.from({ length: 33 }, (_, i) => `https://${i}.test`).join(','), [])
|
||||
).toThrow();
|
||||
expect(() => resolveApiAllowedOrigins('https://' + 'a'.repeat(2048) + '.test', [])).toThrow();
|
||||
await expect(
|
||||
createApiOriginGuard([])({ headers: { origin: 'https://untrusted.example' } })
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ImageWorkBudget, performSignedImageUpload } from '../src/images/imageUpload.js';
|
||||
|
||||
const input = {
|
||||
kind: 'content' as const,
|
||||
baseUrl: 'https://image.example',
|
||||
filename: `${'a'.repeat(32)}.png`,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from('test'),
|
||||
secret: 'synthetic-image-secret',
|
||||
};
|
||||
const payload = () => new Response(JSON.stringify({ path: `uploads/core2026/${input.filename}` }));
|
||||
const listen = async (server: Server) => {
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
};
|
||||
const close = (server: Server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
|
||||
describe('bounded signed image transport', () => {
|
||||
it('never forwards signed PUT bodies across a real redirect', async () => {
|
||||
let forwarded = 0;
|
||||
const target = createServer((_, res) => {
|
||||
forwarded++;
|
||||
res.end(JSON.stringify({ path: `uploads/core2026/${input.filename}` }));
|
||||
});
|
||||
const targetUrl = await listen(target);
|
||||
const origin = createServer((_, res) => {
|
||||
res.writeHead(307, { location: targetUrl });
|
||||
res.end();
|
||||
});
|
||||
const baseUrl = await listen(origin);
|
||||
try {
|
||||
await expect(performSignedImageUpload({ ...input, baseUrl })).rejects.toThrow();
|
||||
expect(forwarded).toBe(0);
|
||||
} finally {
|
||||
await close(origin);
|
||||
await close(target);
|
||||
}
|
||||
});
|
||||
it('bounds streamed bytes independently of Content-Length', async () => {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(10_000));
|
||||
controller.enqueue(new Uint8Array(10_000));
|
||||
},
|
||||
});
|
||||
await expect(performSignedImageUpload(input, async () => new Response(body))).rejects.toThrow('size limit');
|
||||
});
|
||||
it('rejects wrong and malformed JSON paths', async () => {
|
||||
for (const body of ['{', JSON.stringify({ path: 'elsewhere' })]) {
|
||||
await expect(performSignedImageUpload(input, async () => new Response(body))).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
it('keeps timed-out non-cooperative work admitted until it actually settles', async () => {
|
||||
const budget = new ImageWorkBudget(1);
|
||||
let resolve!: (response: Response) => void;
|
||||
const fetchImpl: typeof fetch = () =>
|
||||
new Promise<Response>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
await expect(performSignedImageUpload(input, fetchImpl, Date.now, { timeoutMs: 25, budget })).rejects.toThrow(
|
||||
'timed out'
|
||||
);
|
||||
await expect(performSignedImageUpload(input, async () => payload(), Date.now, { budget })).rejects.toThrow(
|
||||
'busy'
|
||||
);
|
||||
resolve(payload());
|
||||
await new Promise<void>((done) => setImmediate(done));
|
||||
await expect(
|
||||
performSignedImageUpload(input, async () => payload(), Date.now, { budget })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
it.each(['headers', 'body'])(
|
||||
'aborts a real stalled %s response within the whole request deadline',
|
||||
async (phase) => {
|
||||
const server = createServer((_, res) => {
|
||||
if (phase === 'body') {
|
||||
res.writeHead(201);
|
||||
res.write('{');
|
||||
}
|
||||
});
|
||||
const baseUrl = await listen(server);
|
||||
try {
|
||||
await expect(
|
||||
performSignedImageUpload({ ...input, baseUrl }, fetch, Date.now, { timeoutMs: 100 })
|
||||
).rejects.toThrow('timed out');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SessionRevocationCommandBudget, parseSessionRevocationWatermark } from '../src/auth/sessionRevocation.js';
|
||||
|
||||
describe('session revocation command ownership', () => {
|
||||
it('holds capacity after caller timeout until the actual command settles, then recovers', async () => {
|
||||
vi.useFakeTimers();
|
||||
const budget = new SessionRevocationCommandBudget(1);
|
||||
let finish!: () => void;
|
||||
const actual = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
try {
|
||||
const timed = expect(budget.run(() => actual)).rejects.toThrow('unavailable');
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await timed;
|
||||
const extra = vi.fn(async () => 'extra');
|
||||
await expect(budget.run(extra)).rejects.toThrow('unavailable');
|
||||
expect(extra).not.toHaveBeenCalled();
|
||||
finish();
|
||||
await actual;
|
||||
await expect(budget.run(extra)).resolves.toBe('extra');
|
||||
} finally {
|
||||
finish();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it('releases capacity after synchronous startup or asynchronous backend failure', async () => {
|
||||
const budget = new SessionRevocationCommandBudget(1);
|
||||
await expect(
|
||||
budget.run(() => {
|
||||
throw new Error('synthetic');
|
||||
})
|
||||
).rejects.toThrow('unavailable');
|
||||
await expect(
|
||||
budget.run(async () => {
|
||||
throw new Error('synthetic');
|
||||
})
|
||||
).rejects.toThrow('unavailable');
|
||||
await expect(budget.run(async () => 'ok')).resolves.toBe('ok');
|
||||
});
|
||||
it('validates numeric persisted timestamps while distinguishing absent state', () => {
|
||||
expect(parseSessionRevocationWatermark(null)).toBeNull();
|
||||
expect(parseSessionRevocationWatermark('0')?.getTime()).toBe(0);
|
||||
expect(() => parseSessionRevocationWatermark('NaN')).toThrow();
|
||||
expect(() => parseSessionRevocationWatermark('8640000000000001')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import { emitDeclarations } from '../../tools/build-scripts/emit-declarations.mj
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'images/imageUpload': 'src/images/imageUpload.ts',
|
||||
'auth/gameToken': 'src/auth/gameToken.ts',
|
||||
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
|
||||
'auth/sessionRevocation': 'src/auth/sessionRevocation.ts',
|
||||
'auth/sanctions': 'src/auth/sanctions.ts',
|
||||
'gateway/profileStatus': 'src/gateway/profileStatus.ts',
|
||||
'http/trpcTransport': 'src/http/trpcTransport.ts',
|
||||
|
||||
Reference in New Issue
Block a user