create-feedbax 0.1.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/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +3283 -22
- package/dist/index.js +3394 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/LICENSE +201 -0
- package/template/README.md +56 -5
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +48 -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 +74 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +205 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +228 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +24 -0
- package/template/src/comment-creation.test.tsx +311 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +28 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +275 -0
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +324 -0
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +97 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +53 -0
- 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 +119 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +195 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +51 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- 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/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +98 -0
- package/template/src/feedback-runtime.ts +105 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +139 -0
- package/template/src/feedbax/config/index.ts +364 -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/feedback-storage.ts +43 -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.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +996 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +1795 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +21 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +181 -0
- package/template/src/overlay-post-detail.tsx +43 -0
- 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.test.ts +375 -0
- package/template/src/portal-comments.ts +403 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +530 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +58 -0
- package/template/src/portal-feedback-server-function.ts +96 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +224 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +68 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +148 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +94 -0
- package/template/src/public-feedback-page.test.tsx +355 -0
- package/template/src/public-feedback-page.ts +86 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +772 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +250 -0
- package/template/src/public-post-route.tsx +158 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +254 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +411 -0
- package/template/src/public-shell.test.tsx +544 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +177 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +27 -0
- package/template/src/routes/__root.tsx +114 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +47 -0
- package/template/src/server.ts +151 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +244 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { signIdentityHandoff, verifyIdentityHandoff } from "./identity-handoff";
|
|
3
|
+
const secret = "test-secret-for-identity-handoff-32-bytes";
|
|
4
|
+
const claims = {
|
|
5
|
+
subject: "alice",
|
|
6
|
+
displayName: "Alice",
|
|
7
|
+
audience: "https://feedback.example",
|
|
8
|
+
issuer: "https://app.example",
|
|
9
|
+
state: "browser-state",
|
|
10
|
+
nonce: "unique-nonce",
|
|
11
|
+
issuedAt: 1000,
|
|
12
|
+
expiresAt: 1100,
|
|
13
|
+
};
|
|
14
|
+
it("accepts only a signed identity bound to this Installation, browser and time", () => {
|
|
15
|
+
const token = signIdentityHandoff(claims, secret);
|
|
16
|
+
expect(
|
|
17
|
+
verifyIdentityHandoff(token, secret, {
|
|
18
|
+
audience: claims.audience,
|
|
19
|
+
issuer: claims.issuer,
|
|
20
|
+
state: claims.state,
|
|
21
|
+
now: 1020,
|
|
22
|
+
}),
|
|
23
|
+
).toEqual(claims);
|
|
24
|
+
expect(() =>
|
|
25
|
+
verifyIdentityHandoff(token, secret, {
|
|
26
|
+
audience: "https://other.example",
|
|
27
|
+
issuer: claims.issuer,
|
|
28
|
+
state: claims.state,
|
|
29
|
+
now: 1020,
|
|
30
|
+
}),
|
|
31
|
+
).toThrow();
|
|
32
|
+
expect(() =>
|
|
33
|
+
verifyIdentityHandoff(token, secret, {
|
|
34
|
+
audience: claims.audience,
|
|
35
|
+
issuer: claims.issuer,
|
|
36
|
+
state: "different-browser",
|
|
37
|
+
now: 1020,
|
|
38
|
+
}),
|
|
39
|
+
).toThrow();
|
|
40
|
+
expect(() =>
|
|
41
|
+
verifyIdentityHandoff(token, secret, {
|
|
42
|
+
audience: claims.audience,
|
|
43
|
+
issuer: claims.issuer,
|
|
44
|
+
state: claims.state,
|
|
45
|
+
now: 1101,
|
|
46
|
+
}),
|
|
47
|
+
).toThrow();
|
|
48
|
+
expect(() =>
|
|
49
|
+
verifyIdentityHandoff(token + "x", secret, {
|
|
50
|
+
audience: claims.audience,
|
|
51
|
+
issuer: claims.issuer,
|
|
52
|
+
state: claims.state,
|
|
53
|
+
now: 1020,
|
|
54
|
+
}),
|
|
55
|
+
).toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("accepts email delivery addresses only when the trusted application signs a verified assertion", () => {
|
|
59
|
+
const expected = {
|
|
60
|
+
audience: claims.audience,
|
|
61
|
+
issuer: claims.issuer,
|
|
62
|
+
state: claims.state,
|
|
63
|
+
now: 1020,
|
|
64
|
+
};
|
|
65
|
+
const verify = (value: Parameters<typeof signIdentityHandoff>[0]) =>
|
|
66
|
+
verifyIdentityHandoff(signIdentityHandoff(value, secret), secret, expected);
|
|
67
|
+
expect(
|
|
68
|
+
verify({ ...claims, email: "private@example.com" }),
|
|
69
|
+
).not.toHaveProperty("email");
|
|
70
|
+
expect(
|
|
71
|
+
verify({ ...claims, email: "private@example.com", emailVerified: true }),
|
|
72
|
+
).toMatchObject({ email: "private@example.com", emailVerified: true });
|
|
73
|
+
expect(() => verify({ ...claims, emailVerified: true })).toThrow();
|
|
74
|
+
expect(() =>
|
|
75
|
+
verify({
|
|
76
|
+
...claims,
|
|
77
|
+
email: "bad\r\naddress@example.com",
|
|
78
|
+
emailVerified: true,
|
|
79
|
+
}),
|
|
80
|
+
).toThrow();
|
|
81
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface IdentityHandoff {
|
|
4
|
+
email?: string;
|
|
5
|
+
emailVerified?: boolean;
|
|
6
|
+
subject: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
issuer: string;
|
|
9
|
+
audience: string;
|
|
10
|
+
state: string;
|
|
11
|
+
nonce: string;
|
|
12
|
+
issuedAt: number;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
}
|
|
15
|
+
export function signIdentityHandoff(
|
|
16
|
+
claims: IdentityHandoff,
|
|
17
|
+
secret: string,
|
|
18
|
+
): string {
|
|
19
|
+
requireSecret(secret);
|
|
20
|
+
const payload = Buffer.from(
|
|
21
|
+
JSON.stringify({ purpose: "feedbax-identity-v1", ...claims }),
|
|
22
|
+
).toString("base64url");
|
|
23
|
+
return `${payload}.${signature(payload, secret)}`;
|
|
24
|
+
}
|
|
25
|
+
export function verifyIdentityHandoff(
|
|
26
|
+
token: string,
|
|
27
|
+
secret: string,
|
|
28
|
+
expected: { audience: string; issuer: string; state: string; now: number },
|
|
29
|
+
): IdentityHandoff {
|
|
30
|
+
requireSecret(secret);
|
|
31
|
+
if (token.length > 8192) throw invalid();
|
|
32
|
+
const [payload, mac, extra] = token.split(".");
|
|
33
|
+
if (!payload || !mac || extra) throw invalid();
|
|
34
|
+
const received = Buffer.from(mac);
|
|
35
|
+
const wanted = Buffer.from(signature(payload, secret));
|
|
36
|
+
if (wanted.length !== received.length || !timingSafeEqual(wanted, received))
|
|
37
|
+
throw invalid();
|
|
38
|
+
let value: Record<string, unknown>;
|
|
39
|
+
try {
|
|
40
|
+
value = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
41
|
+
} catch {
|
|
42
|
+
throw invalid();
|
|
43
|
+
}
|
|
44
|
+
if (!value || value.purpose !== "feedbax-identity-v1") throw invalid();
|
|
45
|
+
for (const key of [
|
|
46
|
+
"subject",
|
|
47
|
+
"displayName",
|
|
48
|
+
"issuer",
|
|
49
|
+
"audience",
|
|
50
|
+
"state",
|
|
51
|
+
"nonce",
|
|
52
|
+
])
|
|
53
|
+
if (
|
|
54
|
+
typeof value[key] !== "string" ||
|
|
55
|
+
!value[key].trim() ||
|
|
56
|
+
value[key].length > 512
|
|
57
|
+
)
|
|
58
|
+
throw invalid();
|
|
59
|
+
if (
|
|
60
|
+
value.audience !== expected.audience ||
|
|
61
|
+
value.issuer !== expected.issuer ||
|
|
62
|
+
value.state !== expected.state ||
|
|
63
|
+
!expected.state ||
|
|
64
|
+
!Number.isSafeInteger(value.issuedAt) ||
|
|
65
|
+
!Number.isSafeInteger(value.expiresAt)
|
|
66
|
+
)
|
|
67
|
+
throw invalid();
|
|
68
|
+
const issuedAt = Number(value.issuedAt),
|
|
69
|
+
expiresAt = Number(value.expiresAt);
|
|
70
|
+
if (
|
|
71
|
+
issuedAt > expected.now + 30 ||
|
|
72
|
+
expiresAt <= expected.now ||
|
|
73
|
+
expiresAt <= issuedAt ||
|
|
74
|
+
expiresAt - issuedAt > 120
|
|
75
|
+
)
|
|
76
|
+
throw invalid();
|
|
77
|
+
if (
|
|
78
|
+
value.emailVerified !== undefined &&
|
|
79
|
+
typeof value.emailVerified !== "boolean"
|
|
80
|
+
)
|
|
81
|
+
throw invalid();
|
|
82
|
+
if (
|
|
83
|
+
value.email !== undefined &&
|
|
84
|
+
(typeof value.email !== "string" ||
|
|
85
|
+
value.email.length > 254 ||
|
|
86
|
+
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.email))
|
|
87
|
+
)
|
|
88
|
+
throw invalid();
|
|
89
|
+
if (value.emailVerified === true && !value.email) throw invalid();
|
|
90
|
+
return {
|
|
91
|
+
...(value.emailVerified === true
|
|
92
|
+
? { email: String(value.email), emailVerified: true }
|
|
93
|
+
: {}),
|
|
94
|
+
subject: String(value.subject),
|
|
95
|
+
displayName: String(value.displayName),
|
|
96
|
+
issuer: String(value.issuer),
|
|
97
|
+
audience: String(value.audience),
|
|
98
|
+
state: String(value.state),
|
|
99
|
+
nonce: String(value.nonce),
|
|
100
|
+
issuedAt,
|
|
101
|
+
expiresAt,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function signature(payload: string, secret: string) {
|
|
105
|
+
return createHmac("sha256", secret)
|
|
106
|
+
.update(`feedbax-identity-v1:${payload}`)
|
|
107
|
+
.digest("base64url");
|
|
108
|
+
}
|
|
109
|
+
function requireSecret(secret: string) {
|
|
110
|
+
if (secret.length < 32)
|
|
111
|
+
throw new Error(
|
|
112
|
+
"Identity handoff secret must contain at least 32 characters.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
function invalid() {
|
|
116
|
+
return new Error(
|
|
117
|
+
"Identity handoff is invalid or expired. Start sign-in again.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { FeedbackModule, CreateCommentInput } from "./index";
|
|
2
|
+
import type { createImageModule, ImageAttachment } from "./images";
|
|
3
|
+
|
|
4
|
+
export function createImageFeedbackModule(
|
|
5
|
+
base: FeedbackModule,
|
|
6
|
+
images: ReturnType<typeof createImageModule>,
|
|
7
|
+
writeReview?: (postId: string, images: ImageAttachment[]) => Promise<void>,
|
|
8
|
+
): FeedbackModule {
|
|
9
|
+
const create = async (
|
|
10
|
+
input: CreateCommentInput & { discussionId?: string },
|
|
11
|
+
) => {
|
|
12
|
+
const ids = input.imageIds ?? [];
|
|
13
|
+
await images.validatePending(ids);
|
|
14
|
+
const comment = input.discussionId
|
|
15
|
+
? await base.replyToCommentThread({
|
|
16
|
+
...input,
|
|
17
|
+
discussionId: input.discussionId,
|
|
18
|
+
})
|
|
19
|
+
: await base.createComment(input);
|
|
20
|
+
if (!ids.length) return comment;
|
|
21
|
+
try {
|
|
22
|
+
return {
|
|
23
|
+
...comment,
|
|
24
|
+
images: await images.attachComment({
|
|
25
|
+
ids,
|
|
26
|
+
slug: input.slug,
|
|
27
|
+
commentId: comment.id,
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
} catch (error) {
|
|
31
|
+
try {
|
|
32
|
+
await base.deleteComment({ commentId: comment.id });
|
|
33
|
+
await images.discardAttached(input.slug, comment.id);
|
|
34
|
+
} catch {
|
|
35
|
+
/* External cleanup remains best effort; never expose an unpublished attachment. */
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
async submitPost(input) {
|
|
43
|
+
const ids = input.imageIds ?? [];
|
|
44
|
+
await images.validatePending(ids);
|
|
45
|
+
const post = await base.submitPost(input);
|
|
46
|
+
if (!ids.length) return post;
|
|
47
|
+
try {
|
|
48
|
+
const attached = await images.attachPost({
|
|
49
|
+
ids,
|
|
50
|
+
id: post.id,
|
|
51
|
+
browserCapability: post.browserCapability,
|
|
52
|
+
});
|
|
53
|
+
await writeReview?.(post.id, attached);
|
|
54
|
+
return { ...post, images: attached };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
try {
|
|
57
|
+
await base.withdrawDraftPost({
|
|
58
|
+
id: post.id,
|
|
59
|
+
browserCapability: post.browserCapability,
|
|
60
|
+
});
|
|
61
|
+
await images.discardAttached(post.slug);
|
|
62
|
+
} catch {
|
|
63
|
+
/* Preserve the private record for failed external cleanup. */
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async getPublicPost(slug) {
|
|
69
|
+
const post = await base.getPublicPost(slug);
|
|
70
|
+
if (!post) return;
|
|
71
|
+
const attached = await images.forPost(slug);
|
|
72
|
+
return attached.length ? { ...post, images: attached } : post;
|
|
73
|
+
},
|
|
74
|
+
async getDraftPost(input) {
|
|
75
|
+
const post = await base.getDraftPost(input);
|
|
76
|
+
const attached = await images.forPost(post.slug);
|
|
77
|
+
return attached.length ? { ...post, images: attached } : post;
|
|
78
|
+
},
|
|
79
|
+
createComment: create,
|
|
80
|
+
replyToCommentThread: create,
|
|
81
|
+
async listCommentThreads(input) {
|
|
82
|
+
const page = await base.listCommentThreads(input);
|
|
83
|
+
const attachments = await images.forComments(
|
|
84
|
+
input.slug,
|
|
85
|
+
page.items.flatMap((thread) =>
|
|
86
|
+
thread.comments.map((comment) => comment.id),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
...page,
|
|
91
|
+
items: page.items.map((thread) => ({
|
|
92
|
+
...thread,
|
|
93
|
+
comments: thread.comments.map((comment) => {
|
|
94
|
+
const attached = attachments.get(comment.id) ?? [];
|
|
95
|
+
return attached.length ? { ...comment, images: attached } : comment;
|
|
96
|
+
}),
|
|
97
|
+
})),
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, expect, it } from "vitest";
|
|
2
|
+
import { Miniflare } from "miniflare";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { createConnectedStore } from "./connected";
|
|
6
|
+
import { createFeedbackModule } from "./index";
|
|
7
|
+
import { createImageModule } from "./images";
|
|
8
|
+
let runtime: Miniflare | undefined;
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
const previous = runtime;
|
|
11
|
+
runtime = undefined;
|
|
12
|
+
await previous?.dispose();
|
|
13
|
+
});
|
|
14
|
+
const participant = { subject: "alice", displayName: "Alice" };
|
|
15
|
+
const png = Uint8Array.from(
|
|
16
|
+
Buffer.from(
|
|
17
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aE2sAAAAASUVORK5CYII=",
|
|
18
|
+
"base64",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
async function setup() {
|
|
22
|
+
runtime = new Miniflare({
|
|
23
|
+
modules: true,
|
|
24
|
+
script: "export default {fetch(){return new Response('ok')}}",
|
|
25
|
+
compatibilityDate: "2026-07-30",
|
|
26
|
+
d1Databases: ["DB"],
|
|
27
|
+
r2Buckets: ["IMAGES"],
|
|
28
|
+
});
|
|
29
|
+
const database = await runtime.getD1Database("DB");
|
|
30
|
+
for (const file of [
|
|
31
|
+
"0001_connected.sql",
|
|
32
|
+
"0002_participation.sql",
|
|
33
|
+
"0003_images.sql",
|
|
34
|
+
])
|
|
35
|
+
await database.exec(
|
|
36
|
+
await readFile(
|
|
37
|
+
`${existsSync("apps/portal/migrations") ? "apps/portal/migrations" : "migrations"}/${file}`,
|
|
38
|
+
"utf8",
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
await createConnectedStore(database).acceptIdentity({
|
|
42
|
+
...participant,
|
|
43
|
+
nonce: "a",
|
|
44
|
+
sessionHash: "a",
|
|
45
|
+
expiresAt: Date.now() + 60000,
|
|
46
|
+
});
|
|
47
|
+
const feedback = createFeedbackModule();
|
|
48
|
+
return {
|
|
49
|
+
database,
|
|
50
|
+
bucket: await runtime.getR2Bucket("IMAGES"),
|
|
51
|
+
feedback,
|
|
52
|
+
reviewSecret: "s".repeat(32),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
it("keeps uploads private, binds images to an authorized draft, and never serves an unpublished image publicly", async () => {
|
|
56
|
+
const options = await setup();
|
|
57
|
+
const { feedback } = options;
|
|
58
|
+
const owner = createImageModule({ ...options, participant });
|
|
59
|
+
const uploaded = await owner.upload(png, "image/png");
|
|
60
|
+
expect((await owner.read(uploaded.id))?.contentType).toBe("image/png");
|
|
61
|
+
expect(await createImageModule(options).read(uploaded.id)).toBeUndefined();
|
|
62
|
+
await expect(
|
|
63
|
+
owner.upload(new TextEncoder().encode("<svg>bad</svg>"), "image/svg+xml"),
|
|
64
|
+
).rejects.toThrow("JPEG, PNG, or WebP");
|
|
65
|
+
await expect(
|
|
66
|
+
owner.upload(new Uint8Array(5 * 1024 * 1024 + 1), "image/png"),
|
|
67
|
+
).rejects.toThrow("5 MiB");
|
|
68
|
+
const draft = await feedback.submitPost({
|
|
69
|
+
title: "Image Post",
|
|
70
|
+
description: "Description",
|
|
71
|
+
type: "Feature Request",
|
|
72
|
+
});
|
|
73
|
+
await owner.attachPost({
|
|
74
|
+
ids: [uploaded.id],
|
|
75
|
+
id: draft.id,
|
|
76
|
+
browserCapability: draft.browserCapability,
|
|
77
|
+
});
|
|
78
|
+
expect(await createImageModule(options).read(uploaded.id)).toBeUndefined();
|
|
79
|
+
await expect(
|
|
80
|
+
createImageModule({
|
|
81
|
+
...options,
|
|
82
|
+
participant: { subject: "bob", displayName: "Bob" },
|
|
83
|
+
}).attachPost({
|
|
84
|
+
ids: [uploaded.id],
|
|
85
|
+
id: draft.id,
|
|
86
|
+
browserCapability: draft.browserCapability,
|
|
87
|
+
}),
|
|
88
|
+
).rejects.toThrow("unavailable");
|
|
89
|
+
const publicFeedback = createFeedbackModule({
|
|
90
|
+
initialItems: [{ ...draft, published: true, status: "Planned" }],
|
|
91
|
+
});
|
|
92
|
+
const publicImages = createImageModule({
|
|
93
|
+
...options,
|
|
94
|
+
feedback: publicFeedback,
|
|
95
|
+
});
|
|
96
|
+
expect(
|
|
97
|
+
(await publicImages.forPost(draft.slug)).map((image) => image.id),
|
|
98
|
+
).toEqual([uploaded.id]);
|
|
99
|
+
expect(await publicImages.read(uploaded.id)).toBeDefined();
|
|
100
|
+
const review = await owner.reviewUrl(
|
|
101
|
+
uploaded.id,
|
|
102
|
+
"https://feedback.example.com",
|
|
103
|
+
);
|
|
104
|
+
expect(
|
|
105
|
+
await createImageModule(options).read(
|
|
106
|
+
uploaded.id,
|
|
107
|
+
new URL(review).searchParams.get("review")!,
|
|
108
|
+
),
|
|
109
|
+
).toBeDefined();
|
|
110
|
+
expect(
|
|
111
|
+
await createImageModule(options).read(uploaded.id, "forged"),
|
|
112
|
+
).toBeUndefined();
|
|
113
|
+
await feedback.withdrawDraftPost({
|
|
114
|
+
id: draft.id,
|
|
115
|
+
browserCapability: draft.browserCapability,
|
|
116
|
+
});
|
|
117
|
+
expect(await owner.read(uploaded.id)).toBeUndefined();
|
|
118
|
+
expect(
|
|
119
|
+
await createImageModule(options).read(
|
|
120
|
+
uploaded.id,
|
|
121
|
+
new URL(review).searchParams.get("review")!,
|
|
122
|
+
),
|
|
123
|
+
).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rejects attachment overflow without partial binding and cleans up a rolled-back image Post", async () => {
|
|
127
|
+
const options = await setup();
|
|
128
|
+
const owner = createImageModule({ ...options, participant });
|
|
129
|
+
const ids = [];
|
|
130
|
+
for (let i = 0; i < 5; i++)
|
|
131
|
+
ids.push((await owner.upload(png, "image/png")).id);
|
|
132
|
+
const draft = await options.feedback.submitPost({
|
|
133
|
+
title: "Images",
|
|
134
|
+
description: "Description",
|
|
135
|
+
type: "Feature Request",
|
|
136
|
+
});
|
|
137
|
+
await owner.attachPost({
|
|
138
|
+
ids: ids.slice(0, 3),
|
|
139
|
+
id: draft.id,
|
|
140
|
+
browserCapability: draft.browserCapability,
|
|
141
|
+
});
|
|
142
|
+
await expect(
|
|
143
|
+
owner.attachPost({
|
|
144
|
+
ids: ids.slice(3),
|
|
145
|
+
id: draft.id,
|
|
146
|
+
browserCapability: draft.browserCapability,
|
|
147
|
+
}),
|
|
148
|
+
).rejects.toThrow("unavailable");
|
|
149
|
+
expect(await owner.forPost(draft.slug)).toHaveLength(3);
|
|
150
|
+
const { createImageFeedbackModule } = await import("./image-feedback");
|
|
151
|
+
const { createNotionImageReviewWriter } =
|
|
152
|
+
await import("./notion-image-review");
|
|
153
|
+
const writer = createNotionImageReviewWriter({
|
|
154
|
+
token: "private-notion-key",
|
|
155
|
+
reviewUrl: (id) => owner.reviewUrl(id, "https://feedback.example.com"),
|
|
156
|
+
request: async () => new Response("unavailable", { status: 500 }),
|
|
157
|
+
});
|
|
158
|
+
const feedback = createImageFeedbackModule(options.feedback, owner, writer);
|
|
159
|
+
await expect(
|
|
160
|
+
feedback.submitPost({
|
|
161
|
+
title: "Failed review",
|
|
162
|
+
description: "Description",
|
|
163
|
+
type: "Feature Request",
|
|
164
|
+
imageIds: [ids[3]!],
|
|
165
|
+
}),
|
|
166
|
+
).rejects.toThrow("review links");
|
|
167
|
+
expect(await owner.read(ids[3]!)).toBeUndefined();
|
|
168
|
+
expect(await owner.cleanup()).toBe(1);
|
|
169
|
+
expect(await owner.read(ids[3]!)).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("removes public and creator access to images when their Comment is deleted", async () => {
|
|
173
|
+
const options = await setup();
|
|
174
|
+
const { createConnectedFeedbackModule } =
|
|
175
|
+
await import("./connected-feedback");
|
|
176
|
+
const { createImageFeedbackModule } = await import("./image-feedback");
|
|
177
|
+
const base = createFeedbackModule({
|
|
178
|
+
initialItems: [
|
|
179
|
+
{
|
|
180
|
+
id: "p",
|
|
181
|
+
slug: "public",
|
|
182
|
+
title: "Public",
|
|
183
|
+
description: "Description",
|
|
184
|
+
type: "Feature Request",
|
|
185
|
+
status: "Planned",
|
|
186
|
+
published: true,
|
|
187
|
+
createdAt: new Date(1000),
|
|
188
|
+
updatedAt: new Date(1000),
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
const connected = createConnectedFeedbackModule(
|
|
193
|
+
base,
|
|
194
|
+
createConnectedStore(options.database),
|
|
195
|
+
participant,
|
|
196
|
+
);
|
|
197
|
+
const owner = createImageModule({
|
|
198
|
+
...options,
|
|
199
|
+
feedback: connected,
|
|
200
|
+
participant,
|
|
201
|
+
});
|
|
202
|
+
const image = await owner.upload(png, "image/png");
|
|
203
|
+
const feedback = createImageFeedbackModule(connected, owner);
|
|
204
|
+
const comment = await feedback.createComment({
|
|
205
|
+
slug: "public",
|
|
206
|
+
body: "Screenshot attached",
|
|
207
|
+
displayName: "Spoofed",
|
|
208
|
+
imageIds: [image.id],
|
|
209
|
+
});
|
|
210
|
+
expect(comment.images?.[0]?.id).toBe(image.id);
|
|
211
|
+
expect(
|
|
212
|
+
(await feedback.listCommentThreads({ slug: "public" })).items[0]
|
|
213
|
+
?.comments[0]?.images?.[0]?.id,
|
|
214
|
+
).toBe(image.id);
|
|
215
|
+
expect(
|
|
216
|
+
await createImageModule({ ...options, feedback: connected }).read(image.id),
|
|
217
|
+
).toBeDefined();
|
|
218
|
+
await feedback.deleteComment({ commentId: comment.id });
|
|
219
|
+
expect(await owner.read(image.id)).toBeUndefined();
|
|
220
|
+
expect(
|
|
221
|
+
await createImageModule({ ...options, feedback: connected }).read(image.id),
|
|
222
|
+
).toBeUndefined();
|
|
223
|
+
});
|