User CRUD on backend and refactors
This commit is contained in:
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,16 +216,57 @@ 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);
|
|
||||||
return (token: string) => {
|
|
||||||
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
|
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
|
||||||
return pipe(
|
return json as { readonly [_: string]: unknown };
|
||||||
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
Reference in New Issue
Block a user