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,96 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { getRequest, getRequestIP } from "@tanstack/react-start/server";
3
+
4
+ import {
5
+ requestParticipant,
6
+ createConfiguredFeedbackModule,
7
+ feedbackSubmissionLimits,
8
+ } from "./feedback-runtime";
9
+ import {
10
+ createPortalDraftManagement,
11
+ createPortalFeedbackSubmission,
12
+ createPortalPostSubmission,
13
+ } from "./portal-feedback-submission";
14
+ import {
15
+ createPortalSubmissionSecurity,
16
+ warnIfTurnstileDisabled,
17
+ } from "./portal-feedback-runtime";
18
+ import { runSafePortalMutation } from "./safe-public-failure";
19
+
20
+ export const submitPortalFeedback = createServerFn({ method: "POST" })
21
+ .validator((input: unknown) => input)
22
+ .handler(async ({ data }) => {
23
+ const { env } = await import("cloudflare:workers");
24
+ warnIfTurnstileDisabled({
25
+ environment: env,
26
+ nodeEnv: import.meta.env.PROD ? "production" : "development",
27
+ });
28
+ const participant = await requestParticipant(getRequest());
29
+ return runSafePortalMutation(() =>
30
+ createPortalFeedbackSubmission({
31
+ feedback: createConfiguredFeedbackModule(participant),
32
+ limits: feedbackSubmissionLimits,
33
+ ...createPortalSubmissionSecurity({
34
+ environment: env,
35
+ rateLimitKey: getRequestIP() ?? "unknown-participant-ip",
36
+ }),
37
+ })(data),
38
+ );
39
+ });
40
+
41
+ export const submitPortalPost = createServerFn({ method: "POST" })
42
+ .validator((input: unknown) => input)
43
+ .handler(async ({ data }) => {
44
+ const { env } = await import("cloudflare:workers");
45
+ const participant = await requestParticipant(getRequest());
46
+ return runSafePortalMutation(() =>
47
+ createPortalPostSubmission({
48
+ feedback: createConfiguredFeedbackModule(participant),
49
+ limits: feedbackSubmissionLimits,
50
+ ...createPortalSubmissionSecurity({
51
+ environment: env,
52
+ rateLimitKey: getRequestIP() ?? "unknown-participant-ip",
53
+ }),
54
+ })(data),
55
+ );
56
+ });
57
+
58
+ export const editPortalFeedbackDraft = createServerFn({ method: "POST" })
59
+ .validator((input: unknown) => input)
60
+ .handler(({ data }) =>
61
+ runSafePortalMutation(() =>
62
+ createPortalDraftManagement({
63
+ feedback: createConfiguredFeedbackModule(),
64
+ limits: feedbackSubmissionLimits,
65
+ }).edit(data),
66
+ ),
67
+ );
68
+
69
+ export const withdrawPortalFeedbackDraft = createServerFn({ method: "POST" })
70
+ .validator((input: unknown) => input)
71
+ .handler(({ data }) =>
72
+ runSafePortalMutation(() =>
73
+ createPortalDraftManagement({
74
+ feedback: createConfiguredFeedbackModule(),
75
+ limits: feedbackSubmissionLimits,
76
+ }).withdraw(data),
77
+ ),
78
+ );
79
+
80
+ export const getPortalDraftPost = createServerFn({ method: "POST" })
81
+ .validator((input: unknown) => input)
82
+ .handler(({ data }) => {
83
+ if (!data || typeof data !== "object")
84
+ throw new Error("Draft Post request is invalid.");
85
+ const id = Reflect.get(data, "id");
86
+ const browserCapability = Reflect.get(data, "browserCapability");
87
+ if (typeof id !== "string" || typeof browserCapability !== "string")
88
+ throw new Error("Draft Post request is invalid.");
89
+ return runSafePortalMutation(() =>
90
+ createConfiguredFeedbackModule().getDraftPost({
91
+ id,
92
+ browserCapability:
93
+ browserCapability as import("@feedbax/feedback").BrowserCapability,
94
+ }),
95
+ );
96
+ });
@@ -0,0 +1,307 @@
1
+ import {
2
+ createFeedbackModule,
3
+ createNotionFeedbackModule,
4
+ type FeedbackPropertyIds,
5
+ } from "@feedbax/feedback";
6
+ import { describe, expect, it, vi } from "vitest";
7
+
8
+ import { createCloudflareTurnstileVerifier } from "./cloudflare-turnstile";
9
+ import {
10
+ createPortalDraftManagement,
11
+ createPortalFeedbackSubmission,
12
+ } from "./portal-feedback-submission";
13
+
14
+ const propertyIds: FeedbackPropertyIds = {
15
+ title: "title-id",
16
+ slug: "slug-id",
17
+ description: "description-id",
18
+ type: "type-id",
19
+ status: "status-id",
20
+ published: "published-id",
21
+ voteCount: "vote-count-id",
22
+ teamResponse: "team-response-id",
23
+ releaseNotesUrl: "release-notes-url-id",
24
+ submitterName: "submitter-name-id",
25
+ submitterEmail: "submitter-email-id",
26
+ source: "source-id",
27
+ externalId: "external-id",
28
+ editTokenHash: "edit-token-hash-id",
29
+ createdAt: "created-at-id",
30
+ updatedAt: "updated-at-id",
31
+ };
32
+
33
+ describe("Notion-only feedback submission", () => {
34
+ it("edits permitted draft fields through the portal mutation boundary", async () => {
35
+ const feedback = createFeedbackModule();
36
+ const submitted = await feedback.submitPost({
37
+ title: "Original title",
38
+ description: "Original description",
39
+ type: "General Feedback",
40
+ });
41
+ const drafts = createPortalDraftManagement({
42
+ feedback,
43
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
44
+ });
45
+
46
+ await expect(
47
+ drafts.edit({
48
+ id: submitted.id,
49
+ browserCapability: submitted.browserCapability,
50
+ title: "Corrected title",
51
+ description: "Corrected description",
52
+ type: "Bug Report",
53
+ }),
54
+ ).resolves.toMatchObject({
55
+ id: submitted.id,
56
+ title: "Corrected title",
57
+ description: "Corrected description",
58
+ type: "Bug Report",
59
+ });
60
+ });
61
+
62
+ it("withdraws an eligible draft through the portal mutation boundary", async () => {
63
+ const feedback = createFeedbackModule();
64
+ const submitted = await feedback.submitPost({
65
+ title: "Withdraw this draft",
66
+ description: "This submission is no longer needed.",
67
+ type: "General Feedback",
68
+ });
69
+ const drafts = createPortalDraftManagement({
70
+ feedback,
71
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
72
+ });
73
+
74
+ await expect(
75
+ drafts.withdraw({
76
+ id: submitted.id,
77
+ browserCapability: submitted.browserCapability,
78
+ }),
79
+ ).resolves.toBeUndefined();
80
+ });
81
+
82
+ it("creates a New unpublished Post and returns its Browser Capability", async () => {
83
+ const submit = createPortalFeedbackSubmission({
84
+ feedback: createFeedbackModule(),
85
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
86
+ ...allowedSubmissionSecurity(),
87
+ });
88
+
89
+ const result = await submit({
90
+ title: "Keyboard navigation",
91
+ description: "Let participants navigate without a mouse.",
92
+ type: "Feature Request",
93
+ submitter: { name: "Ama", email: "ama@example.com" },
94
+ });
95
+
96
+ expect(result).toMatchObject({
97
+ title: "Keyboard navigation",
98
+ description: "Let participants navigate without a mouse.",
99
+ type: "Feature Request",
100
+ status: "New",
101
+ published: false,
102
+ });
103
+ expect(result.browserCapability).toEqual(expect.any(String));
104
+ expect(result.browserCapability.length).toBeGreaterThanOrEqual(32);
105
+ });
106
+
107
+ it("rejects invalid or out-of-bounds input before the Feedback module writes", async () => {
108
+ const notionRequest = vi.fn<typeof fetch>();
109
+ const submit = createPortalFeedbackSubmission({
110
+ feedback: createNotionFeedbackModule({
111
+ token: "notion-token",
112
+ dataSourceId: "feedback-data-source",
113
+ propertyIds,
114
+ request: notionRequest,
115
+ }),
116
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
117
+ ...allowedSubmissionSecurity(),
118
+ });
119
+
120
+ const completeSubmitter = { name: "Ama", email: "ama@example.com" };
121
+ const invalidInputs = [
122
+ null,
123
+ {},
124
+ {
125
+ title: "A title",
126
+ description: "A description",
127
+ type: "Bug Report",
128
+ },
129
+ {
130
+ title: "A title",
131
+ description: "A description",
132
+ type: "Bug Report",
133
+ submitter: { name: "Ama" },
134
+ },
135
+ {
136
+ title: "A title",
137
+ description: "A description",
138
+ type: "Bug Report",
139
+ submitter: { name: "Ama", email: "not-an-email" },
140
+ },
141
+ {
142
+ title: "",
143
+ description: "A description",
144
+ type: "Bug Report",
145
+ submitter: completeSubmitter,
146
+ },
147
+ {
148
+ title: "t".repeat(161),
149
+ description: "A description",
150
+ type: "Bug Report",
151
+ submitter: completeSubmitter,
152
+ },
153
+ {
154
+ title: "A title",
155
+ description: "",
156
+ type: "Bug Report",
157
+ submitter: completeSubmitter,
158
+ },
159
+ {
160
+ title: "A title",
161
+ description: "d".repeat(5_001),
162
+ type: "Bug Report",
163
+ submitter: completeSubmitter,
164
+ },
165
+ {
166
+ title: "A title",
167
+ description: "A description",
168
+ type: "Other",
169
+ submitter: completeSubmitter,
170
+ },
171
+ ];
172
+
173
+ for (const input of invalidInputs) {
174
+ await expect(submit(input)).rejects.toThrow(
175
+ "Feedback submission is invalid.",
176
+ );
177
+ }
178
+ expect(notionRequest).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it("keeps required unverified profile details inside the private submission", async () => {
182
+ const feedback = createFeedbackModule();
183
+ const submit = createPortalFeedbackSubmission({
184
+ feedback,
185
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
186
+ ...allowedSubmissionSecurity(),
187
+ });
188
+
189
+ const submitted = await submit({
190
+ title: "Keyboard navigation",
191
+ description: "Let participants navigate without a mouse.",
192
+ type: "Feature Request",
193
+ submitter: { name: "Ama", email: "ama@example.com" },
194
+ published: true,
195
+ status: "Shipped",
196
+ });
197
+
198
+ expect(submitted.submitter).toEqual({
199
+ name: "Ama",
200
+ email: "ama@example.com",
201
+ });
202
+ expect(submitted).toMatchObject({ status: "New", published: false });
203
+ await expect(feedback.listPublicPosts()).resolves.toEqual({ items: [] });
204
+ });
205
+
206
+ it("rejects a burst-limited submission before Notion writes", async () => {
207
+ const notionRequest = vi.fn<typeof fetch>();
208
+ const submit = createPortalFeedbackSubmission({
209
+ feedback: createNotionFeedbackModule({
210
+ token: "notion-token",
211
+ dataSourceId: "feedback-data-source",
212
+ propertyIds,
213
+ request: notionRequest,
214
+ }),
215
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
216
+ rateLimiter: {
217
+ limit: vi.fn().mockResolvedValue({ success: false }),
218
+ },
219
+ rateLimitKey: "participant-ip",
220
+ });
221
+
222
+ await expect(
223
+ submit({
224
+ title: "Keyboard navigation",
225
+ description: "Let participants navigate without a mouse.",
226
+ type: "Feature Request",
227
+ }),
228
+ ).rejects.toThrow("Feedback submission rate limit exceeded.");
229
+ expect(notionRequest).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it("fails closed when enabled Turnstile verification rejects the token", async () => {
233
+ const notionRequest = vi.fn<typeof fetch>();
234
+ const siteverifyRequest = vi
235
+ .fn<typeof fetch>()
236
+ .mockResolvedValue(Response.json({ success: false }));
237
+ const submit = createPortalFeedbackSubmission({
238
+ feedback: createNotionFeedbackModule({
239
+ token: "notion-token",
240
+ dataSourceId: "feedback-data-source",
241
+ propertyIds,
242
+ request: notionRequest,
243
+ }),
244
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
245
+ ...allowedSubmissionSecurity(),
246
+ turnstileVerifier: createCloudflareTurnstileVerifier({
247
+ secretKey: "private-turnstile-secret",
248
+ request: siteverifyRequest,
249
+ }),
250
+ });
251
+
252
+ const rejection = submit({
253
+ title: "Keyboard navigation",
254
+ description: "Let participants navigate without a mouse.",
255
+ type: "Feature Request",
256
+ submitter: { name: "Ama", email: "ama@example.com" },
257
+ turnstileToken: "private-participant-token",
258
+ });
259
+
260
+ await expect(rejection).rejects.toThrow(
261
+ "Feedback submission verification failed.",
262
+ );
263
+ await expect(rejection).rejects.not.toThrow("private-turnstile-secret");
264
+ await expect(rejection).rejects.not.toThrow("private-participant-token");
265
+ expect(notionRequest).not.toHaveBeenCalled();
266
+ expect(siteverifyRequest).toHaveBeenCalledOnce();
267
+ });
268
+
269
+ it("verifies every Turnstile-enabled submission and strips the token before storage", async () => {
270
+ const siteverifyRequest = vi
271
+ .fn<typeof fetch>()
272
+ .mockResolvedValue(Response.json({ success: true }));
273
+ const submit = createPortalFeedbackSubmission({
274
+ feedback: createFeedbackModule(),
275
+ limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
276
+ ...allowedSubmissionSecurity(),
277
+ turnstileVerifier: createCloudflareTurnstileVerifier({
278
+ secretKey: "private-turnstile-secret",
279
+ request: siteverifyRequest,
280
+ }),
281
+ });
282
+
283
+ const result = await submit({
284
+ title: "Keyboard navigation",
285
+ description: "Let participants navigate without a mouse.",
286
+ type: "Feature Request",
287
+ submitter: { name: "Ama", email: "ama@example.com" },
288
+ turnstileToken: "private-participant-token",
289
+ });
290
+
291
+ expect(result).not.toHaveProperty("turnstileToken");
292
+ expect(siteverifyRequest).toHaveBeenCalledOnce();
293
+ const [url, init] = siteverifyRequest.mock.calls[0]!;
294
+ expect(url).toBe(
295
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
296
+ );
297
+ expect(init?.method).toBe("POST");
298
+ expect(String(init?.body)).toContain("response=private-participant-token");
299
+ });
300
+ });
301
+
302
+ function allowedSubmissionSecurity() {
303
+ return {
304
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
305
+ rateLimitKey: "participant-ip",
306
+ };
307
+ }
@@ -0,0 +1,224 @@
1
+ import type {
2
+ BrowserCapability,
3
+ Post,
4
+ FeedbackModule,
5
+ SubmitPostInput,
6
+ SubmittedPost,
7
+ } from "@feedbax/feedback";
8
+
9
+ import type { TurnstileVerifier } from "./cloudflare-turnstile";
10
+ import { isValidCommentEmail } from "./comment-profile";
11
+ import { ActionablePortalFailure } from "./safe-public-failure";
12
+
13
+ interface PortalFeedbackSubmissionOptions {
14
+ feedback: Pick<FeedbackModule, "submitPost">;
15
+ limits: {
16
+ maxTitleLength: number;
17
+ maxDescriptionLength: number;
18
+ };
19
+ rateLimiter: PortalSubmissionRateLimiter;
20
+ rateLimitKey: string;
21
+ turnstileVerifier?: TurnstileVerifier;
22
+ }
23
+
24
+ export function createPortalPostSubmission(
25
+ options: Omit<PortalFeedbackSubmissionOptions, "feedback"> & {
26
+ feedback: Pick<FeedbackModule, "submitPost">;
27
+ },
28
+ ): (input: unknown) => Promise<SubmittedPost> {
29
+ return async (input) => {
30
+ const { success } = await options.rateLimiter.limit({
31
+ key: options.rateLimitKey,
32
+ });
33
+ if (!success)
34
+ throw new ActionablePortalFailure("Post submission rate limit exceeded.");
35
+ if (options.turnstileVerifier)
36
+ await verifyTurnstile(
37
+ input,
38
+ options.rateLimitKey,
39
+ options.turnstileVerifier,
40
+ );
41
+ return options.feedback.submitPost(validateInput(input, options.limits));
42
+ };
43
+ }
44
+
45
+ export interface PortalSubmissionRateLimiter {
46
+ limit(options: { key: string }): Promise<{ success: boolean }>;
47
+ }
48
+
49
+ interface PortalDraftManagementOptions {
50
+ feedback: Pick<FeedbackModule, "editDraftPost" | "withdrawDraftPost">;
51
+ limits: PortalFeedbackSubmissionOptions["limits"];
52
+ }
53
+
54
+ export function createPortalDraftManagement({
55
+ feedback,
56
+ limits,
57
+ }: PortalDraftManagementOptions): {
58
+ edit(input: unknown): Promise<Post>;
59
+ withdraw(input: unknown): Promise<void>;
60
+ } {
61
+ return {
62
+ edit(input) {
63
+ const draft = validateDraftIdentity(input);
64
+ const value = input as Record<string, unknown>;
65
+ if (
66
+ !isBoundedRequiredText(value.title, limits.maxTitleLength) ||
67
+ !isBoundedRequiredText(
68
+ value.description,
69
+ limits.maxDescriptionLength,
70
+ ) ||
71
+ !isPostType(value.type)
72
+ ) {
73
+ throw invalidSubmission();
74
+ }
75
+ return feedback.editDraftPost({
76
+ ...draft,
77
+ title: value.title,
78
+ description: value.description,
79
+ type: value.type,
80
+ });
81
+ },
82
+ withdraw(input) {
83
+ return feedback.withdrawDraftPost(validateDraftIdentity(input));
84
+ },
85
+ };
86
+ }
87
+
88
+ export function createPortalFeedbackSubmission({
89
+ feedback,
90
+ limits,
91
+ rateLimiter,
92
+ rateLimitKey,
93
+ turnstileVerifier,
94
+ }: PortalFeedbackSubmissionOptions): (
95
+ input: unknown,
96
+ ) => Promise<SubmittedPost> {
97
+ return async (input) => {
98
+ const { success } = await rateLimiter.limit({ key: rateLimitKey });
99
+ if (!success) {
100
+ throw new ActionablePortalFailure(
101
+ "Feedback submission rate limit exceeded.",
102
+ );
103
+ }
104
+ if (turnstileVerifier) {
105
+ await verifyTurnstile(input, rateLimitKey, turnstileVerifier);
106
+ }
107
+ return feedback.submitPost(validateInput(input, limits));
108
+ };
109
+ }
110
+
111
+ async function verifyTurnstile(
112
+ input: unknown,
113
+ remoteIp: string,
114
+ turnstileVerifier: TurnstileVerifier,
115
+ ): Promise<void> {
116
+ const token =
117
+ input && typeof input === "object" && !Array.isArray(input)
118
+ ? Reflect.get(input, "turnstileToken")
119
+ : undefined;
120
+ if (typeof token !== "string" || token.length === 0) {
121
+ throw turnstileFailure();
122
+ }
123
+
124
+ const verified = await turnstileVerifier.verify({ token, remoteIp });
125
+ if (!verified) throw turnstileFailure();
126
+ }
127
+
128
+ function turnstileFailure(): Error {
129
+ return new ActionablePortalFailure(
130
+ "Feedback submission verification failed.",
131
+ );
132
+ }
133
+
134
+ function validateInput(
135
+ input: unknown,
136
+ limits: PortalFeedbackSubmissionOptions["limits"],
137
+ ): SubmitPostInput {
138
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
139
+ throw invalidSubmission();
140
+ }
141
+ const value = input as Record<string, unknown>;
142
+ if (
143
+ !isBoundedRequiredText(value.title, limits.maxTitleLength) ||
144
+ !isBoundedRequiredText(value.description, limits.maxDescriptionLength) ||
145
+ !isPostType(value.type)
146
+ ) {
147
+ throw invalidSubmission();
148
+ }
149
+ if (!isValidSubmitter(value.submitter)) throw invalidSubmission();
150
+ if (
151
+ value.imageIds !== undefined &&
152
+ (!Array.isArray(value.imageIds) ||
153
+ value.imageIds.length > 4 ||
154
+ value.imageIds.some(
155
+ (id) => typeof id !== "string" || !/^[-a-f0-9]{36}$/.test(id),
156
+ ))
157
+ )
158
+ throw invalidSubmission();
159
+
160
+ return {
161
+ ...(value.imageIds ? { imageIds: value.imageIds as string[] } : {}),
162
+ title: value.title,
163
+ description: value.description,
164
+ type: value.type,
165
+ ...(value.submitter ? { submitter: value.submitter } : {}),
166
+ };
167
+ }
168
+
169
+ function validateDraftIdentity(input: unknown): {
170
+ id: string;
171
+ browserCapability: BrowserCapability;
172
+ } {
173
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
174
+ throw invalidSubmission();
175
+ }
176
+ const value = input as Record<string, unknown>;
177
+ if (
178
+ typeof value.id !== "string" ||
179
+ value.id.length === 0 ||
180
+ typeof value.browserCapability !== "string" ||
181
+ value.browserCapability.length === 0
182
+ ) {
183
+ throw invalidSubmission();
184
+ }
185
+ return {
186
+ id: value.id,
187
+ browserCapability: value.browserCapability as BrowserCapability,
188
+ };
189
+ }
190
+
191
+ function isBoundedRequiredText(
192
+ value: unknown,
193
+ maximum: number,
194
+ ): value is string {
195
+ return (
196
+ typeof value === "string" &&
197
+ value.trim().length > 0 &&
198
+ value.length <= maximum
199
+ );
200
+ }
201
+
202
+ function isPostType(value: unknown): value is SubmitPostInput["type"] {
203
+ return (
204
+ value === "Feature Request" ||
205
+ value === "Bug Report" ||
206
+ value === "General Feedback"
207
+ );
208
+ }
209
+
210
+ function isValidSubmitter(
211
+ value: unknown,
212
+ ): value is SubmitPostInput["submitter"] {
213
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
214
+ const submitter = value as Record<string, unknown>;
215
+ return (
216
+ typeof submitter.name === "string" &&
217
+ submitter.name.trim().length > 0 &&
218
+ isValidCommentEmail(submitter.email)
219
+ );
220
+ }
221
+
222
+ function invalidSubmission(): Error {
223
+ return new ActionablePortalFailure("Feedback submission is invalid.");
224
+ }
@@ -0,0 +1,58 @@
1
+ import type { ProductConfiguration } from "@feedbax/config";
2
+
3
+ export function createPortalHead(
4
+ product: ProductConfiguration,
5
+ stylesheetHref?: string,
6
+ ) {
7
+ return {
8
+ meta: [
9
+ { charSet: "utf-8" },
10
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
11
+ { title: product.name },
12
+ { name: "description", content: product.description },
13
+ { property: "og:title", content: product.name },
14
+ { property: "og:description", content: product.description },
15
+ { property: "og:type", content: "website" },
16
+ ...(product.siteUrl
17
+ ? [{ property: "og:url", content: product.siteUrl }]
18
+ : []),
19
+ ],
20
+ links: [
21
+ { rel: "icon", href: product.logo },
22
+ ...(product.siteUrl ? [{ rel: "canonical", href: product.siteUrl }] : []),
23
+ ...(stylesheetHref ? [{ rel: "stylesheet", href: stylesheetHref }] : []),
24
+ ],
25
+ };
26
+ }
27
+
28
+ export function createRouteHead(
29
+ product: ProductConfiguration,
30
+ route: {
31
+ title?: string;
32
+ description?: string;
33
+ path: string;
34
+ noIndex?: boolean;
35
+ },
36
+ ) {
37
+ const title = route.title ? `${route.title} · ${product.name}` : product.name;
38
+ const description = route.description ?? product.description;
39
+ const canonical = product.siteUrl
40
+ ? new URL(route.path, ensureTrailingSlash(product.siteUrl)).toString()
41
+ : undefined;
42
+ return {
43
+ meta: [
44
+ { title },
45
+ { name: "description", content: description },
46
+ { property: "og:title", content: title },
47
+ { property: "og:description", content: description },
48
+ { property: "og:type", content: "website" },
49
+ ...(canonical ? [{ property: "og:url", content: canonical }] : []),
50
+ ...(route.noIndex ? [{ name: "robots", content: "noindex" }] : []),
51
+ ],
52
+ links: canonical ? [{ rel: "canonical", href: canonical }] : [],
53
+ };
54
+ }
55
+
56
+ function ensureTrailingSlash(value: string) {
57
+ return value.endsWith("/") ? value : `${value}/`;
58
+ }