From cec7d47c9e3b2ee3d4418499f718499acb58a925 Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Wed, 26 Mar 2025 19:42:26 +0100 Subject: [PATCH] Use Azure auth for no good reason --- .gitignore | 1 + packages/backend/package.json | 1 + packages/backend/src/app.ts | 540 ++++++++++------------ packages/backend/src/auth.ts | 234 ++++++++++ packages/backend/src/database.ts | 27 +- packages/backend/src/model.ts | 51 +- packages/backend/src/services/db.ts | 31 ++ packages/backend/src/services/session.ts | 6 + packages/frontend/src/app.tsx | 5 - packages/frontend/src/cache.ts | 7 +- packages/frontend/src/hooks/useLoading.ts | 4 +- packages/frontend/src/hooks/useStore.ts | 16 +- packages/frontend/src/routes/Login.tsx | 95 ---- packages/frontend/src/routes/Root.tsx | 24 +- packages/frontend/src/routes/Settings.tsx | 94 ---- packages/frontend/src/snippets.tsx | 6 +- pnpm-lock.yaml | 108 +++-- 17 files changed, 635 insertions(+), 615 deletions(-) create mode 100644 packages/backend/src/auth.ts create mode 100644 packages/backend/src/services/db.ts create mode 100644 packages/backend/src/services/session.ts delete mode 100644 packages/frontend/src/routes/Login.tsx diff --git a/.gitignore b/.gitignore index 9d26a89..58a0cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env build db.sqlite3 dist diff --git a/packages/backend/package.json b/packages/backend/package.json index 73003ca..05351dd 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -12,6 +12,7 @@ "@elysiajs/static": "catalog:", "@elysiajs/swagger": "catalog:", "common": "workspace:^", + "effect": "catalog:", "elysia": "catalog:", "kysely": "catalog:", "kysely-bun-sqlite": "catalog:" diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 92b893f..b121682 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,11 +1,15 @@ import cors from "@elysiajs/cors"; import { staticPlugin } from "@elysiajs/static"; import { swagger } from "@elysiajs/swagger"; -import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex, UserId } from "common"; +import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex } from "common"; +import { Effect, Option, pipe } from "effect"; import { Elysia, error, t } from "elysia"; import { sql } from "kysely"; +import { CLIENT_ID, EXTERNAL_OAUTH_CONFIGURATION, getUser, INTERNAL_OAUTH_CONFIGURATION, makeAuthorizationUrl, REDIRECT_URI, revalidateTokens } from "./auth"; import * as Db from "./database"; import * as Model from "./model"; +import { DbFromInstance } from "./services/db"; +import { SessionFromValue } from "./services/session"; const app = new Elysia() @@ -36,37 +40,85 @@ const app = new Elysia() .where(sql`datetime()`, ">=", "expiresAt") .execute(); - const sessionId = cookie.sessionId.value; - if (sessionId === undefined) { - return { user: null }; - } + const sessionId = (cookie.sessionId.value as SessionId | undefined) ?? Db.generateSessionId(); - const res = await db + const expiresAt = new Date().getTime() + 604800000; + cookie.sessionId.set({ + value: sessionId, + expires: new Date(expiresAt), + httpOnly: true, + sameSite: "none", + secure: true, + }); + + const returning = [ + "sessionId", + "accessToken", + "codeVerifier", + "external", + "idToken", + "refreshToken", + "state", + ] as const; + + let session = await db .updateTable("Session") .set({ expiresAt: sql`datetime('now', '+7 days') ` }) - .where("sessionId", "=", SessionId(sessionId)) - .returning(["userId"]) + .where("sessionId", "=", sessionId) + .returning(returning) .executeTakeFirst(); - if (res === undefined) { - return { user: null }; + if (session === undefined) { + session = await db + .insertInto("Session") + .values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` }) + .returning(returning) + .executeTakeFirstOrThrow(); } - const user = await db - .selectFrom("User") - .select(["userId", "username", "admin"]) - .where("userId", "=", res.userId) - .executeTakeFirst(); + const { accessToken, idToken, refreshToken, roles, userId } = await pipe( + { + accessToken: Option.fromNullable(session.accessToken), + idToken: Option.fromNullable(session.idToken), + refreshToken: Option.fromNullable(session.refreshToken), + external: Boolean(session.external), + }, + revalidateTokens, + Effect.runPromise, + ); - if (user === undefined) { - return { user: null }; - } + await db + .updateTable("Session") + .set({ + accessToken: pipe( + accessToken, + Option.map((at) => at.token), + Option.getOrNull, + ), + idToken: pipe( + idToken, + Option.map((it) => it.token), + Option.getOrNull, + ), + refreshToken: Option.getOrNull(refreshToken), + }) + .execute(); return { - user: { - userId: user.userId, - username: user.username, - admin: user.admin !== 0, + session: { + sessionId: session.sessionId, + accessToken, + idToken, + refreshToken, + roles, + userId, + codeVerifier: Option.fromNullable(session.codeVerifier), + external: pipe( + session.external, + Option.fromNullable, + Option.map((e) => e !== 0), + ), + state: Option.fromNullable(session.state), }, }; }) @@ -100,66 +152,87 @@ const app = new Elysia() // --- MARK: AUTHENTICATION -------------------------------------------- - .get("/me", ({ user }) => { - - if (user === null) { - return error("Unauthorized", "Session invalid or expired"); - } - - return user; + .get("/me", ({ session: { idToken, roles } }) => { + return Option.match(idToken, { + onNone: () => error("Unauthorized", "Session invalid or expired"), + onSome: ({ payload: { oid, name } }) => ({ + userId: oid, + username: name, + roles: roles as string[], + }), + }); }, { response: { - 200: Model.User, + 200: Model.Me, 401: t.Literal("Session invalid or expired"), }, }) - .post("/login", async ({ db, body: { username, password }, cookie }) => { + .get("/login", async ({ db, query, redirect, session: { sessionId } }) => { - const user = await db - .selectFrom("User") - .selectAll() - .where("username", "=", username) - .executeTakeFirst(); + const url = await pipe( + makeAuthorizationUrl({ external: "external" in query }), + Effect.provide([ + DbFromInstance(db), + SessionFromValue(sessionId), + ]), + Effect.runPromise, + ); - if (user === undefined) { - return error("Unauthorized", "Invalid username or password"); - } - - const valid = await Bun.password.verify(password, user.password); - if (!valid) { - return error("Unauthorized", "Invalid username or password"); - } - - const sessionId = Db.generateSessionId(); - await db - .insertInto("Session") - .values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` }) - .execute(); - - const expiresAt = new Date().getTime() + 604800000; - cookie.sessionId.set({ - value: sessionId, - expires: new Date(expiresAt), - httpOnly: true, - sameSite: "none", - secure: true, - }); - - return { - userId: user.userId, - username: user.username, - admin: user.admin !== 0, - sessionId, - }; + return redirect(url, 302); }, { - body: t.Object({ - username: t.String({ minLength: 1 }), - password: t.String({ minLength: 1 }), - }), response: { - 200: Model.User, - 401: t.Literal("Invalid username or password"), + 302: t.Void(), + }, + }) + + .post("/login", async ({ db, request, redirect, session: { sessionId, external, codeVerifier } }) => { + const data = await request.formData(); + + const code = data.get("code") as string | null; + const state = data.get("state") as string | null; + + if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) { + const { tokenEndpoint } = external.value ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION; + + const res = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + "client_id": CLIENT_ID, + "code": code, + "redirect_uri": REDIRECT_URI, + "grant_type": "authorization_code", + "code_verifier": codeVerifier.value, + "client_secret": process.env.CLIENT_SECRET!, + }).toString(), + }); + + const { + access_token: accessToken, + refresh_token: refreshToken, + id_token: idToken, + } = await res.json() as { access_token: string, refresh_token: string, id_token: string }; + + await db + .updateTable("Session") + .set({ + accessToken, + refreshToken, + idToken, + codeVerifier: null, + state: null, + }) + .where("sessionId", "=", sessionId) + .execute(); + } + + return redirect(process.env.NODE_ENV === "production" ? "https://music.renati.me/" : "http://localhost:5173/", 303); + }, { + response: { + 303: t.Void(), }, }) @@ -180,142 +253,29 @@ const app = new Elysia() .where("sessionId", "=", SessionId(sessionId)) .execute(); }, { - response: { 204: t.Void(), }, }) - .post("/change-password", async ({ db, body: { username, currentPassword, newPassword }, set }) => { - - const user = await db - .selectFrom("User") - .selectAll() - .where("username", "=", username) - .executeTakeFirst(); - - if (user === undefined) { - return error("Unauthorized", "Invalid username or password"); - } - - const valid = await Bun.password.verify(currentPassword, user.password); - if (!valid) { - return error("Unauthorized", "Invalid username or password"); - } - - const password = await Bun.password.hash(newPassword); - - const res = await db - .updateTable("User") - .set({ password }) - .where("username", "=", username) - .returningAll() - .execute(); - - if (res.length === 0) { - return error("Unauthorized", "Invalid username or password"); - } - - set.status = "No Content"; - }, { - body: t.Object({ - username: t.String({ minLength: 1 }), - currentPassword: t.String({ minLength: 1 }), - newPassword: t.String({ minLength: 1 }), - }), - response: { - 204: t.Void(), - 401: t.Literal("Invalid username or password"), - }, - }) - // --- MARK: USER MANAGEMENT ------------------------------------------- - .post("/user", async ({ db, body: { username, password, admin }, user }) => { + .get("/user/:userId", async ({ params: { userId }, session: { accessToken } }) => { - if (user === null) { + if (Option.isNone(accessToken)) { return error("Unauthorized", "Session invalid or expired"); } - if (!user.admin) { - return error("Forbidden", "Operation allowed only for admin users"); - } + const res = await pipe( + { accessToken: accessToken.value.token, userId }, + getUser, + Effect.runPromise, + ); - const userId = UserId(Bun.randomUUIDv7()); - - const newUser = await db - .insertInto("User") - .values({ - userId, - username, - password: await Bun.password.hash(password), - admin: admin ? 1 : 0, - }) - .returningAll() - .executeTakeFirstOrThrow(); - - return { - userId: newUser.userId, - username: newUser.username, - admin: newUser.admin !== 0, - }; - }, { - body: Model.User_Post, - response: { - 200: Model.User, - 401: t.Literal("Session invalid or expired"), - 403: t.Literal("Operation allowed only for admin users"), - }, - }) - - .get("/user", async ({ db, query, user }) => { - - if (user === null) { - return error("Unauthorized", "Session invalid or expired"); - } - - let q = db - .selectFrom("User") - .select("userId") - .orderBy(["username", "userId"]) - .offset(query.offset ?? 0) - .limit(query.limit ?? 100); - - if (query.username !== undefined) { - q = q.where("username", "like", "%" + query.username + "%"); - } - - const res = await q.execute(); - return res.map(({ userId }) => userId); - }, { - query: Model.User_Query, - response: { - 200: t.Array(Model.UserId), - 401: t.Literal("Session invalid or expired"), - }, - }) - - .get("/user/:userId", async ({ db, params: { userId }, user }) => { - - if (user === null) { - return error("Unauthorized", "Session invalid or expired"); - } - - const res = await db - .selectFrom("User") - .select(["userId", "username", "admin"]) - .where("userId", "=", userId) - .executeTakeFirst(); - - if (res === undefined) { - return error("Not Found", undefined); - } - - return { - userId: res.userId, - username: res.username, - admin: res.admin !== 0, - }; + return Option.match(res, { + onNone: () => error("Not Found", undefined), + onSome: ({ displayName }) => ({ userId, displayName }), + }); }, { params: t.Object({ userId: Model.UserId, @@ -327,95 +287,23 @@ const app = new Elysia() }, }) - .patch("/user/:userId", async ({ db, body: { username, password, admin }, params: { userId }, user }) => { - - if (user === null) { - return error("Unauthorized", "Session invalid or expired"); - } - - if (!user.admin) { - return error("Forbidden", "Operation allowed only for admin users"); - } - - const res = await db - .updateTable("User") - .set({ - ...(username !== undefined ? { username } : undefined), - ...(password !== undefined ? { password: await Bun.password.hash(password) } : undefined), - ...(admin !== undefined ? { admin: admin ? 1 : 0 } : undefined), - }) - .where("userId", "=", userId) - .returning(["userId", "username", "admin"]) - .executeTakeFirst(); - - if (res === undefined) { - return error("Not Found", undefined); - } - - return { - userId: res.userId, - username: res.username, - admin: res.admin !== 0, - }; - }, { - body: Model.User_Patch, - params: t.Object({ - userId: Model.UserId, - }), - response: { - 200: Model.User, - 401: t.Literal("Session invalid or expired"), - 403: t.Literal("Operation allowed only for admin users"), - 404: t.Void(), - }, - }) - - .delete("/user/:userId", async ({ db, params: { userId }, set, user }) => { - - if (user === null) { - return error("Unauthorized", "Session invalid or expired"); - } - - if (!user.admin) { - return error("Forbidden", "Operation allowed only for admin users"); - } - - const res = await db - .deleteFrom("User") - .where("userId", "=", userId) - .returningAll() - .execute(); - - if (res.length === 0) { - return error("Not Found"); - } - - set.status = "No Content"; - }, { - params: t.Object({ - userId: Model.UserId, - }), - response: { - 204: t.Void(), - 401: t.Literal("Session invalid or expired"), - 403: t.Literal("Operation allowed only for admin users"), - 404: t.Void(), - }, - }) - // --- MARK: PIECE CRUD ------------------------------------------------ - .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => { + .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const pieceId = PieceId(Bun.randomUUIDv7()); const res = await db .insertInto("Piece") - .values({ pieceId, name, composer, lyricist, arranger, createdBy: user.userId, createdAt: sql`datetime()` }) + .values({ pieceId, name, composer, lyricist, arranger, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` }) .returningAll() .executeTakeFirstOrThrow(); @@ -428,12 +316,13 @@ const app = new Elysia() response: { 200: Model.Piece, 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Must be an Editor"), }, }) - .get("/piece", async ({ db, query, user }) => { + .get("/piece", async ({ db, query, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } @@ -466,9 +355,9 @@ const app = new Elysia() }, }) - .get("/piece/:pieceId", async ({ db, params: { pieceId }, user }) => { + .get("/piece/:pieceId", async ({ db, params: { pieceId }, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } @@ -506,15 +395,19 @@ const app = new Elysia() }, }) - .put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => { + .put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const res = await db .updateTable("Piece") - .set({ name, composer, lyricist, arranger, modifiedBy: user.userId, modifiedAt: sql`datetime()` }) + .set({ name, composer, lyricist, arranger, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` }) .where("pieceId", "=", pieceId) .returningAll() .execute(); @@ -549,16 +442,21 @@ const app = new Elysia() response: { 200: Model.Piece, 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Must be an Editor"), 404: t.Void(), }, }) - .delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => { + .delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const res = await db .deleteFrom("Piece") .where("pieceId", "=", pieceId) @@ -583,12 +481,16 @@ const app = new Elysia() // --- MARK: ATTACHMENT CRUD ------------------------------------------- - .post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => { + .post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const attachmentId = AttachmentId(Bun.randomUUIDv7()); const dataArray = new Uint8Array(await data.arrayBuffer()); @@ -603,7 +505,7 @@ const app = new Elysia() const res = await db .insertInto("Attachment") - .values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` }) + .values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` }) .returningAll() .executeTakeFirstOrThrow(); @@ -623,6 +525,7 @@ const app = new Elysia() response: { 200: Model.Attachment, 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Must be an Editor"), }, }) @@ -633,9 +536,9 @@ const app = new Elysia() * hash). Thus, we reflect the ownership in the URLs. */ - .get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => { + .get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, session: { idToken }, set }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } @@ -668,15 +571,19 @@ const app = new Elysia() }, }) - .put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => { + .put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const res = await db .updateTable("Attachment") - .set({ filename, modifiedBy: user.userId, modifiedAt: sql`datetime()` }) + .set({ filename, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` }) .where((eb) => eb.and([ eb("pieceId", "=", pieceId), eb("attachmentId", "=", attachmentId), @@ -703,16 +610,21 @@ const app = new Elysia() response: { 200: Model.Attachment, 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Must be an Editor"), 404: t.Void(), }, }) - .delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => { + .delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const res = await db .deleteFrom("Attachment") .where((eb) => eb.and([ @@ -735,23 +647,28 @@ const app = new Elysia() response: { 204: t.Void(), 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Must be an Editor"), 404: t.Void(), }, }) // --- MARK: REPERTOIRE CRUD ------------------------------------------- - .post("/repertoire", async ({ db, body: { name, entries }, user }) => { + .post("/repertoire", async ({ db, body: { name, entries }, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const repertoireId = RepertoireId(Bun.randomUUIDv7()); const repertoire = await db .insertInto("Repertoire") - .values({ repertoireId, name, createdBy: user.userId, createdAt: sql`datetime()` }) + .values({ repertoireId, name, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` }) .returningAll() .executeTakeFirstOrThrow(); @@ -779,12 +696,13 @@ const app = new Elysia() }), response: { 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Must be an Editor"), }, }) - .get("/repertoire", async ({ db, query, user }) => { + .get("/repertoire", async ({ db, query, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } @@ -809,9 +727,9 @@ const app = new Elysia() }, }) - .get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, user }) => { + .get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } @@ -847,15 +765,19 @@ const app = new Elysia() }, }) - .put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, user }) => { + .put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const res = await db .updateTable("Repertoire") - .set({ name, modifiedBy: user.userId, modifiedAt: sql`datetime()` }) + .set({ name, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` }) .where("repertoireId", "=", repertoireId) .returningAll() .execute(); @@ -893,16 +815,21 @@ const app = new Elysia() response: { 200: Model.Repertoire, 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Must be an Editor"), 404: t.Void(), }, }) - .delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, user }) => { + .delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, session: { idToken } }) => { - if (user === null) { + if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } + if (!idToken.value.payload.roles.includes("Editor")) { + return error("Forbidden", "Must be an Editor"); + } + const res = await db .deleteFrom("Repertoire") .where("repertoireId", "=", repertoireId) @@ -921,6 +848,7 @@ const app = new Elysia() response: { 204: t.Void(), 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Must be an Editor"), 404: t.Void(), }, // eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here diff --git a/packages/backend/src/auth.ts b/packages/backend/src/auth.ts new file mode 100644 index 0000000..b116409 --- /dev/null +++ b/packages/backend/src/auth.ts @@ -0,0 +1,234 @@ +import { UserId } from "common"; +import { DateTime, Duration, Effect, Option, pipe } from "effect"; +import { constant } from "effect/Function"; +import * as Model from "./model"; +import { Db } from "./services/db"; +import { Session } from "./services/session"; + +export const TENANT_ID = "0817c403-92e4-4648-a9aa-f688ffc5f97a"; +export const TENANT_SUBDOMAIN = "chkvoxastra"; + +export const CLIENT_ID = "e5948f7d-187b-44f9-80cd-63ffda86f9be"; +export const OAUTH_SCOPE = "email offline_access openid profile https://graph.microsoft.com/User.Read.All"; + +export const REDIRECT_URI = process.env.NODE_ENV === "production" ? "https://music.renati.me/api/v1/login" : "http://localhost:3000/api/v1/login"; + +export const EXPIRATION_BUFFER = Duration.seconds(10); + +export interface OAuthConfiguration { + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; +} + +export const INTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze({ + authorizationEndpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize`, + tokenEndpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`, +}); + +export const EXTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze({ + authorizationEndpoint: `https://${TENANT_SUBDOMAIN}.ciamlogin.com/${TENANT_ID}/oauth2/v2.0/authorize`, + tokenEndpoint: `https://${TENANT_SUBDOMAIN}.ciamlogin.com/${TENANT_ID}/oauth2/v2.0/token`, +}); + +export namespace makeAuthorizationUrl { + export interface Args { + readonly external: boolean; + } +} + +export const makeAuthorizationUrl = Effect.fn("makeAuthorizationUrl")( + function* ({ external }: makeAuthorizationUrl.Args) { + const { db, execute } = yield* Db; + const sessionId = yield* Session; + + const { codeVerifier, codeChallenge } = generateCodeVerifier(); + const state = generateRandomState(); + + yield* db + .updateTable("Session") + .set({ + codeVerifier, + state, + external: external ? 1 : 0, + accessToken: null, + idToken: null, + refreshToken: null, + }) + .where("sessionId", "=", sessionId) + .$call(execute); + + const { authorizationEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION; + + const url = new URL(authorizationEndpoint); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", OAUTH_SCOPE); + url.searchParams.set("response_mode", "form_post"); + url.searchParams.set("state", state); + url.searchParams.set("prompt", "select_account"); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + + return url.toString(); + }, +); + +export namespace revaildateTokens { + export interface Args { + readonly accessToken: Option.Option; + readonly idToken: Option.Option; + readonly refreshToken: Option.Option; + readonly external: boolean; + } + + export interface Result { + readonly accessToken: Option.Option<{ + readonly token: string, + readonly payload: Model.AccessTokenPayload, + }>; + readonly idToken: Option.Option<{ + readonly token: string, + readonly payload: Model.IdTokenPayload, + }>; + readonly refreshToken: Option.Option; + readonly userId: Option.Option; + readonly roles: readonly string[]; + } +} + +export const revalidateTokens = Effect.fn("revaildateTokens")( + function* ({ accessToken, idToken, refreshToken, external }: revaildateTokens.Args) { + + const accessTokenPayload = Option.map(accessToken, getJwtTokenPayload); + const idTokenPayload = Option.map(accessToken, getJwtTokenPayload); + + const expirationThreshold = yield* pipe( + DateTime.now, + Effect.map(DateTime.addDuration(EXPIRATION_BUFFER)), + ); + + // Token expired or missing + if (Option.match(accessTokenPayload, { + onNone: constant(false), + onSome: (atp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * atp.exp)), + }) || Option.match(idTokenPayload, { + onNone: constant(false), + onSome: (itp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * itp.exp)), + })) { + + accessToken = Option.none(); + idToken = Option.none(); + + // try refreshing + if (Option.isSome(refreshToken)) { + const refreshTokenValue = refreshToken.value; + const { tokenEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION; + + const res = yield* Effect.promise((signal) => fetch(tokenEndpoint, { + method: "POST", + signal, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + "client_id": CLIENT_ID, + "grant_type": "refresh_token", + "refresh_token": refreshTokenValue, + "client_secret": process.env.CLIENT_SECRET!, + }).toString(), + })); + + const json = (yield* Effect.promise(() => res.json())) as { access_token: string, refresh_token: string, id_token: string }; + + accessToken = Option.some(json.access_token); + idToken = Option.some(json.id_token); + refreshToken = Option.some(json.refresh_token); + } + } + + const it = Option.map(idToken, (it) => Object.freeze({ + token: it, + payload: getJwtTokenPayload(it), + })); + + const res: revaildateTokens.Result = Object.freeze({ + accessToken: Option.map(accessToken, (at) => Object.freeze({ + token: at, + payload: getJwtTokenPayload(at), + })), + idToken: it, + refreshToken, + userId: Option.map(it, ({ payload: { oid } }) => oid), + roles: Option.match(it, { + onNone: constant(Object.freeze([])), + onSome: ({ payload: { roles } }) => roles, + }), + }); + + return res; + }, +); + +function getJwtTokenPayload(token: string): O { + return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8")) as O; +} + +export namespace generateCodeVerifier { + export interface Result { + codeVerifier: string; + codeChallenge: string; + } +} + +export function generateCodeVerifier(byteLength: number = 32): generateCodeVerifier.Result { + const codeVerifierBytes = new Uint8Array(byteLength); + crypto.getRandomValues(codeVerifierBytes); + const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url"); + + const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii"); + const codeVerifierAsciiArray = new Uint8Array( + codeVerifierAsciiBuffer.buffer, + codeVerifierAsciiBuffer.byteOffset, + codeVerifierAsciiBuffer.length, + ); + const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url"); + + return { codeVerifier, codeChallenge }; +} + +export function generateRandomState(byteLength: number = 32): string { + const array = new Uint8Array(byteLength); + crypto.getRandomValues(array); + const state = Buffer.from(array).toString("base64url"); + return state; +} + +export namespace getUser { + export interface Args { + readonly accessToken: string; + readonly userId: UserId; + } + + export interface Result { + readonly displayName: string; + } +} + +export const getUser = Effect.fn("getUser")( + function* ({ accessToken, userId }: getUser.Args) { + const res = yield* Effect.promise((signal) => fetch(`https://graph.microsoft.com/v1.0/users/${userId}?$select=displayName`, { + signal, + headers: { + "Authorization": `Bearer ${accessToken}`, + }, + })); + + if (res.status === 404) { + return Option.none(); + } + + const json = (yield* Effect.promise(() => res.json())) as getUser.Result; + return Option.some(json); + }, +); diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index e8593b5..f221333 100644 --- a/packages/backend/src/database.ts +++ b/packages/backend/src/database.ts @@ -18,7 +18,6 @@ export interface Database { Repertoire: RepertoireTable; RepertoireEntry: RepertoireEntryTable; Session: SessionTable; - User: UserTable; } export interface SystemInformation { @@ -79,7 +78,12 @@ export interface RepertoireEntryTable extends RepertoireEntryData { } export interface SessionData { - userId: UserId; + state: string | null; + codeVerifier: string | null; + accessToken: string | null; + idToken: string | null; + refreshToken: string | null; + external: number | null; } export interface SessionTable extends SessionData { @@ -87,13 +91,6 @@ export interface SessionTable extends SessionData { expiresAt: string; } -export interface UserTable { - userId: ColumnType; - username: string; - password: string; - admin: number; -} - export type AccessLog = Selectable; export type Attachment = Selectable; export type File = Selectable; @@ -101,13 +98,12 @@ export type Piece = Selectable; export type Repertoire = Selectable; export type RepertoireEntry = Selectable; export type Session = Selectable; -export type User = Selectable; function systemInformation(schema: CreateTableBuilder) { return schema - .addColumn("createdBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade")) + .addColumn("createdBy", "text") .addColumn("createdAt", "text", (c) => c.notNull()) - .addColumn("modifiedBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade")) + .addColumn("modifiedBy", "text") .addColumn("modifiedAt", "text"); } @@ -199,7 +195,12 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise c.notNull().primaryKey()) - .addColumn("userId", "text", (c) => c.references("User.userId").onDelete("cascade").onUpdate("cascade")) + .addColumn("state", "text") + .addColumn("codeVerifier", "text") + .addColumn("accessToken", "text") + .addColumn("idToken", "text") + .addColumn("refreshToken", "text") + .addColumn("external", "boolean") .addColumn("expiresAt", "text", (c) => c.notNull()) .execute(); diff --git a/packages/backend/src/model.ts b/packages/backend/src/model.ts index a0136a4..82c056a 100644 --- a/packages/backend/src/model.ts +++ b/packages/backend/src/model.ts @@ -2,6 +2,27 @@ import * as Common from "common"; import * as Function from "common/Function"; import { t } from "elysia"; +export interface AccessTokenPayload { + readonly aud: string; + readonly iss: string; + readonly iat: number; + readonly nbf: number; + readonly exp: number; + readonly name: string; + readonly oid: Common.UserId; +} + +export interface IdTokenPayload { + readonly aud: string; + readonly iss: string; + readonly iat: number; + readonly nbf: number; + readonly exp: number; + readonly name: string; + readonly oid: Common.UserId; + readonly roles: readonly string[]; +} + const brandedString = () => t.Transform(t.String()) .Decode(Function.unsafeCoerce) .Encode(Function.unsafeCoerce); @@ -44,6 +65,12 @@ export const Attachment = t.Object({ ...SystemInformation, }); +export const Me = t.Object({ + userId: UserId, + username: t.String(), + roles: t.Array(t.String()), +}); + export const Piece = t.Object({ pieceId: PieceId, name: t.String({ minLength: 1 }), @@ -81,35 +108,15 @@ export const Repertoire_Query = t.Object({ export const User = t.Object({ userId: UserId, - username: t.String(), - admin: t.Boolean(), -}); - -export const User_Patch = t.Object({ - username: t.Optional(t.String()), - password: t.Optional(t.String({ minLength: 8 })), - admin: t.Optional(t.Boolean()), -}); - -export const User_Post = t.Object({ - username: t.String(), - password: t.String({ minLength: 8 }), - admin: t.Boolean(), -}); - -export const User_Query = t.Object({ - username: t.Optional(t.String()), - ...Pagination, + displayName: t.String(), }); export type AccessLog = typeof AccessLog.static; export type Attachment = typeof Attachment.static; +export type Me = typeof Me.static; export type Piece = typeof Piece.static; export type Piece_Post = typeof Piece_Post.static; export type Piece_Query = typeof Piece_Query.static; export type Repertoire = typeof Repertoire.static; export type Repertoire_Query = typeof Repertoire_Query.static; export type User = typeof User.static; -export type User_Patch = typeof User_Patch.static; -export type User_Post = typeof User_Post.static; -export type User_Query = typeof User_Query.static; diff --git a/packages/backend/src/services/db.ts b/packages/backend/src/services/db.ts new file mode 100644 index 0000000..daa2aaa --- /dev/null +++ b/packages/backend/src/services/db.ts @@ -0,0 +1,31 @@ +import { Cause, Context, Effect, Layer, pipe } from "effect"; +import { Kysely } from "kysely"; +import { Database } from "../database"; + +export interface Executable { + execute(): Promise; + executeTakeFirst(): Promise; +} + +export interface DbInterface { + readonly db: Kysely; + readonly execute: (executable: Executable) => Effect.Effect; + readonly executeTakeFirst: (executable: Executable) => Effect.Effect; + readonly executeTakeFirstOrDefect: (executable: Executable) => Effect.Effect; +} + +export class Db extends Context.Tag("Db")() { } + +export const DbFromInstance = (db: Kysely) => Layer.succeed(Db, Object.freeze({ + db, + execute: (executable) => Effect.promise(() => Object.freeze(executable.execute())), + executeTakeFirst: (executable) => pipe( + Effect.promise(() => executable.executeTakeFirst()), + Effect.flatMap(Effect.fromNullable), + ), + executeTakeFirstOrDefect: (executable) => pipe( + Effect.promise(() => executable.executeTakeFirst()), + Effect.flatMap(Effect.fromNullable), + Effect.orDie, + ), +})); diff --git a/packages/backend/src/services/session.ts b/packages/backend/src/services/session.ts new file mode 100644 index 0000000..8491e19 --- /dev/null +++ b/packages/backend/src/services/session.ts @@ -0,0 +1,6 @@ +import { SessionId } from "common"; +import { Context, Layer } from "effect"; + +export class Session extends Context.Tag("Session")() { } + +export const SessionFromValue = Layer.succeed(Session); diff --git a/packages/frontend/src/app.tsx b/packages/frontend/src/app.tsx index cab2589..3374a23 100644 --- a/packages/frontend/src/app.tsx +++ b/packages/frontend/src/app.tsx @@ -1,5 +1,4 @@ import { Home } from "@/routes/Home"; -import { Login } from "@/routes/Login"; import { Piece } from "@/routes/Piece"; import { Pieces } from "@/routes/Pieces"; import { Repertoire } from "@/routes/Repertoire"; @@ -63,10 +62,6 @@ const router = createBrowserRouter([ }, ], }, - { - path: "/login", - Component: Login, - }, ], { future: { v7_fetcherPersist: true, diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index 89caacb..1c165b7 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -5,8 +5,7 @@ import { client, mapResponse } from "./client"; export interface User { readonly userId: UserId; - readonly username: string; - readonly admin: boolean; + readonly displayName: string; } export interface SystemInformation { @@ -108,7 +107,7 @@ export const denormalizeRepertoire = ({ ); const UserSemaphore = Effect.unsafeMakeSemaphore(1); -const CacheSemaphore = Effect.unsafeMakeSemaphore(4); +const PieceSemaphore = Effect.unsafeMakeSemaphore(4); const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1); export const userLookup = (userId: UserId) => pipe( @@ -123,7 +122,7 @@ export const pieceLookup = (pieceId: PieceId) => pipe( Effect.flatMap(mapResponse), Effect.flatMap(denormalizePiece), Effect.map((x): Piece => x), // safely coerce to interface - CacheSemaphore.withPermits(1), + PieceSemaphore.withPermits(1), ); export const repertoireLookup = (repertoireId: RepertoireId) => pipe( diff --git a/packages/frontend/src/hooks/useLoading.ts b/packages/frontend/src/hooks/useLoading.ts index 9a53226..850dbd8 100644 --- a/packages/frontend/src/hooks/useLoading.ts +++ b/packages/frontend/src/hooks/useLoading.ts @@ -1,3 +1,4 @@ +import { API_URL_PREFIX } from "@/client"; import { mapProp, Update, Updater, useStore } from "@/hooks/useStore"; import { Treaty } from "@elysiajs/eden"; import { Effect, Fiber, pipe } from "effect"; @@ -57,8 +58,7 @@ export function useLoading>(fn: () => Promise< if (error !== null) { if (error.status === 401) { - setUser(null); - navigate("/login"); + window.location.href = `${API_URL_PREFIX}/api/v1/login`; return; } diff --git a/packages/frontend/src/hooks/useStore.ts b/packages/frontend/src/hooks/useStore.ts index 4ddf40d..b7fb2d1 100644 --- a/packages/frontend/src/hooks/useStore.ts +++ b/packages/frontend/src/hooks/useStore.ts @@ -13,31 +13,17 @@ export namespace Store { export interface User { readonly userId: UserId; readonly username: string; - readonly admin: boolean; + readonly roles: readonly string[]; } } export interface Store { - readonly loginUsername: string; - readonly loginPassword: string; - readonly user: Store.User | null; - - readonly setLoginUsername: Updater; - readonly setLoginPassword: Updater; - readonly setUser: Updater; } let store: Store = Object.freeze({ - loginUsername: "", - loginPassword: "", - user: null, - - setLoginUsername: (action) => set(mapProp("loginUsername", action)), - setLoginPassword: (action) => set(mapProp("loginPassword", action)), - setUser: (action) => set(mapProp("user", action)), }); diff --git a/packages/frontend/src/routes/Login.tsx b/packages/frontend/src/routes/Login.tsx deleted file mode 100644 index d701e23..0000000 --- a/packages/frontend/src/routes/Login.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { client } from "@/client"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { useStore } from "@/hooks/useStore"; -import { Loader2 } from "lucide-react"; -import { FormEventHandler, useId, useState } from "react"; -import { useNavigate } from "react-router-dom"; - -export function Login() { - - const navigate = useNavigate(); - - const loginUsername = useStore(state => state.loginUsername); - const loginPassword = useStore(state => state.loginPassword); - - const setLoginUsername = useStore(state => state.setLoginUsername); - const setLoginPassword = useStore(state => state.setLoginPassword); - - const setUser = useStore(state => state.setUser); - - const usernameId = useId(); - const passwordId = useId(); - - const [isLoading, setIsLoading] = useState(false); - - const onSubmit: FormEventHandler = async (e) => { - e.preventDefault(); - - try { - setIsLoading(true); - - const { data, error } = await client.login.post({ - username: loginUsername, - password: loginPassword, - }); - - if (error !== null) { - console.error(error.value); - return; - } - - setLoginUsername(""); - setLoginPassword(""); - setUser(data); - - navigate("/"); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
- - - Repozytorium muzyczne - Zaloguj się, aby kontynuować - - - - setLoginUsername(e.target.value)} - /> - - setLoginPassword(e.target.value)} - /> - - - - - -
-
- ); -} diff --git a/packages/frontend/src/routes/Root.tsx b/packages/frontend/src/routes/Root.tsx index 5fe74f5..6aaa8b6 100644 --- a/packages/frontend/src/routes/Root.tsx +++ b/packages/frontend/src/routes/Root.tsx @@ -1,15 +1,13 @@ -import { client } from "@/client"; +import { API_URL_PREFIX, client } from "@/client"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { useStore } from "@/hooks/useStore"; -import { LogOut, Settings, User } from "lucide-react"; +import { Settings, User } from "lucide-react"; import { useEffect } from "react"; -import { Link, Outlet, useNavigate } from "react-router-dom"; +import { Link, Outlet } from "react-router-dom"; export function Root() { - const navigate = useNavigate(); - const user = useStore(state => state.user); const setUser = useStore(state => state.setUser); @@ -19,7 +17,7 @@ export function Root() { const { data, error } = await client.me.get(); if (error !== null) { - navigate("/login"); + window.location.href = `${API_URL_PREFIX}/api/v1/login`; return; } @@ -29,17 +27,6 @@ export function Root() { // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => void init(), []); - const onLogoutClick = async () => { - const { error } = await client.logout.post(); - - if (error !== null) { - console.error(error.value); - } - - setUser(null); - navigate("/login"); - }; - if (user === null) { return (
@@ -66,9 +53,6 @@ export function Root() { Ustawienia - - Wyloguj się -
diff --git a/packages/frontend/src/routes/Settings.tsx b/packages/frontend/src/routes/Settings.tsx index dfe9b1f..b157724 100644 --- a/packages/frontend/src/routes/Settings.tsx +++ b/packages/frontend/src/routes/Settings.tsx @@ -1,100 +1,6 @@ -import { client } from "@/client"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { useStore } from "@/hooks/useStore"; -import { Label } from "@radix-ui/react-label"; -import { Loader2 } from "lucide-react"; -import { FormEventHandler, useId, useState } from "react"; - export function Settings() { return (
-
); } - -function PasswordChangeCard() { - - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword1, setNewPassword1] = useState(""); - const [newPassword2, setNewPassword2] = useState(""); - - const currentPasswordId = useId(); - const newPassword1Id = useId(); - const newPassword2Id = useId(); - - const [isLoading, setIsLoading] = useState(false); - - const user = useStore(store => store.user); - - const onSubmit: FormEventHandler = async (e) => { - e.preventDefault(); - - try { - setIsLoading(true); - - const { error } = await client["change-password"].post({ - username: user!.username, - currentPassword, - newPassword: newPassword1, - }); - - if (error !== null) { - console.error(error.value); - return; - } - } finally { - setIsLoading(false); - } - }; - - return ( -
- - - Zmiana hasła - - - - setCurrentPassword(e.target.value)} - /> - - setNewPassword1(e.target.value)} - /> - - setNewPassword2(e.target.value)} - /> - - - - - -
- ); -} diff --git a/packages/frontend/src/snippets.tsx b/packages/frontend/src/snippets.tsx index f29c47e..f1ea2f6 100644 --- a/packages/frontend/src/snippets.tsx +++ b/packages/frontend/src/snippets.tsx @@ -36,7 +36,7 @@ export function created({ createdAt, createdBy }: SystemInformation): ReactNode if (Option.isSome(createdBy)) { nodes.push(
); - nodes.push(`przez ${createdBy.value.username}`); + nodes.push(`przez ${createdBy.value.displayName}`); } return nodes; @@ -48,7 +48,7 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo if (Option.isNone(modifiedBy)) { return "\u2014"; } else { - return `przez ${modifiedBy.value.username}`; + return `przez ${modifiedBy.value.displayName}`; } } @@ -56,7 +56,7 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo if (Option.isSome(modifiedBy)) { nodes.push(
); - nodes.push(`przez ${modifiedBy.value.username}`); + nodes.push(`przez ${modifiedBy.value.displayName}`); } return nodes; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bee8091..0a281d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: common: specifier: workspace:^ version: link:../common + effect: + specifier: 'catalog:' + version: 3.11.4 elysia: specifier: 'catalog:' version: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2) @@ -507,32 +510,42 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.5.1': + resolution: {integrity: sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.19.1': - resolution: {integrity: sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==} + '@eslint/config-array@0.19.2': + resolution: {integrity: sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.12.0': + resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/core@0.9.1': resolution: {integrity: sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.2.0': - resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==} + '@eslint/eslintrc@3.3.0': + resolution: {integrity: sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.17.0': resolution: {integrity: sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.5': - resolution: {integrity: sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==} + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.2.4': - resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==} + '@eslint/plugin-kit@0.2.7': + resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@floating-ui/core@1.6.8': @@ -569,8 +582,8 @@ packages: resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} engines: {node: '>=18.18'} - '@humanwhocodes/retry@0.4.1': - resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} + '@humanwhocodes/retry@0.4.2': + resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} '@isaacs/cliui@8.0.2': @@ -1347,6 +1360,15 @@ packages: supports-color: optional: true + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1440,8 +1462,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-scope@8.2.0: - resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==} + eslint-scope@8.3.0: + resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: @@ -1531,8 +1553,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.2: - resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} @@ -1659,8 +1681,8 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} imurmurhash@0.1.4: @@ -2760,28 +2782,37 @@ snapshots: eslint: 9.17.0(jiti@1.21.6) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.5.1(eslint@9.17.0(jiti@1.21.6))': + dependencies: + eslint: 9.17.0(jiti@1.21.6) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.1': {} - '@eslint/config-array@0.19.1': + '@eslint/config-array@0.19.2': dependencies: - '@eslint/object-schema': 2.1.5 - debug: 4.3.7 + '@eslint/object-schema': 2.1.6 + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color + '@eslint/core@0.12.0': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/core@0.9.1': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.2.0': + '@eslint/eslintrc@3.3.0': dependencies: ajv: 6.12.6 - debug: 4.3.7 + debug: 4.4.0 espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2 - import-fresh: 3.3.0 + import-fresh: 3.3.1 js-yaml: 4.1.0 minimatch: 3.1.2 strip-json-comments: 3.1.1 @@ -2790,10 +2821,11 @@ snapshots: '@eslint/js@9.17.0': {} - '@eslint/object-schema@2.1.5': {} + '@eslint/object-schema@2.1.6': {} - '@eslint/plugin-kit@0.2.4': + '@eslint/plugin-kit@0.2.7': dependencies: + '@eslint/core': 0.12.0 levn: 0.4.1 '@floating-ui/core@1.6.8': @@ -2827,7 +2859,7 @@ snapshots: '@humanwhocodes/retry@0.3.1': {} - '@humanwhocodes/retry@0.4.1': {} + '@humanwhocodes/retry@0.4.2': {} '@isaacs/cliui@8.0.2': dependencies: @@ -3620,6 +3652,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.0: + dependencies: + ms: 2.1.3 + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -3717,7 +3753,7 @@ snapshots: dependencies: eslint: 9.17.0(jiti@1.21.6) - eslint-scope@8.2.0: + eslint-scope@8.3.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 @@ -3728,24 +3764,24 @@ snapshots: eslint@9.17.0(jiti@1.21.6): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.6)) + '@eslint-community/eslint-utils': 4.5.1(eslint@9.17.0(jiti@1.21.6)) '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.19.1 + '@eslint/config-array': 0.19.2 '@eslint/core': 0.9.1 - '@eslint/eslintrc': 3.2.0 + '@eslint/eslintrc': 3.3.0 '@eslint/js': 9.17.0 - '@eslint/plugin-kit': 0.2.4 + '@eslint/plugin-kit': 0.2.7 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.1 + '@humanwhocodes/retry': 0.4.2 '@types/estree': 1.0.6 '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.3.7 + debug: 4.4.0 escape-string-regexp: 4.0.0 - eslint-scope: 8.2.0 + eslint-scope: 8.3.0 eslint-visitor-keys: 4.2.0 espree: 10.3.0 esquery: 1.6.0 @@ -3833,10 +3869,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.2 + flatted: 3.3.3 keyv: 4.5.4 - flatted@3.3.2: {} + flatted@3.3.3: {} foreground-child@3.3.0: dependencies: @@ -3992,7 +4028,7 @@ snapshots: immediate@3.0.6: {} - import-fresh@3.3.0: + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0