create-feedbax 0.2.1 → 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 (78) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +502 -209
  3. package/dist/index.js +251 -68
  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 +1 -1
  10. package/template/CONNECTED.md +16 -2
  11. package/template/README.md +52 -13
  12. package/template/feedbax-vite.ts +79 -0
  13. package/template/migrations/0005_github.sql +4 -0
  14. package/template/migrations/0006_integration.sql +3 -0
  15. package/template/migrations/0007_ownership.sql +2 -0
  16. package/template/migrations/0008_integration_recovery.sql +5 -0
  17. package/template/migrations/0009_observed_history.sql +3 -0
  18. package/template/package.json +34 -33
  19. package/template/src/changelog-runtime.ts +2 -1
  20. package/template/src/cloudflare-workers.d.ts +3 -0
  21. package/template/src/comment-creation.test.tsx +53 -6
  22. package/template/src/components/connected-identity-provider.tsx +44 -17
  23. package/template/src/components/follow-post-button.tsx +2 -13
  24. package/template/src/components/post-history.tsx +101 -0
  25. package/template/src/components/profile-menu.tsx +7 -0
  26. package/template/src/configured-feedback.test.ts +82 -0
  27. package/template/src/configured-feedback.ts +61 -0
  28. package/template/src/connected-identity.test.ts +7 -3
  29. package/template/src/connected-identity.ts +6 -1
  30. package/template/src/connected-runtime.test.ts +5 -2
  31. package/template/src/feedback-cache-invalidation.ts +2 -0
  32. package/template/src/feedback-runtime.ts +61 -18
  33. package/template/src/feedbax/changelog/index.test.ts +35 -0
  34. package/template/src/feedbax/changelog/index.ts +5 -1
  35. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  36. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  37. package/template/src/feedbax/config/index.test.ts +45 -1
  38. package/template/src/feedbax/config/index.ts +112 -9
  39. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  40. package/template/src/feedbax/feedback/connected-feedback.ts +41 -12
  41. package/template/src/feedbax/feedback/connected.ts +15 -5
  42. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  43. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  44. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  45. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  46. package/template/src/feedbax/feedback/github-request.ts +120 -0
  47. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  48. package/template/src/feedbax/feedback/image-feedback.ts +23 -10
  49. package/template/src/feedbax/feedback/images.ts +26 -0
  50. package/template/src/feedbax/feedback/index.ts +172 -16
  51. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  52. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  53. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  54. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  55. package/template/src/feedbax/feedback/ownership.ts +170 -0
  56. package/template/src/history-runtime.test.ts +80 -0
  57. package/template/src/history-runtime.ts +37 -0
  58. package/template/src/index.css +6 -0
  59. package/template/src/integration-runtime.test.ts +365 -0
  60. package/template/src/integration-runtime.ts +289 -0
  61. package/template/src/my-feedback-page.test.tsx +152 -0
  62. package/template/src/my-feedback-page.tsx +398 -0
  63. package/template/src/my-feedback-runtime.test.ts +93 -0
  64. package/template/src/my-feedback-runtime.ts +154 -0
  65. package/template/src/participation-runtime.ts +3 -3
  66. package/template/src/portal-comments.ts +11 -0
  67. package/template/src/portal-feedback-form.tsx +11 -3
  68. package/template/src/portal-feedback-submission.ts +9 -0
  69. package/template/src/portal-server-entry.ts +9 -0
  70. package/template/src/public-portal-error.tsx +1 -1
  71. package/template/src/public-post-detail.tsx +332 -256
  72. package/template/src/public-release-links.test.ts +120 -0
  73. package/template/src/public-release-links.ts +130 -0
  74. package/template/src/routeTree.gen.ts +21 -0
  75. package/template/src/routes/my-feedback.tsx +16 -0
  76. package/template/src/server.ts +46 -1
  77. package/template/src/submission-intent.ts +26 -0
  78. package/template/vite.config.ts +2 -0
@@ -0,0 +1,56 @@
1
+ import type { GitHubConfiguration } from "@feedbax/config";
2
+ import { signIntegrationRequest } from "./integration-auth";
3
+
4
+ export function createGitHubServiceAccess(options: {
5
+ config: GitHubConfiguration;
6
+ secret?: string;
7
+ getSecret?: () => Promise<string>;
8
+ request?: typeof fetch;
9
+ }) {
10
+ let cached: { token: string; expires: number; secret: string } | undefined;
11
+ return async () => {
12
+ const secret = options.getSecret
13
+ ? await options.getSecret()
14
+ : (options.secret ?? "");
15
+ if (
16
+ cached &&
17
+ cached.secret === secret &&
18
+ cached.expires > Date.now() + 60_000
19
+ )
20
+ return cached.token;
21
+ const response = await (options.request ?? fetch)(
22
+ signIntegrationRequest({
23
+ url: `${options.config.serviceUrl}/v1/token`,
24
+ installationId: options.config.installationId,
25
+ secret,
26
+ body: "{}",
27
+ }),
28
+ );
29
+ if (!response.ok) {
30
+ cached = undefined;
31
+ throw new Error(
32
+ "GitHub Portal Binding is unavailable. Run feedbax doctor or pair the Installation again.",
33
+ );
34
+ }
35
+ const value = (await response.json()) as {
36
+ token?: unknown;
37
+ expiresAt?: unknown;
38
+ repositoryId?: unknown;
39
+ botUserId?: unknown;
40
+ };
41
+ const expires =
42
+ typeof value.expiresAt === "string" ? Date.parse(value.expiresAt) : NaN;
43
+ if (
44
+ typeof value.token !== "string" ||
45
+ !value.token ||
46
+ !Number.isFinite(expires) ||
47
+ expires <= Date.now() ||
48
+ value.repositoryId !== options.config.repository.id ||
49
+ value.botUserId !== options.config.botUserId
50
+ )
51
+ throw new Error("GitHub integration returned mismatched access.");
52
+ // Cache only within one request factory and recheck local revocation/rotation on every use.
53
+ cached = { token: value.token, expires, secret };
54
+ return cached.token;
55
+ };
56
+ }
@@ -1,4 +1,8 @@
1
- import type { FeedbackModule, CreateCommentInput } from "./index";
1
+ import {
2
+ recoverableComment,
3
+ type FeedbackModule,
4
+ type CreateCommentInput,
5
+ } from "./index";
2
6
  import type { createImageModule, ImageAttachment } from "./images";
3
7
 
4
8
  export function createImageFeedbackModule(
@@ -10,15 +14,23 @@ export function createImageFeedbackModule(
10
14
  input: CreateCommentInput & { discussionId?: string },
11
15
  ) => {
12
16
  const ids = input.imageIds ?? [];
13
- await images.validatePending(ids);
17
+ const attachedComment = input.submissionKey
18
+ ? await images.validateCommentImages(ids, input.slug)
19
+ : (await images.validatePending(ids), undefined);
20
+ const command = {
21
+ ...input,
22
+ ...(attachedComment ? { recoveryCommentId: attachedComment } : {}),
23
+ };
14
24
  const comment = input.discussionId
15
25
  ? await base.replyToCommentThread({
16
- ...input,
26
+ ...command,
17
27
  discussionId: input.discussionId,
18
28
  })
19
- : await base.createComment(input);
29
+ : await base.createComment(command);
20
30
  if (!ids.length) return comment;
21
31
  try {
32
+ if (attachedComment && attachedComment !== comment.id)
33
+ throw new Error("Images belong to another Comment.");
22
34
  return {
23
35
  ...comment,
24
36
  images: await images.attachComment({
@@ -28,12 +40,13 @@ export function createImageFeedbackModule(
28
40
  }),
29
41
  };
30
42
  } catch (error) {
31
- try {
32
- await base.deleteComment({ commentId: comment.id });
33
- await images.discardAttached(input.slug, comment.id);
34
- } catch {
35
- /* External cleanup remains best effort; never expose an unpublished attachment. */
36
- }
43
+ if (!comment[recoverableComment])
44
+ try {
45
+ await base.deleteComment({ commentId: comment.id });
46
+ await images.discardAttached(input.slug, comment.id);
47
+ } catch {
48
+ /* External cleanup remains best effort; never expose an unpublished attachment. */
49
+ }
37
50
  throw error;
38
51
  }
39
52
  };
@@ -93,6 +93,32 @@ export function createImageModule(options: {
93
93
  );
94
94
  };
95
95
  return {
96
+ async validateCommentImages(ids: string[], slug: string) {
97
+ if (!ids.length) return;
98
+ const person = identity();
99
+ if (ids.length > 4 || new Set(ids).size !== ids.length)
100
+ throw new ImageAttachmentError("Choose up to four images.");
101
+ let attachedComment: string | undefined;
102
+ for (const id of ids) {
103
+ const image = await row(id);
104
+ if (
105
+ !image ||
106
+ image.subject !== person.subject ||
107
+ image.deleting ||
108
+ (image.post_slug === null
109
+ ? image.created_at <= now() - 86400000
110
+ : image.post_slug !== slug ||
111
+ !image.comment_id ||
112
+ (attachedComment !== undefined &&
113
+ attachedComment !== image.comment_id))
114
+ )
115
+ throw new ImageAttachmentError(
116
+ "One or more images are unavailable. Upload them again.",
117
+ );
118
+ if (image.comment_id) attachedComment = image.comment_id;
119
+ }
120
+ return attachedComment;
121
+ },
96
122
  async validatePending(ids: string[]) {
97
123
  if (!ids.length) return;
98
124
  const person = identity();
@@ -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,7 @@ export type PostStatus =
35
38
  "New" | "Reviewing" | "Planned" | "In Progress" | "Shipped" | "Closed";
36
39
 
37
40
  export interface SubmitPostInput {
41
+ submissionKey?: string;
38
42
  imageIds?: string[];
39
43
  title: string;
40
44
  description: string;
@@ -137,7 +141,7 @@ export type TrustedSubmittedPost = Omit<Post, "published" | "submitter">;
137
141
 
138
142
  export interface EditDraftPostInput {
139
143
  id: string;
140
- browserCapability: BrowserCapability;
144
+ browserCapability?: BrowserCapability;
141
145
  title?: string;
142
146
  description?: string;
143
147
  type?: PostType;
@@ -145,7 +149,7 @@ export interface EditDraftPostInput {
145
149
 
146
150
  export interface WithdrawDraftPostInput {
147
151
  id: string;
148
- browserCapability: BrowserCapability;
152
+ browserCapability?: BrowserCapability;
149
153
  }
150
154
 
151
155
  export type GetDraftPostInput = WithdrawDraftPostInput;
@@ -193,6 +197,9 @@ export interface ListCommentThreadsInput {
193
197
  }
194
198
 
195
199
  export interface CreateCommentInput {
200
+ submissionKey?: string;
201
+ /** Internal recovery precondition; it never grants Comment authority. */
202
+ recoveryCommentId?: string;
196
203
  imageIds?: string[];
197
204
  slug: string;
198
205
  body: string;
@@ -203,7 +210,9 @@ export interface ReplyToCommentThreadInput extends CreateCommentInput {
203
210
  discussionId: string;
204
211
  }
205
212
 
213
+ export const recoverableComment = Symbol("recoverable-comment");
206
214
  export interface CreatedComment extends Comment {
215
+ [recoverableComment]?: boolean;
207
216
  discussionId: string;
208
217
  }
209
218
 
@@ -360,7 +369,24 @@ class InMemoryFeedbackStorage implements FeedbackStorage {
360
369
  }
361
370
  }
362
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
+
363
385
  export interface FeedbackModule {
386
+ getParticipantPost(id: string): Promise<ParticipantPost | undefined>;
387
+ claimDraftPost(
388
+ input: GetDraftPostInput,
389
+ ): Promise<ParticipantPost | undefined>;
364
390
  getAttachmentPostState(
365
391
  slug: string,
366
392
  ): Promise<"published" | "draft" | "private" | undefined>;
@@ -393,6 +419,7 @@ export type FeedbackMutationModule = Pick<
393
419
  >;
394
420
 
395
421
  interface CreateFeedbackModuleOptions {
422
+ ownership?: PostOwnership;
396
423
  initialItems?: StoredPost[];
397
424
  storage?: FeedbackStorage;
398
425
  votingEnabled?: boolean;
@@ -412,10 +439,39 @@ export function createFeedbackModule(
412
439
  options.commentStorage ??
413
440
  createInMemoryCommentStorage(options.initialComments);
414
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
+ };
415
471
  const editStoredDraft = async (input: EditDraftPostInput) => {
416
472
  const item = await storage.find(input.id);
417
473
 
418
- if (!authorizesDraft(item, input.browserCapability)) {
474
+ if (!item || !(await authorizedDraft(item, input.browserCapability))) {
419
475
  throw new BrowserCapabilityAuthorizationError();
420
476
  }
421
477
 
@@ -441,17 +497,29 @@ export function createFeedbackModule(
441
497
  };
442
498
 
443
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
+ },
444
508
  async getAttachmentPostState(slug) {
445
509
  const post = await storage.findBySlug(slug);
446
510
  return !post
447
511
  ? undefined
448
512
  : post.published
449
513
  ? "published"
450
- : post.status === "New"
514
+ : post.status === "New" && post.draftEditable !== false
451
515
  ? "draft"
452
516
  : "private";
453
517
  },
454
518
  async createComment(input) {
519
+ if (input.recoveryCommentId && !commentStorage.supportsIntentRecovery)
520
+ throw new CommentValidationError(
521
+ "Attached Comment recovery is unavailable.",
522
+ );
455
523
  const { post, body, displayName } = await validateCommentCreation(
456
524
  input,
457
525
  storage,
@@ -459,12 +527,22 @@ export function createFeedbackModule(
459
527
  return toCreatedComment(
460
528
  await commentStorage.createPageComment({
461
529
  postId: post.id,
530
+ ...(input.recoveryCommentId
531
+ ? { recoveryCommentId: input.recoveryCommentId }
532
+ : {}),
533
+ ...(input.submissionKey
534
+ ? { submissionKey: input.submissionKey }
535
+ : {}),
462
536
  body,
463
537
  displayName,
464
538
  }),
465
539
  );
466
540
  },
467
541
  async replyToCommentThread(input) {
542
+ if (input.recoveryCommentId && !commentStorage.supportsIntentRecovery)
543
+ throw new CommentValidationError(
544
+ "Attached Comment recovery is unavailable.",
545
+ );
468
546
  const { post, body, displayName } = await validateCommentCreation(
469
547
  input,
470
548
  storage,
@@ -491,6 +569,12 @@ export function createFeedbackModule(
491
569
  await commentStorage.createDiscussionReply({
492
570
  postId: post.id,
493
571
  discussionId: input.discussionId,
572
+ ...(input.recoveryCommentId
573
+ ? { recoveryCommentId: input.recoveryCommentId }
574
+ : {}),
575
+ ...(input.submissionKey
576
+ ? { submissionKey: input.submissionKey }
577
+ : {}),
494
578
  body,
495
579
  displayName,
496
580
  }),
@@ -555,10 +639,29 @@ export function createFeedbackModule(
555
639
  submitPost(input) {
556
640
  const submission = postCreationQueue.then(async () => {
557
641
  const now = new Date();
558
- 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
+ }
559
661
  const slug = await createUniqueSlug(input.title, storage);
560
662
  const item = await storage.create({
561
- ...input,
663
+ ...content,
664
+ ...(externalId ? { externalId } : {}),
562
665
  slug,
563
666
  status: "New",
564
667
  published: false,
@@ -568,6 +671,7 @@ export function createFeedbackModule(
568
671
  voteCount: 0,
569
672
  });
570
673
 
674
+ await options.ownership?.register(toPost(item));
571
675
  return {
572
676
  ...toPost(item),
573
677
  browserCapability,
@@ -590,7 +694,7 @@ export function createFeedbackModule(
590
694
  },
591
695
  async getDraftPost(input) {
592
696
  const item = await storage.find(input.id);
593
- if (!authorizesDraft(item, input.browserCapability)) {
697
+ if (!item || !(await authorizedDraft(item, input.browserCapability))) {
594
698
  throw new BrowserCapabilityAuthorizationError();
595
699
  }
596
700
  return toDraftPost(toPost(item));
@@ -638,11 +742,12 @@ export function createFeedbackModule(
638
742
  async withdrawDraftPost(input) {
639
743
  const item = await storage.find(input.id);
640
744
 
641
- if (!authorizesDraft(item, input.browserCapability)) {
745
+ if (!item || !(await authorizedDraft(item, input.browserCapability))) {
642
746
  throw new BrowserCapabilityAuthorizationError();
643
747
  }
644
748
 
645
749
  await storage.remove(item.id);
750
+ await options.ownership?.withdrawn(item.id);
646
751
  },
647
752
  async getPublicPostRoadmap() {
648
753
  const pages = await Promise.all(
@@ -659,9 +764,11 @@ export function createNotionFeedbackModule(
659
764
  options: NotionFeedbackStorageOptions & {
660
765
  votingEnabled?: boolean;
661
766
  commentsEnabled?: boolean;
767
+ ownership?: PostOwnership;
662
768
  },
663
769
  ): FeedbackModule {
664
770
  return createFeedbackModule({
771
+ ownership: options.ownership,
665
772
  storage: createNotionFeedbackStorage(options),
666
773
  commentStorage: createNotionCommentStorage(options),
667
774
  votingEnabled: options.votingEnabled,
@@ -669,6 +776,21 @@ export function createNotionFeedbackModule(
669
776
  });
670
777
  }
671
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
+
672
794
  function createInMemoryCommentStorage(
673
795
  initialComments: StoredComment[] = [],
674
796
  ): CommentStorage {
@@ -762,13 +884,16 @@ async function validateCommentCreation(
762
884
  }
763
885
 
764
886
  function toCreatedComment(comment: StoredComment): CreatedComment {
765
- return {
887
+ const result: CreatedComment = {
766
888
  id: comment.id,
767
889
  discussionId: comment.discussionId,
768
890
  body: comment.body,
769
891
  author: comment.author,
770
892
  createdAt: comment.createdAt,
771
893
  };
894
+ if (comment.recoverable)
895
+ Object.defineProperty(result, recoverableComment, { value: true });
896
+ return result;
772
897
  }
773
898
 
774
899
  function encodePublicPostCursor(
@@ -934,9 +1059,15 @@ function hashBrowserCapability(browserCapability: BrowserCapability): string {
934
1059
 
935
1060
  function authorizesDraft(
936
1061
  item: StoredPost | undefined,
937
- browserCapability: BrowserCapability,
1062
+ browserCapability: BrowserCapability | undefined,
938
1063
  ): item is StoredPost & { browserCapabilityHash: string } {
939
- 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
+ ) {
940
1071
  return false;
941
1072
  }
942
1073
 
@@ -994,3 +1125,28 @@ export {
994
1125
 
995
1126
  export { createImageFeedbackModule } from "./image-feedback";
996
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,