123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
import { createClerkClient, verifyToken } from "@clerk/backend";
|
|
import { createMiddleware } from "hono/factory";
|
|
import { HTTPException } from "hono/http-exception";
|
|
import { config } from "../config.js";
|
|
import { db } from "../db/client.js";
|
|
import { users } from "../db/schema.js";
|
|
import { eq } from "drizzle-orm";
|
|
import { log } from "../log.js";
|
|
|
|
export type AuthContext = {
|
|
Variables: {
|
|
userId: string;
|
|
userEmail: string;
|
|
};
|
|
};
|
|
|
|
export const clerk = config.clerkSecretKey
|
|
? createClerkClient({ secretKey: config.clerkSecretKey })
|
|
: null;
|
|
|
|
// Verifies a Clerk session JWT from the Authorization header.
|
|
// Falls back to allowing the configured SERVICE_TOKEN for actor → backend calls.
|
|
//
|
|
// Bootstraps a row in `users` on first sight so downstream code can FK to it.
|
|
export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
|
|
const auth = c.req.header("authorization") ?? "";
|
|
const token = auth.replace(/^Bearer\s+/i, "").trim();
|
|
|
|
// Service-to-service path (Grow Agent actor calling backend).
|
|
// Header `x-growqr-user` is REQUIRED so we can scope the call.
|
|
const trustedServiceTokens = new Set(
|
|
[
|
|
config.serviceToken,
|
|
config.nodeEnv !== "production" ? config.a2aAllowedKey : "",
|
|
].filter(Boolean),
|
|
);
|
|
|
|
if (
|
|
token &&
|
|
trustedServiceTokens.has(token) &&
|
|
c.req.header("x-growqr-user")
|
|
) {
|
|
const userId = c.req.header("x-growqr-user")!;
|
|
let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
if (!row) {
|
|
const inserted = await db
|
|
.insert(users)
|
|
.values({
|
|
id: userId,
|
|
email: `${userId}@service.local`,
|
|
displayName: userId,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
row = inserted[0];
|
|
}
|
|
if (!row) {
|
|
throw new HTTPException(401, { message: "service token references unknown user" });
|
|
}
|
|
c.set("userId", row.id);
|
|
c.set("userEmail", row.email);
|
|
return next();
|
|
}
|
|
|
|
if (!token) {
|
|
throw new HTTPException(401, { message: "missing bearer token" });
|
|
}
|
|
|
|
if (!clerk) {
|
|
throw new HTTPException(500, { message: "Clerk not configured" });
|
|
}
|
|
|
|
let payload: Awaited<ReturnType<typeof verifyToken>>;
|
|
try {
|
|
payload = await verifyToken(token, {
|
|
secretKey: config.clerkSecretKey,
|
|
});
|
|
} catch {
|
|
throw new HTTPException(401, { message: "invalid clerk token" });
|
|
}
|
|
|
|
const userId = payload.sub;
|
|
if (!userId) {
|
|
throw new HTTPException(401, { message: "clerk token missing subject" });
|
|
}
|
|
|
|
// Lazy-mirror Clerk user → users table.
|
|
let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
if (!row) {
|
|
let email = `${userId}@unknown.local`;
|
|
let displayName: string | null = null;
|
|
try {
|
|
const clerkUser = await clerk.users.getUser(userId);
|
|
email =
|
|
clerkUser.primaryEmailAddress?.emailAddress ??
|
|
clerkUser.emailAddresses[0]?.emailAddress ??
|
|
email;
|
|
displayName =
|
|
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") ||
|
|
clerkUser.username ||
|
|
null;
|
|
} catch (err) {
|
|
log.warn({ err, userId }, "failed to hydrate Clerk user details; creating minimal backend mirror row");
|
|
}
|
|
const inserted = await db
|
|
.insert(users)
|
|
.values({ id: userId, email, displayName })
|
|
.onConflictDoUpdate({
|
|
target: users.id,
|
|
set: { email, displayName, updatedAt: new Date() },
|
|
})
|
|
.returning();
|
|
row = inserted[0];
|
|
if (!row) {
|
|
throw new HTTPException(500, { message: "failed to upsert user" });
|
|
}
|
|
}
|
|
|
|
c.set("userId", row.id);
|
|
c.set("userEmail", row.email);
|
|
return next();
|
|
});
|