create-feedbax 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -5
- package/dist/cli.js +502 -209
- package/dist/index.js +251 -68
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +1 -1
- package/template/CONNECTED.md +16 -2
- package/template/README.md +52 -13
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +34 -33
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +3 -0
- package/template/src/comment-creation.test.tsx +53 -6
- package/template/src/components/connected-identity-provider.tsx +44 -17
- package/template/src/components/follow-post-button.tsx +2 -13
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +7 -0
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +7 -3
- package/template/src/connected-identity.ts +6 -1
- package/template/src/connected-runtime.test.ts +5 -2
- package/template/src/feedback-cache-invalidation.ts +2 -0
- package/template/src/feedback-runtime.ts +61 -18
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +45 -1
- package/template/src/feedbax/config/index.ts +112 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +41 -12
- package/template/src/feedbax/feedback/connected.ts +15 -5
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/image-feedback.ts +23 -10
- package/template/src/feedbax/feedback/images.ts +26 -0
- package/template/src/feedbax/feedback/index.ts +172 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/index.css +6 -0
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/participation-runtime.ts +3 -3
- package/template/src/portal-comments.ts +11 -0
- package/template/src/portal-feedback-form.tsx +11 -3
- package/template/src/portal-feedback-submission.ts +9 -0
- package/template/src/portal-server-entry.ts +9 -0
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail.tsx +332 -256
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/routeTree.gen.ts +21 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/server.ts +46 -1
- package/template/src/submission-intent.ts +26 -0
- package/template/vite.config.ts +2 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { afterEach, expect, it } from "vitest";
|
|
2
|
+
import { Miniflare } from "miniflare";
|
|
3
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import type { FeedbaxConfig } from "@feedbax/config";
|
|
6
|
+
import {
|
|
7
|
+
createGitHubFeedbackModule,
|
|
8
|
+
createGitHubServiceAccess,
|
|
9
|
+
signIntegrationRequest,
|
|
10
|
+
} from "@feedbax/feedback";
|
|
11
|
+
import { createPortalIntegration } from "./integration-runtime";
|
|
12
|
+
|
|
13
|
+
const configuration: FeedbaxConfig = {
|
|
14
|
+
provider: "github",
|
|
15
|
+
product: {
|
|
16
|
+
name: "Acme",
|
|
17
|
+
description: "Feedback",
|
|
18
|
+
logo: "/logo.svg",
|
|
19
|
+
siteUrl: "https://portal.account.workers.dev",
|
|
20
|
+
},
|
|
21
|
+
theme: { preset: "lyra-neutral" },
|
|
22
|
+
github: {
|
|
23
|
+
installationId: "portal-acme",
|
|
24
|
+
serviceUrl: "https://service.example",
|
|
25
|
+
repository: { id: 123, owner: "acme", name: "feedback" },
|
|
26
|
+
botUserId: 456,
|
|
27
|
+
},
|
|
28
|
+
features: { voting: true, comments: true, changelog: false },
|
|
29
|
+
submissions: {
|
|
30
|
+
moderation: "required",
|
|
31
|
+
maxTitleLength: 160,
|
|
32
|
+
maxDescriptionLength: 5000,
|
|
33
|
+
},
|
|
34
|
+
turnstile: { enabled: false },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let runtime: Miniflare | undefined;
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
await runtime?.dispose();
|
|
40
|
+
});
|
|
41
|
+
it("accepts one authenticated pairing/delivery, rejects replays and Participant requests, and revokes provider access", async () => {
|
|
42
|
+
runtime = new Miniflare({
|
|
43
|
+
modules: true,
|
|
44
|
+
script: "export default {}",
|
|
45
|
+
d1Databases: ["DB"],
|
|
46
|
+
compatibilityDate: "2026-07-30",
|
|
47
|
+
});
|
|
48
|
+
const database = await runtime.getD1Database("DB");
|
|
49
|
+
const directory = resolve(import.meta.dirname, "../migrations");
|
|
50
|
+
for (const file of (await readdir(directory)).sort())
|
|
51
|
+
await database.exec(
|
|
52
|
+
(await readFile(resolve(directory, file), "utf8"))
|
|
53
|
+
.replace(/--[^\n]*/g, "")
|
|
54
|
+
.replace(/\s+/g, " "),
|
|
55
|
+
);
|
|
56
|
+
const config = configuration;
|
|
57
|
+
const service = createPortalIntegration({
|
|
58
|
+
config,
|
|
59
|
+
database,
|
|
60
|
+
pairingSecret: "p".repeat(40),
|
|
61
|
+
});
|
|
62
|
+
const pair = signIntegrationRequest({
|
|
63
|
+
url: "https://portal.account.workers.dev/internal/integration/pair",
|
|
64
|
+
installationId: "portal-acme",
|
|
65
|
+
secret: "p".repeat(40),
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
challenge: "c".repeat(43),
|
|
68
|
+
repositoryId: 123,
|
|
69
|
+
botUserId: 456,
|
|
70
|
+
credentials: {
|
|
71
|
+
portalSecret: "a".repeat(43),
|
|
72
|
+
deliverySecret: "b".repeat(43),
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
const replayPair = pair.clone();
|
|
77
|
+
expect((await service.handle(pair))?.status).toBe(200);
|
|
78
|
+
expect((await service.handle(replayPair))?.status).toBe(401);
|
|
79
|
+
const url =
|
|
80
|
+
"https://portal.account.workers.dev/internal/integration/delivery";
|
|
81
|
+
expect(
|
|
82
|
+
(
|
|
83
|
+
await service.handle(
|
|
84
|
+
new Request(url, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { Cookie: "participant=anything" },
|
|
87
|
+
body: JSON.stringify({ kind: "revoke" }),
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
)?.status,
|
|
91
|
+
).toBe(401);
|
|
92
|
+
const signed = signIntegrationRequest({
|
|
93
|
+
url,
|
|
94
|
+
installationId: "portal-acme",
|
|
95
|
+
secret: "b".repeat(43),
|
|
96
|
+
body: JSON.stringify({ eventId: "event-one", kind: "reconcile" }),
|
|
97
|
+
});
|
|
98
|
+
const replay = signed.clone();
|
|
99
|
+
expect((await service.handle(signed))?.status).toBe(202);
|
|
100
|
+
expect((await service.handle(replay))?.status).toBe(401);
|
|
101
|
+
expect(await service.portalSecret()).toBe("a".repeat(43));
|
|
102
|
+
if (config.provider !== "github") throw Error("GitHub fixture required");
|
|
103
|
+
const access = createGitHubServiceAccess({
|
|
104
|
+
config: config.github,
|
|
105
|
+
getSecret: service.portalSecret,
|
|
106
|
+
request: async () =>
|
|
107
|
+
Response.json({
|
|
108
|
+
token: "scoped-token",
|
|
109
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
110
|
+
repositoryId: 123,
|
|
111
|
+
botUserId: 456,
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
expect(await access()).toBe("scoped-token");
|
|
115
|
+
expect(
|
|
116
|
+
(
|
|
117
|
+
await service.handle(
|
|
118
|
+
signIntegrationRequest({
|
|
119
|
+
url,
|
|
120
|
+
installationId: "portal-acme",
|
|
121
|
+
secret: "b".repeat(43),
|
|
122
|
+
body: JSON.stringify({ eventId: "event-two", kind: "revoke" }),
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
)?.status,
|
|
126
|
+
).toBe(202);
|
|
127
|
+
await expect(service.portalSecret()).rejects.toThrow("revoked");
|
|
128
|
+
await expect(access()).rejects.toThrow("revoked");
|
|
129
|
+
expect(
|
|
130
|
+
(
|
|
131
|
+
await service.handle(
|
|
132
|
+
signIntegrationRequest({
|
|
133
|
+
url,
|
|
134
|
+
installationId: "portal-acme",
|
|
135
|
+
secret: "b".repeat(43),
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
eventId: "after-revocation",
|
|
138
|
+
kind: "reconcile",
|
|
139
|
+
}),
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
)?.status,
|
|
143
|
+
).toBe(401);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("backs off provider failures and fences concurrent portal reconciliation", async () => {
|
|
147
|
+
runtime = new Miniflare({
|
|
148
|
+
modules: true,
|
|
149
|
+
script: "export default {}",
|
|
150
|
+
d1Databases: ["DB"],
|
|
151
|
+
compatibilityDate: "2026-07-30",
|
|
152
|
+
});
|
|
153
|
+
const database = await runtime.getD1Database("DB");
|
|
154
|
+
const directory = resolve(import.meta.dirname, "../migrations");
|
|
155
|
+
for (const file of (await readdir(directory)).sort())
|
|
156
|
+
await database.exec(
|
|
157
|
+
(await readFile(resolve(directory, file), "utf8"))
|
|
158
|
+
.replace(/--[^\n]*/g, "")
|
|
159
|
+
.replace(/\s+/g, " "),
|
|
160
|
+
);
|
|
161
|
+
const github =
|
|
162
|
+
configuration.provider === "github" ? configuration.github : undefined;
|
|
163
|
+
if (!github) throw new Error("GitHub fixture required");
|
|
164
|
+
let time = Date.now();
|
|
165
|
+
let unavailable = false;
|
|
166
|
+
let reads = 0;
|
|
167
|
+
let release: (() => void) | undefined;
|
|
168
|
+
let observed: (() => void) | undefined;
|
|
169
|
+
let hold = false;
|
|
170
|
+
const started = new Promise<void>((resolve) => {
|
|
171
|
+
observed = resolve;
|
|
172
|
+
});
|
|
173
|
+
const issue = {
|
|
174
|
+
id: 1000,
|
|
175
|
+
number: 1,
|
|
176
|
+
user: { id: 456 },
|
|
177
|
+
labels: [],
|
|
178
|
+
body: "",
|
|
179
|
+
title: "",
|
|
180
|
+
};
|
|
181
|
+
const feedback = createGitHubFeedbackModule({
|
|
182
|
+
...github,
|
|
183
|
+
database,
|
|
184
|
+
getToken: async () => "token",
|
|
185
|
+
request: async (input, init) => {
|
|
186
|
+
const url = String(input);
|
|
187
|
+
if (url.endsWith("/acme/feedback")) return Response.json({ id: 123 });
|
|
188
|
+
if (init?.method === "POST") {
|
|
189
|
+
Object.assign(issue, JSON.parse(String(init.body)));
|
|
190
|
+
return Response.json(issue);
|
|
191
|
+
}
|
|
192
|
+
reads++;
|
|
193
|
+
if (unavailable) return new Response(null, { status: 503 });
|
|
194
|
+
if (hold) {
|
|
195
|
+
observed?.();
|
|
196
|
+
await new Promise<void>((resolve) => {
|
|
197
|
+
release = resolve;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return Response.json(issue);
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
await feedback.submitPost({
|
|
204
|
+
title: "A draft",
|
|
205
|
+
description: "Details",
|
|
206
|
+
type: "General Feedback",
|
|
207
|
+
submitter: { name: "Alice", email: "alice@example.com" },
|
|
208
|
+
});
|
|
209
|
+
await database
|
|
210
|
+
.prepare(
|
|
211
|
+
"INSERT INTO integration_credentials(installation_id,portal_secret,delivery_secret,paired_at) VALUES(?,?,?,?)",
|
|
212
|
+
)
|
|
213
|
+
.bind(github.installationId, "a".repeat(43), "b".repeat(43), time)
|
|
214
|
+
.run();
|
|
215
|
+
const integration = createPortalIntegration({
|
|
216
|
+
config: configuration,
|
|
217
|
+
database,
|
|
218
|
+
now: () => time,
|
|
219
|
+
});
|
|
220
|
+
expect(
|
|
221
|
+
(
|
|
222
|
+
await integration.handle(
|
|
223
|
+
signIntegrationRequest({
|
|
224
|
+
url: `${configuration.product.siteUrl}/internal/integration/delivery`,
|
|
225
|
+
installationId: github.installationId,
|
|
226
|
+
secret: "b".repeat(43),
|
|
227
|
+
body: JSON.stringify({ eventId: "recovery", kind: "reconcile" }),
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
)?.status,
|
|
231
|
+
).toBe(202);
|
|
232
|
+
unavailable = true;
|
|
233
|
+
await integration.reconcile(feedback);
|
|
234
|
+
expect(reads).toBe(3);
|
|
235
|
+
await integration.reconcile(feedback);
|
|
236
|
+
expect(reads).toBe(3);
|
|
237
|
+
time += 70_000;
|
|
238
|
+
unavailable = false;
|
|
239
|
+
hold = true;
|
|
240
|
+
const active = integration.reconcile(feedback);
|
|
241
|
+
await started;
|
|
242
|
+
await integration.reconcile(feedback);
|
|
243
|
+
expect(reads).toBe(4);
|
|
244
|
+
release?.();
|
|
245
|
+
await active;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("advances the existing cursor when a fresh polling receipt arrives on every pass", async () => {
|
|
249
|
+
runtime = new Miniflare({
|
|
250
|
+
modules: true,
|
|
251
|
+
script: "export default {}",
|
|
252
|
+
d1Databases: ["DB"],
|
|
253
|
+
compatibilityDate: "2026-07-30",
|
|
254
|
+
});
|
|
255
|
+
const database = await runtime.getD1Database("DB");
|
|
256
|
+
const directory = resolve(import.meta.dirname, "../migrations");
|
|
257
|
+
for (const file of (await readdir(directory)).sort())
|
|
258
|
+
await database.exec(
|
|
259
|
+
(await readFile(resolve(directory, file), "utf8"))
|
|
260
|
+
.replace(/--[^\n]*/g, "")
|
|
261
|
+
.replace(/\s+/g, " "),
|
|
262
|
+
);
|
|
263
|
+
if (configuration.provider !== "github")
|
|
264
|
+
throw Error("GitHub fixture required");
|
|
265
|
+
const github = configuration.github;
|
|
266
|
+
const issues: Record<string, unknown>[] = [];
|
|
267
|
+
const reads: string[] = [];
|
|
268
|
+
const feedback = createGitHubFeedbackModule({
|
|
269
|
+
...github,
|
|
270
|
+
database,
|
|
271
|
+
getToken: async () => "token",
|
|
272
|
+
request: async (input, init) => {
|
|
273
|
+
const url = String(input);
|
|
274
|
+
if (url.endsWith("/acme/feedback")) return Response.json({ id: 123 });
|
|
275
|
+
if (init?.method === "POST") {
|
|
276
|
+
const issue = {
|
|
277
|
+
...JSON.parse(String(init.body)),
|
|
278
|
+
id: 1000 + issues.length,
|
|
279
|
+
number: issues.length + 1,
|
|
280
|
+
user: { id: 456 },
|
|
281
|
+
labels: [],
|
|
282
|
+
};
|
|
283
|
+
issues.push(issue);
|
|
284
|
+
return Response.json(issue);
|
|
285
|
+
}
|
|
286
|
+
reads.push(url);
|
|
287
|
+
const number = Number(url.split("/").at(-1));
|
|
288
|
+
return Response.json(issues.find((issue) => issue.number === number));
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
for (const title of ["First", "Second"])
|
|
292
|
+
await feedback.submitPost({
|
|
293
|
+
title,
|
|
294
|
+
description: "Details",
|
|
295
|
+
type: "General Feedback",
|
|
296
|
+
submitter: { name: "Alice", email: "alice@example.com" },
|
|
297
|
+
});
|
|
298
|
+
let time = Date.now();
|
|
299
|
+
await database
|
|
300
|
+
.prepare(
|
|
301
|
+
"INSERT INTO integration_credentials(installation_id,portal_secret,delivery_secret,paired_at) VALUES(?,?,?,?)",
|
|
302
|
+
)
|
|
303
|
+
.bind(github.installationId, "a".repeat(43), "b".repeat(43), time)
|
|
304
|
+
.run();
|
|
305
|
+
const integration = createPortalIntegration({
|
|
306
|
+
config: configuration,
|
|
307
|
+
database,
|
|
308
|
+
now: () => time,
|
|
309
|
+
});
|
|
310
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
311
|
+
expect(
|
|
312
|
+
(
|
|
313
|
+
await integration.handle(
|
|
314
|
+
signIntegrationRequest({
|
|
315
|
+
url: `${configuration.product.siteUrl}/internal/integration/delivery`,
|
|
316
|
+
installationId: github.installationId,
|
|
317
|
+
secret: "b".repeat(43),
|
|
318
|
+
now: time,
|
|
319
|
+
body: JSON.stringify({
|
|
320
|
+
eventId: `poll-${pass}`,
|
|
321
|
+
kind: "reconcile",
|
|
322
|
+
}),
|
|
323
|
+
}),
|
|
324
|
+
)
|
|
325
|
+
)?.status,
|
|
326
|
+
).toBe(202);
|
|
327
|
+
await integration.reconcile(feedback);
|
|
328
|
+
time += 300_000;
|
|
329
|
+
}
|
|
330
|
+
expect(reads).toHaveLength(2);
|
|
331
|
+
expect(new Set(reads).size).toBe(2);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("expires terminal diagnostics even when no work is eligible", async () => {
|
|
335
|
+
const { createFeedbackModule } = await import("@feedbax/feedback");
|
|
336
|
+
runtime = new Miniflare({
|
|
337
|
+
modules: true,
|
|
338
|
+
script: "export default {}",
|
|
339
|
+
d1Databases: ["DB"],
|
|
340
|
+
compatibilityDate: "2026-07-30",
|
|
341
|
+
});
|
|
342
|
+
const database = await runtime.getD1Database("DB");
|
|
343
|
+
const directory = resolve(import.meta.dirname, "../migrations");
|
|
344
|
+
for (const file of (await readdir(directory)).sort())
|
|
345
|
+
await database.exec(
|
|
346
|
+
(await readFile(resolve(directory, file), "utf8"))
|
|
347
|
+
.replace(/--[^\n]*/g, "")
|
|
348
|
+
.replace(/\s+/g, " "),
|
|
349
|
+
);
|
|
350
|
+
const time = Date.now();
|
|
351
|
+
await database
|
|
352
|
+
.prepare(
|
|
353
|
+
"INSERT INTO integration_receipts(installation_id,event_id,kind,state,received_at,failure_code) VALUES(?,?,?,'failed',?,'provider_unavailable')",
|
|
354
|
+
)
|
|
355
|
+
.bind("portal-acme", "expired-work", "reconcile", time - 31 * 86400_000)
|
|
356
|
+
.run();
|
|
357
|
+
const integration = createPortalIntegration({
|
|
358
|
+
config: configuration,
|
|
359
|
+
database,
|
|
360
|
+
now: () => time,
|
|
361
|
+
});
|
|
362
|
+
expect((await integration.status()).terminalFailures).toBe(1);
|
|
363
|
+
await integration.reconcile(createFeedbackModule());
|
|
364
|
+
expect((await integration.status()).terminalFailures).toBe(0);
|
|
365
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import type { FeedbaxConfig } from "@feedbax/config";
|
|
3
|
+
import {
|
|
4
|
+
boundedBody,
|
|
5
|
+
signIntegrationRequest,
|
|
6
|
+
verifyIntegrationRequest,
|
|
7
|
+
type ConnectedDatabase,
|
|
8
|
+
type FeedbackModule,
|
|
9
|
+
} from "@feedbax/feedback";
|
|
10
|
+
|
|
11
|
+
interface Credential {
|
|
12
|
+
portal_secret: string;
|
|
13
|
+
delivery_secret: string;
|
|
14
|
+
revoked: number;
|
|
15
|
+
}
|
|
16
|
+
export function createPortalIntegration(options: {
|
|
17
|
+
config: FeedbaxConfig;
|
|
18
|
+
database: ConnectedDatabase;
|
|
19
|
+
pairingSecret?: string;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}) {
|
|
22
|
+
const { config, database } = options;
|
|
23
|
+
const now = options.now ?? Date.now;
|
|
24
|
+
const github = config.provider === "github" ? config.github : undefined;
|
|
25
|
+
const credential = () =>
|
|
26
|
+
github
|
|
27
|
+
? database
|
|
28
|
+
.prepare(
|
|
29
|
+
"SELECT * FROM integration_credentials WHERE installation_id=?",
|
|
30
|
+
)
|
|
31
|
+
.bind(github.installationId)
|
|
32
|
+
.first<Credential>()
|
|
33
|
+
: Promise.resolve(null);
|
|
34
|
+
const json = (body: unknown, status = 200) =>
|
|
35
|
+
Response.json(body, {
|
|
36
|
+
status,
|
|
37
|
+
headers: {
|
|
38
|
+
"Cache-Control": "no-store",
|
|
39
|
+
"Referrer-Policy": "no-referrer",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
async portalSecret() {
|
|
44
|
+
const row = await credential();
|
|
45
|
+
if (!row || row.revoked)
|
|
46
|
+
throw new Error(
|
|
47
|
+
"GitHub Portal Binding is missing or revoked. Pair the Installation again.",
|
|
48
|
+
);
|
|
49
|
+
return row.portal_secret;
|
|
50
|
+
},
|
|
51
|
+
async handle(request: Request): Promise<Response | undefined> {
|
|
52
|
+
const url = new URL(request.url);
|
|
53
|
+
if (!url.pathname.startsWith("/internal/integration/")) return;
|
|
54
|
+
if (
|
|
55
|
+
!github ||
|
|
56
|
+
!config.product.siteUrl ||
|
|
57
|
+
url.origin !== new URL(config.product.siteUrl).origin
|
|
58
|
+
)
|
|
59
|
+
return json({ error: "Not found." }, 404);
|
|
60
|
+
if (request.method !== "POST")
|
|
61
|
+
return json({ error: "Method not allowed." }, 405);
|
|
62
|
+
try {
|
|
63
|
+
const body = await boundedBody(request, 4096);
|
|
64
|
+
const pairing = url.pathname === "/internal/integration/pair";
|
|
65
|
+
const stored = pairing ? null : await credential();
|
|
66
|
+
const secret = pairing
|
|
67
|
+
? options.pairingSecret
|
|
68
|
+
: stored?.delivery_secret;
|
|
69
|
+
if (
|
|
70
|
+
!secret ||
|
|
71
|
+
request.headers.get("X-Feedbax-Installation") !==
|
|
72
|
+
github.installationId ||
|
|
73
|
+
!verifyIntegrationRequest(request, body, secret, now())
|
|
74
|
+
)
|
|
75
|
+
return json({ error: "Unauthorized." }, 401);
|
|
76
|
+
const input = JSON.parse(body) as Record<string, unknown>;
|
|
77
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
78
|
+
return json({ error: "Invalid delivery." }, 400);
|
|
79
|
+
const nonce = database
|
|
80
|
+
.prepare(
|
|
81
|
+
"INSERT INTO portal_integration_nonces(installation_id,nonce,expires_at) VALUES(?,?,?)",
|
|
82
|
+
)
|
|
83
|
+
.bind(
|
|
84
|
+
github.installationId,
|
|
85
|
+
request.headers.get("X-Feedbax-Nonce"),
|
|
86
|
+
now() + 600_000,
|
|
87
|
+
);
|
|
88
|
+
if (pairing) {
|
|
89
|
+
const keys = input.credentials as
|
|
90
|
+
{ portalSecret?: unknown; deliverySecret?: unknown } | undefined;
|
|
91
|
+
if (
|
|
92
|
+
typeof input.challenge !== "string" ||
|
|
93
|
+
!/^[A-Za-z0-9_-]{43}$/.test(input.challenge) ||
|
|
94
|
+
input.repositoryId !== github.repository.id ||
|
|
95
|
+
input.botUserId !== github.botUserId ||
|
|
96
|
+
!keys ||
|
|
97
|
+
typeof keys.portalSecret !== "string" ||
|
|
98
|
+
typeof keys.deliverySecret !== "string" ||
|
|
99
|
+
!/^[A-Za-z0-9_-]{43}$/.test(keys.portalSecret) ||
|
|
100
|
+
!/^[A-Za-z0-9_-]{43}$/.test(keys.deliverySecret) ||
|
|
101
|
+
keys.portalSecret === keys.deliverySecret
|
|
102
|
+
)
|
|
103
|
+
return json(
|
|
104
|
+
{ error: "Pairing does not match this Installation." },
|
|
105
|
+
400,
|
|
106
|
+
);
|
|
107
|
+
await database.batch([
|
|
108
|
+
nonce,
|
|
109
|
+
database
|
|
110
|
+
.prepare(
|
|
111
|
+
"INSERT INTO integration_credentials(installation_id,portal_secret,delivery_secret,paired_at) VALUES(?,?,?,?) ON CONFLICT(installation_id) DO UPDATE SET portal_secret=excluded.portal_secret,delivery_secret=excluded.delivery_secret,paired_at=excluded.paired_at,revoked=0",
|
|
112
|
+
)
|
|
113
|
+
.bind(
|
|
114
|
+
github.installationId,
|
|
115
|
+
keys.portalSecret,
|
|
116
|
+
keys.deliverySecret,
|
|
117
|
+
now(),
|
|
118
|
+
),
|
|
119
|
+
]);
|
|
120
|
+
const proof = createHmac("sha256", secret)
|
|
121
|
+
.update(
|
|
122
|
+
JSON.stringify([
|
|
123
|
+
"feedbax-pair-v1",
|
|
124
|
+
github.installationId,
|
|
125
|
+
url.origin,
|
|
126
|
+
github.serviceUrl,
|
|
127
|
+
input.challenge,
|
|
128
|
+
]),
|
|
129
|
+
)
|
|
130
|
+
.digest("hex");
|
|
131
|
+
return json({ proof });
|
|
132
|
+
}
|
|
133
|
+
if (url.pathname !== "/internal/integration/delivery")
|
|
134
|
+
return json({ error: "Not found." }, 404);
|
|
135
|
+
if (
|
|
136
|
+
typeof input.eventId !== "string" ||
|
|
137
|
+
!/^[A-Za-z0-9:_-]{1,200}$/.test(input.eventId) ||
|
|
138
|
+
!["reconcile", "revoke"].includes(String(input.kind))
|
|
139
|
+
)
|
|
140
|
+
return json({ error: "Invalid delivery." }, 400);
|
|
141
|
+
if (stored?.revoked && input.kind !== "revoke")
|
|
142
|
+
return json({ error: "Unauthorized." }, 401);
|
|
143
|
+
await database.batch([
|
|
144
|
+
nonce,
|
|
145
|
+
database
|
|
146
|
+
.prepare(
|
|
147
|
+
"INSERT OR IGNORE INTO integration_receipts(installation_id,event_id,kind,received_at) VALUES(?,?,?,?)",
|
|
148
|
+
)
|
|
149
|
+
.bind(github.installationId, input.eventId, input.kind, now()),
|
|
150
|
+
...(input.kind === "revoke"
|
|
151
|
+
? [
|
|
152
|
+
database
|
|
153
|
+
.prepare(
|
|
154
|
+
"UPDATE integration_credentials SET revoked=1 WHERE installation_id=?",
|
|
155
|
+
)
|
|
156
|
+
.bind(github.installationId),
|
|
157
|
+
]
|
|
158
|
+
: []),
|
|
159
|
+
]);
|
|
160
|
+
return json({ accepted: true }, 202);
|
|
161
|
+
} catch {
|
|
162
|
+
return json(
|
|
163
|
+
{
|
|
164
|
+
error:
|
|
165
|
+
"Delivery was not accepted. Retry with a fresh authenticated request.",
|
|
166
|
+
},
|
|
167
|
+
401,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
async reconcile(feedback: FeedbackModule, limit = 1) {
|
|
172
|
+
if (!github) return;
|
|
173
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10)
|
|
174
|
+
throw new Error("Reconciliation work limit must be 1–10.");
|
|
175
|
+
await database.batch([
|
|
176
|
+
database
|
|
177
|
+
.prepare("DELETE FROM portal_integration_nonces WHERE expires_at<?")
|
|
178
|
+
.bind(now()),
|
|
179
|
+
database
|
|
180
|
+
.prepare(
|
|
181
|
+
"UPDATE integration_receipts SET failure_code=NULL WHERE received_at<?",
|
|
182
|
+
)
|
|
183
|
+
.bind(now() - 30 * 86400_000),
|
|
184
|
+
]);
|
|
185
|
+
const time = now();
|
|
186
|
+
const candidate = await database
|
|
187
|
+
.prepare(
|
|
188
|
+
"SELECT event_id FROM integration_receipts WHERE installation_id=? AND state='pending' AND next_attempt<=? AND lease_until<=? ORDER BY received_at,event_id LIMIT 1",
|
|
189
|
+
)
|
|
190
|
+
.bind(github.installationId, time, time)
|
|
191
|
+
.first<{ event_id: string }>();
|
|
192
|
+
if (!candidate) return;
|
|
193
|
+
const lease = randomUUID();
|
|
194
|
+
const work = await database
|
|
195
|
+
.prepare(
|
|
196
|
+
"UPDATE integration_receipts SET lease_token=?,lease_until=?,attempts=attempts+1,last_checked_at=? WHERE installation_id=? AND event_id=? AND state='pending' AND next_attempt<=? AND lease_until<=? RETURNING event_id,cursor,attempts",
|
|
197
|
+
)
|
|
198
|
+
.bind(
|
|
199
|
+
lease,
|
|
200
|
+
time + 60_000,
|
|
201
|
+
time,
|
|
202
|
+
github.installationId,
|
|
203
|
+
candidate.event_id,
|
|
204
|
+
time,
|
|
205
|
+
time,
|
|
206
|
+
)
|
|
207
|
+
.first<{ event_id: string; cursor: string; attempts: number }>();
|
|
208
|
+
if (!work) return;
|
|
209
|
+
const record = await credential();
|
|
210
|
+
if (!record || record.revoked) {
|
|
211
|
+
await database
|
|
212
|
+
.prepare(
|
|
213
|
+
"UPDATE integration_receipts SET state='complete',last_checked_at=?,lease_until=0 WHERE installation_id=? AND event_id=? AND lease_token=?",
|
|
214
|
+
)
|
|
215
|
+
.bind(now(), github.installationId, work.event_id, lease)
|
|
216
|
+
.run();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const posts = (
|
|
221
|
+
await database
|
|
222
|
+
.prepare(
|
|
223
|
+
"SELECT post_id,slug FROM github_posts WHERE installation_id=? AND repository_id=? AND state='ready' AND post_id>? ORDER BY post_id LIMIT ?",
|
|
224
|
+
)
|
|
225
|
+
.bind(
|
|
226
|
+
github.installationId,
|
|
227
|
+
github.repository.id,
|
|
228
|
+
work.cursor,
|
|
229
|
+
limit,
|
|
230
|
+
)
|
|
231
|
+
.all<{ post_id: string; slug: string }>()
|
|
232
|
+
).results;
|
|
233
|
+
for (const post of posts) await feedback.getPublicPost(post.slug);
|
|
234
|
+
await database
|
|
235
|
+
.prepare(
|
|
236
|
+
"UPDATE integration_receipts SET cursor=?,state=?,last_checked_at=?,failure_code=NULL,attempts=0,lease_until=0 WHERE installation_id=? AND event_id=? AND cursor=? AND lease_token=?",
|
|
237
|
+
)
|
|
238
|
+
.bind(
|
|
239
|
+
posts.at(-1)?.post_id ?? work.cursor,
|
|
240
|
+
posts.length < limit ? "complete" : "pending",
|
|
241
|
+
now(),
|
|
242
|
+
github.installationId,
|
|
243
|
+
work.event_id,
|
|
244
|
+
work.cursor,
|
|
245
|
+
lease,
|
|
246
|
+
)
|
|
247
|
+
.run();
|
|
248
|
+
} catch {
|
|
249
|
+
const delay =
|
|
250
|
+
Math.min(3600_000, 60_000 * 2 ** Math.min(work.attempts - 1, 6)) +
|
|
251
|
+
Math.floor(Math.random() * 10_000);
|
|
252
|
+
await database
|
|
253
|
+
.prepare(
|
|
254
|
+
"UPDATE integration_receipts SET state=?,failure_code='provider_unavailable',next_attempt=?,lease_until=0 WHERE installation_id=? AND event_id=? AND lease_token=?",
|
|
255
|
+
)
|
|
256
|
+
.bind(
|
|
257
|
+
work.attempts >= 5 ? "failed" : "pending",
|
|
258
|
+
time + delay,
|
|
259
|
+
github.installationId,
|
|
260
|
+
work.event_id,
|
|
261
|
+
lease,
|
|
262
|
+
)
|
|
263
|
+
.run();
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
async status() {
|
|
267
|
+
if (!github) return { pending: 0, terminalFailures: 0 };
|
|
268
|
+
const summary = await database
|
|
269
|
+
.prepare(
|
|
270
|
+
"SELECT SUM(CASE WHEN state='pending' THEN 1 ELSE 0 END) AS pending,SUM(CASE WHEN state='failed' AND failure_code IS NOT NULL THEN 1 ELSE 0 END) AS failures FROM integration_receipts WHERE installation_id=?",
|
|
271
|
+
)
|
|
272
|
+
.bind(github.installationId)
|
|
273
|
+
.first<{ pending: number | null; failures: number | null }>();
|
|
274
|
+
return {
|
|
275
|
+
pending: summary?.pending ?? 0,
|
|
276
|
+
terminalFailures: summary?.failures ?? 0,
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
signedRequest(path: "/v1/status" | "/v1/revoke", secret: string) {
|
|
280
|
+
if (!github) throw new Error("GitHub is not configured.");
|
|
281
|
+
return signIntegrationRequest({
|
|
282
|
+
url: `${github.serviceUrl}${path}`,
|
|
283
|
+
installationId: github.installationId,
|
|
284
|
+
secret,
|
|
285
|
+
body: "{}",
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|