create-feedbax 0.2.0 → 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 (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  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 +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -0,0 +1,56 @@
1
+ import type { GitHubConfiguration } from "@feedbax/config";
2
+ import { signIntegrationRequest } from "./integration-auth";
3
+
4
+ export function createGitHubServiceAccess(options: {
5
+ config: GitHubConfiguration;
6
+ secret?: string;
7
+ getSecret?: () => Promise<string>;
8
+ request?: typeof fetch;
9
+ }) {
10
+ let cached: { token: string; expires: number; secret: string } | undefined;
11
+ return async () => {
12
+ const secret = options.getSecret
13
+ ? await options.getSecret()
14
+ : (options.secret ?? "");
15
+ if (
16
+ cached &&
17
+ cached.secret === secret &&
18
+ cached.expires > Date.now() + 60_000
19
+ )
20
+ return cached.token;
21
+ const response = await (options.request ?? fetch)(
22
+ signIntegrationRequest({
23
+ url: `${options.config.serviceUrl}/v1/token`,
24
+ installationId: options.config.installationId,
25
+ secret,
26
+ body: "{}",
27
+ }),
28
+ );
29
+ if (!response.ok) {
30
+ cached = undefined;
31
+ throw new Error(
32
+ "GitHub Portal Binding is unavailable. Run feedbax doctor or pair the Installation again.",
33
+ );
34
+ }
35
+ const value = (await response.json()) as {
36
+ token?: unknown;
37
+ expiresAt?: unknown;
38
+ repositoryId?: unknown;
39
+ botUserId?: unknown;
40
+ };
41
+ const expires =
42
+ typeof value.expiresAt === "string" ? Date.parse(value.expiresAt) : NaN;
43
+ if (
44
+ typeof value.token !== "string" ||
45
+ !value.token ||
46
+ !Number.isFinite(expires) ||
47
+ expires <= Date.now() ||
48
+ value.repositoryId !== options.config.repository.id ||
49
+ value.botUserId !== options.config.botUserId
50
+ )
51
+ throw new Error("GitHub integration returned mismatched access.");
52
+ // Cache only within one request factory and recheck local revocation/rotation on every use.
53
+ cached = { token: value.token, expires, secret };
54
+ return cached.token;
55
+ };
56
+ }
@@ -0,0 +1,81 @@
1
+ import { expect, it } from "vitest";
2
+ import { signIdentityHandoff, verifyIdentityHandoff } from "./identity-handoff";
3
+ const secret = "test-secret-for-identity-handoff-32-bytes";
4
+ const claims = {
5
+ subject: "alice",
6
+ displayName: "Alice",
7
+ audience: "https://feedback.example",
8
+ issuer: "https://app.example",
9
+ state: "browser-state",
10
+ nonce: "unique-nonce",
11
+ issuedAt: 1000,
12
+ expiresAt: 1100,
13
+ };
14
+ it("accepts only a signed identity bound to this Installation, browser and time", () => {
15
+ const token = signIdentityHandoff(claims, secret);
16
+ expect(
17
+ verifyIdentityHandoff(token, secret, {
18
+ audience: claims.audience,
19
+ issuer: claims.issuer,
20
+ state: claims.state,
21
+ now: 1020,
22
+ }),
23
+ ).toEqual(claims);
24
+ expect(() =>
25
+ verifyIdentityHandoff(token, secret, {
26
+ audience: "https://other.example",
27
+ issuer: claims.issuer,
28
+ state: claims.state,
29
+ now: 1020,
30
+ }),
31
+ ).toThrow();
32
+ expect(() =>
33
+ verifyIdentityHandoff(token, secret, {
34
+ audience: claims.audience,
35
+ issuer: claims.issuer,
36
+ state: "different-browser",
37
+ now: 1020,
38
+ }),
39
+ ).toThrow();
40
+ expect(() =>
41
+ verifyIdentityHandoff(token, secret, {
42
+ audience: claims.audience,
43
+ issuer: claims.issuer,
44
+ state: claims.state,
45
+ now: 1101,
46
+ }),
47
+ ).toThrow();
48
+ expect(() =>
49
+ verifyIdentityHandoff(token + "x", secret, {
50
+ audience: claims.audience,
51
+ issuer: claims.issuer,
52
+ state: claims.state,
53
+ now: 1020,
54
+ }),
55
+ ).toThrow();
56
+ });
57
+
58
+ it("accepts email delivery addresses only when the trusted application signs a verified assertion", () => {
59
+ const expected = {
60
+ audience: claims.audience,
61
+ issuer: claims.issuer,
62
+ state: claims.state,
63
+ now: 1020,
64
+ };
65
+ const verify = (value: Parameters<typeof signIdentityHandoff>[0]) =>
66
+ verifyIdentityHandoff(signIdentityHandoff(value, secret), secret, expected);
67
+ expect(
68
+ verify({ ...claims, email: "private@example.com" }),
69
+ ).not.toHaveProperty("email");
70
+ expect(
71
+ verify({ ...claims, email: "private@example.com", emailVerified: true }),
72
+ ).toMatchObject({ email: "private@example.com", emailVerified: true });
73
+ expect(() => verify({ ...claims, emailVerified: true })).toThrow();
74
+ expect(() =>
75
+ verify({
76
+ ...claims,
77
+ email: "bad\r\naddress@example.com",
78
+ emailVerified: true,
79
+ }),
80
+ ).toThrow();
81
+ });
@@ -0,0 +1,119 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ export interface IdentityHandoff {
4
+ email?: string;
5
+ emailVerified?: boolean;
6
+ subject: string;
7
+ displayName: string;
8
+ issuer: string;
9
+ audience: string;
10
+ state: string;
11
+ nonce: string;
12
+ issuedAt: number;
13
+ expiresAt: number;
14
+ }
15
+ export function signIdentityHandoff(
16
+ claims: IdentityHandoff,
17
+ secret: string,
18
+ ): string {
19
+ requireSecret(secret);
20
+ const payload = Buffer.from(
21
+ JSON.stringify({ purpose: "feedbax-identity-v1", ...claims }),
22
+ ).toString("base64url");
23
+ return `${payload}.${signature(payload, secret)}`;
24
+ }
25
+ export function verifyIdentityHandoff(
26
+ token: string,
27
+ secret: string,
28
+ expected: { audience: string; issuer: string; state: string; now: number },
29
+ ): IdentityHandoff {
30
+ requireSecret(secret);
31
+ if (token.length > 8192) throw invalid();
32
+ const [payload, mac, extra] = token.split(".");
33
+ if (!payload || !mac || extra) throw invalid();
34
+ const received = Buffer.from(mac);
35
+ const wanted = Buffer.from(signature(payload, secret));
36
+ if (wanted.length !== received.length || !timingSafeEqual(wanted, received))
37
+ throw invalid();
38
+ let value: Record<string, unknown>;
39
+ try {
40
+ value = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
41
+ } catch {
42
+ throw invalid();
43
+ }
44
+ if (!value || value.purpose !== "feedbax-identity-v1") throw invalid();
45
+ for (const key of [
46
+ "subject",
47
+ "displayName",
48
+ "issuer",
49
+ "audience",
50
+ "state",
51
+ "nonce",
52
+ ])
53
+ if (
54
+ typeof value[key] !== "string" ||
55
+ !value[key].trim() ||
56
+ value[key].length > 512
57
+ )
58
+ throw invalid();
59
+ if (
60
+ value.audience !== expected.audience ||
61
+ value.issuer !== expected.issuer ||
62
+ value.state !== expected.state ||
63
+ !expected.state ||
64
+ !Number.isSafeInteger(value.issuedAt) ||
65
+ !Number.isSafeInteger(value.expiresAt)
66
+ )
67
+ throw invalid();
68
+ const issuedAt = Number(value.issuedAt),
69
+ expiresAt = Number(value.expiresAt);
70
+ if (
71
+ issuedAt > expected.now + 30 ||
72
+ expiresAt <= expected.now ||
73
+ expiresAt <= issuedAt ||
74
+ expiresAt - issuedAt > 120
75
+ )
76
+ throw invalid();
77
+ if (
78
+ value.emailVerified !== undefined &&
79
+ typeof value.emailVerified !== "boolean"
80
+ )
81
+ throw invalid();
82
+ if (
83
+ value.email !== undefined &&
84
+ (typeof value.email !== "string" ||
85
+ value.email.length > 254 ||
86
+ !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.email))
87
+ )
88
+ throw invalid();
89
+ if (value.emailVerified === true && !value.email) throw invalid();
90
+ return {
91
+ ...(value.emailVerified === true
92
+ ? { email: String(value.email), emailVerified: true }
93
+ : {}),
94
+ subject: String(value.subject),
95
+ displayName: String(value.displayName),
96
+ issuer: String(value.issuer),
97
+ audience: String(value.audience),
98
+ state: String(value.state),
99
+ nonce: String(value.nonce),
100
+ issuedAt,
101
+ expiresAt,
102
+ };
103
+ }
104
+ function signature(payload: string, secret: string) {
105
+ return createHmac("sha256", secret)
106
+ .update(`feedbax-identity-v1:${payload}`)
107
+ .digest("base64url");
108
+ }
109
+ function requireSecret(secret: string) {
110
+ if (secret.length < 32)
111
+ throw new Error(
112
+ "Identity handoff secret must contain at least 32 characters.",
113
+ );
114
+ }
115
+ function invalid() {
116
+ return new Error(
117
+ "Identity handoff is invalid or expired. Start sign-in again.",
118
+ );
119
+ }
@@ -0,0 +1,114 @@
1
+ import {
2
+ recoverableComment,
3
+ type FeedbackModule,
4
+ type CreateCommentInput,
5
+ } from "./index";
6
+ import type { createImageModule, ImageAttachment } from "./images";
7
+
8
+ export function createImageFeedbackModule(
9
+ base: FeedbackModule,
10
+ images: ReturnType<typeof createImageModule>,
11
+ writeReview?: (postId: string, images: ImageAttachment[]) => Promise<void>,
12
+ ): FeedbackModule {
13
+ const create = async (
14
+ input: CreateCommentInput & { discussionId?: string },
15
+ ) => {
16
+ const ids = input.imageIds ?? [];
17
+ const attachedComment = input.submissionKey
18
+ ? await images.validateCommentImages(ids, input.slug)
19
+ : (await images.validatePending(ids), undefined);
20
+ const command = {
21
+ ...input,
22
+ ...(attachedComment ? { recoveryCommentId: attachedComment } : {}),
23
+ };
24
+ const comment = input.discussionId
25
+ ? await base.replyToCommentThread({
26
+ ...command,
27
+ discussionId: input.discussionId,
28
+ })
29
+ : await base.createComment(command);
30
+ if (!ids.length) return comment;
31
+ try {
32
+ if (attachedComment && attachedComment !== comment.id)
33
+ throw new Error("Images belong to another Comment.");
34
+ return {
35
+ ...comment,
36
+ images: await images.attachComment({
37
+ ids,
38
+ slug: input.slug,
39
+ commentId: comment.id,
40
+ }),
41
+ };
42
+ } catch (error) {
43
+ if (!comment[recoverableComment])
44
+ try {
45
+ await base.deleteComment({ commentId: comment.id });
46
+ await images.discardAttached(input.slug, comment.id);
47
+ } catch {
48
+ /* External cleanup remains best effort; never expose an unpublished attachment. */
49
+ }
50
+ throw error;
51
+ }
52
+ };
53
+ return {
54
+ ...base,
55
+ async submitPost(input) {
56
+ const ids = input.imageIds ?? [];
57
+ await images.validatePending(ids);
58
+ const post = await base.submitPost(input);
59
+ if (!ids.length) return post;
60
+ try {
61
+ const attached = await images.attachPost({
62
+ ids,
63
+ id: post.id,
64
+ browserCapability: post.browserCapability,
65
+ });
66
+ await writeReview?.(post.id, attached);
67
+ return { ...post, images: attached };
68
+ } catch (error) {
69
+ try {
70
+ await base.withdrawDraftPost({
71
+ id: post.id,
72
+ browserCapability: post.browserCapability,
73
+ });
74
+ await images.discardAttached(post.slug);
75
+ } catch {
76
+ /* Preserve the private record for failed external cleanup. */
77
+ }
78
+ throw error;
79
+ }
80
+ },
81
+ async getPublicPost(slug) {
82
+ const post = await base.getPublicPost(slug);
83
+ if (!post) return;
84
+ const attached = await images.forPost(slug);
85
+ return attached.length ? { ...post, images: attached } : post;
86
+ },
87
+ async getDraftPost(input) {
88
+ const post = await base.getDraftPost(input);
89
+ const attached = await images.forPost(post.slug);
90
+ return attached.length ? { ...post, images: attached } : post;
91
+ },
92
+ createComment: create,
93
+ replyToCommentThread: create,
94
+ async listCommentThreads(input) {
95
+ const page = await base.listCommentThreads(input);
96
+ const attachments = await images.forComments(
97
+ input.slug,
98
+ page.items.flatMap((thread) =>
99
+ thread.comments.map((comment) => comment.id),
100
+ ),
101
+ );
102
+ return {
103
+ ...page,
104
+ items: page.items.map((thread) => ({
105
+ ...thread,
106
+ comments: thread.comments.map((comment) => {
107
+ const attached = attachments.get(comment.id) ?? [];
108
+ return attached.length ? { ...comment, images: attached } : comment;
109
+ }),
110
+ })),
111
+ };
112
+ },
113
+ };
114
+ }
@@ -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
+ });