initiall commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:03:31 +02:00
commit 7ddfb1f52b
276 changed files with 37629 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-alpine
WORKDIR /workspace
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/auth/package.json ./apps/auth/package.json
COPY apps/frontend/package.json ./apps/frontend/package.json
COPY packages/api-client/package.json ./packages/api-client/package.json
COPY packages/shared-types/package.json ./packages/shared-types/package.json
RUN npm ci
COPY . .
RUN npm run build --workspace @primora/auth
EXPOSE 3001
CMD ["node", "apps/auth/dist/index.js"]
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@primora/auth",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@hono/node-server": "^1.19.4",
"@hono/zod-validator": "^0.7.2",
"better-auth": "^1.5.6",
"hono": "^4.12.9",
"jose": "^6.1.0",
"nodemailer": "^7.0.6",
"pg": "^8.16.3",
"redis": "^5.8.2",
"resend": "^6.1.2",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/nodemailer": "^7.0.2",
"@types/node": "^24.5.2",
"@types/pg": "^8.15.5",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
+84
View File
@@ -0,0 +1,84 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception";
import { createClient } from "redis";
import { auth, authPool, runAuthMigrations } from "./lib/auth.js";
import { env } from "./lib/env.js";
const redisClient = createClient({
url: env.DRAGONFLY_URL,
});
try {
await redisClient.connect();
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "dragonfly unavailable", error }));
}
await retry("auth_migrations", runAuthMigrations);
const app = new Hono();
app.use("*", requestId());
app.use("*", logger());
app.use("/auth/*", async (c, next) => {
if (!redisClient.isOpen) {
await next();
return;
}
const identifier = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "unknown";
const windowKey = `rate-limit:auth:${identifier}:${new Date().toISOString().slice(0, 16)}`;
const count = await redisClient.incr(windowKey);
await redisClient.expire(windowKey, 60);
if (count > 60) {
throw new HTTPException(429, { message: "Too many auth requests" });
}
await next();
});
app.get("/health", async (c) => {
let db = "ok";
let cache = redisClient.isOpen ? "ok" : "disabled";
try {
await authPool.query("select 1");
} catch (error) {
db = error instanceof Error ? error.message : "error";
}
if (redisClient.isOpen) {
try {
await redisClient.ping();
} catch (error) {
cache = error instanceof Error ? error.message : "error";
}
}
return c.json({
status: db === "ok" ? "ok" : "degraded",
checks: { database: db, dragonfly: cache },
});
});
app.on(["GET", "POST"], "/auth/*", (c) => auth.handler(c.req.raw));
serve({
fetch: app.fetch,
port: env.AUTH_PORT,
});
async function retry(label: string, fn: () => Promise<void>) {
let lastError: unknown;
for (let attempt = 1; attempt <= 20; attempt += 1) {
try {
await fn();
return;
} catch (error) {
lastError = error;
console.warn(JSON.stringify({ level: "warn", msg: `${label}_retry`, attempt, error }));
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
throw lastError;
}
+105
View File
@@ -0,0 +1,105 @@
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { getMigrations } from "better-auth/db/migration";
import { Pool } from "pg";
import { sendTransactionalEmail } from "./mail.js";
import { env } from "./env.js";
export const authPool = new Pool({
connectionString: env.DATABASE_URL,
});
const socialProviders = {
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
? {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
}
: {}),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
? {
discord: {
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
},
}
: {}),
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
? {
microsoft: {
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
tenantId: env.MICROSOFT_TENANT_ID,
},
}
: {}),
};
export const auth = betterAuth({
appName: "Primora",
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL],
database: authPool,
emailAndPassword: {
enabled: true,
async sendResetPassword({ user, url }) {
await sendTransactionalEmail({
to: user.email,
subject: "Reset your Primora password",
text: `Reset your Primora password: ${url}`,
html: `<p>Reset your Primora password.</p><p><a href="${url}">Reset password</a></p>`,
});
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
async sendVerificationEmail({ user, url }) {
await sendTransactionalEmail({
to: user.email,
subject: "Verify your Primora email",
text: `Verify your Primora email: ${url}`,
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
});
},
},
socialProviders,
plugins: [
jwt({
jwt: {
issuer: env.JWT_ISSUER,
audience: env.JWT_AUDIENCE,
expirationTime: `${env.JWT_TTL_SECONDS} seconds`,
getSubject({ user }) {
return user.id;
},
definePayload({ user, session }) {
return {
sid: session.id,
email: user.email,
email_verified: user.emailVerified,
name: user.name,
};
},
},
}),
],
});
export async function runAuthMigrations() {
const migrations = await getMigrations(auth.options);
await migrations.runMigrations();
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.string().default("development"),
AUTH_PORT: z.string().default("3001"),
DATABASE_URL: z.string().min(1),
DRAGONFLY_URL: z.string().default("redis://localhost:6379/0"),
BETTER_AUTH_SECRET: z.string().min(16),
BETTER_AUTH_URL: z.string().url(),
AUTH_BASE_URL: z.string().url().optional(),
VITE_APP_URL: z.string().url(),
JWT_ISSUER: z.string().min(1),
JWT_AUDIENCE: z.string().min(1),
JWT_TTL_SECONDS: z.string().default("900"),
MAIL_FROM: z.string().min(1),
RESEND_API_KEY: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().default("1025"),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_CLIENT_SECRET: z.string().optional(),
MICROSOFT_CLIENT_ID: z.string().optional(),
MICROSOFT_CLIENT_SECRET: z.string().optional(),
MICROSOFT_TENANT_ID: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(
"Invalid auth environment:\n" + JSON.stringify(parsed.error.flatten().fieldErrors, null, 2),
);
}
export const env = {
...parsed.data,
AUTH_PORT: Number(parsed.data.AUTH_PORT),
JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS),
SMTP_PORT: Number(parsed.data.SMTP_PORT),
AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL,
};
+52
View File
@@ -0,0 +1,52 @@
import nodemailer from "nodemailer";
import { Resend } from "resend";
import { env } from "./env.js";
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
const transporter =
!resend && env.SMTP_HOST
? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: false,
auth: env.SMTP_USER
? {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
}
: undefined,
})
: null;
export async function sendTransactionalEmail(input: {
to: string;
subject: string;
text: string;
html: string;
}) {
if (resend) {
await resend.emails.send({
from: env.MAIL_FROM,
to: [input.to],
subject: input.subject,
text: input.text,
html: input.html,
});
return;
}
if (!transporter) {
throw new Error("No mail transport configured for auth service.");
}
await transporter.sendMail({
from: env.MAIL_FROM,
to: input.to,
subject: input.subject,
text: input.text,
html: input.html,
});
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}