@ripla/godd-mcp 1.0.7-canary.8 → 1.0.7

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.
Files changed (92) hide show
  1. package/README.md +523 -124
  2. package/dist/godd.cjs +293 -244
  3. package/dist/godd.js +1005 -321
  4. package/dist/index.js +148 -119
  5. package/notes/app/DESIGN.md +5 -2
  6. package/notes/app/e2e/fixtures.ts +39 -0
  7. package/notes/app/e2e/ja-typography-visual.spec.ts +185 -0
  8. package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-desktop-1280-chromium-linux.png +0 -0
  9. package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-mobile-360-chromium-linux.png +0 -0
  10. package/notes/app/e2e/rtl-logical-properties.spec.ts +36 -0
  11. package/notes/app/package-lock.json +0 -8
  12. package/notes/app/package.json +0 -1
  13. package/notes/app/playwright.config.ts +7 -0
  14. package/notes/app/pnpm-lock.yaml +0 -8
  15. package/notes/app/src/components/BranchSelectorModal.tsx +8 -8
  16. package/notes/app/src/components/CommentPanel.tsx +5 -5
  17. package/notes/app/src/components/DiffPreview.tsx +4 -4
  18. package/notes/app/src/components/FileContentView.tsx +1 -1
  19. package/notes/app/src/components/FileContextMenu.tsx +1 -1
  20. package/notes/app/src/components/FileInfoPanel.branch-tab.test.tsx +3 -35
  21. package/notes/app/src/components/IssueCommentThread.test.tsx +40 -19
  22. package/notes/app/src/components/IssueDetailView.test.tsx +3 -40
  23. package/notes/app/src/components/IssueList.tsx +3 -3
  24. package/notes/app/src/components/LabelSelector.tsx +1 -1
  25. package/notes/app/src/components/MarkdownEditor.tsx +1 -1
  26. package/notes/app/src/components/Skeleton.tsx +2 -2
  27. package/notes/app/src/components/ToastContainer.tsx +1 -1
  28. package/notes/app/src/components/TreeNode.tsx +6 -6
  29. package/notes/app/src/contexts/ToastContext.tsx +1 -1
  30. package/notes/app/src/hooks/useComments.ts +1 -1
  31. package/notes/app/src/hooks/useEditSession.ts +1 -1
  32. package/notes/app/src/lib/api.ts +1 -13
  33. package/notes/app/src/lib/csv-utils.ts +1 -1
  34. package/notes/app/src/lib/postLoginRedirect.ts +1 -1
  35. package/notes/app/src/lib/rtl-logical-properties.test.ts +59 -0
  36. package/notes/app/src/lib/user-colors.ts +1 -1
  37. package/notes/app/src/pages/MainPage.tsx +2 -2
  38. package/notes/app/src/pages/SettingsPage.test.tsx +40 -10
  39. package/notes/app/src/pages/SettingsPage.tsx +1 -1
  40. package/notes/app/src/pages/UserManagementPage.tsx +7 -7
  41. package/notes/app/src/styles/globals.css +17 -0
  42. package/notes/app/src/test/mockPeriodicSync.ts +68 -0
  43. package/notes/app/src/test/mocks.ts +1 -1
  44. package/package.json +7 -3
  45. package/templates/feedback/feedback-lambda/index.mjs +342 -0
  46. package/templates/feedback/frontend/FeedbackWidget.tsx +574 -0
  47. package/templates/feedback/frontend/api.ts +38 -0
  48. package/templates/feedback/frontend/composePinImage.ts +80 -0
  49. package/templates/feedback/frontend/config.ts +14 -0
  50. package/templates/feedback/frontend/types.ts +41 -0
  51. package/templates/feedback/terraform/Makefile +43 -0
  52. package/templates/feedback/terraform/backend.tf +10 -0
  53. package/templates/feedback/terraform/lambda.tf +95 -0
  54. package/templates/feedback/terraform/local.tf +12 -0
  55. package/templates/feedback/terraform/output.tf +9 -0
  56. package/templates/feedback/terraform/provider.tf +24 -0
  57. package/templates/feedback/terraform/variables.tf +25 -0
  58. package/templates/github-actions/aws/deploy-notes.yml.hbs +193 -10
  59. package/templates/notes-docs/001_project/README.md +0 -2
  60. package/templates/notes-docs/002_business_flow/README.md +19 -0
  61. package/templates/notes-docs/003_requirements/README.md +20 -0
  62. package/templates/notes-docs/003_requirements/questions.csv +1 -0
  63. package/templates/notes-docs/003_requirements/spec/README.md +11 -0
  64. package/templates/notes-docs/004_pages/README.md +22 -0
  65. package/templates/notes-docs/005_architecture/README.md +20 -0
  66. package/templates/notes-docs/006_development_workflow/README.md +67 -0
  67. package/templates/notes-docs/006_development_workflow/daily_flow.template.md +303 -0
  68. package/templates/notes-docs/006_development_workflow/git.template.md +72 -20
  69. package/templates/notes-docs/006_development_workflow/judgment.template.md +182 -0
  70. package/templates/notes-docs/006_development_workflow/workflow.template.md +167 -192
  71. package/templates/notes-docs/007_guides/README.md +85 -0
  72. package/templates/notes-docs/007_guides/bot-token-setup.md +174 -0
  73. package/templates/notes-docs/007_guides/getting_started.md +112 -0
  74. package/templates/notes-docs/007_guides/glossary.md +145 -0
  75. package/templates/notes-docs/007_guides/godd_notes_features.md +105 -0
  76. package/templates/notes-docs/007_guides/godd_notes_usage.md +28 -19
  77. package/templates/notes-docs/007_guides/godd_tools.md +129 -0
  78. package/templates/notes-docs/007_guides/godd_usage.md +88 -95
  79. package/templates/notes-docs/007_guides/infra/README.md +57 -0
  80. package/templates/notes-docs/007_guides/{setup_aws_accounts.md → infra/setup_aws_accounts.md} +3 -2
  81. package/templates/notes-docs/007_guides/{setup_infra.template.md → infra/setup_infra.template.md} +15 -4
  82. package/templates/notes-docs/007_guides/setup_godd.md +73 -22
  83. package/templates/notes-docs/007_guides/setup_godd_notes.md +97 -42
  84. package/templates/notes-docs/007_guides/setup_godd_notes.md.styles.json +2 -2
  85. package/templates/notes-docs/007_guides/setup_local_environment.template.md +81 -20
  86. package/templates/notes-docs/007_guides/troubleshooting.md +225 -0
  87. package/templates/notes-docs/999_ref/README.md +23 -0
  88. package/templates/notes-docs/GENERATED.md +1 -1
  89. package/templates/notes-docs/README.md +64 -34
  90. package/templates/terraform/aws/iam.tf.hbs +361 -4
  91. /package/templates/notes-docs/007_guides/{setup_aws_cost_alert.md → infra/setup_aws_cost_alert.md} +0 -0
  92. /package/templates/notes-docs/007_guides/{setup_aws_guard_duty.md → infra/setup_aws_guard_duty.md} +0 -0
@@ -0,0 +1,68 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * `usePeriodicSync` のテスト用 faithful mock を生成する。
5
+ *
6
+ * 実フックと同じく `onSync` を ref で捕捉し、effect の依存を
7
+ * `[enabled, intervalMs, skipInitialSync]` に限定する。初回 mount の即時 sync は
8
+ * `skipInitialSync` が有効な場合に抑制し、テストから明示的に poll を発火できる
9
+ * `triggerPeriodicSync` を提供する。
10
+ *
11
+ * `isSyncing` / `visibilitychange` / `setInterval` はモデル化しない。対象テストは
12
+ * `triggerPeriodicSync` で poll を明示発火するため、これらの挙動に依存しない。
13
+ */
14
+
15
+ type TriggerPeriodicSync = {
16
+ mockImplementation: (implementation: () => void) => unknown;
17
+ };
18
+
19
+ type UsePeriodicSyncOptions = {
20
+ onSync: () => void;
21
+ enabled: boolean;
22
+ intervalMs: number;
23
+ skipInitialSync?: boolean;
24
+ };
25
+
26
+ type PeriodicSyncMock = {
27
+ usePeriodicSync: (options: UsePeriodicSyncOptions) => void;
28
+ };
29
+
30
+ /**
31
+ * @public Imported via `require()` (not static `import`) from inside `vi.mock(...)`
32
+ * factories in {@link ../components/FileInfoPanel.branch-tab.test.tsx} and
33
+ * {@link ../components/IssueDetailView.test.tsx} to satisfy vi.mock hoisting —
34
+ * static analysis (knip) does not follow that dynamic require, so it flags this
35
+ * export as unused even though it is genuinely consumed.
36
+ */
37
+ export function faithfulPeriodicSyncMock(
38
+ triggerPeriodicSync: TriggerPeriodicSync,
39
+ ): PeriodicSyncMock {
40
+ return {
41
+ usePeriodicSync: ({
42
+ onSync,
43
+ enabled,
44
+ intervalMs,
45
+ skipInitialSync,
46
+ }: UsePeriodicSyncOptions) => {
47
+ const onSyncRef = useRef(onSync);
48
+ const isInitialMountRef = useRef(true);
49
+
50
+ useEffect(() => {
51
+ onSyncRef.current = onSync;
52
+ }, [onSync]);
53
+
54
+ useEffect(() => {
55
+ // trigger は常に最新 onSync を呼ぶ(mock 差し替え後の closed 応答を確実に拾う)。
56
+ triggerPeriodicSync.mockImplementation(() => {
57
+ if (enabled) onSyncRef.current();
58
+ });
59
+
60
+ const isInitialMount = isInitialMountRef.current;
61
+ isInitialMountRef.current = false;
62
+ if (!enabled || intervalMs <= 0) return;
63
+ if (skipInitialSync && isInitialMount) return;
64
+ onSyncRef.current();
65
+ }, [enabled, intervalMs, skipInitialSync]);
66
+ },
67
+ };
68
+ }
@@ -25,7 +25,7 @@ type CollaborationValue = ReturnType<typeof useCollaboration>;
25
25
  type CommentsValue = ReturnType<typeof useComments>;
26
26
 
27
27
  /** 既定の認証ユーザー(editor)。role/user はテスト側で上書きできる */
28
- export const DEFAULT_AUTH_USER: UserInfo = {
28
+ const DEFAULT_AUTH_USER: UserInfo = {
29
29
  id: "u1",
30
30
  username: "alice",
31
31
  displayName: "Alice",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ripla/godd-mcp",
3
- "version": "1.0.7-canary.8",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "description": "GoDD MCP Server - AI-powered development workflow tools via Model Context Protocol (slash commands support)",
6
6
  "main": "dist/index.js",
@@ -18,6 +18,7 @@
18
18
  "templates/terraform/",
19
19
  "templates/scripts/",
20
20
  "templates/notes-docs/",
21
+ "templates/feedback/",
21
22
  "templates/notes-compose.yml",
22
23
  "README.md"
23
24
  ],
@@ -28,6 +29,8 @@
28
29
  "zod": "^3.24.2"
29
30
  },
30
31
  "devDependencies": {
32
+ "@anthropic-ai/bedrock-sdk": "^0.32.1",
33
+ "@anthropic-ai/sdk": "^0.116.0",
31
34
  "@types/js-yaml": "^4.0.9",
32
35
  "@types/node": "^22.13.4",
33
36
  "@yao-pkg/pkg": "^6.13.1",
@@ -49,12 +52,13 @@
49
52
  "test": "vitest run",
50
53
  "test:watch": "vitest",
51
54
  "encrypt": "tsx src/admin/encrypt.ts",
52
- "keygen": "tsx src/admin/keygen.ts",
55
+ "keygen": "tsx src/license/keygen.ts",
53
56
  "build:user": "tsx src/admin/build-customer.ts",
54
57
  "godd": "tsx src/cli/godd.ts",
55
58
  "godd:init": "tsx src/cli/godd.ts init",
56
59
  "godd:install": "tsx src/cli/godd.ts install",
57
60
  "build:dist": "node esbuild.config.js && node scripts/build-dist.js",
58
- "build:dist:current": "node esbuild.config.js && node scripts/build-dist.js --current"
61
+ "build:dist:current": "node esbuild.config.js && node scripts/build-dist.js --current",
62
+ "eval": "tsx tests/eval/run-eval.ts"
59
63
  }
60
64
  }
@@ -0,0 +1,342 @@
1
+ // Lambda Function URL handler — フィードバック受付 → GitHub Issue 起票
2
+ // 認証: GoDD Notes Bot (GitHub App) のインストールトークンを都度生成
3
+
4
+ import crypto from "node:crypto";
5
+ import {
6
+ SecretsManagerClient,
7
+ GetSecretValueCommand,
8
+ } from "@aws-sdk/client-secrets-manager";
9
+
10
+ const GITHUB_API = "https://api.github.com";
11
+ const TOKEN_BUFFER_SECONDS = 300;
12
+ const JWT_EXPIRY_SECONDS = 600;
13
+ // Function URL のリクエスト上限は 6MB。原画は送らない設計のため 3MB で十分
14
+ const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
15
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
16
+
17
+ const sm = new SecretsManagerClient();
18
+
19
+ // シークレットのキャッシュ(コールドスタートごとにリセット)
20
+ const secretCache = {};
21
+
22
+ async function getSecret(secretName) {
23
+ if (secretCache[secretName]) return secretCache[secretName];
24
+ const res = await sm.send(
25
+ new GetSecretValueCommand({ SecretId: secretName }),
26
+ );
27
+ const value = res.SecretString;
28
+ secretCache[secretName] = value;
29
+ return value;
30
+ }
31
+
32
+ // GitHub App インストールトークンのキャッシュ
33
+ let cachedToken = null;
34
+ let cachedTokenExpiresAt = 0;
35
+
36
+ function generateJWT(appId, privateKey) {
37
+ const now = Math.floor(Date.now() / 1000);
38
+ const header = { alg: "RS256", typ: "JWT" };
39
+ const payload = { iat: now - 60, exp: now + JWT_EXPIRY_SECONDS, iss: appId };
40
+
41
+ const b64url = (obj) =>
42
+ Buffer.from(JSON.stringify(obj)).toString("base64url");
43
+ const unsigned = `${b64url(header)}.${b64url(payload)}`;
44
+
45
+ const sign = crypto.createSign("RSA-SHA256");
46
+ sign.update(unsigned);
47
+ const signature = sign.sign(privateKey.replace(/\\n/g, "\n"), "base64url");
48
+
49
+ return `${unsigned}.${signature}`;
50
+ }
51
+
52
+ async function getInstallationToken() {
53
+ const now = Date.now() / 1000;
54
+ if (cachedToken && now < cachedTokenExpiresAt - TOKEN_BUFFER_SECONDS) {
55
+ return cachedToken;
56
+ }
57
+
58
+ const appId = await getSecret(env("GITHUB_APP_ID_SECRET"));
59
+ const privateKey = await getSecret(env("GITHUB_APP_PRIVATE_KEY_SECRET"));
60
+ const installationId = await getSecret(
61
+ env("GITHUB_APP_INSTALLATION_ID_SECRET"),
62
+ );
63
+
64
+ const jwt = generateJWT(appId, privateKey);
65
+ // トークンは対象リポジトリ + 必要権限に絞って発行する(漏洩時の影響を
66
+ // インストール配下の他リポジトリへ波及させない)
67
+ const repoName = env("GITHUB_REPO").split("/")[1];
68
+ const res = await fetch(
69
+ `${GITHUB_API}/app/installations/${installationId}/access_tokens`,
70
+ {
71
+ method: "POST",
72
+ headers: {
73
+ authorization: `Bearer ${jwt}`,
74
+ accept: "application/vnd.github+json",
75
+ },
76
+ body: JSON.stringify({
77
+ repositories: [repoName],
78
+ permissions: { contents: "write", issues: "write" },
79
+ }),
80
+ },
81
+ );
82
+
83
+ if (!res.ok) {
84
+ const detail = await res.text().catch(() => "");
85
+ throw new Error(`GitHub App token error (${res.status}): ${detail}`);
86
+ }
87
+
88
+ const data = await res.json();
89
+ cachedToken = data.token;
90
+ cachedTokenExpiresAt = data.expires_at
91
+ ? new Date(data.expires_at).getTime() / 1000
92
+ : now + 3600;
93
+
94
+ return cachedToken;
95
+ }
96
+
97
+ function env(key) {
98
+ const v = process.env[key];
99
+ if (!v) throw new Error(`環境変数 ${key} が未設定です`);
100
+ return v;
101
+ }
102
+
103
+ function respond(statusCode, body) {
104
+ return {
105
+ statusCode,
106
+ headers: { "content-type": "application/json" },
107
+ body: JSON.stringify(body),
108
+ };
109
+ }
110
+
111
+ function decodeDataUrl(dataUrl) {
112
+ const match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/);
113
+ if (!match) throw new Error("無効な data URL です");
114
+ return Buffer.from(match[1], "base64");
115
+ }
116
+
117
+ async function ghFetch(path, token, opts = {}) {
118
+ const res = await fetch(`${GITHUB_API}${path}`, {
119
+ ...opts,
120
+ headers: {
121
+ accept: "application/vnd.github+json",
122
+ authorization: `Bearer ${token}`,
123
+ "x-github-api-version": "2022-11-28",
124
+ ...opts.headers,
125
+ },
126
+ });
127
+ const body = await res.json().catch(() => null);
128
+ if (!res.ok) {
129
+ const msg = body?.message ?? res.statusText;
130
+ throw new Error(`GitHub API ${res.status}: ${msg}`);
131
+ }
132
+ return body;
133
+ }
134
+
135
+ async function ensureBranch(repo, branch, token) {
136
+ const ref = await ghFetch(
137
+ `/repos/${repo}/git/ref/heads/${branch}`,
138
+ token,
139
+ ).catch(() => null);
140
+ if (ref) return;
141
+
142
+ // デフォルトブランチは main とは限らない(master 等)ためリポジトリ情報から取得する
143
+ const repoInfo = await ghFetch(`/repos/${repo}`, token);
144
+ const defaultRef = await ghFetch(
145
+ `/repos/${repo}/git/ref/heads/${repoInfo.default_branch}`,
146
+ token,
147
+ );
148
+ const sha = defaultRef.object.sha;
149
+
150
+ await ghFetch(`/repos/${repo}/git/refs`, token, {
151
+ method: "POST",
152
+ body: JSON.stringify({ ref: `refs/heads/${branch}`, sha }),
153
+ });
154
+ }
155
+
156
+ async function ensureLabel(repo, token) {
157
+ const label = await ghFetch(`/repos/${repo}/labels/feedback`, token).catch(
158
+ () => null,
159
+ );
160
+ if (label) return;
161
+
162
+ // 既存ラベルは触らず、無い場合のみ作成する(同時作成の競合は無視して続行)
163
+ await ghFetch(`/repos/${repo}/labels`, token, {
164
+ method: "POST",
165
+ body: JSON.stringify({
166
+ name: "feedback",
167
+ color: "D93F0B",
168
+ description: "フィードバックウィジェットからの自動起票",
169
+ }),
170
+ }).catch(() => {});
171
+ }
172
+
173
+ async function commitImage(repo, branch, filePath, imageBuffer, token) {
174
+ await ghFetch(`/repos/${repo}/contents/${filePath}`, token, {
175
+ method: "PUT",
176
+ body: JSON.stringify({
177
+ message: `feedback: add screenshot ${filePath}`,
178
+ content: imageBuffer.toString("base64"),
179
+ branch,
180
+ }),
181
+ });
182
+ return `https://github.com/${repo}/blob/${branch}/${filePath}`;
183
+ }
184
+
185
+ // Markdown テーブルのセルに利用者入力を埋め込むためのエスケープ。
186
+ // パイプ・改行を無害化しないと表の構造ごと壊せてしまう(偽の指示文の埋め込みも含む)
187
+ function cell(value) {
188
+ return String(value).replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
189
+ }
190
+
191
+ function buildIssueBody(comment, imageUrl, page, meta, pin, extra = {}) {
192
+ const lines = [
193
+ "## フィードバック",
194
+ "",
195
+ // 起票者・画面名は任意入力のため、未指定なら見出しごと省略する
196
+ ...(extra.reporter ? ["### 起票者", "", cell(extra.reporter), ""] : []),
197
+ ...(extra.screenName ? ["### 画面名", "", cell(extra.screenName), ""] : []),
198
+ "### 指摘・要望コメント",
199
+ "",
200
+ `> ${comment.replace(/\n/g, "\n> ")}`,
201
+ "",
202
+ "### スクリーンショット(指摘箇所)",
203
+ `[スクリーンショットを開く](${imageUrl})`,
204
+ "",
205
+ "### メタ情報",
206
+ `| 項目 | 値 |`,
207
+ `|---|---|`,
208
+ // 起票者・画面名は任意入力のため、未指定なら行ごと省略する
209
+ ...(extra.reporter ? [`| 起票者 | ${cell(extra.reporter)} |`] : []),
210
+ ...(extra.screenName ? [`| 画面名 | ${cell(extra.screenName)} |`] : []),
211
+ `| ページ | ${cell(page.path)} |`,
212
+ `| タイトル | ${cell(page.title)} |`,
213
+ `| Viewport | ${meta.viewport.width}×${meta.viewport.height} |`,
214
+ `| UA | ${cell(meta.userAgent)} |`,
215
+ `| 撮影日時 | ${cell(meta.capturedAt)} |`,
216
+ `| ピン位置 | (${pin.x.toFixed(1)}%, ${pin.y.toFixed(1)}%) |`,
217
+ ];
218
+ return lines.join("\n");
219
+ }
220
+
221
+ // ペイロードの形状検証。未検証のまま参照すると TypeError → 500 になり、
222
+ // 内部エラーメッセージが送信者へ漏れるため、先に固定文言の 400 で弾く
223
+ function validatePayload(payload) {
224
+ if (typeof payload !== "object" || payload === null) return "不正なペイロードです";
225
+ const { comment, annotatedScreenshot, pin, page, meta, reporter, screenName } =
226
+ payload;
227
+ if (typeof comment !== "string" || !comment.trim()) return "comment は必須です";
228
+ if (typeof annotatedScreenshot !== "string")
229
+ return "annotatedScreenshot は必須です";
230
+ if (
231
+ typeof pin !== "object" || pin === null ||
232
+ !Number.isFinite(pin.x) || !Number.isFinite(pin.y)
233
+ )
234
+ return "pin は必須です";
235
+ if (
236
+ typeof page !== "object" || page === null ||
237
+ typeof page.path !== "string" || typeof page.title !== "string"
238
+ )
239
+ return "page が不正です";
240
+ if (
241
+ typeof meta !== "object" || meta === null ||
242
+ typeof meta.viewport !== "object" || meta.viewport === null ||
243
+ !Number.isFinite(meta.viewport.width) || !Number.isFinite(meta.viewport.height) ||
244
+ typeof meta.userAgent !== "string" || typeof meta.capturedAt !== "string"
245
+ )
246
+ return "meta が不正です";
247
+ if (reporter !== undefined && typeof reporter !== "string")
248
+ return "reporter が不正です";
249
+ if (screenName !== undefined && typeof screenName !== "string")
250
+ return "screenName が不正です";
251
+ return null;
252
+ }
253
+
254
+ function isValidToken(provided, expected) {
255
+ if (typeof provided !== "string") return false;
256
+ const a = Buffer.from(provided);
257
+ const b = Buffer.from(expected);
258
+ // timingSafeEqual は同長でしか比較できないため、長さ不一致は即不正とする
259
+ if (a.length !== b.length) return false;
260
+ return crypto.timingSafeEqual(a, b);
261
+ }
262
+
263
+ export async function handler(event) {
264
+ // トークン未設定はフェイルクローズ(設定ミスのまま無認証公開しない)
265
+ const expectedToken = process.env.FEEDBACK_TOKEN;
266
+ if (!expectedToken) {
267
+ console.error("FEEDBACK_TOKEN が未設定のためリクエストを拒否しました");
268
+ return respond(500, { error: "サーバー設定エラー" });
269
+ }
270
+ if (!isValidToken(event.headers?.["x-feedback-token"], expectedToken)) {
271
+ return respond(403, { error: "Forbidden" });
272
+ }
273
+
274
+ try {
275
+ const token = await getInstallationToken();
276
+ const repo = env("GITHUB_REPO");
277
+ const branch = process.env.GITHUB_BRANCH ?? "feedback-assets";
278
+
279
+ let payload;
280
+ try {
281
+ payload = JSON.parse(event.body);
282
+ } catch {
283
+ return respond(400, { error: "JSON の解析に失敗しました" });
284
+ }
285
+ const validationError = validatePayload(payload);
286
+ if (validationError) return respond(400, { error: validationError });
287
+ const { comment, annotatedScreenshot, pin, page, meta, reporter, screenName } =
288
+ payload;
289
+
290
+ let imageBuffer;
291
+ try {
292
+ imageBuffer = decodeDataUrl(annotatedScreenshot);
293
+ } catch {
294
+ return respond(400, { error: "画像の形式が不正です" });
295
+ }
296
+ if (imageBuffer.length > MAX_IMAGE_BYTES) {
297
+ return respond(400, {
298
+ error: "画像が大きすぎます(3MB 以下)。ウィンドウを縮小して撮り直してください",
299
+ });
300
+ }
301
+ // PNG マジックバイト検証: 任意バイト列の置き場として使われるのを防ぐ
302
+ if (!imageBuffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
303
+ return respond(400, { error: "PNG 画像のみ受け付けます" });
304
+ }
305
+
306
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
307
+ const rand = Math.random().toString(36).slice(2, 8);
308
+ const filePath = `feedback/${ts}_${rand}.png`;
309
+
310
+ await ensureBranch(repo, branch, token);
311
+ await ensureLabel(repo, token);
312
+ const imageUrl = await commitImage(
313
+ repo,
314
+ branch,
315
+ filePath,
316
+ imageBuffer,
317
+ token,
318
+ );
319
+
320
+ const issueBody = buildIssueBody(comment.trim(), imageUrl, page, meta, pin, {
321
+ reporter: reporter?.trim(),
322
+ screenName: screenName?.trim(),
323
+ });
324
+ const issue = await ghFetch(`/repos/${repo}/issues`, token, {
325
+ method: "POST",
326
+ body: JSON.stringify({
327
+ title: `[フィードバック] ${comment.trim().replace(/\r?\n/g, " ").slice(0, 60)}`,
328
+ body: issueBody,
329
+ labels: ["feedback"],
330
+ }),
331
+ });
332
+
333
+ return respond(
334
+ 200,
335
+ { issueUrl: issue.html_url, issueNumber: issue.number },
336
+ );
337
+ } catch (e) {
338
+ // 詳細は CloudWatch のみに残し、送信者へは内部情報を返さない
339
+ console.error(e);
340
+ return respond(500, { error: "内部エラーが発生しました" });
341
+ }
342
+ }