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,38 +1,148 @@
1
- import { createNotionFeedbackModule } from "@feedbax/feedback";
2
- import { cache } from "cloudflare:workers";
1
+ import {
2
+ createImageModule,
3
+ createImageFeedbackModule,
4
+ createNotionImageReviewWriter,
5
+ createConnectedStore,
6
+ createConnectedFeedbackModule,
7
+ createObservedHistoryModule,
8
+ type VerifiedParticipant,
9
+ } from "@feedbax/feedback";
10
+ import { cache, env } from "cloudflare:workers";
3
11
 
4
12
  import {
5
13
  createInvalidatingFeedbackModule,
6
14
  createPublicCacheInvalidator,
7
15
  } from "./feedback-cache-invalidation";
8
16
  import feedbax from "./feedbax";
17
+ import { createContentFeedbackModule } from "./configured-feedback";
18
+ import { createPortalIntegration } from "./integration-runtime";
19
+ import { createPublicReleaseFeedbackModule } from "./public-release-links";
20
+ import { createConfiguredChangelogModule } from "./changelog-runtime";
9
21
 
10
22
  export const feedbackSubmissionLimits = {
11
23
  maxTitleLength: feedbax.submissions.maxTitleLength,
12
24
  maxDescriptionLength: feedbax.submissions.maxDescriptionLength,
13
25
  } as const;
14
26
 
15
- export function createConfiguredFeedbackModule() {
16
- configuredFeedback ??= createInvalidatingFeedbackModule({
17
- feedback: createNotionFeedbackModule({
18
- votingEnabled: feedbax.features.voting,
19
- commentsEnabled: feedbax.features.comments,
20
- token: requiredEnvironmentValue("NOTION_TOKEN"),
21
- dataSourceId: feedbax.notion.dataSourceId,
22
- propertyIds: feedbax.notion.properties,
27
+ export function createConfiguredContentFeedbackModule(
28
+ participant?: VerifiedParticipant,
29
+ ) {
30
+ return createContentFeedbackModule(
31
+ feedbax,
32
+ {
33
+ FEEDBAX_DB: env.FEEDBAX_DB,
34
+ NOTION_TOKEN: process.env.NOTION_TOKEN,
35
+ GITHUB_SERVICE_SECRET: env.GITHUB_SERVICE_SECRET,
36
+ ...(feedbax.provider === "github" && env.FEEDBAX_DB
37
+ ? {
38
+ getGitHubServiceSecret: () =>
39
+ configuredIntegration().portalSecret(),
40
+ }
41
+ : {}),
42
+ },
43
+ fetch,
44
+ participant,
45
+ );
46
+ }
47
+
48
+ export function createConfiguredFeedbackModule(
49
+ participant?: VerifiedParticipant,
50
+ ) {
51
+ const source = createConfiguredContentFeedbackModule(participant);
52
+ const history = feedbax.history ? configuredHistory(source) : undefined;
53
+ const content = history
54
+ ? { ...source, getPublicPost: history.observe }
55
+ : source;
56
+ const feedback = feedbax.connected
57
+ ? createConnectedFeedbackModule(content, getConnectedStore(), participant)
58
+ : content;
59
+ const images = feedbax.connected?.images
60
+ ? configuredImages(feedback, participant)
61
+ : undefined;
62
+ const withImages = images
63
+ ? createImageFeedbackModule(
64
+ feedback,
65
+ images,
66
+ feedbax.provider === "github"
67
+ ? undefined
68
+ : createNotionImageReviewWriter({
69
+ token: requiredEnvironmentValue("NOTION_TOKEN"),
70
+ reviewUrl: (id) => images.reviewUrl(id, feedbax.product.siteUrl!),
71
+ }),
72
+ )
73
+ : feedback;
74
+ return createInvalidatingFeedbackModule({
75
+ feedback: createPublicReleaseFeedbackModule({
76
+ feedback: withImages,
77
+ siteUrl: feedbax.product.siteUrl,
78
+ ...(feedbax.features.changelog && feedbax.changelog
79
+ ? { changelog: createConfiguredChangelogModule(true) }
80
+ : {}),
23
81
  }),
24
82
  invalidator: createPublicCacheInvalidator({
25
83
  purge: (options) => cache.purge(options),
26
84
  }),
27
85
  });
28
- return configuredFeedback;
29
86
  }
30
87
 
31
- let configuredFeedback:
32
- ReturnType<typeof createInvalidatingFeedbackModule> | undefined;
88
+ export function configuredHistory(
89
+ feedback = createConfiguredContentFeedbackModule(),
90
+ ) {
91
+ if (!feedbax.history || !env.FEEDBAX_DB)
92
+ throw new Error(
93
+ "Observed history requires explicit history configuration and FEEDBAX_DB.",
94
+ );
95
+ return createObservedHistoryModule({ database: env.FEEDBAX_DB, feedback });
96
+ }
33
97
 
34
98
  export function requiredEnvironmentValue(name: string): string {
35
99
  const value = process.env[name];
36
100
  if (!value) throw new Error("Feedback configuration is missing.");
37
101
  return value;
38
102
  }
103
+
104
+ export function getConnectedStore() {
105
+ if (!feedbax.connected || !env.FEEDBAX_DB)
106
+ throw new Error("Connected Profile requires the FEEDBAX_DB D1 binding.");
107
+ return createConnectedStore(env.FEEDBAX_DB);
108
+ }
109
+
110
+ export function configuredImages(
111
+ feedback: import("@feedbax/feedback").FeedbackModule,
112
+ participant?: VerifiedParticipant,
113
+ ) {
114
+ if (
115
+ !env.FEEDBAX_DB ||
116
+ !env.FEEDBAX_IMAGES ||
117
+ !env.PARTICIPATION_SIGNING_SECRET ||
118
+ env.PARTICIPATION_SIGNING_SECRET.length < 32
119
+ )
120
+ throw new Error(
121
+ "Image attachments require D1, private R2, and the participation signing secret.",
122
+ );
123
+ return createImageModule({
124
+ database: env.FEEDBAX_DB,
125
+ bucket: env.FEEDBAX_IMAGES,
126
+ feedback,
127
+ participant,
128
+ reviewSecret: env.PARTICIPATION_SIGNING_SECRET,
129
+ processing: feedbax.connected?.processing ?? "small",
130
+ });
131
+ }
132
+ export async function requestParticipant(request: Request) {
133
+ if (!feedbax.connected) return;
134
+ const { readSessionToken, sessionHash } =
135
+ await import("./connected-identity");
136
+ const token = readSessionToken(request);
137
+ return token ? getConnectedStore().session(sessionHash(token)) : undefined;
138
+ }
139
+
140
+ export function configuredIntegration() {
141
+ if (!env.FEEDBAX_DB)
142
+ throw new Error("GitHub requires the FEEDBAX_DB D1 binding.");
143
+ return createPortalIntegration({
144
+ config: feedbax,
145
+ database: env.FEEDBAX_DB,
146
+ pairingSecret: env.GITHUB_PAIRING_SECRET,
147
+ });
148
+ }
@@ -8,6 +8,41 @@ import {
8
8
  } from ".";
9
9
 
10
10
  describe("Changelog module", () => {
11
+ it("puts newer same-day entries first and uses Slug for exact ties", async () => {
12
+ const changelog = createChangelogModule({
13
+ storage: createInMemoryChangelogStorage([
14
+ entry({ id: "a", slug: "older-creation", date: "2026-10-15" }),
15
+ entry({
16
+ id: "z",
17
+ slug: "a-newer-creation",
18
+ date: "2026-10-15",
19
+ createdAt: new Date("2026-10-15T12:00:00Z"),
20
+ }),
21
+ entry({
22
+ id: "b",
23
+ slug: "z-newer-creation",
24
+ date: "2026-10-15",
25
+ createdAt: new Date("2026-10-15T12:00:00Z"),
26
+ }),
27
+ entry({
28
+ id: "c",
29
+ slug: "older-release-date",
30
+ date: "2026-10-14",
31
+ createdAt: new Date("2026-10-16T12:00:00Z"),
32
+ }),
33
+ ]),
34
+ });
35
+
36
+ expect(
37
+ (await changelog.listPublishedEntries()).items.map(({ slug }) => slug),
38
+ ).toEqual([
39
+ "a-newer-creation",
40
+ "z-newer-creation",
41
+ "older-creation",
42
+ "older-release-date",
43
+ ]);
44
+ });
45
+
11
46
  it("returns only Published Changelog Entries in deterministic Date order", async () => {
12
47
  const changelog = createChangelogModule({
13
48
  storage: createInMemoryChangelogStorage([
@@ -160,7 +160,11 @@ function compareEntries(
160
160
  left: StoredChangelogEntry,
161
161
  right: StoredChangelogEntry,
162
162
  ): number {
163
- return right.date.localeCompare(left.date) || left.id.localeCompare(right.id);
163
+ return (
164
+ right.date.localeCompare(left.date) ||
165
+ right.createdAt.getTime() - left.createdAt.getTime() ||
166
+ left.slug.localeCompare(right.slug)
167
+ );
164
168
  }
165
169
 
166
170
  function toPublicEntry(entry: StoredChangelogEntry): PublicChangelogEntry {
@@ -183,7 +183,7 @@ describe("Notion Changelog query contract", () => {
183
183
  },
184
184
  sorts: [
185
185
  { property: propertyIds.date, direction: "descending" },
186
- { timestamp: "created_time", direction: "ascending" },
186
+ { timestamp: "created_time", direction: "descending" },
187
187
  { property: propertyIds.slug, direction: "ascending" },
188
188
  ],
189
189
  });
@@ -168,7 +168,7 @@ export function createNotionChangelogStorage({
168
168
  },
169
169
  sorts: [
170
170
  { property: propertyIds.date, direction: "descending" },
171
- { timestamp: "created_time", direction: "ascending" },
171
+ { timestamp: "created_time", direction: "descending" },
172
172
  { property: propertyIds.slug, direction: "ascending" },
173
173
  ],
174
174
  }),
@@ -1,10 +1,22 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ assertRuntimeBindings,
3
4
  assertProductionConfiguration,
4
5
  defineFeedbax,
5
6
  type FeedbaxConfig,
6
7
  } from "./index";
7
8
 
9
+ it("keeps observed history opt-in and requires explicitly provisioned D1 without enabling identity", () => {
10
+ expect(defineFeedbax(input).history).toBeUndefined();
11
+ const config = defineFeedbax({ ...input, history: true });
12
+ expect(config.connected).toBeUndefined();
13
+ expect(() => assertRuntimeBindings(config, {})).toThrow("FEEDBAX_DB");
14
+ expect(() => assertRuntimeBindings(config, { FEEDBAX_DB: {} })).not.toThrow();
15
+ expect(() =>
16
+ defineFeedbax({ ...input, history: "yes" } as unknown as FeedbaxConfig),
17
+ ).toThrow("history must be true or false");
18
+ });
19
+
8
20
  const properties = Object.fromEntries(
9
21
  [
10
22
  "title",
@@ -24,7 +36,7 @@ const properties = Object.fromEntries(
24
36
  "createdAt",
25
37
  "updatedAt",
26
38
  ].map((key) => [key, `${key}-id`]),
27
- ) as FeedbaxConfig["notion"]["properties"];
39
+ ) as NonNullable<FeedbaxConfig["notion"]>["properties"];
28
40
  const changelogProperties = Object.fromEntries(
29
41
  [
30
42
  "title",
@@ -63,6 +75,38 @@ const input: FeedbaxConfig = {
63
75
  };
64
76
 
65
77
  describe("Feedbax configuration", () => {
78
+ it("selects GitHub without Notion or verified identity and rejects mixed providers", () => {
79
+ const github = {
80
+ installationId: "portal-acme",
81
+ serviceUrl: "https://app.feedbax.example",
82
+ repository: { id: 123, owner: "acme", name: "feedback" },
83
+ botUserId: 456,
84
+ };
85
+ const configuration = {
86
+ ...input,
87
+ provider: "github",
88
+ notion: undefined,
89
+ changelog: undefined,
90
+ features: { ...input.features, changelog: false },
91
+ github,
92
+ };
93
+ const parsed = defineFeedbax(configuration as unknown as FeedbaxConfig);
94
+ expect(parsed).toMatchObject({ provider: "github", github });
95
+ expect(parsed.notion).toBeUndefined();
96
+ expect(parsed.connected).toBeUndefined();
97
+ expect(() =>
98
+ defineFeedbax({
99
+ ...configuration,
100
+ notion: input.notion,
101
+ } as unknown as FeedbaxConfig),
102
+ ).toThrow("one Content Provider");
103
+ expect(() =>
104
+ defineFeedbax({
105
+ ...configuration,
106
+ features: input.features,
107
+ } as unknown as FeedbaxConfig),
108
+ ).toThrow("disable Changelog");
109
+ });
66
110
  it("accepts the complete generated contract", () =>
67
111
  expect(defineFeedbax(input)).toEqual(input));
68
112
  it("requires enabled capability dependencies", () =>
@@ -109,3 +153,31 @@ it.each(["/portal", "//example.com", "http://example.com"])(
109
153
  ).toThrow("product.siteUrl must be an HTTPS URL");
110
154
  },
111
155
  );
156
+
157
+ it("keeps Connected Profile opt-in and rejects secrets and unsafe handoff configuration", () => {
158
+ const connected = {
159
+ issuer: "https://app.example.com",
160
+ loginUrl: "https://app.example.com/login",
161
+ };
162
+ expect(defineFeedbax({ ...input, connected }).connected).toEqual(connected);
163
+ expect(defineFeedbax(input).connected).toBeUndefined();
164
+ expect(() =>
165
+ defineFeedbax({
166
+ ...input,
167
+ product: { ...input.product, siteUrl: undefined },
168
+ connected,
169
+ }),
170
+ ).toThrow("siteUrl");
171
+ expect(() =>
172
+ defineFeedbax({
173
+ ...input,
174
+ connected: { ...connected, loginUrl: "http://app.example.com/login" },
175
+ }),
176
+ ).toThrow("HTTPS");
177
+ expect(() =>
178
+ defineFeedbax({
179
+ ...input,
180
+ connected: { ...connected, secret: "never-public" },
181
+ } as unknown as FeedbaxConfig),
182
+ ).toThrow("Unknown connected key");
183
+ });
@@ -51,10 +51,33 @@ export type ChangelogConfiguration = {
51
51
  readonly dataSourceId: string;
52
52
  readonly properties: ChangelogPropertyIds;
53
53
  };
54
- export type FeedbaxConfig = {
54
+ export type EmailConfiguration = {
55
+ readonly provider: "cloudflare" | "resend" | "custom";
56
+ readonly from: string;
57
+ readonly endpoint?: string;
58
+ };
59
+ export type ConnectedConfiguration = {
60
+ readonly processing?: "small" | "standard";
61
+ readonly email?: EmailConfiguration;
62
+ readonly images?: boolean;
63
+ readonly issuer: string;
64
+ readonly loginUrl: string;
65
+ };
66
+ export type GitHubConfiguration = {
67
+ readonly installationId: string;
68
+ readonly serviceUrl: string;
69
+ readonly repository: {
70
+ readonly id: number;
71
+ readonly owner: string;
72
+ readonly name: string;
73
+ };
74
+ readonly botUserId: number;
75
+ };
76
+ type CommonConfiguration = {
77
+ readonly history?: boolean;
78
+ readonly connected?: ConnectedConfiguration;
55
79
  readonly product: ProductConfiguration;
56
80
  readonly theme: { readonly preset: string };
57
- readonly notion: NotionConfiguration;
58
81
  readonly changelog?: ChangelogConfiguration;
59
82
  readonly features: PortalFeatures;
60
83
  readonly submissions: {
@@ -65,7 +88,25 @@ export type FeedbaxConfig = {
65
88
  readonly turnstile: { readonly enabled: boolean; readonly siteKey?: string };
66
89
  };
67
90
 
91
+ export type FeedbaxConfig = CommonConfiguration &
92
+ (
93
+ | {
94
+ readonly provider?: "notion";
95
+ readonly notion: NotionConfiguration;
96
+ readonly github?: never;
97
+ }
98
+ | {
99
+ readonly provider: "github";
100
+ readonly github: GitHubConfiguration;
101
+ readonly notion?: never;
102
+ }
103
+ );
104
+
68
105
  const rootKeys = [
106
+ "history",
107
+ "provider",
108
+ "github",
109
+ "connected",
69
110
  "product",
70
111
  "theme",
71
112
  "notion",
@@ -110,6 +151,8 @@ export function defineFeedbax(config: FeedbaxConfig): FeedbaxConfig;
110
151
  export function defineFeedbax(config: unknown): FeedbaxConfig {
111
152
  const value = object(config, "Feedbax configuration");
112
153
  rejectUnknown(value, rootKeys, "Feedbax configuration");
154
+ if (value.history !== undefined && typeof value.history !== "boolean")
155
+ throw new Error("history must be true or false.");
113
156
  const product = object(value.product, "product");
114
157
  rejectUnknown(
115
158
  product,
@@ -124,13 +167,108 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
124
167
  ...optionalPublicUrl(product, "supportUrl"),
125
168
  ...optionalPublicUrl(product, "privacyUrl"),
126
169
  };
170
+ let connected: ConnectedConfiguration | undefined;
171
+ if (value.connected !== undefined) {
172
+ const input = object(value.connected, "connected");
173
+ rejectUnknown(
174
+ input,
175
+ ["issuer", "loginUrl", "email", "images", "processing"],
176
+ "connected",
177
+ );
178
+ const issuer = publicUrl(input.issuer, "connected.issuer", false);
179
+ const loginUrl = publicUrl(input.loginUrl, "connected.loginUrl", false);
180
+ if (!validatedProduct.siteUrl)
181
+ throw new Error("Connected Profile requires product.siteUrl.");
182
+ if (
183
+ new URL(loginUrl).username ||
184
+ new URL(loginUrl).password ||
185
+ new URL(loginUrl).hash
186
+ )
187
+ throw new Error(
188
+ "connected.loginUrl must not contain credentials or a fragment.",
189
+ );
190
+ let email: EmailConfiguration | undefined;
191
+ if (input.email !== undefined) {
192
+ const sender = object(input.email, "connected.email");
193
+ rejectUnknown(
194
+ sender,
195
+ ["provider", "from", "endpoint"],
196
+ "connected.email",
197
+ );
198
+ if (!["cloudflare", "resend", "custom"].includes(String(sender.provider)))
199
+ throw new Error(
200
+ "connected.email.provider must be cloudflare, resend, or custom.",
201
+ );
202
+ const from = text(sender.from, "connected.email.from");
203
+ if (from.length > 254 || !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(from))
204
+ throw new Error(
205
+ "connected.email.from must be a plain sender email address.",
206
+ );
207
+ const endpoint =
208
+ sender.endpoint === undefined
209
+ ? undefined
210
+ : publicUrl(sender.endpoint, "connected.email.endpoint", false);
211
+ if (sender.provider === "custom" && !endpoint)
212
+ throw new Error("Custom email requires connected.email.endpoint.");
213
+ if (sender.provider !== "custom" && endpoint)
214
+ throw new Error("Only custom email uses connected.email.endpoint.");
215
+ if (
216
+ endpoint &&
217
+ (new URL(endpoint).username ||
218
+ new URL(endpoint).password ||
219
+ new URL(endpoint).hash)
220
+ )
221
+ throw new Error(
222
+ "Custom email endpoint must not contain credentials or a fragment.",
223
+ );
224
+ email = {
225
+ provider: sender.provider as EmailConfiguration["provider"],
226
+ from,
227
+ ...(endpoint ? { endpoint } : {}),
228
+ };
229
+ }
230
+ if (input.images !== undefined && typeof input.images !== "boolean")
231
+ throw new Error("connected.images must be true or false.");
232
+ if (
233
+ input.processing !== undefined &&
234
+ input.processing !== "small" &&
235
+ input.processing !== "standard"
236
+ )
237
+ throw new Error("connected.processing must be small or standard.");
238
+ connected = {
239
+ issuer,
240
+ loginUrl,
241
+ ...(input.processing !== undefined
242
+ ? { processing: input.processing }
243
+ : {}),
244
+ ...(email ? { email } : {}),
245
+ ...(input.images !== undefined ? { images: input.images } : {}),
246
+ };
247
+ }
127
248
  const theme = object(value.theme, "theme");
128
249
  rejectUnknown(theme, ["preset"], "theme");
129
- const notion = storage(
130
- value.notion,
131
- feedbackPropertyKeys,
132
- "notion",
133
- ) as NotionConfiguration;
250
+ if (
251
+ value.provider !== undefined &&
252
+ !["notion", "github"].includes(String(value.provider))
253
+ )
254
+ throw new Error("provider must be notion or github.");
255
+ const githubMode = value.provider === "github";
256
+ if (
257
+ (githubMode && value.notion !== undefined) ||
258
+ (!githubMode && value.github !== undefined)
259
+ )
260
+ throw new Error("Select exactly one Content Provider: notion or github.");
261
+ const content: Pick<FeedbaxConfig, "provider" | "notion" | "github"> =
262
+ githubMode
263
+ ? { provider: "github", github: githubConfiguration(value.github) }
264
+ : {
265
+ ...(value.provider ? { provider: "notion" as const } : {}),
266
+ notion: storage(
267
+ value.notion,
268
+ feedbackPropertyKeys,
269
+ "notion",
270
+ ) as NotionConfiguration,
271
+ };
134
272
  const features = object(value.features, "features");
135
273
  rejectUnknown(features, featureKeys, "features");
136
274
  const validatedFeatures = Object.fromEntries(
@@ -140,6 +278,13 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
140
278
  return [key, features[key]];
141
279
  }),
142
280
  ) as PortalFeatures;
281
+ if (
282
+ githubMode &&
283
+ (validatedFeatures.changelog || value.changelog !== undefined)
284
+ )
285
+ throw new Error(
286
+ "GitHub Installations must disable Changelog (features.changelog: false) and remove Notion Changelog configuration.",
287
+ );
143
288
  const changelog =
144
289
  value.changelog === undefined
145
290
  ? undefined
@@ -172,8 +317,10 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
172
317
  throw new Error("Turnstile is enabled but turnstile.siteKey is missing.");
173
318
  return {
174
319
  product: validatedProduct,
320
+ ...(value.history !== undefined ? { history: value.history } : {}),
321
+ ...(connected ? { connected } : {}),
175
322
  theme: { preset: text(theme.preset, "theme.preset") },
176
- notion,
323
+ ...content,
177
324
  ...(changelog ? { changelog } : {}),
178
325
  features: validatedFeatures,
179
326
  submissions: {
@@ -188,7 +335,7 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
188
335
  ),
189
336
  },
190
337
  turnstile: { enabled: turnstile.enabled, ...(siteKey ? { siteKey } : {}) },
191
- };
338
+ } as FeedbaxConfig;
192
339
  }
193
340
 
194
341
  export function assertProductionConfiguration(
@@ -269,3 +416,52 @@ function publicUrl(
269
416
  `${label} must be ${allowRelative ? "root-relative or " : ""}an HTTPS URL.`,
270
417
  );
271
418
  }
419
+
420
+ function githubConfiguration(value: unknown): GitHubConfiguration {
421
+ const input = object(value, "github");
422
+ rejectUnknown(
423
+ input,
424
+ ["installationId", "serviceUrl", "repository", "botUserId"],
425
+ "github",
426
+ );
427
+ const installationId = text(input.installationId, "github.installationId");
428
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(installationId))
429
+ throw new Error(
430
+ "github.installationId must be a stable Installation identifier.",
431
+ );
432
+ const serviceUrl = publicUrl(input.serviceUrl, "github.serviceUrl", false);
433
+ const url = new URL(serviceUrl);
434
+ if (url.origin !== serviceUrl || url.username || url.password)
435
+ throw new Error(
436
+ "github.serviceUrl must be an HTTPS origin without a path or credentials.",
437
+ );
438
+ const repository = object(input.repository, "github.repository");
439
+ rejectUnknown(repository, ["id", "owner", "name"], "github.repository");
440
+ const owner = text(repository.owner, "github.repository.owner");
441
+ const name = text(repository.name, "github.repository.name");
442
+ if (!/^[A-Za-z0-9-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(name))
443
+ throw new Error("Use a valid GitHub repository owner and name.");
444
+ return {
445
+ installationId,
446
+ serviceUrl,
447
+ repository: {
448
+ id: positiveInteger(repository.id, "github.repository.id"),
449
+ owner,
450
+ name,
451
+ },
452
+ botUserId: positiveInteger(input.botUserId, "github.botUserId"),
453
+ };
454
+ }
455
+
456
+ export function assertRuntimeBindings(
457
+ config: FeedbaxConfig,
458
+ bindings: { FEEDBAX_DB?: unknown },
459
+ ) {
460
+ if (
461
+ (config.provider === "github" || config.connected || config.history) &&
462
+ !bindings.FEEDBAX_DB
463
+ )
464
+ throw new Error(
465
+ "This Installation requires the FEEDBAX_DB D1 binding. Provision D1 and apply the included migrations; D1 does not enable verified identity.",
466
+ );
467
+ }
@@ -0,0 +1,38 @@
1
+ import type { CommentAuthor } from "./index";
2
+
3
+ export interface StoredComment {
4
+ recoverable?: boolean;
5
+ id: string;
6
+ postId: string;
7
+ discussionId: string;
8
+ body: string;
9
+ author: CommentAuthor;
10
+ createdAt: Date;
11
+ resolved: boolean;
12
+ scope?: "page" | "block";
13
+ }
14
+
15
+ export interface CommentStorage {
16
+ supportsIntentRecovery?: boolean;
17
+ createPageComment(input: {
18
+ submissionKey?: string;
19
+ recoveryCommentId?: string;
20
+ postId: string;
21
+ body: string;
22
+ displayName: string;
23
+ }): Promise<StoredComment>;
24
+ createDiscussionReply(input: {
25
+ submissionKey?: string;
26
+ recoveryCommentId?: string;
27
+ postId: string;
28
+ discussionId: string;
29
+ body: string;
30
+ displayName: string;
31
+ }): Promise<StoredComment>;
32
+ listPageComments(
33
+ postId: string,
34
+ cursor?: string,
35
+ ): Promise<{ items: StoredComment[]; nextCursor?: string }>;
36
+ updateComment(commentId: string, body: string): Promise<StoredComment>;
37
+ deleteComment(commentId: string): Promise<void>;
38
+ }