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.
- package/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- package/template/vite.config.ts +2 -0
|
@@ -1,38 +1,148 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
-
|
|
130
|
-
value.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
+
}
|