mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: instance administration, bootstrap admin, and managed-tier OAuth gate
Self-hosted instances are now email/password only — OAuth providers (GitHub/Google/Discord/Microsoft) activate solely when PRIMORA_MANAGED=true, reserved for the future hosted tier. Sign-up lifecycle: the first public sign-up becomes the instance admin; after bootstrap, sign-up closes by default. A better-auth databaseHook gates only /sign-up/email, so admin-created users and OAuth flows bypass it. The login page reads GET /instance/public to hide the sign-up tab, show the bootstrap notice, and render only the providers the instance offers. Instance settings live in core.settings (migration 00010) and resolve in-app > env > built-in default, cached 15s. Platform admins (better-auth user.role) manage sign-up, mail transport, and rate limits via GET/PUT/DELETE /instance/settings or the new Settings → Instance tab. Secret values are AES-256-GCM encrypted at rest with PRIMORA_ENCRYPTION_KEY, never returned by the API, and decrypted by the auth service at send time. Auth service reads the same settings table for the sign-up gate and mail config, so in-app changes take effect without a restart. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1214108d6b
commit
c6166de886
36 files changed
+1729
-109
No files matched your search
@@ -5,7 +5,8 @@ import { requestId } from "hono/request-id";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { createClient } from "redis";
|
||||
|
||||
import { auth, authPool, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
||||
import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
||||
import { authPool } from "./lib/db.js";
|
||||
import { env } from "./lib/env.js";
|
||||
|
||||
const redisClient = createClient({
|
||||
|
||||
+67
-40
@@ -1,50 +1,53 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { admin, jwt } from "better-auth/plugins";
|
||||
import { getMigrations } from "better-auth/db/migration";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { authPool } from "./db.js";
|
||||
import { sendTransactionalEmail } from "./mail.js";
|
||||
import { getBoolSetting } from "./settings.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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// OAuth providers exist only on the managed tier (PRIMORA_MANAGED=true).
|
||||
// Self-hosted instances are email/password only — the env keys are ignored
|
||||
// unless the managed flag is set.
|
||||
const socialProviders = env.PRIMORA_MANAGED
|
||||
? {
|
||||
...(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",
|
||||
@@ -63,6 +66,30 @@ export const auth = betterAuth({
|
||||
});
|
||||
},
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
// Gates only the public sign-up endpoint. Users created through the
|
||||
// admin plugin or OAuth callbacks take different paths and pass.
|
||||
async before(user, ctx) {
|
||||
if (ctx?.path !== "/sign-up/email") return;
|
||||
const { rows } = await authPool.query<{ n: number }>(
|
||||
`select count(*)::int as n from "user"`,
|
||||
);
|
||||
if (rows[0].n === 0) {
|
||||
// Bootstrap: the first public signup becomes the instance admin.
|
||||
return { data: { ...user, role: "admin" } };
|
||||
}
|
||||
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
|
||||
if (!enabled) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Sign-up is disabled on this instance.",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { env } from "./env.js";
|
||||
|
||||
// Shared by better-auth and the instance-settings reader. Lives in its own
|
||||
// module so settings.ts can be imported by auth.ts without a cycle.
|
||||
export const authPool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
});
|
||||
@@ -18,6 +18,15 @@ const envSchema = z.object({
|
||||
SMTP_PORT: z.string().default("1025"),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
// Managed tier flag — OAuth providers only activate when this is "true".
|
||||
// Self-hosted instances are email/password only.
|
||||
PRIMORA_MANAGED: z.string().optional(),
|
||||
// Bootstrap override for public sign-up. Without it (or an in-app setting),
|
||||
// sign-up is closed once the first user exists.
|
||||
SIGNUP_ENABLED: z.string().optional(),
|
||||
// Decrypts secret instance settings written by the backend (mail keys etc.).
|
||||
PRIMORA_ENCRYPTION_KEY: z.string().optional(),
|
||||
SMTP_SECURE: z.string().optional(),
|
||||
GITHUB_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
@@ -44,5 +53,8 @@ export const env = {
|
||||
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,
|
||||
PRIMORA_MANAGED: parsed.data.PRIMORA_MANAGED === "true",
|
||||
SIGNUP_ENABLED: parsed.data.SIGNUP_ENABLED === "true",
|
||||
SMTP_SECURE: parsed.data.SMTP_SECURE === "true",
|
||||
};
|
||||
|
||||
+34
-22
@@ -2,23 +2,33 @@ import nodemailer from "nodemailer";
|
||||
import { Resend } from "resend";
|
||||
|
||||
import { env } from "./env.js";
|
||||
import { getStringSetting } from "./settings.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;
|
||||
// Mail config resolves per send: in-app setting → env var. This keeps
|
||||
// SMTP/Resend editable from the dashboard without restarting the service.
|
||||
async function resolveMailConfig() {
|
||||
const from = (await getStringSetting("mail.from", env.MAIL_FROM))!;
|
||||
const resendApiKey = await getStringSetting("mail.resend_api_key", env.RESEND_API_KEY);
|
||||
if (resendApiKey) {
|
||||
return { from, resendApiKey } as const;
|
||||
}
|
||||
const host = await getStringSetting("mail.smtp_host", env.SMTP_HOST);
|
||||
const portRaw = await getStringSetting("mail.smtp_port", String(env.SMTP_PORT));
|
||||
const user = await getStringSetting("mail.smtp_user", env.SMTP_USER);
|
||||
const pass = await getStringSetting("mail.smtp_password", env.SMTP_PASSWORD);
|
||||
const secureRaw = await getStringSetting("mail.smtp_secure", String(env.SMTP_SECURE));
|
||||
return {
|
||||
from,
|
||||
smtp: host
|
||||
? {
|
||||
host,
|
||||
port: Number(portRaw) || 1025,
|
||||
secure: secureRaw === "true",
|
||||
auth: user ? { user, pass } : undefined,
|
||||
}
|
||||
: undefined,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function sendTransactionalEmail(input: {
|
||||
to: string;
|
||||
@@ -26,9 +36,12 @@ export async function sendTransactionalEmail(input: {
|
||||
text: string;
|
||||
html: string;
|
||||
}) {
|
||||
if (resend) {
|
||||
const cfg = await resolveMailConfig();
|
||||
|
||||
if ("resendApiKey" in cfg && cfg.resendApiKey) {
|
||||
const resend = new Resend(cfg.resendApiKey);
|
||||
await resend.emails.send({
|
||||
from: env.MAIL_FROM,
|
||||
from: cfg.from,
|
||||
to: [input.to],
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
@@ -37,16 +50,15 @@ export async function sendTransactionalEmail(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transporter) {
|
||||
if (!("smtp" in cfg) || !cfg.smtp) {
|
||||
throw new Error("No mail transport configured for auth service.");
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: env.MAIL_FROM,
|
||||
await nodemailer.createTransport(cfg.smtp).sendMail({
|
||||
from: cfg.from,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { authPool } from "./db.js";
|
||||
import { env } from "./env.js";
|
||||
|
||||
// Instance settings live in core.settings, written by the backend. A row
|
||||
// overrides the matching env var; no row means env (or the caller's default).
|
||||
// Secret values are AES-256-GCM ciphertext (base64, nonce || ct || tag)
|
||||
// encrypted by the backend with PRIMORA_ENCRYPTION_KEY.
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
const cache = new Map<string, { value: unknown; at: number }>();
|
||||
|
||||
export async function getSetting(key: string): Promise<unknown> {
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
|
||||
const { rows } = await authPool.query(
|
||||
`select value, secret from core.settings where key = $1`,
|
||||
[key],
|
||||
);
|
||||
let value: unknown;
|
||||
if (rows[0]) {
|
||||
const raw = rows[0].value as unknown; // JSONB arrives parsed — a string
|
||||
value = rows[0].secret ? decryptSetting(String(raw)) : raw;
|
||||
}
|
||||
cache.set(key, { value, at: Date.now() });
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function getBoolSetting(key: string, fallback: boolean): Promise<boolean> {
|
||||
const v = await getSetting(key);
|
||||
if (v === undefined) return fallback;
|
||||
return v === true || v === "true";
|
||||
}
|
||||
|
||||
export async function getStringSetting(key: string, fallback: string | undefined): Promise<string | undefined> {
|
||||
const v = await getSetting(key);
|
||||
if (v === undefined || v === "") return fallback;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function decryptSetting(ciphertextB64: string): string {
|
||||
const key = decodeEncryptionKey(env.PRIMORA_ENCRYPTION_KEY);
|
||||
const buf = Buffer.from(ciphertextB64, "base64");
|
||||
const nonce = buf.subarray(0, 12);
|
||||
const body = buf.subarray(12);
|
||||
const tag = body.subarray(body.length - 16);
|
||||
const ciphertext = body.subarray(0, body.length - 16);
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
// Same key formats as the Go Encryptor: 32 bytes as hex or base64.
|
||||
function decodeEncryptionKey(raw: string | undefined): Buffer {
|
||||
if (!raw) throw new Error("PRIMORA_ENCRYPTION_KEY is required to read secret settings");
|
||||
const trimmed = raw.trim();
|
||||
for (const decoded of [
|
||||
Buffer.from(trimmed, "hex"),
|
||||
Buffer.from(trimmed, "base64"),
|
||||
Buffer.from(trimmed, "base64url"),
|
||||
]) {
|
||||
if (decoded.length === 32) return decoded;
|
||||
}
|
||||
throw new Error("PRIMORA_ENCRYPTION_KEY must decode to 32 bytes (hex or base64)");
|
||||
}
|
||||
Reference in new issue
Block a user