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.
- package/dist/cli.js +2042 -16
- package/dist/index.js +1997 -5
- package/package.json +1 -1
- package/template/.feedbax-template.sha256 +1 -1
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/README.md +58 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/package.json +9 -5
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/cloudflare-workers.d.ts +6 -0
- package/template/src/comment-creation.test.tsx +42 -1
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/profile-menu.tsx +30 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/connected-identity.test.ts +144 -0
- package/template/src/connected-identity.ts +122 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +241 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +1 -0
- package/template/src/feedback-runtime.ts +79 -12
- package/template/src/feedbax/config/index.test.ts +28 -0
- package/template/src/feedbax/config/index.ts +93 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +215 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +101 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +324 -0
- package/template/src/feedbax/feedback/index.ts +55 -1
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +97 -440
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +24 -5
- package/template/src/portal-feedback-form.tsx +18 -3
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +10 -0
- package/template/src/portal-server-entry.ts +4 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +298 -208
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +49 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +41 -1
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
|
@@ -109,3 +109,31 @@ it.each(["/portal", "//example.com", "http://example.com"])(
|
|
|
109
109
|
).toThrow("product.siteUrl must be an HTTPS URL");
|
|
110
110
|
},
|
|
111
111
|
);
|
|
112
|
+
|
|
113
|
+
it("keeps Connected Profile opt-in and rejects secrets and unsafe handoff configuration", () => {
|
|
114
|
+
const connected = {
|
|
115
|
+
issuer: "https://app.example.com",
|
|
116
|
+
loginUrl: "https://app.example.com/login",
|
|
117
|
+
};
|
|
118
|
+
expect(defineFeedbax({ ...input, connected }).connected).toEqual(connected);
|
|
119
|
+
expect(defineFeedbax(input).connected).toBeUndefined();
|
|
120
|
+
expect(() =>
|
|
121
|
+
defineFeedbax({
|
|
122
|
+
...input,
|
|
123
|
+
product: { ...input.product, siteUrl: undefined },
|
|
124
|
+
connected,
|
|
125
|
+
}),
|
|
126
|
+
).toThrow("siteUrl");
|
|
127
|
+
expect(() =>
|
|
128
|
+
defineFeedbax({
|
|
129
|
+
...input,
|
|
130
|
+
connected: { ...connected, loginUrl: "http://app.example.com/login" },
|
|
131
|
+
}),
|
|
132
|
+
).toThrow("HTTPS");
|
|
133
|
+
expect(() =>
|
|
134
|
+
defineFeedbax({
|
|
135
|
+
...input,
|
|
136
|
+
connected: { ...connected, secret: "never-public" },
|
|
137
|
+
} as unknown as FeedbaxConfig),
|
|
138
|
+
).toThrow("Unknown connected key");
|
|
139
|
+
});
|
|
@@ -51,7 +51,20 @@ export type ChangelogConfiguration = {
|
|
|
51
51
|
readonly dataSourceId: string;
|
|
52
52
|
readonly properties: ChangelogPropertyIds;
|
|
53
53
|
};
|
|
54
|
+
export type EmailConfiguration = {
|
|
55
|
+
readonly provider: "cloudflare" | "resend" | "custom";
|
|
56
|
+
readonly from: string;
|
|
57
|
+
readonly endpoint?: string;
|
|
58
|
+
};
|
|
59
|
+
export type ConnectedConfiguration = {
|
|
60
|
+
readonly processing?: "small" | "standard";
|
|
61
|
+
readonly email?: EmailConfiguration;
|
|
62
|
+
readonly images?: boolean;
|
|
63
|
+
readonly issuer: string;
|
|
64
|
+
readonly loginUrl: string;
|
|
65
|
+
};
|
|
54
66
|
export type FeedbaxConfig = {
|
|
67
|
+
readonly connected?: ConnectedConfiguration;
|
|
55
68
|
readonly product: ProductConfiguration;
|
|
56
69
|
readonly theme: { readonly preset: string };
|
|
57
70
|
readonly notion: NotionConfiguration;
|
|
@@ -66,6 +79,7 @@ export type FeedbaxConfig = {
|
|
|
66
79
|
};
|
|
67
80
|
|
|
68
81
|
const rootKeys = [
|
|
82
|
+
"connected",
|
|
69
83
|
"product",
|
|
70
84
|
"theme",
|
|
71
85
|
"notion",
|
|
@@ -124,6 +138,84 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
|
|
|
124
138
|
...optionalPublicUrl(product, "supportUrl"),
|
|
125
139
|
...optionalPublicUrl(product, "privacyUrl"),
|
|
126
140
|
};
|
|
141
|
+
let connected: ConnectedConfiguration | undefined;
|
|
142
|
+
if (value.connected !== undefined) {
|
|
143
|
+
const input = object(value.connected, "connected");
|
|
144
|
+
rejectUnknown(
|
|
145
|
+
input,
|
|
146
|
+
["issuer", "loginUrl", "email", "images", "processing"],
|
|
147
|
+
"connected",
|
|
148
|
+
);
|
|
149
|
+
const issuer = publicUrl(input.issuer, "connected.issuer", false);
|
|
150
|
+
const loginUrl = publicUrl(input.loginUrl, "connected.loginUrl", false);
|
|
151
|
+
if (!validatedProduct.siteUrl)
|
|
152
|
+
throw new Error("Connected Profile requires product.siteUrl.");
|
|
153
|
+
if (
|
|
154
|
+
new URL(loginUrl).username ||
|
|
155
|
+
new URL(loginUrl).password ||
|
|
156
|
+
new URL(loginUrl).hash
|
|
157
|
+
)
|
|
158
|
+
throw new Error(
|
|
159
|
+
"connected.loginUrl must not contain credentials or a fragment.",
|
|
160
|
+
);
|
|
161
|
+
let email: EmailConfiguration | undefined;
|
|
162
|
+
if (input.email !== undefined) {
|
|
163
|
+
const sender = object(input.email, "connected.email");
|
|
164
|
+
rejectUnknown(
|
|
165
|
+
sender,
|
|
166
|
+
["provider", "from", "endpoint"],
|
|
167
|
+
"connected.email",
|
|
168
|
+
);
|
|
169
|
+
if (!["cloudflare", "resend", "custom"].includes(String(sender.provider)))
|
|
170
|
+
throw new Error(
|
|
171
|
+
"connected.email.provider must be cloudflare, resend, or custom.",
|
|
172
|
+
);
|
|
173
|
+
const from = text(sender.from, "connected.email.from");
|
|
174
|
+
if (from.length > 254 || !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(from))
|
|
175
|
+
throw new Error(
|
|
176
|
+
"connected.email.from must be a plain sender email address.",
|
|
177
|
+
);
|
|
178
|
+
const endpoint =
|
|
179
|
+
sender.endpoint === undefined
|
|
180
|
+
? undefined
|
|
181
|
+
: publicUrl(sender.endpoint, "connected.email.endpoint", false);
|
|
182
|
+
if (sender.provider === "custom" && !endpoint)
|
|
183
|
+
throw new Error("Custom email requires connected.email.endpoint.");
|
|
184
|
+
if (sender.provider !== "custom" && endpoint)
|
|
185
|
+
throw new Error("Only custom email uses connected.email.endpoint.");
|
|
186
|
+
if (
|
|
187
|
+
endpoint &&
|
|
188
|
+
(new URL(endpoint).username ||
|
|
189
|
+
new URL(endpoint).password ||
|
|
190
|
+
new URL(endpoint).hash)
|
|
191
|
+
)
|
|
192
|
+
throw new Error(
|
|
193
|
+
"Custom email endpoint must not contain credentials or a fragment.",
|
|
194
|
+
);
|
|
195
|
+
email = {
|
|
196
|
+
provider: sender.provider as EmailConfiguration["provider"],
|
|
197
|
+
from,
|
|
198
|
+
...(endpoint ? { endpoint } : {}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (input.images !== undefined && typeof input.images !== "boolean")
|
|
202
|
+
throw new Error("connected.images must be true or false.");
|
|
203
|
+
if (
|
|
204
|
+
input.processing !== undefined &&
|
|
205
|
+
input.processing !== "small" &&
|
|
206
|
+
input.processing !== "standard"
|
|
207
|
+
)
|
|
208
|
+
throw new Error("connected.processing must be small or standard.");
|
|
209
|
+
connected = {
|
|
210
|
+
issuer,
|
|
211
|
+
loginUrl,
|
|
212
|
+
...(input.processing !== undefined
|
|
213
|
+
? { processing: input.processing }
|
|
214
|
+
: {}),
|
|
215
|
+
...(email ? { email } : {}),
|
|
216
|
+
...(input.images !== undefined ? { images: input.images } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
127
219
|
const theme = object(value.theme, "theme");
|
|
128
220
|
rejectUnknown(theme, ["preset"], "theme");
|
|
129
221
|
const notion = storage(
|
|
@@ -172,6 +264,7 @@ export function defineFeedbax(config: unknown): FeedbaxConfig {
|
|
|
172
264
|
throw new Error("Turnstile is enabled but turnstile.siteKey is missing.");
|
|
173
265
|
return {
|
|
174
266
|
product: validatedProduct,
|
|
267
|
+
...(connected ? { connected } : {}),
|
|
175
268
|
theme: { preset: text(theme.preset, "theme.preset") },
|
|
176
269
|
notion,
|
|
177
270
|
...(changelog ? { changelog } : {}),
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FeedbackModule,
|
|
3
|
+
type PublicPost,
|
|
4
|
+
PublicPostCursorError,
|
|
5
|
+
VoteEligibilityError,
|
|
6
|
+
roadmapStatuses,
|
|
7
|
+
type PublicPostRoadmap,
|
|
8
|
+
} from "./index";
|
|
9
|
+
import type { ConnectedStore, VerifiedParticipant } from "./connected";
|
|
10
|
+
|
|
11
|
+
export function createConnectedFeedbackModule(
|
|
12
|
+
base: FeedbackModule,
|
|
13
|
+
store: ConnectedStore,
|
|
14
|
+
participant?: VerifiedParticipant,
|
|
15
|
+
): FeedbackModule {
|
|
16
|
+
const enrich = async (post: PublicPost): Promise<PublicPost> => ({
|
|
17
|
+
...post,
|
|
18
|
+
...(post.voteCount !== undefined
|
|
19
|
+
? { voteCount: (await store.voteState(post.slug)).voteCount }
|
|
20
|
+
: {}),
|
|
21
|
+
});
|
|
22
|
+
const enrichPage = async (posts: PublicPost[]) => {
|
|
23
|
+
const counts = await store.voteCounts(
|
|
24
|
+
posts
|
|
25
|
+
.filter((post) => post.voteCount !== undefined)
|
|
26
|
+
.map((post) => post.slug),
|
|
27
|
+
);
|
|
28
|
+
return posts.map((post) => ({
|
|
29
|
+
...post,
|
|
30
|
+
...(post.voteCount !== undefined
|
|
31
|
+
? { voteCount: counts.get(post.slug) ?? 0 }
|
|
32
|
+
: {}),
|
|
33
|
+
}));
|
|
34
|
+
};
|
|
35
|
+
const authorizeComment = async (commentId: string) => {
|
|
36
|
+
const identity = requireParticipant();
|
|
37
|
+
const subject = await store.commentSubject(commentId);
|
|
38
|
+
if (subject && subject !== identity.subject)
|
|
39
|
+
throw new Error("This Comment belongs to another Participant.");
|
|
40
|
+
};
|
|
41
|
+
const requireParticipant = () => {
|
|
42
|
+
if (!participant) throw new Error("Sign in to participate.");
|
|
43
|
+
return participant;
|
|
44
|
+
};
|
|
45
|
+
const create = async (
|
|
46
|
+
input: Parameters<FeedbackModule["createComment"]>[0] & {
|
|
47
|
+
discussionId?: string;
|
|
48
|
+
},
|
|
49
|
+
) => {
|
|
50
|
+
const identity = requireParticipant();
|
|
51
|
+
const command = { ...input, displayName: identity.displayName };
|
|
52
|
+
const comment = input.discussionId
|
|
53
|
+
? await base.replyToCommentThread({
|
|
54
|
+
...command,
|
|
55
|
+
discussionId: input.discussionId,
|
|
56
|
+
})
|
|
57
|
+
: await base.createComment(command);
|
|
58
|
+
// If attribution cannot be stored, remove the just-created Comment. Never claim verification without durable evidence.
|
|
59
|
+
try {
|
|
60
|
+
await store.attributeComment(comment.id, identity);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
await base
|
|
63
|
+
.deleteComment({ commentId: comment.id })
|
|
64
|
+
.catch(() => undefined);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...comment,
|
|
69
|
+
author: {
|
|
70
|
+
kind: "participant" as const,
|
|
71
|
+
displayName: identity.displayName,
|
|
72
|
+
verified: true,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
const listPublicRoadmapPosts: FeedbackModule["listPublicRoadmapPosts"] =
|
|
77
|
+
async (query) => {
|
|
78
|
+
const page = await base.listPublicRoadmapPosts(query);
|
|
79
|
+
return { ...page, items: await enrichPage(page.items) };
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
...base,
|
|
83
|
+
async getPublicPost(slug) {
|
|
84
|
+
const post = await base.getPublicPost(slug);
|
|
85
|
+
return post ? enrich(post) : undefined;
|
|
86
|
+
},
|
|
87
|
+
async listPublicPosts(query = {}) {
|
|
88
|
+
if (query.sort === "new") {
|
|
89
|
+
const page = await base.listPublicPosts(query);
|
|
90
|
+
return { ...page, items: await enrichPage(page.items) };
|
|
91
|
+
}
|
|
92
|
+
// Rank the complete eligible set, not an already paginated Notion counter projection.
|
|
93
|
+
const { cursor, ...filters } = query;
|
|
94
|
+
const fingerprint = JSON.stringify(filters);
|
|
95
|
+
let offset = 0;
|
|
96
|
+
if (cursor) {
|
|
97
|
+
try {
|
|
98
|
+
const value = JSON.parse(Buffer.from(cursor, "base64url").toString());
|
|
99
|
+
if (
|
|
100
|
+
value.query !== fingerprint ||
|
|
101
|
+
!Number.isSafeInteger(value.offset) ||
|
|
102
|
+
value.offset < 0
|
|
103
|
+
)
|
|
104
|
+
throw new Error();
|
|
105
|
+
offset = value.offset;
|
|
106
|
+
} catch {
|
|
107
|
+
throw new PublicPostCursorError();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const all: PublicPost[] = [];
|
|
111
|
+
let next: string | undefined;
|
|
112
|
+
do {
|
|
113
|
+
const page = await base.listPublicPosts({
|
|
114
|
+
...filters,
|
|
115
|
+
sort: "new",
|
|
116
|
+
cursor: next,
|
|
117
|
+
});
|
|
118
|
+
all.push(...(await enrichPage(page.items)));
|
|
119
|
+
next = page.nextCursor;
|
|
120
|
+
} while (next);
|
|
121
|
+
all.sort(
|
|
122
|
+
(a, b) =>
|
|
123
|
+
(b.voteCount ?? 0) - (a.voteCount ?? 0) ||
|
|
124
|
+
b.createdAt.getTime() - a.createdAt.getTime() ||
|
|
125
|
+
b.slug.localeCompare(a.slug),
|
|
126
|
+
);
|
|
127
|
+
return {
|
|
128
|
+
items: all.slice(offset, offset + 25),
|
|
129
|
+
...(offset + 25 < all.length
|
|
130
|
+
? {
|
|
131
|
+
nextCursor: Buffer.from(
|
|
132
|
+
JSON.stringify({ query: fingerprint, offset: offset + 25 }),
|
|
133
|
+
).toString("base64url"),
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
listPublicRoadmapPosts,
|
|
139
|
+
async getPublicPostRoadmap() {
|
|
140
|
+
return Object.fromEntries(
|
|
141
|
+
await Promise.all(
|
|
142
|
+
roadmapStatuses.map(async (status) => [
|
|
143
|
+
status,
|
|
144
|
+
await listPublicRoadmapPosts({ status }),
|
|
145
|
+
]),
|
|
146
|
+
),
|
|
147
|
+
) as PublicPostRoadmap;
|
|
148
|
+
},
|
|
149
|
+
async changeVote(input) {
|
|
150
|
+
const identity = requireParticipant();
|
|
151
|
+
const post = await base.getPublicPost(input.slug);
|
|
152
|
+
if (!post || post.voteCount === undefined)
|
|
153
|
+
throw new VoteEligibilityError();
|
|
154
|
+
await store.setVote(
|
|
155
|
+
input.slug,
|
|
156
|
+
identity.subject,
|
|
157
|
+
input.intention === "add",
|
|
158
|
+
);
|
|
159
|
+
return {
|
|
160
|
+
slug: input.slug,
|
|
161
|
+
...(await store.voteState(input.slug, identity.subject)),
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
async editComment(input) {
|
|
165
|
+
await authorizeComment(input.commentId);
|
|
166
|
+
const comment = await base.editComment(input);
|
|
167
|
+
return {
|
|
168
|
+
...comment,
|
|
169
|
+
author: (await store.commentAuthor(comment.id)) ?? comment.author,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
async deleteComment(input) {
|
|
173
|
+
await authorizeComment(input.commentId);
|
|
174
|
+
await base.deleteComment(input);
|
|
175
|
+
},
|
|
176
|
+
createComment: create,
|
|
177
|
+
replyToCommentThread: create,
|
|
178
|
+
async listCommentThreads(input) {
|
|
179
|
+
const page = await base.listCommentThreads(input);
|
|
180
|
+
const authors = await store.commentAuthors(
|
|
181
|
+
page.items.flatMap((thread) =>
|
|
182
|
+
thread.comments
|
|
183
|
+
.filter((comment) => comment.author.kind !== "product-team")
|
|
184
|
+
.map((comment) => comment.id),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
return {
|
|
188
|
+
...page,
|
|
189
|
+
items: await Promise.all(
|
|
190
|
+
page.items.map(async (thread) => ({
|
|
191
|
+
...thread,
|
|
192
|
+
comments: await Promise.all(
|
|
193
|
+
thread.comments.map(async (comment) => ({
|
|
194
|
+
...comment,
|
|
195
|
+
author:
|
|
196
|
+
comment.author.kind === "product-team"
|
|
197
|
+
? comment.author
|
|
198
|
+
: (authors.get(comment.id) ?? comment.author),
|
|
199
|
+
})),
|
|
200
|
+
),
|
|
201
|
+
})),
|
|
202
|
+
),
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
|
|
7
|
+
let runtime: Miniflare | undefined;
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
await runtime?.dispose();
|
|
10
|
+
});
|
|
11
|
+
async function migratedDatabase() {
|
|
12
|
+
runtime = new Miniflare({
|
|
13
|
+
modules: true,
|
|
14
|
+
script: "export default {fetch(){return new Response('ok')}}",
|
|
15
|
+
compatibilityDate: "2026-07-30",
|
|
16
|
+
d1Databases: ["DB"],
|
|
17
|
+
});
|
|
18
|
+
const binding = await runtime.getD1Database("DB");
|
|
19
|
+
await binding.exec(
|
|
20
|
+
(await readFile(
|
|
21
|
+
existsSync("apps/portal/migrations/0001_connected.sql")
|
|
22
|
+
? "apps/portal/migrations/0001_connected.sql"
|
|
23
|
+
: "migrations/0001_connected.sql",
|
|
24
|
+
"utf8",
|
|
25
|
+
)) +
|
|
26
|
+
"\n" +
|
|
27
|
+
(await readFile(
|
|
28
|
+
existsSync("apps/portal/migrations/0002_participation.sql")
|
|
29
|
+
? "apps/portal/migrations/0002_participation.sql"
|
|
30
|
+
: "migrations/0002_participation.sql",
|
|
31
|
+
"utf8",
|
|
32
|
+
)),
|
|
33
|
+
);
|
|
34
|
+
return binding;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it("persists one Vote per verified Participant across concurrent retries and browsers", async () => {
|
|
38
|
+
const binding = await migratedDatabase();
|
|
39
|
+
const store = createConnectedStore(binding);
|
|
40
|
+
await store.acceptIdentity({
|
|
41
|
+
subject: "alice",
|
|
42
|
+
displayName: "Alice",
|
|
43
|
+
nonce: "once",
|
|
44
|
+
sessionHash: "session",
|
|
45
|
+
expiresAt: Date.now() + 60000,
|
|
46
|
+
});
|
|
47
|
+
await Promise.all(
|
|
48
|
+
Array.from({ length: 8 }, () => store.setVote("post-1", "alice", true)),
|
|
49
|
+
);
|
|
50
|
+
expect(await store.voteState("post-1", "alice")).toEqual({
|
|
51
|
+
voteCount: 1,
|
|
52
|
+
voted: true,
|
|
53
|
+
});
|
|
54
|
+
const secondBrowser = createConnectedStore(binding);
|
|
55
|
+
expect(await secondBrowser.voteState("post-1", "alice")).toEqual({
|
|
56
|
+
voteCount: 1,
|
|
57
|
+
voted: true,
|
|
58
|
+
});
|
|
59
|
+
await secondBrowser.setVote("post-1", "alice", false);
|
|
60
|
+
await secondBrowser.setVote("post-1", "alice", false);
|
|
61
|
+
expect(await store.voteState("post-1", "alice")).toEqual({
|
|
62
|
+
voteCount: 0,
|
|
63
|
+
voted: false,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
it("rejects replayed handoffs without replacing an existing Participant or creating a session", async () => {
|
|
67
|
+
const binding = await migratedDatabase();
|
|
68
|
+
const store = createConnectedStore(binding);
|
|
69
|
+
const input = {
|
|
70
|
+
subject: "alice",
|
|
71
|
+
displayName: "Alice",
|
|
72
|
+
nonce: "once",
|
|
73
|
+
sessionHash: "session",
|
|
74
|
+
expiresAt: 2000,
|
|
75
|
+
};
|
|
76
|
+
await store.acceptIdentity(input);
|
|
77
|
+
await expect(
|
|
78
|
+
store.acceptIdentity({
|
|
79
|
+
...input,
|
|
80
|
+
displayName: "Changed",
|
|
81
|
+
sessionHash: "replay",
|
|
82
|
+
}),
|
|
83
|
+
).rejects.toThrow();
|
|
84
|
+
expect(await store.session("session", 1500)).toEqual({
|
|
85
|
+
subject: "alice",
|
|
86
|
+
displayName: "Alice",
|
|
87
|
+
});
|
|
88
|
+
expect(await store.session("replay", 1500)).toBeUndefined();
|
|
89
|
+
expect(await store.session("session", 2000)).toBeUndefined();
|
|
90
|
+
await store.logout("session");
|
|
91
|
+
expect(await store.session("session", 1500)).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ranks by verified Votes and attributes only Comments with identity evidence", async () => {
|
|
95
|
+
const binding = await migratedDatabase();
|
|
96
|
+
const store = createConnectedStore(binding);
|
|
97
|
+
const alice = { subject: "alice", displayName: "Alice" };
|
|
98
|
+
await store.acceptIdentity({
|
|
99
|
+
...alice,
|
|
100
|
+
nonce: "a",
|
|
101
|
+
sessionHash: "a",
|
|
102
|
+
expiresAt: Date.now() + 60000,
|
|
103
|
+
});
|
|
104
|
+
const { createFeedbackModule, createConnectedFeedbackModule } =
|
|
105
|
+
await import("./index");
|
|
106
|
+
const base = createFeedbackModule({
|
|
107
|
+
initialItems: [
|
|
108
|
+
{
|
|
109
|
+
id: "p1",
|
|
110
|
+
slug: "old-popular",
|
|
111
|
+
title: "Old",
|
|
112
|
+
description: "Old",
|
|
113
|
+
type: "Feature Request",
|
|
114
|
+
status: "Planned",
|
|
115
|
+
published: true,
|
|
116
|
+
createdAt: new Date(1000),
|
|
117
|
+
updatedAt: new Date(1000),
|
|
118
|
+
voteCount: 500,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "p2",
|
|
122
|
+
slug: "new",
|
|
123
|
+
title: "New",
|
|
124
|
+
description: "New",
|
|
125
|
+
type: "Feature Request",
|
|
126
|
+
status: "Planned",
|
|
127
|
+
published: true,
|
|
128
|
+
createdAt: new Date(2000),
|
|
129
|
+
updatedAt: new Date(2000),
|
|
130
|
+
voteCount: 0,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "private",
|
|
134
|
+
slug: "private",
|
|
135
|
+
title: "Private",
|
|
136
|
+
description: "Private",
|
|
137
|
+
type: "Feature Request",
|
|
138
|
+
status: "New",
|
|
139
|
+
published: false,
|
|
140
|
+
createdAt: new Date(2000),
|
|
141
|
+
updatedAt: new Date(2000),
|
|
142
|
+
voteCount: 0,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
initialComments: [
|
|
146
|
+
{
|
|
147
|
+
id: "old",
|
|
148
|
+
postId: "p1",
|
|
149
|
+
discussionId: "thread",
|
|
150
|
+
body: "Old",
|
|
151
|
+
author: { kind: "participant", displayName: "Unverified Alice" },
|
|
152
|
+
createdAt: new Date(1000),
|
|
153
|
+
resolved: false,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "team",
|
|
157
|
+
postId: "p1",
|
|
158
|
+
discussionId: "thread",
|
|
159
|
+
body: "Team",
|
|
160
|
+
author: { kind: "product-team", displayName: "Product Team" },
|
|
161
|
+
createdAt: new Date(2000),
|
|
162
|
+
resolved: false,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "resolved",
|
|
166
|
+
postId: "p1",
|
|
167
|
+
discussionId: "resolved",
|
|
168
|
+
body: "Hidden",
|
|
169
|
+
author: { kind: "product-team", displayName: "Product Team" },
|
|
170
|
+
createdAt: new Date(2000),
|
|
171
|
+
resolved: true,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
const signedIn = createConnectedFeedbackModule(base, store, alice);
|
|
176
|
+
const publicRead = createConnectedFeedbackModule(base, store);
|
|
177
|
+
expect((await publicRead.getPublicPost("old-popular"))?.voteCount).toBe(0);
|
|
178
|
+
await signedIn.changeVote({ slug: "old-popular", intention: "add" });
|
|
179
|
+
expect(
|
|
180
|
+
(await publicRead.listPublicPosts({ sort: "top" })).items.map(
|
|
181
|
+
(p) => p.slug,
|
|
182
|
+
),
|
|
183
|
+
).toEqual(["old-popular", "new"]);
|
|
184
|
+
expect(
|
|
185
|
+
(await publicRead.listPublicPosts({ sort: "new" })).items.map(
|
|
186
|
+
(p) => p.slug,
|
|
187
|
+
),
|
|
188
|
+
).toEqual(["new", "old-popular"]);
|
|
189
|
+
await expect(
|
|
190
|
+
signedIn.changeVote({ slug: "private", intention: "add" }),
|
|
191
|
+
).rejects.toThrow();
|
|
192
|
+
await expect(
|
|
193
|
+
publicRead.changeVote({ slug: "new", intention: "add" }),
|
|
194
|
+
).rejects.toThrow("Sign in");
|
|
195
|
+
const comment = await signedIn.createComment({
|
|
196
|
+
slug: "old-popular",
|
|
197
|
+
body: "Hello",
|
|
198
|
+
displayName: "Spoofed Admin",
|
|
199
|
+
});
|
|
200
|
+
expect(comment.author).toEqual({
|
|
201
|
+
kind: "participant",
|
|
202
|
+
displayName: "Alice",
|
|
203
|
+
verified: true,
|
|
204
|
+
});
|
|
205
|
+
const comments = (
|
|
206
|
+
await publicRead.listCommentThreads({ slug: "old-popular" })
|
|
207
|
+
).items.flatMap((t) => t.comments);
|
|
208
|
+
expect(comments.find((c) => c.id === "old")?.author).toEqual({
|
|
209
|
+
kind: "participant",
|
|
210
|
+
displayName: "Unverified Alice",
|
|
211
|
+
});
|
|
212
|
+
expect(comments.find((c) => c.id === "team")?.author).toEqual({
|
|
213
|
+
kind: "product-team",
|
|
214
|
+
displayName: "Product Team",
|
|
215
|
+
});
|
|
216
|
+
expect(comments.some((c) => c.id === "resolved")).toBe(false);
|
|
217
|
+
expect(comments.find((c) => c.id === comment.id)?.author).toEqual(
|
|
218
|
+
comment.author,
|
|
219
|
+
);
|
|
220
|
+
expect(JSON.stringify(comments)).not.toContain('"subject"');
|
|
221
|
+
expect(
|
|
222
|
+
(await signedIn.editComment({ commentId: comment.id, body: "Edited" }))
|
|
223
|
+
.author,
|
|
224
|
+
).toEqual(comment.author);
|
|
225
|
+
const other = createConnectedFeedbackModule(base, store, {
|
|
226
|
+
subject: "bob",
|
|
227
|
+
displayName: "Bob",
|
|
228
|
+
});
|
|
229
|
+
await expect(
|
|
230
|
+
other.editComment({ commentId: comment.id, body: "Impersonated" }),
|
|
231
|
+
).rejects.toThrow();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("keeps Top pagination at 25 Posts and binds cursors to their filters", async () => {
|
|
235
|
+
const store = createConnectedStore(await migratedDatabase());
|
|
236
|
+
const { createFeedbackModule, createConnectedFeedbackModule } =
|
|
237
|
+
await import("./index");
|
|
238
|
+
const base = createFeedbackModule({
|
|
239
|
+
initialItems: Array.from({ length: 26 }, (_, i) => ({
|
|
240
|
+
id: `id-${i}`,
|
|
241
|
+
slug: `post-${i}`,
|
|
242
|
+
title: `Post ${i}`,
|
|
243
|
+
description: "Description",
|
|
244
|
+
type: "Feature Request",
|
|
245
|
+
status: "Planned",
|
|
246
|
+
published: true,
|
|
247
|
+
createdAt: new Date(i * 1000),
|
|
248
|
+
updatedAt: new Date(i * 1000),
|
|
249
|
+
voteCount: 0,
|
|
250
|
+
})),
|
|
251
|
+
});
|
|
252
|
+
const feedback = createConnectedFeedbackModule(base, store);
|
|
253
|
+
const first = await feedback.listPublicPosts({ sort: "top" });
|
|
254
|
+
expect(first.items).toHaveLength(25);
|
|
255
|
+
expect(first.nextCursor).toBeTruthy();
|
|
256
|
+
const second = await feedback.listPublicPosts({
|
|
257
|
+
sort: "top",
|
|
258
|
+
cursor: first.nextCursor,
|
|
259
|
+
});
|
|
260
|
+
expect(second.items).toHaveLength(1);
|
|
261
|
+
expect(second.nextCursor).toBeUndefined();
|
|
262
|
+
expect(
|
|
263
|
+
new Set([...first.items, ...second.items].map((post) => post.slug)).size,
|
|
264
|
+
).toBe(26);
|
|
265
|
+
await expect(
|
|
266
|
+
feedback.listPublicPosts({
|
|
267
|
+
sort: "top",
|
|
268
|
+
type: "Bug Report",
|
|
269
|
+
cursor: first.nextCursor,
|
|
270
|
+
}),
|
|
271
|
+
).rejects.toThrow();
|
|
272
|
+
});
|