@clubedge/create-clubedge-app 0.2.0 → 0.3.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/CHANGELOG.md +16 -0
- package/README.md +50 -35
- package/bin/create.mjs +2 -466
- package/dist/cli/args.js +49 -0
- package/dist/cli/ui.js +85 -0
- package/dist/core/customize.js +56 -0
- package/dist/core/execute.js +60 -0
- package/dist/core/manifest.js +81 -0
- package/dist/core/plan.js +67 -0
- package/dist/core/source.js +107 -0
- package/dist/index.js +125 -0
- package/dist/package-info.js +8 -0
- package/dist/steps/git.js +11 -0
- package/dist/steps/install.js +19 -0
- package/dist/utils/fs.js +97 -0
- package/dist/utils/names.js +14 -0
- package/dist/utils/process.js +49 -0
- package/package.json +21 -5
- package/template/.dockerignore +8 -0
- package/template/.env.example +25 -0
- package/template/.gitattributes +6 -0
- package/template/.github/ISSUE_TEMPLATE/bug_report.yml +35 -0
- package/template/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/template/.github/ISSUE_TEMPLATE/feature_request.yml +24 -0
- package/template/.github/pull_request_template.md +20 -0
- package/template/.github/workflows/ci.yml +49 -0
- package/template/.prettierignore +1 -0
- package/template/.prettierrc.json +1 -0
- package/template/AGENTS.md +31 -0
- package/template/CODE_OF_CONDUCT.md +22 -0
- package/template/CONTRIBUTING.md +53 -0
- package/template/Dockerfile +32 -0
- package/template/LICENSE +201 -0
- package/template/NOTICE +6 -0
- package/template/PUBLISHING.md +27 -0
- package/template/README.md +157 -0
- package/template/SECURITY.md +15 -0
- package/template/SETUP.md +174 -0
- package/template/SUPPORT.md +8 -0
- package/template/_gitignore +13 -0
- package/template/apps/web/AGENTS.md +9 -0
- package/template/apps/web/components.json +21 -0
- package/template/apps/web/e2e/auth.spec.ts +37 -0
- package/template/apps/web/e2e/home.spec.ts +15 -0
- package/template/apps/web/next-env.d.ts +7 -0
- package/template/apps/web/next.config.ts +32 -0
- package/template/apps/web/package.json +33 -0
- package/template/apps/web/postcss.config.mjs +3 -0
- package/template/apps/web/public/.gitkeep +0 -0
- package/template/apps/web/public/.well-known/logo.svg +1 -0
- package/template/apps/web/src/app/actions/auth.ts +85 -0
- package/template/apps/web/src/app/api/health/route.ts +11 -0
- package/template/apps/web/src/app/auth/callback/route.ts +21 -0
- package/template/apps/web/src/app/dashboard/_components/account-button.tsx +34 -0
- package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +91 -0
- package/template/apps/web/src/app/dashboard/page.tsx +352 -0
- package/template/apps/web/src/app/favicon.ico +0 -0
- package/template/apps/web/src/app/layout.tsx +36 -0
- package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +88 -0
- package/template/apps/web/src/app/login/page.tsx +149 -0
- package/template/apps/web/src/app/page.tsx +229 -0
- package/template/apps/web/src/app/theme-provider.tsx +43 -0
- package/template/apps/web/src/app/theme-toggle.tsx +23 -0
- package/template/apps/web/src/components/branding/clubedge-mark.tsx +25 -0
- package/template/apps/web/src/components/landing/copy-command.tsx +48 -0
- package/template/apps/web/src/config/site.json +11 -0
- package/template/apps/web/src/config/site.ts +20 -0
- package/template/apps/web/src/env/client.ts +22 -0
- package/template/apps/web/src/env/server.test.ts +35 -0
- package/template/apps/web/src/env/server.ts +45 -0
- package/template/apps/web/src/lib/login-url.test.ts +16 -0
- package/template/apps/web/src/lib/login-url.ts +16 -0
- package/template/apps/web/src/lib/utils.ts +1 -0
- package/template/apps/web/src/proxy.ts +31 -0
- package/template/apps/web/src/server/auth.ts +69 -0
- package/template/apps/web/src/server/cache.ts +38 -0
- package/template/apps/web/src/server/db.ts +16 -0
- package/template/apps/web/src/server/storage.ts +39 -0
- package/template/apps/web/src/server/users.ts +17 -0
- package/template/apps/web/tsconfig.json +36 -0
- package/template/clubedge.template.json +13 -0
- package/template/docker-compose.yml +8 -0
- package/template/eslint.config.mjs +63 -0
- package/template/package.json +47 -0
- package/template/packages/auth/package.json +23 -0
- package/template/packages/auth/src/index.ts +9 -0
- package/template/packages/auth/src/supabase.test.ts +126 -0
- package/template/packages/auth/src/supabase.ts +65 -0
- package/template/packages/auth/src/types.ts +59 -0
- package/template/packages/auth/tsconfig.json +4 -0
- package/template/packages/cache/package.json +20 -0
- package/template/packages/cache/src/index.test.ts +67 -0
- package/template/packages/cache/src/index.ts +19 -0
- package/template/packages/cache/src/rate-limit.test.ts +52 -0
- package/template/packages/cache/src/rate-limit.ts +74 -0
- package/template/packages/cache/src/redis.ts +106 -0
- package/template/packages/cache/tsconfig.json +4 -0
- package/template/packages/core/package.json +17 -0
- package/template/packages/core/src/client-ip.test.ts +19 -0
- package/template/packages/core/src/client-ip.ts +11 -0
- package/template/packages/core/src/errors.test.ts +20 -0
- package/template/packages/core/src/errors.ts +20 -0
- package/template/packages/core/src/index.ts +4 -0
- package/template/packages/core/src/redirect.test.ts +27 -0
- package/template/packages/core/src/redirect.ts +20 -0
- package/template/packages/core/src/result.ts +10 -0
- package/template/packages/core/tsconfig.json +4 -0
- package/template/packages/db/drizzle/0000_initial.sql +10 -0
- package/template/packages/db/drizzle/meta/_journal.json +13 -0
- package/template/packages/db/drizzle.config.ts +9 -0
- package/template/packages/db/package.json +30 -0
- package/template/packages/db/scripts/load-env.ts +14 -0
- package/template/packages/db/scripts/migrate.ts +11 -0
- package/template/packages/db/scripts/seed.ts +14 -0
- package/template/packages/db/src/client.ts +22 -0
- package/template/packages/db/src/index.ts +4 -0
- package/template/packages/db/src/schema/index.ts +1 -0
- package/template/packages/db/src/schema/users.ts +14 -0
- package/template/packages/db/src/users.ts +16 -0
- package/template/packages/db/tsconfig.json +4 -0
- package/template/packages/storage/package.json +24 -0
- package/template/packages/storage/src/index.ts +1 -0
- package/template/packages/storage/src/s3.test.ts +86 -0
- package/template/packages/storage/src/s3.ts +86 -0
- package/template/packages/storage/src/supabase.ts +69 -0
- package/template/packages/storage/src/types.ts +18 -0
- package/template/packages/storage/tsconfig.json +4 -0
- package/template/packages/ui/components.json +21 -0
- package/template/packages/ui/package.json +32 -0
- package/template/packages/ui/src/components/badge.tsx +48 -0
- package/template/packages/ui/src/components/breadcrumb.tsx +102 -0
- package/template/packages/ui/src/components/button.tsx +57 -0
- package/template/packages/ui/src/components/card.tsx +87 -0
- package/template/packages/ui/src/components/input.tsx +19 -0
- package/template/packages/ui/src/components/label.tsx +19 -0
- package/template/packages/ui/src/components/separator.tsx +20 -0
- package/template/packages/ui/src/components/sheet.tsx +125 -0
- package/template/packages/ui/src/components/sidebar.tsx +692 -0
- package/template/packages/ui/src/components/skeleton.tsx +13 -0
- package/template/packages/ui/src/components/switch.tsx +31 -0
- package/template/packages/ui/src/components/table.tsx +88 -0
- package/template/packages/ui/src/components/tooltip.tsx +53 -0
- package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
- package/template/packages/ui/src/lib/utils.ts +1 -0
- package/template/packages/ui/src/styles/globals.css +126 -0
- package/template/packages/ui/tsconfig.json +19 -0
- package/template/playwright.config.ts +19 -0
- package/template/pnpm-lock.yaml +3624 -0
- package/template/pnpm-workspace.yaml +3 -0
- package/template/scripts/start-standalone.mjs +37 -0
- package/template/tsconfig.base.json +16 -0
- package/template/turbo.json +17 -0
- package/template/vitest.config.ts +8 -0
- package/template.lock.json +142 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createMemoryRateLimiter, type RateLimiter, type RateLimitOptions } from "./rate-limit";
|
|
2
|
+
import { createRedisRateLimiter } from "./redis";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
createMemoryRateLimiter,
|
|
6
|
+
type RateLimiter,
|
|
7
|
+
type RateLimitOptions,
|
|
8
|
+
type RateLimitResult,
|
|
9
|
+
} from "./rate-limit";
|
|
10
|
+
export { createRedisCache, createRedisRateLimiter, type Cache } from "./redis";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Uses Redis when a URL is provided so limits are shared across instances. Without one it
|
|
14
|
+
* falls back to a per-process memory limiter, which suits a single server only.
|
|
15
|
+
*/
|
|
16
|
+
export function createRateLimiter(options: RateLimitOptions & { redisUrl?: string }): RateLimiter {
|
|
17
|
+
const { redisUrl, ...limits } = options;
|
|
18
|
+
return redisUrl ? createRedisRateLimiter(redisUrl, limits) : createMemoryRateLimiter(limits);
|
|
19
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createMemoryRateLimiter } from "./rate-limit";
|
|
3
|
+
|
|
4
|
+
describe("createMemoryRateLimiter", () => {
|
|
5
|
+
it("allows requests up to the limit and then blocks", async () => {
|
|
6
|
+
const limiter = createMemoryRateLimiter({ requests: 2, windowSeconds: 60 }, { now: () => 0 });
|
|
7
|
+
|
|
8
|
+
expect(await limiter.limit("client")).toMatchObject({ success: true, remaining: 1 });
|
|
9
|
+
expect(await limiter.limit("client")).toMatchObject({ success: true, remaining: 0 });
|
|
10
|
+
expect(await limiter.limit("client")).toMatchObject({ success: false, remaining: 0 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("tracks identifiers independently", async () => {
|
|
14
|
+
const limiter = createMemoryRateLimiter({ requests: 1, windowSeconds: 60 }, { now: () => 0 });
|
|
15
|
+
|
|
16
|
+
expect((await limiter.limit("a")).success).toBe(true);
|
|
17
|
+
expect((await limiter.limit("b")).success).toBe(true);
|
|
18
|
+
expect((await limiter.limit("a")).success).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("starts a new window after the previous one expires", async () => {
|
|
22
|
+
let time = 0;
|
|
23
|
+
const limiter = createMemoryRateLimiter(
|
|
24
|
+
{ requests: 1, windowSeconds: 10 },
|
|
25
|
+
{ now: () => time },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect((await limiter.limit("client")).success).toBe(true);
|
|
29
|
+
expect((await limiter.limit("client")).success).toBe(false);
|
|
30
|
+
time = 10_000;
|
|
31
|
+
expect(await limiter.limit("client")).toMatchObject({ success: true, resetAt: 20_000 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("never holds more than maxKeys windows", async () => {
|
|
35
|
+
const limiter = createMemoryRateLimiter(
|
|
36
|
+
{ requests: 1, windowSeconds: 60 },
|
|
37
|
+
{ maxKeys: 2, now: () => 0 },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
await limiter.limit("a");
|
|
41
|
+
await limiter.limit("b");
|
|
42
|
+
await limiter.limit("c");
|
|
43
|
+
// "a" was evicted to make room, so it gets a fresh window.
|
|
44
|
+
expect((await limiter.limit("a")).success).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects invalid options and identifiers", async () => {
|
|
48
|
+
expect(() => createMemoryRateLimiter({ requests: 0 })).toThrow();
|
|
49
|
+
expect(() => createMemoryRateLimiter({ windowSeconds: 1.5 })).toThrow();
|
|
50
|
+
await expect(createMemoryRateLimiter().limit("")).rejects.toThrow();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type RateLimitResult = {
|
|
2
|
+
success: boolean;
|
|
3
|
+
limit: number;
|
|
4
|
+
remaining: number;
|
|
5
|
+
resetAt: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type RateLimiter = {
|
|
9
|
+
limit(identifier: string): Promise<RateLimitResult>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type RateLimitOptions = {
|
|
13
|
+
requests?: number;
|
|
14
|
+
windowSeconds?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function validateRateLimitOptions({ requests = 10, windowSeconds = 60 }: RateLimitOptions) {
|
|
18
|
+
if (!Number.isInteger(requests) || requests < 1) {
|
|
19
|
+
throw new Error("Rate limit must allow at least one request.");
|
|
20
|
+
}
|
|
21
|
+
if (!Number.isInteger(windowSeconds) || windowSeconds < 1) {
|
|
22
|
+
throw new Error("Rate limit window must be a positive number of seconds.");
|
|
23
|
+
}
|
|
24
|
+
return { requests, windowSeconds };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateRateLimitIdentifier(identifier: string) {
|
|
28
|
+
if (identifier.length < 1 || identifier.length > 256) {
|
|
29
|
+
throw new Error("Rate limit identifier must contain between 1 and 256 characters.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fixed-window limiter held in process memory. Limits apply per server instance, so use
|
|
35
|
+
* Redis when the app runs on several instances or serverless functions.
|
|
36
|
+
*/
|
|
37
|
+
export function createMemoryRateLimiter(
|
|
38
|
+
options: RateLimitOptions = {},
|
|
39
|
+
{ maxKeys = 10_000, now = Date.now }: { maxKeys?: number; now?: () => number } = {},
|
|
40
|
+
): RateLimiter {
|
|
41
|
+
const { requests, windowSeconds } = validateRateLimitOptions(options);
|
|
42
|
+
const windowMilliseconds = windowSeconds * 1_000;
|
|
43
|
+
const windows = new Map<string, { count: number; resetAt: number }>();
|
|
44
|
+
|
|
45
|
+
function evictExpired(currentTime: number) {
|
|
46
|
+
for (const [key, window] of windows) {
|
|
47
|
+
if (window.resetAt <= currentTime) windows.delete(key);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
async limit(identifier) {
|
|
53
|
+
validateRateLimitIdentifier(identifier);
|
|
54
|
+
const currentTime = now();
|
|
55
|
+
let window = windows.get(identifier);
|
|
56
|
+
|
|
57
|
+
if (!window || window.resetAt <= currentTime) {
|
|
58
|
+
if (windows.size >= maxKeys) evictExpired(currentTime);
|
|
59
|
+
// Still full: drop the oldest entry rather than grow without bound.
|
|
60
|
+
if (windows.size >= maxKeys) windows.delete(windows.keys().next().value!);
|
|
61
|
+
window = { count: 0, resetAt: currentTime + windowMilliseconds };
|
|
62
|
+
windows.set(identifier, window);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
window.count += 1;
|
|
66
|
+
return {
|
|
67
|
+
success: window.count <= requests,
|
|
68
|
+
limit: requests,
|
|
69
|
+
remaining: Math.max(0, requests - window.count),
|
|
70
|
+
resetAt: window.resetAt,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createClient } from "redis";
|
|
2
|
+
import {
|
|
3
|
+
validateRateLimitIdentifier,
|
|
4
|
+
validateRateLimitOptions,
|
|
5
|
+
type RateLimiter,
|
|
6
|
+
type RateLimitOptions,
|
|
7
|
+
} from "./rate-limit";
|
|
8
|
+
|
|
9
|
+
type RedisClient = ReturnType<typeof createClient>;
|
|
10
|
+
|
|
11
|
+
// One client per URL for the life of the process, surviving dev-server module reloads.
|
|
12
|
+
const globalForRedis = globalThis as typeof globalThis & {
|
|
13
|
+
clubedgeRedis?: Map<string, { client: RedisClient; connection?: Promise<RedisClient> }>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function redisEntry(url: string) {
|
|
17
|
+
globalForRedis.clubedgeRedis ??= new Map();
|
|
18
|
+
let entry = globalForRedis.clubedgeRedis.get(url);
|
|
19
|
+
if (!entry) {
|
|
20
|
+
const client = createClient({
|
|
21
|
+
url,
|
|
22
|
+
socket: {
|
|
23
|
+
connectTimeout: 5_000,
|
|
24
|
+
reconnectStrategy: (retries) => Math.min(retries * 250, 3_000),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
client.on("error", (error) => console.error("Redis client error", error));
|
|
28
|
+
entry = { client };
|
|
29
|
+
globalForRedis.clubedgeRedis.set(url, entry);
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function connectRedis(url: string): Promise<RedisClient> {
|
|
35
|
+
const entry = redisEntry(url);
|
|
36
|
+
if (entry.client.isReady) return entry.client;
|
|
37
|
+
|
|
38
|
+
entry.connection ??= entry.client.connect().catch((error: unknown) => {
|
|
39
|
+
entry.connection = undefined;
|
|
40
|
+
throw error;
|
|
41
|
+
});
|
|
42
|
+
return entry.connection;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Cache {
|
|
46
|
+
get<T>(key: string): Promise<T | null>;
|
|
47
|
+
set(key: string, value: unknown, ttlSeconds: number): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createRedisCache(url: string, prefix = "clubedge:cache:"): Cache {
|
|
51
|
+
return {
|
|
52
|
+
async get<T>(key: string) {
|
|
53
|
+
const value = await (await connectRedis(url)).get(`${prefix}${key}`);
|
|
54
|
+
if (value === null) return null;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(value) as T;
|
|
57
|
+
} catch {
|
|
58
|
+
return value as T;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
async set(key, value, ttlSeconds) {
|
|
62
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
|
|
63
|
+
throw new Error("Cache TTL must be a positive integer number of seconds.");
|
|
64
|
+
}
|
|
65
|
+
const serialized = JSON.stringify(value);
|
|
66
|
+
if (serialized === undefined) {
|
|
67
|
+
throw new Error("Cache values must be JSON serializable.");
|
|
68
|
+
}
|
|
69
|
+
await (await connectRedis(url)).set(`${prefix}${key}`, serialized, { EX: ttlSeconds });
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Fixed-window limiter shared by every instance that uses the same Redis database. */
|
|
75
|
+
export function createRedisRateLimiter(
|
|
76
|
+
url: string,
|
|
77
|
+
options: RateLimitOptions = {},
|
|
78
|
+
prefix = "clubedge:rate-limit:",
|
|
79
|
+
): RateLimiter {
|
|
80
|
+
const { requests, windowSeconds } = validateRateLimitOptions(options);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
async limit(identifier) {
|
|
84
|
+
validateRateLimitIdentifier(identifier);
|
|
85
|
+
const client = await connectRedis(url);
|
|
86
|
+
const result = await client.eval(
|
|
87
|
+
`local count = redis.call('INCR', KEYS[1])
|
|
88
|
+
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
|
89
|
+
return { count, redis.call('PTTL', KEYS[1]) }`,
|
|
90
|
+
{ keys: [`${prefix}${identifier}`], arguments: [String(windowSeconds * 1_000)] },
|
|
91
|
+
);
|
|
92
|
+
if (!Array.isArray(result) || result.length !== 2) {
|
|
93
|
+
throw new Error("Redis returned an invalid rate limit response.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const count = Number(result[0]);
|
|
97
|
+
const ttlMilliseconds = Number(result[1]);
|
|
98
|
+
return {
|
|
99
|
+
success: count <= requests,
|
|
100
|
+
limit: requests,
|
|
101
|
+
remaining: Math.max(0, requests - count),
|
|
102
|
+
resetAt: Date.now() + Math.max(0, ttlMilliseconds),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clubedge/core",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "^5.9.2"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getClientIp } from "./client-ip";
|
|
3
|
+
|
|
4
|
+
describe("getClientIp", () => {
|
|
5
|
+
it("uses the first x-forwarded-for address", () => {
|
|
6
|
+
expect(getClientIp(new Headers({ "x-forwarded-for": "203.0.113.1, 10.0.0.1" }))).toBe(
|
|
7
|
+
"203.0.113.1",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("falls back to x-real-ip, then to unknown", () => {
|
|
12
|
+
expect(getClientIp(new Headers({ "x-real-ip": "198.51.100.7" }))).toBe("198.51.100.7");
|
|
13
|
+
expect(getClientIp(new Headers())).toBe("unknown");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("bounds the identifier length", () => {
|
|
17
|
+
expect(getClientIp(new Headers({ "x-forwarded-for": "a".repeat(500) }))).toHaveLength(64);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort client address for rate limiting. Forwarded headers are only trustworthy
|
|
3
|
+
* behind a proxy that overwrites them (Vercel, Cloudflare, most load balancers).
|
|
4
|
+
*/
|
|
5
|
+
export function getClientIp(headers: Pick<Headers, "get">): string {
|
|
6
|
+
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
7
|
+
if (forwarded) return forwarded.slice(0, 64);
|
|
8
|
+
const realIp = headers.get("x-real-ip")?.trim();
|
|
9
|
+
if (realIp) return realIp.slice(0, 64);
|
|
10
|
+
return "unknown";
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AppError, errorResponse } from "./errors";
|
|
3
|
+
|
|
4
|
+
describe("errorResponse", () => {
|
|
5
|
+
it("returns a safe structured response for application errors", async () => {
|
|
6
|
+
const response = errorResponse(new AppError("Not found", "NOT_FOUND", 404));
|
|
7
|
+
expect(response.status).toBe(404);
|
|
8
|
+
expect(await response.json()).toEqual({ error: { code: "NOT_FOUND", message: "Not found" } });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("hides unexpected error details from clients", async () => {
|
|
12
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
13
|
+
const response = errorResponse(new Error("database password leaked"));
|
|
14
|
+
expect(response.status).toBe(500);
|
|
15
|
+
expect(await response.json()).toEqual({
|
|
16
|
+
error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred." },
|
|
17
|
+
});
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
message: string,
|
|
4
|
+
public readonly code: string,
|
|
5
|
+
public readonly status = 400,
|
|
6
|
+
options?: ErrorOptions,
|
|
7
|
+
) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = "AppError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function errorResponse(error: unknown) {
|
|
14
|
+
const known = error instanceof AppError;
|
|
15
|
+
const status = known ? error.status : 500;
|
|
16
|
+
const message = known ? error.message : "An unexpected error occurred.";
|
|
17
|
+
const code = known ? error.code : "INTERNAL_ERROR";
|
|
18
|
+
if (!known) console.error("Unhandled application error", error);
|
|
19
|
+
return Response.json({ error: { code, message } }, { status });
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { safeRedirectPath } from "./redirect";
|
|
3
|
+
|
|
4
|
+
describe("safeRedirectPath", () => {
|
|
5
|
+
it("keeps same-origin paths with query and hash", () => {
|
|
6
|
+
expect(safeRedirectPath("/dashboard")).toBe("/dashboard");
|
|
7
|
+
expect(safeRedirectPath("/projects/1?tab=a#top")).toBe("/projects/1?tab=a#top");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it.each([
|
|
11
|
+
undefined,
|
|
12
|
+
null,
|
|
13
|
+
"",
|
|
14
|
+
"dashboard",
|
|
15
|
+
"https://evil.example",
|
|
16
|
+
"//evil.example",
|
|
17
|
+
"/\\evil.example",
|
|
18
|
+
"\\\\evil.example",
|
|
19
|
+
"javascript:alert(1)",
|
|
20
|
+
])("falls back for %j", (value) => {
|
|
21
|
+
expect(safeRedirectPath(value)).toBe("/dashboard");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses the provided fallback", () => {
|
|
25
|
+
expect(safeRedirectPath("https://evil.example", "/")).toBe("/");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const placeholderOrigin = "http://localhost";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a same-origin path for post-authentication redirects. Anything that could leave
|
|
5
|
+
* the site (absolute URLs, protocol-relative URLs, backslash tricks) falls back.
|
|
6
|
+
*/
|
|
7
|
+
export function safeRedirectPath(value: unknown, fallback = "/dashboard"): string {
|
|
8
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) {
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
if (value.includes("\\")) return fallback;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(value, placeholderOrigin);
|
|
15
|
+
if (url.origin !== placeholderOrigin) return fallback;
|
|
16
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
17
|
+
} catch {
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Expected failures are returned as values; exceptions are reserved for the unexpected. */
|
|
2
|
+
export type Result<T, E extends string = string> = { ok: true; value: T } | { ok: false; error: E };
|
|
3
|
+
|
|
4
|
+
export function ok<T>(value: T): Result<T, never> {
|
|
5
|
+
return { ok: true, value };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function err<E extends string>(error: E): Result<never, E> {
|
|
9
|
+
return { ok: false, error };
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "users" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"auth_user_id" uuid,
|
|
4
|
+
"email" text NOT NULL,
|
|
5
|
+
"display_name" text,
|
|
6
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
7
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
8
|
+
CONSTRAINT "users_auth_user_id_unique" UNIQUE("auth_user_id"),
|
|
9
|
+
CONSTRAINT "users_email_unique" UNIQUE("email")
|
|
10
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineConfig } from "drizzle-kit";
|
|
2
|
+
import { requireDatabaseUrl } from "./scripts/load-env";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
schema: "./src/schema/index.ts",
|
|
6
|
+
out: "./drizzle",
|
|
7
|
+
dialect: "postgresql",
|
|
8
|
+
dbCredentials: { url: requireDatabaseUrl("run drizzle-kit") },
|
|
9
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clubedge/db",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"db:generate": "drizzle-kit generate",
|
|
14
|
+
"db:migrate": "tsx scripts/migrate.ts",
|
|
15
|
+
"db:push": "drizzle-kit push",
|
|
16
|
+
"db:studio": "drizzle-kit studio",
|
|
17
|
+
"db:check": "drizzle-kit check",
|
|
18
|
+
"db:seed": "tsx scripts/seed.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"drizzle-orm": "^0.45.4",
|
|
22
|
+
"postgres": "^3.4.7"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"dotenv": "^16.6.1",
|
|
26
|
+
"drizzle-kit": "^0.31.4",
|
|
27
|
+
"tsx": "^4.20.5",
|
|
28
|
+
"typescript": "^5.9.2"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { config } from "dotenv";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Database commands run from packages/db and share the app's environment file, so a project
|
|
5
|
+
// has one place for secrets. Variables already set in the shell take precedence.
|
|
6
|
+
for (const file of ["../../apps/web/.env.local", ".env.local"]) {
|
|
7
|
+
config({ path: resolve(process.cwd(), file), quiet: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function requireDatabaseUrl(purpose: string): string {
|
|
11
|
+
const url = process.env.DATABASE_URL;
|
|
12
|
+
if (!url) throw new Error(`DATABASE_URL is required to ${purpose}.`);
|
|
13
|
+
return url;
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
2
|
+
import { createDb } from "../src/client";
|
|
3
|
+
import { requireDatabaseUrl } from "./load-env";
|
|
4
|
+
|
|
5
|
+
const db = createDb({ url: requireDatabaseUrl("run migrations") });
|
|
6
|
+
try {
|
|
7
|
+
await migrate(db, { migrationsFolder: "./drizzle" });
|
|
8
|
+
console.info("Database migrations applied.");
|
|
9
|
+
} finally {
|
|
10
|
+
await db.$client.end();
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createDb } from "../src/client";
|
|
2
|
+
import { users } from "../src/schema";
|
|
3
|
+
import { requireDatabaseUrl } from "./load-env";
|
|
4
|
+
|
|
5
|
+
const db = createDb({ url: requireDatabaseUrl("seed the database") });
|
|
6
|
+
try {
|
|
7
|
+
await db
|
|
8
|
+
.insert(users)
|
|
9
|
+
.values({ email: "hello@example.com", displayName: "Starter User" })
|
|
10
|
+
.onConflictDoNothing();
|
|
11
|
+
console.info("Seed complete.");
|
|
12
|
+
} finally {
|
|
13
|
+
await db.$client.end();
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
|
|
5
|
+
export interface DatabaseConfig {
|
|
6
|
+
url: string;
|
|
7
|
+
/** Pool size. Keep it small for serverless and transaction poolers. */
|
|
8
|
+
maxConnections?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createDb({ url, maxConnections = 1 }: DatabaseConfig) {
|
|
12
|
+
const client = postgres(url, {
|
|
13
|
+
// Required for Supabase transaction pooling and other PgBouncer-style poolers.
|
|
14
|
+
prepare: false,
|
|
15
|
+
max: maxConnections,
|
|
16
|
+
idle_timeout: 20,
|
|
17
|
+
connect_timeout: 10,
|
|
18
|
+
});
|
|
19
|
+
return drizzle({ client, schema });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Database = ReturnType<typeof createDb>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./users";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
/** App-owned profile data. Authentication credentials remain in Supabase Auth. */
|
|
4
|
+
export const users = pgTable("users", {
|
|
5
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
6
|
+
authUserId: uuid("auth_user_id").unique(),
|
|
7
|
+
email: text("email").notNull().unique(),
|
|
8
|
+
displayName: text("display_name"),
|
|
9
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
10
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type User = typeof users.$inferSelect;
|
|
14
|
+
export type NewUser = typeof users.$inferInsert;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Database } from "./client";
|
|
2
|
+
import { users } from "./schema";
|
|
3
|
+
|
|
4
|
+
/** Creates or updates the app-owned profile row for an identity-provider user. */
|
|
5
|
+
export async function upsertUserProfile(
|
|
6
|
+
db: Database,
|
|
7
|
+
profile: { authUserId: string; email: string },
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
await db
|
|
10
|
+
.insert(users)
|
|
11
|
+
.values(profile)
|
|
12
|
+
.onConflictDoUpdate({
|
|
13
|
+
target: users.authUserId,
|
|
14
|
+
set: { email: profile.email, updatedAt: new Date() },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clubedge/storage",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./s3": "./src/s3.ts",
|
|
11
|
+
"./supabase": "./src/supabase.ts"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@aws-sdk/client-s3": "^3.850.0",
|
|
18
|
+
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
|
19
|
+
"@supabase/supabase-js": "^2.57.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.9.2"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { SignedUrlOptions, StorageProvider, UploadInput } from "./types";
|