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,56 @@
|
|
|
1
|
+
import type { GitHubConfiguration } from "@feedbax/config";
|
|
2
|
+
import { signIntegrationRequest } from "./integration-auth";
|
|
3
|
+
|
|
4
|
+
export function createGitHubServiceAccess(options: {
|
|
5
|
+
config: GitHubConfiguration;
|
|
6
|
+
secret?: string;
|
|
7
|
+
getSecret?: () => Promise<string>;
|
|
8
|
+
request?: typeof fetch;
|
|
9
|
+
}) {
|
|
10
|
+
let cached: { token: string; expires: number; secret: string } | undefined;
|
|
11
|
+
return async () => {
|
|
12
|
+
const secret = options.getSecret
|
|
13
|
+
? await options.getSecret()
|
|
14
|
+
: (options.secret ?? "");
|
|
15
|
+
if (
|
|
16
|
+
cached &&
|
|
17
|
+
cached.secret === secret &&
|
|
18
|
+
cached.expires > Date.now() + 60_000
|
|
19
|
+
)
|
|
20
|
+
return cached.token;
|
|
21
|
+
const response = await (options.request ?? fetch)(
|
|
22
|
+
signIntegrationRequest({
|
|
23
|
+
url: `${options.config.serviceUrl}/v1/token`,
|
|
24
|
+
installationId: options.config.installationId,
|
|
25
|
+
secret,
|
|
26
|
+
body: "{}",
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
cached = undefined;
|
|
31
|
+
throw new Error(
|
|
32
|
+
"GitHub Portal Binding is unavailable. Run feedbax doctor or pair the Installation again.",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const value = (await response.json()) as {
|
|
36
|
+
token?: unknown;
|
|
37
|
+
expiresAt?: unknown;
|
|
38
|
+
repositoryId?: unknown;
|
|
39
|
+
botUserId?: unknown;
|
|
40
|
+
};
|
|
41
|
+
const expires =
|
|
42
|
+
typeof value.expiresAt === "string" ? Date.parse(value.expiresAt) : NaN;
|
|
43
|
+
if (
|
|
44
|
+
typeof value.token !== "string" ||
|
|
45
|
+
!value.token ||
|
|
46
|
+
!Number.isFinite(expires) ||
|
|
47
|
+
expires <= Date.now() ||
|
|
48
|
+
value.repositoryId !== options.config.repository.id ||
|
|
49
|
+
value.botUserId !== options.config.botUserId
|
|
50
|
+
)
|
|
51
|
+
throw new Error("GitHub integration returned mismatched access.");
|
|
52
|
+
// Cache only within one request factory and recheck local revocation/rotation on every use.
|
|
53
|
+
cached = { token: value.token, expires, secret };
|
|
54
|
+
return cached.token;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
recoverableComment,
|
|
3
|
+
type FeedbackModule,
|
|
4
|
+
type CreateCommentInput,
|
|
5
|
+
} from "./index";
|
|
2
6
|
import type { createImageModule, ImageAttachment } from "./images";
|
|
3
7
|
|
|
4
8
|
export function createImageFeedbackModule(
|
|
@@ -10,15 +14,23 @@ export function createImageFeedbackModule(
|
|
|
10
14
|
input: CreateCommentInput & { discussionId?: string },
|
|
11
15
|
) => {
|
|
12
16
|
const ids = input.imageIds ?? [];
|
|
13
|
-
|
|
17
|
+
const attachedComment = input.submissionKey
|
|
18
|
+
? await images.validateCommentImages(ids, input.slug)
|
|
19
|
+
: (await images.validatePending(ids), undefined);
|
|
20
|
+
const command = {
|
|
21
|
+
...input,
|
|
22
|
+
...(attachedComment ? { recoveryCommentId: attachedComment } : {}),
|
|
23
|
+
};
|
|
14
24
|
const comment = input.discussionId
|
|
15
25
|
? await base.replyToCommentThread({
|
|
16
|
-
...
|
|
26
|
+
...command,
|
|
17
27
|
discussionId: input.discussionId,
|
|
18
28
|
})
|
|
19
|
-
: await base.createComment(
|
|
29
|
+
: await base.createComment(command);
|
|
20
30
|
if (!ids.length) return comment;
|
|
21
31
|
try {
|
|
32
|
+
if (attachedComment && attachedComment !== comment.id)
|
|
33
|
+
throw new Error("Images belong to another Comment.");
|
|
22
34
|
return {
|
|
23
35
|
...comment,
|
|
24
36
|
images: await images.attachComment({
|
|
@@ -28,12 +40,13 @@ export function createImageFeedbackModule(
|
|
|
28
40
|
}),
|
|
29
41
|
};
|
|
30
42
|
} catch (error) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
if (!comment[recoverableComment])
|
|
44
|
+
try {
|
|
45
|
+
await base.deleteComment({ commentId: comment.id });
|
|
46
|
+
await images.discardAttached(input.slug, comment.id);
|
|
47
|
+
} catch {
|
|
48
|
+
/* External cleanup remains best effort; never expose an unpublished attachment. */
|
|
49
|
+
}
|
|
37
50
|
throw error;
|
|
38
51
|
}
|
|
39
52
|
};
|
|
@@ -93,6 +93,32 @@ export function createImageModule(options: {
|
|
|
93
93
|
);
|
|
94
94
|
};
|
|
95
95
|
return {
|
|
96
|
+
async validateCommentImages(ids: string[], slug: string) {
|
|
97
|
+
if (!ids.length) return;
|
|
98
|
+
const person = identity();
|
|
99
|
+
if (ids.length > 4 || new Set(ids).size !== ids.length)
|
|
100
|
+
throw new ImageAttachmentError("Choose up to four images.");
|
|
101
|
+
let attachedComment: string | undefined;
|
|
102
|
+
for (const id of ids) {
|
|
103
|
+
const image = await row(id);
|
|
104
|
+
if (
|
|
105
|
+
!image ||
|
|
106
|
+
image.subject !== person.subject ||
|
|
107
|
+
image.deleting ||
|
|
108
|
+
(image.post_slug === null
|
|
109
|
+
? image.created_at <= now() - 86400000
|
|
110
|
+
: image.post_slug !== slug ||
|
|
111
|
+
!image.comment_id ||
|
|
112
|
+
(attachedComment !== undefined &&
|
|
113
|
+
attachedComment !== image.comment_id))
|
|
114
|
+
)
|
|
115
|
+
throw new ImageAttachmentError(
|
|
116
|
+
"One or more images are unavailable. Upload them again.",
|
|
117
|
+
);
|
|
118
|
+
if (image.comment_id) attachedComment = image.comment_id;
|
|
119
|
+
}
|
|
120
|
+
return attachedComment;
|
|
121
|
+
},
|
|
96
122
|
async validatePending(ids: string[]) {
|
|
97
123
|
if (!ids.length) return;
|
|
98
124
|
const person = identity();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PostOwnership } from "./ownership";
|
|
1
2
|
import {
|
|
2
3
|
createHash,
|
|
3
4
|
randomBytes,
|
|
@@ -9,16 +10,18 @@ import {
|
|
|
9
10
|
createNotionFeedbackStorage,
|
|
10
11
|
type NotionFeedbackStorageOptions,
|
|
11
12
|
} from "./notion-feedback";
|
|
13
|
+
import {
|
|
14
|
+
createGitHubFeedbackStorage,
|
|
15
|
+
type GitHubFeedbackOptions,
|
|
16
|
+
} from "./github-feedback";
|
|
17
|
+
import { createGitHubCommentStorage } from "./github-comments";
|
|
18
|
+
import type { CommentStorage, StoredComment } from "./comment-storage";
|
|
12
19
|
import type {
|
|
13
20
|
FeedbackStorage,
|
|
14
21
|
NewStoredPost,
|
|
15
22
|
StoredPost,
|
|
16
23
|
} from "./feedback-storage";
|
|
17
|
-
import {
|
|
18
|
-
createNotionCommentStorage,
|
|
19
|
-
type CommentStorage,
|
|
20
|
-
type StoredComment,
|
|
21
|
-
} from "./notion-comments";
|
|
24
|
+
import { createNotionCommentStorage } from "./notion-comments";
|
|
22
25
|
|
|
23
26
|
export {
|
|
24
27
|
createNotionFeedbackDataSource,
|
|
@@ -35,6 +38,7 @@ export type PostStatus =
|
|
|
35
38
|
"New" | "Reviewing" | "Planned" | "In Progress" | "Shipped" | "Closed";
|
|
36
39
|
|
|
37
40
|
export interface SubmitPostInput {
|
|
41
|
+
submissionKey?: string;
|
|
38
42
|
imageIds?: string[];
|
|
39
43
|
title: string;
|
|
40
44
|
description: string;
|
|
@@ -137,7 +141,7 @@ export type TrustedSubmittedPost = Omit<Post, "published" | "submitter">;
|
|
|
137
141
|
|
|
138
142
|
export interface EditDraftPostInput {
|
|
139
143
|
id: string;
|
|
140
|
-
browserCapability
|
|
144
|
+
browserCapability?: BrowserCapability;
|
|
141
145
|
title?: string;
|
|
142
146
|
description?: string;
|
|
143
147
|
type?: PostType;
|
|
@@ -145,7 +149,7 @@ export interface EditDraftPostInput {
|
|
|
145
149
|
|
|
146
150
|
export interface WithdrawDraftPostInput {
|
|
147
151
|
id: string;
|
|
148
|
-
browserCapability
|
|
152
|
+
browserCapability?: BrowserCapability;
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
export type GetDraftPostInput = WithdrawDraftPostInput;
|
|
@@ -193,6 +197,9 @@ export interface ListCommentThreadsInput {
|
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
export interface CreateCommentInput {
|
|
200
|
+
submissionKey?: string;
|
|
201
|
+
/** Internal recovery precondition; it never grants Comment authority. */
|
|
202
|
+
recoveryCommentId?: string;
|
|
196
203
|
imageIds?: string[];
|
|
197
204
|
slug: string;
|
|
198
205
|
body: string;
|
|
@@ -203,7 +210,9 @@ export interface ReplyToCommentThreadInput extends CreateCommentInput {
|
|
|
203
210
|
discussionId: string;
|
|
204
211
|
}
|
|
205
212
|
|
|
213
|
+
export const recoverableComment = Symbol("recoverable-comment");
|
|
206
214
|
export interface CreatedComment extends Comment {
|
|
215
|
+
[recoverableComment]?: boolean;
|
|
207
216
|
discussionId: string;
|
|
208
217
|
}
|
|
209
218
|
|
|
@@ -360,7 +369,24 @@ class InMemoryFeedbackStorage implements FeedbackStorage {
|
|
|
360
369
|
}
|
|
361
370
|
}
|
|
362
371
|
|
|
372
|
+
export type ParticipantPost = Pick<
|
|
373
|
+
Post,
|
|
374
|
+
| "id"
|
|
375
|
+
| "slug"
|
|
376
|
+
| "title"
|
|
377
|
+
| "description"
|
|
378
|
+
| "type"
|
|
379
|
+
| "status"
|
|
380
|
+
| "published"
|
|
381
|
+
| "createdAt"
|
|
382
|
+
| "updatedAt"
|
|
383
|
+
> & { editable: boolean };
|
|
384
|
+
|
|
363
385
|
export interface FeedbackModule {
|
|
386
|
+
getParticipantPost(id: string): Promise<ParticipantPost | undefined>;
|
|
387
|
+
claimDraftPost(
|
|
388
|
+
input: GetDraftPostInput,
|
|
389
|
+
): Promise<ParticipantPost | undefined>;
|
|
364
390
|
getAttachmentPostState(
|
|
365
391
|
slug: string,
|
|
366
392
|
): Promise<"published" | "draft" | "private" | undefined>;
|
|
@@ -393,6 +419,7 @@ export type FeedbackMutationModule = Pick<
|
|
|
393
419
|
>;
|
|
394
420
|
|
|
395
421
|
interface CreateFeedbackModuleOptions {
|
|
422
|
+
ownership?: PostOwnership;
|
|
396
423
|
initialItems?: StoredPost[];
|
|
397
424
|
storage?: FeedbackStorage;
|
|
398
425
|
votingEnabled?: boolean;
|
|
@@ -412,10 +439,39 @@ export function createFeedbackModule(
|
|
|
412
439
|
options.commentStorage ??
|
|
413
440
|
createInMemoryCommentStorage(options.initialComments);
|
|
414
441
|
const voteQueues = new Map<string, Promise<void>>();
|
|
442
|
+
const authorizedDraft = async (
|
|
443
|
+
item: StoredPost | undefined,
|
|
444
|
+
capability?: BrowserCapability,
|
|
445
|
+
): Promise<boolean> =>
|
|
446
|
+
Boolean(
|
|
447
|
+
item &&
|
|
448
|
+
isEditableDraft(item) &&
|
|
449
|
+
(authorizesDraft(item, capability) ||
|
|
450
|
+
(await options.ownership?.owns(item.id))),
|
|
451
|
+
);
|
|
452
|
+
const participantPost = async (
|
|
453
|
+
id: string,
|
|
454
|
+
): Promise<ParticipantPost | undefined> => {
|
|
455
|
+
if (!(await options.ownership?.owns(id))) return;
|
|
456
|
+
const item = await storage.find(id);
|
|
457
|
+
if (!item) return;
|
|
458
|
+
return {
|
|
459
|
+
id: item.id,
|
|
460
|
+
slug: item.slug,
|
|
461
|
+
title: item.title,
|
|
462
|
+
description: item.description,
|
|
463
|
+
type: item.type,
|
|
464
|
+
status: item.status,
|
|
465
|
+
published: item.published,
|
|
466
|
+
createdAt: item.createdAt,
|
|
467
|
+
updatedAt: item.updatedAt,
|
|
468
|
+
editable: isEditableDraft(item),
|
|
469
|
+
};
|
|
470
|
+
};
|
|
415
471
|
const editStoredDraft = async (input: EditDraftPostInput) => {
|
|
416
472
|
const item = await storage.find(input.id);
|
|
417
473
|
|
|
418
|
-
if (!
|
|
474
|
+
if (!item || !(await authorizedDraft(item, input.browserCapability))) {
|
|
419
475
|
throw new BrowserCapabilityAuthorizationError();
|
|
420
476
|
}
|
|
421
477
|
|
|
@@ -441,17 +497,29 @@ export function createFeedbackModule(
|
|
|
441
497
|
};
|
|
442
498
|
|
|
443
499
|
return {
|
|
500
|
+
getParticipantPost: participantPost,
|
|
501
|
+
async claimDraftPost(input) {
|
|
502
|
+
const item = await storage.find(input.id);
|
|
503
|
+
if (!options.ownership || !authorizesDraft(item, input.browserCapability))
|
|
504
|
+
throw new BrowserCapabilityAuthorizationError();
|
|
505
|
+
await options.ownership.register(toPost(item));
|
|
506
|
+
return participantPost(item.id);
|
|
507
|
+
},
|
|
444
508
|
async getAttachmentPostState(slug) {
|
|
445
509
|
const post = await storage.findBySlug(slug);
|
|
446
510
|
return !post
|
|
447
511
|
? undefined
|
|
448
512
|
: post.published
|
|
449
513
|
? "published"
|
|
450
|
-
: post.status === "New"
|
|
514
|
+
: post.status === "New" && post.draftEditable !== false
|
|
451
515
|
? "draft"
|
|
452
516
|
: "private";
|
|
453
517
|
},
|
|
454
518
|
async createComment(input) {
|
|
519
|
+
if (input.recoveryCommentId && !commentStorage.supportsIntentRecovery)
|
|
520
|
+
throw new CommentValidationError(
|
|
521
|
+
"Attached Comment recovery is unavailable.",
|
|
522
|
+
);
|
|
455
523
|
const { post, body, displayName } = await validateCommentCreation(
|
|
456
524
|
input,
|
|
457
525
|
storage,
|
|
@@ -459,12 +527,22 @@ export function createFeedbackModule(
|
|
|
459
527
|
return toCreatedComment(
|
|
460
528
|
await commentStorage.createPageComment({
|
|
461
529
|
postId: post.id,
|
|
530
|
+
...(input.recoveryCommentId
|
|
531
|
+
? { recoveryCommentId: input.recoveryCommentId }
|
|
532
|
+
: {}),
|
|
533
|
+
...(input.submissionKey
|
|
534
|
+
? { submissionKey: input.submissionKey }
|
|
535
|
+
: {}),
|
|
462
536
|
body,
|
|
463
537
|
displayName,
|
|
464
538
|
}),
|
|
465
539
|
);
|
|
466
540
|
},
|
|
467
541
|
async replyToCommentThread(input) {
|
|
542
|
+
if (input.recoveryCommentId && !commentStorage.supportsIntentRecovery)
|
|
543
|
+
throw new CommentValidationError(
|
|
544
|
+
"Attached Comment recovery is unavailable.",
|
|
545
|
+
);
|
|
468
546
|
const { post, body, displayName } = await validateCommentCreation(
|
|
469
547
|
input,
|
|
470
548
|
storage,
|
|
@@ -491,6 +569,12 @@ export function createFeedbackModule(
|
|
|
491
569
|
await commentStorage.createDiscussionReply({
|
|
492
570
|
postId: post.id,
|
|
493
571
|
discussionId: input.discussionId,
|
|
572
|
+
...(input.recoveryCommentId
|
|
573
|
+
? { recoveryCommentId: input.recoveryCommentId }
|
|
574
|
+
: {}),
|
|
575
|
+
...(input.submissionKey
|
|
576
|
+
? { submissionKey: input.submissionKey }
|
|
577
|
+
: {}),
|
|
494
578
|
body,
|
|
495
579
|
displayName,
|
|
496
580
|
}),
|
|
@@ -555,10 +639,29 @@ export function createFeedbackModule(
|
|
|
555
639
|
submitPost(input) {
|
|
556
640
|
const submission = postCreationQueue.then(async () => {
|
|
557
641
|
const now = new Date();
|
|
558
|
-
const
|
|
642
|
+
const { submissionKey, ...content } = input;
|
|
643
|
+
if (
|
|
644
|
+
submissionKey !== undefined &&
|
|
645
|
+
!/^[A-Za-z0-9_-]{43}$/.test(submissionKey)
|
|
646
|
+
)
|
|
647
|
+
throw new Error("Submission intent is invalid.");
|
|
648
|
+
const browserCapability = submissionKey
|
|
649
|
+
? (submissionKey as BrowserCapability)
|
|
650
|
+
: createBrowserCapability();
|
|
651
|
+
const externalId = submissionKey
|
|
652
|
+
? `portal:${hashBrowserCapability(browserCapability)}`
|
|
653
|
+
: undefined;
|
|
654
|
+
if (externalId) {
|
|
655
|
+
const previous = await storage.findByExternalId(externalId);
|
|
656
|
+
if (previous) {
|
|
657
|
+
await options.ownership?.register(toPost(previous));
|
|
658
|
+
return { ...toPost(previous), browserCapability };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
559
661
|
const slug = await createUniqueSlug(input.title, storage);
|
|
560
662
|
const item = await storage.create({
|
|
561
|
-
...
|
|
663
|
+
...content,
|
|
664
|
+
...(externalId ? { externalId } : {}),
|
|
562
665
|
slug,
|
|
563
666
|
status: "New",
|
|
564
667
|
published: false,
|
|
@@ -568,6 +671,7 @@ export function createFeedbackModule(
|
|
|
568
671
|
voteCount: 0,
|
|
569
672
|
});
|
|
570
673
|
|
|
674
|
+
await options.ownership?.register(toPost(item));
|
|
571
675
|
return {
|
|
572
676
|
...toPost(item),
|
|
573
677
|
browserCapability,
|
|
@@ -590,7 +694,7 @@ export function createFeedbackModule(
|
|
|
590
694
|
},
|
|
591
695
|
async getDraftPost(input) {
|
|
592
696
|
const item = await storage.find(input.id);
|
|
593
|
-
if (!
|
|
697
|
+
if (!item || !(await authorizedDraft(item, input.browserCapability))) {
|
|
594
698
|
throw new BrowserCapabilityAuthorizationError();
|
|
595
699
|
}
|
|
596
700
|
return toDraftPost(toPost(item));
|
|
@@ -638,11 +742,12 @@ export function createFeedbackModule(
|
|
|
638
742
|
async withdrawDraftPost(input) {
|
|
639
743
|
const item = await storage.find(input.id);
|
|
640
744
|
|
|
641
|
-
if (!
|
|
745
|
+
if (!item || !(await authorizedDraft(item, input.browserCapability))) {
|
|
642
746
|
throw new BrowserCapabilityAuthorizationError();
|
|
643
747
|
}
|
|
644
748
|
|
|
645
749
|
await storage.remove(item.id);
|
|
750
|
+
await options.ownership?.withdrawn(item.id);
|
|
646
751
|
},
|
|
647
752
|
async getPublicPostRoadmap() {
|
|
648
753
|
const pages = await Promise.all(
|
|
@@ -659,9 +764,11 @@ export function createNotionFeedbackModule(
|
|
|
659
764
|
options: NotionFeedbackStorageOptions & {
|
|
660
765
|
votingEnabled?: boolean;
|
|
661
766
|
commentsEnabled?: boolean;
|
|
767
|
+
ownership?: PostOwnership;
|
|
662
768
|
},
|
|
663
769
|
): FeedbackModule {
|
|
664
770
|
return createFeedbackModule({
|
|
771
|
+
ownership: options.ownership,
|
|
665
772
|
storage: createNotionFeedbackStorage(options),
|
|
666
773
|
commentStorage: createNotionCommentStorage(options),
|
|
667
774
|
votingEnabled: options.votingEnabled,
|
|
@@ -669,6 +776,21 @@ export function createNotionFeedbackModule(
|
|
|
669
776
|
});
|
|
670
777
|
}
|
|
671
778
|
|
|
779
|
+
export function createGitHubFeedbackModule(
|
|
780
|
+
options: GitHubFeedbackOptions & { ownership?: PostOwnership },
|
|
781
|
+
): FeedbackModule {
|
|
782
|
+
const storage = createGitHubFeedbackStorage(options);
|
|
783
|
+
return createFeedbackModule({
|
|
784
|
+
storage,
|
|
785
|
+
ownership: options.ownership,
|
|
786
|
+
commentStorage: createGitHubCommentStorage(options, storage),
|
|
787
|
+
votingEnabled: options.votingEnabled,
|
|
788
|
+
commentsEnabled: options.commentsEnabled,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export type { GitHubFeedbackOptions } from "./github-feedback";
|
|
793
|
+
|
|
672
794
|
function createInMemoryCommentStorage(
|
|
673
795
|
initialComments: StoredComment[] = [],
|
|
674
796
|
): CommentStorage {
|
|
@@ -762,13 +884,16 @@ async function validateCommentCreation(
|
|
|
762
884
|
}
|
|
763
885
|
|
|
764
886
|
function toCreatedComment(comment: StoredComment): CreatedComment {
|
|
765
|
-
|
|
887
|
+
const result: CreatedComment = {
|
|
766
888
|
id: comment.id,
|
|
767
889
|
discussionId: comment.discussionId,
|
|
768
890
|
body: comment.body,
|
|
769
891
|
author: comment.author,
|
|
770
892
|
createdAt: comment.createdAt,
|
|
771
893
|
};
|
|
894
|
+
if (comment.recoverable)
|
|
895
|
+
Object.defineProperty(result, recoverableComment, { value: true });
|
|
896
|
+
return result;
|
|
772
897
|
}
|
|
773
898
|
|
|
774
899
|
function encodePublicPostCursor(
|
|
@@ -934,9 +1059,15 @@ function hashBrowserCapability(browserCapability: BrowserCapability): string {
|
|
|
934
1059
|
|
|
935
1060
|
function authorizesDraft(
|
|
936
1061
|
item: StoredPost | undefined,
|
|
937
|
-
browserCapability: BrowserCapability,
|
|
1062
|
+
browserCapability: BrowserCapability | undefined,
|
|
938
1063
|
): item is StoredPost & { browserCapabilityHash: string } {
|
|
939
|
-
if (
|
|
1064
|
+
if (
|
|
1065
|
+
!browserCapability ||
|
|
1066
|
+
!item?.browserCapabilityHash ||
|
|
1067
|
+
item.status !== "New" ||
|
|
1068
|
+
item.published ||
|
|
1069
|
+
item.draftEditable === false
|
|
1070
|
+
) {
|
|
940
1071
|
return false;
|
|
941
1072
|
}
|
|
942
1073
|
|
|
@@ -994,3 +1125,28 @@ export {
|
|
|
994
1125
|
|
|
995
1126
|
export { createImageFeedbackModule } from "./image-feedback";
|
|
996
1127
|
export { createNotionImageReviewWriter } from "./notion-image-review";
|
|
1128
|
+
|
|
1129
|
+
export { createGitHubServiceAccess } from "./github-service-client";
|
|
1130
|
+
export {
|
|
1131
|
+
createObservedHistoryModule,
|
|
1132
|
+
type ObservedHistoryEvent,
|
|
1133
|
+
type ObservedHistoryPage,
|
|
1134
|
+
} from "./observed-history";
|
|
1135
|
+
export {
|
|
1136
|
+
boundedBody,
|
|
1137
|
+
signIntegrationRequest,
|
|
1138
|
+
verifyIntegrationRequest,
|
|
1139
|
+
} from "./integration-auth";
|
|
1140
|
+
|
|
1141
|
+
function isEditableDraft(item: StoredPost) {
|
|
1142
|
+
return (
|
|
1143
|
+
item.status === "New" && !item.published && item.draftEditable !== false
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
export {
|
|
1147
|
+
createPostOwnership,
|
|
1148
|
+
createMyFeedbackModule,
|
|
1149
|
+
type MyFeedbackFilter,
|
|
1150
|
+
type MyFeedbackItem,
|
|
1151
|
+
type MyFeedbackPage,
|
|
1152
|
+
} from "./ownership";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function integrationSecret(
|
|
4
|
+
master: string,
|
|
5
|
+
installationId: string,
|
|
6
|
+
direction: "portal" | "service",
|
|
7
|
+
version: number,
|
|
8
|
+
) {
|
|
9
|
+
if (master.length < 32)
|
|
10
|
+
throw new Error(
|
|
11
|
+
"Integration signing secret must have at least 32 characters.",
|
|
12
|
+
);
|
|
13
|
+
return createHmac("sha256", master)
|
|
14
|
+
.update(
|
|
15
|
+
JSON.stringify([
|
|
16
|
+
"feedbax-integration-v1",
|
|
17
|
+
installationId,
|
|
18
|
+
direction,
|
|
19
|
+
version,
|
|
20
|
+
]),
|
|
21
|
+
)
|
|
22
|
+
.digest("base64url");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function signIntegrationRequest(input: {
|
|
26
|
+
url: string;
|
|
27
|
+
installationId: string;
|
|
28
|
+
secret: string;
|
|
29
|
+
body?: string;
|
|
30
|
+
method?: string;
|
|
31
|
+
now?: number;
|
|
32
|
+
nonce?: string;
|
|
33
|
+
}) {
|
|
34
|
+
const method = input.method ?? "POST";
|
|
35
|
+
const time = String(Math.floor((input.now ?? Date.now()) / 1000));
|
|
36
|
+
const nonce = input.nonce ?? randomUUID();
|
|
37
|
+
const body = input.body ?? "";
|
|
38
|
+
const signature = mac(
|
|
39
|
+
input.secret,
|
|
40
|
+
method,
|
|
41
|
+
input.url,
|
|
42
|
+
input.installationId,
|
|
43
|
+
time,
|
|
44
|
+
nonce,
|
|
45
|
+
body,
|
|
46
|
+
);
|
|
47
|
+
return new Request(input.url, {
|
|
48
|
+
method,
|
|
49
|
+
redirect: "manual",
|
|
50
|
+
headers: {
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"X-Feedbax-Installation": input.installationId,
|
|
53
|
+
"X-Feedbax-Time": time,
|
|
54
|
+
"X-Feedbax-Nonce": nonce,
|
|
55
|
+
"X-Feedbax-Signature": signature,
|
|
56
|
+
},
|
|
57
|
+
...(method === "GET" ? {} : { body }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function verifyIntegrationRequest(
|
|
62
|
+
request: Request,
|
|
63
|
+
body: string,
|
|
64
|
+
secret: string,
|
|
65
|
+
now = Date.now(),
|
|
66
|
+
) {
|
|
67
|
+
const installationId = request.headers.get("X-Feedbax-Installation") ?? "";
|
|
68
|
+
const time = request.headers.get("X-Feedbax-Time") ?? "";
|
|
69
|
+
const nonce = request.headers.get("X-Feedbax-Nonce") ?? "";
|
|
70
|
+
const signature = request.headers.get("X-Feedbax-Signature") ?? "";
|
|
71
|
+
if (
|
|
72
|
+
secret.length < 32 ||
|
|
73
|
+
!/^[A-Za-z0-9_-]{1,128}$/.test(installationId) ||
|
|
74
|
+
!/^\d{10}$/.test(time) ||
|
|
75
|
+
Math.abs(now - Number(time) * 1000) > 300_000 ||
|
|
76
|
+
!/^[A-Za-z0-9_-]{16,128}$/.test(nonce)
|
|
77
|
+
)
|
|
78
|
+
return false;
|
|
79
|
+
return equalSignature(
|
|
80
|
+
signature,
|
|
81
|
+
mac(secret, request.method, request.url, installationId, time, nonce, body),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function equalSignature(actual: string, expected: string) {
|
|
86
|
+
const a = Buffer.from(actual);
|
|
87
|
+
const b = Buffer.from(expected);
|
|
88
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
89
|
+
}
|
|
90
|
+
function mac(
|
|
91
|
+
secret: string,
|
|
92
|
+
method: string,
|
|
93
|
+
url: string,
|
|
94
|
+
installation: string,
|
|
95
|
+
time: string,
|
|
96
|
+
nonce: string,
|
|
97
|
+
body: string,
|
|
98
|
+
) {
|
|
99
|
+
return createHmac("sha256", secret)
|
|
100
|
+
.update(
|
|
101
|
+
JSON.stringify([
|
|
102
|
+
"feedbax-integration-v1",
|
|
103
|
+
method,
|
|
104
|
+
url,
|
|
105
|
+
installation,
|
|
106
|
+
time,
|
|
107
|
+
nonce,
|
|
108
|
+
body,
|
|
109
|
+
]),
|
|
110
|
+
)
|
|
111
|
+
.digest("hex");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function boundedBody(request: Request, maximum: number) {
|
|
115
|
+
if (Number(request.headers.get("Content-Length")) > maximum)
|
|
116
|
+
throw new Error("Request body limit exceeded.");
|
|
117
|
+
const reader = request.body?.getReader();
|
|
118
|
+
const chunks: Uint8Array[] = [];
|
|
119
|
+
let size = 0;
|
|
120
|
+
if (reader)
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done) break;
|
|
124
|
+
size += value.byteLength;
|
|
125
|
+
if (size > maximum) {
|
|
126
|
+
await reader.cancel();
|
|
127
|
+
throw new Error("Request body limit exceeded.");
|
|
128
|
+
}
|
|
129
|
+
chunks.push(value);
|
|
130
|
+
}
|
|
131
|
+
const bytes = new Uint8Array(size);
|
|
132
|
+
let offset = 0;
|
|
133
|
+
for (const chunk of chunks) {
|
|
134
|
+
bytes.set(chunk, offset);
|
|
135
|
+
offset += chunk.byteLength;
|
|
136
|
+
}
|
|
137
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
138
|
+
}
|
|
@@ -1,44 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CommentStorage, StoredComment } from "./comment-storage";
|
|
2
|
+
export type { CommentStorage, StoredComment } from "./comment-storage";
|
|
2
3
|
import type { NotionFeedbackStorageOptions } from "./notion-feedback";
|
|
3
4
|
import { requestNotion } from "./notion-request";
|
|
4
5
|
|
|
5
6
|
const NOTION_API_URL = "https://api.notion.com/v1";
|
|
6
7
|
const NOTION_API_VERSION = "2026-03-11";
|
|
7
8
|
|
|
8
|
-
export interface StoredComment {
|
|
9
|
-
id: string;
|
|
10
|
-
postId: string;
|
|
11
|
-
discussionId: string;
|
|
12
|
-
body: string;
|
|
13
|
-
author: CommentAuthor;
|
|
14
|
-
createdAt: Date;
|
|
15
|
-
resolved: boolean;
|
|
16
|
-
scope?: "page" | "block";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface CommentStorage {
|
|
20
|
-
createPageComment(input: {
|
|
21
|
-
postId: string;
|
|
22
|
-
body: string;
|
|
23
|
-
displayName: string;
|
|
24
|
-
}): Promise<StoredComment>;
|
|
25
|
-
createDiscussionReply(input: {
|
|
26
|
-
postId: string;
|
|
27
|
-
discussionId: string;
|
|
28
|
-
body: string;
|
|
29
|
-
displayName: string;
|
|
30
|
-
}): Promise<StoredComment>;
|
|
31
|
-
listPageComments(
|
|
32
|
-
postId: string,
|
|
33
|
-
cursor?: string,
|
|
34
|
-
): Promise<{
|
|
35
|
-
items: StoredComment[];
|
|
36
|
-
nextCursor?: string;
|
|
37
|
-
}>;
|
|
38
|
-
updateComment(commentId: string, body: string): Promise<StoredComment>;
|
|
39
|
-
deleteComment(commentId: string): Promise<void>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
9
|
export function createNotionCommentStorage({
|
|
43
10
|
token,
|
|
44
11
|
request = fetch,
|