@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.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/NODE.md +80 -0
  3. package/NOTICE.md +45 -0
  4. package/README.md +411 -2
  5. package/cli/agent.mjs +615 -0
  6. package/cli/config.mjs +34 -0
  7. package/cli/index.mjs +405 -0
  8. package/cli/project.mjs +136 -0
  9. package/cli/workflow.mjs +42 -0
  10. package/dist/MorphingMenu.d.ts +30 -0
  11. package/dist/PointerIcon.d.ts +2 -0
  12. package/dist/SmileIcon.d.ts +2 -0
  13. package/dist/THIRD_PARTY_NOTICES.txt +387 -0
  14. package/dist/accent-picker-F5UI2NP7.js +1 -0
  15. package/dist/accent-picker.d.ts +4 -0
  16. package/dist/accent.d.ts +2 -0
  17. package/dist/account-usage.d.ts +2 -0
  18. package/dist/action-menu.d.ts +10 -0
  19. package/dist/agent-prompt.d.ts +6 -0
  20. package/dist/agent-prompt.js +1 -0
  21. package/dist/anchors.d.ts +48 -0
  22. package/dist/api.d.ts +39 -0
  23. package/dist/approved-sites.d.ts +9 -0
  24. package/dist/card-motion.d.ts +8 -0
  25. package/dist/chunk-7YUXYVQA.js +1 -0
  26. package/dist/chunk-I7AUKTXE.js +1 -0
  27. package/dist/chunk-JQVBZ2BB.js +1 -0
  28. package/dist/chunk-Q7WSWLMV.js +1 -0
  29. package/dist/chunk-Y2GRLW4Q.js +1 -0
  30. package/dist/chunk-YZP3KX4Y.js +1 -0
  31. package/dist/chunk-Z2ABBESO.js +1 -0
  32. package/dist/config.d.ts +11 -0
  33. package/dist/connect-project.d.ts +14 -0
  34. package/dist/connection-issue.d.ts +7 -0
  35. package/dist/design-comment-QPTW66JO.js +1 -0
  36. package/dist/design-comment.d.ts +42 -0
  37. package/dist/design-export-BRBI7S3V.js +1 -0
  38. package/dist/design-export.d.ts +8 -0
  39. package/dist/design-mode-styles.d.ts +1 -0
  40. package/dist/design-mode.d.ts +19 -0
  41. package/dist/design-mode.js +1 -0
  42. package/dist/design-panel-BPVXFERR.js +1 -0
  43. package/dist/design-panel-styles.d.ts +1 -0
  44. package/dist/design-panel.d.ts +33 -0
  45. package/dist/design-tokens.d.ts +76 -0
  46. package/dist/dom.d.ts +20 -0
  47. package/dist/drawer-expand-motion.d.ts +46 -0
  48. package/dist/drawer-presence.d.ts +4 -0
  49. package/dist/edge-sidebar.d.ts +39 -0
  50. package/dist/emoji-history.d.ts +8 -0
  51. package/dist/emoji.d.ts +1 -0
  52. package/dist/floating-drag.d.ts +9 -0
  53. package/dist/icon-markup.d.ts +4 -0
  54. package/dist/index.d.ts +8 -0
  55. package/dist/index.js +1 -0
  56. package/dist/inline-text-editor-F7B6FQQ3.js +1 -0
  57. package/dist/inline-text-editor.d.ts +70 -0
  58. package/dist/lazy-accent-picker.d.ts +4 -0
  59. package/dist/lazy-toolbar.d.ts +19 -0
  60. package/dist/message-links.d.ts +2 -0
  61. package/dist/morphing-menu-styles.d.ts +1 -0
  62. package/dist/node.mjs +2607 -0
  63. package/dist/onboarding-NO3RQWR7.js +1 -0
  64. package/dist/onboarding.d.ts +6 -0
  65. package/dist/optimistic.d.ts +26 -0
  66. package/dist/page.d.ts +2 -0
  67. package/dist/picker-WQN53YJ5.js +1 -0
  68. package/dist/pin-direction.d.ts +12 -0
  69. package/dist/pin-stacks.d.ts +3 -0
  70. package/dist/polling.d.ts +10 -0
  71. package/dist/postgres/001_initial.sql +128 -0
  72. package/dist/postgres/002_thread_tags.sql +1 -0
  73. package/dist/postgres/003_project_webhooks.sql +4 -0
  74. package/dist/project-management.d.ts +2 -0
  75. package/dist/react-inspector.d.ts +43 -0
  76. package/dist/react-props-panel-XSMDR2CY.js +1 -0
  77. package/dist/react-props-panel.d.ts +39 -0
  78. package/dist/react.d.ts +38 -0
  79. package/dist/react.js +1 -0
  80. package/dist/reaction-picker.d.ts +1 -0
  81. package/dist/review-layout.d.ts +23 -0
  82. package/dist/setup-browser.d.ts +1 -0
  83. package/dist/setup.d.ts +4 -0
  84. package/dist/setup.js +1 -0
  85. package/dist/shortcuts.d.ts +15 -0
  86. package/dist/static-svg.d.ts +3 -0
  87. package/dist/styles.d.ts +1 -0
  88. package/dist/tag-editor-VDPZ6CO3.js +1 -0
  89. package/dist/tag-editor.d.ts +12 -0
  90. package/dist/toolbar-runtime-2SIUE6SQ.js +1 -0
  91. package/dist/toolbar-runtime.d.ts +5 -0
  92. package/dist/types.d.ts +163 -0
  93. package/package.json +114 -4
  94. package/server/digest.ts +8 -0
  95. package/server/import-project.ts +164 -0
  96. package/server/index.ts +1656 -0
  97. package/server/migrations/0001_comments.sql +54 -0
  98. package/server/migrations/0002_identity.sql +2 -0
  99. package/server/migrations/0003_accent_color.sql +1 -0
  100. package/server/migrations/0004_workspaces.sql +46 -0
  101. package/server/migrations/0005_domains.sql +6 -0
  102. package/server/migrations/0006_member_budget.sql +16 -0
  103. package/server/migrations/0007_revisions.sql +17 -0
  104. package/server/migrations/0008_starter_comments.sql +1 -0
  105. package/server/migrations/0009_retention.sql +19 -0
  106. package/server/migrations/0010_reviewer_history.sql +1 -0
  107. package/server/migrations/0011_project_management.sql +52 -0
  108. package/server/migrations/0012_setup_sites.sql +3 -0
  109. package/server/migrations/0013_project_sites.sql +8 -0
  110. package/server/migrations/0014_project_site_removals.sql +3 -0
  111. package/server/migrations/0015_query_efficiency.sql +6 -0
  112. package/server/migrations/0016_thread_tags.sql +1 -0
  113. package/server/migrations/0017_project_webhooks.sql +4 -0
  114. package/server/node-webhooks.ts +126 -0
  115. package/server/project-management.ts +387 -0
  116. package/server/project-sites.ts +117 -0
  117. package/server/secrets.d.ts +24 -0
  118. package/server/setup-client.txt +2932 -0
  119. package/server/setup-page.ts +14 -0
  120. package/server/tsconfig.json +12 -0
  121. package/server/validation.ts +220 -0
  122. package/server/webhooks.ts +206 -0
  123. package/server/workspaces.ts +168 -0
  124. package/src/emoji.ts +7 -0
  125. package/src/page.ts +4 -0
  126. package/src/types.ts +166 -0
@@ -0,0 +1,14 @@
1
+ export function setupPage() {
2
+ return new Response(
3
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Set up komo</title><style>html{color-scheme:dark}body{margin:0;background:#111;color:#777;font:15px system-ui}main{height:100dvh;display:grid;place-items:center;background:#171717}h1{font-size:32px;letter-spacing:-1.5px;font-weight:500;color:#444}</style></head><body><main><h1>komo</h1></main><script type="module" src="/setup-client.js"></script></body></html>`,
4
+ {
5
+ headers: {
6
+ "Content-Type": "text/html; charset=utf-8",
7
+ "Cache-Control": "no-store",
8
+ "Referrer-Policy": "no-referrer",
9
+ "Content-Security-Policy":
10
+ "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; img-src https: data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
11
+ },
12
+ }
13
+ );
14
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "types": ["node"]
10
+ },
11
+ "include": ["*.ts", "worker-configuration.d.ts", "../src/types.ts"]
12
+ }
@@ -0,0 +1,220 @@
1
+ import { canonicalPage } from "../src/page.js";
2
+ import type { Anchor } from "../src/types.js";
3
+
4
+ export class HttpError extends Error {
5
+ constructor(
6
+ public status: number,
7
+ message: string,
8
+ public code?: string,
9
+ ) {
10
+ super(message);
11
+ }
12
+ }
13
+ export function check(
14
+ condition: unknown,
15
+ status: number,
16
+ message: string,
17
+ ): asserts condition {
18
+ if (!condition) throw new HttpError(status, message);
19
+ }
20
+ export function string(value: unknown, max: number, label: string): string {
21
+ check(
22
+ typeof value === "string" && value.trim().length > 0 && value.length <= max,
23
+ 400,
24
+ `Invalid ${label}.`,
25
+ );
26
+ return value.trim();
27
+ }
28
+ export function pagePath(value: unknown): string {
29
+ const page = string(value, 2000, "page");
30
+ check(
31
+ page.startsWith("/") &&
32
+ !page.startsWith("//") &&
33
+ !/[?#\\]/.test(page) &&
34
+ ![...page].some((char) => char.charCodeAt(0) < 32),
35
+ 400,
36
+ "Invalid page path.",
37
+ );
38
+ return canonicalPage(page);
39
+ }
40
+ export function anchorValue(value: unknown): Anchor {
41
+ check(value && typeof value === "object", 400, "Invalid anchor.");
42
+ const a = value as Record<string, unknown>;
43
+ for (const key of [
44
+ "x",
45
+ "y",
46
+ "width",
47
+ "height",
48
+ "pageX",
49
+ "pageY",
50
+ "viewportWidth",
51
+ ]) {
52
+ check(
53
+ typeof a[key] === "number" && Number.isFinite(a[key]) && a[key] >= 0,
54
+ 400,
55
+ `Invalid anchor ${key}.`,
56
+ );
57
+ }
58
+ for (const key of ["x", "y", "width", "height"])
59
+ check(Number(a[key]) <= 1, 400, "Anchor outside element.");
60
+ check(
61
+ Number(a.x) + Number(a.width) <= 1.001 &&
62
+ Number(a.y) + Number(a.height) <= 1.001,
63
+ 400,
64
+ "Area outside element.",
65
+ );
66
+ check(
67
+ Number(a.viewportWidth) > 0 &&
68
+ Number(a.viewportWidth) <= 20000 &&
69
+ Number(a.pageY) <= 10000000 &&
70
+ Number(a.pageX) <= 20000,
71
+ 400,
72
+ "Anchor outside page.",
73
+ );
74
+ check(
75
+ typeof a.selector === "string" &&
76
+ a.selector.length <= 2000 &&
77
+ typeof a.text === "string" &&
78
+ a.text.length <= 160,
79
+ 400,
80
+ "Invalid anchor selector.",
81
+ );
82
+ let context: Anchor["context"];
83
+ if (a.context !== undefined) {
84
+ check(
85
+ a.context && typeof a.context === "object" && !Array.isArray(a.context),
86
+ 400,
87
+ "Invalid anchor context.",
88
+ );
89
+ context = {};
90
+ const supplied = a.context as Record<string, unknown>;
91
+ for (const [key, max] of Object.entries({
92
+ tag: 32,
93
+ role: 80,
94
+ label: 160,
95
+ nearby: 160,
96
+ classes: 200,
97
+ selectedText: 200,
98
+ styles: 500,
99
+ scope: 2000,
100
+ })) {
101
+ if (supplied[key] === undefined) continue;
102
+ check(
103
+ typeof supplied[key] === "string" && supplied[key].length <= max,
104
+ 400,
105
+ `Invalid anchor context ${key}.`,
106
+ );
107
+ context[key as keyof typeof context] = supplied[key];
108
+ }
109
+ }
110
+ const source =
111
+ typeof a.source === "string" &&
112
+ a.source.length <= 500 &&
113
+ !a.source.includes("..") &&
114
+ !a.source.startsWith("/")
115
+ ? a.source
116
+ : undefined;
117
+ return {
118
+ selector: a.selector,
119
+ text: a.text,
120
+ x: Number(a.x),
121
+ y: Number(a.y),
122
+ width: Number(a.width),
123
+ height: Number(a.height),
124
+ pageX: Number(a.pageX),
125
+ pageY: Number(a.pageY),
126
+ viewportWidth: Number(a.viewportWidth),
127
+ source,
128
+ ...(context ? { context } : {}),
129
+ ...(a.unstacked === true ? { unstacked: true } : {}),
130
+ };
131
+ }
132
+
133
+ /** A local dev server on any port. Browsers never send this from a remote site. */
134
+ export function localOrigin(origin: string): boolean {
135
+ return /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
136
+ }
137
+
138
+ /**
139
+ * Every deploy of the same Cloudflare Pages project, derived from one of its
140
+ * origins. Only that project's owner can publish under *.<project>.pages.dev.
141
+ */
142
+ export function previewPattern(origin: string): string | undefined {
143
+ const pages = /^https:\/\/(?:[a-z0-9-]+\.)?([a-z0-9-]+\.pages\.dev)$/.exec(
144
+ origin,
145
+ );
146
+ return pages ? `https://*.${pages[1]}` : undefined;
147
+ }
148
+
149
+ export function originAllowed(origin: string, patterns: string[]): boolean {
150
+ let url: URL;
151
+ try {
152
+ url = new URL(origin);
153
+ } catch {
154
+ return false;
155
+ }
156
+ if (url.origin !== origin) return false;
157
+ return patterns.some((pattern) => {
158
+ if (!pattern.includes("*")) return pattern === origin;
159
+ const escaped = pattern
160
+ .split("*")
161
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
162
+ .join("[a-zA-Z0-9-]+");
163
+ return new RegExp(`^${escaped}$`).test(origin);
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Validate an owner-approved site: an exact HTTPS origin, localhost, or an
169
+ * HTTPS pattern with `*` in the leftmost host label only, such as
170
+ * https://*-site.example.workers.dev. The wildcard never spans dots.
171
+ */
172
+ export function sitePattern(value: unknown): string {
173
+ check(
174
+ typeof value === "string" && value.length <= 300,
175
+ 400,
176
+ "Enter a site address.",
177
+ );
178
+ const site = value.trim().replace(/\/$/, "").toLowerCase();
179
+ if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(site)) return site;
180
+ const match = /^https:\/\/([^/?#@]+)$/.exec(site);
181
+ const host = match?.[1] ?? "";
182
+ const [first = "", ...rest] = host.split(".");
183
+ let url: URL | undefined;
184
+ try {
185
+ url = new URL(site);
186
+ } catch {
187
+ url = undefined;
188
+ }
189
+ check(
190
+ !!match &&
191
+ url?.origin === site &&
192
+ !rest.join(".").includes("*") &&
193
+ first.split("*").length - 1 <= 1 &&
194
+ (!first.includes("*") ||
195
+ (rest.length >= 2 && /^[a-z0-9*-]+$/.test(first))),
196
+ 400,
197
+ "Use an HTTPS site like https://your-site.com, or a wildcard like https://*-preview.your-site.com.",
198
+ );
199
+ return site;
200
+ }
201
+
202
+ export function cliReturnOrigin(value: unknown, fallback: string): string {
203
+ if (value === undefined) return fallback;
204
+ check(typeof value === "string", 400, "Invalid CLI return origin.");
205
+ let url: URL;
206
+ try {
207
+ url = new URL(value);
208
+ } catch {
209
+ throw new HttpError(400, "Invalid CLI return origin.");
210
+ }
211
+ check(
212
+ url.protocol === "http:" &&
213
+ url.hostname === "127.0.0.1" &&
214
+ Number(url.port) >= 1024 &&
215
+ url.origin === value,
216
+ 400,
217
+ "CLI sign-in must return to an ephemeral loopback port.",
218
+ );
219
+ return url.origin;
220
+ }
@@ -0,0 +1,206 @@
1
+ const encoder = new TextEncoder();
2
+
3
+ export const WEBHOOK_EVENTS = [
4
+ "comment.created",
5
+ "comment.replied",
6
+ "comment.resolved",
7
+ ] as const;
8
+ export type WebhookEventName = (typeof WEBHOOK_EVENTS)[number];
9
+
10
+ export type WebhookEvent = {
11
+ id: string;
12
+ event: WebhookEventName;
13
+ occurredAt: string;
14
+ projectKey: string;
15
+ repo: string;
16
+ branch: string;
17
+ threadId: string;
18
+ pageUrl: string;
19
+ selector: string;
20
+ author: string;
21
+ body: string | null;
22
+ };
23
+
24
+ type WebhookEnv = {
25
+ DB: {
26
+ prepare(sql: string): {
27
+ bind(...values: unknown[]): {
28
+ first<T>(): Promise<T | null>;
29
+ };
30
+ };
31
+ };
32
+ KOMO_WEBHOOK_KEYS?: string;
33
+ KOMO_WEBHOOK_SEND?: (
34
+ url: string,
35
+ body: string,
36
+ headers: Record<string, string>
37
+ ) => Promise<void>;
38
+ };
39
+
40
+ export function validateWebhookUrl(value: unknown): string {
41
+ if (typeof value !== "string" || value.length > 2048)
42
+ throw new Error("Enter an HTTPS webhook URL under 2048 characters.");
43
+ let url: URL;
44
+ try {
45
+ url = new URL(value);
46
+ } catch {
47
+ throw new Error("Enter a valid HTTPS webhook URL.");
48
+ }
49
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
50
+ if (
51
+ url.protocol !== "https:" ||
52
+ url.username ||
53
+ url.password ||
54
+ url.hash ||
55
+ (url.port && url.port !== "443") ||
56
+ !host.includes(".") ||
57
+ /^(localhost|metadata|metadata\.google\.internal)(\.|$)/.test(host) ||
58
+ /\.(localhost|local|internal|test|invalid|example)$/.test(host) ||
59
+ host.startsWith("[") ||
60
+ /^(?:\d{1,3}\.){3}\d{1,3}$/.test(host)
61
+ )
62
+ throw new Error("Webhook URL must use a public HTTPS hostname on port 443.");
63
+ url.hostname = host;
64
+ return url.toString();
65
+ }
66
+
67
+ function keyring(raw: string | undefined) {
68
+ if (!raw) throw new Error("Webhook encryption is not configured.");
69
+ let value: unknown;
70
+ try {
71
+ value = JSON.parse(raw);
72
+ } catch {
73
+ throw new Error("Webhook encryption is not configured.");
74
+ }
75
+ if (!value || typeof value !== "object" || Array.isArray(value))
76
+ throw new Error("Webhook encryption is not configured.");
77
+ const ring = value as { current?: unknown; keys?: unknown };
78
+ if (
79
+ typeof ring.current !== "string" ||
80
+ !ring.keys ||
81
+ typeof ring.keys !== "object" ||
82
+ Array.isArray(ring.keys)
83
+ )
84
+ throw new Error("Webhook encryption is not configured.");
85
+ return ring as { current: string; keys: Record<string, string> };
86
+ }
87
+
88
+ async function aesKey(raw: string) {
89
+ let bytes: Uint8Array;
90
+ try {
91
+ bytes = Uint8Array.from(atob(raw), (char) => char.charCodeAt(0));
92
+ } catch {
93
+ throw new Error("Webhook encryption key is invalid.");
94
+ }
95
+ if (bytes.length !== 32) throw new Error("Webhook encryption key is invalid.");
96
+ const buffer = bytes.buffer.slice(
97
+ bytes.byteOffset,
98
+ bytes.byteOffset + bytes.byteLength
99
+ ) as ArrayBuffer;
100
+ return crypto.subtle.importKey("raw", buffer, "AES-GCM", false, ["encrypt", "decrypt"]);
101
+ }
102
+
103
+ export async function encryptWebhookSecret(
104
+ rawKeyring: string | undefined,
105
+ project: string,
106
+ secret: string
107
+ ) {
108
+ if (secret.length < 32 || secret.length > 256)
109
+ throw new Error("Webhook signing secret must be 32–256 characters.");
110
+ const ring = keyring(rawKeyring);
111
+ const material = ring.keys[ring.current];
112
+ if (!material) throw new Error("Current webhook encryption key is unavailable.");
113
+ const iv = crypto.getRandomValues(new Uint8Array(12));
114
+ const ciphertext = await crypto.subtle.encrypt(
115
+ { name: "AES-GCM", iv, additionalData: encoder.encode(`komo-webhook:v1:${project}`) },
116
+ await aesKey(material),
117
+ encoder.encode(secret)
118
+ );
119
+ return `v1.${ring.current}.${toBase64(iv)}.${toBase64(new Uint8Array(ciphertext))}`;
120
+ }
121
+
122
+ export async function decryptWebhookSecret(
123
+ rawKeyring: string | undefined,
124
+ project: string,
125
+ stored: string
126
+ ) {
127
+ const [version, keyId, ivPart, dataPart, extra] = stored.split(".");
128
+ if (version !== "v1" || !keyId || !ivPart || !dataPart || extra)
129
+ throw new Error("Stored webhook secret is invalid.");
130
+ const ring = keyring(rawKeyring);
131
+ const material = ring.keys[keyId];
132
+ if (!material) throw new Error("Webhook decryption key is unavailable.");
133
+ try {
134
+ const clear = await crypto.subtle.decrypt(
135
+ {
136
+ name: "AES-GCM",
137
+ iv: fromBase64(ivPart),
138
+ additionalData: encoder.encode(`komo-webhook:v1:${project}`),
139
+ },
140
+ await aesKey(material),
141
+ fromBase64(dataPart)
142
+ );
143
+ return new TextDecoder().decode(clear);
144
+ } catch {
145
+ throw new Error("Webhook secret could not be decrypted.");
146
+ }
147
+ }
148
+
149
+ export async function signWebhook(secret: string, timestamp: number, body: string) {
150
+ const key = await crypto.subtle.importKey(
151
+ "raw",
152
+ encoder.encode(secret),
153
+ { name: "HMAC", hash: "SHA-256" },
154
+ false,
155
+ ["sign"]
156
+ );
157
+ const digest = new Uint8Array(
158
+ await crypto.subtle.sign("HMAC", key, encoder.encode(`${timestamp}.${body}`))
159
+ );
160
+ return `t=${timestamp},v1=${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
161
+ }
162
+
163
+ export async function dispatchWebhook(
164
+ env: WebhookEnv,
165
+ project: string,
166
+ event: WebhookEvent
167
+ ) {
168
+ const row = await env.DB.prepare(
169
+ "SELECT webhook_url,webhook_secret,webhook_events,webhook_enabled FROM project_settings WHERE project=?"
170
+ )
171
+ .bind(project)
172
+ .first<{
173
+ webhook_url: string | null;
174
+ webhook_secret: string | null;
175
+ webhook_events: string | null;
176
+ webhook_enabled: number | null;
177
+ }>();
178
+ if (!row?.webhook_enabled || !row.webhook_url || !row.webhook_secret) return;
179
+ const events = JSON.parse(row.webhook_events ?? "[]") as unknown;
180
+ if (!Array.isArray(events) || !events.includes(event.event)) return;
181
+ if (!env.KOMO_WEBHOOK_SEND) return;
182
+ const body = JSON.stringify(event);
183
+ if (encoder.encode(body).length > 16 * 1024) return;
184
+ const timestamp = Math.floor(Date.now() / 1000);
185
+ const secret = await decryptWebhookSecret(
186
+ env.KOMO_WEBHOOK_KEYS,
187
+ project,
188
+ row.webhook_secret
189
+ );
190
+ const signature = await signWebhook(secret, timestamp, body);
191
+ await env.KOMO_WEBHOOK_SEND(row.webhook_url, body, {
192
+ "X-Komo-Delivery": event.id,
193
+ "X-Komo-Event": event.event,
194
+ "X-Komo-Signature": signature,
195
+ });
196
+ }
197
+
198
+ function toBase64(bytes: Uint8Array) {
199
+ let value = "";
200
+ for (const byte of bytes) value += String.fromCharCode(byte);
201
+ return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
202
+ }
203
+ function fromBase64(value: string) {
204
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
205
+ return Uint8Array.from(atob(normalized), (char) => char.charCodeAt(0));
206
+ }
@@ -0,0 +1,168 @@
1
+ import { check, string, originAllowed } from "./validation";
2
+ import type { Identity } from "../src/types";
3
+ import { editedOrigins, siteEdits } from "./project-sites";
4
+ export type Project = {
5
+ repo: string;
6
+ origins: string[];
7
+ allowGuests?: boolean;
8
+ allowGuestResolve?: boolean;
9
+ requireOwner?: boolean;
10
+ bootstrapHash?: string;
11
+ suspended?: boolean;
12
+ writesPerDay?: number;
13
+ retainedCommentsPerUser?: number;
14
+ };
15
+
16
+ export function retainReviewerComments(
17
+ db: D1Database,
18
+ project: string,
19
+ user: string,
20
+ limit: number | undefined
21
+ ): D1PreparedStatement[] {
22
+ if (!limit || !Number.isInteger(limit) || limit < 1) return [];
23
+ // Remove only this reviewer's threads that would become empty. Do this before
24
+ // pruning their comments so candidate IDs remain available; delete triggers
25
+ // preserve quota/revision accounting. Newest retained comments are untouched.
26
+ return [
27
+ db.prepare(`WITH kept AS MATERIALIZED (
28
+ SELECT c.id FROM comments c JOIN threads t ON t.id=c.thread_id
29
+ WHERE c.user_id=? AND t.project=? ORDER BY c.created_at DESC,c.rowid DESC LIMIT ?
30
+ ) DELETE FROM threads WHERE project=?
31
+ AND id IN (SELECT thread_id FROM comments WHERE user_id=?)
32
+ AND NOT EXISTS (SELECT 1 FROM comments c WHERE c.thread_id=threads.id
33
+ AND (c.user_id<>? OR c.id IN (SELECT id FROM kept)))`)
34
+ .bind(user, project, limit, project, user, user),
35
+ db.prepare(`DELETE FROM comments WHERE user_id=?
36
+ AND thread_id IN (SELECT id FROM threads WHERE project=?)
37
+ AND id NOT IN (SELECT c.id FROM comments c JOIN threads t ON t.id=c.thread_id
38
+ WHERE c.user_id=? AND t.project=? ORDER BY c.created_at DESC,c.rowid DESC LIMIT ?)`)
39
+ .bind(user, project, user, project, limit),
40
+ ];
41
+ }
42
+ export async function projectConfig(
43
+ env: Env,
44
+ project: string
45
+ ): Promise<Project | undefined> {
46
+ const staticConfig = (JSON.parse(env.PROJECTS) as Record<string, Project>)[
47
+ project
48
+ ];
49
+ if (staticConfig)
50
+ return {
51
+ ...staticConfig,
52
+ origins: editedOrigins(
53
+ staticConfig.origins,
54
+ await siteEdits(env, project)
55
+ ),
56
+ };
57
+ const row = await env.DB.prepare(
58
+ "SELECT repo,origins,suspended FROM workspaces WHERE id=?"
59
+ )
60
+ .bind(project)
61
+ .first<{ repo: string; origins: string; suspended: number }>();
62
+ if (!row) return undefined;
63
+ const verified = await env.DB.prepare(
64
+ "SELECT origin FROM workspace_domains WHERE project=?"
65
+ )
66
+ .bind(project)
67
+ .all<{ origin: string }>();
68
+ // Localhost is allowed for every project at the router.
69
+ return {
70
+ repo: row.repo,
71
+ origins: verified.results.map((item) => item.origin),
72
+ requireOwner: true,
73
+ suspended: !!row.suspended,
74
+ writesPerDay: 500,
75
+ };
76
+ }
77
+ export async function googleOwner(env: Env, project: string, user: Identity) {
78
+ check(
79
+ user.verified && user.id.startsWith("google:"),
80
+ 403,
81
+ "Sign in with Google to manage this workspace."
82
+ );
83
+ const owner = await env.DB.prepare(
84
+ "SELECT user_id FROM project_owners WHERE project=?"
85
+ )
86
+ .bind(project)
87
+ .first<{ user_id: string }>();
88
+ check(
89
+ owner?.user_id === user.id,
90
+ 403,
91
+ "Only the workspace owner can do that."
92
+ );
93
+ }
94
+ export function workspaceConfig(config: Record<string, unknown>): {
95
+ repo: string;
96
+ origins: string[];
97
+ } {
98
+ const repo = string(config.repo, 200, "repository");
99
+ const origins = config.origins;
100
+ check(
101
+ Array.isArray(origins) &&
102
+ origins.length > 0 &&
103
+ origins.length <= 10 &&
104
+ origins.every(
105
+ (origin) =>
106
+ typeof origin === "string" &&
107
+ origin.length <= 300 &&
108
+ originAllowed(origin, [origin]) &&
109
+ (origin.startsWith("https://") ||
110
+ /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin))
111
+ ),
112
+ 400,
113
+ "Use exact HTTPS site origins or localhost."
114
+ );
115
+ return { repo, origins };
116
+ }
117
+
118
+ export async function provision(
119
+ env: Env,
120
+ user: Identity,
121
+ config: Record<string, unknown>
122
+ ) {
123
+ check(
124
+ user.verified && user.id.startsWith("google:"),
125
+ 403,
126
+ "A Google account is required to create a workspace."
127
+ );
128
+ const { repo, origins } = workspaceConfig(config);
129
+ const id = `komo_${crypto.randomUUID().replaceAll("-", "")}`;
130
+ const results = await env.DB.batch([
131
+ env.DB.prepare(
132
+ "INSERT INTO workspaces(id,owner_id,repo,origins,created_at) SELECT ?,?,?,?,? WHERE (SELECT COUNT(*) FROM workspaces WHERE owner_id=?)<3"
133
+ ).bind(id, user.id, repo, JSON.stringify(origins), Date.now(), user.id),
134
+ env.DB.prepare(
135
+ "INSERT INTO project_owners(project,user_id) SELECT id,owner_id FROM workspaces WHERE id=?"
136
+ ).bind(id),
137
+ env.DB.prepare(
138
+ "INSERT INTO project_quotas(project,max_comments,max_bytes) SELECT id,250,10485760 FROM workspaces WHERE id=?"
139
+ ).bind(id),
140
+ ]);
141
+ check(
142
+ results[0].meta.changes,
143
+ 409,
144
+ "Your account has reached three hosted projects."
145
+ );
146
+ return { project: id, repo };
147
+ }
148
+
149
+ // Cron owns hosted cleanup. Self-hosted deployments without cron get one attempt
150
+ // per database/isolate/hour; concurrent requests share the same deadline.
151
+ const nextCleanup = new WeakMap<D1Database, number>();
152
+ export async function maintain(db: D1Database, scheduled = false) {
153
+ const now = Date.now();
154
+ if (!scheduled && now < (nextCleanup.get(db) ?? 0)) return;
155
+ nextCleanup.set(db, now + 3600000);
156
+ try {
157
+ await db.batch([
158
+ ...["rate_limits", "sessions", "project_invites", "oauth_states", "setup_requests"].map(
159
+ table => db.prepare(`DELETE FROM ${table} WHERE expires_at<?`).bind(now)
160
+ ),
161
+ db.prepare("DELETE FROM users WHERE id LIKE 'guest:%' AND id NOT IN (SELECT user_id FROM sessions) AND id NOT IN (SELECT user_id FROM comments) AND id NOT IN (SELECT user_id FROM project_members)"),
162
+ ]);
163
+ } catch (error) {
164
+ // Retry transient failures after a minute, not on every incoming request.
165
+ nextCleanup.set(db, now + 60000);
166
+ throw error;
167
+ }
168
+ }
package/src/emoji.ts ADDED
@@ -0,0 +1,7 @@
1
+ import emojiRegex from "emoji-regex";
2
+
3
+ export function isEmoji(value: unknown): value is string {
4
+ if (typeof value !== "string" || value.length > 128) return false;
5
+ const match = value.match(emojiRegex());
6
+ return match?.length === 1 && match[0] === value;
7
+ }
package/src/page.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Static hosts may add a trailing slash to the same page. */
2
+ export function canonicalPage(pathname: string): string {
3
+ return pathname.replace(/\/+$/, "") || "/";
4
+ }