feat: admin API (#106)

This commit is contained in:
Paul Makles
2026-05-29 13:33:04 +00:00
committed by GitHub
parent 1faaf4329e
commit 1f3ee2b5e6
81 changed files with 3538 additions and 10 deletions
+1 -1
View File
@@ -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"
+6
View File
@@ -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
+12
View File
@@ -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
+16
View File
@@ -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}
+8
View File
@@ -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
+6
View File
@@ -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 "$@"
+6
View File
@@ -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 "$@"
+6
View File
@@ -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 "$@"
+3
View File
@@ -30,3 +30,6 @@ yarn.lock
# task structure, so these subtrees are exempt from root prettier checks.
ansible/
tf/
# auto-generated
yaak/
+3
View File
@@ -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);
+2 -2
View File
@@ -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',
+56
View File
@@ -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
+55
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+682
View File
@@ -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"
]
}
}
}
}
+89
View File
@@ -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;
}
+14
View File
@@ -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,
}
+25
View File
@@ -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);
+17
View File
@@ -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
View File
@@ -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
}
+54
View File
@@ -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"]
}
+29
View File
@@ -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"]
}
+4 -1
View File
@@ -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)', () => {
+5
View File
@@ -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);
+209 -1
View File
@@ -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
+13
View File
@@ -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
+21
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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
+23
View File
@@ -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: []
+31
View File
@@ -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: []
+26
View File
@@ -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: []
+26
View File
@@ -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: []
+26
View File
@@ -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: []
+23
View File
@@ -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: []
+26
View File
@@ -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: []
+26
View File
@@ -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: []
+26
View File
@@ -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: []
+30
View File
@@ -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: []
+26
View File
@@ -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: []
+14
View File
@@ -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