create-feedbax 0.1.0 → 0.2.1

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 (251) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +3283 -22
  4. package/dist/index.js +3394 -160
  5. package/package.json +40 -14
  6. package/template/.feedbax-template.sha256 +1 -0
  7. package/template/.gitignore.template +6 -0
  8. package/template/.npmignore.template +5 -0
  9. package/template/.prettierignore.template +4 -0
  10. package/template/AGENTS.md +13 -0
  11. package/template/CONNECTED.md +404 -0
  12. package/template/LICENSE +201 -0
  13. package/template/README.md +56 -5
  14. package/template/components.json +25 -0
  15. package/template/eslint.config.mjs +48 -0
  16. package/template/migrations/0001_connected.sql +6 -0
  17. package/template/migrations/0002_participation.sql +8 -0
  18. package/template/migrations/0003_images.sql +3 -0
  19. package/template/migrations/0004_followed_status.sql +9 -0
  20. package/template/package.json +74 -14
  21. package/template/public/feedbax-mark.svg +4 -0
  22. package/template/public/robots.txt +3 -0
  23. package/template/src/authorized-draft-post.test.tsx +212 -0
  24. package/template/src/authorized-draft-post.tsx +205 -0
  25. package/template/src/authorized-draft-query.ts +74 -0
  26. package/template/src/browser-post-state.test.ts +120 -0
  27. package/template/src/browser-post-state.ts +134 -0
  28. package/template/src/browser-vote-state.test.ts +14 -0
  29. package/template/src/browser-vote-state.ts +27 -0
  30. package/template/src/capability-authorization-error.ts +3 -0
  31. package/template/src/changelog-page.test.tsx +139 -0
  32. package/template/src/changelog-page.tsx +228 -0
  33. package/template/src/changelog-query.ts +13 -0
  34. package/template/src/changelog-runtime.ts +27 -0
  35. package/template/src/cloudflare-turnstile.ts +31 -0
  36. package/template/src/cloudflare-workers-test.ts +3 -0
  37. package/template/src/cloudflare-workers.d.ts +24 -0
  38. package/template/src/comment-creation.test.tsx +311 -0
  39. package/template/src/comment-profile.ts +5 -0
  40. package/template/src/comment-thread-page.ts +28 -0
  41. package/template/src/components/command-palette-context.tsx +71 -0
  42. package/template/src/components/command-palette.tsx +275 -0
  43. package/template/src/components/connected-identity-provider.tsx +108 -0
  44. package/template/src/components/create-post-trigger.tsx +44 -0
  45. package/template/src/components/device-profile-provider.tsx +324 -0
  46. package/template/src/components/follow-post-button.tsx +89 -0
  47. package/template/src/components/header.tsx +97 -0
  48. package/template/src/components/image-attachments.tsx +187 -0
  49. package/template/src/components/loader.tsx +53 -0
  50. package/template/src/components/notification-inbox.tsx +232 -0
  51. package/template/src/components/portal-footer.tsx +14 -0
  52. package/template/src/components/profile-menu.tsx +119 -0
  53. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  54. package/template/src/components/ui/components/attachment.tsx +207 -0
  55. package/template/src/components/ui/components/avatar.tsx +107 -0
  56. package/template/src/components/ui/components/badge.tsx +52 -0
  57. package/template/src/components/ui/components/bubble.tsx +128 -0
  58. package/template/src/components/ui/components/button-group.tsx +87 -0
  59. package/template/src/components/ui/components/button.tsx +56 -0
  60. package/template/src/components/ui/components/card.tsx +103 -0
  61. package/template/src/components/ui/components/checkbox.tsx +26 -0
  62. package/template/src/components/ui/components/command.tsx +195 -0
  63. package/template/src/components/ui/components/dialog.tsx +154 -0
  64. package/template/src/components/ui/components/drawer.tsx +229 -0
  65. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  66. package/template/src/components/ui/components/empty.tsx +101 -0
  67. package/template/src/components/ui/components/field.tsx +236 -0
  68. package/template/src/components/ui/components/input-group.tsx +155 -0
  69. package/template/src/components/ui/components/input.tsx +20 -0
  70. package/template/src/components/ui/components/item.tsx +201 -0
  71. package/template/src/components/ui/components/kbd.tsx +26 -0
  72. package/template/src/components/ui/components/label.tsx +18 -0
  73. package/template/src/components/ui/components/marker.tsx +71 -0
  74. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  75. package/template/src/components/ui/components/message.tsx +92 -0
  76. package/template/src/components/ui/components/native-select.tsx +65 -0
  77. package/template/src/components/ui/components/popover.tsx +88 -0
  78. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  79. package/template/src/components/ui/components/radio-group.tsx +36 -0
  80. package/template/src/components/ui/components/scroll-area.tsx +51 -0
  81. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  82. package/template/src/components/ui/components/select.tsx +199 -0
  83. package/template/src/components/ui/components/separator.tsx +23 -0
  84. package/template/src/components/ui/components/sheet.tsx +135 -0
  85. package/template/src/components/ui/components/skeleton.tsx +13 -0
  86. package/template/src/components/ui/components/sonner.tsx +43 -0
  87. package/template/src/components/ui/components/spinner.tsx +16 -0
  88. package/template/src/components/ui/components/switch.tsx +29 -0
  89. package/template/src/components/ui/components/tabs.tsx +80 -0
  90. package/template/src/components/ui/components/textarea.tsx +18 -0
  91. package/template/src/components/ui/components/tooltip.tsx +64 -0
  92. package/template/src/components/ui/hooks/.gitkeep +0 -0
  93. package/template/src/components/ui/lib/utils.ts +6 -0
  94. package/template/src/components/ui/styles/globals.css +151 -0
  95. package/template/src/connected-identity.test.ts +144 -0
  96. package/template/src/connected-identity.ts +122 -0
  97. package/template/src/connected-participation.test.tsx +67 -0
  98. package/template/src/connected-runtime.test.ts +241 -0
  99. package/template/src/connected-runtime.ts +176 -0
  100. package/template/src/connected-social.test.tsx +158 -0
  101. package/template/src/create-post-overlay.tsx +82 -0
  102. package/template/src/draft-post-list.tsx +39 -0
  103. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  104. package/template/src/feedback-cache-invalidation.ts +98 -0
  105. package/template/src/feedback-runtime.ts +105 -0
  106. package/template/src/feedbax/changelog/index.test.ts +156 -0
  107. package/template/src/feedbax/changelog/index.ts +206 -0
  108. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  109. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  110. package/template/src/feedbax/config/index.test.ts +139 -0
  111. package/template/src/feedbax/config/index.ts +364 -0
  112. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  113. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  114. package/template/src/feedbax/feedback/connected.ts +215 -0
  115. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  116. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  117. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  118. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  119. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  120. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  121. package/template/src/feedbax/feedback/images.test.ts +223 -0
  122. package/template/src/feedbax/feedback/images.ts +324 -0
  123. package/template/src/feedbax/feedback/index.test.ts +900 -0
  124. package/template/src/feedbax/feedback/index.ts +996 -0
  125. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  126. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  127. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  128. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  129. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  130. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  131. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  132. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  133. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  134. package/template/src/feedbax/feedback/participation.ts +502 -0
  135. package/template/src/feedbax.test-fixture.ts +64 -0
  136. package/template/src/feedbax.ts +69 -0
  137. package/template/src/images-runtime.test.ts +133 -0
  138. package/template/src/images-runtime.ts +125 -0
  139. package/template/src/index.css +1795 -0
  140. package/template/src/lazy-create-post-overlay.tsx +6 -0
  141. package/template/src/lazy-post-detail-overlay.tsx +21 -0
  142. package/template/src/masked-post-link.tsx +27 -0
  143. package/template/src/overlay-post-detail.test.tsx +181 -0
  144. package/template/src/overlay-post-detail.tsx +43 -0
  145. package/template/src/participation-client.ts +63 -0
  146. package/template/src/participation-runtime.test.ts +35 -0
  147. package/template/src/participation-runtime.ts +255 -0
  148. package/template/src/portal-comments.test.ts +375 -0
  149. package/template/src/portal-comments.ts +403 -0
  150. package/template/src/portal-features.test.tsx +150 -0
  151. package/template/src/portal-feedback-form.tsx +530 -0
  152. package/template/src/portal-feedback-runtime.test.ts +32 -0
  153. package/template/src/portal-feedback-runtime.ts +58 -0
  154. package/template/src/portal-feedback-server-function.ts +96 -0
  155. package/template/src/portal-feedback-submission.test.ts +307 -0
  156. package/template/src/portal-feedback-submission.ts +224 -0
  157. package/template/src/portal-head.ts +58 -0
  158. package/template/src/portal-server-entry.ts +68 -0
  159. package/template/src/portal-voting.test.ts +106 -0
  160. package/template/src/portal-voting.ts +149 -0
  161. package/template/src/post-badges.tsx +27 -0
  162. package/template/src/post-creation-flow.test.tsx +230 -0
  163. package/template/src/post-creation-flow.tsx +88 -0
  164. package/template/src/post-detail-content.test.tsx +233 -0
  165. package/template/src/post-detail-content.tsx +162 -0
  166. package/template/src/post-detail-overlay.test.tsx +114 -0
  167. package/template/src/post-detail-overlay.tsx +67 -0
  168. package/template/src/post-detail-query.ts +34 -0
  169. package/template/src/post-feed-controls.test.tsx +239 -0
  170. package/template/src/post-feed-controls.tsx +298 -0
  171. package/template/src/post-presentation.ts +37 -0
  172. package/template/src/post-queries.test.ts +158 -0
  173. package/template/src/post-queries.ts +26 -0
  174. package/template/src/post-results.tsx +165 -0
  175. package/template/src/public-cache-policy.test.ts +148 -0
  176. package/template/src/public-cache-policy.ts +119 -0
  177. package/template/src/public-cache-tags.ts +7 -0
  178. package/template/src/public-changelog-page.ts +15 -0
  179. package/template/src/public-changelog-server-function.ts +27 -0
  180. package/template/src/public-date.ts +11 -0
  181. package/template/src/public-failure-state.test.tsx +69 -0
  182. package/template/src/public-feedback-index.tsx +94 -0
  183. package/template/src/public-feedback-page.test.tsx +355 -0
  184. package/template/src/public-feedback-page.ts +86 -0
  185. package/template/src/public-feedback-server-function.ts +22 -0
  186. package/template/src/public-portal-error.tsx +27 -0
  187. package/template/src/public-post-detail-skeleton.tsx +54 -0
  188. package/template/src/public-post-detail.tsx +772 -0
  189. package/template/src/public-post-page.test.tsx +173 -0
  190. package/template/src/public-post-page.ts +15 -0
  191. package/template/src/public-post-route.test.tsx +250 -0
  192. package/template/src/public-post-route.tsx +158 -0
  193. package/template/src/public-post-server-function.ts +48 -0
  194. package/template/src/public-post-unavailable.tsx +10 -0
  195. package/template/src/public-roadmap-page.test.tsx +139 -0
  196. package/template/src/public-roadmap-page.ts +24 -0
  197. package/template/src/public-roadmap-route.test.tsx +254 -0
  198. package/template/src/public-roadmap-route.tsx +50 -0
  199. package/template/src/public-roadmap-server-function.ts +44 -0
  200. package/template/src/public-roadmap-view.tsx +411 -0
  201. package/template/src/public-shell.test.tsx +544 -0
  202. package/template/src/roadmap-query.ts +21 -0
  203. package/template/src/root-branding.test.ts +53 -0
  204. package/template/src/routeTree.gen.ts +177 -0
  205. package/template/src/routed-post-creation-form.tsx +28 -0
  206. package/template/src/router.tsx +27 -0
  207. package/template/src/routes/__root.tsx +114 -0
  208. package/template/src/routes/changelog.tsx +39 -0
  209. package/template/src/routes/email-preferences.tsx +77 -0
  210. package/template/src/routes/index.tsx +61 -0
  211. package/template/src/routes/p.$slug.tsx +16 -0
  212. package/template/src/routes/roadmap.tsx +11 -0
  213. package/template/src/routes/submit.tsx +28 -0
  214. package/template/src/safe-public-failure.ts +47 -0
  215. package/template/src/server.ts +151 -0
  216. package/template/src/similar-posts.tsx +45 -0
  217. package/template/src/submit-route.test.tsx +134 -0
  218. package/template/src/trusted-feedback-handler.test.ts +425 -0
  219. package/template/src/trusted-feedback-handler.ts +181 -0
  220. package/template/src/trusted-feedback-runtime.ts +17 -0
  221. package/template/src/turnstile-configuration.test.ts +37 -0
  222. package/template/src/turnstile-configuration.ts +19 -0
  223. package/template/src/use-media-query.ts +17 -0
  224. package/template/src/vote-toggle.test.tsx +171 -0
  225. package/template/src/vote-toggle.tsx +244 -0
  226. package/template/tsconfig.json +45 -0
  227. package/template/vite.config.ts +20 -0
  228. package/template/vitest.config.ts +4 -0
  229. package/template/vitest.setup.ts +3 -0
  230. package/template/wrangler.jsonc +35 -0
  231. package/dist/cli.d.ts +0 -3
  232. package/dist/cli.d.ts.map +0 -1
  233. package/dist/cli.js.map +0 -1
  234. package/dist/index.d.ts +0 -21
  235. package/dist/index.d.ts.map +0 -1
  236. package/dist/index.js.map +0 -1
  237. package/template/.env.example +0 -4
  238. package/template/apps/portal/components.json +0 -20
  239. package/template/apps/portal/package.json +0 -31
  240. package/template/apps/portal/src/lib/utils.ts +0 -6
  241. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  242. package/template/apps/portal/src/router.tsx +0 -10
  243. package/template/apps/portal/src/routes/__root.tsx +0 -31
  244. package/template/apps/portal/src/routes/index.tsx +0 -35
  245. package/template/apps/portal/src/styles.css +0 -91
  246. package/template/apps/portal/tsconfig.json +0 -15
  247. package/template/apps/portal/vite.config.ts +0 -13
  248. package/template/feedbax.config.ts +0 -13
  249. package/template/feedbax.jsonc +0 -11
  250. package/template/pnpm-workspace.yaml +0 -3
  251. package/template/turbo.json +0 -8
@@ -0,0 +1,272 @@
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 { createConnectedStore } from "./connected";
6
+
7
+ let runtime: Miniflare | undefined;
8
+ afterEach(async () => {
9
+ await runtime?.dispose();
10
+ });
11
+ async function migratedDatabase() {
12
+ runtime = new Miniflare({
13
+ modules: true,
14
+ script: "export default {fetch(){return new Response('ok')}}",
15
+ compatibilityDate: "2026-07-30",
16
+ d1Databases: ["DB"],
17
+ });
18
+ const binding = await runtime.getD1Database("DB");
19
+ await binding.exec(
20
+ (await readFile(
21
+ existsSync("apps/portal/migrations/0001_connected.sql")
22
+ ? "apps/portal/migrations/0001_connected.sql"
23
+ : "migrations/0001_connected.sql",
24
+ "utf8",
25
+ )) +
26
+ "\n" +
27
+ (await readFile(
28
+ existsSync("apps/portal/migrations/0002_participation.sql")
29
+ ? "apps/portal/migrations/0002_participation.sql"
30
+ : "migrations/0002_participation.sql",
31
+ "utf8",
32
+ )),
33
+ );
34
+ return binding;
35
+ }
36
+
37
+ it("persists one Vote per verified Participant across concurrent retries and browsers", async () => {
38
+ const binding = await migratedDatabase();
39
+ const store = createConnectedStore(binding);
40
+ await store.acceptIdentity({
41
+ subject: "alice",
42
+ displayName: "Alice",
43
+ nonce: "once",
44
+ sessionHash: "session",
45
+ expiresAt: Date.now() + 60000,
46
+ });
47
+ await Promise.all(
48
+ Array.from({ length: 8 }, () => store.setVote("post-1", "alice", true)),
49
+ );
50
+ expect(await store.voteState("post-1", "alice")).toEqual({
51
+ voteCount: 1,
52
+ voted: true,
53
+ });
54
+ const secondBrowser = createConnectedStore(binding);
55
+ expect(await secondBrowser.voteState("post-1", "alice")).toEqual({
56
+ voteCount: 1,
57
+ voted: true,
58
+ });
59
+ await secondBrowser.setVote("post-1", "alice", false);
60
+ await secondBrowser.setVote("post-1", "alice", false);
61
+ expect(await store.voteState("post-1", "alice")).toEqual({
62
+ voteCount: 0,
63
+ voted: false,
64
+ });
65
+ });
66
+ it("rejects replayed handoffs without replacing an existing Participant or creating a session", async () => {
67
+ const binding = await migratedDatabase();
68
+ const store = createConnectedStore(binding);
69
+ const input = {
70
+ subject: "alice",
71
+ displayName: "Alice",
72
+ nonce: "once",
73
+ sessionHash: "session",
74
+ expiresAt: 2000,
75
+ };
76
+ await store.acceptIdentity(input);
77
+ await expect(
78
+ store.acceptIdentity({
79
+ ...input,
80
+ displayName: "Changed",
81
+ sessionHash: "replay",
82
+ }),
83
+ ).rejects.toThrow();
84
+ expect(await store.session("session", 1500)).toEqual({
85
+ subject: "alice",
86
+ displayName: "Alice",
87
+ });
88
+ expect(await store.session("replay", 1500)).toBeUndefined();
89
+ expect(await store.session("session", 2000)).toBeUndefined();
90
+ await store.logout("session");
91
+ expect(await store.session("session", 1500)).toBeUndefined();
92
+ });
93
+
94
+ it("ranks by verified Votes and attributes only Comments with identity evidence", async () => {
95
+ const binding = await migratedDatabase();
96
+ const store = createConnectedStore(binding);
97
+ const alice = { subject: "alice", displayName: "Alice" };
98
+ await store.acceptIdentity({
99
+ ...alice,
100
+ nonce: "a",
101
+ sessionHash: "a",
102
+ expiresAt: Date.now() + 60000,
103
+ });
104
+ const { createFeedbackModule, createConnectedFeedbackModule } =
105
+ await import("./index");
106
+ const base = createFeedbackModule({
107
+ initialItems: [
108
+ {
109
+ id: "p1",
110
+ slug: "old-popular",
111
+ title: "Old",
112
+ description: "Old",
113
+ type: "Feature Request",
114
+ status: "Planned",
115
+ published: true,
116
+ createdAt: new Date(1000),
117
+ updatedAt: new Date(1000),
118
+ voteCount: 500,
119
+ },
120
+ {
121
+ id: "p2",
122
+ slug: "new",
123
+ title: "New",
124
+ description: "New",
125
+ type: "Feature Request",
126
+ status: "Planned",
127
+ published: true,
128
+ createdAt: new Date(2000),
129
+ updatedAt: new Date(2000),
130
+ voteCount: 0,
131
+ },
132
+ {
133
+ id: "private",
134
+ slug: "private",
135
+ title: "Private",
136
+ description: "Private",
137
+ type: "Feature Request",
138
+ status: "New",
139
+ published: false,
140
+ createdAt: new Date(2000),
141
+ updatedAt: new Date(2000),
142
+ voteCount: 0,
143
+ },
144
+ ],
145
+ initialComments: [
146
+ {
147
+ id: "old",
148
+ postId: "p1",
149
+ discussionId: "thread",
150
+ body: "Old",
151
+ author: { kind: "participant", displayName: "Unverified Alice" },
152
+ createdAt: new Date(1000),
153
+ resolved: false,
154
+ },
155
+ {
156
+ id: "team",
157
+ postId: "p1",
158
+ discussionId: "thread",
159
+ body: "Team",
160
+ author: { kind: "product-team", displayName: "Product Team" },
161
+ createdAt: new Date(2000),
162
+ resolved: false,
163
+ },
164
+ {
165
+ id: "resolved",
166
+ postId: "p1",
167
+ discussionId: "resolved",
168
+ body: "Hidden",
169
+ author: { kind: "product-team", displayName: "Product Team" },
170
+ createdAt: new Date(2000),
171
+ resolved: true,
172
+ },
173
+ ],
174
+ });
175
+ const signedIn = createConnectedFeedbackModule(base, store, alice);
176
+ const publicRead = createConnectedFeedbackModule(base, store);
177
+ expect((await publicRead.getPublicPost("old-popular"))?.voteCount).toBe(0);
178
+ await signedIn.changeVote({ slug: "old-popular", intention: "add" });
179
+ expect(
180
+ (await publicRead.listPublicPosts({ sort: "top" })).items.map(
181
+ (p) => p.slug,
182
+ ),
183
+ ).toEqual(["old-popular", "new"]);
184
+ expect(
185
+ (await publicRead.listPublicPosts({ sort: "new" })).items.map(
186
+ (p) => p.slug,
187
+ ),
188
+ ).toEqual(["new", "old-popular"]);
189
+ await expect(
190
+ signedIn.changeVote({ slug: "private", intention: "add" }),
191
+ ).rejects.toThrow();
192
+ await expect(
193
+ publicRead.changeVote({ slug: "new", intention: "add" }),
194
+ ).rejects.toThrow("Sign in");
195
+ const comment = await signedIn.createComment({
196
+ slug: "old-popular",
197
+ body: "Hello",
198
+ displayName: "Spoofed Admin",
199
+ });
200
+ expect(comment.author).toEqual({
201
+ kind: "participant",
202
+ displayName: "Alice",
203
+ verified: true,
204
+ });
205
+ const comments = (
206
+ await publicRead.listCommentThreads({ slug: "old-popular" })
207
+ ).items.flatMap((t) => t.comments);
208
+ expect(comments.find((c) => c.id === "old")?.author).toEqual({
209
+ kind: "participant",
210
+ displayName: "Unverified Alice",
211
+ });
212
+ expect(comments.find((c) => c.id === "team")?.author).toEqual({
213
+ kind: "product-team",
214
+ displayName: "Product Team",
215
+ });
216
+ expect(comments.some((c) => c.id === "resolved")).toBe(false);
217
+ expect(comments.find((c) => c.id === comment.id)?.author).toEqual(
218
+ comment.author,
219
+ );
220
+ expect(JSON.stringify(comments)).not.toContain('"subject"');
221
+ expect(
222
+ (await signedIn.editComment({ commentId: comment.id, body: "Edited" }))
223
+ .author,
224
+ ).toEqual(comment.author);
225
+ const other = createConnectedFeedbackModule(base, store, {
226
+ subject: "bob",
227
+ displayName: "Bob",
228
+ });
229
+ await expect(
230
+ other.editComment({ commentId: comment.id, body: "Impersonated" }),
231
+ ).rejects.toThrow();
232
+ });
233
+
234
+ it("keeps Top pagination at 25 Posts and binds cursors to their filters", async () => {
235
+ const store = createConnectedStore(await migratedDatabase());
236
+ const { createFeedbackModule, createConnectedFeedbackModule } =
237
+ await import("./index");
238
+ const base = createFeedbackModule({
239
+ initialItems: Array.from({ length: 26 }, (_, i) => ({
240
+ id: `id-${i}`,
241
+ slug: `post-${i}`,
242
+ title: `Post ${i}`,
243
+ description: "Description",
244
+ type: "Feature Request",
245
+ status: "Planned",
246
+ published: true,
247
+ createdAt: new Date(i * 1000),
248
+ updatedAt: new Date(i * 1000),
249
+ voteCount: 0,
250
+ })),
251
+ });
252
+ const feedback = createConnectedFeedbackModule(base, store);
253
+ const first = await feedback.listPublicPosts({ sort: "top" });
254
+ expect(first.items).toHaveLength(25);
255
+ expect(first.nextCursor).toBeTruthy();
256
+ const second = await feedback.listPublicPosts({
257
+ sort: "top",
258
+ cursor: first.nextCursor,
259
+ });
260
+ expect(second.items).toHaveLength(1);
261
+ expect(second.nextCursor).toBeUndefined();
262
+ expect(
263
+ new Set([...first.items, ...second.items].map((post) => post.slug)).size,
264
+ ).toBe(26);
265
+ await expect(
266
+ feedback.listPublicPosts({
267
+ sort: "top",
268
+ type: "Bug Report",
269
+ cursor: first.nextCursor,
270
+ }),
271
+ ).rejects.toThrow();
272
+ });
@@ -0,0 +1,215 @@
1
+ import type { D1Database } from "@cloudflare/workers-types";
2
+ import { and, count, eq, gt, inArray } from "drizzle-orm";
3
+ import { drizzle } from "drizzle-orm/d1";
4
+ import {
5
+ integer,
6
+ primaryKey,
7
+ sqliteTable,
8
+ text,
9
+ } from "drizzle-orm/sqlite-core";
10
+
11
+ export type ConnectedDatabase = D1Database;
12
+ export interface VerifiedParticipant {
13
+ subject: string;
14
+ displayName: string;
15
+ }
16
+ const participants = sqliteTable("participants", {
17
+ subject: text("subject").primaryKey(),
18
+ displayName: text("display_name").notNull(),
19
+ });
20
+ const contacts = sqliteTable("participant_contacts", {
21
+ subject: text("subject").primaryKey(),
22
+ email: text("email"),
23
+ emailEnabled: integer("email_enabled").notNull().default(0),
24
+ });
25
+ const nonces = sqliteTable("identity_nonces", {
26
+ nonce: text("nonce").primaryKey(),
27
+ expiresAt: integer("expires_at").notNull(),
28
+ });
29
+ const sessions = sqliteTable("participant_sessions", {
30
+ tokenHash: text("token_hash").primaryKey(),
31
+ subject: text("subject")
32
+ .notNull()
33
+ .references(() => participants.subject),
34
+ expiresAt: integer("expires_at").notNull(),
35
+ });
36
+ const votes = sqliteTable(
37
+ "participant_votes",
38
+ {
39
+ postSlug: text("post_slug").notNull(),
40
+ subject: text("subject")
41
+ .notNull()
42
+ .references(() => participants.subject),
43
+ },
44
+ (t) => [primaryKey({ columns: [t.postSlug, t.subject] })],
45
+ );
46
+ const attribution = sqliteTable("comment_attribution", {
47
+ commentId: text("comment_id").primaryKey(),
48
+ subject: text("subject")
49
+ .notNull()
50
+ .references(() => participants.subject),
51
+ displayName: text("display_name").notNull(),
52
+ });
53
+
54
+ export function createConnectedStore(binding: D1Database) {
55
+ // Without read-replication sessions, D1 routes these queries to its primary.
56
+ const db = drizzle(binding);
57
+ return {
58
+ async acceptIdentity(
59
+ input: VerifiedParticipant & {
60
+ email?: string;
61
+ nonce: string;
62
+ challenge?: string;
63
+ sessionHash: string;
64
+ expiresAt: number;
65
+ },
66
+ ) {
67
+ // A nonce collision rolls the entire D1 batch back, including session creation.
68
+ await db.batch([
69
+ db.insert(nonces).values([
70
+ { nonce: `token:${input.nonce}`, expiresAt: input.expiresAt },
71
+ ...(input.challenge
72
+ ? [
73
+ {
74
+ nonce: `browser:${input.challenge}`,
75
+ expiresAt: input.expiresAt,
76
+ },
77
+ ]
78
+ : []),
79
+ ]),
80
+ db
81
+ .insert(participants)
82
+ .values({ subject: input.subject, displayName: input.displayName })
83
+ .onConflictDoUpdate({
84
+ target: participants.subject,
85
+ set: { displayName: input.displayName },
86
+ }),
87
+ db
88
+ .insert(contacts)
89
+ .values({ subject: input.subject, email: input.email ?? null })
90
+ .onConflictDoUpdate({
91
+ target: contacts.subject,
92
+ set: { email: input.email ?? null },
93
+ }),
94
+ db.insert(sessions).values({
95
+ tokenHash: input.sessionHash,
96
+ subject: input.subject,
97
+ expiresAt: input.expiresAt,
98
+ }),
99
+ ]);
100
+ },
101
+ async session(
102
+ tokenHash: string,
103
+ now = Date.now(),
104
+ ): Promise<VerifiedParticipant | undefined> {
105
+ return db
106
+ .select({
107
+ subject: participants.subject,
108
+ displayName: participants.displayName,
109
+ })
110
+ .from(sessions)
111
+ .innerJoin(participants, eq(participants.subject, sessions.subject))
112
+ .where(
113
+ and(eq(sessions.tokenHash, tokenHash), gt(sessions.expiresAt, now)),
114
+ )
115
+ .get();
116
+ },
117
+ async logout(tokenHash: string) {
118
+ await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
119
+ },
120
+ async setVote(postSlug: string, subject: string, voted: boolean) {
121
+ if (voted)
122
+ await db
123
+ .insert(votes)
124
+ .values({ postSlug, subject })
125
+ .onConflictDoNothing();
126
+ else
127
+ await db
128
+ .delete(votes)
129
+ .where(and(eq(votes.postSlug, postSlug), eq(votes.subject, subject)));
130
+ },
131
+ async voteState(postSlug: string, subject?: string) {
132
+ const [total, own] = await db.batch([
133
+ db
134
+ .select({ value: count() })
135
+ .from(votes)
136
+ .where(eq(votes.postSlug, postSlug)),
137
+ db
138
+ .select({ subject: votes.subject })
139
+ .from(votes)
140
+ .where(
141
+ and(eq(votes.postSlug, postSlug), eq(votes.subject, subject ?? "")),
142
+ )
143
+ .limit(1),
144
+ ]);
145
+ return { voteCount: total[0]?.value ?? 0, voted: own.length > 0 };
146
+ },
147
+ async voteCounts(slugs: string[]) {
148
+ const result = new Map<string, number>();
149
+ for (let offset = 0; offset < slugs.length; offset += 80) {
150
+ const rows = await db
151
+ .select({ slug: votes.postSlug, total: count() })
152
+ .from(votes)
153
+ .where(inArray(votes.postSlug, slugs.slice(offset, offset + 80)))
154
+ .groupBy(votes.postSlug);
155
+ for (const row of rows) result.set(row.slug, row.total);
156
+ }
157
+ return result;
158
+ },
159
+ async commentSubject(commentId: string) {
160
+ return (
161
+ await db
162
+ .select({ subject: attribution.subject })
163
+ .from(attribution)
164
+ .where(eq(attribution.commentId, commentId))
165
+ .get()
166
+ )?.subject;
167
+ },
168
+ async attributeComment(
169
+ commentId: string,
170
+ participant: VerifiedParticipant,
171
+ ) {
172
+ await db.insert(attribution).values({
173
+ commentId,
174
+ subject: participant.subject,
175
+ displayName: participant.displayName,
176
+ });
177
+ },
178
+ async commentAuthors(ids: string[]) {
179
+ const result = new Map<
180
+ string,
181
+ { kind: "participant"; displayName: string; verified: true }
182
+ >();
183
+ for (let offset = 0; offset < ids.length; offset += 80) {
184
+ const rows = await db
185
+ .select()
186
+ .from(attribution)
187
+ .where(
188
+ inArray(attribution.commentId, ids.slice(offset, offset + 80)),
189
+ );
190
+ for (const row of rows)
191
+ result.set(row.commentId, {
192
+ kind: "participant",
193
+ displayName: row.displayName,
194
+ verified: true,
195
+ });
196
+ }
197
+ return result;
198
+ },
199
+ async commentAuthor(commentId: string) {
200
+ const row = await db
201
+ .select({ displayName: attribution.displayName })
202
+ .from(attribution)
203
+ .where(eq(attribution.commentId, commentId))
204
+ .get();
205
+ return row
206
+ ? {
207
+ kind: "participant" as const,
208
+ displayName: row.displayName,
209
+ verified: true as const,
210
+ }
211
+ : undefined;
212
+ },
213
+ };
214
+ }
215
+ export type ConnectedStore = ReturnType<typeof createConnectedStore>;
@@ -0,0 +1,65 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { expect, it } from "vitest";
3
+ import { createFetchMock, Miniflare } from "miniflare";
4
+ import ts from "typescript";
5
+
6
+ it.each([200, 302])(
7
+ "sends email inside the actual Workers fetch runtime and rejects provider redirects (%s)",
8
+ async (status) => {
9
+ const source = await readFile(
10
+ new URL("./email-delivery.ts", import.meta.url),
11
+ "utf8",
12
+ );
13
+ const sender = ts.transpileModule(source, {
14
+ compilerOptions: {
15
+ target: ts.ScriptTarget.ES2022,
16
+ module: ts.ModuleKind.ESNext,
17
+ },
18
+ }).outputText;
19
+ const network = createFetchMock();
20
+ network.disableNetConnect();
21
+ network
22
+ .get("https://api.resend.com")
23
+ .intercept({ path: "/emails", method: "POST" })
24
+ .reply(status, "{}", {
25
+ headers:
26
+ status === 302 ? { location: "https://unexpected.example/leak" } : {},
27
+ });
28
+ let redirected = 0;
29
+ network
30
+ .get("https://unexpected.example")
31
+ .intercept({ path: "/leak", method: "GET" })
32
+ .reply(() => {
33
+ redirected++;
34
+ return { statusCode: 200, data: "{}" };
35
+ });
36
+ const runtime = new Miniflare({
37
+ compatibilityDate: "2026-07-30",
38
+ modulesRoot: "/email-worker",
39
+ fetchMock: network,
40
+ modules: [
41
+ {
42
+ type: "ESModule",
43
+ path: "/email-worker/entry.mjs",
44
+ contents: `import {createEmailSender} from './sender.mjs'; export default {async fetch(){ try {await createEmailSender({provider:'resend',token:'test-provider-key'})({from:'updates@example.com',to:['recipient@example.com'],subject:'Update',text:'A Post changed.',headers:{}},'test-stable-key');return new Response('accepted');}catch(error){return new Response(error.message,{status:502});}}};`,
45
+ },
46
+ {
47
+ type: "ESModule",
48
+ path: "/email-worker/sender.mjs",
49
+ contents: sender,
50
+ },
51
+ ],
52
+ });
53
+ try {
54
+ const response = await runtime.dispatchFetch(
55
+ "https://worker.example/send",
56
+ );
57
+ expect(await response.text()).not.toContain("test-provider-key");
58
+ expect(response.status).toBe(status === 200 ? 200 : 502);
59
+ expect(redirected).toBe(0);
60
+ } finally {
61
+ await runtime.dispose();
62
+ await network.close();
63
+ }
64
+ },
65
+ );
@@ -0,0 +1,66 @@
1
+ export interface NotificationEmail {
2
+ from: string;
3
+ to: string[];
4
+ subject: string;
5
+ text: string;
6
+ headers: Record<string, string>;
7
+ }
8
+ export type EmailSender = (
9
+ message: NotificationEmail,
10
+ deliveryKey: string,
11
+ ) => Promise<void>;
12
+ export type CloudflareEmailBinding = {
13
+ send(message: NotificationEmail): Promise<unknown>;
14
+ };
15
+
16
+ /** The custom endpoint accepts the same JSON message as Resend and must honor Idempotency-Key. */
17
+ export function createEmailSender(
18
+ options:
19
+ | { provider: "cloudflare"; binding: CloudflareEmailBinding }
20
+ | { provider: "resend"; token: string; request?: typeof fetch }
21
+ | {
22
+ provider: "custom";
23
+ url: string;
24
+ token: string;
25
+ request?: typeof fetch;
26
+ },
27
+ ): EmailSender {
28
+ if (options.provider === "cloudflare")
29
+ return async (message) => {
30
+ await options.binding.send(message);
31
+ };
32
+ const url =
33
+ options.provider === "resend"
34
+ ? "https://api.resend.com/emails"
35
+ : options.url;
36
+ const parsed = new URL(url);
37
+ if (
38
+ parsed.protocol !== "https:" ||
39
+ parsed.username ||
40
+ parsed.password ||
41
+ parsed.hash
42
+ )
43
+ throw new Error(
44
+ "Email sender requires an HTTPS endpoint without credentials or fragments.",
45
+ );
46
+ if (!options.token.trim()) throw new Error("Email sender secret is missing.");
47
+ return async (message, deliveryKey) => {
48
+ const response = await (options.request ?? fetch)(url, {
49
+ method: "POST",
50
+ // Workers supports manual/follow; manual also prevents forwarding credentials.
51
+ redirect: "manual",
52
+ signal: AbortSignal.timeout(10000),
53
+ headers: {
54
+ Authorization: `Bearer ${options.token}`,
55
+ "Content-Type": "application/json",
56
+ "Idempotency-Key": deliveryKey,
57
+ },
58
+ body: JSON.stringify(message),
59
+ });
60
+ if (!response.ok) {
61
+ await response.body?.cancel();
62
+ throw new Error("Email provider did not accept this delivery.");
63
+ }
64
+ await response.body?.cancel();
65
+ };
66
+ }
@@ -0,0 +1,43 @@
1
+ import type { Post, PublicPostQuery, RoadmapStatus } from "./index";
2
+
3
+ export type PostSource = "Portal" | "API";
4
+
5
+ export type StoredPost = Post &
6
+ Partial<{
7
+ browserCapabilityHash: string;
8
+ source: PostSource;
9
+ externalId: string;
10
+ }> &
11
+ Record<string, unknown>;
12
+
13
+ export type NewStoredPost = Omit<Post, "id"> &
14
+ Partial<{
15
+ browserCapabilityHash: string;
16
+ source: PostSource;
17
+ externalId: string;
18
+ }> &
19
+ Record<string, unknown>;
20
+
21
+ export interface FeedbackStorage {
22
+ create(item: NewStoredPost): Promise<StoredPost>;
23
+ save(item: StoredPost): Promise<StoredPost>;
24
+ updateVoteCount(item: StoredPost, voteCount: number): Promise<StoredPost>;
25
+ find(id: string): Promise<StoredPost | undefined>;
26
+ findBySlug(slug: string): Promise<StoredPost | undefined>;
27
+ findPublicBySlug(slug: string): Promise<StoredPost | undefined>;
28
+ findByExternalId(externalId: string): Promise<StoredPost | undefined>;
29
+ list(): Promise<StoredPost[]>;
30
+ listPublic(query: PublicPostQuery): Promise<{
31
+ items: StoredPost[];
32
+ nextCursor?: string;
33
+ }>;
34
+ listPublicRoadmap(query: {
35
+ status: RoadmapStatus;
36
+ cursor?: string;
37
+ }): Promise<{
38
+ items: StoredPost[];
39
+ nextCursor?: string;
40
+ totalCount: number;
41
+ }>;
42
+ remove(id: string): Promise<void>;
43
+ }