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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
2
|
+
import { and, count, eq, gt, inArray } from "drizzle-orm";
|
|
3
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
4
|
+
import {
|
|
5
|
+
integer,
|
|
6
|
+
primaryKey,
|
|
7
|
+
sqliteTable,
|
|
8
|
+
text,
|
|
9
|
+
} from "drizzle-orm/sqlite-core";
|
|
10
|
+
|
|
11
|
+
export type ConnectedDatabase = D1Database;
|
|
12
|
+
export interface VerifiedParticipant {
|
|
13
|
+
subject: string;
|
|
14
|
+
displayName: string;
|
|
15
|
+
}
|
|
16
|
+
const participants = sqliteTable("participants", {
|
|
17
|
+
subject: text("subject").primaryKey(),
|
|
18
|
+
displayName: text("display_name").notNull(),
|
|
19
|
+
});
|
|
20
|
+
const contacts = sqliteTable("participant_contacts", {
|
|
21
|
+
subject: text("subject").primaryKey(),
|
|
22
|
+
email: text("email"),
|
|
23
|
+
emailEnabled: integer("email_enabled").notNull().default(0),
|
|
24
|
+
});
|
|
25
|
+
const nonces = sqliteTable("identity_nonces", {
|
|
26
|
+
nonce: text("nonce").primaryKey(),
|
|
27
|
+
expiresAt: integer("expires_at").notNull(),
|
|
28
|
+
});
|
|
29
|
+
const sessions = sqliteTable("participant_sessions", {
|
|
30
|
+
tokenHash: text("token_hash").primaryKey(),
|
|
31
|
+
subject: text("subject")
|
|
32
|
+
.notNull()
|
|
33
|
+
.references(() => participants.subject),
|
|
34
|
+
expiresAt: integer("expires_at").notNull(),
|
|
35
|
+
});
|
|
36
|
+
const votes = sqliteTable(
|
|
37
|
+
"participant_votes",
|
|
38
|
+
{
|
|
39
|
+
postSlug: text("post_slug").notNull(),
|
|
40
|
+
subject: text("subject")
|
|
41
|
+
.notNull()
|
|
42
|
+
.references(() => participants.subject),
|
|
43
|
+
},
|
|
44
|
+
(t) => [primaryKey({ columns: [t.postSlug, t.subject] })],
|
|
45
|
+
);
|
|
46
|
+
const attribution = sqliteTable("comment_attribution", {
|
|
47
|
+
commentId: text("comment_id").primaryKey(),
|
|
48
|
+
subject: text("subject")
|
|
49
|
+
.notNull()
|
|
50
|
+
.references(() => participants.subject),
|
|
51
|
+
displayName: text("display_name").notNull(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function createConnectedStore(binding: D1Database) {
|
|
55
|
+
// Without read-replication sessions, D1 routes these queries to its primary.
|
|
56
|
+
const db = drizzle(binding);
|
|
57
|
+
return {
|
|
58
|
+
async acceptIdentity(
|
|
59
|
+
input: VerifiedParticipant & {
|
|
60
|
+
email?: string;
|
|
61
|
+
nonce: string;
|
|
62
|
+
challenge?: string;
|
|
63
|
+
sessionHash: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
},
|
|
66
|
+
) {
|
|
67
|
+
// A nonce collision rolls the entire D1 batch back, including session creation.
|
|
68
|
+
await db.batch([
|
|
69
|
+
db.insert(nonces).values([
|
|
70
|
+
{ nonce: `token:${input.nonce}`, expiresAt: input.expiresAt },
|
|
71
|
+
...(input.challenge
|
|
72
|
+
? [
|
|
73
|
+
{
|
|
74
|
+
nonce: `browser:${input.challenge}`,
|
|
75
|
+
expiresAt: input.expiresAt,
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
: []),
|
|
79
|
+
]),
|
|
80
|
+
db
|
|
81
|
+
.insert(participants)
|
|
82
|
+
.values({ subject: input.subject, displayName: input.displayName })
|
|
83
|
+
.onConflictDoUpdate({
|
|
84
|
+
target: participants.subject,
|
|
85
|
+
set: { displayName: input.displayName },
|
|
86
|
+
}),
|
|
87
|
+
db
|
|
88
|
+
.insert(contacts)
|
|
89
|
+
.values({ subject: input.subject, email: input.email ?? null })
|
|
90
|
+
.onConflictDoUpdate({
|
|
91
|
+
target: contacts.subject,
|
|
92
|
+
set: { email: input.email ?? null },
|
|
93
|
+
}),
|
|
94
|
+
db.insert(sessions).values({
|
|
95
|
+
tokenHash: input.sessionHash,
|
|
96
|
+
subject: input.subject,
|
|
97
|
+
expiresAt: input.expiresAt,
|
|
98
|
+
}),
|
|
99
|
+
]);
|
|
100
|
+
},
|
|
101
|
+
async session(
|
|
102
|
+
tokenHash: string,
|
|
103
|
+
now = Date.now(),
|
|
104
|
+
): Promise<VerifiedParticipant | undefined> {
|
|
105
|
+
return db
|
|
106
|
+
.select({
|
|
107
|
+
subject: participants.subject,
|
|
108
|
+
displayName: participants.displayName,
|
|
109
|
+
})
|
|
110
|
+
.from(sessions)
|
|
111
|
+
.innerJoin(participants, eq(participants.subject, sessions.subject))
|
|
112
|
+
.where(
|
|
113
|
+
and(eq(sessions.tokenHash, tokenHash), gt(sessions.expiresAt, now)),
|
|
114
|
+
)
|
|
115
|
+
.get();
|
|
116
|
+
},
|
|
117
|
+
async logout(tokenHash: string) {
|
|
118
|
+
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
|
|
119
|
+
},
|
|
120
|
+
async setVote(postSlug: string, subject: string, voted: boolean) {
|
|
121
|
+
if (voted)
|
|
122
|
+
await db
|
|
123
|
+
.insert(votes)
|
|
124
|
+
.values({ postSlug, subject })
|
|
125
|
+
.onConflictDoNothing();
|
|
126
|
+
else
|
|
127
|
+
await db
|
|
128
|
+
.delete(votes)
|
|
129
|
+
.where(and(eq(votes.postSlug, postSlug), eq(votes.subject, subject)));
|
|
130
|
+
},
|
|
131
|
+
async voteState(postSlug: string, subject?: string) {
|
|
132
|
+
const [total, own] = await db.batch([
|
|
133
|
+
db
|
|
134
|
+
.select({ value: count() })
|
|
135
|
+
.from(votes)
|
|
136
|
+
.where(eq(votes.postSlug, postSlug)),
|
|
137
|
+
db
|
|
138
|
+
.select({ subject: votes.subject })
|
|
139
|
+
.from(votes)
|
|
140
|
+
.where(
|
|
141
|
+
and(eq(votes.postSlug, postSlug), eq(votes.subject, subject ?? "")),
|
|
142
|
+
)
|
|
143
|
+
.limit(1),
|
|
144
|
+
]);
|
|
145
|
+
return { voteCount: total[0]?.value ?? 0, voted: own.length > 0 };
|
|
146
|
+
},
|
|
147
|
+
async voteCounts(slugs: string[]) {
|
|
148
|
+
const result = new Map<string, number>();
|
|
149
|
+
for (let offset = 0; offset < slugs.length; offset += 80) {
|
|
150
|
+
const rows = await db
|
|
151
|
+
.select({ slug: votes.postSlug, total: count() })
|
|
152
|
+
.from(votes)
|
|
153
|
+
.where(inArray(votes.postSlug, slugs.slice(offset, offset + 80)))
|
|
154
|
+
.groupBy(votes.postSlug);
|
|
155
|
+
for (const row of rows) result.set(row.slug, row.total);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
},
|
|
159
|
+
async commentSubject(commentId: string) {
|
|
160
|
+
return (
|
|
161
|
+
await db
|
|
162
|
+
.select({ subject: attribution.subject })
|
|
163
|
+
.from(attribution)
|
|
164
|
+
.where(eq(attribution.commentId, commentId))
|
|
165
|
+
.get()
|
|
166
|
+
)?.subject;
|
|
167
|
+
},
|
|
168
|
+
async attributeComment(
|
|
169
|
+
commentId: string,
|
|
170
|
+
participant: VerifiedParticipant,
|
|
171
|
+
) {
|
|
172
|
+
await db.insert(attribution).values({
|
|
173
|
+
commentId,
|
|
174
|
+
subject: participant.subject,
|
|
175
|
+
displayName: participant.displayName,
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
async commentAuthors(ids: string[]) {
|
|
179
|
+
const result = new Map<
|
|
180
|
+
string,
|
|
181
|
+
{ kind: "participant"; displayName: string; verified: true }
|
|
182
|
+
>();
|
|
183
|
+
for (let offset = 0; offset < ids.length; offset += 80) {
|
|
184
|
+
const rows = await db
|
|
185
|
+
.select()
|
|
186
|
+
.from(attribution)
|
|
187
|
+
.where(
|
|
188
|
+
inArray(attribution.commentId, ids.slice(offset, offset + 80)),
|
|
189
|
+
);
|
|
190
|
+
for (const row of rows)
|
|
191
|
+
result.set(row.commentId, {
|
|
192
|
+
kind: "participant",
|
|
193
|
+
displayName: row.displayName,
|
|
194
|
+
verified: true,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
},
|
|
199
|
+
async commentAuthor(commentId: string) {
|
|
200
|
+
const row = await db
|
|
201
|
+
.select({ displayName: attribution.displayName })
|
|
202
|
+
.from(attribution)
|
|
203
|
+
.where(eq(attribution.commentId, commentId))
|
|
204
|
+
.get();
|
|
205
|
+
return row
|
|
206
|
+
? {
|
|
207
|
+
kind: "participant" as const,
|
|
208
|
+
displayName: row.displayName,
|
|
209
|
+
verified: true as const,
|
|
210
|
+
}
|
|
211
|
+
: undefined;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export type ConnectedStore = ReturnType<typeof createConnectedStore>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import { createFetchMock, Miniflare } from "miniflare";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
|
|
6
|
+
it.each([200, 302])(
|
|
7
|
+
"sends email inside the actual Workers fetch runtime and rejects provider redirects (%s)",
|
|
8
|
+
async (status) => {
|
|
9
|
+
const source = await readFile(
|
|
10
|
+
new URL("./email-delivery.ts", import.meta.url),
|
|
11
|
+
"utf8",
|
|
12
|
+
);
|
|
13
|
+
const sender = ts.transpileModule(source, {
|
|
14
|
+
compilerOptions: {
|
|
15
|
+
target: ts.ScriptTarget.ES2022,
|
|
16
|
+
module: ts.ModuleKind.ESNext,
|
|
17
|
+
},
|
|
18
|
+
}).outputText;
|
|
19
|
+
const network = createFetchMock();
|
|
20
|
+
network.disableNetConnect();
|
|
21
|
+
network
|
|
22
|
+
.get("https://api.resend.com")
|
|
23
|
+
.intercept({ path: "/emails", method: "POST" })
|
|
24
|
+
.reply(status, "{}", {
|
|
25
|
+
headers:
|
|
26
|
+
status === 302 ? { location: "https://unexpected.example/leak" } : {},
|
|
27
|
+
});
|
|
28
|
+
let redirected = 0;
|
|
29
|
+
network
|
|
30
|
+
.get("https://unexpected.example")
|
|
31
|
+
.intercept({ path: "/leak", method: "GET" })
|
|
32
|
+
.reply(() => {
|
|
33
|
+
redirected++;
|
|
34
|
+
return { statusCode: 200, data: "{}" };
|
|
35
|
+
});
|
|
36
|
+
const runtime = new Miniflare({
|
|
37
|
+
compatibilityDate: "2026-07-30",
|
|
38
|
+
modulesRoot: "/email-worker",
|
|
39
|
+
fetchMock: network,
|
|
40
|
+
modules: [
|
|
41
|
+
{
|
|
42
|
+
type: "ESModule",
|
|
43
|
+
path: "/email-worker/entry.mjs",
|
|
44
|
+
contents: `import {createEmailSender} from './sender.mjs'; export default {async fetch(){ try {await createEmailSender({provider:'resend',token:'test-provider-key'})({from:'updates@example.com',to:['recipient@example.com'],subject:'Update',text:'A Post changed.',headers:{}},'test-stable-key');return new Response('accepted');}catch(error){return new Response(error.message,{status:502});}}};`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "ESModule",
|
|
48
|
+
path: "/email-worker/sender.mjs",
|
|
49
|
+
contents: sender,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
const response = await runtime.dispatchFetch(
|
|
55
|
+
"https://worker.example/send",
|
|
56
|
+
);
|
|
57
|
+
expect(await response.text()).not.toContain("test-provider-key");
|
|
58
|
+
expect(response.status).toBe(status === 200 ? 200 : 502);
|
|
59
|
+
expect(redirected).toBe(0);
|
|
60
|
+
} finally {
|
|
61
|
+
await runtime.dispose();
|
|
62
|
+
await network.close();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface NotificationEmail {
|
|
2
|
+
from: string;
|
|
3
|
+
to: string[];
|
|
4
|
+
subject: string;
|
|
5
|
+
text: string;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export type EmailSender = (
|
|
9
|
+
message: NotificationEmail,
|
|
10
|
+
deliveryKey: string,
|
|
11
|
+
) => Promise<void>;
|
|
12
|
+
export type CloudflareEmailBinding = {
|
|
13
|
+
send(message: NotificationEmail): Promise<unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The custom endpoint accepts the same JSON message as Resend and must honor Idempotency-Key. */
|
|
17
|
+
export function createEmailSender(
|
|
18
|
+
options:
|
|
19
|
+
| { provider: "cloudflare"; binding: CloudflareEmailBinding }
|
|
20
|
+
| { provider: "resend"; token: string; request?: typeof fetch }
|
|
21
|
+
| {
|
|
22
|
+
provider: "custom";
|
|
23
|
+
url: string;
|
|
24
|
+
token: string;
|
|
25
|
+
request?: typeof fetch;
|
|
26
|
+
},
|
|
27
|
+
): EmailSender {
|
|
28
|
+
if (options.provider === "cloudflare")
|
|
29
|
+
return async (message) => {
|
|
30
|
+
await options.binding.send(message);
|
|
31
|
+
};
|
|
32
|
+
const url =
|
|
33
|
+
options.provider === "resend"
|
|
34
|
+
? "https://api.resend.com/emails"
|
|
35
|
+
: options.url;
|
|
36
|
+
const parsed = new URL(url);
|
|
37
|
+
if (
|
|
38
|
+
parsed.protocol !== "https:" ||
|
|
39
|
+
parsed.username ||
|
|
40
|
+
parsed.password ||
|
|
41
|
+
parsed.hash
|
|
42
|
+
)
|
|
43
|
+
throw new Error(
|
|
44
|
+
"Email sender requires an HTTPS endpoint without credentials or fragments.",
|
|
45
|
+
);
|
|
46
|
+
if (!options.token.trim()) throw new Error("Email sender secret is missing.");
|
|
47
|
+
return async (message, deliveryKey) => {
|
|
48
|
+
const response = await (options.request ?? fetch)(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
// Workers supports manual/follow; manual also prevents forwarding credentials.
|
|
51
|
+
redirect: "manual",
|
|
52
|
+
signal: AbortSignal.timeout(10000),
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${options.token}`,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
"Idempotency-Key": deliveryKey,
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(message),
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
await response.body?.cancel();
|
|
62
|
+
throw new Error("Email provider did not accept this delivery.");
|
|
63
|
+
}
|
|
64
|
+
await response.body?.cancel();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -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
|
+
}
|