@hazem3500/komo 0.9.2 → 0.9.5

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 (44) hide show
  1. package/NODE.md +1 -1
  2. package/README.md +84 -9
  3. package/cli/agent.mjs +122 -27
  4. package/cli/design-edit.mjs +269 -0
  5. package/dist/adapters/css-variables.d.ts +9 -0
  6. package/dist/adapters/css-variables.js +1 -0
  7. package/dist/adapters/react-fiber.d.ts +27 -0
  8. package/dist/adapters/react-fiber.js +1 -0
  9. package/dist/agent-prompt.d.ts +3 -1
  10. package/dist/agent-prompt.js +1 -1
  11. package/dist/{chunk-Y2GRLW4Q.js → chunk-DH22G2S5.js} +1 -1
  12. package/dist/chunk-NWRSH6AL.js +1 -0
  13. package/dist/design-comment-MPAF2YAN.js +1 -0
  14. package/dist/design-export-DD2NJI4O.js +1 -0
  15. package/dist/design-export.d.ts +9 -2
  16. package/dist/design-mode-styles.d.ts +1 -1
  17. package/dist/design-mode.js +1 -1
  18. package/dist/design-panel-NU2WUI4D.js +1 -0
  19. package/dist/design-panel-styles.d.ts +1 -1
  20. package/dist/design-system.d.ts +24 -5
  21. package/dist/index.js +1 -1
  22. package/dist/{inline-text-editor-3JLDAUPO.js → inline-text-editor-JWVS7J75.js} +1 -1
  23. package/dist/node.mjs +513 -18
  24. package/dist/postgres/004_personal_access_tokens.sql +25 -0
  25. package/dist/react-props-panel-BW4ZQPD7.js +1 -0
  26. package/dist/react-props-panel.d.ts +19 -2
  27. package/dist/react.d.ts +1 -1
  28. package/dist/react.js +1 -1
  29. package/dist/select-menu-V7RYOZFK.js +1 -0
  30. package/dist/select-menu.d.ts +5 -0
  31. package/dist/setup.js +1 -1
  32. package/package.json +14 -3
  33. package/server/account-page.ts +231 -0
  34. package/server/index.ts +62 -5
  35. package/server/migrations/0018_personal_access_tokens.sql +25 -0
  36. package/server/secrets.d.ts +2 -0
  37. package/server/setup-client.txt +54 -59
  38. package/server/tokens.ts +324 -0
  39. package/server/workspaces.ts +2 -0
  40. package/dist/chunk-HCGLCO3R.js +0 -1
  41. package/dist/design-comment-MWNQNKWT.js +0 -1
  42. package/dist/design-export-ZHCBPSWY.js +0 -1
  43. package/dist/design-panel-BPVXFERR.js +0 -1
  44. package/dist/react-props-panel-R5XEVATL.js +0 -1
@@ -0,0 +1,324 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { check, string } from "./validation";
3
+
4
+ // Personal access tokens let a bot act as its owner with a narrower grant.
5
+ // Format: komo_pat_<12-char lookup id>_<43-char secret>. Only a salted SHA-256
6
+ // of the secret is stored (plus an optional server pepper), never the token.
7
+ export const PAT_PREFIX = "komo_pat_";
8
+ export const PAT_SCOPES = ["comments:read", "comments:write"] as const;
9
+ export type PatAccess = "read" | "write" | "any";
10
+ const PAT_PATTERN = /^komo_pat_([a-z0-9]{12})_([A-Za-z0-9_-]{43})$/;
11
+ const MAX_ACTIVE_TOKENS = 25;
12
+ const MAX_LIFETIME_DAYS = 365;
13
+ const USE_LOG_INTERVAL = 60000;
14
+ const AUDIT_RETENTION = 180 * 86400000;
15
+
16
+ type TokenRow = {
17
+ id: string;
18
+ user_id: string;
19
+ name: string;
20
+ last4: string;
21
+ salt: string;
22
+ token_hash: string;
23
+ scopes: string;
24
+ projects: string | null;
25
+ created_at: number;
26
+ expires_at: number | null;
27
+ last_used_at: number | null;
28
+ revoked_at: number | null;
29
+ };
30
+ type TokenUser = {
31
+ id: string;
32
+ name: string;
33
+ verified: number;
34
+ avatar_url?: string;
35
+ accent_color?: string;
36
+ };
37
+
38
+ async function sha256(value: string) {
39
+ return Array.from(
40
+ new Uint8Array(
41
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
42
+ ),
43
+ (byte) => byte.toString(16).padStart(2, "0")
44
+ ).join("");
45
+ }
46
+ const digest = (env: Env, salt: string, secret: string) =>
47
+ sha256(`${env.KOMO_TOKEN_PEPPER ?? ""}\0${salt}\0${secret}`);
48
+ function randomId(length: number) {
49
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
50
+ let result = "";
51
+ while (result.length < length)
52
+ for (const byte of crypto.getRandomValues(new Uint8Array(length)))
53
+ // 252 = 7 * 36: discard the remainder range to avoid modulo bias.
54
+ if (byte < 252 && result.length < length)
55
+ result += alphabet[byte % alphabet.length];
56
+ return result;
57
+ }
58
+ const randomSecret = () =>
59
+ btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))))
60
+ .replace(/\+/g, "-")
61
+ .replace(/\//g, "_")
62
+ .replace(/=+$/, "");
63
+ const list = (value: string | null): string[] | null => {
64
+ if (value === null) return null;
65
+ try {
66
+ const parsed = JSON.parse(value);
67
+ return Array.isArray(parsed) ? parsed.map(String) : [];
68
+ } catch {
69
+ return [];
70
+ }
71
+ };
72
+
73
+ export const isPersonalToken = (bearer: string) =>
74
+ bearer.startsWith(PAT_PREFIX);
75
+
76
+ export function auditStatement(
77
+ env: Env,
78
+ actor: string,
79
+ action: string,
80
+ target: string,
81
+ detail: Record<string, unknown>
82
+ ) {
83
+ return env.DB.prepare(
84
+ "INSERT INTO audit_log(id,at,actor,action,target,detail) VALUES(?,?,?,?,?,?)"
85
+ ).bind(
86
+ crypto.randomUUID(),
87
+ Date.now(),
88
+ actor,
89
+ action,
90
+ target,
91
+ JSON.stringify(detail)
92
+ );
93
+ }
94
+
95
+ export const pruneAudit = (db: D1Database, now: number) =>
96
+ db.prepare("DELETE FROM audit_log WHERE at<?").bind(now - AUDIT_RETENTION);
97
+
98
+ /** Projects an account holder may limit a token to: owned or joined. */
99
+ export async function accountProjects(env: Env, userId: string) {
100
+ const rows = await env.DB.prepare(
101
+ "SELECT id AS project FROM workspaces WHERE owner_id=? UNION SELECT project FROM project_members WHERE user_id=? UNION SELECT project FROM project_owners WHERE user_id=?"
102
+ )
103
+ .bind(userId, userId, userId)
104
+ .all<{ project: string }>();
105
+ return rows.results.map((row) => row.project).sort();
106
+ }
107
+
108
+ const publicToken = (row: TokenRow, now: number) => ({
109
+ id: row.id,
110
+ name: row.name,
111
+ prefix: `${PAT_PREFIX}${row.id}`,
112
+ last4: row.last4,
113
+ scopes: list(row.scopes) ?? [],
114
+ projects: list(row.projects),
115
+ createdAt: row.created_at,
116
+ expiresAt: row.expires_at,
117
+ lastUsedAt: row.last_used_at,
118
+ expired: row.expires_at !== null && row.expires_at <= now,
119
+ });
120
+
121
+ export async function listTokens(env: Env, userId: string) {
122
+ const rows = await env.DB.prepare(
123
+ "SELECT * FROM personal_access_tokens WHERE user_id=? AND revoked_at IS NULL ORDER BY created_at DESC,id"
124
+ )
125
+ .bind(userId)
126
+ .all<TokenRow>();
127
+ const now = Date.now();
128
+ return rows.results.map((row) => publicToken(row, now));
129
+ }
130
+
131
+ export async function createToken(
132
+ env: Env,
133
+ userId: string,
134
+ data: Record<string, unknown>
135
+ ) {
136
+ const name = string(data.name, 60, "token name").trim();
137
+ check(name.length > 0, 400, "Name your token.");
138
+ let expiresAt: number | null = null;
139
+ if (data.expiresInDays !== undefined && data.expiresInDays !== null) {
140
+ const days = data.expiresInDays;
141
+ check(
142
+ typeof days === "number" &&
143
+ Number.isInteger(days) &&
144
+ days >= 1 &&
145
+ days <= MAX_LIFETIME_DAYS,
146
+ 400,
147
+ `Choose an expiry between 1 and ${MAX_LIFETIME_DAYS} days.`
148
+ );
149
+ expiresAt = Date.now() + days * 86400000;
150
+ }
151
+ check(
152
+ Array.isArray(data.scopes) &&
153
+ data.scopes.length > 0 &&
154
+ data.scopes.every((scope) =>
155
+ (PAT_SCOPES as readonly unknown[]).includes(scope)
156
+ ),
157
+ 400,
158
+ `Choose at least one scope: ${PAT_SCOPES.join(", ")}.`
159
+ );
160
+ const scopes = PAT_SCOPES.filter((scope) =>
161
+ (data.scopes as unknown[]).includes(scope)
162
+ );
163
+ let projects: string[] | null = null;
164
+ if (data.projects !== undefined && data.projects !== null) {
165
+ check(
166
+ Array.isArray(data.projects) && data.projects.length <= 50,
167
+ 400,
168
+ "Invalid project list."
169
+ );
170
+ if (data.projects.length) {
171
+ projects = [...new Set(data.projects.map(String))].sort();
172
+ const allowed = new Set(await accountProjects(env, userId));
173
+ check(
174
+ projects.every((project) => allowed.has(project)),
175
+ 400,
176
+ "You can only limit a token to projects you own or have joined."
177
+ );
178
+ }
179
+ }
180
+ const active = await env.DB.prepare(
181
+ "SELECT COUNT(*) AS used FROM personal_access_tokens WHERE user_id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)"
182
+ )
183
+ .bind(userId, Date.now())
184
+ .first<{ used: number }>();
185
+ check(
186
+ (active?.used ?? 0) < MAX_ACTIVE_TOKENS,
187
+ 409,
188
+ `You can have up to ${MAX_ACTIVE_TOKENS} active tokens. Revoke one first.`
189
+ );
190
+ const id = randomId(12);
191
+ const secret = randomSecret();
192
+ const salt = randomId(16);
193
+ const now = Date.now();
194
+ await env.DB.batch([
195
+ env.DB.prepare(
196
+ "INSERT INTO personal_access_tokens(id,user_id,name,last4,salt,token_hash,scopes,projects,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?)"
197
+ ).bind(
198
+ id,
199
+ userId,
200
+ name,
201
+ secret.slice(-4),
202
+ salt,
203
+ await digest(env, salt, secret),
204
+ JSON.stringify(scopes),
205
+ projects && JSON.stringify(projects),
206
+ now,
207
+ expiresAt
208
+ ),
209
+ auditStatement(env, userId, "token.create", id, {
210
+ name,
211
+ scopes,
212
+ projects,
213
+ expiresAt,
214
+ }),
215
+ ]);
216
+ return {
217
+ token: `${PAT_PREFIX}${id}_${secret}`,
218
+ item: publicToken(
219
+ {
220
+ id,
221
+ user_id: userId,
222
+ name,
223
+ last4: secret.slice(-4),
224
+ salt,
225
+ token_hash: "",
226
+ scopes: JSON.stringify(scopes),
227
+ projects: projects && JSON.stringify(projects),
228
+ created_at: now,
229
+ expires_at: expiresAt,
230
+ last_used_at: null,
231
+ revoked_at: null,
232
+ },
233
+ now
234
+ ),
235
+ };
236
+ }
237
+
238
+ export async function revokeToken(env: Env, userId: string, id: string) {
239
+ const results = await env.DB.batch([
240
+ env.DB.prepare(
241
+ "UPDATE personal_access_tokens SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL"
242
+ ).bind(Date.now(), id, userId),
243
+ ]);
244
+ check(results[0].meta.changes, 404, "Token not found.");
245
+ await auditStatement(env, userId, "token.revoke", id, {}).run();
246
+ }
247
+
248
+ /**
249
+ * Resolves a bearer personal access token to its owner. 401 for unknown,
250
+ * revoked, or expired tokens; 403 when the token's scopes or project list
251
+ * do not cover the request. `access` is the permission the route needs;
252
+ * routes that pass none refuse tokens entirely.
253
+ */
254
+ export async function authenticateToken(
255
+ env: Env,
256
+ bearer: string,
257
+ project: string,
258
+ access: PatAccess | undefined,
259
+ request: Request
260
+ ): Promise<TokenUser> {
261
+ const parts = bearer.match(PAT_PATTERN);
262
+ const row = parts
263
+ ? await env.DB.prepare(
264
+ "SELECT * FROM personal_access_tokens WHERE id=?"
265
+ )
266
+ .bind(parts[1])
267
+ .first<TokenRow>()
268
+ : null;
269
+ const expected = await digest(
270
+ env,
271
+ row?.salt ?? "-",
272
+ parts?.[2] ?? bearer
273
+ );
274
+ const encoder = new TextEncoder();
275
+ const stored = encoder.encode(row?.token_hash ?? "0".repeat(64));
276
+ const given = encoder.encode(expected);
277
+ const now = Date.now();
278
+ const valid =
279
+ row &&
280
+ stored.length === given.length &&
281
+ timingSafeEqual(stored, given) &&
282
+ row.revoked_at === null &&
283
+ (row.expires_at === null || row.expires_at > now);
284
+ check(valid, 401, "This access token is invalid, expired, or revoked.");
285
+ const user = await env.DB.prepare("SELECT * FROM users WHERE id=?")
286
+ .bind(row.user_id)
287
+ .first<TokenUser>();
288
+ check(
289
+ user && user.verified && user.id.startsWith("google:"),
290
+ 401,
291
+ "This access token is invalid, expired, or revoked."
292
+ );
293
+ check(
294
+ access,
295
+ 403,
296
+ "Access tokens can only read and write comments."
297
+ );
298
+ const scopes = list(row.scopes) ?? [];
299
+ check(
300
+ access === "any" ||
301
+ scopes.includes(access === "read" ? "comments:read" : "comments:write"),
302
+ 403,
303
+ `This token needs the comments:${access === "read" ? "read" : "write"} scope.`
304
+ );
305
+ const projects = list(row.projects);
306
+ check(
307
+ !projects || projects.includes(project),
308
+ 403,
309
+ "This token is not allowed for this project."
310
+ );
311
+ // Record use at most once a minute per token: last_used_at is the throttle.
312
+ const touched = await env.DB.prepare(
313
+ "UPDATE personal_access_tokens SET last_used_at=? WHERE id=? AND (last_used_at IS NULL OR last_used_at<?)"
314
+ )
315
+ .bind(now, row.id, now - USE_LOG_INTERVAL)
316
+ .run();
317
+ if (touched.meta.changes)
318
+ await auditStatement(env, row.user_id, "token.use", row.id, {
319
+ project,
320
+ method: request.method,
321
+ path: new URL(request.url).pathname,
322
+ }).run();
323
+ return user;
324
+ }
@@ -1,5 +1,6 @@
1
1
  import { check, string, originAllowed } from "./validation";
2
2
  import type { Identity } from "../src/types";
3
+ import { pruneAudit } from "./tokens";
3
4
  import { editedOrigins, siteEdits } from "./project-sites";
4
5
  export type Project = {
5
6
  repo: string;
@@ -158,6 +159,7 @@ export async function maintain(db: D1Database, scheduled = false) {
158
159
  ...["rate_limits", "sessions", "project_invites", "oauth_states", "setup_requests"].map(
159
160
  table => db.prepare(`DELETE FROM ${table} WHERE expires_at<?`).bind(now)
160
161
  ),
162
+ pruneAudit(db, now),
161
163
  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
164
  ]);
163
165
  } catch (error) {