create-feedbax 0.1.0 → 0.2.0

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 (210) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +1261 -26
  4. package/dist/index.js +1402 -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/LICENSE +201 -0
  11. package/template/components.json +25 -0
  12. package/template/eslint.config.mjs +25 -0
  13. package/template/package.json +70 -14
  14. package/template/public/feedbax-mark.svg +4 -0
  15. package/template/public/robots.txt +3 -0
  16. package/template/src/authorized-draft-post.test.tsx +212 -0
  17. package/template/src/authorized-draft-post.tsx +204 -0
  18. package/template/src/authorized-draft-query.ts +74 -0
  19. package/template/src/browser-post-state.test.ts +120 -0
  20. package/template/src/browser-post-state.ts +134 -0
  21. package/template/src/browser-vote-state.test.ts +14 -0
  22. package/template/src/browser-vote-state.ts +27 -0
  23. package/template/src/capability-authorization-error.ts +3 -0
  24. package/template/src/changelog-page.test.tsx +139 -0
  25. package/template/src/changelog-page.tsx +219 -0
  26. package/template/src/changelog-query.ts +13 -0
  27. package/template/src/changelog-runtime.ts +27 -0
  28. package/template/src/cloudflare-turnstile.ts +31 -0
  29. package/template/src/cloudflare-workers-test.ts +3 -0
  30. package/template/src/cloudflare-workers.d.ts +18 -0
  31. package/template/src/comment-creation.test.tsx +270 -0
  32. package/template/src/comment-profile.ts +5 -0
  33. package/template/src/comment-thread-page.ts +18 -0
  34. package/template/src/components/command-palette-context.tsx +71 -0
  35. package/template/src/components/command-palette.tsx +246 -0
  36. package/template/src/components/create-post-trigger.tsx +44 -0
  37. package/template/src/components/device-profile-provider.tsx +309 -0
  38. package/template/src/components/header.tsx +95 -0
  39. package/template/src/components/loader.tsx +10 -0
  40. package/template/src/components/profile-menu.tsx +89 -0
  41. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  42. package/template/src/components/ui/components/attachment.tsx +207 -0
  43. package/template/src/components/ui/components/avatar.tsx +107 -0
  44. package/template/src/components/ui/components/badge.tsx +52 -0
  45. package/template/src/components/ui/components/bubble.tsx +128 -0
  46. package/template/src/components/ui/components/button-group.tsx +87 -0
  47. package/template/src/components/ui/components/button.tsx +56 -0
  48. package/template/src/components/ui/components/card.tsx +103 -0
  49. package/template/src/components/ui/components/checkbox.tsx +26 -0
  50. package/template/src/components/ui/components/command.tsx +194 -0
  51. package/template/src/components/ui/components/dialog.tsx +154 -0
  52. package/template/src/components/ui/components/drawer.tsx +229 -0
  53. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  54. package/template/src/components/ui/components/empty.tsx +101 -0
  55. package/template/src/components/ui/components/field.tsx +236 -0
  56. package/template/src/components/ui/components/input-group.tsx +155 -0
  57. package/template/src/components/ui/components/input.tsx +20 -0
  58. package/template/src/components/ui/components/item.tsx +201 -0
  59. package/template/src/components/ui/components/kbd.tsx +26 -0
  60. package/template/src/components/ui/components/label.tsx +18 -0
  61. package/template/src/components/ui/components/marker.tsx +71 -0
  62. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  63. package/template/src/components/ui/components/message.tsx +92 -0
  64. package/template/src/components/ui/components/native-select.tsx +65 -0
  65. package/template/src/components/ui/components/popover.tsx +88 -0
  66. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  67. package/template/src/components/ui/components/radio-group.tsx +36 -0
  68. package/template/src/components/ui/components/scroll-area.tsx +52 -0
  69. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  70. package/template/src/components/ui/components/select.tsx +199 -0
  71. package/template/src/components/ui/components/separator.tsx +23 -0
  72. package/template/src/components/ui/components/sheet.tsx +135 -0
  73. package/template/src/components/ui/components/skeleton.tsx +13 -0
  74. package/template/src/components/ui/components/sonner.tsx +43 -0
  75. package/template/src/components/ui/components/spinner.tsx +16 -0
  76. package/template/src/components/ui/components/tabs.tsx +80 -0
  77. package/template/src/components/ui/components/textarea.tsx +18 -0
  78. package/template/src/components/ui/components/tooltip.tsx +64 -0
  79. package/template/src/components/ui/hooks/.gitkeep +0 -0
  80. package/template/src/components/ui/lib/utils.ts +6 -0
  81. package/template/src/components/ui/styles/globals.css +151 -0
  82. package/template/src/create-post-overlay.tsx +82 -0
  83. package/template/src/draft-post-list.tsx +39 -0
  84. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  85. package/template/src/feedback-cache-invalidation.ts +97 -0
  86. package/template/src/feedback-runtime.ts +38 -0
  87. package/template/src/feedbax/changelog/index.test.ts +156 -0
  88. package/template/src/feedbax/changelog/index.ts +206 -0
  89. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  90. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  91. package/template/src/feedbax/config/index.test.ts +111 -0
  92. package/template/src/feedbax/config/index.ts +271 -0
  93. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  94. package/template/src/feedbax/feedback/index.test.ts +900 -0
  95. package/template/src/feedbax/feedback/index.ts +942 -0
  96. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  97. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  98. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  99. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  100. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  101. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  102. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  103. package/template/src/feedbax.test-fixture.ts +64 -0
  104. package/template/src/feedbax.ts +69 -0
  105. package/template/src/index.css +2138 -0
  106. package/template/src/lazy-create-post-overlay.tsx +6 -0
  107. package/template/src/lazy-post-detail-overlay.tsx +10 -0
  108. package/template/src/masked-post-link.tsx +27 -0
  109. package/template/src/overlay-post-detail.test.tsx +130 -0
  110. package/template/src/overlay-post-detail.tsx +92 -0
  111. package/template/src/portal-comments.test.ts +375 -0
  112. package/template/src/portal-comments.ts +384 -0
  113. package/template/src/portal-features.test.tsx +150 -0
  114. package/template/src/portal-feedback-form.tsx +515 -0
  115. package/template/src/portal-feedback-runtime.test.ts +32 -0
  116. package/template/src/portal-feedback-runtime.ts +55 -0
  117. package/template/src/portal-feedback-server-function.ts +93 -0
  118. package/template/src/portal-feedback-submission.test.ts +307 -0
  119. package/template/src/portal-feedback-submission.ts +214 -0
  120. package/template/src/portal-head.ts +58 -0
  121. package/template/src/portal-server-entry.ts +64 -0
  122. package/template/src/portal-voting.test.ts +106 -0
  123. package/template/src/portal-voting.ts +149 -0
  124. package/template/src/post-badges.tsx +27 -0
  125. package/template/src/post-creation-flow.test.tsx +230 -0
  126. package/template/src/post-creation-flow.tsx +88 -0
  127. package/template/src/post-detail-overlay.test.tsx +114 -0
  128. package/template/src/post-detail-overlay.tsx +67 -0
  129. package/template/src/post-feed-controls.test.tsx +239 -0
  130. package/template/src/post-feed-controls.tsx +298 -0
  131. package/template/src/post-presentation.ts +37 -0
  132. package/template/src/post-queries.test.ts +158 -0
  133. package/template/src/post-queries.ts +26 -0
  134. package/template/src/post-results.tsx +165 -0
  135. package/template/src/public-cache-policy.test.ts +141 -0
  136. package/template/src/public-cache-policy.ts +119 -0
  137. package/template/src/public-cache-tags.ts +7 -0
  138. package/template/src/public-changelog-page.ts +15 -0
  139. package/template/src/public-changelog-server-function.ts +27 -0
  140. package/template/src/public-date.ts +11 -0
  141. package/template/src/public-failure-state.test.tsx +69 -0
  142. package/template/src/public-feedback-index.tsx +124 -0
  143. package/template/src/public-feedback-page.test.tsx +290 -0
  144. package/template/src/public-feedback-page.ts +75 -0
  145. package/template/src/public-feedback-server-function.ts +22 -0
  146. package/template/src/public-portal-error.tsx +27 -0
  147. package/template/src/public-post-detail.tsx +682 -0
  148. package/template/src/public-post-page.test.tsx +173 -0
  149. package/template/src/public-post-page.ts +15 -0
  150. package/template/src/public-post-route.test.tsx +214 -0
  151. package/template/src/public-post-route.tsx +171 -0
  152. package/template/src/public-post-server-function.ts +48 -0
  153. package/template/src/public-post-unavailable.tsx +10 -0
  154. package/template/src/public-roadmap-page.test.tsx +139 -0
  155. package/template/src/public-roadmap-page.ts +24 -0
  156. package/template/src/public-roadmap-route.test.tsx +218 -0
  157. package/template/src/public-roadmap-route.tsx +50 -0
  158. package/template/src/public-roadmap-server-function.ts +44 -0
  159. package/template/src/public-roadmap-view.tsx +378 -0
  160. package/template/src/public-shell.test.tsx +518 -0
  161. package/template/src/roadmap-query.ts +21 -0
  162. package/template/src/root-branding.test.ts +53 -0
  163. package/template/src/routeTree.gen.ts +131 -0
  164. package/template/src/routed-post-creation-form.tsx +28 -0
  165. package/template/src/router.tsx +25 -0
  166. package/template/src/routes/__root.tsx +116 -0
  167. package/template/src/routes/changelog.tsx +39 -0
  168. package/template/src/routes/index.tsx +61 -0
  169. package/template/src/routes/p.$slug.tsx +16 -0
  170. package/template/src/routes/roadmap.tsx +11 -0
  171. package/template/src/routes/submit.tsx +28 -0
  172. package/template/src/safe-public-failure.ts +45 -0
  173. package/template/src/server.ts +111 -0
  174. package/template/src/similar-posts.tsx +45 -0
  175. package/template/src/submit-route.test.tsx +134 -0
  176. package/template/src/trusted-feedback-handler.test.ts +425 -0
  177. package/template/src/trusted-feedback-handler.ts +181 -0
  178. package/template/src/trusted-feedback-runtime.ts +17 -0
  179. package/template/src/turnstile-configuration.test.ts +37 -0
  180. package/template/src/turnstile-configuration.ts +19 -0
  181. package/template/src/use-media-query.ts +17 -0
  182. package/template/src/vote-toggle.test.tsx +171 -0
  183. package/template/src/vote-toggle.tsx +211 -0
  184. package/template/tsconfig.json +45 -0
  185. package/template/vite.config.ts +20 -0
  186. package/template/vitest.config.ts +4 -0
  187. package/template/vitest.setup.ts +3 -0
  188. package/template/wrangler.jsonc +35 -0
  189. package/dist/cli.d.ts +0 -3
  190. package/dist/cli.d.ts.map +0 -1
  191. package/dist/cli.js.map +0 -1
  192. package/dist/index.d.ts +0 -21
  193. package/dist/index.d.ts.map +0 -1
  194. package/dist/index.js.map +0 -1
  195. package/template/.env.example +0 -4
  196. package/template/README.md +0 -7
  197. package/template/apps/portal/components.json +0 -20
  198. package/template/apps/portal/package.json +0 -31
  199. package/template/apps/portal/src/lib/utils.ts +0 -6
  200. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  201. package/template/apps/portal/src/router.tsx +0 -10
  202. package/template/apps/portal/src/routes/__root.tsx +0 -31
  203. package/template/apps/portal/src/routes/index.tsx +0 -35
  204. package/template/apps/portal/src/styles.css +0 -91
  205. package/template/apps/portal/tsconfig.json +0 -15
  206. package/template/apps/portal/vite.config.ts +0 -13
  207. package/template/feedbax.config.ts +0 -13
  208. package/template/feedbax.jsonc +0 -11
  209. package/template/pnpm-workspace.yaml +0 -3
  210. package/template/turbo.json +0 -8
@@ -0,0 +1,6 @@
1
+ import { CreatePostOverlay } from "./create-post-overlay";
2
+ import { portalFeedbackMutations } from "./routed-post-creation-form";
3
+
4
+ export function LazyCreatePostOverlay() {
5
+ return <CreatePostOverlay mutations={portalFeedbackMutations} />;
6
+ }
@@ -0,0 +1,10 @@
1
+ import { OverlayPostDetail } from "./overlay-post-detail";
2
+ import { PostDetailOverlay } from "./post-detail-overlay";
3
+
4
+ export function LazyPostDetailOverlay() {
5
+ return (
6
+ <PostDetailOverlay
7
+ renderDetail={(slug) => <OverlayPostDetail slug={slug} />}
8
+ />
9
+ );
10
+ }
@@ -0,0 +1,27 @@
1
+ import { Link } from "@tanstack/react-router";
2
+ import type { AnchorHTMLAttributes } from "react";
3
+
4
+ export function PostLink({
5
+ slug,
6
+ contextual = false,
7
+ ...props
8
+ }: AnchorHTMLAttributes<HTMLAnchorElement> & {
9
+ slug: string;
10
+ contextual?: boolean;
11
+ }) {
12
+ if (!contextual) {
13
+ return <a {...props} href={`/p/${encodeURIComponent(slug)}`} />;
14
+ }
15
+ return (
16
+ <Link
17
+ {...props}
18
+ to="."
19
+ state={{ postDetailOverlay: { slug } }}
20
+ mask={{
21
+ to: "/p/$slug",
22
+ params: { slug },
23
+ unmaskOnReload: true,
24
+ }}
25
+ />
26
+ );
27
+ }
@@ -0,0 +1,130 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, render, screen, within } from "@testing-library/react";
4
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
+
6
+ import { OverlayPostDetail } from "./overlay-post-detail";
7
+ import {
8
+ getPublicPost,
9
+ getPublicPostComments,
10
+ } from "./public-post-server-function";
11
+ import { saveDeviceProfile } from "./browser-post-state";
12
+
13
+ vi.mock("./public-post-server-function", () => ({
14
+ getPublicPost: vi.fn(),
15
+ getPublicPostComments: vi.fn().mockResolvedValue({ items: [] }),
16
+ }));
17
+ vi.mock("./authorized-draft-post", () => ({
18
+ AuthorizedDraftPost: () => <p>Draft fallback</p>,
19
+ }));
20
+
21
+ beforeEach(() => {
22
+ saveDeviceProfile(localStorage, {
23
+ name: "Ari",
24
+ email: "ari@example.com",
25
+ });
26
+ });
27
+
28
+ afterEach(() => {
29
+ cleanup();
30
+ localStorage.clear();
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ it("shows loading instead of stale content when the requested slug changes", async () => {
35
+ let resolveSecond!: (post: typeof firstPost) => void;
36
+ vi.mocked(getPublicPost)
37
+ .mockResolvedValueOnce(firstPost)
38
+ .mockReturnValueOnce(
39
+ new Promise((resolve) => {
40
+ resolveSecond = resolve;
41
+ }),
42
+ );
43
+
44
+ const view = render(<OverlayPostDetail slug="first-post" />);
45
+ expect(
46
+ await screen.findByRole("heading", { name: "First Post" }),
47
+ ).toBeTruthy();
48
+
49
+ view.rerender(<OverlayPostDetail slug="second-post" />);
50
+ expect(
51
+ screen
52
+ .getByRole("main", { name: "Loading Post details" })
53
+ .getAttribute("aria-busy"),
54
+ ).toBe("true");
55
+ expect(screen.queryByRole("heading", { name: "First Post" })).toBeNull();
56
+
57
+ resolveSecond({ ...firstPost, slug: "second-post", title: "Second Post" });
58
+ expect(
59
+ await screen.findByRole("heading", { name: "Second Post" }),
60
+ ).toBeTruthy();
61
+ });
62
+
63
+ it("shows the designed Post detail with a canonical full-page link", async () => {
64
+ vi.mocked(getPublicPost).mockResolvedValue(firstPost);
65
+ vi.mocked(getPublicPostComments).mockResolvedValue({
66
+ items: [
67
+ {
68
+ id: "discussion-1",
69
+ comments: [
70
+ {
71
+ id: "comment-1",
72
+ body: "Visible in the dialog",
73
+ author: { kind: "participant", displayName: "Ari" },
74
+ createdAt: new Date("2026-08-25T01:00:00.000Z"),
75
+ },
76
+ ],
77
+ },
78
+ ],
79
+ });
80
+
81
+ render(<OverlayPostDetail slug="first-post" />);
82
+
83
+ expect(
84
+ await screen.findByRole("heading", { name: "First Post" }),
85
+ ).toBeTruthy();
86
+ expect(
87
+ screen.getByRole("link", { name: "Open full page" }).getAttribute("href"),
88
+ ).toBe("/p/first-post");
89
+ expect(screen.getByRole("complementary", { name: "Details" })).toBeTruthy();
90
+ expect(
91
+ within(screen.getByRole("region", { name: "Comments" })).getByText(
92
+ "Visible in the dialog",
93
+ ),
94
+ ).toBeTruthy();
95
+ expect(
96
+ await screen.findByRole("form", { name: "Comment composer" }),
97
+ ).toBeTruthy();
98
+ expect(getPublicPostComments).toHaveBeenCalledWith({
99
+ data: { slug: "first-post" },
100
+ });
101
+ expect(screen.queryByRole("link", { name: "Back to Feedback" })).toBeNull();
102
+ });
103
+
104
+ it("keeps the Post and Comment composer available when Comment loading fails", async () => {
105
+ vi.mocked(getPublicPost).mockResolvedValue(firstPost);
106
+ vi.mocked(getPublicPostComments).mockRejectedValue(
107
+ new Error("Comments temporarily unavailable"),
108
+ );
109
+
110
+ render(<OverlayPostDetail slug="first-post" />);
111
+
112
+ expect(
113
+ await screen.findByRole("heading", { name: "First Post" }),
114
+ ).toBeTruthy();
115
+ expect(
116
+ await screen.findByRole("form", { name: "Comment composer" }),
117
+ ).toBeTruthy();
118
+ expect(screen.getByText("No comments yet")).toBeTruthy();
119
+ expect(screen.queryByText("Draft fallback")).toBeNull();
120
+ });
121
+
122
+ const firstPost = {
123
+ slug: "first-post",
124
+ title: "First Post",
125
+ description: "First description",
126
+ type: "Feature Request" as const,
127
+ status: "New" as const,
128
+ createdAt: new Date("2026-08-22T10:00:00.000Z"),
129
+ updatedAt: new Date("2026-08-22T10:00:00.000Z"),
130
+ };
@@ -0,0 +1,92 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
3
+ import { ExternalLink } from "lucide-react";
4
+
5
+ import {
6
+ getPublicPost,
7
+ getPublicPostComments,
8
+ } from "./public-post-server-function";
9
+ import {
10
+ PublicPostDetail,
11
+ PublicPostDetailSkeleton,
12
+ } from "./public-post-detail";
13
+ import { AuthorizedDraftPost } from "./authorized-draft-post";
14
+ import { mutatePublicComment, submitPublicComment } from "./public-post-route";
15
+ import { mergeCommentThreads } from "./comment-thread-page";
16
+
17
+ type OverlayDetail = {
18
+ post: PublicPost | null;
19
+ comments: CommentThreadPage;
20
+ };
21
+
22
+ export function OverlayPostDetail({ slug }: { slug: string }) {
23
+ const [detail, setDetail] = useState<OverlayDetail>();
24
+ useEffect(() => {
25
+ let current = true;
26
+ setDetail(undefined);
27
+ getPublicPost({ data: { slug } })
28
+ .then(async (post) => ({
29
+ post: post ?? null,
30
+ comments: post
31
+ ? await getPublicPostComments({ data: { slug } }).catch(() => ({
32
+ items: [],
33
+ }))
34
+ : { items: [] },
35
+ }))
36
+ .then((value) => current && setDetail(value))
37
+ .catch(
38
+ () => current && setDetail({ post: null, comments: { items: [] } }),
39
+ );
40
+ return () => {
41
+ current = false;
42
+ };
43
+ }, [slug]);
44
+
45
+ if (detail === undefined) return <PublicPostDetailSkeleton />;
46
+ return (
47
+ <div className="overlay-post-detail">
48
+ <a
49
+ className="post-full-page-link"
50
+ href={`/p/${encodeURIComponent(slug)}`}
51
+ >
52
+ Open full page <ExternalLink aria-hidden="true" />
53
+ </a>
54
+ {detail.post ? (
55
+ <PublicPostDetail
56
+ post={detail.post}
57
+ display="overlay"
58
+ comments={detail.comments}
59
+ submitComment={submitPublicComment}
60
+ mutateComment={mutatePublicComment}
61
+ loadMore={
62
+ detail.comments.nextCursor
63
+ ? async () => {
64
+ const next = await getPublicPostComments({
65
+ data: { slug, cursor: detail.comments.nextCursor },
66
+ });
67
+ setDetail((current) =>
68
+ current
69
+ ? {
70
+ ...current,
71
+ comments: {
72
+ items: mergeCommentThreads(
73
+ current.comments.items,
74
+ next.items,
75
+ ),
76
+ ...(next.nextCursor
77
+ ? { nextCursor: next.nextCursor }
78
+ : {}),
79
+ },
80
+ }
81
+ : current,
82
+ );
83
+ }
84
+ : undefined
85
+ }
86
+ />
87
+ ) : (
88
+ <AuthorizedDraftPost slug={slug} />
89
+ )}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,375 @@
1
+ import { createFeedbackModule } from "@feedbax/feedback";
2
+ import { createHmac } from "node:crypto";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ createPortalCommentHandler,
7
+ createPortalCommentMutationHandler,
8
+ createPortalCommentRequestHandler,
9
+ } from "./portal-comments";
10
+
11
+ const signingSecret = "a-high-entropy-secret-with-at-least-32-characters";
12
+
13
+ function publishedFeedback() {
14
+ const now = new Date("2026-08-25T00:00:00.000Z");
15
+ return createFeedbackModule({
16
+ initialItems: [
17
+ {
18
+ id: "post",
19
+ slug: "post",
20
+ title: "Post",
21
+ description: "Visible.",
22
+ type: "Feature Request",
23
+ status: "Planned",
24
+ published: true,
25
+ createdAt: now,
26
+ updatedAt: now,
27
+ },
28
+ ],
29
+ });
30
+ }
31
+
32
+ describe("portal Comment handler", () => {
33
+ it("requires an unverified Device Profile name and valid email while omitting email from the command", async () => {
34
+ const feedback = publishedFeedback();
35
+ const comment = createPortalCommentRequestHandler({
36
+ feedback,
37
+ commentsEnabled: true,
38
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
39
+ rateLimitKey: "participant-ip",
40
+ participationSigningSecret: signingSecret,
41
+ });
42
+
43
+ const response = await comment(
44
+ new Request("https://portal.test/internal/comments", {
45
+ method: "POST",
46
+ body: JSON.stringify({
47
+ slug: "post",
48
+ body: "Hello",
49
+ displayName: "Ari",
50
+ email: "private@example.com",
51
+ }),
52
+ }),
53
+ );
54
+ const serialized = await response.text();
55
+ expect(response.status).toBe(200);
56
+ expect(serialized).not.toContain("private@example.com");
57
+ await expect(
58
+ feedback.listCommentThreads({ slug: "post" }),
59
+ ).resolves.toMatchObject({
60
+ items: [
61
+ {
62
+ comments: [
63
+ {
64
+ body: "Hello",
65
+ author: { kind: "participant", displayName: "Ari" },
66
+ },
67
+ ],
68
+ },
69
+ ],
70
+ });
71
+
72
+ const missingProfile = await comment(
73
+ new Request("https://portal.test/internal/comments", {
74
+ method: "POST",
75
+ body: JSON.stringify({ slug: "post", body: "Hello", displayName: " " }),
76
+ }),
77
+ );
78
+ await expect(missingProfile.json()).resolves.toMatchObject({
79
+ error: expect.stringContaining("display name is required"),
80
+ });
81
+
82
+ for (const email of [undefined, "not-an-email"]) {
83
+ const response = await comment(
84
+ new Request("https://portal.test/internal/comments", {
85
+ method: "POST",
86
+ body: JSON.stringify({
87
+ slug: "post",
88
+ body: "Hello",
89
+ displayName: "Ari",
90
+ email,
91
+ }),
92
+ }),
93
+ );
94
+ expect(response.status).toBe(400);
95
+ await expect(response.json()).resolves.toMatchObject({
96
+ error: expect.stringContaining("valid email is required"),
97
+ });
98
+ }
99
+ });
100
+
101
+ it("creates replies, rate-limits writes, and reuses a thirty-minute Participation Pass", async () => {
102
+ let now = 1_000;
103
+ const verify = vi.fn().mockResolvedValue(true);
104
+ const rateLimit = vi.fn().mockResolvedValue({ success: true });
105
+ const comment = createPortalCommentHandler({
106
+ feedback: publishedFeedback(),
107
+ commentsEnabled: true,
108
+ rateLimiter: { limit: rateLimit },
109
+ rateLimitKey: "participant-ip",
110
+ turnstileVerifier: { verify },
111
+ participationSigningSecret: signingSecret,
112
+ now: () => now,
113
+ });
114
+ const first = await comment({
115
+ slug: "post",
116
+ body: "Top level",
117
+ displayName: "Ari",
118
+ email: "private@example.com",
119
+ turnstileToken: "verified",
120
+ });
121
+ now += 29 * 60 * 1_000;
122
+ const reply = await comment({
123
+ slug: "post",
124
+ discussionId: first.comment.discussionId,
125
+ body: "Reply",
126
+ displayName: "Ari",
127
+ email: "private@example.com",
128
+ participationPass: first.participationPass,
129
+ });
130
+
131
+ expect(reply.comment.discussionId).toBe(first.comment.discussionId);
132
+ expect(verify).toHaveBeenCalledTimes(1);
133
+ expect(rateLimit).toHaveBeenCalledWith({ key: "comment:participant-ip" });
134
+ });
135
+
136
+ it("returns an actionable failure when the Comment rate limit is exceeded", async () => {
137
+ const comment = createPortalCommentHandler({
138
+ feedback: publishedFeedback(),
139
+ commentsEnabled: true,
140
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: false }) },
141
+ rateLimitKey: "participant-ip",
142
+ participationSigningSecret: signingSecret,
143
+ });
144
+ await expect(
145
+ comment({
146
+ slug: "post",
147
+ body: "Hello",
148
+ displayName: "Ari",
149
+ email: "private@example.com",
150
+ }),
151
+ ).rejects.toThrow("Comment rate limit exceeded");
152
+ });
153
+
154
+ it("issues a scoped fifteen-minute capability after creation and authorizes edit and delete", async () => {
155
+ const now = Date.now();
156
+ const feedback = publishedFeedback();
157
+ const secret = "a-high-entropy-secret-with-at-least-32-characters";
158
+ const create = createPortalCommentHandler({
159
+ feedback,
160
+ commentsEnabled: true,
161
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
162
+ rateLimitKey: "participant-ip",
163
+ participationSigningSecret: secret,
164
+ now: () => now,
165
+ });
166
+ const created = await create({
167
+ slug: "post",
168
+ body: "Original",
169
+ displayName: "Ari",
170
+ email: "private@example.com",
171
+ });
172
+ expect(created.commentCapability).toEqual(expect.any(String));
173
+
174
+ const mutate = createPortalCommentMutationHandler({
175
+ feedback,
176
+ commentsEnabled: true,
177
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
178
+ rateLimitKey: "participant-ip",
179
+ participationSigningSecret: secret,
180
+ now: () => now,
181
+ });
182
+ await expect(
183
+ mutate({
184
+ action: "edit",
185
+ commentId: created.comment.id,
186
+ commentCapability: created.commentCapability,
187
+ body: "Corrected",
188
+ }),
189
+ ).resolves.toMatchObject({ comment: { body: "Corrected" } });
190
+ await expect(
191
+ mutate({
192
+ action: "delete",
193
+ commentId: created.comment.id,
194
+ commentCapability: created.commentCapability,
195
+ }),
196
+ ).resolves.toEqual({ deleted: true });
197
+ });
198
+
199
+ it("rejects tampered, cross-Comment, expired, and Product Team Comment capabilities uniformly", async () => {
200
+ let now = Date.now();
201
+ const feedback = publishedFeedback();
202
+ const secret = "a-high-entropy-secret-with-at-least-32-characters";
203
+ const create = createPortalCommentHandler({
204
+ feedback,
205
+ commentsEnabled: true,
206
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
207
+ rateLimitKey: "participant-ip",
208
+ participationSigningSecret: secret,
209
+ now: () => now,
210
+ });
211
+ const first = await create({
212
+ slug: "post",
213
+ body: "First",
214
+ displayName: "Ari",
215
+ email: "private@example.com",
216
+ });
217
+ const second = await create({
218
+ slug: "post",
219
+ body: "Second",
220
+ displayName: "Ari",
221
+ email: "private@example.com",
222
+ });
223
+ const mutate = createPortalCommentMutationHandler({
224
+ feedback,
225
+ commentsEnabled: true,
226
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
227
+ rateLimitKey: "participant-ip",
228
+ participationSigningSecret: secret,
229
+ now: () => now,
230
+ });
231
+ const rejected = "Comment could not be changed with this capability.";
232
+
233
+ await expect(
234
+ mutate({
235
+ action: "delete",
236
+ commentId: second.comment.id,
237
+ commentCapability: first.commentCapability,
238
+ }),
239
+ ).rejects.toThrow(rejected);
240
+ await expect(
241
+ mutate({
242
+ action: "delete",
243
+ commentId: first.comment.id,
244
+ commentCapability: `${first.commentCapability}x`,
245
+ }),
246
+ ).rejects.toThrow(rejected);
247
+ now = first.commentCapabilityExpiresAt;
248
+ await expect(
249
+ mutate({
250
+ action: "delete",
251
+ commentId: first.comment.id,
252
+ commentCapability: first.commentCapability,
253
+ }),
254
+ ).rejects.toThrow(rejected);
255
+ });
256
+
257
+ it("enforces the exact expiry boundary and cannot mutate Product Team Comments", async () => {
258
+ const createdAt = new Date();
259
+ let now = createdAt.getTime() + 15 * 60 * 1_000 - 1;
260
+ const feedback = createFeedbackModule({
261
+ initialItems: [
262
+ {
263
+ id: "post",
264
+ slug: "post",
265
+ title: "Post",
266
+ description: "Visible.",
267
+ type: "Feature Request",
268
+ status: "Planned",
269
+ published: true,
270
+ createdAt,
271
+ updatedAt: createdAt,
272
+ },
273
+ ],
274
+ initialComments: [
275
+ {
276
+ id: "team-comment",
277
+ postId: "post",
278
+ discussionId: "discussion",
279
+ body: "Official response",
280
+ author: { kind: "product-team", displayName: "Product Team" },
281
+ createdAt,
282
+ resolved: false,
283
+ },
284
+ ],
285
+ });
286
+ const capability = commentCapabilityFor(
287
+ "team-comment",
288
+ createdAt.getTime() + 15 * 60 * 1_000,
289
+ signingSecret,
290
+ );
291
+ const mutate = createPortalCommentMutationHandler({
292
+ feedback,
293
+ commentsEnabled: true,
294
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
295
+ rateLimitKey: "participant-ip",
296
+ participationSigningSecret: signingSecret,
297
+ now: () => now,
298
+ });
299
+
300
+ await expect(
301
+ mutate({
302
+ action: "delete",
303
+ commentId: "team-comment",
304
+ commentCapability: capability,
305
+ }),
306
+ ).rejects.toThrow("no longer available");
307
+ now += 1;
308
+ await expect(
309
+ mutate({
310
+ action: "delete",
311
+ commentId: "team-comment",
312
+ commentCapability: capability,
313
+ }),
314
+ ).rejects.toThrow("could not be changed with this capability");
315
+ });
316
+
317
+ it("rate-limits Comment mutations and requires a current Participation Pass when verification is enabled", async () => {
318
+ const feedback = publishedFeedback();
319
+ const createdAt = Date.now();
320
+ const blocked = createPortalCommentMutationHandler({
321
+ feedback,
322
+ commentsEnabled: true,
323
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: false }) },
324
+ rateLimitKey: "participant-ip",
325
+ participationSigningSecret: signingSecret,
326
+ now: () => createdAt,
327
+ });
328
+ await expect(
329
+ blocked({
330
+ action: "delete",
331
+ commentId: "comment",
332
+ commentCapability: commentCapabilityFor(
333
+ "comment",
334
+ createdAt + 60_000,
335
+ signingSecret,
336
+ ),
337
+ }),
338
+ ).rejects.toThrow("rate limit exceeded");
339
+
340
+ const verificationRequired = createPortalCommentMutationHandler({
341
+ feedback,
342
+ commentsEnabled: true,
343
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
344
+ rateLimitKey: "participant-ip",
345
+ turnstileVerifier: { verify: vi.fn() },
346
+ participationSigningSecret: signingSecret,
347
+ now: () => createdAt,
348
+ });
349
+ await expect(
350
+ verificationRequired({
351
+ action: "delete",
352
+ commentId: "comment",
353
+ commentCapability: commentCapabilityFor(
354
+ "comment",
355
+ createdAt + 60_000,
356
+ signingSecret,
357
+ ),
358
+ }),
359
+ ).rejects.toThrow("verification is required");
360
+ });
361
+ });
362
+
363
+ function commentCapabilityFor(
364
+ commentId: string,
365
+ expiresAt: number,
366
+ secret: string,
367
+ ) {
368
+ const payload = Buffer.from(
369
+ JSON.stringify({ commentId, actions: ["edit", "delete"], expiresAt }),
370
+ ).toString("base64url");
371
+ const signature = createHmac("sha256", secret)
372
+ .update(payload)
373
+ .digest("base64url");
374
+ return `${payload}.${signature}`;
375
+ }