Use Azure auth for no good reason

This commit is contained in:
2025-03-26 19:42:26 +01:00
parent 52933e617a
commit cec7d47c9e
17 changed files with 635 additions and 615 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.env
build
db.sqlite3
dist

View File

@@ -12,6 +12,7 @@
"@elysiajs/static": "catalog:",
"@elysiajs/swagger": "catalog:",
"common": "workspace:^",
"effect": "catalog:",
"elysia": "catalog:",
"kysely": "catalog:",
"kysely-bun-sqlite": "catalog:"

View File

@@ -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

View File

@@ -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<OAuthConfiguration>({
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<OAuthConfiguration>({
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<string>;
readonly idToken: Option.Option<string>;
readonly refreshToken: Option.Option<string>;
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<string>;
readonly userId: Option.Option<UserId>;
readonly roles: readonly string[];
}
}
export const revalidateTokens = Effect.fn("revaildateTokens")(
function* ({ accessToken, idToken, refreshToken, external }: revaildateTokens.Args) {
const accessTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.AccessTokenPayload>);
const idTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.IdTokenPayload>);
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<Model.IdTokenPayload>(it),
}));
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
accessToken: Option.map(accessToken, (at) => Object.freeze({
token: at,
payload: getJwtTokenPayload<Model.AccessTokenPayload>(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<O extends object = object>(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);
},
);

View File

@@ -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<UserId, UserId, never>;
username: string;
password: string;
admin: number;
}
export type AccessLog = Selectable<AccessLogTable>;
export type Attachment = Selectable<AttachmentTable>;
export type File = Selectable<FileTable>;
@@ -101,13 +98,12 @@ export type Piece = Selectable<PieceTable>;
export type Repertoire = Selectable<RepertoireTable>;
export type RepertoireEntry = Selectable<RepertoireEntryTable>;
export type Session = Selectable<SessionTable>;
export type User = Selectable<UserTable>;
function systemInformation<TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) {
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<Kys
.createTable("Session")
.ifNotExists()
.addColumn("sessionId", "text", (c) => 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();

View File

@@ -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>() => t.Transform(t.String())
.Decode(Function.unsafeCoerce<string, T>)
.Encode(Function.unsafeCoerce<T, string>);
@@ -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;

View File

@@ -0,0 +1,31 @@
import { Cause, Context, Effect, Layer, pipe } from "effect";
import { Kysely } from "kysely";
import { Database } from "../database";
export interface Executable<O> {
execute(): Promise<O[]>;
executeTakeFirst(): Promise<O | undefined>;
}
export interface DbInterface {
readonly db: Kysely<Database>;
readonly execute: <O>(executable: Executable<O>) => Effect.Effect<readonly O[]>;
readonly executeTakeFirst: <O>(executable: Executable<O>) => Effect.Effect<O, Cause.NoSuchElementException>;
readonly executeTakeFirstOrDefect: <O>(executable: Executable<O>) => Effect.Effect<O>;
}
export class Db extends Context.Tag("Db")<Db, DbInterface>() { }
export const DbFromInstance = (db: Kysely<Database>) => Layer.succeed(Db, Object.freeze<DbInterface>({
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,
),
}));

View File

@@ -0,0 +1,6 @@
import { SessionId } from "common";
import { Context, Layer } from "effect";
export class Session extends Context.Tag("Session")<Session, SessionId>() { }
export const SessionFromValue = Layer.succeed(Session);

View File

@@ -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,

View File

@@ -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(

View File

@@ -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<R extends Record<number, unknown>>(fn: () => Promise<
if (error !== null) {
if (error.status === 401) {
setUser(null);
navigate("/login");
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
return;
}

View File

@@ -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<string>;
readonly setLoginPassword: Updater<string>;
readonly setUser: Updater<Store.User | null>;
}
let store: Store = Object.freeze<Store>({
loginUsername: "",
loginPassword: "",
user: null,
setLoginUsername: (action) => set(mapProp("loginUsername", action)),
setLoginPassword: (action) => set(mapProp("loginPassword", action)),
setUser: (action) => set(mapProp("user", action)),
});

View File

@@ -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<HTMLFormElement> = 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 (
<div className="w-full h-full flex items-center justify-center">
<form onSubmit={onSubmit}>
<Card>
<CardHeader>
<CardTitle>Repozytorium muzyczne</CardTitle>
<CardDescription>Zaloguj się, aby kontynuować</CardDescription>
</CardHeader>
<CardContent>
<Label htmlFor={usernameId}>Nazwa użytkownika</Label>
<Input
id={usernameId}
className="w-[32ch]"
type="text"
autoComplete="username"
value={loginUsername}
autoFocus
required
onChange={(e) => setLoginUsername(e.target.value)}
/>
<Label htmlFor={passwordId}>Hasło</Label>
<Input
id={passwordId}
className="w-[32ch]"
type="password"
autoComplete="current-password"
value={loginPassword}
required
onChange={(e) => setLoginPassword(e.target.value)}
/>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Zaloguj się
</Button>
</CardFooter>
</Card>
</form>
</div>
);
}

View File

@@ -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 (
<div className="w-full h-full overflow-hidden flex items-center justify-center">
@@ -66,9 +53,6 @@ export function Root() {
<Settings />Ustawienia
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={onLogoutClick}>
<LogOut />Wyloguj się
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -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 (
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
<PasswordChangeCard />
</div>
);
}
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<HTMLFormElement> = 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 (
<form onSubmit={onSubmit}>
<Card>
<CardHeader>
<CardTitle>Zmiana hasła</CardTitle>
</CardHeader>
<CardContent>
<Label htmlFor={currentPasswordId}>Obecne hasło</Label>
<Input
id={currentPasswordId}
className="w-[32ch]"
type="password"
autoComplete="current-password"
value={currentPassword}
required
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<Label htmlFor={currentPasswordId}>Nowe hasło</Label>
<Input
id={newPassword1Id}
className="w-[32ch]"
type="password"
autoComplete="new-password"
value={newPassword1}
required
onChange={(e) => setNewPassword1(e.target.value)}
/>
<Label htmlFor={currentPasswordId}>Potwierdź hasło</Label>
<Input
id={newPassword2Id}
className="w-[32ch]"
type="password"
autoComplete="new-password"
value={newPassword2}
required
onChange={(e) => setNewPassword2(e.target.value)}
/>
</CardContent>
<CardFooter className="flex-row justify-end">
<Button type="submit" disabled={isLoading || newPassword1 !== newPassword2}>
{isLoading && <Loader2 className="animate-spin" />}
Zmień hasło
</Button>
</CardFooter>
</Card>
</form>
);
}

View File

@@ -36,7 +36,7 @@ export function created({ createdAt, createdBy }: SystemInformation): ReactNode
if (Option.isSome(createdBy)) {
nodes.push(<br />);
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(<br />);
nodes.push(`przez ${modifiedBy.value.username}`);
nodes.push(`przez ${modifiedBy.value.displayName}`);
}
return nodes;

108
pnpm-lock.yaml generated
View File

@@ -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