forked from Mike/freno-dev
feat: migrate Nessa auth to Clerk session tokens (task 03)
- src/server/nessa-auth.ts: replace jose HS256 sign/verify with Clerk session JWT verification via @clerk/backend verifyToken (RS256/JWKS). signNessaToken removed — frontend now supplies Clerk session tokens. - src/server/api/utils.ts: createTRPCContext verifies Clerk JWT, resolves ctx.nessaUserId via SELECT id FROM users WHERE clerkUserId=? on the shared NessaConnectionFactory. Lookup miss throws typed UNAUTHORIZED (webhook has not run yet). Invalid/expired tokens are swallowed; the enforceNessaUser middleware rejects null nessaUserId. - src/server/api/routers/nessa-community-authz.test.ts: add clerkUserId lookup tests (seeded match, missing row, mismatched id, local≠clerk). - src/server/nessa-auth.test.ts: verifyNessaToken unit tests with mocked @clerk/backend (valid sub, missing sub, malformed/expired/wrong-signature rejection) plus static audit that signNessaToken is gone. - src/server/clerk-user-webhook.ts + src/routes/api/clerk-webhook.ts: Clerk user.created/user.updated webhook handler (Svix signature verification, idempotent upsert by clerkUserId, lazy ALTER TABLE migration) with full test suite. - src/server/api/routers/nessa.ts: remove legacy register/login/google/ apple sign-in mutations (Clerk is now the sole identity provider). - src/env/server.ts: add NESSA_CLERK_SECRET, NESSA_CLERK_JWT_ISSUER, NESSA_CLERK_WEBHOOK_SECRET; NESSA_JWT_SECRET moved to optional. - package.json: add @clerk/backend, svix; lineage/auth.test.ts and nessa-ownership.test.ts: add Clerk env vars to env mocks. - .env.example: document Clerk config vars and rotation. - delete nessa-google-oauth.test.ts (Google auth removed). ctx.nessaUserId remains the local users.id — router bodies are untouched.
This commit is contained in:
@@ -1,13 +1,8 @@
|
||||
import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
|
||||
import { createTRPCRouter, nessaProcedure } from "../utils";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { jwtVerify, importJWK } from "jose";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import { env } from "~/env/server";
|
||||
import { NessaConnectionFactory } from "~/server/database";
|
||||
import { cache } from "~/server/cache";
|
||||
import { hashPassword, checkPasswordSafe } from "~/server/utils";
|
||||
import { signNessaToken } from "~/server/nessa-auth";
|
||||
import type { Client } from "@libsql/client/web";
|
||||
|
||||
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -244,45 +239,6 @@ const bulkSchema = z.object({
|
||||
authProviders: z.array(providerSchema).optional()
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1)
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
const googleSignInSchema = z.object({
|
||||
idToken: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional()
|
||||
});
|
||||
|
||||
const appleSignInSchema = z.object({
|
||||
idToken: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
appleUserId: z.string().min(1)
|
||||
});
|
||||
|
||||
interface AppleTokenPayload {
|
||||
iss: string;
|
||||
aud: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: boolean | string;
|
||||
is_private_email?: boolean | string;
|
||||
real_user_status?: number;
|
||||
}
|
||||
|
||||
export const nessaDbRouter = createTRPCRouter({
|
||||
health: nessaProcedure.query(async () => {
|
||||
try {
|
||||
@@ -298,603 +254,6 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
register: publicProcedure
|
||||
.input(registerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const existing = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE email = ?",
|
||||
args: [input.email]
|
||||
});
|
||||
|
||||
if (existing.rows.length) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email already registered"
|
||||
});
|
||||
}
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, provider, status, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
userId,
|
||||
input.email,
|
||||
0,
|
||||
input.firstName,
|
||||
input.lastName,
|
||||
`${input.firstName} ${input.lastName}`.trim(),
|
||||
"email",
|
||||
"active"
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"email",
|
||||
null,
|
||||
input.email,
|
||||
null,
|
||||
null
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"password",
|
||||
passwordHash,
|
||||
input.email,
|
||||
null,
|
||||
null
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"Getting Started",
|
||||
"strength",
|
||||
"beginner",
|
||||
"strength",
|
||||
0
|
||||
]
|
||||
});
|
||||
|
||||
const token = await signNessaToken(userId);
|
||||
return { success: true, token, userId };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to register Nessa user:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to register user"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
login: publicProcedure.input(loginSchema).mutation(async ({ input }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT userId, email, provider, providerUserId FROM authProviders WHERE email = ? AND provider IN ('email', 'password')",
|
||||
args: [input.email]
|
||||
});
|
||||
|
||||
if (!result.rows.length) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
const rows = result.rows as Array<{
|
||||
userId: string;
|
||||
email: string | null;
|
||||
provider: string;
|
||||
providerUserId: string | null;
|
||||
}>;
|
||||
const emailProvider = rows.find((row) => row.provider === "email");
|
||||
const passwordProvider = rows.find((row) => row.provider === "password");
|
||||
|
||||
if (emailProvider?.userId !== passwordProvider?.userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
if (!emailProvider || !passwordProvider) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
const matches = await checkPasswordSafe(
|
||||
input.password,
|
||||
passwordProvider.providerUserId
|
||||
);
|
||||
if (!matches) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
const token = await signNessaToken(emailProvider.userId);
|
||||
await conn.execute({
|
||||
sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||
args: [emailProvider.userId]
|
||||
});
|
||||
|
||||
return { success: true, token, userId: emailProvider.userId };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to login Nessa user:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to login"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
googleSignIn: publicProcedure
|
||||
.input(googleSignInSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const client = new OAuth2Client(env.GOOGLE_CLIENT_ID);
|
||||
let ticket;
|
||||
try {
|
||||
// verifyIdToken fetches Google's JWKS and verifies the signature
|
||||
// locally — the token is sent in the POST body, never in a URL query
|
||||
// string (unlike the deprecated HTTP lookup endpoint). audience ===
|
||||
// env.GOOGLE_CLIENT_ID enforces the `aud` claim so a token minted for
|
||||
// a different OAuth client (or a tampered/expired token) is rejected.
|
||||
ticket = await client.verifyIdToken({
|
||||
idToken: input.idToken,
|
||||
audience: env.GOOGLE_CLIENT_ID
|
||||
});
|
||||
} catch (verifyErr) {
|
||||
// Signature failure, wrong audience, expired token, malformed JWT —
|
||||
// all surface as a thrown Error from verifyIdToken. Map every
|
||||
// verification failure to UNAUTHORIZED so the caller cannot tell
|
||||
// signature vs audience vs expiry apart (avoid leaking which check
|
||||
// failed).
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token"
|
||||
});
|
||||
}
|
||||
const tokenPayload = ticket.getPayload();
|
||||
|
||||
if (!tokenPayload) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the issuer (verifyIdToken already checks this, but we
|
||||
// assert explicitly for defense-in-depth).
|
||||
if (
|
||||
tokenPayload.iss !== "accounts.google.com" &&
|
||||
tokenPayload.iss !== "https://accounts.google.com"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token issuer"
|
||||
});
|
||||
}
|
||||
|
||||
// Email must be verified for email-based account linking.
|
||||
// google-auth-library's verified TokenPayload types email_verified
|
||||
// as a boolean (true when verified).
|
||||
const emailVerified = tokenPayload.email_verified === true;
|
||||
if (tokenPayload.email && !emailVerified) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Google email is not verified"
|
||||
});
|
||||
}
|
||||
|
||||
const googleUserId = tokenPayload.sub;
|
||||
const email = tokenPayload.email ?? input.email;
|
||||
const firstName =
|
||||
tokenPayload.given_name ?? input.firstName ?? "Google";
|
||||
const lastName = tokenPayload.family_name ?? input.lastName ?? "User";
|
||||
const displayName =
|
||||
tokenPayload.name ?? `${firstName} ${lastName}`.trim();
|
||||
const avatarUrl = tokenPayload.picture ?? null;
|
||||
|
||||
const conn = NessaConnectionFactory();
|
||||
|
||||
// Check if user exists by Google provider ID
|
||||
const existingByGoogle = await conn.execute({
|
||||
sql: "SELECT userId FROM authProviders WHERE provider = 'google' AND providerUserId = ?",
|
||||
args: [googleUserId]
|
||||
});
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (existingByGoogle.rows.length > 0) {
|
||||
// User exists with Google account - log them in
|
||||
userId = existingByGoogle.rows[0].userId as string;
|
||||
await conn.execute({
|
||||
sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
} else if (email) {
|
||||
// Check if user exists by email
|
||||
const existingByEmail = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE email = ?",
|
||||
args: [email]
|
||||
});
|
||||
|
||||
if (existingByEmail.rows.length > 0) {
|
||||
// User exists with email - link Google account
|
||||
userId = existingByEmail.rows[0].id as string;
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"google",
|
||||
googleUserId,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "UPDATE users SET provider = 'google', lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
} else {
|
||||
// Create new user with Google account
|
||||
userId = crypto.randomUUID();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
userId,
|
||||
email,
|
||||
tokenPayload.email_verified ? 1 : 0,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
"google",
|
||||
"active"
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"google",
|
||||
googleUserId,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl
|
||||
]
|
||||
});
|
||||
// Create default workout plan for new user
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"Getting Started",
|
||||
"strength",
|
||||
"beginner",
|
||||
"strength",
|
||||
0
|
||||
]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No email available - create user without email
|
||||
userId = crypto.randomUUID();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
userId,
|
||||
null,
|
||||
0,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
"google",
|
||||
"active"
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"google",
|
||||
googleUserId,
|
||||
null,
|
||||
displayName,
|
||||
avatarUrl
|
||||
]
|
||||
});
|
||||
// Create default workout plan for new user
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"Getting Started",
|
||||
"strength",
|
||||
"beginner",
|
||||
"strength",
|
||||
0
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const token = await signNessaToken(userId);
|
||||
return { success: true, token, userId };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to sign in with Google:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to sign in with Google"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
appleSignIn: publicProcedure
|
||||
.input(appleSignInSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// Verify the Apple ID token
|
||||
// Apple's public keys for JWT verification
|
||||
const appleKeysResponse = await fetch(
|
||||
"https://appleid.apple.com/auth/keys"
|
||||
);
|
||||
if (!appleKeysResponse.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch Apple public keys"
|
||||
});
|
||||
}
|
||||
|
||||
const appleKeys = (await appleKeysResponse.json()) as {
|
||||
keys: Array<{
|
||||
kty: string;
|
||||
kid: string;
|
||||
use: string;
|
||||
alg: string;
|
||||
n: string;
|
||||
e: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Decode the JWT header to get the key ID
|
||||
const [headerB64] = input.idToken.split(".");
|
||||
if (!headerB64) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Apple ID token format"
|
||||
});
|
||||
}
|
||||
|
||||
const headerJson = Buffer.from(headerB64, "base64url").toString("utf8");
|
||||
const header = JSON.parse(headerJson) as { kid: string; alg: string };
|
||||
|
||||
// Find the matching key
|
||||
const jwk = appleKeys.keys.find((k) => k.kid === header.kid);
|
||||
if (!jwk) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Apple public key not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Import the Apple JWK key for signature verification
|
||||
const publicKey = await importJWK(jwk, "RS256");
|
||||
|
||||
// Verify the Apple ID token signature and claims using jose
|
||||
const jwtOptions: Parameters<typeof jwtVerify>[2] = {
|
||||
algorithms: ["RS256"],
|
||||
issuer: "https://appleid.apple.com"
|
||||
};
|
||||
if (env.APPLE_CLIENT_ID_NESSA) {
|
||||
jwtOptions.audience = env.APPLE_CLIENT_ID_NESSA;
|
||||
}
|
||||
const { payload: tokenPayload } = await jwtVerify(
|
||||
input.idToken,
|
||||
publicKey,
|
||||
jwtOptions
|
||||
);
|
||||
|
||||
// Apple user ID from token should match the one provided
|
||||
if (tokenPayload.sub !== input.appleUserId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Apple user ID mismatch"
|
||||
});
|
||||
}
|
||||
|
||||
const appleUserId = tokenPayload.sub as string;
|
||||
// Apple only sends email on first sign-in, so use input.email if token doesn't have it
|
||||
const email = tokenPayload.email ?? input.email;
|
||||
const firstName = input.firstName ?? "Apple";
|
||||
const lastName = input.lastName ?? "User";
|
||||
const displayName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
const conn = NessaConnectionFactory();
|
||||
|
||||
// Check if user exists by Apple provider ID
|
||||
const existingByApple = await conn.execute({
|
||||
sql: "SELECT userId FROM authProviders WHERE provider = 'apple' AND providerUserId = ?",
|
||||
args: [appleUserId]
|
||||
});
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (existingByApple.rows.length > 0) {
|
||||
// User exists with Apple account - log them in
|
||||
userId = existingByApple.rows[0].userId as string;
|
||||
await conn.execute({
|
||||
sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
} else if (email) {
|
||||
// Check if user exists by email
|
||||
const existingByEmail = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE email = ?",
|
||||
args: [email]
|
||||
});
|
||||
|
||||
if (existingByEmail.rows.length > 0) {
|
||||
// User exists with email - link Apple account
|
||||
userId = existingByEmail.rows[0].id as string;
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"apple",
|
||||
appleUserId,
|
||||
email,
|
||||
displayName,
|
||||
null
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "UPDATE users SET provider = 'apple', appleUserId = ?, lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||
args: [appleUserId, userId]
|
||||
});
|
||||
} else {
|
||||
// Create new user with Apple account
|
||||
userId = crypto.randomUUID();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
userId,
|
||||
email,
|
||||
tokenPayload.email_verified === true ||
|
||||
tokenPayload.email_verified === "true"
|
||||
? 1
|
||||
: 0,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
null,
|
||||
"apple",
|
||||
appleUserId,
|
||||
"active"
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"apple",
|
||||
appleUserId,
|
||||
email,
|
||||
displayName,
|
||||
null
|
||||
]
|
||||
});
|
||||
// Create default workout plan for new user
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"Getting Started",
|
||||
"strength",
|
||||
"beginner",
|
||||
"strength",
|
||||
0
|
||||
]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No email available - create user without email
|
||||
userId = crypto.randomUUID();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
userId,
|
||||
null,
|
||||
0,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
null,
|
||||
"apple",
|
||||
appleUserId,
|
||||
"active"
|
||||
]
|
||||
});
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"apple",
|
||||
appleUserId,
|
||||
null,
|
||||
displayName,
|
||||
null
|
||||
]
|
||||
});
|
||||
// Create default workout plan for new user
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
"Getting Started",
|
||||
"strength",
|
||||
"beginner",
|
||||
"strength",
|
||||
0
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const token = await signNessaToken(userId);
|
||||
return { success: true, token, userId };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to sign in with Apple:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to sign in with Apple"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getUsers: nessaProcedure
|
||||
.input(paginatedQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
Reference in New Issue
Block a user