create-feedbax 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/cli.js +2042 -16
  2. package/dist/index.js +1997 -5
  3. package/package.json +1 -1
  4. package/template/.feedbax-template.sha256 +1 -1
  5. package/template/AGENTS.md +13 -0
  6. package/template/CONNECTED.md +404 -0
  7. package/template/README.md +58 -0
  8. package/template/components.json +1 -1
  9. package/template/eslint.config.mjs +23 -0
  10. package/template/migrations/0001_connected.sql +6 -0
  11. package/template/migrations/0002_participation.sql +8 -0
  12. package/template/migrations/0003_images.sql +3 -0
  13. package/template/migrations/0004_followed_status.sql +9 -0
  14. package/template/package.json +9 -5
  15. package/template/src/authorized-draft-post.tsx +1 -0
  16. package/template/src/changelog-page.tsx +63 -54
  17. package/template/src/cloudflare-workers.d.ts +6 -0
  18. package/template/src/comment-creation.test.tsx +42 -1
  19. package/template/src/comment-thread-page.ts +11 -1
  20. package/template/src/components/command-palette.tsx +59 -30
  21. package/template/src/components/connected-identity-provider.tsx +108 -0
  22. package/template/src/components/device-profile-provider.tsx +41 -26
  23. package/template/src/components/follow-post-button.tsx +89 -0
  24. package/template/src/components/header.tsx +2 -0
  25. package/template/src/components/image-attachments.tsx +187 -0
  26. package/template/src/components/loader.tsx +48 -5
  27. package/template/src/components/notification-inbox.tsx +232 -0
  28. package/template/src/components/portal-footer.tsx +14 -0
  29. package/template/src/components/profile-menu.tsx +30 -0
  30. package/template/src/components/ui/components/command.tsx +30 -29
  31. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  32. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  33. package/template/src/components/ui/components/switch.tsx +29 -0
  34. package/template/src/components/ui/styles/globals.css +1 -1
  35. package/template/src/connected-identity.test.ts +144 -0
  36. package/template/src/connected-identity.ts +122 -0
  37. package/template/src/connected-participation.test.tsx +67 -0
  38. package/template/src/connected-runtime.test.ts +241 -0
  39. package/template/src/connected-runtime.ts +176 -0
  40. package/template/src/connected-social.test.tsx +158 -0
  41. package/template/src/feedback-cache-invalidation.ts +1 -0
  42. package/template/src/feedback-runtime.ts +79 -12
  43. package/template/src/feedbax/config/index.test.ts +28 -0
  44. package/template/src/feedbax/config/index.ts +93 -0
  45. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  46. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  47. package/template/src/feedbax/feedback/connected.ts +215 -0
  48. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  49. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  50. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  51. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  52. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  53. package/template/src/feedbax/feedback/images.test.ts +223 -0
  54. package/template/src/feedbax/feedback/images.ts +324 -0
  55. package/template/src/feedbax/feedback/index.ts +55 -1
  56. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  57. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  58. package/template/src/feedbax/feedback/participation.ts +502 -0
  59. package/template/src/images-runtime.test.ts +133 -0
  60. package/template/src/images-runtime.ts +125 -0
  61. package/template/src/index.css +97 -440
  62. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  63. package/template/src/overlay-post-detail.test.tsx +59 -8
  64. package/template/src/overlay-post-detail.tsx +20 -69
  65. package/template/src/participation-client.ts +63 -0
  66. package/template/src/participation-runtime.test.ts +35 -0
  67. package/template/src/participation-runtime.ts +255 -0
  68. package/template/src/portal-comments.ts +24 -5
  69. package/template/src/portal-feedback-form.tsx +18 -3
  70. package/template/src/portal-feedback-runtime.ts +3 -0
  71. package/template/src/portal-feedback-server-function.ts +6 -3
  72. package/template/src/portal-feedback-submission.ts +10 -0
  73. package/template/src/portal-server-entry.ts +4 -0
  74. package/template/src/post-creation-flow.tsx +2 -2
  75. package/template/src/post-detail-content.test.tsx +233 -0
  76. package/template/src/post-detail-content.tsx +162 -0
  77. package/template/src/post-detail-query.ts +34 -0
  78. package/template/src/post-results.tsx +1 -1
  79. package/template/src/public-cache-policy.test.ts +11 -4
  80. package/template/src/public-cache-policy.ts +1 -1
  81. package/template/src/public-feedback-index.tsx +9 -39
  82. package/template/src/public-feedback-page.test.tsx +65 -0
  83. package/template/src/public-feedback-page.ts +12 -1
  84. package/template/src/public-post-detail-skeleton.tsx +54 -0
  85. package/template/src/public-post-detail.tsx +298 -208
  86. package/template/src/public-post-route.test.tsx +39 -3
  87. package/template/src/public-post-route.tsx +15 -28
  88. package/template/src/public-roadmap-route.test.tsx +38 -2
  89. package/template/src/public-roadmap-view.tsx +56 -23
  90. package/template/src/public-shell.test.tsx +26 -0
  91. package/template/src/routeTree.gen.ts +49 -3
  92. package/template/src/router.tsx +2 -0
  93. package/template/src/routes/__root.tsx +3 -5
  94. package/template/src/routes/email-preferences.tsx +77 -0
  95. package/template/src/routes/submit.tsx +1 -1
  96. package/template/src/safe-public-failure.ts +3 -1
  97. package/template/src/server.ts +41 -1
  98. package/template/src/trusted-feedback-handler.test.ts +1 -1
  99. package/template/src/vote-toggle.tsx +42 -9
@@ -0,0 +1,14 @@
1
+ export function PortalFooter() {
2
+ return (
3
+ <footer className="mt-auto flex justify-end border-t px-6 py-4 text-xs text-muted-foreground lg:px-10">
4
+ <a
5
+ href="https://github.com/jessequartey/feedbax"
6
+ target="_blank"
7
+ rel="noopener noreferrer"
8
+ className="transition-colors hover:text-foreground"
9
+ >
10
+ Powered by Feedbax
11
+ </a>
12
+ </footer>
13
+ );
14
+ }
@@ -1,3 +1,4 @@
1
+ import { useConnectedIdentity } from "./connected-identity-provider";
1
2
  import { useTheme } from "next-themes";
2
3
  import { UserCircle } from "lucide-react";
3
4
 
@@ -20,8 +21,37 @@ import { useDeviceProfile } from "./device-profile-provider";
20
21
  export function ProfileMenu() {
21
22
  const { profile, openProfileSetup, clearProfile } = useDeviceProfile();
22
23
 
24
+ const identity = useConnectedIdentity();
25
+
23
26
  const initials = profile ? deriveDeviceProfileInitials(profile) : undefined;
24
27
 
28
+ if (identity.enabled)
29
+ return (
30
+ <div>
31
+ <Button variant="ghost" onClick={openProfileSetup}>
32
+ Contact profile
33
+ </Button>
34
+ {identity.displayName ? (
35
+ <>
36
+ <span>{identity.displayName}</span>
37
+ <Button variant="outline" onClick={() => void identity.signOut()}>
38
+ Sign out
39
+ </Button>
40
+ </>
41
+ ) : (
42
+ <Button
43
+ variant="outline"
44
+ disabled={!identity.ready}
45
+ onClick={() => {
46
+ window.location.href = "/internal/identity/start";
47
+ }}
48
+ >
49
+ Sign in
50
+ </Button>
51
+ )}
52
+ {identity.error ? <p role="alert">{identity.error}</p> : null}
53
+ </div>
54
+ );
25
55
  return profile ? (
26
56
  <DropdownMenu>
27
57
  <DropdownMenuTrigger
@@ -1,7 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { Command as CommandPrimitive } from "cmdk";
3
-
4
3
  import { cn } from "@feedbax/ui/lib/utils";
4
+
5
5
  import {
6
6
  Dialog,
7
7
  DialogContent,
@@ -9,10 +9,6 @@ import {
9
9
  DialogHeader,
10
10
  DialogTitle,
11
11
  } from "@feedbax/ui/components/dialog";
12
- import {
13
- InputGroup,
14
- InputGroupAddon,
15
- } from "@feedbax/ui/components/input-group";
16
12
  import { SearchIcon, CheckIcon } from "lucide-react";
17
13
 
18
14
  function Command({
@@ -47,17 +43,17 @@ function CommandDialog({
47
43
  }) {
48
44
  return (
49
45
  <Dialog {...props}>
50
- <DialogHeader className="sr-only">
51
- <DialogTitle>{title}</DialogTitle>
52
- <DialogDescription>{description}</DialogDescription>
53
- </DialogHeader>
54
46
  <DialogContent
55
47
  className={cn(
56
- "top-1/3 translate-y-0 overflow-hidden rounded-none p-0",
48
+ "top-24 translate-y-0 overflow-hidden rounded-none p-0 sm:top-32",
57
49
  className,
58
50
  )}
59
51
  showCloseButton={showCloseButton}
60
52
  >
53
+ <DialogHeader className="sr-only">
54
+ <DialogTitle>{title}</DialogTitle>
55
+ <DialogDescription>{description}</DialogDescription>
56
+ </DialogHeader>
61
57
  {children}
62
58
  </DialogContent>
63
59
  </Dialog>
@@ -69,20 +65,22 @@ function CommandInput({
69
65
  ...props
70
66
  }: React.ComponentProps<typeof CommandPrimitive.Input>) {
71
67
  return (
72
- <div data-slot="command-input-wrapper" className="border-b pb-0">
73
- <InputGroup className="h-11 border-none border-input/30 bg-input/30 shadow-none! md:h-8 *:data-[slot=input-group-addon]:pl-2!">
74
- <CommandPrimitive.Input
75
- data-slot="command-input"
76
- className={cn(
77
- "w-full text-base outline-hidden disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
78
- className,
79
- )}
80
- {...props}
81
- />
82
- <InputGroupAddon>
83
- <SearchIcon className="size-4 shrink-0 opacity-50" />
84
- </InputGroupAddon>
85
- </InputGroup>
68
+ <div
69
+ data-slot="command-input-wrapper"
70
+ className="flex h-14 shrink-0 items-center gap-3 border-b px-4"
71
+ >
72
+ <SearchIcon
73
+ className="size-5 shrink-0 text-muted-foreground"
74
+ aria-hidden="true"
75
+ />
76
+ <CommandPrimitive.Input
77
+ data-slot="command-input"
78
+ className={cn(
79
+ "h-full w-full min-w-0 border-0 bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
80
+ className,
81
+ )}
82
+ {...props}
83
+ />
86
84
  </div>
87
85
  );
88
86
  }
@@ -95,7 +93,7 @@ function CommandList({
95
93
  <CommandPrimitive.List
96
94
  data-slot="command-list"
97
95
  className={cn(
98
- "no-scrollbar max-h-72 scroll-py-0 overflow-x-hidden overflow-y-auto outline-none",
96
+ "no-scrollbar max-h-80 scroll-py-2 overflow-x-hidden overflow-y-auto outline-none",
99
97
  className,
100
98
  )}
101
99
  {...props}
@@ -110,7 +108,10 @@ function CommandEmpty({
110
108
  return (
111
109
  <CommandPrimitive.Empty
112
110
  data-slot="command-empty"
113
- className={cn("py-6 text-center text-xs", className)}
111
+ className={cn(
112
+ "px-6 py-10 text-center text-sm text-muted-foreground",
113
+ className,
114
+ )}
114
115
  {...props}
115
116
  />
116
117
  );
@@ -124,7 +125,7 @@ function CommandGroup({
124
125
  <CommandPrimitive.Group
125
126
  data-slot="command-group"
126
127
  className={cn(
127
- "overflow-hidden text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:text-muted-foreground",
128
+ "overflow-hidden text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:text-muted-foreground",
128
129
  className,
129
130
  )}
130
131
  {...props}
@@ -139,7 +140,7 @@ function CommandSeparator({
139
140
  return (
140
141
  <CommandPrimitive.Separator
141
142
  data-slot="command-separator"
142
- className={cn("-mx-1 h-px bg-border", className)}
143
+ className={cn("my-1 h-px bg-border", className)}
143
144
  {...props}
144
145
  />
145
146
  );
@@ -154,7 +155,7 @@ function CommandItem({
154
155
  <CommandPrimitive.Item
155
156
  data-slot="command-item"
156
157
  className={cn(
157
- "group/command-item relative flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none in-data-[slot=dialog-content]:rounded-none! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
158
+ "group/command-item relative flex cursor-default items-center gap-2 rounded-none px-3 py-3 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-none! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
158
159
  className,
159
160
  )}
160
161
  {...props}
@@ -5,8 +5,8 @@ import {
5
5
  useMessageScrollerScrollable,
6
6
  useMessageScrollerVisibility,
7
7
  } from "@shadcn/react/message-scroller";
8
-
9
8
  import { cn } from "@feedbax/ui/lib/utils";
9
+
10
10
  import { Button } from "@feedbax/ui/components/button";
11
11
  import { ArrowDownIcon } from "lucide-react";
12
12
 
@@ -40,7 +40,7 @@ function MessageScrollerViewport({
40
40
  <MessageScrollerPrimitive.Viewport
41
41
  data-slot="message-scroller-viewport"
42
42
  className={cn(
43
- "size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
43
+ "size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",
44
44
  className,
45
45
  )}
46
46
  {...props}
@@ -1,5 +1,4 @@
1
1
  import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
2
-
3
2
  import { cn } from "@feedbax/ui/lib/utils";
4
3
 
5
4
  function ScrollArea({
@@ -0,0 +1,29 @@
1
+ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
2
+ import { cn } from "@feedbax/ui/lib/utils";
3
+
4
+ function Switch({
5
+ className,
6
+ size = "default",
7
+ ...props
8
+ }: SwitchPrimitive.Root.Props & {
9
+ size?: "sm" | "default";
10
+ }) {
11
+ return (
12
+ <SwitchPrimitive.Root
13
+ data-slot="switch"
14
+ data-size={size}
15
+ className={cn(
16
+ "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
17
+ className,
18
+ )}
19
+ {...props}
20
+ >
21
+ <SwitchPrimitive.Thumb
22
+ data-slot="switch-thumb"
23
+ className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
24
+ />
25
+ </SwitchPrimitive.Root>
26
+ );
27
+ }
28
+
29
+ export { Switch };
@@ -2,7 +2,7 @@
2
2
  @import "tw-animate-css";
3
3
  @import "shadcn/tailwind.css";
4
4
  @import "@fontsource-variable/geist";
5
- @source "../../../apps/**/*.{ts,tsx}";
5
+ @source "../../../**/*.{ts,tsx}";
6
6
  @source "../**/*.{ts,tsx}";
7
7
 
8
8
  @custom-variant dark (&:is(.dark *));
@@ -0,0 +1,144 @@
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
+ expect(await response.json()).toEqual({
116
+ participant: { displayName: "Alice" },
117
+ });
118
+ expect(
119
+ (
120
+ await handler(
121
+ new Request("https://feedback.example/internal/identity/logout", {
122
+ method: "POST",
123
+ headers: { Cookie: session, Origin: "https://evil.example" },
124
+ }),
125
+ )
126
+ ).status,
127
+ ).toBe(403);
128
+ expect(
129
+ (
130
+ await handler(
131
+ new Request("https://feedback.example/internal/identity/logout", {
132
+ method: "POST",
133
+ headers: { Cookie: session, Origin: "https://feedback.example" },
134
+ }),
135
+ )
136
+ ).status,
137
+ ).toBe(200);
138
+ expect(await (await handler(sessionRequest())).json()).toEqual({
139
+ participant: null,
140
+ });
141
+ } finally {
142
+ await runtime.dispose();
143
+ }
144
+ });
@@ -0,0 +1,122 @@
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
+ ? { displayName: participant.displayName }
93
+ : null,
94
+ });
95
+ }
96
+ if (path === "/internal/identity/logout" && request.method === "POST") {
97
+ if (!sameOrigin(request, origin))
98
+ return json({ error: "Request origin is invalid." }, 403);
99
+ const token = readSessionToken(request);
100
+ if (token) await options.store.logout(sessionHash(token));
101
+ headers.set(
102
+ "Set-Cookie",
103
+ `${sessionCookie}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`,
104
+ );
105
+ return json({ signedOut: true });
106
+ }
107
+ return json({ error: "Not found." }, 404);
108
+ } catch {
109
+ return json(
110
+ { error: "Sign-in could not be completed. Start sign-in again." },
111
+ 401,
112
+ );
113
+ }
114
+ };
115
+ }
116
+ function cookie(request: Request, name: string): string | undefined {
117
+ const pairs = (request.headers.get("cookie") ?? "")
118
+ .split(";")
119
+ .map((v) => v.trim())
120
+ .filter((v) => v.startsWith(`${name}=`));
121
+ return pairs.length === 1 ? pairs[0]!.slice(name.length + 1) : undefined;
122
+ }
@@ -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
+ });