create-feedbax 0.2.0 → 0.2.2

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 (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -1,3 +1,4 @@
1
+ import type { PostOwnership } from "./ownership";
1
2
  import {
2
3
  createHash,
3
4
  randomBytes,
@@ -9,16 +10,18 @@ import {
9
10
  createNotionFeedbackStorage,
10
11
  type NotionFeedbackStorageOptions,
11
12
  } from "./notion-feedback";
13
+ import {
14
+ createGitHubFeedbackStorage,
15
+ type GitHubFeedbackOptions,
16
+ } from "./github-feedback";
17
+ import { createGitHubCommentStorage } from "./github-comments";
18
+ import type { CommentStorage, StoredComment } from "./comment-storage";
12
19
  import type {
13
20
  FeedbackStorage,
14
21
  NewStoredPost,
15
22
  StoredPost,
16
23
  } from "./feedback-storage";
17
- import {
18
- createNotionCommentStorage,
19
- type CommentStorage,
20
- type StoredComment,
21
- } from "./notion-comments";
24
+ import { createNotionCommentStorage } from "./notion-comments";
22
25
 
23
26
  export {
24
27
  createNotionFeedbackDataSource,
@@ -35,6 +38,8 @@ export type PostStatus =
35
38
  "New" | "Reviewing" | "Planned" | "In Progress" | "Shipped" | "Closed";
36
39
 
37
40
  export interface SubmitPostInput {
41
+ submissionKey?: string;
42
+ imageIds?: string[];
38
43
  title: string;
39
44
  description: string;
40
45
  type: PostType;
@@ -49,6 +54,7 @@ export interface SubmitTrustedPostInput extends SubmitPostInput {
49
54
  }
50
55
 
51
56
  export interface Post extends SubmitPostInput {
57
+ images?: import("./images").ImageAttachment[];
52
58
  id: string;
53
59
  slug: string;
54
60
  status: PostStatus;
@@ -90,6 +96,7 @@ export type PublicPost = Pick<
90
96
  voteCount?: number;
91
97
  teamResponse?: string;
92
98
  releaseNotesUrl?: string;
99
+ images?: import("./images").ImageAttachment[];
93
100
  };
94
101
 
95
102
  export interface ChangeVoteInput {
@@ -127,13 +134,14 @@ export type DraftPost = Pick<
127
134
  | "createdAt"
128
135
  | "updatedAt"
129
136
  | "submitter"
137
+ | "images"
130
138
  >;
131
139
 
132
140
  export type TrustedSubmittedPost = Omit<Post, "published" | "submitter">;
133
141
 
134
142
  export interface EditDraftPostInput {
135
143
  id: string;
136
- browserCapability: BrowserCapability;
144
+ browserCapability?: BrowserCapability;
137
145
  title?: string;
138
146
  description?: string;
139
147
  type?: PostType;
@@ -141,7 +149,7 @@ export interface EditDraftPostInput {
141
149
 
142
150
  export interface WithdrawDraftPostInput {
143
151
  id: string;
144
- browserCapability: BrowserCapability;
152
+ browserCapability?: BrowserCapability;
145
153
  }
146
154
 
147
155
  export type GetDraftPostInput = WithdrawDraftPostInput;
@@ -162,10 +170,11 @@ export interface PublicPostPage {
162
170
  }
163
171
 
164
172
  export type CommentAuthor =
165
- | { kind: "participant"; displayName: string }
173
+ | { kind: "participant"; displayName: string; verified?: boolean }
166
174
  | { kind: "product-team"; displayName: "Product Team" };
167
175
 
168
176
  export interface Comment {
177
+ images?: import("./images").ImageAttachment[];
169
178
  id: string;
170
179
  body: string;
171
180
  author: CommentAuthor;
@@ -188,6 +197,10 @@ export interface ListCommentThreadsInput {
188
197
  }
189
198
 
190
199
  export interface CreateCommentInput {
200
+ submissionKey?: string;
201
+ /** Internal recovery precondition; it never grants Comment authority. */
202
+ recoveryCommentId?: string;
203
+ imageIds?: string[];
191
204
  slug: string;
192
205
  body: string;
193
206
  displayName: string;
@@ -197,7 +210,9 @@ export interface ReplyToCommentThreadInput extends CreateCommentInput {
197
210
  discussionId: string;
198
211
  }
199
212
 
213
+ export const recoverableComment = Symbol("recoverable-comment");
200
214
  export interface CreatedComment extends Comment {
215
+ [recoverableComment]?: boolean;
201
216
  discussionId: string;
202
217
  }
203
218
 
@@ -354,7 +369,27 @@ class InMemoryFeedbackStorage implements FeedbackStorage {
354
369
  }
355
370
  }
356
371
 
372
+ export type ParticipantPost = Pick<
373
+ Post,
374
+ | "id"
375
+ | "slug"
376
+ | "title"
377
+ | "description"
378
+ | "type"
379
+ | "status"
380
+ | "published"
381
+ | "createdAt"
382
+ | "updatedAt"
383
+ > & { editable: boolean };
384
+
357
385
  export interface FeedbackModule {
386
+ getParticipantPost(id: string): Promise<ParticipantPost | undefined>;
387
+ claimDraftPost(
388
+ input: GetDraftPostInput,
389
+ ): Promise<ParticipantPost | undefined>;
390
+ getAttachmentPostState(
391
+ slug: string,
392
+ ): Promise<"published" | "draft" | "private" | undefined>;
358
393
  createComment(input: CreateCommentInput): Promise<CreatedComment>;
359
394
  replyToCommentThread(
360
395
  input: ReplyToCommentThreadInput,
@@ -384,6 +419,7 @@ export type FeedbackMutationModule = Pick<
384
419
  >;
385
420
 
386
421
  interface CreateFeedbackModuleOptions {
422
+ ownership?: PostOwnership;
387
423
  initialItems?: StoredPost[];
388
424
  storage?: FeedbackStorage;
389
425
  votingEnabled?: boolean;
@@ -403,10 +439,39 @@ export function createFeedbackModule(
403
439
  options.commentStorage ??
404
440
  createInMemoryCommentStorage(options.initialComments);
405
441
  const voteQueues = new Map<string, Promise<void>>();
442
+ const authorizedDraft = async (
443
+ item: StoredPost | undefined,
444
+ capability?: BrowserCapability,
445
+ ): Promise<boolean> =>
446
+ Boolean(
447
+ item &&
448
+ isEditableDraft(item) &&
449
+ (authorizesDraft(item, capability) ||
450
+ (await options.ownership?.owns(item.id))),
451
+ );
452
+ const participantPost = async (
453
+ id: string,
454
+ ): Promise<ParticipantPost | undefined> => {
455
+ if (!(await options.ownership?.owns(id))) return;
456
+ const item = await storage.find(id);
457
+ if (!item) return;
458
+ return {
459
+ id: item.id,
460
+ slug: item.slug,
461
+ title: item.title,
462
+ description: item.description,
463
+ type: item.type,
464
+ status: item.status,
465
+ published: item.published,
466
+ createdAt: item.createdAt,
467
+ updatedAt: item.updatedAt,
468
+ editable: isEditableDraft(item),
469
+ };
470
+ };
406
471
  const editStoredDraft = async (input: EditDraftPostInput) => {
407
472
  const item = await storage.find(input.id);
408
473
 
409
- if (!authorizesDraft(item, input.browserCapability)) {
474
+ if (!item || !(await authorizedDraft(item, input.browserCapability))) {
410
475
  throw new BrowserCapabilityAuthorizationError();
411
476
  }
412
477
 
@@ -432,7 +497,29 @@ export function createFeedbackModule(
432
497
  };
433
498
 
434
499
  return {
500
+ getParticipantPost: participantPost,
501
+ async claimDraftPost(input) {
502
+ const item = await storage.find(input.id);
503
+ if (!options.ownership || !authorizesDraft(item, input.browserCapability))
504
+ throw new BrowserCapabilityAuthorizationError();
505
+ await options.ownership.register(toPost(item));
506
+ return participantPost(item.id);
507
+ },
508
+ async getAttachmentPostState(slug) {
509
+ const post = await storage.findBySlug(slug);
510
+ return !post
511
+ ? undefined
512
+ : post.published
513
+ ? "published"
514
+ : post.status === "New" && post.draftEditable !== false
515
+ ? "draft"
516
+ : "private";
517
+ },
435
518
  async createComment(input) {
519
+ if (input.recoveryCommentId && !commentStorage.supportsIntentRecovery)
520
+ throw new CommentValidationError(
521
+ "Attached Comment recovery is unavailable.",
522
+ );
436
523
  const { post, body, displayName } = await validateCommentCreation(
437
524
  input,
438
525
  storage,
@@ -440,12 +527,22 @@ export function createFeedbackModule(
440
527
  return toCreatedComment(
441
528
  await commentStorage.createPageComment({
442
529
  postId: post.id,
530
+ ...(input.recoveryCommentId
531
+ ? { recoveryCommentId: input.recoveryCommentId }
532
+ : {}),
533
+ ...(input.submissionKey
534
+ ? { submissionKey: input.submissionKey }
535
+ : {}),
443
536
  body,
444
537
  displayName,
445
538
  }),
446
539
  );
447
540
  },
448
541
  async replyToCommentThread(input) {
542
+ if (input.recoveryCommentId && !commentStorage.supportsIntentRecovery)
543
+ throw new CommentValidationError(
544
+ "Attached Comment recovery is unavailable.",
545
+ );
449
546
  const { post, body, displayName } = await validateCommentCreation(
450
547
  input,
451
548
  storage,
@@ -472,6 +569,12 @@ export function createFeedbackModule(
472
569
  await commentStorage.createDiscussionReply({
473
570
  postId: post.id,
474
571
  discussionId: input.discussionId,
572
+ ...(input.recoveryCommentId
573
+ ? { recoveryCommentId: input.recoveryCommentId }
574
+ : {}),
575
+ ...(input.submissionKey
576
+ ? { submissionKey: input.submissionKey }
577
+ : {}),
475
578
  body,
476
579
  displayName,
477
580
  }),
@@ -536,10 +639,29 @@ export function createFeedbackModule(
536
639
  submitPost(input) {
537
640
  const submission = postCreationQueue.then(async () => {
538
641
  const now = new Date();
539
- const browserCapability = createBrowserCapability();
642
+ const { submissionKey, ...content } = input;
643
+ if (
644
+ submissionKey !== undefined &&
645
+ !/^[A-Za-z0-9_-]{43}$/.test(submissionKey)
646
+ )
647
+ throw new Error("Submission intent is invalid.");
648
+ const browserCapability = submissionKey
649
+ ? (submissionKey as BrowserCapability)
650
+ : createBrowserCapability();
651
+ const externalId = submissionKey
652
+ ? `portal:${hashBrowserCapability(browserCapability)}`
653
+ : undefined;
654
+ if (externalId) {
655
+ const previous = await storage.findByExternalId(externalId);
656
+ if (previous) {
657
+ await options.ownership?.register(toPost(previous));
658
+ return { ...toPost(previous), browserCapability };
659
+ }
660
+ }
540
661
  const slug = await createUniqueSlug(input.title, storage);
541
662
  const item = await storage.create({
542
- ...input,
663
+ ...content,
664
+ ...(externalId ? { externalId } : {}),
543
665
  slug,
544
666
  status: "New",
545
667
  published: false,
@@ -549,6 +671,7 @@ export function createFeedbackModule(
549
671
  voteCount: 0,
550
672
  });
551
673
 
674
+ await options.ownership?.register(toPost(item));
552
675
  return {
553
676
  ...toPost(item),
554
677
  browserCapability,
@@ -571,7 +694,7 @@ export function createFeedbackModule(
571
694
  },
572
695
  async getDraftPost(input) {
573
696
  const item = await storage.find(input.id);
574
- if (!authorizesDraft(item, input.browserCapability)) {
697
+ if (!item || !(await authorizedDraft(item, input.browserCapability))) {
575
698
  throw new BrowserCapabilityAuthorizationError();
576
699
  }
577
700
  return toDraftPost(toPost(item));
@@ -619,11 +742,12 @@ export function createFeedbackModule(
619
742
  async withdrawDraftPost(input) {
620
743
  const item = await storage.find(input.id);
621
744
 
622
- if (!authorizesDraft(item, input.browserCapability)) {
745
+ if (!item || !(await authorizedDraft(item, input.browserCapability))) {
623
746
  throw new BrowserCapabilityAuthorizationError();
624
747
  }
625
748
 
626
749
  await storage.remove(item.id);
750
+ await options.ownership?.withdrawn(item.id);
627
751
  },
628
752
  async getPublicPostRoadmap() {
629
753
  const pages = await Promise.all(
@@ -640,9 +764,11 @@ export function createNotionFeedbackModule(
640
764
  options: NotionFeedbackStorageOptions & {
641
765
  votingEnabled?: boolean;
642
766
  commentsEnabled?: boolean;
767
+ ownership?: PostOwnership;
643
768
  },
644
769
  ): FeedbackModule {
645
770
  return createFeedbackModule({
771
+ ownership: options.ownership,
646
772
  storage: createNotionFeedbackStorage(options),
647
773
  commentStorage: createNotionCommentStorage(options),
648
774
  votingEnabled: options.votingEnabled,
@@ -650,6 +776,21 @@ export function createNotionFeedbackModule(
650
776
  });
651
777
  }
652
778
 
779
+ export function createGitHubFeedbackModule(
780
+ options: GitHubFeedbackOptions & { ownership?: PostOwnership },
781
+ ): FeedbackModule {
782
+ const storage = createGitHubFeedbackStorage(options);
783
+ return createFeedbackModule({
784
+ storage,
785
+ ownership: options.ownership,
786
+ commentStorage: createGitHubCommentStorage(options, storage),
787
+ votingEnabled: options.votingEnabled,
788
+ commentsEnabled: options.commentsEnabled,
789
+ });
790
+ }
791
+
792
+ export type { GitHubFeedbackOptions } from "./github-feedback";
793
+
653
794
  function createInMemoryCommentStorage(
654
795
  initialComments: StoredComment[] = [],
655
796
  ): CommentStorage {
@@ -743,13 +884,16 @@ async function validateCommentCreation(
743
884
  }
744
885
 
745
886
  function toCreatedComment(comment: StoredComment): CreatedComment {
746
- return {
887
+ const result: CreatedComment = {
747
888
  id: comment.id,
748
889
  discussionId: comment.discussionId,
749
890
  body: comment.body,
750
891
  author: comment.author,
751
892
  createdAt: comment.createdAt,
752
893
  };
894
+ if (comment.recoverable)
895
+ Object.defineProperty(result, recoverableComment, { value: true });
896
+ return result;
753
897
  }
754
898
 
755
899
  function encodePublicPostCursor(
@@ -915,9 +1059,15 @@ function hashBrowserCapability(browserCapability: BrowserCapability): string {
915
1059
 
916
1060
  function authorizesDraft(
917
1061
  item: StoredPost | undefined,
918
- browserCapability: BrowserCapability,
1062
+ browserCapability: BrowserCapability | undefined,
919
1063
  ): item is StoredPost & { browserCapabilityHash: string } {
920
- if (!item?.browserCapabilityHash || item.status !== "New" || item.published) {
1064
+ if (
1065
+ !browserCapability ||
1066
+ !item?.browserCapabilityHash ||
1067
+ item.status !== "New" ||
1068
+ item.published ||
1069
+ item.draftEditable === false
1070
+ ) {
921
1071
  return false;
922
1072
  }
923
1073
 
@@ -940,3 +1090,63 @@ function isPublicPostRoadmapItem<Item extends Post>(
940
1090
  item.status === "Shipped")
941
1091
  );
942
1092
  }
1093
+
1094
+ export {
1095
+ createConnectedStore,
1096
+ type ConnectedStore,
1097
+ type ConnectedDatabase,
1098
+ type VerifiedParticipant,
1099
+ } from "./connected";
1100
+ export {
1101
+ signIdentityHandoff,
1102
+ verifyIdentityHandoff,
1103
+ type IdentityHandoff,
1104
+ } from "./identity-handoff";
1105
+
1106
+ export { createConnectedFeedbackModule } from "./connected-feedback";
1107
+
1108
+ export {
1109
+ createParticipationModule,
1110
+ type ParticipantNotification,
1111
+ type FollowState,
1112
+ } from "./participation";
1113
+ export {
1114
+ createEmailSender,
1115
+ type EmailSender,
1116
+ type NotificationEmail,
1117
+ type CloudflareEmailBinding,
1118
+ } from "./email-delivery";
1119
+
1120
+ export {
1121
+ createImageModule,
1122
+ ImageAttachmentError,
1123
+ type ImageAttachment,
1124
+ } from "./images";
1125
+
1126
+ export { createImageFeedbackModule } from "./image-feedback";
1127
+ export { createNotionImageReviewWriter } from "./notion-image-review";
1128
+
1129
+ export { createGitHubServiceAccess } from "./github-service-client";
1130
+ export {
1131
+ createObservedHistoryModule,
1132
+ type ObservedHistoryEvent,
1133
+ type ObservedHistoryPage,
1134
+ } from "./observed-history";
1135
+ export {
1136
+ boundedBody,
1137
+ signIntegrationRequest,
1138
+ verifyIntegrationRequest,
1139
+ } from "./integration-auth";
1140
+
1141
+ function isEditableDraft(item: StoredPost) {
1142
+ return (
1143
+ item.status === "New" && !item.published && item.draftEditable !== false
1144
+ );
1145
+ }
1146
+ export {
1147
+ createPostOwnership,
1148
+ createMyFeedbackModule,
1149
+ type MyFeedbackFilter,
1150
+ type MyFeedbackItem,
1151
+ type MyFeedbackPage,
1152
+ } from "./ownership";
@@ -0,0 +1,138 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
+
3
+ export function integrationSecret(
4
+ master: string,
5
+ installationId: string,
6
+ direction: "portal" | "service",
7
+ version: number,
8
+ ) {
9
+ if (master.length < 32)
10
+ throw new Error(
11
+ "Integration signing secret must have at least 32 characters.",
12
+ );
13
+ return createHmac("sha256", master)
14
+ .update(
15
+ JSON.stringify([
16
+ "feedbax-integration-v1",
17
+ installationId,
18
+ direction,
19
+ version,
20
+ ]),
21
+ )
22
+ .digest("base64url");
23
+ }
24
+
25
+ export function signIntegrationRequest(input: {
26
+ url: string;
27
+ installationId: string;
28
+ secret: string;
29
+ body?: string;
30
+ method?: string;
31
+ now?: number;
32
+ nonce?: string;
33
+ }) {
34
+ const method = input.method ?? "POST";
35
+ const time = String(Math.floor((input.now ?? Date.now()) / 1000));
36
+ const nonce = input.nonce ?? randomUUID();
37
+ const body = input.body ?? "";
38
+ const signature = mac(
39
+ input.secret,
40
+ method,
41
+ input.url,
42
+ input.installationId,
43
+ time,
44
+ nonce,
45
+ body,
46
+ );
47
+ return new Request(input.url, {
48
+ method,
49
+ redirect: "manual",
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ "X-Feedbax-Installation": input.installationId,
53
+ "X-Feedbax-Time": time,
54
+ "X-Feedbax-Nonce": nonce,
55
+ "X-Feedbax-Signature": signature,
56
+ },
57
+ ...(method === "GET" ? {} : { body }),
58
+ });
59
+ }
60
+
61
+ export function verifyIntegrationRequest(
62
+ request: Request,
63
+ body: string,
64
+ secret: string,
65
+ now = Date.now(),
66
+ ) {
67
+ const installationId = request.headers.get("X-Feedbax-Installation") ?? "";
68
+ const time = request.headers.get("X-Feedbax-Time") ?? "";
69
+ const nonce = request.headers.get("X-Feedbax-Nonce") ?? "";
70
+ const signature = request.headers.get("X-Feedbax-Signature") ?? "";
71
+ if (
72
+ secret.length < 32 ||
73
+ !/^[A-Za-z0-9_-]{1,128}$/.test(installationId) ||
74
+ !/^\d{10}$/.test(time) ||
75
+ Math.abs(now - Number(time) * 1000) > 300_000 ||
76
+ !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)
77
+ )
78
+ return false;
79
+ return equalSignature(
80
+ signature,
81
+ mac(secret, request.method, request.url, installationId, time, nonce, body),
82
+ );
83
+ }
84
+
85
+ export function equalSignature(actual: string, expected: string) {
86
+ const a = Buffer.from(actual);
87
+ const b = Buffer.from(expected);
88
+ return a.length === b.length && timingSafeEqual(a, b);
89
+ }
90
+ function mac(
91
+ secret: string,
92
+ method: string,
93
+ url: string,
94
+ installation: string,
95
+ time: string,
96
+ nonce: string,
97
+ body: string,
98
+ ) {
99
+ return createHmac("sha256", secret)
100
+ .update(
101
+ JSON.stringify([
102
+ "feedbax-integration-v1",
103
+ method,
104
+ url,
105
+ installation,
106
+ time,
107
+ nonce,
108
+ body,
109
+ ]),
110
+ )
111
+ .digest("hex");
112
+ }
113
+
114
+ export async function boundedBody(request: Request, maximum: number) {
115
+ if (Number(request.headers.get("Content-Length")) > maximum)
116
+ throw new Error("Request body limit exceeded.");
117
+ const reader = request.body?.getReader();
118
+ const chunks: Uint8Array[] = [];
119
+ let size = 0;
120
+ if (reader)
121
+ while (true) {
122
+ const { done, value } = await reader.read();
123
+ if (done) break;
124
+ size += value.byteLength;
125
+ if (size > maximum) {
126
+ await reader.cancel();
127
+ throw new Error("Request body limit exceeded.");
128
+ }
129
+ chunks.push(value);
130
+ }
131
+ const bytes = new Uint8Array(size);
132
+ let offset = 0;
133
+ for (const chunk of chunks) {
134
+ bytes.set(chunk, offset);
135
+ offset += chunk.byteLength;
136
+ }
137
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
138
+ }
@@ -1,44 +1,11 @@
1
- import type { CommentAuthor } from "./index";
1
+ import type { CommentStorage, StoredComment } from "./comment-storage";
2
+ export type { CommentStorage, StoredComment } from "./comment-storage";
2
3
  import type { NotionFeedbackStorageOptions } from "./notion-feedback";
3
4
  import { requestNotion } from "./notion-request";
4
5
 
5
6
  const NOTION_API_URL = "https://api.notion.com/v1";
6
7
  const NOTION_API_VERSION = "2026-03-11";
7
8
 
8
- export interface StoredComment {
9
- id: string;
10
- postId: string;
11
- discussionId: string;
12
- body: string;
13
- author: CommentAuthor;
14
- createdAt: Date;
15
- resolved: boolean;
16
- scope?: "page" | "block";
17
- }
18
-
19
- export interface CommentStorage {
20
- createPageComment(input: {
21
- postId: string;
22
- body: string;
23
- displayName: string;
24
- }): Promise<StoredComment>;
25
- createDiscussionReply(input: {
26
- postId: string;
27
- discussionId: string;
28
- body: string;
29
- displayName: string;
30
- }): Promise<StoredComment>;
31
- listPageComments(
32
- postId: string,
33
- cursor?: string,
34
- ): Promise<{
35
- items: StoredComment[];
36
- nextCursor?: string;
37
- }>;
38
- updateComment(commentId: string, body: string): Promise<StoredComment>;
39
- deleteComment(commentId: string): Promise<void>;
40
- }
41
-
42
9
  export function createNotionCommentStorage({
43
10
  token,
44
11
  request = fetch,
@@ -0,0 +1,55 @@
1
+ import { requestNotion, type NotionRetryOptions } from "./notion-request";
2
+ import type { ImageAttachment } from "./images";
3
+ /** Private page-body links let Team Members review drafts without a second dashboard. */
4
+ export function createNotionImageReviewWriter(options: {
5
+ token: string;
6
+ reviewUrl(id: string): Promise<string>;
7
+ request?: typeof fetch;
8
+ retry?: NotionRetryOptions;
9
+ }) {
10
+ return async (postId: string, images: ImageAttachment[]) => {
11
+ const children = await Promise.all(
12
+ images.map(async (image) => ({
13
+ object: "block",
14
+ type: "paragraph",
15
+ paragraph: {
16
+ rich_text: [
17
+ {
18
+ type: "text",
19
+ text: {
20
+ content: "Review Participant image",
21
+ link: { url: await options.reviewUrl(image.id) },
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ })),
27
+ );
28
+ const response = await requestNotion(
29
+ `https://api.notion.com/v1/blocks/${encodeURIComponent(postId)}/children`,
30
+ {
31
+ method: "PATCH",
32
+ headers: {
33
+ Authorization: `Bearer ${options.token}`,
34
+ "Notion-Version": "2025-09-03",
35
+ "Content-Type": "application/json",
36
+ },
37
+ body: JSON.stringify({ children }),
38
+ },
39
+ {
40
+ operation: "create",
41
+ retry: options.retry,
42
+ request: (input, init) =>
43
+ (options.request ?? fetch)(input, {
44
+ ...init,
45
+ signal: AbortSignal.timeout(10000),
46
+ }),
47
+ },
48
+ );
49
+ if (!response.ok) {
50
+ await response.body?.cancel();
51
+ throw new Error("Image review links could not be saved in Notion.");
52
+ }
53
+ await response.body?.cancel();
54
+ };
55
+ }