Added auth, refactored schema, almost have TRPC MVP working

This commit is contained in:
Ryan Pandya 2024-11-13 16:23:53 -08:00
parent ef1d1f04e3
commit c023fa1730
28 changed files with 2252 additions and 45 deletions

View File

@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
export const GET = async (_req: NextRequest) => {
return NextResponse.json({
status: "ok",
message: "Web app is working",
});
};

View File

@ -0,0 +1,22 @@
import { createContextFromRequest } from "@/server/api/client";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@lifetracker/trpc/routers/_app";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
onError: ({ path, error }) => {
if (process.env.NODE_ENV === "development") {
console.error(`❌ tRPC failed on ${path}`);
}
console.error(error);
},
createContext: async (opts) => {
return await createContextFromRequest(opts.req);
},
});
export { handler as GET, handler as POST };

View File

@ -0,0 +1,60 @@
import { headers } from "next/headers";
import { getServerAuthSession } from "@/server/auth";
import requestIp from "request-ip";
import { db } from "@lifetracker/db";
import { Context, createCallerFactory } from "@lifetracker/trpc";
import { authenticateApiKey } from "@lifetracker/trpc/auth";
import { appRouter } from "@lifetracker/trpc/routers/_app";
export async function createContextFromRequest(req: Request) {
// TODO: This is a hack until we offer a proper REST API instead of the trpc based one.
// Check if the request has an Authorization token, if it does, assume that API key authentication is requested.
const ip = requestIp.getClientIp({
headers: Object.fromEntries(req.headers.entries()),
});
const authorizationHeader = req.headers.get("Authorization");
if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
const token = authorizationHeader.split(" ")[1];
try {
const user = await authenticateApiKey(token);
return {
user,
db,
req: {
ip,
},
};
} catch (e) {
// Fallthrough to cookie-based auth
}
}
return createContext(db, ip);
}
export const createContext = async (
database?: typeof db,
ip?: string | null,
): Promise<Context> => {
const session = await getServerAuthSession();
if (ip === undefined) {
const hdrs = headers();
ip = requestIp.getClientIp({
headers: Object.fromEntries(hdrs.entries()),
});
}
return {
user: session?.user ?? null,
db: database ?? db,
req: {
ip,
},
};
};
const createCaller = createCallerFactory(appRouter);
export const api = createCaller(createContext);
export const createTrcpClientFromCtx = createCaller;

View File

@ -3,17 +3,29 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev",
"clean": "git clean -xdf .next .turbo node_modules",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"test": "vitest",
"typecheck": "tsc --noEmit",
"format": "prettier --check . --ignore-path ../../.gitignore"
}, },
"dependencies": { "dependencies": {
"@auth/drizzle-adapter": "^1.7.3",
"@lifetracker/db": "workspace:^", "@lifetracker/db": "workspace:^",
"@lifetracker/shared": "workspace:^",
"@lifetracker/trpc": "workspace:^",
"@lifetracker/ui": "workspace:*", "@lifetracker/ui": "workspace:*",
"@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2",
"drizzle-orm": "^0.33.0",
"next": "14.2.6", "next": "14.2.6",
"next-auth": "^4.24.10",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1",
"request-ip": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@lifetracker/eslint-config": "workspace:*", "@lifetracker/eslint-config": "workspace:*",
@ -21,8 +33,10 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/request-ip": "^0.0.41",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.6", "eslint-config-next": "14.2.6",
"typescript": "^5" "typescript": "^5",
"vite-tsconfig-paths": "^4.3.1"
} }
} }

View File

@ -0,0 +1,60 @@
import { headers } from "next/headers";
import { getServerAuthSession } from "@/server/auth";
import requestIp from "request-ip";
import { db } from "@lifetracker/db";
import { Context, createCallerFactory } from "@lifetracker/trpc";
import { authenticateApiKey } from "@lifetracker/trpc/auth";
import { appRouter } from "@lifetracker/trpc/routers/_app";
export async function createContextFromRequest(req: Request) {
// TODO: This is a hack until we offer a proper REST API instead of the trpc based one.
// Check if the request has an Authorization token, if it does, assume that API key authentication is requested.
const ip = requestIp.getClientIp({
headers: Object.fromEntries(req.headers.entries()),
});
const authorizationHeader = req.headers.get("Authorization");
if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
const token = authorizationHeader.split(" ")[1];
try {
const user = await authenticateApiKey(token);
return {
user,
db,
req: {
ip,
},
};
} catch (e) {
// Fallthrough to cookie-based auth
}
}
return createContext(db, ip);
}
export const createContext = async (
database?: typeof db,
ip?: string | null,
): Promise<Context> => {
const session = await getServerAuthSession();
if (ip === undefined) {
const hdrs = headers();
ip = requestIp.getClientIp({
headers: Object.fromEntries(hdrs.entries()),
});
}
return {
user: session?.user ?? null,
db: database ?? db,
req: {
ip,
},
};
};
const createCaller = createCallerFactory(appRouter);
export const api = createCaller(createContext);
export const createTrcpClientFromCtx = createCaller;

191
apps/web/server/auth.ts Normal file
View File

@ -0,0 +1,191 @@
import type { Adapter } from "next-auth/adapters";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { and, count, eq } from "drizzle-orm";
import NextAuth, {
DefaultSession,
getServerSession,
NextAuthOptions,
} from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { Provider } from "next-auth/providers/index";
import requestIp from "request-ip";
import { db } from "@lifetracker/db";
import {
accounts,
sessions,
users,
verificationTokens,
} from "@lifetracker/db/schema";
import serverConfig from "@lifetracker/shared/config";
import { logAuthenticationError, validatePassword } from "@lifetracker/trpc/auth";
type UserRole = "admin" | "user";
declare module "next-auth/jwt" {
export interface JWT {
user: {
id: string;
role: UserRole;
} & DefaultSession["user"];
}
}
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
export interface Session {
user: {
id: string;
role: UserRole;
} & DefaultSession["user"];
}
export interface DefaultUser {
role: UserRole | null;
}
}
/**
* Returns true if the user table is empty, which indicates that this user is going to be
* the first one. This can be racy if multiple users are created at the same time, but
* that should be fine.
*/
async function isFirstUser(): Promise<boolean> {
const [{ count: userCount }] = await db
.select({ count: count() })
.from(users);
return userCount == 0;
}
/**
* Returns true if the user is an admin
*/
async function isAdmin(email: string): Promise<boolean> {
const res = await db.query.users.findFirst({
columns: { role: true },
where: eq(users.email, email),
});
return res?.role == "admin";
}
const providers: Provider[] = [
CredentialsProvider({
// The name to display on the sign in form (e.g. "Sign in with...")
name: "Credentials",
credentials: {
email: { label: "Email", type: "email", placeholder: "Email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
if (!credentials) {
return null;
}
try {
return await validatePassword(
credentials?.email,
credentials?.password,
);
} catch (e) {
const error = e as Error;
logAuthenticationError(
credentials?.email,
error.message,
requestIp.getClientIp({ headers: req.headers }),
);
return null;
}
},
}),
];
const oauth = serverConfig.auth.oauth;
if (oauth.wellKnownUrl) {
providers.push({
id: "custom",
name: oauth.name,
type: "oauth",
wellKnown: oauth.wellKnownUrl,
authorization: { params: { scope: oauth.scope } },
clientId: oauth.clientId,
clientSecret: oauth.clientSecret,
allowDangerousEmailAccountLinking: oauth.allowDangerousEmailAccountLinking,
idToken: true,
checks: ["pkce", "state"],
async profile(profile: Record<string, string>) {
const [admin, firstUser] = await Promise.all([
isAdmin(profile.email),
isFirstUser(),
]);
return {
id: profile.sub,
name: profile.name || profile.email,
email: profile.email,
image: profile.picture,
role: admin || firstUser ? "admin" : "user",
};
},
});
}
export const authOptions: NextAuthOptions = {
// https://github.com/nextauthjs/next-auth/issues/9493
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}) as Adapter,
providers: providers,
session: {
strategy: "jwt",
},
pages: {
signIn: "/signin",
signOut: "/signin",
error: "/signin",
newUser: "/signin",
},
callbacks: {
async signIn({ credentials, profile }) {
if (credentials) {
return true;
}
if (!profile?.email) {
throw new Error("No profile");
}
const [{ count: userCount }] = await db
.select({ count: count() })
.from(users)
.where(and(eq(users.email, profile.email)));
// If it's a new user and signups are disabled, fail the sign in
if (userCount === 0 && serverConfig.auth.disableSignups) {
throw new Error("Signups are disabled in server config");
}
return true;
},
async jwt({ token, user }) {
if (user) {
token.user = {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
role: user.role ?? "user",
};
}
return token;
},
async session({ session, token }) {
session.user = { ...token.user };
return session;
},
},
};
export const authHandler = NextAuth(authOptions);
export const getServerAuthSession = () => getServerSession(authOptions);

View File

@ -1,20 +1,27 @@
{ {
"extends": "@lifetracker/typescript-config/nextjs.json", "$schema": "https://json.schemastore.org/tsconfig",
"extends": "@lifetracker/typescript-config/base.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
],
"paths": {
"@/*": [
"./*"
] ]
}
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
"next.config.mjs",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts" ".next/types/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules",
".next"
] ]
} }

View File

@ -5,7 +5,7 @@ const databaseURL = "./lifetracker.db";
export default { export default {
dialect: "sqlite", dialect: "sqlite",
schema: "./schema", schema: "./schema.ts",
out: "./migrations", out: "./migrations",
dbCredentials: { dbCredentials: {
url: databaseURL, url: databaseURL,

View File

@ -1,20 +1,21 @@
import "dotenv/config"; import "dotenv/config";
import { drizzle } from "drizzle-orm/better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import * as schema from "./schema/day"; import * as schema from "./schema";
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import path from "path"; import path from "path";
import dbConfig from "./drizzle.config"; import dbConfig from "./drizzle.config";
const sqlite = new Database(dbConfig.dbCredentials.url); const sqlite = new Database(dbConfig.dbCredentials.url);
export const db = drizzle(sqlite, { schema }); export const db = drizzle(sqlite, { schema });
export function getInMemoryDB(runMigrations: boolean) { export function getInMemoryDB(runMigrations: boolean) {
const mem = new Database(":memory:"); const mem = new Database(":memory:");
const db = drizzle(mem, { schema, logger: true }); const db = drizzle(mem, { schema, logger: true });
if (runMigrations) { if (runMigrations) {
migrate(db, { migrationsFolder: path.resolve(__dirname, "./drizzle") }); migrate(db, { migrationsFolder: path.resolve(__dirname, "./migrations") });
} }
return db; return db;
} }

Binary file not shown.

View File

@ -0,0 +1,53 @@
CREATE TABLE `account` (
`userId` text NOT NULL,
`type` text NOT NULL,
`provider` text NOT NULL,
`providerAccountId` text NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` integer,
`token_type` text,
`scope` text,
`id_token` text,
`session_state` text,
PRIMARY KEY(`provider`, `providerAccountId`),
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `apiKey` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`createdAt` integer NOT NULL,
`keyId` text NOT NULL,
`keyHash` text NOT NULL,
`userId` text NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `session` (
`sessionToken` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`expires` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`emailVerified` integer,
`image` text,
`password` text,
`role` text DEFAULT 'user'
);
--> statement-breakpoint
CREATE TABLE `verificationToken` (
`identifier` text NOT NULL,
`token` text NOT NULL,
`expires` integer NOT NULL,
PRIMARY KEY(`identifier`, `token`)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `apiKey_keyId_unique` ON `apiKey` (`keyId`);--> statement-breakpoint
CREATE UNIQUE INDEX `apiKey_name_userId_unique` ON `apiKey` (`name`,`userId`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);

View File

@ -0,0 +1,3 @@
ALTER TABLE `days` RENAME TO `day`;--> statement-breakpoint
DROP INDEX IF EXISTS `days_date_unique`;--> statement-breakpoint
CREATE UNIQUE INDEX `day_date_unique` ON `day` (`date`);

View File

@ -0,0 +1,400 @@
{
"version": "6",
"dialect": "sqlite",
"id": "709a800b-1a51-4e31-96ef-0e8dd1c42a95",
"prevId": "6ad45ca2-1f2d-4e94-a8fd-b749a8577a61",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {}
},
"apiKey": {
"name": "apiKey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyId": {
"name": "keyId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyHash": {
"name": "keyHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"apiKey_keyId_unique": {
"name": "apiKey_keyId_unique",
"columns": [
"keyId"
],
"isUnique": true
},
"apiKey_name_userId_unique": {
"name": "apiKey_name_userId_unique",
"columns": [
"name",
"userId"
],
"isUnique": true
}
},
"foreignKeys": {
"apiKey_userId_user_id_fk": {
"name": "apiKey_userId_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'user'"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": [
"identifier",
"token"
],
"name": "verificationToken_identifier_token_pk"
}
},
"uniqueConstraints": {}
},
"days": {
"name": "days",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"mood": {
"name": "mood",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"days_date_unique": {
"name": "days_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,402 @@
{
"version": "6",
"dialect": "sqlite",
"id": "67edcacc-ad95-4e34-b368-4beeda535072",
"prevId": "709a800b-1a51-4e31-96ef-0e8dd1c42a95",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {}
},
"apiKey": {
"name": "apiKey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyId": {
"name": "keyId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyHash": {
"name": "keyHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"apiKey_keyId_unique": {
"name": "apiKey_keyId_unique",
"columns": [
"keyId"
],
"isUnique": true
},
"apiKey_name_userId_unique": {
"name": "apiKey_name_userId_unique",
"columns": [
"name",
"userId"
],
"isUnique": true
}
},
"foreignKeys": {
"apiKey_userId_user_id_fk": {
"name": "apiKey_userId_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'user'"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": [
"identifier",
"token"
],
"name": "verificationToken_identifier_token_pk"
}
},
"uniqueConstraints": {}
},
"day": {
"name": "day",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"mood": {
"name": "mood",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"day_date_unique": {
"name": "day_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {
"\"days\"": "\"day\""
},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -15,6 +15,20 @@
"when": 1731100507972, "when": 1731100507972,
"tag": "0001_hard_lionheart", "tag": "0001_hard_lionheart",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1731542160896,
"tag": "0002_minor_toxin",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1731542202120,
"tag": "0003_complex_ares",
"breakpoints": true
} }
] ]
} }

View File

@ -12,6 +12,8 @@
"studio": "drizzle-kit studio" "studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@auth/core": "^0.37.3",
"@paralleldrive/cuid2": "^2.2.2",
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",

106
packages/db/schema.ts Normal file
View File

@ -0,0 +1,106 @@
import type { AdapterAccount } from "@auth/core/adapters";
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import {
AnySQLiteColumn,
index,
integer,
primaryKey,
sqliteTable,
text,
unique,
} from "drizzle-orm/sqlite-core";
function createdAtField() {
return integer("createdAt", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date());
}
export const apiKeys = sqliteTable(
"apiKey",
{
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
name: text("name").notNull(),
createdAt: createdAtField(),
keyId: text("keyId").notNull().unique(),
keyHash: text("keyHash").notNull(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(ak) => ({
unq: unique().on(ak.name, ak.userId),
}),
);
export const users = sqliteTable("user", {
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
image: text("image"),
password: text("password"),
role: text("role", { enum: ["admin", "user"] }).default("user"),
});
export const accounts = sqliteTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
}),
);
export const sessions = sqliteTable("session", {
sessionToken: text("sessionToken")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
});
export const verificationTokens = sqliteTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
}),
);
export const days = sqliteTable("day", {
id: integer("id").primaryKey({ autoIncrement: true }),
mood: integer("mood"),
date: text("date").notNull().unique(),
comment: text("comment").notNull(),
});

View File

@ -0,0 +1,99 @@
import type { AdapterAccount } from "@auth/core/adapters";
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import {
AnySQLiteColumn,
index,
integer,
primaryKey,
sqliteTable,
text,
unique,
} from "drizzle-orm/sqlite-core";
function createdAtField() {
return integer("createdAt", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date());
}
export const apiKeys = sqliteTable(
"apiKey",
{
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
name: text("name").notNull(),
createdAt: createdAtField(),
keyId: text("keyId").notNull().unique(),
keyHash: text("keyHash").notNull(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(ak) => ({
unq: unique().on(ak.name, ak.userId),
}),
);
export const users = sqliteTable("user", {
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
image: text("image"),
password: text("password"),
role: text("role", { enum: ["admin", "user"] }).default("user"),
});
export const accounts = sqliteTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
}),
);
export const sessions = sqliteTable("session", {
sessionToken: text("sessionToken")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
});
export const verificationTokens = sqliteTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
}),
);

View File

@ -1,6 +1,6 @@
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core"; import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
export const days = sqliteTable("days", { export const days = sqliteTable("day", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
mood: integer("mood"), mood: integer("mood"),
date: text("date").notNull().unique(), date: text("date").notNull().unique(),

View File

@ -0,0 +1 @@
# `@lifetracker/shared`

150
packages/shared/config.ts Normal file
View File

@ -0,0 +1,150 @@
import { z } from "zod";
const stringBool = (defaultValue: string) =>
z
.string()
.default(defaultValue)
.refine((s) => s === "true" || s === "false")
.transform((s) => s === "true");
const allEnv = z.object({
API_URL: z.string().url().default("http://localhost:3000"),
DISABLE_SIGNUPS: stringBool("false"),
DISABLE_PASSWORD_AUTH: stringBool("false"),
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: stringBool("false"),
OAUTH_WELLKNOWN_URL: z.string().url().optional(),
OAUTH_CLIENT_SECRET: z.string().optional(),
OAUTH_CLIENT_ID: z.string().optional(),
OAUTH_SCOPE: z.string().default("openid email profile"),
OAUTH_PROVIDER_NAME: z.string().default("Custom Provider"),
OPENAI_API_KEY: z.string().optional(),
OPENAI_BASE_URL: z.string().url().optional(),
OLLAMA_BASE_URL: z.string().url().optional(),
OLLAMA_KEEP_ALIVE: z.string().optional(),
INFERENCE_JOB_TIMEOUT_SEC: z.coerce.number().default(30),
INFERENCE_TEXT_MODEL: z.string().default("gpt-4o-mini"),
INFERENCE_IMAGE_MODEL: z.string().default("gpt-4o-mini"),
INFERENCE_CONTEXT_LENGTH: z.coerce.number().default(2048),
OCR_CACHE_DIR: z.string().optional(),
OCR_LANGS: z
.string()
.default("eng")
.transform((val) => val.split(",")),
OCR_CONFIDENCE_THRESHOLD: z.coerce.number().default(50),
CRAWLER_HEADLESS_BROWSER: stringBool("true"),
BROWSER_WEB_URL: z.string().url().optional(),
BROWSER_WEBSOCKET_URL: z.string().url().optional(),
BROWSER_CONNECT_ONDEMAND: stringBool("false"),
CRAWLER_JOB_TIMEOUT_SEC: z.coerce.number().default(60),
CRAWLER_NAVIGATE_TIMEOUT_SEC: z.coerce.number().default(30),
CRAWLER_NUM_WORKERS: z.coerce.number().default(1),
CRAWLER_DOWNLOAD_BANNER_IMAGE: stringBool("true"),
CRAWLER_STORE_SCREENSHOT: stringBool("true"),
CRAWLER_FULL_PAGE_SCREENSHOT: stringBool("false"),
CRAWLER_FULL_PAGE_ARCHIVE: stringBool("false"),
CRAWLER_VIDEO_DOWNLOAD: stringBool("false"),
CRAWLER_VIDEO_DOWNLOAD_MAX_SIZE: z.coerce.number().default(50),
CRAWLER_VIDEO_DOWNLOAD_TIMEOUT_SEC: z.coerce.number().default(10 * 60),
MEILI_ADDR: z.string().optional(),
MEILI_MASTER_KEY: z.string().default(""),
LOG_LEVEL: z.string().default("debug"),
DEMO_MODE: stringBool("false"),
DEMO_MODE_EMAIL: z.string().optional(),
DEMO_MODE_PASSWORD: z.string().optional(),
DATA_DIR: z.string().default(""),
MAX_ASSET_SIZE_MB: z.coerce.number().default(4),
INFERENCE_LANG: z.string().default("english"),
// Build only flag
SERVER_VERSION: z.string().optional(),
DISABLE_NEW_RELEASE_CHECK: stringBool("false"),
// A flag to detect if the user is running in the old separete containers setup
USING_LEGACY_SEPARATE_CONTAINERS: stringBool("false"),
});
const serverConfigSchema = allEnv.transform((val) => {
return {
apiUrl: val.API_URL,
auth: {
disableSignups: val.DISABLE_SIGNUPS,
disablePasswordAuth: val.DISABLE_PASSWORD_AUTH,
oauth: {
allowDangerousEmailAccountLinking:
val.OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING,
wellKnownUrl: val.OAUTH_WELLKNOWN_URL,
clientSecret: val.OAUTH_CLIENT_SECRET,
clientId: val.OAUTH_CLIENT_ID,
scope: val.OAUTH_SCOPE,
name: val.OAUTH_PROVIDER_NAME,
},
},
inference: {
jobTimeoutSec: val.INFERENCE_JOB_TIMEOUT_SEC,
openAIApiKey: val.OPENAI_API_KEY,
openAIBaseUrl: val.OPENAI_BASE_URL,
ollamaBaseUrl: val.OLLAMA_BASE_URL,
ollamaKeepAlive: val.OLLAMA_KEEP_ALIVE,
textModel: val.INFERENCE_TEXT_MODEL,
imageModel: val.INFERENCE_IMAGE_MODEL,
inferredTagLang: val.INFERENCE_LANG,
contextLength: val.INFERENCE_CONTEXT_LENGTH,
},
crawler: {
numWorkers: val.CRAWLER_NUM_WORKERS,
headlessBrowser: val.CRAWLER_HEADLESS_BROWSER,
browserWebUrl: val.BROWSER_WEB_URL,
browserWebSocketUrl: val.BROWSER_WEBSOCKET_URL,
browserConnectOnDemand: val.BROWSER_CONNECT_ONDEMAND,
jobTimeoutSec: val.CRAWLER_JOB_TIMEOUT_SEC,
navigateTimeoutSec: val.CRAWLER_NAVIGATE_TIMEOUT_SEC,
downloadBannerImage: val.CRAWLER_DOWNLOAD_BANNER_IMAGE,
storeScreenshot: val.CRAWLER_STORE_SCREENSHOT,
fullPageScreenshot: val.CRAWLER_FULL_PAGE_SCREENSHOT,
fullPageArchive: val.CRAWLER_FULL_PAGE_ARCHIVE,
downloadVideo: val.CRAWLER_VIDEO_DOWNLOAD,
maxVideoDownloadSize: val.CRAWLER_VIDEO_DOWNLOAD_MAX_SIZE,
downloadVideoTimeout: val.CRAWLER_VIDEO_DOWNLOAD_TIMEOUT_SEC,
},
ocr: {
langs: val.OCR_LANGS,
cacheDir: val.OCR_CACHE_DIR,
confidenceThreshold: val.OCR_CONFIDENCE_THRESHOLD,
},
meilisearch: val.MEILI_ADDR
? {
address: val.MEILI_ADDR,
key: val.MEILI_MASTER_KEY,
}
: undefined,
logLevel: val.LOG_LEVEL,
demoMode: val.DEMO_MODE
? {
email: val.DEMO_MODE_EMAIL,
password: val.DEMO_MODE_PASSWORD,
}
: undefined,
dataDir: val.DATA_DIR,
maxAssetSizeMb: val.MAX_ASSET_SIZE_MB,
serverVersion: val.SERVER_VERSION,
disableNewReleaseCheck: val.DISABLE_NEW_RELEASE_CHECK,
usingLegacySeparateContainers: val.USING_LEGACY_SEPARATE_CONTAINERS,
};
});
const serverConfig = serverConfigSchema.parse(process.env);
// Always explicitly pick up stuff from server config to avoid accidentally leaking stuff
export const clientConfig = {
demoMode: serverConfig.demoMode,
auth: {
disableSignups: serverConfig.auth.disableSignups,
disablePasswordAuth: serverConfig.auth.disablePasswordAuth,
},
inference: {
inferredTagLang: serverConfig.inference.inferredTagLang,
},
serverVersion: serverConfig.serverVersion,
disableNewReleaseCheck: serverConfig.disableNewReleaseCheck,
};
export type ClientConfig = typeof clientConfig;
export default serverConfig;

36
packages/shared/logger.ts Normal file
View File

@ -0,0 +1,36 @@
import winston from "winston";
import serverConfig from "./config";
const logger = winston.createLogger({
level: serverConfig.logLevel,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`,
),
),
transports: [new winston.transports.Console()],
});
export default logger;
export const authFailureLogger = winston.createLogger({
level: "debug",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`,
),
),
transports: [
new winston.transports.Console(),
new winston.transports.File({
filename: "auth_failures.log",
dirname: serverConfig.dataDir,
maxFiles: 2,
maxsize: 1024 * 1024,
}),
],
});

View File

@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@lifetracker/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"winston": "^3.17.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@lifetracker/eslint-config": "workspace:^",
"@lifetracker/typescript-config": "workspace:^"
},
"scripts": {
"typecheck": "tsc --noEmit",
"format": "prettier . --ignore-path ../../.prettierignore",
"lint": "eslint ."
},
"main": "index.ts",
"eslintConfig": {
"root": true,
"extends": [
"@lifetracker/eslint-config/base"
]
}
}

View File

@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@lifetracker/typescript-config/node.json",
"include": [
"**/*.ts"
],
"exclude": [
"node_modules"
],
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
}

115
packages/trpc/auth.ts Normal file
View File

@ -0,0 +1,115 @@
import { randomBytes } from "crypto";
import * as bcrypt from "bcryptjs";
import { db } from "@lifetracker/db";
import { apiKeys } from "@lifetracker/db/schema/auth";
import serverConfig from "@lifetracker/shared/config";
import { authFailureLogger } from "@lifetracker/shared/logger";
// API Keys
const BCRYPT_SALT_ROUNDS = 10;
const API_KEY_PREFIX = "ak1";
export async function generateApiKey(name: string, userId: string) {
const id = randomBytes(10).toString("hex");
const secret = randomBytes(10).toString("hex");
const secretHash = await bcrypt.hash(secret, BCRYPT_SALT_ROUNDS);
const plain = `${API_KEY_PREFIX}_${id}_${secret}`;
const key = (
await db
.insert(apiKeys)
.values({
name: name,
userId: userId,
keyId: id,
keyHash: secretHash,
})
.returning()
)[0];
return {
id: key.id,
name: key.name,
createdAt: key.createdAt,
key: plain,
};
}
function parseApiKey(plain: string) {
const parts = plain.split("_");
if (parts.length != 3) {
throw new Error(
`Malformed API key. API keys should have 3 segments, found ${parts.length} instead.`,
);
}
if (parts[0] !== API_KEY_PREFIX) {
throw new Error(`Malformed API key. Got unexpected key prefix.`);
}
return {
keyId: parts[1],
keySecret: parts[2],
};
}
export async function authenticateApiKey(key: string) {
const { keyId, keySecret } = parseApiKey(key);
const apiKey = await db.query.apiKeys.findFirst({
where: (k, { eq }) => eq(k.keyId, keyId),
with: {
user: true,
},
});
if (!apiKey) {
throw new Error("API key not found");
}
const hash = apiKey.keyHash;
const validation = await bcrypt.compare(keySecret, hash);
if (!validation) {
throw new Error("Invalid API Key");
}
return apiKey.user;
}
export async function hashPassword(password: string) {
return bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
}
export async function validatePassword(email: string, password: string) {
if (serverConfig.auth.disablePasswordAuth) {
throw new Error("Password authentication is currently disabled");
}
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, email),
});
if (!user) {
throw new Error("User not found");
}
if (!user.password) {
throw new Error("This user doesn't have a password defined");
}
const validation = await bcrypt.compare(password, user.password);
if (!validation) {
throw new Error("Wrong password");
}
return user;
}
export function logAuthenticationError(
user: string,
message: string,
ip: string | null,
): void {
authFailureLogger.error(
`Authentication error. User: "${user}", Message: "${message}", IP-Address: "${ip}"`,
);
}

View File

@ -1,5 +1,23 @@
import { initTRPC } from "@trpc/server"; import { initTRPC } from "@trpc/server";
import type { db } from "@lifetracker/db";
const t = initTRPC.create(); const t = initTRPC.create();
export const router = t.router; export const router = t.router;
export const publicProcedure = t.procedure; export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
interface User {
id: string;
name?: string | null | undefined;
email?: string | null | undefined;
role: "admin" | "user" | null;
}
export interface Context {
user: User | null;
db: typeof db;
req: {
ip: string | null;
};
}

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@lifetracker/db": "workspace:*", "@lifetracker/db": "workspace:*",
"@lifetracker/shared": "workspace:^",
"@lifetracker/ui": "workspace:*", "@lifetracker/ui": "workspace:*",
"@trpc/server": "^10.45.2", "@trpc/server": "^10.45.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",

470
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff