import { type Application, type Request, type Response, type NextFunction } from 'express'; import express from 'express'; import { logger } from '../logger.js'; import type { Repository, NotifyEventType, NotificationPrefsUpdate } from '../db/repository.js'; import type { VapidKeyStore } from '../vapid-store.js'; import type { PushService } from '../push-service.js'; /** * `/api/notifications/*` routes for Web Push V2. * Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md. * * When `pushService` is null (push.enabled === false), all POST/DELETE/test * endpoints return 503; read endpoints still work so the UI can show * "管理者により無効化されています". */ type AuthedUser = { id: string; role?: string }; function getUser(req: Request): AuthedUser | undefined { return (req as unknown as { user?: AuthedUser }).user; } function requireUser(req: Request, res: Response): AuthedUser | null { const user = getUser(req); if (!user) { res.status(401).json({ error: 'auth required' }); return null; } return user; } // ── Per-user in-memory sliding-window rate limiter ───────────────────── // Maps `${key}:${userId}` → array of recent request timestamps (ms). // Window is fixed at 1 hour; sufficient for low-frequency notification ops. const rateBuckets = new Map(); const ONE_HOUR_MS = 60 * 60 * 1000; function rateLimit(key: string, maxPerHour: number) { return (req: Request, res: Response, next: NextFunction): void => { const user = getUser(req); if (!user) { res.status(401).json({ error: 'auth required' }); return; } const bucketKey = `${key}:${user.id}`; const now = Date.now(); const cutoff = now - ONE_HOUR_MS; const bucket = (rateBuckets.get(bucketKey) ?? []).filter(t => t > cutoff); if (bucket.length >= maxPerHour) { res.status(429).json({ error: 'rate limit exceeded', retryAfter: Math.ceil((bucket[0]! + ONE_HOUR_MS - now) / 1000), }); return; } bucket.push(now); rateBuckets.set(bucketKey, bucket); next(); }; } /** Test hook — only used by unit tests. */ export function resetRateLimitsForTest(): void { rateBuckets.clear(); } // ── Input validation ────────────────────────────────────────────────── function validateSubscriptionInput(body: unknown): { endpoint: string; p256dh: string; auth: string; userAgent?: string; } | { error: string } { if (!body || typeof body !== 'object') return { error: 'body required' }; const b = body as Record; if (typeof b.endpoint !== 'string') return { error: 'endpoint required' }; if (!b.endpoint.startsWith('https://')) return { error: 'endpoint must be https' }; if (b.endpoint.length > 2048) return { error: 'endpoint too long' }; if (typeof b.p256dh !== 'string' || b.p256dh.length === 0 || b.p256dh.length > 200) { return { error: 'p256dh must be 1..200 chars' }; } if (typeof b.auth !== 'string' || b.auth.length === 0 || b.auth.length > 200) { return { error: 'auth must be 1..200 chars' }; } const ua = typeof b.userAgent === 'string' ? b.userAgent.slice(0, 200) : undefined; return { endpoint: b.endpoint, p256dh: b.p256dh, auth: b.auth, ...(ua ? { userAgent: ua } : {}) }; } function validatePrefsInput(body: unknown): NotificationPrefsUpdate | { error: string } { if (!body || typeof body !== 'object') return { error: 'body required' }; const b = body as Record; const update: NotificationPrefsUpdate = {}; if (b.enabled !== undefined) { if (typeof b.enabled !== 'boolean') return { error: 'enabled must be boolean' }; update.enabled = b.enabled; } if (b.events !== undefined) { if (!b.events || typeof b.events !== 'object') return { error: 'events must be object' }; const events = b.events as Record; const eventUpdate: Partial> = {}; for (const key of ['running', 'succeeded', 'failed', 'waiting_human'] as const) { if (events[key] !== undefined) { if (typeof events[key] !== 'boolean') return { error: `events.${key} must be boolean` }; eventUpdate[key] = events[key] as boolean; } } update.events = eventUpdate as Record; } if (b.includeDetails !== undefined) { if (typeof b.includeDetails !== 'boolean') return { error: 'includeDetails must be boolean' }; update.includeDetails = b.includeDetails; } return update; } // ── Public DTOs (do NOT leak p256dh / auth / privateKey / etc.) ──────── function toPublicSubscription(sub: { id: string; endpoint: string; userAgent: string | null; createdAt: string; lastSuccessAt: string | null; lastFailureAt: string | null; failureCount: number; }) { return { id: sub.id, // Truncate endpoint to scheme + host for UI display; full URL is sensitive. endpointHost: (() => { try { return new URL(sub.endpoint).host; } catch { return 'unknown'; } })(), userAgent: sub.userAgent, createdAt: sub.createdAt, lastSuccessAt: sub.lastSuccessAt, lastFailureAt: sub.lastFailureAt, failureCount: sub.failureCount, }; } // ── Mount ────────────────────────────────────────────────────────────── export interface NotificationsApiDeps { repo: Repository; pushService: PushService | null; vapidStore: VapidKeyStore | null; /** Plugged in by server.ts when auth is active; identity transform otherwise. */ requireAuth: (req: Request, res: Response, next: NextFunction) => void; } export function mountNotificationsApi(app: Application, deps: NotificationsApiDeps): void { const { repo, pushService, vapidStore, requireAuth } = deps; const json = express.json({ limit: '64kb' }); // GET /vapid-public-key — always 200 when push is enabled; required before subscribe. app.get('/api/notifications/vapid-public-key', requireAuth, (req, res) => { const user = requireUser(req, res); if (!user) return; if (!pushService || !vapidStore) { res.status(503).json({ error: 'push disabled' }); return; } const k = vapidStore.getCurrent(); res.json({ publicKey: k.publicKey, keyId: k.keyId }); }); // GET /subscriptions — caller's own devices. app.get('/api/notifications/subscriptions', requireAuth, (req, res) => { const user = requireUser(req, res); if (!user) return; const subs = repo.listPushSubscriptionsForUser(user.id); res.json({ subscriptions: subs.map(toPublicSubscription) }); }); // POST /subscriptions — register/upsert (endpoint UNIQUE moves ownership). app.post( '/api/notifications/subscriptions', requireAuth, json, rateLimit('push:subscribe', 10), (req, res) => { const user = requireUser(req, res); if (!user) return; if (!pushService || !vapidStore) { res.status(503).json({ error: 'push disabled' }); return; } const parsed = validateSubscriptionInput(req.body); if ('error' in parsed) { res.status(400).json({ error: parsed.error }); return; } const current = vapidStore.getCurrent(); const { id } = repo.upsertPushSubscription({ userId: user.id, endpoint: parsed.endpoint, p256dh: parsed.p256dh, auth: parsed.auth, userAgent: parsed.userAgent ?? null, vapidKeyId: current.keyId, }); res.json({ id }); }, ); // DELETE /subscriptions/:id — only delete your own. app.delete( '/api/notifications/subscriptions/:id', requireAuth, rateLimit('push:unsubscribe', 30), (req, res) => { const user = requireUser(req, res); if (!user) return; const sub = repo.getPushSubscriptionById(req.params.id!); if (!sub || sub.userId !== user.id) { res.status(404).json({ error: 'not found' }); return; } repo.deletePushSubscription(sub.id); res.json({ ok: true }); }, ); // GET /preferences — auto-creates a default row on first access. app.get('/api/notifications/preferences', requireAuth, (req, res) => { const user = requireUser(req, res); if (!user) return; res.json(repo.getUserNotificationPrefs(user.id)); }); // PUT /preferences — partial update. app.put( '/api/notifications/preferences', requireAuth, json, rateLimit('push:prefs', 30), (req, res) => { const user = requireUser(req, res); if (!user) return; const parsed = validatePrefsInput(req.body); if ('error' in parsed) { res.status(400).json({ error: parsed.error }); return; } repo.upsertUserNotificationPrefs(user.id, parsed); res.json(repo.getUserNotificationPrefs(user.id)); }, ); // POST /preferences/migrate-from-localstorage — one-shot V1 → V2. app.post( '/api/notifications/preferences/migrate-from-localstorage', requireAuth, json, rateLimit('push:migrate', 3), (req, res) => { const user = requireUser(req, res); if (!user) return; const parsed = validatePrefsInput(req.body); if ('error' in parsed) { res.status(400).json({ error: parsed.error }); return; } const flipped = repo.markV1MigrationComplete(user.id); if (!flipped) { res.status(409).json({ error: 'already migrated' }); return; } repo.upsertUserNotificationPrefs(user.id, parsed); res.json({ ok: true, prefs: repo.getUserNotificationPrefs(user.id) }); }, ); // POST /test — send a test push. app.post( '/api/notifications/test', requireAuth, rateLimit('push:test', 5), (req, res) => { const user = requireUser(req, res); if (!user) return; if (!pushService) { res.status(503).json({ error: 'push disabled' }); return; } pushService.enqueue({ event: 'succeeded', taskId: 0, taskTitle: 'テスト通知', pieceName: 'V2 Web Push 動作確認', ownerId: user.id, }); res.json({ ok: true }); }, ); logger.info('[notifications-api] mounted (/api/notifications/*)'); }