Extract groombook/api from monorepo with CI workflow

- Add source code from apps/api
- Add packages/db and packages/types workspace dependencies
- Add GitHub Actions CI workflow (lint, typecheck, test, docker)
- Generate pnpm-lock.yaml
- Add .gitignore

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-11 01:26:56 +00:00
committed by Flea Flicker [agent]
parent 4d7baec939
commit abac9dfe6c
114 changed files with 31941 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db";
import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
// Auth instance — initialized lazily via initAuth()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let authInstance: any = null;
let authInitPromise: Promise<void> | null = null;
/** Returns the current auth instance. Throws if not yet initialized. */
export function getAuth() {
if (!authInstance) {
throw new Error(
"Auth not initialized. Call initAuth() at startup before handling requests."
);
}
return authInstance;
}
/** Returns a promise that resolves when auth is initialized. */
export function getAuthPromise() {
return authInitPromise;
}
/** Returns which OAuth/social providers are configured via env vars. */
export function getActiveProviders(): string[] {
const providers: string[] = [];
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
providers.push("google");
}
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
providers.push("github");
}
if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) {
providers.push("authentik");
}
return providers;
}
/**
* Re-initializes the Better-Auth instance after auth config changes.
*
* Clears both authInstance and authInitPromise, then calls initAuth() to
* re-read config from DB and build a fresh Better-Auth instance.
* Sessions are DB-backed and survive the re-init.
*/
export async function reinitAuth(): Promise<void> {
authInstance = null;
authInitPromise = null;
await initAuth();
console.log("[auth] Re-initialized auth instance after config change");
}
/**
* Initializes the Better-Auth instance.
*
* Config resolution chain:
* 1. Query auth_provider_config table for an enabled provider
* 2. If DB config exists → use it (decrypt clientSecret)
* 3. If no DB config → fall back to OIDC_* env vars
* 4. If neither → auth is unconfigured (getAuth() returns null, AUTH_DISABLED implied)
*
* Idempotent — subsequent calls return immediately after initialization completes.
*/
export async function initAuth(): Promise<void> {
if (authInstance) return; // Already initialized
if (authInitPromise) {
await authInitPromise;
return;
}
authInitPromise = (async () => {
// Guard: require BETTER_AUTH_SECRET unless explicitly in dev/demo mode
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
throw new Error(
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
);
}
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
// config so auth.handler exists (middleware bypasses it anyway)
if (process.env.AUTH_DISABLED === "true") {
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
authInstance = betterAuth({
database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET!,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
storage: "memory",
customRules: {
"/get-session": false,
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "authentik",
clientId: "placeholder",
clientSecret: "placeholder",
discoveryUrl: undefined,
scopes: ["openid", "profile", "email"],
},
],
}),
],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: { enabled: false },
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
return;
}
// Step 1: Try to load config from DB
const db = getDb();
const [dbConfig] = await db
.select()
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
let providerConfig: {
providerId: string;
clientId: string;
clientSecret: string;
issuerUrl: string;
internalBaseUrl?: string;
scopes: string;
};
if (dbConfig) {
// Step 2: Use DB config (decrypt clientSecret)
const decryptedSecret = decryptSecret(dbConfig.clientSecret);
providerConfig = {
providerId: dbConfig.providerId,
clientId: dbConfig.clientId,
clientSecret: decryptedSecret,
issuerUrl: dbConfig.issuerUrl,
internalBaseUrl: dbConfig.internalBaseUrl ?? undefined,
scopes: dbConfig.scopes,
};
console.log("[auth] Using DB config for provider:", dbConfig.providerId);
} else {
// Step 3: Fall back to env vars
const oidcIssuer = process.env.OIDC_ISSUER;
const oidcClientId = process.env.OIDC_CLIENT_ID;
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
if (!oidcIssuer || !oidcClientId || !oidcClientSecret) {
// Step 4: Neither DB config nor env vars — auth is unconfigured
console.warn(
"[auth] No auth provider configured. Set up auth_provider_config in DB or OIDC_* env vars."
);
return; // authInstance stays null — AUTH_DISABLED mode
}
providerConfig = {
providerId: "authentik",
clientId: oidcClientId,
clientSecret: oidcClientSecret,
issuerUrl: oidcIssuer,
internalBaseUrl: process.env.OIDC_INTERNAL_BASE,
scopes: "openid profile email",
};
console.log("[auth] Using env var config (no DB config found)");
}
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
const issuerUrlObj = new URL(providerConfig.issuerUrl);
const issuerHostname = issuerUrlObj.hostname;
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr);
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
};
const replaceHost = (url: string, newHost: string) => {
try {
const parsed = new URL(url);
const newParsed = new URL(newHost);
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
} catch {
return url;
}
};
const authzUrl = discovery.authorization_endpoint;
const tokenUrl = discovery.token_endpoint;
const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) {
const authzUrlObj = new URL(authzUrl);
// Only validate authorizationUrl hostname against issuer — token/userinfo
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
if (authzUrlObj.hostname !== issuerHostname) {
throw new Error(
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
);
}
oidcConfig = {
authorizationUrl: authzUrl,
tokenUrl: providerConfig.internalBaseUrl
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
: tokenUrl,
userInfoUrl: providerConfig.internalBaseUrl
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
: userInfoUrl,
};
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
} else {
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
}
} else {
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
}
} catch (err) {
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
}
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
storage: "memory",
customRules: {
"/get-session": false,
},
},
account: {
storeStateStrategy: "cookie" as const,
},
emailAndPassword: {
enabled: true,
emailVerification: {
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
await sendEmail({
to: user.email,
subject: "Verify your GroomBook email",
text: `Click the link to verify your email: ${url}`,
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
});
},
},
},
plugins: [
genericOAuth({
config: [
{
providerId: providerConfig.providerId,
clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret,
discoveryUrl: discoveryUrlStr,
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
scopes: providerConfig.scopes.split(" ").filter(Boolean),
},
],
}),
],
socialProviders: {
...(hasGoogle ? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
} : {}),
...(hasGitHub ? {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
} : {}),
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
})();
await authInitPromise;
}
+107
View File
@@ -0,0 +1,107 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
let s3Instance: S3Client | null = null;
function getS3Client(): S3Client {
if (!s3Instance) {
s3Instance = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION ?? "us-east-1",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
},
forcePathStyle: true, // required for Ceph RGW
});
}
return s3Instance;
}
function getBucket(): string {
return process.env.S3_BUCKET ?? "groombook-pet-photos";
}
/** Generate a presigned PUT URL for uploading a pet photo. Expires in 15 min. */
export async function getPresignedUploadUrl(
key: string,
contentType: string,
sizeBytes: number,
expiresIn = 900
): Promise<string> {
const client = getS3Client();
const command = new PutObjectCommand({
Bucket: getBucket(),
Key: key,
ContentType: contentType,
ContentLength: sizeBytes,
});
return getSignedUrl(client, command, { expiresIn });
}
/** Generate a presigned GET URL for viewing a pet photo. Expires in 1 hour. */
export async function getPresignedGetUrl(
key: string,
expiresIn = 3600
): Promise<string> {
const client = getS3Client();
const command = new GetObjectCommand({
Bucket: getBucket(),
Key: key,
});
return getSignedUrl(client, command, { expiresIn });
}
/** Delete a pet photo object from storage. */
export async function deleteObject(key: string): Promise<void> {
const client = getS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: getBucket(),
Key: key,
})
);
}
/** Read an object from S3 and return its body buffer and content type. */
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
const client = getS3Client();
const response = await client.send(
new GetObjectCommand({
Bucket: getBucket(),
Key: key,
})
);
const chunks: Uint8Array[] = [];
// response.Body is a Readable stream; collect chunks into a buffer
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const contentType = response.ContentType ?? "application/octet-stream";
return { body, contentType };
}
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
export async function putObject(
key: string,
body: Buffer | Uint8Array | string,
contentType: string,
contentLength: number
): Promise<void> {
const client = getS3Client();
await client.send(
new PutObjectCommand({
Bucket: getBucket(),
Key: key,
Body: body,
ContentType: contentType,
ContentLength: contentLength,
})
);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Business hours slot generation — pure utility, no DB dependencies.
* Extracted so it can be unit tested independently of the route layer.
*/
export const BUSINESS_START_HOUR = 9; // UTC
export const BUSINESS_END_HOUR = 17; // UTC
export interface BookedSlot {
staffId: string | null;
startTime: Date;
endTime: Date;
}
/**
* Generate all available appointment start times for a given date,
* returning only slots where at least one groomer is free.
*/
export function generateAvailableSlots({
dateStr,
durationMinutes,
groomerIds,
booked,
}: {
dateStr: string;
durationMinutes: number;
groomerIds: string[];
booked: BookedSlot[];
}): string[] {
const dayStart = new Date(`${dateStr}T00:00:00Z`);
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
const durationMs = durationMinutes * 60_000;
const slots: string[] = [];
let slotStart = dayStart.getTime();
while (slotStart + durationMs <= dayEnd.getTime()) {
const slotEnd = slotStart + durationMs;
const hasGroomer = groomerIds.some(
(groomerId) =>
!booked.some(
(a) =>
a.staffId === groomerId &&
a.startTime.getTime() < slotEnd &&
a.endTime.getTime() > slotStart
)
);
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
slotStart += durationMs;
}
return slots;
}