mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat: admin API (#106)
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
Executable
+6
@@ -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
|
||||
Executable
+12
@@ -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
|
||||
Executable
+16
@@ -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}
|
||||
Executable
+8
@@ -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
|
||||
Executable
+6
@@ -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 "$@"
|
||||
Executable
+6
@@ -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 "$@"
|
||||
Executable
+6
@@ -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 "$@"
|
||||
@@ -30,3 +30,6 @@ yarn.lock
|
||||
# task structure, so these subtrees are exempt from root prettier checks.
|
||||
ansible/
|
||||
tf/
|
||||
|
||||
# auto-generated
|
||||
yaak/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<NestExpressApplication>(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));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<RepositoryListResponseDto> {
|
||||
return this.repository.list(query);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
@AuthRoute()
|
||||
@ApiOkResponse({ type: RepositoryGetResponseDto })
|
||||
getRepository(@Param('id') id: string): Promise<RepositoryGetResponseDto> {
|
||||
return this.repository.get(id);
|
||||
}
|
||||
|
||||
@Patch('/:id')
|
||||
@AuthRoute()
|
||||
@ApiOkResponse({ type: RepositoryUpdateResponseDto })
|
||||
updateRepository(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RepositoryUpdateRequestDto,
|
||||
): Promise<RepositoryUpdateResponseDto> {
|
||||
return this.repository.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@AuthRoute()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteRepository(@Param('id') id: string): Promise<void> {
|
||||
return this.repository.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
return this.session.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -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<UserListResponseDto> {
|
||||
return this.user.list(query);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
@AuthRoute()
|
||||
@ApiOkResponse({ type: UserGetResponseDto })
|
||||
getUser(@Param('id') id: string): Promise<UserGetResponseDto> {
|
||||
return this.user.get(id);
|
||||
}
|
||||
|
||||
@Patch('/:id')
|
||||
@AuthRoute()
|
||||
@ApiOkResponse({ type: UserUpdateResponseDto })
|
||||
updateUser(@Param('id') id: string, @Body() dto: UserUpdateRequestDto): Promise<UserUpdateResponseDto> {
|
||||
return this.user.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@AuthRoute()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteUser(@Param('id') id: string): Promise<void> {
|
||||
return this.user.delete(id);
|
||||
}
|
||||
|
||||
@Get('/:userId/session')
|
||||
@AuthRoute()
|
||||
@ApiOkResponse({ type: SessionListResponseDto })
|
||||
listUserSessions(@Param('userId') userId: string): Promise<SessionListResponseDto> {
|
||||
return this.session.listForUser(userId);
|
||||
}
|
||||
|
||||
@Delete('/:userId/session')
|
||||
@AuthRoute()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteUserSessions(@Param('userId') userId: string): Promise<void> {
|
||||
return this.session.deleteForUser(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AuthDto {
|
||||
@ApiProperty()
|
||||
sub!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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<AuthRequest>().auth;
|
||||
});
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private service: AuthService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const targets = [context.getHandler()];
|
||||
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(MetadataKey.Auth, targets);
|
||||
if (!options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthRequest>();
|
||||
request.auth = await this.service.authenticate(request.headers);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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<DB>,
|
||||
private logger: LoggerRepository,
|
||||
) {}
|
||||
|
||||
async shutdown() {
|
||||
await this.db.destroy();
|
||||
}
|
||||
|
||||
async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {
|
||||
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<DB>): Promise<void> {
|
||||
await sql`SELECT pg_advisory_lock(${lock})`.execute(connection);
|
||||
}
|
||||
|
||||
private async releaseLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<void> {
|
||||
await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection);
|
||||
}
|
||||
|
||||
async runMigrations(): Promise<void> {
|
||||
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'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<client.TokenEndpointResponse & client.TokenEndpointResponseHelpers> {
|
||||
return await client.authorizationCodeGrant(this.config, url, {
|
||||
pkceCodeVerifier,
|
||||
expectedState,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchUserInfo(accessToken: string, sub: string): Promise<client.UserInfoResponse | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DB, 'repositories' | 'users'>) =>
|
||||
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<DB, 'repositories' | 'repositoryMetrics'>) =>
|
||||
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<DB>) {}
|
||||
|
||||
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<RepositoryTable>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<DB>) {}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<DB>) {}
|
||||
|
||||
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<UserTable>) {
|
||||
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<boolean> {
|
||||
const row = await this.db
|
||||
.selectFrom('repositories')
|
||||
.where('userId', '=', userId)
|
||||
.select('id')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row !== undefined;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../yucca-api/src/schema
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<AuthDto> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<RepositoryListResponseDto> {
|
||||
return this.repositories.list({
|
||||
cursor: query.cursor,
|
||||
limit: resolveLimit(query.limit),
|
||||
userId: query.userId,
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: string): Promise<RepositoryGetResponseDto> {
|
||||
return { repository: await this.repositories.get(id) };
|
||||
}
|
||||
|
||||
async update(id: string, dto: RepositoryUpdateRequestDto): Promise<RepositoryUpdateResponseDto> {
|
||||
return { repository: await this.repositories.update(id, dto) };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
throw new InternalServerErrorException('unimplemented - must talk to S3');
|
||||
await this.repositories.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -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<SessionListResponseDto> {
|
||||
return { items: await this.sessions.getByUser(userId) };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.sessions.delete(id);
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string): Promise<void> {
|
||||
await this.sessions.deleteByUser(userId);
|
||||
}
|
||||
}
|
||||
@@ -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<UserListResponseDto> {
|
||||
return this.users.list({ cursor: query.cursor, limit: resolveLimit(query.limit) });
|
||||
}
|
||||
|
||||
async get(id: string): Promise<UserGetResponseDto> {
|
||||
return { user: await this.users.get(id) };
|
||||
}
|
||||
|
||||
async update(id: string, dto: UserUpdateRequestDto): Promise<UserUpdateResponseDto> {
|
||||
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<void> {
|
||||
if (await this.users.hasRepositories(id)) {
|
||||
throw new ConflictException('Cannot delete a user that still owns repositories');
|
||||
}
|
||||
await this.users.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -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<postgres.Options<Record<string, postgres.PostgresType>>> = {},
|
||||
): 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { DEFAULT_PAGE_SIZE } from 'src/dto/pagination.dto';
|
||||
|
||||
export interface CursorPage<T> {
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export function resolveLimit(limit?: string): number {
|
||||
return limit ? Number.parseInt(limit) : DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
export function toCursorPage<T extends { id: string }>(rows: T[], limit: number): CursorPage<T> {
|
||||
const hasMore = rows.length > limit;
|
||||
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||
return { items, nextCursor: hasMore ? (items.at(-1)?.id ?? null) : null };
|
||||
}
|
||||
@@ -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<App>;
|
||||
|
||||
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='),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/(.*)$": "<rootDir>/../src/$1"
|
||||
},
|
||||
"forceExit": true
|
||||
}
|
||||
@@ -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<T extends object> = Pick<T, keyof T>;
|
||||
|
||||
export const newDatabaseRepositoryMock = (): jest.Mocked<RepositoryInterface<DatabaseRepository>> => {
|
||||
return {
|
||||
shutdown: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
export const newOidcRepositoryMock = (): jest.Mocked<RepositoryInterface<OidcRepository>> => {
|
||||
return {
|
||||
authorize: jest.fn(),
|
||||
callback: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
onModuleInit: jest.fn(),
|
||||
fetchUserInfo: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
export const newLoggerRepositoryMock = (): jest.Mocked<RepositoryInterface<LoggerRepository>> => {
|
||||
return {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
export const newWideContextRepositoryMock = (): jest.Mocked<RepositoryInterface<WideContextRepository>> => ({
|
||||
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<typeof newMocks>;
|
||||
@@ -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<App>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<App>;
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<DB>;
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
@@ -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<App>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {
|
||||
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<DB>): Promise<void> {
|
||||
await sql`SELECT pg_advisory_lock(${lock})`.execute(connection);
|
||||
}
|
||||
|
||||
private async releaseLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<void> {
|
||||
await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection);
|
||||
}
|
||||
|
||||
async runMigrations(): Promise<void> {
|
||||
this.logger.debug('Running migrations');
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "users" ADD "disabled" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "users" DROP COLUMN "disabled";`.execute(db);
|
||||
}
|
||||
@@ -13,4 +13,7 @@ export class UserTable {
|
||||
|
||||
@Column({ unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: () => 'false' })
|
||||
disabled!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+209
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user