create-stackr 0.2.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/LICENSE +21 -0
- package/README.md +642 -0
- package/bin/cli.js +12 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +113 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/dependencies.d.ts +82 -0
- package/dist/config/dependencies.d.ts.map +1 -0
- package/dist/config/dependencies.js +82 -0
- package/dist/config/dependencies.js.map +1 -0
- package/dist/config/presets.d.ts +3 -0
- package/dist/config/presets.d.ts.map +1 -0
- package/dist/config/presets.js +174 -0
- package/dist/config/presets.js.map +1 -0
- package/dist/generators/index.d.ts +40 -0
- package/dist/generators/index.d.ts.map +1 -0
- package/dist/generators/index.js +130 -0
- package/dist/generators/index.js.map +1 -0
- package/dist/generators/onboarding.d.ts +8 -0
- package/dist/generators/onboarding.d.ts.map +1 -0
- package/dist/generators/onboarding.js +141 -0
- package/dist/generators/onboarding.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +65 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/features.d.ts +14 -0
- package/dist/prompts/features.d.ts.map +1 -0
- package/dist/prompts/features.js +96 -0
- package/dist/prompts/features.js.map +1 -0
- package/dist/prompts/index.d.ts +3 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +93 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/onboarding.d.ts +6 -0
- package/dist/prompts/onboarding.d.ts.map +1 -0
- package/dist/prompts/onboarding.js +37 -0
- package/dist/prompts/onboarding.js.map +1 -0
- package/dist/prompts/orm.d.ts +3 -0
- package/dist/prompts/orm.d.ts.map +1 -0
- package/dist/prompts/orm.js +23 -0
- package/dist/prompts/orm.js.map +1 -0
- package/dist/prompts/packageManager.d.ts +2 -0
- package/dist/prompts/packageManager.d.ts.map +1 -0
- package/dist/prompts/packageManager.js +18 -0
- package/dist/prompts/packageManager.js.map +1 -0
- package/dist/prompts/platform.d.ts +3 -0
- package/dist/prompts/platform.d.ts.map +1 -0
- package/dist/prompts/platform.js +21 -0
- package/dist/prompts/platform.js.map +1 -0
- package/dist/prompts/preset.d.ts +4 -0
- package/dist/prompts/preset.d.ts.map +1 -0
- package/dist/prompts/preset.js +165 -0
- package/dist/prompts/preset.js.map +1 -0
- package/dist/prompts/project.d.ts +2 -0
- package/dist/prompts/project.d.ts.map +1 -0
- package/dist/prompts/project.js +27 -0
- package/dist/prompts/project.js.map +1 -0
- package/dist/prompts/sdks.d.ts +2 -0
- package/dist/prompts/sdks.d.ts.map +1 -0
- package/dist/prompts/sdks.js +46 -0
- package/dist/prompts/sdks.js.map +1 -0
- package/dist/types/index.d.ts +77 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +25 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/cleanup.d.ts +5 -0
- package/dist/utils/cleanup.d.ts.map +1 -0
- package/dist/utils/cleanup.js +38 -0
- package/dist/utils/cleanup.js.map +1 -0
- package/dist/utils/copy.d.ts +10 -0
- package/dist/utils/copy.d.ts.map +1 -0
- package/dist/utils/copy.js +53 -0
- package/dist/utils/copy.js.map +1 -0
- package/dist/utils/errors.d.ts +33 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +136 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/git.d.ts +5 -0
- package/dist/utils/git.d.ts.map +1 -0
- package/dist/utils/git.js +33 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/logger.d.ts +9 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +22 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/package.d.ts +16 -0
- package/dist/utils/package.d.ts.map +1 -0
- package/dist/utils/package.js +86 -0
- package/dist/utils/package.js.map +1 -0
- package/dist/utils/system-validation.d.ts +9 -0
- package/dist/utils/system-validation.d.ts.map +1 -0
- package/dist/utils/system-validation.js +31 -0
- package/dist/utils/system-validation.js.map +1 -0
- package/dist/utils/template.d.ts +20 -0
- package/dist/utils/template.d.ts.map +1 -0
- package/dist/utils/template.js +234 -0
- package/dist/utils/template.js.map +1 -0
- package/dist/utils/validation.d.ts +8 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +94 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +96 -0
- package/templates/base/backend/.dockerignore.ejs +62 -0
- package/templates/base/backend/.env.example.ejs +116 -0
- package/templates/base/backend/Dockerfile.ejs +142 -0
- package/templates/base/backend/controllers/event-queue/index.ts +20 -0
- package/templates/base/backend/controllers/event-queue/workers/user.ts +39 -0
- package/templates/base/backend/controllers/rest-api/index.ts +48 -0
- package/templates/base/backend/controllers/rest-api/plugins/auth.ts +152 -0
- package/templates/base/backend/controllers/rest-api/plugins/config.ts +64 -0
- package/templates/base/backend/controllers/rest-api/plugins/error-handler.ts +118 -0
- package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +180 -0
- package/templates/base/backend/controllers/rest-api/routes/device-sessions.ts +197 -0
- package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +375 -0
- package/templates/base/backend/controllers/rest-api/server.ts.ejs +87 -0
- package/templates/base/backend/domain/device-session/repository.drizzle.ts +209 -0
- package/templates/base/backend/domain/device-session/repository.prisma.ts +248 -0
- package/templates/base/backend/domain/device-session/schema.ts +72 -0
- package/templates/base/backend/domain/session/repository.drizzle.ts +72 -0
- package/templates/base/backend/domain/session/repository.prisma.ts +72 -0
- package/templates/base/backend/domain/session/schema.ts +29 -0
- package/templates/base/backend/domain/user/repository.drizzle.ts +127 -0
- package/templates/base/backend/domain/user/repository.prisma.ts +115 -0
- package/templates/base/backend/domain/user/schema.ts +14 -0
- package/templates/base/backend/drizzle/schema.drizzle.ts +111 -0
- package/templates/base/backend/drizzle.config.drizzle.ts +13 -0
- package/templates/base/backend/lib/auth.drizzle.ts.ejs +104 -0
- package/templates/base/backend/lib/auth.prisma.ts.ejs +97 -0
- package/templates/base/backend/lib/constants.ts.ejs +29 -0
- package/templates/base/backend/package.json.ejs +50 -0
- package/templates/base/backend/prisma/schema.prisma.ejs +102 -0
- package/templates/base/backend/prisma.config.prisma.ts +12 -0
- package/templates/base/backend/tsconfig.json +39 -0
- package/templates/base/backend/utils/db.drizzle.ts +41 -0
- package/templates/base/backend/utils/db.prisma.ts +51 -0
- package/templates/base/backend/utils/email.ts.ejs +35 -0
- package/templates/base/backend/utils/errors.ts +348 -0
- package/templates/base/backend/utils/redis.ts.ejs +279 -0
- package/templates/base/mobile/.env.example.ejs +35 -0
- package/templates/base/mobile/.gitignore.ejs +167 -0
- package/templates/base/mobile/app/+not-found.tsx +85 -0
- package/templates/base/mobile/app/_layout.tsx.ejs +71 -0
- package/templates/base/mobile/app.json.ejs +88 -0
- package/templates/base/mobile/assets/images/adaptive-icon.png +0 -0
- package/templates/base/mobile/assets/images/favicon.png +0 -0
- package/templates/base/mobile/assets/images/icon.png +0 -0
- package/templates/base/mobile/assets/images/onboarding_page_1.png +0 -0
- package/templates/base/mobile/assets/images/onboarding_page_2.png +0 -0
- package/templates/base/mobile/assets/images/onboarding_page_3.png +0 -0
- package/templates/base/mobile/assets/images/paywall_image.png +0 -0
- package/templates/base/mobile/assets/images/splash.png +0 -0
- package/templates/base/mobile/eas.json.ejs +49 -0
- package/templates/base/mobile/metro.config.js +9 -0
- package/templates/base/mobile/package.json.ejs +53 -0
- package/templates/base/mobile/src/components/ui/Button.tsx +131 -0
- package/templates/base/mobile/src/components/ui/Card.tsx +68 -0
- package/templates/base/mobile/src/components/ui/IconSymbol.tsx +90 -0
- package/templates/base/mobile/src/components/ui/Input.tsx +142 -0
- package/templates/base/mobile/src/components/ui/LoadingSpinner.tsx +98 -0
- package/templates/base/mobile/src/components/ui/OnboardingLayout.tsx +356 -0
- package/templates/base/mobile/src/components/ui/PaywallLayout.tsx +311 -0
- package/templates/base/mobile/src/components/ui/Skeleton.tsx +58 -0
- package/templates/base/mobile/src/components/ui/index.ts +6 -0
- package/templates/base/mobile/src/constants/Theme.ts +163 -0
- package/templates/base/mobile/src/context/ThemeContext.tsx +157 -0
- package/templates/base/mobile/src/lib/auth-client.ts.ejs +51 -0
- package/templates/base/mobile/src/services/api.ts.ejs +71 -0
- package/templates/base/mobile/src/services/errorService.ts +179 -0
- package/templates/base/mobile/src/services/sdkInitializer.ts.ejs +36 -0
- package/templates/base/mobile/src/store/index.ts.ejs +18 -0
- package/templates/base/mobile/src/store/ui.store.ts +100 -0
- package/templates/base/mobile/src/utils/formatters.ts +105 -0
- package/templates/base/mobile/src/utils/logger.ts +73 -0
- package/templates/base/mobile/src/utils/responsive.ts +234 -0
- package/templates/base/mobile/tsconfig.json +32 -0
- package/templates/base/web/.env.example.ejs +26 -0
- package/templates/base/web/components.json +22 -0
- package/templates/base/web/eslint.config.mjs +18 -0
- package/templates/base/web/next.config.ts +7 -0
- package/templates/base/web/package.json.ejs +35 -0
- package/templates/base/web/postcss.config.mjs +7 -0
- package/templates/base/web/public/.gitkeep +0 -0
- package/templates/base/web/public/file.svg +1 -0
- package/templates/base/web/public/globe.svg +1 -0
- package/templates/base/web/public/next.svg +1 -0
- package/templates/base/web/public/vercel.svg +1 -0
- package/templates/base/web/public/window.svg +1 -0
- package/templates/base/web/src/app/favicon.ico +0 -0
- package/templates/base/web/src/app/globals.css +152 -0
- package/templates/base/web/src/app/layout.tsx.ejs +54 -0
- package/templates/base/web/src/app/page.tsx.ejs +92 -0
- package/templates/base/web/src/components/auth/auth-hydrator.tsx.ejs +19 -0
- package/templates/base/web/src/components/auth/protected-route.tsx.ejs +109 -0
- package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +56 -0
- package/templates/base/web/src/components/providers/theme-provider.tsx +17 -0
- package/templates/base/web/src/components/theme-toggle.tsx +34 -0
- package/templates/base/web/src/components/ui/button.tsx +62 -0
- package/templates/base/web/src/components/ui/card.tsx +92 -0
- package/templates/base/web/src/components/ui/input.tsx +21 -0
- package/templates/base/web/src/components/ui/label.tsx +24 -0
- package/templates/base/web/src/components/ui/skeleton.tsx +13 -0
- package/templates/base/web/src/components/ui/spinner.tsx +20 -0
- package/templates/base/web/src/hooks/use-device-session.ts.ejs +40 -0
- package/templates/base/web/src/hooks/use-session.ts.ejs +56 -0
- package/templates/base/web/src/lib/auth/actions.ts.ejs +334 -0
- package/templates/base/web/src/lib/auth/config.ts.ejs +65 -0
- package/templates/base/web/src/lib/auth/cookies.ts.ejs +74 -0
- package/templates/base/web/src/lib/auth/index.ts.ejs +40 -0
- package/templates/base/web/src/lib/auth/oauth.ts.ejs +72 -0
- package/templates/base/web/src/lib/auth/pkce.ts.ejs +48 -0
- package/templates/base/web/src/lib/auth/sessions.ts.ejs +135 -0
- package/templates/base/web/src/lib/auth/user-agent.ts.ejs +47 -0
- package/templates/base/web/src/lib/device/actions.ts.ejs +148 -0
- package/templates/base/web/src/lib/device/id.ts.ejs +74 -0
- package/templates/base/web/src/lib/utils.ts +6 -0
- package/templates/base/web/src/proxy.ts.ejs +66 -0
- package/templates/base/web/src/store/auth.store.ts.ejs +89 -0
- package/templates/base/web/src/store/deviceSession.store.ts.ejs +141 -0
- package/templates/base/web/tsconfig.json +34 -0
- package/templates/features/mobile/auth/app/(auth)/_layout.tsx +16 -0
- package/templates/features/mobile/auth/app/(auth)/login.tsx +86 -0
- package/templates/features/mobile/auth/app/(auth)/register.tsx +86 -0
- package/templates/features/mobile/auth/components/auth/LoginForm.tsx.ejs +349 -0
- package/templates/features/mobile/auth/components/auth/RegisterForm.tsx.ejs +407 -0
- package/templates/features/mobile/auth/components/auth/index.ts +2 -0
- package/templates/features/mobile/auth/hooks/index.ts.ejs +1 -0
- package/templates/features/mobile/auth/hooks/useAuth.ts.ejs +367 -0
- package/templates/features/mobile/auth/services/deviceSession.ts +370 -0
- package/templates/features/mobile/auth/store/deviceSession.store.ts +326 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/_layout.tsx.ejs +11 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +52 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +52 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +60 -0
- package/templates/features/mobile/paywall/app/paywall.tsx +550 -0
- package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +26 -0
- package/templates/features/mobile/tabs/app/(tabs)/index.tsx +565 -0
- package/templates/features/web/.gitkeep +0 -0
- package/templates/features/web/auth/app/(app)/dashboard/dashboard-client.tsx.ejs +166 -0
- package/templates/features/web/auth/app/(app)/dashboard/page.tsx.ejs +24 -0
- package/templates/features/web/auth/app/(app)/layout.tsx.ejs +43 -0
- package/templates/features/web/auth/app/(app)/settings/sessions/page.tsx.ejs +29 -0
- package/templates/features/web/auth/app/(app)/settings/sessions/sessions-client.tsx.ejs +77 -0
- package/templates/features/web/auth/app/(auth)/forgot-password/page.tsx.ejs +127 -0
- package/templates/features/web/auth/app/(auth)/layout.tsx.ejs +32 -0
- package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +35 -0
- package/templates/features/web/auth/app/(auth)/register/page.tsx.ejs +19 -0
- package/templates/features/web/auth/app/(auth)/reset-password/page.tsx.ejs +40 -0
- package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +198 -0
- package/templates/features/web/auth/app/auth/callback/route.ts.ejs +152 -0
- package/templates/features/web/auth/components/auth/login-form.tsx.ejs +100 -0
- package/templates/features/web/auth/components/auth/oauth-buttons.tsx.ejs +126 -0
- package/templates/features/web/auth/components/auth/password-reset-form.tsx.ejs +103 -0
- package/templates/features/web/auth/components/auth/register-form.tsx.ejs +139 -0
- package/templates/features/web/auth/components/settings/session-card.tsx.ejs +132 -0
- package/templates/integrations/mobile/adjust/services/adjustService.ts.ejs +163 -0
- package/templates/integrations/mobile/adjust/store/adjust.store.ts +243 -0
- package/templates/integrations/mobile/att/services/attService.ts +84 -0
- package/templates/integrations/mobile/att/services/trackingPermissions.ts +208 -0
- package/templates/integrations/mobile/att/store/att.store.ts +162 -0
- package/templates/integrations/mobile/revenuecat/services/revenuecatService.ts.ejs +174 -0
- package/templates/integrations/mobile/revenuecat/store/revenuecat.store.ts +286 -0
- package/templates/integrations/mobile/scate/services/scateService.ts.ejs +85 -0
- package/templates/integrations/mobile/scate/store/scate.store.ts +125 -0
- package/templates/integrations/web/.gitkeep +0 -0
- package/templates/shared/.env.example.ejs +21 -0
- package/templates/shared/.gitignore.ejs +145 -0
- package/templates/shared/README.md.ejs +134 -0
- package/templates/shared/docker-compose.prod.yml.ejs +120 -0
- package/templates/shared/docker-compose.yml.ejs +129 -0
- package/templates/shared/scripts/docker-dev.sh.ejs +395 -0
- package/templates/shared/scripts/docker-prod.sh.ejs +542 -0
- package/templates/shared/scripts/setup.sh.ejs +979 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import { oauthStore, isRedisAvailable, isUsingMemoryFallback } from '../../../utils/redis';
|
|
4
|
+
import { auth } from '../../../lib/auth';
|
|
5
|
+
import { BETTER_AUTH_COOKIE_NAME } from '../../../lib/constants';
|
|
6
|
+
import { Type } from '@sinclair/typebox';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Web OAuth routes with PKCE support
|
|
10
|
+
*
|
|
11
|
+
* These routes wrap Better Auth's OAuth flow to support the BFF pattern
|
|
12
|
+
* where Next.js manages cookies and Fastify manages sessions.
|
|
13
|
+
*
|
|
14
|
+
* FLOW:
|
|
15
|
+
* 1. /init - Store PKCE challenge, call Better Auth to get OAuth URL, redirect
|
|
16
|
+
* 2. OAuth provider authenticates user
|
|
17
|
+
* 3. Better Auth /api/auth/callback/:provider handles OAuth callback
|
|
18
|
+
* 4. /callback/:provider - Read session from cookies (set by Better Auth), create exchange token
|
|
19
|
+
* 5. /exchange - Verify PKCE, return session token
|
|
20
|
+
*/
|
|
21
|
+
const oauthWebRoutes: FastifyPluginAsync = async (server) => {
|
|
22
|
+
// Health check for OAuth subsystem
|
|
23
|
+
server.get('/health', async (request, reply) => {
|
|
24
|
+
const redisOk = isRedisAvailable();
|
|
25
|
+
return reply.status(redisOk ? 200 : 503).send({
|
|
26
|
+
status: redisOk ? 'ok' : 'degraded',
|
|
27
|
+
redis: redisOk,
|
|
28
|
+
usingMemoryFallback: isUsingMemoryFallback(),
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Step 1: Initialize OAuth flow
|
|
35
|
+
*
|
|
36
|
+
* Called by Next.js server action after setting PKCE cookies.
|
|
37
|
+
* Stores PKCE challenge in Redis and redirects to OAuth provider via Better Auth.
|
|
38
|
+
*/
|
|
39
|
+
server.get(
|
|
40
|
+
'/init',
|
|
41
|
+
{
|
|
42
|
+
schema: {
|
|
43
|
+
querystring: Type.Object({
|
|
44
|
+
provider: Type.Union([
|
|
45
|
+
Type.Literal('google'),
|
|
46
|
+
Type.Literal('apple'),
|
|
47
|
+
Type.Literal('github'),
|
|
48
|
+
]),
|
|
49
|
+
code_challenge: Type.String({ minLength: 43, maxLength: 128 }),
|
|
50
|
+
code_challenge_method: Type.Literal('S256'),
|
|
51
|
+
state: Type.String({ pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }),
|
|
52
|
+
callback_url: Type.String(),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
async (request, reply) => {
|
|
57
|
+
const { provider, code_challenge, state, callback_url } = request.query as {
|
|
58
|
+
provider: 'google' | 'apple' | 'github';
|
|
59
|
+
code_challenge: string;
|
|
60
|
+
state: string;
|
|
61
|
+
callback_url: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Check Redis availability
|
|
65
|
+
if (!isRedisAvailable()) {
|
|
66
|
+
server.log.error('Redis unavailable during OAuth init');
|
|
67
|
+
return reply.redirect(`${callback_url}?error=service_unavailable`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validate callback URL is from allowed origins
|
|
71
|
+
const allowedOrigins = (process.env.WEB_APP_URL || 'http://localhost:3000')
|
|
72
|
+
.split(',')
|
|
73
|
+
.map((o) => o.trim());
|
|
74
|
+
|
|
75
|
+
let callbackOrigin: string;
|
|
76
|
+
try {
|
|
77
|
+
callbackOrigin = new URL(callback_url).origin;
|
|
78
|
+
} catch {
|
|
79
|
+
return reply.status(400).send({ error: 'Invalid callback URL' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!allowedOrigins.some((origin) => callbackOrigin === origin)) {
|
|
83
|
+
server.log.warn(
|
|
84
|
+
`OAuth init rejected: callback origin ${callbackOrigin} not in allowed list`
|
|
85
|
+
);
|
|
86
|
+
return reply.status(400).send({ error: 'Invalid callback URL origin' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Store PKCE challenge in store (Redis or in-memory fallback in dev)
|
|
91
|
+
await oauthStore.storeOAuthState(state, {
|
|
92
|
+
code_challenge,
|
|
93
|
+
callback_url,
|
|
94
|
+
provider,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (isUsingMemoryFallback()) {
|
|
98
|
+
server.log.warn('OAuth state stored in memory - will be lost on server restart');
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
server.log.error({ err }, 'Failed to store OAuth state');
|
|
102
|
+
return reply.redirect(`${callback_url}?error=service_unavailable`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Build the callback URL for Better Auth that points to our callback handler
|
|
106
|
+
// Include web_state so we can retrieve PKCE data later
|
|
107
|
+
const backendUrl = process.env.BETTER_AUTH_URL || `${request.protocol}://${request.hostname}`;
|
|
108
|
+
const internalCallback = `${backendUrl}/api/auth/web/oauth/callback/${provider}?web_state=${state}`;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// Use Better Auth's server-side API to get the OAuth redirect URL
|
|
112
|
+
// This properly handles OAuth state, nonce, and provider-specific configuration
|
|
113
|
+
//
|
|
114
|
+
// CRITICAL: We must forward the Set-Cookie headers from Better Auth to the browser.
|
|
115
|
+
// Better Auth stores a state value in a cookie during OAuth init, and verifies it
|
|
116
|
+
// on callback. Without forwarding these cookies, we get state_mismatch errors.
|
|
117
|
+
//
|
|
118
|
+
// We use returnHeaders: true to get both the response data and headers.
|
|
119
|
+
// See: https://www.better-auth.com/docs/concepts/api
|
|
120
|
+
const { headers, response } = await auth.api.signInSocial({
|
|
121
|
+
returnHeaders: true,
|
|
122
|
+
body: {
|
|
123
|
+
provider,
|
|
124
|
+
callbackURL: internalCallback,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Forward Set-Cookie headers from Better Auth to the browser
|
|
129
|
+
// This ensures the OAuth state cookie is properly set before redirecting to Google
|
|
130
|
+
const setCookies = headers.getSetCookie?.() ?? [];
|
|
131
|
+
for (const cookie of setCookies) {
|
|
132
|
+
reply.header('Set-Cookie', cookie);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (setCookies.length > 0) {
|
|
136
|
+
server.log.debug(
|
|
137
|
+
{ cookieCount: setCookies.length },
|
|
138
|
+
'Forwarded Better Auth cookies to browser'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Extract redirect URL from response
|
|
143
|
+
// The response object contains { url, redirect } when using returnHeaders
|
|
144
|
+
const responseData = response as { url?: string; redirect?: boolean } | null;
|
|
145
|
+
const redirectUrl = responseData?.url;
|
|
146
|
+
|
|
147
|
+
if (redirectUrl) {
|
|
148
|
+
return reply.redirect(redirectUrl);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
server.log.error('Better Auth signInSocial did not return a redirect URL');
|
|
152
|
+
return reply.redirect(`${callback_url}?error=oauth_init_failed`);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
server.log.error({ err }, 'Failed to initiate OAuth with Better Auth');
|
|
155
|
+
// Clean up stored state on failure
|
|
156
|
+
await oauthStore.deleteOAuthState(state).catch(() => {});
|
|
157
|
+
return reply.redirect(`${callback_url}?error=oauth_init_failed`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Step 2: Handle OAuth callback from Better Auth
|
|
164
|
+
*
|
|
165
|
+
* IMPORTANT: This is called AFTER Better Auth has:
|
|
166
|
+
* 1. Received the callback from the OAuth provider
|
|
167
|
+
* 2. Exchanged the auth code for tokens
|
|
168
|
+
* 3. Created/updated the user in the database
|
|
169
|
+
* 4. Created a session
|
|
170
|
+
* 5. Set the session cookie
|
|
171
|
+
* 6. Redirected to this URL
|
|
172
|
+
*
|
|
173
|
+
* The session token is already in the REQUEST COOKIES at this point.
|
|
174
|
+
* We do NOT call Better Auth's handler again.
|
|
175
|
+
*/
|
|
176
|
+
server.get(
|
|
177
|
+
'/callback/:provider',
|
|
178
|
+
{
|
|
179
|
+
schema: {
|
|
180
|
+
params: Type.Object({
|
|
181
|
+
provider: Type.String(),
|
|
182
|
+
}),
|
|
183
|
+
querystring: Type.Object({
|
|
184
|
+
web_state: Type.Optional(Type.String({ pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' })),
|
|
185
|
+
// OAuth error params (returned by provider on failure)
|
|
186
|
+
error: Type.Optional(Type.String()),
|
|
187
|
+
error_description: Type.Optional(Type.String()),
|
|
188
|
+
}),
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
async (request, reply) => {
|
|
192
|
+
const { provider } = request.params as { provider: string };
|
|
193
|
+
const { web_state, error, error_description } = request.query as {
|
|
194
|
+
web_state?: string;
|
|
195
|
+
error?: string;
|
|
196
|
+
error_description?: string;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Default error redirect
|
|
200
|
+
const defaultErrorUrl = `${process.env.WEB_APP_URL || 'http://localhost:3000'}/login`;
|
|
201
|
+
|
|
202
|
+
// Handle OAuth provider errors (e.g., user denied access)
|
|
203
|
+
if (error) {
|
|
204
|
+
server.log.warn(
|
|
205
|
+
{ provider, error, error_description },
|
|
206
|
+
'OAuth provider returned an error'
|
|
207
|
+
);
|
|
208
|
+
return reply.redirect(
|
|
209
|
+
`${defaultErrorUrl}?error=oauth_${error}${error_description ? `&message=${encodeURIComponent(error_description)}` : ''}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate required web_state parameter
|
|
214
|
+
if (!web_state) {
|
|
215
|
+
server.log.warn('OAuth callback: missing web_state parameter');
|
|
216
|
+
return reply.redirect(`${defaultErrorUrl}?error=invalid_callback`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Check Redis availability
|
|
220
|
+
if (!isRedisAvailable()) {
|
|
221
|
+
server.log.error('Redis unavailable during OAuth callback');
|
|
222
|
+
return reply.redirect(`${defaultErrorUrl}?error=service_unavailable`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Retrieve PKCE data from store
|
|
226
|
+
let pkceData;
|
|
227
|
+
try {
|
|
228
|
+
pkceData = await oauthStore.getOAuthState(web_state);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
server.log.error({ err }, 'Failed to retrieve OAuth state from store');
|
|
231
|
+
return reply.redirect(`${defaultErrorUrl}?error=service_unavailable`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!pkceData) {
|
|
235
|
+
server.log.warn(`OAuth callback: invalid or expired state ${web_state}`);
|
|
236
|
+
return reply.redirect(`${defaultErrorUrl}?error=invalid_state`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// CRITICAL: Read session token from REQUEST cookies
|
|
240
|
+
// Better Auth has already set this cookie when it processed the OAuth callback
|
|
241
|
+
const sessionToken = extractSessionTokenFromCookies(request.headers.cookie);
|
|
242
|
+
|
|
243
|
+
if (!sessionToken) {
|
|
244
|
+
server.log.warn('OAuth callback: no session token in cookies');
|
|
245
|
+
await oauthStore.deleteOAuthState(web_state);
|
|
246
|
+
return reply.redirect(`${pkceData.callback_url}?error=no_session`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Generate exchange token
|
|
250
|
+
const exchangeToken = crypto.randomUUID();
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
// Store exchange token with session and challenge
|
|
254
|
+
await oauthStore.storeExchangeToken(exchangeToken, {
|
|
255
|
+
session_token: sessionToken,
|
|
256
|
+
code_challenge: pkceData.code_challenge,
|
|
257
|
+
});
|
|
258
|
+
} catch (err) {
|
|
259
|
+
server.log.error({ err }, 'Failed to store exchange token');
|
|
260
|
+
await oauthStore.deleteOAuthState(web_state);
|
|
261
|
+
return reply.redirect(`${pkceData.callback_url}?error=service_unavailable`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Clean up OAuth state
|
|
265
|
+
await oauthStore.deleteOAuthState(web_state);
|
|
266
|
+
|
|
267
|
+
// Redirect to Next.js callback with exchange token
|
|
268
|
+
const redirectUrl = new URL(pkceData.callback_url);
|
|
269
|
+
redirectUrl.searchParams.set('exchange_token', exchangeToken);
|
|
270
|
+
redirectUrl.searchParams.set('state', web_state);
|
|
271
|
+
|
|
272
|
+
server.log.info(
|
|
273
|
+
{ provider, state: web_state },
|
|
274
|
+
'OAuth callback successful, redirecting with exchange token'
|
|
275
|
+
);
|
|
276
|
+
return reply.redirect(redirectUrl.toString());
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Step 3: Exchange token for session with PKCE verification
|
|
282
|
+
*
|
|
283
|
+
* Called by Next.js server to exchange the short-lived token for a session.
|
|
284
|
+
* Verifies PKCE challenge to prevent token interception attacks.
|
|
285
|
+
*/
|
|
286
|
+
server.post(
|
|
287
|
+
'/exchange',
|
|
288
|
+
{
|
|
289
|
+
schema: {
|
|
290
|
+
body: Type.Object({
|
|
291
|
+
exchange_token: Type.String({ pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }),
|
|
292
|
+
code_verifier: Type.String({ minLength: 43, maxLength: 128 }),
|
|
293
|
+
}),
|
|
294
|
+
response: {
|
|
295
|
+
200: Type.Object({
|
|
296
|
+
session_token: Type.String(),
|
|
297
|
+
}),
|
|
298
|
+
400: Type.Object({
|
|
299
|
+
error: Type.String(),
|
|
300
|
+
}),
|
|
301
|
+
503: Type.Object({
|
|
302
|
+
error: Type.String(),
|
|
303
|
+
}),
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
async (request, reply) => {
|
|
308
|
+
const { exchange_token, code_verifier } = request.body as {
|
|
309
|
+
exchange_token: string;
|
|
310
|
+
code_verifier: string;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Check Redis availability
|
|
314
|
+
if (!isRedisAvailable()) {
|
|
315
|
+
return reply.status(503).send({ error: 'Service temporarily unavailable' });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Retrieve and consume exchange token (single use)
|
|
319
|
+
let exchangeData;
|
|
320
|
+
try {
|
|
321
|
+
exchangeData = await oauthStore.consumeExchangeToken(exchange_token);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
server.log.error({ err }, 'Failed to retrieve exchange token from store');
|
|
324
|
+
return reply.status(503).send({ error: 'Service temporarily unavailable' });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!exchangeData) {
|
|
328
|
+
server.log.warn('Token exchange: invalid or expired exchange token');
|
|
329
|
+
return reply.status(400).send({ error: 'Invalid or expired exchange token' });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Verify PKCE: SHA256(verifier) should equal stored challenge
|
|
333
|
+
const computedChallenge = crypto
|
|
334
|
+
.createHash('sha256')
|
|
335
|
+
.update(code_verifier)
|
|
336
|
+
.digest('base64url');
|
|
337
|
+
|
|
338
|
+
if (computedChallenge !== exchangeData.code_challenge) {
|
|
339
|
+
// PKCE verification failed - possible interception attempt
|
|
340
|
+
server.log.warn('Token exchange: PKCE verification failed');
|
|
341
|
+
return reply.status(400).send({ error: 'PKCE verification failed' });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// PKCE verified! Return session token
|
|
345
|
+
server.log.info('Token exchange successful');
|
|
346
|
+
return reply.send({ session_token: exchangeData.session_token });
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Extract Better Auth session token from cookie header
|
|
353
|
+
*
|
|
354
|
+
* @param cookieHeader - The Cookie header string from the request
|
|
355
|
+
* @returns The session token or null if not found
|
|
356
|
+
*/
|
|
357
|
+
function extractSessionTokenFromCookies(cookieHeader: string | undefined): string | null {
|
|
358
|
+
if (!cookieHeader) return null;
|
|
359
|
+
|
|
360
|
+
// Parse cookies (simple parser, handles most cases)
|
|
361
|
+
const cookies = cookieHeader.split(';').reduce(
|
|
362
|
+
(acc, cookie) => {
|
|
363
|
+
const [key, ...valueParts] = cookie.trim().split('=');
|
|
364
|
+
if (key) {
|
|
365
|
+
acc[key.trim()] = valueParts.join('='); // Handle values with = in them
|
|
366
|
+
}
|
|
367
|
+
return acc;
|
|
368
|
+
},
|
|
369
|
+
{} as Record<string, string>
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
return cookies[BETTER_AUTH_COOKIE_NAME] || null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default oauthWebRoutes;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fastify from "fastify";
|
|
2
|
+
|
|
3
|
+
import config from "./plugins/config";
|
|
4
|
+
import auth from "./plugins/auth";
|
|
5
|
+
import errorHandler from "./plugins/error-handler";
|
|
6
|
+
import authRoutes from "./routes/auth";
|
|
7
|
+
import deviceSessionRoutes from "./routes/device-sessions";
|
|
8
|
+
<% if (platforms.includes('web')) { %>
|
|
9
|
+
import oauthWebRoutes from "./routes/oauth-web";
|
|
10
|
+
import { initRedis } from "../../utils/redis";
|
|
11
|
+
<% } %>
|
|
12
|
+
|
|
13
|
+
const server = fastify({
|
|
14
|
+
ajv: {
|
|
15
|
+
customOptions: {
|
|
16
|
+
removeAdditional: "all",
|
|
17
|
+
coerceTypes: true,
|
|
18
|
+
useDefaults: true,
|
|
19
|
+
formats: {
|
|
20
|
+
'date-time': true, // Accept any string for date-time format
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
logger: {
|
|
25
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
26
|
+
transport: process.env.NODE_ENV === 'development' ? {
|
|
27
|
+
target: 'pino-pretty',
|
|
28
|
+
options: {
|
|
29
|
+
translateTime: 'HH:MM:ss Z',
|
|
30
|
+
ignore: 'pid,hostname',
|
|
31
|
+
},
|
|
32
|
+
} : undefined,
|
|
33
|
+
},
|
|
34
|
+
// Generate request IDs for better error tracking
|
|
35
|
+
genReqId: () => `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Register core plugins first
|
|
39
|
+
await server.register(config);
|
|
40
|
+
|
|
41
|
+
// Register error handler early to catch all errors
|
|
42
|
+
await server.register(errorHandler);
|
|
43
|
+
|
|
44
|
+
// Register CORS with credentials support for cookie-based auth
|
|
45
|
+
await server.register(import('@fastify/cors'), {
|
|
46
|
+
// In production, use explicit origins; in development, allow all
|
|
47
|
+
origin: process.env.NODE_ENV === 'production'
|
|
48
|
+
? (process.env.ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || [])
|
|
49
|
+
: true,
|
|
50
|
+
// Required for cookie-based auth - allows cookies to be sent cross-origin
|
|
51
|
+
credentials: true,
|
|
52
|
+
// Allowed headers for requests
|
|
53
|
+
allowedHeaders: ['Content-Type', 'Cookie', 'X-Device-Session-Token'],
|
|
54
|
+
// Expose Set-Cookie header so clients can receive cookies
|
|
55
|
+
exposedHeaders: ['set-cookie'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Register auth plugin
|
|
59
|
+
await server.register(auth);
|
|
60
|
+
|
|
61
|
+
<% if (platforms.includes('web')) { %>
|
|
62
|
+
// Initialize Redis for web OAuth PKCE flow
|
|
63
|
+
await initRedis();
|
|
64
|
+
|
|
65
|
+
<% } %>
|
|
66
|
+
// Register routes
|
|
67
|
+
// Auth routes at /api/auth to match BetterAuth basePath
|
|
68
|
+
await server.register(authRoutes, { prefix: "/api/auth" });
|
|
69
|
+
// Device session routes for anonymous sessions
|
|
70
|
+
await server.register(deviceSessionRoutes, { prefix: "/device-sessions" });
|
|
71
|
+
<% if (platforms.includes('web')) { %>
|
|
72
|
+
// Web OAuth routes for BFF pattern with PKCE
|
|
73
|
+
await server.register(oauthWebRoutes, { prefix: "/api/auth/web/oauth" });
|
|
74
|
+
<% } %>
|
|
75
|
+
|
|
76
|
+
// Root health check
|
|
77
|
+
server.get("/", async (request, reply) => {
|
|
78
|
+
return reply.code(200).send({
|
|
79
|
+
message: "API is running",
|
|
80
|
+
timestamp: new Date().toISOString(),
|
|
81
|
+
version: "1.0.0"
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await server.ready();
|
|
86
|
+
|
|
87
|
+
export default server;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { eq, lt } from 'drizzle-orm';
|
|
2
|
+
import { db, schema } from "../../utils/db";
|
|
3
|
+
import { ErrorFactory } from "../../utils/errors";
|
|
4
|
+
import {
|
|
5
|
+
DeviceSession,
|
|
6
|
+
CreateDeviceSessionBody,
|
|
7
|
+
CreateDeviceSessionResponse,
|
|
8
|
+
DeviceSessionMigrationEligibilityResponse,
|
|
9
|
+
} from "./schema";
|
|
10
|
+
|
|
11
|
+
const generateSessionToken = (): string => {
|
|
12
|
+
return crypto.randomUUID();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const createDeviceSession = async (data: CreateDeviceSessionBody): Promise<CreateDeviceSessionResponse> => {
|
|
16
|
+
const { deviceId } = data;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const sessionToken = generateSessionToken();
|
|
20
|
+
|
|
21
|
+
const [session] = await db
|
|
22
|
+
.insert(schema.deviceSession)
|
|
23
|
+
.values({
|
|
24
|
+
deviceId,
|
|
25
|
+
sessionToken,
|
|
26
|
+
preferredCurrency: 'USD',
|
|
27
|
+
migrated: false,
|
|
28
|
+
lastActiveAt: new Date(),
|
|
29
|
+
})
|
|
30
|
+
.returning();
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
session: {
|
|
34
|
+
...session,
|
|
35
|
+
createdAt: session.createdAt.toISOString(),
|
|
36
|
+
lastActiveAt: session.lastActiveAt.toISOString(),
|
|
37
|
+
},
|
|
38
|
+
sessionToken,
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw ErrorFactory.databaseError({
|
|
42
|
+
operation: 'createDeviceSession',
|
|
43
|
+
deviceId,
|
|
44
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const validateDeviceSession = async (sessionToken: string): Promise<DeviceSession | null> => {
|
|
50
|
+
try {
|
|
51
|
+
const [session] = await db
|
|
52
|
+
.select()
|
|
53
|
+
.from(schema.deviceSession)
|
|
54
|
+
.where(eq(schema.deviceSession.sessionToken, sessionToken))
|
|
55
|
+
.limit(1);
|
|
56
|
+
|
|
57
|
+
if (!session) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check if session is expired (30 days of inactivity)
|
|
62
|
+
const thirtyDaysAgo = new Date();
|
|
63
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
64
|
+
|
|
65
|
+
if (session.lastActiveAt < thirtyDaysAgo) {
|
|
66
|
+
await deleteDeviceSession(sessionToken);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...session,
|
|
72
|
+
createdAt: session.createdAt.toISOString(),
|
|
73
|
+
lastActiveAt: session.lastActiveAt.toISOString(),
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw ErrorFactory.databaseError({
|
|
77
|
+
operation: 'validateDeviceSession',
|
|
78
|
+
sessionToken,
|
|
79
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const updateDeviceSessionActivity = async (sessionToken: string): Promise<void> => {
|
|
85
|
+
try {
|
|
86
|
+
await db
|
|
87
|
+
.update(schema.deviceSession)
|
|
88
|
+
.set({ lastActiveAt: new Date() })
|
|
89
|
+
.where(eq(schema.deviceSession.sessionToken, sessionToken));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw ErrorFactory.databaseError({
|
|
92
|
+
operation: 'updateDeviceSessionActivity',
|
|
93
|
+
sessionToken,
|
|
94
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const deleteDeviceSession = async (sessionToken: string): Promise<void> => {
|
|
100
|
+
try {
|
|
101
|
+
await db
|
|
102
|
+
.delete(schema.deviceSession)
|
|
103
|
+
.where(eq(schema.deviceSession.sessionToken, sessionToken));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw ErrorFactory.databaseError({
|
|
106
|
+
operation: 'deleteDeviceSession',
|
|
107
|
+
sessionToken,
|
|
108
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const validateDeviceSessionMigrationEligibility = async (sessionToken: string): Promise<DeviceSessionMigrationEligibilityResponse> => {
|
|
114
|
+
try {
|
|
115
|
+
const session = await validateDeviceSession(sessionToken);
|
|
116
|
+
|
|
117
|
+
if (!session) {
|
|
118
|
+
return {
|
|
119
|
+
canMigrate: false,
|
|
120
|
+
reason: 'Device session not found or expired',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (session.migrated) {
|
|
125
|
+
return {
|
|
126
|
+
canMigrate: false,
|
|
127
|
+
reason: 'Device session already migrated to user account',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
canMigrate: true,
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw ErrorFactory.databaseError({
|
|
136
|
+
operation: 'validateDeviceSessionMigrationEligibility',
|
|
137
|
+
sessionToken,
|
|
138
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const migrateDeviceSessionToUser = async (sessionToken: string, userId: string): Promise<void> => {
|
|
144
|
+
try {
|
|
145
|
+
await db
|
|
146
|
+
.update(schema.deviceSession)
|
|
147
|
+
.set({
|
|
148
|
+
migrated: true,
|
|
149
|
+
migratedToUserId: userId,
|
|
150
|
+
})
|
|
151
|
+
.where(eq(schema.deviceSession.sessionToken, sessionToken));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw ErrorFactory.databaseError({
|
|
154
|
+
operation: 'migrateDeviceSessionToUser',
|
|
155
|
+
sessionToken,
|
|
156
|
+
userId,
|
|
157
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const cleanupExpiredDeviceSessions = async (): Promise<number> => {
|
|
163
|
+
try {
|
|
164
|
+
const thirtyDaysAgo = new Date();
|
|
165
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
166
|
+
|
|
167
|
+
const result = await db
|
|
168
|
+
.delete(schema.deviceSession)
|
|
169
|
+
.where(lt(schema.deviceSession.lastActiveAt, thirtyDaysAgo))
|
|
170
|
+
.returning({ id: schema.deviceSession.id });
|
|
171
|
+
|
|
172
|
+
return result.length;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
throw ErrorFactory.databaseError({
|
|
175
|
+
operation: 'cleanupExpiredDeviceSessions',
|
|
176
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const getDeviceSessionById = async (sessionId: string): Promise<DeviceSession | null> => {
|
|
182
|
+
try {
|
|
183
|
+
const [session] = await db
|
|
184
|
+
.select()
|
|
185
|
+
.from(schema.deviceSession)
|
|
186
|
+
.where(eq(schema.deviceSession.id, sessionId))
|
|
187
|
+
.limit(1);
|
|
188
|
+
|
|
189
|
+
if (!session) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
...session,
|
|
195
|
+
createdAt: session.createdAt.toISOString(),
|
|
196
|
+
lastActiveAt: session.lastActiveAt.toISOString(),
|
|
197
|
+
};
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw ErrorFactory.databaseError({
|
|
200
|
+
operation: 'getDeviceSessionById',
|
|
201
|
+
sessionId,
|
|
202
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const getDeviceSessionByToken = async (sessionToken: string): Promise<DeviceSession | null> => {
|
|
208
|
+
return validateDeviceSession(sessionToken);
|
|
209
|
+
};
|