@clipr/worker 0.0.5 → 0.0.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @clipr/worker
2
2
 
3
+ ## 0.0.10
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @clipr/core@0.0.11
9
+
10
+ ## 0.0.9
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies
15
+ - @clipr/core@0.0.10
16
+
17
+ ## 0.0.8
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies
22
+ - @clipr/core@0.0.9
23
+
24
+ ## 0.0.7
25
+
26
+ ### Patch Changes
27
+
28
+ - Updated dependencies
29
+ - @clipr/core@0.0.8
30
+
31
+ ## 0.0.6
32
+
33
+ ### Patch Changes
34
+
35
+ - Updated dependencies
36
+ - @clipr/core@0.0.7
37
+
3
38
  ## 0.0.5
4
39
 
5
40
  ### Patch Changes
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@clipr/worker",
3
- "version": "0.0.5",
3
+ "version": "0.0.10",
4
4
  "description": "Cloudflare Worker for clipr URL redirects",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "dependencies": {
8
8
  "hono": "^4.7.0",
9
9
  "qrcode": "^1.5.4",
10
- "@clipr/core": "0.0.6"
10
+ "@clipr/core": "0.0.11"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@cloudflare/workers-types": "^4.20250327.0",
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { hashPassword, verifyPassword } from './crypto.js';
3
+
4
+ describe('hashPassword', () => {
5
+ it('returns a string in salt:hash format', async () => {
6
+ const result = await hashPassword('mypassword');
7
+ const parts = result.split(':');
8
+ expect(parts).toHaveLength(2);
9
+ // Salt is 16 bytes = 32 hex chars
10
+ expect(parts[0]).toMatch(/^[0-9a-f]{32}$/);
11
+ // Hash is 256 bits = 32 bytes = 64 hex chars
12
+ expect(parts[1]).toMatch(/^[0-9a-f]{64}$/);
13
+ });
14
+
15
+ it('produces different hashes for the same password (random salt)', async () => {
16
+ const a = await hashPassword('same');
17
+ const b = await hashPassword('same');
18
+ expect(a).not.toBe(b);
19
+ });
20
+ });
21
+
22
+ describe('verifyPassword', () => {
23
+ it('returns true for correct password', async () => {
24
+ const stored = await hashPassword('correct');
25
+ expect(await verifyPassword('correct', stored)).toBe(true);
26
+ });
27
+
28
+ it('returns false for wrong password', async () => {
29
+ const stored = await hashPassword('correct');
30
+ expect(await verifyPassword('wrong', stored)).toBe(false);
31
+ });
32
+
33
+ it('returns false for malformed stored hash (no colon)', async () => {
34
+ expect(await verifyPassword('anything', 'nocolonhere')).toBe(false);
35
+ });
36
+
37
+ it('returns false for malformed stored hash (empty parts)', async () => {
38
+ expect(await verifyPassword('anything', ':')).toBe(false);
39
+ });
40
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
3
  import { authMiddleware } from './middleware/auth.js';
4
+ import { rateLimitMiddleware } from './middleware/rate-limit.js';
4
5
  import { handleHealth } from './routes/health.js';
5
6
  import { handleExport, handleImport } from './routes/import-export.js';
6
7
  import {
@@ -37,8 +38,8 @@ app.get('/health', handleHealth);
37
38
  app.get('/password/:code', handlePasswordPage);
38
39
  app.post('/password/:code', handlePasswordVerify);
39
40
 
40
- // --- API routes (protected by auth middleware) ---
41
- app.post('/api/shorten', handleShorten);
41
+ // --- Public API (rate-limited, no auth) ---
42
+ app.post('/api/shorten', rateLimitMiddleware, handleShorten);
42
43
 
43
44
  app.get('/api/links', handleListLinks);
44
45
  app.get('/api/links/:code', handleGetLink);
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import app from '../index.js';
3
+ import { createMockKV } from '../test-utils.js';
4
+
5
+ const ENV = (kv: KVNamespace) => ({
6
+ URLS: kv,
7
+ API_TOKEN: 'test-token',
8
+ BASE_URL: 'https://test.sh',
9
+ });
10
+
11
+ describe('auth middleware', () => {
12
+ // --- Public routes pass without token ---
13
+ it('allows GET /health without token', async () => {
14
+ const kv = createMockKV();
15
+ const res = await app.request('/health', {}, ENV(kv));
16
+ expect(res.status).toBe(200);
17
+ });
18
+
19
+ it('allows GET /:slug without token (redirect/404)', async () => {
20
+ const kv = createMockKV();
21
+ const res = await app.request('/some-slug', {}, ENV(kv));
22
+ // No entry seeded, so it returns 404 — but NOT 401
23
+ expect(res.status).toBe(404);
24
+ });
25
+
26
+ // --- API routes require token ---
27
+ it('returns 401 for API route without Authorization header', async () => {
28
+ const kv = createMockKV();
29
+ const res = await app.request('/api/links', {}, ENV(kv));
30
+ expect(res.status).toBe(401);
31
+ const body = await res.json();
32
+ expect(body.error).toMatch(/Missing Authorization/);
33
+ });
34
+
35
+ it('returns 401 for API route with wrong token', async () => {
36
+ const kv = createMockKV();
37
+ const res = await app.request(
38
+ '/api/links',
39
+ { headers: { Authorization: 'Bearer wrong-token' } },
40
+ ENV(kv),
41
+ );
42
+ expect(res.status).toBe(401);
43
+ const body = await res.json();
44
+ expect(body.error).toMatch(/Invalid API token/);
45
+ });
46
+
47
+ it('returns 401 for malformed Authorization header', async () => {
48
+ const kv = createMockKV();
49
+ const res = await app.request(
50
+ '/api/links',
51
+ { headers: { Authorization: 'Basic abc123' } },
52
+ ENV(kv),
53
+ );
54
+ expect(res.status).toBe(401);
55
+ const body = await res.json();
56
+ expect(body.error).toMatch(/Invalid Authorization format/);
57
+ });
58
+
59
+ it('allows API route with correct Bearer token', async () => {
60
+ const kv = createMockKV();
61
+ const res = await app.request(
62
+ '/api/links',
63
+ { headers: { Authorization: 'Bearer test-token' } },
64
+ ENV(kv),
65
+ );
66
+ expect(res.status).toBe(200);
67
+ });
68
+
69
+ it('allows POST /password/:code without token', async () => {
70
+ const kv = createMockKV();
71
+ const res = await app.request(
72
+ '/password/some-code',
73
+ {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
+ body: 'password=test',
77
+ },
78
+ ENV(kv),
79
+ );
80
+ // The route may return 404 if link not found, but NOT 401
81
+ expect(res.status).not.toBe(401);
82
+ });
83
+ });
@@ -5,6 +5,7 @@ import type { Env } from '../types.js';
5
5
  const PUBLIC_PATTERNS = [
6
6
  /^\/health$/,
7
7
  /^\/password\/[^/]+$/,
8
+ /^\/api\/shorten$/, // POST /api/shorten is public (rate-limited separately)
8
9
  /^\/[^/]+$/, // GET /:slug redirect
9
10
  ];
10
11
 
@@ -20,22 +21,16 @@ export async function authMiddleware(
20
21
  const path = new URL(c.req.url).pathname;
21
22
  const method = c.req.method;
22
23
 
23
- // Skip auth for public routes
24
- if (method === 'GET') {
25
- for (const pattern of PUBLIC_PATTERNS) {
26
- if (pattern.test(path)) {
24
+ // Skip auth for public routes (GET + specific POST routes)
25
+ for (const pattern of PUBLIC_PATTERNS) {
26
+ if (pattern.test(path)) {
27
+ if (method === 'GET' || method === 'POST') {
27
28
  await next();
28
29
  return;
29
30
  }
30
31
  }
31
32
  }
32
33
 
33
- // POST /password/:code is also public
34
- if (method === 'POST' && /^\/password\/[^/]+$/.test(path)) {
35
- await next();
36
- return;
37
- }
38
-
39
34
  const authHeader = c.req.header('Authorization');
40
35
  if (!authHeader) {
41
36
  return c.json({ error: 'Missing Authorization header' }, 401);
@@ -0,0 +1,33 @@
1
+ import type { Context, Next } from 'hono';
2
+ import type { Env } from '../types.js';
3
+
4
+ const RATE_LIMIT = 100; // requests per window
5
+ const WINDOW_SECONDS = 3600; // 1 hour
6
+
7
+ /**
8
+ * Rate limiting middleware for public endpoints.
9
+ * Uses Cloudflare KV with TTL expiration.
10
+ * Limits by IP address: 100 requests per hour.
11
+ */
12
+ export async function rateLimitMiddleware(
13
+ c: Context<{ Bindings: Env }>,
14
+ next: Next,
15
+ ): Promise<undefined | Response> {
16
+ const ip = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown';
17
+ const key = `ratelimit:${ip}`;
18
+
19
+ const current = await c.env.URLS.get(key, 'text');
20
+ const count = current ? Number.parseInt(current, 10) : 0;
21
+
22
+ if (count >= RATE_LIMIT) {
23
+ return c.json({ error: 'Rate limit exceeded. Maximum 100 URLs per hour.' }, 429);
24
+ }
25
+
26
+ await c.env.URLS.put(key, String(count + 1), { expirationTtl: WINDOW_SECONDS });
27
+
28
+ // Set rate limit headers
29
+ c.header('X-RateLimit-Limit', String(RATE_LIMIT));
30
+ c.header('X-RateLimit-Remaining', String(RATE_LIMIT - count - 1));
31
+
32
+ await next();
33
+ }