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.
- package/README.md +17 -5
- package/dist/cli.js +502 -209
- package/dist/index.js +251 -68
- 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 +1 -1
- package/template/CONNECTED.md +16 -2
- package/template/README.md +52 -13
- package/template/feedbax-vite.ts +79 -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 +34 -33
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +3 -0
- package/template/src/comment-creation.test.tsx +53 -6
- package/template/src/components/connected-identity-provider.tsx +44 -17
- package/template/src/components/follow-post-button.tsx +2 -13
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +7 -0
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +7 -3
- package/template/src/connected-identity.ts +6 -1
- package/template/src/connected-runtime.test.ts +5 -2
- package/template/src/feedback-cache-invalidation.ts +2 -0
- package/template/src/feedback-runtime.ts +61 -18
- 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 +45 -1
- package/template/src/feedbax/config/index.ts +112 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +41 -12
- package/template/src/feedbax/feedback/connected.ts +15 -5
- 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/image-feedback.ts +23 -10
- package/template/src/feedbax/feedback/images.ts +26 -0
- package/template/src/feedbax/feedback/index.ts +172 -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/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/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/index.css +6 -0
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- 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/participation-runtime.ts +3 -3
- package/template/src/portal-comments.ts +11 -0
- package/template/src/portal-feedback-form.tsx +11 -3
- package/template/src/portal-feedback-submission.ts +9 -0
- package/template/src/portal-server-entry.ts +9 -0
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail.tsx +332 -256
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/routeTree.gen.ts +21 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/server.ts +46 -1
- package/template/src/submission-intent.ts +26 -0
- package/template/vite.config.ts +2 -0
|
@@ -63,11 +63,21 @@ export type ConnectedConfiguration = {
|
|
|
63
63
|
readonly issuer: string;
|
|
64
64
|
readonly loginUrl: string;
|
|
65
65
|
};
|
|
66
|
-
export type
|
|
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;
|
|
67
78
|
readonly connected?: ConnectedConfiguration;
|
|
68
79
|
readonly product: ProductConfiguration;
|
|
69
80
|
readonly theme: { readonly preset: string };
|
|
70
|
-
readonly notion: NotionConfiguration;
|
|
71
81
|
readonly changelog?: ChangelogConfiguration;
|
|
72
82
|
readonly features: PortalFeatures;
|
|
73
83
|
readonly submissions: {
|
|
@@ -78,7 +88,24 @@ export type FeedbaxConfig = {
|
|
|
78
88
|
readonly turnstile: { readonly enabled: boolean; readonly siteKey?: string };
|
|
79
89
|
};
|
|
80
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
|
+
|
|
81
105
|
const rootKeys = [
|
|
106
|
+
"history",
|
|
107
|
+
"provider",
|
|
108
|
+
"github",
|
|
82
109
|
"connected",
|
|
83
110
|
"product",
|
|
84
111
|
"theme",
|
|
@@ -124,6 +151,8 @@ export function defineFeedbax(config: FeedbaxConfig): FeedbaxConfig;
|
|
|
124
151
|
export function defineFeedbax(config: unknown): FeedbaxConfig {
|
|
125
152
|
const value = object(config, "Feedbax configuration");
|
|
126
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.");
|
|
127
156
|
const product = object(value.product, "product");
|
|
128
157
|
rejectUnknown(
|
|
129
158
|
product,
|
|
@@ -218,11 +247,28 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
|
|
|
218
247
|
}
|
|
219
248
|
const theme = object(value.theme, "theme");
|
|
220
249
|
rejectUnknown(theme, ["preset"], "theme");
|
|
221
|
-
|
|
222
|
-
value.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
};
|
|
226
272
|
const features = object(value.features, "features");
|
|
227
273
|
rejectUnknown(features, featureKeys, "features");
|
|
228
274
|
const validatedFeatures = Object.fromEntries(
|
|
@@ -232,6 +278,13 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
|
|
|
232
278
|
return [key, features[key]];
|
|
233
279
|
}),
|
|
234
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
|
+
);
|
|
235
288
|
const changelog =
|
|
236
289
|
value.changelog === undefined
|
|
237
290
|
? undefined
|
|
@@ -264,9 +317,10 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
|
|
|
264
317
|
throw new Error("Turnstile is enabled but turnstile.siteKey is missing.");
|
|
265
318
|
return {
|
|
266
319
|
product: validatedProduct,
|
|
320
|
+
...(value.history !== undefined ? { history: value.history } : {}),
|
|
267
321
|
...(connected ? { connected } : {}),
|
|
268
322
|
theme: { preset: text(theme.preset, "theme.preset") },
|
|
269
|
-
|
|
323
|
+
...content,
|
|
270
324
|
...(changelog ? { changelog } : {}),
|
|
271
325
|
features: validatedFeatures,
|
|
272
326
|
submissions: {
|
|
@@ -281,7 +335,7 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
|
|
|
281
335
|
),
|
|
282
336
|
},
|
|
283
337
|
turnstile: { enabled: turnstile.enabled, ...(siteKey ? { siteKey } : {}) },
|
|
284
|
-
};
|
|
338
|
+
} as FeedbaxConfig;
|
|
285
339
|
}
|
|
286
340
|
|
|
287
341
|
export function assertProductionConfiguration(
|
|
@@ -362,3 +416,52 @@ function publicUrl(
|
|
|
362
416
|
`${label} must be ${allowRelative ? "root-relative or " : ""}an HTTPS URL.`,
|
|
363
417
|
);
|
|
364
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
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import {
|
|
2
3
|
type FeedbackModule,
|
|
3
4
|
type PublicPost,
|
|
4
5
|
PublicPostCursorError,
|
|
5
6
|
VoteEligibilityError,
|
|
7
|
+
recoverableComment,
|
|
8
|
+
CommentValidationError,
|
|
6
9
|
roadmapStatuses,
|
|
7
10
|
type PublicPostRoadmap,
|
|
8
11
|
} from "./index";
|
|
@@ -48,30 +51,56 @@ export function createConnectedFeedbackModule(
|
|
|
48
51
|
},
|
|
49
52
|
) => {
|
|
50
53
|
const identity = requireParticipant();
|
|
51
|
-
|
|
54
|
+
if (
|
|
55
|
+
input.submissionKey !== undefined &&
|
|
56
|
+
!/^[A-Za-z0-9_-]{43}$/.test(input.submissionKey)
|
|
57
|
+
)
|
|
58
|
+
throw new CommentValidationError("Comment intent is invalid.");
|
|
59
|
+
const command = {
|
|
60
|
+
...input,
|
|
61
|
+
displayName: identity.displayName,
|
|
62
|
+
...(input.submissionKey
|
|
63
|
+
? {
|
|
64
|
+
submissionKey: createHash("sha256")
|
|
65
|
+
.update(
|
|
66
|
+
JSON.stringify([
|
|
67
|
+
"feedbax-verified-comment-v1",
|
|
68
|
+
identity.subject,
|
|
69
|
+
input.submissionKey,
|
|
70
|
+
]),
|
|
71
|
+
)
|
|
72
|
+
.digest("base64url"),
|
|
73
|
+
}
|
|
74
|
+
: {}),
|
|
75
|
+
};
|
|
52
76
|
const comment = input.discussionId
|
|
53
77
|
? await base.replyToCommentThread({
|
|
54
78
|
...command,
|
|
55
79
|
discussionId: input.discussionId,
|
|
56
80
|
})
|
|
57
81
|
: await base.createComment(command);
|
|
58
|
-
//
|
|
82
|
+
// Stable provider intents retain their record for attribution recovery. Older adapters compensate best-effort; verification is returned only after the private write.
|
|
59
83
|
try {
|
|
60
84
|
await store.attributeComment(comment.id, identity);
|
|
61
85
|
} catch (error) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
86
|
+
if (!comment[recoverableComment])
|
|
87
|
+
await base
|
|
88
|
+
.deleteComment({ commentId: comment.id })
|
|
89
|
+
.catch(() => undefined);
|
|
65
90
|
throw error;
|
|
66
91
|
}
|
|
67
|
-
return
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
92
|
+
return Object.defineProperty(
|
|
93
|
+
{
|
|
94
|
+
...comment,
|
|
95
|
+
author: {
|
|
96
|
+
kind: "participant" as const,
|
|
97
|
+
displayName: identity.displayName,
|
|
98
|
+
verified: true,
|
|
99
|
+
},
|
|
73
100
|
},
|
|
74
|
-
|
|
101
|
+
recoverableComment,
|
|
102
|
+
{ value: comment[recoverableComment] },
|
|
103
|
+
);
|
|
75
104
|
};
|
|
76
105
|
const listPublicRoadmapPosts: FeedbackModule["listPublicRoadmapPosts"] =
|
|
77
106
|
async (query) => {
|
|
@@ -169,11 +169,21 @@ export function createConnectedStore(binding: D1Database) {
|
|
|
169
169
|
commentId: string,
|
|
170
170
|
participant: VerifiedParticipant,
|
|
171
171
|
) {
|
|
172
|
-
await db
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
172
|
+
await db
|
|
173
|
+
.insert(attribution)
|
|
174
|
+
.values({
|
|
175
|
+
commentId,
|
|
176
|
+
subject: participant.subject,
|
|
177
|
+
displayName: participant.displayName,
|
|
178
|
+
})
|
|
179
|
+
.onConflictDoNothing();
|
|
180
|
+
const owner = await db
|
|
181
|
+
.select({ subject: attribution.subject })
|
|
182
|
+
.from(attribution)
|
|
183
|
+
.where(eq(attribution.commentId, commentId))
|
|
184
|
+
.get();
|
|
185
|
+
if (owner?.subject !== participant.subject)
|
|
186
|
+
throw new Error("Comment attribution is unavailable.");
|
|
177
187
|
},
|
|
178
188
|
async commentAuthors(ids: string[]) {
|
|
179
189
|
const result = new Map<
|