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
@@ -0,0 +1,150 @@
1
+ import { afterEach, expect, it } from "vitest";
2
+ import { Miniflare } from "miniflare";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { createFeedbackModule, type Post } from "./index";
6
+ import { createObservedHistoryModule } from "./observed-history";
7
+
8
+ let runtime: Miniflare | undefined;
9
+ afterEach(async () => {
10
+ await runtime?.dispose();
11
+ runtime = undefined;
12
+ });
13
+
14
+ it("records only observed public progress, deduplicates retries and revokes history while unpublished", async () => {
15
+ runtime = new Miniflare({
16
+ modules: true,
17
+ script: "export default {}",
18
+ d1Databases: ["DB"],
19
+ compatibilityDate: "2026-07-30",
20
+ });
21
+ const database = await runtime.getD1Database("DB");
22
+ const path = "0009_observed_history.sql";
23
+ await database.exec(
24
+ await readFile(
25
+ existsSync(`apps/portal/migrations/${path}`)
26
+ ? `apps/portal/migrations/${path}`
27
+ : `migrations/${path}`,
28
+ "utf8",
29
+ ),
30
+ );
31
+ let time = 10000;
32
+ const post: Post = {
33
+ id: "private-provider-id",
34
+ slug: "export-reports",
35
+ title: "Export reports",
36
+ description: "Public description",
37
+ type: "Feature Request",
38
+ status: "New",
39
+ published: false,
40
+ createdAt: new Date(1000),
41
+ updatedAt: new Date(2000),
42
+ submitter: { name: "Private name", email: "private@example.com" },
43
+ teamResponse: "Private draft response",
44
+ };
45
+ const view = () =>
46
+ createObservedHistoryModule({
47
+ database,
48
+ feedback: createFeedbackModule({ initialItems: [{ ...post }] }),
49
+ now: () => time,
50
+ });
51
+ expect((await view().list({ slug: post.slug })).items).toEqual([]);
52
+ post.status = "Planned";
53
+ post.published = true;
54
+ post.teamResponse = "Approved current response";
55
+ expect((await view().list({ slug: post.slug })).items).toEqual([]);
56
+ post.status = "In Progress";
57
+ post.teamResponse = "Work has started";
58
+ time = 20000;
59
+ const first = await view().list({ slug: post.slug });
60
+ expect(first.items).toMatchObject([
61
+ { kind: "status", status: "In Progress", observedAt: new Date(20000) },
62
+ { kind: "update", body: "Work has started", observedAt: new Date(20000) },
63
+ ]);
64
+ expect((await view().list({ slug: post.slug })).items).toEqual(first.items);
65
+ expect(JSON.stringify(first)).not.toMatch(
66
+ /Private|private-provider-id|private@example|actor|subject/,
67
+ );
68
+ post.published = false;
69
+ post.teamResponse = "Private later discussion";
70
+ time = 30000;
71
+ expect((await view().list({ slug: post.slug })).items).toEqual([]);
72
+ post.status = "Shipped";
73
+ post.published = true;
74
+ time = 40000;
75
+ expect((await view().list({ slug: post.slug })).items).toEqual(first.items);
76
+ post.status = "Planned";
77
+ post.teamResponse = "Another approved update";
78
+ time = 50000;
79
+ await Promise.all([view().observe(post.slug), view().observe(post.slug)]);
80
+ expect((await view().list({ slug: post.slug })).items).toHaveLength(4);
81
+ post.status = "Shipped";
82
+ time = 60000;
83
+ await database.exec(
84
+ "CREATE TRIGGER fail_history BEFORE INSERT ON public_post_history BEGIN SELECT RAISE(FAIL,'History unavailable'); END;",
85
+ );
86
+ await expect(view().observe(post.slug)).rejects.toThrow();
87
+ await database.exec("DROP TRIGGER fail_history;");
88
+ expect((await view().list({ slug: post.slug })).items).toHaveLength(5);
89
+ });
90
+
91
+ it("paginates a longer observed timeline without duplicates and rejects another Post's cursor", async () => {
92
+ runtime = new Miniflare({
93
+ modules: true,
94
+ script: "export default {}",
95
+ d1Databases: ["DB"],
96
+ compatibilityDate: "2026-07-30",
97
+ });
98
+ const database = await runtime.getD1Database("DB");
99
+ await database.exec(
100
+ await readFile(
101
+ existsSync("apps/portal/migrations/0009_observed_history.sql")
102
+ ? "apps/portal/migrations/0009_observed_history.sql"
103
+ : "migrations/0009_observed_history.sql",
104
+ "utf8",
105
+ ),
106
+ );
107
+ const post: Post = {
108
+ id: "source-id",
109
+ slug: "long-history",
110
+ title: "Long history",
111
+ description: "Public",
112
+ type: "General Feedback",
113
+ status: "Planned",
114
+ published: true,
115
+ createdAt: new Date(1000),
116
+ updatedAt: new Date(1000),
117
+ };
118
+ let time = 1000;
119
+ const view = () =>
120
+ createObservedHistoryModule({
121
+ database,
122
+ feedback: createFeedbackModule({ initialItems: [{ ...post }] }),
123
+ now: () => time,
124
+ });
125
+ await view().observe(post.slug);
126
+ for (let i = 0; i < 27; i++) {
127
+ time++;
128
+ post.status = i % 2 ? "Planned" : "In Progress";
129
+ await view().observe(post.slug);
130
+ }
131
+ const first = await view().list({ slug: post.slug });
132
+ expect(first.items).toHaveLength(25);
133
+ expect(first.items[0]).toMatchObject({
134
+ kind: "status",
135
+ status: "In Progress",
136
+ observedAt: new Date(1001),
137
+ });
138
+ const second = await view().list({
139
+ slug: post.slug,
140
+ cursor: first.nextCursor,
141
+ });
142
+ expect(second.items).toHaveLength(2);
143
+ expect(
144
+ new Set([...first.items, ...second.items].map((event) => event.id)).size,
145
+ ).toBe(27);
146
+ post.slug = "another-post";
147
+ await expect(
148
+ view().list({ slug: post.slug, cursor: first.nextCursor }),
149
+ ).rejects.toThrow("cursor");
150
+ });
@@ -0,0 +1,219 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { D1Database } from "@cloudflare/workers-types";
3
+ import type { FeedbackModule, PostStatus } from "./index";
4
+
5
+ export type ObservedHistoryEvent = {
6
+ id: string;
7
+ observedAt: Date;
8
+ } & ({ kind: "status"; status: PostStatus } | { kind: "update"; body: string });
9
+ export interface ObservedHistoryPage {
10
+ available: boolean;
11
+ items: ObservedHistoryEvent[];
12
+ nextCursor?: string;
13
+ }
14
+ type Snapshot = { status: PostStatus; response: string | null };
15
+ type Observation = { revision: number; projection: string | null };
16
+ type EventRow = {
17
+ id: string;
18
+ revision: number;
19
+ kind: "status" | "update";
20
+ status: PostStatus | null;
21
+ message: string | null;
22
+ observed_at: number;
23
+ };
24
+
25
+ // Callers supply the configured Feedback module, never provider payloads. The
26
+ // initial visible snapshot is a baseline, not reconstructed historical activity.
27
+ export function createObservedHistoryModule(options: {
28
+ database: D1Database;
29
+ feedback: FeedbackModule;
30
+ now?: () => number;
31
+ }) {
32
+ const { database, feedback } = options;
33
+ const now = options.now ?? Date.now;
34
+ const observe = async (slug: string) => {
35
+ const time = now();
36
+ const known = await database
37
+ .prepare("SELECT 1 FROM public_post_observations WHERE post_slug=?")
38
+ .bind(slug)
39
+ .first();
40
+ if (!known) {
41
+ const post = await feedback.getPublicPost(slug);
42
+ if (!post) return;
43
+ await database
44
+ .prepare(
45
+ "INSERT OR IGNORE INTO public_post_observations(post_slug,projection,checked_at) VALUES(?,?,?)",
46
+ )
47
+ .bind(
48
+ slug,
49
+ JSON.stringify({
50
+ status: post.status,
51
+ response: post.teamResponse ?? null,
52
+ }),
53
+ time,
54
+ )
55
+ .run();
56
+ return post;
57
+ }
58
+ const token = randomUUID();
59
+ const previous = await database
60
+ .prepare(
61
+ "UPDATE public_post_observations SET lease_token=?,lease_until=?,checked_at=? WHERE post_slug=? AND lease_until<=? RETURNING revision,projection",
62
+ )
63
+ .bind(token, time + 60000, time, slug, time)
64
+ .first<Observation>();
65
+ if (!previous) return feedback.getPublicPost(slug);
66
+ let providerReadSucceeded = false;
67
+ try {
68
+ const post = await feedback.getPublicPost(slug);
69
+ providerReadSucceeded = true;
70
+ const snapshot: Snapshot | null = post
71
+ ? { status: post.status, response: post.teamResponse ?? null }
72
+ : null;
73
+ const encoded = snapshot ? JSON.stringify(snapshot) : null;
74
+ const before: Snapshot | null = previous.projection
75
+ ? JSON.parse(previous.projection)
76
+ : null;
77
+ const revision = previous.revision + 1;
78
+ const statements = [];
79
+ if (before && snapshot) {
80
+ if (before.status !== snapshot.status)
81
+ statements.push(
82
+ database
83
+ .prepare(
84
+ "INSERT OR IGNORE INTO public_post_history(id,post_slug,revision,kind,status,observed_at) SELECT ?,?,?,'status',?,? WHERE EXISTS(SELECT 1 FROM public_post_observations WHERE post_slug=? AND lease_token=?)",
85
+ )
86
+ .bind(
87
+ randomUUID(),
88
+ slug,
89
+ revision,
90
+ snapshot.status,
91
+ time,
92
+ slug,
93
+ token,
94
+ ),
95
+ );
96
+ if (snapshot.response && before.response !== snapshot.response)
97
+ statements.push(
98
+ database
99
+ .prepare(
100
+ "INSERT OR IGNORE INTO public_post_history(id,post_slug,revision,kind,message,observed_at) SELECT ?,?,?,'update',?,? WHERE EXISTS(SELECT 1 FROM public_post_observations WHERE post_slug=? AND lease_token=?)",
101
+ )
102
+ .bind(
103
+ randomUUID(),
104
+ slug,
105
+ revision,
106
+ snapshot.response,
107
+ time,
108
+ slug,
109
+ token,
110
+ ),
111
+ );
112
+ }
113
+ statements.push(
114
+ database
115
+ .prepare(
116
+ "UPDATE public_post_observations SET revision=?,projection=?,lease_token=NULL,lease_until=0 WHERE post_slug=? AND lease_token=?",
117
+ )
118
+ .bind(revision, encoded, slug, token),
119
+ );
120
+ const committed = await database.batch(statements);
121
+ // A later observer may have revoked visibility after this lease expired.
122
+ // Its checkpoint wins; a fenced completion cannot authorize a public read.
123
+ return committed.at(-1)?.meta.changes === 1 ? post : undefined;
124
+ } catch (error) {
125
+ // Provider access loss breaks the public baseline. A storage failure
126
+ // preserves it so retry can durably record the same observed transition.
127
+ await database
128
+ .prepare(
129
+ "UPDATE public_post_observations SET projection=CASE WHEN ? THEN projection ELSE NULL END,lease_token=NULL,lease_until=0 WHERE post_slug=? AND lease_token=?",
130
+ )
131
+ .bind(providerReadSucceeded ? 1 : 0, slug, token)
132
+ .run();
133
+ throw error;
134
+ }
135
+ };
136
+ return {
137
+ observe,
138
+ async list(input: {
139
+ slug: string;
140
+ cursor?: string;
141
+ }): Promise<ObservedHistoryPage> {
142
+ if (!(await observe(input.slug))) return { available: false, items: [] };
143
+ let revision = -1;
144
+ let kind = "";
145
+ if (input.cursor) {
146
+ try {
147
+ const parsed = JSON.parse(
148
+ Buffer.from(input.cursor, "base64url").toString("utf8"),
149
+ );
150
+ if (
151
+ parsed.slug !== input.slug ||
152
+ !Number.isSafeInteger(parsed.revision) ||
153
+ parsed.revision < 0 ||
154
+ !["status", "update"].includes(parsed.kind)
155
+ )
156
+ throw Error();
157
+ revision = parsed.revision;
158
+ kind = parsed.kind;
159
+ } catch {
160
+ throw Error("Invalid observed-history cursor.");
161
+ }
162
+ }
163
+ const rows = (
164
+ await database
165
+ .prepare(
166
+ "SELECT id,revision,kind,status,message,observed_at FROM public_post_history WHERE post_slug=? AND (revision>? OR (revision=? AND kind>?)) ORDER BY revision,kind LIMIT 26",
167
+ )
168
+ .bind(input.slug, revision, revision, kind)
169
+ .all<EventRow>()
170
+ ).results;
171
+ const selected = rows.slice(0, 25);
172
+ const last = selected.at(-1);
173
+ return {
174
+ available: true,
175
+ items: selected.map((row) => ({
176
+ id: row.id,
177
+ observedAt: new Date(row.observed_at),
178
+ ...(row.kind === "status"
179
+ ? { kind: "status" as const, status: row.status! }
180
+ : { kind: "update" as const, body: row.message! }),
181
+ })),
182
+ ...(rows.length > 25 && last
183
+ ? {
184
+ nextCursor: Buffer.from(
185
+ JSON.stringify({
186
+ slug: input.slug,
187
+ revision: last.revision,
188
+ kind: last.kind,
189
+ }),
190
+ ).toString("base64url"),
191
+ }
192
+ : {}),
193
+ };
194
+ },
195
+ async synchronize(limit = 1) {
196
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10)
197
+ throw Error("History work limit must be 1–10.");
198
+ const rows = (
199
+ await database
200
+ .prepare(
201
+ "SELECT post_slug FROM public_post_observations WHERE lease_until<=? ORDER BY checked_at,post_slug LIMIT ?",
202
+ )
203
+ .bind(now(), limit)
204
+ .all<{ post_slug: string }>()
205
+ ).results;
206
+ let checked = 0,
207
+ failed = 0;
208
+ for (const row of rows) {
209
+ try {
210
+ await observe(row.post_slug);
211
+ checked++;
212
+ } catch {
213
+ failed++;
214
+ }
215
+ }
216
+ return { checked, failed };
217
+ },
218
+ };
219
+ }
@@ -0,0 +1,170 @@
1
+ import { createHash } from "node:crypto";
2
+ import { BrowserCapabilityAuthorizationError } from "./index";
3
+ import type { D1Database } from "@cloudflare/workers-types";
4
+ import type {
5
+ FeedbackModule,
6
+ ParticipantPost,
7
+ Post,
8
+ PostType,
9
+ PublicPost,
10
+ } from "./index";
11
+ import type { VerifiedParticipant } from "./connected";
12
+
13
+ export interface PostOwnership {
14
+ owns(id: string): Promise<boolean>;
15
+ register(post: Post): Promise<void>;
16
+ withdrawn(id: string): Promise<void>;
17
+ }
18
+ export function createPostOwnership(
19
+ database: D1Database,
20
+ participant: VerifiedParticipant,
21
+ ): PostOwnership {
22
+ return {
23
+ async owns(id) {
24
+ return Boolean(
25
+ await database
26
+ .prepare("SELECT 1 FROM post_ownership WHERE post_id=? AND subject=?")
27
+ .bind(id, participant.subject)
28
+ .first(),
29
+ );
30
+ },
31
+ async register(post) {
32
+ await database
33
+ .prepare(
34
+ "INSERT OR IGNORE INTO post_ownership(post_id,post_slug,subject,title,type,created_at) VALUES(?,?,?,?,?,?)",
35
+ )
36
+ .bind(
37
+ post.id,
38
+ post.slug,
39
+ participant.subject,
40
+ post.title,
41
+ post.type,
42
+ post.createdAt.getTime(),
43
+ )
44
+ .run();
45
+ if (!(await this.owns(post.id)))
46
+ throw new BrowserCapabilityAuthorizationError();
47
+ },
48
+ async withdrawn(id) {
49
+ await database
50
+ .prepare(
51
+ "UPDATE post_ownership SET withdrawn=1 WHERE post_id=? AND subject=?",
52
+ )
53
+ .bind(id, participant.subject)
54
+ .run();
55
+ },
56
+ };
57
+ }
58
+ export type MyFeedbackFilter = "submitted" | "supported" | "following";
59
+ export type MyFeedbackItem = Partial<ParticipantPost> &
60
+ Pick<PublicPost, "slug" | "title"> & {
61
+ unavailable?: boolean;
62
+ withdrawn?: boolean;
63
+ };
64
+ export interface MyFeedbackPage {
65
+ items: MyFeedbackItem[];
66
+ nextCursor?: string;
67
+ }
68
+ export function createMyFeedbackModule(options: {
69
+ database: D1Database;
70
+ feedback: FeedbackModule;
71
+ participant: VerifiedParticipant;
72
+ }) {
73
+ const { database, feedback, participant } = options;
74
+ return {
75
+ async list(input: {
76
+ filter: MyFeedbackFilter;
77
+ cursor?: string;
78
+ }): Promise<MyFeedbackPage> {
79
+ if (!["submitted", "supported", "following"].includes(input.filter))
80
+ throw new Error("Invalid My feedback filter.");
81
+ const fingerprint = createHash("sha256")
82
+ .update(JSON.stringify([participant.subject, input.filter]))
83
+ .digest("hex");
84
+ let cursor = { at: Number.MAX_SAFE_INTEGER, key: "" };
85
+ if (input.cursor) {
86
+ try {
87
+ if (input.cursor.length > 2048) throw new Error();
88
+ const value = JSON.parse(
89
+ Buffer.from(input.cursor, "base64url").toString(),
90
+ );
91
+ if (
92
+ value.view !== fingerprint ||
93
+ !Number.isSafeInteger(value.at) ||
94
+ value.at < 0 ||
95
+ typeof value.key !== "string" ||
96
+ value.key.length > 512
97
+ )
98
+ throw new Error();
99
+ cursor = value;
100
+ } catch {
101
+ throw new Error("Invalid My feedback cursor.");
102
+ }
103
+ }
104
+ const selection =
105
+ input.filter === "submitted"
106
+ ? "SELECT post_id AS key,post_slug AS slug,created_at AS at,title,type,withdrawn FROM post_ownership WHERE subject=?"
107
+ : input.filter === "supported"
108
+ ? "SELECT post_slug AS key,post_slug AS slug,0 AS at FROM participant_votes WHERE subject=?"
109
+ : "SELECT post_slug AS key,post_slug AS slug,followed_at AS at FROM post_follows WHERE subject=?";
110
+ const rows = (
111
+ await database
112
+ .prepare(
113
+ `SELECT * FROM (${selection}) WHERE at<? OR (at=? AND key>?) ORDER BY at DESC,key LIMIT 11`,
114
+ )
115
+ .bind(participant.subject, cursor.at, cursor.at, cursor.key)
116
+ .all<{
117
+ key: string;
118
+ slug: string;
119
+ at: number;
120
+ title?: string;
121
+ type?: PostType;
122
+ withdrawn?: number;
123
+ }>()
124
+ ).results;
125
+ const items = await Promise.all(
126
+ rows.slice(0, 10).map(async (row): Promise<MyFeedbackItem> => {
127
+ if (input.filter === "submitted") {
128
+ if (!row.withdrawn) {
129
+ const post = await feedback.getParticipantPost(row.key);
130
+ if (post) return post;
131
+ }
132
+ return {
133
+ id: row.key,
134
+ slug: row.slug,
135
+ title: row.title ?? "Submission unavailable",
136
+ type: row.type,
137
+ editable: false,
138
+ unavailable: true,
139
+ ...(row.withdrawn ? { withdrawn: true } : {}),
140
+ };
141
+ }
142
+ const post = await feedback.getPublicPost(row.slug);
143
+ return post
144
+ ? { ...post, published: true, editable: false }
145
+ : {
146
+ slug: row.slug,
147
+ title: "Post unavailable",
148
+ unavailable: true,
149
+ editable: false,
150
+ };
151
+ }),
152
+ );
153
+ const last = rows[9];
154
+ return {
155
+ items,
156
+ ...(rows.length > 10 && last
157
+ ? {
158
+ nextCursor: Buffer.from(
159
+ JSON.stringify({
160
+ at: last.at,
161
+ key: last.key,
162
+ view: fingerprint,
163
+ }),
164
+ ).toString("base64url"),
165
+ }
166
+ : {}),
167
+ };
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,80 @@
1
+ import { afterEach, expect, it } from "vitest";
2
+ import { Miniflare } from "miniflare";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import {
6
+ createFeedbackModule,
7
+ createObservedHistoryModule,
8
+ } from "@feedbax/feedback";
9
+ import { createPublicHistoryRequestHandler } from "./history-runtime";
10
+ let runtime: Miniflare | undefined;
11
+ afterEach(async () => {
12
+ await runtime?.dispose();
13
+ runtime = undefined;
14
+ });
15
+ it("returns only current public observed history with no-store and rejects invalid requests", async () => {
16
+ runtime = new Miniflare({
17
+ modules: true,
18
+ script: "export default {}",
19
+ d1Databases: ["DB"],
20
+ compatibilityDate: "2026-07-30",
21
+ });
22
+ const database = await runtime.getD1Database("DB");
23
+ await database.exec(
24
+ await readFile(
25
+ existsSync("apps/portal/migrations/0009_observed_history.sql")
26
+ ? "apps/portal/migrations/0009_observed_history.sql"
27
+ : "migrations/0009_observed_history.sql",
28
+ "utf8",
29
+ ),
30
+ );
31
+ const feedback = createFeedbackModule();
32
+ const history = createObservedHistoryModule({ database, feedback });
33
+ const handle = createPublicHistoryRequestHandler({
34
+ enabled: true,
35
+ history,
36
+ rateLimit: async () => true,
37
+ });
38
+ const response = await handle(
39
+ new Request(
40
+ "https://portal.example/internal/post-history?slug=unknown-post",
41
+ ),
42
+ );
43
+ expect(response?.headers.get("Cache-Control")).toBe("no-store");
44
+ expect(await response?.json()).toEqual({ available: false, items: [] });
45
+ expect(
46
+ (
47
+ await handle(
48
+ new Request(
49
+ "https://portal.example/internal/post-history?slug=%2Fprivate",
50
+ ),
51
+ )
52
+ )?.status,
53
+ ).toBe(400);
54
+ expect(
55
+ (
56
+ await handle(
57
+ new Request(
58
+ "https://portal.example/internal/post-history?slug=unknown-post",
59
+ { method: "POST" },
60
+ ),
61
+ )
62
+ )?.status,
63
+ ).toBe(405);
64
+ expect(
65
+ await handle(new Request("https://portal.example/other")),
66
+ ).toBeUndefined();
67
+ expect(
68
+ (
69
+ await createPublicHistoryRequestHandler({
70
+ enabled: false,
71
+ history,
72
+ rateLimit: async () => true,
73
+ })(
74
+ new Request(
75
+ "https://portal.example/internal/post-history?slug=unknown-post",
76
+ ),
77
+ )
78
+ )?.status,
79
+ ).toBe(404);
80
+ });
@@ -0,0 +1,37 @@
1
+ import type { createObservedHistoryModule } from "@feedbax/feedback";
2
+
3
+ export function createPublicHistoryRequestHandler(options: {
4
+ enabled: boolean;
5
+ history?: ReturnType<typeof createObservedHistoryModule>;
6
+ rateLimit: (request: Request) => Promise<boolean>;
7
+ }) {
8
+ return async (request: Request): Promise<Response | undefined> => {
9
+ const url = new URL(request.url);
10
+ if (url.pathname !== "/internal/post-history") return;
11
+ const json = (body: unknown, status = 200) =>
12
+ Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
13
+ if (!options.enabled || !options.history)
14
+ return json({ error: "Not found." }, 404);
15
+ if (request.method !== "GET")
16
+ return json({ error: "Method not allowed." }, 405);
17
+ const slug = url.searchParams.get("slug");
18
+ const cursor = url.searchParams.get("cursor") ?? undefined;
19
+ if (
20
+ !slug ||
21
+ slug.length > 200 ||
22
+ !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) ||
23
+ (cursor && (cursor.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(cursor)))
24
+ )
25
+ return json({ error: "Invalid history request." }, 400);
26
+ if (!(await options.rateLimit(request)))
27
+ return json({ error: "Too many requests. Try again shortly." }, 429);
28
+ try {
29
+ return json(await options.history.list({ slug, cursor }));
30
+ } catch {
31
+ return json(
32
+ { error: "Progress history is temporarily unavailable." },
33
+ 503,
34
+ );
35
+ }
36
+ };
37
+ }
@@ -1304,6 +1304,12 @@ input[data-slot="command-input"]:focus-visible {
1304
1304
  border-top: 1px solid var(--portal-rule);
1305
1305
  }
1306
1306
 
1307
+ .post-detail-comments-tab {
1308
+ margin-top: 0;
1309
+ padding-top: 0;
1310
+ border-top: 0;
1311
+ }
1312
+
1307
1313
  .post-detail-comments > h2,
1308
1314
  .post-detail-sidebar > h2 {
1309
1315
  margin: 0;