Skip to content

Commit 48cfde0

Browse files
fullstackjamclaude
andcommitted
fix: resolve snapshot upload, auth race condition, and add rate limiting
- Fix from-snapshot: convert string[] to typed {name,type}[] for validation - Fix from-snapshot: respect visibility field from CLI instead of hardcoding - Fix auth poll: atomic UPDATE prevents double device code redemption - Add rate limiting (30/min per IP) to homebrew and npm search endpoints - Add 26 missing package metadata entries to align with CLI catalog - Fix install count errors: log instead of silently swallowing - Fix expires_at: return null instead of undefined when token has no expiry - Remove empty index.ts barrel file Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5efb5bf commit 48cfde0

File tree

8 files changed

+219
-18
lines changed

8 files changed

+219
-18
lines changed

src/hooks.server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const handle: Handle = async ({ event, resolve }) => {
6868

6969
const script = generateInstallScript(config.username, config.slug, config.custom_script, config.dotfiles_repo || '');
7070

71-
env.DB.prepare('UPDATE configs SET install_count = install_count + 1 WHERE alias = ?').bind(alias).run().catch(() => {});
71+
env.DB.prepare('UPDATE configs SET install_count = install_count + 1 WHERE alias = ?').bind(alias).run().catch((e: unknown) => console.error('install count update failed:', e));
7272

7373
return withSecurityHeaders(new Response(script, {
7474
headers: {
@@ -106,7 +106,7 @@ export const handle: Handle = async ({ event, resolve }) => {
106106

107107
const script = generateInstallScript(username, slug, config.custom_script, config.dotfiles_repo || '');
108108

109-
env.DB.prepare('UPDATE configs SET install_count = install_count + 1 WHERE user_id = ? AND slug = ?').bind(user.id, slug).run().catch(() => {});
109+
env.DB.prepare('UPDATE configs SET install_count = install_count + 1 WHERE user_id = ? AND slug = ?').bind(user.id, slug).run().catch((e: unknown) => console.error('install count update failed:', e));
110110

111111
return withSecurityHeaders(new Response(script, {
112112
headers: {
@@ -145,7 +145,7 @@ export const handle: Handle = async ({ event, resolve }) => {
145145

146146
const script = generateInstallScript(username, slug, config.custom_script, config.dotfiles_repo || '');
147147

148-
env.DB.prepare('UPDATE configs SET install_count = install_count + 1 WHERE user_id = ? AND slug = ?').bind(user.id, slug).run().catch(() => {});
148+
env.DB.prepare('UPDATE configs SET install_count = install_count + 1 WHERE user_id = ? AND slug = ?').bind(user.id, slug).run().catch((e: unknown) => console.error('install count update failed:', e));
149149

150150
return withSecurityHeaders(new Response(script, {
151151
headers: {

src/lib/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/lib/package-metadata.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,138 @@ export const PACKAGE_METADATA: Record<string, PackageMetadata> = {
467467
type: 'gui'
468468
},
469469

470+
// Additional Languages
471+
zig: {
472+
name: 'zig',
473+
description: 'Systems programming language with manual memory management',
474+
category: 'optional',
475+
type: 'language'
476+
},
477+
elixir: {
478+
name: 'elixir',
479+
description: 'Functional programming language for scalable applications',
480+
category: 'optional',
481+
type: 'language'
482+
},
483+
484+
// Additional DevOps
485+
argocd: {
486+
name: 'argocd',
487+
description: 'GitOps continuous delivery tool for Kubernetes',
488+
category: 'optional',
489+
type: 'devops'
490+
},
491+
492+
// Additional Database Tools
493+
mysql: {
494+
name: 'mysql',
495+
description: 'Popular open-source relational database client',
496+
category: 'development',
497+
type: 'database'
498+
},
499+
datagrip: {
500+
name: 'datagrip',
501+
description: 'JetBrains database IDE for multiple engines',
502+
category: 'optional',
503+
type: 'gui'
504+
},
505+
pgadmin4: {
506+
name: 'pgadmin4',
507+
description: 'PostgreSQL administration and management GUI',
508+
category: 'optional',
509+
type: 'gui'
510+
},
511+
512+
// Additional Editors & Terminals
513+
zed: {
514+
name: 'zed',
515+
description: 'High-performance code editor built in Rust',
516+
category: 'optional',
517+
type: 'gui'
518+
},
519+
'sublime-text': {
520+
name: 'sublime-text',
521+
description: 'Lightweight and fast text editor',
522+
category: 'optional',
523+
type: 'gui'
524+
},
525+
webstorm: {
526+
name: 'webstorm',
527+
description: 'JetBrains IDE for JavaScript and TypeScript',
528+
category: 'optional',
529+
type: 'gui'
530+
},
531+
iterm2: {
532+
name: 'iterm2',
533+
description: 'Feature-rich terminal emulator for macOS',
534+
category: 'optional',
535+
type: 'gui'
536+
},
537+
alacritty: {
538+
name: 'alacritty',
539+
description: 'GPU-accelerated cross-platform terminal emulator',
540+
category: 'optional',
541+
type: 'gui'
542+
},
543+
kitty: {
544+
name: 'kitty',
545+
description: 'GPU-based terminal emulator with advanced features',
546+
category: 'optional',
547+
type: 'gui'
548+
},
549+
ghostty: {
550+
name: 'ghostty',
551+
description: 'Fast native terminal emulator',
552+
category: 'optional',
553+
type: 'gui'
554+
},
555+
556+
// Additional Browsers
557+
'microsoft-edge': {
558+
name: 'microsoft-edge',
559+
description: 'Chromium-based web browser by Microsoft',
560+
category: 'optional',
561+
type: 'gui'
562+
},
563+
'brave-browser': {
564+
name: 'brave-browser',
565+
description: 'Privacy-focused web browser with ad blocking',
566+
category: 'optional',
567+
type: 'gui'
568+
},
569+
570+
// Additional Productivity & Communication
571+
slack: {
572+
name: 'slack',
573+
description: 'Team communication and collaboration platform',
574+
category: 'productivity',
575+
type: 'gui'
576+
},
577+
discord: {
578+
name: 'discord',
579+
description: 'Community chat platform for voice and text',
580+
category: 'optional',
581+
type: 'gui'
582+
},
583+
telegram: {
584+
name: 'telegram',
585+
description: 'Fast and secure messaging app',
586+
category: 'optional',
587+
type: 'gui'
588+
},
589+
sketch: {
590+
name: 'sketch',
591+
description: 'Vector design tool for macOS',
592+
category: 'optional',
593+
type: 'gui'
594+
},
595+
imageoptim: {
596+
name: 'imageoptim',
597+
description: 'Image compression and optimization tool',
598+
category: 'productivity',
599+
type: 'gui'
600+
},
601+
470602
// NPM Packages
471603
typescript: {
472604
name: 'typescript',
@@ -521,6 +653,42 @@ export const PACKAGE_METADATA: Record<string, PackageMetadata> = {
521653
description: 'CLI for Cloudflare Workers development',
522654
category: 'development',
523655
type: 'cli'
656+
},
657+
'firebase-tools': {
658+
name: 'firebase-tools',
659+
description: 'Firebase CLI for managing and deploying projects',
660+
category: 'development',
661+
type: 'cli'
662+
},
663+
'@angular/cli': {
664+
name: '@angular/cli',
665+
description: 'Angular framework command-line interface',
666+
category: 'development',
667+
type: 'cli'
668+
},
669+
'create-react-app': {
670+
name: 'create-react-app',
671+
description: 'Create React applications with zero configuration',
672+
category: 'development',
673+
type: 'cli'
674+
},
675+
degit: {
676+
name: 'degit',
677+
description: 'Scaffold projects from git repositories',
678+
category: 'development',
679+
type: 'cli'
680+
},
681+
np: {
682+
name: 'np',
683+
description: 'Better npm publish with version management',
684+
category: 'development',
685+
type: 'cli'
686+
},
687+
'npm-check-updates': {
688+
name: 'npm-check-updates',
689+
description: 'Update package.json dependency versions',
690+
category: 'development',
691+
type: 'cli'
524692
}
525693
};
526694

src/lib/server/rate-limit.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ export const RATE_LIMITS = {
7575
CLI_APPROVE: { maxRequests: 10, windowMs: 60000 },
7676
CLI_POLL: { maxRequests: 20, windowMs: 60000 },
7777
CONFIG_READ: { maxRequests: 30, windowMs: 60000 },
78-
CONFIG_WRITE: { maxRequests: 30, windowMs: 60000 }
78+
CONFIG_WRITE: { maxRequests: 30, windowMs: 60000 },
79+
SEARCH: { maxRequests: 30, windowMs: 60000 }
7980
} as const;
8081

8182
export function checkRateLimit(key: string, config: RateLimitConfig): RateLimitResult {

src/routes/api/auth/cli/poll/+server.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,24 @@ export const GET: RequestHandler = async ({ platform, url }) => {
4242
.bind(row.user_id)
4343
.first<{ username: string }>();
4444

45-
// Mark as used after first successful fetch (idempotent)
45+
// Atomically mark as used to prevent double-redemption
4646
if (row.status === 'approved') {
47-
await env.DB.prepare("UPDATE cli_auth_codes SET status = 'used' WHERE id = ?")
48-
.bind(code_id)
49-
.run();
47+
const updateResult = await env.DB.prepare(
48+
"UPDATE cli_auth_codes SET status = 'used' WHERE id = ? AND status = 'approved'"
49+
).bind(code_id).run();
50+
51+
// If no rows were updated, another request already redeemed this code
52+
if (!updateResult.meta.changes) {
53+
return json({ status: 'used' });
54+
}
5055
}
5156

5257
return json({
5358
status: 'approved',
5459
token: token?.token,
5560
username: user?.username,
5661
// Ensure strict RFC3339 format for Go client
57-
expires_at: token?.expires_at ? token.expires_at.replace(' ', 'T') + 'Z' : undefined
62+
expires_at: token?.expires_at ? token.expires_at.replace(' ', 'T') + 'Z' : null
5863
});
5964
}
6065

src/routes/api/configs/from-snapshot/+server.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,31 @@ export const POST: RequestHandler = async ({ platform, cookies, request }) => {
2323
return json({ error: 'Invalid request body' }, { status: 400 });
2424
}
2525

26-
const { name, description, snapshot, config_slug } = body;
26+
const { name, description, snapshot, config_slug, visibility } = body;
2727

2828
if (!name) return json({ error: 'Name is required' }, { status: 400 });
2929
if (!snapshot) return json({ error: 'Snapshot is required' }, { status: 400 });
3030

31+
const validVisibilities = ['public', 'unlisted', 'private'];
32+
const validVisibility = validVisibilities.includes(visibility) ? visibility : 'unlisted';
33+
3134
const snapshotSize = JSON.stringify(snapshot).length;
3235
if (snapshotSize > 100000) {
3336
return json({ error: 'Snapshot payload too large (max 100KB)' }, { status: 400 });
3437
}
3538

36-
const packages = snapshot.catalog_match?.matched || [];
39+
const matchedNames: string[] = snapshot.catalog_match?.matched || [];
3740
const base_preset = snapshot.matched_preset || 'developer';
3841

42+
// Convert plain string names from CLI snapshot into typed package objects
43+
const snapshotCasks = new Set<string>(snapshot.packages?.casks || []);
44+
const snapshotNpm = new Set<string>(snapshot.packages?.npm || []);
45+
const packages = matchedNames.map((name: string) => {
46+
if (snapshotCasks.has(name)) return { name, type: 'cask' };
47+
if (snapshotNpm.has(name)) return { name, type: 'npm' };
48+
return { name, type: 'formula' };
49+
});
50+
3951
const pv = validatePackages(packages);
4052
if (!pv.valid) return json({ error: pv.error }, { status: 400 });
4153

@@ -49,13 +61,14 @@ export const POST: RequestHandler = async ({ platform, cookies, request }) => {
4961
}
5062

5163
await env.DB.prepare(
52-
`UPDATE configs
53-
SET snapshot = ?, snapshot_at = datetime('now'), packages = ?
64+
`UPDATE configs
65+
SET snapshot = ?, snapshot_at = datetime('now'), packages = ?, visibility = ?
5466
WHERE user_id = ? AND slug = ?`
5567
)
5668
.bind(
5769
JSON.stringify(snapshot),
5870
JSON.stringify(packages),
71+
validVisibility,
5972
user.id,
6073
config_slug
6174
)
@@ -114,7 +127,7 @@ export const POST: RequestHandler = async ({ platform, cookies, request }) => {
114127
try {
115128
await env.DB.prepare(
116129
`INSERT INTO configs (id, user_id, slug, name, description, base_preset, packages, snapshot, snapshot_at, visibility)
117-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 'unlisted')`
130+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)`
118131
)
119132
.bind(
120133
id,
@@ -124,7 +137,8 @@ export const POST: RequestHandler = async ({ platform, cookies, request }) => {
124137
description || '',
125138
base_preset,
126139
JSON.stringify(packages),
127-
JSON.stringify(snapshot)
140+
JSON.stringify(snapshot),
141+
validVisibility
128142
)
129143
.run();
130144
} catch (e) {

src/routes/api/homebrew/search/+server.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { json } from '@sveltejs/kit';
22
import type { RequestHandler } from './$types';
3+
import { checkRateLimit, getRateLimitKey, RATE_LIMITS } from '$lib/server/rate-limit';
34

45
interface Formula {
56
name: string;
@@ -63,9 +64,15 @@ async function fetchCasks(): Promise<Cask[]> {
6364
return data;
6465
}
6566

66-
export const GET: RequestHandler = async ({ url }) => {
67+
export const GET: RequestHandler = async ({ url, request }) => {
6768
const query = url.searchParams.get('q')?.toLowerCase().trim();
6869

70+
const clientIp = request.headers.get('cf-connecting-ip') || 'unknown';
71+
const rl = checkRateLimit(getRateLimitKey('homebrew-search', clientIp), RATE_LIMITS.SEARCH);
72+
if (!rl.allowed) {
73+
return json({ results: [], error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfter! / 1000)) } });
74+
}
75+
6976
if (!query || query.length < 2) {
7077
return json({ results: [], error: 'Query must be at least 2 characters' });
7178
}

src/routes/api/npm/search/+server.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { json } from '@sveltejs/kit';
22
import type { RequestHandler } from './$types';
3+
import { checkRateLimit, getRateLimitKey, RATE_LIMITS } from '$lib/server/rate-limit';
34

45
interface NpmPackage {
56
name: string;
@@ -23,9 +24,15 @@ interface NpmRegistryResponse {
2324
}>;
2425
}
2526

26-
export const GET: RequestHandler = async ({ url }) => {
27+
export const GET: RequestHandler = async ({ url, request }) => {
2728
const query = url.searchParams.get('q')?.toLowerCase().trim();
2829

30+
const clientIp = request.headers.get('cf-connecting-ip') || 'unknown';
31+
const rl = checkRateLimit(getRateLimitKey('npm-search', clientIp), RATE_LIMITS.SEARCH);
32+
if (!rl.allowed) {
33+
return json({ results: [], error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfter! / 1000)) } });
34+
}
35+
2936
if (!query || query.length < 2) {
3037
return json({ results: [], error: 'Query must be at least 2 characters' });
3138
}

0 commit comments

Comments
 (0)