create-feedbax 0.2.0 → 0.2.2

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 (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -0,0 +1,1138 @@
1
+ import { afterEach, expect, it } from "vitest";
2
+ import { Miniflare } from "miniflare";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { createGitHubFeedbackModule } from "./index";
6
+ import { createConnectedStore } from "./connected";
7
+ import { createConnectedFeedbackModule } from "./connected-feedback";
8
+ import { createImageModule } from "./images";
9
+ import { createImageFeedbackModule } from "./image-feedback";
10
+ import { createObservedHistoryModule } from "./observed-history";
11
+
12
+ let runtime: Miniflare | undefined;
13
+ afterEach(async () => {
14
+ await runtime?.dispose();
15
+ runtime = undefined;
16
+ });
17
+
18
+ async function setup() {
19
+ runtime = new Miniflare({
20
+ modules: true,
21
+ script: "export default {fetch(){return new Response('ok')}}",
22
+ compatibilityDate: "2026-07-30",
23
+ d1Databases: ["DB"],
24
+ r2Buckets: ["IMAGES"],
25
+ });
26
+ const database = await runtime.getD1Database("DB");
27
+ await database.exec(
28
+ await readFile(
29
+ existsSync("apps/portal/migrations/0005_github.sql")
30
+ ? "apps/portal/migrations/0005_github.sql"
31
+ : "migrations/0005_github.sql",
32
+ "utf8",
33
+ ),
34
+ );
35
+ const issues: Record<string, unknown>[] = [];
36
+ const comments: Record<string, unknown>[] = [];
37
+ const writes: { method: string; body: string }[] = [];
38
+ const request: typeof fetch = async (input, init) => {
39
+ const url = new URL(String(input));
40
+ expect(url.origin).toBe("https://api.github.com");
41
+ expect(new Headers(init?.headers).get("Authorization")).toBe(
42
+ "Bearer scoped-token",
43
+ );
44
+ expect(new Headers(init?.headers).get("User-Agent")).toBe("Feedbax");
45
+ expect(new Headers(init?.headers).get("X-GitHub-Api-Version")).toBe(
46
+ "2026-03-10",
47
+ );
48
+ expect(init?.redirect).toBe("manual");
49
+ if (url.pathname === "/repos/team/feedback")
50
+ return Response.json({ id: 100, private: true });
51
+ const thread = url.pathname.match(/\/issues\/(\d+)\/comments$/);
52
+ if (thread) {
53
+ if (init?.method !== "POST")
54
+ return Response.json(
55
+ comments.filter(
56
+ (comment) => comment.issueNumber === Number(thread[1]),
57
+ ),
58
+ );
59
+ writes.push({ method: "POST", body: String(init.body) });
60
+ const comment = {
61
+ ...JSON.parse(String(init.body)),
62
+ id: 10000 + comments.length,
63
+ issueNumber: Number(thread[1]),
64
+ user: { id: 42, type: "Bot" },
65
+ created_at: "2026-10-15T13:00:00Z",
66
+ };
67
+ comments.push(comment);
68
+ return Response.json(comment, { status: 201 });
69
+ }
70
+ if (url.pathname.includes("/issues/comments/")) {
71
+ const comment = comments.find(
72
+ (value) => String(value.id) === url.pathname.split("/").at(-1),
73
+ );
74
+ if (!comment || comment.deleted)
75
+ return new Response(null, { status: 404 });
76
+ if (init?.method === "PATCH")
77
+ Object.assign(comment, JSON.parse(String(init.body)));
78
+ if (init?.method === "DELETE") {
79
+ comment.deleted = true;
80
+ return new Response(null, { status: 204 });
81
+ }
82
+ return Response.json(comment);
83
+ }
84
+ if (init?.method === "POST") {
85
+ const body = JSON.parse(String(init.body));
86
+ writes.push({ method: "POST", body: String(init.body) });
87
+ const issue = {
88
+ ...body,
89
+ id: 1000 + issues.length,
90
+ number: issues.length + 1,
91
+ user: { id: 42, type: "Bot" },
92
+ created_at: "2026-10-15T12:00:00Z",
93
+ updated_at: "2026-10-15T12:00:00Z",
94
+ };
95
+ issues.push(issue);
96
+ return Response.json(issue, { status: 201 });
97
+ }
98
+ if (url.pathname === "/repos/team/feedback/issues")
99
+ return Response.json(issues);
100
+ const issue = issues.find(
101
+ (value) => String(value.number) === url.pathname.split("/").at(-1),
102
+ );
103
+ if (!issue) return new Response(null, { status: 404 });
104
+ if (init?.method === "PATCH") {
105
+ writes.push({ method: "PATCH", body: String(init.body) });
106
+ Object.assign(issue, JSON.parse(String(init.body)));
107
+ }
108
+ return Response.json(issue);
109
+ };
110
+ const options = {
111
+ database,
112
+ installationId: "portal-a",
113
+ repository: { owner: "team", name: "feedback", id: 100 },
114
+ botUserId: 42,
115
+ getToken: async () => "scoped-token",
116
+ request,
117
+ };
118
+ return {
119
+ database,
120
+ issues,
121
+ comments,
122
+ writes,
123
+ options,
124
+ feedback: createGitHubFeedbackModule(options),
125
+ };
126
+ }
127
+
128
+ const submission = {
129
+ title: "Export reports",
130
+ description: "Export a report to CSV.",
131
+ type: "Feature Request" as const,
132
+ submitter: { name: "Alice private", email: "private@example.com" },
133
+ };
134
+
135
+ it("fences an expired public-history reader after a newer GitHub observation revokes publication", async () => {
136
+ const { options, feedback, issues } = await setup();
137
+ await options.database.exec(
138
+ await readFile(
139
+ existsSync("apps/portal/migrations/0009_observed_history.sql")
140
+ ? "apps/portal/migrations/0009_observed_history.sql"
141
+ : "migrations/0009_observed_history.sql",
142
+ "utf8",
143
+ ),
144
+ );
145
+ const post = await feedback.submitPost(submission);
146
+ issues[0]!.labels = ["feedbax:planned", "feedbax:published"];
147
+ let time = 1000;
148
+ const current = createObservedHistoryModule({
149
+ database: options.database,
150
+ feedback,
151
+ now: () => time,
152
+ });
153
+ await current.observe(post.slug);
154
+ issues[0]!.labels = ["feedbax:in-progress", "feedbax:published"];
155
+ expect((await current.list({ slug: post.slug })).items).toHaveLength(1);
156
+ let resume!: (response: Response) => void;
157
+ let capturedResponse!: Response;
158
+ let captured!: () => void;
159
+ const capture = new Promise<void>((resolve) => {
160
+ captured = resolve;
161
+ });
162
+ const stale = createObservedHistoryModule({
163
+ database: options.database,
164
+ now: () => time,
165
+ feedback: createGitHubFeedbackModule({
166
+ ...options,
167
+ request: async (input, init) => {
168
+ const response = await options.request(input, init);
169
+ if (String(input).endsWith("/issues/1") && init?.method === "GET") {
170
+ capturedResponse = response;
171
+ captured();
172
+ return new Promise<Response>((resolve) => {
173
+ resume = resolve;
174
+ });
175
+ }
176
+ return response;
177
+ },
178
+ }),
179
+ });
180
+ const pending = stale.list({ slug: post.slug });
181
+ await capture;
182
+ time += 60001;
183
+ issues[0]!.labels = ["feedbax:in-progress"];
184
+ expect((await current.list({ slug: post.slug })).items).toEqual([]);
185
+ resume(capturedResponse);
186
+ expect((await pending).items).toEqual([]);
187
+ });
188
+
189
+ it("creates a moderated issue while keeping contacts and draft authority private across module restarts", async () => {
190
+ const { feedback, options, writes } = await setup();
191
+ const post = await feedback.submitPost(submission);
192
+ expect(post).toMatchObject({
193
+ slug: "export-reports",
194
+ status: "New",
195
+ published: false,
196
+ });
197
+ expect(writes).toHaveLength(1);
198
+ expect(writes[0]!.body).not.toMatch(
199
+ /Alice private|private@example|browserCapability|externalId|submitter/,
200
+ );
201
+ expect(writes[0]!.body).not.toContain(post.browserCapability);
202
+ expect(await feedback.getPublicPost(post.slug)).toBeUndefined();
203
+ expect(
204
+ await createGitHubFeedbackModule(options).getDraftPost({
205
+ id: post.id,
206
+ browserCapability: post.browserCapability,
207
+ }),
208
+ ).toMatchObject({ title: submission.title, submitter: submission.submitter });
209
+ });
210
+
211
+ it("recovers the original issue after an ambiguous create without exposing or duplicating the idempotency key", async () => {
212
+ const { options, issues, writes } = await setup();
213
+ let lost = false;
214
+ const interrupted = createGitHubFeedbackModule({
215
+ ...options,
216
+ request: async (input, init) => {
217
+ const response = await options.request(input, init);
218
+ if (init?.method === "POST" && !lost) {
219
+ lost = true;
220
+ throw new Error("Response lost after GitHub commit");
221
+ }
222
+ return response;
223
+ },
224
+ });
225
+ const command = { ...submission, externalId: "private-idempotency-key" };
226
+ await expect(interrupted.submitTrustedPost(command)).rejects.toThrow(
227
+ "Response lost",
228
+ );
229
+ const resumed =
230
+ await createGitHubFeedbackModule(options).submitTrustedPost(command);
231
+ expect(resumed).toMatchObject({
232
+ slug: "export-reports",
233
+ title: "Export reports",
234
+ });
235
+ expect(issues).toHaveLength(1);
236
+ expect(writes).toHaveLength(1);
237
+ expect(writes[0]!.body).not.toContain(command.externalId);
238
+ });
239
+
240
+ it("publishes only explicit approved content and revokes it independently of issue closure and status", async () => {
241
+ const { feedback, issues } = await setup();
242
+ const post = await feedback.submitPost(submission);
243
+ const issue = issues[0]!;
244
+ issue.title = "Private engineering title";
245
+ issue.body = `Internal discussion and private@example.com\n<!-- feedbax:public:v1 -->\n${JSON.stringify(
246
+ {
247
+ slug: post.slug,
248
+ title: "Approved title",
249
+ description: "Public description",
250
+ type: "Bug Report",
251
+ teamResponse: "We are investigating.",
252
+ releaseNotesUrl: "https://example.com/releases",
253
+ internalNote: "Do not publish this",
254
+ },
255
+ )}\n<!-- /feedbax:public -->\nPrivate engineering notes`;
256
+ issue.labels = [{ name: "feedbax:planned" }, { name: "feedbax:published" }];
257
+ issue.state = "closed";
258
+ const visible = await feedback.getPublicPost(post.slug);
259
+ expect(visible).toMatchObject({
260
+ slug: post.slug,
261
+ title: "Approved title",
262
+ description: "Public description",
263
+ type: "Bug Report",
264
+ status: "Planned",
265
+ teamResponse: "We are investigating.",
266
+ releaseNotesUrl: "https://example.com/releases",
267
+ });
268
+ expect(JSON.stringify(visible)).not.toMatch(
269
+ /Private|private@example|internalNote|issue_id|submitter/,
270
+ );
271
+ issue.labels = ["feedbax:planned"];
272
+ expect(await feedback.getPublicPost(post.slug)).toBeUndefined();
273
+ });
274
+
275
+ it("edits and withdraws only eligible drafts, preserving private engineering notes and the immutable slug", async () => {
276
+ const { feedback, options, issues } = await setup();
277
+ const post = await feedback.submitPost(submission);
278
+ const authority = { id: post.id, browserCapability: post.browserCapability };
279
+ issues[0]!.body = `Private prefix\n${issues[0]!.body}\nPrivate suffix`;
280
+ const edited = await feedback.editDraftPost({
281
+ ...authority,
282
+ title: "A renamed request",
283
+ });
284
+ expect(edited).toMatchObject({
285
+ title: "A renamed request",
286
+ slug: "export-reports",
287
+ });
288
+ expect(issues[0]!.body).toMatch(/^Private prefix\n/);
289
+ expect(issues[0]!.body).toMatch(/\nPrivate suffix$/);
290
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
291
+ await expect(
292
+ feedback.editDraftPost({ ...authority, title: "Forbidden" }),
293
+ ).rejects.toThrow("did not authorize");
294
+ issues[0]!.labels = ["feedbax:reviewing"];
295
+ await expect(feedback.withdrawDraftPost(authority)).rejects.toThrow(
296
+ "did not authorize",
297
+ );
298
+ issues[0]!.labels = ["feedbax:new"];
299
+ await feedback.withdrawDraftPost(authority);
300
+ expect(issues[0]!.state).toBe("closed");
301
+ expect(
302
+ await createGitHubFeedbackModule(options).getPublicPost(post.slug),
303
+ ).toBeUndefined();
304
+ await expect(feedback.getDraftPost(authority)).rejects.toThrow(
305
+ "did not authorize",
306
+ );
307
+ });
308
+
309
+ it("filters and paginates public Posts and roadmap totals without admitting unmapped issues or PRs", async () => {
310
+ const { feedback, issues } = await setup();
311
+ await Promise.all(
312
+ Array.from({ length: 27 }, (_, index) =>
313
+ feedback.submitPost({ ...submission, title: `Request ${index}` }),
314
+ ),
315
+ );
316
+ for (const issue of issues)
317
+ issue.labels = ["feedbax:planned", "feedbax:published"];
318
+ issues.push({ ...issues[0], id: 9999, number: 9999 });
319
+ issues.push({ ...issues[0], pull_request: { url: "private-pr-url" } });
320
+ const first = await feedback.listPublicPosts({ sort: "new" });
321
+ expect(first.items).toHaveLength(25);
322
+ expect(first.nextCursor).toBeTruthy();
323
+ const second = await feedback.listPublicPosts({
324
+ sort: "new",
325
+ cursor: first.nextCursor,
326
+ });
327
+ expect(second.items).toHaveLength(2);
328
+ expect(
329
+ new Set([...first.items, ...second.items].map((post) => post.slug)).size,
330
+ ).toBe(27);
331
+ expect(
332
+ (await feedback.listPublicPosts({ search: "Request 26" })).items.map(
333
+ (post) => post.slug,
334
+ ),
335
+ ).toEqual(["request-26"]);
336
+ expect(
337
+ (await feedback.listPublicRoadmapPosts({ status: "Planned" })).totalCount,
338
+ ).toBe(27);
339
+ await expect(
340
+ feedback.listPublicPosts({ search: "changed", cursor: first.nextCursor }),
341
+ ).rejects.toThrow("cursor");
342
+ issues[0]!.labels = ["feedbax:planned"];
343
+ expect(
344
+ (await feedback.listPublicRoadmapPosts({ status: "Planned" })).totalCount,
345
+ ).toBe(26);
346
+ });
347
+
348
+ it("persists best-effort support privately and ranks by it without using GitHub reactions", async () => {
349
+ const { feedback, options, issues, writes } = await setup();
350
+ const first = await feedback.submitPost(submission);
351
+ await feedback.submitPost({ ...submission, title: "Another request" });
352
+ for (const issue of issues)
353
+ issue.labels = ["feedbax:planned", "feedbax:published"];
354
+ expect(
355
+ await feedback.changeVote({ slug: first.slug, intention: "add" }),
356
+ ).toEqual({ slug: first.slug, voteCount: 1 });
357
+ const restarted = createGitHubFeedbackModule(options);
358
+ expect((await restarted.listPublicPosts()).items[0]).toMatchObject({
359
+ slug: first.slug,
360
+ voteCount: 1,
361
+ });
362
+ expect(writes).toHaveLength(2);
363
+ expect(
364
+ await restarted.changeVote({ slug: first.slug, intention: "remove" }),
365
+ ).toEqual({ slug: first.slug, voteCount: 0 });
366
+ });
367
+
368
+ it("honors a bounded Retry-After on GitHub reads without exposing the provider response", async () => {
369
+ const { feedback, options, issues } = await setup();
370
+ const post = await feedback.submitPost(submission);
371
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
372
+ const delays: number[] = [];
373
+ let limited = false;
374
+ const retried = createGitHubFeedbackModule({
375
+ ...options,
376
+ sleep: async (delay) => {
377
+ delays.push(delay);
378
+ },
379
+ request: async (input, init) => {
380
+ if (new URL(String(input)).pathname.endsWith("/issues/1") && !limited) {
381
+ limited = true;
382
+ return new Response("Private rate-limit diagnostic", {
383
+ status: 429,
384
+ headers: { "Retry-After": "1" },
385
+ });
386
+ }
387
+ return options.request(input, init);
388
+ },
389
+ });
390
+ expect(await retried.getPublicPost(post.slug)).toMatchObject({
391
+ title: submission.title,
392
+ });
393
+ expect(delays).toEqual([1000]);
394
+ });
395
+
396
+ it("keeps marker-looking Participant text as content without turning it into another public section", async () => {
397
+ const { feedback, issues } = await setup();
398
+ const description =
399
+ "Please support <!-- feedbax:public:v1 --> and <!-- /feedbax:public --> in examples.";
400
+ const post = await feedback.submitPost({ ...submission, description });
401
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
402
+ expect(await feedback.getPublicPost(post.slug)).toMatchObject({
403
+ description,
404
+ });
405
+ });
406
+
407
+ it("never reuses the slug of a withdrawn Post", async () => {
408
+ const { feedback } = await setup();
409
+ const original = await feedback.submitPost(submission);
410
+ await feedback.withdrawDraftPost({
411
+ id: original.id,
412
+ browserCapability: original.browserCapability,
413
+ });
414
+ const next = await feedback.submitPost(submission);
415
+ expect(next.slug).toBe("export-reports-2");
416
+ expect(await feedback.getPublicPost(original.slug)).toBeUndefined();
417
+ });
418
+
419
+ it("persists portal Comment threads and edits without publishing engineering comments or forged markers", async () => {
420
+ const { feedback, options, issues, comments } = await setup();
421
+ const post = await feedback.submitPost(submission);
422
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
423
+ const first = await feedback.createComment({
424
+ slug: post.slug,
425
+ displayName: "Alice",
426
+ body: "Please add CSV.",
427
+ });
428
+ await feedback.replyToCommentThread({
429
+ slug: post.slug,
430
+ discussionId: first.discussionId,
431
+ displayName: "Bob",
432
+ body: "I agree.",
433
+ });
434
+ comments.push({
435
+ id: 99990,
436
+ issueNumber: 1,
437
+ body: "Private engineering conversation",
438
+ user: { id: 22 },
439
+ created_at: "2026-10-15T13:00:00Z",
440
+ });
441
+ comments.push({ ...comments[0], id: 99991, user: { id: 22 } });
442
+ comments.push({ ...comments[0], id: 99992 });
443
+ const restarted = createGitHubFeedbackModule(options);
444
+ const page = await restarted.listCommentThreads({ slug: post.slug });
445
+ expect(page.items).toHaveLength(1);
446
+ expect(
447
+ page.items[0]!.comments.map((comment) => [
448
+ comment.body,
449
+ comment.author.displayName,
450
+ ]),
451
+ ).toEqual([
452
+ ["Please add CSV.", "Alice"],
453
+ ["I agree.", "Bob"],
454
+ ]);
455
+ await restarted.editComment({
456
+ commentId: first.id,
457
+ body: "CSV and JSON, please.",
458
+ });
459
+ expect(
460
+ (await feedback.listCommentThreads({ slug: post.slug })).items[0]!
461
+ .comments[0]!.body,
462
+ ).toBe("CSV and JSON, please.");
463
+ await restarted.deleteComment({ commentId: first.id });
464
+ expect(
465
+ (await feedback.listCommentThreads({ slug: post.slug })).items
466
+ .flatMap((thread) => thread.comments)
467
+ .map((comment) => comment.body),
468
+ ).toEqual(["I agree."]);
469
+ });
470
+
471
+ it("recovers a provider success followed by a real D1 mapping failure", async () => {
472
+ const { database, feedback, options, issues, writes } = await setup();
473
+ await database.exec(
474
+ "CREATE TRIGGER fail_mapping BEFORE UPDATE ON github_posts WHEN NEW.state='ready' BEGIN SELECT RAISE(ABORT,'injected mapping failure'); END;",
475
+ );
476
+ const input = { ...submission, externalId: "mapping-failure" };
477
+ await expect(feedback.submitTrustedPost(input)).rejects.toThrow(
478
+ "injected mapping failure",
479
+ );
480
+ await database.exec("DROP TRIGGER fail_mapping;");
481
+ const recovered =
482
+ await createGitHubFeedbackModule(options).submitTrustedPost(input);
483
+ expect(recovered.slug).toBe("export-reports");
484
+ expect(issues).toHaveLength(1);
485
+ expect(writes).toHaveLength(1);
486
+ });
487
+
488
+ it("isolates private mappings and public results by authenticated Installation", async () => {
489
+ const { feedback, options, issues } = await setup();
490
+ const post = await feedback.submitPost(submission);
491
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
492
+ const other = createGitHubFeedbackModule({
493
+ ...options,
494
+ installationId: "portal-b",
495
+ });
496
+ expect(await other.getPublicPost(post.slug)).toBeUndefined();
497
+ expect((await other.listPublicPosts()).items).toEqual([]);
498
+ await expect(
499
+ other.getDraftPost({
500
+ id: post.id,
501
+ browserCapability: post.browserCapability,
502
+ }),
503
+ ).rejects.toThrow("did not authorize");
504
+ });
505
+
506
+ it("retains the last valid status on label conflicts while publication revocation wins", async () => {
507
+ const { feedback, issues } = await setup();
508
+ const post = await feedback.submitPost(submission);
509
+ issues[0]!.labels = ["feedbax:planned", "feedbax:published"];
510
+ expect((await feedback.getPublicPost(post.slug))?.status).toBe("Planned");
511
+ issues[0]!.labels = [
512
+ "feedbax:closed",
513
+ "feedbax:shipped",
514
+ "feedbax:published",
515
+ ];
516
+ expect((await feedback.getPublicPost(post.slug))?.status).toBe("Planned");
517
+ issues[0]!.labels = ["feedbax:closed", "feedbax:shipped"];
518
+ expect(await feedback.getPublicPost(post.slug)).toBeUndefined();
519
+ });
520
+
521
+ it.each(["missing", "duplicate", "slug-change", "unsafe-link"])(
522
+ "fails closed for malformed public content: %s",
523
+ async (variant) => {
524
+ const { feedback, issues } = await setup();
525
+ const post = await feedback.submitPost(submission);
526
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
527
+ const original = String(issues[0]!.body);
528
+ if (variant === "missing") issues[0]!.body = "Private notes only";
529
+ if (variant === "duplicate") issues[0]!.body = original + original;
530
+ if (variant === "slug-change")
531
+ issues[0]!.body = original.replace("export-reports", "different-slug");
532
+ if (variant === "unsafe-link") {
533
+ issues[0]!.body = original.replace(
534
+ '"type":',
535
+ '"releaseNotesUrl":"javascript:private()","type":',
536
+ );
537
+ expect(await feedback.getPublicPost(post.slug)).not.toHaveProperty(
538
+ "releaseNotesUrl",
539
+ );
540
+ } else expect(await feedback.getPublicPost(post.slug)).toBeUndefined();
541
+ },
542
+ );
543
+
544
+ it("preserves Connected unique Votes and verified Comment attribution on the GitHub adapter", async () => {
545
+ const { database, feedback, options, issues } = await setup();
546
+ for (const name of ["0001_connected.sql", "0002_participation.sql"])
547
+ await database.exec(
548
+ await readFile(
549
+ existsSync(`apps/portal/migrations/${name}`)
550
+ ? `apps/portal/migrations/${name}`
551
+ : `migrations/${name}`,
552
+ "utf8",
553
+ ),
554
+ );
555
+ const person = { subject: "private-subject", displayName: "Alice" };
556
+ const store = createConnectedStore(database);
557
+ await store.acceptIdentity({
558
+ ...person,
559
+ nonce: "nonce",
560
+ sessionHash: "hash",
561
+ expiresAt: Date.now() + 60000,
562
+ });
563
+ const post = await feedback.submitPost(submission);
564
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
565
+ const one = createConnectedFeedbackModule(feedback, store, person);
566
+ const two = createConnectedFeedbackModule(
567
+ createGitHubFeedbackModule(options),
568
+ store,
569
+ person,
570
+ );
571
+ await Promise.all([
572
+ one.changeVote({ slug: post.slug, intention: "add" }),
573
+ two.changeVote({ slug: post.slug, intention: "add" }),
574
+ ]);
575
+ expect(await two.getPublicPost(post.slug)).toMatchObject({ voteCount: 1 });
576
+ await one.createComment({
577
+ slug: post.slug,
578
+ body: "Signed feedback",
579
+ displayName: "Forged name",
580
+ });
581
+ const thread = await two.listCommentThreads({ slug: post.slug });
582
+ expect(thread.items[0]!.comments[0]!.author).toEqual({
583
+ kind: "participant",
584
+ displayName: "Alice",
585
+ verified: true,
586
+ });
587
+ expect(JSON.stringify(thread)).not.toContain(person.subject);
588
+ });
589
+
590
+ it("retries a definitively rate-limited creation later using the same reserved operation", async () => {
591
+ const { feedback, options, issues } = await setup();
592
+ let attempts = 0;
593
+ const limited = createGitHubFeedbackModule({
594
+ ...options,
595
+ sleep: async () => {},
596
+ request: async (input, init) => {
597
+ if (init?.method === "POST") {
598
+ attempts++;
599
+ return new Response("private diagnostics", {
600
+ status: 429,
601
+ headers: { "Retry-After": "0" },
602
+ });
603
+ }
604
+ return options.request(input, init);
605
+ },
606
+ });
607
+ const command = { ...submission, externalId: "rate-limited-command" };
608
+ await expect(limited.submitTrustedPost(command)).rejects.toThrow("429");
609
+ expect(attempts).toBe(3);
610
+ expect((await feedback.submitTrustedPost(command)).slug).toBe(
611
+ "export-reports",
612
+ );
613
+ expect(issues).toHaveLength(1);
614
+ });
615
+
616
+ it("uses approved content changes for public update times without exposing later engineering activity", async () => {
617
+ const { feedback, issues } = await setup();
618
+ const post = await feedback.submitPost(submission);
619
+ issues[0]!.labels = ["feedbax:planned", "feedbax:published"];
620
+ await feedback.getPublicPost(post.slug);
621
+ issues[0]!.body = String(issues[0]!.body).replace(
622
+ "Export a report to CSV.",
623
+ "CSV export has been approved.",
624
+ );
625
+ issues[0]!.updated_at = "2026-10-15T13:10:00Z";
626
+ expect((await feedback.getPublicPost(post.slug))?.updatedAt).toEqual(
627
+ new Date("2026-10-15T13:10:00Z"),
628
+ );
629
+ issues[0]!.body = `Private new engineering notes\n${issues[0]!.body}`;
630
+ issues[0]!.updated_at = "2026-10-15T14:20:00Z";
631
+ expect(
632
+ (await feedback.listPublicRoadmapPosts({ status: "Planned" })).totalCount,
633
+ ).toBe(1);
634
+ expect((await feedback.getPublicPost(post.slug))?.updatedAt).toEqual(
635
+ new Date("2026-10-15T13:10:00Z"),
636
+ );
637
+ });
638
+
639
+ it("preserves Team Response during Participant draft edits and honors its later removal", async () => {
640
+ const { feedback, issues } = await setup();
641
+ const post = await feedback.submitPost(submission);
642
+ const original = String(issues[0]!.body);
643
+ issues[0]!.body = original.replace(
644
+ '"type":',
645
+ '"teamResponse":"Approved response","type":',
646
+ );
647
+ await feedback.editDraftPost({
648
+ id: post.id,
649
+ browserCapability: post.browserCapability,
650
+ title: "New draft title",
651
+ });
652
+ expect(String(issues[0]!.body)).toContain("Approved response");
653
+ issues[0]!.body = original;
654
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
655
+ expect(await feedback.getPublicPost(post.slug)).not.toHaveProperty(
656
+ "teamResponse",
657
+ );
658
+ });
659
+
660
+ it("does not duplicate concurrent trusted submissions with the same operation key", async () => {
661
+ const { options, feedback, issues, writes } = await setup();
662
+ const command = { ...submission, externalId: "concurrent-key" };
663
+ const attempts = await Promise.allSettled(
664
+ Array.from({ length: 6 }, () =>
665
+ createGitHubFeedbackModule(options).submitTrustedPost(command),
666
+ ),
667
+ );
668
+ expect(attempts.some((attempt) => attempt.status === "fulfilled")).toBe(true);
669
+ const resumed = await feedback.submitTrustedPost(command);
670
+ expect(issues).toHaveLength(1);
671
+ expect(writes).toHaveLength(1);
672
+ for (const attempt of attempts)
673
+ if (attempt.status === "fulfilled")
674
+ expect(attempt.value.id).toBe(resumed.id);
675
+ });
676
+
677
+ it("does not blindly repeat a create whose upstream failure has an uncertain outcome", async () => {
678
+ const { options, feedback, writes } = await setup();
679
+ let creates = 0;
680
+ const unavailable = createGitHubFeedbackModule({
681
+ ...options,
682
+ request: async (input, init) => {
683
+ if (init?.method === "POST") {
684
+ creates++;
685
+ return new Response("Private diagnostic", { status: 503 });
686
+ }
687
+ return options.request(input, init);
688
+ },
689
+ });
690
+ const command = { ...submission, externalId: "uncertain-key" };
691
+ await expect(unavailable.submitTrustedPost(command)).rejects.toThrow("503");
692
+ await expect(feedback.submitTrustedPost(command)).rejects.toThrow(
693
+ "pending reconciliation",
694
+ );
695
+ expect(creates).toBe(1);
696
+ expect(writes).toHaveLength(0);
697
+ });
698
+
699
+ it("rejects a repository whose current identity no longer matches the authenticated binding", async () => {
700
+ const { options, writes } = await setup();
701
+ const moved = createGitHubFeedbackModule({
702
+ ...options,
703
+ request: async (input, init) => {
704
+ if (new URL(String(input)).pathname === "/repos/team/feedback")
705
+ return Response.json({ id: 200, private: false });
706
+ return options.request(input, init);
707
+ },
708
+ });
709
+ await expect(
710
+ moved.submitTrustedPost({ ...submission, externalId: "wrong-repo" }),
711
+ ).rejects.toThrow("Portal Binding");
712
+ expect(writes).toHaveLength(0);
713
+ });
714
+
715
+ it("keeps real R2 attachments private until the GitHub Post is published and revokes access on unpublication", async () => {
716
+ const { database, feedback, issues } = await setup();
717
+ for (const name of [
718
+ "0001_connected.sql",
719
+ "0002_participation.sql",
720
+ "0003_images.sql",
721
+ ])
722
+ await database.exec(
723
+ await readFile(
724
+ existsSync(`apps/portal/migrations/${name}`)
725
+ ? `apps/portal/migrations/${name}`
726
+ : `migrations/${name}`,
727
+ "utf8",
728
+ ),
729
+ );
730
+ const participant = { subject: "alice", displayName: "Alice" };
731
+ await createConnectedStore(database).acceptIdentity({
732
+ ...participant,
733
+ nonce: "image",
734
+ sessionHash: "image",
735
+ expiresAt: Date.now() + 60000,
736
+ });
737
+ const bucket = await runtime!.getR2Bucket("IMAGES");
738
+ const options = { database, bucket, feedback, reviewSecret: "s".repeat(32) };
739
+ const owner = createImageModule({ ...options, participant });
740
+ const png = Uint8Array.from(
741
+ Buffer.from(
742
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aE2sAAAAASUVORK5CYII=",
743
+ "base64",
744
+ ),
745
+ );
746
+ const uploaded = await owner.upload(png, "image/png");
747
+ await createImageFeedbackModule(feedback, owner).submitPost({
748
+ ...submission,
749
+ imageIds: [uploaded.id],
750
+ });
751
+ const anonymous = createImageModule(options);
752
+ expect(await anonymous.read(uploaded.id)).toBeUndefined();
753
+ expect(String(issues[0]!.body)).not.toContain(uploaded.id);
754
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
755
+ expect(await anonymous.read(uploaded.id)).toBeDefined();
756
+ issues[0]!.labels = ["feedbax:new"];
757
+ expect(await anonymous.read(uploaded.id)).toBeUndefined();
758
+ });
759
+
760
+ it("does not treat a retained New status as draft editing authority while workflow labels conflict", async () => {
761
+ const { feedback, issues } = await setup();
762
+ const post = await feedback.submitPost(submission);
763
+ const authority = { id: post.id, browserCapability: post.browserCapability };
764
+ issues[0]!.labels = ["feedbax:new", "feedbax:planned"];
765
+ await expect(feedback.getDraftPost(authority)).rejects.toThrow(
766
+ "did not authorize",
767
+ );
768
+ await expect(
769
+ feedback.editDraftPost({ ...authority, title: "Forbidden" }),
770
+ ).rejects.toThrow("did not authorize");
771
+ await expect(feedback.withdrawDraftPost(authority)).rejects.toThrow(
772
+ "did not authorize",
773
+ );
774
+ });
775
+
776
+ it("recovers a browser submission and its draft authority after a lost create response without storing or publishing the secret intent", async () => {
777
+ const { options, issues, writes } = await setup();
778
+ let lost = false;
779
+ const first = createGitHubFeedbackModule({
780
+ ...options,
781
+ request: async (input, init) => {
782
+ const response = await options.request(input, init);
783
+ if (init?.method === "POST" && !lost) {
784
+ lost = true;
785
+ throw new Error("Browser response lost");
786
+ }
787
+ return response;
788
+ },
789
+ });
790
+ const command = { ...submission, submissionKey: "k".repeat(43) };
791
+ await expect(first.submitPost(command)).rejects.toThrow("response lost");
792
+ const restarted = createGitHubFeedbackModule(options);
793
+ const recovered = await restarted.submitPost(command);
794
+ expect(issues).toHaveLength(1);
795
+ expect(writes).toHaveLength(1);
796
+ expect(writes[0]!.body).not.toContain(command.submissionKey);
797
+ expect(recovered.slug).toBe("export-reports");
798
+ expect(
799
+ await restarted.getDraftPost({
800
+ id: recovered.id,
801
+ browserCapability: recovered.browserCapability,
802
+ }),
803
+ ).toMatchObject({ title: submission.title });
804
+ expect(
805
+ (await restarted.submitPost({ ...command, title: "Changed retry" })).id,
806
+ ).toBe(recovered.id);
807
+ });
808
+
809
+ it("keeps verified ownership private across devices and permits only eligible owner draft changes", async () => {
810
+ const { createPostOwnership, createMyFeedbackModule } =
811
+ await import("./ownership");
812
+ const { options, issues } = await setup();
813
+ await options.database.exec(
814
+ await readFile(
815
+ existsSync("apps/portal/migrations/0007_ownership.sql")
816
+ ? "apps/portal/migrations/0007_ownership.sql"
817
+ : "migrations/0007_ownership.sql",
818
+ "utf8",
819
+ ),
820
+ );
821
+ const alice = { subject: "application:alice", displayName: "Alice" };
822
+ const bob = { subject: "application:bob", displayName: "Bob" };
823
+ const first = createGitHubFeedbackModule({
824
+ ...options,
825
+ ownership: createPostOwnership(options.database, alice),
826
+ });
827
+ const post = await first.submitPost({
828
+ ...submission,
829
+ submissionKey: "o".repeat(43),
830
+ });
831
+ const otherDevice = createGitHubFeedbackModule({
832
+ ...options,
833
+ ownership: createPostOwnership(options.database, alice),
834
+ });
835
+ expect(await otherDevice.getDraftPost({ id: post.id })).toMatchObject({
836
+ title: submission.title,
837
+ });
838
+ await otherDevice.editDraftPost({
839
+ id: post.id,
840
+ title: "Owner edit from another device",
841
+ });
842
+ const ownerView = createMyFeedbackModule({
843
+ database: options.database,
844
+ feedback: otherDevice,
845
+ participant: alice,
846
+ });
847
+ expect(
848
+ (await ownerView.list({ filter: "submitted" })).items[0],
849
+ ).toMatchObject({ title: "Owner edit from another device", editable: true });
850
+ const otherParticipant = createGitHubFeedbackModule({
851
+ ...options,
852
+ ownership: createPostOwnership(options.database, bob),
853
+ });
854
+ await expect(otherParticipant.getDraftPost({ id: post.id })).rejects.toThrow(
855
+ "authorize",
856
+ );
857
+ expect(await otherParticipant.getParticipantPost(post.id)).toBeUndefined();
858
+ expect(
859
+ (
860
+ await createMyFeedbackModule({
861
+ database: options.database,
862
+ feedback: otherParticipant,
863
+ participant: bob,
864
+ }).list({ filter: "submitted" })
865
+ ).items,
866
+ ).toEqual([]);
867
+ issues[0]!.labels = ["feedbax:published", "feedbax:planned"];
868
+ await expect(
869
+ otherDevice.editDraftPost({ id: post.id, title: "Forbidden" }),
870
+ ).rejects.toThrow("authorize");
871
+ expect(
872
+ (await ownerView.list({ filter: "submitted" })).items[0],
873
+ ).toMatchObject({ status: "Planned", editable: false });
874
+ expect(JSON.stringify(issues)).not.toContain(alice.subject);
875
+ });
876
+
877
+ it("links an unsigned old draft only with both verified identity and its browser capability, without transferring an existing owner", async () => {
878
+ const { createPostOwnership, createMyFeedbackModule } =
879
+ await import("./ownership");
880
+ const { options, feedback } = await setup();
881
+ await options.database.exec(
882
+ await readFile(
883
+ existsSync("apps/portal/migrations/0007_ownership.sql")
884
+ ? "apps/portal/migrations/0007_ownership.sql"
885
+ : "migrations/0007_ownership.sql",
886
+ "utf8",
887
+ ),
888
+ );
889
+ const draft = await feedback.submitPost(submission);
890
+ const alice = { subject: "issuer:alice", displayName: "Alice" };
891
+ const bob = { subject: "issuer:bob", displayName: "Bob" };
892
+ const owned = createGitHubFeedbackModule({
893
+ ...options,
894
+ ownership: createPostOwnership(options.database, alice),
895
+ });
896
+ await expect(
897
+ owned.claimDraftPost({
898
+ id: draft.id,
899
+ browserCapability: "forged" as import("./index").BrowserCapability,
900
+ }),
901
+ ).rejects.toThrow("authorize");
902
+ await expect(
903
+ feedback.claimDraftPost({
904
+ id: draft.id,
905
+ browserCapability: draft.browserCapability,
906
+ }),
907
+ ).rejects.toThrow("authorize");
908
+ expect(
909
+ await owned.claimDraftPost({
910
+ id: draft.id,
911
+ browserCapability: draft.browserCapability,
912
+ }),
913
+ ).toMatchObject({ id: draft.id, editable: true });
914
+ const attacker = createGitHubFeedbackModule({
915
+ ...options,
916
+ ownership: createPostOwnership(options.database, bob),
917
+ });
918
+ await expect(
919
+ attacker.claimDraftPost({
920
+ id: draft.id,
921
+ browserCapability: draft.browserCapability,
922
+ }),
923
+ ).rejects.toThrow("authorize");
924
+ expect(await attacker.getParticipantPost(draft.id)).toBeUndefined();
925
+ expect(
926
+ (
927
+ await createMyFeedbackModule({
928
+ database: options.database,
929
+ feedback: owned,
930
+ participant: alice,
931
+ }).list({ filter: "submitted" })
932
+ ).items,
933
+ ).toHaveLength(1);
934
+ });
935
+
936
+ it("recovers a retried Comment intent after GitHub succeeds but its reply is lost", async () => {
937
+ const { options, feedback, issues, comments, writes } = await setup();
938
+ const post = await feedback.submitPost(submission);
939
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
940
+ let lost = false;
941
+ const interrupted = createGitHubFeedbackModule({
942
+ ...options,
943
+ request: async (input, init) => {
944
+ const response = await options.request(input, init);
945
+ if (
946
+ init?.method === "POST" &&
947
+ String(input).endsWith("/comments") &&
948
+ !lost
949
+ ) {
950
+ lost = true;
951
+ throw Error("Comment reply lost");
952
+ }
953
+ return response;
954
+ },
955
+ });
956
+ const intent = {
957
+ slug: post.slug,
958
+ body: "A useful public comment",
959
+ displayName: "Alice",
960
+ submissionKey: "c".repeat(43),
961
+ };
962
+ await expect(interrupted.createComment(intent)).rejects.toThrow("reply lost");
963
+ const restarted = createGitHubFeedbackModule(options);
964
+ const recovered = await restarted.createComment(intent);
965
+ expect(comments).toHaveLength(1);
966
+ expect((await restarted.createComment(intent)).id).toBe(recovered.id);
967
+ expect(comments).toHaveLength(1);
968
+ expect(JSON.stringify(writes)).not.toContain(intent.submissionKey);
969
+ await restarted.createComment({ ...intent, submissionKey: "d".repeat(43) });
970
+ expect(comments).toHaveLength(2);
971
+ });
972
+
973
+ it("recovers Connected Comment attribution without deleting or duplicating an already attributed Comment", async () => {
974
+ const { options, feedback, issues, comments } = await setup();
975
+ for (const name of ["0001_connected.sql", "0002_participation.sql"])
976
+ await options.database.exec(
977
+ await readFile(
978
+ existsSync(`apps/portal/migrations/${name}`)
979
+ ? `apps/portal/migrations/${name}`
980
+ : `migrations/${name}`,
981
+ "utf8",
982
+ ),
983
+ );
984
+ const alice = { subject: "issuer:alice", displayName: "Alice" };
985
+ const store = createConnectedStore(options.database);
986
+ await store.acceptIdentity({
987
+ ...alice,
988
+ nonce: "comment-intent",
989
+ sessionHash: "comment-intent",
990
+ expiresAt: Date.now() + 60000,
991
+ });
992
+ const post = await feedback.submitPost(submission);
993
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
994
+ const connected = createConnectedFeedbackModule(
995
+ createGitHubFeedbackModule(options),
996
+ store,
997
+ alice,
998
+ );
999
+ const intent = {
1000
+ slug: post.slug,
1001
+ body: "Verified comment",
1002
+ displayName: "Ignored",
1003
+ submissionKey: "e".repeat(43),
1004
+ };
1005
+ const first = await connected.createComment(intent);
1006
+ const retried = await connected.createComment(intent);
1007
+ expect(retried.id).toBe(first.id);
1008
+ expect(retried.author).toMatchObject({
1009
+ displayName: "Alice",
1010
+ verified: true,
1011
+ });
1012
+ expect(comments).toHaveLength(1);
1013
+ expect(comments[0]!.deleted).not.toBe(true);
1014
+ expect(
1015
+ (await connected.listCommentThreads({ slug: post.slug })).items[0]
1016
+ ?.comments[0]?.id,
1017
+ ).toBe(first.id);
1018
+ });
1019
+
1020
+ it("resumes the same Comment intent after definitive rate limiting clears", async () => {
1021
+ const { options, feedback, issues, comments } = await setup();
1022
+ const post = await feedback.submitPost(submission);
1023
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
1024
+ let limited = true;
1025
+ const module = createGitHubFeedbackModule({
1026
+ ...options,
1027
+ sleep: async () => {},
1028
+ request: async (input, init) => {
1029
+ if (
1030
+ limited &&
1031
+ init?.method === "POST" &&
1032
+ String(input).endsWith("/comments")
1033
+ )
1034
+ return new Response(null, {
1035
+ status: 429,
1036
+ headers: { "Retry-After": "0" },
1037
+ });
1038
+ return options.request(input, init);
1039
+ },
1040
+ });
1041
+ const intent = {
1042
+ slug: post.slug,
1043
+ body: "Retry after quota",
1044
+ displayName: "Alice",
1045
+ submissionKey: "r".repeat(43),
1046
+ };
1047
+ await expect(module.createComment(intent)).rejects.toThrow("rate limited");
1048
+ limited = false;
1049
+ const recovered = await module.createComment(intent);
1050
+ expect((await module.createComment(intent)).id).toBe(recovered.id);
1051
+ expect(comments).toHaveLength(1);
1052
+ });
1053
+
1054
+ it("recovers an attached verified Comment and a transient attribution failure without deleting the original", async () => {
1055
+ const { options, feedback, issues, comments } = await setup();
1056
+ for (const name of [
1057
+ "0001_connected.sql",
1058
+ "0002_participation.sql",
1059
+ "0003_images.sql",
1060
+ ])
1061
+ await options.database.exec(
1062
+ await readFile(
1063
+ existsSync(`apps/portal/migrations/${name}`)
1064
+ ? `apps/portal/migrations/${name}`
1065
+ : `migrations/${name}`,
1066
+ "utf8",
1067
+ ),
1068
+ );
1069
+ const alice = { subject: "issuer:alice", displayName: "Alice" };
1070
+ const store = createConnectedStore(options.database);
1071
+ await store.acceptIdentity({
1072
+ ...alice,
1073
+ nonce: "attached-recovery",
1074
+ sessionHash: "attached-recovery",
1075
+ expiresAt: Date.now() + 60000,
1076
+ });
1077
+ const post = await feedback.submitPost(submission);
1078
+ issues[0]!.labels = ["feedbax:new", "feedbax:published"];
1079
+ const connected = createConnectedFeedbackModule(
1080
+ createGitHubFeedbackModule(options),
1081
+ store,
1082
+ alice,
1083
+ );
1084
+ const images = createImageModule({
1085
+ database: options.database,
1086
+ bucket: await runtime!.getR2Bucket("IMAGES"),
1087
+ feedback: connected,
1088
+ participant: alice,
1089
+ reviewSecret: "s".repeat(40),
1090
+ });
1091
+ const module = createImageFeedbackModule(connected, images);
1092
+ const png = Uint8Array.from(
1093
+ Buffer.from(
1094
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aE2sAAAAASUVORK5CYII=",
1095
+ "base64",
1096
+ ),
1097
+ );
1098
+ const image = await images.upload(png, "image/png");
1099
+ const intent = {
1100
+ slug: post.slug,
1101
+ body: "With screenshot",
1102
+ displayName: "Ignored",
1103
+ imageIds: [image.id],
1104
+ submissionKey: "i".repeat(43),
1105
+ };
1106
+ await options.database.exec(
1107
+ "CREATE TRIGGER fail_attribution BEFORE INSERT ON comment_attribution BEGIN SELECT RAISE(FAIL,'Attribution unavailable'); END;",
1108
+ );
1109
+ await expect(module.createComment(intent)).rejects.toThrow();
1110
+ await options.database.exec("DROP TRIGGER fail_attribution;");
1111
+ const first = await module.createComment(intent);
1112
+ const retried = await module.createComment(intent);
1113
+ expect(retried.id).toBe(first.id);
1114
+ expect(retried.images?.map((image) => image.id)).toEqual([image.id]);
1115
+ expect(retried.author).toMatchObject({
1116
+ displayName: "Alice",
1117
+ verified: true,
1118
+ });
1119
+ expect(comments).toHaveLength(1);
1120
+ expect(comments[0]!.deleted).not.toBe(true);
1121
+ expect(JSON.stringify(retried)).not.toContain("recoverable");
1122
+ await expect(
1123
+ module.createComment({
1124
+ ...intent,
1125
+ body: "Edited after lost reply",
1126
+ submissionKey: "j".repeat(43),
1127
+ }),
1128
+ ).rejects.toThrow("another Comment intent");
1129
+ expect(comments).toHaveLength(1);
1130
+ expect(
1131
+ (await module.listCommentThreads({ slug: post.slug })).items.flatMap(
1132
+ (thread) => thread.comments,
1133
+ ),
1134
+ ).toHaveLength(1);
1135
+ await module.deleteComment({ commentId: retried.id });
1136
+ await expect(module.createComment(intent)).rejects.toThrow();
1137
+ expect(comments).toHaveLength(1);
1138
+ });