운영 보안을 보존하여 빌드 개선 배포본 통합
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
|
||||
|
||||
export interface UserIconUploadResult {
|
||||
picture: string;
|
||||
@@ -9,20 +9,6 @@ export interface UserIconUploadStore {
|
||||
upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult>;
|
||||
}
|
||||
|
||||
const signature = (
|
||||
secret: string,
|
||||
expires: string,
|
||||
requestId: string,
|
||||
pathname: string,
|
||||
contentType: string,
|
||||
body: Buffer
|
||||
): string => {
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
return createHmac('sha256', secret)
|
||||
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
|
||||
.digest('hex');
|
||||
};
|
||||
|
||||
export class RemoteUserIconStore implements UserIconUploadStore {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
@@ -36,35 +22,17 @@ export class RemoteUserIconStore implements UserIconUploadStore {
|
||||
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
|
||||
throw new Error('Invalid user icon filename.');
|
||||
}
|
||||
const pathname = `/v1/uploads/user-icons/core2026/${input.filename}`;
|
||||
const expires = String(Math.floor(this.now() / 1000) + 60);
|
||||
const requestId = randomUUID();
|
||||
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
'x-image-client': 'core2026',
|
||||
'x-image-expires': expires,
|
||||
'x-image-request-id': requestId,
|
||||
'x-image-signature': signature(
|
||||
this.secret,
|
||||
expires,
|
||||
requestId,
|
||||
pathname,
|
||||
input.contentType,
|
||||
input.body
|
||||
),
|
||||
await performSignedImageUpload(
|
||||
{
|
||||
...input,
|
||||
kind: 'user-icons',
|
||||
baseUrl: this.baseUrl,
|
||||
secret: this.secret,
|
||||
},
|
||||
body: new Uint8Array(input.body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
|
||||
}
|
||||
this.fetchImpl,
|
||||
this.now
|
||||
);
|
||||
const picture = `users/core2026/${input.filename}`;
|
||||
const payload: unknown = await response.json();
|
||||
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
|
||||
throw new Error('Image repository returned an unexpected upload path.');
|
||||
}
|
||||
return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
ImageWorkBudget,
|
||||
ImageWorkBusyError,
|
||||
ImageUploadError,
|
||||
isRasterImage,
|
||||
} from '@sammo-ts/common/images/imageUpload';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
@@ -13,6 +19,9 @@ import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
|
||||
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
|
||||
|
||||
const zSessionToken = z.string().min(1);
|
||||
const iconImageBudget = new ImageWorkBudget();
|
||||
const MAX_IMAGE_PIXELS = 128 * 128 * 256;
|
||||
|
||||
const MAX_ICON_BYTES = 50 * 1024;
|
||||
const MAX_ACTIVE_ICONS = 5;
|
||||
const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -265,52 +274,91 @@ export const accountRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
const now = new Date();
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const buffer = decodeImage(input.imageData);
|
||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
|
||||
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
|
||||
});
|
||||
}
|
||||
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
|
||||
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
|
||||
if (!ctx.userIconUpload) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
||||
}
|
||||
const uploaded = await ctx.userIconUpload.upload({
|
||||
filename,
|
||||
contentType: ICON_CONTENT_TYPES[extension]!,
|
||||
body: buffer,
|
||||
});
|
||||
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS);
|
||||
if (!stored.ok) {
|
||||
if (stored.reason === 'LIMIT') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
|
||||
try {
|
||||
return await iconImageBudget.run(async () => {
|
||||
const now = new Date();
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const buffer = decodeImage(input.imageData);
|
||||
if (!isRasterImage(buffer))
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
});
|
||||
if ((metadata.pages ?? 1) > 256)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
|
||||
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
|
||||
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
!metadata.width ||
|
||||
metadata.width < 64 ||
|
||||
metadata.width > 128 ||
|
||||
metadata.height !== metadata.width
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
|
||||
});
|
||||
}
|
||||
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
|
||||
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
|
||||
if (!ctx.userIconUpload) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '이미지 저장소가 설정되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
const uploaded = await ctx.userIconUpload.upload({
|
||||
filename,
|
||||
contentType: ICON_CONTENT_TYPES[extension]!,
|
||||
body: buffer,
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
|
||||
const stored = await ctx.users.addIconForWindow(
|
||||
user.id,
|
||||
uploaded.picture,
|
||||
0,
|
||||
now,
|
||||
MAX_ACTIVE_ICONS
|
||||
);
|
||||
if (!stored.ok) {
|
||||
if (stored.reason === 'LIMIT') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: uploaded.publicUrl,
|
||||
revision: stored.revision,
|
||||
icon: buildLibraryIcon(ctx, stored.icon),
|
||||
profiles,
|
||||
flushPublished,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImageWorkBusyError)
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
if (error instanceof ImageUploadError)
|
||||
throw new TRPCError({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: uploaded.publicUrl,
|
||||
revision: stored.revision,
|
||||
icon: buildLibraryIcon(ctx, stored.icon),
|
||||
profiles,
|
||||
flushPublished,
|
||||
};
|
||||
}),
|
||||
setPreferredIcon: procedure
|
||||
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
export interface AuthCounter {
|
||||
key: string;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export interface AuthCounterStore {
|
||||
readonly available?: boolean;
|
||||
/** Atomically check every counter, then charge them only when accepted. */
|
||||
consume(counters: AuthCounter[]): Promise<number>;
|
||||
}
|
||||
|
||||
export class AuthBudgetError extends Error {
|
||||
constructor(
|
||||
readonly reason: 'limited' | 'unavailable',
|
||||
readonly retryAfterSeconds = 1
|
||||
) {
|
||||
super(
|
||||
reason === 'limited'
|
||||
? 'Authentication attempts are temporarily limited.'
|
||||
: 'Authentication protection is unavailable.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CONSUME_SCRIPT = `
|
||||
local retry = 0
|
||||
for i, key in ipairs(KEYS) do
|
||||
local count = tonumber(redis.call('GET', key) or '0')
|
||||
if count >= tonumber(ARGV[i * 2 - 1]) then
|
||||
local ttl = redis.call('PTTL', key)
|
||||
if ttl < 0 then
|
||||
return -1
|
||||
end
|
||||
retry = math.max(retry, math.max(ttl, 1))
|
||||
end
|
||||
end
|
||||
if retry > 0 then return retry end
|
||||
for i, key in ipairs(KEYS) do
|
||||
local count = redis.call('INCR', key)
|
||||
if count == 1 then redis.call('PEXPIRE', key, ARGV[i * 2]) end
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
export class RedisAuthCounterStore implements AuthCounterStore {
|
||||
constructor(
|
||||
private readonly client: {
|
||||
readonly isReady?: boolean;
|
||||
withAbortSignal?: (signal: AbortSignal) => {
|
||||
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
|
||||
};
|
||||
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
|
||||
}
|
||||
) {}
|
||||
|
||||
get available(): boolean {
|
||||
return this.client.isReady !== false;
|
||||
}
|
||||
|
||||
async consume(counters: AuthCounter[]): Promise<number> {
|
||||
if (this.client.isReady === false) throw new AuthBudgetError('unavailable');
|
||||
const client = this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
const result = await client.eval(CONSUME_SCRIPT, {
|
||||
keys: counters.map((counter) => counter.key),
|
||||
arguments: counters.flatMap((counter) => [String(counter.limit), String(counter.windowMs)]),
|
||||
});
|
||||
if (typeof result !== 'number' || !Number.isFinite(result) || result < 0) {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Direct callers/tests have a bounded local store; the HTTP server always injects Redis. */
|
||||
export class InMemoryAuthCounterStore implements AuthCounterStore {
|
||||
private readonly entries = new Map<string, { count: number; expiresAt: number }>();
|
||||
|
||||
async consume(counters: AuthCounter[]): Promise<number> {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(key);
|
||||
if (this.entries.size + counters.filter((counter) => !this.entries.has(counter.key)).length > 8192) {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
}
|
||||
let retryMs = 0;
|
||||
for (const counter of counters) {
|
||||
const entry = this.entries.get(counter.key);
|
||||
if (entry && entry.count >= counter.limit) retryMs = Math.max(retryMs, entry.expiresAt - now);
|
||||
}
|
||||
if (retryMs > 0) return retryMs;
|
||||
for (const counter of counters) {
|
||||
const entry = this.entries.get(counter.key) ?? { count: 0, expiresAt: now + counter.windowMs };
|
||||
entry.count += 1;
|
||||
this.entries.set(counter.key, entry);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const EXPENSIVE_ACTIONS = new Set([
|
||||
'auth.bootstrapLocal',
|
||||
'auth.kakaoExchange',
|
||||
'auth.kakaoResolveAccount',
|
||||
'auth.kakaoSetPassword',
|
||||
'auth.register',
|
||||
'auth.registerLocal',
|
||||
'auth.login',
|
||||
'auth.kakaoOtp',
|
||||
'account.changePassword',
|
||||
'account.scheduleDeletion',
|
||||
]);
|
||||
const LIGHTWEIGHT_ACTIONS = new Set([
|
||||
'auth.passwordKey',
|
||||
'auth.checkRegistrationField',
|
||||
'auth.kakaoStart',
|
||||
'auth.issueGameSession',
|
||||
]);
|
||||
|
||||
export const isBudgetedAuthAction = (action: string): boolean =>
|
||||
EXPENSIVE_ACTIONS.has(action) || LIGHTWEIGHT_ACTIONS.has(action);
|
||||
|
||||
export class AuthAttemptBudget {
|
||||
private active = 0;
|
||||
private pendingChecks = 0;
|
||||
|
||||
constructor(
|
||||
private readonly store: AuthCounterStore,
|
||||
private readonly prefix: string,
|
||||
private readonly secret: string
|
||||
) {}
|
||||
|
||||
private key(kind: string, value: string): string {
|
||||
const digest = createHmac('sha256', this.secret).update(`auth-budget-v1:${kind}:${value}`).digest('hex');
|
||||
return `${this.prefix}:auth-budget:v1:${kind}:${digest}`;
|
||||
}
|
||||
|
||||
async run<T>(
|
||||
action: string,
|
||||
client: string,
|
||||
subject: string | undefined | (() => Promise<string | undefined>),
|
||||
work: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (this.store.available === false || this.pendingChecks >= 32) throw new AuthBudgetError('unavailable');
|
||||
const expensive = EXPENSIVE_ACTIONS.has(action);
|
||||
if (expensive && this.active >= 4) throw new AuthBudgetError('limited');
|
||||
if (expensive) this.active += 1;
|
||||
try {
|
||||
const consume = async (): Promise<number> => {
|
||||
const resolvedSubject = typeof subject === 'function' ? await subject() : subject;
|
||||
const counters: AuthCounter[] = [
|
||||
{ key: this.key('global', 'all'), limit: 1200, windowMs: 60_000 },
|
||||
{ key: this.key('client', client), limit: 600, windowMs: 60_000 },
|
||||
];
|
||||
if (resolvedSubject)
|
||||
counters.push({
|
||||
key: this.key('subject', resolvedSubject),
|
||||
limit: action === 'auth.issueGameSession' ? 32 : 10,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
return this.store.consume(counters);
|
||||
};
|
||||
this.pendingChecks += 1;
|
||||
const checking = consume();
|
||||
// A caller timeout does not prove the underlying Redis/actor read settled.
|
||||
void checking.then(
|
||||
() => {
|
||||
this.pendingChecks -= 1;
|
||||
},
|
||||
() => {
|
||||
this.pendingChecks -= 1;
|
||||
}
|
||||
);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryMs: number;
|
||||
try {
|
||||
retryMs = await Promise.race([
|
||||
checking,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new AuthBudgetError('unavailable')), 2000);
|
||||
timeout.unref();
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (retryMs > 0) throw new AuthBudgetError('limited', Math.max(1, Math.ceil(retryMs / 1000)));
|
||||
return await work();
|
||||
} finally {
|
||||
if (expensive) this.active -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,28 @@
|
||||
import {
|
||||
buildSessionRevocationBaselineKey,
|
||||
buildUserSessionRevocationKey,
|
||||
SessionRevocationCommandBudget,
|
||||
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||
|
||||
interface RedisClientLike {
|
||||
readonly isReady?: boolean;
|
||||
set(key: string, value: string, options: { NX: true }): Promise<string | null>;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
withAbortSignal?(signal: AbortSignal): RedisClientLike;
|
||||
}
|
||||
|
||||
// 지속 watermark를 먼저 저장한 뒤 알림을 보낸다. 구독자가 0이어도 회수는 유효하다.
|
||||
const PUBLISH_FLUSH_SCRIPT = `
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'NX')
|
||||
local raw = redis.call('GET', KEYS[2])
|
||||
local prior = tonumber(raw)
|
||||
if raw and not prior then return redis.error_reply('Invalid session revocation state') end
|
||||
local cutoff = math.max(prior or 0, tonumber(ARGV[1]))
|
||||
redis.call('SET', KEYS[2], string.format('%.0f', cutoff))
|
||||
redis.call('PUBLISH', ARGV[2], ARGV[3])
|
||||
return 1
|
||||
`;
|
||||
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
@@ -17,13 +42,28 @@ export interface GatewayFlushPublisher {
|
||||
|
||||
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
private readonly channel: string;
|
||||
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly commands = new SessionRevocationCommandBudget(32);
|
||||
|
||||
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
|
||||
constructor(client: RedisClientLike, channel: string) {
|
||||
this.client = client;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
private commandClient(): RedisClientLike {
|
||||
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
|
||||
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// 새 계약 최초 활성화에서만 기존 게임 credential을 회수한다. 재시작은 cutoff를 보존한다.
|
||||
await this.commands.run(() =>
|
||||
this.commandClient().set(buildSessionRevocationBaselineKey(this.channel), String(Date.now()), {
|
||||
NX: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async publishUserFlush(
|
||||
userId: string,
|
||||
reason?: string,
|
||||
@@ -37,6 +77,15 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
|
||||
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
|
||||
};
|
||||
await this.client.publish(this.channel, JSON.stringify(payload));
|
||||
const result = await this.commands.run(() =>
|
||||
this.commandClient().eval(PUBLISH_FLUSH_SCRIPT, {
|
||||
keys: [
|
||||
buildSessionRevocationBaselineKey(this.channel),
|
||||
buildUserSessionRevocationKey(this.channel, userId),
|
||||
],
|
||||
arguments: [String(Date.parse(payload.flushedAt)), this.channel, JSON.stringify(payload)],
|
||||
})
|
||||
);
|
||||
if (result !== 1) throw new Error('Session revocation persistence failed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
|
||||
|
||||
import type {
|
||||
GameSessionInfo,
|
||||
@@ -60,7 +61,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > stored.expiresAt) {
|
||||
if (Date.now() >= stored.expiresAt) {
|
||||
this.sessions.delete(sessionToken);
|
||||
this.sessionGames.delete(sessionToken);
|
||||
return null;
|
||||
@@ -86,9 +87,19 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
|
||||
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
|
||||
const session = await this.getSession(sessionToken);
|
||||
if (!session) {
|
||||
const parent = this.sessions.get(sessionToken);
|
||||
if (!session || !parent || parent.info !== session || parent.expiresAt <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
|
||||
for (const key of set) {
|
||||
const stored = this.gameSessions.get(key);
|
||||
if (!stored || stored.expiresAt <= Date.now()) {
|
||||
this.gameSessions.delete(key);
|
||||
set.delete(key);
|
||||
}
|
||||
}
|
||||
if (set.size >= MAX_GAME_SESSIONS_PER_SESSION) throw new GameSessionLimitError();
|
||||
const gameToken = randomUUID();
|
||||
const info: GameSessionInfo = {
|
||||
profile,
|
||||
@@ -109,7 +120,6 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
info,
|
||||
expiresAt: Date.now() + this.gameSessionTtlMs,
|
||||
});
|
||||
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
|
||||
set.add(key);
|
||||
this.sessionGames.set(sessionToken, set);
|
||||
return info;
|
||||
@@ -121,7 +131,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > stored.expiresAt) {
|
||||
if (Date.now() >= stored.expiresAt) {
|
||||
this.gameSessions.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface KakaoOAuthConfig {
|
||||
redirectUri: string;
|
||||
oauthHost?: string;
|
||||
apiHost?: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface KakaoOAuthToken {
|
||||
@@ -52,12 +53,57 @@ export class KakaoOAuthClient {
|
||||
private readonly redirectUri: string;
|
||||
private readonly oauthHost: string;
|
||||
private readonly apiHost: string;
|
||||
private readonly requestTimeoutMs: number;
|
||||
|
||||
constructor(config: KakaoOAuthConfig) {
|
||||
this.restKey = config.restKey;
|
||||
this.redirectUri = config.redirectUri;
|
||||
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
|
||||
this.apiHost = config.apiHost ?? 'https://kapi.kakao.com';
|
||||
this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000;
|
||||
if (!Number.isInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 50 || this.requestTimeoutMs > 10_000) {
|
||||
throw new Error('Kakao request timeout must be between 50 and 10000 milliseconds.');
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
url: URL,
|
||||
init: RequestInit
|
||||
): Promise<{ response: Response; payload: Record<string, unknown> }> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
if (!response.body) throw new Error('Missing response body.');
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 64 * 1024) {
|
||||
void reader.cancel().catch(() => undefined);
|
||||
throw new Error('Response is too large.');
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const payload: unknown = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
|
||||
);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
||||
throw new Error('Invalid response shape.');
|
||||
return { response, payload: payload as Record<string, unknown> };
|
||||
} catch {
|
||||
// Provider/network payloads can include access tokens or account details.
|
||||
throw new Error('Kakao HTTP request failed.');
|
||||
}
|
||||
}
|
||||
|
||||
buildAuthUrl(state: string, scopes: string[]): string {
|
||||
@@ -73,7 +119,7 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async exchangeCode(code: string): Promise<KakaoOAuthToken> {
|
||||
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -85,15 +131,14 @@ export class KakaoOAuthClient {
|
||||
code,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao OAuth token error.');
|
||||
}
|
||||
return parseToken(payload);
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<KakaoOAuthToken> {
|
||||
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -104,27 +149,25 @@ export class KakaoOAuthClient {
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao OAuth refresh error.');
|
||||
}
|
||||
return parseToken(payload);
|
||||
}
|
||||
|
||||
async signup(accessToken: string): Promise<KakaoSignupResult> {
|
||||
const response = await fetch(new URL('/v1/user/signup', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v1/user/signup', this.apiHost), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok && payload.code === -102 && payload.msg === 'already registered') {
|
||||
return {
|
||||
alreadyRegistered: true,
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao signup error.');
|
||||
}
|
||||
return {
|
||||
id: payload.id ? String(payload.id) : undefined,
|
||||
@@ -133,15 +176,14 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async getMe(accessToken: string): Promise<KakaoUserInfo> {
|
||||
const response = await fetch(new URL('/v2/user/me', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v2/user/me', this.apiHost), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao me error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao me error.');
|
||||
}
|
||||
const kakaoAccount = (payload.kakao_account ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -156,7 +198,7 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async sendTalkMessage(accessToken: string, message: string, link: string): Promise<void> {
|
||||
const response = await fetch(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -174,10 +216,9 @@ export class KakaoOAuthClient {
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
const code = Number(payload.code ?? 0);
|
||||
if (!response.ok || code < 0) {
|
||||
throw new Error(`Kakao talk message error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao talk message error.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { parseJson } from '@sammo-ts/common';
|
||||
|
||||
import { createGatewayRedisKeyBuilder } from './redisKeys.js';
|
||||
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
|
||||
import type {
|
||||
GameSessionInfo,
|
||||
GatewaySessionConfig,
|
||||
@@ -15,22 +16,38 @@ interface RedisGatewaySessionOptions extends GatewaySessionConfig {
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
interface RedisPipeline {
|
||||
set(key: string, value: string, options?: { EX?: number }): RedisPipeline;
|
||||
sAdd(key: string, member: string): RedisPipeline;
|
||||
expire(key: string, seconds: number): RedisPipeline;
|
||||
del(key: string): RedisPipeline;
|
||||
exec(): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface RedisClientLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
|
||||
sMembers(key: string): Promise<string[]>;
|
||||
multi(): RedisPipeline;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
del(key: string): Promise<number>;
|
||||
}
|
||||
|
||||
// 부모 확인·만료 항목 정리·상한·새 항목 저장을 한 원자적 경계에서 수행한다.
|
||||
// 기존 oversized index는 SMEMBERS로 펼치지 않고 발급을 거절한다.
|
||||
const CREATE_GAME_SESSION_SCRIPT = `
|
||||
local parentTtl = redis.call('PTTL', KEYS[1])
|
||||
if parentTtl <= 0 or redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
|
||||
local limit = tonumber(ARGV[4])
|
||||
if redis.call('SCARD', KEYS[2]) > limit then return -1 end
|
||||
for _, entry in ipairs(redis.call('SMEMBERS', KEYS[2])) do
|
||||
if redis.call('EXISTS', entry) == 0 then redis.call('SREM', KEYS[2], entry) end
|
||||
end
|
||||
if redis.call('SCARD', KEYS[2]) >= limit then return -1 end
|
||||
redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[3])
|
||||
redis.call('SADD', KEYS[2], KEYS[3])
|
||||
redis.call('PEXPIRE', KEYS[2], math.max(parentTtl, tonumber(ARGV[3]) * 1000))
|
||||
return 1
|
||||
`;
|
||||
|
||||
// 삭제와 index pop도 한 경계다. 중간 실패에서 추적만 잃는 것을 방지한다.
|
||||
const REVOKE_GAME_SESSION_BATCH_SCRIPT = `
|
||||
redis.call('DEL', KEYS[1])
|
||||
local entries = redis.call('SPOP', KEYS[2], 64)
|
||||
for _, entry in ipairs(entries) do redis.call('DEL', entry) end
|
||||
return #entries
|
||||
`;
|
||||
|
||||
// Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다.
|
||||
export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
private readonly client: RedisClientLike;
|
||||
@@ -75,22 +92,21 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
options: SessionRevocationOptions = { revokeGames: true }
|
||||
): Promise<void> {
|
||||
const key = this.keys.sessionKey(sessionToken);
|
||||
if (options.revokeGames ?? true) {
|
||||
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
|
||||
const games = await this.client.sMembers(gameSetKey);
|
||||
if (games.length > 0) {
|
||||
const pipeline = this.client.multi();
|
||||
for (const entry of games) {
|
||||
pipeline.del(entry);
|
||||
}
|
||||
pipeline.del(gameSetKey);
|
||||
pipeline.del(key);
|
||||
await pipeline.exec();
|
||||
return;
|
||||
}
|
||||
await this.client.del(gameSetKey);
|
||||
if (!(options.revokeGames ?? true)) {
|
||||
await this.client.del(key);
|
||||
return;
|
||||
}
|
||||
// 부모 제거와 각 64개 삭제를 원자적으로 처리한다. 이후 발급 Lua는 실패한다.
|
||||
// 이전 버전에서 커진 index도 응답/transaction 한 번에 전부 펼치지 않는다.
|
||||
for (;;) {
|
||||
const count = await this.client.eval(REVOKE_GAME_SESSION_BATCH_SCRIPT, {
|
||||
keys: [key, this.keys.sessionGameSetKey(sessionToken)],
|
||||
arguments: [],
|
||||
});
|
||||
if (typeof count !== 'number' || count < 0 || count > 64)
|
||||
throw new Error('Unexpected game session revocation result.');
|
||||
if (count < 64) break;
|
||||
}
|
||||
await this.client.del(key);
|
||||
}
|
||||
|
||||
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
|
||||
@@ -115,12 +131,18 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
};
|
||||
const gameKey = this.keys.gameSessionKey(profile, gameToken);
|
||||
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
|
||||
await this.client
|
||||
.multi()
|
||||
.set(gameKey, JSON.stringify(info), { EX: this.gameSessionTtlSeconds })
|
||||
.sAdd(gameSetKey, gameKey)
|
||||
.expire(gameSetKey, this.sessionTtlSeconds)
|
||||
.exec();
|
||||
const result = await this.client.eval(CREATE_GAME_SESSION_SCRIPT, {
|
||||
keys: [this.keys.sessionKey(sessionToken), gameSetKey, gameKey],
|
||||
arguments: [
|
||||
JSON.stringify(session),
|
||||
JSON.stringify(info),
|
||||
String(this.gameSessionTtlSeconds),
|
||||
String(MAX_GAME_SESSIONS_PER_SESSION),
|
||||
],
|
||||
});
|
||||
if (result === 0) return null;
|
||||
if (result === -1) throw new GameSessionLimitError();
|
||||
if (result !== 1) throw new Error('Unexpected game session admission result.');
|
||||
return info;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { UserRecord, UserSanctions } from './userRepository.js';
|
||||
|
||||
// 한 Gateway 로그인에서 여러 profile/탭을 사용하되 추적 자료는 유한하게 유지한다.
|
||||
export const MAX_GAME_SESSIONS_PER_SESSION = 256;
|
||||
export class GameSessionLimitError extends Error {
|
||||
constructor() {
|
||||
super('Game session limit reached.');
|
||||
}
|
||||
}
|
||||
|
||||
export interface GatewaySessionInfo {
|
||||
sessionToken: string;
|
||||
userId: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { parseBooleanWithFallback, parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
|
||||
|
||||
export interface GatewayApiConfig {
|
||||
@@ -19,6 +19,7 @@ export interface GatewayApiConfig {
|
||||
kakaoAdminKey?: string;
|
||||
kakaoRedirectUri: string;
|
||||
publicBaseUrl: string;
|
||||
corsAllowedOrigins: string[];
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
imageUploadBaseUrl: string;
|
||||
@@ -129,6 +130,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||
kakaoRedirectUri,
|
||||
publicBaseUrl,
|
||||
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [publicBaseUrl]),
|
||||
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
|
||||
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AuthAttemptBudget, InMemoryAuthCounterStore } from './auth/attemptBudget.js';
|
||||
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||
import type { GatewaySessionService } from './auth/sessionService.js';
|
||||
import type { UserRepository } from './auth/userRepository.js';
|
||||
@@ -40,6 +41,8 @@ export interface GatewayApiContext {
|
||||
releases: GatewayReleaseRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
authBudget: AuthAttemptBudget;
|
||||
requestIp: string;
|
||||
requestHeaders: Record<string, string | string[] | undefined>;
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit: AdminAuditStore;
|
||||
@@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: {
|
||||
releases?: GatewayReleaseRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
authBudget?: AuthAttemptBudget;
|
||||
requestIp?: string;
|
||||
requestHeaders?: Record<string, string | string[] | undefined>;
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit?: AdminAuditStore;
|
||||
@@ -95,6 +100,10 @@ export const createGatewayApiContext = (options: {
|
||||
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
|
||||
orchestrator: options.orchestrator,
|
||||
profileStatus: options.profileStatus,
|
||||
authBudget:
|
||||
options.authBudget ??
|
||||
new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'direct-caller', options.gameTokenSecret),
|
||||
requestIp: options.requestIp ?? 'unknown',
|
||||
requestHeaders: options.requestHeaders ?? {},
|
||||
prisma: options.prisma,
|
||||
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface BuildCommand {
|
||||
@@ -33,7 +34,8 @@ export interface BuildRunner {
|
||||
|
||||
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
|
||||
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
|
||||
const RELEASE_BUILD_ENV_NAME = /^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
|
||||
const RELEASE_BUILD_ENV_NAME =
|
||||
/^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
|
||||
|
||||
export const sanitizeReleaseBuildEnv = (
|
||||
env: NodeJS.ProcessEnv | Record<string, string> | undefined
|
||||
@@ -236,11 +238,100 @@ interface RemoteBuildMessage {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const MAX_REMOTE_BUILD_FRAME_BYTES = 512 * 1024;
|
||||
const REMOTE_BUILD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
const validRemoteMessage = (value: unknown): value is RemoteBuildMessage => {
|
||||
if (!record(value)) return false;
|
||||
if (Object.keys(value).length !== 1) return false;
|
||||
if (value.result) {
|
||||
const result = value.result;
|
||||
return (
|
||||
record(result) &&
|
||||
typeof result.ok === 'boolean' &&
|
||||
(!result.ok || (result.exitCode === 0 && result.aborted !== true)) &&
|
||||
(result.exitCode === null || (typeof result.exitCode === 'number' && Number.isInteger(result.exitCode))) &&
|
||||
typeof result.output === 'string' &&
|
||||
result.output.length <= MAX_BUILD_OUTPUT_CHARS &&
|
||||
(result.aborted === undefined || typeof result.aborted === 'boolean')
|
||||
);
|
||||
}
|
||||
if (typeof value.error === 'string') return value.error.length <= 2000;
|
||||
if (!record(value.event)) return false;
|
||||
const event = value.event;
|
||||
if (event.type === 'OUTPUT')
|
||||
return (
|
||||
typeof event.stream === 'string' &&
|
||||
['stdout', 'stderr'].includes(event.stream) &&
|
||||
typeof event.message === 'string' &&
|
||||
event.message.length <= 2000
|
||||
);
|
||||
if (
|
||||
typeof event.type !== 'string' ||
|
||||
!['COMMAND_START', 'COMMAND_END'].includes(event.type) ||
|
||||
!record(event.command)
|
||||
)
|
||||
return false;
|
||||
const command = event.command;
|
||||
if (
|
||||
typeof command.command !== 'string' ||
|
||||
!['pnpm', 'node'].includes(command.command) ||
|
||||
typeof command.cwd !== 'string' ||
|
||||
command.cwd.length > 2048 ||
|
||||
!Array.isArray(command.args) ||
|
||||
command.args.length > 40 ||
|
||||
!command.args.every((arg: unknown) => typeof arg === 'string' && arg.length <= 2048)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
command.env !== undefined &&
|
||||
(!record(command.env) ||
|
||||
Object.keys(command.env).length > 32 ||
|
||||
!Object.values(command.env).every((entry) => typeof entry === 'string' && entry.length <= 8192))
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
event.type === 'COMMAND_START' ||
|
||||
event.exitCode === null ||
|
||||
(typeof event.exitCode === 'number' && Number.isInteger(event.exitCode))
|
||||
);
|
||||
};
|
||||
|
||||
const readRemoteBuildToken = (filename: string | undefined): string => {
|
||||
try {
|
||||
if (!filename || !statSync(filename).isFile() || statSync(filename).size > 256) throw new Error();
|
||||
const token = readFileSync(filename, 'utf8').trim();
|
||||
if (token.startsWith('replace-with-') || !/^[A-Za-z0-9_-]{43,128}$/.test(token)) throw new Error();
|
||||
return token;
|
||||
} catch {
|
||||
throw new Error('A valid RELEASE_BUILDER_TOKEN_FILE is required.');
|
||||
}
|
||||
};
|
||||
|
||||
export interface RemoteBuildOptions {
|
||||
tokenFile?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class RemoteBuildRunner implements BuildRunner {
|
||||
private readonly endpoint: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {
|
||||
this.endpoint = new URL('/v1/builds', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
private readonly fetchImpl: typeof fetch = fetch,
|
||||
private readonly config: RemoteBuildOptions = {}
|
||||
) {
|
||||
const parsed = new URL('/v1/builds', baseUrl);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
||||
throw new Error('Invalid release builder endpoint.');
|
||||
}
|
||||
this.endpoint = parsed.toString();
|
||||
this.timeoutMs = config.timeoutMs ?? REMOTE_BUILD_TIMEOUT_MS;
|
||||
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > REMOTE_BUILD_TIMEOUT_MS) {
|
||||
throw new Error('Invalid release builder deadline.');
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
@@ -249,73 +340,92 @@ export class RemoteBuildRunner implements BuildRunner {
|
||||
options?: BuildRunOptions
|
||||
): Promise<BuildResult> {
|
||||
let output = '';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
timeout.unref();
|
||||
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
try {
|
||||
const token = readRemoteBuildToken(this.config.tokenFile);
|
||||
const response = await this.fetchImpl(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
redirect: 'error',
|
||||
headers: { 'content-type': 'application/json', 'x-release-builder-token': token },
|
||||
body: JSON.stringify({
|
||||
commands: commands.map((command) => ({
|
||||
...command,
|
||||
env: sanitizeReleaseBuildEnv(command.env),
|
||||
})),
|
||||
commands: commands.map((command) => ({ ...command, env: sanitizeReleaseBuildEnv(command.env) })),
|
||||
}),
|
||||
signal: options?.signal,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
return {
|
||||
ok: false,
|
||||
exitCode: null,
|
||||
output: appendOutputTail(output, detail || `Release builder returned HTTP ${response.status}.`),
|
||||
};
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return { ok: false, exitCode: null, output: `Release builder returned HTTP ${response.status}.` };
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let buffer = '';
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const result = await this.handleRemoteMessage(line, onProgress);
|
||||
if (result.event?.type === 'OUTPUT') {
|
||||
output = appendOutputTail(output, `${result.event.message}\n`);
|
||||
}
|
||||
if (result.result) return result.result;
|
||||
if (result.error) {
|
||||
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
|
||||
}
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) {
|
||||
buffer += decoder.decode();
|
||||
break;
|
||||
}
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 64 * 1024 * 1024) throw new Error('Build stream too large.');
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
let newline: number;
|
||||
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
const result = await this.handleRemoteMessage(line, onProgress);
|
||||
if (result.event?.type === 'OUTPUT') output = appendOutputTail(output, `${result.event.message}\n`);
|
||||
if (result.result) return result.result;
|
||||
if (result.error)
|
||||
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
|
||||
}
|
||||
if (Buffer.byteLength(buffer) > MAX_REMOTE_BUILD_FRAME_BYTES) throw new Error('Build frame too large.');
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const result = await this.handleRemoteMessage(buffer, onProgress);
|
||||
if (result.result) return result.result;
|
||||
if (result.error) return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
|
||||
}
|
||||
return { ok: false, exitCode: null, output: appendOutputTail(output, 'Release builder closed without a result.') };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
exitCode: null,
|
||||
output: appendOutputTail(output, 'Release builder closed without a result.'),
|
||||
};
|
||||
} catch {
|
||||
const aborted = options?.signal?.aborted ?? false;
|
||||
return {
|
||||
ok: false,
|
||||
exitCode: null,
|
||||
output: appendOutputTail(
|
||||
output,
|
||||
aborted ? 'Build cancelled by operator.' : error instanceof Error ? error.message : String(error)
|
||||
aborted ? 'Build cancelled by operator.' : 'Release builder request failed.'
|
||||
),
|
||||
...(aborted ? { aborted: true } : {}),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
await reader?.cancel().catch(() => undefined);
|
||||
reader?.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRemoteMessage(
|
||||
line: string,
|
||||
onProgress?: BuildProgressObserver
|
||||
): Promise<RemoteBuildMessage> {
|
||||
let message: RemoteBuildMessage;
|
||||
private async handleRemoteMessage(line: string, onProgress?: BuildProgressObserver): Promise<RemoteBuildMessage> {
|
||||
if (!line.trim()) return {};
|
||||
if (Buffer.byteLength(line) > MAX_REMOTE_BUILD_FRAME_BYTES)
|
||||
return { error: 'Release builder frame is too large.' };
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(line) as RemoteBuildMessage;
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return { error: 'Release builder returned malformed progress data.' };
|
||||
}
|
||||
if (!validRemoteMessage(message)) return { error: 'Release builder returned invalid progress data.' };
|
||||
if (message.error !== undefined) return { error: 'Release builder rejected the build request.' };
|
||||
if (message.event && onProgress) await onProgress(message.event);
|
||||
return message;
|
||||
}
|
||||
@@ -324,5 +434,6 @@ export class RemoteBuildRunner implements BuildRunner {
|
||||
export const createReleaseBuildRunner = (
|
||||
baseUrl: string | undefined,
|
||||
localRunner: BuildRunner,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl) : localRunner);
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
tokenFile?: string
|
||||
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl, { tokenFile }) : localRunner);
|
||||
|
||||
@@ -1024,7 +1024,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.releaseBuildRunner = createReleaseBuildRunner(
|
||||
options.processConfig.releaseBuilderUrl,
|
||||
options.buildRunner,
|
||||
options.fetchImpl ?? fetch
|
||||
options.fetchImpl ?? fetch,
|
||||
options.processConfig.baseEnv?.RELEASE_BUILDER_TOKEN_FILE
|
||||
);
|
||||
this.workspaceManager = options.workspaceManager;
|
||||
this.processConfig = options.processConfig;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
|
||||
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
|
||||
import { isGatewaySessionCurrent } from './auth/sessionValidity.js';
|
||||
import { GameSessionLimitError } from './auth/sessionService.js';
|
||||
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||
import type { GatewayApiContext } from './context.js';
|
||||
import { listScenarioPreviews } from './scenario/scenarioCatalog.js';
|
||||
@@ -1048,11 +1049,13 @@ export const appRouter = router({
|
||||
issueGameSession: procedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionToken: z.string().min(1),
|
||||
sessionToken: z.string().min(1).max(256),
|
||||
profile: zProfile,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// 권한 snapshot보다 이후에 발생한 flush도 이 발급을 회수해야 한다.
|
||||
const authorizationStartedAt = Date.now();
|
||||
const gatewaySession = await ctx.sessions.getSession(input.sessionToken);
|
||||
if (!gatewaySession) {
|
||||
throw new TRPCError({
|
||||
@@ -1089,14 +1092,23 @@ export const appRouter = router({
|
||||
message: '카카오 인증 유예기간이 만료되었습니다. 인증 후 계속 이용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const gameSession = await ctx.sessions.createGameSession(input.sessionToken, input.profile);
|
||||
const gameSession = await ctx.sessions
|
||||
.createGameSession(input.sessionToken, input.profile)
|
||||
.catch((error: unknown) => {
|
||||
if (!(error instanceof GameSessionLimitError)) throw error;
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '게임 세션이 많습니다. 기존 세션이 만료된 뒤 다시 시도해 주세요.',
|
||||
});
|
||||
});
|
||||
if (!gameSession) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Session is not valid.',
|
||||
});
|
||||
}
|
||||
const now = new Date();
|
||||
// 발급 뒤 logout/flush가 겹쳐도 issuedAt을 나중 시각으로 다시 쓰지 않는다.
|
||||
const now = new Date(Math.min(authorizationStartedAt, Date.parse(gameSession.issuedAt)));
|
||||
const accountIcon = resolveEffectiveAccountIcon(user);
|
||||
const accountIcons = await ctx.users.listIcons(user.id);
|
||||
const payload = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -24,6 +25,7 @@ import { createPasswordEnvelopeService } from './auth/passwordEnvelope.js';
|
||||
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
|
||||
import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRepository.js';
|
||||
import { AuthAttemptBudget, RedisAuthCounterStore } from './auth/attemptBudget.js';
|
||||
import { appRouter } from './router.js';
|
||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||
@@ -65,7 +67,23 @@ export const createGatewayApiServer = async () => {
|
||||
sessionTtlSeconds: config.sessionTtlSeconds,
|
||||
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||
});
|
||||
const authBudget = new AuthAttemptBudget(
|
||||
new RedisAuthCounterStore(redis.client),
|
||||
config.redisKeyPrefix,
|
||||
config.gameTokenSecret
|
||||
);
|
||||
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
||||
try {
|
||||
await flushPublisher.initialize();
|
||||
} catch {
|
||||
await Promise.allSettled([
|
||||
Promise.resolve().then(() => {
|
||||
if (redis.client.isOpen) redis.client.destroy();
|
||||
}),
|
||||
postgres.disconnect(),
|
||||
]);
|
||||
throw new Error('Gateway session revocation initialization failed.');
|
||||
}
|
||||
const kakaoClient = new KakaoOAuthClient({
|
||||
restKey: config.kakaoRestKey,
|
||||
adminKey: config.kakaoAdminKey,
|
||||
@@ -89,9 +107,12 @@ export const createGatewayApiServer = async () => {
|
||||
config.defaultNavigationConfigFile
|
||||
);
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
logger: safeHttpLoggerOptions,
|
||||
// Count the actual peer; forwarded headers are untrusted without an explicit proxy policy.
|
||||
trustProxy: false,
|
||||
routerOptions: gatewayFastifyRouterOptions,
|
||||
});
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
const webPush = new WebPushCoordinator(
|
||||
postgres.prisma as GatewayPrismaClient,
|
||||
{
|
||||
@@ -104,8 +125,9 @@ export const createGatewayApiServer = async () => {
|
||||
(error) => app.log.error({ err: error }, 'web push delivery failed')
|
||||
);
|
||||
|
||||
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: config.corsAllowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
|
||||
@@ -152,6 +174,8 @@ export const createGatewayApiServer = async () => {
|
||||
releases,
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
authBudget,
|
||||
requestIp: req.ip,
|
||||
requestHeaders: req.headers,
|
||||
prisma: postgres.prisma as GatewayPrismaClient,
|
||||
navigationConfig,
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
|
||||
import { AuthBudgetError, isBudgetedAuthAction } from './auth/attemptBudget.js';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
|
||||
import type { GatewayApiContext } from './context.js';
|
||||
|
||||
const t = initTRPC.context<GatewayApiContext>().create();
|
||||
const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }) => {
|
||||
if (!isBudgetedAuthAction(path)) return next();
|
||||
const raw: unknown = await getRawInput();
|
||||
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
||||
const resolveSubject = async (): Promise<string | undefined> => {
|
||||
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
|
||||
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
|
||||
}
|
||||
if (
|
||||
(path.startsWith('account.') || path === 'auth.issueGameSession') &&
|
||||
typeof input.sessionToken === 'string' &&
|
||||
input.sessionToken.length <= 256
|
||||
) {
|
||||
const session = await ctx.sessions.getSession(input.sessionToken);
|
||||
if (session) return `${path === 'auth.issueGameSession' ? 'game-session' : 'account'}:${session.userId}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
try {
|
||||
return await ctx.authBudget.run(path, ctx.requestIp, resolveSubject, () => next());
|
||||
} catch (error) {
|
||||
if (!(error instanceof AuthBudgetError)) throw error;
|
||||
throw new TRPCError({
|
||||
code: error.reason === 'limited' ? 'TOO_MANY_REQUESTS' : 'SERVICE_UNAVAILABLE',
|
||||
message:
|
||||
error.reason === 'limited'
|
||||
? `인증 요청이 많습니다. ${error.retryAfterSeconds}초 후 다시 시도해 주세요.`
|
||||
: '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sendBoundedNotification } from './sendNotification.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
@@ -12,7 +13,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
|
||||
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
|
||||
import { parsePushEndpoint, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
|
||||
|
||||
export interface WebPushCoordinatorConfig {
|
||||
enabled: boolean;
|
||||
@@ -455,7 +456,7 @@ export class WebPushCoordinator {
|
||||
try {
|
||||
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
|
||||
parsePushEndpoint(delivery.subscription.endpoint);
|
||||
await webPush.sendNotification(
|
||||
await sendBoundedNotification(
|
||||
{
|
||||
endpoint: delivery.subscription.endpoint,
|
||||
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
|
||||
@@ -465,8 +466,7 @@ export class WebPushCoordinator {
|
||||
body: delivery.notification.body,
|
||||
url: delivery.notification.url,
|
||||
tag: delivery.notification.tag,
|
||||
}),
|
||||
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
|
||||
})
|
||||
);
|
||||
await this.prisma.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { request, type RequestOptions } from 'node:https';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
import webPush from 'web-push';
|
||||
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError } from './endpointPolicy.js';
|
||||
|
||||
export class PushTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode = 0
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const MAX_PUSH_RESPONSE_BYTES = 16 * 1024;
|
||||
type PushRequest = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
||||
|
||||
/** Keep web-push encryption/VAPID, but bound the entire exchange and discard response content. */
|
||||
export const sendPushRequest = (
|
||||
details: webPush.RequestDetails,
|
||||
requestImpl: PushRequest = request,
|
||||
timeoutMs = 10_000
|
||||
): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const endpoint = parsePushEndpoint(details.endpoint);
|
||||
let response: IncomingMessage | undefined;
|
||||
let req: ClientRequest | undefined;
|
||||
let settled = false;
|
||||
const finish = (error?: PushTransportError | PushEndpointPolicyError) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error) {
|
||||
response?.destroy();
|
||||
req?.destroy();
|
||||
reject(error);
|
||||
} else resolve();
|
||||
};
|
||||
// Socket inactivity timeouts can be bypassed by a response that drips bytes forever.
|
||||
const timer = setTimeout(() => finish(new PushTransportError('Push service request timed out.')), timeoutMs);
|
||||
try {
|
||||
req = requestImpl(
|
||||
{
|
||||
protocol: 'https:',
|
||||
hostname: endpoint.hostname,
|
||||
port: 443,
|
||||
path: `${endpoint.pathname}${endpoint.search}`,
|
||||
method: details.method,
|
||||
headers: details.headers,
|
||||
agent: publicPushAgent,
|
||||
},
|
||||
(incoming) => {
|
||||
response = incoming;
|
||||
if (settled) {
|
||||
incoming.destroy();
|
||||
return;
|
||||
}
|
||||
if (Number(incoming.headers['content-length'] ?? 0) > MAX_PUSH_RESPONSE_BYTES) {
|
||||
finish(new PushTransportError('Push service response exceeds its size limit.'));
|
||||
return;
|
||||
}
|
||||
let bytes = 0;
|
||||
incoming.on('data', (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_PUSH_RESPONSE_BYTES)
|
||||
finish(new PushTransportError('Push service response exceeds its size limit.'));
|
||||
});
|
||||
incoming.on('error', () => finish(new PushTransportError('Push service response failed.')));
|
||||
incoming.on('aborted', () => finish(new PushTransportError('Push service response failed.')));
|
||||
incoming.on('end', () => {
|
||||
const status = incoming.statusCode ?? 0;
|
||||
finish(
|
||||
status >= 200 && status < 300
|
||||
? undefined
|
||||
: new PushTransportError(`Push service returned HTTP ${status}.`, status)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', (error) =>
|
||||
finish(
|
||||
error instanceof PushEndpointPolicyError
|
||||
? error
|
||||
: new PushTransportError('Push service request failed.')
|
||||
)
|
||||
);
|
||||
req.end(details.body ?? undefined);
|
||||
} catch {
|
||||
finish(new PushTransportError('Push service request failed.'));
|
||||
}
|
||||
});
|
||||
|
||||
export const sendBoundedNotification = async (
|
||||
subscription: webPush.PushSubscription,
|
||||
payload: string
|
||||
): Promise<void> => {
|
||||
parsePushEndpoint(subscription.endpoint);
|
||||
const details = webPush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 });
|
||||
await sendPushRequest(details);
|
||||
};
|
||||
Reference in New Issue
Block a user