User CRUD on backend and refactors

This commit is contained in:
2025-10-09 16:25:15 +02:00
parent 486ff6279d
commit 2f96b55205
7 changed files with 246 additions and 174 deletions

View File

@@ -1,6 +1,6 @@
import * as Body from "common/Body"; import * as Body from "common/Body";
import { fetch } from "common/Fetch"; import { fetch } from "common/Fetch";
import { Cause, Effect, Layer, Option, pipe, Record, Redacted, Stream } from "effect"; import { Cause, Effect, Layer, Match, Option, pipe, Record, Redacted, Stream } from "effect";
import * as path from "node:path"; import * as path from "node:path";
import { config } from "./config"; import { config } from "./config";
import * as Authentication from "./services/Authentication"; import * as Authentication from "./services/Authentication";
@@ -10,6 +10,17 @@ import { handle } from "./the_api";
const FRONTEND_ROOT = "packages/frontend/build"; const FRONTEND_ROOT = "packages/frontend/build";
const FRONTEND_ASSETS_ROOT = path.join(FRONTEND_ROOT, "assets"); const FRONTEND_ASSETS_ROOT = path.join(FRONTEND_ROOT, "assets");
const CORS_HEADERS: [string, string][] = Match.value(config.NODE_ENV).pipe(
Match.when("development", (): [string, string][] => [
["Access-Control-Allow-Origin", "http://localhost:5173"],
["Access-Control-Allow-Methods", "POST, OPTIONS"],
["Access-Control-Allow-Credentials", "true"],
["Access-Control-Allow-Headers", "Content-Type"],
]),
Match.when("production", () => []),
Match.exhaustive,
);
const assetRoutes = await pipe( const assetRoutes = await pipe(
Stream.fromAsyncIterable( Stream.fromAsyncIterable(
new Bun.Glob("**/*").scan(FRONTEND_ASSETS_ROOT), new Bun.Glob("**/*").scan(FRONTEND_ASSETS_ROOT),
@@ -24,81 +35,28 @@ const assetRoutes = await pipe(
Effect.runPromise, Effect.runPromise,
); );
const CORS_HEADERS: [string, string][] = [
["Access-Control-Allow-Origin", "http://localhost:5173"],
["Access-Control-Allow-Methods", "POST, OPTIONS"],
["Access-Control-Allow-Credentials", "true"],
["Access-Control-Allow-Headers", "Content-Type"],
];
const homepage = new Response(Bun.file(path.join(FRONTEND_ROOT, "index.html"))); const homepage = new Response(Bun.file(path.join(FRONTEND_ROOT, "index.html")));
const databaseLayer = Database.FromPath(config.DB_PATH); const databaseLayer = Database.FromPath(config.DB_PATH);
const login = (code: string | null, state: string | null) => Effect.gen(function* () {
const { sessionId } = yield* Authentication.Authentication;
const db = yield* Database.Database;
const session = yield* db
.selectFrom("Session")
.select(["codeVerifier"])
.where("sessionId", "=", sessionId)
.$call(Database.executeTakeFirst);
const codeVerifier = Option.fromNullable(session.codeVerifier);
if (code !== null && state !== null && Option.isSome(codeVerifier)) {
const res = yield* fetch(config.OAUTH_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
"client_id": config.CLIENT_ID,
"code": code,
"redirect_uri": Authentication.REDIRECT_URI,
"grant_type": "authorization_code",
"code_verifier": codeVerifier.value,
"client_secret": Redacted.value(config.CLIENT_SECRET),
}).toString(),
});
const body = yield* Body.json(res);
const { id_token: idToken } = body as { id_token: string };
const idTokenPayload = yield* pipe(
idToken,
Authentication.getJwtTokenPayload(Authentication.IdTokenPayload),
);
const { userId } = yield* Authentication.upsertUser(idTokenPayload);
yield* db
.updateTable("Session")
.set({
codeVerifier: null,
state: null,
userId,
})
.where("sessionId", "=", sessionId)
.$call(Database.execute);
}
});
Bun.serve({ Bun.serve({
routes: { routes: {
...assetRoutes, ...assetRoutes,
"/login": { "/login": {
GET: (req) => Effect.gen(function* () { GET: (req) => Effect.gen(function* () {
const searchParams = new URL(req.url).searchParams; const searchParams = new URL(req.url).searchParams;
// Callback URL with query response type
if (searchParams.has("code") || searchParams.has("state")) { if (searchParams.has("code") || searchParams.has("state")) {
const code = searchParams.get("code"); const code = searchParams.get("code");
const state = searchParams.get("state"); const state = searchParams.get("state");
yield* login(code, state); yield* Authentication.getAndProcessIdToken(code, state);
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
} }
// Initial login request; redirect to identity provider
const res = yield* pipe( const res = yield* pipe(
Authentication.Authentication, Authentication.Authentication,
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)), Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)),
@@ -111,12 +69,14 @@ Bun.serve({
Effect.runPromise, Effect.runPromise,
), ),
POST: (req) => Effect.gen(function* () { POST: (req) => Effect.gen(function* () {
// Callback URL with form_post response type
const data = yield* Body.formData(req); const data = yield* Body.formData(req);
const code = data.get("code") as string | null; const code = data.get("code") as string | null;
const state = data.get("state") as string | null; const state = data.get("state") as string | null;
yield* login(code, state); yield* Authentication.getAndProcessIdToken(code, state);
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
}).pipe( }).pipe(
@@ -136,6 +96,13 @@ Bun.serve({
}); });
} }
if (req.method !== "POST") {
return new Response(null, {
status: 405,
headers: CORS_HEADERS,
});
}
const authenticationLayer = Authentication.Live(req); const authenticationLayer = Authentication.Live(req);
const layers = Layer.provideMerge(authenticationLayer, databaseLayer); const layers = Layer.provideMerge(authenticationLayer, databaseLayer);

View File

@@ -4,11 +4,6 @@ import { constant } from "effect/Function";
/* NOTE I know "effect/Config" exists, but I also don't care. This works for me. */ /* NOTE I know "effect/Config" exists, but I also don't care. This works for me. */
export const Config = Schema.Struct({ export const Config = Schema.Struct({
CLIENT_ID: Schema.UUID,
CLIENT_SECRET: pipe(
Schema.String,
Schema.Redacted,
),
DB_PATH: pipe( DB_PATH: pipe(
Schema.String, Schema.String,
Schema.optionalWith({ default: constant("db.sqlite3") }), Schema.optionalWith({ default: constant("db.sqlite3") }),
@@ -22,8 +17,25 @@ export const Config = Schema.Struct({
Schema.NumberFromString, Schema.NumberFromString,
Schema.optionalWith({ default: constant(3000) }), Schema.optionalWith({ default: constant(3000) }),
), ),
OAUTH_CLIENT_ID: Schema.String,
OAUTH_CLIENT_SECRET: pipe(
Schema.String,
Schema.Redacted,
),
OAUTH_AUTHORIZATION_ENDPOINT: Schema.String, OAUTH_AUTHORIZATION_ENDPOINT: Schema.String,
OAUTH_TOKEN_ENDPOINT: Schema.String, OAUTH_TOKEN_ENDPOINT: Schema.String,
OAUTH_USER_ID_CLAIM: pipe(
Schema.String,
Schema.optionalWith({ default: constant("sub") }),
),
OAUTH_DISPLAY_NAME_CLAIM: pipe(
Schema.String,
Schema.optionalWith({ default: constant("display_name") }),
),
OAUTH_AVATAR_URL_CLAIM: pipe(
Schema.String,
Schema.optionalWith({ default: constant("picture") }),
),
}); });
export type Config = typeof Config.Type; export type Config = typeof Config.Type;

View File

@@ -1,8 +1,10 @@
import { config } from "backend/config"; import { config } from "backend/config";
import { BunRequest } from "bun"; import { BunRequest } from "bun";
import { SessionId, UserId } from "common"; import { SessionId, UserId } from "common";
import * as Body from "common/Body";
import { fetch } from "common/Fetch";
import { NotFound, Unauthenticated, User } from "common/the_api"; import { NotFound, Unauthenticated, User } from "common/the_api";
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Schema } from "effect"; import { Context, DateTime, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect";
import { constant } from "effect/Function"; import { constant } from "effect/Function";
import { sql } from "kysely"; import { sql } from "kysely";
import * as Database from "./Database"; import * as Database from "./Database";
@@ -11,16 +13,12 @@ export interface AuthenticationInterface {
readonly me: Effect.Effect<User, Unauthenticated>; readonly me: Effect.Effect<User, Unauthenticated>;
readonly logout: Effect.Effect<void>; readonly logout: Effect.Effect<void>;
readonly sessionId: SessionId; readonly sessionId: SessionId;
readonly getUser: (userId: UserId) => Effect.Effect<User, NotFound>;
} }
export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { } export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { }
export const OAUTH_SCOPE = "email openid profile"; export const OAUTH_SCOPE = "email openid profile";
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login"; export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login";
export const EXPIRATION_BUFFER = Duration.seconds(10);
export const SESSION_COOKIE_NAME = "sessionId"; export const SESSION_COOKIE_NAME = "sessionId";
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () { export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
@@ -51,8 +49,6 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
const returning = [ const returning = [
"sessionId", "sessionId",
"codeVerifier",
"state",
"userId", "userId",
] as const; ] as const;
@@ -71,8 +67,6 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
const state = Object.freeze({ const state = Object.freeze({
sessionId: session.sessionId, sessionId: session.sessionId,
codeVerifier: Option.fromNullable(session.codeVerifier),
state: Option.fromNullable(session.state),
userId: Option.fromNullable(session.userId), userId: Option.fromNullable(session.userId),
}); });
@@ -92,67 +86,18 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
.where("sessionId", "=", state.sessionId) .where("sessionId", "=", state.sessionId)
.$call(Database.execute), .$call(Database.execute),
sessionId: state.sessionId, sessionId: state.sessionId,
getUser: (userId) => pipe(
getUser(userId),
Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())),
Effect.provideService(Database.Database, database),
),
}); });
})); }));
export const Test = (me: Option.Option<User>, users: HashMap.HashMap<UserId, User>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({ export const Test = (me: Option.Option<User>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
me: Option.match(me, { me: Option.match(me, {
onNone: () => Effect.fail(Unauthenticated.make()), onNone: () => Effect.fail(Unauthenticated.make()),
onSome: (me) => Effect.succeed(me), onSome: (me) => Effect.succeed(me),
}), }),
logout: Effect.void, logout: Effect.void,
sessionId: generateSessionId(), sessionId: generateSessionId(),
getUser: (userId) => pipe(
users,
HashMap.get(userId),
Option.match({
onNone: () => Effect.fail(NotFound.make()),
onSome: Effect.succeed,
}),
),
}))); })));
export const AccessTokenPayload = Schema.Struct({
aud: pipe(
Schema.String,
Schema.HashSet,
),
exp: Schema.Number,
iat: Schema.Number,
iss: Schema.String,
sub: UserId,
});
export const IdTokenPayload = Schema.Struct({
aud: pipe(
Schema.String,
Schema.HashSet,
),
exp: Schema.Number,
iat: Schema.Number,
iss: Schema.String,
sub: UserId,
name: Schema.String,
given_name: Schema.String,
family_name: Schema.String,
display_name: Schema.String,
preferred_username: Schema.String,
email: Schema.String,
email_verified: Schema.Boolean,
picture: Schema.String,
});
export type AccessTokenPayload = typeof AccessTokenPayload.Type;
export type IdTokenPayload = typeof IdTokenPayload.Type;
function generateCodeVerifier(byteLength: number = 32) { function generateCodeVerifier(byteLength: number = 32) {
const codeVerifierBytes = new Uint8Array(byteLength); const codeVerifierBytes = new Uint8Array(byteLength);
crypto.getRandomValues(codeVerifierBytes); crypto.getRandomValues(codeVerifierBytes);
@@ -183,7 +128,7 @@ function generateRandomState(byteLength: number = 32): string {
return state; return state;
} }
const getUser = (userId: UserId) => Effect.gen(function* () { export const getUser = (userId: UserId) => Effect.gen(function* () {
const database = yield* Database.Database; const database = yield* Database.Database;
const user = yield* database const user = yield* database
@@ -224,12 +169,24 @@ const getOrAddUser = (userId: UserId) => pipe(
)), )),
); );
export const upsertUser = (idTokenPayload: IdTokenPayload) => Effect.gen(function* () { const upsertUser = (idTokenPayload: { readonly [_: string]: unknown }) => Effect.gen(function* () {
const database = yield* Database.Database; const database = yield* Database.Database;
const userId = idTokenPayload.sub; const userId = config.OAUTH_USER_ID_CLAIM in idTokenPayload
const displayName = idTokenPayload.display_name; ? UserId.make(String(idTokenPayload[config.OAUTH_USER_ID_CLAIM]))
const avatarUrl = idTokenPayload.picture; : null;
const displayName = config.OAUTH_DISPLAY_NAME_CLAIM in idTokenPayload
? String(idTokenPayload[config.OAUTH_DISPLAY_NAME_CLAIM])
: null;
const avatarUrl = config.OAUTH_AVATAR_URL_CLAIM in idTokenPayload
? String(idTokenPayload[config.OAUTH_AVATAR_URL_CLAIM])
: null;
if (userId === null) {
return yield* Effect.fail(Unauthenticated.make());
}
const user = yield* database const user = yield* database
.insertInto("User") .insertInto("User")
@@ -259,17 +216,58 @@ export const upsertUser = (idTokenPayload: IdTokenPayload) => Effect.gen(functio
}); });
}); });
export const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => { const getJwtTokenPayload = (token: string) => {
const decoder = Schema.decodeUnknown(schema); const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
return (token: string) => { return json as { readonly [_: string]: unknown };
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
return pipe(
decoder(json),
Effect.orDie,
);
};
}; };
export const getAndProcessIdToken = (code: string | null, state: string | null) => Effect.gen(function* () {
const { sessionId } = yield* Authentication;
const db = yield* Database.Database;
const session = yield* db
.selectFrom("Session")
.select(["codeVerifier"])
.where("sessionId", "=", sessionId)
.$call(Database.executeTakeFirst);
const codeVerifier = Option.fromNullable(session.codeVerifier);
if (code !== null && state !== null && Option.isSome(codeVerifier)) {
const idTokenPayload = yield* pipe(
fetch(config.OAUTH_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
"client_id": config.OAUTH_CLIENT_ID,
"code": code,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code",
"code_verifier": codeVerifier.value,
"client_secret": Redacted.value(config.OAUTH_CLIENT_SECRET),
}).toString(),
}),
Effect.flatMap(Body.json),
Effect.map((body) => (body as { id_token: string }).id_token),
Effect.map(getJwtTokenPayload),
);
const { userId } = yield* upsertUser(idTokenPayload);
yield* db
.updateTable("Session")
.set({
codeVerifier: null,
state: null,
userId,
})
.where("sessionId", "=", sessionId)
.$call(Database.execute);
}
});
export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () { export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () {
const database = yield* Database.Database; const database = yield* Database.Database;

View File

@@ -6,32 +6,20 @@ import { implement } from "./api";
import * as Authentication from "./services/Authentication"; import * as Authentication from "./services/Authentication";
import * as Database from "./services/Database"; import * as Database from "./services/Database";
const READ_ACCESS = HashSet.make(Role.Editor, Role.Viewer); const READ_ACCESS = HashSet.make(Role.Admin, Role.Editor, Role.Viewer);
const WRITE_ACCESS = HashSet.make(Role.Editor); const WRITE_ACCESS = HashSet.make(Role.Admin, Role.Editor);
const ADMIN_ACCESS = HashSet.make(Role.Admin);
const requireAuthenticated = pipe( const requireAuthenticated = pipe(
Authentication.Authentication, Authentication.Authentication,
Effect.flatMap(({ me }) => me), Effect.flatMap(({ me }) => me),
); );
const requireReadAccess = pipe( const requireOneOf = (roles: HashSet.HashSet<Role>) => pipe(
Authentication.Authentication, Authentication.Authentication,
Effect.flatMap(({ me }) => me), Effect.flatMap(({ me }) => me),
Effect.flatMap((user) => pipe( Effect.flatMap((user) => pipe(
HashSet.intersection(user.roles, READ_ACCESS), HashSet.intersection(user.roles, roles),
HashSet.size,
Number.greaterThan(0),
)
? Effect.succeed(user)
: Effect.fail(Unauthorized.make())
),
);
const requireWriteAccess = pipe(
Authentication.Authentication,
Effect.flatMap(({ me }) => me),
Effect.flatMap((user) => pipe(
HashSet.intersection(user.roles, WRITE_ACCESS),
HashSet.size, HashSet.size,
Number.greaterThan(0), Number.greaterThan(0),
) )
@@ -52,19 +40,93 @@ export const handle = implement(api, {
Authentication.Authentication, Authentication.Authentication,
Effect.flatMap(({ logout }) => logout), Effect.flatMap(({ logout }) => logout),
), ),
// --- User CRUD ---
getUser: (userId) => Effect.gen(function* () { getUser: (userId) => Effect.gen(function* () {
yield* requireAuthenticated; yield* requireAuthenticated;
const { getUser } = yield* Authentication.Authentication; const user = yield* pipe(
const user = yield* getUser(userId); Authentication.getUser(userId),
Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())),
);
return user; return user;
}), }),
queryUsers: ({ displayName, role, offset, limit }) => Effect.gen(function* () {
yield* requireAuthenticated;
const db = yield* Database.Database;
let query = db
.selectFrom("User")
.leftJoin("UserRole", "User.userId", "UserRole.role")
.select("User.userId")
.groupBy("User.userId")
.orderBy("displayName")
.offset(offset)
.limit(limit);
query = Option.match(displayName, {
onNone: () => query,
onSome: (displayName) => query.where("User.displayName", "like", "%" + displayName + "%"),
});
query = Option.match(role, {
onNone: () => query,
onSome: (role) => query.where("UserRole.role", "=", role),
});
const res = yield* query.$call(Database.execute);
return res.map(({ userId }) => userId);
}),
updateUser: ({ userId, ...user }) => Effect.gen(function* () {
yield* requireOneOf(ADMIN_ACCESS);
const db = yield* Database.Database;
const res = yield* pipe(
Authentication.getUser(userId),
Effect.mapError(() => NotFound.make()),
);
yield* db
.deleteFrom("UserRole")
.where("userId", "=", userId)
.$call(Database.execute);
const roles = yield* pipe(
user.roles,
Effect.forEach((role) => db
.insertInto("UserRole")
.values({ userId, role })
.returning("role")
.$call(Database.executeTakeFirstOrDie)
.pipe(Effect.map(({ role }) => role)),
),
);
return {
userId: res.userId,
displayName: res.displayName,
avatarUrl: res.avatarUrl,
roles: HashSet.fromIterable(roles),
};
}),
deleteUser: (userId) => Effect.gen(function* () {
yield* requireOneOf(ADMIN_ACCESS);
const db = yield* Database.Database;
yield* db
.deleteFrom("User")
.where("userId", "=", userId)
.returning("userId")
.$call(Database.executeTakeFirst)
.pipe(Effect.mapError(() => NotFound.make()));
}),
// --- Piece CRUD --- // --- Piece CRUD ---
createPiece: (piece) => Effect.gen(function* () { createPiece: (piece) => Effect.gen(function* () {
const { userId } = yield* requireWriteAccess; const { userId } = yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const res = yield* db const res = yield* db
@@ -99,7 +161,7 @@ export const handle = implement(api, {
}; };
}), }),
getPiece: (pieceId) => Effect.gen(function* () { getPiece: (pieceId) => Effect.gen(function* () {
yield* requireReadAccess; yield* requireOneOf(READ_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const piece = yield* db const piece = yield* db
@@ -149,13 +211,15 @@ export const handle = implement(api, {
}; };
}), }),
queryPieces: ({ name, author, offset, limit }) => Effect.gen(function* () { queryPieces: ({ name, author, offset, limit }) => Effect.gen(function* () {
yield* requireReadAccess; yield* requireOneOf(READ_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
let query = db let query = db
.selectFrom("Piece") .selectFrom("Piece")
.select("pieceId") .select("pieceId")
.orderBy(["name", "composer", "arranger"]) .orderBy("name")
.orderBy("composer")
.orderBy("arranger")
.offset(offset) .offset(offset)
.limit(limit); .limit(limit);
@@ -177,7 +241,7 @@ export const handle = implement(api, {
return res.map(({ pieceId }) => pieceId); return res.map(({ pieceId }) => pieceId);
}), }),
updatePiece: ({ pieceId, ...piece }) => Effect.gen(function* () { updatePiece: ({ pieceId, ...piece }) => Effect.gen(function* () {
const { userId } = yield* requireWriteAccess; const { userId } = yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const res = yield* db const res = yield* db
@@ -235,7 +299,7 @@ export const handle = implement(api, {
}; };
}), }),
deletePiece: (pieceId) => Effect.gen(function* () { deletePiece: (pieceId) => Effect.gen(function* () {
yield* requireWriteAccess; yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
yield* db yield* db
@@ -249,7 +313,7 @@ export const handle = implement(api, {
// --- Attachment CRUD --- // --- Attachment CRUD ---
createAttachment: (attachment) => Effect.gen(function* () { createAttachment: (attachment) => Effect.gen(function* () {
const { userId } = yield* requireWriteAccess; const { userId } = yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength)); const sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
@@ -292,7 +356,7 @@ export const handle = implement(api, {
}; };
}), }),
getAttachment: (attachmentId) => Effect.gen(function* () { getAttachment: (attachmentId) => Effect.gen(function* () {
yield* requireReadAccess; yield* requireOneOf(READ_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const res = yield* db const res = yield* db
@@ -306,7 +370,7 @@ export const handle = implement(api, {
return res; return res;
}), }),
updateAttachment: ({ attachmentId, ...attachment }) => Effect.gen(function* () { updateAttachment: ({ attachmentId, ...attachment }) => Effect.gen(function* () {
const { userId } = yield* requireWriteAccess; const { userId } = yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const res = yield* db const res = yield* db
@@ -337,7 +401,7 @@ export const handle = implement(api, {
}; };
}), }),
deleteAttachment: (attachmentId) => Effect.gen(function* () { deleteAttachment: (attachmentId) => Effect.gen(function* () {
yield* requireWriteAccess; yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
yield* db yield* db
@@ -351,7 +415,7 @@ export const handle = implement(api, {
// --- Repertoire CRUD --- // --- Repertoire CRUD ---
createRepertoire: (repertoire) => Effect.gen(function* () { createRepertoire: (repertoire) => Effect.gen(function* () {
const { userId } = yield* requireWriteAccess; const { userId } = yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const repertoireId = RepertoireId.make(Bun.randomUUIDv7()); const repertoireId = RepertoireId.make(Bun.randomUUIDv7());
@@ -393,7 +457,7 @@ export const handle = implement(api, {
}; };
}), }),
getRepertoire: (repertoireId) => Effect.gen(function* () { getRepertoire: (repertoireId) => Effect.gen(function* () {
yield* requireReadAccess; yield* requireOneOf(READ_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const repertoire = yield* db const repertoire = yield* db
@@ -424,8 +488,8 @@ export const handle = implement(api, {
), ),
}; };
}), }),
queryRepertoire: ({ name, offset, limit }) => Effect.gen(function* () { queryRepertoires: ({ name, offset, limit }) => Effect.gen(function* () {
yield* requireReadAccess; yield* requireOneOf(READ_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
let query = db let query = db
@@ -444,7 +508,7 @@ export const handle = implement(api, {
return res.map(({ repertoireId }) => repertoireId); return res.map(({ repertoireId }) => repertoireId);
}), }),
updateRepertoire: ({ repertoireId, ...repertoire }) => Effect.gen(function* () { updateRepertoire: ({ repertoireId, ...repertoire }) => Effect.gen(function* () {
const { userId } = yield* requireWriteAccess; const { userId } = yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
const res = yield* db const res = yield* db
@@ -490,7 +554,7 @@ export const handle = implement(api, {
}; };
}), }),
deleteRepertoire: (repertoireId) => Effect.gen(function* () { deleteRepertoire: (repertoireId) => Effect.gen(function* () {
yield* requireWriteAccess; yield* requireOneOf(WRITE_ACCESS);
const db = yield* Database.Database; const db = yield* Database.Database;
yield* db yield* db

View File

@@ -22,5 +22,5 @@ export type RepertoireId = typeof RepertoireId.Type;
export const RequestId = pipe(Schema.UUID, Schema.brand("RequestId")); export const RequestId = pipe(Schema.UUID, Schema.brand("RequestId"));
export type RequestId = typeof RequestId.Type; export type RequestId = typeof RequestId.Type;
export const UserId = pipe(Schema.UUID, Schema.brand("UserId")); export const UserId = pipe(Schema.String, Schema.brand("UserId"));
export type UserId = typeof UserId.Type; export type UserId = typeof UserId.Type;

View File

@@ -8,6 +8,7 @@ import * as Api from "./Api";
export enum Role { export enum Role {
Viewer = "Viewer", Viewer = "Viewer",
Editor = "Editor", Editor = "Editor",
Admin = "Admin",
} }
export const SystemInformation = Schema.Struct({ export const SystemInformation = Schema.Struct({
@@ -78,6 +79,16 @@ export const User = Schema.Struct({
roles: Schema.HashSet(Schema.Enums(Role)), roles: Schema.HashSet(Schema.Enums(Role)),
}); });
export const User_Query = Schema.Struct({
displayName: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
role: pipe(Schema.Enums(Role), Schema.optionalWith({ as: "Option", exact: true })),
}).pipe(Schema.extend(Pagination));
export const User_AssignRoles = Schema.Struct({
userId: UserId,
roles: Schema.HashSet(Schema.Enums(Role)),
});
export type Attachment = typeof Attachment.Type; export type Attachment = typeof Attachment.Type;
export type Piece = typeof Piece.Type; export type Piece = typeof Piece.Type;
export type Piece_Create = typeof Piece_Create.Type; export type Piece_Create = typeof Piece_Create.Type;
@@ -85,6 +96,7 @@ export type Piece_Query = typeof Piece_Query.Type;
export type Repertoire = typeof Repertoire.Type; export type Repertoire = typeof Repertoire.Type;
export type Repertoire_Query = typeof Repertoire_Query.Type; export type Repertoire_Query = typeof Repertoire_Query.Type;
export type User = typeof User.Type; export type User = typeof User.Type;
export type User_AssignRoles = typeof User_AssignRoles.Type;
// --- MARK: ERROR TYPES ------------------------------------------------------- // --- MARK: ERROR TYPES -------------------------------------------------------
@@ -102,11 +114,29 @@ export default Api.bundle({
me: Api.make(Schema.Void, User, Unauthenticated), me: Api.make(Schema.Void, User, Unauthenticated),
logout: Api.make(Schema.Void, Schema.Void), logout: Api.make(Schema.Void, Schema.Void),
// --- User CRUD ---
getUser: Api.make( getUser: Api.make(
UserId, UserId,
User, User,
Schema.Union(Unauthenticated, NotFound), Schema.Union(Unauthenticated, NotFound),
), ),
queryUsers: Api.make(
User_Query,
pipe(UserId, Schema.Array),
Schema.Union(Unauthenticated),
),
updateUser: Api.make(
User_AssignRoles,
User,
Schema.Union(Unauthenticated, Unauthorized, NotFound),
),
deleteUser: Api.make(
UserId,
Schema.Void,
Schema.Union(Unauthenticated, Unauthorized, NotFound),
),
// --- Piece CRUD --- // --- Piece CRUD ---
@@ -183,7 +213,7 @@ export default Api.bundle({
Repertoire, Repertoire,
Schema.Union(Unauthenticated, Unauthorized, NotFound), Schema.Union(Unauthenticated, Unauthorized, NotFound),
), ),
queryRepertoire: Api.make( queryRepertoires: Api.make(
Repertoire_Query, Repertoire_Query,
pipe(RepertoireId, Schema.Array), pipe(RepertoireId, Schema.Array),
Schema.Union(Unauthenticated, Unauthorized), Schema.Union(Unauthenticated, Unauthorized),

View File

@@ -17,6 +17,7 @@
"vite": "catalog:" "vite": "catalog:"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "catalog:",
"@radix-ui/react-dialog": "catalog:", "@radix-ui/react-dialog": "catalog:",
"@radix-ui/react-dropdown-menu": "catalog:", "@radix-ui/react-dropdown-menu": "catalog:",
"@radix-ui/react-label": "catalog:", "@radix-ui/react-label": "catalog:",