feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다
게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
@@ -36,6 +36,7 @@ GATEWAY_API_PORT=13000
|
||||
GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000
|
||||
# 실행 중 Game API가 누락된 관리자 아이콘 초기화를 Gateway 원장에서 다시 확인하는 주기입니다(최소 1000ms).
|
||||
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
|
||||
WEB_PUSH_OUTBOX_POLL_MS=1000
|
||||
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||
GATEWAY_REDIS_PREFIX=sammo:gateway
|
||||
GATEWAY_DB_SCHEMA=public
|
||||
@@ -64,6 +65,14 @@ KAKAO_REST_KEY=your-kakao-rest-key
|
||||
KAKAO_ADMIN_KEY=your-kakao-admin-key
|
||||
KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback
|
||||
|
||||
# Web Push is prepared but globally disabled by default. The private VAPID key is
|
||||
# a server secret and must never be exposed through a VITE_* variable or committed.
|
||||
WEB_PUSH_ENABLED=false
|
||||
WEB_PUSH_POLL_INTERVAL_MS=1000
|
||||
# WEB_PUSH_VAPID_SUBJECT=mailto:operator@example.invalid
|
||||
# WEB_PUSH_VAPID_PUBLIC_KEY=replace-with-vapid-public-key
|
||||
# WEB_PUSH_VAPID_PRIVATE_KEY_FILE=/run/secrets/web_push_vapid_private_key
|
||||
|
||||
# Game API
|
||||
GAME_API_HOST=0.0.0.0
|
||||
GAME_API_PORT=14000
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface GameApiConfig {
|
||||
gatewayInternalApiUrl: string;
|
||||
accountIconResetReconcileIntervalMs: number;
|
||||
flushChannel: string;
|
||||
webPushOutboxPollMs: number;
|
||||
}
|
||||
|
||||
export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env): GameApiConfig => {
|
||||
@@ -86,5 +87,6 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? 'http://127.0.0.1:13000',
|
||||
accountIconResetReconcileIntervalMs: parseReconcileInterval(env.ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS),
|
||||
flushChannel: `${gatewayPrefix}:flush`,
|
||||
webPushOutboxPollMs: parseNumberWithFallback(env.WEB_PUSH_OUTBOX_POLL_MS, 1_000, 'WEB_PUSH_OUTBOX_POLL_MS'),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
|
||||
|
||||
export interface MessageView {
|
||||
id: number;
|
||||
@@ -88,6 +89,7 @@ export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraf
|
||||
if (!id) {
|
||||
throw new Error('Failed to insert message row.');
|
||||
}
|
||||
await enqueuePrivateMessageWebPush(db, draft, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { createBestEffortResourceCloser } from './services/bestEffortResourceClo
|
||||
import { RemoteContentImageStore } from './services/remoteContentImageStore.js';
|
||||
import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
|
||||
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
||||
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
@@ -168,9 +169,23 @@ export const createGameApiServer = async () => {
|
||||
onError: (error) => app.log.error({ err: error }, 'deferred general access flush failed'),
|
||||
}
|
||||
);
|
||||
const webPushOutboxWorker = new WebPushOutboxWorker(
|
||||
postgres.prisma,
|
||||
config.gatewayInternalApiUrl,
|
||||
config.gameTokenSecret,
|
||||
config.profileName,
|
||||
{
|
||||
intervalMs: config.webPushOutboxPollMs,
|
||||
onError: (error) => app.log.error({ err: error }, 'web push outbox dispatch failed'),
|
||||
}
|
||||
);
|
||||
let flushSubscriberStarted = false;
|
||||
let realtimeHubStarted = false;
|
||||
const closeResources = createBestEffortResourceCloser([
|
||||
{
|
||||
name: 'web-push-outbox-worker',
|
||||
run: () => webPushOutboxWorker.stop(),
|
||||
},
|
||||
{
|
||||
name: 'deferred-general-access-worker',
|
||||
run: () => deferredGeneralAccessWorker.stop(),
|
||||
@@ -395,6 +410,7 @@ export const createGameApiServer = async () => {
|
||||
await flushSubscriber.start();
|
||||
flushSubscriberStarted = true;
|
||||
readModelOutboxWorker.start();
|
||||
webPushOutboxWorker.start();
|
||||
deferredGeneralAccessWorker.start();
|
||||
accountIconResetReconciler.start();
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
const INTERNAL_TOKEN_CONTEXT = 'sammo:web-push-event-ingest:v1';
|
||||
const MAX_EVENT_AGE_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
const deriveInternalToken = (secret: string): string =>
|
||||
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||
|
||||
export interface WebPushOutboxWorkerOptions {
|
||||
intervalMs?: number;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class WebPushOutboxWorker {
|
||||
private readonly owner: string;
|
||||
private readonly intervalMs: number;
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string;
|
||||
private readonly onError: (error: unknown) => void;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private nextPruneAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly db: GamePrismaClient,
|
||||
gatewayInternalApiUrl: string,
|
||||
secret: string,
|
||||
private readonly profileName: string,
|
||||
options: WebPushOutboxWorkerOptions = {}
|
||||
) {
|
||||
this.baseUrl = gatewayInternalApiUrl.replace(/\/$/u, '');
|
||||
this.token = deriveInternalToken(secret);
|
||||
this.owner = `game-web-push:${profileName}:${process.pid}:${randomUUID()}`;
|
||||
this.intervalMs = Math.max(250, Math.floor(options.intervalMs ?? 1_000));
|
||||
this.onError = options.onError ?? (() => undefined);
|
||||
}
|
||||
|
||||
private async dispatchBatch(): Promise<void> {
|
||||
const claimed = await this.db.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>(GamePrisma.sql`
|
||||
SELECT "id"
|
||||
FROM "web_push_outbox"
|
||||
WHERE "delivered_at" IS NULL
|
||||
AND "available_at" <= CURRENT_TIMESTAMP
|
||||
AND ("locked_at" IS NULL OR "locked_at" <= CURRENT_TIMESTAMP - INTERVAL '30 seconds')
|
||||
ORDER BY "id"
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 50
|
||||
`);
|
||||
if (rows.length === 0) return [];
|
||||
const ids = rows.map((row) => row.id);
|
||||
await tx.webPushOutbox.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
||||
});
|
||||
return tx.webPushOutbox.findMany({
|
||||
where: { id: { in: ids }, lockOwner: this.owner },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
for (const event of claimed) {
|
||||
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const envelope: WebPushEventEnvelopeV1 = {
|
||||
version: 1,
|
||||
eventId: `game:${this.profileName}:${event.eventId}`,
|
||||
eventType: event.eventType as WebPushEventType,
|
||||
profileName: this.profileName,
|
||||
userIds: event.userIds,
|
||||
...(event.year == null ? {} : { year: event.year }),
|
||||
...(event.month == null ? {} : { month: event.month }),
|
||||
occurredAt: event.createdAt.toISOString(),
|
||||
};
|
||||
const response = await fetch(`${this.baseUrl}/internal/web-push-events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sammo-internal-token': this.token,
|
||||
},
|
||||
body: JSON.stringify(envelope),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
} catch (error) {
|
||||
const attempts = event.attempts;
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: {
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
|
||||
},
|
||||
});
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
if (Date.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = Date.now() + 60_000;
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
WITH expired AS (
|
||||
SELECT "id"
|
||||
FROM "web_push_outbox"
|
||||
WHERE "delivered_at" < CURRENT_TIMESTAMP - INTERVAL '1 day'
|
||||
ORDER BY "id"
|
||||
LIMIT 500
|
||||
)
|
||||
DELETE FROM "web_push_outbox"
|
||||
WHERE "id" IN (SELECT "id" FROM expired)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (this.inFlight) return;
|
||||
this.inFlight = this.dispatchBatch()
|
||||
.catch(this.onError)
|
||||
.finally(() => {
|
||||
this.inFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => this.run(), this.intervalMs);
|
||||
this.timer.unref?.();
|
||||
this.run();
|
||||
}
|
||||
|
||||
wake(): void {
|
||||
this.run();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
await this.inFlight;
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,7 @@ const buildContext = (options: {
|
||||
const logCreate = vi.fn(async () => ({}));
|
||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
|
||||
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const activeWorldState =
|
||||
options.configConst === undefined
|
||||
? worldState
|
||||
@@ -167,9 +168,11 @@ const buildContext = (options: {
|
||||
general?.userId === where.userId ? general : null
|
||||
),
|
||||
findMany,
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
target?.id === where.id ? target : null
|
||||
),
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => {
|
||||
if (target?.id === where.id) return target;
|
||||
if (general?.id === where.id) return general;
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
@@ -193,6 +196,9 @@ const buildContext = (options: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
upsert: vi.fn(async () => ({})),
|
||||
},
|
||||
webPushOutbox: {
|
||||
createMany: webPushOutboxCreateMany,
|
||||
},
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
@@ -225,6 +231,7 @@ const buildContext = (options: {
|
||||
findMany,
|
||||
inheritanceLogFindMany,
|
||||
messageRows,
|
||||
webPushOutboxCreateMany,
|
||||
changeJournal,
|
||||
};
|
||||
};
|
||||
@@ -613,6 +620,14 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(1, {
|
||||
data: [{ eventId: 'message:101', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-1'] }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(2, {
|
||||
data: [{ eventId: 'message:102', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-2'] }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 7 },
|
||||
{ domain: 'messages.mailbox', entityId: 8 },
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
createGamePostgresConnector,
|
||||
GamePrisma,
|
||||
writeReadModelChangeJournal,
|
||||
enqueuePrivateMessageWebPush,
|
||||
enqueueWebPushOutboxEvents,
|
||||
type InputJsonValue,
|
||||
type ReadModelJournalWriteResult,
|
||||
type TurnEngineCityUpdateInput,
|
||||
@@ -50,6 +52,7 @@ import { buildPersistedRankRows } from './rankData.js';
|
||||
import { persistUnificationFinalization } from './unificationPersistence.js';
|
||||
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
||||
import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js';
|
||||
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
@@ -1039,6 +1042,7 @@ export const createDatabaseTurnHooks = async (
|
||||
const readModelBaseline = createRealtimeReadModelBaseline(world);
|
||||
let worldReadModelBaseline = createWorldReadModelSignature(world);
|
||||
let persistedTickSeconds = world.getState().tickSeconds;
|
||||
let webPushTurnBaseline = captureWebPushTurnBaseline(world, options?.reservedTurns);
|
||||
const committedReceipts = new Map<bigint, CommittedReadModelChangeReceipt>();
|
||||
|
||||
const enqueueCommittedReceipt = (
|
||||
@@ -1109,6 +1113,13 @@ export const createDatabaseTurnHooks = async (
|
||||
const persistedReservedTurnChanges = reservedTurnChanges
|
||||
? excludeDeletedReservedTurnQueues(reservedTurnChanges, deletedGenerals, deletedNations)
|
||||
: undefined;
|
||||
const nextWebPushTurnBaseline = captureWebPushTurnBaseline(world, options?.reservedTurns);
|
||||
const webPushEvents = buildTurnWebPushEvents({
|
||||
before: webPushTurnBaseline,
|
||||
after: nextWebPushTurnBaseline,
|
||||
changes,
|
||||
...(persistedReservedTurnChanges ? { reservedTurnChanges: persistedReservedTurnChanges } : {}),
|
||||
});
|
||||
|
||||
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
|
||||
currentYear: state.currentYear,
|
||||
@@ -1597,6 +1608,7 @@ export const createDatabaseTurnHooks = async (
|
||||
if (!id) {
|
||||
throw new Error('Failed to persist turn message.');
|
||||
}
|
||||
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
||||
persistedMessageMailboxes.push(draft.mailbox);
|
||||
return id;
|
||||
},
|
||||
@@ -1657,6 +1669,7 @@ export const createDatabaseTurnHooks = async (
|
||||
journal.mark('betting');
|
||||
}
|
||||
const journalWrite = await writeReadModelChangeJournal(prisma, journal.snapshot());
|
||||
await enqueueWebPushOutboxEvents(prisma, webPushEvents);
|
||||
return { readModelChanges, journalWrite, worldReadModelSignature };
|
||||
};
|
||||
const persisted = transaction
|
||||
@@ -1671,6 +1684,7 @@ export const createDatabaseTurnHooks = async (
|
||||
applyRealtimeReadModelBaseline(readModelBaseline, changes);
|
||||
worldReadModelBaseline = persisted.worldReadModelSignature;
|
||||
persistedTickSeconds = state.tickSeconds;
|
||||
webPushTurnBaseline = nextWebPushTurnBaseline;
|
||||
},
|
||||
readModelChanges: persisted.readModelChanges,
|
||||
journalWrite: persisted.journalWrite,
|
||||
|
||||
@@ -189,6 +189,13 @@ export class InMemoryReservedTurnStore {
|
||||
return this.captureState();
|
||||
}
|
||||
|
||||
inspectGeneralTurnActivity(): Array<[number, boolean]> {
|
||||
return Array.from(this.generalTurns, ([generalId, turns]) => [
|
||||
generalId,
|
||||
turns.some((turn) => turn.action !== DEFAULT_TURN_ACTION || Object.keys(turn.args).length > 0),
|
||||
]);
|
||||
}
|
||||
|
||||
async loadAll(): Promise<void> {
|
||||
const [generalRows, nationRows] = await Promise.all([
|
||||
this.prisma.generalTurn.findMany(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
|
||||
import { acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
|
||||
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
||||
|
||||
@@ -130,6 +130,7 @@ const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: M
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
|
||||
await enqueuePrivateMessageWebPush(transaction, draft, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { WebPushOutboxEventInput } from '@sammo-ts/infra';
|
||||
|
||||
import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
|
||||
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
|
||||
|
||||
interface GeneralNotificationState {
|
||||
userId: string | null;
|
||||
nationId: number;
|
||||
crew: number;
|
||||
deathCrew: number;
|
||||
autorunLimit: number | null;
|
||||
}
|
||||
|
||||
export interface WebPushTurnBaseline {
|
||||
serverId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
turnTick: string;
|
||||
generals: Map<number, GeneralNotificationState>;
|
||||
hasReservedTurns: Map<number, boolean>;
|
||||
}
|
||||
|
||||
const readFiniteNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const readDeathCrew = (meta: Record<string, unknown>): number =>
|
||||
readFiniteNumber(meta.rank_deathcrew) ?? readFiniteNumber(meta.deathcrew) ?? 0;
|
||||
|
||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
|
||||
export const captureWebPushTurnBaseline = (
|
||||
world: InMemoryTurnWorld,
|
||||
reservedTurns?: InMemoryReservedTurnStore
|
||||
): WebPushTurnBaseline => {
|
||||
const state = world.getState();
|
||||
const meta = asRecord(state.meta);
|
||||
return {
|
||||
serverId: typeof meta.serverId === 'string' && meta.serverId ? meta.serverId : 'active-season',
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
turnTick: String(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
|
||||
generals: new Map(
|
||||
world.listGenerals().map((general) => {
|
||||
const generalMeta = asRecord(general.meta);
|
||||
return [
|
||||
general.id,
|
||||
{
|
||||
userId: general.userId ?? null,
|
||||
nationId: general.nationId,
|
||||
crew: general.crew,
|
||||
deathCrew: readDeathCrew(generalMeta),
|
||||
autorunLimit: readFiniteNumber(generalMeta.autorun_limit),
|
||||
},
|
||||
];
|
||||
})
|
||||
),
|
||||
hasReservedTurns: new Map(reservedTurns?.inspectGeneralTurnActivity() ?? []),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildTurnWebPushEvents = (input: {
|
||||
before: WebPushTurnBaseline;
|
||||
after: WebPushTurnBaseline;
|
||||
changes: Pick<TurnWorldChanges, 'deletedNationSnapshots'>;
|
||||
reservedTurnChanges?: Pick<ReservedTurnChanges, 'generalIds'>;
|
||||
}): WebPushOutboxEventInput[] => {
|
||||
const events: WebPushOutboxEventInput[] = [];
|
||||
const { before, after } = input;
|
||||
|
||||
for (const [generalId, current] of after.generals) {
|
||||
const previous = before.generals.get(generalId);
|
||||
if (!previous?.userId || previous.userId !== current.userId) continue;
|
||||
if (previous.crew > 0 && current.crew <= 0 && current.deathCrew > previous.deathCrew) {
|
||||
events.push({
|
||||
eventId: `${after.serverId}:troop-annihilated:${generalId}:${current.deathCrew}`,
|
||||
eventType: 'TROOP_ANNIHILATED',
|
||||
userIds: [current.userId],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dirtyReservedGeneralIds = new Set(input.reservedTurnChanges?.generalIds ?? []);
|
||||
for (const generalId of dirtyReservedGeneralIds) {
|
||||
if (!before.hasReservedTurns.get(generalId) || after.hasReservedTurns.get(generalId)) continue;
|
||||
const userId = after.generals.get(generalId)?.userId ?? before.generals.get(generalId)?.userId;
|
||||
if (!userId) continue;
|
||||
events.push({
|
||||
eventId: `${after.serverId}:reserved-turns-ended:${generalId}:${after.turnTick}`,
|
||||
eventType: 'RESERVED_TURNS_ENDED',
|
||||
userIds: [userId],
|
||||
});
|
||||
}
|
||||
|
||||
const beforeYearMonth = joinYearMonth(before.year, before.month);
|
||||
const afterYearMonth = joinYearMonth(after.year, after.month);
|
||||
if (afterYearMonth > beforeYearMonth) {
|
||||
events.push({
|
||||
eventId: `${after.serverId}:calendar:${after.year}:${after.month}`,
|
||||
eventType: 'TARGET_DATE_REACHED',
|
||||
year: after.year,
|
||||
month: after.month,
|
||||
});
|
||||
for (const [generalId, current] of after.generals) {
|
||||
const previous = before.generals.get(generalId);
|
||||
const limit = current.autorunLimit ?? previous?.autorunLimit;
|
||||
if (!current.userId || limit == null) continue;
|
||||
if (beforeYearMonth < limit && afterYearMonth >= limit) {
|
||||
events.push({
|
||||
eventId: `${after.serverId}:autorun-ended:${generalId}:${limit}`,
|
||||
eventType: 'AUTONOMOUS_ACTION_ENDED',
|
||||
userIds: [current.userId],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const snapshot of input.changes.deletedNationSnapshots) {
|
||||
const userIds = snapshot.generalIds
|
||||
.map((generalId) => before.generals.get(generalId)?.userId)
|
||||
.filter((userId): userId is string => Boolean(userId));
|
||||
if (userIds.length === 0) continue;
|
||||
events.push({
|
||||
eventId: `${after.serverId}:nation-destroyed:${snapshot.nation.id}:${snapshot.removedAt.toISOString()}`,
|
||||
eventType: 'NATION_DESTROYED',
|
||||
userIds,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTurnWebPushEvents, type WebPushTurnBaseline } from '../src/turn/webPushEvents.js';
|
||||
|
||||
const general = (
|
||||
userId: string | null,
|
||||
options: { nationId?: number; crew?: number; deathCrew?: number; autorunLimit?: number | null } = {}
|
||||
) => ({
|
||||
userId,
|
||||
nationId: options.nationId ?? 1,
|
||||
crew: options.crew ?? 100,
|
||||
deathCrew: options.deathCrew ?? 0,
|
||||
autorunLimit: options.autorunLimit ?? null,
|
||||
});
|
||||
|
||||
const baseline = (input: Partial<WebPushTurnBaseline> = {}): WebPushTurnBaseline => ({
|
||||
serverId: 'season-1',
|
||||
year: 200,
|
||||
month: 1,
|
||||
turnTick: '100',
|
||||
generals: new Map(),
|
||||
hasReservedTurns: new Map(),
|
||||
...input,
|
||||
});
|
||||
|
||||
describe('turn web push event projection', () => {
|
||||
it('emits annihilation only when battle casualty totals also increase', () => {
|
||||
const before = baseline({ generals: new Map([[1, general('user-1', { crew: 500, deathCrew: 10 })]]) });
|
||||
const battleAfter = baseline({
|
||||
generals: new Map([[1, general('user-1', { crew: 0, deathCrew: 510 })]]),
|
||||
turnTick: '101',
|
||||
});
|
||||
const disbandAfter = baseline({
|
||||
generals: new Map([[1, general('user-1', { crew: 0, deathCrew: 10 })]]),
|
||||
turnTick: '101',
|
||||
});
|
||||
|
||||
expect(buildTurnWebPushEvents({ before, after: battleAfter, changes: { deletedNationSnapshots: [] } })).toEqual(
|
||||
[expect.objectContaining({ eventType: 'TROOP_ANNIHILATED', userIds: ['user-1'] })]
|
||||
);
|
||||
expect(
|
||||
buildTurnWebPushEvents({ before, after: disbandAfter, changes: { deletedNationSnapshots: [] } })
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits reserved-turn completion only for a dirty queue that consumed its last command', () => {
|
||||
const before = baseline({
|
||||
generals: new Map([[1, general('user-1')]]),
|
||||
hasReservedTurns: new Map([[1, true]]),
|
||||
});
|
||||
const after = baseline({
|
||||
generals: new Map([[1, general('user-1')]]),
|
||||
hasReservedTurns: new Map([[1, false]]),
|
||||
turnTick: '102',
|
||||
});
|
||||
const events = buildTurnWebPushEvents({
|
||||
before,
|
||||
after,
|
||||
changes: { deletedNationSnapshots: [] },
|
||||
reservedTurnChanges: { generalIds: [1] },
|
||||
});
|
||||
expect(events).toEqual([expect.objectContaining({ eventType: 'RESERVED_TURNS_ENDED', userIds: ['user-1'] })]);
|
||||
});
|
||||
|
||||
it('emits calendar and autonomous-expiry events at the exclusive limit month', () => {
|
||||
const before = baseline({
|
||||
year: 200,
|
||||
month: 1,
|
||||
generals: new Map([[1, general('user-1', { autorunLimit: 2401 })]]),
|
||||
});
|
||||
const after = baseline({
|
||||
year: 200,
|
||||
month: 2,
|
||||
generals: new Map([[1, general('user-1', { autorunLimit: 2401 })]]),
|
||||
turnTick: '103',
|
||||
});
|
||||
expect(
|
||||
buildTurnWebPushEvents({ before, after, changes: { deletedNationSnapshots: [] } }).map(
|
||||
(event) => event.eventType
|
||||
)
|
||||
).toEqual(['TARGET_DATE_REACHED', 'AUTONOMOUS_ACTION_ENDED']);
|
||||
});
|
||||
|
||||
it('targets the users who belonged to a destroyed nation before its removal', () => {
|
||||
const before = baseline({
|
||||
generals: new Map([
|
||||
[1, general('user-1', { nationId: 7 })],
|
||||
[2, general(null, { nationId: 7 })],
|
||||
[3, general('user-3', { nationId: 7 })],
|
||||
]),
|
||||
});
|
||||
const after = baseline({
|
||||
generals: new Map([
|
||||
[1, general('user-1', { nationId: 0 })],
|
||||
[3, general('user-3', { nationId: 0 })],
|
||||
]),
|
||||
turnTick: '104',
|
||||
});
|
||||
const events = buildTurnWebPushEvents({
|
||||
before,
|
||||
after,
|
||||
changes: {
|
||||
deletedNationSnapshots: [
|
||||
{
|
||||
nation: { id: 7 },
|
||||
generalIds: [1, 2, 3],
|
||||
removedAt: new Date('0200-01-01T00:00:00.000Z'),
|
||||
} as never,
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ eventType: 'NATION_DESTROYED', userIds: ['user-1', 'user-3'] }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/web-push": "3.6.4",
|
||||
"tsdown": "^0.22.14",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
@@ -44,6 +45,7 @@
|
||||
"redis": "^5.10.0",
|
||||
"sanitize-html": "2.17.6",
|
||||
"sharp": "^0.35.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { procedure, router } from '../trpc.js';
|
||||
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
|
||||
import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js';
|
||||
import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
|
||||
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
|
||||
|
||||
const zSessionToken = z.string().min(1);
|
||||
const MAX_ICON_BYTES = 50 * 1024;
|
||||
@@ -117,6 +118,84 @@ const publishIconFlush = async (
|
||||
};
|
||||
|
||||
export const accountRouter = router({
|
||||
notifications: router({
|
||||
get: procedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionToken: zSessionToken,
|
||||
currentEndpoint: z.string().url().max(4096).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
return ctx.webPush.getAccountState(user.id, input.currentEndpoint);
|
||||
}),
|
||||
setPreference: procedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionToken: zSessionToken,
|
||||
profileName: z.string().min(1).max(128),
|
||||
eventType: z.enum(WEB_PUSH_EVENT_TYPES),
|
||||
enabled: z.boolean(),
|
||||
targetYear: z.number().int().min(0).max(9999).nullable().optional(),
|
||||
targetMonth: z.number().int().min(1).max(12).nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
try {
|
||||
await ctx.webPush.setPreference(user.id, input);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: error instanceof Error ? error.message : '알림 설정을 저장하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
subscribe: procedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionToken: zSessionToken,
|
||||
subscription: z
|
||||
.object({
|
||||
endpoint: z.string().url().max(4096),
|
||||
expirationTime: z.number().int().positive().nullable(),
|
||||
keys: z.object({
|
||||
p256dh: z.string().min(1).max(1024),
|
||||
auth: z.string().min(1).max(1024),
|
||||
}),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
try {
|
||||
const rawUserAgent = ctx.requestHeaders['user-agent'];
|
||||
const userAgent = Array.isArray(rawUserAgent) ? rawUserAgent[0] : rawUserAgent;
|
||||
await ctx.webPush.subscribe(user.id, input.subscription, userAgent);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: error instanceof Error ? error.message : '이 기기의 알림 구독을 저장하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
unsubscribe: procedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionToken: zSessionToken,
|
||||
endpoint: z.string().url().max(4096),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
await ctx.webPush.unsubscribe(user.id, input.endpoint);
|
||||
return { ok: true };
|
||||
}),
|
||||
}),
|
||||
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
const icons = await ctx.users.listIcons(user.id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
|
||||
@@ -41,6 +42,11 @@ export interface GatewayApiConfig {
|
||||
frontendArtifactRoot: string;
|
||||
frontendReadinessOrigin: string;
|
||||
releaseBuilderUrl?: string;
|
||||
webPushEnabled: boolean;
|
||||
webPushVapidSubject?: string;
|
||||
webPushVapidPublicKey?: string;
|
||||
webPushVapidPrivateKey?: string;
|
||||
webPushPollIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface GatewayOrchestratorConfig {
|
||||
@@ -82,6 +88,23 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
|
||||
const workspaceRootHint = env.GATEWAY_WORKSPACE_ROOT ?? process.cwd();
|
||||
const webPushEnabled = parseBooleanWithFallback(env.WEB_PUSH_ENABLED, false);
|
||||
const webPushVapidSubject = env.WEB_PUSH_VAPID_SUBJECT?.trim() || undefined;
|
||||
const webPushVapidPublicKey = env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() || undefined;
|
||||
let webPushVapidPrivateKey = env.WEB_PUSH_VAPID_PRIVATE_KEY?.trim() || undefined;
|
||||
const webPushVapidPrivateKeyFile = env.WEB_PUSH_VAPID_PRIVATE_KEY_FILE?.trim();
|
||||
if (webPushEnabled && !webPushVapidPrivateKey && webPushVapidPrivateKeyFile) {
|
||||
try {
|
||||
webPushVapidPrivateKey = readFileSync(webPushVapidPrivateKeyFile, 'utf8').trim() || undefined;
|
||||
} catch (error) {
|
||||
throw new Error('WEB_PUSH_VAPID_PRIVATE_KEY_FILE could not be read.', { cause: error });
|
||||
}
|
||||
}
|
||||
if (webPushEnabled && (!webPushVapidSubject || !webPushVapidPublicKey || !webPushVapidPrivateKey)) {
|
||||
throw new Error(
|
||||
'WEB_PUSH_ENABLED requires WEB_PUSH_VAPID_SUBJECT, WEB_PUSH_VAPID_PUBLIC_KEY, and a VAPID private key.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||
port,
|
||||
@@ -149,6 +172,15 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
frontendArtifactRoot: path.resolve(env.FRONTEND_ARTIFACT_ROOT ?? '/srv/frontend-artifacts'),
|
||||
frontendReadinessOrigin: env.FRONTEND_READINESS_ORIGIN?.trim() || 'http://caddy',
|
||||
releaseBuilderUrl: env.RELEASE_BUILDER_URL?.trim() || undefined,
|
||||
webPushEnabled,
|
||||
webPushVapidSubject,
|
||||
webPushVapidPublicKey,
|
||||
webPushVapidPrivateKey,
|
||||
webPushPollIntervalMs: parseNumberWithFallback(
|
||||
env.WEB_PUSH_POLL_INTERVAL_MS,
|
||||
1_000,
|
||||
'WEB_PUSH_POLL_INTERVAL_MS'
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
|
||||
import type { UserIconUploadStore } from './account/remoteUserIconStore.js';
|
||||
import path from 'node:path';
|
||||
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
|
||||
import { WebPushCoordinator } from './webPush/coordinator.js';
|
||||
|
||||
export interface GatewayApiContext {
|
||||
users: UserRepository;
|
||||
@@ -44,6 +45,7 @@ export interface GatewayApiContext {
|
||||
adminAudit: AdminAuditStore;
|
||||
adminAuth?: AdminAuthContext;
|
||||
navigationConfig: RuntimeNavigationConfigStore;
|
||||
webPush: WebPushCoordinator;
|
||||
}
|
||||
|
||||
export const createGatewayApiContext = (options: {
|
||||
@@ -71,6 +73,7 @@ export const createGatewayApiContext = (options: {
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit?: AdminAuditStore;
|
||||
navigationConfig?: RuntimeNavigationConfigStore;
|
||||
webPush?: WebPushCoordinator;
|
||||
}): GatewayApiContext => ({
|
||||
users: options.users,
|
||||
sessions: options.sessions,
|
||||
@@ -98,4 +101,5 @@ export const createGatewayApiContext = (options: {
|
||||
navigationConfig:
|
||||
options.navigationConfig ??
|
||||
new RuntimeNavigationConfigStore(null, path.resolve(process.cwd(), 'resources/navigation.json')),
|
||||
webPush: options.webPush ?? new WebPushCoordinator(options.prisma, { enabled: false }),
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
|
||||
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
|
||||
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
|
||||
import { registerRuntimeNavigationRoute } from './navigation/runtimeNavigationRoute.js';
|
||||
import { WebPushCoordinator } from './webPush/coordinator.js';
|
||||
import { registerWebPushInternalRoute } from './webPush/internalRoute.js';
|
||||
|
||||
export const createGatewayApiServer = async () => {
|
||||
const config = resolveGatewayApiConfigFromEnv();
|
||||
@@ -86,11 +88,21 @@ export const createGatewayApiServer = async () => {
|
||||
config.navigationConfigFile,
|
||||
config.defaultNavigationConfigFile
|
||||
);
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
routerOptions: gatewayFastifyRouterOptions,
|
||||
});
|
||||
const webPush = new WebPushCoordinator(
|
||||
postgres.prisma as GatewayPrismaClient,
|
||||
{
|
||||
enabled: config.webPushEnabled,
|
||||
vapidSubject: config.webPushVapidSubject,
|
||||
vapidPublicKey: config.webPushVapidPublicKey,
|
||||
vapidPrivateKey: config.webPushVapidPrivateKey,
|
||||
pollIntervalMs: config.webPushPollIntervalMs,
|
||||
},
|
||||
(error) => app.log.error({ err: error }, 'web push delivery failed')
|
||||
);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
@@ -110,6 +122,7 @@ export const createGatewayApiServer = async () => {
|
||||
profiles,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
registerWebPushInternalRoute(app, { secret: config.gameTokenSecret, webPush });
|
||||
registerRuntimeNavigationRoute(app, navigationConfig);
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
@@ -142,6 +155,7 @@ export const createGatewayApiServer = async () => {
|
||||
requestHeaders: req.headers,
|
||||
prisma: postgres.prisma as GatewayPrismaClient,
|
||||
navigationConfig,
|
||||
webPush,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -152,11 +166,14 @@ export const createGatewayApiServer = async () => {
|
||||
}));
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await webPush.stop();
|
||||
await orchestrator.stop();
|
||||
await redis.disconnect();
|
||||
await postgres.disconnect();
|
||||
});
|
||||
|
||||
webPush.start();
|
||||
|
||||
return {
|
||||
app,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
WEB_PUSH_EVENT_TYPES,
|
||||
isWebPushEventType,
|
||||
type WebPushClientSubscription,
|
||||
type WebPushEventEnvelopeV1,
|
||||
type WebPushEventType,
|
||||
} from '@sammo-ts/common';
|
||||
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import webPush from 'web-push';
|
||||
|
||||
export interface WebPushCoordinatorConfig {
|
||||
enabled: boolean;
|
||||
vapidSubject?: string;
|
||||
vapidPublicKey?: string;
|
||||
vapidPrivateKey?: string;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
type GatewayTransaction = GatewayPrisma.TransactionClient;
|
||||
|
||||
const profileWideEvents = new Set<WebPushEventType>([
|
||||
'PROFILE_PREOPENED',
|
||||
'PROFILE_OPEN_SCHEDULED',
|
||||
'PROFILE_OPENED',
|
||||
'TARGET_DATE_REACHED',
|
||||
]);
|
||||
|
||||
const uniqueUserIds = (values: readonly string[]): string[] => [...new Set(values.filter(Boolean))].sort();
|
||||
|
||||
const copyFor = (
|
||||
eventType: WebPushEventType,
|
||||
profileLabel: string,
|
||||
year?: number,
|
||||
month?: number
|
||||
): { title: string; body: string } => {
|
||||
switch (eventType) {
|
||||
case 'TROOP_ANNIHILATED':
|
||||
return { title: '병력 전멸', body: `${profileLabel}에서 내 병력이 전멸했습니다.` };
|
||||
case 'PRIVATE_MESSAGE_RECEIVED':
|
||||
return { title: '새 개인 서신', body: `${profileLabel}에 새 개인 서신이 도착했습니다.` };
|
||||
case 'AUTONOMOUS_ACTION_ENDED':
|
||||
return { title: '자율행동 종료', body: `${profileLabel}의 자율행동 기간이 끝났습니다.` };
|
||||
case 'RESERVED_TURNS_ENDED':
|
||||
return { title: '예턴 종료', body: `${profileLabel}에서 등록한 예턴이 모두 실행되었습니다.` };
|
||||
case 'PROFILE_PREOPENED':
|
||||
return { title: '서버 가오픈', body: `${profileLabel} 서버가 가오픈되었습니다.` };
|
||||
case 'PROFILE_OPEN_SCHEDULED':
|
||||
return { title: '서버 오픈 예약', body: `${profileLabel} 서버의 오픈 시간이 예약되었습니다.` };
|
||||
case 'PROFILE_OPENED':
|
||||
return { title: '서버 오픈', body: `${profileLabel} 서버가 오픈되었습니다.` };
|
||||
case 'NATION_DESTROYED':
|
||||
return { title: '국가 멸망', body: `${profileLabel}에서 내 국가가 멸망했습니다.` };
|
||||
case 'TARGET_DATE_REACHED':
|
||||
return {
|
||||
title: '설정 연월 도달',
|
||||
body:
|
||||
year !== undefined && month !== undefined
|
||||
? `${profileLabel}이 ${year}년 ${month}월에 도달했습니다.`
|
||||
: `${profileLabel}이 설정한 연월에 도달했습니다.`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isConfigured = (config: WebPushCoordinatorConfig): boolean =>
|
||||
Boolean(config.enabled && config.vapidSubject && config.vapidPublicKey && config.vapidPrivateKey);
|
||||
|
||||
export class WebPushCoordinator {
|
||||
private readonly configured: boolean;
|
||||
private readonly owner = `gateway-web-push:${process.pid}:${randomUUID()}`;
|
||||
private readonly pollIntervalMs: number;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private nextProfileReconcileAt = 0;
|
||||
private nextPruneAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: GatewayPrismaClient,
|
||||
private readonly config: WebPushCoordinatorConfig,
|
||||
private readonly onError: (error: unknown) => void = () => undefined
|
||||
) {
|
||||
this.configured = isConfigured(config);
|
||||
this.pollIntervalMs = Math.max(250, Math.floor(config.pollIntervalMs ?? 1_000));
|
||||
if (this.configured) {
|
||||
webPush.setVapidDetails(config.vapidSubject!, config.vapidPublicKey!, config.vapidPrivateKey!);
|
||||
}
|
||||
}
|
||||
|
||||
getCapability(): { enabled: boolean; publicKey: string | null } {
|
||||
return {
|
||||
enabled: this.configured,
|
||||
publicKey: this.configured ? this.config.vapidPublicKey! : null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAccountState(userId: string, currentEndpoint?: string) {
|
||||
const now = new Date();
|
||||
const activeSubscriptionWhere: GatewayPrisma.WebPushSubscriptionWhereInput = {
|
||||
userId,
|
||||
disabledAt: null,
|
||||
OR: [{ expirationTime: null }, { expirationTime: { gt: now } }],
|
||||
};
|
||||
const [profiles, preferences, subscriptionCount, currentSubscription] = await Promise.all([
|
||||
this.prisma.gatewayProfile.findMany({
|
||||
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
|
||||
select: { profileName: true, profile: true, currentScenario: true, status: true },
|
||||
}),
|
||||
this.prisma.webPushPreference.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
profileName: true,
|
||||
eventType: true,
|
||||
enabled: true,
|
||||
targetYear: true,
|
||||
targetMonth: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.webPushSubscription.count({ where: activeSubscriptionWhere }),
|
||||
currentEndpoint
|
||||
? this.prisma.webPushSubscription.findFirst({
|
||||
where: { ...activeSubscriptionWhere, endpoint: currentEndpoint },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
return {
|
||||
capability: this.getCapability(),
|
||||
eventTypes: WEB_PUSH_EVENT_TYPES,
|
||||
profiles: profiles.map((profile) => ({
|
||||
...profile,
|
||||
status: String(profile.status),
|
||||
})),
|
||||
preferences: preferences.filter((preference) => isWebPushEventType(preference.eventType)),
|
||||
subscriptionCount,
|
||||
currentDeviceSubscribed: Boolean(currentSubscription),
|
||||
};
|
||||
}
|
||||
|
||||
async setPreference(
|
||||
userId: string,
|
||||
input: {
|
||||
profileName: string;
|
||||
eventType: WebPushEventType;
|
||||
enabled: boolean;
|
||||
targetYear?: number | null;
|
||||
targetMonth?: number | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const profile = await this.prisma.gatewayProfile.findUnique({
|
||||
where: { profileName: input.profileName },
|
||||
select: { profileName: true },
|
||||
});
|
||||
if (!profile) throw new Error('알림 대상 서버를 찾을 수 없습니다.');
|
||||
const isTargetDate = input.eventType === 'TARGET_DATE_REACHED';
|
||||
if (isTargetDate && input.enabled && (input.targetYear == null || input.targetMonth == null)) {
|
||||
throw new Error('도달 알림의 연도와 월을 입력해 주세요.');
|
||||
}
|
||||
await this.prisma.webPushPreference.upsert({
|
||||
where: {
|
||||
userId_profileName_eventType: {
|
||||
userId,
|
||||
profileName: input.profileName,
|
||||
eventType: input.eventType,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
profileName: input.profileName,
|
||||
eventType: input.eventType,
|
||||
enabled: input.enabled,
|
||||
targetYear: isTargetDate ? (input.targetYear ?? null) : null,
|
||||
targetMonth: isTargetDate ? (input.targetMonth ?? null) : null,
|
||||
},
|
||||
update: {
|
||||
enabled: input.enabled,
|
||||
targetYear: isTargetDate ? (input.targetYear ?? null) : null,
|
||||
targetMonth: isTargetDate ? (input.targetMonth ?? null) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async subscribe(userId: string, subscription: WebPushClientSubscription, userAgent?: string): Promise<void> {
|
||||
if (!this.configured) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.');
|
||||
const endpointUrl = new URL(subscription.endpoint);
|
||||
if (endpointUrl.protocol !== 'https:') throw new Error('보안 연결의 Push 구독만 저장할 수 있습니다.');
|
||||
const expirationTime = subscription.expirationTime ? new Date(subscription.expirationTime) : null;
|
||||
await this.prisma.webPushSubscription.upsert({
|
||||
where: { endpoint: subscription.endpoint },
|
||||
create: {
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
expirationTime,
|
||||
userAgent: userAgent?.slice(0, 500),
|
||||
},
|
||||
update: {
|
||||
userId,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
expirationTime,
|
||||
userAgent: userAgent?.slice(0, 500),
|
||||
disabledAt: null,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||
await this.prisma.webPushSubscription.updateMany({
|
||||
where: { userId, endpoint },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async enqueueEventTx(tx: GatewayTransaction, event: WebPushEventEnvelopeV1): Promise<boolean> {
|
||||
if (!this.configured) return false;
|
||||
const profile = await tx.gatewayProfile.findUnique({
|
||||
where: { profileName: event.profileName },
|
||||
select: { profile: true, profileName: true },
|
||||
});
|
||||
if (!profile) return false;
|
||||
const receipt = await tx.webPushEventReceipt.createMany({
|
||||
data: [{ eventId: event.eventId, profileName: event.profileName, eventType: event.eventType }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
if (receipt.count === 0) return false;
|
||||
|
||||
const preferenceWhere: GatewayPrisma.WebPushPreferenceWhereInput = {
|
||||
profileName: event.profileName,
|
||||
eventType: event.eventType,
|
||||
enabled: true,
|
||||
...(event.eventType === 'TARGET_DATE_REACHED'
|
||||
? { targetYear: event.year, targetMonth: event.month }
|
||||
: profileWideEvents.has(event.eventType)
|
||||
? {}
|
||||
: { userId: { in: uniqueUserIds(event.userIds) } }),
|
||||
};
|
||||
const preferences = await tx.webPushPreference.findMany({
|
||||
where: preferenceWhere,
|
||||
select: { userId: true },
|
||||
});
|
||||
const selectedUserIds = uniqueUserIds(preferences.map((preference) => preference.userId));
|
||||
if (selectedUserIds.length === 0) return true;
|
||||
|
||||
const subscriptions = await tx.webPushSubscription.findMany({
|
||||
where: {
|
||||
userId: { in: selectedUserIds },
|
||||
disabledAt: null,
|
||||
OR: [{ expirationTime: null }, { expirationTime: { gt: new Date() } }],
|
||||
},
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
const subscriptionIdsByUser = new Map<string, string[]>();
|
||||
for (const subscription of subscriptions) {
|
||||
const ids = subscriptionIdsByUser.get(subscription.userId) ?? [];
|
||||
ids.push(subscription.id);
|
||||
subscriptionIdsByUser.set(subscription.userId, ids);
|
||||
}
|
||||
const copy = copyFor(event.eventType, profile.profile, event.year, event.month);
|
||||
for (const userId of selectedUserIds) {
|
||||
const subscriptionIds = subscriptionIdsByUser.get(userId) ?? [];
|
||||
if (subscriptionIds.length === 0) continue;
|
||||
const dedupeKey = `${event.eventId}:${userId}`;
|
||||
const notification = await tx.webPushNotification.upsert({
|
||||
where: { dedupeKey },
|
||||
create: {
|
||||
dedupeKey,
|
||||
userId,
|
||||
profileName: event.profileName,
|
||||
eventType: event.eventType,
|
||||
title: copy.title,
|
||||
body: copy.body,
|
||||
url: `/${encodeURIComponent(profile.profile)}/`,
|
||||
tag: `sammo-${event.profileName}-${event.eventType}`,
|
||||
},
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.webPushDelivery.createMany({
|
||||
data: subscriptionIds.map((subscriptionId) => ({
|
||||
notificationId: notification.id,
|
||||
subscriptionId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async ingest(event: WebPushEventEnvelopeV1): Promise<{ queued: boolean }> {
|
||||
if (!this.configured) return { queued: false };
|
||||
const queued = await this.prisma.$transaction((tx) => this.enqueueEventTx(tx, event));
|
||||
this.wake();
|
||||
return { queued };
|
||||
}
|
||||
|
||||
async reconcileProfiles(now = new Date()): Promise<void> {
|
||||
const profiles = await this.prisma.gatewayProfile.findMany({
|
||||
select: {
|
||||
profileName: true,
|
||||
status: true,
|
||||
preopenAt: true,
|
||||
openAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
for (const profile of profiles) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.webPushProfileCursor.findUnique({
|
||||
where: { profileName: profile.profileName },
|
||||
});
|
||||
await tx.webPushProfileCursor.upsert({
|
||||
where: { profileName: profile.profileName },
|
||||
create: {
|
||||
profileName: profile.profileName,
|
||||
status: String(profile.status),
|
||||
preopenAt: profile.preopenAt,
|
||||
openAt: profile.openAt,
|
||||
},
|
||||
update: {
|
||||
status: String(profile.status),
|
||||
preopenAt: profile.preopenAt,
|
||||
openAt: profile.openAt,
|
||||
},
|
||||
});
|
||||
if (!previous || !this.configured) return;
|
||||
const events: WebPushEventEnvelopeV1[] = [];
|
||||
const eventBase = `gateway:${profile.profileName}:${profile.updatedAt.toISOString()}`;
|
||||
if (previous.status !== String(profile.status) && profile.status === 'PREOPEN') {
|
||||
events.push({
|
||||
version: 1,
|
||||
eventId: `${eventBase}:preopen`,
|
||||
eventType: 'PROFILE_PREOPENED',
|
||||
profileName: profile.profileName,
|
||||
userIds: [],
|
||||
occurredAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
if (previous.status !== String(profile.status) && profile.status === 'RUNNING') {
|
||||
events.push({
|
||||
version: 1,
|
||||
eventId: `${eventBase}:opened`,
|
||||
eventType: 'PROFILE_OPENED',
|
||||
profileName: profile.profileName,
|
||||
userIds: [],
|
||||
occurredAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
profile.openAt &&
|
||||
profile.openAt.getTime() > now.getTime() &&
|
||||
previous.openAt?.getTime() !== profile.openAt.getTime()
|
||||
) {
|
||||
events.push({
|
||||
version: 1,
|
||||
eventId: `${eventBase}:open-scheduled:${profile.openAt.toISOString()}`,
|
||||
eventType: 'PROFILE_OPEN_SCHEDULED',
|
||||
profileName: profile.profileName,
|
||||
userIds: [],
|
||||
occurredAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
for (const event of events) await this.enqueueEventTx(tx, event);
|
||||
});
|
||||
}
|
||||
this.wake();
|
||||
}
|
||||
|
||||
private async dispatchBatch(): Promise<void> {
|
||||
if (!this.configured) return;
|
||||
const claimed = await this.prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>(GatewayPrisma.sql`
|
||||
SELECT "id"
|
||||
FROM "web_push_delivery"
|
||||
WHERE "status" = 'PENDING'
|
||||
AND "available_at" <= CURRENT_TIMESTAMP
|
||||
AND ("locked_at" IS NULL OR "locked_at" <= CURRENT_TIMESTAMP - INTERVAL '30 seconds')
|
||||
ORDER BY "id"
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 25
|
||||
`);
|
||||
if (rows.length === 0) return [];
|
||||
const ids = rows.map((row) => row.id);
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
||||
});
|
||||
return tx.webPushDelivery.findMany({
|
||||
where: { id: { in: ids }, lockOwner: this.owner },
|
||||
include: { notification: true, subscription: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
for (const delivery of claimed) {
|
||||
if (
|
||||
delivery.subscription.expirationTime &&
|
||||
delivery.subscription.expirationTime.getTime() <= Date.now()
|
||||
) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: 'Push subscription expired.',
|
||||
},
|
||||
});
|
||||
await tx.webPushSubscription.update({
|
||||
where: { id: delivery.subscriptionId },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await webPush.sendNotification(
|
||||
{
|
||||
endpoint: delivery.subscription.endpoint,
|
||||
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
|
||||
},
|
||||
JSON.stringify({
|
||||
title: delivery.notification.title,
|
||||
body: delivery.notification.body,
|
||||
url: delivery.notification.url,
|
||||
tag: delivery.notification.tag,
|
||||
}),
|
||||
{ TTL: 60 * 60 }
|
||||
);
|
||||
await this.prisma.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
data: {
|
||||
status: 'DELIVERED',
|
||||
deliveredAt: new Date(),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
typeof error === 'object' && error !== null && 'statusCode' in error
|
||||
? Number((error as { statusCode?: unknown }).statusCode)
|
||||
: 0;
|
||||
const terminal = statusCode === 404 || statusCode === 410 || (statusCode >= 400 && statusCode < 500 && statusCode !== 429);
|
||||
const attempts = delivery.attempts;
|
||||
const exhausted = attempts >= 8;
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||
const safeError = statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
data: {
|
||||
status: terminal || exhausted ? 'FAILED' : 'PENDING',
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: safeError,
|
||||
},
|
||||
});
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
await tx.webPushSubscription.update({
|
||||
where: { id: delivery.subscriptionId },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!terminal) this.onError(new Error(safeError));
|
||||
}
|
||||
}
|
||||
if (Date.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = Date.now() + 60_000;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
WITH expired AS (
|
||||
SELECT "event_id"
|
||||
FROM "web_push_event_receipt"
|
||||
WHERE "created_at" < CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
ORDER BY "created_at"
|
||||
LIMIT 500
|
||||
)
|
||||
DELETE FROM "web_push_event_receipt"
|
||||
WHERE "event_id" IN (SELECT "event_id" FROM expired)
|
||||
`);
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
WITH expired AS (
|
||||
SELECT notification."id"
|
||||
FROM "web_push_notification" AS notification
|
||||
WHERE notification."created_at" < CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "web_push_delivery" AS delivery
|
||||
WHERE delivery."notification_id" = notification."id"
|
||||
AND delivery."status" = 'PENDING'
|
||||
)
|
||||
ORDER BY notification."created_at"
|
||||
LIMIT 500
|
||||
)
|
||||
DELETE FROM "web_push_notification"
|
||||
WHERE "id" IN (SELECT "id" FROM expired)
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (!this.configured || this.inFlight) return;
|
||||
const now = Date.now();
|
||||
const shouldReconcileProfiles = now >= this.nextProfileReconcileAt;
|
||||
if (shouldReconcileProfiles) this.nextProfileReconcileAt = now + 5_000;
|
||||
this.inFlight = (shouldReconcileProfiles ? this.reconcileProfiles() : Promise.resolve())
|
||||
.then(() => this.dispatchBatch())
|
||||
.catch(this.onError)
|
||||
.finally(() => {
|
||||
this.inFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.configured || this.timer) return;
|
||||
this.timer = setInterval(() => this.run(), this.pollIntervalMs);
|
||||
this.timer.unref?.();
|
||||
this.run();
|
||||
}
|
||||
|
||||
wake(): void {
|
||||
this.run();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
await this.inFlight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { WEB_PUSH_EVENT_TYPES, type WebPushEventEnvelopeV1 } from '@sammo-ts/common';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { WebPushCoordinator } from './coordinator.js';
|
||||
|
||||
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
|
||||
const INTERNAL_TOKEN_CONTEXT = 'sammo:web-push-event-ingest:v1';
|
||||
|
||||
const zEnvelope = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
eventId: z.string().min(1).max(500),
|
||||
eventType: z.enum(WEB_PUSH_EVENT_TYPES),
|
||||
profileName: z.string().min(1).max(128),
|
||||
userIds: z.array(z.string().uuid()).max(5_000),
|
||||
year: z.number().int().min(0).max(9999).optional(),
|
||||
month: z.number().int().min(1).max(12).optional(),
|
||||
occurredAt: z.iso.datetime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const deriveWebPushIngestToken = (secret: string): string =>
|
||||
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||
|
||||
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
|
||||
const candidate = Array.isArray(provided) ? provided[0] : provided;
|
||||
if (!candidate) return false;
|
||||
const candidateBuffer = Buffer.from(candidate);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
|
||||
};
|
||||
|
||||
export const registerWebPushInternalRoute = (
|
||||
app: FastifyInstance,
|
||||
options: { secret: string; webPush: WebPushCoordinator }
|
||||
): void => {
|
||||
app.post('/internal/web-push-events', async (request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveWebPushIngestToken(options.secret))) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
const parsed = zEnvelope.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
await reply.status(400).send({ ok: false, error: 'invalid_event' });
|
||||
return;
|
||||
}
|
||||
const result = await options.webPush.ingest(parsed.data as WebPushEventEnvelopeV1);
|
||||
await reply.send({ ok: true, queued: result.queued });
|
||||
});
|
||||
};
|
||||
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260821173000_gateway_release_instant_timestamps',
|
||||
gameSchemaHead: '20260820002000_persist_official_game_index',
|
||||
gatewaySchemaHead: '20260823010000_add_web_push_notifications',
|
||||
gameSchemaHead: '20260823010000_add_web_push_outbox',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveGatewayApiConfigFromEnv } from '../src/config.js';
|
||||
|
||||
const requiredEnv = {
|
||||
GAME_TOKEN_SECRET: 'test-game-token-secret',
|
||||
KAKAO_REST_KEY: 'test-kakao-key',
|
||||
KAKAO_REDIRECT_URI: 'https://gateway.test.invalid/gateway/oauth/callback',
|
||||
};
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveGatewayApiConfigFromEnv web push', () => {
|
||||
it('stays disabled without reading a configured private-key file', () => {
|
||||
const config = resolveGatewayApiConfigFromEnv({
|
||||
...requiredEnv,
|
||||
WEB_PUSH_ENABLED: 'false',
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: '/does/not/exist',
|
||||
});
|
||||
|
||||
expect(config.webPushEnabled).toBe(false);
|
||||
expect(config.webPushVapidPrivateKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads the VAPID private key from a file only when enabled', () => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'sammo-web-push-config-'));
|
||||
tempDirectories.push(directory);
|
||||
const privateKeyFile = path.join(directory, 'vapid-private-key');
|
||||
writeFileSync(privateKeyFile, 'test-private-key\n', { mode: 0o600 });
|
||||
|
||||
const config = resolveGatewayApiConfigFromEnv({
|
||||
...requiredEnv,
|
||||
WEB_PUSH_ENABLED: 'true',
|
||||
WEB_PUSH_VAPID_SUBJECT: 'mailto:admin@test.invalid',
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY: 'test-public-key',
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: privateKeyFile,
|
||||
});
|
||||
|
||||
expect(config.webPushEnabled).toBe(true);
|
||||
expect(config.webPushVapidPrivateKey).toBe('test-private-key');
|
||||
});
|
||||
|
||||
it('fails closed when activation is incomplete', () => {
|
||||
expect(() =>
|
||||
resolveGatewayApiConfigFromEnv({
|
||||
...requiredEnv,
|
||||
WEB_PUSH_ENABLED: 'true',
|
||||
})
|
||||
).toThrow(/WEB_PUSH_ENABLED requires/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { WebPushCoordinator } from '../src/webPush/coordinator.js';
|
||||
|
||||
const databaseUrl = process.env.WEB_PUSH_GATEWAY_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const schema = process.env.WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA;
|
||||
const userId = '8c770c8d-3515-4f6c-8a54-5f17330d9f66';
|
||||
const profileName = 'hwe:web-push-integration';
|
||||
|
||||
const assertDedicatedSchema = (): void => {
|
||||
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
if (!schema?.endsWith('_web_push_integration') || actual !== schema) {
|
||||
throw new Error('Refusing to mutate a Gateway database outside the web-push integration schema.');
|
||||
}
|
||||
};
|
||||
|
||||
integration('web push Gateway persistence boundary', () => {
|
||||
let db: GatewayPrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let coordinator: WebPushCoordinator;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedSchema();
|
||||
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.webPushEventReceipt.deleteMany({ where: { profileName } });
|
||||
await db.webPushProfileCursor.deleteMany({ where: { profileName } });
|
||||
await db.gatewayProfile.deleteMany({ where: { profileName } });
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
await db.appUser.create({
|
||||
data: {
|
||||
id: userId,
|
||||
loginId: 'web-push-integration',
|
||||
displayName: '웹 푸시 통합',
|
||||
passwordHash: 'not-used',
|
||||
passwordSalt: 'not-used',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
},
|
||||
});
|
||||
await db.gatewayProfile.create({
|
||||
data: {
|
||||
profileName,
|
||||
profile: 'hwe',
|
||||
instanceKey: 'web-push-integration',
|
||||
currentScenario: 'default',
|
||||
scenario: 'default',
|
||||
apiPort: 15015,
|
||||
status: 'RESERVED',
|
||||
},
|
||||
});
|
||||
await db.webPushSubscription.create({
|
||||
data: {
|
||||
userId,
|
||||
endpoint: 'https://push.example.invalid/subscription/integration',
|
||||
p256dh: 'public-key-placeholder',
|
||||
auth: 'auth-placeholder',
|
||||
},
|
||||
});
|
||||
const vapid = webPush.generateVAPIDKeys();
|
||||
coordinator = new WebPushCoordinator(db, {
|
||||
enabled: true,
|
||||
vapidSubject: 'mailto:web-push-test@example.invalid',
|
||||
vapidPublicKey: vapid.publicKey,
|
||||
vapidPrivateKey: vapid.privateKey,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.webPushEventReceipt.deleteMany({ where: { profileName } });
|
||||
await db.webPushProfileCursor.deleteMany({ where: { profileName } });
|
||||
await db.gatewayProfile.deleteMany({ where: { profileName } });
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('fans out an enabled private-message event once without persisting its content', async () => {
|
||||
await coordinator.setPreference(userId, {
|
||||
profileName,
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
enabled: true,
|
||||
});
|
||||
const event = {
|
||||
version: 1 as const,
|
||||
eventId: 'integration:private-message:1',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED' as const,
|
||||
profileName,
|
||||
userIds: [userId],
|
||||
occurredAt: '2026-08-23T00:00:00.000Z',
|
||||
};
|
||||
await expect(coordinator.ingest(event)).resolves.toEqual({ queued: true });
|
||||
await expect(coordinator.ingest(event)).resolves.toEqual({ queued: false });
|
||||
|
||||
const notifications = await db.webPushNotification.findMany({
|
||||
where: { profileName, eventType: 'PRIVATE_MESSAGE_RECEIVED' },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]).toMatchObject({
|
||||
userId,
|
||||
title: '새 개인 서신',
|
||||
url: '/hwe/',
|
||||
deliveries: [expect.objectContaining({ status: 'PENDING' })],
|
||||
});
|
||||
expect(
|
||||
JSON.stringify(notifications.map(({ title, body, url, tag }) => ({ title, body, url, tag })))
|
||||
).not.toContain('private message content');
|
||||
});
|
||||
|
||||
it('matches a target-date preference and records profile lifecycle transitions', async () => {
|
||||
await coordinator.setPreference(userId, {
|
||||
profileName,
|
||||
eventType: 'TARGET_DATE_REACHED',
|
||||
enabled: true,
|
||||
targetYear: 201,
|
||||
targetMonth: 3,
|
||||
});
|
||||
await coordinator.setPreference(userId, {
|
||||
profileName,
|
||||
eventType: 'PROFILE_PREOPENED',
|
||||
enabled: true,
|
||||
});
|
||||
await coordinator.ingest({
|
||||
version: 1,
|
||||
eventId: 'integration:calendar:201:3',
|
||||
eventType: 'TARGET_DATE_REACHED',
|
||||
profileName,
|
||||
userIds: [],
|
||||
year: 201,
|
||||
month: 3,
|
||||
occurredAt: '2026-08-23T00:00:00.000Z',
|
||||
});
|
||||
await coordinator.reconcileProfiles(new Date('2026-08-23T00:00:00.000Z'));
|
||||
await db.gatewayProfile.update({ where: { profileName }, data: { status: 'PREOPEN' } });
|
||||
await coordinator.reconcileProfiles(new Date('2026-08-23T00:01:00.000Z'));
|
||||
|
||||
const rows = await db.webPushNotification.findMany({
|
||||
where: { profileName, eventType: { in: ['TARGET_DATE_REACHED', 'PROFILE_PREOPENED'] } },
|
||||
orderBy: { eventType: 'asc' },
|
||||
});
|
||||
expect(rows.map((row) => row.eventType).sort()).toEqual(['PROFILE_PREOPENED', 'TARGET_DATE_REACHED']);
|
||||
expect(rows.find((row) => row.eventType === 'TARGET_DATE_REACHED')?.body).toContain('201년 3월');
|
||||
});
|
||||
|
||||
it('drops events while globally disabled instead of creating a future backlog', async () => {
|
||||
const disabled = new WebPushCoordinator(db, { enabled: false });
|
||||
await expect(
|
||||
disabled.ingest({
|
||||
version: 1,
|
||||
eventId: 'integration:disabled:1',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
profileName,
|
||||
userIds: [userId],
|
||||
occurredAt: '2026-08-23T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toEqual({ queued: false });
|
||||
await expect(
|
||||
db.webPushEventReceipt.findUnique({ where: { eventId: 'integration:disabled:1' } })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import fastify from 'fastify';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WebPushCoordinator } from '../src/webPush/coordinator.js';
|
||||
import { deriveWebPushIngestToken, registerWebPushInternalRoute } from '../src/webPush/internalRoute.js';
|
||||
|
||||
const secret = 'web-push-route-test-secret';
|
||||
const userId = '11111111-1111-4111-8111-111111111111';
|
||||
const event = {
|
||||
version: 1,
|
||||
eventId: 'game:hwe:default:message:42',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
profileName: 'hwe:default',
|
||||
userIds: [userId],
|
||||
occurredAt: '2026-08-23T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('web push internal event route', () => {
|
||||
it('accepts the strict privacy-safe envelope with a purpose-derived token', async () => {
|
||||
const app = fastify();
|
||||
const ingest = vi.fn().mockResolvedValue({ queued: true });
|
||||
registerWebPushInternalRoute(app, {
|
||||
secret,
|
||||
webPush: { ingest } as unknown as WebPushCoordinator,
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/web-push-events',
|
||||
headers: { 'x-sammo-internal-token': secret },
|
||||
payload: event,
|
||||
});
|
||||
expect(unauthorized.statusCode).toBe(401);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/web-push-events',
|
||||
headers: { 'x-sammo-internal-token': deriveWebPushIngestToken(secret) },
|
||||
payload: event,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.json()).toEqual({ ok: true, queued: true });
|
||||
expect(ingest).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('rejects payload extensions such as private message text', async () => {
|
||||
const app = fastify();
|
||||
const ingest = vi.fn();
|
||||
registerWebPushInternalRoute(app, {
|
||||
secret,
|
||||
webPush: { ingest } as unknown as WebPushCoordinator,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/web-push-events',
|
||||
headers: { 'x-sammo-internal-token': deriveWebPushIngestToken(secret) },
|
||||
payload: { ...event, message: 'private message content' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,3 +29,4 @@ export * from './legacyArchive/ArchivedGeneralSnapshot.js';
|
||||
export * from './gateway/profileStatus.js';
|
||||
export * from './game/accessPenalty.js';
|
||||
export * from './http/trpcTransport.js';
|
||||
export * from './webPush/types.js';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export const WEB_PUSH_EVENT_TYPES = [
|
||||
'TROOP_ANNIHILATED',
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
'AUTONOMOUS_ACTION_ENDED',
|
||||
'RESERVED_TURNS_ENDED',
|
||||
'PROFILE_PREOPENED',
|
||||
'PROFILE_OPEN_SCHEDULED',
|
||||
'PROFILE_OPENED',
|
||||
'NATION_DESTROYED',
|
||||
'TARGET_DATE_REACHED',
|
||||
] as const;
|
||||
|
||||
export type WebPushEventType = (typeof WEB_PUSH_EVENT_TYPES)[number];
|
||||
|
||||
export const WEB_PUSH_TARGETED_EVENT_TYPES = [
|
||||
'TROOP_ANNIHILATED',
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
'AUTONOMOUS_ACTION_ENDED',
|
||||
'RESERVED_TURNS_ENDED',
|
||||
'NATION_DESTROYED',
|
||||
] as const satisfies readonly WebPushEventType[];
|
||||
|
||||
export type WebPushTargetedEventType = (typeof WEB_PUSH_TARGETED_EVENT_TYPES)[number];
|
||||
|
||||
export interface WebPushEventEnvelopeV1 {
|
||||
version: 1;
|
||||
eventId: string;
|
||||
eventType: WebPushEventType;
|
||||
profileName: string;
|
||||
userIds: string[];
|
||||
year?: number;
|
||||
month?: number;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface WebPushClientSubscription {
|
||||
endpoint: string;
|
||||
expirationTime: number | null;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const isWebPushEventType = (value: unknown): value is WebPushEventType =>
|
||||
typeof value === 'string' && (WEB_PUSH_EVENT_TYPES as readonly string[]).includes(value);
|
||||
@@ -108,6 +108,25 @@ model ReadModelOutbox {
|
||||
@@map("read_model_outbox")
|
||||
}
|
||||
|
||||
model WebPushOutbox {
|
||||
id BigInt @id @default(autoincrement())
|
||||
eventId String @unique @map("event_id")
|
||||
eventType String @map("event_type")
|
||||
userIds String[] @default([]) @map("user_ids")
|
||||
year Int?
|
||||
month Int?
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(now()) @map("available_at")
|
||||
lockedAt DateTime? @map("locked_at")
|
||||
lockOwner String? @map("lock_owner")
|
||||
deliveredAt DateTime? @map("delivered_at")
|
||||
lastError String? @map("last_error")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx")
|
||||
@@map("web_push_outbox")
|
||||
}
|
||||
|
||||
model ReadModelRevisionMeta {
|
||||
id Int @id
|
||||
coverageVersion Int @default(0) @map("coverage_version")
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
CREATE TABLE "web_push_subscription" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"user_id" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"expiration_time" TIMESTAMP(3),
|
||||
"user_agent" TEXT,
|
||||
"disabled_at" TIMESTAMP(3),
|
||||
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_subscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_preference" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"user_id" TEXT NOT NULL,
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"target_year" INTEGER,
|
||||
"target_month" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_preference_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_event_receipt" (
|
||||
"event_id" TEXT NOT NULL,
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_event_receipt_pkey" PRIMARY KEY ("event_id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_notification" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"dedupe_key" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"tag" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_delivery" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"notification_id" UUID NOT NULL,
|
||||
"subscription_id" UUID NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"locked_at" TIMESTAMP(3),
|
||||
"lock_owner" TEXT,
|
||||
"delivered_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_delivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_profile_cursor" (
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"preopen_at" TIMESTAMP(3),
|
||||
"open_at" TIMESTAMP(3),
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_profile_cursor_pkey" PRIMARY KEY ("profile_name")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "web_push_subscription_endpoint_key" ON "web_push_subscription"("endpoint");
|
||||
CREATE INDEX "web_push_subscription_user_id_disabled_at_updated_at_idx"
|
||||
ON "web_push_subscription"("user_id", "disabled_at", "updated_at");
|
||||
CREATE UNIQUE INDEX "web_push_preference_user_id_profile_name_event_type_key"
|
||||
ON "web_push_preference"("user_id", "profile_name", "event_type");
|
||||
CREATE INDEX "web_push_preference_profile_name_event_type_enabled_idx"
|
||||
ON "web_push_preference"("profile_name", "event_type", "enabled");
|
||||
CREATE INDEX "web_push_event_receipt_created_at_idx" ON "web_push_event_receipt"("created_at");
|
||||
CREATE UNIQUE INDEX "web_push_notification_dedupe_key_key" ON "web_push_notification"("dedupe_key");
|
||||
CREATE INDEX "web_push_notification_user_id_created_at_idx" ON "web_push_notification"("user_id", "created_at");
|
||||
CREATE UNIQUE INDEX "web_push_delivery_notification_id_subscription_id_key"
|
||||
ON "web_push_delivery"("notification_id", "subscription_id");
|
||||
CREATE INDEX "web_push_delivery_status_available_at_id_idx" ON "web_push_delivery"("status", "available_at", "id");
|
||||
|
||||
ALTER TABLE "web_push_subscription"
|
||||
ADD CONSTRAINT "web_push_subscription_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "web_push_preference"
|
||||
ADD CONSTRAINT "web_push_preference_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "web_push_notification"
|
||||
ADD CONSTRAINT "web_push_notification_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "web_push_delivery"
|
||||
ADD CONSTRAINT "web_push_delivery_notification_id_fkey"
|
||||
FOREIGN KEY ("notification_id") REFERENCES "web_push_notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "web_push_delivery"
|
||||
ADD CONSTRAINT "web_push_delivery_subscription_id_fkey"
|
||||
FOREIGN KEY ("subscription_id") REFERENCES "web_push_subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -111,6 +111,9 @@ model AppUser {
|
||||
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
|
||||
icons UserIcon[]
|
||||
specialAccessGrants SpecialAccountAccessGrant[]
|
||||
webPushSubscriptions WebPushSubscription[]
|
||||
webPushPreferences WebPushPreference[]
|
||||
webPushNotifications WebPushNotification[]
|
||||
|
||||
@@map("app_user")
|
||||
}
|
||||
@@ -240,6 +243,100 @@ model GatewayProfile {
|
||||
@@map("gateway_profile")
|
||||
}
|
||||
|
||||
model WebPushSubscription {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
endpoint String @unique @db.Text
|
||||
p256dh String @db.Text
|
||||
auth String @db.Text
|
||||
expirationTime DateTime? @map("expiration_time")
|
||||
userAgent String? @map("user_agent") @db.Text
|
||||
disabledAt DateTime? @map("disabled_at")
|
||||
lastSeenAt DateTime @default(now()) @map("last_seen_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deliveries WebPushDelivery[]
|
||||
|
||||
@@index([userId, disabledAt, updatedAt])
|
||||
@@map("web_push_subscription")
|
||||
}
|
||||
|
||||
model WebPushPreference {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
profileName String @map("profile_name")
|
||||
eventType String @map("event_type")
|
||||
enabled Boolean @default(false)
|
||||
targetYear Int? @map("target_year")
|
||||
targetMonth Int? @map("target_month")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([userId, profileName, eventType])
|
||||
@@index([profileName, eventType, enabled])
|
||||
@@map("web_push_preference")
|
||||
}
|
||||
|
||||
model WebPushEventReceipt {
|
||||
eventId String @id @map("event_id")
|
||||
profileName String @map("profile_name")
|
||||
eventType String @map("event_type")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([createdAt])
|
||||
@@map("web_push_event_receipt")
|
||||
}
|
||||
|
||||
model WebPushNotification {
|
||||
id String @id @default(uuid())
|
||||
dedupeKey String @unique @map("dedupe_key")
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
profileName String @map("profile_name")
|
||||
eventType String @map("event_type")
|
||||
title String
|
||||
body String
|
||||
url String
|
||||
tag String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
deliveries WebPushDelivery[]
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@map("web_push_notification")
|
||||
}
|
||||
|
||||
model WebPushDelivery {
|
||||
id BigInt @id @default(autoincrement())
|
||||
notificationId String @map("notification_id")
|
||||
notification WebPushNotification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
subscriptionId String @map("subscription_id")
|
||||
subscription WebPushSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
status String @default("PENDING")
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(now()) @map("available_at")
|
||||
lockedAt DateTime? @map("locked_at")
|
||||
lockOwner String? @map("lock_owner")
|
||||
deliveredAt DateTime? @map("delivered_at")
|
||||
lastError String? @map("last_error")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([notificationId, subscriptionId])
|
||||
@@index([status, availableAt, id])
|
||||
@@map("web_push_delivery")
|
||||
}
|
||||
|
||||
model WebPushProfileCursor {
|
||||
profileName String @id @map("profile_name")
|
||||
status String
|
||||
preopenAt DateTime? @map("preopen_at")
|
||||
openAt DateTime? @map("open_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("web_push_profile_cursor")
|
||||
}
|
||||
|
||||
model GatewayRuntimeAction {
|
||||
id String @id @default(uuid())
|
||||
profileName String @map("profile_name")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "web_push_outbox" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"event_id" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"user_ids" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"year" INTEGER,
|
||||
"month" INTEGER,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"locked_at" TIMESTAMP(3),
|
||||
"lock_owner" TEXT,
|
||||
"delivered_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_outbox_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "web_push_outbox_event_id_key" ON "web_push_outbox"("event_id");
|
||||
CREATE INDEX "web_push_outbox_delivered_at_available_at_id_idx"
|
||||
ON "web_push_outbox"("delivered_at", "available_at", "id");
|
||||
@@ -47,4 +47,5 @@ export interface DatabaseClient {
|
||||
inputEvent: GamePrisma.InputEventDelegate;
|
||||
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
|
||||
readModelOutbox: GamePrisma.ReadModelOutboxDelegate;
|
||||
webPushOutbox: GamePrisma.WebPushOutboxDelegate;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from './readModelChangeJournal.js';
|
||||
export * from './readModelOutboxDispatcher.js';
|
||||
export * from './readModelCoverageActivation.js';
|
||||
export * from './gameSchemaAdvisoryLock.js';
|
||||
export * from './webPushOutbox.js';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { WebPushEventType } from '@sammo-ts/common';
|
||||
|
||||
import type { GamePrisma } from './gamePrisma.js';
|
||||
|
||||
export type WebPushOutboxDatabase = Pick<GamePrisma.TransactionClient, 'general' | 'webPushOutbox'>;
|
||||
|
||||
export interface WebPushOutboxEventInput {
|
||||
eventId: string;
|
||||
eventType: WebPushEventType;
|
||||
userIds?: readonly string[];
|
||||
year?: number;
|
||||
month?: number;
|
||||
}
|
||||
|
||||
const uniqueUserIds = (values: readonly string[]): string[] => [...new Set(values.filter(Boolean))].sort();
|
||||
|
||||
export const enqueueWebPushOutboxEvents = async (
|
||||
db: WebPushOutboxDatabase,
|
||||
events: readonly WebPushOutboxEventInput[]
|
||||
): Promise<number> => {
|
||||
if (events.length === 0) return 0;
|
||||
const result = await db.webPushOutbox.createMany({
|
||||
data: events.map((event) => ({
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
userIds: uniqueUserIds(event.userIds ?? []),
|
||||
...(event.year === undefined ? {} : { year: event.year }),
|
||||
...(event.month === undefined ? {} : { month: event.month }),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return result.count;
|
||||
};
|
||||
|
||||
export const enqueuePrivateMessageWebPush = async (
|
||||
db: WebPushOutboxDatabase,
|
||||
draft: { msgType: string; mailbox: number; destId: number },
|
||||
messageId: number
|
||||
): Promise<void> => {
|
||||
if (draft.msgType !== 'private' || draft.mailbox !== draft.destId) return;
|
||||
const recipient = await db.general.findUnique({
|
||||
where: { id: draft.destId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!recipient?.userId) return;
|
||||
await enqueueWebPushOutboxEvents(db, [
|
||||
{
|
||||
eventId: `message:${messageId}`,
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
userIds: [recipient.userId],
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrismaClient } from '../src/gamePrisma.js';
|
||||
import { enqueuePrivateMessageWebPush } from '../src/webPushOutbox.js';
|
||||
|
||||
describe('web push outbox event writer', () => {
|
||||
it('stores only a private receiver event without message content', async () => {
|
||||
const createMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const db = {
|
||||
general: { findUnique: vi.fn().mockResolvedValue({ userId: '11111111-1111-4111-8111-111111111111' }) },
|
||||
webPushOutbox: { createMany },
|
||||
} as unknown as GamePrismaClient;
|
||||
|
||||
await enqueuePrivateMessageWebPush(db, { msgType: 'private', mailbox: 8, destId: 8 }, 42);
|
||||
|
||||
expect(createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
eventId: 'message:42',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
userIds: ['11111111-1111-4111-8111-111111111111'],
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(JSON.stringify(createMany.mock.calls)).not.toContain('message content');
|
||||
});
|
||||
|
||||
it('does not notify for the sender copy or a non-private message', async () => {
|
||||
const findUnique = vi.fn();
|
||||
const createMany = vi.fn();
|
||||
const db = {
|
||||
general: { findUnique },
|
||||
webPushOutbox: { createMany },
|
||||
} as unknown as GamePrismaClient;
|
||||
|
||||
await enqueuePrivateMessageWebPush(db, { msgType: 'private', mailbox: 3, destId: 8 }, 43);
|
||||
await enqueuePrivateMessageWebPush(db, { msgType: 'national', mailbox: 9001, destId: 9001 }, 44);
|
||||
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
expect(createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Generated
+88
@@ -307,6 +307,9 @@ importers:
|
||||
sharp:
|
||||
specifier: ^0.35.0
|
||||
version: 0.35.3(@types/node@26.2.0)
|
||||
web-push:
|
||||
specifier: 3.6.7
|
||||
version: 3.6.7(supports-color@7.2.0)
|
||||
zod:
|
||||
specifier: ^4.3.5
|
||||
version: 4.4.3
|
||||
@@ -314,6 +317,9 @@ importers:
|
||||
'@types/sanitize-html':
|
||||
specifier: 2.16.1
|
||||
version: 2.16.1
|
||||
'@types/web-push':
|
||||
specifier: 3.6.4
|
||||
version: 3.6.4
|
||||
tsdown:
|
||||
specifier: ^0.22.14
|
||||
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(synckit@0.11.13))(vue-tsc@3.3.10(typescript@6.0.3))
|
||||
@@ -2272,6 +2278,9 @@ packages:
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.67.0':
|
||||
resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -2862,6 +2871,9 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2917,6 +2929,9 @@ packages:
|
||||
birpc@2.9.0:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
|
||||
bn.js@4.12.5:
|
||||
resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
@@ -2933,6 +2948,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
c12@3.3.4:
|
||||
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
||||
peerDependencies:
|
||||
@@ -3194,6 +3212,9 @@ packages:
|
||||
oxc-resolver:
|
||||
optional: true
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
effect@3.20.0:
|
||||
resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==}
|
||||
|
||||
@@ -3548,6 +3569,10 @@ packages:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
http_ece@1.2.0:
|
||||
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3646,6 +3671,12 @@ packages:
|
||||
json-stringify-safe@5.0.1:
|
||||
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -3865,10 +3896,16 @@ packages:
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
minimatch@10.2.6:
|
||||
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@7.1.3:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -4825,6 +4862,11 @@ packages:
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
web-push@3.6.7:
|
||||
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -6271,6 +6313,10 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
dependencies:
|
||||
'@types/node': 26.2.0
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -6847,6 +6893,13 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
asn1.js@5.4.1:
|
||||
dependencies:
|
||||
bn.js: 4.12.5
|
||||
inherits: 2.0.4
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types@0.13.4:
|
||||
@@ -6889,6 +6942,8 @@ snapshots:
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
bn.js@4.12.5: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
@@ -6907,6 +6962,8 @@ snapshots:
|
||||
node-releases: 2.0.53
|
||||
update-browserslist-db: 1.3.1(browserslist@4.28.8)
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
c12@3.3.4:
|
||||
dependencies:
|
||||
chokidar: 5.0.0
|
||||
@@ -7135,6 +7192,10 @@ snapshots:
|
||||
|
||||
dts-resolver@3.0.0: {}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
effect@3.20.0:
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -7557,6 +7618,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http_ece@1.2.0: {}
|
||||
|
||||
https-proxy-agent@7.0.6(supports-color@7.2.0):
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -7626,6 +7689,17 @@ snapshots:
|
||||
|
||||
json-stringify-safe@5.0.1: {}
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -7804,10 +7878,14 @@ snapshots:
|
||||
|
||||
mime@3.0.0: {}
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
minisearch@7.2.0: {}
|
||||
@@ -8848,6 +8926,16 @@ snapshots:
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
web-push@3.6.7(supports-color@7.2.0):
|
||||
dependencies:
|
||||
asn1.js: 5.4.1
|
||||
http_ece: 1.2.0
|
||||
https-proxy-agent: 7.0.6(supports-color@7.2.0)
|
||||
jws: 4.0.1
|
||||
minimist: 1.2.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 2,
|
||||
"gatewaySchemaHead": "20260821173000_gateway_release_instant_timestamps",
|
||||
"gameSchemaHead": "20260820002000_persist_official_game_index",
|
||||
"gatewaySchemaHead": "20260823010000_add_web_push_notifications",
|
||||
"gameSchemaHead": "20260823010000_add_web_push_outbox",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user