@umec/core 0.1.0-alpha.10 → 0.1.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,818 @@
1
+ import { Resend } from 'resend';
2
+ import { getServerEnv } from './env';
3
+ import {
4
+ countRequestsByEmailSince,
5
+ countRequestsByIpSince,
6
+ countRequestsSince,
7
+ incrementSendAttempts,
8
+ insertDisclosureRequest,
9
+ listRetryableUndisclosed,
10
+ listUndisclosedForReminder,
11
+ markDisclosed,
12
+ markOperatorNotified,
13
+ type DisclosureRequestRow,
14
+ } from './disclosure-db';
15
+ import type { ShopDatabase } from '@umec/core/db';
16
+
17
+ export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
18
+ export const DISCLOSURE_RETRY_CRON = '*/15 * * * *';
19
+ export const DISCLOSURE_RETRY_MIN_AGE_MS = 5 * 60 * 1000;
20
+ export const DEFAULT_RATE_PER_EMAIL = 3;
21
+ export const DEFAULT_RATE_PER_IP = 10;
22
+ export const DEFAULT_RATE_GLOBAL = 50;
23
+ export const DEFAULT_RETRY_MAX = 3;
24
+ export const TURNSTILE_SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
25
+
26
+ const DAY_MS = 24 * 60 * 60 * 1000;
27
+ const USER_AGENT_MAX_LENGTH = 512;
28
+ const IMMEDIATE_SEND_ATTEMPTS = 2;
29
+
30
+ export type TokushohoDisplayMode = 'full-omission' | 'name-visible';
31
+ export type DisclosureMode = 'auto' | 'manual';
32
+
33
+ export type DisclosurePayload = {
34
+ sellerName: string;
35
+ operatorName: string;
36
+ address: string;
37
+ phone: string;
38
+ email: string;
39
+ };
40
+
41
+ export type DisclosureEnv = {
42
+ DB?: ShopDatabase;
43
+ RESEND_API_KEY?: string;
44
+ DISCLOSURE_PAYLOAD?: string;
45
+ DISCLOSURE_MODE?: string;
46
+ DISCLOSURE_NOTIFY_TO?: string;
47
+ DISCLOSURE_RATE_PER_EMAIL?: string;
48
+ DISCLOSURE_RATE_PER_IP?: string;
49
+ DISCLOSURE_RATE_GLOBAL?: string;
50
+ DISCLOSURE_IP_SALT?: string;
51
+ DISCLOSURE_RETRY_MAX?: string;
52
+ TURNSTILE_SECRET_KEY?: string;
53
+ PUBLIC_TURNSTILE_SITE_KEY?: string;
54
+ };
55
+
56
+ export type DisclosureBrandContext = {
57
+ brandName: string;
58
+ brandUrl: string;
59
+ emailFrom: string;
60
+ emailReplyTo?: string;
61
+ notifyFallback?: string;
62
+ };
63
+
64
+ export type DisclosureHandlerOptions = DisclosureBrandContext & {
65
+ isProduction: boolean;
66
+ now?: () => number;
67
+ createId?: () => string;
68
+ fetchImpl?: typeof fetch;
69
+ };
70
+
71
+ export type DisclosureMailCopy = {
72
+ requesterSubject: string;
73
+ operatorSubject: string;
74
+ exhaustedSubject: string;
75
+ globalLimitSubject: string;
76
+ staleReminderSubject: string;
77
+ };
78
+
79
+ function jsonError(error: string, status: number) {
80
+ return new Response(JSON.stringify({ error }), {
81
+ status,
82
+ headers: { 'Content-Type': 'application/json' },
83
+ });
84
+ }
85
+
86
+ function jsonOk() {
87
+ return new Response(JSON.stringify({ ok: true }), {
88
+ status: 200,
89
+ headers: { 'Content-Type': 'application/json' },
90
+ });
91
+ }
92
+
93
+ function asDisclosureEnv(env: unknown): DisclosureEnv {
94
+ return env as DisclosureEnv;
95
+ }
96
+
97
+ export function parsePositiveInt(value: string | undefined, fallback: number): number {
98
+ if (!value) return fallback;
99
+ const parsed = Number(value);
100
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
101
+ }
102
+
103
+ export function resolveDisclosureMode(value: string | undefined): DisclosureMode {
104
+ return value === 'manual' ? 'manual' : 'auto';
105
+ }
106
+
107
+ export function resolveTokushohoDisplayMode(value: string | undefined): TokushohoDisplayMode {
108
+ return value === 'name-visible' ? 'name-visible' : 'full-omission';
109
+ }
110
+
111
+ export function resolveDisclosureNotifyTo(
112
+ env: Pick<DisclosureEnv, 'DISCLOSURE_NOTIFY_TO'>,
113
+ fallback?: string,
114
+ ): string | null {
115
+ if (typeof env.DISCLOSURE_NOTIFY_TO === 'string' && env.DISCLOSURE_NOTIFY_TO.trim()) {
116
+ return env.DISCLOSURE_NOTIFY_TO.trim();
117
+ }
118
+ if (typeof fallback === 'string' && fallback.trim()) return fallback.trim();
119
+ return null;
120
+ }
121
+
122
+ export function parseDisclosurePayload(raw: string | undefined): DisclosurePayload | null {
123
+ if (!raw || !raw.trim()) return null;
124
+ try {
125
+ const parsed: unknown = JSON.parse(raw);
126
+ if (typeof parsed !== 'object' || parsed === null) return null;
127
+ const value = parsed as Record<string, unknown>;
128
+ const sellerName = typeof value.sellerName === 'string' ? value.sellerName.trim() : '';
129
+ const address = typeof value.address === 'string' ? value.address.trim() : '';
130
+ const phone = typeof value.phone === 'string' ? value.phone.trim() : '';
131
+ const email = typeof value.email === 'string' ? value.email.trim() : '';
132
+ const operatorName = typeof value.operatorName === 'string' ? value.operatorName.trim() : '';
133
+ if (!sellerName || !address || !phone || !email) return null;
134
+ return { sellerName, operatorName, address, phone, email };
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ export function createDisclosureRequestId(): string {
141
+ return crypto.randomUUID();
142
+ }
143
+
144
+ export function getClientIp(request: Request, clientAddress?: string): string {
145
+ const cf = request.headers.get('CF-Connecting-IP')?.trim();
146
+ if (cf) return cf;
147
+ const forwarded = request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim();
148
+ if (forwarded) return forwarded;
149
+ return clientAddress?.trim() ?? '';
150
+ }
151
+
152
+ export async function hashIp(ip: string, salt: string): Promise<string> {
153
+ const data = new TextEncoder().encode(`${ip}${salt}`);
154
+ const digest = await crypto.subtle.digest('SHA-256', data);
155
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
156
+ }
157
+
158
+ export async function verifyTurnstileToken(input: {
159
+ secret: string;
160
+ token: string;
161
+ ip?: string;
162
+ fetchImpl?: typeof fetch;
163
+ }): Promise<boolean> {
164
+ const body = new URLSearchParams();
165
+ body.set('secret', input.secret);
166
+ body.set('response', input.token);
167
+ if (input.ip) body.set('remoteip', input.ip);
168
+
169
+ try {
170
+ const fetchImpl = input.fetchImpl ?? fetch;
171
+ const response = await fetchImpl(TURNSTILE_SITEVERIFY_URL, {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
174
+ body,
175
+ });
176
+ if (!response.ok) return false;
177
+ const payload: unknown = await response.json();
178
+ return (
179
+ typeof payload === 'object' &&
180
+ payload !== null &&
181
+ (payload as { success?: unknown }).success === true
182
+ );
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ function escapeHtml(value: string) {
189
+ return value
190
+ .replaceAll('&', '&amp;')
191
+ .replaceAll('<', '&lt;')
192
+ .replaceAll('>', '&gt;')
193
+ .replaceAll('"', '&quot;')
194
+ .replaceAll("'", '&#39;');
195
+ }
196
+
197
+ export function formatReceivedAt(ms: number): string {
198
+ return `${new Intl.DateTimeFormat('ja-JP', {
199
+ timeZone: 'Asia/Tokyo',
200
+ year: 'numeric',
201
+ month: '2-digit',
202
+ day: '2-digit',
203
+ hour: '2-digit',
204
+ minute: '2-digit',
205
+ second: '2-digit',
206
+ hour12: false,
207
+ }).format(new Date(ms))} (JST)`;
208
+ }
209
+
210
+ function adminDisclosureUrl(brandUrl: string): string {
211
+ return `${brandUrl.replace(/\/$/, '')}/admin/disclosure`;
212
+ }
213
+
214
+ export function defaultDisclosureMailCopy(brandName: string, requestId?: string): DisclosureMailCopy {
215
+ // TODO: 文言確定待ち(ヒューマンゲート)。件名は設計書 §3.1 / §3.2 の案文。
216
+ return {
217
+ requesterSubject: `【${brandName}】特定商取引法に基づく表示事項の開示`,
218
+ operatorSubject: requestId
219
+ ? `【${brandName}】特商法 開示請求を受付(請求番号 ${requestId})`
220
+ : `【${brandName}】特商法 開示請求を受付`,
221
+ exhaustedSubject: `【${brandName}】特商法 開示メールの自動送信が上限に達しました`,
222
+ globalLimitSubject: `【${brandName}】特商法 開示請求が1日の上限に達しました`,
223
+ staleReminderSubject: `【${brandName}】特商法 未開示の請求があります`,
224
+ };
225
+ }
226
+
227
+ export function renderRequesterDisclosureMail(input: {
228
+ brandName: string;
229
+ requestId: string;
230
+ receivedAtMs: number;
231
+ payload: DisclosurePayload;
232
+ copy?: Pick<DisclosureMailCopy, 'requesterSubject'>;
233
+ }): { subject: string; text: string; html: string } {
234
+ // TODO: 文言確定待ち(ヒューマンゲート)。本文構成は設計書 §3.1。
235
+ const copy = input.copy ?? defaultDisclosureMailCopy(input.brandName);
236
+ const receivedAt = formatReceivedAt(input.receivedAtMs);
237
+ const operatorLine = input.payload.operatorName
238
+ ? `運営責任者名: ${input.payload.operatorName}`
239
+ : '運営責任者名: (個人事業主のため販売事業者名に同じ)';
240
+ const text = [
241
+ '特定商取引法に基づく表示事項の開示請求を受け付けました。',
242
+ '',
243
+ `請求番号: ${input.requestId}`,
244
+ `受付日時: ${receivedAt}`,
245
+ '',
246
+ '【開示事項】',
247
+ `販売事業者名: ${input.payload.sellerName}`,
248
+ operatorLine,
249
+ `所在地: ${input.payload.address}`,
250
+ `電話番号: ${input.payload.phone}`,
251
+ `メールアドレス: ${input.payload.email}`,
252
+ '',
253
+ 'このメールは、特定商取引法に基づく開示請求への回答として送信しています。',
254
+ ].join('\n');
255
+
256
+ const html = `<!DOCTYPE html>
257
+ <html lang="ja">
258
+ <head><meta charset="UTF-8"></head>
259
+ <body style="font-family:sans-serif;color:#222;max-width:600px;margin:0 auto;padding:32px 16px;">
260
+ <p style="font-size:13px;letter-spacing:0.2em;color:#888;margin-bottom:24px;">${escapeHtml(input.brandName)}</p>
261
+ <p style="font-size:14px;line-height:1.8;color:#555;">
262
+ 特定商取引法に基づく表示事項の開示請求を受け付けました。
263
+ </p>
264
+ <p style="font-size:14px;line-height:1.8;color:#555;">
265
+ 請求番号: ${escapeHtml(input.requestId)}<br>
266
+ 受付日時: ${escapeHtml(receivedAt)}
267
+ </p>
268
+ <h2 style="font-size:13px;letter-spacing:0.2em;color:#888;margin:32px 0 12px;">開示事項</h2>
269
+ <table style="width:100%;border-collapse:collapse;font-size:14px;">
270
+ <tr><th style="text-align:left;padding:8px 0;border-bottom:1px solid #eee;color:#888;width:40%;">販売事業者名</th><td style="padding:8px 0;border-bottom:1px solid #eee;">${escapeHtml(input.payload.sellerName)}</td></tr>
271
+ <tr><th style="text-align:left;padding:8px 0;border-bottom:1px solid #eee;color:#888;">運営責任者名</th><td style="padding:8px 0;border-bottom:1px solid #eee;">${escapeHtml(input.payload.operatorName || '(個人事業主のため販売事業者名に同じ)')}</td></tr>
272
+ <tr><th style="text-align:left;padding:8px 0;border-bottom:1px solid #eee;color:#888;">所在地</th><td style="padding:8px 0;border-bottom:1px solid #eee;">${escapeHtml(input.payload.address)}</td></tr>
273
+ <tr><th style="text-align:left;padding:8px 0;border-bottom:1px solid #eee;color:#888;">電話番号</th><td style="padding:8px 0;border-bottom:1px solid #eee;">${escapeHtml(input.payload.phone)}</td></tr>
274
+ <tr><th style="text-align:left;padding:8px 0;border-bottom:1px solid #eee;color:#888;">メールアドレス</th><td style="padding:8px 0;border-bottom:1px solid #eee;">${escapeHtml(input.payload.email)}</td></tr>
275
+ </table>
276
+ <p style="font-size:13px;line-height:1.8;color:#888;margin-top:40px;padding-top:24px;border-top:1px solid #eee;">
277
+ このメールは、特定商取引法に基づく開示請求への回答として送信しています。
278
+ </p>
279
+ </body>
280
+ </html>`;
281
+
282
+ return { subject: copy.requesterSubject, text, html };
283
+ }
284
+
285
+ export function renderOperatorNotificationMail(input: {
286
+ brandName: string;
287
+ brandUrl: string;
288
+ requestId: string;
289
+ receivedAtMs: number;
290
+ requesterEmail: string;
291
+ disclosureSendResult: 'success' | 'retry' | 'manual' | 'exhausted';
292
+ copy?: Pick<DisclosureMailCopy, 'operatorSubject' | 'exhaustedSubject'>;
293
+ }): { subject: string; text: string; html: string } {
294
+ // TODO: 文言確定待ち(ヒューマンゲート)。本文は設計書 §3.2。
295
+ const copy = input.copy ?? defaultDisclosureMailCopy(input.brandName, input.requestId);
296
+ const receivedAt = formatReceivedAt(input.receivedAtMs);
297
+ const resultLabel =
298
+ input.disclosureSendResult === 'success'
299
+ ? '成功'
300
+ : input.disclosureSendResult === 'retry'
301
+ ? 'リトライ委譲'
302
+ : input.disclosureSendResult === 'exhausted'
303
+ ? '自動送信上限超過'
304
+ : '手動開示待ち';
305
+ const adminUrl = adminDisclosureUrl(input.brandUrl);
306
+ const subject =
307
+ input.disclosureSendResult === 'exhausted' ? copy.exhaustedSubject : copy.operatorSubject;
308
+ const text = [
309
+ '特商法の開示請求を受け付けました。',
310
+ '',
311
+ `請求番号: ${input.requestId}`,
312
+ `請求者メールアドレス: ${input.requesterEmail}`,
313
+ `受付日時: ${receivedAt}`,
314
+ `開示メール送信結果: ${resultLabel}`,
315
+ '',
316
+ `管理画面: ${adminUrl}`,
317
+ ].join('\n');
318
+ const html = `<!DOCTYPE html>
319
+ <html lang="ja">
320
+ <head><meta charset="UTF-8"></head>
321
+ <body style="font-family:sans-serif;color:#222;max-width:600px;margin:0 auto;padding:32px 16px;">
322
+ <p style="font-size:14px;line-height:1.8;">特商法の開示請求を受け付けました。</p>
323
+ <p style="font-size:14px;line-height:1.8;">
324
+ 請求番号: ${escapeHtml(input.requestId)}<br>
325
+ 請求者メールアドレス: ${escapeHtml(input.requesterEmail)}<br>
326
+ 受付日時: ${escapeHtml(receivedAt)}<br>
327
+ 開示メール送信結果: ${escapeHtml(resultLabel)}
328
+ </p>
329
+ <p style="font-size:14px;line-height:1.8;">
330
+ 管理画面: <a href="${escapeHtml(adminUrl)}">${escapeHtml(adminUrl)}</a>
331
+ </p>
332
+ </body>
333
+ </html>`;
334
+ return { subject, text, html };
335
+ }
336
+
337
+ export function renderStaleDisclosureReminderMail(input: {
338
+ brandName: string;
339
+ brandUrl: string;
340
+ rows: DisclosureRequestRow[];
341
+ copy?: Pick<DisclosureMailCopy, 'staleReminderSubject'>;
342
+ }): { subject: string; text: string; html: string } {
343
+ // TODO: 文言確定待ち(ヒューマンゲート)。日次未開示一覧は設計書 §5.3。
344
+ const copy = input.copy ?? defaultDisclosureMailCopy(input.brandName);
345
+ const adminUrl = adminDisclosureUrl(input.brandUrl);
346
+ const count = input.rows.length;
347
+ const subject = `${copy.staleReminderSubject}(${count}件)`;
348
+ const text = [
349
+ `未開示の特商法開示請求が ${count} 件あります。`,
350
+ '自動リトライと別に、日次の確認としてお送りしています。',
351
+ '',
352
+ ...input.rows.map(
353
+ (row) =>
354
+ `請求番号: ${row.id} / 請求者: ${row.requester_email} / 受付日時: ${formatReceivedAt(row.received_at)} / 送信試行: ${row.send_attempts}`,
355
+ ),
356
+ '',
357
+ `管理画面: ${adminUrl}`,
358
+ ].join('\n');
359
+ const rowsHtml = input.rows
360
+ .map(
361
+ (row) => `<tr>
362
+ <td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(row.id)}</td>
363
+ <td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(row.requester_email)}</td>
364
+ <td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(formatReceivedAt(row.received_at))}</td>
365
+ <td style="padding:8px;border-bottom:1px solid #eee;text-align:right;">${escapeHtml(String(row.send_attempts))}</td>
366
+ </tr>`,
367
+ )
368
+ .join('');
369
+ const html = `<!DOCTYPE html>
370
+ <html lang="ja">
371
+ <head><meta charset="UTF-8"></head>
372
+ <body style="font-family:sans-serif;color:#222;max-width:720px;margin:0 auto;padding:32px 16px;">
373
+ <p style="font-size:14px;line-height:1.8;">未開示の特商法開示請求が ${count} 件あります。</p>
374
+ <p style="font-size:13px;line-height:1.8;color:#555;">自動リトライと別に、日次の確認としてお送りしています。</p>
375
+ <table style="width:100%;border-collapse:collapse;font-size:14px;">
376
+ <thead>
377
+ <tr>
378
+ <th style="padding:8px;border-bottom:2px solid #222;text-align:left;">請求番号</th>
379
+ <th style="padding:8px;border-bottom:2px solid #222;text-align:left;">請求者</th>
380
+ <th style="padding:8px;border-bottom:2px solid #222;text-align:left;">受付日時</th>
381
+ <th style="padding:8px;border-bottom:2px solid #222;text-align:right;">送信試行</th>
382
+ </tr>
383
+ </thead>
384
+ <tbody>${rowsHtml}</tbody>
385
+ </table>
386
+ <p style="font-size:14px;line-height:1.8;margin-top:24px;">
387
+ 管理画面: <a href="${escapeHtml(adminUrl)}">${escapeHtml(adminUrl)}</a>
388
+ </p>
389
+ </body>
390
+ </html>`;
391
+ return { subject, text, html };
392
+ }
393
+
394
+ type SendEmailInput = {
395
+ apiKey: string;
396
+ from: string;
397
+ to: string;
398
+ subject: string;
399
+ text: string;
400
+ html: string;
401
+ replyTo?: string;
402
+ };
403
+
404
+ async function sendResendEmail(input: SendEmailInput): Promise<{ id: string } | { error: unknown }> {
405
+ const resend = new Resend(input.apiKey);
406
+ const { data, error } = await resend.emails.send({
407
+ from: input.from,
408
+ to: input.to,
409
+ subject: input.subject,
410
+ text: input.text,
411
+ html: input.html,
412
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
413
+ });
414
+ if (error || !data?.id) {
415
+ return { error: error ?? new Error('Resend did not return an email id') };
416
+ }
417
+ return { id: data.id };
418
+ }
419
+
420
+ async function notifyOperator(input: {
421
+ env: DisclosureEnv;
422
+ brand: DisclosureBrandContext;
423
+ requestId: string;
424
+ receivedAtMs: number;
425
+ requesterEmail: string;
426
+ disclosureSendResult: 'success' | 'retry' | 'manual' | 'exhausted';
427
+ }): Promise<boolean> {
428
+ const notifyTo = resolveDisclosureNotifyTo(input.env, input.brand.notifyFallback);
429
+ if (!notifyTo) {
430
+ console.error('[disclosure] DISCLOSURE_NOTIFY_TO (or email.bcc) is not configured');
431
+ return false;
432
+ }
433
+ if (!input.env.RESEND_API_KEY) {
434
+ console.error('[disclosure] RESEND_API_KEY is not configured; operator notify skipped');
435
+ return false;
436
+ }
437
+
438
+ const mail = renderOperatorNotificationMail({
439
+ brandName: input.brand.brandName,
440
+ brandUrl: input.brand.brandUrl,
441
+ requestId: input.requestId,
442
+ receivedAtMs: input.receivedAtMs,
443
+ requesterEmail: input.requesterEmail,
444
+ disclosureSendResult: input.disclosureSendResult,
445
+ });
446
+
447
+ try {
448
+ const result = await sendResendEmail({
449
+ apiKey: input.env.RESEND_API_KEY,
450
+ from: input.brand.emailFrom,
451
+ to: notifyTo,
452
+ subject: mail.subject,
453
+ text: mail.text,
454
+ html: mail.html,
455
+ replyTo: input.brand.emailReplyTo,
456
+ });
457
+ if ('error' in result) {
458
+ console.error('[disclosure] operator notify failed');
459
+ return false;
460
+ }
461
+ return true;
462
+ } catch {
463
+ console.error('[disclosure] operator notify threw');
464
+ return false;
465
+ }
466
+ }
467
+
468
+ async function sendDisclosureToRequester(input: {
469
+ env: DisclosureEnv;
470
+ brand: DisclosureBrandContext;
471
+ payload: DisclosurePayload;
472
+ requestId: string;
473
+ receivedAtMs: number;
474
+ requesterEmail: string;
475
+ }): Promise<{ id: string } | { error: unknown }> {
476
+ if (!input.env.RESEND_API_KEY) {
477
+ return { error: new Error('RESEND_API_KEY is not configured') };
478
+ }
479
+ const mail = renderRequesterDisclosureMail({
480
+ brandName: input.brand.brandName,
481
+ requestId: input.requestId,
482
+ receivedAtMs: input.receivedAtMs,
483
+ payload: input.payload,
484
+ });
485
+ return sendResendEmail({
486
+ apiKey: input.env.RESEND_API_KEY,
487
+ from: input.brand.emailFrom,
488
+ to: input.requesterEmail,
489
+ subject: mail.subject,
490
+ text: mail.text,
491
+ html: mail.html,
492
+ replyTo: input.brand.emailReplyTo,
493
+ });
494
+ }
495
+
496
+ function configuredForProduction(env: DisclosureEnv): boolean {
497
+ return Boolean(env.DB && env.RESEND_API_KEY && parseDisclosurePayload(env.DISCLOSURE_PAYLOAD) && env.TURNSTILE_SECRET_KEY);
498
+ }
499
+
500
+ function configuredForDevProcessing(env: DisclosureEnv): boolean {
501
+ return Boolean(env.DB && env.RESEND_API_KEY && parseDisclosurePayload(env.DISCLOSURE_PAYLOAD));
502
+ }
503
+
504
+ export function createDisclosureRequestHandler(options: DisclosureHandlerOptions) {
505
+ return async ({
506
+ request,
507
+ clientAddress,
508
+ }: {
509
+ request: Request;
510
+ clientAddress?: string;
511
+ }): Promise<Response> => {
512
+ const contentType = request.headers.get('content-type') ?? '';
513
+ if (!contentType.toLowerCase().includes('application/json')) {
514
+ return jsonError('Invalid request body', 400);
515
+ }
516
+
517
+ let body: unknown;
518
+ try {
519
+ body = await request.json();
520
+ } catch {
521
+ return jsonError('Invalid request body', 400);
522
+ }
523
+
524
+ if (typeof body !== 'object' || body === null) {
525
+ return jsonError('Invalid request body', 400);
526
+ }
527
+
528
+ const { email, turnstileToken } = body as Record<string, unknown>;
529
+ if (typeof email !== 'string' || !EMAIL_PATTERN.test(email.trim())) {
530
+ return jsonError('Invalid email', 400);
531
+ }
532
+ const requesterEmail = email.trim().toLowerCase();
533
+
534
+ const env = asDisclosureEnv(getServerEnv());
535
+ const ready = options.isProduction
536
+ ? configuredForProduction(env)
537
+ : configuredForDevProcessing(env);
538
+
539
+ if (!ready) {
540
+ if (options.isProduction) {
541
+ console.error('[disclosure] required env missing in production');
542
+ return jsonError('Disclosure service unavailable', 503);
543
+ }
544
+ console.log('[disclosure] env not set (dev), skipping');
545
+ return jsonOk();
546
+ }
547
+
548
+ const db = env.DB;
549
+ const payload = parseDisclosurePayload(env.DISCLOSURE_PAYLOAD);
550
+ if (!db || !payload || !env.RESEND_API_KEY) {
551
+ if (options.isProduction) return jsonError('Disclosure service unavailable', 503);
552
+ console.log('[disclosure] env not set (dev), skipping');
553
+ return jsonOk();
554
+ }
555
+
556
+ const now = options.now?.() ?? Date.now();
557
+ const ip = getClientIp(request, clientAddress);
558
+ const fetchImpl = options.fetchImpl ?? fetch;
559
+
560
+ if (env.TURNSTILE_SECRET_KEY) {
561
+ const token = typeof turnstileToken === 'string' ? turnstileToken.trim() : '';
562
+ if (!token) return jsonError('Verification failed', 400);
563
+ const ok = await verifyTurnstileToken({
564
+ secret: env.TURNSTILE_SECRET_KEY,
565
+ token,
566
+ ip: ip || undefined,
567
+ fetchImpl,
568
+ });
569
+ if (!ok) return jsonError('Verification failed', 400);
570
+ } else if (options.isProduction) {
571
+ console.error('[disclosure] TURNSTILE_SECRET_KEY missing in production');
572
+ return jsonError('Disclosure service unavailable', 503);
573
+ }
574
+
575
+ const salt = typeof env.DISCLOSURE_IP_SALT === 'string' ? env.DISCLOSURE_IP_SALT : '';
576
+ if (!salt) {
577
+ console.warn('[disclosure] DISCLOSURE_IP_SALT is not set; IP hashing is weaker');
578
+ }
579
+ const ipHash = ip ? await hashIp(ip, salt) : null;
580
+
581
+ const since = now - DAY_MS;
582
+ const perEmail = parsePositiveInt(env.DISCLOSURE_RATE_PER_EMAIL, DEFAULT_RATE_PER_EMAIL);
583
+ const perIp = parsePositiveInt(env.DISCLOSURE_RATE_PER_IP, DEFAULT_RATE_PER_IP);
584
+ const perGlobal = parsePositiveInt(env.DISCLOSURE_RATE_GLOBAL, DEFAULT_RATE_GLOBAL);
585
+
586
+ const emailCount = await countRequestsByEmailSince(db, requesterEmail, since);
587
+ if (emailCount >= perEmail) {
588
+ return jsonError('Too many requests', 429);
589
+ }
590
+ if (ipHash) {
591
+ const ipCount = await countRequestsByIpSince(db, ipHash, since);
592
+ if (ipCount >= perIp) {
593
+ return jsonError('Too many requests', 429);
594
+ }
595
+ }
596
+ const globalCount = await countRequestsSince(db, since);
597
+ if (globalCount >= perGlobal) {
598
+ try {
599
+ const notifyTo = resolveDisclosureNotifyTo(env, options.notifyFallback);
600
+ if (notifyTo && env.RESEND_API_KEY) {
601
+ const copy = defaultDisclosureMailCopy(options.brandName);
602
+ await sendResendEmail({
603
+ apiKey: env.RESEND_API_KEY,
604
+ from: options.emailFrom,
605
+ to: notifyTo,
606
+ subject: copy.globalLimitSubject,
607
+ text: '開示請求フォームのインスタンス全体の1日上限に達しました。',
608
+ html: '<p>開示請求フォームのインスタンス全体の1日上限に達しました。</p>',
609
+ replyTo: options.emailReplyTo,
610
+ });
611
+ }
612
+ } catch {
613
+ console.error('[disclosure] global rate-limit notify failed');
614
+ }
615
+ return jsonError('Too many requests', 429);
616
+ }
617
+
618
+ const requestId = options.createId?.() ?? createDisclosureRequestId();
619
+ const userAgentRaw = request.headers.get('user-agent');
620
+ const userAgent = userAgentRaw ? userAgentRaw.slice(0, USER_AGENT_MAX_LENGTH) : null;
621
+
622
+ await insertDisclosureRequest(db, {
623
+ id: requestId,
624
+ requesterEmail,
625
+ receivedAt: now,
626
+ ipHash,
627
+ userAgent,
628
+ });
629
+
630
+ const mode = resolveDisclosureMode(env.DISCLOSURE_MODE);
631
+ let disclosureSendResult: 'success' | 'retry' | 'manual' = 'manual';
632
+
633
+ if (mode === 'auto') {
634
+ let lastError: unknown;
635
+ let sentId: string | null = null;
636
+ for (let attempt = 0; attempt < IMMEDIATE_SEND_ATTEMPTS; attempt += 1) {
637
+ try {
638
+ const result = await sendDisclosureToRequester({
639
+ env,
640
+ brand: options,
641
+ payload,
642
+ requestId,
643
+ receivedAtMs: now,
644
+ requesterEmail,
645
+ });
646
+ await incrementSendAttempts(db, requestId);
647
+ if ('id' in result) {
648
+ sentId = result.id;
649
+ break;
650
+ }
651
+ lastError = result.error;
652
+ } catch (error) {
653
+ await incrementSendAttempts(db, requestId);
654
+ lastError = error;
655
+ }
656
+ }
657
+
658
+ if (sentId) {
659
+ await markDisclosed(db, requestId, {
660
+ disclosedAt: options.now?.() ?? Date.now(),
661
+ disclosureEmailId: sentId,
662
+ });
663
+ disclosureSendResult = 'success';
664
+ } else {
665
+ disclosureSendResult = 'retry';
666
+ if (lastError) {
667
+ console.error('[disclosure] disclosure email failed; delegated to retry cron');
668
+ }
669
+ }
670
+ } else {
671
+ console.warn('[disclosure] DISCLOSURE_MODE=manual; auto-send skipped (PaF-2 fallback path)');
672
+ }
673
+
674
+ const notified = await notifyOperator({
675
+ env,
676
+ brand: options,
677
+ requestId,
678
+ receivedAtMs: now,
679
+ requesterEmail,
680
+ disclosureSendResult,
681
+ });
682
+ if (notified) {
683
+ await markOperatorNotified(db, requestId, options.now?.() ?? Date.now());
684
+ }
685
+
686
+ return jsonOk();
687
+ };
688
+ }
689
+
690
+ export async function retryDisclosureEmails(
691
+ env: DisclosureEnv,
692
+ brand: DisclosureBrandContext,
693
+ now = Date.now(),
694
+ ): Promise<void> {
695
+ if (!env.DB) {
696
+ console.error('[scheduled/disclosure] DB binding is not available');
697
+ return;
698
+ }
699
+ if (!env.RESEND_API_KEY) {
700
+ console.error('[scheduled/disclosure] RESEND_API_KEY is not configured');
701
+ return;
702
+ }
703
+
704
+ const payload = parseDisclosurePayload(env.DISCLOSURE_PAYLOAD);
705
+ if (!payload) {
706
+ console.error('[scheduled/disclosure] DISCLOSURE_PAYLOAD is missing or invalid');
707
+ return;
708
+ }
709
+
710
+ const maxAttempts = parsePositiveInt(env.DISCLOSURE_RETRY_MAX, DEFAULT_RETRY_MAX);
711
+ const rows = await listRetryableUndisclosed(env.DB, {
712
+ maxAttempts,
713
+ receivedAtOnOrBefore: now - DISCLOSURE_RETRY_MIN_AGE_MS,
714
+ });
715
+ if (rows.length === 0) return;
716
+
717
+ for (const row of rows) {
718
+ await retryOneDisclosureEmail(env, brand, payload, row, now, maxAttempts);
719
+ }
720
+ }
721
+
722
+ async function retryOneDisclosureEmail(
723
+ env: DisclosureEnv,
724
+ brand: DisclosureBrandContext,
725
+ payload: DisclosurePayload,
726
+ row: DisclosureRequestRow,
727
+ now: number,
728
+ maxAttempts: number,
729
+ ): Promise<void> {
730
+ const db = env.DB;
731
+ if (!db) return;
732
+
733
+ try {
734
+ const result = await sendDisclosureToRequester({
735
+ env,
736
+ brand,
737
+ payload,
738
+ requestId: row.id,
739
+ receivedAtMs: row.received_at,
740
+ requesterEmail: row.requester_email,
741
+ });
742
+ const attempts = await incrementSendAttempts(db, row.id);
743
+ if ('id' in result) {
744
+ await markDisclosed(db, row.id, { disclosedAt: now, disclosureEmailId: result.id });
745
+ return;
746
+ }
747
+ if (attempts >= maxAttempts) {
748
+ await notifyOperator({
749
+ env,
750
+ brand,
751
+ requestId: row.id,
752
+ receivedAtMs: row.received_at,
753
+ requesterEmail: row.requester_email,
754
+ disclosureSendResult: 'exhausted',
755
+ });
756
+ }
757
+ } catch {
758
+ const attempts = await incrementSendAttempts(db, row.id);
759
+ console.error('[scheduled/disclosure] retry send threw');
760
+ if (attempts >= maxAttempts) {
761
+ await notifyOperator({
762
+ env,
763
+ brand,
764
+ requestId: row.id,
765
+ receivedAtMs: row.received_at,
766
+ requesterEmail: row.requester_email,
767
+ disclosureSendResult: 'exhausted',
768
+ });
769
+ }
770
+ }
771
+ }
772
+
773
+ export async function alertStaleDisclosureRequests(
774
+ env: DisclosureEnv,
775
+ brand: DisclosureBrandContext,
776
+ ): Promise<void> {
777
+ if (!env.DB) {
778
+ console.error('[scheduled/disclosure] DB binding is not available');
779
+ return;
780
+ }
781
+
782
+ const alertTo = resolveDisclosureNotifyTo(env, brand.notifyFallback);
783
+ if (!alertTo) {
784
+ console.error('[scheduled/disclosure] DISCLOSURE_NOTIFY_TO (or email.bcc) is not configured');
785
+ return;
786
+ }
787
+
788
+ const rows = await listUndisclosedForReminder(env.DB);
789
+ if (rows.length === 0) return;
790
+
791
+ if (!env.RESEND_API_KEY) {
792
+ console.error('[scheduled/disclosure] RESEND_API_KEY is not configured');
793
+ return;
794
+ }
795
+
796
+ const mail = renderStaleDisclosureReminderMail({
797
+ brandName: brand.brandName,
798
+ brandUrl: brand.brandUrl,
799
+ rows,
800
+ });
801
+
802
+ try {
803
+ const result = await sendResendEmail({
804
+ apiKey: env.RESEND_API_KEY,
805
+ from: brand.emailFrom,
806
+ to: alertTo,
807
+ subject: mail.subject,
808
+ text: mail.text,
809
+ html: mail.html,
810
+ replyTo: brand.emailReplyTo,
811
+ });
812
+ if ('error' in result) {
813
+ console.error('[scheduled/disclosure] stale reminder notify failed');
814
+ }
815
+ } catch {
816
+ console.error('[scheduled/disclosure] stale reminder notify threw');
817
+ }
818
+ }