@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,38 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import {
|
|
3
|
+
createRateLimiter as createLimiter,
|
|
4
|
+
createRedisCache,
|
|
5
|
+
type RateLimiter,
|
|
6
|
+
} from "@clubedge/cache";
|
|
7
|
+
import { getRedisUrl } from "@/env/server";
|
|
8
|
+
|
|
9
|
+
export type { RateLimiter, RateLimitResult } from "@clubedge/cache";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Uses Redis when REDIS_URL is configured, otherwise a per-process memory limiter. The
|
|
13
|
+
* backend is chosen on first use so module-level limiters do not read env at import time.
|
|
14
|
+
*/
|
|
15
|
+
export function createRateLimiter(requests = 10, windowSeconds = 60): RateLimiter {
|
|
16
|
+
let limiter: RateLimiter | undefined;
|
|
17
|
+
return {
|
|
18
|
+
limit(identifier) {
|
|
19
|
+
limiter ??= createLimiter({
|
|
20
|
+
requests,
|
|
21
|
+
windowSeconds,
|
|
22
|
+
redisUrl: getRedisUrl(),
|
|
23
|
+
});
|
|
24
|
+
return limiter.limit(identifier);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Cache reads miss and writes are skipped while Redis is not configured. */
|
|
30
|
+
export async function cacheGet<T>(key: string): Promise<T | null> {
|
|
31
|
+
const url = getRedisUrl();
|
|
32
|
+
return url ? createRedisCache(url).get<T>(key) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function cacheSet(key: string, value: unknown, ttlSeconds: number): Promise<void> {
|
|
36
|
+
const url = getRedisUrl();
|
|
37
|
+
if (url) await createRedisCache(url).set(key, value, ttlSeconds);
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { createDb, type Database } from "@clubedge/db";
|
|
3
|
+
import { getServerEnv } from "@/env/server";
|
|
4
|
+
|
|
5
|
+
const globalForDb = globalThis as typeof globalThis & { clubedgeDb?: Database };
|
|
6
|
+
|
|
7
|
+
/** Creates the connection pool on first use and reuses it across dev hot reloads. */
|
|
8
|
+
export function getDb(): Database {
|
|
9
|
+
if (globalForDb.clubedgeDb) return globalForDb.clubedgeDb;
|
|
10
|
+
const env = getServerEnv();
|
|
11
|
+
globalForDb.clubedgeDb = createDb({
|
|
12
|
+
url: env.DATABASE_URL,
|
|
13
|
+
maxConnections: env.NODE_ENV === "production" ? 10 : 1,
|
|
14
|
+
});
|
|
15
|
+
return globalForDb.clubedgeDb;
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { StorageProvider } from "@clubedge/storage";
|
|
3
|
+
import { createS3Storage } from "@clubedge/storage/s3";
|
|
4
|
+
import { createSupabaseStorage } from "@clubedge/storage/supabase";
|
|
5
|
+
import { getServerEnv } from "@/env/server";
|
|
6
|
+
import { getSupabaseClient } from "./auth";
|
|
7
|
+
|
|
8
|
+
export type { SignedUrlOptions, StorageProvider, UploadInput } from "@clubedge/storage";
|
|
9
|
+
|
|
10
|
+
let provider: StorageProvider | undefined;
|
|
11
|
+
|
|
12
|
+
function resolveProvider(): StorageProvider {
|
|
13
|
+
if (provider) return provider;
|
|
14
|
+
const env = getServerEnv();
|
|
15
|
+
provider =
|
|
16
|
+
env.STORAGE_PROVIDER === "supabase"
|
|
17
|
+
? createSupabaseStorage({
|
|
18
|
+
bucket: env.SUPABASE_STORAGE_BUCKET || undefined,
|
|
19
|
+
getClient: getSupabaseClient,
|
|
20
|
+
})
|
|
21
|
+
: createS3Storage({
|
|
22
|
+
bucket: env.STORAGE_BUCKET || undefined,
|
|
23
|
+
region: env.STORAGE_REGION,
|
|
24
|
+
endpoint: env.STORAGE_ENDPOINT || undefined,
|
|
25
|
+
accessKeyId: env.STORAGE_ACCESS_KEY_ID || undefined,
|
|
26
|
+
secretAccessKey: env.STORAGE_SECRET_ACCESS_KEY || undefined,
|
|
27
|
+
publicUrl: env.STORAGE_PUBLIC_URL || undefined,
|
|
28
|
+
});
|
|
29
|
+
return provider;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The configured storage provider, resolved on first use so imports stay side-effect free. */
|
|
33
|
+
export const storage: StorageProvider = {
|
|
34
|
+
upload: (input) => resolveProvider().upload(input),
|
|
35
|
+
download: (key) => resolveProvider().download(key),
|
|
36
|
+
delete: (key) => resolveProvider().delete(key),
|
|
37
|
+
exists: (key) => resolveProvider().exists(key),
|
|
38
|
+
getSignedUrl: (key, options) => resolveProvider().getSignedUrl(key, options),
|
|
39
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { AuthUser } from "@clubedge/auth";
|
|
3
|
+
import { upsertUserProfile } from "@clubedge/db";
|
|
4
|
+
import { getDb } from "./db";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Keeps the app-owned users row in step with the identity provider. Failures are logged
|
|
8
|
+
* rather than thrown so a database outage does not block an otherwise valid sign-in.
|
|
9
|
+
*/
|
|
10
|
+
export async function ensureUserProfile(user: AuthUser | null): Promise<void> {
|
|
11
|
+
if (!user?.email) return;
|
|
12
|
+
try {
|
|
13
|
+
await upsertUserProfile(getDb(), { authUserId: user.id, email: user.email });
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error("Could not sync the user profile", error);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [
|
|
17
|
+
{
|
|
18
|
+
"name": "next"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"baseUrl": ".",
|
|
22
|
+
"paths": {
|
|
23
|
+
"@/*": ["./src/*"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"include": [
|
|
27
|
+
"next-env.d.ts",
|
|
28
|
+
".next/types/**/*.ts",
|
|
29
|
+
".next/dev/types/**/*.ts",
|
|
30
|
+
"**/*.ts",
|
|
31
|
+
"**/*.tsx",
|
|
32
|
+
"../../packages/ui/src/**/*.ts",
|
|
33
|
+
"../../packages/ui/src/**/*.tsx"
|
|
34
|
+
],
|
|
35
|
+
"exclude": ["node_modules"]
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "clubedge-starter",
|
|
4
|
+
"packageManager": "pnpm",
|
|
5
|
+
"app": "apps/web",
|
|
6
|
+
"siteConfig": "apps/web/src/config/site.json",
|
|
7
|
+
"env": {
|
|
8
|
+
"example": ".env.example",
|
|
9
|
+
"target": "apps/web/.env.local"
|
|
10
|
+
},
|
|
11
|
+
"dockerImage": "clubedge-starter",
|
|
12
|
+
"exclude": ["clubedge.template.json", "scripts/template-manifest.test.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
2
|
+
import tseslint from "typescript-eslint";
|
|
3
|
+
|
|
4
|
+
// Architecture boundaries. Shared packages stay framework-agnostic so any app (Next.js today,
|
|
5
|
+
// other frameworks later) can reuse them; apps reach providers only through src/server.
|
|
6
|
+
const frameworkImports = {
|
|
7
|
+
group: ["next", "next/*", "@tanstack/*", "react", "react-dom", "react/*", "server-only"],
|
|
8
|
+
message: "Shared packages must stay framework-agnostic. Accept dependencies as arguments.",
|
|
9
|
+
};
|
|
10
|
+
const appImports = {
|
|
11
|
+
group: ["@/*", "**/apps/**"],
|
|
12
|
+
message: "Packages must not import application code.",
|
|
13
|
+
};
|
|
14
|
+
const providerImports = {
|
|
15
|
+
group: ["@supabase/*", "@aws-sdk/*", "redis", "postgres", "drizzle-orm/postgres-js"],
|
|
16
|
+
message: "Use the composition root in src/server instead of a provider SDK.",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default defineConfig([
|
|
20
|
+
...tseslint.configs.recommended,
|
|
21
|
+
{
|
|
22
|
+
files: ["packages/{auth,cache,core,db,storage}/**/*.ts"],
|
|
23
|
+
rules: {
|
|
24
|
+
"no-restricted-imports": ["error", { patterns: [frameworkImports, appImports] }],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
files: ["packages/core/**/*.ts"],
|
|
29
|
+
rules: {
|
|
30
|
+
"no-restricted-imports": [
|
|
31
|
+
"error",
|
|
32
|
+
{
|
|
33
|
+
patterns: [
|
|
34
|
+
frameworkImports,
|
|
35
|
+
appImports,
|
|
36
|
+
{
|
|
37
|
+
group: ["@clubedge/*"],
|
|
38
|
+
message: "@clubedge/core must not depend on other packages.",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
files: ["apps/*/src/**/*.{ts,tsx}"],
|
|
47
|
+
ignores: ["apps/*/src/server/**"],
|
|
48
|
+
rules: {
|
|
49
|
+
"no-restricted-imports": ["error", { patterns: [providerImports] }],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
globalIgnores([
|
|
53
|
+
"**/node_modules/**",
|
|
54
|
+
"**/.next/**",
|
|
55
|
+
"**/.turbo/**",
|
|
56
|
+
".pnpm-store/**",
|
|
57
|
+
"out/**",
|
|
58
|
+
"build/**",
|
|
59
|
+
"**/next-env.d.ts",
|
|
60
|
+
"test-results/**",
|
|
61
|
+
"playwright-report/**",
|
|
62
|
+
]),
|
|
63
|
+
]);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clubedge-starter-v1",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"packageManager": "pnpm@10.9.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.12.0"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "turbo run dev --filter=@clubedge/web",
|
|
12
|
+
"build": "turbo run build --filter=@clubedge/web",
|
|
13
|
+
"start": "node scripts/start-standalone.mjs",
|
|
14
|
+
"lint": "eslint .",
|
|
15
|
+
"typecheck": "turbo run typecheck",
|
|
16
|
+
"format": "prettier --write .",
|
|
17
|
+
"format:check": "prettier --check .",
|
|
18
|
+
"db:generate": "pnpm --filter @clubedge/db db:generate",
|
|
19
|
+
"db:migrate": "pnpm --filter @clubedge/db db:migrate",
|
|
20
|
+
"db:push": "pnpm --filter @clubedge/db db:push",
|
|
21
|
+
"db:studio": "pnpm --filter @clubedge/db db:studio",
|
|
22
|
+
"db:check": "pnpm --filter @clubedge/db db:check",
|
|
23
|
+
"db:seed": "pnpm --filter @clubedge/db db:seed",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"test:e2e": "playwright test",
|
|
27
|
+
"test:e2e:ui": "playwright test --ui",
|
|
28
|
+
"docker:dev": "docker compose up --build",
|
|
29
|
+
"docker:build": "docker build -t clubedge-starter .",
|
|
30
|
+
"docker:start": "docker run --rm -p 3000:3000 --env-file apps/web/.env.local clubedge-starter"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@playwright/test": "^1.55.0",
|
|
34
|
+
"@types/node": "^22.18.6",
|
|
35
|
+
"eslint": "^10.12.0",
|
|
36
|
+
"prettier": "^3.6.2",
|
|
37
|
+
"turbo": "^2.5.0",
|
|
38
|
+
"typescript": "^5.9.2",
|
|
39
|
+
"typescript-eslint": "^8.71.1",
|
|
40
|
+
"vitest": "^5.0.3"
|
|
41
|
+
},
|
|
42
|
+
"pnpm": {
|
|
43
|
+
"overrides": {
|
|
44
|
+
"esbuild": "^0.25.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clubedge/auth",
|
|
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
|
+
"./supabase": "./src/supabase.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@clubedge/core": "workspace:*",
|
|
17
|
+
"@supabase/ssr": "^0.6.1",
|
|
18
|
+
"@supabase/supabase-js": "^2.57.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"typescript": "^5.9.2"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { CookieStore } from "./types";
|
|
3
|
+
|
|
4
|
+
const supabaseAuth = {
|
|
5
|
+
getUser: vi.fn(),
|
|
6
|
+
signInWithPassword: vi.fn(),
|
|
7
|
+
signUp: vi.fn(),
|
|
8
|
+
exchangeCodeForSession: vi.fn(),
|
|
9
|
+
signOut: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
let cookieMethods: { getAll(): unknown; setAll(values: unknown): void } | undefined;
|
|
12
|
+
|
|
13
|
+
vi.mock("@supabase/ssr", () => ({
|
|
14
|
+
createServerClient: vi.fn(
|
|
15
|
+
(_url: string, _key: string, options: { cookies: typeof cookieMethods }) => {
|
|
16
|
+
cookieMethods = options.cookies;
|
|
17
|
+
return { auth: supabaseAuth };
|
|
18
|
+
},
|
|
19
|
+
),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { createSupabaseAuth } = await import("./supabase");
|
|
23
|
+
|
|
24
|
+
const supabaseUser = { id: "user-1", email: "ada@example.com" };
|
|
25
|
+
|
|
26
|
+
function setup() {
|
|
27
|
+
const cookies: CookieStore = {
|
|
28
|
+
getAll: vi.fn(() => [{ name: "sb", value: "token" }]),
|
|
29
|
+
setAll: vi.fn(),
|
|
30
|
+
};
|
|
31
|
+
const auth = createSupabaseAuth({ url: "https://x.supabase.co", publishableKey: "pk", cookies });
|
|
32
|
+
return { auth, cookies };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("createSupabaseAuth", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("bridges the framework cookie store to Supabase", () => {
|
|
41
|
+
const { cookies } = setup();
|
|
42
|
+
expect(cookieMethods?.getAll()).toEqual([{ name: "sb", value: "token" }]);
|
|
43
|
+
const values = [{ name: "sb", value: "next", options: { path: "/" } }];
|
|
44
|
+
cookieMethods?.setAll(values);
|
|
45
|
+
expect(cookies.setAll).toHaveBeenCalledWith(values);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns the verified user, or null when Supabase rejects the session", async () => {
|
|
49
|
+
const { auth } = setup();
|
|
50
|
+
supabaseAuth.getUser.mockResolvedValueOnce({ data: { user: supabaseUser }, error: null });
|
|
51
|
+
expect(await auth.getUser()).toEqual({ id: "user-1", email: "ada@example.com" });
|
|
52
|
+
|
|
53
|
+
supabaseAuth.getUser.mockResolvedValueOnce({
|
|
54
|
+
data: { user: null },
|
|
55
|
+
error: new Error("expired"),
|
|
56
|
+
});
|
|
57
|
+
expect(await auth.getUser()).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("maps sign-in results without leaking provider errors", async () => {
|
|
61
|
+
const { auth } = setup();
|
|
62
|
+
const credentials = { email: "ada@example.com", password: "correct horse" };
|
|
63
|
+
|
|
64
|
+
supabaseAuth.signInWithPassword.mockResolvedValueOnce({
|
|
65
|
+
data: { user: supabaseUser },
|
|
66
|
+
error: null,
|
|
67
|
+
});
|
|
68
|
+
expect(await auth.signInWithPassword(credentials)).toEqual({
|
|
69
|
+
ok: true,
|
|
70
|
+
value: { id: "user-1", email: "ada@example.com" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
supabaseAuth.signInWithPassword.mockResolvedValueOnce({
|
|
74
|
+
data: { user: null },
|
|
75
|
+
error: new Error("Invalid login credentials"),
|
|
76
|
+
});
|
|
77
|
+
expect(await auth.signInWithPassword(credentials)).toEqual({
|
|
78
|
+
ok: false,
|
|
79
|
+
error: "invalid-credentials",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("reports when sign-up needs email confirmation", async () => {
|
|
84
|
+
const { auth } = setup();
|
|
85
|
+
supabaseAuth.signUp.mockResolvedValueOnce({
|
|
86
|
+
data: { user: supabaseUser, session: null },
|
|
87
|
+
error: null,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const result = await auth.signUp({
|
|
91
|
+
email: "ada@example.com",
|
|
92
|
+
password: "correct horse",
|
|
93
|
+
emailRedirectTo: "https://app.example/auth/callback",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(supabaseAuth.signUp).toHaveBeenCalledWith({
|
|
97
|
+
email: "ada@example.com",
|
|
98
|
+
password: "correct horse",
|
|
99
|
+
options: { emailRedirectTo: "https://app.example/auth/callback" },
|
|
100
|
+
});
|
|
101
|
+
expect(result).toEqual({
|
|
102
|
+
ok: true,
|
|
103
|
+
value: { user: { id: "user-1", email: "ada@example.com" }, needsConfirmation: true },
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("maps sign-up and code exchange failures", async () => {
|
|
108
|
+
const { auth } = setup();
|
|
109
|
+
supabaseAuth.signUp.mockResolvedValueOnce({ data: {}, error: new Error("taken") });
|
|
110
|
+
expect(
|
|
111
|
+
await auth.signUp({ email: "a@b.co", password: "12345678", emailRedirectTo: "https://a.b" }),
|
|
112
|
+
).toEqual({ ok: false, error: "signup-failed" });
|
|
113
|
+
|
|
114
|
+
supabaseAuth.exchangeCodeForSession.mockResolvedValueOnce({
|
|
115
|
+
data: { user: null },
|
|
116
|
+
error: new Error("bad code"),
|
|
117
|
+
});
|
|
118
|
+
expect(await auth.exchangeCodeForSession("code")).toEqual({ ok: false, error: "invalid-code" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws when sign-out fails", async () => {
|
|
122
|
+
const { auth } = setup();
|
|
123
|
+
supabaseAuth.signOut.mockResolvedValueOnce({ error: new Error("network") });
|
|
124
|
+
await expect(auth.signOut()).rejects.toThrow("network");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createServerClient, type CookieMethodsServer } from "@supabase/ssr";
|
|
2
|
+
import type { User } from "@supabase/supabase-js";
|
|
3
|
+
import { err, ok } from "@clubedge/core";
|
|
4
|
+
import type { AuthProvider, AuthUser, CookieStore } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface SupabaseAuthConfig {
|
|
7
|
+
url: string;
|
|
8
|
+
publishableKey: string;
|
|
9
|
+
cookies: CookieStore;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A request-scoped Supabase client. Storage adapters can reuse it for user-scoped access. */
|
|
13
|
+
export function createSupabaseServerClient({ url, publishableKey, cookies }: SupabaseAuthConfig) {
|
|
14
|
+
return createServerClient(url, publishableKey, {
|
|
15
|
+
cookies: {
|
|
16
|
+
getAll: () => cookies.getAll(),
|
|
17
|
+
setAll: (values) => cookies.setAll(values),
|
|
18
|
+
} satisfies CookieMethodsServer,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toAuthUser(user: User): AuthUser {
|
|
23
|
+
return { id: user.id, email: user.email ?? null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createSupabaseAuth(config: SupabaseAuthConfig): AuthProvider {
|
|
27
|
+
const supabase = createSupabaseServerClient(config);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async getUser() {
|
|
31
|
+
const { data, error } = await supabase.auth.getUser();
|
|
32
|
+
if (error || !data.user) return null;
|
|
33
|
+
return toAuthUser(data.user);
|
|
34
|
+
},
|
|
35
|
+
async signInWithPassword(credentials) {
|
|
36
|
+
const { data, error } = await supabase.auth.signInWithPassword(credentials);
|
|
37
|
+
if (error || !data.user) return err("invalid-credentials");
|
|
38
|
+
return ok(toAuthUser(data.user));
|
|
39
|
+
},
|
|
40
|
+
async signUp({ emailRedirectTo, ...credentials }) {
|
|
41
|
+
const { data, error } = await supabase.auth.signUp({
|
|
42
|
+
...credentials,
|
|
43
|
+
options: { emailRedirectTo },
|
|
44
|
+
});
|
|
45
|
+
if (error) return err("signup-failed");
|
|
46
|
+
return ok({
|
|
47
|
+
user: data.user ? toAuthUser(data.user) : null,
|
|
48
|
+
needsConfirmation: !data.session,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
async exchangeCodeForSession(code) {
|
|
52
|
+
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
|
|
53
|
+
if (error) return err("invalid-code");
|
|
54
|
+
return ok(data.user ? toAuthUser(data.user) : null);
|
|
55
|
+
},
|
|
56
|
+
async refreshSession() {
|
|
57
|
+
// getUser() validates the JWT with Supabase and rotates the cookies when needed.
|
|
58
|
+
await supabase.auth.getUser();
|
|
59
|
+
},
|
|
60
|
+
async signOut() {
|
|
61
|
+
const { error } = await supabase.auth.signOut();
|
|
62
|
+
if (error) throw error;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Result } from "@clubedge/core";
|
|
2
|
+
|
|
3
|
+
export interface AuthUser {
|
|
4
|
+
id: string;
|
|
5
|
+
email: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Credentials {
|
|
9
|
+
email: string;
|
|
10
|
+
password: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CookieOptions {
|
|
14
|
+
domain?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
expires?: Date;
|
|
17
|
+
maxAge?: number;
|
|
18
|
+
httpOnly?: boolean;
|
|
19
|
+
secure?: boolean;
|
|
20
|
+
partitioned?: boolean;
|
|
21
|
+
priority?: "low" | "medium" | "high";
|
|
22
|
+
sameSite?: boolean | "lax" | "strict" | "none";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CookieToSet {
|
|
26
|
+
name: string;
|
|
27
|
+
value: string;
|
|
28
|
+
options?: CookieOptions;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The only thing an auth adapter needs from the web framework. Each app bridges its own
|
|
33
|
+
* request and response cookies (Next.js `cookies()`, TanStack Start helpers, and so on).
|
|
34
|
+
*/
|
|
35
|
+
export interface CookieStore {
|
|
36
|
+
getAll(): Array<{ name: string; value: string }>;
|
|
37
|
+
/** May throw where cookies are read-only, such as React Server Components. */
|
|
38
|
+
setAll(cookies: CookieToSet[]): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SignUpOutcome {
|
|
42
|
+
user: AuthUser | null;
|
|
43
|
+
/** True when the provider requires email confirmation before a session exists. */
|
|
44
|
+
needsConfirmation: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Application code depends on this interface, never on a provider SDK. */
|
|
48
|
+
export interface AuthProvider {
|
|
49
|
+
/** Returns the user verified with the provider, not just decoded from a cookie. */
|
|
50
|
+
getUser(): Promise<AuthUser | null>;
|
|
51
|
+
signInWithPassword(credentials: Credentials): Promise<Result<AuthUser, "invalid-credentials">>;
|
|
52
|
+
signUp(
|
|
53
|
+
credentials: Credentials & { emailRedirectTo: string },
|
|
54
|
+
): Promise<Result<SignUpOutcome, "signup-failed">>;
|
|
55
|
+
exchangeCodeForSession(code: string): Promise<Result<AuthUser | null, "invalid-code">>;
|
|
56
|
+
/** Validates the session and writes refreshed cookies; call it from request middleware. */
|
|
57
|
+
refreshSession(): Promise<void>;
|
|
58
|
+
signOut(): Promise<void>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clubedge/cache",
|
|
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
|
+
"dependencies": {
|
|
15
|
+
"redis": "^5.8.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.9.2"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const redisClient = {
|
|
4
|
+
isReady: false,
|
|
5
|
+
on: vi.fn(),
|
|
6
|
+
connect: vi.fn(async () => {
|
|
7
|
+
redisClient.isReady = true;
|
|
8
|
+
return redisClient;
|
|
9
|
+
}),
|
|
10
|
+
eval: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
vi.mock("redis", () => ({ createClient: vi.fn(() => redisClient) }));
|
|
14
|
+
|
|
15
|
+
const { createClient } = await import("redis");
|
|
16
|
+
const { createRateLimiter } = await import("./index");
|
|
17
|
+
|
|
18
|
+
describe("createRateLimiter", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
redisClient.isReady = false;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses process memory when no Redis URL is configured", async () => {
|
|
25
|
+
const limiter = createRateLimiter({ requests: 1, windowSeconds: 60 });
|
|
26
|
+
|
|
27
|
+
expect((await limiter.limit("client")).success).toBe(true);
|
|
28
|
+
expect((await limiter.limit("client")).success).toBe(false);
|
|
29
|
+
expect(createClient).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("counts in Redis when a URL is configured", async () => {
|
|
33
|
+
redisClient.eval.mockResolvedValueOnce([3, 30_000]);
|
|
34
|
+
const limiter = createRateLimiter({
|
|
35
|
+
requests: 2,
|
|
36
|
+
windowSeconds: 60,
|
|
37
|
+
redisUrl: "redis://localhost:6379",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await limiter.limit("client");
|
|
41
|
+
|
|
42
|
+
expect(redisClient.eval).toHaveBeenCalledWith(expect.any(String), {
|
|
43
|
+
keys: ["clubedge:rate-limit:client"],
|
|
44
|
+
arguments: ["60000"],
|
|
45
|
+
});
|
|
46
|
+
expect(result).toMatchObject({ success: false, limit: 2, remaining: 0 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("reuses one connection per Redis URL", async () => {
|
|
50
|
+
redisClient.eval.mockResolvedValue([1, 60_000]);
|
|
51
|
+
const url = "redis://reuse.example:6379";
|
|
52
|
+
const first = createRateLimiter({ redisUrl: url });
|
|
53
|
+
const second = createRateLimiter({ redisUrl: url });
|
|
54
|
+
|
|
55
|
+
await first.limit("a");
|
|
56
|
+
await second.limit("b");
|
|
57
|
+
|
|
58
|
+
expect(createClient).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(redisClient.connect).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects an unexpected Redis response", async () => {
|
|
63
|
+
redisClient.eval.mockResolvedValueOnce("nope");
|
|
64
|
+
const limiter = createRateLimiter({ redisUrl: "redis://bad.example:6379" });
|
|
65
|
+
await expect(limiter.limit("client")).rejects.toThrow("invalid rate limit response");
|
|
66
|
+
});
|
|
67
|
+
});
|