maestro/src/bridge/notifications-api.ts
2026-06-03 05:08:00 +00:00

292 lines
10 KiB
TypeScript

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<string, number[]>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const eventUpdate: Partial<Record<NotifyEventType, boolean>> = {};
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<NotifyEventType, boolean>;
}
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/*)');
}