aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/auth.ts99
-rw-r--r--packages/trpc/index.ts57
-rw-r--r--packages/trpc/package.json28
-rw-r--r--packages/trpc/routers/_app.ts15
-rw-r--r--packages/trpc/routers/admin.ts77
-rw-r--r--packages/trpc/routers/apiKeys.ts61
-rw-r--r--packages/trpc/routers/bookmarks.test.ts200
-rw-r--r--packages/trpc/routers/bookmarks.ts454
-rw-r--r--packages/trpc/routers/lists.ts173
-rw-r--r--packages/trpc/routers/users.test.ts99
-rw-r--r--packages/trpc/routers/users.ts93
-rw-r--r--packages/trpc/testUtils.ts70
-rw-r--r--packages/trpc/tsconfig.json13
-rw-r--r--packages/trpc/types/bookmarks.ts70
-rw-r--r--packages/trpc/types/lists.ts18
-rw-r--r--packages/trpc/types/tags.ts10
-rw-r--r--packages/trpc/types/users.ts13
-rw-r--r--packages/trpc/vitest.config.ts14
18 files changed, 1564 insertions, 0 deletions
diff --git a/packages/trpc/auth.ts b/packages/trpc/auth.ts
new file mode 100644
index 00000000..6854303b
--- /dev/null
+++ b/packages/trpc/auth.ts
@@ -0,0 +1,99 @@
+import { randomBytes } from "crypto";
+import { apiKeys } from "@hoarder/db/schema";
+import * as bcrypt from "bcrypt";
+import { db } from "@hoarder/db";
+
+// 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(
+ `Malformd API key. API keys should have 3 segments, found ${parts.length} instead.`,
+ );
+ }
+ if (parts[0] !== API_KEY_PREFIX) {
+ throw new Error(`Malformd 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) {
+ 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;
+}
diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts
new file mode 100644
index 00000000..a32eb871
--- /dev/null
+++ b/packages/trpc/index.ts
@@ -0,0 +1,57 @@
+import { db } from "@hoarder/db";
+import serverConfig from "@hoarder/shared/config";
+import { TRPCError, initTRPC } from "@trpc/server";
+import superjson from "superjson";
+
+type User = {
+ id: string;
+ role: "admin" | "user" | null;
+};
+
+export type Context = {
+ user: User | null;
+ db: typeof db;
+};
+
+// Avoid exporting the entire t-object
+// since it's not very descriptive.
+// For instance, the use of a t variable
+// is common in i18n libraries.
+const t = initTRPC.context<Context>().create({
+ transformer: superjson,
+});
+export const createCallerFactory = t.createCallerFactory;
+// Base router and procedure helpers
+export const router = t.router;
+export const procedure = t.procedure.use(function isDemoMode(opts) {
+ if (serverConfig.demoMode && opts.type == "mutation") {
+ throw new TRPCError({
+ message: "Mutations are not allowed in demo mode",
+ code: "FORBIDDEN",
+ });
+ }
+ return opts.next();
+});
+export const publicProcedure = procedure;
+
+export const authedProcedure = procedure.use(function isAuthed(opts) {
+ const user = opts.ctx.user;
+
+ if (!user || !user.id) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ return opts.next({
+ ctx: {
+ user,
+ },
+ });
+});
+
+export const adminProcedure = authedProcedure.use(function isAdmin(opts) {
+ const user = opts.ctx.user;
+ if (user.role != "admin") {
+ throw new TRPCError({ code: "FORBIDDEN" });
+ }
+ return opts.next(opts);
+});
diff --git a/packages/trpc/package.json b/packages/trpc/package.json
new file mode 100644
index 00000000..1e33eff0
--- /dev/null
+++ b/packages/trpc/package.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "name": "@hoarder/trpc",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "vitest"
+ },
+ "dependencies": {
+ "@hoarder/db": "workspace:*",
+ "@hoarder/shared": "workspace:*",
+ "@trpc/server": "11.0.0-next-beta.304",
+ "bcrypt": "^5.1.1",
+ "drizzle-orm": "^0.29.4",
+ "superjson": "^2.2.1",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@tsconfig/node21": "^21.0.1",
+ "@types/bcrypt": "^5.0.2",
+ "aws-sdk": "^2.1570.0",
+ "mock-aws-s3": "^4.0.2",
+ "nock": "^13.5.4",
+ "vite-tsconfig-paths": "^4.3.1",
+ "vitest": "^1.3.1"
+ }
+}
diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts
new file mode 100644
index 00000000..6e5dd91d
--- /dev/null
+++ b/packages/trpc/routers/_app.ts
@@ -0,0 +1,15 @@
+import { router } from "../index";
+import { adminAppRouter } from "./admin";
+import { apiKeysAppRouter } from "./apiKeys";
+import { bookmarksAppRouter } from "./bookmarks";
+import { listsAppRouter } from "./lists";
+import { usersAppRouter } from "./users";
+export const appRouter = router({
+ bookmarks: bookmarksAppRouter,
+ apiKeys: apiKeysAppRouter,
+ users: usersAppRouter,
+ lists: listsAppRouter,
+ admin: adminAppRouter,
+});
+// export type definition of API
+export type AppRouter = typeof appRouter;
diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts
new file mode 100644
index 00000000..8a7b592d
--- /dev/null
+++ b/packages/trpc/routers/admin.ts
@@ -0,0 +1,77 @@
+import { adminProcedure, router } from "../index";
+import { z } from "zod";
+import { count } from "drizzle-orm";
+import { bookmarks, users } from "@hoarder/db/schema";
+import {
+ LinkCrawlerQueue,
+ OpenAIQueue,
+ SearchIndexingQueue,
+} from "@hoarder/shared/queues";
+
+export const adminAppRouter = router({
+ stats: adminProcedure
+ .output(
+ z.object({
+ numUsers: z.number(),
+ numBookmarks: z.number(),
+ pendingCrawls: z.number(),
+ pendingIndexing: z.number(),
+ pendingOpenai: z.number(),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const [
+ [{ value: numUsers }],
+ [{ value: numBookmarks }],
+ pendingCrawls,
+ pendingIndexing,
+ pendingOpenai,
+ ] = await Promise.all([
+ ctx.db.select({ value: count() }).from(users),
+ ctx.db.select({ value: count() }).from(bookmarks),
+ LinkCrawlerQueue.getWaitingCount(),
+ SearchIndexingQueue.getWaitingCount(),
+ OpenAIQueue.getWaitingCount(),
+ ]);
+
+ return {
+ numUsers,
+ numBookmarks,
+ pendingCrawls,
+ pendingIndexing,
+ pendingOpenai,
+ };
+ }),
+ recrawlAllLinks: adminProcedure.mutation(async ({ ctx }) => {
+ const bookmarkIds = await ctx.db.query.bookmarkLinks.findMany({
+ columns: {
+ id: true,
+ },
+ });
+
+ await Promise.all(
+ bookmarkIds.map((b) =>
+ LinkCrawlerQueue.add("crawl", {
+ bookmarkId: b.id,
+ }),
+ ),
+ );
+ }),
+
+ reindexAllBookmarks: adminProcedure.mutation(async ({ ctx }) => {
+ const bookmarkIds = await ctx.db.query.bookmarks.findMany({
+ columns: {
+ id: true,
+ },
+ });
+
+ await Promise.all(
+ bookmarkIds.map((b) =>
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: b.id,
+ type: "index",
+ }),
+ ),
+ );
+ }),
+});
diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts
new file mode 100644
index 00000000..d13f87fb
--- /dev/null
+++ b/packages/trpc/routers/apiKeys.ts
@@ -0,0 +1,61 @@
+import { generateApiKey } from "../auth";
+import { authedProcedure, router } from "../index";
+import { z } from "zod";
+import { apiKeys } from "@hoarder/db/schema";
+import { eq, and } from "drizzle-orm";
+
+export const apiKeysAppRouter = router({
+ create: authedProcedure
+ .input(
+ z.object({
+ name: z.string(),
+ }),
+ )
+ .output(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ key: z.string(),
+ createdAt: z.date(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ return await generateApiKey(input.name, ctx.user.id);
+ }),
+ revoke: authedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ await ctx.db
+ .delete(apiKeys)
+ .where(and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.user.id)));
+ }),
+ list: authedProcedure
+ .output(
+ z.object({
+ keys: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ createdAt: z.date(),
+ keyId: z.string(),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const resp = await ctx.db.query.apiKeys.findMany({
+ where: eq(apiKeys.userId, ctx.user.id),
+ columns: {
+ id: true,
+ name: true,
+ createdAt: true,
+ keyId: true,
+ },
+ });
+ return { keys: resp };
+ }),
+});
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
new file mode 100644
index 00000000..724a9998
--- /dev/null
+++ b/packages/trpc/routers/bookmarks.test.ts
@@ -0,0 +1,200 @@
+import { CustomTestContext, defaultBeforeEach } from "../testUtils";
+import { expect, describe, test, beforeEach, assert } from "vitest";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(true));
+
+describe("Bookmark Routes", () => {
+ test<CustomTestContext>("create bookmark", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+ const bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ const res = await api.getBookmark({ bookmarkId: bookmark.id });
+ assert(res.content.type == "link");
+ expect(res.content.url).toEqual("https://google.com");
+ expect(res.favourited).toEqual(false);
+ expect(res.archived).toEqual(false);
+ expect(res.content.type).toEqual("link");
+ });
+
+ test<CustomTestContext>("delete bookmark", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+
+ // Create the bookmark
+ const bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ // It should exist
+ await api.getBookmark({ bookmarkId: bookmark.id });
+
+ // Delete it
+ await api.deleteBookmark({ bookmarkId: bookmark.id });
+
+ // It shouldn't be there anymore
+ await expect(() =>
+ api.getBookmark({ bookmarkId: bookmark.id }),
+ ).rejects.toThrow(/Bookmark not found/);
+ });
+
+ test<CustomTestContext>("update bookmark", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+
+ // Create the bookmark
+ const bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ await api.updateBookmark({
+ bookmarkId: bookmark.id,
+ archived: true,
+ favourited: true,
+ });
+
+ const res = await api.getBookmark({ bookmarkId: bookmark.id });
+ expect(res.archived).toBeTruthy();
+ expect(res.favourited).toBeTruthy();
+ });
+
+ test<CustomTestContext>("list bookmarks", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+ const emptyBookmarks = await api.getBookmarks({});
+ expect(emptyBookmarks.bookmarks.length).toEqual(0);
+
+ const bookmark1 = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ const bookmark2 = await api.createBookmark({
+ url: "https://google2.com",
+ type: "link",
+ });
+
+ {
+ const bookmarks = await api.getBookmarks({});
+ expect(bookmarks.bookmarks.length).toEqual(2);
+ }
+
+ // Archive and favourite bookmark1
+ await api.updateBookmark({
+ bookmarkId: bookmark1.id,
+ archived: true,
+ favourited: true,
+ });
+
+ {
+ const bookmarks = await api.getBookmarks({ archived: false });
+ expect(bookmarks.bookmarks.length).toEqual(1);
+ expect(bookmarks.bookmarks[0].id).toEqual(bookmark2.id);
+ }
+
+ {
+ const bookmarks = await api.getBookmarks({ favourited: true });
+ expect(bookmarks.bookmarks.length).toEqual(1);
+ expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
+ }
+
+ {
+ const bookmarks = await api.getBookmarks({ archived: true });
+ expect(bookmarks.bookmarks.length).toEqual(1);
+ expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
+ }
+
+ {
+ const bookmarks = await api.getBookmarks({ ids: [bookmark1.id] });
+ expect(bookmarks.bookmarks.length).toEqual(1);
+ expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
+ }
+ });
+
+ test<CustomTestContext>("update tags", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+ let bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tag: "tag1" }, { tag: "tag2" }],
+ detach: [],
+ });
+
+ bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
+ expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag1", "tag2"]);
+
+ const tag1Id = bookmark.tags.filter((t) => t.name == "tag1")[0].id;
+
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tag: "tag3" }],
+ detach: [{ tagId: tag1Id }],
+ });
+
+ bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
+ expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag2", "tag3"]);
+ });
+
+ test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+ let bookmark = await api.createBookmark({
+ text: "HELLO WORLD",
+ type: "text",
+ });
+
+ await api.updateBookmarkText({
+ bookmarkId: bookmark.id,
+ text: "WORLD HELLO",
+ });
+
+ bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
+ assert(bookmark.content.type == "text");
+ expect(bookmark.content.text).toEqual("WORLD HELLO");
+ });
+
+ test<CustomTestContext>("privacy", async ({ apiCallers }) => {
+ const user1Bookmark = await apiCallers[0].bookmarks.createBookmark({
+ type: "link",
+ url: "https://google.com",
+ });
+ const user2Bookmark = await apiCallers[1].bookmarks.createBookmark({
+ type: "link",
+ url: "https://google.com",
+ });
+
+ // All interactions with the wrong user should fail
+ await expect(() =>
+ apiCallers[0].bookmarks.deleteBookmark({ bookmarkId: user2Bookmark.id }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+ await expect(() =>
+ apiCallers[0].bookmarks.getBookmark({ bookmarkId: user2Bookmark.id }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+ await expect(() =>
+ apiCallers[0].bookmarks.updateBookmark({ bookmarkId: user2Bookmark.id }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+ await expect(() =>
+ apiCallers[0].bookmarks.updateTags({
+ bookmarkId: user2Bookmark.id,
+ attach: [],
+ detach: [],
+ }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+
+ // Get bookmarks should only show the correct one
+ expect(
+ (await apiCallers[0].bookmarks.getBookmarks({})).bookmarks.map(
+ (b) => b.id,
+ ),
+ ).toEqual([user1Bookmark.id]);
+ expect(
+ (await apiCallers[1].bookmarks.getBookmarks({})).bookmarks.map(
+ (b) => b.id,
+ ),
+ ).toEqual([user2Bookmark.id]);
+ });
+});
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
new file mode 100644
index 00000000..ea7ffef8
--- /dev/null
+++ b/packages/trpc/routers/bookmarks.ts
@@ -0,0 +1,454 @@
+import { z } from "zod";
+import { Context, authedProcedure, router } from "../index";
+import { getSearchIdxClient } from "@hoarder/shared/search";
+import {
+ ZBookmark,
+ ZBookmarkContent,
+ zBareBookmarkSchema,
+ zBookmarkSchema,
+ zGetBookmarksRequestSchema,
+ zGetBookmarksResponseSchema,
+ zNewBookmarkRequestSchema,
+ zUpdateBookmarksRequestSchema,
+} from "../types/bookmarks";
+import {
+ bookmarkLinks,
+ bookmarkTags,
+ bookmarkTexts,
+ bookmarks,
+ tagsOnBookmarks,
+} from "@hoarder/db/schema";
+import {
+ LinkCrawlerQueue,
+ OpenAIQueue,
+ SearchIndexingQueue,
+} from "@hoarder/shared/queues";
+import { TRPCError, experimental_trpcMiddleware } from "@trpc/server";
+import { and, desc, eq, inArray } from "drizzle-orm";
+import { ZBookmarkTags } from "../types/tags";
+
+import { db as DONT_USE_db } from "@hoarder/db";
+
+const ensureBookmarkOwnership = experimental_trpcMiddleware<{
+ ctx: Context;
+ input: { bookmarkId: string };
+}>().create(async (opts) => {
+ const bookmark = await opts.ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, opts.input.bookmarkId),
+ columns: {
+ userId: true,
+ },
+ });
+ if (!opts.ctx.user) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "User is not authorized",
+ });
+ }
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+ if (bookmark.userId != opts.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+
+ return opts.next();
+});
+
+async function dummyDrizzleReturnType() {
+ const x = await DONT_USE_db.query.bookmarks.findFirst({
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+ if (!x) {
+ throw new Error();
+ }
+ return x;
+}
+
+function toZodSchema(
+ bookmark: Awaited<ReturnType<typeof dummyDrizzleReturnType>>,
+): ZBookmark {
+ const { tagsOnBookmarks, link, text, ...rest } = bookmark;
+
+ let content: ZBookmarkContent;
+ if (link) {
+ content = { type: "link", ...link };
+ } else if (text) {
+ content = { type: "text", text: text.text || "" };
+ } else {
+ throw new Error("Unknown content type");
+ }
+
+ return {
+ tags: tagsOnBookmarks.map((t) => ({
+ attachedBy: t.attachedBy,
+ ...t.tag,
+ })),
+ content,
+ ...rest,
+ };
+}
+
+export const bookmarksAppRouter = router({
+ createBookmark: authedProcedure
+ .input(zNewBookmarkRequestSchema)
+ .output(zBookmarkSchema)
+ .mutation(async ({ input, ctx }) => {
+ const bookmark = await ctx.db.transaction(
+ async (tx): Promise<ZBookmark> => {
+ const bookmark = (
+ await tx
+ .insert(bookmarks)
+ .values({
+ userId: ctx.user.id,
+ })
+ .returning()
+ )[0];
+
+ let content: ZBookmarkContent;
+
+ switch (input.type) {
+ case "link": {
+ const link = (
+ await tx
+ .insert(bookmarkLinks)
+ .values({
+ id: bookmark.id,
+ url: input.url,
+ })
+ .returning()
+ )[0];
+ content = {
+ type: "link",
+ ...link,
+ };
+ break;
+ }
+ case "text": {
+ const text = (
+ await tx
+ .insert(bookmarkTexts)
+ .values({ id: bookmark.id, text: input.text })
+ .returning()
+ )[0];
+ content = {
+ type: "text",
+ text: text.text || "",
+ };
+ break;
+ }
+ }
+
+ return {
+ tags: [] as ZBookmarkTags[],
+ content,
+ ...bookmark,
+ };
+ },
+ );
+
+ // Enqueue crawling request
+ switch (bookmark.content.type) {
+ case "link": {
+ // The crawling job triggers openai when it's done
+ await LinkCrawlerQueue.add("crawl", {
+ bookmarkId: bookmark.id,
+ });
+ break;
+ }
+ case "text": {
+ await OpenAIQueue.add("openai", {
+ bookmarkId: bookmark.id,
+ });
+ break;
+ }
+ }
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: bookmark.id,
+ type: "index",
+ });
+ return bookmark;
+ }),
+
+ updateBookmark: authedProcedure
+ .input(zUpdateBookmarksRequestSchema)
+ .output(zBareBookmarkSchema)
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ const res = await ctx.db
+ .update(bookmarks)
+ .set({
+ archived: input.archived,
+ favourited: input.favourited,
+ })
+ .where(
+ and(
+ eq(bookmarks.userId, ctx.user.id),
+ eq(bookmarks.id, input.bookmarkId),
+ ),
+ )
+ .returning();
+ if (res.length == 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+ return res[0];
+ }),
+
+ updateBookmarkText: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ text: z.string().max(2000),
+ }),
+ )
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ const res = await ctx.db
+ .update(bookmarkTexts)
+ .set({
+ text: input.text,
+ })
+ .where(and(eq(bookmarkTexts.id, input.bookmarkId)))
+ .returning();
+ if (res.length == 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: input.bookmarkId,
+ type: "index",
+ });
+ }),
+
+ deleteBookmark: authedProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.db
+ .delete(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, ctx.user.id),
+ eq(bookmarks.id, input.bookmarkId),
+ ),
+ );
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: input.bookmarkId,
+ type: "delete",
+ });
+ }),
+ recrawlBookmark: authedProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input }) => {
+ await LinkCrawlerQueue.add("crawl", {
+ bookmarkId: input.bookmarkId,
+ });
+ }),
+ getBookmark: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ }),
+ )
+ .output(zBookmarkSchema)
+ .use(ensureBookmarkOwnership)
+ .query(async ({ input, ctx }) => {
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: and(
+ eq(bookmarks.userId, ctx.user.id),
+ eq(bookmarks.id, input.bookmarkId),
+ ),
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ return toZodSchema(bookmark);
+ }),
+ searchBookmarks: authedProcedure
+ .input(
+ z.object({
+ text: z.string(),
+ }),
+ )
+ .output(zGetBookmarksResponseSchema)
+ .query(async ({ input, ctx }) => {
+ const client = await getSearchIdxClient();
+ if (!client) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Search functionality is not configured",
+ });
+ }
+ const resp = await client.search(input.text, {
+ filter: [`userId = '${ctx.user.id}'`],
+ });
+
+ if (resp.hits.length == 0) {
+ return { bookmarks: [] };
+ }
+ const results = await ctx.db.query.bookmarks.findMany({
+ where: and(
+ eq(bookmarks.userId, ctx.user.id),
+ inArray(
+ bookmarks.id,
+ resp.hits.map((h) => h.id),
+ ),
+ ),
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+
+ return { bookmarks: results.map(toZodSchema) };
+ }),
+ getBookmarks: authedProcedure
+ .input(zGetBookmarksRequestSchema)
+ .output(zGetBookmarksResponseSchema)
+ .query(async ({ input, ctx }) => {
+ if (input.ids && input.ids.length == 0) {
+ return { bookmarks: [] };
+ }
+ const results = await ctx.db.query.bookmarks.findMany({
+ where: and(
+ eq(bookmarks.userId, ctx.user.id),
+ input.archived !== undefined
+ ? eq(bookmarks.archived, input.archived)
+ : undefined,
+ input.favourited !== undefined
+ ? eq(bookmarks.favourited, input.favourited)
+ : undefined,
+ input.ids ? inArray(bookmarks.id, input.ids) : undefined,
+ ),
+ orderBy: [desc(bookmarks.createdAt)],
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+
+ return { bookmarks: results.map(toZodSchema) };
+ }),
+
+ updateTags: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ attach: z.array(
+ z.object({
+ tagId: z.string().optional(), // If the tag already exists and we know its id
+ tag: z.string(),
+ }),
+ ),
+ // Detach by tag ids
+ detach: z.array(z.object({ tagId: z.string() })),
+ }),
+ )
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.db.transaction(async (tx) => {
+ // Detaches
+ if (input.detach.length > 0) {
+ await tx.delete(tagsOnBookmarks).where(
+ and(
+ eq(tagsOnBookmarks.bookmarkId, input.bookmarkId),
+ inArray(
+ tagsOnBookmarks.tagId,
+ input.detach.map((t) => t.tagId),
+ ),
+ ),
+ );
+ }
+
+ if (input.attach.length == 0) {
+ return;
+ }
+
+ // New Tags
+ const toBeCreatedTags = input.attach
+ .filter((i) => i.tagId === undefined)
+ .map((i) => ({
+ name: i.tag,
+ userId: ctx.user.id,
+ }));
+
+ if (toBeCreatedTags.length > 0) {
+ await tx
+ .insert(bookmarkTags)
+ .values(toBeCreatedTags)
+ .onConflictDoNothing()
+ .returning();
+ }
+
+ const allIds = (
+ await tx.query.bookmarkTags.findMany({
+ where: and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ inArray(
+ bookmarkTags.name,
+ input.attach.map((t) => t.tag),
+ ),
+ ),
+ columns: {
+ id: true,
+ },
+ })
+ ).map((t) => t.id);
+
+ await tx
+ .insert(tagsOnBookmarks)
+ .values(
+ allIds.map((i) => ({
+ tagId: i as string,
+ bookmarkId: input.bookmarkId,
+ attachedBy: "human" as const,
+ userId: ctx.user.id,
+ })),
+ )
+ .onConflictDoNothing();
+ });
+ }),
+});
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
new file mode 100644
index 00000000..fa97929d
--- /dev/null
+++ b/packages/trpc/routers/lists.ts
@@ -0,0 +1,173 @@
+import { Context, authedProcedure, router } from "../index";
+import { SqliteError } from "@hoarder/db";
+import { z } from "zod";
+import { TRPCError, experimental_trpcMiddleware } from "@trpc/server";
+import { bookmarkLists, bookmarksInLists } from "@hoarder/db/schema";
+import { and, eq } from "drizzle-orm";
+import { zBookmarkListSchema } from "../types/lists";
+
+const ensureListOwnership = experimental_trpcMiddleware<{
+ ctx: Context;
+ input: { listId: string };
+}>().create(async (opts) => {
+ const list = await opts.ctx.db.query.bookmarkLists.findFirst({
+ where: eq(bookmarkLists.id, opts.input.listId),
+ columns: {
+ userId: true,
+ },
+ });
+ if (!opts.ctx.user) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "User is not authorized",
+ });
+ }
+ if (!list) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "List not found",
+ });
+ }
+ if (list.userId != opts.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+
+ return opts.next();
+});
+
+export const listsAppRouter = router({
+ create: authedProcedure
+ .input(
+ z.object({
+ name: z.string().min(1).max(20),
+ icon: z.string(),
+ }),
+ )
+ .output(zBookmarkListSchema)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const result = await ctx.db
+ .insert(bookmarkLists)
+ .values({
+ name: input.name,
+ icon: input.icon,
+ userId: ctx.user.id,
+ })
+ .returning();
+ return result[0];
+ } catch (e) {
+ if (e instanceof SqliteError) {
+ if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "List already exists",
+ });
+ }
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Something went wrong",
+ });
+ }
+ }),
+ delete: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ }),
+ )
+ .use(ensureListOwnership)
+ .mutation(async ({ input, ctx }) => {
+ const res = await ctx.db
+ .delete(bookmarkLists)
+ .where(
+ and(
+ eq(bookmarkLists.id, input.listId),
+ eq(bookmarkLists.userId, ctx.user.id),
+ ),
+ );
+ if (res.changes == 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ }),
+ addToList: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ bookmarkId: z.string(),
+ }),
+ )
+ .use(ensureListOwnership)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ await ctx.db.insert(bookmarksInLists).values({
+ listId: input.listId,
+ bookmarkId: input.bookmarkId,
+ });
+ } catch (e) {
+ if (e instanceof SqliteError) {
+ if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Bookmark already in the list",
+ });
+ }
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Something went wrong",
+ });
+ }
+ }),
+ get: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ }),
+ )
+ .output(
+ zBookmarkListSchema.merge(
+ z.object({
+ bookmarks: z.array(z.string()),
+ }),
+ ),
+ )
+ .use(ensureListOwnership)
+ .query(async ({ input, ctx }) => {
+ const res = await ctx.db.query.bookmarkLists.findFirst({
+ where: and(
+ eq(bookmarkLists.id, input.listId),
+ eq(bookmarkLists.userId, ctx.user.id),
+ ),
+ with: {
+ bookmarksInLists: true,
+ },
+ });
+ if (!res) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ return {
+ id: res.id,
+ name: res.name,
+ icon: res.icon,
+ bookmarks: res.bookmarksInLists.map((b) => b.bookmarkId),
+ };
+ }),
+ list: authedProcedure
+ .output(
+ z.object({
+ lists: z.array(zBookmarkListSchema),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const lists = await ctx.db.query.bookmarkLists.findMany({
+ where: and(eq(bookmarkLists.userId, ctx.user.id)),
+ });
+
+ return { lists };
+ }),
+});
diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts
new file mode 100644
index 00000000..87814407
--- /dev/null
+++ b/packages/trpc/routers/users.test.ts
@@ -0,0 +1,99 @@
+import {
+ CustomTestContext,
+ defaultBeforeEach,
+ getApiCaller,
+} from "../testUtils";
+import { expect, describe, test, beforeEach, assert } from "vitest";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(false));
+
+describe("User Routes", () => {
+ test<CustomTestContext>("create user", async ({ unauthedAPICaller }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ expect(user.name).toEqual("Test User");
+ expect(user.email).toEqual("test123@test.com");
+ });
+
+ test<CustomTestContext>("first user is admin", async ({
+ unauthedAPICaller,
+ }) => {
+ const user1 = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const user2 = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test124@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ expect(user1.role).toEqual("admin");
+ expect(user2.role).toEqual("user");
+ });
+
+ test<CustomTestContext>("unique emails", async ({ unauthedAPICaller }) => {
+ await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ await expect(() =>
+ unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ }),
+ ).rejects.toThrow(/Email is already taken/);
+ });
+
+ test<CustomTestContext>("privacy checks", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const adminUser = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const [user1, user2] = await Promise.all(
+ ["test1234@test.com", "test12345@test.com"].map((e) =>
+ unauthedAPICaller.users.create({
+ name: "Test User",
+ email: e,
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ }),
+ ),
+ );
+
+ assert(adminUser.role == "admin");
+ assert(user1.role == "user");
+ assert(user2.role == "user");
+
+ const user2Caller = getApiCaller(db, user2.id);
+
+ // A normal user can't delete other users
+ await expect(() =>
+ user2Caller.users.delete({
+ userId: user1.id,
+ }),
+ ).rejects.toThrow(/FORBIDDEN/);
+
+ // A normal user can't list all users
+ await expect(() => user2Caller.users.list()).rejects.toThrow(/FORBIDDEN/);
+ });
+});
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
new file mode 100644
index 00000000..b5334f99
--- /dev/null
+++ b/packages/trpc/routers/users.ts
@@ -0,0 +1,93 @@
+import { zSignUpSchema } from "../types/users";
+import { adminProcedure, publicProcedure, router } from "../index";
+import { SqliteError } from "@hoarder/db";
+import { z } from "zod";
+import { hashPassword } from "../auth";
+import { TRPCError } from "@trpc/server";
+import { users } from "@hoarder/db/schema";
+import { count, eq } from "drizzle-orm";
+
+export const usersAppRouter = router({
+ create: publicProcedure
+ .input(zSignUpSchema)
+ .output(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ role: z.enum(["user", "admin"]).nullable(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ // TODO: This is racy, but that's probably fine.
+ const [{ count: userCount }] = await ctx.db
+ .select({ count: count() })
+ .from(users);
+ try {
+ const result = await ctx.db
+ .insert(users)
+ .values({
+ name: input.name,
+ email: input.email,
+ password: await hashPassword(input.password),
+ role: userCount == 0 ? "admin" : "user",
+ })
+ .returning({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ });
+ return result[0];
+ } catch (e) {
+ if (e instanceof SqliteError) {
+ if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email is already taken",
+ });
+ }
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Something went wrong",
+ });
+ }
+ }),
+ list: adminProcedure
+ .output(
+ z.object({
+ users: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ role: z.enum(["user", "admin"]).nullable(),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const users = await ctx.db.query.users.findMany({
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ role: true,
+ },
+ });
+ return { users };
+ }),
+ delete: adminProcedure
+ .input(
+ z.object({
+ userId: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const res = await ctx.db.delete(users).where(eq(users.id, input.userId));
+ if (res.changes == 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ }),
+});
diff --git a/packages/trpc/testUtils.ts b/packages/trpc/testUtils.ts
new file mode 100644
index 00000000..d5f24def
--- /dev/null
+++ b/packages/trpc/testUtils.ts
@@ -0,0 +1,70 @@
+import { users } from "@hoarder/db/schema";
+import { getInMemoryDB } from "@hoarder/db/drizzle";
+import { appRouter } from "./routers/_app";
+import { createCallerFactory } from "./index";
+
+export function getTestDB() {
+ return getInMemoryDB(true);
+}
+
+export type TestDB = ReturnType<typeof getTestDB>;
+
+export async function seedUsers(db: TestDB) {
+ return await db
+ .insert(users)
+ .values([
+ {
+ name: "Test User 1",
+ email: "test1@test.com",
+ },
+ {
+ name: "Test User 2",
+ email: "test2@test.com",
+ },
+ ])
+ .returning();
+}
+
+export function getApiCaller(db: TestDB, userId?: string) {
+ const createCaller = createCallerFactory(appRouter);
+ return createCaller({
+ user: userId
+ ? {
+ id: userId,
+ role: "user",
+ }
+ : null,
+ db,
+ });
+}
+
+export type APICallerType = ReturnType<typeof getApiCaller>;
+
+export interface CustomTestContext {
+ apiCallers: APICallerType[];
+ unauthedAPICaller: APICallerType;
+ db: TestDB;
+}
+
+export async function buildTestContext(
+ seedDB: boolean,
+): Promise<CustomTestContext> {
+ const db = getTestDB();
+ let users: Awaited<ReturnType<typeof seedUsers>> = [];
+ if (seedDB) {
+ users = await seedUsers(db);
+ }
+ const callers = users.map((u) => getApiCaller(db, u.id));
+
+ return {
+ apiCallers: callers,
+ unauthedAPICaller: getApiCaller(db),
+ db,
+ };
+}
+
+export function defaultBeforeEach(seedDB: boolean = true) {
+ return async (context: object) => {
+ Object.assign(context, await buildTestContext(seedDB));
+ };
+}
diff --git a/packages/trpc/tsconfig.json b/packages/trpc/tsconfig.json
new file mode 100644
index 00000000..bf020b01
--- /dev/null
+++ b/packages/trpc/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@tsconfig/node21/tsconfig.json",
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"],
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "node",
+ "baseUrl": "./",
+ "esModuleInterop": true
+ }
+}
+
diff --git a/packages/trpc/types/bookmarks.ts b/packages/trpc/types/bookmarks.ts
new file mode 100644
index 00000000..b61ab0e0
--- /dev/null
+++ b/packages/trpc/types/bookmarks.ts
@@ -0,0 +1,70 @@
+import { z } from "zod";
+import { zBookmarkTagSchema } from "./tags";
+
+export const zBookmarkedLinkSchema = z.object({
+ type: z.literal("link"),
+ url: z.string().url(),
+ title: z.string().nullish(),
+ description: z.string().nullish(),
+ imageUrl: z.string().url().nullish(),
+ favicon: z.string().url().nullish(),
+ htmlContent: z.string().nullish(),
+ crawledAt: z.date().nullish(),
+});
+export type ZBookmarkedLink = z.infer<typeof zBookmarkedLinkSchema>;
+
+export const zBookmarkedTextSchema = z.object({
+ type: z.literal("text"),
+ text: z.string().max(2000),
+});
+export type ZBookmarkedText = z.infer<typeof zBookmarkedTextSchema>;
+
+export const zBookmarkContentSchema = z.discriminatedUnion("type", [
+ zBookmarkedLinkSchema,
+ zBookmarkedTextSchema,
+]);
+export type ZBookmarkContent = z.infer<typeof zBookmarkContentSchema>;
+
+export const zBareBookmarkSchema = z.object({
+ id: z.string(),
+ createdAt: z.date(),
+ archived: z.boolean(),
+ favourited: z.boolean(),
+ taggingStatus: z.enum(["success", "failure", "pending"]).nullable(),
+});
+
+export const zBookmarkSchema = zBareBookmarkSchema.merge(
+ z.object({
+ tags: z.array(zBookmarkTagSchema),
+ content: zBookmarkContentSchema,
+ }),
+);
+export type ZBookmark = z.infer<typeof zBookmarkSchema>;
+
+// POST /v1/bookmarks
+export const zNewBookmarkRequestSchema = zBookmarkContentSchema;
+export type ZNewBookmarkRequest = z.infer<typeof zNewBookmarkRequestSchema>;
+
+// GET /v1/bookmarks
+
+export const zGetBookmarksRequestSchema = z.object({
+ ids: z.array(z.string()).optional(),
+ archived: z.boolean().optional(),
+ favourited: z.boolean().optional(),
+});
+export type ZGetBookmarksRequest = z.infer<typeof zGetBookmarksRequestSchema>;
+
+export const zGetBookmarksResponseSchema = z.object({
+ bookmarks: z.array(zBookmarkSchema),
+});
+export type ZGetBookmarksResponse = z.infer<typeof zGetBookmarksResponseSchema>;
+
+// PATCH /v1/bookmarks/[bookmarkId]
+export const zUpdateBookmarksRequestSchema = z.object({
+ bookmarkId: z.string(),
+ archived: z.boolean().optional(),
+ favourited: z.boolean().optional(),
+});
+export type ZUpdateBookmarksRequest = z.infer<
+ typeof zUpdateBookmarksRequestSchema
+>;
diff --git a/packages/trpc/types/lists.ts b/packages/trpc/types/lists.ts
new file mode 100644
index 00000000..4b0ccaca
--- /dev/null
+++ b/packages/trpc/types/lists.ts
@@ -0,0 +1,18 @@
+import { z } from "zod";
+
+export const zBookmarkListSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ icon: z.string(),
+});
+
+export const zBookmarkListWithBookmarksSchema = zBookmarkListSchema.merge(
+ z.object({
+ bookmarks: z.array(z.string()),
+ }),
+);
+
+export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>;
+export type ZBookmarkListWithBookmarks = z.infer<
+ typeof zBookmarkListWithBookmarksSchema
+>;
diff --git a/packages/trpc/types/tags.ts b/packages/trpc/types/tags.ts
new file mode 100644
index 00000000..7a99dad4
--- /dev/null
+++ b/packages/trpc/types/tags.ts
@@ -0,0 +1,10 @@
+import { z } from "zod";
+
+export const zAttachedByEnumSchema = z.enum(["ai", "human"]);
+export type ZAttachedByEnum = z.infer<typeof zAttachedByEnumSchema>;
+export const zBookmarkTagSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ attachedBy: zAttachedByEnumSchema,
+});
+export type ZBookmarkTags = z.infer<typeof zBookmarkTagSchema>;
diff --git a/packages/trpc/types/users.ts b/packages/trpc/types/users.ts
new file mode 100644
index 00000000..c2fe182a
--- /dev/null
+++ b/packages/trpc/types/users.ts
@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+export const zSignUpSchema = z
+ .object({
+ name: z.string().min(1, { message: "Name can't be empty" }),
+ email: z.string().email(),
+ password: z.string().min(8),
+ confirmPassword: z.string(),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: "Passwords don't match",
+ path: ["confirmPassword"],
+ });
diff --git a/packages/trpc/vitest.config.ts b/packages/trpc/vitest.config.ts
new file mode 100644
index 00000000..c3d02f71
--- /dev/null
+++ b/packages/trpc/vitest.config.ts
@@ -0,0 +1,14 @@
+/// <reference types="vitest" />
+
+import { defineConfig } from "vitest/config";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [tsconfigPaths()],
+ test: {
+ alias: {
+ "@/*": "./*",
+ },
+ },
+});