diff --git a/.env.example b/.env.example index 67f36dd2..4c3d4706 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index df90e26c..c9125420 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -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'), }; }; diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index a2cd83f0..2563cf06 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -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; }; diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 1fab7480..b306dd7d 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -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) { diff --git a/app/game-api/src/services/webPushOutboxWorker.ts b/app/game-api/src/services/webPushOutboxWorker.ts new file mode 100644 index 00000000..89d837d4 --- /dev/null +++ b/app/game-api/src/services/webPushOutboxWorker.ts @@ -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 | 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 { + const claimed = await this.db.$transaction(async (tx) => { + const rows = await tx.$queryRaw>(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 { + if (this.timer) clearInterval(this.timer); + this.timer = null; + await this.inFlight; + } +} diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 9535e424..0a3ed896 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -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 }, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index b17e4910..22d492ce 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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(); 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, diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 234c019d..0adfbaa6 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -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 { const [generalRows, nationRows] = await Promise.all([ this.prisma.generalTurn.findMany(), diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 62315d46..010e3c92 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -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; }; diff --git a/app/game-engine/src/turn/webPushEvents.ts b/app/game-engine/src/turn/webPushEvents.ts new file mode 100644 index 00000000..ddb61bbf --- /dev/null +++ b/app/game-engine/src/turn/webPushEvents.ts @@ -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; + hasReservedTurns: Map; +} + +const readFiniteNumber = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const readDeathCrew = (meta: Record): 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; + reservedTurnChanges?: Pick; +}): 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; +}; diff --git a/app/game-engine/test/webPushEvents.test.ts b/app/game-engine/test/webPushEvents.test.ts new file mode 100644 index 00000000..4134f2db --- /dev/null +++ b/app/game-engine/test/webPushEvents.test.ts @@ -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 => ({ + 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'] }), + ]); + }); +}); diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index 90b36ab3..cb27ef64 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -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" } } diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index e2c24cdc..3ebcec6b 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -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); diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 45237f95..31f4356d 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -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' + ), }; }; diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index 98debf80..6fc94805 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -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 }), }); diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index b9e26339..5e3a08cc 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -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, diff --git a/app/gateway-api/src/webPush/coordinator.ts b/app/gateway-api/src/webPush/coordinator.ts new file mode 100644 index 00000000..25759d4b --- /dev/null +++ b/app/gateway-api/src/webPush/coordinator.ts @@ -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([ + '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 | 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 { + 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 { + 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 { + await this.prisma.webPushSubscription.updateMany({ + where: { userId, endpoint }, + data: { disabledAt: new Date() }, + }); + } + + private async enqueueEventTx(tx: GatewayTransaction, event: WebPushEventEnvelopeV1): Promise { + 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(); + 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 { + 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 { + if (!this.configured) return; + const claimed = await this.prisma.$transaction(async (tx) => { + const rows = await tx.$queryRaw>(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 { + if (this.timer) clearInterval(this.timer); + this.timer = null; + await this.inFlight; + } +} diff --git a/app/gateway-api/src/webPush/internalRoute.ts b/app/gateway-api/src/webPush/internalRoute.ts new file mode 100644 index 00000000..e70c8d76 --- /dev/null +++ b/app/gateway-api/src/webPush/internalRoute.ts @@ -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 }); + }); +}; diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index a87e15d6..ba261de8 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -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', }); }); diff --git a/app/gateway-api/test/webPushConfig.test.ts b/app/gateway-api/test/webPushConfig.test.ts new file mode 100644 index 00000000..674b54ce --- /dev/null +++ b/app/gateway-api/test/webPushConfig.test.ts @@ -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); + }); +}); diff --git a/app/gateway-api/test/webPushCoordinator.integration.test.ts b/app/gateway-api/test/webPushCoordinator.integration.test.ts new file mode 100644 index 00000000..360a9f47 --- /dev/null +++ b/app/gateway-api/test/webPushCoordinator.integration.test.ts @@ -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) | 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(); + }); +}); diff --git a/app/gateway-api/test/webPushInternalRoute.test.ts b/app/gateway-api/test/webPushInternalRoute.test.ts new file mode 100644 index 00000000..535f5436 --- /dev/null +++ b/app/gateway-api/test/webPushInternalRoute.test.ts @@ -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(); + }); +}); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 3d68b05e..700899b6 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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'; diff --git a/packages/common/src/webPush/types.ts b/packages/common/src/webPush/types.ts new file mode 100644 index 00000000..07fae626 --- /dev/null +++ b/packages/common/src/webPush/types.ts @@ -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); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index dfc815d8..06d8873c 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -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") diff --git a/packages/infra/prisma/gateway-migrations/20260823010000_add_web_push_notifications/migration.sql b/packages/infra/prisma/gateway-migrations/20260823010000_add_web_push_notifications/migration.sql new file mode 100644 index 00000000..a23bbbff --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260823010000_add_web_push_notifications/migration.sql @@ -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; diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index e17de6fe..f7df2914 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -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") diff --git a/packages/infra/prisma/migrations/20260823010000_add_web_push_outbox/migration.sql b/packages/infra/prisma/migrations/20260823010000_add_web_push_outbox/migration.sql new file mode 100644 index 00000000..8f1818b7 --- /dev/null +++ b/packages/infra/prisma/migrations/20260823010000_add_web_push_outbox/migration.sql @@ -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"); diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 6a0a5d18..aa1dfede 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -47,4 +47,5 @@ export interface DatabaseClient { inputEvent: GamePrisma.InputEventDelegate; turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate; readModelOutbox: GamePrisma.ReadModelOutboxDelegate; + webPushOutbox: GamePrisma.WebPushOutboxDelegate; } diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index aba382c6..63c8ddfa 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -10,3 +10,4 @@ export * from './readModelChangeJournal.js'; export * from './readModelOutboxDispatcher.js'; export * from './readModelCoverageActivation.js'; export * from './gameSchemaAdvisoryLock.js'; +export * from './webPushOutbox.js'; diff --git a/packages/infra/src/webPushOutbox.ts b/packages/infra/src/webPushOutbox.ts new file mode 100644 index 00000000..60a21cc2 --- /dev/null +++ b/packages/infra/src/webPushOutbox.ts @@ -0,0 +1,53 @@ +import type { WebPushEventType } from '@sammo-ts/common'; + +import type { GamePrisma } from './gamePrisma.js'; + +export type WebPushOutboxDatabase = Pick; + +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 => { + 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 => { + 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], + }, + ]); +}; diff --git a/packages/infra/test/webPushOutbox.test.ts b/packages/infra/test/webPushOutbox.test.ts new file mode 100644 index 00000000..ddf76afa --- /dev/null +++ b/packages/infra/test/webPushOutbox.test.ts @@ -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(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3400c45f..d40f66df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/release-manifest.json b/release-manifest.json index 25ec550c..2537fe48 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -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"] }