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
@@ -63,11 +63,21 @@ export type ConnectedConfiguration = {
63
63
  readonly issuer: string;
64
64
  readonly loginUrl: string;
65
65
  };
66
- export type FeedbaxConfig = {
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
- const notion = storage(
222
- value.notion,
223
- feedbackPropertyKeys,
224
- "notion",
225
- ) 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
+ };
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
- notion,
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
- const command = { ...input, displayName: identity.displayName };
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
- // If attribution cannot be stored, remove the just-created Comment. Never claim verification without durable evidence.
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
- await base
63
- .deleteComment({ commentId: comment.id })
64
- .catch(() => undefined);
86
+ if (!comment[recoverableComment])
87
+ await base
88
+ .deleteComment({ commentId: comment.id })
89
+ .catch(() => undefined);
65
90
  throw error;
66
91
  }
67
- return {
68
- ...comment,
69
- author: {
70
- kind: "participant" as const,
71
- displayName: identity.displayName,
72
- verified: true,
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.insert(attribution).values({
173
- commentId,
174
- subject: participant.subject,
175
- displayName: participant.displayName,
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<
@@ -7,6 +7,7 @@ export type StoredPost = Post &
7
7
  browserCapabilityHash: string;
8
8
  source: PostSource;
9
9
  externalId: string;
10
+ draftEditable: boolean;
10
11
  }> &
11
12
  Record<string, unknown>;
12
13