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
@@ -8,7 +8,7 @@ import { followQuery, participationRequest } from "../participation-client";
8
8
 
9
9
  export function FollowPostButton({ slug }: { slug: string }) {
10
10
  const identity = useConnectedIdentity();
11
- if (!identity.enabled) return null;
11
+ if (!identity.enabled || !identity.displayName) return null;
12
12
  return <ConnectedFollowPostButton slug={slug} />;
13
13
  }
14
14
  function ConnectedFollowPostButton({ slug }: { slug: string }) {
@@ -20,18 +20,7 @@ function ConnectedFollowPostButton({ slug }: { slug: string }) {
20
20
  });
21
21
  const [pending, setPending] = useState(false);
22
22
  const [error, setError] = useState<string>();
23
- if (!identity.enabled) return null;
24
- if (!identity.displayName)
25
- return (
26
- <Button
27
- variant="outline"
28
- size="sm"
29
- nativeButton={false}
30
- render={<a href="/internal/identity/start" />}
31
- >
32
- Sign in to follow
33
- </Button>
34
- );
23
+ if (!identity.enabled || !identity.displayName) return null;
35
24
  async function toggle() {
36
25
  setPending(true);
37
26
  setError(undefined);
@@ -0,0 +1,101 @@
1
+ import type { ObservedHistoryPage } from "@feedbax/feedback";
2
+ import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { Button } from "@feedbax/ui/components/button";
4
+ import { Skeleton } from "@feedbax/ui/components/skeleton";
5
+ import { formatPublicDate } from "../public-date";
6
+
7
+ export function PostHistory({ slug }: { slug: string }) {
8
+ const client = useQueryClient();
9
+ const clearPublicPost = () => {
10
+ client.setQueryData(["public-post", slug], null);
11
+ client.setQueryData(["public-post-comments", slug], {
12
+ pages: [{ items: [] }],
13
+ pageParams: [undefined],
14
+ });
15
+ void client.invalidateQueries({ queryKey: ["public-post-roadmap"] });
16
+ };
17
+ const query = useInfiniteQuery({
18
+ queryKey: ["public-post-history", slug],
19
+ initialPageParam: undefined as string | undefined,
20
+ queryFn: async ({ pageParam }) => {
21
+ const params = new URLSearchParams({
22
+ slug,
23
+ ...(pageParam ? { cursor: pageParam } : {}),
24
+ });
25
+ try {
26
+ const response = await fetch(`/internal/post-history?${params}`, {
27
+ cache: "no-store",
28
+ });
29
+ if (!response.ok) throw Error("Progress history is unavailable.");
30
+ const page = (await response.json()) as ObservedHistoryPage;
31
+ if (!page.available) clearPublicPost();
32
+ return page;
33
+ } catch (error) {
34
+ clearPublicPost();
35
+ throw error;
36
+ }
37
+ },
38
+ getNextPageParam: (page) => page.nextCursor,
39
+ staleTime: 0,
40
+ retry: false,
41
+ });
42
+ const events = query.isError
43
+ ? []
44
+ : (query.data?.pages.flatMap((page) => page.items) ?? []);
45
+ return (
46
+ <section className="space-y-3" aria-label="Activities">
47
+ <h2 className="sr-only">Activities</h2>
48
+ {query.isPending ? (
49
+ <Skeleton className="h-12 w-full" />
50
+ ) : query.isError ? (
51
+ <div role="alert" className="space-y-2">
52
+ <p className="text-sm">
53
+ Progress history is temporarily unavailable.
54
+ </p>
55
+ <Button
56
+ variant="outline"
57
+ size="sm"
58
+ onClick={() => void query.refetch()}
59
+ >
60
+ Retry history
61
+ </Button>
62
+ </div>
63
+ ) : events.length ? (
64
+ <ol className="space-y-4 border-l pl-4">
65
+ {events.map((event) => (
66
+ <li key={event.id} className="space-y-1 text-sm">
67
+ <time
68
+ dateTime={new Date(event.observedAt).toISOString()}
69
+ className="text-xs text-muted-foreground"
70
+ >
71
+ Observed {formatPublicDate(new Date(event.observedAt))}
72
+ </time>
73
+ {event.kind === "status" ? (
74
+ <p>
75
+ Moved to <span className="font-medium">{event.status}</span>
76
+ </p>
77
+ ) : (
78
+ <>
79
+ <p className="font-medium">Product Team update</p>
80
+ <p className="whitespace-pre-wrap">{event.body}</p>
81
+ </>
82
+ )}
83
+ </li>
84
+ ))}
85
+ </ol>
86
+ ) : (
87
+ <p className="text-sm text-muted-foreground">No activities yet.</p>
88
+ )}
89
+ {query.hasNextPage && !query.isError ? (
90
+ <Button
91
+ variant="outline"
92
+ size="sm"
93
+ disabled={query.isFetchingNextPage}
94
+ onClick={() => void query.fetchNextPage()}
95
+ >
96
+ {query.isFetchingNextPage ? "Loading history…" : "More history"}
97
+ </Button>
98
+ ) : null}
99
+ </section>
100
+ );
101
+ }
@@ -33,6 +33,13 @@ export function ProfileMenu() {
33
33
  </Button>
34
34
  {identity.displayName ? (
35
35
  <>
36
+ <Button
37
+ nativeButton={false}
38
+ variant="ghost"
39
+ render={<a href="/my-feedback" />}
40
+ >
41
+ My feedback
42
+ </Button>
36
43
  <span>{identity.displayName}</span>
37
44
  <Button variant="outline" onClick={() => void identity.signOut()}>
38
45
  Sign out
@@ -0,0 +1,82 @@
1
+ import { afterEach, expect, it } from "vitest";
2
+ import { Miniflare } from "miniflare";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { createContentFeedbackModule } from "./configured-feedback";
6
+ import type { FeedbaxConfig } from "@feedbax/config";
7
+
8
+ let runtime: Miniflare | undefined;
9
+ afterEach(async () => {
10
+ await runtime?.dispose();
11
+ });
12
+ it("runs a GitHub-only portal without Notion or verified identity and requires D1", async () => {
13
+ const config: FeedbaxConfig = {
14
+ provider: "github",
15
+ product: {
16
+ name: "Acme",
17
+ description: "Feedback",
18
+ logo: "/logo.svg",
19
+ siteUrl: "https://portal.example",
20
+ },
21
+ theme: { preset: "lyra-neutral" },
22
+ github: {
23
+ installationId: "portal-acme",
24
+ serviceUrl: "https://service.example",
25
+ repository: { id: 123, owner: "acme", name: "feedback" },
26
+ botUserId: 456,
27
+ },
28
+ features: { voting: true, comments: true, changelog: false },
29
+ submissions: {
30
+ moderation: "required",
31
+ maxTitleLength: 160,
32
+ maxDescriptionLength: 5000,
33
+ },
34
+ turnstile: { enabled: false },
35
+ };
36
+ expect(() => createContentFeedbackModule(config, {})).toThrow("FEEDBAX_DB");
37
+ runtime = new Miniflare({
38
+ modules: true,
39
+ script: "export default { fetch() { return new Response('ok') } }",
40
+ d1Databases: ["DB"],
41
+ compatibilityDate: "2026-07-30",
42
+ });
43
+ const database = await runtime.getD1Database("DB");
44
+ const directory = resolve(import.meta.dirname, "../migrations");
45
+ for (const file of (await readdir(directory)).sort())
46
+ await database.exec(
47
+ (await readFile(resolve(directory, file), "utf8"))
48
+ .replace(/--[^\n]*/g, "")
49
+ .replace(/\s+/g, " "),
50
+ );
51
+ const urls: string[] = [];
52
+ const feedback = createContentFeedbackModule(
53
+ config,
54
+ { FEEDBAX_DB: database, GITHUB_SERVICE_SECRET: "p".repeat(40) },
55
+ async (input, init) => {
56
+ const request =
57
+ input instanceof Request ? input : new Request(input, init);
58
+ urls.push(request.url);
59
+ if (request.url === "https://service.example/v1/token") {
60
+ expect(request.headers.get("X-Feedbax-Installation")).toBe(
61
+ "portal-acme",
62
+ );
63
+ expect(request.headers.get("X-Feedbax-Signature")).toMatch(
64
+ /^[a-f0-9]{64}$/,
65
+ );
66
+ return Response.json({
67
+ token: "scoped-access",
68
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
69
+ repositoryId: 123,
70
+ botUserId: 456,
71
+ });
72
+ }
73
+ expect(request.headers.get("Authorization")).toBe("Bearer scoped-access");
74
+ if (request.url === "https://api.github.com/repos/acme/feedback")
75
+ return Response.json({ id: 123 });
76
+ return Response.json([]);
77
+ },
78
+ );
79
+ expect(await feedback.listPublicPosts()).toMatchObject({ items: [] });
80
+ expect(urls.some((url) => url.includes("notion"))).toBe(false);
81
+ expect(urls).toContain("https://service.example/v1/token");
82
+ });
@@ -0,0 +1,61 @@
1
+ import { assertRuntimeBindings, type FeedbaxConfig } from "@feedbax/config";
2
+ import {
3
+ createGitHubFeedbackModule,
4
+ createGitHubServiceAccess,
5
+ createNotionFeedbackModule,
6
+ type ConnectedDatabase,
7
+ type VerifiedParticipant,
8
+ createPostOwnership,
9
+ } from "@feedbax/feedback";
10
+
11
+ export function createContentFeedbackModule(
12
+ config: FeedbaxConfig,
13
+ environment: {
14
+ FEEDBAX_DB?: ConnectedDatabase;
15
+ NOTION_TOKEN?: string;
16
+ GITHUB_SERVICE_SECRET?: string;
17
+ getGitHubServiceSecret?: () => Promise<string>;
18
+ },
19
+ request: typeof fetch = fetch,
20
+ participant?: VerifiedParticipant,
21
+ ) {
22
+ assertRuntimeBindings(config, environment);
23
+ const features = {
24
+ votingEnabled: config.features.voting,
25
+ commentsEnabled: config.features.comments,
26
+ ...(config.connected && participant && environment.FEEDBAX_DB
27
+ ? { ownership: createPostOwnership(environment.FEEDBAX_DB, participant) }
28
+ : {}),
29
+ };
30
+ if (config.provider === "github") {
31
+ if (
32
+ !environment.getGitHubServiceSecret &&
33
+ (!environment.GITHUB_SERVICE_SECRET ||
34
+ environment.GITHUB_SERVICE_SECRET.length < 32)
35
+ )
36
+ throw new Error(
37
+ "GitHub requires GITHUB_SERVICE_SECRET. Pair this portal with the GitHub App service.",
38
+ );
39
+ return createGitHubFeedbackModule({
40
+ ...features,
41
+ ...config.github,
42
+ database: environment.FEEDBAX_DB!,
43
+ request,
44
+ getToken: createGitHubServiceAccess({
45
+ config: config.github,
46
+ secret: environment.GITHUB_SERVICE_SECRET,
47
+ getSecret: environment.getGitHubServiceSecret,
48
+ request,
49
+ }),
50
+ });
51
+ }
52
+ if (!environment.NOTION_TOKEN)
53
+ throw new Error("Notion requires NOTION_TOKEN.");
54
+ return createNotionFeedbackModule({
55
+ ...features,
56
+ token: environment.NOTION_TOKEN,
57
+ dataSourceId: config.notion.dataSourceId,
58
+ propertyIds: config.notion.properties,
59
+ request,
60
+ });
61
+ }
@@ -112,9 +112,13 @@ it("exchanges a browser-bound handoff once, keeps identity private, and revokes
112
112
  });
113
113
  const response = await handler(sessionRequest());
114
114
  expect(response.headers.get("cache-control")).toBe("no-store");
115
- expect(await response.json()).toEqual({
116
- participant: { displayName: "Alice" },
117
- });
115
+ const identity = (await response.json()) as {
116
+ participant: { displayName: string; sessionKey: string };
117
+ };
118
+ expect(identity.participant.displayName).toBe("Alice");
119
+ expect(identity.participant.sessionKey).toMatch(/^[A-Za-z0-9_-]{43}$/);
120
+ expect(JSON.stringify(identity)).not.toContain("private-subject");
121
+ expect(JSON.stringify(identity)).not.toContain(session.split("=")[1]);
118
122
  expect(
119
123
  (
120
124
  await handler(
@@ -89,7 +89,12 @@ export function createConnectedIdentityHandler(options: {
89
89
  : undefined;
90
90
  return json({
91
91
  participant: participant
92
- ? { displayName: participant.displayName }
92
+ ? {
93
+ displayName: participant.displayName,
94
+ sessionKey: createHash("sha256")
95
+ .update(`feedbax-ui-session-v1:${token}`)
96
+ .digest("base64url"),
97
+ }
93
98
  : null,
94
99
  });
95
100
  }
@@ -219,8 +219,11 @@ it("requires a valid session and same-origin mutations, and ignores browser-supp
219
219
  }),
220
220
  );
221
221
  expect(sessionRead?.status).toBe(200);
222
- expect(await sessionRead?.json()).toEqual({
223
- participant: { displayName: "Alice" },
222
+ expect(await sessionRead?.json()).toMatchObject({
223
+ participant: {
224
+ displayName: "Alice",
225
+ sessionKey: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
226
+ },
224
227
  });
225
228
  const unavailable = createConnectedRequestHandler({
226
229
  ...options,
@@ -47,6 +47,8 @@ export function createInvalidatingFeedbackModule({
47
47
  invalidator: PublicCacheInvalidator;
48
48
  }): FeedbackModule {
49
49
  return {
50
+ getParticipantPost: (id) => feedback.getParticipantPost(id),
51
+ claimDraftPost: (input) => feedback.claimDraftPost(input),
50
52
  getAttachmentPostState: (slug) => feedback.getAttachmentPostState(slug),
51
53
  editComment: (input) => feedback.editComment(input),
52
54
  deleteComment: (input) => feedback.deleteComment(input),
@@ -4,8 +4,8 @@ import {
4
4
  createNotionImageReviewWriter,
5
5
  createConnectedStore,
6
6
  createConnectedFeedbackModule,
7
+ createObservedHistoryModule,
7
8
  type VerifiedParticipant,
8
- createNotionFeedbackModule,
9
9
  } from "@feedbax/feedback";
10
10
  import { cache, env } from "cloudflare:workers";
11
11
 
@@ -14,27 +14,45 @@ import {
14
14
  createPublicCacheInvalidator,
15
15
  } from "./feedback-cache-invalidation";
16
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";
17
21
 
18
22
  export const feedbackSubmissionLimits = {
19
23
  maxTitleLength: feedbax.submissions.maxTitleLength,
20
24
  maxDescriptionLength: feedbax.submissions.maxDescriptionLength,
21
25
  } as const;
22
26
 
23
- export function createConfiguredNotionFeedbackModule() {
24
- configuredFeedback ??= createNotionFeedbackModule({
25
- votingEnabled: feedbax.features.voting,
26
- commentsEnabled: feedbax.features.comments,
27
- token: requiredEnvironmentValue("NOTION_TOKEN"),
28
- dataSourceId: feedbax.notion.dataSourceId,
29
- propertyIds: feedbax.notion.properties,
30
- });
31
- return configuredFeedback;
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
+ );
32
46
  }
33
47
 
34
48
  export function createConfiguredFeedbackModule(
35
49
  participant?: VerifiedParticipant,
36
50
  ) {
37
- const content = createConfiguredNotionFeedbackModule();
51
+ const source = createConfiguredContentFeedbackModule(participant);
52
+ const history = feedbax.history ? configuredHistory(source) : undefined;
53
+ const content = history
54
+ ? { ...source, getPublicPost: history.observe }
55
+ : source;
38
56
  const feedback = feedbax.connected
39
57
  ? createConnectedFeedbackModule(content, getConnectedStore(), participant)
40
58
  : content;
@@ -45,22 +63,37 @@ export function createConfiguredFeedbackModule(
45
63
  ? createImageFeedbackModule(
46
64
  feedback,
47
65
  images,
48
- createNotionImageReviewWriter({
49
- token: requiredEnvironmentValue("NOTION_TOKEN"),
50
- reviewUrl: (id) => images.reviewUrl(id, feedbax.product.siteUrl!),
51
- }),
66
+ feedbax.provider === "github"
67
+ ? undefined
68
+ : createNotionImageReviewWriter({
69
+ token: requiredEnvironmentValue("NOTION_TOKEN"),
70
+ reviewUrl: (id) => images.reviewUrl(id, feedbax.product.siteUrl!),
71
+ }),
52
72
  )
53
73
  : feedback;
54
74
  return createInvalidatingFeedbackModule({
55
- feedback: withImages,
75
+ feedback: createPublicReleaseFeedbackModule({
76
+ feedback: withImages,
77
+ siteUrl: feedbax.product.siteUrl,
78
+ ...(feedbax.features.changelog && feedbax.changelog
79
+ ? { changelog: createConfiguredChangelogModule(true) }
80
+ : {}),
81
+ }),
56
82
  invalidator: createPublicCacheInvalidator({
57
83
  purge: (options) => cache.purge(options),
58
84
  }),
59
85
  });
60
86
  }
61
87
 
62
- let configuredFeedback:
63
- 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
+ }
64
97
 
65
98
  export function requiredEnvironmentValue(name: string): string {
66
99
  const value = process.env[name];
@@ -103,3 +136,13 @@ export async function requestParticipant(request: Request) {
103
136
  const token = readSessionToken(request);
104
137
  return token ? getConnectedStore().session(sessionHash(token)) : undefined;
105
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", () =>