@hazem3500/komo 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NODE.md +80 -0
- package/NOTICE.md +45 -0
- package/README.md +411 -2
- package/cli/agent.mjs +615 -0
- package/cli/config.mjs +34 -0
- package/cli/index.mjs +405 -0
- package/cli/project.mjs +136 -0
- package/cli/workflow.mjs +42 -0
- package/dist/MorphingMenu.d.ts +30 -0
- package/dist/PointerIcon.d.ts +2 -0
- package/dist/SmileIcon.d.ts +2 -0
- package/dist/THIRD_PARTY_NOTICES.txt +387 -0
- package/dist/accent-picker-F5UI2NP7.js +1 -0
- package/dist/accent-picker.d.ts +4 -0
- package/dist/accent.d.ts +2 -0
- package/dist/account-usage.d.ts +2 -0
- package/dist/action-menu.d.ts +10 -0
- package/dist/agent-prompt.d.ts +6 -0
- package/dist/agent-prompt.js +1 -0
- package/dist/anchors.d.ts +48 -0
- package/dist/api.d.ts +39 -0
- package/dist/approved-sites.d.ts +9 -0
- package/dist/card-motion.d.ts +8 -0
- package/dist/chunk-7YUXYVQA.js +1 -0
- package/dist/chunk-I7AUKTXE.js +1 -0
- package/dist/chunk-JQVBZ2BB.js +1 -0
- package/dist/chunk-Q7WSWLMV.js +1 -0
- package/dist/chunk-Y2GRLW4Q.js +1 -0
- package/dist/chunk-YZP3KX4Y.js +1 -0
- package/dist/chunk-Z2ABBESO.js +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/connect-project.d.ts +14 -0
- package/dist/connection-issue.d.ts +7 -0
- package/dist/design-comment-QPTW66JO.js +1 -0
- package/dist/design-comment.d.ts +42 -0
- package/dist/design-export-BRBI7S3V.js +1 -0
- package/dist/design-export.d.ts +8 -0
- package/dist/design-mode-styles.d.ts +1 -0
- package/dist/design-mode.d.ts +19 -0
- package/dist/design-mode.js +1 -0
- package/dist/design-panel-BPVXFERR.js +1 -0
- package/dist/design-panel-styles.d.ts +1 -0
- package/dist/design-panel.d.ts +33 -0
- package/dist/design-tokens.d.ts +76 -0
- package/dist/dom.d.ts +20 -0
- package/dist/drawer-expand-motion.d.ts +46 -0
- package/dist/drawer-presence.d.ts +4 -0
- package/dist/edge-sidebar.d.ts +39 -0
- package/dist/emoji-history.d.ts +8 -0
- package/dist/emoji.d.ts +1 -0
- package/dist/floating-drag.d.ts +9 -0
- package/dist/icon-markup.d.ts +4 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1 -0
- package/dist/inline-text-editor-F7B6FQQ3.js +1 -0
- package/dist/inline-text-editor.d.ts +70 -0
- package/dist/lazy-accent-picker.d.ts +4 -0
- package/dist/lazy-toolbar.d.ts +19 -0
- package/dist/message-links.d.ts +2 -0
- package/dist/morphing-menu-styles.d.ts +1 -0
- package/dist/node.mjs +2607 -0
- package/dist/onboarding-NO3RQWR7.js +1 -0
- package/dist/onboarding.d.ts +6 -0
- package/dist/optimistic.d.ts +26 -0
- package/dist/page.d.ts +2 -0
- package/dist/picker-WQN53YJ5.js +1 -0
- package/dist/pin-direction.d.ts +12 -0
- package/dist/pin-stacks.d.ts +3 -0
- package/dist/polling.d.ts +10 -0
- package/dist/postgres/001_initial.sql +128 -0
- package/dist/postgres/002_thread_tags.sql +1 -0
- package/dist/postgres/003_project_webhooks.sql +4 -0
- package/dist/project-management.d.ts +2 -0
- package/dist/react-inspector.d.ts +43 -0
- package/dist/react-props-panel-XSMDR2CY.js +1 -0
- package/dist/react-props-panel.d.ts +39 -0
- package/dist/react.d.ts +38 -0
- package/dist/react.js +1 -0
- package/dist/reaction-picker.d.ts +1 -0
- package/dist/review-layout.d.ts +23 -0
- package/dist/setup-browser.d.ts +1 -0
- package/dist/setup.d.ts +4 -0
- package/dist/setup.js +1 -0
- package/dist/shortcuts.d.ts +15 -0
- package/dist/static-svg.d.ts +3 -0
- package/dist/styles.d.ts +1 -0
- package/dist/tag-editor-VDPZ6CO3.js +1 -0
- package/dist/tag-editor.d.ts +12 -0
- package/dist/toolbar-runtime-2SIUE6SQ.js +1 -0
- package/dist/toolbar-runtime.d.ts +5 -0
- package/dist/types.d.ts +163 -0
- package/package.json +114 -4
- package/server/digest.ts +8 -0
- package/server/import-project.ts +164 -0
- package/server/index.ts +1656 -0
- package/server/migrations/0001_comments.sql +54 -0
- package/server/migrations/0002_identity.sql +2 -0
- package/server/migrations/0003_accent_color.sql +1 -0
- package/server/migrations/0004_workspaces.sql +46 -0
- package/server/migrations/0005_domains.sql +6 -0
- package/server/migrations/0006_member_budget.sql +16 -0
- package/server/migrations/0007_revisions.sql +17 -0
- package/server/migrations/0008_starter_comments.sql +1 -0
- package/server/migrations/0009_retention.sql +19 -0
- package/server/migrations/0010_reviewer_history.sql +1 -0
- package/server/migrations/0011_project_management.sql +52 -0
- package/server/migrations/0012_setup_sites.sql +3 -0
- package/server/migrations/0013_project_sites.sql +8 -0
- package/server/migrations/0014_project_site_removals.sql +3 -0
- package/server/migrations/0015_query_efficiency.sql +6 -0
- package/server/migrations/0016_thread_tags.sql +1 -0
- package/server/migrations/0017_project_webhooks.sql +4 -0
- package/server/node-webhooks.ts +126 -0
- package/server/project-management.ts +387 -0
- package/server/project-sites.ts +117 -0
- package/server/secrets.d.ts +24 -0
- package/server/setup-client.txt +2932 -0
- package/server/setup-page.ts +14 -0
- package/server/tsconfig.json +12 -0
- package/server/validation.ts +220 -0
- package/server/webhooks.ts +206 -0
- package/server/workspaces.ts +168 -0
- package/src/emoji.ts +7 -0
- package/src/page.ts +4 -0
- package/src/types.ts +166 -0
package/server/index.ts
ADDED
|
@@ -0,0 +1,1656 @@
|
|
|
1
|
+
import { projectSites, saveProjectSites } from "./project-sites";
|
|
2
|
+
import {
|
|
3
|
+
manageProject,
|
|
4
|
+
privateAccess,
|
|
5
|
+
requireMember,
|
|
6
|
+
} from "./project-management";
|
|
7
|
+
import { setupPage } from "./setup-page";
|
|
8
|
+
import { authPrefix, iapIdentity, type IapIdentity } from "./iap";
|
|
9
|
+
import setupClient from "./setup-client.txt";
|
|
10
|
+
import {
|
|
11
|
+
dispatchWebhook,
|
|
12
|
+
type WebhookEvent,
|
|
13
|
+
} from "./webhooks";
|
|
14
|
+
import {
|
|
15
|
+
projectConfig,
|
|
16
|
+
maintain,
|
|
17
|
+
workspaceConfig,
|
|
18
|
+
provision,
|
|
19
|
+
googleOwner,
|
|
20
|
+
retainReviewerComments,
|
|
21
|
+
type Project,
|
|
22
|
+
} from "./workspaces";
|
|
23
|
+
import { isEmoji } from "../src/emoji.js";
|
|
24
|
+
import { timingSafeEqual } from "node:crypto";
|
|
25
|
+
import type { Identity, Thread, Comment } from "../src/types.js";
|
|
26
|
+
import {
|
|
27
|
+
anchorValue,
|
|
28
|
+
cliReturnOrigin,
|
|
29
|
+
check,
|
|
30
|
+
HttpError,
|
|
31
|
+
localOrigin,
|
|
32
|
+
originAllowed,
|
|
33
|
+
previewPattern,
|
|
34
|
+
pagePath,
|
|
35
|
+
string,
|
|
36
|
+
} from "./validation";
|
|
37
|
+
|
|
38
|
+
type UserRow = {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
verified: number;
|
|
42
|
+
avatar_url?: string;
|
|
43
|
+
accent_color?: string;
|
|
44
|
+
};
|
|
45
|
+
type ThreadRow = {
|
|
46
|
+
id: string;
|
|
47
|
+
page: string;
|
|
48
|
+
anchor: string;
|
|
49
|
+
resolved: number;
|
|
50
|
+
resolved_by: string | null;
|
|
51
|
+
created_at: number;
|
|
52
|
+
updated_at: number;
|
|
53
|
+
tags: string;
|
|
54
|
+
resolver_name: string | null;
|
|
55
|
+
resolver_verified: number | null;
|
|
56
|
+
};
|
|
57
|
+
type CommentRow = {
|
|
58
|
+
id: string;
|
|
59
|
+
thread_id: string;
|
|
60
|
+
body: string;
|
|
61
|
+
user_id: string;
|
|
62
|
+
avatar_url?: string;
|
|
63
|
+
accent_color?: string;
|
|
64
|
+
name: string;
|
|
65
|
+
verified: number;
|
|
66
|
+
created_at: number;
|
|
67
|
+
edited_at: number | null;
|
|
68
|
+
};
|
|
69
|
+
const identity = (row: UserRow): Identity => ({
|
|
70
|
+
id: row.id,
|
|
71
|
+
name: row.name,
|
|
72
|
+
verified: !!row.verified,
|
|
73
|
+
avatarUrl: row.avatar_url || undefined,
|
|
74
|
+
accentColor: row.accent_color || undefined,
|
|
75
|
+
});
|
|
76
|
+
function webhookEvent(
|
|
77
|
+
event: WebhookEvent["event"],
|
|
78
|
+
projectKey: string,
|
|
79
|
+
repo: string,
|
|
80
|
+
branch: string,
|
|
81
|
+
threadId: string,
|
|
82
|
+
page: string,
|
|
83
|
+
anchor: string,
|
|
84
|
+
author: string,
|
|
85
|
+
body: string | null,
|
|
86
|
+
origin: string
|
|
87
|
+
): WebhookEvent {
|
|
88
|
+
let selector = "";
|
|
89
|
+
try {
|
|
90
|
+
selector = (JSON.parse(anchor) as { selector?: string }).selector ?? "";
|
|
91
|
+
} catch {
|
|
92
|
+
// A malformed stored anchor must not fail the comment request.
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
id: crypto.randomUUID(),
|
|
96
|
+
event,
|
|
97
|
+
occurredAt: new Date().toISOString(),
|
|
98
|
+
projectKey,
|
|
99
|
+
repo,
|
|
100
|
+
branch,
|
|
101
|
+
threadId,
|
|
102
|
+
pageUrl: new URL(page, origin).toString(),
|
|
103
|
+
selector,
|
|
104
|
+
author,
|
|
105
|
+
body,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const json = (data: unknown, status = 200) => Response.json(data, { status });
|
|
109
|
+
const token = () => crypto.randomUUID() + crypto.randomUUID();
|
|
110
|
+
// Sessions should not force reviewers back through sign-in:
|
|
111
|
+
// keep them valid for a century and only revoke on logout.
|
|
112
|
+
const SESSION_LIFETIME = 100 * 365 * 86400000;
|
|
113
|
+
async function hash(value: string) {
|
|
114
|
+
return Array.from(
|
|
115
|
+
new Uint8Array(
|
|
116
|
+
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
|
|
117
|
+
),
|
|
118
|
+
(byte) => byte.toString(16).padStart(2, "0")
|
|
119
|
+
).join("");
|
|
120
|
+
}
|
|
121
|
+
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
122
|
+
check(
|
|
123
|
+
request.headers.get("Content-Type")?.split(";")[0] === "application/json",
|
|
124
|
+
415,
|
|
125
|
+
"Use application/json."
|
|
126
|
+
);
|
|
127
|
+
const reader = request.body?.getReader();
|
|
128
|
+
check(reader, 400, "Missing body.");
|
|
129
|
+
let length = 0;
|
|
130
|
+
const parts: Uint8Array[] = [];
|
|
131
|
+
for (;;) {
|
|
132
|
+
const { done, value } = await reader.read();
|
|
133
|
+
if (done) break;
|
|
134
|
+
length += value.length;
|
|
135
|
+
if (length > 16384) {
|
|
136
|
+
await reader.cancel();
|
|
137
|
+
throw new HttpError(413, "Request too large.");
|
|
138
|
+
}
|
|
139
|
+
parts.push(value);
|
|
140
|
+
}
|
|
141
|
+
const bytes = new Uint8Array(length);
|
|
142
|
+
let offset = 0;
|
|
143
|
+
for (const part of parts) {
|
|
144
|
+
bytes.set(part, offset);
|
|
145
|
+
offset += part.length;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const result = JSON.parse(new TextDecoder().decode(bytes));
|
|
149
|
+
check(
|
|
150
|
+
result && typeof result === "object" && !Array.isArray(result),
|
|
151
|
+
400,
|
|
152
|
+
"Invalid JSON object."
|
|
153
|
+
);
|
|
154
|
+
const path = new URL(request.url).pathname;
|
|
155
|
+
const allowed = path.startsWith("/project")
|
|
156
|
+
? path === "/project/import"
|
|
157
|
+
? ["source", "kind", "records"]
|
|
158
|
+
: path === "/project/join"
|
|
159
|
+
? ["invite"]
|
|
160
|
+
: path === "/project/invites"
|
|
161
|
+
? ["email"]
|
|
162
|
+
: path === "/project/members"
|
|
163
|
+
? ["user"]
|
|
164
|
+
: path === "/project/sites"
|
|
165
|
+
? ["sites"]
|
|
166
|
+
: path === "/project/webhook"
|
|
167
|
+
? ["url", "secret", "events", "enabled"]
|
|
168
|
+
: ["access", "confirm", "threadIds"]
|
|
169
|
+
: ["/auth/google/start", "/auth/github/start"].includes(path)
|
|
170
|
+
? ["returnOrigin"]
|
|
171
|
+
: path === "/setup/start"
|
|
172
|
+
? ["repo", "origins"]
|
|
173
|
+
: path === "/setup/poll"
|
|
174
|
+
? ["id", "secret"]
|
|
175
|
+
: path === "/setup/complete"
|
|
176
|
+
? ["code"]
|
|
177
|
+
: ["/workspace/sites", "/workspace/verify"].includes(path)
|
|
178
|
+
? ["project", "origin"]
|
|
179
|
+
: path === "/owner/claim"
|
|
180
|
+
? ["key"]
|
|
181
|
+
: path === "/auth/guest"
|
|
182
|
+
? ["name"]
|
|
183
|
+
: path.endsWith("/reactions")
|
|
184
|
+
? ["emoji", "active"]
|
|
185
|
+
: path === "/me"
|
|
186
|
+
? ["name", "avatarUrl", "accentColor"]
|
|
187
|
+
: path === "/threads"
|
|
188
|
+
? ["body", "page", "anchor", "tags"]
|
|
189
|
+
: path.endsWith("/comments")
|
|
190
|
+
? ["body"]
|
|
191
|
+
: path.includes("/comments/")
|
|
192
|
+
? ["body"]
|
|
193
|
+
: path.startsWith("/threads/")
|
|
194
|
+
? ["anchor", "resolved", "tag", "untag"]
|
|
195
|
+
: [];
|
|
196
|
+
check(
|
|
197
|
+
Object.keys(result).every((key) => allowed.includes(key)),
|
|
198
|
+
400,
|
|
199
|
+
"Unexpected request fields."
|
|
200
|
+
);
|
|
201
|
+
return result;
|
|
202
|
+
} catch {
|
|
203
|
+
throw new HttpError(400, "Invalid JSON object.");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function limit(env: Env, key: string, max: number, seconds = 60) {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const bucket = Math.floor(now / (seconds * 1000));
|
|
209
|
+
const row = await env.DB.prepare(
|
|
210
|
+
"INSERT INTO rate_limits(key,count,expires_at) VALUES(?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1 RETURNING count"
|
|
211
|
+
)
|
|
212
|
+
.bind(`${key}:${bucket}`, now + seconds * 2000)
|
|
213
|
+
.first<{ count: number }>();
|
|
214
|
+
check(row && row.count <= max, 429, "Too many requests. Try again shortly.");
|
|
215
|
+
}
|
|
216
|
+
async function session(env: Env, project: string, userId: string) {
|
|
217
|
+
const accessToken = token();
|
|
218
|
+
await env.DB.batch([
|
|
219
|
+
env.DB.prepare(
|
|
220
|
+
"INSERT OR IGNORE INTO project_members(project,user_id) SELECT ?,? WHERE NOT EXISTS(SELECT 1 FROM project_settings WHERE project=? AND access='private') OR EXISTS(SELECT 1 FROM project_owners WHERE project=? AND user_id=?) OR EXISTS(SELECT 1 FROM project_access WHERE project=? AND user_id=?)"
|
|
221
|
+
).bind(project, userId, project, project, userId, project, userId),
|
|
222
|
+
env.DB.prepare(
|
|
223
|
+
"INSERT INTO sessions(token_hash,user_id,project,expires_at) VALUES(?,?,?,?)"
|
|
224
|
+
).bind(
|
|
225
|
+
await hash(accessToken),
|
|
226
|
+
userId,
|
|
227
|
+
project,
|
|
228
|
+
Date.now() + SESSION_LIFETIME
|
|
229
|
+
),
|
|
230
|
+
]);
|
|
231
|
+
return accessToken;
|
|
232
|
+
}
|
|
233
|
+
async function authenticate(
|
|
234
|
+
request: Request,
|
|
235
|
+
env: Env,
|
|
236
|
+
project: string
|
|
237
|
+
): Promise<Identity> {
|
|
238
|
+
const bearer = request.headers
|
|
239
|
+
.get("Authorization")
|
|
240
|
+
?.match(/^Bearer (.{1,200})$/)?.[1];
|
|
241
|
+
check(bearer, 401, "Enter your name to comment.");
|
|
242
|
+
const row = await env.DB.prepare(
|
|
243
|
+
"SELECT u.* FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.project=? AND s.expires_at>?"
|
|
244
|
+
)
|
|
245
|
+
.bind(await hash(bearer), project, Date.now())
|
|
246
|
+
.first<UserRow>();
|
|
247
|
+
check(row, 401, "Your session expired. Enter your name again.");
|
|
248
|
+
return identity(row);
|
|
249
|
+
}
|
|
250
|
+
// Both hosted recovery and in-project OAuth use the same one-use setup claim.
|
|
251
|
+
async function completeSetup(
|
|
252
|
+
env: Env,
|
|
253
|
+
user: Identity,
|
|
254
|
+
code: string,
|
|
255
|
+
origin?: string,
|
|
256
|
+
sites: string[] = origin ? [origin] : []
|
|
257
|
+
) {
|
|
258
|
+
const setup = await env.DB.prepare(
|
|
259
|
+
"DELETE FROM setup_requests WHERE id=? AND project IS NULL AND expires_at>? RETURNING *"
|
|
260
|
+
)
|
|
261
|
+
.bind(code, Date.now())
|
|
262
|
+
.first<{ poll_hash: string; config: string; expires_at: number }>();
|
|
263
|
+
check(setup, 409, "Setup expired or already completed. Run komo init again.");
|
|
264
|
+
const config = JSON.parse(setup.config);
|
|
265
|
+
let created: { project: string; repo: string };
|
|
266
|
+
try {
|
|
267
|
+
if (origin)
|
|
268
|
+
check(
|
|
269
|
+
originAllowed(origin, config.origins),
|
|
270
|
+
403,
|
|
271
|
+
"Return to the site where you started setup."
|
|
272
|
+
);
|
|
273
|
+
created = await provision(env, user, config);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
// A quota or validation error must not turn a retry into an expired request.
|
|
276
|
+
await env.DB.prepare(
|
|
277
|
+
"INSERT INTO setup_requests(id,poll_hash,config,expires_at) VALUES(?,?,?,?)"
|
|
278
|
+
)
|
|
279
|
+
.bind(code, setup.poll_hash, setup.config, setup.expires_at)
|
|
280
|
+
.run();
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
// The site setup ran on proves the deploy host, so its previews come too.
|
|
284
|
+
const preview = origin && previewPattern(origin);
|
|
285
|
+
const httpsSites = [
|
|
286
|
+
...new Set([...sites, ...(preview ? [preview] : [])]),
|
|
287
|
+
].filter((site) => site.startsWith("https://"));
|
|
288
|
+
if (httpsSites.length) {
|
|
289
|
+
await env.DB.batch(
|
|
290
|
+
httpsSites.map((site) =>
|
|
291
|
+
env.DB.prepare(
|
|
292
|
+
"INSERT INTO workspace_domains(project,origin,verified_at) VALUES(?,?,?)"
|
|
293
|
+
).bind(created.project, site, Date.now())
|
|
294
|
+
)
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
await env.DB.prepare(
|
|
298
|
+
"INSERT INTO setup_requests(id,poll_hash,config,project,expires_at) VALUES(?,?,?,?,?)"
|
|
299
|
+
)
|
|
300
|
+
.bind(
|
|
301
|
+
code,
|
|
302
|
+
setup.poll_hash,
|
|
303
|
+
setup.config,
|
|
304
|
+
created.project,
|
|
305
|
+
setup.expires_at
|
|
306
|
+
)
|
|
307
|
+
.run();
|
|
308
|
+
return created;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function oauthAuthorize(
|
|
312
|
+
request: Request,
|
|
313
|
+
env: Env,
|
|
314
|
+
provider: "github" | "google"
|
|
315
|
+
) {
|
|
316
|
+
const url = new URL(request.url);
|
|
317
|
+
const state = string(url.searchParams.get("state"), 200, "state");
|
|
318
|
+
const saved = await env.DB.prepare(
|
|
319
|
+
"SELECT verifier FROM oauth_states WHERE state_hash=? AND expires_at>? AND provider=?"
|
|
320
|
+
)
|
|
321
|
+
.bind(await hash(state), Date.now(), provider)
|
|
322
|
+
.first<{ verifier: string }>();
|
|
323
|
+
check(saved, 400, "Sign-in expired. Return to the site and try again.");
|
|
324
|
+
const digest = new Uint8Array(
|
|
325
|
+
await crypto.subtle.digest(
|
|
326
|
+
"SHA-256",
|
|
327
|
+
new TextEncoder().encode(saved.verifier)
|
|
328
|
+
)
|
|
329
|
+
);
|
|
330
|
+
const challenge = btoa(String.fromCharCode(...digest))
|
|
331
|
+
.replace(/\+/g, "-")
|
|
332
|
+
.replace(/\//g, "_")
|
|
333
|
+
.replace(/=+$/, "");
|
|
334
|
+
const authorize = new URL(
|
|
335
|
+
provider === "google"
|
|
336
|
+
? "https://accounts.google.com/o/oauth2/v2/auth"
|
|
337
|
+
: "https://github.com/login/oauth/authorize"
|
|
338
|
+
);
|
|
339
|
+
authorize.search = new URLSearchParams({
|
|
340
|
+
client_id:
|
|
341
|
+
provider === "google" ? env.GOOGLE_CLIENT_ID : env.GITHUB_CLIENT_ID,
|
|
342
|
+
redirect_uri: `${url.origin}${env.KOMO_PUBLIC_PATH ?? ""}/auth/${provider}/callback`,
|
|
343
|
+
...(provider === "google"
|
|
344
|
+
? { response_type: "code", scope: "openid profile email" }
|
|
345
|
+
: {}),
|
|
346
|
+
state,
|
|
347
|
+
code_challenge: challenge,
|
|
348
|
+
code_challenge_method: "S256",
|
|
349
|
+
}).toString();
|
|
350
|
+
return new Response(null, {
|
|
351
|
+
status: 302,
|
|
352
|
+
headers: {
|
|
353
|
+
Location: authorize.href,
|
|
354
|
+
"Set-Cookie": `__Host-comments-oauth=${state}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
async function oauthCallback(
|
|
359
|
+
request: Request,
|
|
360
|
+
env: Env,
|
|
361
|
+
provider: "github" | "google"
|
|
362
|
+
) {
|
|
363
|
+
const url = new URL(request.url);
|
|
364
|
+
const state = url.searchParams.get("state") ?? "";
|
|
365
|
+
const code = url.searchParams.get("code");
|
|
366
|
+
const cookie =
|
|
367
|
+
request.headers
|
|
368
|
+
.get("Cookie")
|
|
369
|
+
?.match(/(?:^|;\s*)__Host-comments-oauth=([^;]+)/)?.[1] ?? "";
|
|
370
|
+
const encoder = new TextEncoder();
|
|
371
|
+
check(
|
|
372
|
+
state.length > 0 &&
|
|
373
|
+
cookie.length === state.length &&
|
|
374
|
+
timingSafeEqual(encoder.encode(state), encoder.encode(cookie)),
|
|
375
|
+
400,
|
|
376
|
+
"Sign-in browser does not match. Return to the site and try again."
|
|
377
|
+
);
|
|
378
|
+
const saved = await env.DB.prepare(
|
|
379
|
+
"DELETE FROM oauth_states WHERE state_hash=? AND expires_at>? AND provider=? RETURNING *"
|
|
380
|
+
)
|
|
381
|
+
.bind(await hash(state), Date.now(), provider)
|
|
382
|
+
.first<{
|
|
383
|
+
verifier: string;
|
|
384
|
+
project: string;
|
|
385
|
+
origin: string;
|
|
386
|
+
exchange_hash: string;
|
|
387
|
+
approved_origins: string | null;
|
|
388
|
+
}>();
|
|
389
|
+
check(
|
|
390
|
+
saved &&
|
|
391
|
+
code &&
|
|
392
|
+
(provider === "google"
|
|
393
|
+
? env.GOOGLE_CLIENT_SECRET
|
|
394
|
+
: env.GITHUB_CLIENT_SECRET),
|
|
395
|
+
400,
|
|
396
|
+
"Sign-in expired or was cancelled. Close this window and try again."
|
|
397
|
+
);
|
|
398
|
+
const response = await fetch(
|
|
399
|
+
provider === "google"
|
|
400
|
+
? "https://oauth2.googleapis.com/token"
|
|
401
|
+
: "https://github.com/login/oauth/access_token",
|
|
402
|
+
{
|
|
403
|
+
method: "POST",
|
|
404
|
+
headers: {
|
|
405
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
406
|
+
Accept: "application/json",
|
|
407
|
+
},
|
|
408
|
+
body: new URLSearchParams({
|
|
409
|
+
client_id:
|
|
410
|
+
provider === "google" ? env.GOOGLE_CLIENT_ID : env.GITHUB_CLIENT_ID,
|
|
411
|
+
client_secret:
|
|
412
|
+
provider === "google"
|
|
413
|
+
? env.GOOGLE_CLIENT_SECRET
|
|
414
|
+
: env.GITHUB_CLIENT_SECRET,
|
|
415
|
+
grant_type: "authorization_code",
|
|
416
|
+
code,
|
|
417
|
+
redirect_uri: `${url.origin}${env.KOMO_PUBLIC_PATH ?? ""}/auth/${provider}/callback`,
|
|
418
|
+
code_verifier: saved.verifier,
|
|
419
|
+
}),
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
const result = await response.json<{ access_token?: string }>();
|
|
423
|
+
check(response.ok && result.access_token, 401, "Sign-in failed.");
|
|
424
|
+
const profileResponse = await fetch(
|
|
425
|
+
provider === "google"
|
|
426
|
+
? "https://openidconnect.googleapis.com/v1/userinfo"
|
|
427
|
+
: "https://api.github.com/user",
|
|
428
|
+
{
|
|
429
|
+
headers: {
|
|
430
|
+
Authorization: `Bearer ${result.access_token}`,
|
|
431
|
+
Accept: "application/vnd.github+json",
|
|
432
|
+
"User-Agent": "branch-comments",
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
check(profileResponse.ok, 401, "Could not verify identity.");
|
|
437
|
+
const profile = await profileResponse.json<{
|
|
438
|
+
sub?: string;
|
|
439
|
+
picture?: string;
|
|
440
|
+
avatar_url?: string;
|
|
441
|
+
accent_color?: string;
|
|
442
|
+
id: number;
|
|
443
|
+
login: string;
|
|
444
|
+
name: string | null;
|
|
445
|
+
email?: string;
|
|
446
|
+
email_verified?: boolean;
|
|
447
|
+
}>();
|
|
448
|
+
check(
|
|
449
|
+
provider === "google"
|
|
450
|
+
? typeof profile.sub === "string" && !!profile.sub && !!profile.name
|
|
451
|
+
: Number.isInteger(profile.id) && !!profile.login,
|
|
452
|
+
401,
|
|
453
|
+
"Invalid identity."
|
|
454
|
+
);
|
|
455
|
+
const id = `${provider}:${provider === "google" ? profile.sub : profile.id}`;
|
|
456
|
+
const avatarUrl = profile.picture || profile.avatar_url || "";
|
|
457
|
+
const safeAvatar = /^https:\/\//.test(avatarUrl) ? avatarUrl : "";
|
|
458
|
+
await env.DB.prepare(
|
|
459
|
+
"INSERT INTO users(id,name,verified,avatar_url) VALUES(?,?,1,?) ON CONFLICT(id) DO NOTHING"
|
|
460
|
+
)
|
|
461
|
+
.bind(id, (profile.name || profile.login).slice(0, 60), safeAvatar)
|
|
462
|
+
.run();
|
|
463
|
+
if (
|
|
464
|
+
provider === "google" &&
|
|
465
|
+
profile.email_verified === true &&
|
|
466
|
+
typeof profile.email === "string"
|
|
467
|
+
)
|
|
468
|
+
await env.DB.prepare("UPDATE users SET email=? WHERE id=?")
|
|
469
|
+
.bind(profile.email.toLowerCase(), id)
|
|
470
|
+
.run();
|
|
471
|
+
const user = identity(
|
|
472
|
+
(await env.DB.prepare("SELECT * FROM users WHERE id=?")
|
|
473
|
+
.bind(id)
|
|
474
|
+
.first<UserRow>())!
|
|
475
|
+
);
|
|
476
|
+
const setupCode =
|
|
477
|
+
env.KOMO_HOSTED === "true" &&
|
|
478
|
+
saved.approved_origins &&
|
|
479
|
+
saved.project.startsWith("setup:")
|
|
480
|
+
? saved.project.slice(6)
|
|
481
|
+
: undefined;
|
|
482
|
+
check(
|
|
483
|
+
!setupCode || provider === "google",
|
|
484
|
+
403,
|
|
485
|
+
"Use Google to connect komo."
|
|
486
|
+
);
|
|
487
|
+
const created = setupCode
|
|
488
|
+
? await completeSetup(
|
|
489
|
+
env,
|
|
490
|
+
user,
|
|
491
|
+
setupCode,
|
|
492
|
+
saved.origin,
|
|
493
|
+
saved.approved_origins
|
|
494
|
+
? JSON.parse(saved.approved_origins)
|
|
495
|
+
: [saved.origin]
|
|
496
|
+
)
|
|
497
|
+
: undefined;
|
|
498
|
+
const accessToken = await session(env, created?.project ?? saved.project, id);
|
|
499
|
+
return signedInPage(
|
|
500
|
+
{
|
|
501
|
+
type: created ? "komo:setup" : "branch-comments:auth",
|
|
502
|
+
...(created ? { ...created, code: setupCode } : {}),
|
|
503
|
+
token: accessToken,
|
|
504
|
+
user,
|
|
505
|
+
},
|
|
506
|
+
saved.origin
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
// The popup posts the session back to the site that opened it, then closes.
|
|
510
|
+
function signedInPage(message: Record<string, unknown>, origin: string) {
|
|
511
|
+
const nonce = crypto.randomUUID();
|
|
512
|
+
const body = JSON.stringify(message).replace(/</g, "\\u003c");
|
|
513
|
+
const target = JSON.stringify(origin).replace(/</g, "\\u003c");
|
|
514
|
+
return new Response(
|
|
515
|
+
`<!doctype html><meta charset="utf-8"><title>Signed in</title><p>You’re signed in. You can close this window and return to your comments.</p><script nonce="${nonce}">if(window.opener){window.opener.postMessage(${body},${target});window.close();}</script>`,
|
|
516
|
+
{
|
|
517
|
+
headers: {
|
|
518
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
519
|
+
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; frame-ancestors 'none'`,
|
|
520
|
+
"Cache-Control": "no-store",
|
|
521
|
+
"Set-Cookie":
|
|
522
|
+
"__Host-comments-oauth=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0",
|
|
523
|
+
},
|
|
524
|
+
}
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
// Records the IAP-verified Google account as a verified reviewer.
|
|
528
|
+
async function iapUser(env: Env, signedIn: IapIdentity) {
|
|
529
|
+
await env.DB.prepare(
|
|
530
|
+
"INSERT INTO users(id,name,verified) VALUES(?,?,1) ON CONFLICT(id) DO NOTHING"
|
|
531
|
+
)
|
|
532
|
+
.bind(signedIn.id, signedIn.email.split("@")[0].slice(0, 60))
|
|
533
|
+
.run();
|
|
534
|
+
await env.DB.prepare("UPDATE users SET email=?,verified=1 WHERE id=?")
|
|
535
|
+
.bind(signedIn.email, signedIn.id)
|
|
536
|
+
.run();
|
|
537
|
+
return identity(
|
|
538
|
+
(await env.DB.prepare("SELECT * FROM users WHERE id=?")
|
|
539
|
+
.bind(signedIn.id)
|
|
540
|
+
.first<UserRow>())!
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
// Behind IAP the authorize step is the whole sign-in: IAP has already
|
|
544
|
+
// authenticated the browser, so there is no Google redirect or callback.
|
|
545
|
+
async function iapSignIn(request: Request, env: Env) {
|
|
546
|
+
const url = new URL(request.url);
|
|
547
|
+
const state = string(url.searchParams.get("state"), 200, "state");
|
|
548
|
+
const signedIn = await iapIdentity(request, env.KOMO_IAP_AUDIENCE!);
|
|
549
|
+
const saved = await env.DB.prepare(
|
|
550
|
+
"DELETE FROM oauth_states WHERE state_hash=? AND expires_at>? AND provider=? RETURNING project,origin"
|
|
551
|
+
)
|
|
552
|
+
.bind(await hash(state), Date.now(), "google")
|
|
553
|
+
.first<{ project: string; origin: string }>();
|
|
554
|
+
check(saved, 400, "Sign-in expired. Return to the site and try again.");
|
|
555
|
+
const user = await iapUser(env, signedIn);
|
|
556
|
+
return signedInPage(
|
|
557
|
+
{
|
|
558
|
+
type: "branch-comments:auth",
|
|
559
|
+
token: await session(env, saved.project, user.id),
|
|
560
|
+
user,
|
|
561
|
+
},
|
|
562
|
+
saved.origin
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
async function route(
|
|
566
|
+
request: Request,
|
|
567
|
+
env: Env,
|
|
568
|
+
ctx: ExecutionContext
|
|
569
|
+
): Promise<Response> {
|
|
570
|
+
const url = new URL(request.url);
|
|
571
|
+
if (url.pathname === "/health") return json({ ok: true, version: env.KOMO_VERSION?.id });
|
|
572
|
+
if (url.pathname === "/setup-client.js" && request.method === "GET")
|
|
573
|
+
return new Response(setupClient, {
|
|
574
|
+
headers: { "Content-Type": "text/javascript; charset=utf-8" },
|
|
575
|
+
});
|
|
576
|
+
if (env.KOMO_PAUSED === "true")
|
|
577
|
+
throw new HttpError(503, "komo is temporarily paused.");
|
|
578
|
+
if (env.EDGE_LIMIT) {
|
|
579
|
+
const allowed = await env.EDGE_LIMIT.limit({
|
|
580
|
+
key: request.headers.get("CF-Connecting-IP") ?? "local",
|
|
581
|
+
});
|
|
582
|
+
check(allowed.success, 429, "Too many requests. Try again shortly.");
|
|
583
|
+
}
|
|
584
|
+
// Keep exact shared quotas: the per-location edge limiter cannot enforce a
|
|
585
|
+
// service-wide daily budget. Unchanged polls still consume these counters.
|
|
586
|
+
if (env.KOMO_HOSTED === "true")
|
|
587
|
+
await limit(env, "service:requests", 100000, 86400);
|
|
588
|
+
if (
|
|
589
|
+
url.pathname === "/setup/connect" &&
|
|
590
|
+
request.method === "GET" &&
|
|
591
|
+
env.KOMO_HOSTED === "true"
|
|
592
|
+
) {
|
|
593
|
+
check(
|
|
594
|
+
env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET,
|
|
595
|
+
503,
|
|
596
|
+
"Google sign-in is not configured."
|
|
597
|
+
);
|
|
598
|
+
await limit(
|
|
599
|
+
env,
|
|
600
|
+
`connect:${request.headers.get("CF-Connecting-IP") ?? "local"}`,
|
|
601
|
+
20,
|
|
602
|
+
3600
|
|
603
|
+
);
|
|
604
|
+
const code = string(url.searchParams.get("code"), 100, "setup code");
|
|
605
|
+
const origin = string(url.searchParams.get("origin"), 300, "site origin");
|
|
606
|
+
const setup = await env.DB.prepare(
|
|
607
|
+
"SELECT config FROM setup_requests WHERE id=? AND project IS NULL AND expires_at>?"
|
|
608
|
+
)
|
|
609
|
+
.bind(code, Date.now())
|
|
610
|
+
.first<{ config: string }>();
|
|
611
|
+
check(
|
|
612
|
+
setup,
|
|
613
|
+
409,
|
|
614
|
+
"Setup expired or already completed. Run komo init again."
|
|
615
|
+
);
|
|
616
|
+
check(
|
|
617
|
+
originAllowed(origin, JSON.parse(setup.config).origins),
|
|
618
|
+
403,
|
|
619
|
+
"Open the site configured by komo init."
|
|
620
|
+
);
|
|
621
|
+
let sites: unknown;
|
|
622
|
+
try {
|
|
623
|
+
sites = JSON.parse(
|
|
624
|
+
url.searchParams.get("sites") || JSON.stringify([origin])
|
|
625
|
+
);
|
|
626
|
+
} catch {
|
|
627
|
+
throw new HttpError(400, "Invalid site addresses.");
|
|
628
|
+
}
|
|
629
|
+
check(
|
|
630
|
+
Array.isArray(sites) &&
|
|
631
|
+
sites.length > 0 &&
|
|
632
|
+
sites.length <= 10 &&
|
|
633
|
+
sites.includes(origin) &&
|
|
634
|
+
sites.every(
|
|
635
|
+
(site) =>
|
|
636
|
+
typeof site === "string" &&
|
|
637
|
+
site.length <= 300 &&
|
|
638
|
+
originAllowed(site, [site]) &&
|
|
639
|
+
(site.startsWith("https://") || site === origin)
|
|
640
|
+
),
|
|
641
|
+
400,
|
|
642
|
+
"Use up to ten exact HTTPS sites, including your current site."
|
|
643
|
+
);
|
|
644
|
+
const approved = [...new Set(sites)];
|
|
645
|
+
const state = token();
|
|
646
|
+
await env.DB.prepare(
|
|
647
|
+
"INSERT INTO oauth_states(state_hash,verifier,project,origin,expires_at,exchange_hash,provider,approved_origins) VALUES(?,?,?,?,?,?,?,?)"
|
|
648
|
+
)
|
|
649
|
+
.bind(
|
|
650
|
+
await hash(state),
|
|
651
|
+
token(),
|
|
652
|
+
`setup:${code}`,
|
|
653
|
+
origin,
|
|
654
|
+
Date.now() + 600000,
|
|
655
|
+
await hash(token()),
|
|
656
|
+
"google",
|
|
657
|
+
JSON.stringify(approved)
|
|
658
|
+
)
|
|
659
|
+
.run();
|
|
660
|
+
const authorize = new URL("/auth/google/authorize", url);
|
|
661
|
+
authorize.searchParams.set("state", state);
|
|
662
|
+
return oauthAuthorize(new Request(authorize), env, "google");
|
|
663
|
+
}
|
|
664
|
+
if (url.pathname === "/setup") return setupPage();
|
|
665
|
+
if (
|
|
666
|
+
url.pathname === "/setup/start" &&
|
|
667
|
+
request.method === "POST" &&
|
|
668
|
+
env.KOMO_HOSTED === "true"
|
|
669
|
+
) {
|
|
670
|
+
await limit(
|
|
671
|
+
env,
|
|
672
|
+
`setup:${request.headers.get("CF-Connecting-IP") ?? "local"}`,
|
|
673
|
+
10,
|
|
674
|
+
3600
|
|
675
|
+
);
|
|
676
|
+
check(
|
|
677
|
+
env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET,
|
|
678
|
+
503,
|
|
679
|
+
"Hosted setup is awaiting Google sign-in configuration. Use --self-host or try again later."
|
|
680
|
+
);
|
|
681
|
+
const config = workspaceConfig(await body(request));
|
|
682
|
+
const id = crypto.randomUUID(),
|
|
683
|
+
secret = token();
|
|
684
|
+
await env.DB.prepare(
|
|
685
|
+
"INSERT INTO setup_requests(id,poll_hash,config,expires_at) VALUES(?,?,?,?)"
|
|
686
|
+
)
|
|
687
|
+
.bind(id, await hash(secret), JSON.stringify(config), Date.now() + 600000)
|
|
688
|
+
.run();
|
|
689
|
+
return json({ id, secret, url: `${url.origin}${env.KOMO_PUBLIC_PATH ?? ""}/setup?code=${id}` });
|
|
690
|
+
}
|
|
691
|
+
if (
|
|
692
|
+
url.pathname === "/setup/poll" &&
|
|
693
|
+
request.method === "POST" &&
|
|
694
|
+
env.KOMO_HOSTED === "true"
|
|
695
|
+
) {
|
|
696
|
+
await limit(
|
|
697
|
+
env,
|
|
698
|
+
`poll:${request.headers.get("CF-Connecting-IP") ?? "local"}`,
|
|
699
|
+
30
|
|
700
|
+
);
|
|
701
|
+
const data = await body(request);
|
|
702
|
+
const row = await env.DB.prepare(
|
|
703
|
+
"SELECT project,config FROM setup_requests WHERE id=? AND poll_hash=? AND expires_at>?"
|
|
704
|
+
)
|
|
705
|
+
.bind(
|
|
706
|
+
string(data.id, 100, "setup ID"),
|
|
707
|
+
await hash(string(data.secret, 200, "setup secret")),
|
|
708
|
+
Date.now()
|
|
709
|
+
)
|
|
710
|
+
.first<{ project: string | null; config: string }>();
|
|
711
|
+
check(row, 404, "Setup expired. Run komo init again.");
|
|
712
|
+
return json(
|
|
713
|
+
row.project
|
|
714
|
+
? {
|
|
715
|
+
project: row.project,
|
|
716
|
+
repo: JSON.parse(row.config).repo,
|
|
717
|
+
endpoint: `${url.origin}${env.KOMO_PUBLIC_PATH ?? ""}`,
|
|
718
|
+
}
|
|
719
|
+
: { pending: true }
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const oauthPath = url.pathname.match(
|
|
723
|
+
/^\/auth\/(github|google)\/(authorize|callback)$/
|
|
724
|
+
);
|
|
725
|
+
if (env.KOMO_IAP_AUDIENCE) {
|
|
726
|
+
// IAP sign-in replaces OAuth. Its handlers live only under KOMO_AUTH_PREFIX,
|
|
727
|
+
// the path the load balancer puts behind IAP.
|
|
728
|
+
const prefix = authPrefix(env.KOMO_AUTH_PREFIX);
|
|
729
|
+
const signInPath =
|
|
730
|
+
!prefix || url.pathname.startsWith(`${prefix}/auth/`)
|
|
731
|
+
? url.pathname.slice(prefix.length)
|
|
732
|
+
: "";
|
|
733
|
+
if (signInPath === "/auth/google/authorize") return iapSignIn(request, env);
|
|
734
|
+
// For agents: an IAP-authenticated caller exchanges its identity for a
|
|
735
|
+
// project session, the non-interactive counterpart of the popup.
|
|
736
|
+
if (signInPath === "/auth/iap/session" && request.method === "POST") {
|
|
737
|
+
const signedIn = await iapIdentity(request, env.KOMO_IAP_AUDIENCE);
|
|
738
|
+
const target = string(url.searchParams.get("project"), 100, "project");
|
|
739
|
+
check(await projectConfig(env, target), 404, "Unknown comments project.");
|
|
740
|
+
const user = await iapUser(env, signedIn);
|
|
741
|
+
return json({ token: await session(env, target, user.id), user }, 201);
|
|
742
|
+
}
|
|
743
|
+
check(!oauthPath || oauthPath[1] === "google", 403, "Sign in with your Google account.");
|
|
744
|
+
check(
|
|
745
|
+
!oauthPath &&
|
|
746
|
+
url.pathname !== "/auth/iap/session" &&
|
|
747
|
+
!(prefix && url.pathname.startsWith(`${prefix}/`)),
|
|
748
|
+
404,
|
|
749
|
+
"Not found."
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
if (oauthPath)
|
|
753
|
+
return oauthPath[2] === "authorize"
|
|
754
|
+
? oauthAuthorize(request, env, oauthPath[1] as "github" | "google")
|
|
755
|
+
: oauthCallback(request, env, oauthPath[1] as "github" | "google");
|
|
756
|
+
const project = string(url.searchParams.get("project"), 100, "project");
|
|
757
|
+
const config: Project | undefined =
|
|
758
|
+
project === "_komo" && env.KOMO_HOSTED === "true"
|
|
759
|
+
? { repo: "_komo", origins: [url.origin], allowGuests: false }
|
|
760
|
+
: await projectConfig(env, project);
|
|
761
|
+
check(config, 404, "Unknown comments project.");
|
|
762
|
+
const origin =
|
|
763
|
+
request.headers.get("Origin") ??
|
|
764
|
+
(request.headers.get("Sec-Fetch-Site") === "same-origin" ? url.origin : "");
|
|
765
|
+
if (
|
|
766
|
+
!(
|
|
767
|
+
originAllowed(origin, config.origins) ||
|
|
768
|
+
(project !== "_komo" && localOrigin(origin)) ||
|
|
769
|
+
(origin === url.origin &&
|
|
770
|
+
[
|
|
771
|
+
"/auth/google/start",
|
|
772
|
+
"/project",
|
|
773
|
+
"/project/join",
|
|
774
|
+
"/project/invites",
|
|
775
|
+
"/project/members",
|
|
776
|
+
"/project/export",
|
|
777
|
+
"/project/import",
|
|
778
|
+
"/project/clear-resolved",
|
|
779
|
+
"/project/webhook",
|
|
780
|
+
"/owner/claim",
|
|
781
|
+
"/config",
|
|
782
|
+
"/me",
|
|
783
|
+
"/usage",
|
|
784
|
+
].includes(url.pathname))
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
throw new HttpError(
|
|
788
|
+
403,
|
|
789
|
+
"This site is not approved for this komo project.",
|
|
790
|
+
"site_not_approved"
|
|
791
|
+
);
|
|
792
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204 });
|
|
793
|
+
check(
|
|
794
|
+
project !== "_komo" ||
|
|
795
|
+
url.pathname.startsWith("/project") ||
|
|
796
|
+
[
|
|
797
|
+
"/setup/complete",
|
|
798
|
+
"/workspace/verify",
|
|
799
|
+
"/workspace/sites",
|
|
800
|
+
"/workspace",
|
|
801
|
+
"/usage",
|
|
802
|
+
"/auth/google/start",
|
|
803
|
+
"/me",
|
|
804
|
+
"/config",
|
|
805
|
+
].includes(url.pathname),
|
|
806
|
+
404,
|
|
807
|
+
"Unknown management route."
|
|
808
|
+
);
|
|
809
|
+
check(
|
|
810
|
+
["GET", "POST", "PATCH", "DELETE"].includes(request.method),
|
|
811
|
+
405,
|
|
812
|
+
"Method not allowed."
|
|
813
|
+
);
|
|
814
|
+
const ip = request.headers.get("CF-Connecting-IP") ?? "local";
|
|
815
|
+
check(!config.suspended, 403, "This workspace is suspended.");
|
|
816
|
+
await limit(env, `${project}:read:${ip}`, 180);
|
|
817
|
+
await limit(env, `${project}:requests`, 100000, 86400);
|
|
818
|
+
if (request.method !== "GET") {
|
|
819
|
+
await limit(env, `${project}:write:${ip}`, 90);
|
|
820
|
+
await limit(env, `${project}:writes`, config.writesPerDay ?? 10000, 86400);
|
|
821
|
+
}
|
|
822
|
+
const owner = config.requireOwner
|
|
823
|
+
? await env.DB.prepare("SELECT user_id FROM project_owners WHERE project=?")
|
|
824
|
+
.bind(project)
|
|
825
|
+
.first<{ user_id: string }>()
|
|
826
|
+
: null;
|
|
827
|
+
if (
|
|
828
|
+
url.pathname === "/setup/complete" &&
|
|
829
|
+
request.method === "POST" &&
|
|
830
|
+
project === "_komo" &&
|
|
831
|
+
env.KOMO_HOSTED === "true"
|
|
832
|
+
) {
|
|
833
|
+
const user = await authenticate(request, env, project);
|
|
834
|
+
check(
|
|
835
|
+
user.verified && user.id.startsWith("google:"),
|
|
836
|
+
403,
|
|
837
|
+
"Sign in with Google to own a workspace."
|
|
838
|
+
);
|
|
839
|
+
const data = await body(request);
|
|
840
|
+
const code = string(data.code, 100, "setup code");
|
|
841
|
+
const created = await completeSetup(env, user, code);
|
|
842
|
+
return json(created, 201);
|
|
843
|
+
}
|
|
844
|
+
if (
|
|
845
|
+
["/workspace/sites", "/workspace/verify"].includes(url.pathname) &&
|
|
846
|
+
request.method === "POST" &&
|
|
847
|
+
project === "_komo"
|
|
848
|
+
) {
|
|
849
|
+
const user = await authenticate(request, env, project);
|
|
850
|
+
const data = await body(request);
|
|
851
|
+
const target = string(data.project, 100, "project");
|
|
852
|
+
await googleOwner(env, target, user);
|
|
853
|
+
const origin = string(data.origin, 300, "origin");
|
|
854
|
+
check(
|
|
855
|
+
(await projectConfig(env, target)) &&
|
|
856
|
+
origin.startsWith("https://") &&
|
|
857
|
+
originAllowed(origin, [origin]),
|
|
858
|
+
400,
|
|
859
|
+
"Use an exact HTTPS origin for a hosted workspace."
|
|
860
|
+
);
|
|
861
|
+
// Shared with the sidebar editor: covers hosted and configured projects.
|
|
862
|
+
const sites = await projectSites(env, target);
|
|
863
|
+
if (!sites.includes(origin))
|
|
864
|
+
await saveProjectSites(env, target, [...sites, origin]);
|
|
865
|
+
return json({ ok: true });
|
|
866
|
+
}
|
|
867
|
+
if (
|
|
868
|
+
url.pathname === "/workspace" &&
|
|
869
|
+
request.method === "GET" &&
|
|
870
|
+
project === "_komo"
|
|
871
|
+
) {
|
|
872
|
+
const user = await authenticate(request, env, project);
|
|
873
|
+
const target = string(url.searchParams.get("workspace"), 100, "workspace");
|
|
874
|
+
await googleOwner(env, target, user);
|
|
875
|
+
const config = await projectConfig(env, target);
|
|
876
|
+
check(config, 404, "Workspace not found.");
|
|
877
|
+
const workspace = await env.DB.prepare(
|
|
878
|
+
"SELECT origins FROM workspaces WHERE id=?"
|
|
879
|
+
)
|
|
880
|
+
.bind(target)
|
|
881
|
+
.first<{ origins: string }>();
|
|
882
|
+
return json({
|
|
883
|
+
repo: config.repo,
|
|
884
|
+
sites: await projectSites(env, target),
|
|
885
|
+
suggested: workspace
|
|
886
|
+
? (JSON.parse(workspace.origins) as string[]).filter((origin) =>
|
|
887
|
+
origin.startsWith("https://")
|
|
888
|
+
)
|
|
889
|
+
: [],
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
if (url.pathname === "/project" || url.pathname.startsWith("/project/")) {
|
|
893
|
+
const user = await authenticate(request, env, project);
|
|
894
|
+
const target =
|
|
895
|
+
project === "_komo"
|
|
896
|
+
? string(url.searchParams.get("workspace"), 100, "workspace")
|
|
897
|
+
: project;
|
|
898
|
+
return manageProject(request, env, target, user, () => body(request));
|
|
899
|
+
}
|
|
900
|
+
if (url.pathname === "/owner/claim" && request.method === "POST") {
|
|
901
|
+
const user = await authenticate(request, env, project);
|
|
902
|
+
check(
|
|
903
|
+
user.verified && user.id.startsWith("google:"),
|
|
904
|
+
403,
|
|
905
|
+
"Sign in with Google to own a workspace."
|
|
906
|
+
);
|
|
907
|
+
const data = await body(request);
|
|
908
|
+
check(
|
|
909
|
+
config.bootstrapHash &&
|
|
910
|
+
(await hash(string(data.key, 200, "setup key"))) ===
|
|
911
|
+
config.bootstrapHash,
|
|
912
|
+
403,
|
|
913
|
+
"Invalid owner setup key."
|
|
914
|
+
);
|
|
915
|
+
const result = await env.DB.prepare(
|
|
916
|
+
"INSERT OR IGNORE INTO project_owners(project,user_id) VALUES(?,?)"
|
|
917
|
+
)
|
|
918
|
+
.bind(project, user.id)
|
|
919
|
+
.run();
|
|
920
|
+
check(result.meta.changes, 409, "This workspace already has an owner.");
|
|
921
|
+
return json({ ok: true });
|
|
922
|
+
}
|
|
923
|
+
if (
|
|
924
|
+
config.requireOwner &&
|
|
925
|
+
!owner &&
|
|
926
|
+
!["/config", "/auth/google/start", "/me"].includes(url.pathname)
|
|
927
|
+
)
|
|
928
|
+
throw new HttpError(
|
|
929
|
+
403,
|
|
930
|
+
"A Google-authenticated owner must finish workspace setup first."
|
|
931
|
+
);
|
|
932
|
+
if (env.KOMO_HOSTED !== "true") ctx.waitUntil(maintain(env.DB));
|
|
933
|
+
if (url.pathname === "/config" && request.method === "GET")
|
|
934
|
+
return json({
|
|
935
|
+
repo: config.repo,
|
|
936
|
+
github: !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET),
|
|
937
|
+
google: !!(
|
|
938
|
+
env.KOMO_IAP_AUDIENCE ||
|
|
939
|
+
(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET)
|
|
940
|
+
),
|
|
941
|
+
guests:
|
|
942
|
+
!env.KOMO_IAP_AUDIENCE &&
|
|
943
|
+
config.allowGuests !== false &&
|
|
944
|
+
(!config.requireOwner || !!owner) &&
|
|
945
|
+
!(await privateAccess(env, project)),
|
|
946
|
+
private: await privateAccess(env, project),
|
|
947
|
+
guestResolve: config.allowGuestResolve !== false,
|
|
948
|
+
});
|
|
949
|
+
if (url.pathname === "/usage" && request.method === "GET") {
|
|
950
|
+
const user = await authenticate(request, env, project);
|
|
951
|
+
const target =
|
|
952
|
+
project === "_komo"
|
|
953
|
+
? string(url.searchParams.get("workspace"), 100, "workspace")
|
|
954
|
+
: project;
|
|
955
|
+
if (project === "_komo") await googleOwner(env, target, user);
|
|
956
|
+
else if (await privateAccess(env, target))
|
|
957
|
+
await requireMember(env, target, user);
|
|
958
|
+
const quota = await env.DB.prepare(
|
|
959
|
+
"SELECT comments,max_comments FROM project_quotas WHERE project=?"
|
|
960
|
+
)
|
|
961
|
+
.bind(target)
|
|
962
|
+
.first<{ comments: number; max_comments: number }>();
|
|
963
|
+
const workspace = await env.DB.prepare(
|
|
964
|
+
"SELECT id FROM workspaces WHERE id=?"
|
|
965
|
+
)
|
|
966
|
+
.bind(target)
|
|
967
|
+
.first();
|
|
968
|
+
const owned =
|
|
969
|
+
workspace && user.verified && user.id.startsWith("google:")
|
|
970
|
+
? await env.DB.prepare(
|
|
971
|
+
"SELECT COUNT(*) AS used FROM workspaces WHERE owner_id=?"
|
|
972
|
+
)
|
|
973
|
+
.bind(user.id)
|
|
974
|
+
.first<{ used: number }>()
|
|
975
|
+
: null;
|
|
976
|
+
const count = quota
|
|
977
|
+
? quota.comments
|
|
978
|
+
: ((
|
|
979
|
+
await env.DB.prepare(
|
|
980
|
+
"SELECT COUNT(*) AS used FROM comments c JOIN threads t ON t.id=c.thread_id WHERE t.project=?"
|
|
981
|
+
)
|
|
982
|
+
.bind(target)
|
|
983
|
+
.first<{ used: number }>()
|
|
984
|
+
)?.used ?? 0);
|
|
985
|
+
let projects: { used: number; limit: number | null } | null = owned
|
|
986
|
+
? { used: owned.used, limit: 3 }
|
|
987
|
+
: null;
|
|
988
|
+
if (!workspace) {
|
|
989
|
+
const connected = await env.DB.prepare(
|
|
990
|
+
"SELECT project FROM project_members WHERE user_id=? UNION SELECT project FROM project_owners WHERE user_id=?"
|
|
991
|
+
)
|
|
992
|
+
.bind(user.id, user.id)
|
|
993
|
+
.all<{ project: string }>();
|
|
994
|
+
const configured = JSON.parse(env.PROJECTS) as Record<string, Project>;
|
|
995
|
+
const ids = new Set([
|
|
996
|
+
target,
|
|
997
|
+
...connected.results.map((row) => row.project),
|
|
998
|
+
]);
|
|
999
|
+
projects = {
|
|
1000
|
+
used: [...ids].filter((id) => Object.hasOwn(configured, id)).length,
|
|
1001
|
+
limit: null,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
return json({
|
|
1005
|
+
hosted: !!workspace,
|
|
1006
|
+
projects,
|
|
1007
|
+
comments: { used: count, limit: quota?.max_comments ?? null },
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
if (url.pathname === "/auth/guest" && request.method === "POST") {
|
|
1011
|
+
check(!env.KOMO_IAP_AUDIENCE, 403, "Guest comments are disabled.");
|
|
1012
|
+
check(
|
|
1013
|
+
!(await privateAccess(env, project)),
|
|
1014
|
+
403,
|
|
1015
|
+
"This project requires an invited Google account."
|
|
1016
|
+
);
|
|
1017
|
+
check(config.allowGuests !== false, 403, "Guest comments are disabled.");
|
|
1018
|
+
await limit(env, `${project}:guest:${ip}`, 20, 3600);
|
|
1019
|
+
const data = await body(request);
|
|
1020
|
+
const name = string(data.name, 60, "name");
|
|
1021
|
+
const id = `guest:${crypto.randomUUID()}`;
|
|
1022
|
+
await env.DB.prepare("INSERT INTO users(id,name) VALUES(?,?)")
|
|
1023
|
+
.bind(id, name)
|
|
1024
|
+
.run();
|
|
1025
|
+
return json(
|
|
1026
|
+
{
|
|
1027
|
+
token: await session(env, project, id),
|
|
1028
|
+
user: { id, name, verified: false },
|
|
1029
|
+
},
|
|
1030
|
+
201
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
if (
|
|
1034
|
+
["/auth/github/start", "/auth/google/start"].includes(url.pathname) &&
|
|
1035
|
+
request.method === "POST"
|
|
1036
|
+
) {
|
|
1037
|
+
const provider = url.pathname.includes("/google/") ? "google" : "github";
|
|
1038
|
+
check(
|
|
1039
|
+
env.KOMO_IAP_AUDIENCE
|
|
1040
|
+
? provider === "google"
|
|
1041
|
+
: provider === "google"
|
|
1042
|
+
? env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
|
1043
|
+
: env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET,
|
|
1044
|
+
503,
|
|
1045
|
+
"Sign-in is not configured."
|
|
1046
|
+
);
|
|
1047
|
+
await limit(env, `${project}:oauth:${ip}`, 20, 3600);
|
|
1048
|
+
const data = request.body ? await body(request) : {};
|
|
1049
|
+
const returnOrigin = cliReturnOrigin(data.returnOrigin, origin);
|
|
1050
|
+
const state = token();
|
|
1051
|
+
const verifier = token();
|
|
1052
|
+
const exchange = token();
|
|
1053
|
+
await env.DB.prepare(
|
|
1054
|
+
"INSERT INTO oauth_states(state_hash,verifier,project,origin,expires_at,exchange_hash,provider) VALUES(?,?,?,?,?,?,?)"
|
|
1055
|
+
)
|
|
1056
|
+
.bind(
|
|
1057
|
+
await hash(state),
|
|
1058
|
+
verifier,
|
|
1059
|
+
project,
|
|
1060
|
+
returnOrigin,
|
|
1061
|
+
Date.now() + 600000,
|
|
1062
|
+
await hash(exchange),
|
|
1063
|
+
provider
|
|
1064
|
+
)
|
|
1065
|
+
.run();
|
|
1066
|
+
return json({
|
|
1067
|
+
// KOMO_PUBLIC_PATH covers apps served under a path behind a proxy that strips it.
|
|
1068
|
+
url: `${url.origin}${env.KOMO_PUBLIC_PATH ?? ""}${env.KOMO_IAP_AUDIENCE ? authPrefix(env.KOMO_AUTH_PREFIX) : ""}/auth/${provider}/authorize?state=${encodeURIComponent(state)}`,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (url.pathname === "/me") {
|
|
1072
|
+
const user = await authenticate(request, env, project);
|
|
1073
|
+
if (request.method === "DELETE") {
|
|
1074
|
+
const bearer = request.headers.get("Authorization")!.slice(7);
|
|
1075
|
+
await env.DB.prepare("DELETE FROM sessions WHERE token_hash=?")
|
|
1076
|
+
.bind(await hash(bearer))
|
|
1077
|
+
.run();
|
|
1078
|
+
return json({ ok: true });
|
|
1079
|
+
}
|
|
1080
|
+
if (request.method === "PATCH") {
|
|
1081
|
+
const data = await body(request);
|
|
1082
|
+
const name = string(data.name, 60, "name");
|
|
1083
|
+
check(
|
|
1084
|
+
typeof data.avatarUrl === "string" && data.avatarUrl.length <= 12000,
|
|
1085
|
+
400,
|
|
1086
|
+
"Invalid photo URL."
|
|
1087
|
+
);
|
|
1088
|
+
const accentColor = data.accentColor ?? user.accentColor;
|
|
1089
|
+
check(
|
|
1090
|
+
accentColor === undefined ||
|
|
1091
|
+
(typeof accentColor === "string" &&
|
|
1092
|
+
/^#[0-9a-f]{6}$/i.test(accentColor)),
|
|
1093
|
+
400,
|
|
1094
|
+
"Choose a valid accent color."
|
|
1095
|
+
);
|
|
1096
|
+
const normalizedAccent = accentColor?.toLowerCase();
|
|
1097
|
+
const avatarUrl = data.avatarUrl.trim();
|
|
1098
|
+
const uploadedPhoto =
|
|
1099
|
+
/^data:image\/jpeg;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatarUrl);
|
|
1100
|
+
if (avatarUrl && !uploadedPhoto) {
|
|
1101
|
+
check(avatarUrl.length <= 2048, 400, "Photo URL is too long.");
|
|
1102
|
+
let photo: URL;
|
|
1103
|
+
try {
|
|
1104
|
+
photo = new URL(avatarUrl);
|
|
1105
|
+
} catch {
|
|
1106
|
+
throw new HttpError(400, "Use an HTTPS photo URL.");
|
|
1107
|
+
}
|
|
1108
|
+
check(
|
|
1109
|
+
photo.protocol === "https:" && !photo.username && !photo.password,
|
|
1110
|
+
400,
|
|
1111
|
+
"Use an HTTPS photo URL."
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
await env.DB.prepare(
|
|
1115
|
+
"UPDATE users SET name=?,avatar_url=?,accent_color=? WHERE id=?"
|
|
1116
|
+
)
|
|
1117
|
+
.bind(name, avatarUrl, normalizedAccent ?? null, user.id)
|
|
1118
|
+
.run();
|
|
1119
|
+
return json({
|
|
1120
|
+
user: {
|
|
1121
|
+
...user,
|
|
1122
|
+
name,
|
|
1123
|
+
avatarUrl: avatarUrl || undefined,
|
|
1124
|
+
accentColor: normalizedAccent,
|
|
1125
|
+
},
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
check(request.method === "GET", 405, "Method not allowed.");
|
|
1129
|
+
return json({ user });
|
|
1130
|
+
}
|
|
1131
|
+
// Behind IAP, comments are never readable or writable without a session
|
|
1132
|
+
// minted from an IAP-verified Google account.
|
|
1133
|
+
if (env.KOMO_IAP_AUDIENCE) {
|
|
1134
|
+
const reader = await authenticate(request, env, project);
|
|
1135
|
+
check(
|
|
1136
|
+
reader.verified && reader.id.startsWith("google:"),
|
|
1137
|
+
403,
|
|
1138
|
+
"Sign in with your Google account to see comments."
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
if (await privateAccess(env, project)) {
|
|
1142
|
+
await requireMember(
|
|
1143
|
+
env,
|
|
1144
|
+
project,
|
|
1145
|
+
await authenticate(request, env, project)
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
const requestedRepo = url.searchParams.get("repo");
|
|
1149
|
+
const repo =
|
|
1150
|
+
!requestedRepo || requestedRepo === project
|
|
1151
|
+
? config.repo
|
|
1152
|
+
: string(requestedRepo, 200, "repository");
|
|
1153
|
+
const branch = string(url.searchParams.get("branch"), 250, "branch");
|
|
1154
|
+
check(repo === config.repo, 403, "Repository does not match this project.");
|
|
1155
|
+
if (url.pathname === "/threads" && request.method === "GET") {
|
|
1156
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
1157
|
+
check(
|
|
1158
|
+
Number.isInteger(offset) && offset >= 0 && offset <= 100000,
|
|
1159
|
+
400,
|
|
1160
|
+
"Invalid offset."
|
|
1161
|
+
);
|
|
1162
|
+
let cursor: [number, string] | undefined;
|
|
1163
|
+
if (url.searchParams.has("cursor")) {
|
|
1164
|
+
try {
|
|
1165
|
+
cursor = JSON.parse(string(url.searchParams.get("cursor"), 200, "cursor"));
|
|
1166
|
+
} catch {
|
|
1167
|
+
throw new HttpError(400, "Invalid cursor.");
|
|
1168
|
+
}
|
|
1169
|
+
check(
|
|
1170
|
+
Array.isArray(cursor) && cursor.length === 2 &&
|
|
1171
|
+
Number.isSafeInteger(cursor[0]) && cursor[0] >= 0 &&
|
|
1172
|
+
typeof cursor[1] === "string" && cursor[1].length > 0 && cursor[1].length <= 100,
|
|
1173
|
+
400, "Invalid cursor."
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
const revision =
|
|
1177
|
+
(
|
|
1178
|
+
await env.DB.prepare(
|
|
1179
|
+
"SELECT version FROM scope_revisions WHERE project=? AND repo=? AND branch=?"
|
|
1180
|
+
)
|
|
1181
|
+
.bind(project, repo, branch)
|
|
1182
|
+
.first<{ version: number }>()
|
|
1183
|
+
)?.version ?? 0;
|
|
1184
|
+
if (!cursor && offset === 0 && url.searchParams.get("revision") === String(revision))
|
|
1185
|
+
return json({ notModified: true, revision });
|
|
1186
|
+
const requestedId = url.searchParams.get("id");
|
|
1187
|
+
const rows = await env.DB.prepare(
|
|
1188
|
+
`SELECT t.*,u.name AS resolver_name,u.verified AS resolver_verified FROM threads t LEFT JOIN users u ON u.id=t.resolved_by WHERE t.project=? AND t.repo=? AND t.branch=?${requestedId ? " AND t.id=?" : ""}${cursor ? " AND (t.created_at,t.id)>(?,?)" : ""} ORDER BY t.created_at,t.id LIMIT 50 OFFSET ?`
|
|
1189
|
+
)
|
|
1190
|
+
.bind(
|
|
1191
|
+
project,
|
|
1192
|
+
repo,
|
|
1193
|
+
branch,
|
|
1194
|
+
...(requestedId ? [string(requestedId, 100, "thread ID")] : []),
|
|
1195
|
+
...(cursor ?? []),
|
|
1196
|
+
cursor ? 0 : offset
|
|
1197
|
+
)
|
|
1198
|
+
.all<ThreadRow>();
|
|
1199
|
+
const last = rows.results.at(-1);
|
|
1200
|
+
const nextCursor = rows.results.length === 50 && last ? JSON.stringify([last.created_at, last.id]) : null;
|
|
1201
|
+
const ids = rows.results.map((row) => row.id);
|
|
1202
|
+
if (!ids.length) return json({ threads: [], next: null, revision });
|
|
1203
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
1204
|
+
const comments = await env.DB.prepare(
|
|
1205
|
+
`SELECT c.*,u.name,u.verified,u.avatar_url,u.accent_color FROM comments c JOIN users u ON u.id=c.user_id WHERE c.thread_id IN (${placeholders}) ORDER BY c.created_at,c.id`
|
|
1206
|
+
)
|
|
1207
|
+
.bind(...ids)
|
|
1208
|
+
.all<CommentRow>();
|
|
1209
|
+
const reactions = await env.DB.prepare(
|
|
1210
|
+
`SELECT r.* FROM reactions r JOIN comments c ON c.id=r.comment_id WHERE c.thread_id IN (${placeholders})`
|
|
1211
|
+
)
|
|
1212
|
+
.bind(...ids)
|
|
1213
|
+
.all<{ comment_id: string; user_id: string; emoji: string }>();
|
|
1214
|
+
const commentsByThread = new Map<string, CommentRow[]>();
|
|
1215
|
+
for (const comment of comments.results) {
|
|
1216
|
+
const group = commentsByThread.get(comment.thread_id) ?? [];
|
|
1217
|
+
group.push(comment);
|
|
1218
|
+
commentsByThread.set(comment.thread_id, group);
|
|
1219
|
+
}
|
|
1220
|
+
const reactionsByComment = new Map<string, Comment["reactions"]>();
|
|
1221
|
+
for (const reaction of reactions.results) {
|
|
1222
|
+
const group = reactionsByComment.get(reaction.comment_id) ?? {};
|
|
1223
|
+
(group[reaction.emoji] ??= []).push(reaction.user_id);
|
|
1224
|
+
reactionsByComment.set(reaction.comment_id, group);
|
|
1225
|
+
}
|
|
1226
|
+
const threads: Thread[] = rows.results.map((row) => ({
|
|
1227
|
+
id: row.id,
|
|
1228
|
+
page: pagePath(row.page),
|
|
1229
|
+
anchor: JSON.parse(row.anchor),
|
|
1230
|
+
tags: parseTags(row.tags),
|
|
1231
|
+
resolved: !!row.resolved,
|
|
1232
|
+
resolvedBy: row.resolved_by
|
|
1233
|
+
? {
|
|
1234
|
+
id: row.resolved_by,
|
|
1235
|
+
name: row.resolver_name ?? "Reviewer",
|
|
1236
|
+
verified: !!row.resolver_verified,
|
|
1237
|
+
}
|
|
1238
|
+
: null,
|
|
1239
|
+
createdAt: row.created_at,
|
|
1240
|
+
updatedAt: row.updated_at,
|
|
1241
|
+
comments: (commentsByThread.get(row.id) ?? []).map((c) => ({
|
|
1242
|
+
id: c.id,
|
|
1243
|
+
body: c.body,
|
|
1244
|
+
author: {
|
|
1245
|
+
id: c.user_id,
|
|
1246
|
+
name: c.name,
|
|
1247
|
+
verified: !!c.verified,
|
|
1248
|
+
avatarUrl: c.avatar_url || undefined,
|
|
1249
|
+
accentColor: c.accent_color || undefined,
|
|
1250
|
+
},
|
|
1251
|
+
createdAt: c.created_at,
|
|
1252
|
+
editedAt: c.edited_at,
|
|
1253
|
+
reactions: reactionsByComment.get(c.id) ?? {},
|
|
1254
|
+
})),
|
|
1255
|
+
}));
|
|
1256
|
+
if (url.searchParams.get("authors") === "1") {
|
|
1257
|
+
const authors: Record<string, Identity> = Object.create(null);
|
|
1258
|
+
const authorId = (author: Identity) => {
|
|
1259
|
+
authors[author.id] = { ...authors[author.id], ...author };
|
|
1260
|
+
return author.id;
|
|
1261
|
+
};
|
|
1262
|
+
return json({
|
|
1263
|
+
threads: threads.map(thread => ({
|
|
1264
|
+
...thread,
|
|
1265
|
+
resolvedBy: thread.resolvedBy ? authorId(thread.resolvedBy) : null,
|
|
1266
|
+
comments: thread.comments.map(comment => ({ ...comment, author: authorId(comment.author) })),
|
|
1267
|
+
})),
|
|
1268
|
+
authors,
|
|
1269
|
+
nextCursor,
|
|
1270
|
+
next: ids.length === 50 ? offset + 50 : null,
|
|
1271
|
+
revision,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
return json({
|
|
1275
|
+
threads,
|
|
1276
|
+
nextCursor,
|
|
1277
|
+
next: ids.length === 50 ? offset + 50 : null,
|
|
1278
|
+
revision,
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
const user = await authenticate(request, env, project);
|
|
1282
|
+
await limit(env, `${project}:session:${user.id}`, 60);
|
|
1283
|
+
const now = Date.now();
|
|
1284
|
+
if (url.pathname === "/threads" && request.method === "POST") {
|
|
1285
|
+
const data = await body(request);
|
|
1286
|
+
const text = string(data.body, 4000, "comment");
|
|
1287
|
+
const page = pagePath(data.page);
|
|
1288
|
+
const anchor = anchorValue(data.anchor);
|
|
1289
|
+
let tags: string[] = [];
|
|
1290
|
+
if (data.tags !== undefined) {
|
|
1291
|
+
check(Array.isArray(data.tags), 400, "Invalid tags.");
|
|
1292
|
+
tags = [...new Set((data.tags as unknown[]).map(tagValue))].sort();
|
|
1293
|
+
check(tags.length <= 12, 400, "A thread can have at most 12 tags.");
|
|
1294
|
+
}
|
|
1295
|
+
const id = crypto.randomUUID();
|
|
1296
|
+
const firstCommentId = crypto.randomUUID();
|
|
1297
|
+
await env.DB.batch([
|
|
1298
|
+
env.DB.prepare(
|
|
1299
|
+
"INSERT INTO threads(id,project,repo,branch,page,anchor,tags,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)"
|
|
1300
|
+
).bind(id, project, repo, branch, page, JSON.stringify(anchor), JSON.stringify(tags), now, now),
|
|
1301
|
+
env.DB.prepare(
|
|
1302
|
+
"INSERT INTO comments(id,thread_id,user_id,body,created_at) VALUES(?,?,?,?,?)"
|
|
1303
|
+
).bind(firstCommentId, id, user.id, text, now),
|
|
1304
|
+
...retainReviewerComments(
|
|
1305
|
+
env.DB,
|
|
1306
|
+
project,
|
|
1307
|
+
user.id,
|
|
1308
|
+
config.retainedCommentsPerUser
|
|
1309
|
+
),
|
|
1310
|
+
]);
|
|
1311
|
+
ctx.waitUntil(
|
|
1312
|
+
dispatchWebhook(
|
|
1313
|
+
env,
|
|
1314
|
+
project,
|
|
1315
|
+
webhookEvent(
|
|
1316
|
+
"comment.created",
|
|
1317
|
+
project,
|
|
1318
|
+
repo,
|
|
1319
|
+
branch,
|
|
1320
|
+
id,
|
|
1321
|
+
page,
|
|
1322
|
+
JSON.stringify(anchor),
|
|
1323
|
+
user.name,
|
|
1324
|
+
text,
|
|
1325
|
+
origin || url.origin
|
|
1326
|
+
)
|
|
1327
|
+
).catch(() => {})
|
|
1328
|
+
);
|
|
1329
|
+
return json({ id, commentId: firstCommentId }, 201);
|
|
1330
|
+
}
|
|
1331
|
+
const match = url.pathname.match(
|
|
1332
|
+
/^\/threads\/([\w:-]{1,100})(?:\/comments(?:\/([\w:-]{1,100})(?:\/(reactions))?)?)?$/
|
|
1333
|
+
);
|
|
1334
|
+
check(match, 404, "Not found.");
|
|
1335
|
+
const [, threadId, commentId, reaction] = match;
|
|
1336
|
+
const thread = await env.DB.prepare(
|
|
1337
|
+
"SELECT * FROM threads WHERE id=? AND project=? AND repo=? AND branch=?"
|
|
1338
|
+
)
|
|
1339
|
+
.bind(threadId, project, repo, branch)
|
|
1340
|
+
.first<ThreadRow>();
|
|
1341
|
+
check(thread, 404, "Thread not found on this branch.");
|
|
1342
|
+
if (url.pathname === `/threads/${threadId}` && request.method === "PATCH") {
|
|
1343
|
+
const data = await body(request);
|
|
1344
|
+
if (data.untag !== undefined) {
|
|
1345
|
+
const tag = tagValue(data.untag);
|
|
1346
|
+
const tags = parseTags(thread.tags);
|
|
1347
|
+
if (tags.includes(tag)) {
|
|
1348
|
+
const next = tags.filter((item) => item !== tag);
|
|
1349
|
+
await env.DB.prepare(
|
|
1350
|
+
"UPDATE threads SET tags=?,updated_at=? WHERE id=?",
|
|
1351
|
+
)
|
|
1352
|
+
.bind(JSON.stringify(next), now, threadId)
|
|
1353
|
+
.run();
|
|
1354
|
+
return json({ ok: true, tags: next });
|
|
1355
|
+
}
|
|
1356
|
+
return json({ ok: true, tags });
|
|
1357
|
+
}
|
|
1358
|
+
if (data.tag !== undefined) {
|
|
1359
|
+
const tag = tagValue(data.tag);
|
|
1360
|
+
const tags = parseTags(thread.tags);
|
|
1361
|
+
if (!tags.includes(tag)) {
|
|
1362
|
+
check(tags.length < 12, 400, "A thread can have at most 12 tags.");
|
|
1363
|
+
tags.push(tag);
|
|
1364
|
+
tags.sort();
|
|
1365
|
+
await env.DB.prepare(
|
|
1366
|
+
"UPDATE threads SET tags=?,updated_at=? WHERE id=?",
|
|
1367
|
+
)
|
|
1368
|
+
.bind(JSON.stringify(tags), now, threadId)
|
|
1369
|
+
.run();
|
|
1370
|
+
}
|
|
1371
|
+
return json({ ok: true, tags });
|
|
1372
|
+
}
|
|
1373
|
+
if (data.anchor !== undefined) {
|
|
1374
|
+
const owner = await env.DB.prepare(
|
|
1375
|
+
"SELECT user_id FROM comments WHERE thread_id=? ORDER BY created_at,id LIMIT 1"
|
|
1376
|
+
)
|
|
1377
|
+
.bind(threadId)
|
|
1378
|
+
.first<{ user_id: string }>();
|
|
1379
|
+
check(
|
|
1380
|
+
user.verified || owner?.user_id === user.id,
|
|
1381
|
+
403,
|
|
1382
|
+
"Only the author or a signed-in reviewer can move this comment."
|
|
1383
|
+
);
|
|
1384
|
+
const anchor = anchorValue(data.anchor);
|
|
1385
|
+
await env.DB.prepare(
|
|
1386
|
+
"UPDATE threads SET anchor=?,updated_at=? WHERE id=?"
|
|
1387
|
+
)
|
|
1388
|
+
.bind(JSON.stringify(anchor), now, threadId)
|
|
1389
|
+
.run();
|
|
1390
|
+
return json({ ok: true });
|
|
1391
|
+
}
|
|
1392
|
+
check(
|
|
1393
|
+
user.verified || config.allowGuestResolve !== false,
|
|
1394
|
+
403,
|
|
1395
|
+
"Sign in to resolve comments."
|
|
1396
|
+
);
|
|
1397
|
+
check(typeof data.resolved === "boolean", 400, "Invalid resolved state.");
|
|
1398
|
+
const becameResolved = !thread.resolved && data.resolved;
|
|
1399
|
+
await env.DB.prepare(
|
|
1400
|
+
"UPDATE threads SET resolved=?,resolved_by=?,updated_at=? WHERE id=?"
|
|
1401
|
+
)
|
|
1402
|
+
.bind(
|
|
1403
|
+
Number(data.resolved),
|
|
1404
|
+
data.resolved ? user.id : null,
|
|
1405
|
+
now,
|
|
1406
|
+
threadId
|
|
1407
|
+
)
|
|
1408
|
+
.run();
|
|
1409
|
+
if (becameResolved)
|
|
1410
|
+
ctx.waitUntil(
|
|
1411
|
+
dispatchWebhook(
|
|
1412
|
+
env,
|
|
1413
|
+
project,
|
|
1414
|
+
webhookEvent(
|
|
1415
|
+
"comment.resolved",
|
|
1416
|
+
project,
|
|
1417
|
+
repo,
|
|
1418
|
+
branch,
|
|
1419
|
+
threadId,
|
|
1420
|
+
thread.page,
|
|
1421
|
+
thread.anchor,
|
|
1422
|
+
user.name,
|
|
1423
|
+
null,
|
|
1424
|
+
origin || url.origin
|
|
1425
|
+
)
|
|
1426
|
+
).catch(() => {})
|
|
1427
|
+
);
|
|
1428
|
+
return json({ ok: true });
|
|
1429
|
+
}
|
|
1430
|
+
if (
|
|
1431
|
+
url.pathname === `/threads/${threadId}/comments` &&
|
|
1432
|
+
request.method === "POST"
|
|
1433
|
+
) {
|
|
1434
|
+
const data = await body(request);
|
|
1435
|
+
const text = string(data.body, 4000, "reply");
|
|
1436
|
+
const replyId = crypto.randomUUID();
|
|
1437
|
+
const result = await env.DB.batch([
|
|
1438
|
+
env.DB.prepare(
|
|
1439
|
+
"INSERT INTO comments(id,thread_id,user_id,body,created_at) SELECT ?,?,?,?,? WHERE (SELECT COUNT(*) FROM comments WHERE thread_id=?)<200"
|
|
1440
|
+
).bind(replyId, threadId, user.id, text, now, threadId),
|
|
1441
|
+
env.DB.prepare("UPDATE threads SET updated_at=? WHERE id=?").bind(
|
|
1442
|
+
now,
|
|
1443
|
+
threadId
|
|
1444
|
+
),
|
|
1445
|
+
...retainReviewerComments(
|
|
1446
|
+
env.DB,
|
|
1447
|
+
project,
|
|
1448
|
+
user.id,
|
|
1449
|
+
config.retainedCommentsPerUser
|
|
1450
|
+
),
|
|
1451
|
+
]);
|
|
1452
|
+
check(
|
|
1453
|
+
result[0].meta.changes,
|
|
1454
|
+
409,
|
|
1455
|
+
"This thread has reached 200 comments. Start another thread."
|
|
1456
|
+
);
|
|
1457
|
+
ctx.waitUntil(
|
|
1458
|
+
dispatchWebhook(
|
|
1459
|
+
env,
|
|
1460
|
+
project,
|
|
1461
|
+
webhookEvent(
|
|
1462
|
+
"comment.replied",
|
|
1463
|
+
project,
|
|
1464
|
+
repo,
|
|
1465
|
+
branch,
|
|
1466
|
+
threadId,
|
|
1467
|
+
thread.page,
|
|
1468
|
+
thread.anchor,
|
|
1469
|
+
user.name,
|
|
1470
|
+
text,
|
|
1471
|
+
origin || url.origin
|
|
1472
|
+
)
|
|
1473
|
+
).catch(() => {})
|
|
1474
|
+
);
|
|
1475
|
+
return json({ ok: true, id: replyId }, 201);
|
|
1476
|
+
}
|
|
1477
|
+
check(commentId, 405, "Method not allowed.");
|
|
1478
|
+
const comment = await env.DB.prepare(
|
|
1479
|
+
"SELECT * FROM comments WHERE id=? AND thread_id=?"
|
|
1480
|
+
)
|
|
1481
|
+
.bind(commentId, threadId)
|
|
1482
|
+
.first<CommentRow>();
|
|
1483
|
+
check(comment, 404, "Comment not found.");
|
|
1484
|
+
if (reaction && request.method === "POST") {
|
|
1485
|
+
const data = await body(request);
|
|
1486
|
+
check(
|
|
1487
|
+
isEmoji(data.emoji) && typeof data.active === "boolean",
|
|
1488
|
+
400,
|
|
1489
|
+
"Invalid reaction."
|
|
1490
|
+
);
|
|
1491
|
+
await env.DB.batch([
|
|
1492
|
+
...(data.active
|
|
1493
|
+
? [
|
|
1494
|
+
env.DB.prepare(
|
|
1495
|
+
"DELETE FROM reactions WHERE comment_id=? AND user_id=?"
|
|
1496
|
+
).bind(commentId, user.id),
|
|
1497
|
+
]
|
|
1498
|
+
: []),
|
|
1499
|
+
data.active
|
|
1500
|
+
? env.DB.prepare(
|
|
1501
|
+
"INSERT OR IGNORE INTO reactions(comment_id,user_id,emoji) VALUES(?,?,?)"
|
|
1502
|
+
).bind(commentId, user.id, data.emoji)
|
|
1503
|
+
: env.DB.prepare(
|
|
1504
|
+
"DELETE FROM reactions WHERE comment_id=? AND user_id=? AND emoji=?"
|
|
1505
|
+
).bind(commentId, user.id, data.emoji),
|
|
1506
|
+
env.DB.prepare("UPDATE threads SET updated_at=? WHERE id=?").bind(
|
|
1507
|
+
now,
|
|
1508
|
+
threadId
|
|
1509
|
+
),
|
|
1510
|
+
]);
|
|
1511
|
+
return json({ ok: true });
|
|
1512
|
+
}
|
|
1513
|
+
check(
|
|
1514
|
+
!reaction && comment.user_id === user.id,
|
|
1515
|
+
403,
|
|
1516
|
+
"Only the author can change this comment."
|
|
1517
|
+
);
|
|
1518
|
+
if (request.method === "PATCH") {
|
|
1519
|
+
const data = await body(request);
|
|
1520
|
+
await env.DB.batch([
|
|
1521
|
+
env.DB.prepare("UPDATE comments SET body=?,edited_at=? WHERE id=?").bind(
|
|
1522
|
+
string(data.body, 4000, "comment"),
|
|
1523
|
+
now,
|
|
1524
|
+
commentId
|
|
1525
|
+
),
|
|
1526
|
+
env.DB.prepare("UPDATE threads SET updated_at=? WHERE id=?").bind(
|
|
1527
|
+
now,
|
|
1528
|
+
threadId
|
|
1529
|
+
),
|
|
1530
|
+
]);
|
|
1531
|
+
return json({ ok: true });
|
|
1532
|
+
}
|
|
1533
|
+
if (request.method === "DELETE") {
|
|
1534
|
+
// Preserve the thread and replies when the original comment is deleted.
|
|
1535
|
+
await env.DB.batch([
|
|
1536
|
+
env.DB.prepare("UPDATE comments SET body=?,edited_at=? WHERE id=?").bind(
|
|
1537
|
+
"[Comment deleted]",
|
|
1538
|
+
now,
|
|
1539
|
+
commentId
|
|
1540
|
+
),
|
|
1541
|
+
env.DB.prepare("DELETE FROM reactions WHERE comment_id=?").bind(
|
|
1542
|
+
commentId
|
|
1543
|
+
),
|
|
1544
|
+
env.DB.prepare("UPDATE threads SET updated_at=? WHERE id=?").bind(
|
|
1545
|
+
now,
|
|
1546
|
+
threadId
|
|
1547
|
+
),
|
|
1548
|
+
]);
|
|
1549
|
+
return json({ ok: true });
|
|
1550
|
+
}
|
|
1551
|
+
throw new HttpError(405, "Method not allowed.");
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** Normalized group tag: lowercase, up to 32 letters, numbers, spaces, dots, underscores, or hyphens. */
|
|
1555
|
+
function tagValue(value: unknown): string {
|
|
1556
|
+
const tag = string(value, 32, "tag").trim().toLocaleLowerCase("en-US");
|
|
1557
|
+
check(
|
|
1558
|
+
/^[\p{L}\p{N}][\p{L}\p{N} ._-]{0,31}$/u.test(tag),
|
|
1559
|
+
400,
|
|
1560
|
+
"Use a tag with up to 32 letters, numbers, spaces, dots, underscores, or hyphens.",
|
|
1561
|
+
);
|
|
1562
|
+
return tag;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function parseTags(value: string | null | undefined): string[] {
|
|
1566
|
+
try {
|
|
1567
|
+
const parsed = JSON.parse(value ?? "[]");
|
|
1568
|
+
return Array.isArray(parsed)
|
|
1569
|
+
? parsed.filter((tag): tag is string => typeof tag === "string")
|
|
1570
|
+
: [];
|
|
1571
|
+
} catch {
|
|
1572
|
+
return [];
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
export default {
|
|
1576
|
+
async scheduled(_event, env, ctx) {
|
|
1577
|
+
ctx.waitUntil(maintain(env.DB, true));
|
|
1578
|
+
},
|
|
1579
|
+
async fetch(request, env, ctx) {
|
|
1580
|
+
let response: Response;
|
|
1581
|
+
// An unapproved site may read why /config refused it, and nothing else.
|
|
1582
|
+
let refusedSite = false;
|
|
1583
|
+
try {
|
|
1584
|
+
response = await route(request, env, ctx);
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
refusedSite =
|
|
1587
|
+
error instanceof HttpError &&
|
|
1588
|
+
error.code === "site_not_approved" &&
|
|
1589
|
+
new URL(request.url).pathname === "/config";
|
|
1590
|
+
if (refusedSite && request.method === "OPTIONS")
|
|
1591
|
+
response = new Response(null, { status: 204 });
|
|
1592
|
+
else if (error instanceof HttpError)
|
|
1593
|
+
response = json(
|
|
1594
|
+
error.code
|
|
1595
|
+
? { error: error.message, code: error.code }
|
|
1596
|
+
: { error: error.message },
|
|
1597
|
+
error.status
|
|
1598
|
+
);
|
|
1599
|
+
else if (
|
|
1600
|
+
error instanceof Error &&
|
|
1601
|
+
error.message.includes("komo_quota_exceeded")
|
|
1602
|
+
)
|
|
1603
|
+
response = json(
|
|
1604
|
+
{
|
|
1605
|
+
error:
|
|
1606
|
+
"This workspace reached its storage or comment limit. Export your feedback or use your own deployment.",
|
|
1607
|
+
},
|
|
1608
|
+
409
|
|
1609
|
+
);
|
|
1610
|
+
else {
|
|
1611
|
+
console.error("comments_request_failed", {
|
|
1612
|
+
path: new URL(request.url).pathname,
|
|
1613
|
+
});
|
|
1614
|
+
response = json(
|
|
1615
|
+
{ error: "Comments are temporarily unavailable. Try again." },
|
|
1616
|
+
500
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
const project = new URL(request.url).searchParams.get("project");
|
|
1621
|
+
let config: { origins: string[] } | undefined;
|
|
1622
|
+
try {
|
|
1623
|
+
config = project
|
|
1624
|
+
? project === "_komo" && env.KOMO_HOSTED === "true"
|
|
1625
|
+
? { origins: [new URL(request.url).origin] }
|
|
1626
|
+
: await projectConfig(env, project)
|
|
1627
|
+
: undefined;
|
|
1628
|
+
} catch {
|
|
1629
|
+
// Do not leak a successful body or allow CORS without a known site policy.
|
|
1630
|
+
response = json({ error: "Comments are temporarily unavailable. Try again." }, 500);
|
|
1631
|
+
}
|
|
1632
|
+
const headers = new Headers(response.headers);
|
|
1633
|
+
const origin = request.headers.get("Origin") ?? "";
|
|
1634
|
+
if (
|
|
1635
|
+
config &&
|
|
1636
|
+
(originAllowed(origin, config.origins) ||
|
|
1637
|
+
(project !== "_komo" && localOrigin(origin)) ||
|
|
1638
|
+
(refusedSite && originAllowed(origin, [origin])))
|
|
1639
|
+
) {
|
|
1640
|
+
headers.set("Access-Control-Allow-Origin", origin);
|
|
1641
|
+
headers.set(
|
|
1642
|
+
"Access-Control-Allow-Methods",
|
|
1643
|
+
"GET, POST, PATCH, DELETE, OPTIONS"
|
|
1644
|
+
);
|
|
1645
|
+
headers.set(
|
|
1646
|
+
"Access-Control-Allow-Headers",
|
|
1647
|
+
"Content-Type, Authorization"
|
|
1648
|
+
);
|
|
1649
|
+
headers.set("Access-Control-Max-Age", "600");
|
|
1650
|
+
}
|
|
1651
|
+
headers.set("Vary", "Origin");
|
|
1652
|
+
headers.set("Cache-Control", "no-store");
|
|
1653
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
1654
|
+
return new Response(response.body, { status: response.status, headers });
|
|
1655
|
+
},
|
|
1656
|
+
} satisfies ExportedHandler<Env>;
|