create-feedbax 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -0,0 +1,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
+ }
@@ -1,10 +1,21 @@
1
- import { OverlayPostDetail } from "./overlay-post-detail";
1
+ import { lazy, Suspense } from "react";
2
2
  import { PostDetailOverlay } from "./post-detail-overlay";
3
+ import { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
4
+
5
+ const OverlayPostDetail = lazy(() =>
6
+ import("./overlay-post-detail").then((module) => ({
7
+ default: module.OverlayPostDetail,
8
+ })),
9
+ );
3
10
 
4
11
  export function LazyPostDetailOverlay() {
5
12
  return (
6
13
  <PostDetailOverlay
7
- renderDetail={(slug) => <OverlayPostDetail slug={slug} />}
14
+ renderDetail={(slug) => (
15
+ <Suspense fallback={<PublicPostDetailSkeleton display="overlay" />}>
16
+ <OverlayPostDetail key={slug} slug={slug} />
17
+ </Suspense>
18
+ )}
8
19
  />
9
20
  );
10
21
  }