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,425 @@
1
+ import {
2
+ createFeedbackModule,
3
+ createNotionFeedbackModule,
4
+ type FeedbackPropertyIds,
5
+ } from "@feedbax/feedback";
6
+ import { describe, expect, it, vi } from "vitest";
7
+
8
+ import { createTrustedFeedbackHandler } from "./trusted-feedback-handler";
9
+ import { createPortalServerEntry } from "./portal-server-entry";
10
+
11
+ const propertyIds: FeedbackPropertyIds = {
12
+ title: "title-id",
13
+ slug: "slug-id",
14
+ description: "description-id",
15
+ type: "type-id",
16
+ status: "status-id",
17
+ published: "published-id",
18
+ voteCount: "vote-count-id",
19
+ teamResponse: "team-response-id",
20
+ releaseNotesUrl: "release-notes-url-id",
21
+ submitterName: "submitter-name-id",
22
+ submitterEmail: "submitter-email-id",
23
+ source: "source-id",
24
+ externalId: "external-id",
25
+ editTokenHash: "edit-token-hash-id",
26
+ createdAt: "created-at-id",
27
+ updatedAt: "updated-at-id",
28
+ };
29
+
30
+ describe("trusted feedback Worker handler", () => {
31
+ it("redirects equivalent public filter URLs to one canonical cache identity", async () => {
32
+ const applicationHandler = vi.fn(async () => new Response("public"));
33
+ const server = createPortalServerEntry({
34
+ trustedFeedbackHandler: vi.fn(),
35
+ applicationHandler,
36
+ });
37
+
38
+ const response = await server.fetch(
39
+ new Request(
40
+ "https://feedback.example.com/?type=Bug%20Report&status=New&ignored=value",
41
+ ),
42
+ );
43
+
44
+ expect(response.status).toBe(308);
45
+ expect(response.headers.get("location")).toBe(
46
+ "https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=2",
47
+ );
48
+ expect(applicationHandler).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it("serves minimal deployment health without invoking application handlers", async () => {
52
+ const server = createPortalServerEntry({
53
+ trustedFeedbackHandler: async () => {
54
+ throw new Error("Health dispatch reached the trusted handler.");
55
+ },
56
+ });
57
+
58
+ const response = await server.fetch(
59
+ new Request("https://feedback.example.com/health"),
60
+ );
61
+
62
+ expect(response.status).toBe(200);
63
+ expect(response.headers.get("cache-control")).toBe("no-store");
64
+ await expect(response.json()).resolves.toEqual({ status: "ok" });
65
+ });
66
+
67
+ it("serves the trusted POST contract through the production server-entry dispatch", async () => {
68
+ const server = createPortalServerEntry({
69
+ trustedFeedbackHandler: createTrustedFeedbackHandler({
70
+ ...trustedHandlerSecurity("correct-api-key"),
71
+ feedback: createFeedbackModule(),
72
+ }),
73
+ });
74
+
75
+ const response = await server.fetch(
76
+ trustedRequest({}, "product-a:feedback-123"),
77
+ );
78
+
79
+ expect(response.status).toBe(200);
80
+ const body = await response.json();
81
+ expect(body).toMatchObject({
82
+ title: "Keyboard navigation",
83
+ description: "Let users navigate without a mouse.",
84
+ type: "Feature Request",
85
+ status: "New",
86
+ });
87
+ expect(body).not.toHaveProperty("published");
88
+ });
89
+
90
+ it("rejects missing and invalid Bearer keys before any Notion request", async () => {
91
+ const notionRequest = vi.fn<typeof fetch>();
92
+ const handler = createTrustedFeedbackHandler({
93
+ ...trustedHandlerSecurity("correct-api-key"),
94
+ feedback: createNotionFeedbackModule({
95
+ token: "notion-token",
96
+ dataSourceId: "feedback-data-source",
97
+ propertyIds,
98
+ request: notionRequest,
99
+ }),
100
+ });
101
+ const body = JSON.stringify({
102
+ title: "Keyboard navigation",
103
+ description: "Let users navigate without a mouse.",
104
+ type: "Feature Request",
105
+ });
106
+
107
+ const missing = await handler(
108
+ new Request("https://feedback.example.com/api/v1/posts", {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body,
112
+ }),
113
+ );
114
+ const invalid = await handler(
115
+ new Request("https://feedback.example.com/api/v1/posts", {
116
+ method: "POST",
117
+ headers: {
118
+ Authorization: "Bearer wrong-api-key",
119
+ "Content-Type": "application/json",
120
+ },
121
+ body,
122
+ }),
123
+ );
124
+
125
+ expect(missing.status).toBe(401);
126
+ expect(invalid.status).toBe(401);
127
+ await expect(missing.json()).resolves.toEqual({
128
+ error: "Unauthorized trusted submission.",
129
+ });
130
+ await expect(invalid.json()).resolves.toEqual({
131
+ error: "Unauthorized trusted submission.",
132
+ });
133
+ expect(notionRequest).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("requires and honors Idempotency-Key by returning the original Post", async () => {
137
+ const page = notionPage({
138
+ title: "Keyboard navigation",
139
+ description: "Let users navigate without a mouse.",
140
+ type: "Feature Request",
141
+ externalId: "product-a:feedback-123",
142
+ });
143
+ const notionRequest = vi
144
+ .fn<typeof fetch>()
145
+ .mockResolvedValueOnce(
146
+ Response.json({ results: [], has_more: false, next_cursor: null }),
147
+ )
148
+ .mockResolvedValueOnce(
149
+ Response.json({ results: [], has_more: false, next_cursor: null }),
150
+ )
151
+ .mockResolvedValueOnce(Response.json(page))
152
+ .mockResolvedValueOnce(
153
+ Response.json({ results: [page], has_more: false, next_cursor: null }),
154
+ );
155
+ const handler = createTrustedFeedbackHandler({
156
+ ...trustedHandlerSecurity("correct-api-key"),
157
+ feedback: createNotionFeedbackModule({
158
+ token: "notion-token",
159
+ dataSourceId: "feedback-data-source",
160
+ propertyIds,
161
+ request: notionRequest,
162
+ }),
163
+ });
164
+
165
+ const missingKey = await handler(trustedRequest({}));
166
+ const first = await handler(trustedRequest({}, "product-a:feedback-123"));
167
+ const retried = await handler(
168
+ trustedRequest(
169
+ {
170
+ title: "A changed retry must not overwrite the original",
171
+ type: "Bug Report",
172
+ },
173
+ "product-a:feedback-123",
174
+ ),
175
+ );
176
+
177
+ expect(missingKey.status).toBe(400);
178
+ await expect(missingKey.json()).resolves.toEqual({
179
+ error: "Idempotency-Key header is required.",
180
+ });
181
+ expect(first.status).toBe(200);
182
+ expect(retried.status).toBe(200);
183
+ const firstBody = await first.json();
184
+ const retriedBody = await retried.json();
185
+ expect(retriedBody).toEqual(firstBody);
186
+ expect(firstBody).not.toHaveProperty("published");
187
+ expect(firstBody).not.toHaveProperty("submitter");
188
+ expect(notionRequest).toHaveBeenCalledTimes(4);
189
+ expect(
190
+ notionRequest.mock.calls.filter(([url]) =>
191
+ String(url).endsWith("/pages"),
192
+ ),
193
+ ).toHaveLength(1);
194
+ const [lookupUrl, lookupInit] = notionRequest.mock.calls[0]!;
195
+ expect(String(lookupUrl)).toBe(
196
+ "https://api.notion.com/v1/data_sources/feedback-data-source/query",
197
+ );
198
+ expect(JSON.parse(String(lookupInit?.body))).toEqual({
199
+ page_size: 1,
200
+ filter: {
201
+ property: "external-id",
202
+ rich_text: { equals: "product-a:feedback-123" },
203
+ },
204
+ });
205
+ const createInit = notionRequest.mock.calls[2]?.[1];
206
+ const createBody = JSON.parse(String(createInit?.body)) as {
207
+ properties: Record<string, unknown>;
208
+ };
209
+ expect(createBody.properties).toMatchObject({
210
+ "source-id": { select: { name: "API" } },
211
+ "external-id": {
212
+ rich_text: [
213
+ {
214
+ type: "text",
215
+ text: { content: "product-a:feedback-123" },
216
+ },
217
+ ],
218
+ },
219
+ "edit-token-hash-id": { rich_text: [] },
220
+ });
221
+ });
222
+
223
+ it("rejects out-of-bounds trusted input before any Notion request", async () => {
224
+ const notionRequest = vi.fn<typeof fetch>();
225
+ const handler = createTrustedFeedbackHandler({
226
+ ...trustedHandlerSecurity("correct-api-key"),
227
+ feedback: createNotionFeedbackModule({
228
+ token: "notion-token",
229
+ dataSourceId: "feedback-data-source",
230
+ propertyIds,
231
+ request: notionRequest,
232
+ }),
233
+ });
234
+ const requests = [
235
+ trustedRequest({ title: "" }, "valid-key"),
236
+ trustedRequest({ title: "t".repeat(161) }, "valid-key"),
237
+ trustedRequest({ description: "" }, "valid-key"),
238
+ trustedRequest({ description: "d".repeat(5_001) }, "valid-key"),
239
+ trustedRequest({ submitter: { name: "n".repeat(201) } }, "valid-key"),
240
+ trustedRequest({ submitter: { email: "e".repeat(321) } }, "valid-key"),
241
+ trustedRequest({}, "k".repeat(201)),
242
+ trustedRequest({ ignored: "x".repeat(20_000) }, "valid-key"),
243
+ ];
244
+
245
+ const responses = await Promise.all(requests.map(handler));
246
+
247
+ expect(responses.map(({ status }) => status)).toEqual(
248
+ Array(requests.length).fill(400),
249
+ );
250
+ for (const response of responses) {
251
+ await expect(response.json()).resolves.toEqual({
252
+ error: "Trusted submission exceeds an accepted bound.",
253
+ });
254
+ }
255
+ expect(notionRequest).not.toHaveBeenCalled();
256
+ });
257
+
258
+ it("does not log or serialize API keys and private submission fields on failure", async () => {
259
+ const notionRequest = vi
260
+ .fn<typeof fetch>()
261
+ .mockResolvedValue(
262
+ Response.json(
263
+ { object: "error", message: "private Notion diagnostic" },
264
+ { status: 500 },
265
+ ),
266
+ );
267
+ const consoleError = vi
268
+ .spyOn(console, "error")
269
+ .mockImplementation(() => {});
270
+ const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
271
+ const handler = createTrustedFeedbackHandler({
272
+ ...trustedHandlerSecurity("plaintext-api-key"),
273
+ feedback: createNotionFeedbackModule({
274
+ token: "notion-token",
275
+ dataSourceId: "feedback-data-source",
276
+ propertyIds,
277
+ request: notionRequest,
278
+ }),
279
+ });
280
+ const request = new Request("https://feedback.example.com/api/v1/posts", {
281
+ method: "POST",
282
+ headers: {
283
+ Authorization: "Bearer plaintext-api-key",
284
+ "Content-Type": "application/json",
285
+ "Idempotency-Key": "private-external-id",
286
+ },
287
+ body: JSON.stringify({
288
+ title: "private title",
289
+ description: "private description",
290
+ type: "General Feedback",
291
+ submitter: { email: "private@example.com" },
292
+ }),
293
+ });
294
+
295
+ const response = await handler(request);
296
+ const serialized = await response.text();
297
+
298
+ expect(response.status).toBe(502);
299
+ expect(serialized).toBe('{"error":"Trusted submission failed."}');
300
+ expect(serialized).not.toContain("plaintext-api-key");
301
+ expect(serialized).not.toContain("private title");
302
+ expect(serialized).not.toContain("private@example.com");
303
+ expect(consoleError).not.toHaveBeenCalled();
304
+ expect(consoleLog).not.toHaveBeenCalled();
305
+ consoleError.mockRestore();
306
+ consoleLog.mockRestore();
307
+ });
308
+
309
+ it("rate-limits by API-key hash before any Notion request", async () => {
310
+ const notionRequest = vi.fn<typeof fetch>();
311
+ const rateLimiter = {
312
+ limit: vi.fn().mockResolvedValue({ success: false }),
313
+ };
314
+ const apiKeyHash = hashApiKey("correct-api-key");
315
+ const handler = createTrustedFeedbackHandler({
316
+ apiKeyHash,
317
+ rateLimiter,
318
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
319
+ feedback: createNotionFeedbackModule({
320
+ token: "notion-token",
321
+ dataSourceId: "feedback-data-source",
322
+ propertyIds,
323
+ request: notionRequest,
324
+ }),
325
+ });
326
+
327
+ const response = await handler(trustedRequest({}, "valid-key"));
328
+
329
+ expect(response.status).toBe(429);
330
+ await expect(response.json()).resolves.toEqual({
331
+ error: "Trusted submission rate limit exceeded.",
332
+ });
333
+ expect(rateLimiter.limit).toHaveBeenCalledWith({ key: apiKeyHash });
334
+ expect(rateLimiter.limit).not.toHaveBeenCalledWith({
335
+ key: "correct-api-key",
336
+ });
337
+ expect(notionRequest).not.toHaveBeenCalled();
338
+ });
339
+ });
340
+
341
+ function trustedHandlerSecurity(apiKey: string) {
342
+ return {
343
+ apiKeyHash: hashApiKey(apiKey),
344
+ rateLimiter: {
345
+ limit: vi.fn().mockResolvedValue({ success: true }),
346
+ },
347
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
348
+ };
349
+ }
350
+
351
+ function hashApiKey(apiKey: string): string {
352
+ return createHash("sha256").update(apiKey).digest("base64url");
353
+ }
354
+
355
+ function trustedRequest(
356
+ overrides: Record<string, unknown>,
357
+ idempotencyKey?: string,
358
+ ): Request {
359
+ return new Request("https://feedback.example.com/api/v1/posts", {
360
+ method: "POST",
361
+ headers: {
362
+ Authorization: "Bearer correct-api-key",
363
+ "Content-Type": "application/json",
364
+ ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
365
+ },
366
+ body: JSON.stringify({
367
+ title: "Keyboard navigation",
368
+ description: "Let users navigate without a mouse.",
369
+ type: "Feature Request",
370
+ submitter: { name: "Ama", email: "ama@example.com" },
371
+ ...overrides,
372
+ }),
373
+ });
374
+ }
375
+
376
+ function notionPage({
377
+ title,
378
+ description,
379
+ type,
380
+ externalId,
381
+ }: {
382
+ title: string;
383
+ description: string;
384
+ type: "Feature Request" | "Bug Report" | "General Feedback";
385
+ externalId: string;
386
+ }) {
387
+ return {
388
+ object: "page",
389
+ id: "notion-page-id",
390
+ created_time: "2026-08-20T13:00:00.000Z",
391
+ last_edited_time: "2026-08-20T13:00:00.000Z",
392
+ properties: {
393
+ Title: { id: "title-id", title: [{ plain_text: title }] },
394
+ Slug: {
395
+ id: "slug-id",
396
+ rich_text: [{ plain_text: "keyboard-navigation" }],
397
+ },
398
+ Description: {
399
+ id: "description-id",
400
+ rich_text: [{ plain_text: description }],
401
+ },
402
+ Type: { id: "type-id", select: { name: type } },
403
+ Status: { id: "status-id", select: { name: "New" } },
404
+ Published: { id: "published-id", checkbox: false },
405
+ "Vote Count": { id: "vote-count-id", number: 0 },
406
+ "Team Response": { id: "team-response-id", rich_text: [] },
407
+ "Release Notes URL": { id: "release-notes-url-id", url: null },
408
+ "Submitter Name": {
409
+ id: "submitter-name-id",
410
+ rich_text: [{ plain_text: "Ama" }],
411
+ },
412
+ "Submitter Email": {
413
+ id: "submitter-email-id",
414
+ email: "ama@example.com",
415
+ },
416
+ Source: { id: "source-id", select: { name: "API" } },
417
+ "External ID": {
418
+ id: "external-id",
419
+ rich_text: [{ plain_text: externalId }],
420
+ },
421
+ "Edit Token Hash": { id: "edit-token-hash-id", rich_text: [] },
422
+ },
423
+ };
424
+ }
425
+ import { createHash } from "node:crypto";
@@ -0,0 +1,181 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+
3
+ import type { FeedbackModule } from "@feedbax/feedback";
4
+ import type { PostType, SubmitTrustedPostInput } from "@feedbax/feedback";
5
+
6
+ const FIXED_TRUSTED_SUBMISSION_BOUNDS = {
7
+ submitterName: 200,
8
+ submitterEmail: 320,
9
+ idempotencyKey: 200,
10
+ bodyBytes: 16 * 1_024,
11
+ } as const;
12
+
13
+ export interface TrustedSubmissionRateLimiter {
14
+ limit(options: { key: string }): Promise<{ success: boolean }>;
15
+ }
16
+
17
+ interface TrustedFeedbackHandlerOptions {
18
+ apiKeyHash: string;
19
+ feedback: Pick<FeedbackModule, "submitTrustedPost">;
20
+ rateLimiter: TrustedSubmissionRateLimiter;
21
+ limits: {
22
+ maxTitleLength: number;
23
+ maxDescriptionLength: number;
24
+ };
25
+ }
26
+
27
+ export function createTrustedFeedbackHandler({
28
+ apiKeyHash,
29
+ feedback,
30
+ rateLimiter,
31
+ limits,
32
+ }: TrustedFeedbackHandlerOptions): (request: Request) => Promise<Response> {
33
+ return async (request) => {
34
+ if (!hasValidBearerKey(request.headers.get("Authorization"), apiKeyHash)) {
35
+ return jsonResponse(401, { error: "Unauthorized trusted submission." });
36
+ }
37
+
38
+ try {
39
+ const { success } = await rateLimiter.limit({ key: apiKeyHash });
40
+ if (!success) {
41
+ return jsonResponse(429, {
42
+ error: "Trusted submission rate limit exceeded.",
43
+ });
44
+ }
45
+ } catch {
46
+ return jsonResponse(503, { error: "Trusted submission is unavailable." });
47
+ }
48
+
49
+ const idempotencyKey = request.headers.get("Idempotency-Key")?.trim();
50
+ if (!idempotencyKey) {
51
+ return jsonResponse(400, {
52
+ error: "Idempotency-Key header is required.",
53
+ });
54
+ }
55
+ if (
56
+ idempotencyKey.length > FIXED_TRUSTED_SUBMISSION_BOUNDS.idempotencyKey
57
+ ) {
58
+ return boundsError();
59
+ }
60
+
61
+ const input = await readTrustedSubmission(request, idempotencyKey, limits);
62
+ if (input instanceof Response) return input;
63
+
64
+ try {
65
+ const item = await feedback.submitTrustedPost(input);
66
+ return jsonResponse(200, item);
67
+ } catch {
68
+ return jsonResponse(502, { error: "Trusted submission failed." });
69
+ }
70
+ };
71
+ }
72
+
73
+ async function readTrustedSubmission(
74
+ request: Request,
75
+ externalId: string,
76
+ limits: TrustedFeedbackHandlerOptions["limits"],
77
+ ): Promise<SubmitTrustedPostInput | Response> {
78
+ let body: unknown;
79
+ try {
80
+ const text = await request.text();
81
+ if (
82
+ new TextEncoder().encode(text).byteLength >
83
+ FIXED_TRUSTED_SUBMISSION_BOUNDS.bodyBytes
84
+ ) {
85
+ return boundsError();
86
+ }
87
+ body = JSON.parse(text) as unknown;
88
+ } catch {
89
+ return jsonResponse(400, { error: "Request body must be valid JSON." });
90
+ }
91
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
92
+ return jsonResponse(400, { error: "Request body is invalid." });
93
+ }
94
+ const value = body as Record<string, unknown>;
95
+ if (
96
+ typeof value.title !== "string" ||
97
+ typeof value.description !== "string" ||
98
+ !isPostType(value.type)
99
+ ) {
100
+ return jsonResponse(400, { error: "Request body is invalid." });
101
+ }
102
+ if (
103
+ !hasBoundedRequiredText(value.title, limits.maxTitleLength) ||
104
+ !hasBoundedRequiredText(value.description, limits.maxDescriptionLength)
105
+ ) {
106
+ return boundsError();
107
+ }
108
+ const submitter = readSubmitter(value.submitter);
109
+ if (submitter instanceof Response) return submitter;
110
+ return {
111
+ externalId,
112
+ title: value.title,
113
+ description: value.description,
114
+ type: value.type,
115
+ ...(submitter ? { submitter } : {}),
116
+ };
117
+ }
118
+
119
+ function readSubmitter(
120
+ value: unknown,
121
+ ): SubmitTrustedPostInput["submitter"] | Response {
122
+ if (value === undefined) return undefined;
123
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
124
+ return jsonResponse(400, { error: "Request body is invalid." });
125
+ }
126
+ const submitter = value as Record<string, unknown>;
127
+ if (
128
+ (submitter.name !== undefined && typeof submitter.name !== "string") ||
129
+ (submitter.email !== undefined && typeof submitter.email !== "string")
130
+ ) {
131
+ return jsonResponse(400, { error: "Request body is invalid." });
132
+ }
133
+ if (
134
+ (typeof submitter.name === "string" &&
135
+ submitter.name.length > FIXED_TRUSTED_SUBMISSION_BOUNDS.submitterName) ||
136
+ (typeof submitter.email === "string" &&
137
+ submitter.email.length > FIXED_TRUSTED_SUBMISSION_BOUNDS.submitterEmail)
138
+ ) {
139
+ return boundsError();
140
+ }
141
+ return {
142
+ ...(typeof submitter.name === "string" ? { name: submitter.name } : {}),
143
+ ...(typeof submitter.email === "string" ? { email: submitter.email } : {}),
144
+ };
145
+ }
146
+
147
+ function hasBoundedRequiredText(value: string, maximum: number): boolean {
148
+ return value.trim().length > 0 && value.length <= maximum;
149
+ }
150
+
151
+ function boundsError(): Response {
152
+ return jsonResponse(400, {
153
+ error: "Trusted submission exceeds an accepted bound.",
154
+ });
155
+ }
156
+
157
+ function isPostType(value: unknown): value is PostType {
158
+ return (
159
+ value === "Feature Request" ||
160
+ value === "Bug Report" ||
161
+ value === "General Feedback"
162
+ );
163
+ }
164
+
165
+ function hasValidBearerKey(
166
+ authorization: string | null,
167
+ expectedApiKeyHash: string,
168
+ ): boolean {
169
+ if (!authorization?.startsWith("Bearer ")) return false;
170
+ const presentedApiKey = authorization.slice("Bearer ".length);
171
+ const expectedHash = Buffer.from(expectedApiKeyHash, "base64url");
172
+ const presentedHash = createHash("sha256").update(presentedApiKey).digest();
173
+ return (
174
+ expectedHash.length === presentedHash.length &&
175
+ timingSafeEqual(expectedHash, presentedHash)
176
+ );
177
+ }
178
+
179
+ function jsonResponse(status: number, body: unknown): Response {
180
+ return Response.json(body, { status });
181
+ }
@@ -0,0 +1,17 @@
1
+ import { env } from "cloudflare:workers";
2
+
3
+ import {
4
+ createConfiguredFeedbackModule,
5
+ feedbackSubmissionLimits,
6
+ requiredEnvironmentValue,
7
+ } from "./feedback-runtime";
8
+ import { createTrustedFeedbackHandler } from "./trusted-feedback-handler";
9
+
10
+ export function createConfiguredTrustedFeedbackHandler() {
11
+ return createTrustedFeedbackHandler({
12
+ apiKeyHash: requiredEnvironmentValue("FEEDBAX_API_KEY_HASH"),
13
+ rateLimiter: env.TRUSTED_SUBMISSIONS_RATE_LIMITER,
14
+ limits: feedbackSubmissionLimits,
15
+ feedback: createConfiguredFeedbackModule(),
16
+ });
17
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ configuredTurnstileSecret,
4
+ configuredTurnstileSiteKey,
5
+ } from "./turnstile-configuration";
6
+ describe("typed Turnstile configuration", () => {
7
+ it("uses the public typed site key without a Vite environment variable", () => {
8
+ expect(
9
+ configuredTurnstileSiteKey({
10
+ enabled: true,
11
+ siteKey: "configured-public-key",
12
+ }),
13
+ ).toBe("configured-public-key");
14
+ });
15
+ it("disables browser widgets and server verification together even when a secret remains", () => {
16
+ expect(
17
+ configuredTurnstileSiteKey({ enabled: false, siteKey: "old-key" }),
18
+ ).toBeUndefined();
19
+ expect(
20
+ configuredTurnstileSecret("retained-secret", { enabled: false }),
21
+ ).toBeUndefined();
22
+ });
23
+ it("fails closed when enabled verification lacks its secret", () => {
24
+ expect(() =>
25
+ configuredTurnstileSecret(undefined, {
26
+ enabled: true,
27
+ siteKey: "public-key",
28
+ }),
29
+ ).toThrow("TURNSTILE_SECRET_KEY is missing");
30
+ expect(
31
+ configuredTurnstileSecret(" private-secret ", {
32
+ enabled: true,
33
+ siteKey: "public-key",
34
+ }),
35
+ ).toBe("private-secret");
36
+ });
37
+ });
@@ -0,0 +1,19 @@
1
+ import type { FeedbaxConfig } from "@feedbax/config";
2
+ import feedbax from "./feedbax";
3
+ type TurnstileConfiguration = FeedbaxConfig["turnstile"];
4
+ export function configuredTurnstileSiteKey(
5
+ config: TurnstileConfiguration = feedbax.turnstile,
6
+ ): string | undefined {
7
+ return config.enabled ? config.siteKey : undefined;
8
+ }
9
+ export function configuredTurnstileSecret(
10
+ secret: string | undefined,
11
+ config: TurnstileConfiguration = feedbax.turnstile,
12
+ ): string | undefined {
13
+ if (!config.enabled) return undefined;
14
+ if (!secret?.trim())
15
+ throw new Error(
16
+ "Turnstile is enabled but TURNSTILE_SECRET_KEY is missing.",
17
+ );
18
+ return secret.trim();
19
+ }
@@ -0,0 +1,17 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+
3
+ export function useMediaQuery(query: string): boolean {
4
+ const subscribe = useCallback(
5
+ (onChange: () => void) => {
6
+ if (typeof window.matchMedia !== "function") return () => {};
7
+ const media = window.matchMedia(query);
8
+ media.addEventListener("change", onChange);
9
+ return () => media.removeEventListener("change", onChange);
10
+ },
11
+ [query],
12
+ );
13
+ const getSnapshot = () =>
14
+ typeof window.matchMedia === "function" && window.matchMedia(query).matches;
15
+
16
+ return useSyncExternalStore(subscribe, getSnapshot, () => false);
17
+ }