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,82 @@
1
+ import { afterEach, expect, it } from "vitest";
2
+ import { Miniflare } from "miniflare";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { createContentFeedbackModule } from "./configured-feedback";
6
+ import type { FeedbaxConfig } from "@feedbax/config";
7
+
8
+ let runtime: Miniflare | undefined;
9
+ afterEach(async () => {
10
+ await runtime?.dispose();
11
+ });
12
+ it("runs a GitHub-only portal without Notion or verified identity and requires D1", async () => {
13
+ const config: FeedbaxConfig = {
14
+ provider: "github",
15
+ product: {
16
+ name: "Acme",
17
+ description: "Feedback",
18
+ logo: "/logo.svg",
19
+ siteUrl: "https://portal.example",
20
+ },
21
+ theme: { preset: "lyra-neutral" },
22
+ github: {
23
+ installationId: "portal-acme",
24
+ serviceUrl: "https://service.example",
25
+ repository: { id: 123, owner: "acme", name: "feedback" },
26
+ botUserId: 456,
27
+ },
28
+ features: { voting: true, comments: true, changelog: false },
29
+ submissions: {
30
+ moderation: "required",
31
+ maxTitleLength: 160,
32
+ maxDescriptionLength: 5000,
33
+ },
34
+ turnstile: { enabled: false },
35
+ };
36
+ expect(() => createContentFeedbackModule(config, {})).toThrow("FEEDBAX_DB");
37
+ runtime = new Miniflare({
38
+ modules: true,
39
+ script: "export default { fetch() { return new Response('ok') } }",
40
+ d1Databases: ["DB"],
41
+ compatibilityDate: "2026-07-30",
42
+ });
43
+ const database = await runtime.getD1Database("DB");
44
+ const directory = resolve(import.meta.dirname, "../migrations");
45
+ for (const file of (await readdir(directory)).sort())
46
+ await database.exec(
47
+ (await readFile(resolve(directory, file), "utf8"))
48
+ .replace(/--[^\n]*/g, "")
49
+ .replace(/\s+/g, " "),
50
+ );
51
+ const urls: string[] = [];
52
+ const feedback = createContentFeedbackModule(
53
+ config,
54
+ { FEEDBAX_DB: database, GITHUB_SERVICE_SECRET: "p".repeat(40) },
55
+ async (input, init) => {
56
+ const request =
57
+ input instanceof Request ? input : new Request(input, init);
58
+ urls.push(request.url);
59
+ if (request.url === "https://service.example/v1/token") {
60
+ expect(request.headers.get("X-Feedbax-Installation")).toBe(
61
+ "portal-acme",
62
+ );
63
+ expect(request.headers.get("X-Feedbax-Signature")).toMatch(
64
+ /^[a-f0-9]{64}$/,
65
+ );
66
+ return Response.json({
67
+ token: "scoped-access",
68
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
69
+ repositoryId: 123,
70
+ botUserId: 456,
71
+ });
72
+ }
73
+ expect(request.headers.get("Authorization")).toBe("Bearer scoped-access");
74
+ if (request.url === "https://api.github.com/repos/acme/feedback")
75
+ return Response.json({ id: 123 });
76
+ return Response.json([]);
77
+ },
78
+ );
79
+ expect(await feedback.listPublicPosts()).toMatchObject({ items: [] });
80
+ expect(urls.some((url) => url.includes("notion"))).toBe(false);
81
+ expect(urls).toContain("https://service.example/v1/token");
82
+ });
@@ -0,0 +1,61 @@
1
+ import { assertRuntimeBindings, type FeedbaxConfig } from "@feedbax/config";
2
+ import {
3
+ createGitHubFeedbackModule,
4
+ createGitHubServiceAccess,
5
+ createNotionFeedbackModule,
6
+ type ConnectedDatabase,
7
+ type VerifiedParticipant,
8
+ createPostOwnership,
9
+ } from "@feedbax/feedback";
10
+
11
+ export function createContentFeedbackModule(
12
+ config: FeedbaxConfig,
13
+ environment: {
14
+ FEEDBAX_DB?: ConnectedDatabase;
15
+ NOTION_TOKEN?: string;
16
+ GITHUB_SERVICE_SECRET?: string;
17
+ getGitHubServiceSecret?: () => Promise<string>;
18
+ },
19
+ request: typeof fetch = fetch,
20
+ participant?: VerifiedParticipant,
21
+ ) {
22
+ assertRuntimeBindings(config, environment);
23
+ const features = {
24
+ votingEnabled: config.features.voting,
25
+ commentsEnabled: config.features.comments,
26
+ ...(config.connected && participant && environment.FEEDBAX_DB
27
+ ? { ownership: createPostOwnership(environment.FEEDBAX_DB, participant) }
28
+ : {}),
29
+ };
30
+ if (config.provider === "github") {
31
+ if (
32
+ !environment.getGitHubServiceSecret &&
33
+ (!environment.GITHUB_SERVICE_SECRET ||
34
+ environment.GITHUB_SERVICE_SECRET.length < 32)
35
+ )
36
+ throw new Error(
37
+ "GitHub requires GITHUB_SERVICE_SECRET. Pair this portal with the GitHub App service.",
38
+ );
39
+ return createGitHubFeedbackModule({
40
+ ...features,
41
+ ...config.github,
42
+ database: environment.FEEDBAX_DB!,
43
+ request,
44
+ getToken: createGitHubServiceAccess({
45
+ config: config.github,
46
+ secret: environment.GITHUB_SERVICE_SECRET,
47
+ getSecret: environment.getGitHubServiceSecret,
48
+ request,
49
+ }),
50
+ });
51
+ }
52
+ if (!environment.NOTION_TOKEN)
53
+ throw new Error("Notion requires NOTION_TOKEN.");
54
+ return createNotionFeedbackModule({
55
+ ...features,
56
+ token: environment.NOTION_TOKEN,
57
+ dataSourceId: config.notion.dataSourceId,
58
+ propertyIds: config.notion.properties,
59
+ request,
60
+ });
61
+ }
@@ -0,0 +1,148 @@
1
+ import { expect, it } from "vitest";
2
+ import { createConnectedIdentityHandler } from "./connected-identity";
3
+
4
+ it("starts sign-in with a secure browser challenge and never accepts a return URL", async () => {
5
+ const handler = createConnectedIdentityHandler({
6
+ siteUrl: "https://feedback.example",
7
+ loginUrl: "https://app.example/feedback-login",
8
+ issuer: "https://app.example",
9
+ secret: "identity-test-secret-at-least-32-characters",
10
+ store: undefined!,
11
+ });
12
+ const response = await handler(
13
+ new Request(
14
+ "https://feedback.example/internal/identity/start?returnTo=https://evil.example",
15
+ ),
16
+ );
17
+ expect(response.status).toBe(303);
18
+ const target = new URL(response.headers.get("location")!);
19
+ expect(target.origin).toBe("https://app.example");
20
+ expect(target.searchParams.get("state")?.length).toBeGreaterThan(30);
21
+ expect(target.searchParams.get("callback")).toBe(
22
+ "https://feedback.example/internal/identity/callback",
23
+ );
24
+ expect(response.headers.get("set-cookie")).toContain(
25
+ "HttpOnly; Secure; SameSite=None",
26
+ );
27
+ expect(response.headers.get("cache-control")).toBe("no-store");
28
+ });
29
+
30
+ it("exchanges a browser-bound handoff once, keeps identity private, and revokes sessions on logout", async () => {
31
+ const { Miniflare } = await import("miniflare");
32
+ const { readFile } = await import("node:fs/promises");
33
+ const { existsSync } = await import("node:fs");
34
+ const { createConnectedStore, signIdentityHandoff } =
35
+ await import("@feedbax/feedback");
36
+ const runtime = new Miniflare({
37
+ modules: true,
38
+ script: "export default {fetch(){return new Response('ok')}}",
39
+ compatibilityDate: "2026-07-30",
40
+ d1Databases: ["DB"],
41
+ });
42
+ try {
43
+ const db = await runtime.getD1Database("DB");
44
+ await db.exec(
45
+ (await readFile(
46
+ existsSync("apps/portal/migrations/0001_connected.sql")
47
+ ? "apps/portal/migrations/0001_connected.sql"
48
+ : "migrations/0001_connected.sql",
49
+ "utf8",
50
+ )) +
51
+ "\n" +
52
+ (await readFile(
53
+ existsSync("apps/portal/migrations/0002_participation.sql")
54
+ ? "apps/portal/migrations/0002_participation.sql"
55
+ : "migrations/0002_participation.sql",
56
+ "utf8",
57
+ )),
58
+ );
59
+ const secret = "identity-test-secret-at-least-32-characters";
60
+ const handler = createConnectedIdentityHandler({
61
+ siteUrl: "https://feedback.example",
62
+ loginUrl: "https://app.example/login",
63
+ issuer: "https://app.example",
64
+ secret,
65
+ store: createConnectedStore(db),
66
+ now: () => 1000000,
67
+ });
68
+ const started = await handler(
69
+ new Request("https://feedback.example/internal/identity/start"),
70
+ );
71
+ const challenge = started.headers.get("set-cookie")!.split(";")[0]!;
72
+ const state = new URL(started.headers.get("location")!).searchParams.get(
73
+ "state",
74
+ )!;
75
+ const token = signIdentityHandoff(
76
+ {
77
+ subject: "private-subject",
78
+ displayName: "Alice",
79
+ audience: "https://feedback.example",
80
+ issuer: "https://app.example",
81
+ state,
82
+ nonce: "nonce",
83
+ issuedAt: 1000,
84
+ expiresAt: 1100,
85
+ },
86
+ secret,
87
+ );
88
+ const callback = (cookie: string, origin = "https://app.example") =>
89
+ new Request("https://feedback.example/internal/identity/callback", {
90
+ method: "POST",
91
+ headers: {
92
+ Origin: origin,
93
+ Cookie: cookie,
94
+ "Content-Type": "application/x-www-form-urlencoded",
95
+ },
96
+ body: new URLSearchParams({ token }),
97
+ });
98
+ expect((await handler(callback(""))).status).toBe(401);
99
+ expect(
100
+ (await handler(callback(challenge, "https://evil.example"))).status,
101
+ ).toBe(403);
102
+ const signedIn = await handler(callback(challenge));
103
+ expect(signedIn.status).toBe(303);
104
+ expect((await handler(callback(challenge))).status).toBe(401);
105
+ const session = signedIn.headers
106
+ .getSetCookie()
107
+ .find((c) => c.startsWith("__Host-feedbax-session="))!
108
+ .split(";")[0]!;
109
+ const sessionRequest = () =>
110
+ new Request("https://feedback.example/internal/identity/session", {
111
+ headers: { Cookie: session },
112
+ });
113
+ const response = await handler(sessionRequest());
114
+ expect(response.headers.get("cache-control")).toBe("no-store");
115
+ const identity = (await response.json()) as {
116
+ participant: { displayName: string; sessionKey: string };
117
+ };
118
+ expect(identity.participant.displayName).toBe("Alice");
119
+ expect(identity.participant.sessionKey).toMatch(/^[A-Za-z0-9_-]{43}$/);
120
+ expect(JSON.stringify(identity)).not.toContain("private-subject");
121
+ expect(JSON.stringify(identity)).not.toContain(session.split("=")[1]);
122
+ expect(
123
+ (
124
+ await handler(
125
+ new Request("https://feedback.example/internal/identity/logout", {
126
+ method: "POST",
127
+ headers: { Cookie: session, Origin: "https://evil.example" },
128
+ }),
129
+ )
130
+ ).status,
131
+ ).toBe(403);
132
+ expect(
133
+ (
134
+ await handler(
135
+ new Request("https://feedback.example/internal/identity/logout", {
136
+ method: "POST",
137
+ headers: { Cookie: session, Origin: "https://feedback.example" },
138
+ }),
139
+ )
140
+ ).status,
141
+ ).toBe(200);
142
+ expect(await (await handler(sessionRequest())).json()).toEqual({
143
+ participant: null,
144
+ });
145
+ } finally {
146
+ await runtime.dispose();
147
+ }
148
+ });
@@ -0,0 +1,127 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { verifyIdentityHandoff, type ConnectedStore } from "@feedbax/feedback";
3
+
4
+ const challengeCookie = "__Host-feedbax-handoff";
5
+ const sessionCookie = "__Host-feedbax-session";
6
+ export const sessionHash = (token: string) =>
7
+ createHash("sha256").update(token).digest("hex");
8
+ export function readSessionToken(request: Request) {
9
+ return cookie(request, sessionCookie);
10
+ }
11
+ export function sameOrigin(request: Request, siteUrl: string) {
12
+ return request.headers.get("origin") === new URL(siteUrl).origin;
13
+ }
14
+ export function createConnectedIdentityHandler(options: {
15
+ siteUrl: string;
16
+ loginUrl: string;
17
+ issuer: string;
18
+ secret: string;
19
+ store: ConnectedStore;
20
+ now?: () => number;
21
+ }) {
22
+ const origin = new URL(options.siteUrl).origin;
23
+ return async (request: Request): Promise<Response> => {
24
+ const path = new URL(request.url).pathname;
25
+ const headers = new Headers({
26
+ "Cache-Control": "no-store",
27
+ "Referrer-Policy": "no-referrer",
28
+ });
29
+ const json = (value: unknown, status = 200) =>
30
+ Response.json(value, { status, headers });
31
+ try {
32
+ if (path === "/internal/identity/start" && request.method === "GET") {
33
+ const state = randomBytes(32).toString("base64url");
34
+ const target = new URL(options.loginUrl);
35
+ target.searchParams.set("state", state);
36
+ target.searchParams.set(
37
+ "callback",
38
+ `${origin}/internal/identity/callback`,
39
+ );
40
+ headers.set(
41
+ "Set-Cookie",
42
+ `${challengeCookie}=${state}; Path=/; Max-Age=300; HttpOnly; Secure; SameSite=None`,
43
+ );
44
+ headers.set("Location", target.toString());
45
+ return new Response(null, { status: 303, headers });
46
+ }
47
+ if (path === "/internal/identity/callback" && request.method === "POST") {
48
+ if (request.headers.get("origin") !== new URL(options.loginUrl).origin)
49
+ return json({ error: "Sign-in origin is invalid." }, 403);
50
+ const body = await request.text();
51
+ if (body.length > 12000)
52
+ return json({ error: "Sign-in request is too large." }, 413);
53
+ const form = new URLSearchParams(body);
54
+ const now = options.now?.() ?? Date.now();
55
+ const identity = verifyIdentityHandoff(
56
+ form.get("token") ?? "",
57
+ options.secret,
58
+ {
59
+ audience: origin,
60
+ issuer: options.issuer,
61
+ state: cookie(request, challengeCookie) ?? "",
62
+ now: Math.floor(now / 1000),
63
+ },
64
+ );
65
+ const token = randomBytes(32).toString("base64url");
66
+ await options.store.acceptIdentity({
67
+ ...identity,
68
+ subject: JSON.stringify([identity.issuer, identity.subject]),
69
+ nonce: identity.nonce,
70
+ challenge: identity.state,
71
+ sessionHash: sessionHash(token),
72
+ expiresAt: now + 7 * 86400000,
73
+ });
74
+ headers.append(
75
+ "Set-Cookie",
76
+ `${sessionCookie}=${token}; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax`,
77
+ );
78
+ headers.append(
79
+ "Set-Cookie",
80
+ `${challengeCookie}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None`,
81
+ );
82
+ headers.set("Location", origin);
83
+ return new Response(null, { status: 303, headers });
84
+ }
85
+ if (path === "/internal/identity/session" && request.method === "GET") {
86
+ const token = readSessionToken(request);
87
+ const participant = token
88
+ ? await options.store.session(sessionHash(token), options.now?.())
89
+ : undefined;
90
+ return json({
91
+ participant: participant
92
+ ? {
93
+ displayName: participant.displayName,
94
+ sessionKey: createHash("sha256")
95
+ .update(`feedbax-ui-session-v1:${token}`)
96
+ .digest("base64url"),
97
+ }
98
+ : null,
99
+ });
100
+ }
101
+ if (path === "/internal/identity/logout" && request.method === "POST") {
102
+ if (!sameOrigin(request, origin))
103
+ return json({ error: "Request origin is invalid." }, 403);
104
+ const token = readSessionToken(request);
105
+ if (token) await options.store.logout(sessionHash(token));
106
+ headers.set(
107
+ "Set-Cookie",
108
+ `${sessionCookie}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`,
109
+ );
110
+ return json({ signedOut: true });
111
+ }
112
+ return json({ error: "Not found." }, 404);
113
+ } catch {
114
+ return json(
115
+ { error: "Sign-in could not be completed. Start sign-in again." },
116
+ 401,
117
+ );
118
+ }
119
+ };
120
+ }
121
+ function cookie(request: Request, name: string): string | undefined {
122
+ const pairs = (request.headers.get("cookie") ?? "")
123
+ .split(";")
124
+ .map((v) => v.trim())
125
+ .filter((v) => v.startsWith(`${name}=`));
126
+ return pairs.length === 1 ? pairs[0]!.slice(name.length + 1) : undefined;
127
+ }
@@ -0,0 +1,67 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import { ConnectedIdentityProvider } from "./components/connected-identity-provider";
11
+ import { VoteToggle } from "./vote-toggle";
12
+ vi.mock("./feedbax", async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import("./feedbax")>();
14
+ return {
15
+ default: {
16
+ ...actual.default,
17
+ connected: {
18
+ issuer: "https://app.example",
19
+ loginUrl: "https://app.example/login",
20
+ },
21
+ },
22
+ };
23
+ });
24
+ afterEach(() => {
25
+ cleanup();
26
+ vi.unstubAllGlobals();
27
+ localStorage.clear();
28
+ sessionStorage.clear();
29
+ });
30
+ it("restores a signed-in Participant's Vote from the server instead of device storage", async () => {
31
+ const request = vi.fn<typeof fetch>().mockImplementation(async (input) => {
32
+ const url = String(input);
33
+ if (url === "/internal/identity/session")
34
+ return Response.json({ participant: { displayName: "Alice" } });
35
+ if (url.startsWith("/internal/connected/votes?"))
36
+ return Response.json({ voted: true, voteCount: 4 });
37
+ return Response.json({ slug: "post", voteCount: 3, voted: false });
38
+ });
39
+ vi.stubGlobal("fetch", request);
40
+ render(
41
+ <ConnectedIdentityProvider>
42
+ <VoteToggle
43
+ post={{
44
+ slug: "post",
45
+ title: "Post",
46
+ description: "Description",
47
+ type: "Feature Request",
48
+ status: "Planned",
49
+ createdAt: new Date(),
50
+ updatedAt: new Date(),
51
+ voteCount: 4,
52
+ }}
53
+ />
54
+ </ConnectedIdentityProvider>,
55
+ );
56
+ const button = await screen.findByRole("button", {
57
+ name: "Remove Vote, 4 Votes",
58
+ });
59
+ expect(button.getAttribute("aria-pressed")).toBe("true");
60
+ fireEvent.click(button);
61
+ await waitFor(() =>
62
+ expect(
63
+ screen.getByRole("button", { name: "Add Vote, 3 Votes" }),
64
+ ).toBeTruthy(),
65
+ );
66
+ expect(localStorage.length).toBe(0);
67
+ });