create-feedbax 0.2.0 → 0.2.1

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 (99) hide show
  1. package/dist/cli.js +2042 -16
  2. package/dist/index.js +1997 -5
  3. package/package.json +1 -1
  4. package/template/.feedbax-template.sha256 +1 -1
  5. package/template/AGENTS.md +13 -0
  6. package/template/CONNECTED.md +404 -0
  7. package/template/README.md +58 -0
  8. package/template/components.json +1 -1
  9. package/template/eslint.config.mjs +23 -0
  10. package/template/migrations/0001_connected.sql +6 -0
  11. package/template/migrations/0002_participation.sql +8 -0
  12. package/template/migrations/0003_images.sql +3 -0
  13. package/template/migrations/0004_followed_status.sql +9 -0
  14. package/template/package.json +9 -5
  15. package/template/src/authorized-draft-post.tsx +1 -0
  16. package/template/src/changelog-page.tsx +63 -54
  17. package/template/src/cloudflare-workers.d.ts +6 -0
  18. package/template/src/comment-creation.test.tsx +42 -1
  19. package/template/src/comment-thread-page.ts +11 -1
  20. package/template/src/components/command-palette.tsx +59 -30
  21. package/template/src/components/connected-identity-provider.tsx +108 -0
  22. package/template/src/components/device-profile-provider.tsx +41 -26
  23. package/template/src/components/follow-post-button.tsx +89 -0
  24. package/template/src/components/header.tsx +2 -0
  25. package/template/src/components/image-attachments.tsx +187 -0
  26. package/template/src/components/loader.tsx +48 -5
  27. package/template/src/components/notification-inbox.tsx +232 -0
  28. package/template/src/components/portal-footer.tsx +14 -0
  29. package/template/src/components/profile-menu.tsx +30 -0
  30. package/template/src/components/ui/components/command.tsx +30 -29
  31. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  32. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  33. package/template/src/components/ui/components/switch.tsx +29 -0
  34. package/template/src/components/ui/styles/globals.css +1 -1
  35. package/template/src/connected-identity.test.ts +144 -0
  36. package/template/src/connected-identity.ts +122 -0
  37. package/template/src/connected-participation.test.tsx +67 -0
  38. package/template/src/connected-runtime.test.ts +241 -0
  39. package/template/src/connected-runtime.ts +176 -0
  40. package/template/src/connected-social.test.tsx +158 -0
  41. package/template/src/feedback-cache-invalidation.ts +1 -0
  42. package/template/src/feedback-runtime.ts +79 -12
  43. package/template/src/feedbax/config/index.test.ts +28 -0
  44. package/template/src/feedbax/config/index.ts +93 -0
  45. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  46. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  47. package/template/src/feedbax/feedback/connected.ts +215 -0
  48. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  49. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  50. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  51. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  52. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  53. package/template/src/feedbax/feedback/images.test.ts +223 -0
  54. package/template/src/feedbax/feedback/images.ts +324 -0
  55. package/template/src/feedbax/feedback/index.ts +55 -1
  56. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  57. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  58. package/template/src/feedbax/feedback/participation.ts +502 -0
  59. package/template/src/images-runtime.test.ts +133 -0
  60. package/template/src/images-runtime.ts +125 -0
  61. package/template/src/index.css +97 -440
  62. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  63. package/template/src/overlay-post-detail.test.tsx +59 -8
  64. package/template/src/overlay-post-detail.tsx +20 -69
  65. package/template/src/participation-client.ts +63 -0
  66. package/template/src/participation-runtime.test.ts +35 -0
  67. package/template/src/participation-runtime.ts +255 -0
  68. package/template/src/portal-comments.ts +24 -5
  69. package/template/src/portal-feedback-form.tsx +18 -3
  70. package/template/src/portal-feedback-runtime.ts +3 -0
  71. package/template/src/portal-feedback-server-function.ts +6 -3
  72. package/template/src/portal-feedback-submission.ts +10 -0
  73. package/template/src/portal-server-entry.ts +4 -0
  74. package/template/src/post-creation-flow.tsx +2 -2
  75. package/template/src/post-detail-content.test.tsx +233 -0
  76. package/template/src/post-detail-content.tsx +162 -0
  77. package/template/src/post-detail-query.ts +34 -0
  78. package/template/src/post-results.tsx +1 -1
  79. package/template/src/public-cache-policy.test.ts +11 -4
  80. package/template/src/public-cache-policy.ts +1 -1
  81. package/template/src/public-feedback-index.tsx +9 -39
  82. package/template/src/public-feedback-page.test.tsx +65 -0
  83. package/template/src/public-feedback-page.ts +12 -1
  84. package/template/src/public-post-detail-skeleton.tsx +54 -0
  85. package/template/src/public-post-detail.tsx +298 -208
  86. package/template/src/public-post-route.test.tsx +39 -3
  87. package/template/src/public-post-route.tsx +15 -28
  88. package/template/src/public-roadmap-route.test.tsx +38 -2
  89. package/template/src/public-roadmap-view.tsx +56 -23
  90. package/template/src/public-shell.test.tsx +26 -0
  91. package/template/src/routeTree.gen.ts +49 -3
  92. package/template/src/router.tsx +2 -0
  93. package/template/src/routes/__root.tsx +3 -5
  94. package/template/src/routes/email-preferences.tsx +77 -0
  95. package/template/src/routes/submit.tsx +1 -1
  96. package/template/src/safe-public-failure.ts +3 -1
  97. package/template/src/server.ts +41 -1
  98. package/template/src/trusted-feedback-handler.test.ts +1 -1
  99. package/template/src/vote-toggle.tsx +42 -9
@@ -0,0 +1,223 @@
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 { createConnectedStore } from "./connected";
6
+ import { createFeedbackModule } from "./index";
7
+ import { createImageModule } from "./images";
8
+ let runtime: Miniflare | undefined;
9
+ afterEach(async () => {
10
+ const previous = runtime;
11
+ runtime = undefined;
12
+ await previous?.dispose();
13
+ });
14
+ const participant = { subject: "alice", displayName: "Alice" };
15
+ const png = Uint8Array.from(
16
+ Buffer.from(
17
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aE2sAAAAASUVORK5CYII=",
18
+ "base64",
19
+ ),
20
+ );
21
+ async function setup() {
22
+ runtime = new Miniflare({
23
+ modules: true,
24
+ script: "export default {fetch(){return new Response('ok')}}",
25
+ compatibilityDate: "2026-07-30",
26
+ d1Databases: ["DB"],
27
+ r2Buckets: ["IMAGES"],
28
+ });
29
+ const database = await runtime.getD1Database("DB");
30
+ for (const file of [
31
+ "0001_connected.sql",
32
+ "0002_participation.sql",
33
+ "0003_images.sql",
34
+ ])
35
+ await database.exec(
36
+ await readFile(
37
+ `${existsSync("apps/portal/migrations") ? "apps/portal/migrations" : "migrations"}/${file}`,
38
+ "utf8",
39
+ ),
40
+ );
41
+ await createConnectedStore(database).acceptIdentity({
42
+ ...participant,
43
+ nonce: "a",
44
+ sessionHash: "a",
45
+ expiresAt: Date.now() + 60000,
46
+ });
47
+ const feedback = createFeedbackModule();
48
+ return {
49
+ database,
50
+ bucket: await runtime.getR2Bucket("IMAGES"),
51
+ feedback,
52
+ reviewSecret: "s".repeat(32),
53
+ };
54
+ }
55
+ it("keeps uploads private, binds images to an authorized draft, and never serves an unpublished image publicly", async () => {
56
+ const options = await setup();
57
+ const { feedback } = options;
58
+ const owner = createImageModule({ ...options, participant });
59
+ const uploaded = await owner.upload(png, "image/png");
60
+ expect((await owner.read(uploaded.id))?.contentType).toBe("image/png");
61
+ expect(await createImageModule(options).read(uploaded.id)).toBeUndefined();
62
+ await expect(
63
+ owner.upload(new TextEncoder().encode("<svg>bad</svg>"), "image/svg+xml"),
64
+ ).rejects.toThrow("JPEG, PNG, or WebP");
65
+ await expect(
66
+ owner.upload(new Uint8Array(5 * 1024 * 1024 + 1), "image/png"),
67
+ ).rejects.toThrow("5 MiB");
68
+ const draft = await feedback.submitPost({
69
+ title: "Image Post",
70
+ description: "Description",
71
+ type: "Feature Request",
72
+ });
73
+ await owner.attachPost({
74
+ ids: [uploaded.id],
75
+ id: draft.id,
76
+ browserCapability: draft.browserCapability,
77
+ });
78
+ expect(await createImageModule(options).read(uploaded.id)).toBeUndefined();
79
+ await expect(
80
+ createImageModule({
81
+ ...options,
82
+ participant: { subject: "bob", displayName: "Bob" },
83
+ }).attachPost({
84
+ ids: [uploaded.id],
85
+ id: draft.id,
86
+ browserCapability: draft.browserCapability,
87
+ }),
88
+ ).rejects.toThrow("unavailable");
89
+ const publicFeedback = createFeedbackModule({
90
+ initialItems: [{ ...draft, published: true, status: "Planned" }],
91
+ });
92
+ const publicImages = createImageModule({
93
+ ...options,
94
+ feedback: publicFeedback,
95
+ });
96
+ expect(
97
+ (await publicImages.forPost(draft.slug)).map((image) => image.id),
98
+ ).toEqual([uploaded.id]);
99
+ expect(await publicImages.read(uploaded.id)).toBeDefined();
100
+ const review = await owner.reviewUrl(
101
+ uploaded.id,
102
+ "https://feedback.example.com",
103
+ );
104
+ expect(
105
+ await createImageModule(options).read(
106
+ uploaded.id,
107
+ new URL(review).searchParams.get("review")!,
108
+ ),
109
+ ).toBeDefined();
110
+ expect(
111
+ await createImageModule(options).read(uploaded.id, "forged"),
112
+ ).toBeUndefined();
113
+ await feedback.withdrawDraftPost({
114
+ id: draft.id,
115
+ browserCapability: draft.browserCapability,
116
+ });
117
+ expect(await owner.read(uploaded.id)).toBeUndefined();
118
+ expect(
119
+ await createImageModule(options).read(
120
+ uploaded.id,
121
+ new URL(review).searchParams.get("review")!,
122
+ ),
123
+ ).toBeUndefined();
124
+ });
125
+
126
+ it("rejects attachment overflow without partial binding and cleans up a rolled-back image Post", async () => {
127
+ const options = await setup();
128
+ const owner = createImageModule({ ...options, participant });
129
+ const ids = [];
130
+ for (let i = 0; i < 5; i++)
131
+ ids.push((await owner.upload(png, "image/png")).id);
132
+ const draft = await options.feedback.submitPost({
133
+ title: "Images",
134
+ description: "Description",
135
+ type: "Feature Request",
136
+ });
137
+ await owner.attachPost({
138
+ ids: ids.slice(0, 3),
139
+ id: draft.id,
140
+ browserCapability: draft.browserCapability,
141
+ });
142
+ await expect(
143
+ owner.attachPost({
144
+ ids: ids.slice(3),
145
+ id: draft.id,
146
+ browserCapability: draft.browserCapability,
147
+ }),
148
+ ).rejects.toThrow("unavailable");
149
+ expect(await owner.forPost(draft.slug)).toHaveLength(3);
150
+ const { createImageFeedbackModule } = await import("./image-feedback");
151
+ const { createNotionImageReviewWriter } =
152
+ await import("./notion-image-review");
153
+ const writer = createNotionImageReviewWriter({
154
+ token: "private-notion-key",
155
+ reviewUrl: (id) => owner.reviewUrl(id, "https://feedback.example.com"),
156
+ request: async () => new Response("unavailable", { status: 500 }),
157
+ });
158
+ const feedback = createImageFeedbackModule(options.feedback, owner, writer);
159
+ await expect(
160
+ feedback.submitPost({
161
+ title: "Failed review",
162
+ description: "Description",
163
+ type: "Feature Request",
164
+ imageIds: [ids[3]!],
165
+ }),
166
+ ).rejects.toThrow("review links");
167
+ expect(await owner.read(ids[3]!)).toBeUndefined();
168
+ expect(await owner.cleanup()).toBe(1);
169
+ expect(await owner.read(ids[3]!)).toBeUndefined();
170
+ });
171
+
172
+ it("removes public and creator access to images when their Comment is deleted", async () => {
173
+ const options = await setup();
174
+ const { createConnectedFeedbackModule } =
175
+ await import("./connected-feedback");
176
+ const { createImageFeedbackModule } = await import("./image-feedback");
177
+ const base = createFeedbackModule({
178
+ initialItems: [
179
+ {
180
+ id: "p",
181
+ slug: "public",
182
+ title: "Public",
183
+ description: "Description",
184
+ type: "Feature Request",
185
+ status: "Planned",
186
+ published: true,
187
+ createdAt: new Date(1000),
188
+ updatedAt: new Date(1000),
189
+ },
190
+ ],
191
+ });
192
+ const connected = createConnectedFeedbackModule(
193
+ base,
194
+ createConnectedStore(options.database),
195
+ participant,
196
+ );
197
+ const owner = createImageModule({
198
+ ...options,
199
+ feedback: connected,
200
+ participant,
201
+ });
202
+ const image = await owner.upload(png, "image/png");
203
+ const feedback = createImageFeedbackModule(connected, owner);
204
+ const comment = await feedback.createComment({
205
+ slug: "public",
206
+ body: "Screenshot attached",
207
+ displayName: "Spoofed",
208
+ imageIds: [image.id],
209
+ });
210
+ expect(comment.images?.[0]?.id).toBe(image.id);
211
+ expect(
212
+ (await feedback.listCommentThreads({ slug: "public" })).items[0]
213
+ ?.comments[0]?.images?.[0]?.id,
214
+ ).toBe(image.id);
215
+ expect(
216
+ await createImageModule({ ...options, feedback: connected }).read(image.id),
217
+ ).toBeDefined();
218
+ await feedback.deleteComment({ commentId: comment.id });
219
+ expect(await owner.read(image.id)).toBeUndefined();
220
+ expect(
221
+ await createImageModule({ ...options, feedback: connected }).read(image.id),
222
+ ).toBeUndefined();
223
+ });
@@ -0,0 +1,324 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import type { D1Database } from "@cloudflare/workers-types";
3
+ import { sql } from "drizzle-orm";
4
+ import { drizzle } from "drizzle-orm/d1";
5
+ import type { BrowserCapability, FeedbackModule } from "./index";
6
+ import type { VerifiedParticipant } from "./connected";
7
+
8
+ export interface ImageAttachment {
9
+ id: string;
10
+ src: string;
11
+ contentType: string;
12
+ bytes: number;
13
+ }
14
+ type ImageRow = {
15
+ id: string;
16
+ subject: string;
17
+ object_key: string;
18
+ content_type: string;
19
+ bytes: number;
20
+ created_at: number;
21
+ deleting: number;
22
+ post_slug: string | null;
23
+ comment_id: string | null;
24
+ };
25
+ const limit = 5 * 1024 * 1024;
26
+ export class ImageAttachmentError extends Error {}
27
+ export function createImageModule(options: {
28
+ database: D1Database;
29
+ bucket: {
30
+ put(
31
+ key: string,
32
+ value: ArrayBuffer,
33
+ options: { httpMetadata: { contentType: string } },
34
+ ): Promise<unknown>;
35
+ get(key: string): Promise<{ body: unknown } | null>;
36
+ delete(key: string): Promise<void>;
37
+ };
38
+ feedback: FeedbackModule;
39
+ participant?: VerifiedParticipant;
40
+ processing?: "small" | "standard";
41
+ reviewSecret: string;
42
+ now?: () => number;
43
+ }) {
44
+ const db = drizzle(options.database);
45
+ const now = () => options.now?.() ?? Date.now();
46
+ const identity = () => {
47
+ if (!options.participant)
48
+ throw new ImageAttachmentError("Sign in to add images.");
49
+ return options.participant;
50
+ };
51
+ const row = (id: string) =>
52
+ db.get<ImageRow>(sql`SELECT * FROM image_attachments WHERE id=${id}`);
53
+ const projection = (image: ImageRow): ImageAttachment => ({
54
+ id: image.id,
55
+ src: `/internal/images/${image.id}`,
56
+ contentType: image.content_type,
57
+ bytes: image.bytes,
58
+ });
59
+ const publicComment = async (slug: string, commentId: string) => {
60
+ let cursor: string | undefined;
61
+ do {
62
+ const page = await options.feedback.listCommentThreads({ slug, cursor });
63
+ if (
64
+ page.items.some((thread) =>
65
+ thread.comments.some((comment) => comment.id === commentId),
66
+ )
67
+ )
68
+ return true;
69
+ cursor = page.nextCursor;
70
+ } while (cursor);
71
+ return false;
72
+ };
73
+ const claim = async (ids: string[], slug: string, commentId?: string) => {
74
+ const person = identity();
75
+ if (!ids.length) return;
76
+ if (
77
+ ids.length > 4 ||
78
+ new Set(ids).size !== ids.length ||
79
+ ids.some((id) => !/^[-a-f0-9]{36}$/.test(id))
80
+ )
81
+ throw new ImageAttachmentError("Choose up to four images.");
82
+ const list = sql.join(
83
+ ids.map((id) => sql`${id}`),
84
+ sql`, `,
85
+ );
86
+ const eligible = sql`subject=${person.subject} AND id IN (${list}) AND (post_slug IS NULL OR (post_slug=${slug} AND comment_id IS ${commentId ?? null})) AND deleting=0 AND (post_slug IS NOT NULL OR created_at > ${now() - 86400000})`;
87
+ const claimed = await db.all<{ id: string }>(
88
+ sql`UPDATE image_attachments SET post_slug=${slug},comment_id=${commentId ?? null} WHERE ${eligible} AND (SELECT COUNT(*) FROM image_attachments WHERE ${eligible})=${ids.length} AND (SELECT COUNT(*) FROM image_attachments WHERE post_slug=${slug} AND comment_id IS ${commentId ?? null} AND id NOT IN (${list}))+${ids.length} <= 4 RETURNING id`,
89
+ );
90
+ if (claimed.length !== ids.length)
91
+ throw new ImageAttachmentError(
92
+ "One or more images are unavailable. Upload them again.",
93
+ );
94
+ };
95
+ return {
96
+ async validatePending(ids: string[]) {
97
+ if (!ids.length) return;
98
+ const person = identity();
99
+ if (ids.length > 4 || new Set(ids).size !== ids.length)
100
+ throw new ImageAttachmentError("Choose up to four images.");
101
+ for (const id of ids) {
102
+ const image = await row(id);
103
+ if (
104
+ !image ||
105
+ image.subject !== person.subject ||
106
+ image.post_slug ||
107
+ image.deleting ||
108
+ image.created_at <= now() - 86400000
109
+ )
110
+ throw new ImageAttachmentError(
111
+ "One or more images are unavailable. Upload them again.",
112
+ );
113
+ }
114
+ },
115
+ async upload(
116
+ bytes: Uint8Array,
117
+ contentType: string,
118
+ ): Promise<ImageAttachment> {
119
+ const person = identity();
120
+ if (bytes.byteLength > limit)
121
+ throw new ImageAttachmentError("Each image must be 5 MiB or smaller.");
122
+ if (!validImage(bytes, contentType))
123
+ throw new ImageAttachmentError(
124
+ "Choose a valid JPEG, PNG, or WebP image.",
125
+ );
126
+ const id = randomUUID(),
127
+ objectKey = `participant-images/${id}`;
128
+ const reserved = await db.get(
129
+ sql`INSERT INTO image_attachments(id,subject,object_key,content_type,bytes,created_at) SELECT ${id},${person.subject},${objectKey},${contentType},${bytes.byteLength},${now()} WHERE (SELECT COUNT(*) FROM image_attachments WHERE subject=${person.subject} AND post_slug IS NULL AND deleting=0 AND created_at > ${now() - 86400000}) < 20 RETURNING id`,
130
+ );
131
+ if (!reserved)
132
+ throw new ImageAttachmentError(
133
+ "Too many unused images. Remove one or try again later.",
134
+ );
135
+ try {
136
+ await options.bucket.put(objectKey, bytes.slice().buffer, {
137
+ httpMetadata: { contentType },
138
+ });
139
+ } catch (error) {
140
+ await db.run(
141
+ sql`UPDATE image_attachments SET deleting=1 WHERE id=${id}`,
142
+ );
143
+ throw error;
144
+ }
145
+ return {
146
+ id,
147
+ src: `/internal/images/${id}`,
148
+ contentType,
149
+ bytes: bytes.byteLength,
150
+ };
151
+ },
152
+ async attachPost(input: {
153
+ ids: string[];
154
+ id: string;
155
+ browserCapability: BrowserCapability;
156
+ }) {
157
+ identity();
158
+ const post = await options.feedback.getDraftPost({
159
+ id: input.id,
160
+ browserCapability: input.browserCapability,
161
+ });
162
+ await claim(input.ids, post.slug);
163
+ return this.forPost(post.slug);
164
+ },
165
+ async attachComment(input: {
166
+ ids: string[];
167
+ slug: string;
168
+ commentId: string;
169
+ }) {
170
+ const person = identity();
171
+ if (
172
+ !(await options.feedback.getPublicPost(input.slug)) ||
173
+ !(await publicComment(input.slug, input.commentId))
174
+ )
175
+ throw new ImageAttachmentError("Comment is unavailable.");
176
+ const author = await db.get<{ subject: string }>(
177
+ sql`SELECT subject FROM comment_attribution WHERE comment_id=${input.commentId}`,
178
+ );
179
+ if (author?.subject !== person.subject)
180
+ throw new ImageAttachmentError("Comment is unavailable.");
181
+ await claim(input.ids, input.slug, input.commentId);
182
+ return this.forComment(input.slug, input.commentId);
183
+ },
184
+ async forPost(slug: string): Promise<ImageAttachment[]> {
185
+ return (
186
+ await db.all<ImageRow>(
187
+ sql`SELECT * FROM image_attachments WHERE post_slug=${slug} AND comment_id IS NULL AND deleting=0 ORDER BY created_at,id`,
188
+ )
189
+ ).map(projection);
190
+ },
191
+ async forComment(
192
+ slug: string,
193
+ commentId: string,
194
+ ): Promise<ImageAttachment[]> {
195
+ return (
196
+ await db.all<ImageRow>(
197
+ sql`SELECT * FROM image_attachments WHERE post_slug=${slug} AND comment_id=${commentId} AND deleting=0 ORDER BY created_at,id`,
198
+ )
199
+ ).map(projection);
200
+ },
201
+ async forComments(slug: string, ids: string[]) {
202
+ const result = new Map<string, ImageAttachment[]>();
203
+ if (!ids.length) return result;
204
+ const list = sql.join(
205
+ ids.map((id) => sql`${id}`),
206
+ sql`, `,
207
+ );
208
+ const rows = await db.all<ImageRow>(
209
+ sql`SELECT * FROM image_attachments WHERE post_slug=${slug} AND comment_id IN (${list}) AND deleting=0 ORDER BY created_at,id`,
210
+ );
211
+ for (const image of rows) {
212
+ const key = image.comment_id!;
213
+ result.set(key, [...(result.get(key) ?? []), projection(image)]);
214
+ }
215
+ return result;
216
+ },
217
+ async reviewUrl(id: string, siteUrl: string) {
218
+ if (options.reviewSecret.length < 32)
219
+ throw new Error("Image review secret is missing.");
220
+ if (!(await row(id)))
221
+ throw new ImageAttachmentError("Image is unavailable.");
222
+ const target = new URL(`/internal/images/${id}`, siteUrl);
223
+ target.searchParams.set("review", reviewMac(id, options.reviewSecret));
224
+ return target.toString();
225
+ },
226
+ async read(id: string, review?: string) {
227
+ const image = await row(id);
228
+ if (!image || image.deleting) return;
229
+ const owner = options.participant?.subject === image.subject;
230
+ let reviewer = false;
231
+ if (review && options.reviewSecret.length >= 32) {
232
+ const wanted = Buffer.from(reviewMac(id, options.reviewSecret)),
233
+ got = Buffer.from(review);
234
+ reviewer = wanted.length === got.length && timingSafeEqual(wanted, got);
235
+ }
236
+ let allowed: boolean;
237
+ if (!image.post_slug)
238
+ allowed = owner && image.created_at > now() - 86400000;
239
+ else {
240
+ const state = await options.feedback.getAttachmentPostState(
241
+ image.post_slug,
242
+ );
243
+ if (image.comment_id)
244
+ allowed =
245
+ state === "published" &&
246
+ (await publicComment(image.post_slug, image.comment_id));
247
+ else
248
+ allowed =
249
+ state === "published" ||
250
+ Boolean(state && reviewer) ||
251
+ (state === "draft" && owner);
252
+ }
253
+ if (!allowed) return;
254
+ const object = await options.bucket.get(image.object_key);
255
+ return object
256
+ ? {
257
+ body: object.body,
258
+ contentType: image.content_type,
259
+ bytes: image.bytes,
260
+ }
261
+ : undefined;
262
+ },
263
+ async discardAttached(slug: string, commentId?: string) {
264
+ const person = identity();
265
+ await db.run(
266
+ sql`UPDATE image_attachments SET deleting=1 WHERE post_slug=${slug} AND comment_id IS ${commentId ?? null} AND subject=${person.subject}`,
267
+ );
268
+ },
269
+ async removePending(id: string) {
270
+ const person = identity();
271
+ const image = await db.get<ImageRow>(
272
+ sql`UPDATE image_attachments SET deleting=1 WHERE id=${id} AND subject=${person.subject} AND post_slug IS NULL RETURNING *`,
273
+ );
274
+ if (!image) throw new ImageAttachmentError("Image is unavailable.");
275
+ await options.bucket.delete(image.object_key);
276
+ await db.run(
277
+ sql`DELETE FROM image_attachments WHERE id=${id} AND deleting=1`,
278
+ );
279
+ },
280
+ async cleanup() {
281
+ const rows = await db.all<ImageRow>(
282
+ sql`UPDATE image_attachments SET deleting=1 WHERE id IN(SELECT id FROM image_attachments WHERE deleting=1 OR (post_slug IS NULL AND created_at <= ${now() - 86400000}) LIMIT ${options.processing === "small" ? 5 : 50}) RETURNING *`,
283
+ );
284
+ for (const image of rows) {
285
+ await options.bucket.delete(image.object_key);
286
+ await db.run(
287
+ sql`DELETE FROM image_attachments WHERE id=${image.id} AND deleting=1`,
288
+ );
289
+ }
290
+ return rows.length;
291
+ },
292
+ };
293
+ }
294
+ const reviewMac = (id: string, secret: string) =>
295
+ createHmac("sha256", secret)
296
+ .update(`feedbax-image-review-v1:${id}`)
297
+ .digest("base64url");
298
+ function validImage(b: Uint8Array, type: string) {
299
+ if (type === "image/png")
300
+ return (
301
+ b.length >= 33 &&
302
+ [137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => b[i] === v) &&
303
+ String.fromCharCode(...b.slice(12, 16)) === "IHDR"
304
+ );
305
+ if (type === "image/jpeg")
306
+ return (
307
+ b.length >= 4 &&
308
+ b[0] === 255 &&
309
+ b[1] === 216 &&
310
+ b[2] === 255 &&
311
+ b[b.length - 2] === 255 &&
312
+ b[b.length - 1] === 217
313
+ );
314
+ if (type === "image/webp")
315
+ return (
316
+ b.length >= 20 &&
317
+ String.fromCharCode(...b.slice(0, 4)) === "RIFF" &&
318
+ String.fromCharCode(...b.slice(8, 12)) === "WEBP" &&
319
+ new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(4, true) +
320
+ 8 ===
321
+ b.length
322
+ );
323
+ return false;
324
+ }
@@ -35,6 +35,7 @@ export type PostStatus =
35
35
  "New" | "Reviewing" | "Planned" | "In Progress" | "Shipped" | "Closed";
36
36
 
37
37
  export interface SubmitPostInput {
38
+ imageIds?: string[];
38
39
  title: string;
39
40
  description: string;
40
41
  type: PostType;
@@ -49,6 +50,7 @@ export interface SubmitTrustedPostInput extends SubmitPostInput {
49
50
  }
50
51
 
51
52
  export interface Post extends SubmitPostInput {
53
+ images?: import("./images").ImageAttachment[];
52
54
  id: string;
53
55
  slug: string;
54
56
  status: PostStatus;
@@ -90,6 +92,7 @@ export type PublicPost = Pick<
90
92
  voteCount?: number;
91
93
  teamResponse?: string;
92
94
  releaseNotesUrl?: string;
95
+ images?: import("./images").ImageAttachment[];
93
96
  };
94
97
 
95
98
  export interface ChangeVoteInput {
@@ -127,6 +130,7 @@ export type DraftPost = Pick<
127
130
  | "createdAt"
128
131
  | "updatedAt"
129
132
  | "submitter"
133
+ | "images"
130
134
  >;
131
135
 
132
136
  export type TrustedSubmittedPost = Omit<Post, "published" | "submitter">;
@@ -162,10 +166,11 @@ export interface PublicPostPage {
162
166
  }
163
167
 
164
168
  export type CommentAuthor =
165
- | { kind: "participant"; displayName: string }
169
+ | { kind: "participant"; displayName: string; verified?: boolean }
166
170
  | { kind: "product-team"; displayName: "Product Team" };
167
171
 
168
172
  export interface Comment {
173
+ images?: import("./images").ImageAttachment[];
169
174
  id: string;
170
175
  body: string;
171
176
  author: CommentAuthor;
@@ -188,6 +193,7 @@ export interface ListCommentThreadsInput {
188
193
  }
189
194
 
190
195
  export interface CreateCommentInput {
196
+ imageIds?: string[];
191
197
  slug: string;
192
198
  body: string;
193
199
  displayName: string;
@@ -355,6 +361,9 @@ class InMemoryFeedbackStorage implements FeedbackStorage {
355
361
  }
356
362
 
357
363
  export interface FeedbackModule {
364
+ getAttachmentPostState(
365
+ slug: string,
366
+ ): Promise<"published" | "draft" | "private" | undefined>;
358
367
  createComment(input: CreateCommentInput): Promise<CreatedComment>;
359
368
  replyToCommentThread(
360
369
  input: ReplyToCommentThreadInput,
@@ -432,6 +441,16 @@ export function createFeedbackModule(
432
441
  };
433
442
 
434
443
  return {
444
+ async getAttachmentPostState(slug) {
445
+ const post = await storage.findBySlug(slug);
446
+ return !post
447
+ ? undefined
448
+ : post.published
449
+ ? "published"
450
+ : post.status === "New"
451
+ ? "draft"
452
+ : "private";
453
+ },
435
454
  async createComment(input) {
436
455
  const { post, body, displayName } = await validateCommentCreation(
437
456
  input,
@@ -940,3 +959,38 @@ function isPublicPostRoadmapItem<Item extends Post>(
940
959
  item.status === "Shipped")
941
960
  );
942
961
  }
962
+
963
+ export {
964
+ createConnectedStore,
965
+ type ConnectedStore,
966
+ type ConnectedDatabase,
967
+ type VerifiedParticipant,
968
+ } from "./connected";
969
+ export {
970
+ signIdentityHandoff,
971
+ verifyIdentityHandoff,
972
+ type IdentityHandoff,
973
+ } from "./identity-handoff";
974
+
975
+ export { createConnectedFeedbackModule } from "./connected-feedback";
976
+
977
+ export {
978
+ createParticipationModule,
979
+ type ParticipantNotification,
980
+ type FollowState,
981
+ } from "./participation";
982
+ export {
983
+ createEmailSender,
984
+ type EmailSender,
985
+ type NotificationEmail,
986
+ type CloudflareEmailBinding,
987
+ } from "./email-delivery";
988
+
989
+ export {
990
+ createImageModule,
991
+ ImageAttachmentError,
992
+ type ImageAttachment,
993
+ } from "./images";
994
+
995
+ export { createImageFeedbackModule } from "./image-feedback";
996
+ export { createNotionImageReviewWriter } from "./notion-image-review";