From 1f3ee2b5e62feb72b71d69a41cd07641b7f938ea Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Fri, 29 May 2026 14:33:04 +0100 Subject: [PATCH] feat: admin API (#106) --- .mise/config.toml | 2 +- .mise/tasks/yucca-admin-api/build | 6 + .mise/tasks/yucca-admin-api/dev | 12 + .mise/tasks/yucca-admin-api/env | 16 + .mise/tasks/yucca-admin-api/sync-openapi | 8 + .mise/tasks/yucca-admin-api/test/_default | 6 + .mise/tasks/yucca-admin-api/test/integration | 6 + .mise/tasks/yucca-admin-api/test/watch | 6 + .prettierignore | 3 + packages/mock-oidc-provider/src/env.ts | 3 + packages/mock-oidc-provider/src/index.ts | 4 +- packages/yucca-admin-api/.gitignore | 56 ++ packages/yucca-admin-api/Dockerfile | 55 ++ packages/yucca-admin-api/nest-cli.json | 8 + packages/yucca-admin-api/openapi-specs.json | 682 ++++++++++++++++++ packages/yucca-admin-api/package.json | 89 +++ packages/yucca-admin-api/src/app.module.ts | 48 ++ .../yucca-admin-api/src/bin/sync-openapi.ts | 14 + .../src/controllers/auth.controller.ts | 71 ++ .../src/controllers/repository.controller.ts | 47 ++ .../src/controllers/session.controller.ts | 15 + .../src/controllers/user.controller.ts | 63 ++ packages/yucca-admin-api/src/dto/auth.dto.ts | 6 + .../yucca-admin-api/src/dto/pagination.dto.ts | 16 + .../yucca-admin-api/src/dto/repository.dto.ts | 88 +++ .../yucca-admin-api/src/dto/session.dto.ts | 14 + packages/yucca-admin-api/src/dto/user.dto.ts | 47 ++ packages/yucca-admin-api/src/enum.ts | 14 + packages/yucca-admin-api/src/env.ts | 25 + packages/yucca-admin-api/src/main.ts | 17 + .../src/middleware/auth.guard.ts | 46 ++ .../src/repositories/database.repository.ts | 89 +++ .../src/repositories/oidc.repository.ts | 75 ++ .../src/repositories/repository.repository.ts | 67 ++ .../src/repositories/session.repository.ts | 21 + .../src/repositories/user.repository.ts | 46 ++ packages/yucca-admin-api/src/schema | 1 + .../src/services/auth.service.spec.ts | 144 ++++ .../src/services/auth.service.ts | 91 +++ .../src/services/database.service.ts | 12 + .../src/services/repository.service.ts | 36 + .../src/services/session.service.ts | 20 + .../src/services/user.service.ts | 42 ++ .../yucca-admin-api/src/utils/database.ts | 44 ++ packages/yucca-admin-api/src/utils/openapi.ts | 33 + .../yucca-admin-api/src/utils/pagination.ts | 16 + .../test/auth.integration-spec.ts | 138 ++++ .../test/jest-integration.json | 14 + packages/yucca-admin-api/test/mocks.ts | 54 ++ .../test/repository.integration-spec.ts | 130 ++++ .../test/session.integration-spec.ts | 88 +++ packages/yucca-admin-api/test/testUtils.ts | 55 ++ .../test/user.integration-spec.ts | 138 ++++ packages/yucca-admin-api/tsconfig.build.json | 4 + packages/yucca-admin-api/tsconfig.json | 29 + packages/yucca-api/src/enum.ts | 5 +- .../src/repositories/database.repository.ts | 26 +- .../src/repositories/user.repository.ts | 6 +- .../20260521170000-AddDisabledToUsers.ts | 9 + .../yucca-api/src/schema/tables/user.table.ts | 3 + .../yucca-api/src/services/auth.service.ts | 4 + .../src/services/database.service.ts | 3 +- .../yucca-api/test/auth.integration-spec.ts | 34 + packages/yucca-api/test/testUtils.ts | 5 + pnpm-lock.yaml | 210 +++++- yaak/yaak.fl_SpbNeUZw3W.yaml | 13 + yaak/yaak.fl_ZepPmNAPfD.yaml | 21 + yaak/yaak.fl_ei6idjq9Ei.yaml | 13 + yaak/yaak.fl_kzC8MnKVou.yaml | 13 + yaak/yaak.rq_BsWTVt8r8R.yaml | 23 + yaak/yaak.rq_CnqMCQeftW.yaml | 31 + yaak/yaak.rq_LLQ6ZXE2H8.yaml | 26 + yaak/yaak.rq_Qa8Tg8MEML.yaml | 26 + yaak/yaak.rq_Y4im3VxxoL.yaml | 26 + yaak/yaak.rq_dnySvQ5JXU.yaml | 23 + yaak/yaak.rq_f6NWDkr8sW.yaml | 26 + yaak/yaak.rq_jygywNzahd.yaml | 26 + yaak/yaak.rq_kQje8YLe97.yaml | 26 + yaak/yaak.rq_q6RBqymUhr.yaml | 30 + yaak/yaak.rq_rvQnzVJpeo.yaml | 26 + yaak/yaak.wk_sf38TckR4U.yaml | 14 + 81 files changed, 3538 insertions(+), 10 deletions(-) create mode 100755 .mise/tasks/yucca-admin-api/build create mode 100755 .mise/tasks/yucca-admin-api/dev create mode 100755 .mise/tasks/yucca-admin-api/env create mode 100755 .mise/tasks/yucca-admin-api/sync-openapi create mode 100755 .mise/tasks/yucca-admin-api/test/_default create mode 100755 .mise/tasks/yucca-admin-api/test/integration create mode 100755 .mise/tasks/yucca-admin-api/test/watch create mode 100644 packages/yucca-admin-api/.gitignore create mode 100644 packages/yucca-admin-api/Dockerfile create mode 100644 packages/yucca-admin-api/nest-cli.json create mode 100644 packages/yucca-admin-api/openapi-specs.json create mode 100644 packages/yucca-admin-api/package.json create mode 100644 packages/yucca-admin-api/src/app.module.ts create mode 100644 packages/yucca-admin-api/src/bin/sync-openapi.ts create mode 100644 packages/yucca-admin-api/src/controllers/auth.controller.ts create mode 100644 packages/yucca-admin-api/src/controllers/repository.controller.ts create mode 100644 packages/yucca-admin-api/src/controllers/session.controller.ts create mode 100644 packages/yucca-admin-api/src/controllers/user.controller.ts create mode 100644 packages/yucca-admin-api/src/dto/auth.dto.ts create mode 100644 packages/yucca-admin-api/src/dto/pagination.dto.ts create mode 100644 packages/yucca-admin-api/src/dto/repository.dto.ts create mode 100644 packages/yucca-admin-api/src/dto/session.dto.ts create mode 100644 packages/yucca-admin-api/src/dto/user.dto.ts create mode 100644 packages/yucca-admin-api/src/enum.ts create mode 100644 packages/yucca-admin-api/src/env.ts create mode 100644 packages/yucca-admin-api/src/main.ts create mode 100644 packages/yucca-admin-api/src/middleware/auth.guard.ts create mode 100644 packages/yucca-admin-api/src/repositories/database.repository.ts create mode 100644 packages/yucca-admin-api/src/repositories/oidc.repository.ts create mode 100644 packages/yucca-admin-api/src/repositories/repository.repository.ts create mode 100644 packages/yucca-admin-api/src/repositories/session.repository.ts create mode 100644 packages/yucca-admin-api/src/repositories/user.repository.ts create mode 120000 packages/yucca-admin-api/src/schema create mode 100644 packages/yucca-admin-api/src/services/auth.service.spec.ts create mode 100644 packages/yucca-admin-api/src/services/auth.service.ts create mode 100644 packages/yucca-admin-api/src/services/database.service.ts create mode 100644 packages/yucca-admin-api/src/services/repository.service.ts create mode 100644 packages/yucca-admin-api/src/services/session.service.ts create mode 100644 packages/yucca-admin-api/src/services/user.service.ts create mode 100644 packages/yucca-admin-api/src/utils/database.ts create mode 100644 packages/yucca-admin-api/src/utils/openapi.ts create mode 100644 packages/yucca-admin-api/src/utils/pagination.ts create mode 100644 packages/yucca-admin-api/test/auth.integration-spec.ts create mode 100644 packages/yucca-admin-api/test/jest-integration.json create mode 100644 packages/yucca-admin-api/test/mocks.ts create mode 100644 packages/yucca-admin-api/test/repository.integration-spec.ts create mode 100644 packages/yucca-admin-api/test/session.integration-spec.ts create mode 100644 packages/yucca-admin-api/test/testUtils.ts create mode 100644 packages/yucca-admin-api/test/user.integration-spec.ts create mode 100644 packages/yucca-admin-api/tsconfig.build.json create mode 100644 packages/yucca-admin-api/tsconfig.json create mode 100644 packages/yucca-api/src/schema/migrations/20260521170000-AddDisabledToUsers.ts create mode 100644 yaak/yaak.fl_SpbNeUZw3W.yaml create mode 100644 yaak/yaak.fl_ZepPmNAPfD.yaml create mode 100644 yaak/yaak.fl_ei6idjq9Ei.yaml create mode 100644 yaak/yaak.fl_kzC8MnKVou.yaml create mode 100644 yaak/yaak.rq_BsWTVt8r8R.yaml create mode 100644 yaak/yaak.rq_CnqMCQeftW.yaml create mode 100644 yaak/yaak.rq_LLQ6ZXE2H8.yaml create mode 100644 yaak/yaak.rq_Qa8Tg8MEML.yaml create mode 100644 yaak/yaak.rq_Y4im3VxxoL.yaml create mode 100644 yaak/yaak.rq_dnySvQ5JXU.yaml create mode 100644 yaak/yaak.rq_f6NWDkr8sW.yaml create mode 100644 yaak/yaak.rq_jygywNzahd.yaml create mode 100644 yaak/yaak.rq_kQje8YLe97.yaml create mode 100644 yaak/yaak.rq_q6RBqymUhr.yaml create mode 100644 yaak/yaak.rq_rvQnzVJpeo.yaml create mode 100644 yaak/yaak.wk_sf38TckR4U.yaml diff --git a/.mise/config.toml b/.mise/config.toml index 05f127fa..42d3a4a4 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -30,7 +30,7 @@ depends = ["*:test"] [tasks."test:integration"] description = "Run all integration tests" -run = [{ task = "*:test:integration" }] +run = "mise run '*:test:integration' --jobs 1" [tasks."test:e2e:web"] description = "Run all web e2e tests" diff --git a/.mise/tasks/yucca-admin-api/build b/.mise/tasks/yucca-admin-api/build new file mode 100755 index 00000000..3108131e --- /dev/null +++ b/.mise/tasks/yucca-admin-api/build @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Build yucca-admin-api" +#MISE depends=["common:build"] +set -e + +NODE_ENV=production pnpm --filter yucca-admin-api build diff --git a/.mise/tasks/yucca-admin-api/dev b/.mise/tasks/yucca-admin-api/dev new file mode 100755 index 00000000..009f9641 --- /dev/null +++ b/.mise/tasks/yucca-admin-api/dev @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +#MISE description="Run yucca-admin-api in development mode" +#MISE depends=["common:build"] +#MISE dir="{{config_root}}" +set -e +source "$(dirname "$0")/env" + +if command -v op &>/dev/null; then + op run --env-file="./.env" -- pnpm --filter yucca-admin-api start:dev +else + pnpm --filter yucca-admin-api start:dev +fi diff --git a/.mise/tasks/yucca-admin-api/env b/.mise/tasks/yucca-admin-api/env new file mode 100755 index 00000000..499f2e11 --- /dev/null +++ b/.mise/tasks/yucca-admin-api/env @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +export YUCCA_ADMIN_API_PORT=${YUCCA_ADMIN_API_PORT:-3030} + +export POSTGRES_HOST=${POSTGRES_HOST:-localhost} +export POSTGRES_USERNAME=${POSTGRES_USERNAME:-postgres} +export POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} +export POSTGRES_DATABASE=${POSTGRES_DATABASE:-yucca} +export POSTGRES_PORT=${POSTGRES_PORT:-15432} + +export OIDC_ADMIN_ISSUER=${OIDC_ISSUER:-http://localhost:8092} +export OIDC_ADMIN_CLIENT_ID=${OIDC_CLIENT_ID:-client ID} +export OIDC_ADMIN_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-client secret} +export OIDC_ADMIN_ALLOW_INSECURE=${OIDC_ALLOW_INSECURE:-true} +export OIDC_ADMIN_REDIRECT_URI=${OIDC_REDIRECT_URI:-http://localhost:3030/api/auth/oidc/callback} +export OIDC_ADMIN_LOGOUT_REDIRECT_URI=${OIDC_LOGOUT_REDIRECT_URI:-http://localhost:3030} diff --git a/.mise/tasks/yucca-admin-api/sync-openapi b/.mise/tasks/yucca-admin-api/sync-openapi new file mode 100755 index 00000000..b9c5cbae --- /dev/null +++ b/.mise/tasks/yucca-admin-api/sync-openapi @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +#MISE description="yucca-admin-api: sync OpenAPI specification" +#MISE dir="{{config_root}}/packages/yucca-admin-api" +#MISE depends=["yucca-admin-api:build"] +set -e +source "$(dirname "$0")/env" + +node dist/bin/sync-openapi.js diff --git a/.mise/tasks/yucca-admin-api/test/_default b/.mise/tasks/yucca-admin-api/test/_default new file mode 100755 index 00000000..c33c6a8a --- /dev/null +++ b/.mise/tasks/yucca-admin-api/test/_default @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Run unit tests for yucca-admin-api" +set -e +source "$(dirname "$0")/../env" + +pnpm --filter yucca-admin-api test "$@" diff --git a/.mise/tasks/yucca-admin-api/test/integration b/.mise/tasks/yucca-admin-api/test/integration new file mode 100755 index 00000000..4d7ffe47 --- /dev/null +++ b/.mise/tasks/yucca-admin-api/test/integration @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Run integration tests for yucca-admin-api" +set -e +source "$(dirname "$0")/../env" + +pnpm --filter yucca-admin-api test:integration "$@" diff --git a/.mise/tasks/yucca-admin-api/test/watch b/.mise/tasks/yucca-admin-api/test/watch new file mode 100755 index 00000000..e518132a --- /dev/null +++ b/.mise/tasks/yucca-admin-api/test/watch @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Run unit tests in watch mode for yucca-admin-api" +set -e +source "$(dirname "$0")/../env" + +pnpm --filter yucca-admin-api test --watch "$@" diff --git a/.prettierignore b/.prettierignore index fc66b66a..6639213a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -30,3 +30,6 @@ yarn.lock # task structure, so these subtrees are exempt from root prettier checks. ansible/ tf/ + +# auto-generated +yaak/ diff --git a/packages/mock-oidc-provider/src/env.ts b/packages/mock-oidc-provider/src/env.ts index aa23f64d..399a5399 100644 --- a/packages/mock-oidc-provider/src/env.ts +++ b/packages/mock-oidc-provider/src/env.ts @@ -10,6 +10,9 @@ const schema = z.object({ REDIRECT_URI: z.url().default('http://localhost:36033/api/auth/oidc/callback'), POST_LOGOUT_REDIRECT_URI: z.url().default('http://localhost:36033'), + + ADMIN_REDIRECT_URI: z.url().default('http://localhost:3030/api/auth/oidc/callback'), + ADMIN_POST_LOGOUT_REDIRECT_URI: z.url().default('http://localhost:3030'), }); export const env = schema.parse(process.env); diff --git a/packages/mock-oidc-provider/src/index.ts b/packages/mock-oidc-provider/src/index.ts index cb27c479..8cbd77f8 100644 --- a/packages/mock-oidc-provider/src/index.ts +++ b/packages/mock-oidc-provider/src/index.ts @@ -9,8 +9,8 @@ const configuration: Configuration = { { client_id: env.CLIENT_ID, client_secret: env.CLIENT_SECRET, - redirect_uris: [env.REDIRECT_URI], - post_logout_redirect_uris: [env.POST_LOGOUT_REDIRECT_URI], + redirect_uris: [env.REDIRECT_URI, env.ADMIN_REDIRECT_URI], + post_logout_redirect_uris: [env.POST_LOGOUT_REDIRECT_URI, env.ADMIN_POST_LOGOUT_REDIRECT_URI], grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_basic', diff --git a/packages/yucca-admin-api/.gitignore b/packages/yucca-admin-api/.gitignore new file mode 100644 index 00000000..4b56acfb --- /dev/null +++ b/packages/yucca-admin-api/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/packages/yucca-admin-api/Dockerfile b/packages/yucca-admin-api/Dockerfile new file mode 100644 index 00000000..0d483e35 --- /dev/null +++ b/packages/yucca-admin-api/Dockerfile @@ -0,0 +1,55 @@ +# yucca-api Docker image +# Built on/for Alpine Linux +# +# mise is used as a command runner only +# node.js & pnpm pinned in image =( +# (node.js 26 will remove yarn v1 fixing corepack install) +# +# References: +# =========== +# https://docs.nestjs.com/deployment +# https://mise.jdx.dev/mise-cookbook/docker.html +# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md +# https://pnpm.io/cli/deploy + +ARG ALPINE_VERSION=3.23 + +FROM node:25-alpine${ALPINE_VERSION} AS builder +WORKDIR /build-stage +COPY . ./ + +RUN apk add --no-cache curl bash + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +ENV MISE_DATA_DIR="/mise" +ENV MISE_CONFIG_DIR="/mise" +ENV MISE_CACHE_DIR="/mise/cache" +ENV MISE_INSTALL_PATH="/usr/local/bin/mise" +ENV MISE_TASK_RUN_AUTO_INSTALL=false +ENV PATH="/mise/shims:$PATH" +ENV NODE_ENV="production" + +RUN npm install -g pnpm@10.28.1 +RUN curl https://mise.run | sh +RUN mise trust +RUN pnpm i --frozen-lockfile +RUN mise yucca-api:build +RUN pnpm --filter yucca-api deploy /deploy --prod --legacy + +FROM alpine:${ALPINE_VERSION} + +WORKDIR /usr/src/app + +RUN apk add --no-cache libstdc++ dumb-init \ + && addgroup -g 1000 node && adduser -u 1000 -G node -s /bin/sh -D node \ + && chown node:node ./ +COPY --from=builder /usr/local/bin/node /usr/local/bin/ +COPY --from=builder /usr/local/bin/docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] +USER node + +COPY --from=builder /deploy ./ + +ENV NODE_ENV="production" +EXPOSE 3020 +CMD ["dumb-init", "node", "dist/main"] diff --git a/packages/yucca-admin-api/nest-cli.json b/packages/yucca-admin-api/nest-cli.json new file mode 100644 index 00000000..f9aa683b --- /dev/null +++ b/packages/yucca-admin-api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/packages/yucca-admin-api/openapi-specs.json b/packages/yucca-admin-api/openapi-specs.json new file mode 100644 index 00000000..891fa5b1 --- /dev/null +++ b/packages/yucca-admin-api/openapi-specs.json @@ -0,0 +1,682 @@ +{ + "openapi": "3.0.0", + "paths": { + "/api/auth": { + "get": { + "operationId": "getAuth", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthDto" + } + } + } + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/logout": { + "get": { + "operationId": "logout", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/oidc/login": { + "get": { + "operationId": "oidcAuthorize", + "parameters": [ + { + "name": "code_challenge", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/oidc/callback": { + "get": { + "operationId": "oidcCallback", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/user": { + "get": { + "operationId": "listUsers", + "parameters": [ + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "default": 50, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserListResponseDto" + } + } + } + } + }, + "tags": [ + "User" + ] + } + }, + "/api/user/{id}": { + "get": { + "operationId": "getUser", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserGetResponseDto" + } + } + } + } + }, + "tags": [ + "User" + ] + }, + "patch": { + "operationId": "updateUser", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateRequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateResponseDto" + } + } + } + } + }, + "tags": [ + "User" + ] + }, + "delete": { + "operationId": "deleteUser", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": [ + "User" + ] + } + }, + "/api/user/{userId}/session": { + "get": { + "operationId": "listUserSessions", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionListResponseDto" + } + } + } + } + }, + "tags": [ + "User" + ] + }, + "delete": { + "operationId": "deleteUserSessions", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": [ + "User" + ] + } + }, + "/api/session/{id}": { + "delete": { + "operationId": "deleteSession", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": [ + "Session" + ] + } + }, + "/api/repository": { + "get": { + "operationId": "listRepositories", + "parameters": [ + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "default": 50, + "type": "string" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryListResponseDto" + } + } + } + } + }, + "tags": [ + "Repository" + ] + } + }, + "/api/repository/{id}": { + "get": { + "operationId": "getRepository", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryGetResponseDto" + } + } + } + } + }, + "tags": [ + "Repository" + ] + }, + "patch": { + "operationId": "updateRepository", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryUpdateRequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryUpdateResponseDto" + } + } + } + } + }, + "tags": [ + "Repository" + ] + }, + "delete": { + "operationId": "deleteRepository", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": [ + "Repository" + ] + } + } + }, + "info": { + "title": "yucca-admin", + "description": "yucca admin API", + "version": "1.0.0", + "contact": {} + }, + "tags": [], + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "AuthDto": { + "type": "object", + "properties": { + "sub": { + "type": "string" + } + }, + "required": [ + "sub" + ] + }, + "UserDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sub": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "disabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "sub", + "name", + "email", + "disabled" + ] + }, + "UserListResponseDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + }, + "nextCursor": { + "type": "object", + "nullable": true + } + }, + "required": [ + "items" + ] + }, + "UserGetResponseDto": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/UserDto" + } + }, + "required": [ + "user" + ] + }, + "UserUpdateRequestDto": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + } + } + }, + "UserUpdateResponseDto": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/UserDto" + } + }, + "required": [ + "user" + ] + }, + "SessionDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "id", + "userId" + ] + }, + "SessionListResponseDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionDto" + } + } + }, + "required": [ + "items" + ] + }, + "RepositoryOwnerDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "disabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "email", + "disabled" + ] + }, + "RepositoryMetricsDto": { + "type": "object", + "properties": { + "sizeBytes": { + "type": "number" + }, + "lastStarted": { + "type": "string", + "nullable": true + }, + "lastBackup": { + "type": "string", + "nullable": true + }, + "lastSuccessfulBackup": { + "type": "string", + "nullable": true + }, + "lastBackupDuration": { + "type": "object", + "nullable": true + } + }, + "required": [ + "sizeBytes" + ] + }, + "RepositoryAdminDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "worm": { + "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/RepositoryOwnerDto" + }, + "metrics": { + "$ref": "#/components/schemas/RepositoryMetricsDto" + } + }, + "required": [ + "id", + "name", + "worm", + "user", + "metrics" + ] + }, + "RepositoryListResponseDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RepositoryAdminDto" + } + }, + "nextCursor": { + "type": "object", + "nullable": true + } + }, + "required": [ + "items" + ] + }, + "RepositoryGetResponseDto": { + "type": "object", + "properties": { + "repository": { + "$ref": "#/components/schemas/RepositoryAdminDto" + } + }, + "required": [ + "repository" + ] + }, + "RepositoryUpdateRequestDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "worm": { + "type": "boolean" + } + } + }, + "RepositoryUpdateResponseDto": { + "type": "object", + "properties": { + "repository": { + "$ref": "#/components/schemas/RepositoryAdminDto" + } + }, + "required": [ + "repository" + ] + } + } + } +} \ No newline at end of file diff --git a/packages/yucca-admin-api/package.json b/packages/yucca-admin-api/package.json new file mode 100644 index 00000000..077270ae --- /dev/null +++ b/packages/yucca-admin-api/package.json @@ -0,0 +1,89 @@ +{ + "name": "yucca-admin-api", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "test": "jest", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:integration": "jest --config ./test/jest-integration.json --runInBand" + }, + "files": [ + "dist" + ], + "dependencies": { + "@common/server": "workspace:^", + "@immich/sql-tools": "^0.3.2", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.5", + "@opentelemetry/api": "^1.9.0", + "class-validator": "^0.14.3", + "cookie": "^1.1.1", + "event-iterator": "^2.0.0", + "kysely": "0.28.2", + "kysely-postgres-js": "^3.0.0", + "luxon": "^3.7.2", + "nestjs-kysely": "^3.1.2", + "openid-client": "^6.8.1", + "postgres": "^3.4.8", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "zod": "^4.3.5" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/luxon": "^3.7.1", + "@types/ms": "^2.1.0", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0", + "vitest": "^4.0.16" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "transformIgnorePatterns": [ + "/node_modules/.pnpm/(?!(openid-client|oauth4webapi|jose))" + ], + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/packages/yucca-admin-api/src/app.module.ts b/packages/yucca-admin-api/src/app.module.ts new file mode 100644 index 00000000..889af6b6 --- /dev/null +++ b/packages/yucca-admin-api/src/app.module.ts @@ -0,0 +1,48 @@ +import { LoggerRepository, LoggingInterceptor, OtelModule, WideContextRepository } from '@common/server/otel'; +import { Module } from '@nestjs/common'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { KyselyModule } from 'nestjs-kysely'; +import { AuthController } from './controllers/auth.controller'; +import { RepositoryController } from './controllers/repository.controller'; +import { SessionController } from './controllers/session.controller'; +import { UserController } from './controllers/user.controller'; +import { AuthGuard } from './middleware/auth.guard'; +import { DatabaseRepository } from './repositories/database.repository'; +import { OidcRepository } from './repositories/oidc.repository'; +import { RepositoryRepository } from './repositories/repository.repository'; +import { SessionRepository } from './repositories/session.repository'; +import { UserRepository } from './repositories/user.repository'; +import { AuthService } from './services/auth.service'; +import { DatabaseService } from './services/database.service'; +import { RepositoryService } from './services/repository.service'; +import { SessionService } from './services/session.service'; +import { UserService } from './services/user.service'; +import { getKyselyConfig } from './utils/database'; + +export const imports = [KyselyModule.forRoot(getKyselyConfig())]; + +export const controllers = [AuthController, UserController, SessionController, RepositoryController]; + +export const providers = [ + WideContextRepository, + LoggerRepository, + DatabaseRepository, + DatabaseService, + OidcRepository, + UserRepository, + SessionRepository, + RepositoryRepository, + AuthService, + UserService, + SessionService, + RepositoryService, + { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, + { provide: APP_GUARD, useClass: AuthGuard }, +]; + +@Module({ + imports: [OtelModule, ...imports], + controllers, + providers, +}) +export class AppModule {} diff --git a/packages/yucca-admin-api/src/bin/sync-openapi.ts b/packages/yucca-admin-api/src/bin/sync-openapi.ts new file mode 100644 index 00000000..68d96a07 --- /dev/null +++ b/packages/yucca-admin-api/src/bin/sync-openapi.ts @@ -0,0 +1,14 @@ +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { AppModule } from 'src/app.module'; +import { useSwagger } from 'src/utils/openapi'; + +async function main() { + const app = await NestFactory.create(AppModule, { preview: true }); + app.setGlobalPrefix('/api'); + useSwagger(app, { write: true }); + await app.close(); +} + +// eslint-disable-next-line unicorn/no-process-exit +void main().finally(() => process.exit(0)); diff --git a/packages/yucca-admin-api/src/controllers/auth.controller.ts b/packages/yucca-admin-api/src/controllers/auth.controller.ts new file mode 100644 index 00000000..4e9e1c00 --- /dev/null +++ b/packages/yucca-admin-api/src/controllers/auth.controller.ts @@ -0,0 +1,71 @@ +import { Controller, Get, Query, Req, Res } from '@nestjs/common'; +import { ApiOkResponse, ApiQuery } from '@nestjs/swagger'; +import { type Request, type Response } from 'express'; +import { Duration } from 'luxon'; +import { AuthDto } from 'src/dto/auth.dto'; +import { CookieName } from 'src/enum'; +import { Auth, AuthRoute } from 'src/middleware/auth.guard'; +import { AuthService } from 'src/services/auth.service'; + +@Controller('/auth') +export class AuthController { + constructor(private readonly auth: AuthService) {} + + @Get() + @AuthRoute() + @ApiOkResponse({ type: AuthDto }) + getAuth(@Auth() auth: AuthDto) { + return auth; + } + + @Get('/logout') + @AuthRoute() + logout(@Res() response: Response) { + const url = this.auth.logout(); + response.clearCookie(CookieName.Sub); + response.clearCookie(CookieName.AccessToken); + response.redirect(url?.href || '/'); + } + + @Get('/oidc/login') + @ApiQuery({ name: 'code_challenge', type: String }) + @ApiQuery({ name: 'state', type: String }) + async oidcAuthorize( + @Query('code_challenge') codeChallenge: string, + @Query('state') state: string, + @Res({ passthrough: true }) response: Response, + ) { + const { redirectTo, state: newState, codeVerifier } = await this.auth.oidcAuthorize(codeChallenge, state); + + response.cookie(CookieName.OidcState, newState); + response.cookie(CookieName.OidcCodeVerifier, codeVerifier); + + response.redirect(redirectTo); + } + + @Get('/oidc/callback') + async oidcCallback(@Req() request: Request, @Res() response: Response) { + const { sub, accessToken, redirectTo } = await this.auth.oidcCallback(request); + + response.clearCookie(CookieName.OidcState); + response.clearCookie(CookieName.OidcCodeVerifier); + + response.cookie(CookieName.Sub, sub, { + path: '/', + sameSite: 'lax', + httpOnly: true, + secure: request.protocol === 'https', + maxAge: Duration.fromObject({ days: 7 }).toMillis(), + }); + + response.cookie(CookieName.AccessToken, accessToken, { + path: '/', + sameSite: 'lax', + httpOnly: true, + secure: request.protocol === 'https', + maxAge: Duration.fromObject({ days: 7 }).toMillis(), + }); + + response.redirect(redirectTo); + } +} diff --git a/packages/yucca-admin-api/src/controllers/repository.controller.ts b/packages/yucca-admin-api/src/controllers/repository.controller.ts new file mode 100644 index 00000000..d39537b0 --- /dev/null +++ b/packages/yucca-admin-api/src/controllers/repository.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Query } from '@nestjs/common'; +import { ApiOkResponse } from '@nestjs/swagger'; +import { + RepositoryGetResponseDto, + RepositoryListQueryDto, + RepositoryListResponseDto, + RepositoryUpdateRequestDto, + RepositoryUpdateResponseDto, +} from 'src/dto/repository.dto'; +import { AuthRoute } from 'src/middleware/auth.guard'; +import { RepositoryService } from 'src/services/repository.service'; + +@Controller('/repository') +export class RepositoryController { + constructor(private readonly repository: RepositoryService) {} + + @Get() + @AuthRoute() + @ApiOkResponse({ type: RepositoryListResponseDto }) + listRepositories(@Query() query: RepositoryListQueryDto): Promise { + return this.repository.list(query); + } + + @Get('/:id') + @AuthRoute() + @ApiOkResponse({ type: RepositoryGetResponseDto }) + getRepository(@Param('id') id: string): Promise { + return this.repository.get(id); + } + + @Patch('/:id') + @AuthRoute() + @ApiOkResponse({ type: RepositoryUpdateResponseDto }) + updateRepository( + @Param('id') id: string, + @Body() dto: RepositoryUpdateRequestDto, + ): Promise { + return this.repository.update(id, dto); + } + + @Delete('/:id') + @AuthRoute() + @HttpCode(HttpStatus.NO_CONTENT) + deleteRepository(@Param('id') id: string): Promise { + return this.repository.delete(id); + } +} diff --git a/packages/yucca-admin-api/src/controllers/session.controller.ts b/packages/yucca-admin-api/src/controllers/session.controller.ts new file mode 100644 index 00000000..b1ef101b --- /dev/null +++ b/packages/yucca-admin-api/src/controllers/session.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Delete, HttpCode, HttpStatus, Param } from '@nestjs/common'; +import { AuthRoute } from 'src/middleware/auth.guard'; +import { SessionService } from 'src/services/session.service'; + +@Controller('/session') +export class SessionController { + constructor(private readonly session: SessionService) {} + + @Delete('/:id') + @AuthRoute() + @HttpCode(HttpStatus.NO_CONTENT) + deleteSession(@Param('id') id: string): Promise { + return this.session.delete(id); + } +} diff --git a/packages/yucca-admin-api/src/controllers/user.controller.ts b/packages/yucca-admin-api/src/controllers/user.controller.ts new file mode 100644 index 00000000..72b28ef1 --- /dev/null +++ b/packages/yucca-admin-api/src/controllers/user.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Query } from '@nestjs/common'; +import { ApiOkResponse } from '@nestjs/swagger'; +import { SessionListResponseDto } from 'src/dto/session.dto'; +import { + UserGetResponseDto, + UserListQueryDto, + UserListResponseDto, + UserUpdateRequestDto, + UserUpdateResponseDto, +} from 'src/dto/user.dto'; +import { AuthRoute } from 'src/middleware/auth.guard'; +import { SessionService } from 'src/services/session.service'; +import { UserService } from 'src/services/user.service'; + +@Controller('/user') +export class UserController { + constructor( + private readonly user: UserService, + private readonly session: SessionService, + ) {} + + @Get() + @AuthRoute() + @ApiOkResponse({ type: UserListResponseDto }) + listUsers(@Query() query: UserListQueryDto): Promise { + return this.user.list(query); + } + + @Get('/:id') + @AuthRoute() + @ApiOkResponse({ type: UserGetResponseDto }) + getUser(@Param('id') id: string): Promise { + return this.user.get(id); + } + + @Patch('/:id') + @AuthRoute() + @ApiOkResponse({ type: UserUpdateResponseDto }) + updateUser(@Param('id') id: string, @Body() dto: UserUpdateRequestDto): Promise { + return this.user.update(id, dto); + } + + @Delete('/:id') + @AuthRoute() + @HttpCode(HttpStatus.NO_CONTENT) + deleteUser(@Param('id') id: string): Promise { + return this.user.delete(id); + } + + @Get('/:userId/session') + @AuthRoute() + @ApiOkResponse({ type: SessionListResponseDto }) + listUserSessions(@Param('userId') userId: string): Promise { + return this.session.listForUser(userId); + } + + @Delete('/:userId/session') + @AuthRoute() + @HttpCode(HttpStatus.NO_CONTENT) + deleteUserSessions(@Param('userId') userId: string): Promise { + return this.session.deleteForUser(userId); + } +} diff --git a/packages/yucca-admin-api/src/dto/auth.dto.ts b/packages/yucca-admin-api/src/dto/auth.dto.ts new file mode 100644 index 00000000..a80e516e --- /dev/null +++ b/packages/yucca-admin-api/src/dto/auth.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthDto { + @ApiProperty() + sub!: string; +} diff --git a/packages/yucca-admin-api/src/dto/pagination.dto.ts b/packages/yucca-admin-api/src/dto/pagination.dto.ts new file mode 100644 index 00000000..c16d41c9 --- /dev/null +++ b/packages/yucca-admin-api/src/dto/pagination.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumberString, IsOptional, IsUUID } from 'class-validator'; + +export const DEFAULT_PAGE_SIZE = 50; + +export class CursorPaginationDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsUUID() + cursor?: string; + + @ApiProperty({ required: false, default: DEFAULT_PAGE_SIZE }) + @IsOptional() + @IsNumberString() + limit?: string; +} diff --git a/packages/yucca-admin-api/src/dto/repository.dto.ts b/packages/yucca-admin-api/src/dto/repository.dto.ts new file mode 100644 index 00000000..781163f2 --- /dev/null +++ b/packages/yucca-admin-api/src/dto/repository.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator'; +import { CursorPaginationDto } from 'src/dto/pagination.dto'; + +export class RepositoryOwnerDto { + @ApiProperty() + id!: string; + + @ApiProperty() + name!: string; + + @ApiProperty() + email!: string; + + @ApiProperty() + disabled!: boolean; +} + +export class RepositoryMetricsDto { + @ApiProperty() + sizeBytes!: number; + + @ApiProperty({ type: 'string', required: false, nullable: true }) + lastStarted!: Date | null; + + @ApiProperty({ type: 'string', required: false, nullable: true }) + lastBackup!: Date | null; + + @ApiProperty({ type: 'string', required: false, nullable: true }) + lastSuccessfulBackup!: Date | null; + + @ApiProperty({ required: false, nullable: true }) + lastBackupDuration!: number | null; +} + +export class RepositoryAdminDto { + @ApiProperty() + id!: string; + + @ApiProperty() + name!: string; + + @ApiProperty() + worm!: boolean; + + @ApiProperty() + user!: RepositoryOwnerDto; + + @ApiProperty() + metrics!: RepositoryMetricsDto; +} + +export class RepositoryListQueryDto extends CursorPaginationDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsUUID() + userId?: string; +} + +export class RepositoryListResponseDto { + @ApiProperty({ type: [RepositoryAdminDto] }) + items!: RepositoryAdminDto[]; + + @ApiProperty({ required: false, nullable: true }) + nextCursor!: string | null; +} + +export class RepositoryGetResponseDto { + @ApiProperty() + repository!: RepositoryAdminDto; +} + +export class RepositoryUpdateRequestDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + worm?: boolean; +} + +export class RepositoryUpdateResponseDto { + @ApiProperty() + repository!: RepositoryAdminDto; +} diff --git a/packages/yucca-admin-api/src/dto/session.dto.ts b/packages/yucca-admin-api/src/dto/session.dto.ts new file mode 100644 index 00000000..62a67052 --- /dev/null +++ b/packages/yucca-admin-api/src/dto/session.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SessionDto { + @ApiProperty() + id!: string; + + @ApiProperty() + userId!: string; +} + +export class SessionListResponseDto { + @ApiProperty({ type: [SessionDto] }) + items!: SessionDto[]; +} diff --git a/packages/yucca-admin-api/src/dto/user.dto.ts b/packages/yucca-admin-api/src/dto/user.dto.ts new file mode 100644 index 00000000..bff13dbf --- /dev/null +++ b/packages/yucca-admin-api/src/dto/user.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { CursorPaginationDto } from 'src/dto/pagination.dto'; + +export class UserDto { + @ApiProperty() + id!: string; + + @ApiProperty() + sub!: string; + + @ApiProperty() + name!: string; + + @ApiProperty() + email!: string; + + @ApiProperty() + disabled!: boolean; +} + +export class UserListQueryDto extends CursorPaginationDto {} + +export class UserListResponseDto { + @ApiProperty({ type: [UserDto] }) + items!: UserDto[]; + + @ApiProperty({ required: false, nullable: true }) + nextCursor!: string | null; +} + +export class UserGetResponseDto { + @ApiProperty() + user!: UserDto; +} + +export class UserUpdateRequestDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + disabled?: boolean; +} + +export class UserUpdateResponseDto { + @ApiProperty() + user!: UserDto; +} diff --git a/packages/yucca-admin-api/src/enum.ts b/packages/yucca-admin-api/src/enum.ts new file mode 100644 index 00000000..da3b55cc --- /dev/null +++ b/packages/yucca-admin-api/src/enum.ts @@ -0,0 +1,14 @@ +export enum CookieName { + Sub = 'yucca-admin-sub', + AccessToken = 'yucca-admin-access-token', + OidcState = 'yucca-admin-oidc-state', + OidcCodeVerifier = 'yucca-admin-oidc-code-verifier', +} + +export enum MetadataKey { + Auth = 'AUTH', +} + +export enum DatabaseLock { + Migrations = 67, +} diff --git a/packages/yucca-admin-api/src/env.ts b/packages/yucca-admin-api/src/env.ts new file mode 100644 index 00000000..c0803118 --- /dev/null +++ b/packages/yucca-admin-api/src/env.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +const schema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test', 'provision']).default('development'), + + YUCCA_ADMIN_API_PORT: z.coerce.number().min(1000), + + POSTGRES_HOST: z.string(), + POSTGRES_PORT: z.coerce.number().default(5432), + POSTGRES_USERNAME: z.string(), + POSTGRES_PASSWORD: z.string(), + POSTGRES_DATABASE: z.string(), + POSTGRES_SSL: z.union([z.enum(['require', 'allow', 'prefer', 'verify-full']), z.boolean()]).default(false), + + OIDC_ADMIN_ISSUER: z.url().transform((url) => new URL(url)), + OIDC_ADMIN_CLIENT_ID: z.string(), + OIDC_ADMIN_CLIENT_SECRET: z.string(), + OIDC_ADMIN_ALLOW_INSECURE: z.coerce.boolean().default(false), + OIDC_ADMIN_REQUIRE_PKCE: z.coerce.boolean().default(true), + OIDC_ADMIN_REDIRECT_URI: z.string(), + OIDC_ADMIN_LOGOUT_REDIRECT_URI: z.string(), + OIDC_ADMIN_SCOPE: z.string().default('openid profile email'), +}); + +export const env = schema.parse(process.env); diff --git a/packages/yucca-admin-api/src/main.ts b/packages/yucca-admin-api/src/main.ts new file mode 100644 index 00000000..63e53313 --- /dev/null +++ b/packages/yucca-admin-api/src/main.ts @@ -0,0 +1,17 @@ +import '@common/server/otel'; + +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { env } from './env'; +import { useSwagger } from './utils/openapi'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + app.setGlobalPrefix('/api'); + useSwagger(app, { write: env.NODE_ENV === 'development' }); + await app.listen(env.YUCCA_ADMIN_API_PORT); +} + +void bootstrap(); diff --git a/packages/yucca-admin-api/src/middleware/auth.guard.ts b/packages/yucca-admin-api/src/middleware/auth.guard.ts new file mode 100644 index 00000000..216e7600 --- /dev/null +++ b/packages/yucca-admin-api/src/middleware/auth.guard.ts @@ -0,0 +1,46 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Scope, + SetMetadata, + applyDecorators, + createParamDecorator, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { AuthDto } from 'src/dto/auth.dto'; +import { MetadataKey } from 'src/enum'; +import { AuthService } from 'src/services/auth.service'; + +export const AuthRoute = (options = {}): MethodDecorator => { + return applyDecorators(SetMetadata(MetadataKey.Auth, options)); +}; + +export interface AuthRequest extends Request { + auth: AuthDto; +} + +export const Auth = createParamDecorator((_, context: ExecutionContext): AuthDto => { + return context.switchToHttp().getRequest().auth; +}); + +@Injectable({ scope: Scope.REQUEST }) +export class AuthGuard implements CanActivate { + constructor( + private reflector: Reflector, + private service: AuthService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const targets = [context.getHandler()]; + const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(MetadataKey.Auth, targets); + if (!options) { + return true; + } + + const request = context.switchToHttp().getRequest(); + request.auth = await this.service.authenticate(request.headers); + return true; + } +} diff --git a/packages/yucca-admin-api/src/repositories/database.repository.ts b/packages/yucca-admin-api/src/repositories/database.repository.ts new file mode 100644 index 00000000..d9590da6 --- /dev/null +++ b/packages/yucca-admin-api/src/repositories/database.repository.ts @@ -0,0 +1,89 @@ +import { LoggerRepository } from '@common/server/otel'; +import { Injectable } from '@nestjs/common'; +import { FileMigrationProvider, Kysely, Migrator, sql } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { DatabaseLock } from 'src/enum'; +import { env } from 'src/env'; +import { DB } from 'src/schema'; + +@Injectable() +export class DatabaseRepository { + constructor( + @InjectKysely() private db: Kysely, + private logger: LoggerRepository, + ) {} + + async shutdown() { + await this.db.destroy(); + } + + async withLock(lock: DatabaseLock, callback: () => Promise): Promise { + let result; + + await this.db.connection().execute(async (connection) => { + try { + await this.acquireLock(lock, connection); + result = await callback(); + } finally { + await this.releaseLock(lock, connection); + } + }); + + return result as R; + } + + private async acquireLock(lock: DatabaseLock, connection: Kysely): Promise { + await sql`SELECT pg_advisory_lock(${lock})`.execute(connection); + } + + private async releaseLock(lock: DatabaseLock, connection: Kysely): Promise { + await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection); + } + + async runMigrations(): Promise { + this.logger.debug('Running migrations'); + + const migrator = this.createMigrator(); + const { error, results } = await migrator.migrateToLatest(); + + for (const result of results ?? []) { + if (result.status === 'Success') { + this.logger.debug(`Migration "${result.migrationName}" succeeded`); + } + + if (result.status === 'Error') { + this.logger.error(`Migration "${result.migrationName}" failed`); + } + } + + if (error) { + this.logger.error(`Migrations failed: ${error}`); + + if (env.NODE_ENV === 'development') { + this.logger.warn(`⚠️ Ignoring error in development mode!`); + } else { + throw error; + } + } + + this.logger.info('Finished running migrations'); + } + + private createMigrator(): Migrator { + return new Migrator({ + db: this.db, + migrationLockTableName: 'kysely_migrations_lock', + allowUnorderedMigrations: env.NODE_ENV === 'development', + migrationTableName: 'kysely_migrations', + provider: new FileMigrationProvider({ + fs: { + readdir, + }, + path: { join }, + migrationFolder: join(__dirname, '..', 'schema/migrations'), + }), + }); + } +} diff --git a/packages/yucca-admin-api/src/repositories/oidc.repository.ts b/packages/yucca-admin-api/src/repositories/oidc.repository.ts new file mode 100644 index 00000000..575580a5 --- /dev/null +++ b/packages/yucca-admin-api/src/repositories/oidc.repository.ts @@ -0,0 +1,75 @@ +import { OnModuleInit } from '@nestjs/common'; +import * as client from 'openid-client'; +import { env } from 'src/env'; + +export class OidcRepository implements OnModuleInit { + private config!: client.Configuration; + + async onModuleInit() { + this.config = await client.discovery( + env.OIDC_ADMIN_ISSUER, + env.OIDC_ADMIN_CLIENT_ID, + env.OIDC_ADMIN_CLIENT_SECRET, + undefined, + { + execute: env.NODE_ENV === 'development' ? [client.allowInsecureRequests] : [], + }, + ); + + if (env.OIDC_ADMIN_REQUIRE_PKCE && !this.config.serverMetadata().supportsPKCE()) { + throw new Error('OIDC server does not support PKCE while OIDC_REQUIRE_PKCE is true!'); + } + } + + async authorize( + codeChallenge?: string, + state?: string, + ): Promise<{ redirectTo: URL; state: string; codeVerifier?: string }> { + let codeVerifier; + if (!codeChallenge) { + codeVerifier = client.randomPKCECodeVerifier(); + codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + } + + const parameters: Record = { + redirect_uri: env.OIDC_ADMIN_REDIRECT_URI, + scope: env.OIDC_ADMIN_SCOPE, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }; + + // non-PKCE fallback + state ??= client.randomState(); + parameters.state = state; + + const redirectTo: URL = client.buildAuthorizationUrl(this.config, parameters); + + return { redirectTo, state, codeVerifier }; + } + + async callback( + url: URL, + expectedState: string, + pkceCodeVerifier: string, + ): Promise { + return await client.authorizationCodeGrant(this.config, url, { + pkceCodeVerifier, + expectedState, + }); + } + + async fetchUserInfo(accessToken: string, sub: string): Promise { + return await client.fetchUserInfo(this.config, accessToken, sub); + } + + logout(): URL | void { + const endpoint = this.config.serverMetadata().end_session_endpoint; + + if (endpoint) { + const url = new URL(endpoint); + url.searchParams.set('client_id', env.OIDC_ADMIN_CLIENT_ID); + url.searchParams.set('post_logout_redirect_uri', env.OIDC_ADMIN_LOGOUT_REDIRECT_URI); + return url; + } + } +} diff --git a/packages/yucca-admin-api/src/repositories/repository.repository.ts b/packages/yucca-admin-api/src/repositories/repository.repository.ts new file mode 100644 index 00000000..c12ff188 --- /dev/null +++ b/packages/yucca-admin-api/src/repositories/repository.repository.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { ExpressionBuilder, Kysely, Updateable } from 'kysely'; +import { jsonBuildObject } from 'kysely/helpers/postgres'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/schema'; +import { RepositoryTable } from 'src/schema/tables/repository.table'; +import { toCursorPage } from 'src/utils/pagination'; + +const ownerJson = (eb: ExpressionBuilder) => + jsonBuildObject({ + id: eb.ref('users.id'), + name: eb.ref('users.name'), + email: eb.ref('users.email'), + disabled: eb.ref('users.disabled'), + }).as('user'); + +const metricsJson = (eb: ExpressionBuilder) => + jsonBuildObject({ + sizeBytes: eb.fn.coalesce('repositoryMetrics.sizeBytes', eb.val(0)), + lastStarted: eb.ref('repositoryMetrics.lastStarted'), + lastBackup: eb.ref('repositoryMetrics.lastBackup'), + lastSuccessfulBackup: eb.ref('repositoryMetrics.lastSuccessfulBackup'), + lastBackupDuration: eb.ref('repositoryMetrics.lastBackupDuration'), + }).as('metrics'); + +@Injectable() +export class RepositoryRepository { + constructor(@InjectKysely() private db: Kysely) {} + + async list({ cursor, limit, userId }: { cursor?: string; limit: number; userId?: string }) { + const rows = await this.db + .selectFrom('repositories') + .innerJoin('users', 'users.id', 'repositories.userId') + .leftJoin('repositoryMetrics', 'repositoryMetrics.id', 'repositories.id') + .select(['repositories.id', 'repositories.name', 'repositories.worm']) + .select(ownerJson) + .select(metricsJson) + .orderBy('repositories.id', 'asc') + .limit(limit + 1) + .$if(cursor !== undefined, (qb) => qb.where('repositories.id', '>', cursor!)) + .$if(userId !== undefined, (qb) => qb.where('repositories.userId', '=', userId!)) + .execute(); + + return toCursorPage(rows, limit); + } + + get(id: string) { + return this.db + .selectFrom('repositories') + .innerJoin('users', 'users.id', 'repositories.userId') + .leftJoin('repositoryMetrics', 'repositoryMetrics.id', 'repositories.id') + .where('repositories.id', '=', id) + .select(['repositories.id', 'repositories.name', 'repositories.worm']) + .select(ownerJson) + .select(metricsJson) + .executeTakeFirstOrThrow(); + } + + async update(id: string, repository: Updateable) { + await this.db.updateTable('repositories').where('id', '=', id).set(repository).execute(); + return this.get(id); + } + + async delete(id: string) { + await this.db.deleteFrom('repositories').where('id', '=', id).execute(); + } +} diff --git a/packages/yucca-admin-api/src/repositories/session.repository.ts b/packages/yucca-admin-api/src/repositories/session.repository.ts new file mode 100644 index 00000000..30cd6abe --- /dev/null +++ b/packages/yucca-admin-api/src/repositories/session.repository.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/schema'; + +@Injectable() +export class SessionRepository { + constructor(@InjectKysely() private db: Kysely) {} + + getByUser(userId: string) { + return this.db.selectFrom('sessions').select(['id', 'userId']).where('userId', '=', userId).execute(); + } + + delete(id: string) { + return this.db.deleteFrom('sessions').where('id', '=', id).execute(); + } + + deleteByUser(userId: string) { + return this.db.deleteFrom('sessions').where('userId', '=', userId).execute(); + } +} diff --git a/packages/yucca-admin-api/src/repositories/user.repository.ts b/packages/yucca-admin-api/src/repositories/user.repository.ts new file mode 100644 index 00000000..e36ce811 --- /dev/null +++ b/packages/yucca-admin-api/src/repositories/user.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely, Updateable } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/schema'; +import { UserTable } from 'src/schema/tables/user.table'; +import { toCursorPage } from 'src/utils/pagination'; + +@Injectable() +export class UserRepository { + constructor(@InjectKysely() private db: Kysely) {} + + async list({ cursor, limit }: { cursor?: string; limit: number }) { + const rows = await this.db + .selectFrom('users') + .selectAll() + .orderBy('id', 'asc') + .limit(limit + 1) + .$if(cursor !== undefined, (qb) => qb.where('id', '>', cursor!)) + .execute(); + + return toCursorPage(rows, limit); + } + + get(id: string) { + return this.db.selectFrom('users').selectAll().where('id', '=', id).executeTakeFirstOrThrow(); + } + + update(id: string, user: Updateable) { + return this.db.updateTable('users').where('id', '=', id).set(user).execute(); + } + + delete(id: string) { + return this.db.deleteFrom('users').where('id', '=', id).execute(); + } + + async hasRepositories(userId: string): Promise { + const row = await this.db + .selectFrom('repositories') + .where('userId', '=', userId) + .select('id') + .limit(1) + .executeTakeFirst(); + + return row !== undefined; + } +} diff --git a/packages/yucca-admin-api/src/schema b/packages/yucca-admin-api/src/schema new file mode 120000 index 00000000..95c01783 --- /dev/null +++ b/packages/yucca-admin-api/src/schema @@ -0,0 +1 @@ +../../yucca-api/src/schema \ No newline at end of file diff --git a/packages/yucca-admin-api/src/services/auth.service.spec.ts b/packages/yucca-admin-api/src/services/auth.service.spec.ts new file mode 100644 index 00000000..a7a80171 --- /dev/null +++ b/packages/yucca-admin-api/src/services/auth.service.spec.ts @@ -0,0 +1,144 @@ +import { Mocks, newMocks } from '../../test/mocks'; +import { AuthService } from './auth.service'; + +describe(AuthService.name, () => { + let mocks: Mocks; + let sut: AuthService; + + beforeEach(() => { + mocks = newMocks(); + sut = new AuthService(mocks.oidc as never, mocks.wideContext); + }); + + it('should exist', () => { + expect(sut).toBeDefined(); + }); + + describe('authenticate', () => { + it('should fail if missing sub cookie', async () => { + await expect(sut.authenticate({})).rejects.toThrowErrorMatchingInlineSnapshot(`"Missing yucca-admin-sub cookie"`); + }); + + it('should fail if missing access token cookie', async () => { + await expect( + sut.authenticate({ + cookie: 'yucca-admin-sub=oidc-sub', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Missing yucca-admin-access-token cookie"`); + }); + + it('should fail if user info is not returned by provider', async () => { + await expect( + sut.authenticate({ + cookie: 'yucca-admin-sub=oidc-sub; yucca-admin-access-token=my-token', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Missing user info"`); + }); + + it('should return sub if user info is found', async () => { + const userInfo = { sub: 'oidc-sub', name: 'name', email: 'email' }; + mocks.oidc.fetchUserInfo.mockResolvedValue(userInfo as never); + + await expect( + sut.authenticate({ + cookie: 'yucca-admin-sub=oidc-sub; yucca-admin-access-token=my-token', + }), + ).resolves.toEqual({ sub: userInfo.sub }); + + expect(mocks.oidc.fetchUserInfo).toHaveBeenCalledWith('my-token', 'oidc-sub'); + expect(mocks.wideContext.assignContext).toHaveBeenCalledWith({ userInfo }); + }); + }); + + describe('logout', () => { + it('forwards the end-session URL from the OIDC provider', () => { + const url = new URL('https://idp.example/session/end'); + mocks.oidc.logout.mockReturnValue(url); + expect(sut.logout()).toBe(url); + }); + }); + + describe('oidcAuthorize', () => { + it('should forward from OIDC provider', async () => { + const redirectTo = Symbol('Redirect URL'); + const state = Symbol('State'); + const codeVerifier = Symbol('Code Verifier'); + mocks.oidc.authorize.mockResolvedValue({ redirectTo: { href: redirectTo }, state, codeVerifier } as never); + await expect(sut.oidcAuthorize()).resolves.toEqual({ redirectTo, state, codeVerifier }); + }); + }); + + describe('oidcCallback', () => { + const request = { + protocol: 'http', + get() { + return 'localhost'; + }, + originalUrl: '', + headers: {}, + query: {}, + }; + + it('should fail if error in URL', async () => { + await expect( + sut.oidcCallback({ + ...request, + originalUrl: '?error=abc&error_description=failure', + } as never), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"OIDC error: failure"`); + }); + + it('should fail if missing state cookie', async () => { + await expect( + sut.oidcCallback({ + ...request, + } as never), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"missing expectedState"`); + }); + + it('should fail if missing code verifier cookie', async () => { + await expect( + sut.oidcCallback({ + ...request, + headers: { + cookie: 'yucca-admin-oidc-state=state', + }, + } as never), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"missing codeVerifier"`); + }); + + it('should fail if no id token returned from provider', async () => { + mocks.oidc.callback.mockResolvedValue({ claims: () => null } as never); + await expect( + sut.oidcCallback({ + ...request, + headers: { + cookie: 'yucca-admin-oidc-state=state; yucca-admin-oidc-code-verifier=code', + }, + } as never), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"no id token received"`); + }); + + it('should return sub and access token from successful callback', async () => { + const claims = { sub: 'oidc-sub', name: 'name', email: 'email' }; + const accessToken = 'access-token'; + mocks.oidc.callback.mockResolvedValue({ claims: () => claims, access_token: accessToken } as never); + + await expect( + sut.oidcCallback({ + ...request, + headers: { + cookie: 'yucca-admin-oidc-state=state; yucca-admin-oidc-code-verifier=code', + }, + } as never), + ).resolves.toEqual({ + redirectTo: '/', + sub: claims.sub, + accessToken, + }); + + expect(mocks.oidc.callback).toHaveBeenCalledWith(expect.any(URL), 'state', 'code'); + expect(mocks.wideContext.assignContext).toHaveBeenCalledWith({ claims }); + }); + }); +}); diff --git a/packages/yucca-admin-api/src/services/auth.service.ts b/packages/yucca-admin-api/src/services/auth.service.ts new file mode 100644 index 00000000..9789a26b --- /dev/null +++ b/packages/yucca-admin-api/src/services/auth.service.ts @@ -0,0 +1,91 @@ +import { WideContextRepository } from '@common/server/otel'; +import { Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; +import { parse } from 'cookie'; +import { Request } from 'express'; +import { IncomingHttpHeaders } from 'node:http'; +import { AuthDto } from 'src/dto/auth.dto'; +import { CookieName } from 'src/enum'; +import { env } from 'src/env'; +import { OidcRepository } from 'src/repositories/oidc.repository'; + +@Injectable() +export class AuthService { + constructor( + private readonly oidc: OidcRepository, + private readonly wideContext: WideContextRepository, + ) {} + + async authenticate(headers: IncomingHttpHeaders): Promise { + const cookies = parse(headers.cookie ?? ''); + const sub = cookies[CookieName.Sub]; + const accessToken = cookies[CookieName.AccessToken]; + + if (!sub) { + throw new UnauthorizedException(`Missing ${CookieName.Sub} cookie`); + } + + if (!accessToken) { + throw new UnauthorizedException(`Missing ${CookieName.AccessToken} cookie`); + } + + const userInfo = await this.oidc.fetchUserInfo(accessToken, sub); + + if (!userInfo) { + throw new UnauthorizedException(`Missing user info`); + } + + this.wideContext.assignContext({ userInfo }); + + return { + sub: userInfo.sub, + }; + } + + logout(): URL | void { + return this.oidc.logout(); + } + + async oidcAuthorize( + codeChallenge?: string, + state?: string, + ): Promise<{ redirectTo: string; state: string; codeVerifier?: string }> { + const { redirectTo, state: newState, codeVerifier } = await this.oidc.authorize(codeChallenge, state); + return { redirectTo: redirectTo.href, state: newState, codeVerifier }; + } + + async oidcCallback(request: Request): Promise<{ redirectTo: string; sub: string; accessToken: string }> { + const redirectUri = new URL(env.OIDC_ADMIN_REDIRECT_URI); + const url = new URL(`${redirectUri.origin}${request.originalUrl}`); + + const error = url.searchParams.has('error'); + + if (error) { + throw new InternalServerErrorException(`OIDC error: ${url.searchParams.get('error_description') ?? error}`); + } + + const cookies = parse(request.headers.cookie || ''); + const { [CookieName.OidcState]: expectedState, [CookieName.OidcCodeVerifier]: codeVerifier } = cookies; + + if (!expectedState) { + throw new InternalServerErrorException('missing expectedState'); + } + + if (!codeVerifier) { + throw new InternalServerErrorException('missing codeVerifier'); + } + + const response = await this.oidc.callback(url, expectedState, codeVerifier); + const claims = response.claims(); + if (!claims) { + throw new InternalServerErrorException('no id token received'); + } + + this.wideContext.assignContext({ claims }); + + return { + redirectTo: '/', + sub: claims.sub, + accessToken: response.access_token, + }; + } +} diff --git a/packages/yucca-admin-api/src/services/database.service.ts b/packages/yucca-admin-api/src/services/database.service.ts new file mode 100644 index 00000000..a4b098d0 --- /dev/null +++ b/packages/yucca-admin-api/src/services/database.service.ts @@ -0,0 +1,12 @@ +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { DatabaseLock } from 'src/enum'; +import { DatabaseRepository } from 'src/repositories/database.repository'; + +@Injectable() +export class DatabaseService implements OnApplicationBootstrap { + constructor(private readonly repository: DatabaseRepository) {} + + async onApplicationBootstrap() { + await this.repository.withLock(DatabaseLock.Migrations, () => this.repository.runMigrations()); + } +} diff --git a/packages/yucca-admin-api/src/services/repository.service.ts b/packages/yucca-admin-api/src/services/repository.service.ts new file mode 100644 index 00000000..8b5319d9 --- /dev/null +++ b/packages/yucca-admin-api/src/services/repository.service.ts @@ -0,0 +1,36 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { + RepositoryGetResponseDto, + RepositoryListQueryDto, + RepositoryListResponseDto, + RepositoryUpdateRequestDto, + RepositoryUpdateResponseDto, +} from 'src/dto/repository.dto'; +import { RepositoryRepository } from 'src/repositories/repository.repository'; +import { resolveLimit } from 'src/utils/pagination'; + +@Injectable() +export class RepositoryService { + constructor(private readonly repositories: RepositoryRepository) {} + + list(query: RepositoryListQueryDto): Promise { + return this.repositories.list({ + cursor: query.cursor, + limit: resolveLimit(query.limit), + userId: query.userId, + }); + } + + async get(id: string): Promise { + return { repository: await this.repositories.get(id) }; + } + + async update(id: string, dto: RepositoryUpdateRequestDto): Promise { + return { repository: await this.repositories.update(id, dto) }; + } + + async delete(id: string): Promise { + throw new InternalServerErrorException('unimplemented - must talk to S3'); + await this.repositories.delete(id); + } +} diff --git a/packages/yucca-admin-api/src/services/session.service.ts b/packages/yucca-admin-api/src/services/session.service.ts new file mode 100644 index 00000000..c2ca0eee --- /dev/null +++ b/packages/yucca-admin-api/src/services/session.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { SessionListResponseDto } from 'src/dto/session.dto'; +import { SessionRepository } from 'src/repositories/session.repository'; + +@Injectable() +export class SessionService { + constructor(private readonly sessions: SessionRepository) {} + + async listForUser(userId: string): Promise { + return { items: await this.sessions.getByUser(userId) }; + } + + async delete(id: string): Promise { + await this.sessions.delete(id); + } + + async deleteForUser(userId: string): Promise { + await this.sessions.deleteByUser(userId); + } +} diff --git a/packages/yucca-admin-api/src/services/user.service.ts b/packages/yucca-admin-api/src/services/user.service.ts new file mode 100644 index 00000000..81c9fea5 --- /dev/null +++ b/packages/yucca-admin-api/src/services/user.service.ts @@ -0,0 +1,42 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { + UserGetResponseDto, + UserListQueryDto, + UserListResponseDto, + UserUpdateRequestDto, + UserUpdateResponseDto, +} from 'src/dto/user.dto'; +import { SessionRepository } from 'src/repositories/session.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { resolveLimit } from 'src/utils/pagination'; + +@Injectable() +export class UserService { + constructor( + private readonly users: UserRepository, + private readonly sessions: SessionRepository, + ) {} + + list(query: UserListQueryDto): Promise { + return this.users.list({ cursor: query.cursor, limit: resolveLimit(query.limit) }); + } + + async get(id: string): Promise { + return { user: await this.users.get(id) }; + } + + async update(id: string, dto: UserUpdateRequestDto): Promise { + if (dto.disabled === true) { + await this.sessions.deleteByUser(id); + } + await this.users.update(id, dto); + return { user: await this.users.get(id) }; + } + + async delete(id: string): Promise { + if (await this.users.hasRepositories(id)) { + throw new ConflictException('Cannot delete a user that still owns repositories'); + } + await this.users.delete(id); + } +} diff --git a/packages/yucca-admin-api/src/utils/database.ts b/packages/yucca-admin-api/src/utils/database.ts new file mode 100644 index 00000000..2f59ccd3 --- /dev/null +++ b/packages/yucca-admin-api/src/utils/database.ts @@ -0,0 +1,44 @@ +import { KyselyConfig } from 'kysely'; +import { PostgresJSDialect } from 'kysely-postgres-js'; +import postgres, { Notice } from 'postgres'; +import { env } from 'src/env'; + +export const getKyselyConnectionParameters = () => ({ + connectionType: 'parts' as const, + host: env.POSTGRES_HOST, + port: env.POSTGRES_PORT, + username: env.POSTGRES_USERNAME, + password: env.POSTGRES_PASSWORD, + database: env.POSTGRES_DATABASE, +}); + +export const getKyselyConfig = ( + options: Partial>> = {}, +): KyselyConfig => { + return { + dialect: new PostgresJSDialect({ + postgres: postgres({ + onnotice: (notice: Notice) => { + if (notice['severity'] !== 'NOTICE') { + console.warn('Postgres notice:', notice); + } + }, + connection: { + TimeZone: 'UTC', + }, + ...getKyselyConnectionParameters(), + ...options, + }), + }), + log(event) { + if (event.level === 'error') { + console.error('Query failed:', { + durationMs: event.queryDurationMillis, + error: event.error, + sql: event.query.sql, + params: event.query.parameters, + }); + } + }, + }; +}; diff --git a/packages/yucca-admin-api/src/utils/openapi.ts b/packages/yucca-admin-api/src/utils/openapi.ts new file mode 100644 index 00000000..aa593fac --- /dev/null +++ b/packages/yucca-admin-api/src/utils/openapi.ts @@ -0,0 +1,33 @@ +import { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerCustomOptions, SwaggerDocumentOptions, SwaggerModule } from '@nestjs/swagger'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export const useSwagger = (app: INestApplication, { write }: { write: boolean }) => { + const builder = new DocumentBuilder().setTitle('yucca-admin').setDescription('yucca admin API').addServer('/'); + + const config = builder.build(); + + const options: SwaggerDocumentOptions = { + operationIdFactory: (_: string, methodKey: string) => methodKey, + }; + + const specification = SwaggerModule.createDocument(app, config, options); + + const customOptions: SwaggerCustomOptions = { + swaggerOptions: { + persistAuthorization: true, + }, + jsonDocumentUrl: '/api/spec.json', + yamlDocumentUrl: '/api/spec.yaml', + customSiteTitle: 'yucca admin API Documentation', + }; + + SwaggerModule.setup('doc', app, specification, customOptions); + + if (write) { + // Generate API Documentation only in development mode + const outputPath = resolve(process.cwd(), 'openapi-specs.json'); + writeFileSync(outputPath, JSON.stringify(specification, null, 2), { encoding: 'utf8' }); + } +}; diff --git a/packages/yucca-admin-api/src/utils/pagination.ts b/packages/yucca-admin-api/src/utils/pagination.ts new file mode 100644 index 00000000..db274d38 --- /dev/null +++ b/packages/yucca-admin-api/src/utils/pagination.ts @@ -0,0 +1,16 @@ +import { DEFAULT_PAGE_SIZE } from 'src/dto/pagination.dto'; + +export interface CursorPage { + items: T[]; + nextCursor: string | null; +} + +export function resolveLimit(limit?: string): number { + return limit ? Number.parseInt(limit) : DEFAULT_PAGE_SIZE; +} + +export function toCursorPage(rows: T[], limit: number): CursorPage { + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + return { items, nextCursor: hasMore ? (items.at(-1)?.id ?? null) : null }; +} diff --git a/packages/yucca-admin-api/test/auth.integration-spec.ts b/packages/yucca-admin-api/test/auth.integration-spec.ts new file mode 100644 index 00000000..8b47b23f --- /dev/null +++ b/packages/yucca-admin-api/test/auth.integration-spec.ts @@ -0,0 +1,138 @@ +import { MetricService } from '@common/server/otel'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { parse } from 'cookie'; +import { env } from 'src/env'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { controllers, imports, providers } from '../src/app.module'; +import { newMetricServiceMock } from './mocks'; +import { testUtils } from './testUtils'; + +describe('AuthController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports, + controllers, + providers: [MetricService, ...providers], + }) + .overrideProvider(MetricService) + .useValue(newMetricServiceMock()) + .compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('/api'); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + + await testUtils.resetDatabase(); + }); + + const loginAs = async (sub: string) => { + const { header } = await request(app.getHttpServer()).get('/api/auth/oidc/login').expect(302); + const stateCookies = parse((header['set-cookie'] as never as string[]).join('; ')); + + const redirectUrl = new URL(header.location); + redirectUrl.pathname = '/api/form'; + redirectUrl.searchParams.set('sub', sub); + + const { headers } = await fetch(redirectUrl, { redirect: 'manual' }); + const callbackUrl = new URL(headers.get('location')!); + + const { header: authHeader } = await request(app.getHttpServer()) + .get(callbackUrl.pathname + callbackUrl.search) + .set('Cookie', [ + `yucca-admin-oidc-state=${stateCookies['yucca-admin-oidc-state']}`, + `yucca-admin-oidc-code-verifier=${stateCookies['yucca-admin-oidc-code-verifier']}`, + ]) + .expect(302); + + const authCookies = parse((authHeader['set-cookie'] as never as string[]).join('; ')); + return { + sub: authCookies['yucca-admin-sub']!, + accessToken: authCookies['yucca-admin-access-token']!, + cookies: [ + `yucca-admin-sub=${authCookies['yucca-admin-sub']}`, + `yucca-admin-access-token=${authCookies['yucca-admin-access-token']}`, + ], + }; + }; + + describe('GET /auth', () => { + it('fails with no auth provided', async () => { + await request(app.getHttpServer()).get('/api/auth').expect(401); + }); + + it('responds with the authenticated sub', async () => { + const { sub, cookies } = await loginAs('admin-user'); + + await request(app.getHttpServer()).get('/api/auth').set('Cookie', cookies).expect(200).expect({ sub }); + }); + }); + + describe('GET /auth/logout', () => { + it('fails if not authenticated', async () => { + await request(app.getHttpServer()).get('/api/auth/logout').expect(401); + }); + + it('clears the auth cookies and redirects to the IdP end-session endpoint', async () => { + const { cookies } = await loginAs('admin-user'); + + const { header } = await request(app.getHttpServer()).get('/api/auth/logout').set('Cookie', cookies).expect(302); + + expect(header['set-cookie']).toEqual( + expect.arrayContaining([ + expect.stringContaining('yucca-admin-sub=;'), + expect.stringContaining('yucca-admin-access-token=;'), + ]), + ); + expect(header.location).toEqual(expect.stringContaining(env.OIDC_ADMIN_ISSUER.href)); + }); + }); + + describe('GET /auth/oidc/login', () => { + it('redirects to IdP with state cookies', async () => { + const { header } = await request(app.getHttpServer()).get('/api/auth/oidc/login').expect(302); + + expect(header['set-cookie']).toEqual( + expect.arrayContaining([ + expect.stringContaining('yucca-admin-oidc-state='), + expect.stringContaining('yucca-admin-oidc-code-verifier='), + ]), + ); + + expect(header.location).toEqual(expect.stringContaining(env.OIDC_ADMIN_ISSUER.href)); + }); + }); + + describe('GET /auth/oidc/callback', () => { + it('sets sub and access-token cookies after a successful OIDC flow', async () => { + const { header } = await request(app.getHttpServer()).get('/api/auth/oidc/login').expect(302); + const stateCookies = parse((header['set-cookie'] as never as string[]).join('; ')); + + const redirectUrl = new URL(header.location); + redirectUrl.pathname = '/api/form'; + redirectUrl.searchParams.set('sub', 'admin-user'); + + const { headers } = await fetch(redirectUrl, { redirect: 'manual' }); + const callbackUrl = new URL(headers.get('location')!); + + const { header: authHeader } = await request(app.getHttpServer()) + .get(callbackUrl.pathname + callbackUrl.search) + .set('Cookie', [ + `yucca-admin-oidc-state=${stateCookies['yucca-admin-oidc-state']}`, + `yucca-admin-oidc-code-verifier=${stateCookies['yucca-admin-oidc-code-verifier']}`, + ]) + .expect(302); + + expect(authHeader['set-cookie']).toEqual( + expect.arrayContaining([ + expect.stringContaining('yucca-admin-sub=admin-user'), + expect.stringContaining('yucca-admin-access-token='), + ]), + ); + }); + }); +}); diff --git a/packages/yucca-admin-api/test/jest-integration.json b/packages/yucca-admin-api/test/jest-integration.json new file mode 100644 index 00000000..afc9d2d4 --- /dev/null +++ b/packages/yucca-admin-api/test/jest-integration.json @@ -0,0 +1,14 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".integration-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "transformIgnorePatterns": ["/node_modules/.pnpm/(?!(openid-client|oauth4webapi|jose))"], + "moduleNameMapper": { + "^src/(.*)$": "/../src/$1" + }, + "forceExit": true +} diff --git a/packages/yucca-admin-api/test/mocks.ts b/packages/yucca-admin-api/test/mocks.ts new file mode 100644 index 00000000..430d0cca --- /dev/null +++ b/packages/yucca-admin-api/test/mocks.ts @@ -0,0 +1,54 @@ +import type { LoggerRepository, WideContextRepository } from '@common/server/otel'; +import type { DatabaseRepository } from 'src/repositories/database.repository'; +import type { OidcRepository } from 'src/repositories/oidc.repository'; + +export type RepositoryInterface = Pick; + +export const newDatabaseRepositoryMock = (): jest.Mocked> => { + return { + shutdown: jest.fn(), + }; +}; + +export const newOidcRepositoryMock = (): jest.Mocked> => { + return { + authorize: jest.fn(), + callback: jest.fn(), + logout: jest.fn(), + onModuleInit: jest.fn(), + fetchUserInfo: jest.fn(), + }; +}; + +export const newLoggerRepositoryMock = (): jest.Mocked> => { + return { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }; +}; + +export const newWideContextRepositoryMock = (): jest.Mocked> => ({ + context: {}, + addContext: jest.fn(), + applyContext: jest.fn(), + assignContext: jest.fn(), + setErrorCause: jest.fn(), +}); + +export const newMetricServiceMock = () => ({ + getCounter: jest.fn().mockReturnValue({ add: jest.fn() }), +}); + +export const newMocks = () => { + return { + database: newDatabaseRepositoryMock(), + oidc: newOidcRepositoryMock(), + logger: newLoggerRepositoryMock(), + wideContext: newWideContextRepositoryMock(), + metrics: newMetricServiceMock(), + }; +}; + +export type Mocks = ReturnType; diff --git a/packages/yucca-admin-api/test/repository.integration-spec.ts b/packages/yucca-admin-api/test/repository.integration-spec.ts new file mode 100644 index 00000000..87c7331e --- /dev/null +++ b/packages/yucca-admin-api/test/repository.integration-spec.ts @@ -0,0 +1,130 @@ +import { MetricService } from '@common/server/otel'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { OidcRepository } from 'src/repositories/oidc.repository'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { controllers, imports, providers } from '../src/app.module'; +import { newMetricServiceMock } from './mocks'; +import { testUtils } from './testUtils'; + +const authCookie = ['yucca-admin-sub=admin', 'yucca-admin-access-token=token']; + +describe('RepositoryController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports, + controllers, + providers: [MetricService, ...providers], + }) + .overrideProvider(MetricService) + .useValue(newMetricServiceMock()) + .overrideProvider(OidcRepository) + .useValue({ onModuleInit: jest.fn(), fetchUserInfo: jest.fn().mockResolvedValue({ sub: 'admin' }) }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('/api'); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + + await testUtils.resetDatabase(); + }); + + describe('GET /repository', () => { + it('requires authentication', async () => { + await request(app.getHttpServer()).get('/api/repository').expect(401); + }); + + it('lists repositories with owner and metrics', async () => { + const owner = await testUtils.createUser({ name: 'owner' }); + const repository = await testUtils.createRepository(owner.id, { name: 'Repo A' }); + + const { body } = await request(app.getHttpServer()).get('/api/repository').set('Cookie', authCookie).expect(200); + + expect(body.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: repository.id, + name: 'Repo A', + worm: false, + user: expect.objectContaining({ id: owner.id, name: 'owner', email: owner.email }), + metrics: expect.objectContaining({ sizeBytes: expect.anything() }), + }), + ]), + ); + expect(body.nextCursor).toBeNull(); + }); + + it('filters by userId', async () => { + const owner = await testUtils.createUser(); + const other = await testUtils.createUser(); + const repository = await testUtils.createRepository(owner.id); + await testUtils.createRepository(other.id); + + const { body } = await request(app.getHttpServer()) + .get(`/api/repository?userId=${owner.id}`) + .set('Cookie', authCookie) + .expect(200); + + expect(body.items).toHaveLength(1); + expect(body.items[0].id).toBe(repository.id); + }); + }); + + describe('GET /repository/:id', () => { + it('returns a repository with its owner and metrics', async () => { + const owner = await testUtils.createUser({ name: 'owner' }); + const repository = await testUtils.createRepository(owner.id, { name: 'Repo B' }); + + const { body } = await request(app.getHttpServer()) + .get(`/api/repository/${repository.id}`) + .set('Cookie', authCookie) + .expect(200); + + expect(body.repository).toEqual( + expect.objectContaining({ + id: repository.id, + name: 'Repo B', + worm: false, + user: expect.objectContaining({ id: owner.id }), + metrics: expect.any(Object), + }), + ); + }); + }); + + describe('PATCH /repository/:id', () => { + it('updates the repository name', async () => { + const owner = await testUtils.createUser(); + const repository = await testUtils.createRepository(owner.id, { name: 'Before' }); + + const { body } = await request(app.getHttpServer()) + .patch(`/api/repository/${repository.id}`) + .set('Cookie', authCookie) + .send({ name: 'After' }) + .expect(200); + + expect(body.repository).toEqual(expect.objectContaining({ id: repository.id, name: 'After' })); + }); + + it('lets an admin disable WORM', async () => { + const owner = await testUtils.createUser(); + const repository = await testUtils.createRepository(owner.id, { worm: true }); + + const { body } = await request(app.getHttpServer()) + .patch(`/api/repository/${repository.id}`) + .set('Cookie', authCookie) + .send({ worm: false }) + .expect(200); + + expect(body.repository.worm).toBe(false); + }); + }); + + describe('DELETE /repository/:id', () => { + it.todo('deletes a repository'); + }); +}); diff --git a/packages/yucca-admin-api/test/session.integration-spec.ts b/packages/yucca-admin-api/test/session.integration-spec.ts new file mode 100644 index 00000000..b1a8151c --- /dev/null +++ b/packages/yucca-admin-api/test/session.integration-spec.ts @@ -0,0 +1,88 @@ +import { MetricService } from '@common/server/otel'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { OidcRepository } from 'src/repositories/oidc.repository'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { controllers, imports, providers } from '../src/app.module'; +import { newMetricServiceMock } from './mocks'; +import { testUtils } from './testUtils'; + +const authCookie = ['yucca-admin-sub=admin', 'yucca-admin-access-token=token']; + +describe('Sessions (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports, + controllers, + providers: [MetricService, ...providers], + }) + .overrideProvider(MetricService) + .useValue(newMetricServiceMock()) + .overrideProvider(OidcRepository) + .useValue({ onModuleInit: jest.fn(), fetchUserInfo: jest.fn().mockResolvedValue({ sub: 'admin' }) }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('/api'); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + + await testUtils.resetDatabase(); + }); + + describe('GET /user/:userId/session', () => { + it('requires authentication', async () => { + const user = await testUtils.createUser(); + await request(app.getHttpServer()).get(`/api/user/${user.id}/session`).expect(401); + }); + + it("lists a user's sessions without leaking access tokens", async () => { + const user = await testUtils.createUser(); + const first = await testUtils.createSession(user.id); + const second = await testUtils.createSession(user.id); + + const { body } = await request(app.getHttpServer()) + .get(`/api/user/${user.id}/session`) + .set('Cookie', authCookie) + .expect(200); + + expect(body.items).toEqual( + expect.arrayContaining([ + { id: first.id, userId: user.id }, + { id: second.id, userId: user.id }, + ]), + ); + expect(body.items[0]).not.toHaveProperty('accessToken'); + }); + }); + + describe('DELETE /session/:id', () => { + it('revokes a single session', async () => { + const user = await testUtils.createUser(); + const session = await testUtils.createSession(user.id); + + await request(app.getHttpServer()).delete(`/api/session/${session.id}`).set('Cookie', authCookie).expect(204); + await expect(testUtils.getSession(session.id)).resolves.toBeUndefined(); + }); + }); + + describe('DELETE /user/:userId/session', () => { + it("revokes all of a user's sessions", async () => { + const user = await testUtils.createUser(); + await testUtils.createSession(user.id); + await testUtils.createSession(user.id); + + await request(app.getHttpServer()).delete(`/api/user/${user.id}/session`).set('Cookie', authCookie).expect(204); + + const { body } = await request(app.getHttpServer()) + .get(`/api/user/${user.id}/session`) + .set('Cookie', authCookie) + .expect(200); + + expect(body.items).toEqual([]); + }); + }); +}); diff --git a/packages/yucca-admin-api/test/testUtils.ts b/packages/yucca-admin-api/test/testUtils.ts new file mode 100644 index 00000000..d78f0fde --- /dev/null +++ b/packages/yucca-admin-api/test/testUtils.ts @@ -0,0 +1,55 @@ +import { Kysely } from 'kysely'; +import { randomUUID } from 'node:crypto'; +import { DB } from 'src/schema'; +import { getKyselyConfig } from 'src/utils/database'; + +let db: Kysely; + +function getDb() { + if (!db) { + db = new Kysely(getKyselyConfig()); + } + + return db; +} + +export const testUtils = { + resetDatabase: async () => { + const db = getDb(); + await db.deleteFrom('repositories').execute(); + await db.deleteFrom('sessions').execute(); + await db.deleteFrom('users').execute(); + }, + + createUser: ({ + name = 'foo', + email, + sub, + disabled = false, + }: Partial<{ name: string; email: string; sub: string; disabled: boolean }> = {}) => { + return getDb() + .insertInto('users') + .values({ name, email: email ?? `${randomUUID()}@example.test`, sub: sub ?? randomUUID(), disabled }) + .returningAll() + .executeTakeFirstOrThrow(); + }, + + createSession: (userId: string, accessToken: string = randomUUID()) => { + return getDb().insertInto('sessions').values({ userId, accessToken }).returningAll().executeTakeFirstOrThrow(); + }, + + createRepository: ( + userId: string, + { name = 'My Repository', worm = false }: Partial<{ name: string; worm: boolean }> = {}, + ) => { + return getDb().insertInto('repositories').values({ userId, name, worm }).returningAll().executeTakeFirstOrThrow(); + }, + + getUser: (id: string) => { + return getDb().selectFrom('users').selectAll().where('id', '=', id).executeTakeFirst(); + }, + + getSession: (id: string) => { + return getDb().selectFrom('sessions').selectAll().where('id', '=', id).executeTakeFirst(); + }, +}; diff --git a/packages/yucca-admin-api/test/user.integration-spec.ts b/packages/yucca-admin-api/test/user.integration-spec.ts new file mode 100644 index 00000000..afc0b95e --- /dev/null +++ b/packages/yucca-admin-api/test/user.integration-spec.ts @@ -0,0 +1,138 @@ +import { MetricService } from '@common/server/otel'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { OidcRepository } from 'src/repositories/oidc.repository'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { controllers, imports, providers } from '../src/app.module'; +import { newMetricServiceMock } from './mocks'; +import { testUtils } from './testUtils'; + +const authCookie = ['yucca-admin-sub=admin', 'yucca-admin-access-token=token']; + +describe('UserController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports, + controllers, + providers: [MetricService, ...providers], + }) + .overrideProvider(MetricService) + .useValue(newMetricServiceMock()) + .overrideProvider(OidcRepository) + .useValue({ onModuleInit: jest.fn(), fetchUserInfo: jest.fn().mockResolvedValue({ sub: 'admin' }) }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('/api'); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + + await testUtils.resetDatabase(); + }); + + describe('GET /user', () => { + it('requires authentication', async () => { + await request(app.getHttpServer()).get('/api/user').expect(401); + }); + + it('lists users', async () => { + const alice = await testUtils.createUser({ name: 'alice' }); + const bob = await testUtils.createUser({ name: 'bob' }); + + const { body } = await request(app.getHttpServer()).get('/api/user').set('Cookie', authCookie).expect(200); + + expect(body.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: alice.id, name: 'alice', disabled: false }), + expect.objectContaining({ id: bob.id, name: 'bob' }), + ]), + ); + expect(body.nextCursor).toBeNull(); + }); + + it('paginates with limit and returns a cursor', async () => { + await testUtils.createUser(); + await testUtils.createUser(); + await testUtils.createUser(); + + const { body } = await request(app.getHttpServer()) + .get('/api/user?limit=2') + .set('Cookie', authCookie) + .expect(200); + + expect(body.items).toHaveLength(2); + expect(body.nextCursor).toEqual(expect.any(String)); + + const { body: page2 } = await request(app.getHttpServer()) + .get(`/api/user?limit=2&cursor=${body.nextCursor}`) + .set('Cookie', authCookie) + .expect(200); + + expect(page2.items).toHaveLength(1); + expect(page2.nextCursor).toBeNull(); + }); + }); + + describe('GET /user/:id', () => { + it('returns a single user', async () => { + const user = await testUtils.createUser({ name: 'carol' }); + + const { body } = await request(app.getHttpServer()) + .get(`/api/user/${user.id}`) + .set('Cookie', authCookie) + .expect(200); + + expect(body).toEqual({ + user: { id: user.id, sub: user.sub, name: 'carol', email: user.email, disabled: false }, + }); + }); + }); + + describe('PATCH /user/:id', () => { + it('disables a user and revokes their sessions', async () => { + const user = await testUtils.createUser(); + const session = await testUtils.createSession(user.id); + + const { body } = await request(app.getHttpServer()) + .patch(`/api/user/${user.id}`) + .set('Cookie', authCookie) + .send({ disabled: true }) + .expect(200); + + expect(body.user).toEqual(expect.objectContaining({ id: user.id, disabled: true })); + await expect(testUtils.getSession(session.id)).resolves.toBeUndefined(); + }); + + it('re-enables a disabled user', async () => { + const user = await testUtils.createUser({ disabled: true }); + + const { body } = await request(app.getHttpServer()) + .patch(`/api/user/${user.id}`) + .set('Cookie', authCookie) + .send({ disabled: false }) + .expect(200); + + expect(body.user.disabled).toBe(false); + }); + }); + + describe('DELETE /user/:id', () => { + it('deletes a user with no repositories', async () => { + const user = await testUtils.createUser(); + + await request(app.getHttpServer()).delete(`/api/user/${user.id}`).set('Cookie', authCookie).expect(204); + await expect(testUtils.getUser(user.id)).resolves.toBeUndefined(); + }); + + it('refuses to delete a user that still owns repositories', async () => { + const user = await testUtils.createUser(); + await testUtils.createRepository(user.id); + + await request(app.getHttpServer()).delete(`/api/user/${user.id}`).set('Cookie', authCookie).expect(409); + await expect(testUtils.getUser(user.id)).resolves.toBeTruthy(); + }); + }); +}); diff --git a/packages/yucca-admin-api/tsconfig.build.json b/packages/yucca-admin-api/tsconfig.build.json new file mode 100644 index 00000000..64f86c6b --- /dev/null +++ b/packages/yucca-admin-api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/packages/yucca-admin-api/tsconfig.json b/packages/yucca-admin-api/tsconfig.json new file mode 100644 index 00000000..01a82167 --- /dev/null +++ b/packages/yucca-admin-api/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictBindCallApply": false, + "noFallthroughCasesInSwitch": false, + "paths": { + "src/*": ["./src/*"] + }, + "types": ["jest"] + }, + "include": ["src", "test"] +} diff --git a/packages/yucca-api/src/enum.ts b/packages/yucca-api/src/enum.ts index eaf0fc25..5fa72dca 100644 --- a/packages/yucca-api/src/enum.ts +++ b/packages/yucca-api/src/enum.ts @@ -2,9 +2,12 @@ export enum CookieName { AccessToken = 'yucca-access-token', OidcState = 'yucca-oidc-state', OidcCodeVerifier = 'yucca-oidc-code-verifier', - AppCodeChallenge = 'yucca-app-code-challenge', } export enum MetadataKey { Auth = 'AUTH', } + +export enum DatabaseLock { + Migrations = 67, +} diff --git a/packages/yucca-api/src/repositories/database.repository.ts b/packages/yucca-api/src/repositories/database.repository.ts index f2fa4c3d..d9590da6 100644 --- a/packages/yucca-api/src/repositories/database.repository.ts +++ b/packages/yucca-api/src/repositories/database.repository.ts @@ -1,9 +1,10 @@ import { LoggerRepository } from '@common/server/otel'; import { Injectable } from '@nestjs/common'; -import { FileMigrationProvider, Kysely, Migrator } from 'kysely'; +import { FileMigrationProvider, Kysely, Migrator, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; +import { DatabaseLock } from 'src/enum'; import { env } from 'src/env'; import { DB } from 'src/schema'; @@ -18,6 +19,29 @@ export class DatabaseRepository { await this.db.destroy(); } + async withLock(lock: DatabaseLock, callback: () => Promise): Promise { + let result; + + await this.db.connection().execute(async (connection) => { + try { + await this.acquireLock(lock, connection); + result = await callback(); + } finally { + await this.releaseLock(lock, connection); + } + }); + + return result as R; + } + + private async acquireLock(lock: DatabaseLock, connection: Kysely): Promise { + await sql`SELECT pg_advisory_lock(${lock})`.execute(connection); + } + + private async releaseLock(lock: DatabaseLock, connection: Kysely): Promise { + await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection); + } + async runMigrations(): Promise { this.logger.debug('Running migrations'); diff --git a/packages/yucca-api/src/repositories/user.repository.ts b/packages/yucca-api/src/repositories/user.repository.ts index 80bcd059..d1ed5f17 100644 --- a/packages/yucca-api/src/repositories/user.repository.ts +++ b/packages/yucca-api/src/repositories/user.repository.ts @@ -13,7 +13,7 @@ export class UserRepository { } getBySub(sub: string) { - return this.db.selectFrom('users').select('id').where('sub', '=', sub).executeTakeFirst(); + return this.db.selectFrom('users').select(['id', 'disabled']).where('sub', '=', sub).executeTakeFirst(); } getByAccessToken(accessToken: string) { @@ -21,8 +21,8 @@ export class UserRepository { .selectFrom('sessions') .where('accessToken', '=', accessToken) .innerJoin('users', 'users.id', 'sessions.userId') - .selectAll('users') - .select(['sessions.id as sessionId']) + .where('users.disabled', '=', false) + .select(['users.id', 'users.sub', 'users.name', 'users.email', 'sessions.id as sessionId']) .executeTakeFirst(); } diff --git a/packages/yucca-api/src/schema/migrations/20260521170000-AddDisabledToUsers.ts b/packages/yucca-api/src/schema/migrations/20260521170000-AddDisabledToUsers.ts new file mode 100644 index 00000000..20e795f1 --- /dev/null +++ b/packages/yucca-api/src/schema/migrations/20260521170000-AddDisabledToUsers.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "users" ADD "disabled" boolean NOT NULL DEFAULT false;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "users" DROP COLUMN "disabled";`.execute(db); +} diff --git a/packages/yucca-api/src/schema/tables/user.table.ts b/packages/yucca-api/src/schema/tables/user.table.ts index 7e5d9dc5..ba3ef35a 100644 --- a/packages/yucca-api/src/schema/tables/user.table.ts +++ b/packages/yucca-api/src/schema/tables/user.table.ts @@ -13,4 +13,7 @@ export class UserTable { @Column({ unique: true }) email!: string; + + @Column({ type: 'boolean', default: () => 'false' }) + disabled!: Generated; } diff --git a/packages/yucca-api/src/services/auth.service.ts b/packages/yucca-api/src/services/auth.service.ts index c00725d4..18ca00c2 100644 --- a/packages/yucca-api/src/services/auth.service.ts +++ b/packages/yucca-api/src/services/auth.service.ts @@ -115,6 +115,10 @@ export class AuthService { let user = await this.user.getBySub(claims.sub); if (user) { + if (user.disabled) { + throw new UnauthorizedException('Account is disabled'); + } + await this.user.update(user.id, { name: claims.name, email: claims.email, diff --git a/packages/yucca-api/src/services/database.service.ts b/packages/yucca-api/src/services/database.service.ts index d04da94a..a4b098d0 100644 --- a/packages/yucca-api/src/services/database.service.ts +++ b/packages/yucca-api/src/services/database.service.ts @@ -1,4 +1,5 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { DatabaseLock } from 'src/enum'; import { DatabaseRepository } from 'src/repositories/database.repository'; @Injectable() @@ -6,6 +7,6 @@ export class DatabaseService implements OnApplicationBootstrap { constructor(private readonly repository: DatabaseRepository) {} async onApplicationBootstrap() { - await this.repository.runMigrations(); + await this.repository.withLock(DatabaseLock.Migrations, () => this.repository.runMigrations()); } } diff --git a/packages/yucca-api/test/auth.integration-spec.ts b/packages/yucca-api/test/auth.integration-spec.ts index fb507b7a..d044e4f9 100644 --- a/packages/yucca-api/test/auth.integration-spec.ts +++ b/packages/yucca-api/test/auth.integration-spec.ts @@ -56,6 +56,15 @@ describe('AuthController (e2e)', () => { sessionId: session.id, }); }); + + it('rejects a disabled user with an existing session', async () => { + await testUtils.disableUser(user.id); + + await request(app.getHttpServer()) + .get('/api/auth') + .set('Cookie', `yucca-access-token=${session.accessToken}`) + .expect(401); + }); }); describe('GET /auth/logout', () => { @@ -179,6 +188,31 @@ describe('AuthController (e2e)', () => { }), ); }); + + it('refuses to log in a disabled user', async () => { + await testUtils.disableUser(user.id); + + const { header } = await request(app.getHttpServer()).get('/api/auth/oidc/login').expect(302); + const cookies = parse((header['set-cookie'] as never as string[]).join('; ')); + + const redirectUrl = new URL(header.location); + redirectUrl.pathname = '/api/form'; + redirectUrl.searchParams.set('sub', user.sub); + + const { headers } = await fetch(redirectUrl, { + redirect: 'manual', + }); + + const callbackUrl = new URL(headers.get('location')!); + + await request(app.getHttpServer()) + .get(callbackUrl.pathname + callbackUrl.search) + .set('Cookie', [ + `yucca-oidc-state=${cookies['yucca-oidc-state']}`, + `yucca-oidc-code-verifier=${cookies['yucca-oidc-code-verifier']}`, + ]) + .expect(401); + }); }); describe('GET /auth/oidc/device (SSE)', () => { diff --git a/packages/yucca-api/test/testUtils.ts b/packages/yucca-api/test/testUtils.ts index 38e9e802..8be86e3a 100644 --- a/packages/yucca-api/test/testUtils.ts +++ b/packages/yucca-api/test/testUtils.ts @@ -54,6 +54,11 @@ export const testUtils = { return userRepository.getBySub(sub); }, + disableUser: async (id: string) => { + const db = getDb(); + await db.updateTable('users').set({ disabled: true }).where('id', '=', id).execute(); + }, + getUserByAccessToken: (accessToken: string) => { const db = getDb(); const userRepository = new UserRepository(db); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 556fe698..3875f65f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,6 +346,130 @@ importers: specifier: ^2.0.1 version: 2.0.1(svelte@5.47.0)(vitest@4.0.17) + packages/yucca-admin-api: + dependencies: + '@common/server': + specifier: workspace:^ + version: link:../common + '@immich/sql-tools': + specifier: ^0.3.2 + version: 0.3.2 + '@nestjs/common': + specifier: ^11.0.1 + version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.1 + version: 11.1.11(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/platform-express': + specifier: ^11.0.1 + version: 11.1.11(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11) + '@nestjs/swagger': + specifier: ^11.2.5 + version: 11.2.5(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + class-validator: + specifier: ^0.14.3 + version: 0.14.3 + cookie: + specifier: ^1.1.1 + version: 1.1.1 + event-iterator: + specifier: ^2.0.0 + version: 2.0.0 + kysely: + specifier: 0.28.2 + version: 0.28.2 + kysely-postgres-js: + specifier: ^3.0.0 + version: 3.0.0(kysely@0.28.2)(postgres@3.4.8) + luxon: + specifier: ^3.7.2 + version: 3.7.2 + nestjs-kysely: + specifier: ^3.1.2 + version: 3.1.2(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(kysely@0.28.2)(reflect-metadata@0.2.2) + openid-client: + specifier: ^6.8.1 + version: 6.8.1 + postgres: + specifier: ^3.4.8 + version: 3.4.8 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + zod: + specifier: ^4.3.5 + version: 4.3.5 + devDependencies: + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.14(@types/node@22.19.3) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.1 + version: 11.1.11(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-express@11.1.11) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/luxon': + specifier: ^3.7.1 + version: 3.7.1 + '@types/ms': + specifier: ^2.1.0 + version: 2.1.0 + '@types/node': + specifier: ^22.10.7 + version: 22.19.3 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + globals: + specifier: ^16.0.0 + version: 16.5.0 + jest: + specifier: ^30.0.0 + version: 30.2.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + supertest: + specifier: ^7.0.0 + version: 7.2.2 + ts-jest: + specifier: ^29.2.5 + version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: ^9.5.2 + version: 9.5.4(typescript@5.9.3)(webpack@5.103.0) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.19.3)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: ^4.0.16 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/yucca-api: dependencies: '@common/server': @@ -8689,6 +8813,12 @@ snapshots: '@types/jsonwebtoken': 9.0.10 jsonwebtoken: 9.0.3 + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.0.1(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 11.0.1(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -8740,7 +8870,6 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color - optional: true '@nestjs/platform-socket.io@11.1.14(@nestjs/common@11.0.1(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(rxjs@7.8.1)': dependencies: @@ -10465,6 +10594,20 @@ snapshots: - utf-8-validate - vite + '@vitest/browser-playwright@4.0.18(playwright@1.58.1)(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': + dependencies: + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + playwright: 1.58.1 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.0.17(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17)': dependencies: '@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -10500,6 +10643,24 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@4.0.18(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': + dependencies: + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/utils': 4.0.18 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -10551,6 +10712,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 @@ -14262,6 +14431,45 @@ snapshots: - tsx - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.3 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.1)(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 diff --git a/yaak/yaak.fl_SpbNeUZw3W.yaml b/yaak/yaak.fl_SpbNeUZw3W.yaml new file mode 100644 index 00000000..8c193bc6 --- /dev/null +++ b/yaak/yaak.fl_SpbNeUZw3W.yaml @@ -0,0 +1,13 @@ +type: folder +model: folder +id: fl_SpbNeUZw3W +createdAt: 2026-05-27T11:50:21.212315861 +updatedAt: 2026-05-27T11:54:58.664978366 +workspaceId: wk_sf38TckR4U +folderId: fl_ZepPmNAPfD +authentication: {} +authenticationType: null +description: '' +headers: [] +name: Repository +sortPriority: 2000.0 diff --git a/yaak/yaak.fl_ZepPmNAPfD.yaml b/yaak/yaak.fl_ZepPmNAPfD.yaml new file mode 100644 index 00000000..e41f5888 --- /dev/null +++ b/yaak/yaak.fl_ZepPmNAPfD.yaml @@ -0,0 +1,21 @@ +type: folder +model: folder +id: fl_ZepPmNAPfD +createdAt: 2026-05-27T10:52:09.283525598 +updatedAt: 2026-05-27T11:28:00.886650947 +workspaceId: wk_sf38TckR4U +folderId: null +authentication: {} +authenticationType: null +description: '' +headers: +- enabled: true + name: Cookie + value: yucca-admin-access-token=x9XS91HThu1T7rqByvoFa0OiNsKwVa6eyGYhluzQnsC; yucca-admin-sub=admin + id: y90jtJ4YVI +- enabled: true + name: '' + value: '' + id: B7BgB8A7nX +name: Admin +sortPriority: -1779879100000.0 diff --git a/yaak/yaak.fl_ei6idjq9Ei.yaml b/yaak/yaak.fl_ei6idjq9Ei.yaml new file mode 100644 index 00000000..4c28c40e --- /dev/null +++ b/yaak/yaak.fl_ei6idjq9Ei.yaml @@ -0,0 +1,13 @@ +type: folder +model: folder +id: fl_ei6idjq9Ei +createdAt: 2026-05-27T10:52:13.812585723 +updatedAt: 2026-05-27T11:54:58.660634491 +workspaceId: wk_sf38TckR4U +folderId: fl_ZepPmNAPfD +authentication: {} +authenticationType: null +description: '' +headers: [] +name: User +sortPriority: 0.0 diff --git a/yaak/yaak.fl_kzC8MnKVou.yaml b/yaak/yaak.fl_kzC8MnKVou.yaml new file mode 100644 index 00000000..218ff799 --- /dev/null +++ b/yaak/yaak.fl_kzC8MnKVou.yaml @@ -0,0 +1,13 @@ +type: folder +model: folder +id: fl_kzC8MnKVou +createdAt: 2026-05-27T11:54:54.468713402 +updatedAt: 2026-05-27T11:54:58.663266805 +workspaceId: wk_sf38TckR4U +folderId: fl_ZepPmNAPfD +authentication: {} +authenticationType: null +description: '' +headers: [] +name: Session +sortPriority: 1000.0 diff --git a/yaak/yaak.rq_BsWTVt8r8R.yaml b/yaak/yaak.rq_BsWTVt8r8R.yaml new file mode 100644 index 00000000..2dd30f11 --- /dev/null +++ b/yaak/yaak.rq_BsWTVt8r8R.yaml @@ -0,0 +1,23 @@ +type: http_request +model: http_request +id: rq_BsWTVt8r8R +createdAt: 2026-05-27T11:49:42.768682218 +updatedAt: 2026-05-27T11:56:01.232927157 +workspaceId: wk_sf38TckR4U +folderId: fl_ei6idjq9Ei +authentication: {} +authenticationType: null +body: + text: '' +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: DELETE +name: Delete user +sortPriority: 3000.0 +url: http://localhost:3030/api/user/${[ response.body.path(request='rq_f6NWDkr8sW', path=b64'JC5bMF0uaWQ', behavior='smart') ]} +urlParameters: [] diff --git a/yaak/yaak.rq_CnqMCQeftW.yaml b/yaak/yaak.rq_CnqMCQeftW.yaml new file mode 100644 index 00000000..4ff77177 --- /dev/null +++ b/yaak/yaak.rq_CnqMCQeftW.yaml @@ -0,0 +1,31 @@ +type: http_request +model: http_request +id: rq_CnqMCQeftW +createdAt: 2026-05-27T11:50:21.212840558 +updatedAt: 2026-05-27T11:51:41.972558832 +workspaceId: wk_sf38TckR4U +folderId: fl_SpbNeUZw3W +authentication: {} +authenticationType: null +body: + text: |- + { + "name": "New Repository Name", + "worm": true + } +bodyType: application/json +description: '' +headers: +- enabled: true + name: Content-Type + value: application/json + id: vjHVsgspSd +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: PATCH +name: Edit repository +sortPriority: 1000.001 +url: http://localhost:3030/api/repository/${[ response.body.path(request='rq_kQje8YLe97', path=b64'JC5bMF0uaWQ', behavior='smart') ]} +urlParameters: [] diff --git a/yaak/yaak.rq_LLQ6ZXE2H8.yaml b/yaak/yaak.rq_LLQ6ZXE2H8.yaml new file mode 100644 index 00000000..35e13f05 --- /dev/null +++ b/yaak/yaak.rq_LLQ6ZXE2H8.yaml @@ -0,0 +1,26 @@ +type: http_request +model: http_request +id: rq_LLQ6ZXE2H8 +createdAt: 2026-05-27T11:53:53.305973032 +updatedAt: 2026-05-27T11:54:03.806774281 +workspaceId: wk_sf38TckR4U +folderId: fl_ei6idjq9Ei +authentication: {} +authenticationType: null +body: + text: |- + { + "disabled": true + } +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: GET +name: Get user sessions +sortPriority: 4000.0 +url: http://localhost:3030/api/user/${[ response.body.path(request='rq_f6NWDkr8sW', path=b64'JC5bMF0uaWQ', behavior='smart') ]}/session +urlParameters: [] diff --git a/yaak/yaak.rq_Qa8Tg8MEML.yaml b/yaak/yaak.rq_Qa8Tg8MEML.yaml new file mode 100644 index 00000000..d9b1d457 --- /dev/null +++ b/yaak/yaak.rq_Qa8Tg8MEML.yaml @@ -0,0 +1,26 @@ +type: http_request +model: http_request +id: rq_Qa8Tg8MEML +createdAt: 2026-05-27T11:54:10.589531588 +updatedAt: 2026-05-27T11:54:15.320397718 +workspaceId: wk_sf38TckR4U +folderId: fl_ei6idjq9Ei +authentication: {} +authenticationType: null +body: + text: |- + { + "disabled": true + } +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: DELETE +name: Delete user sessions +sortPriority: 4000.001 +url: http://localhost:3030/api/user/${[ response.body.path(request='rq_f6NWDkr8sW', path=b64'JC5bMF0uaWQ', behavior='smart') ]}/session +urlParameters: [] diff --git a/yaak/yaak.rq_Y4im3VxxoL.yaml b/yaak/yaak.rq_Y4im3VxxoL.yaml new file mode 100644 index 00000000..7dca38a0 --- /dev/null +++ b/yaak/yaak.rq_Y4im3VxxoL.yaml @@ -0,0 +1,26 @@ +type: http_request +model: http_request +id: rq_Y4im3VxxoL +createdAt: 2026-05-27T11:55:03.457266681 +updatedAt: 2026-05-27T11:55:19.011353546 +workspaceId: wk_sf38TckR4U +folderId: fl_kzC8MnKVou +authentication: {} +authenticationType: null +body: + text: |- + { + "disabled": true + } +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: DELETE +name: Delete session +sortPriority: 0.0 +url: http://localhost:3030/api/session/${[ response.body.path(request='rq_LLQ6ZXE2H8', path=b64'JC5bMF0uaWQ', behavior='smart') ]} +urlParameters: [] diff --git a/yaak/yaak.rq_dnySvQ5JXU.yaml b/yaak/yaak.rq_dnySvQ5JXU.yaml new file mode 100644 index 00000000..6758d77a --- /dev/null +++ b/yaak/yaak.rq_dnySvQ5JXU.yaml @@ -0,0 +1,23 @@ +type: http_request +model: http_request +id: rq_dnySvQ5JXU +createdAt: 2026-05-27T11:50:21.212660239 +updatedAt: 2026-05-27T11:52:04.270075471 +workspaceId: wk_sf38TckR4U +folderId: fl_SpbNeUZw3W +authentication: {} +authenticationType: null +body: + text: '' +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: DELETE +name: Delete repository +sortPriority: 1000.002 +url: http://localhost:3030/api/repository/${[ response.body.path(request='rq_kQje8YLe97', path=b64'JC5bMF0uaWQ', behavior='smart') ]} +urlParameters: [] diff --git a/yaak/yaak.rq_f6NWDkr8sW.yaml b/yaak/yaak.rq_f6NWDkr8sW.yaml new file mode 100644 index 00000000..1f6d80b2 --- /dev/null +++ b/yaak/yaak.rq_f6NWDkr8sW.yaml @@ -0,0 +1,26 @@ +type: http_request +model: http_request +id: rq_f6NWDkr8sW +createdAt: 2026-05-27T10:51:01.505799241 +updatedAt: 2026-05-27T11:55:56.468193906 +workspaceId: wk_sf38TckR4U +folderId: fl_ei6idjq9Ei +authentication: {} +authenticationType: null +body: + text: |- + { + "disabled": true + } +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: 4MHREZ8vkQ +method: GET +name: List users +sortPriority: 0.0 +url: http://localhost:3030/api/user +urlParameters: [] diff --git a/yaak/yaak.rq_jygywNzahd.yaml b/yaak/yaak.rq_jygywNzahd.yaml new file mode 100644 index 00000000..1f400d4f --- /dev/null +++ b/yaak/yaak.rq_jygywNzahd.yaml @@ -0,0 +1,26 @@ +type: http_request +model: http_request +id: rq_jygywNzahd +createdAt: 2026-05-27T10:32:12.880395723 +updatedAt: 2026-05-27T11:53:55.066774114 +workspaceId: wk_sf38TckR4U +folderId: fl_ei6idjq9Ei +authentication: {} +authenticationType: null +body: + text: |- + { + "disabled": true + } +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: GET +name: Get user +sortPriority: 1000.0 +url: http://localhost:3030/api/user/${[ response.body.path(request='rq_f6NWDkr8sW', path=b64'JC5bMF0uaWQ', behavior='smart') ]} +urlParameters: [] diff --git a/yaak/yaak.rq_kQje8YLe97.yaml b/yaak/yaak.rq_kQje8YLe97.yaml new file mode 100644 index 00000000..b42a21b8 --- /dev/null +++ b/yaak/yaak.rq_kQje8YLe97.yaml @@ -0,0 +1,26 @@ +type: http_request +model: http_request +id: rq_kQje8YLe97 +createdAt: 2026-05-27T11:50:21.212948381 +updatedAt: 2026-05-27T11:50:41.998277619 +workspaceId: wk_sf38TckR4U +folderId: fl_SpbNeUZw3W +authentication: {} +authenticationType: null +body: + text: |- + { + "disabled": true + } +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: 4MHREZ8vkQ +method: GET +name: List repositories +sortPriority: 0.0 +url: http://localhost:3030/api/repository +urlParameters: [] diff --git a/yaak/yaak.rq_q6RBqymUhr.yaml b/yaak/yaak.rq_q6RBqymUhr.yaml new file mode 100644 index 00000000..1873ac26 --- /dev/null +++ b/yaak/yaak.rq_q6RBqymUhr.yaml @@ -0,0 +1,30 @@ +type: http_request +model: http_request +id: rq_q6RBqymUhr +createdAt: 2026-05-27T11:48:03.948210070 +updatedAt: 2026-05-27T11:53:55.070042195 +workspaceId: wk_sf38TckR4U +folderId: fl_ei6idjq9Ei +authentication: {} +authenticationType: null +body: + text: |- + { + "disabled": true + } +bodyType: application/json +description: '' +headers: +- enabled: true + name: Content-Type + value: application/json + id: vjHVsgspSd +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: PATCH +name: Edit user +sortPriority: 2000.0 +url: http://localhost:3030/api/user/${[ response.body.path(request='rq_f6NWDkr8sW', path=b64'JC5bMF0uaWQ', behavior='smart') ]} +urlParameters: [] diff --git a/yaak/yaak.rq_rvQnzVJpeo.yaml b/yaak/yaak.rq_rvQnzVJpeo.yaml new file mode 100644 index 00000000..ad5321be --- /dev/null +++ b/yaak/yaak.rq_rvQnzVJpeo.yaml @@ -0,0 +1,26 @@ +type: http_request +model: http_request +id: rq_rvQnzVJpeo +createdAt: 2026-05-27T11:50:21.213069068 +updatedAt: 2026-05-27T11:51:14.013760158 +workspaceId: wk_sf38TckR4U +folderId: fl_SpbNeUZw3W +authentication: {} +authenticationType: null +body: + text: |- + { + "disabled": true + } +bodyType: null +description: '' +headers: +- enabled: true + name: '' + value: '' + id: b2BJhS2v3T +method: GET +name: Get repository +sortPriority: 1000.0 +url: http://localhost:3030/api/repository/${[ response.body.path(request='rq_kQje8YLe97', path=b64'JC5bMF0uaWQ', behavior='smart') ]} +urlParameters: [] diff --git a/yaak/yaak.wk_sf38TckR4U.yaml b/yaak/yaak.wk_sf38TckR4U.yaml new file mode 100644 index 00000000..9d640acb --- /dev/null +++ b/yaak/yaak.wk_sf38TckR4U.yaml @@ -0,0 +1,14 @@ +type: workspace +model: workspace +id: wk_sf38TckR4U +createdAt: 2026-05-27T10:32:05.333579968 +updatedAt: 2026-05-27T11:49:28.861211875 +authentication: {} +authenticationType: null +description: '' +headers: [] +name: Yucca +encryptionKeyChallenge: null +settingValidateCertificates: true +settingFollowRedirects: true +settingRequestTimeout: 0