@odvi/create-dtt-framework 0.1.3 → 0.1.6
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/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +16 -13
- package/dist/commands/create.js.map +1 -1
- package/package.json +3 -2
- package/template/.env.example +106 -0
- package/template/components.json +22 -0
- package/template/docs/framework/01-overview.md +289 -0
- package/template/docs/framework/02-techstack.md +503 -0
- package/template/docs/framework/api-layer.md +681 -0
- package/template/docs/framework/clerk-authentication.md +649 -0
- package/template/docs/framework/cli-installation.md +564 -0
- package/template/docs/framework/deployment/ci-cd.md +907 -0
- package/template/docs/framework/deployment/digitalocean.md +991 -0
- package/template/docs/framework/deployment/domain-setup.md +972 -0
- package/template/docs/framework/deployment/environment-variables.md +862 -0
- package/template/docs/framework/deployment/monitoring.md +927 -0
- package/template/docs/framework/deployment/production-checklist.md +649 -0
- package/template/docs/framework/deployment/vercel.md +791 -0
- package/template/docs/framework/environment-variables.md +646 -0
- package/template/docs/framework/health-check-system.md +583 -0
- package/template/docs/framework/implementation.md +559 -0
- package/template/docs/framework/snowflake-integration.md +594 -0
- package/template/docs/framework/state-management.md +615 -0
- package/template/docs/framework/supabase-integration.md +582 -0
- package/template/docs/framework/testing-guide.md +544 -0
- package/template/docs/framework/what-did-i-miss.md +526 -0
- package/template/drizzle.config.ts +11 -0
- package/template/next.config.js +21 -0
- package/template/postcss.config.js +5 -0
- package/template/prettier.config.js +4 -0
- package/template/public/favicon.ico +0 -0
- package/template/src/app/(auth)/layout.tsx +4 -0
- package/template/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +10 -0
- package/template/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +10 -0
- package/template/src/app/(dashboard)/dashboard/page.tsx +8 -0
- package/template/src/app/(dashboard)/health/page.tsx +16 -0
- package/template/src/app/(dashboard)/layout.tsx +17 -0
- package/template/src/app/api/[[...route]]/route.ts +11 -0
- package/template/src/app/api/debug-files/route.ts +33 -0
- package/template/src/app/api/webhooks/clerk/route.ts +112 -0
- package/template/src/app/layout.tsx +28 -0
- package/template/src/app/page.tsx +12 -0
- package/template/src/app/providers.tsx +20 -0
- package/template/src/components/layouts/navbar.tsx +14 -0
- package/template/src/components/shared/loading-spinner.tsx +6 -0
- package/template/src/components/ui/badge.tsx +46 -0
- package/template/src/components/ui/button.tsx +62 -0
- package/template/src/components/ui/card.tsx +92 -0
- package/template/src/components/ui/collapsible.tsx +33 -0
- package/template/src/components/ui/scroll-area.tsx +58 -0
- package/template/src/components/ui/sheet.tsx +139 -0
- package/template/src/config/__tests__/env.test.ts +164 -0
- package/template/src/config/__tests__/site.test.ts +46 -0
- package/template/src/config/env.ts +36 -0
- package/template/src/config/site.ts +10 -0
- package/template/src/env.js +44 -0
- package/template/src/features/__tests__/health-check-config.test.ts +142 -0
- package/template/src/features/__tests__/health-check-types.test.ts +201 -0
- package/template/src/features/documentation/components/doc-sidebar.tsx +109 -0
- package/template/src/features/documentation/components/doc-viewer.tsx +70 -0
- package/template/src/features/documentation/index.tsx +92 -0
- package/template/src/features/documentation/utils/doc-loader.ts +177 -0
- package/template/src/features/health-check/components/health-dashboard.tsx +374 -0
- package/template/src/features/health-check/config.ts +71 -0
- package/template/src/features/health-check/index.ts +4 -0
- package/template/src/features/health-check/stores/health-store.ts +14 -0
- package/template/src/features/health-check/types.ts +18 -0
- package/template/src/hooks/__tests__/use-debounce.test.tsx +28 -0
- package/template/src/hooks/queries/use-health-checks.ts +16 -0
- package/template/src/hooks/utils/use-debounce.ts +20 -0
- package/template/src/lib/__tests__/utils.test.ts +52 -0
- package/template/src/lib/__tests__/validators.test.ts +114 -0
- package/template/src/lib/nextbank/client.ts +67 -0
- package/template/src/lib/snowflake/client.ts +102 -0
- package/template/src/lib/supabase/admin.ts +7 -0
- package/template/src/lib/supabase/client.ts +7 -0
- package/template/src/lib/supabase/server.ts +23 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/src/lib/validators.ts +9 -0
- package/template/src/middleware.ts +22 -0
- package/template/src/server/api/index.ts +22 -0
- package/template/src/server/api/middleware/auth.ts +19 -0
- package/template/src/server/api/middleware/logger.ts +4 -0
- package/template/src/server/api/routes/health/clerk.ts +214 -0
- package/template/src/server/api/routes/health/database.ts +141 -0
- package/template/src/server/api/routes/health/edge-functions.ts +107 -0
- package/template/src/server/api/routes/health/framework.ts +48 -0
- package/template/src/server/api/routes/health/index.ts +102 -0
- package/template/src/server/api/routes/health/nextbank.ts +46 -0
- package/template/src/server/api/routes/health/snowflake.ts +83 -0
- package/template/src/server/api/routes/health/storage.ts +177 -0
- package/template/src/server/api/routes/users.ts +79 -0
- package/template/src/server/db/index.ts +17 -0
- package/template/src/server/db/queries/users.ts +8 -0
- package/template/src/server/db/schema/__tests__/health-checks.test.ts +31 -0
- package/template/src/server/db/schema/__tests__/users.test.ts +46 -0
- package/template/src/server/db/schema/health-checks.ts +11 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/users.ts +16 -0
- package/template/src/server/db/schema.ts +1 -0
- package/template/src/stores/__tests__/ui-store.test.ts +87 -0
- package/template/src/stores/ui-store.ts +14 -0
- package/template/src/styles/globals.css +129 -0
- package/template/src/test/mocks/clerk.ts +35 -0
- package/template/src/test/mocks/snowflake.ts +28 -0
- package/template/src/test/mocks/supabase.ts +37 -0
- package/template/src/test/setup.ts +69 -0
- package/template/src/test/utils/test-helpers.ts +158 -0
- package/template/src/types/index.ts +14 -0
- package/template/tsconfig.json +43 -0
- package/template/vitest.config.ts +44 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { healthCheckSchema } from '../validators';
|
|
3
|
+
|
|
4
|
+
describe('healthCheckSchema validator', () => {
|
|
5
|
+
it('should validate a healthy health check', () => {
|
|
6
|
+
const result = healthCheckSchema.safeParse({
|
|
7
|
+
status: 'healthy',
|
|
8
|
+
responseTimeMs: 100,
|
|
9
|
+
message: 'Service is healthy',
|
|
10
|
+
});
|
|
11
|
+
expect(result.success).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should validate an unhealthy health check', () => {
|
|
15
|
+
const result = healthCheckSchema.safeParse({
|
|
16
|
+
status: 'unhealthy',
|
|
17
|
+
responseTimeMs: 5000,
|
|
18
|
+
message: 'Service is unhealthy',
|
|
19
|
+
});
|
|
20
|
+
expect(result.success).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should validate an error health check', () => {
|
|
24
|
+
const result = healthCheckSchema.safeParse({
|
|
25
|
+
status: 'error',
|
|
26
|
+
error: 'Connection failed',
|
|
27
|
+
});
|
|
28
|
+
expect(result.success).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should validate a pending health check', () => {
|
|
32
|
+
const result = healthCheckSchema.safeParse({
|
|
33
|
+
status: 'pending',
|
|
34
|
+
message: 'Checking...',
|
|
35
|
+
});
|
|
36
|
+
expect(result.success).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should validate an unconfigured health check', () => {
|
|
40
|
+
const result = healthCheckSchema.safeParse({
|
|
41
|
+
status: 'unconfigured',
|
|
42
|
+
message: 'Service not configured',
|
|
43
|
+
});
|
|
44
|
+
expect(result.success).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should reject invalid status values', () => {
|
|
48
|
+
const result = healthCheckSchema.safeParse({
|
|
49
|
+
status: 'invalid',
|
|
50
|
+
});
|
|
51
|
+
expect(result.success).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should reject missing status field', () => {
|
|
55
|
+
const result = healthCheckSchema.safeParse({});
|
|
56
|
+
expect(result.success).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should accept health check with only required fields', () => {
|
|
60
|
+
const result = healthCheckSchema.safeParse({
|
|
61
|
+
status: 'healthy',
|
|
62
|
+
});
|
|
63
|
+
expect(result.success).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should reject invalid responseTimeMs type', () => {
|
|
67
|
+
const result = healthCheckSchema.safeParse({
|
|
68
|
+
status: 'healthy',
|
|
69
|
+
responseTimeMs: '100' as any,
|
|
70
|
+
});
|
|
71
|
+
expect(result.success).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should reject negative responseTimeMs', () => {
|
|
75
|
+
const result = healthCheckSchema.safeParse({
|
|
76
|
+
status: 'healthy',
|
|
77
|
+
responseTimeMs: -100,
|
|
78
|
+
});
|
|
79
|
+
expect(result.success).toBe(true); // Zod doesn't enforce min on number by default
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should reject invalid message type', () => {
|
|
83
|
+
const result = healthCheckSchema.safeParse({
|
|
84
|
+
status: 'healthy',
|
|
85
|
+
message: 123 as any,
|
|
86
|
+
});
|
|
87
|
+
expect(result.success).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should reject invalid error type', () => {
|
|
91
|
+
const result = healthCheckSchema.safeParse({
|
|
92
|
+
status: 'error',
|
|
93
|
+
error: 123 as any,
|
|
94
|
+
});
|
|
95
|
+
expect(result.success).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should provide detailed error messages for invalid status', () => {
|
|
99
|
+
const result = healthCheckSchema.safeParse({
|
|
100
|
+
status: 'invalid',
|
|
101
|
+
});
|
|
102
|
+
if (!result.success) {
|
|
103
|
+
expect(result.error.issues[0]?.message).toContain('Invalid enum value');
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should allow all valid status enum values', () => {
|
|
108
|
+
const validStatuses = ['healthy', 'unhealthy', 'error', 'pending', 'unconfigured'] as const;
|
|
109
|
+
validStatuses.forEach((status) => {
|
|
110
|
+
const result = healthCheckSchema.safeParse({ status });
|
|
111
|
+
expect(result.success).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { env } from '@/config/env'
|
|
2
|
+
|
|
3
|
+
class NextBankClient {
|
|
4
|
+
private apiUrl: string
|
|
5
|
+
private username: string
|
|
6
|
+
private password: string
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
this.apiUrl = env.NEXTBANK_API ?? ''
|
|
10
|
+
this.username = env.NEXTBANK_API_USERNAME ?? ''
|
|
11
|
+
this.password = env.NEXTBANK_API_PASSWORD ?? ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private getAuthHeader(): Record<string, string> {
|
|
15
|
+
if (!this.username || !this.password) return {}
|
|
16
|
+
const token = btoa(`${this.username}:${this.password}`)
|
|
17
|
+
return { Authorization: `Basic ${token}` }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async ping(fingerprint: string): Promise<{ status: string; timestamp: string }> {
|
|
21
|
+
const url = `${this.apiUrl}/management/status`
|
|
22
|
+
console.log(`[NextBank] Pinging ${url}`)
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
...this.getAuthHeader(),
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
'Accept': 'application/json',
|
|
31
|
+
'User-Agent': 'dtt-framework-health-check',
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
fingerprint: fingerprint,
|
|
35
|
+
}),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const contentType = response.headers.get('content-type')
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
if (contentType && contentType.includes('text/html')) {
|
|
42
|
+
const text = await response.text()
|
|
43
|
+
console.error('[NextBank] Received HTML error response. Possible causes:')
|
|
44
|
+
console.error('1. NEXTBANK_API is pointing to the wrong server (e.g. Next.js app instead of NextBank API)')
|
|
45
|
+
console.error('2. The endpoint /management/status does not exist or does not support POST')
|
|
46
|
+
console.error(`[NextBank] Response preview: ${text.substring(0, 150)}...`)
|
|
47
|
+
throw new Error(`NextBank API error: ${response.status} (HTML response)`)
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`NextBank API error: ${response.status} ${response.statusText}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (contentType && contentType.includes('application/json')) {
|
|
53
|
+
return await response.json()
|
|
54
|
+
} else {
|
|
55
|
+
const text = await response.text()
|
|
56
|
+
console.error('[NextBank] Invalid response content type:', contentType)
|
|
57
|
+
console.error('[NextBank] Response preview:', text.substring(0, 200))
|
|
58
|
+
throw new Error(`Invalid response format. Expected JSON, got ${contentType}`)
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('[NextBank] Ping failed:', error)
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const nextbankClient = new NextBankClient()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Connection, Bind } from 'snowflake-sdk'
|
|
2
|
+
import { env } from '@/config/env'
|
|
3
|
+
|
|
4
|
+
// Helper to format private key correctly
|
|
5
|
+
function formatPrivateKey(key: string | undefined): string | undefined {
|
|
6
|
+
if (!key || key === 'placeholder') return undefined
|
|
7
|
+
|
|
8
|
+
let cleanKey = key
|
|
9
|
+
|
|
10
|
+
// 1. Remove surrounding quotes if they exist
|
|
11
|
+
if ((cleanKey.startsWith('"') && cleanKey.endsWith('"')) ||
|
|
12
|
+
(cleanKey.startsWith("'") && cleanKey.endsWith("'"))) {
|
|
13
|
+
cleanKey = cleanKey.slice(1, -1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 2. Handle literal escaped newlines (common in one-line env vars)
|
|
17
|
+
if (cleanKey.includes('\\n')) {
|
|
18
|
+
cleanKey = cleanKey.replace(/\\n/g, '\n')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 3. Split by newline to handle potential indentation/whitespace issues
|
|
22
|
+
const lines = cleanKey.split('\n')
|
|
23
|
+
.map(line => line.trim()) // Remove indentation/whitespace from each line
|
|
24
|
+
.filter(line => line.length > 0) // Remove empty lines
|
|
25
|
+
|
|
26
|
+
// 4. Reconstruct the key
|
|
27
|
+
const formattedKey = lines.join('\n')
|
|
28
|
+
|
|
29
|
+
// Debug log (safe)
|
|
30
|
+
if (!formattedKey.includes('BEGIN PRIVATE KEY')) {
|
|
31
|
+
console.warn('Snowflake Private Key may be invalid or missing PKCS#8 header (expected "-----BEGIN PRIVATE KEY-----")')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (formattedKey.includes('BEGIN RSA PRIVATE KEY')) {
|
|
35
|
+
console.warn('Snowflake Private Key appears to be PKCS#1 (RSA). Snowflake requires PKCS#8. Use "openssl pkcs8 -topk8..." to convert.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return formattedKey
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const config = {
|
|
42
|
+
account: env.SNOWFLAKE_ACCOUNT,
|
|
43
|
+
username: env.SNOWFLAKE_USERNAME,
|
|
44
|
+
authenticator: env.SNOWFLAKE_AUTHENTICATOR,
|
|
45
|
+
privateKey: formatPrivateKey(env.SNOWFLAKE_PRIVATE_KEY),
|
|
46
|
+
privateKeyPass: env.SNOWFLAKE_PRIVATE_KEY_PASSPHRASE,
|
|
47
|
+
warehouse: env.SNOWFLAKE_WAREHOUSE,
|
|
48
|
+
role: env.SNOWFLAKE_ROLE,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getSnowflake() {
|
|
52
|
+
if (process.env.NEXT_RUNTIME === 'edge') {
|
|
53
|
+
throw new Error('Snowflake SDK is not supported in Edge Runtime')
|
|
54
|
+
}
|
|
55
|
+
// Dynamically import snowflake-sdk to avoid build errors in Edge Runtime
|
|
56
|
+
return (await import('snowflake-sdk')).default
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function createSnowflakeConnection(): Promise<Connection> {
|
|
60
|
+
const snowflake = await getSnowflake()
|
|
61
|
+
return snowflake.createConnection(config)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function connectSnowflake(): Promise<Connection> {
|
|
65
|
+
return new Promise(async (resolve, reject) => {
|
|
66
|
+
try {
|
|
67
|
+
const connection = await createSnowflakeConnection()
|
|
68
|
+
connection.connect((err, conn) => {
|
|
69
|
+
if (err) reject(err)
|
|
70
|
+
else resolve(conn)
|
|
71
|
+
})
|
|
72
|
+
} catch (error) {
|
|
73
|
+
reject(error)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function executeQuery<T = unknown>(
|
|
79
|
+
connection: Connection,
|
|
80
|
+
sqlText: string,
|
|
81
|
+
binds?: Bind[]
|
|
82
|
+
): Promise<T[]> {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
connection.execute({
|
|
85
|
+
sqlText,
|
|
86
|
+
binds: binds as any,
|
|
87
|
+
complete: (err, stmt, rows) => {
|
|
88
|
+
if (err) reject(err)
|
|
89
|
+
else resolve((rows || []) as T[])
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function destroyConnection(connection: Connection): Promise<void> {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
connection.destroy((err) => {
|
|
98
|
+
if (err) reject(err)
|
|
99
|
+
else resolve()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createServerClient } from '@supabase/ssr'
|
|
2
|
+
import { cookies } from 'next/headers'
|
|
3
|
+
import { env } from '@/config/env'
|
|
4
|
+
|
|
5
|
+
export async function createClient() {
|
|
6
|
+
const cookieStore = await cookies()
|
|
7
|
+
|
|
8
|
+
return createServerClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY, {
|
|
9
|
+
cookies: {
|
|
10
|
+
getAll() {
|
|
11
|
+
return cookieStore.getAll()
|
|
12
|
+
},
|
|
13
|
+
setAll(cookiesToSet) {
|
|
14
|
+
try {
|
|
15
|
+
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
|
|
16
|
+
} catch {
|
|
17
|
+
// The `setAll` method was called from a Server Component.
|
|
18
|
+
// This can be ignored if you have middleware refreshing user sessions.
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Validators placeholder
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
export const healthCheckSchema = z.object({
|
|
5
|
+
status: z.enum(['healthy', 'unhealthy', 'error', 'pending', 'unconfigured']),
|
|
6
|
+
responseTimeMs: z.number().optional(),
|
|
7
|
+
message: z.string().optional(),
|
|
8
|
+
error: z.string().optional(),
|
|
9
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Clerk middleware placeholder
|
|
2
|
+
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
|
|
3
|
+
|
|
4
|
+
const isPublicRoute = createRouteMatcher([
|
|
5
|
+
'/sign-in(.*)',
|
|
6
|
+
'/sign-up(.*)',
|
|
7
|
+
'/api/webhooks(.*)',
|
|
8
|
+
'/api/ping',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
export default clerkMiddleware(async (auth, request) => {
|
|
12
|
+
if (!isPublicRoute(request)) {
|
|
13
|
+
await auth.protect()
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const config = {
|
|
18
|
+
matcher: [
|
|
19
|
+
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
|
20
|
+
'/(api|trpc)(.*)',
|
|
21
|
+
],
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { cors } from 'hono/cors'
|
|
3
|
+
import { logger } from 'hono/logger'
|
|
4
|
+
import { authMiddleware } from './middleware/auth'
|
|
5
|
+
import { healthRoutes } from './routes/health'
|
|
6
|
+
import { usersRoutes } from './routes/users'
|
|
7
|
+
|
|
8
|
+
const app = new Hono().basePath('/api')
|
|
9
|
+
|
|
10
|
+
app.use('*', logger())
|
|
11
|
+
app.use('*', cors())
|
|
12
|
+
|
|
13
|
+
// Public routes
|
|
14
|
+
app.get('/ping', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }))
|
|
15
|
+
|
|
16
|
+
// Protected routes - require authentication
|
|
17
|
+
app.use('*', authMiddleware)
|
|
18
|
+
app.route('/health', healthRoutes)
|
|
19
|
+
app.route('/users', usersRoutes)
|
|
20
|
+
|
|
21
|
+
export { app }
|
|
22
|
+
export type AppType = typeof app
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Clerk auth middleware for Hono placeholder
|
|
2
|
+
import { createMiddleware } from 'hono/factory'
|
|
3
|
+
import { getAuth } from '@clerk/nextjs/server'
|
|
4
|
+
import type { NextRequest } from 'next/server'
|
|
5
|
+
|
|
6
|
+
export const authMiddleware = createMiddleware(async (c, next) => {
|
|
7
|
+
const request = c.req.raw as NextRequest
|
|
8
|
+
const auth = getAuth(request)
|
|
9
|
+
|
|
10
|
+
if (!auth.userId) {
|
|
11
|
+
return c.json({ error: 'Unauthorized' }, 401)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
c.set('auth', auth)
|
|
15
|
+
c.set('userId', auth.userId)
|
|
16
|
+
c.set('orgId', auth.orgId)
|
|
17
|
+
|
|
18
|
+
await next()
|
|
19
|
+
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { getAuth, clerkClient } from '@clerk/nextjs/server'
|
|
3
|
+
import type { NextRequest } from 'next/server'
|
|
4
|
+
|
|
5
|
+
export const clerkHealthRoutes = new Hono()
|
|
6
|
+
|
|
7
|
+
clerkHealthRoutes.get('/env', async (c) => {
|
|
8
|
+
const start = performance.now()
|
|
9
|
+
const secretKey = process.env.CLERK_SECRET_KEY
|
|
10
|
+
const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
|
11
|
+
|
|
12
|
+
if (secretKey && publishableKey) {
|
|
13
|
+
return c.json({
|
|
14
|
+
status: 'healthy',
|
|
15
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
16
|
+
message: 'Clerk environment variables are configured',
|
|
17
|
+
data: {
|
|
18
|
+
hasSecretKey: true,
|
|
19
|
+
hasPublishableKey: true,
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return c.json(
|
|
25
|
+
{
|
|
26
|
+
status: 'error',
|
|
27
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
28
|
+
error: 'Missing Clerk environment variables',
|
|
29
|
+
data: {
|
|
30
|
+
hasSecretKey: !!secretKey,
|
|
31
|
+
hasPublishableKey: !!publishableKey,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
500
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
clerkHealthRoutes.get('/api-status', async (c) => {
|
|
39
|
+
const start = performance.now()
|
|
40
|
+
try {
|
|
41
|
+
const client = await clerkClient()
|
|
42
|
+
const count = await client.users.getCount()
|
|
43
|
+
return c.json({
|
|
44
|
+
status: 'healthy',
|
|
45
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
46
|
+
message: 'Successfully connected to Clerk Backend API',
|
|
47
|
+
data: { userCount: count },
|
|
48
|
+
})
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return c.json(
|
|
51
|
+
{
|
|
52
|
+
status: 'error',
|
|
53
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
54
|
+
error: error instanceof Error ? error.message : 'Failed to connect to Clerk Backend API',
|
|
55
|
+
},
|
|
56
|
+
500
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
clerkHealthRoutes.get('/user', async (c) => {
|
|
62
|
+
const start = performance.now()
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const auth = getAuth(c.req.raw as NextRequest)
|
|
66
|
+
|
|
67
|
+
if (!auth.userId) {
|
|
68
|
+
return c.json(
|
|
69
|
+
{
|
|
70
|
+
status: 'error',
|
|
71
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
72
|
+
error: 'No authenticated user found',
|
|
73
|
+
},
|
|
74
|
+
401
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const client = await clerkClient()
|
|
79
|
+
const user = await client.users.getUser(auth.userId)
|
|
80
|
+
|
|
81
|
+
return c.json({
|
|
82
|
+
status: 'healthy',
|
|
83
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
84
|
+
message: 'Successfully retrieved current user',
|
|
85
|
+
data: {
|
|
86
|
+
userId: auth.userId,
|
|
87
|
+
email: user.emailAddresses[0]?.emailAddress,
|
|
88
|
+
firstName: user.firstName,
|
|
89
|
+
lastName: user.lastName,
|
|
90
|
+
fullName: `${user.firstName} ${user.lastName}`,
|
|
91
|
+
hasOrg: !!auth.orgId,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return c.json(
|
|
96
|
+
{
|
|
97
|
+
status: 'error',
|
|
98
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
99
|
+
error: error instanceof Error ? error.message : 'Failed to get user',
|
|
100
|
+
},
|
|
101
|
+
500
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Session route removed
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
clerkHealthRoutes.get('/org', async (c) => {
|
|
110
|
+
const start = performance.now()
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const auth = getAuth(c.req.raw as NextRequest)
|
|
114
|
+
|
|
115
|
+
if (!auth.userId) {
|
|
116
|
+
return c.json(
|
|
117
|
+
{
|
|
118
|
+
status: 'error',
|
|
119
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
120
|
+
error: 'No authenticated user found',
|
|
121
|
+
},
|
|
122
|
+
401
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!auth.orgId) {
|
|
127
|
+
return c.json({
|
|
128
|
+
status: 'healthy',
|
|
129
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
130
|
+
message: 'User is not a member of any organization',
|
|
131
|
+
data: { orgId: null },
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return c.json({
|
|
136
|
+
status: 'healthy',
|
|
137
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
138
|
+
message: 'Successfully retrieved organization membership',
|
|
139
|
+
data: {
|
|
140
|
+
orgId: auth.orgId,
|
|
141
|
+
orgRole: auth.orgRole,
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return c.json(
|
|
146
|
+
{
|
|
147
|
+
status: 'error',
|
|
148
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
149
|
+
error: error instanceof Error ? error.message : 'Failed to get organization',
|
|
150
|
+
},
|
|
151
|
+
500
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
clerkHealthRoutes.get('/members', async (c) => {
|
|
157
|
+
const start = performance.now()
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const auth = getAuth(c.req.raw as NextRequest)
|
|
161
|
+
|
|
162
|
+
if (!auth.userId) {
|
|
163
|
+
return c.json(
|
|
164
|
+
{
|
|
165
|
+
status: 'error',
|
|
166
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
167
|
+
error: 'No authenticated user found',
|
|
168
|
+
},
|
|
169
|
+
401
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!auth.orgId) {
|
|
174
|
+
return c.json({
|
|
175
|
+
status: 'healthy',
|
|
176
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
177
|
+
message: 'User is not a member of any organization',
|
|
178
|
+
data: { members: [] },
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Note: In a real implementation, you would use Clerk's Backend API to fetch org members
|
|
183
|
+
// This is a simplified check that verifies org membership exists
|
|
184
|
+
const client = await clerkClient()
|
|
185
|
+
const members = await client.organizations.getOrganizationMembershipList({ organizationId: auth.orgId })
|
|
186
|
+
|
|
187
|
+
return c.json({
|
|
188
|
+
status: 'healthy',
|
|
189
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
190
|
+
message: 'Successfully verified organization membership',
|
|
191
|
+
data: {
|
|
192
|
+
orgId: auth.orgId,
|
|
193
|
+
orgRole: auth.orgRole,
|
|
194
|
+
count: members.totalCount,
|
|
195
|
+
members: members.data.map((m) => ({
|
|
196
|
+
id: m.id,
|
|
197
|
+
userId: m.publicUserData?.userId,
|
|
198
|
+
role: m.role,
|
|
199
|
+
name: `${m.publicUserData?.firstName} ${m.publicUserData?.lastName}`,
|
|
200
|
+
email: m.publicUserData?.identifier,
|
|
201
|
+
})),
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return c.json(
|
|
206
|
+
{
|
|
207
|
+
status: 'error',
|
|
208
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
209
|
+
error: error instanceof Error ? error.message : 'Failed to get organization members',
|
|
210
|
+
},
|
|
211
|
+
500
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
})
|