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,403 @@
1
+ import {
2
+ CommentEligibilityError,
3
+ CommentValidationError,
4
+ type CreatedComment,
5
+ type FeedbackModule,
6
+ } from "@feedbax/feedback";
7
+ import { createHmac, timingSafeEqual } from "node:crypto";
8
+
9
+ import type { TurnstileVerifier } from "./cloudflare-turnstile";
10
+ import type { PortalSubmissionRateLimiter } from "./portal-feedback-submission";
11
+ import {
12
+ issueParticipationPass,
13
+ requiredParticipationSigningSecret,
14
+ verifyParticipationPass,
15
+ } from "./portal-voting";
16
+ import { ActionablePortalFailure } from "./safe-public-failure";
17
+ import { isValidCommentEmail } from "./comment-profile";
18
+
19
+ export interface PortalCommentResult {
20
+ comment: CreatedComment;
21
+ commentCapability: string;
22
+ commentCapabilityExpiresAt: number;
23
+ participationPass?: string;
24
+ }
25
+
26
+ const commentCapabilityLifetime = 15 * 60 * 1_000;
27
+
28
+ export interface PortalCommentRequest {
29
+ imageIds?: string[];
30
+ slug: string;
31
+ body: string;
32
+ displayName: string;
33
+ email: string;
34
+ discussionId?: string;
35
+ turnstileToken?: string;
36
+ participationPass?: string;
37
+ }
38
+
39
+ export function createPortalCommentHandler(options: {
40
+ verifiedParticipant?: { displayName: string };
41
+ feedback: Pick<FeedbackModule, "createComment" | "replyToCommentThread">;
42
+ commentsEnabled: boolean;
43
+ rateLimiter: PortalSubmissionRateLimiter;
44
+ rateLimitKey: string;
45
+ turnstileVerifier?: TurnstileVerifier;
46
+ participationSigningSecret?: string;
47
+ now?: () => number;
48
+ }) {
49
+ return async (input: unknown): Promise<PortalCommentResult> => {
50
+ if (!options.commentsEnabled)
51
+ throw new ActionablePortalFailure(
52
+ "Comments are disabled for this Installation.",
53
+ "comments_disabled",
54
+ );
55
+ const value = validateCommentInput(input, options.verifiedParticipant);
56
+ const { success } = await options.rateLimiter.limit({
57
+ key: `comment:${options.rateLimitKey}`,
58
+ });
59
+ if (!success)
60
+ throw new ActionablePortalFailure(
61
+ "Comment rate limit exceeded. Try again shortly.",
62
+ "rate_limited",
63
+ );
64
+
65
+ let participationPass: string | undefined;
66
+ if (options.turnstileVerifier) {
67
+ const secret = requiredParticipationSigningSecret(
68
+ options.participationSigningSecret,
69
+ );
70
+ const now = options.now?.() ?? Date.now();
71
+ if (
72
+ !value.participationPass ||
73
+ !verifyParticipationPass(value.participationPass, secret, now)
74
+ ) {
75
+ if (!value.turnstileToken)
76
+ throw new ActionablePortalFailure(
77
+ "Comment verification is required.",
78
+ "verification_required",
79
+ );
80
+ const verified = await options.turnstileVerifier.verify({
81
+ token: value.turnstileToken,
82
+ remoteIp: options.rateLimitKey,
83
+ });
84
+ if (!verified)
85
+ throw new ActionablePortalFailure(
86
+ "Comment verification failed.",
87
+ "verification_failed",
88
+ );
89
+ participationPass = issueParticipationPass(secret, now);
90
+ }
91
+ }
92
+
93
+ const command = {
94
+ ...(value.imageIds ? { imageIds: value.imageIds } : {}),
95
+ slug: value.slug,
96
+ body: value.body,
97
+ displayName: value.displayName,
98
+ };
99
+ const comment = value.discussionId
100
+ ? await options.feedback.replyToCommentThread({
101
+ ...command,
102
+ discussionId: value.discussionId,
103
+ })
104
+ : await options.feedback.createComment(command);
105
+ const capabilitySecret = requiredParticipationSigningSecret(
106
+ options.participationSigningSecret,
107
+ );
108
+ const capabilityExpiresAt =
109
+ comment.createdAt.getTime() + commentCapabilityLifetime;
110
+ return {
111
+ comment,
112
+ commentCapability: issueCommentCapability(
113
+ comment.id,
114
+ capabilitySecret,
115
+ capabilityExpiresAt,
116
+ ),
117
+ commentCapabilityExpiresAt: capabilityExpiresAt,
118
+ ...(participationPass ? { participationPass } : {}),
119
+ };
120
+ };
121
+ }
122
+
123
+ export function createPortalCommentMutationHandler(options: {
124
+ feedback: Pick<FeedbackModule, "editComment" | "deleteComment">;
125
+ commentsEnabled: boolean;
126
+ rateLimiter: PortalSubmissionRateLimiter;
127
+ rateLimitKey: string;
128
+ turnstileVerifier?: TurnstileVerifier;
129
+ participationSigningSecret?: string;
130
+ now?: () => number;
131
+ }) {
132
+ return async (input: unknown) => {
133
+ if (!options.commentsEnabled) throw invalidCommentCapability();
134
+ const value = validateCommentMutationInput(input);
135
+ const secret = requiredParticipationSigningSecret(
136
+ options.participationSigningSecret,
137
+ );
138
+ const { success } = await options.rateLimiter.limit({
139
+ key: `comment:${options.rateLimitKey}`,
140
+ });
141
+ if (!success)
142
+ throw new ActionablePortalFailure(
143
+ "Comment rate limit exceeded. Try again shortly.",
144
+ "rate_limited",
145
+ );
146
+ if (
147
+ options.turnstileVerifier &&
148
+ (!value.participationPass ||
149
+ !verifyParticipationPass(
150
+ value.participationPass,
151
+ secret,
152
+ options.now?.() ?? Date.now(),
153
+ ))
154
+ )
155
+ throw new ActionablePortalFailure(
156
+ "Comment verification is required.",
157
+ "verification_required",
158
+ );
159
+ if (
160
+ !verifyCommentCapability(
161
+ value.commentCapability,
162
+ value.commentId,
163
+ value.action,
164
+ secret,
165
+ options.now?.() ?? Date.now(),
166
+ )
167
+ )
168
+ throw invalidCommentCapability();
169
+ try {
170
+ if (value.action === "delete") {
171
+ await options.feedback.deleteComment({ commentId: value.commentId });
172
+ return { deleted: true as const };
173
+ }
174
+ return {
175
+ comment: await options.feedback.editComment({
176
+ commentId: value.commentId,
177
+ body: value.body,
178
+ }),
179
+ };
180
+ } catch {
181
+ throw new ActionablePortalFailure(
182
+ "Comment is no longer available to change. Refresh the discussion and try again.",
183
+ "comment_unavailable",
184
+ );
185
+ }
186
+ };
187
+ }
188
+
189
+ function issueCommentCapability(
190
+ commentId: string,
191
+ secret: string,
192
+ expiresAt: number,
193
+ ) {
194
+ const payload = Buffer.from(
195
+ JSON.stringify({
196
+ commentId,
197
+ actions: ["edit", "delete"],
198
+ expiresAt,
199
+ }),
200
+ ).toString("base64url");
201
+ return `${payload}.${signCommentCapability(payload, secret)}`;
202
+ }
203
+
204
+ function verifyCommentCapability(
205
+ capability: string,
206
+ commentId: string,
207
+ action: "edit" | "delete",
208
+ secret: string,
209
+ now: number,
210
+ ) {
211
+ const [payload, signature, extra] = capability.split(".");
212
+ if (!payload || !signature || extra) return false;
213
+ const expected = Buffer.from(signCommentCapability(payload, secret));
214
+ const received = Buffer.from(signature);
215
+ if (
216
+ expected.length !== received.length ||
217
+ !timingSafeEqual(expected, received)
218
+ )
219
+ return false;
220
+ try {
221
+ const value: unknown = JSON.parse(
222
+ Buffer.from(payload, "base64url").toString("utf8"),
223
+ );
224
+ return Boolean(
225
+ value &&
226
+ typeof value === "object" &&
227
+ Reflect.get(value, "commentId") === commentId &&
228
+ Array.isArray(Reflect.get(value, "actions")) &&
229
+ (Reflect.get(value, "actions") as unknown[]).includes(action) &&
230
+ typeof Reflect.get(value, "expiresAt") === "number" &&
231
+ (Reflect.get(value, "expiresAt") as number) > now,
232
+ );
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ function signCommentCapability(payload: string, secret: string) {
239
+ return createHmac("sha256", secret).update(payload).digest("base64url");
240
+ }
241
+
242
+ function validateCommentMutationInput(input: unknown): {
243
+ action: "edit" | "delete";
244
+ commentId: string;
245
+ commentCapability: string;
246
+ body: string;
247
+ participationPass?: string;
248
+ } {
249
+ if (!input || typeof input !== "object" || Array.isArray(input))
250
+ throw invalidCommentCapability();
251
+ const value = input as Record<string, unknown>;
252
+ if (
253
+ (value.action !== "edit" && value.action !== "delete") ||
254
+ typeof value.commentId !== "string" ||
255
+ !value.commentId ||
256
+ typeof value.commentCapability !== "string" ||
257
+ !value.commentCapability ||
258
+ (value.action === "edit" &&
259
+ (typeof value.body !== "string" || !value.body.trim()))
260
+ )
261
+ throw invalidCommentCapability();
262
+ return {
263
+ action: value.action as "edit" | "delete",
264
+ commentId: value.commentId,
265
+ commentCapability: value.commentCapability,
266
+ body: typeof value.body === "string" ? value.body.trim() : "",
267
+ ...(typeof value.participationPass === "string"
268
+ ? { participationPass: value.participationPass }
269
+ : {}),
270
+ };
271
+ }
272
+
273
+ function invalidCommentCapability() {
274
+ return new ActionablePortalFailure(
275
+ "Comment could not be changed with this capability.",
276
+ "invalid_comment_capability",
277
+ );
278
+ }
279
+
280
+ export function createPortalCommentRequestHandler(
281
+ options: Parameters<typeof createPortalCommentHandler>[0],
282
+ ) {
283
+ const comment = createPortalCommentHandler(options);
284
+ return async (request: Request): Promise<Response> => {
285
+ try {
286
+ return Response.json(await comment(await request.json()));
287
+ } catch (error) {
288
+ const actionable =
289
+ error instanceof ActionablePortalFailure ||
290
+ error instanceof CommentEligibilityError ||
291
+ error instanceof CommentValidationError;
292
+ return Response.json(
293
+ {
294
+ error: actionable
295
+ ? error.message
296
+ : "Comment could not be saved. Try again.",
297
+ code:
298
+ error instanceof ActionablePortalFailure
299
+ ? (error.code ?? "action_failed")
300
+ : error instanceof CommentEligibilityError
301
+ ? "ineligible_post"
302
+ : error instanceof CommentValidationError
303
+ ? "invalid_request"
304
+ : "temporarily_unavailable",
305
+ },
306
+ {
307
+ status:
308
+ error instanceof ActionablePortalFailure &&
309
+ error.code === "rate_limited"
310
+ ? 429
311
+ : actionable
312
+ ? 400
313
+ : 503,
314
+ },
315
+ );
316
+ }
317
+ };
318
+ }
319
+
320
+ export function createPortalCommentMutationRequestHandler(
321
+ options: Parameters<typeof createPortalCommentMutationHandler>[0],
322
+ ) {
323
+ const mutate = createPortalCommentMutationHandler(options);
324
+ return async (request: Request): Promise<Response> => {
325
+ try {
326
+ return Response.json(await mutate(await request.json()));
327
+ } catch (error) {
328
+ const actionable = error instanceof ActionablePortalFailure;
329
+ return Response.json(
330
+ {
331
+ error: actionable
332
+ ? error.message
333
+ : "Comment could not be changed. Try again.",
334
+ code: actionable
335
+ ? (error.code ?? "action_failed")
336
+ : "temporarily_unavailable",
337
+ },
338
+ { status: actionable ? 400 : 503 },
339
+ );
340
+ }
341
+ };
342
+ }
343
+
344
+ function validateCommentInput(
345
+ input: unknown,
346
+ participant?: { displayName: string },
347
+ ): PortalCommentRequest {
348
+ if (!input || typeof input !== "object" || Array.isArray(input))
349
+ throw invalidCommentRequest();
350
+ const value: Record<string, unknown> = {
351
+ ...(input as Record<string, unknown>),
352
+ ...(participant ? { displayName: participant.displayName } : {}),
353
+ };
354
+ if (typeof value.slug !== "string" || !value.slug.trim())
355
+ throw invalidCommentRequest();
356
+ if (typeof value.body !== "string" || !value.body.trim())
357
+ throw new ActionablePortalFailure(
358
+ "Comment text is required.",
359
+ "invalid_request",
360
+ );
361
+ if (typeof value.displayName !== "string" || !value.displayName.trim())
362
+ throw new ActionablePortalFailure(
363
+ "Device Profile display name is required before commenting.",
364
+ "profile_required",
365
+ );
366
+ if (!participant && !isValidCommentEmail(value.email))
367
+ throw new ActionablePortalFailure(
368
+ "A Device Profile with a valid email is required before commenting.",
369
+ "profile_required",
370
+ );
371
+ if (
372
+ value.imageIds !== undefined &&
373
+ (!Array.isArray(value.imageIds) ||
374
+ value.imageIds.length > 4 ||
375
+ value.imageIds.some(
376
+ (id) => typeof id !== "string" || !/^[-a-f0-9]{36}$/.test(id),
377
+ ))
378
+ )
379
+ throw invalidCommentRequest();
380
+ return {
381
+ ...(value.imageIds ? { imageIds: value.imageIds as string[] } : {}),
382
+ slug: value.slug.trim(),
383
+ body: value.body.trim(),
384
+ displayName: value.displayName.trim(),
385
+ email: typeof value.email === "string" ? value.email.trim() : "",
386
+ ...(typeof value.discussionId === "string" && value.discussionId
387
+ ? { discussionId: value.discussionId }
388
+ : {}),
389
+ ...(typeof value.turnstileToken === "string"
390
+ ? { turnstileToken: value.turnstileToken }
391
+ : {}),
392
+ ...(typeof value.participationPass === "string"
393
+ ? { participationPass: value.participationPass }
394
+ : {}),
395
+ };
396
+ }
397
+
398
+ function invalidCommentRequest() {
399
+ return new ActionablePortalFailure(
400
+ "Comment request is invalid.",
401
+ "invalid_request",
402
+ );
403
+ }
@@ -0,0 +1,150 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, render, screen, within } from "@testing-library/react";
4
+ import {
5
+ createMemoryHistory,
6
+ createRootRoute,
7
+ createRouter,
8
+ RouterProvider,
9
+ } from "@tanstack/react-router";
10
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
11
+ import { afterEach, describe, expect, it } from "vitest";
12
+
13
+ import {
14
+ CommandPalette,
15
+ CommandPaletteProvider,
16
+ CommandPaletteTrigger,
17
+ } from "./components/command-palette";
18
+ import Header from "./components/header";
19
+ import { PostFeedControls } from "./post-feed-controls";
20
+ import { PublicPostDetail } from "./public-post-detail";
21
+ import { publicPostSearch } from "./public-feedback-page";
22
+ import { ensureChangelogEnabled } from "./routes/changelog";
23
+
24
+ const votingDisabled = {
25
+ voting: false,
26
+ comments: true,
27
+ changelog: true,
28
+ } as const;
29
+ const commentsDisabled = {
30
+ voting: true,
31
+ comments: false,
32
+ changelog: true,
33
+ } as const;
34
+ const changelogDisabled = {
35
+ voting: true,
36
+ comments: true,
37
+ changelog: false,
38
+ } as const;
39
+ const allDisabled = {
40
+ voting: false,
41
+ comments: false,
42
+ changelog: false,
43
+ } as const;
44
+
45
+ afterEach(cleanup);
46
+
47
+ describe("disabled portal capabilities", () => {
48
+ it("makes New the only accepted and visible feed ordering without voting", () => {
49
+ expect(publicPostSearch({ sort: "top" }, votingDisabled).sort).toBe("new");
50
+ render(
51
+ <PostFeedControls features={votingDisabled} search={{ sort: "new" }} />,
52
+ );
53
+ expect(
54
+ within(screen.getByRole("group", { name: "Sort Posts" }))
55
+ .getAllByRole("button")
56
+ .map((button) => button.textContent),
57
+ ).toEqual(["New"]);
58
+ });
59
+
60
+ it("removes the complete Comment treatment independently", () => {
61
+ render(
62
+ <PublicPostDetail
63
+ features={commentsDisabled}
64
+ post={{
65
+ slug: "plain-post",
66
+ title: "Plain Post",
67
+ description: "No unavailable capabilities are advertised.",
68
+ type: "General Feedback",
69
+ status: "New",
70
+ voteCount: 0,
71
+ createdAt: new Date("2026-08-25T00:00:00Z"),
72
+ updatedAt: new Date("2026-08-25T00:00:00Z"),
73
+ }}
74
+ />,
75
+ );
76
+ expect(screen.queryByText(/comment/i)).toBeNull();
77
+ expect(
78
+ screen.getByRole("button", { name: "Add Vote, 0 Votes" }),
79
+ ).toBeTruthy();
80
+ });
81
+
82
+ it("removes score treatment independently", () => {
83
+ render(
84
+ <PublicPostDetail
85
+ features={votingDisabled}
86
+ post={{
87
+ slug: "plain-post",
88
+ title: "Plain Post",
89
+ description: "Voting is not advertised.",
90
+ type: "General Feedback",
91
+ status: "New",
92
+ createdAt: new Date("2026-08-25T00:00:00Z"),
93
+ updatedAt: new Date("2026-08-25T00:00:00Z"),
94
+ }}
95
+ />,
96
+ );
97
+ expect(screen.queryByLabelText(/score/i)).toBeNull();
98
+ expect(screen.getByRole("region", { name: "Comments" })).toBeTruthy();
99
+ });
100
+
101
+ it("removes Changelog from header and command navigation", async () => {
102
+ const rootRoute = createRootRoute({
103
+ component: () => (
104
+ <QueryClientProvider client={new QueryClient()}>
105
+ <CommandPaletteProvider>
106
+ <Header features={changelogDisabled} />
107
+ <CommandPaletteTrigger />
108
+ <CommandPalette
109
+ features={changelogDisabled}
110
+ searchPosts={async () => ({ items: [] })}
111
+ />
112
+ </CommandPaletteProvider>
113
+ </QueryClientProvider>
114
+ ),
115
+ });
116
+ const router = createRouter({
117
+ routeTree: rootRoute,
118
+ history: createMemoryHistory({ initialEntries: ["/"] }),
119
+ });
120
+ await router.load();
121
+ render(<RouterProvider router={router} />);
122
+ expect(screen.queryByRole("link", { name: "Changelog" })).toBeNull();
123
+ screen.getByRole("button", { name: "Search" }).click();
124
+ expect(screen.queryByText("Changelog")).toBeNull();
125
+ });
126
+
127
+ it("rejects the Changelog route when Changelog is disabled", () => {
128
+ expect(() => ensureChangelogEnabled(changelogDisabled)).toThrow();
129
+ });
130
+
131
+ it("removes every unavailable promise in combination", () => {
132
+ render(
133
+ <PublicPostDetail
134
+ features={allDisabled}
135
+ post={{
136
+ slug: "minimal-post",
137
+ title: "Minimal Post",
138
+ description: "Only feedback details remain.",
139
+ type: "General Feedback",
140
+ status: "New",
141
+ createdAt: new Date("2026-08-25T00:00:00Z"),
142
+ updatedAt: new Date("2026-08-25T00:00:00Z"),
143
+ }}
144
+ />,
145
+ );
146
+ expect(screen.queryByLabelText(/score|comment/i)).toBeNull();
147
+ expect(screen.queryByText(/comment/i)).toBeNull();
148
+ });
149
+ });
150
+ // @vitest-environment jsdom