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,133 @@
1
+ import { 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
+ createConnectedStore,
7
+ createFeedbackModule,
8
+ createImageModule,
9
+ } from "@feedbax/feedback";
10
+ import { createImageRequestHandler } from "./images-runtime";
11
+ import { readSessionToken, sessionHash } from "./connected-identity";
12
+ it("authenticates uploads, bounds image bodies, and serves private objects only through authorized no-store responses", async () => {
13
+ const runtime = new Miniflare({
14
+ modules: true,
15
+ script: "export default {fetch(){return new Response('ok')}}",
16
+ compatibilityDate: "2026-07-30",
17
+ d1Databases: ["DB"],
18
+ r2Buckets: ["IMAGES"],
19
+ });
20
+ try {
21
+ const database = await runtime.getD1Database("DB");
22
+ for (const name of [
23
+ "0001_connected.sql",
24
+ "0002_participation.sql",
25
+ "0003_images.sql",
26
+ ])
27
+ await database.exec(
28
+ await readFile(
29
+ `${existsSync("apps/portal/migrations") ? "apps/portal/migrations" : "migrations"}/${name}`,
30
+ "utf8",
31
+ ),
32
+ );
33
+ const store = createConnectedStore(database);
34
+ await store.acceptIdentity({
35
+ subject: "alice",
36
+ displayName: "Alice",
37
+ nonce: "a",
38
+ sessionHash: sessionHash("session"),
39
+ expiresAt: Date.now() + 60000,
40
+ });
41
+ const participant = async (request: Request) => {
42
+ const token = readSessionToken(request);
43
+ return token ? store.session(sessionHash(token)) : undefined;
44
+ };
45
+ const bucket = await runtime.getR2Bucket("IMAGES");
46
+ const feedback = createFeedbackModule();
47
+ const handler = createImageRequestHandler({
48
+ enabled: true,
49
+ siteUrl: "https://feedback.example.com",
50
+ signedIn: async (request) => Boolean(await participant(request)),
51
+ getImages: async (request) =>
52
+ createImageModule({
53
+ database,
54
+ bucket,
55
+ feedback,
56
+ participant: await participant(request),
57
+ reviewSecret: "s".repeat(32),
58
+ }),
59
+ rateLimit: async () => true,
60
+ });
61
+ const png = Uint8Array.from(
62
+ Buffer.from(
63
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aE2sAAAAASUVORK5CYII=",
64
+ "base64",
65
+ ),
66
+ );
67
+ const headers = {
68
+ Origin: "https://feedback.example.com",
69
+ "Content-Type": "image/png",
70
+ Cookie: "__Host-feedbax-session=session",
71
+ };
72
+ expect(
73
+ (
74
+ await handler(
75
+ new Request("https://feedback.example.com/internal/images", {
76
+ method: "POST",
77
+ headers: { ...headers, Origin: "https://evil.example.com" },
78
+ body: png,
79
+ }),
80
+ )
81
+ )?.status,
82
+ ).toBe(403);
83
+ expect(
84
+ (
85
+ await handler(
86
+ new Request("https://feedback.example.com/internal/images", {
87
+ method: "POST",
88
+ headers: { Origin: headers.Origin, "Content-Type": "image/png" },
89
+ body: png,
90
+ }),
91
+ )
92
+ )?.status,
93
+ ).toBe(401);
94
+ expect(
95
+ (
96
+ await handler(
97
+ new Request("https://feedback.example.com/internal/images", {
98
+ method: "POST",
99
+ headers: {
100
+ ...headers,
101
+ "Content-Length": String(5 * 1024 * 1024 + 1),
102
+ },
103
+ body: png,
104
+ }),
105
+ )
106
+ )?.status,
107
+ ).toBe(413);
108
+ const upload = await handler(
109
+ new Request("https://feedback.example.com/internal/images", {
110
+ method: "POST",
111
+ headers,
112
+ body: png,
113
+ }),
114
+ );
115
+ expect(upload?.status).toBe(201);
116
+ const image = JSON.parse(await upload!.text());
117
+ const publicRead = await handler(
118
+ new Request(`https://feedback.example.com${image.src}`),
119
+ );
120
+ expect(publicRead?.status).toBe(404);
121
+ const ownRead = await handler(
122
+ new Request(`https://feedback.example.com${image.src}`, {
123
+ headers: { Cookie: headers.Cookie },
124
+ }),
125
+ );
126
+ expect(ownRead?.status).toBe(200);
127
+ expect(ownRead?.headers.get("Cache-Control")).toBe("no-store");
128
+ expect(ownRead?.headers.get("X-Content-Type-Options")).toBe("nosniff");
129
+ expect(new Uint8Array(await ownRead!.arrayBuffer())).toEqual(png);
130
+ } finally {
131
+ await runtime.dispose();
132
+ }
133
+ });
@@ -0,0 +1,125 @@
1
+ import {
2
+ ImageAttachmentError,
3
+ type createImageModule,
4
+ } from "@feedbax/feedback";
5
+ import { env } from "cloudflare:workers";
6
+ import feedbax from "./feedbax";
7
+ import {
8
+ configuredImages,
9
+ createConfiguredFeedbackModule,
10
+ requestParticipant,
11
+ } from "./feedback-runtime";
12
+ import { sameOrigin } from "./connected-identity";
13
+
14
+ export function createImageRequestHandler(options: {
15
+ enabled: boolean;
16
+ siteUrl: string;
17
+ getImages(request: Request): Promise<ReturnType<typeof createImageModule>>;
18
+ signedIn(request: Request): Promise<boolean>;
19
+ rateLimit(request: Request): Promise<boolean>;
20
+ }) {
21
+ return async (request: Request): Promise<Response | undefined> => {
22
+ const url = new URL(request.url);
23
+ if (
24
+ url.pathname !== "/internal/images" &&
25
+ !url.pathname.startsWith("/internal/images/")
26
+ )
27
+ return;
28
+ const json = (body: unknown, status = 200) =>
29
+ Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
30
+ if (!options.enabled) return json({ error: "Not found." }, 404);
31
+ const id = url.pathname.slice("/internal/images/".length);
32
+ try {
33
+ if (request.method === "GET" && /^[-a-f0-9]{36}$/.test(id)) {
34
+ const image = await (
35
+ await options.getImages(request)
36
+ ).read(id, url.searchParams.get("review") ?? undefined);
37
+ if (!image) return json({ error: "Image is unavailable." }, 404);
38
+ return new Response(image.body as BodyInit, {
39
+ headers: {
40
+ "Content-Type": image.contentType,
41
+ "Content-Length": String(image.bytes),
42
+ "Cache-Control": "no-store",
43
+ "X-Content-Type-Options": "nosniff",
44
+ "Content-Security-Policy": "default-src 'none'; sandbox",
45
+ "Cross-Origin-Resource-Policy": "same-origin",
46
+ "Referrer-Policy": "no-referrer",
47
+ },
48
+ });
49
+ }
50
+ if (request.method !== "POST" && request.method !== "DELETE")
51
+ return json({ error: "Method not allowed." }, 405);
52
+ if (!sameOrigin(request, options.siteUrl))
53
+ return json({ error: "Request origin is invalid." }, 403);
54
+ if (!(await options.signedIn(request)))
55
+ return json({ error: "Sign in to add images." }, 401);
56
+ if (!(await options.rateLimit(request)))
57
+ return json(
58
+ { error: "Too many image requests. Try again shortly." },
59
+ 429,
60
+ );
61
+ const images = await options.getImages(request);
62
+ if (request.method === "DELETE") {
63
+ if (!/^[-a-f0-9]{36}$/.test(id))
64
+ return json({ error: "Invalid image." }, 400);
65
+ await images.removePending(id);
66
+ return json({ removed: true });
67
+ }
68
+ if (url.pathname !== "/internal/images")
69
+ return json({ error: "Not found." }, 404);
70
+ const type = request.headers.get("Content-Type") ?? "";
71
+ if (!["image/jpeg", "image/png", "image/webp"].includes(type))
72
+ return json({ error: "Choose a JPEG, PNG, or WebP image." }, 400);
73
+ const max = 5 * 1024 * 1024;
74
+ if (Number(request.headers.get("Content-Length")) > max)
75
+ return json({ error: "Each image must be 5 MiB or smaller." }, 413);
76
+ const reader = request.body?.getReader();
77
+ if (!reader) return json({ error: "Choose an image." }, 400);
78
+ const chunks: Uint8Array[] = [];
79
+ let length = 0;
80
+ while (true) {
81
+ const { value, done } = await reader.read();
82
+ if (done) break;
83
+ length += value.byteLength;
84
+ if (length > max) {
85
+ await reader.cancel();
86
+ return json({ error: "Each image must be 5 MiB or smaller." }, 413);
87
+ }
88
+ chunks.push(value);
89
+ }
90
+ const bytes = new Uint8Array(length);
91
+ let offset = 0;
92
+ for (const chunk of chunks) {
93
+ bytes.set(chunk, offset);
94
+ offset += chunk.byteLength;
95
+ }
96
+ return json(await images.upload(bytes, type), 201);
97
+ } catch (error) {
98
+ return json(
99
+ {
100
+ error:
101
+ error instanceof ImageAttachmentError
102
+ ? error.message
103
+ : "Images are temporarily unavailable. Try again.",
104
+ },
105
+ error instanceof ImageAttachmentError ? 400 : 503,
106
+ );
107
+ }
108
+ };
109
+ }
110
+ export const handleImageRequest = createImageRequestHandler({
111
+ enabled: Boolean(feedbax.connected?.images),
112
+ siteUrl: feedbax.product.siteUrl ?? "https://not-configured.invalid",
113
+ signedIn: async (request) => Boolean(await requestParticipant(request)),
114
+ getImages: async (request) =>
115
+ configuredImages(
116
+ createConfiguredFeedbackModule(),
117
+ await requestParticipant(request),
118
+ ),
119
+ rateLimit: async (request) =>
120
+ (
121
+ await env.PORTAL_SUBMISSIONS_RATE_LIMITER.limit({
122
+ key: `images:${request.headers.get("CF-Connecting-IP") ?? "unknown"}`,
123
+ })
124
+ ).success,
125
+ });