From de30388369a81ed7a870835dfee27ccc3016174a Mon Sep 17 00:00:00 2001 From: spaenleh Date: Mon, 30 Jun 2025 10:34:01 +0000 Subject: [PATCH 01/13] fix: add oauth with github for admins --- package.json | 7 +- src/@types/declarations.d.ts | 2 + src/app.ts | 11 +- src/config/env.ts | 1 - src/config/secrets.ts | 6 + src/drizzle/0008_smooth_orphan.sql | 7 + src/drizzle/meta/0008_snapshot.json | 3641 ++++++++++++++++++ src/drizzle/meta/_journal.json | 7 + src/drizzle/schema.ts | 9 + src/plugins/admin.repository.ts | 25 + src/plugins/admin/admin.plugin.ts | 119 + src/services/auth/plugins/passport/plugin.ts | 4 +- yarn.lock | 118 +- 13 files changed, 3943 insertions(+), 14 deletions(-) create mode 100644 src/drizzle/0008_smooth_orphan.sql create mode 100644 src/drizzle/meta/0008_snapshot.json create mode 100644 src/plugins/admin.repository.ts create mode 100644 src/plugins/admin/admin.plugin.ts diff --git a/package.json b/package.json index ee2281de64..cd44c2e28c 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@bull-board/fastify": "6.10.1", "@bull-board/ui": "6.10.1", "@fastify/busboy": "3.1.1", + "@fastify/cookie": "11.0.2", "@fastify/cors": "11.0.1", "@fastify/error": "4.1.0", "@fastify/forwarded": "3.0.0", @@ -80,7 +81,7 @@ "drizzle-orm": "0.41.0", "extract-zip": "2.0.1", "fast-json-stringify": "6.0.1", - "fastify": "5.3.3", + "fastify": "5.4.0", "fastify-nodemailer": "5.0.0", "fastify-plugin": "5.0.1", "form-data": "4.0.2", @@ -101,7 +102,9 @@ "nodemailer": "6.10.1", "openai": "4.91.1", "papaparse": "^5.4.1", + "passport": "0.7.0", "passport-custom": "1.1.1", + "passport-github2": "0.1.12", "passport-jwt": "4.0.1", "passport-local": "1.0.0", "pdf-text-reader": "3.0.2", @@ -144,6 +147,8 @@ "@types/node-fetch": "2", "@types/nodemailer": "6.4.15", "@types/papaparse": "5.3.15", + "@types/passport": "1.0.17", + "@types/passport-github2": "1.2.9", "@types/passport-jwt": "4.0.1", "@types/passport-local": "^1", "@types/pg": "8.15.4", diff --git a/src/@types/declarations.d.ts b/src/@types/declarations.d.ts index 724425a458..8c08ab595f 100644 --- a/src/@types/declarations.d.ts +++ b/src/@types/declarations.d.ts @@ -1,6 +1,7 @@ import 'fastify'; import { ItemRaw } from '../drizzle/types'; +import { AdminUser } from '../plugins/admin.repository'; import { WebsocketService } from '../services/websockets/ws-service'; import { MaybeUser } from '../types'; @@ -22,6 +23,7 @@ declare module 'fastify' { key: string; origin: string; }; + admin?: AdminUser; } } diff --git a/src/app.ts b/src/app.ts index f569a6a6ea..935d747ad3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import fp from 'fastify-plugin'; import { REDIS_CONNECTION } from './config/redis'; import { registerDependencies } from './di/container'; +import adminPlugin from './plugins/admin/admin.plugin'; import databasePlugin from './plugins/database'; import metaPlugin from './plugins/meta'; import swaggerPlugin from './plugins/swagger'; @@ -32,14 +33,18 @@ export default async function (instance: FastifyInstance): Promise { await instance.register(fp(metaPlugin)); - await instance.register(fp(passportPlugin)); + await instance.register(adminPlugin); + // need to be defined before member and item for auth check await instance.register(maintenancePlugin); - await instance.register(fp(authPlugin)); - + // scope the next registration to the core functionalities await instance.register(async (instance) => { + await instance.register(fp(passportPlugin)); + + await instance.register(fp(authPlugin)); + // core API modules await instance // the websockets plugin must be registered before but in the same scope as the apis diff --git a/src/config/env.ts b/src/config/env.ts index 3734aa9c75..d105160a0a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -39,6 +39,5 @@ export const getEnv = once(() => { }); export const PROD = NODE_ENV === Environment.production; -export const STAGING = NODE_ENV === Environment.staging; export const DEV = NODE_ENV === Environment.development; export const TEST = NODE_ENV === Environment.test; diff --git a/src/config/secrets.ts b/src/config/secrets.ts index 4cc8ed84c3..4fb14d08f5 100644 --- a/src/config/secrets.ts +++ b/src/config/secrets.ts @@ -11,6 +11,12 @@ export const SECURE_SESSION_SECRET_KEY = requiredEnvVar('SECURE_SESSION_SECRET_K export const SECURE_SESSION_EXPIRATION_IN_SECONDS = 604800; // 7days export const MAX_SECURE_SESSION_EXPIRATION_IN_SECONDS = 15552000; // 6 * 30days -> 6 months +/** + * Admin session key + */ +export const ADMIN_SESSION_SECRET_KEY = requiredEnvVar('ADMIN_SESSION_SECRET_KEY'); +export const ADMIN_SESSION_EXPIRATION_IN_SECONDS = 86_400; // 1 day + /** * JWT */ diff --git a/src/drizzle/0008_smooth_orphan.sql b/src/drizzle/0008_smooth_orphan.sql new file mode 100644 index 0000000000..7606518a13 --- /dev/null +++ b/src/drizzle/0008_smooth_orphan.sql @@ -0,0 +1,7 @@ +ALTER TYPE "public"."action_view_enum" ADD VALUE 'analytics' BEFORE 'home';--> statement-breakpoint +CREATE TABLE "admins" ( + "userName" varchar PRIMARY KEY NOT NULL, + "id" varchar, + "last_authenticated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "admins_userName_unique" UNIQUE("userName") +); diff --git a/src/drizzle/meta/0008_snapshot.json b/src/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000000..8badcfa973 --- /dev/null +++ b/src/drizzle/meta/0008_snapshot.json @@ -0,0 +1,3641 @@ +{ + "id": "c995e36d-251c-4bcf-97b2-764aca4c65c1", + "prevId": "a13269a4-7c7d-4a55-943d-296d433921d7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(150)", + "primaryKey": false, + "notNull": false + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "type": { + "name": "type", + "type": "account_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'individual'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_agreements_date": { + "name": "user_agreements_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enable_save_actions": { + "name": "enable_save_actions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "item_login_schema_id": { + "name": "item_login_schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_account_type": { + "name": "IDX_account_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "enum_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_item_login_schema_id_item_login_schema_id_fk": { + "name": "account_item_login_schema_id_item_login_schema_id_fk", + "tableFrom": "account", + "tableTo": "item_login_schema", + "columnsFrom": [ + "item_login_schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_account_name_item_login_schema_id": { + "name": "UQ_account_name_item_login_schema_id", + "nullsNotDistinct": false, + "columns": [ + "name", + "item_login_schema_id" + ] + }, + "member_email_key1": { + "name": "member_email_key1", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": { + "CHK_account_is_validated": { + "name": "CHK_account_is_validated", + "value": "(is_validated IS NOT NULL) OR ((type)::text <> 'individual'::text)" + }, + "CHK_account_email": { + "name": "CHK_account_email", + "value": "(email IS NOT NULL) OR ((type)::text <> 'individual'::text)" + }, + "CHK_account_extra": { + "name": "CHK_account_extra", + "value": "(extra IS NOT NULL) OR ((type)::text <> 'individual'::text)" + }, + "CHK_account_enable_save_actions": { + "name": "CHK_account_enable_save_actions", + "value": "(enable_save_actions IS NOT NULL) OR ((type)::text <> 'individual'::text)" + } + }, + "isRLSEnabled": false + }, + "public.action_request_export": { + "name": "action_request_export", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "action_request_export_format_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'json'" + } + }, + "indexes": {}, + "foreignKeys": { + "FK_fea823c4374f507a68cf8f926a4": { + "name": "FK_fea823c4374f507a68cf8f926a4", + "tableFrom": "action_request_export", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "FK_bc85ef3298df8c7974b33081b47": { + "name": "FK_bc85ef3298df8c7974b33081b47", + "tableFrom": "action_request_export", + "tableTo": "account", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action": { + "name": "action", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "view": { + "name": "view", + "type": "action_view_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "geolocation": { + "name": "geolocation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_1214f6f4d832c402751617361c": { + "name": "IDX_1214f6f4d832c402751617361c", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_action_account_id": { + "name": "IDX_action_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "FK_1214f6f4d832c402751617361c0": { + "name": "FK_1214f6f4d832c402751617361c0", + "tableFrom": "action", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "FK_action_account_id": { + "name": "FK_action_account_id", + "tableFrom": "action", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admins": { + "name": "admins", + "schema": "", + "columns": { + "userName": { + "name": "userName", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admins_userName_unique": { + "name": "admins_userName_unique", + "nullsNotDistinct": false, + "columns": [ + "userName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_action": { + "name": "app_action", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "type": { + "name": "type", + "type": "varchar(25)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "FK_c415fc186dda51fa260d338d776": { + "name": "FK_c415fc186dda51fa260d338d776", + "tableFrom": "app_action", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_app_action_account_id": { + "name": "FK_app_action_account_id", + "tableFrom": "app_action", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_data": { + "name": "app_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "type": { + "name": "type", + "type": "varchar(25)", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_6079b3bb63c13f815f7dd8d8a2": { + "name": "IDX_6079b3bb63c13f815f7dd8d8a2", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "FK_8c3e2463c67d9865658941c9e2d": { + "name": "FK_8c3e2463c67d9865658941c9e2d", + "tableFrom": "app_data", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_27cb180cb3f372e4cf55302644a": { + "name": "FK_27cb180cb3f372e4cf55302644a", + "tableFrom": "app_data", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "FK_app_data_account_id": { + "name": "FK_app_data_account_id", + "tableFrom": "app_data", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_setting": { + "name": "app_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_61546c650608c1e68789c64915": { + "name": "IDX_61546c650608c1e68789c64915", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "FK_f5922b885e2680beab8add96008": { + "name": "FK_f5922b885e2680beab8add96008", + "tableFrom": "app_setting", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_22d3d051ee6f94932c1373a3d09": { + "name": "FK_22d3d051ee6f94932c1373a3d09", + "tableFrom": "app_setting", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "FK_37eb7baab82e11150157ec0b5a6": { + "name": "FK_37eb7baab82e11150157ec0b5a6", + "tableFrom": "app", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_key_key": { + "name": "app_key_key", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + }, + "UQ_f36adbb7b096ceeb6f3e80ad14c": { + "name": "UQ_f36adbb7b096ceeb6f3e80ad14c", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_url_key": { + "name": "app_url_key", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category": { + "name": "category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category-name-type": { + "name": "category-name-type", + "nullsNotDistinct": false, + "columns": [ + "name", + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_mention": { + "name": "chat_mention", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "chat_mention_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unread'" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_mention_message_id_chat_message_id_fk": { + "name": "chat_mention_message_id_chat_message_id_fk", + "tableFrom": "chat_mention", + "tableTo": "chat_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chat_mention_account_id_account_id_fk": { + "name": "chat_mention_account_id_account_id_fk", + "tableFrom": "chat_mention", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "FK_e5199951167b722215127651e7c": { + "name": "FK_e5199951167b722215127651e7c", + "tableFrom": "chat_mention", + "tableTo": "chat_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_chat_mention_account_id": { + "name": "FK_chat_mention_account_id", + "tableFrom": "chat_mention", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_message": { + "name": "chat_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "body": { + "name": "body", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "FK_b31e627ea7a4787672e265a1579": { + "name": "FK_b31e627ea7a4787672e265a1579", + "tableFrom": "chat_message", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_71fdcb9038eca1b903102bdfd17": { + "name": "FK_71fdcb9038eca1b903102bdfd17", + "tableFrom": "chat_message", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guest_password": { + "name": "guest_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "password": { + "name": "password", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "guest_id": { + "name": "guest_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_guest_password_guest_id": { + "name": "FK_guest_password_guest_id", + "tableFrom": "guest_password", + "tableTo": "account", + "columnsFrom": [ + "guest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_guest_password_guest_id": { + "name": "UQ_guest_password_guest_id", + "nullsNotDistinct": false, + "columns": [ + "guest_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_creator_id_account_id_fk": { + "name": "invitation_creator_id_account_id_fk", + "tableFrom": "invitation", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitation_item_path_item_path_fk": { + "name": "invitation_item_path_item_path_fk", + "tableFrom": "invitation", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "FK_7ad4a490d5b9f79a677827b641c": { + "name": "FK_7ad4a490d5b9f79a677827b641c", + "tableFrom": "invitation", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "FK_dc1d92accde1c2fbb7e729e4dcc": { + "name": "FK_dc1d92accde1c2fbb7e729e4dcc", + "tableFrom": "invitation", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "item-email": { + "name": "item-email", + "nullsNotDistinct": false, + "columns": [ + "item_path", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_favorite": { + "name": "item_favorite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "FK_a169d350392956511697f7e7d38": { + "name": "FK_a169d350392956511697f7e7d38", + "tableFrom": "item_favorite", + "tableTo": "account", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_10ea93bde287762010695378f94": { + "name": "FK_10ea93bde287762010695378f94", + "tableFrom": "item_favorite", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "favorite_key": { + "name": "favorite_key", + "nullsNotDistinct": false, + "columns": [ + "member_id", + "item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_category": { + "name": "item_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "FK_638552fc7d9a2035c2b53182d8a": { + "name": "FK_638552fc7d9a2035c2b53182d8a", + "tableFrom": "item_category", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_9a34a079b5b24f4396462546d26": { + "name": "FK_9a34a079b5b24f4396462546d26", + "tableFrom": "item_category", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "FK_5681d1785eea699e9cae8818fe0": { + "name": "FK_5681d1785eea699e9cae8818fe0", + "tableFrom": "item_category", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category-item": { + "name": "category-item", + "nullsNotDistinct": false, + "columns": [ + "item_path", + "category_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_export_request": { + "name": "item_export_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "item_export_request_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "item_export_request_member_id_account_id_fk": { + "name": "item_export_request_member_id_account_id_fk", + "tableFrom": "item_export_request", + "tableTo": "account", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_export_request_item_id_item_id_fk": { + "name": "item_export_request_item_id_item_id_fk", + "tableFrom": "item_export_request", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_flag": { + "name": "item_flag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "FK_b04d0adf4b73d82537c92fa55ea": { + "name": "FK_b04d0adf4b73d82537c92fa55ea", + "tableFrom": "item_flag", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_bde9b9ab1da1483a71c9b916dd2": { + "name": "FK_bde9b9ab1da1483a71c9b916dd2", + "tableFrom": "item_flag", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "item-flag-creator": { + "name": "item-flag-creator", + "nullsNotDistinct": false, + "columns": [ + "type", + "creator_id", + "item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_geolocation": { + "name": "item_geolocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lat": { + "name": "lat", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "lng": { + "name": "lng", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "addressLabel": { + "name": "addressLabel", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false + }, + "helperLabel": { + "name": "helperLabel", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_66d4b13df4e7765068c8268d719": { + "name": "FK_66d4b13df4e7765068c8268d719", + "tableFrom": "item_geolocation", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "item_geolocation_unique_item": { + "name": "item_geolocation_unique_item", + "nullsNotDistinct": false, + "columns": [ + "item_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_like": { + "name": "item_like", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_item_like_item": { + "name": "IDX_item_like_item", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "FK_4a56eba1ce30dc93f118a51ff26": { + "name": "FK_4a56eba1ce30dc93f118a51ff26", + "tableFrom": "item_like", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_159827eb667d019dc71372d7463": { + "name": "FK_159827eb667d019dc71372d7463", + "tableFrom": "item_like", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "id": { + "name": "id", + "nullsNotDistinct": false, + "columns": [ + "creator_id", + "item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_login_schema": { + "name": "item_login_schema", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "item_login_schema_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "item_login_schema_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_login_schema_item_path_item_path_fk": { + "name": "item_login_schema_item_path_item_path_fk", + "tableFrom": "item_login_schema", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "item-login-schema": { + "name": "item-login-schema", + "nullsNotDistinct": false, + "columns": [ + "item_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_membership": { + "name": "item_membership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "permission": { + "name": "permission", + "type": "permission_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_5ac5bdde333fca6bbeaf177ef9": { + "name": "IDX_5ac5bdde333fca6bbeaf177ef9", + "columns": [ + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_d935785e7ecc015ed3ca048ff0": { + "name": "IDX_d935785e7ecc015ed3ca048ff0", + "columns": [ + { + "expression": "item_path", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "ltree_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_gist_item_membership_path": { + "name": "IDX_gist_item_membership_path", + "columns": [ + { + "expression": "item_path", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gist_ltree_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "IDX_item_membership_account_id": { + "name": "IDX_item_membership_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_item_membership_account_id_permission": { + "name": "IDX_item_membership_account_id_permission", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "item_membership_item_path_item_path_fk": { + "name": "item_membership_item_path_item_path_fk", + "tableFrom": "item_membership", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "item_membership_creator_id_account_id_fk": { + "name": "item_membership_creator_id_account_id_fk", + "tableFrom": "item_membership", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "item_membership_account_id_account_id_fk": { + "name": "item_membership_account_id_account_id_fk", + "tableFrom": "item_membership", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "item_membership-item-member": { + "name": "item_membership-item-member", + "nullsNotDistinct": false, + "columns": [ + "item_path", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_tag": { + "name": "item_tag", + "schema": "", + "columns": { + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_item_tag_item": { + "name": "IDX_item_tag_item", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "FK_16ab8afb42f763f7cbaa4bff66a": { + "name": "FK_16ab8afb42f763f7cbaa4bff66a", + "tableFrom": "item_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_39b492fda03c7ac846afe164b58": { + "name": "FK_39b492fda03c7ac846afe164b58", + "tableFrom": "item_tag", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "PK_a04bb2298e37d95233a0c92347e": { + "name": "PK_a04bb2298e37d95233a0c92347e", + "columns": [ + "tag_id", + "item_id" + ] + } + }, + "uniqueConstraints": { + "UQ_item_tag": { + "name": "UQ_item_tag", + "nullsNotDistinct": false, + "columns": [ + "tag_id", + "item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_validation_group": { + "name": "item_validation_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "FK_a9e83cf5f53c026b774b53d3c60": { + "name": "FK_a9e83cf5f53c026b774b53d3c60", + "tableFrom": "item_validation_group", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_validation_review": { + "name": "item_validation_review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_validation_id": { + "name": "item_validation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "FK_59fd000835c70c728e525d82950": { + "name": "FK_59fd000835c70c728e525d82950", + "tableFrom": "item_validation_review", + "tableTo": "item_validation", + "columnsFrom": [ + "item_validation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_44bf14fee580ae08702d70e622e": { + "name": "FK_44bf14fee580ae08702d70e622e", + "tableFrom": "item_validation_review", + "tableTo": "account", + "columnsFrom": [ + "reviewer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_validation": { + "name": "item_validation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "process": { + "name": "process", + "type": "item_validation_process", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "item_validation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "item_validation_group_id": { + "name": "item_validation_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "FK_d60969d5e478e7c844532ac4e7f": { + "name": "FK_d60969d5e478e7c844532ac4e7f", + "tableFrom": "item_validation", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_e92da280941f666acf87baedc65": { + "name": "FK_e92da280941f666acf87baedc65", + "tableFrom": "item_validation", + "tableTo": "item_validation_group", + "columnsFrom": [ + "item_validation_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_visibility": { + "name": "item_visibility", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "item_visibility_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_gist_item_visibility_path": { + "name": "IDX_gist_item_visibility_path", + "columns": [ + { + "expression": "item_path", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gist_ltree_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": { + "FK_item_visibility_creator": { + "name": "FK_item_visibility_creator", + "tableFrom": "item_visibility", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "FK_item_visibility_item": { + "name": "FK_item_visibility_item", + "tableFrom": "item_visibility", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_item_visibility_item_type": { + "name": "UQ_item_visibility_item_type", + "nullsNotDistinct": false, + "columns": [ + "type", + "item_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'folder'" + }, + "description": { + "name": "description", + "type": "varchar(5000)", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "order": { + "name": "order", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_bdc46717fadc2f04f3093e51fd": { + "name": "IDX_bdc46717fadc2f04f3093e51fd", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_gist_item_path": { + "name": "IDX_gist_item_path", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gist_ltree_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "IDX_gist_item_path_deleted_at": { + "name": "IDX_gist_item_path_deleted_at", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gist_ltree_ops" + } + ], + "isUnique": false, + "where": "\"item\".\"deleted_at\" is null", + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": { + "FK_bdc46717fadc2f04f3093e51fd5": { + "name": "FK_bdc46717fadc2f04f3093e51fd5", + "tableFrom": "item", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "item_path_key1": { + "name": "item_path_key1", + "nullsNotDistinct": false, + "columns": [ + "path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.maintenance": { + "name": "maintenance", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_maintenance_slug": { + "name": "UQ_maintenance_slug", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member_password": { + "name": "member_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "password": { + "name": "password", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_password_member_id_account_id_fk": { + "name": "member_password_member_id_account_id_fk", + "tableFrom": "member_password", + "tableTo": "account", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "member-password": { + "name": "member-password", + "nullsNotDistinct": false, + "columns": [ + "member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member_profile": { + "name": "member_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bio": { + "name": "bio", + "type": "varchar(5000)", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "facebookId": { + "name": "facebookId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "linkedinId": { + "name": "linkedinId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "twitterId": { + "name": "twitterId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_91fa43bc5482dc6b00892baf01": { + "name": "IDX_91fa43bc5482dc6b00892baf01", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_profile_member_id_account_id_fk": { + "name": "member_profile_member_id_account_id_fk", + "tableFrom": "member_profile", + "tableTo": "account", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "FK_91fa43bc5482dc6b00892baf016": { + "name": "FK_91fa43bc5482dc6b00892baf016", + "tableFrom": "member_profile", + "tableTo": "account", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "member-profile": { + "name": "member-profile", + "nullsNotDistinct": false, + "columns": [ + "member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_request": { + "name": "membership_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "FK_membership_request_member_id": { + "name": "FK_membership_request_member_id", + "tableFrom": "membership_request", + "tableTo": "account", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_membership_request_item_id": { + "name": "FK_membership_request_item_id", + "tableFrom": "membership_request", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_membership_request_item-member": { + "name": "UQ_membership_request_item-member", + "nullsNotDistinct": false, + "columns": [ + "member_id", + "item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_published": { + "name": "item_published", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_gist_item_published_path": { + "name": "IDX_gist_item_published_path", + "columns": [ + { + "expression": "item_path", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gist_ltree_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": { + "item_published_creator_id_account_id_fk": { + "name": "item_published_creator_id_account_id_fk", + "tableFrom": "item_published", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "item_published_item_path_item_path_fk": { + "name": "item_published_item_path_item_path_fk", + "tableFrom": "item_published", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "published-item": { + "name": "published-item", + "nullsNotDistinct": false, + "columns": [ + "item_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "origins": { + "name": "origins", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "publisher_name_key": { + "name": "publisher_name_key", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recycled_item_data": { + "name": "recycled_item_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_path": { + "name": "item_path", + "type": "ltree", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "FK_3e3650ebd5c49843013429d510a": { + "name": "FK_3e3650ebd5c49843013429d510a", + "tableFrom": "recycled_item_data", + "tableTo": "account", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "FK_f8a4db4476e3d81e18de5d63c42": { + "name": "FK_f8a4db4476e3d81e18de5d63c42", + "tableFrom": "recycled_item_data", + "tableTo": "item", + "columnsFrom": [ + "item_path" + ], + "columnsTo": [ + "path" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recycled-item-data": { + "name": "recycled-item-data", + "nullsNotDistinct": false, + "columns": [ + "item_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.short_link": { + "name": "short_link", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "short_link_platform_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_43c8a0471d5e58f99fc9c36b99": { + "name": "IDX_43c8a0471d5e58f99fc9c36b99", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "FK_43c8a0471d5e58f99fc9c36b991": { + "name": "FK_43c8a0471d5e58f99fc9c36b991", + "tableFrom": "short_link", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_859a3384cadaa460b84e04e5375": { + "name": "UQ_859a3384cadaa460b84e04e5375", + "nullsNotDistinct": false, + "columns": [ + "platform", + "item_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "CHK_200ef28b2168aaf1e36b6896fc": { + "name": "CHK_200ef28b2168aaf1e36b6896fc", + "value": "(length((alias)::text) >= 6) AND (length((alias)::text) <= 255) AND ((alias)::text ~ '^[a-zA-Z0-9-]*$'::text)" + } + }, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "tag_category_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_tag_name_category": { + "name": "UQ_tag_name_category", + "nullsNotDistinct": false, + "columns": [ + "name", + "category" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_type_enum": { + "name": "account_type_enum", + "schema": "public", + "values": [ + "individual", + "guest" + ] + }, + "public.action_request_export_format_enum": { + "name": "action_request_export_format_enum", + "schema": "public", + "values": [ + "json", + "csv" + ] + }, + "public.action_view_enum": { + "name": "action_view_enum", + "schema": "public", + "values": [ + "builder", + "player", + "library", + "account", + "analytics", + "home", + "auth", + "unknown" + ] + }, + "public.chat_mention_status_enum": { + "name": "chat_mention_status_enum", + "schema": "public", + "values": [ + "unread", + "read" + ] + }, + "public.item_export_request_type_enum": { + "name": "item_export_request_type_enum", + "schema": "public", + "values": [ + "raw", + "graasp" + ] + }, + "public.item_login_schema_status": { + "name": "item_login_schema_status", + "schema": "public", + "values": [ + "active", + "freeze", + "disabled" + ] + }, + "public.item_login_schema_type": { + "name": "item_login_schema_type", + "schema": "public", + "values": [ + "username", + "username+password", + "anonymous", + "anonymous+password" + ] + }, + "public.item_validation_process": { + "name": "item_validation_process", + "schema": "public", + "values": [ + "bad-words-detection", + "image-classification" + ] + }, + "public.item_validation_status": { + "name": "item_validation_status", + "schema": "public", + "values": [ + "success", + "failure", + "pending", + "pending-manual" + ] + }, + "public.item_visibility_type": { + "name": "item_visibility_type", + "schema": "public", + "values": [ + "public", + "hidden" + ] + }, + "public.permission_enum": { + "name": "permission_enum", + "schema": "public", + "values": [ + "read", + "write", + "admin" + ] + }, + "public.short_link_platform_enum": { + "name": "short_link_platform_enum", + "schema": "public", + "values": [ + "builder", + "player", + "library" + ] + }, + "public.tag_category_enum": { + "name": "tag_category_enum", + "schema": "public", + "values": [ + "level", + "discipline", + "resource-type" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.guests_view": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "type": { + "name": "type", + "type": "account_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'individual'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "item_login_schema_id": { + "name": "item_login_schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"id\", \"name\", \"extra\", \"type\", \"created_at\", \"updated_at\", \"last_authenticated_at\", \"is_validated\", \"item_login_schema_id\" from \"account\" where (\"account\".\"type\" = 'guest' and \"account\".\"item_login_schema_id\" is not null)", + "name": "guests_view", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.item_view": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'folder'" + }, + "description": { + "name": "description", + "type": "varchar(5000)", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "ltree", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lang": { + "name": "lang", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "order": { + "name": "order", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"id\", \"name\", \"type\", \"description\", \"path\", \"creator_id\", \"extra\", \"settings\", \"created_at\", \"updated_at\", \"lang\", \"order\" from \"item\" where \"item\".\"deleted_at\" is null", + "name": "item_view", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.members_view": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(150)", + "primaryKey": false, + "notNull": false + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "type": { + "name": "type", + "type": "account_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'individual'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_agreements_date": { + "name": "user_agreements_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enable_save_actions": { + "name": "enable_save_actions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "definition": "select \"id\", \"name\", \"email\", \"extra\", \"type\", \"created_at\", \"updated_at\", \"user_agreements_date\", \"enable_save_actions\", \"last_authenticated_at\", \"is_validated\" from \"account\" where (\"account\".\"type\" = 'individual' and \"account\".\"email\" is not null)", + "name": "members_view", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/drizzle/meta/_journal.json b/src/drizzle/meta/_journal.json index ab5d3f152d..1ad239982f 100644 --- a/src/drizzle/meta/_journal.json +++ b/src/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1750768930827, "tag": "0007_action_view_enum", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1751279569270, + "tag": "0008_smooth_orphan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 62a197216e..0c7071086f 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -83,6 +83,15 @@ export const itemValidationStatusEnum = pgEnum('item_validation_status', [ 'pending-manual', ]); +export const adminsTable = pgTable('admins', { + userName: varchar().primaryKey().notNull().unique(), + id: varchar(), + lastAuthenticatedAt: timestamp('last_authenticated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdate(() => sql.raw('DEFAULT')), +}); + export const categoriesTable = pgTable( 'category', { diff --git a/src/plugins/admin.repository.ts b/src/plugins/admin.repository.ts new file mode 100644 index 0000000000..6b7db80fa1 --- /dev/null +++ b/src/plugins/admin.repository.ts @@ -0,0 +1,25 @@ +import { eq } from 'drizzle-orm'; + +import { DBConnection } from '../drizzle/db'; +import { adminsTable } from '../drizzle/schema'; + +export type AdminUser = typeof adminsTable.$inferSelect; + +export class AdminRepository { + async get(dbConnection: DBConnection, adminId: string): Promise { + const adminUser = await dbConnection.query.adminsTable.findFirst({ + where: eq(adminsTable.id, adminId), + }); + return adminUser; + } + + async isAdmin(dbConnection, userName): Promise { + const admin = await dbConnection.query.adminsTable.findFirst({ + where: eq(adminsTable.userName, userName), + }); + if (admin) { + return true; + } + return false; + } +} diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts new file mode 100644 index 0000000000..425276b3aa --- /dev/null +++ b/src/plugins/admin/admin.plugin.ts @@ -0,0 +1,119 @@ +import { Strategy as GitHubStrategy } from 'passport-github2'; + +import fastifyPassport from '@fastify/passport'; +import { fastifySecureSession } from '@fastify/secure-session'; +import { FastifyInstance, PassportUser } from 'fastify'; + +import { PROD } from '../../config/env'; +import { + ADMIN_SESSION_EXPIRATION_IN_SECONDS, + ADMIN_SESSION_SECRET_KEY, +} from '../../config/secrets'; +import { db } from '../../drizzle/db'; +import { assertIsDefined } from '../../utils/assertions'; +import { AdminRepository } from '../admin.repository'; + +const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'YOUR_CLIENT_ID'; +const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || 'YOUR_CLIENT_SECRET'; + +const GITHUB_OAUTH_STRATEGY = 'github-admin'; + +export default async (fastify: FastifyInstance) => { + const adminRepository = new AdminRepository(); + + fastify.register(fastifySecureSession, { + key: Buffer.from(ADMIN_SESSION_SECRET_KEY, 'hex'), + cookie: { + path: '/admin', + secure: PROD, + httpOnly: true, + maxAge: ADMIN_SESSION_EXPIRATION_IN_SECONDS, + }, + expiry: ADMIN_SESSION_EXPIRATION_IN_SECONDS, + }); + await fastify.register(fastifyPassport.initialize()); + await fastify.register(fastifyPassport.secureSession()); + + fastifyPassport.use( + GITHUB_OAUTH_STRATEGY, + new GitHubStrategy( + { + clientID: GITHUB_CLIENT_ID, + clientSecret: GITHUB_CLIENT_SECRET, + callbackURL: 'http://localhost:3000/admin/auth/github/callback', + }, + async (accessToken, refreshToken, profile, done) => { + // only allow users that are present in the admin table by their username + if (await adminRepository.isAdmin(db, profile.username)) { + // You can add admin checks here + return done(null, profile); + } + }, + ), + ); + + fastifyPassport.registerUserSerializer(async (user: PassportUser, _req) => { + _req.log.info(user); + assertIsDefined(user.admin); + return user.admin.id; + }); + fastifyPassport.registerUserDeserializer(async (uuid: string, _req): Promise => { + _req.log.info('uuuid', uuid); + + const admin = await adminRepository.get(db, uuid); + + return { admin }; + }); + + fastify.get( + '/admin/auth/github', + { + preValidation: fastifyPassport.authenticate(GITHUB_OAUTH_STRATEGY, { scope: ['user:email'] }), + }, + async (_request, _reply) => {}, + ); + + fastify.get( + '/admin/auth/github/callback', + { + preValidation: fastifyPassport.authenticate(GITHUB_OAUTH_STRATEGY, { + failureRedirect: '/admin/login', + }), + }, + async (request, reply) => { + request.log.info('callback user', request.user); + reply.redirect('/admin'); + }, + ); + + fastify.get('/admin/login', async (_request, reply) => { + reply.type('text/html').send('Login with GitHub'); + }); + + fastify.addHook('preHandler', (request, reply, done) => { + const url = request.raw.url || ''; + if ( + url.startsWith('/admin') && + !url.startsWith('/admin/login') && + !url.startsWith('/admin/auth/github') && + !request.isAuthenticated() + ) { + reply.redirect('/admin/login'); + } else { + done(); + } + }); + + fastify.get('/admin', async (request, reply) => { + reply + .type('text/html') + .send( + `Hello, ${request.user?.admin?.userName || 'admin'}! Logout`, + ); + }); + + fastify.get('/admin/logout', async (request, reply) => { + await request.logout(); + reply.redirect('/admin/login'); + }); +}; diff --git a/src/services/auth/plugins/passport/plugin.ts b/src/services/auth/plugins/passport/plugin.ts index e12a21ce90..26dabb4129 100644 --- a/src/services/auth/plugins/passport/plugin.ts +++ b/src/services/auth/plugins/passport/plugin.ts @@ -2,7 +2,7 @@ import fastifyPassport from '@fastify/passport'; import { fastifySecureSession } from '@fastify/secure-session'; import type { FastifyInstance, FastifyPluginAsync, PassportUser } from 'fastify'; -import { PROD, STAGING } from '../../../../config/env'; +import { PROD } from '../../../../config/env'; import { JWT_SECRET, MAX_SECURE_SESSION_EXPIRATION_IN_SECONDS, @@ -41,7 +41,7 @@ const plugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { cookie: { domain: COOKIE_DOMAIN, path: '/', - secure: PROD || STAGING, + secure: PROD, httpOnly: true, // Timeout before the session is invalidated. The user can renew the session since the timeout is not reached. // The session will be automatically renewed on each request. diff --git a/yarn.lock b/yarn.lock index 15d874ba16..a664549882 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2338,7 +2338,7 @@ __metadata: languageName: node linkType: hard -"@fastify/cookie@npm:^11.0.1": +"@fastify/cookie@npm:11.0.2, @fastify/cookie@npm:^11.0.1": version: 11.0.2 resolution: "@fastify/cookie@npm:11.0.2" dependencies: @@ -4675,6 +4675,15 @@ __metadata: languageName: node linkType: hard +"@types/oauth@npm:*": + version: 0.9.6 + resolution: "@types/oauth@npm:0.9.6" + dependencies: + "@types/node": "npm:*" + checksum: 10/6e1d42585a77b73c607be3c50c25d6f7f772fc1f3927c2ea10c9955b4ba118dfe6cc7303538e68cd53ffe6f133cf8b8c61f703a37023175c5bbb218460732147 + languageName: node + linkType: hard + "@types/papaparse@npm:5.3.15": version: 5.3.15 resolution: "@types/papaparse@npm:5.3.15" @@ -4684,6 +4693,17 @@ __metadata: languageName: node linkType: hard +"@types/passport-github2@npm:1.2.9": + version: 1.2.9 + resolution: "@types/passport-github2@npm:1.2.9" + dependencies: + "@types/express": "npm:*" + "@types/passport": "npm:*" + "@types/passport-oauth2": "npm:*" + checksum: 10/375b16d5ab80ff0601a56abc1a2ec1eadda0a8f38bef9c5d7406b6141751a10f6bb565890459a27ecbcb9a622771250fd5539e0842ce1b205f68dc4c3d83c235 + languageName: node + linkType: hard + "@types/passport-jwt@npm:4.0.1": version: 4.0.1 resolution: "@types/passport-jwt@npm:4.0.1" @@ -4705,6 +4725,17 @@ __metadata: languageName: node linkType: hard +"@types/passport-oauth2@npm:*": + version: 1.8.0 + resolution: "@types/passport-oauth2@npm:1.8.0" + dependencies: + "@types/express": "npm:*" + "@types/oauth": "npm:*" + "@types/passport": "npm:*" + checksum: 10/f874fb3c8646a720c4ae2b7da905b9db03e19617981e42c64b1dbe3313e7df9ab66682de41538db3279e1805b146ba6fc35902462c1858d605c30bb20cac7609 + languageName: node + linkType: hard + "@types/passport-strategy@npm:*": version: 0.2.38 resolution: "@types/passport-strategy@npm:0.2.38" @@ -4715,7 +4746,7 @@ __metadata: languageName: node linkType: hard -"@types/passport@npm:*": +"@types/passport@npm:*, @types/passport@npm:1.0.17": version: 1.0.17 resolution: "@types/passport@npm:1.0.17" dependencies: @@ -5742,6 +5773,13 @@ __metadata: languageName: node linkType: hard +"base64url@npm:3.x.x": + version: 3.0.1 + resolution: "base64url@npm:3.0.1" + checksum: 10/a77b2a3a526b3343e25be424de3ae0aa937d78f6af7c813ef9020ef98001c0f4e2323afcd7d8b2d2978996bf8c42445c3e9f60c218c622593e5fdfd54a3d6e18 + languageName: node + linkType: hard + "bcrypt@npm:5.1.1": version: 5.1.1 resolution: "bcrypt@npm:5.1.1" @@ -7987,9 +8025,9 @@ __metadata: languageName: node linkType: hard -"fastify@npm:5.3.3": - version: 5.3.3 - resolution: "fastify@npm:5.3.3" +"fastify@npm:5.4.0": + version: 5.4.0 + resolution: "fastify@npm:5.4.0" dependencies: "@fastify/ajv-compiler": "npm:^4.0.0" "@fastify/error": "npm:^4.0.0" @@ -8006,7 +8044,7 @@ __metadata: secure-json-parse: "npm:^4.0.0" semver: "npm:^7.6.0" toad-cache: "npm:^3.7.0" - checksum: 10/8a736ed5c4281576a1b939d9201963d7be68cf4baaf960033468e7774f15efa09af93b17d0b4d9bf0973cb3d367b316ac2978e54b38722f07d46544130c19cfc + checksum: 10/bc474e9b3ab05ed54e3d31d124f262d1e64afe758496ea9a8f0ad2b4555c0922887c41e3e7f96a7d203a5ef90040f682b5227917f70aecf60d72a70a942709cd languageName: node linkType: hard @@ -8625,6 +8663,7 @@ __metadata: "@commitlint/config-conventional": "npm:19.8.0" "@faker-js/faker": "npm:9.8.0" "@fastify/busboy": "npm:3.1.1" + "@fastify/cookie": "npm:11.0.2" "@fastify/cors": "npm:11.0.1" "@fastify/error": "npm:4.1.0" "@fastify/forwarded": "npm:3.0.0" @@ -8663,6 +8702,8 @@ __metadata: "@types/node-fetch": "npm:2" "@types/nodemailer": "npm:6.4.15" "@types/papaparse": "npm:5.3.15" + "@types/passport": "npm:1.0.17" + "@types/passport-github2": "npm:1.2.9" "@types/passport-jwt": "npm:4.0.1" "@types/passport-local": "npm:^1" "@types/pg": "npm:8.15.4" @@ -8688,7 +8729,7 @@ __metadata: eslint-plugin-import: "npm:2.32.0" extract-zip: "npm:2.0.1" fast-json-stringify: "npm:6.0.1" - fastify: "npm:5.3.3" + fastify: "npm:5.4.0" fastify-nodemailer: "npm:5.0.0" fastify-plugin: "npm:5.0.1" form-data: "npm:4.0.2" @@ -8713,7 +8754,9 @@ __metadata: nodemon: "npm:3.1.9" openai: "npm:4.91.1" papaparse: "npm:^5.4.1" + passport: "npm:0.7.0" passport-custom: "npm:1.1.1" + passport-github2: "npm:0.1.12" passport-jwt: "npm:4.0.1" passport-local: "npm:1.0.0" pdf-text-reader: "npm:3.0.2" @@ -11486,6 +11529,13 @@ __metadata: languageName: node linkType: hard +"oauth@npm:0.10.x": + version: 0.10.2 + resolution: "oauth@npm:0.10.2" + checksum: 10/b86f3c1ef35ff59d6659acc10e05b468dff4e6b5570c36d20eff1296db07565e09c3da8a009b656909c432ce0432f646664f301e5337f0dfd5a3d637c956ac2f + languageName: node + linkType: hard + "object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -11782,6 +11832,15 @@ __metadata: languageName: node linkType: hard +"passport-github2@npm:0.1.12": + version: 0.1.12 + resolution: "passport-github2@npm:0.1.12" + dependencies: + passport-oauth2: "npm:1.x.x" + checksum: 10/cf174d1738a2b1d398ec9f4ba2e1ca229214ef65cfd05f5754303f664d5cfd6e3bacbf49ece19fcd576be43bfcc1d3795541f50692148436b9e4ddbffbe625ac + languageName: node + linkType: hard + "passport-jwt@npm:4.0.1": version: 4.0.1 resolution: "passport-jwt@npm:4.0.1" @@ -11801,6 +11860,19 @@ __metadata: languageName: node linkType: hard +"passport-oauth2@npm:1.x.x": + version: 1.8.0 + resolution: "passport-oauth2@npm:1.8.0" + dependencies: + base64url: "npm:3.x.x" + oauth: "npm:0.10.x" + passport-strategy: "npm:1.x.x" + uid2: "npm:0.0.x" + utils-merge: "npm:1.x.x" + checksum: 10/31af6c59686bdc0460f3099a857ae4243952b44ad1afc5a12779524711ea97266491e97f83671120f84575b2c6202f3cf0bb8500fdc5a6414e10ff45617454ca + languageName: node + linkType: hard + "passport-strategy@npm:1.x.x, passport-strategy@npm:^1.0.0": version: 1.0.0 resolution: "passport-strategy@npm:1.0.0" @@ -11808,6 +11880,17 @@ __metadata: languageName: node linkType: hard +"passport@npm:0.7.0": + version: 0.7.0 + resolution: "passport@npm:0.7.0" + dependencies: + passport-strategy: "npm:1.x.x" + pause: "npm:0.0.1" + utils-merge: "npm:^1.0.1" + checksum: 10/0ebd4de8e3cba6731b1fddd09b95b8332526f316afded9c9589ff68751e10001c9f1c007170a516e8c1909f9fafdc378c12feb82820241856775005924735b29 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -11884,6 +11967,13 @@ __metadata: languageName: node linkType: hard +"pause@npm:0.0.1": + version: 0.0.1 + resolution: "pause@npm:0.0.1" + checksum: 10/e96ee581b68085e6f2ba5adbcb4d4a41fe88e5b514061e76df2fe1905f0f65f4fe5a843b538e9551122c6b9184ff4be266c2ee0ea4614702f9a3d04466d9f462 + languageName: node + linkType: hard + "pdf-text-reader@npm:3.0.2": version: 3.0.2 resolution: "pdf-text-reader@npm:3.0.2" @@ -14228,6 +14318,13 @@ __metadata: languageName: node linkType: hard +"uid2@npm:0.0.x": + version: 0.0.4 + resolution: "uid2@npm:0.0.4" + checksum: 10/e92325ce2e3b7be504b19e835dbb5a8b0495031f364b08ca46745468ed0ae0f202a4fdaf99a1a2715844156efc3ab410456ae24a0f7c0ae4b0a2e9f2784edfd9 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.1.0": version: 1.1.0 resolution: "unbox-primitive@npm:1.1.0" @@ -14442,6 +14539,13 @@ __metadata: languageName: node linkType: hard +"utils-merge@npm:1.x.x, utils-merge@npm:^1.0.1": + version: 1.0.1 + resolution: "utils-merge@npm:1.0.1" + checksum: 10/5d6949693d58cb2e636a84f3ee1c6e7b2f9c16cb1d42d0ecb386d8c025c69e327205aa1c69e2868cc06a01e5e20681fbba55a4e0ed0cce913d60334024eae798 + languageName: node + linkType: hard + "uuid@npm:11.1.0": version: 11.1.0 resolution: "uuid@npm:11.1.0" From 2c9c3860602567fb90fd385e25355df236d49892 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Mon, 30 Jun 2025 12:14:50 +0000 Subject: [PATCH 02/13] fix: make the admin auth work --- README.md | 8 ++- src/@types/declarations.d.ts | 3 +- src/config/admin.ts | 12 ++++ src/drizzle/0008_add-admin-table.sql | 6 ++ src/drizzle/0008_smooth_orphan.sql | 7 --- src/drizzle/meta/0007_snapshot.json | 3 +- src/drizzle/meta/0008_snapshot.json | 14 ++--- src/drizzle/meta/_journal.json | 4 +- src/drizzle/schema.ts | 3 +- src/fastify.ts | 3 - src/plugins/admin.repository.ts | 19 +++++- src/plugins/admin/README.md | 31 ++++++++++ src/plugins/admin/admin.plugin.ts | 93 ++++++++++++++++++++-------- src/workers/dashboard.controller.ts | 28 ++++----- 14 files changed, 167 insertions(+), 67 deletions(-) create mode 100644 src/config/admin.ts create mode 100644 src/drizzle/0008_add-admin-table.sql delete mode 100644 src/drizzle/0008_smooth_orphan.sql create mode 100644 src/plugins/admin/README.md diff --git a/README.md b/README.md index 04987f9548..02519ac5aa 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,8 @@ CORS_ORIGIN_REGEX=^http?:\/\/(localhost)?:[0-9]{4}$ # Session cookie key (to generate one: https://github.com/fastify/fastify-secure-session#using-a-pregenerated-key and https://github.com/fastify/fastify-secure-session#using-keys-as-strings) # TLDR: npx @fastify/secure-session > secret-key && node -e "let fs=require('fs'),file=path.join(__dirname, 'secret-key');console.log(fs.readFileSync(file).toString('hex'));fs.unlinkSync(file)" SECURE_SESSION_SECRET_KEY= - +# session key for the admin dashboard, (can use the same command as for SECURE_SESSION_SECRET_KEY) +ADMIN_SESSION_SECRET_KEY= ### Auth @@ -218,6 +219,11 @@ OPENAI_API_KEY= # GEOLOCATION API - this can be empty if you don't use geolocation GEOLOCATION_API_KEY= + +# Github Oauth provider secrets +# refer to the documentation in /src/plugins/admin on how to configure the OAuth app in GitHub +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= ``` ### Umami diff --git a/src/@types/declarations.d.ts b/src/@types/declarations.d.ts index 8c08ab595f..b6d14fd3df 100644 --- a/src/@types/declarations.d.ts +++ b/src/@types/declarations.d.ts @@ -1,7 +1,6 @@ import 'fastify'; import { ItemRaw } from '../drizzle/types'; -import { AdminUser } from '../plugins/admin.repository'; import { WebsocketService } from '../services/websockets/ws-service'; import { MaybeUser } from '../types'; @@ -23,7 +22,7 @@ declare module 'fastify' { key: string; origin: string; }; - admin?: AdminUser; + // admin?: AdminUser; } } diff --git a/src/config/admin.ts b/src/config/admin.ts new file mode 100644 index 0000000000..7e49ebf824 --- /dev/null +++ b/src/config/admin.ts @@ -0,0 +1,12 @@ +import { getEnv } from './env'; +import { requiredEnvVar } from './helpers'; + +// ensure env is setup +getEnv(); + +// GitHub OAuth app secrets +// to generate these values go to: Settings -> Developer Settings -> OAuth Apps -> New OAuth app +// the callback url should be /admin/auth/github/callback +// so for local developement: http://localhost:3000/admin/auth/github/callback +export const GITHUB_CLIENT_ID = requiredEnvVar('GITHUB_CLIENT_ID'); +export const GITHUB_CLIENT_SECRET = requiredEnvVar('GITHUB_CLIENT_SECRET'); diff --git a/src/drizzle/0008_add-admin-table.sql b/src/drizzle/0008_add-admin-table.sql new file mode 100644 index 0000000000..fd0a3ecb4c --- /dev/null +++ b/src/drizzle/0008_add-admin-table.sql @@ -0,0 +1,6 @@ +CREATE TABLE "admins" ( + "user_name" varchar PRIMARY KEY NOT NULL, + "id" varchar, + "last_authenticated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "admins_user_name_unique" UNIQUE("user_name") +); diff --git a/src/drizzle/0008_smooth_orphan.sql b/src/drizzle/0008_smooth_orphan.sql deleted file mode 100644 index 7606518a13..0000000000 --- a/src/drizzle/0008_smooth_orphan.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TYPE "public"."action_view_enum" ADD VALUE 'analytics' BEFORE 'home';--> statement-breakpoint -CREATE TABLE "admins" ( - "userName" varchar PRIMARY KEY NOT NULL, - "id" varchar, - "last_authenticated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "admins_userName_unique" UNIQUE("userName") -); diff --git a/src/drizzle/meta/0007_snapshot.json b/src/drizzle/meta/0007_snapshot.json index bfd040867a..98ede6fbf2 100644 --- a/src/drizzle/meta/0007_snapshot.json +++ b/src/drizzle/meta/0007_snapshot.json @@ -3252,6 +3252,7 @@ "player", "library", "account", + "analytics", "home", "auth", "unknown" @@ -3597,4 +3598,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/src/drizzle/meta/0008_snapshot.json b/src/drizzle/meta/0008_snapshot.json index 8badcfa973..a8ae7629d5 100644 --- a/src/drizzle/meta/0008_snapshot.json +++ b/src/drizzle/meta/0008_snapshot.json @@ -1,5 +1,5 @@ { - "id": "c995e36d-251c-4bcf-97b2-764aca4c65c1", + "id": "919e28eb-85b7-40d9-99c9-2e075962ecb3", "prevId": "a13269a4-7c7d-4a55-943d-296d433921d7", "version": "7", "dialect": "postgresql", @@ -365,8 +365,8 @@ "name": "admins", "schema": "", "columns": { - "userName": { - "name": "userName", + "user_name": { + "name": "user_name", "type": "varchar", "primaryKey": true, "notNull": true @@ -389,11 +389,11 @@ "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { - "admins_userName_unique": { - "name": "admins_userName_unique", + "admins_user_name_unique": { + "name": "admins_user_name_unique", "nullsNotDistinct": false, "columns": [ - "userName" + "user_name" ] } }, @@ -3638,4 +3638,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/src/drizzle/meta/_journal.json b/src/drizzle/meta/_journal.json index 1ad239982f..8037263aea 100644 --- a/src/drizzle/meta/_journal.json +++ b/src/drizzle/meta/_journal.json @@ -61,8 +61,8 @@ { "idx": 8, "version": "7", - "when": 1751279569270, - "tag": "0008_smooth_orphan", + "when": 1751279758451, + "tag": "0008_add-admin-table", "breakpoints": true } ] diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 0c7071086f..a374f2cc9c 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -84,7 +84,8 @@ export const itemValidationStatusEnum = pgEnum('item_validation_status', [ ]); export const adminsTable = pgTable('admins', { - userName: varchar().primaryKey().notNull().unique(), + // the userName is the primary key since we want to allow admins based on their github username + userName: varchar('user_name').primaryKey().notNull().unique(), id: varchar(), lastAuthenticatedAt: timestamp('last_authenticated_at', { withTimezone: true, mode: 'string' }) .defaultNow() diff --git a/src/fastify.ts b/src/fastify.ts index bde9be3177..8c346e0004 100644 --- a/src/fastify.ts +++ b/src/fastify.ts @@ -9,7 +9,6 @@ import ajvFormats from './schemas/ajvFormats'; import { initSentry } from './sentry'; import { APP_VERSION, CORS_ORIGIN_REGEX, HOST_LISTEN_ADDRESS, PORT } from './utils/config'; import { GREETING } from './utils/constants'; -import { queueDashboardPlugin } from './workers/dashboard.controller'; export const instance = fastify({ // allows to remove logging of incomming requests @@ -57,8 +56,6 @@ const start = async () => { await registerAppPlugins(instance); - instance.register(queueDashboardPlugin); - try { await instance.listen({ port: PORT, host: HOST_LISTEN_ADDRESS }); instance.log.info('App is running version %s in %s mode', APP_VERSION, NODE_ENV); diff --git a/src/plugins/admin.repository.ts b/src/plugins/admin.repository.ts index 6b7db80fa1..d96c06956a 100644 --- a/src/plugins/admin.repository.ts +++ b/src/plugins/admin.repository.ts @@ -4,6 +4,7 @@ import { DBConnection } from '../drizzle/db'; import { adminsTable } from '../drizzle/schema'; export type AdminUser = typeof adminsTable.$inferSelect; +export type AdminUserUpdateData = typeof adminsTable.$inferInsert; export class AdminRepository { async get(dbConnection: DBConnection, adminId: string): Promise { @@ -13,7 +14,7 @@ export class AdminRepository { return adminUser; } - async isAdmin(dbConnection, userName): Promise { + async isAdmin(dbConnection: DBConnection, userName: string): Promise { const admin = await dbConnection.query.adminsTable.findFirst({ where: eq(adminsTable.userName, userName), }); @@ -22,4 +23,20 @@ export class AdminRepository { } return false; } + + async update( + dbConnection: DBConnection, + userName: string, + data: { id: string }, + ): Promise { + const admin = await dbConnection + .update(adminsTable) + .set({ id: data.id }) + .where(eq(adminsTable.userName, userName)) + .returning(); + if (!admin || !admin[0]) { + throw new Error('Could not update admin info'); + } + return admin[0]; + } } diff --git a/src/plugins/admin/README.md b/src/plugins/admin/README.md new file mode 100644 index 0000000000..8902966f56 --- /dev/null +++ b/src/plugins/admin/README.md @@ -0,0 +1,31 @@ +# Admin plugin + +The admin plugin is responsible for providing administrative access to feature to allowed users (called "admins"). + +## Authentication + +The admins are authenticated using GitHub OAuth2 api. +We only store the user github ID and their username, as well as their last authentication time. + +To allow a user to access the administration part, they need to exist in the `admins` table. +The `admins` table has a unique constraint on the `user_name` field. + +To add a `someUser` as a new admin run the following statement: + +```sql +insert into admins ("user_name") values ('someUser'); +``` + +Replace `someUser` with the github handle of the user you want to authorize. + +## Features + +The Admin dashboard priovides multiple features to the admins. + +### Queue Dashboard UI + +The admins can access the BullMQ dashboard to check on queues and job status as well as trigger new jobs. + +### TBA: email bulk send + +This needs to be added. diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts index 425276b3aa..46ceac8963 100644 --- a/src/plugins/admin/admin.plugin.ts +++ b/src/plugins/admin/admin.plugin.ts @@ -1,28 +1,50 @@ -import { Strategy as GitHubStrategy } from 'passport-github2'; +import { StatusCodes } from 'http-status-codes'; +import { Profile as GitHubProfile, Strategy as GitHubStrategy } from 'passport-github2'; +import { createError } from '@fastify/error'; import fastifyPassport from '@fastify/passport'; import { fastifySecureSession } from '@fastify/secure-session'; -import { FastifyInstance, PassportUser } from 'fastify'; +import { FastifyInstance } from 'fastify'; +import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from '../../config/admin'; import { PROD } from '../../config/env'; import { ADMIN_SESSION_EXPIRATION_IN_SECONDS, ADMIN_SESSION_SECRET_KEY, } from '../../config/secrets'; import { db } from '../../drizzle/db'; -import { assertIsDefined } from '../../utils/assertions'; -import { AdminRepository } from '../admin.repository'; - -const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'YOUR_CLIENT_ID'; -const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || 'YOUR_CLIENT_SECRET'; - +import { PUBLIC_URL } from '../../utils/config'; +import { queueDashboardPlugin } from '../../workers/dashboard.controller'; +import { AdminRepository, AdminUser } from '../admin.repository'; + +// module augmentation so the types are right when getting the admin user +declare module 'fastify' { + interface PassportUser { + admin?: AdminUser; + } +} + +// name of the passport strategy const GITHUB_OAUTH_STRATEGY = 'github-admin'; +// Common error definions for this module +const NotAnAuthorizedAdmin = createError( + 'GAERR001', + 'User is not an authorized admin', + StatusCodes.UNAUTHORIZED, +); +const MissingGithubUsername = createError( + 'GAERR002', + 'Response from Github is missing key `username`', + StatusCodes.BAD_REQUEST, +); + export default async (fastify: FastifyInstance) => { const adminRepository = new AdminRepository(); fastify.register(fastifySecureSession, { key: Buffer.from(ADMIN_SESSION_SECRET_KEY, 'hex'), + cookieName: 'adminSession', cookie: { path: '/admin', secure: PROD, @@ -40,30 +62,40 @@ export default async (fastify: FastifyInstance) => { { clientID: GITHUB_CLIENT_ID, clientSecret: GITHUB_CLIENT_SECRET, - callbackURL: 'http://localhost:3000/admin/auth/github/callback', + callbackURL: `${PUBLIC_URL.origin}/admin/auth/github/callback`, }, - async (accessToken, refreshToken, profile, done) => { + async (accessToken, refreshToken, profile: GitHubProfile, done) => { + const { username } = profile; + if (!username) { + throw new MissingGithubUsername(); + } // only allow users that are present in the admin table by their username - if (await adminRepository.isAdmin(db, profile.username)) { + if (await adminRepository.isAdmin(db, username)) { + console.debug('user is an allowed admin'); + // update info stored in the table + await adminRepository.update(db, username, { id: profile.id }); // You can add admin checks here return done(null, profile); } + console.debug('user is not an allowed admin', profile); + return done(new NotAnAuthorizedAdmin()); }, ), ); - fastifyPassport.registerUserSerializer(async (user: PassportUser, _req) => { - _req.log.info(user); - assertIsDefined(user.admin); - return user.admin.id; + fastifyPassport.registerUserSerializer(async (user: GitHubProfile, req) => { + req.log.info(user); + return user.id; }); - fastifyPassport.registerUserDeserializer(async (uuid: string, _req): Promise => { - _req.log.info('uuuid', uuid); + fastifyPassport.registerUserDeserializer( + async (uuid: string, req): Promise<{ admin: AdminUser | undefined }> => { + req.log.info('uuuid', uuid); - const admin = await adminRepository.get(db, uuid); + const admin = await adminRepository.get(db, uuid); - return { admin }; - }); + return { admin }; + }, + ); fastify.get( '/admin/auth/github', @@ -86,10 +118,13 @@ export default async (fastify: FastifyInstance) => { }, ); + // login page when user is not authenticated fastify.get('/admin/login', async (_request, reply) => { reply.type('text/html').send('Login with GitHub'); }); + // this redirects all unauthenticated requests to the login + // only /admin/login and /admin/auth/github should be let through to prevent redirection loops fastify.addHook('preHandler', (request, reply, done) => { const url = request.raw.url || ''; if ( @@ -104,16 +139,24 @@ export default async (fastify: FastifyInstance) => { } }); + // return the admin home, for the moment it is a bit bare fastify.get('/admin', async (request, reply) => { - reply - .type('text/html') - .send( - `Hello, ${request.user?.admin?.userName || 'admin'}! Logout`, - ); + request.log.info(request.user); + reply.type('text/html').send( + `Hello, ${request.user?.admin?.userName || 'admin'}! + Logout
+ Queue Dashboard + `, + ); }); fastify.get('/admin/logout', async (request, reply) => { await request.logout(); reply.redirect('/admin/login'); }); + + // register the queue Dashboard for BullMQ + // warning inside this module it registers the path as absolute, + // so we should beware that when moving the registration we should also update the absolute paths + fastify.register(queueDashboardPlugin); }; diff --git a/src/workers/dashboard.controller.ts b/src/workers/dashboard.controller.ts index 433e2233b7..ec0dc5ebe5 100644 --- a/src/workers/dashboard.controller.ts +++ b/src/workers/dashboard.controller.ts @@ -5,28 +5,22 @@ import { Queue } from 'bullmq'; import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; -import { DEV } from '../config/env'; import { REDIS_CONNECTION } from '../config/redis'; import { Queues } from './config'; export const queueDashboardPlugin: FastifyPluginAsyncTypebox = async (instance) => { - if (DEV) { - const serverAdapter = new FastifyAdapter(); + const serverAdapter = new FastifyAdapter(); - const queues = [ - new Queue(Queues.ItemExport.queueName, { connection: { url: REDIS_CONNECTION } }), - new Queue(Queues.SearchIndex.queueName, { connection: { url: REDIS_CONNECTION } }), - ]; + const queues = [ + new Queue(Queues.ItemExport.queueName, { connection: { url: REDIS_CONNECTION } }), + new Queue(Queues.SearchIndex.queueName, { connection: { url: REDIS_CONNECTION } }), + ]; - createBullBoard({ - queues: queues.map((q) => new BullMQAdapter(q)), - serverAdapter, - }); + createBullBoard({ + queues: queues.map((q) => new BullMQAdapter(q)), + serverAdapter, + }); - serverAdapter.setBasePath('/ui'); - instance.register(serverAdapter.registerPlugin(), { prefix: '/ui' }); - } else { - // warn that the dashboard will not be available. - instance.log.info('Not running in dev mode, the bullmq dashboard will not be available.'); - } + serverAdapter.setBasePath('/admin/queues/ui'); + instance.register(serverAdapter.registerPlugin(), { prefix: '/admin/queues/ui' }); }; From bc57f82d270e7c0bb5c88b6ced30ce9fa9cb0557 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Mon, 30 Jun 2025 12:31:25 +0000 Subject: [PATCH 03/13] fix: add documentation on how to setup the oauth app --- src/plugins/admin/README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/plugins/admin/README.md b/src/plugins/admin/README.md index 8902966f56..16d687cd01 100644 --- a/src/plugins/admin/README.md +++ b/src/plugins/admin/README.md @@ -1,15 +1,35 @@ # Admin plugin -The admin plugin is responsible for providing administrative access to feature to allowed users (called "admins"). +The admin plugin is responsible for providing administrative access to features for designated admin users (called "admins"). ## Authentication -The admins are authenticated using GitHub OAuth2 api. +The admins are authenticated using GitHub OAuth2 api. For how to setup an OAuth app to work with your local setup see [Local setup for OAuth App](#local-setup-for-oauth-app) We only store the user github ID and their username, as well as their last authentication time. To allow a user to access the administration part, they need to exist in the `admins` table. The `admins` table has a unique constraint on the `user_name` field. +### Local setup for OAuth app + +For the admin feature to work in your local development environement you need to have GitHub OAuth app credentials. See the [GitHub OAuth app documentation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) for a full walkthrough of how to set one up. + +For our purpose, you will need to create an OAuth app from your GitHub account. + +1. Go to: Settings -> Developer Settings -> OAuth apps +1. Create an OAuth app ("New OAuth app" button) + 1. Choose the name you want (i.e. "Admin dev" or "Local Graasp admin") + 1. For "Homepage URL" set `http://localhost:3000` + 1. You can add a description (optional) + 1. Set the "Authorization callback URL" to `http://localhost:3000/admin/auth/github/callback` + 1. Validate +1. Copy the "Client Id" and paste it in your `.env.development` file next to the `GITHUB_CLIENT_ID` +1. Generate a new client secret, copy it and paste it in your `.env.development` file next to `GITHUB_CLIENT_SECRET` (be carefull, as this value will not be shown again, you cna always re-generate it if you loose it, also DO NOT SHARE IT WITH ANYONE!) + +With this in place, you just need to add the admins to the database following instructions in [adding admins](#adding-admins) + +### Adding admins + To add a `someUser` as a new admin run the following statement: ```sql From f2360509c84dddda578aa240a1b981aa82164509 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Mon, 30 Jun 2025 12:37:31 +0000 Subject: [PATCH 04/13] chore: cleanup and fix package issues and file placement --- package.json | 2 -- src/@types/declarations.d.ts | 1 - src/plugins/admin/admin.plugin.ts | 2 +- src/plugins/{ => admin}/admin.repository.ts | 4 ++-- yarn.lock | 24 ++------------------- 5 files changed, 5 insertions(+), 28 deletions(-) rename src/plugins/{ => admin}/admin.repository.ts (91%) diff --git a/package.json b/package.json index cd44c2e28c..f6bf3251a3 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,6 @@ "nodemailer": "6.10.1", "openai": "4.91.1", "papaparse": "^5.4.1", - "passport": "0.7.0", "passport-custom": "1.1.1", "passport-github2": "0.1.12", "passport-jwt": "4.0.1", @@ -147,7 +146,6 @@ "@types/node-fetch": "2", "@types/nodemailer": "6.4.15", "@types/papaparse": "5.3.15", - "@types/passport": "1.0.17", "@types/passport-github2": "1.2.9", "@types/passport-jwt": "4.0.1", "@types/passport-local": "^1", diff --git a/src/@types/declarations.d.ts b/src/@types/declarations.d.ts index b6d14fd3df..724425a458 100644 --- a/src/@types/declarations.d.ts +++ b/src/@types/declarations.d.ts @@ -22,7 +22,6 @@ declare module 'fastify' { key: string; origin: string; }; - // admin?: AdminUser; } } diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts index 46ceac8963..8879a4803e 100644 --- a/src/plugins/admin/admin.plugin.ts +++ b/src/plugins/admin/admin.plugin.ts @@ -15,7 +15,7 @@ import { import { db } from '../../drizzle/db'; import { PUBLIC_URL } from '../../utils/config'; import { queueDashboardPlugin } from '../../workers/dashboard.controller'; -import { AdminRepository, AdminUser } from '../admin.repository'; +import { AdminRepository, AdminUser } from './admin.repository'; // module augmentation so the types are right when getting the admin user declare module 'fastify' { diff --git a/src/plugins/admin.repository.ts b/src/plugins/admin/admin.repository.ts similarity index 91% rename from src/plugins/admin.repository.ts rename to src/plugins/admin/admin.repository.ts index d96c06956a..3ab9b9e852 100644 --- a/src/plugins/admin.repository.ts +++ b/src/plugins/admin/admin.repository.ts @@ -1,7 +1,7 @@ import { eq } from 'drizzle-orm'; -import { DBConnection } from '../drizzle/db'; -import { adminsTable } from '../drizzle/schema'; +import { DBConnection } from '../../drizzle/db'; +import { adminsTable } from '../../drizzle/schema'; export type AdminUser = typeof adminsTable.$inferSelect; export type AdminUserUpdateData = typeof adminsTable.$inferInsert; diff --git a/yarn.lock b/yarn.lock index a664549882..697819e65e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4746,7 +4746,7 @@ __metadata: languageName: node linkType: hard -"@types/passport@npm:*, @types/passport@npm:1.0.17": +"@types/passport@npm:*": version: 1.0.17 resolution: "@types/passport@npm:1.0.17" dependencies: @@ -8702,7 +8702,6 @@ __metadata: "@types/node-fetch": "npm:2" "@types/nodemailer": "npm:6.4.15" "@types/papaparse": "npm:5.3.15" - "@types/passport": "npm:1.0.17" "@types/passport-github2": "npm:1.2.9" "@types/passport-jwt": "npm:4.0.1" "@types/passport-local": "npm:^1" @@ -8754,7 +8753,6 @@ __metadata: nodemon: "npm:3.1.9" openai: "npm:4.91.1" papaparse: "npm:^5.4.1" - passport: "npm:0.7.0" passport-custom: "npm:1.1.1" passport-github2: "npm:0.1.12" passport-jwt: "npm:4.0.1" @@ -11880,17 +11878,6 @@ __metadata: languageName: node linkType: hard -"passport@npm:0.7.0": - version: 0.7.0 - resolution: "passport@npm:0.7.0" - dependencies: - passport-strategy: "npm:1.x.x" - pause: "npm:0.0.1" - utils-merge: "npm:^1.0.1" - checksum: 10/0ebd4de8e3cba6731b1fddd09b95b8332526f316afded9c9589ff68751e10001c9f1c007170a516e8c1909f9fafdc378c12feb82820241856775005924735b29 - languageName: node - linkType: hard - "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -11967,13 +11954,6 @@ __metadata: languageName: node linkType: hard -"pause@npm:0.0.1": - version: 0.0.1 - resolution: "pause@npm:0.0.1" - checksum: 10/e96ee581b68085e6f2ba5adbcb4d4a41fe88e5b514061e76df2fe1905f0f65f4fe5a843b538e9551122c6b9184ff4be266c2ee0ea4614702f9a3d04466d9f462 - languageName: node - linkType: hard - "pdf-text-reader@npm:3.0.2": version: 3.0.2 resolution: "pdf-text-reader@npm:3.0.2" @@ -14539,7 +14519,7 @@ __metadata: languageName: node linkType: hard -"utils-merge@npm:1.x.x, utils-merge@npm:^1.0.1": +"utils-merge@npm:1.x.x": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" checksum: 10/5d6949693d58cb2e636a84f3ee1c6e7b2f9c16cb1d42d0ecb386d8c025c69e327205aa1c69e2868cc06a01e5e20681fbba55a4e0ed0cce913d60334024eae798 From 26223d09801bdc9e036850dd9c7a3bbfbe4c192c Mon Sep 17 00:00:00 2001 From: spaenleh Date: Mon, 30 Jun 2025 12:53:30 +0000 Subject: [PATCH 05/13] fix: apply recommendations for sql schema --- .../{0008_add-admin-table.sql => 0008_add-admins.sql} | 4 ++-- src/drizzle/meta/0008_snapshot.json | 8 ++++---- src/drizzle/meta/_journal.json | 4 ++-- src/drizzle/schema.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) rename src/drizzle/{0008_add-admin-table.sql => 0008_add-admins.sql} (70%) diff --git a/src/drizzle/0008_add-admin-table.sql b/src/drizzle/0008_add-admins.sql similarity index 70% rename from src/drizzle/0008_add-admin-table.sql rename to src/drizzle/0008_add-admins.sql index fd0a3ecb4c..11cd98234b 100644 --- a/src/drizzle/0008_add-admin-table.sql +++ b/src/drizzle/0008_add-admins.sql @@ -1,6 +1,6 @@ CREATE TABLE "admins" ( - "user_name" varchar PRIMARY KEY NOT NULL, - "id" varchar, + "user_name" varchar(39) PRIMARY KEY NOT NULL, + "id" varchar(15), "last_authenticated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "admins_user_name_unique" UNIQUE("user_name") ); diff --git a/src/drizzle/meta/0008_snapshot.json b/src/drizzle/meta/0008_snapshot.json index a8ae7629d5..11d4660b35 100644 --- a/src/drizzle/meta/0008_snapshot.json +++ b/src/drizzle/meta/0008_snapshot.json @@ -1,5 +1,5 @@ { - "id": "919e28eb-85b7-40d9-99c9-2e075962ecb3", + "id": "8b8bad4b-1b1d-4408-843e-ba855600d1ce", "prevId": "a13269a4-7c7d-4a55-943d-296d433921d7", "version": "7", "dialect": "postgresql", @@ -367,13 +367,13 @@ "columns": { "user_name": { "name": "user_name", - "type": "varchar", + "type": "varchar(39)", "primaryKey": true, "notNull": true }, "id": { "name": "id", - "type": "varchar", + "type": "varchar(15)", "primaryKey": false, "notNull": false }, @@ -3638,4 +3638,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/src/drizzle/meta/_journal.json b/src/drizzle/meta/_journal.json index 8037263aea..8ecf44b2ac 100644 --- a/src/drizzle/meta/_journal.json +++ b/src/drizzle/meta/_journal.json @@ -61,8 +61,8 @@ { "idx": 8, "version": "7", - "when": 1751279758451, - "tag": "0008_add-admin-table", + "when": 1751288131091, + "tag": "0008_add-admins", "breakpoints": true } ] diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index a374f2cc9c..49c4956eec 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -85,8 +85,8 @@ export const itemValidationStatusEnum = pgEnum('item_validation_status', [ export const adminsTable = pgTable('admins', { // the userName is the primary key since we want to allow admins based on their github username - userName: varchar('user_name').primaryKey().notNull().unique(), - id: varchar(), + userName: varchar('user_name', { length: 39 }).primaryKey().notNull().unique(), + id: varchar({ length: 15 }), lastAuthenticatedAt: timestamp('last_authenticated_at', { withTimezone: true, mode: 'string' }) .defaultNow() .notNull() From 6aca11ba88479546b7ab985b023f23d0e61799f0 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Mon, 30 Jun 2025 13:35:09 +0000 Subject: [PATCH 06/13] fix: add env vars to tests --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3bd0457f04..405498e345 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,7 @@ env: H5P_PATH_PREFIX: h5p-content/ H5P_STORAGE_ROOT_PATH: /tmp/h5p H5P_FILE_STORAGE_HOST: http://localhost:1081 + ADMIN_SESSION_SECRET_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef PASSWORD_RESET_JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef EMAIL_CHANGE_JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef @@ -51,6 +52,8 @@ env: MEILISEARCH_MASTER_KEY: fake GEOLOCATION_API_KEY: geolocation-key GEOLOCATION_API_HOST: http://localhost:12345 + GITHUB_CLIENT_ID: 6237481259 + GITHUB_CLIENT_SECRET: 72843529435293450182439587289345714895819043580 jobs: build-node: From 887c409fade4dba3815488950728db0bf0973eff Mon Sep 17 00:00:00 2001 From: spaenleh Date: Tue, 1 Jul 2025 06:45:57 +0000 Subject: [PATCH 07/13] fix: tests with isolation of passport instances --- src/plugins/admin/admin.plugin.ts | 54 ++++++++++++++++--------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts index 8879a4803e..844cca5bbd 100644 --- a/src/plugins/admin/admin.plugin.ts +++ b/src/plugins/admin/admin.plugin.ts @@ -2,9 +2,9 @@ import { StatusCodes } from 'http-status-codes'; import { Profile as GitHubProfile, Strategy as GitHubStrategy } from 'passport-github2'; import { createError } from '@fastify/error'; -import fastifyPassport from '@fastify/passport'; +import { Authenticator } from '@fastify/passport'; import { fastifySecureSession } from '@fastify/secure-session'; -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyRequest } from 'fastify'; import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from '../../config/admin'; import { PROD } from '../../config/env'; @@ -18,10 +18,10 @@ import { queueDashboardPlugin } from '../../workers/dashboard.controller'; import { AdminRepository, AdminUser } from './admin.repository'; // module augmentation so the types are right when getting the admin user -declare module 'fastify' { - interface PassportUser { - admin?: AdminUser; - } +// this interface can be used in place of the FastifyRequest in request handlers to get correct typing when inside the admin plugin. +// we use this manual approache to not pollute the global type system with type augmentation. +interface AdminRequest extends FastifyRequest { + admin?: AdminUser; } // name of the passport strategy @@ -53,10 +53,13 @@ export default async (fastify: FastifyInstance) => { }, expiry: ADMIN_SESSION_EXPIRATION_IN_SECONDS, }); - await fastify.register(fastifyPassport.initialize()); - await fastify.register(fastifyPassport.secureSession()); + // declare a new authenticator so that passport for admins does not interfere with the user passport strategies + // also set the user property to `admin` so that it will be available on `request.admin` + const adminPassport = new Authenticator({ userProperty: 'admin' }); + await fastify.register(adminPassport.initialize()); + await fastify.register(adminPassport.secureSession()); - fastifyPassport.use( + adminPassport.use( GITHUB_OAUTH_STRATEGY, new GitHubStrategy( { @@ -64,7 +67,7 @@ export default async (fastify: FastifyInstance) => { clientSecret: GITHUB_CLIENT_SECRET, callbackURL: `${PUBLIC_URL.origin}/admin/auth/github/callback`, }, - async (accessToken, refreshToken, profile: GitHubProfile, done) => { + async (accessToken: string, refreshToken: string, profile: GitHubProfile) => { const { username } = profile; if (!username) { throw new MissingGithubUsername(); @@ -75,20 +78,20 @@ export default async (fastify: FastifyInstance) => { // update info stored in the table await adminRepository.update(db, username, { id: profile.id }); // You can add admin checks here - return done(null, profile); + return profile; } console.debug('user is not an allowed admin', profile); - return done(new NotAnAuthorizedAdmin()); + throw new NotAnAuthorizedAdmin(); }, ), ); - fastifyPassport.registerUserSerializer(async (user: GitHubProfile, req) => { + adminPassport.registerUserSerializer(async (user: GitHubProfile, req: AdminRequest) => { req.log.info(user); return user.id; }); - fastifyPassport.registerUserDeserializer( - async (uuid: string, req): Promise<{ admin: AdminUser | undefined }> => { + adminPassport.registerUserDeserializer( + async (uuid: string, req: AdminRequest): Promise<{ admin: AdminUser | undefined }> => { req.log.info('uuuid', uuid); const admin = await adminRepository.get(db, uuid); @@ -100,32 +103,31 @@ export default async (fastify: FastifyInstance) => { fastify.get( '/admin/auth/github', { - preValidation: fastifyPassport.authenticate(GITHUB_OAUTH_STRATEGY, { scope: ['user:email'] }), + preValidation: adminPassport.authenticate(GITHUB_OAUTH_STRATEGY, { scope: ['user:email'] }), }, - async (_request, _reply) => {}, + async (_request: AdminRequest, _reply) => {}, ); fastify.get( '/admin/auth/github/callback', { - preValidation: fastifyPassport.authenticate(GITHUB_OAUTH_STRATEGY, { + preValidation: adminPassport.authenticate(GITHUB_OAUTH_STRATEGY, { failureRedirect: '/admin/login', }), }, - async (request, reply) => { - request.log.info('callback user', request.user); + async (_request: AdminRequest, reply) => { reply.redirect('/admin'); }, ); // login page when user is not authenticated - fastify.get('/admin/login', async (_request, reply) => { + fastify.get('/admin/login', async (_request: AdminRequest, reply) => { reply.type('text/html').send('Login with GitHub'); }); // this redirects all unauthenticated requests to the login // only /admin/login and /admin/auth/github should be let through to prevent redirection loops - fastify.addHook('preHandler', (request, reply, done) => { + fastify.addHook('preHandler', (request: AdminRequest, reply, done) => { const url = request.raw.url || ''; if ( url.startsWith('/admin') && @@ -140,17 +142,17 @@ export default async (fastify: FastifyInstance) => { }); // return the admin home, for the moment it is a bit bare - fastify.get('/admin', async (request, reply) => { - request.log.info(request.user); + fastify.get('/admin', async (request: AdminRequest, reply) => { + request.log.info(request.admin); reply.type('text/html').send( - `Hello, ${request.user?.admin?.userName || 'admin'}! + `Hello, ${request.admin?.userName || 'admin'}! Logout
Queue Dashboard `, ); }); - fastify.get('/admin/logout', async (request, reply) => { + fastify.get('/admin/logout', async (request: AdminRequest, reply) => { await request.logout(); reply.redirect('/admin/login'); }); From c3047c964138d22c9fb5297359afffcc7ae6bef0 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Tue, 1 Jul 2025 07:13:01 +0000 Subject: [PATCH 08/13] fix(test): temporarily disabled matchOne tests because passport is not setup there --- src/services/auth/plugins/passport/preHandlers.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/services/auth/plugins/passport/preHandlers.test.ts b/src/services/auth/plugins/passport/preHandlers.test.ts index 2d40dee50a..10acc12889 100644 --- a/src/services/auth/plugins/passport/preHandlers.test.ts +++ b/src/services/auth/plugins/passport/preHandlers.test.ts @@ -10,9 +10,11 @@ import { assertIsMember } from '../../../authentication'; import { validatedMemberAccountRole } from '../../../member/strategies/validatedMemberAccountRole'; import { isAuthenticated, matchOne } from './preHandlers'; +// TODO: this tests fails now because passport registration has been moved to inside the core registration and we can not register a route there, +// all top level routes do not use passport. // move this test closer to matchone // other prehandlers are tested in plugin.test.ts -describe('matchOne', () => { +describe.skip('matchOne', () => { let app: FastifyInstance; let member: MinimalMember; let handler: jest.Mock; @@ -58,6 +60,7 @@ describe('matchOne', () => { it('No Whitelist', async () => { handler.mockImplementation(shouldBeActor(member)); const response = await app.inject({ path: MOCKED_ROUTE }); + console.log(await response.json()); expect(handler).toHaveBeenCalledTimes(1); expect(response.statusCode).toBe(StatusCodes.OK); }); From 6c09f4a1b032d730c48a49fd7e65350b407e7777 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Tue, 1 Jul 2025 08:14:44 +0000 Subject: [PATCH 09/13] fix: hang with callback --- src/plugins/admin/admin.plugin.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts index 844cca5bbd..33eea9b513 100644 --- a/src/plugins/admin/admin.plugin.ts +++ b/src/plugins/admin/admin.plugin.ts @@ -67,7 +67,7 @@ export default async (fastify: FastifyInstance) => { clientSecret: GITHUB_CLIENT_SECRET, callbackURL: `${PUBLIC_URL.origin}/admin/auth/github/callback`, }, - async (accessToken: string, refreshToken: string, profile: GitHubProfile) => { + async (accessToken: string, refreshToken: string, profile: GitHubProfile, done) => { const { username } = profile; if (!username) { throw new MissingGithubUsername(); @@ -78,10 +78,10 @@ export default async (fastify: FastifyInstance) => { // update info stored in the table await adminRepository.update(db, username, { id: profile.id }); // You can add admin checks here - return profile; + return done(null, profile); } console.debug('user is not an allowed admin', profile); - throw new NotAnAuthorizedAdmin(); + return done(new NotAnAuthorizedAdmin()); }, ), ); @@ -147,7 +147,7 @@ export default async (fastify: FastifyInstance) => { reply.type('text/html').send( `Hello, ${request.admin?.userName || 'admin'}! Logout
- Queue Dashboard + Queue Dashboard `, ); }); From 8606f6bf7520849f8d4cfdea24cc8c7f71da5fec Mon Sep 17 00:00:00 2001 From: spaenleh Date: Fri, 4 Jul 2025 06:20:24 +0000 Subject: [PATCH 10/13] fix: comments --- .devcontainer/devcontainer.json | 3 +- src/drizzle/0008_add-admins.sql | 6 -- src/drizzle/0008_add_admin.sql | 8 ++ src/drizzle/meta/0008_snapshot.json | 43 ++++++---- src/drizzle/meta/_journal.json | 4 +- src/drizzle/schema.ts | 16 ++-- src/plugins/admin/README.md | 29 ++++--- src/plugins/admin/admin.plugin.ts | 80 ++++++++++--------- src/plugins/admin/admin.repository.ts | 15 ++-- .../auth/plugins/passport/preHandlers.test.ts | 2 +- 10 files changed, 118 insertions(+), 88 deletions(-) delete mode 100644 src/drizzle/0008_add-admins.sql create mode 100644 src/drizzle/0008_add_admin.sql diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 94c2fc7ce4..38c1c27801 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -65,7 +65,8 @@ "mhutchie.git-graph", "firsttris.vscode-jest-runner", "Orta.vscode-jest", - "Gruntfuggly.todo-tree" + "Gruntfuggly.todo-tree", + "streetsidesoftware.code-spell-checker" ] } } diff --git a/src/drizzle/0008_add-admins.sql b/src/drizzle/0008_add-admins.sql deleted file mode 100644 index 11cd98234b..0000000000 --- a/src/drizzle/0008_add-admins.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE "admins" ( - "user_name" varchar(39) PRIMARY KEY NOT NULL, - "id" varchar(15), - "last_authenticated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "admins_user_name_unique" UNIQUE("user_name") -); diff --git a/src/drizzle/0008_add_admin.sql b/src/drizzle/0008_add_admin.sql new file mode 100644 index 0000000000..441b99e8b8 --- /dev/null +++ b/src/drizzle/0008_add_admin.sql @@ -0,0 +1,8 @@ +CREATE TABLE "admin" ( + "github_id" varchar(15) PRIMARY KEY NOT NULL, + "github_name" varchar(39) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_authenticated_at" timestamp with time zone, + CONSTRAINT "admin_github_id_unique" UNIQUE("github_id"), + CONSTRAINT "admin_github_name_unique" UNIQUE("github_name") +); diff --git a/src/drizzle/meta/0008_snapshot.json b/src/drizzle/meta/0008_snapshot.json index 11d4660b35..fe640efbac 100644 --- a/src/drizzle/meta/0008_snapshot.json +++ b/src/drizzle/meta/0008_snapshot.json @@ -1,5 +1,5 @@ { - "id": "8b8bad4b-1b1d-4408-843e-ba855600d1ce", + "id": "0c841877-4d4c-454e-a637-2a26584f4db8", "prevId": "a13269a4-7c7d-4a55-943d-296d433921d7", "version": "7", "dialect": "postgresql", @@ -361,39 +361,52 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.admins": { - "name": "admins", + "public.admin": { + "name": "admin", "schema": "", "columns": { - "user_name": { - "name": "user_name", - "type": "varchar(39)", + "github_id": { + "name": "github_id", + "type": "varchar(15)", "primaryKey": true, "notNull": true }, - "id": { - "name": "id", - "type": "varchar(15)", + "github_name": { + "name": "github_name", + "type": "varchar(39)", "primaryKey": false, - "notNull": false + "notNull": true }, - "last_authenticated_at": { - "name": "last_authenticated_at", + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { - "admins_user_name_unique": { - "name": "admins_user_name_unique", + "admin_github_id_unique": { + "name": "admin_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + }, + "admin_github_name_unique": { + "name": "admin_github_name_unique", "nullsNotDistinct": false, "columns": [ - "user_name" + "github_name" ] } }, diff --git a/src/drizzle/meta/_journal.json b/src/drizzle/meta/_journal.json index 8ecf44b2ac..7c232c0c7d 100644 --- a/src/drizzle/meta/_journal.json +++ b/src/drizzle/meta/_journal.json @@ -61,8 +61,8 @@ { "idx": 8, "version": "7", - "when": 1751288131091, - "tag": "0008_add-admins", + "when": 1751544905609, + "tag": "0008_add_admin", "breakpoints": true } ] diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 49c4956eec..9f4878d8d0 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -83,14 +83,14 @@ export const itemValidationStatusEnum = pgEnum('item_validation_status', [ 'pending-manual', ]); -export const adminsTable = pgTable('admins', { - // the userName is the primary key since we want to allow admins based on their github username - userName: varchar('user_name', { length: 39 }).primaryKey().notNull().unique(), - id: varchar({ length: 15 }), - lastAuthenticatedAt: timestamp('last_authenticated_at', { withTimezone: true, mode: 'string' }) - .defaultNow() - .notNull() - .$onUpdate(() => sql.raw('DEFAULT')), +export const adminsTable = pgTable('admin', { + githubId: varchar('github_id', { length: 15 }).primaryKey().unique().notNull(), + githubName: varchar('github_name', { length: 39 }).notNull().unique(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + lastAuthenticatedAt: timestamp('last_authenticated_at', { + withTimezone: true, + mode: 'string', + }), }); export const categoriesTable = pgTable( diff --git a/src/plugins/admin/README.md b/src/plugins/admin/README.md index 16d687cd01..964b2bc198 100644 --- a/src/plugins/admin/README.md +++ b/src/plugins/admin/README.md @@ -5,14 +5,16 @@ The admin plugin is responsible for providing administrative access to features ## Authentication The admins are authenticated using GitHub OAuth2 api. For how to setup an OAuth app to work with your local setup see [Local setup for OAuth App](#local-setup-for-oauth-app) -We only store the user github ID and their username, as well as their last authentication time. +We only store the user's github id and github username, as well as the last authentication time (updated each time user logs in). -To allow a user to access the administration part, they need to exist in the `admins` table. -The `admins` table has a unique constraint on the `user_name` field. +To allow a user to access the administration part, they need to exist in the `admin` table. +The `admin` table has a unique constraint on the `github_name` and `github_id` fields. ### Local setup for OAuth app -For the admin feature to work in your local development environement you need to have GitHub OAuth app credentials. See the [GitHub OAuth app documentation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) for a full walkthrough of how to set one up. +> There is currently no way to have the admin work without a valid GitHub OAuth client id and secret. This might be added in the future. + +For the admin feature to work in your local development environment you need to have GitHub OAuth app credentials. See the [GitHub OAuth app documentation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) for a full walkthrough of how to set one up. For our purpose, you will need to create an OAuth app from your GitHub account. @@ -24,23 +26,32 @@ For our purpose, you will need to create an OAuth app from your GitHub account. 1. Set the "Authorization callback URL" to `http://localhost:3000/admin/auth/github/callback` 1. Validate 1. Copy the "Client Id" and paste it in your `.env.development` file next to the `GITHUB_CLIENT_ID` -1. Generate a new client secret, copy it and paste it in your `.env.development` file next to `GITHUB_CLIENT_SECRET` (be carefull, as this value will not be shown again, you cna always re-generate it if you loose it, also DO NOT SHARE IT WITH ANYONE!) +1. Generate a new client secret, copy it and paste it in your `.env.development` file next to `GITHUB_CLIENT_SECRET` (be careful, as this value will not be shown again, you can always re-generate it if you loose it, also DO NOT SHARE IT WITH ANYONE!) With this in place, you just need to add the admins to the database following instructions in [adding admins](#adding-admins) ### Adding admins -To add a `someUser` as a new admin run the following statement: +To add a new admin you need to know their github `id`. You can get the user `id` using a call to the github API if you know their username (github handle). +To get the github id: + +```sh +curl -s https://api.github.com/users/ | jq '.id' +``` + +The number output is the unique github id for that user. + +Add the new admin by their id and username using the following statement: ```sql -insert into admins ("user_name") values ('someUser'); +insert into admins ("github_id", "github_name") values ('', ''); ``` -Replace `someUser` with the github handle of the user you want to authorize. +Replace `` and `` with the github id and the github handle of the user you want to authorize. ## Features -The Admin dashboard priovides multiple features to the admins. +The Admin dashboard provides multiple features to the admins. ### Queue Dashboard UI diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts index 33eea9b513..982a073d78 100644 --- a/src/plugins/admin/admin.plugin.ts +++ b/src/plugins/admin/admin.plugin.ts @@ -19,7 +19,7 @@ import { AdminRepository, AdminUser } from './admin.repository'; // module augmentation so the types are right when getting the admin user // this interface can be used in place of the FastifyRequest in request handlers to get correct typing when inside the admin plugin. -// we use this manual approache to not pollute the global type system with type augmentation. +// we use this manual approach to not pollute the global type system with type augmentation. interface AdminRequest extends FastifyRequest { admin?: AdminUser; } @@ -27,14 +27,19 @@ interface AdminRequest extends FastifyRequest { // name of the passport strategy const GITHUB_OAUTH_STRATEGY = 'github-admin'; -// Common error definions for this module +// Common error definiens for this module const NotAnAuthorizedAdmin = createError( 'GAERR001', 'User is not an authorized admin', StatusCodes.UNAUTHORIZED, ); -const MissingGithubUsername = createError( +const MissingGithubId = createError( 'GAERR002', + 'Response from Github is missing key `id`', + StatusCodes.BAD_REQUEST, +); +const MissingGithubUsername = createError( + 'GAERR003', 'Response from Github is missing key `username`', StatusCodes.BAD_REQUEST, ); @@ -68,15 +73,18 @@ export default async (fastify: FastifyInstance) => { callbackURL: `${PUBLIC_URL.origin}/admin/auth/github/callback`, }, async (accessToken: string, refreshToken: string, profile: GitHubProfile, done) => { - const { username } = profile; + const { username, id: githubId } = profile; + if (!githubId) { + throw new MissingGithubId(); + } if (!username) { throw new MissingGithubUsername(); } // only allow users that are present in the admin table by their username - if (await adminRepository.isAdmin(db, username)) { + if (await adminRepository.isAdmin(db, githubId)) { console.debug('user is an allowed admin'); // update info stored in the table - await adminRepository.update(db, username, { id: profile.id }); + await adminRepository.update(db, { githubId, githubName: username }); // You can add admin checks here return done(null, profile); } @@ -121,44 +129,40 @@ export default async (fastify: FastifyInstance) => { ); // login page when user is not authenticated - fastify.get('/admin/login', async (_request: AdminRequest, reply) => { + fastify.get('/admin/login', async (_request, reply) => { reply.type('text/html').send('Login with GitHub'); }); - // this redirects all unauthenticated requests to the login - // only /admin/login and /admin/auth/github should be let through to prevent redirection loops - fastify.addHook('preHandler', (request: AdminRequest, reply, done) => { - const url = request.raw.url || ''; - if ( - url.startsWith('/admin') && - !url.startsWith('/admin/login') && - !url.startsWith('/admin/auth/github') && - !request.isAuthenticated() - ) { - reply.redirect('/admin/login'); - } else { - done(); - } - }); - - // return the admin home, for the moment it is a bit bare - fastify.get('/admin', async (request: AdminRequest, reply) => { - request.log.info(request.admin); - reply.type('text/html').send( - `Hello, ${request.admin?.userName || 'admin'}! + // create a scope where if the user is not authenticated they get redirected to the login page + fastify.register(async (authenticatedAdmin) => { + // this redirects all unauthenticated requests to the login + authenticatedAdmin.addHook('preHandler', (request: AdminRequest, reply, done) => { + if (!request.isAuthenticated()) { + reply.redirect('/admin/login'); + } else { + done(); + } + }); + + // return the admin home, for the moment it is a bit bare + authenticatedAdmin.get('/admin', async (request: AdminRequest, reply) => { + request.log.info(request.admin); + reply.type('text/html').send( + `Hello, ${request.admin?.githubName || 'admin'}! Logout
Queue Dashboard `, - ); - }); + ); + }); - fastify.get('/admin/logout', async (request: AdminRequest, reply) => { - await request.logout(); - reply.redirect('/admin/login'); - }); + authenticatedAdmin.get('/admin/logout', async (request: AdminRequest, reply) => { + await request.logout(); + reply.redirect('/admin/login'); + }); - // register the queue Dashboard for BullMQ - // warning inside this module it registers the path as absolute, - // so we should beware that when moving the registration we should also update the absolute paths - fastify.register(queueDashboardPlugin); + // register the queue Dashboard for BullMQ + // warning inside this module it registers the path as absolute, + // so we should beware that when moving the registration we should also update the absolute paths + authenticatedAdmin.register(queueDashboardPlugin); + }); }; diff --git a/src/plugins/admin/admin.repository.ts b/src/plugins/admin/admin.repository.ts index 3ab9b9e852..75c30ec29d 100644 --- a/src/plugins/admin/admin.repository.ts +++ b/src/plugins/admin/admin.repository.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { DBConnection } from '../../drizzle/db'; import { adminsTable } from '../../drizzle/schema'; @@ -9,14 +9,14 @@ export type AdminUserUpdateData = typeof adminsTable.$inferInsert; export class AdminRepository { async get(dbConnection: DBConnection, adminId: string): Promise { const adminUser = await dbConnection.query.adminsTable.findFirst({ - where: eq(adminsTable.id, adminId), + where: eq(adminsTable.githubId, adminId), }); return adminUser; } - async isAdmin(dbConnection: DBConnection, userName: string): Promise { + async isAdmin(dbConnection: DBConnection, githubId: string): Promise { const admin = await dbConnection.query.adminsTable.findFirst({ - where: eq(adminsTable.userName, userName), + where: eq(adminsTable.githubId, githubId), }); if (admin) { return true; @@ -26,13 +26,12 @@ export class AdminRepository { async update( dbConnection: DBConnection, - userName: string, - data: { id: string }, + data: { githubId: string; githubName: string }, ): Promise { const admin = await dbConnection .update(adminsTable) - .set({ id: data.id }) - .where(eq(adminsTable.userName, userName)) + .set({ githubName: data.githubName, lastAuthenticatedAt: sql`now()` }) + .where(eq(adminsTable.githubId, data.githubId)) .returning(); if (!admin || !admin[0]) { throw new Error('Could not update admin info'); diff --git a/src/services/auth/plugins/passport/preHandlers.test.ts b/src/services/auth/plugins/passport/preHandlers.test.ts index 10acc12889..2add663952 100644 --- a/src/services/auth/plugins/passport/preHandlers.test.ts +++ b/src/services/auth/plugins/passport/preHandlers.test.ts @@ -14,7 +14,7 @@ import { isAuthenticated, matchOne } from './preHandlers'; // all top level routes do not use passport. // move this test closer to matchone // other prehandlers are tested in plugin.test.ts -describe.skip('matchOne', () => { +describe('matchOne', () => { let app: FastifyInstance; let member: MinimalMember; let handler: jest.Mock; From c39f6a7337b2a5194e1e78a56543809d78f60a84 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Fri, 4 Jul 2025 07:09:02 +0000 Subject: [PATCH 11/13] fix: tests --- src/app.ts | 46 ++++++------- src/plugins/admin/admin.plugin.ts | 66 ++++++++++--------- src/plugins/database.ts | 6 +- src/plugins/meta.ts | 5 +- src/plugins/swagger.ts | 7 +- .../auth/plugins/passport/plugin.test.ts | 44 +++++++++++-- .../auth/plugins/passport/preHandlers.test.ts | 49 ++++++++++++-- 7 files changed, 154 insertions(+), 69 deletions(-) diff --git a/src/app.ts b/src/app.ts index 935d747ad3..e88ed2e698 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ // should not be reimported in any other files ! import 'reflect-metadata'; +import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; import type { FastifyInstance } from 'fastify'; import fp from 'fastify-plugin'; @@ -35,29 +36,30 @@ export default async function (instance: FastifyInstance): Promise { await instance.register(adminPlugin); - // need to be defined before member and item for auth check - await instance.register(maintenancePlugin); // scope the next registration to the core functionalities - await instance.register(async (instance) => { - await instance.register(fp(passportPlugin)); - - await instance.register(fp(authPlugin)); - - // core API modules - await instance - // the websockets plugin must be registered before but in the same scope as the apis - // otherwise tests somehow bypass mocking the authentication through jest.spyOn(app, 'verifyAuthentication') - .register(fp(websocketsPlugin), { - prefix: '/ws', - redis: { - channelName: 'graasp-realtime-updates', - connection: REDIS_CONNECTION, - }, - }) - .register(fp(MemberServiceApi)) - .register(fp(ItemServiceApi)) - .register(tagPlugin); - }); + await instance.register(coreApp); } + +export const coreApp: FastifyPluginAsyncTypebox = async (instance) => { + // need to be defined before member and item for auth check + await instance.register(fp(passportPlugin)); + + await instance.register(fp(authPlugin)); + + // core API modules + await instance + // the websockets plugin must be registered before but in the same scope as the apis + // otherwise tests somehow bypass mocking the authentication through jest.spyOn(app, 'verifyAuthentication') + .register(fp(websocketsPlugin), { + prefix: '/ws', + redis: { + channelName: 'graasp-realtime-updates', + connection: REDIS_CONNECTION, + }, + }) + .register(fp(MemberServiceApi)) + .register(fp(ItemServiceApi)) + .register(tagPlugin); +}; diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts index 982a073d78..80bceeca90 100644 --- a/src/plugins/admin/admin.plugin.ts +++ b/src/plugins/admin/admin.plugin.ts @@ -5,6 +5,7 @@ import { createError } from '@fastify/error'; import { Authenticator } from '@fastify/passport'; import { fastifySecureSession } from '@fastify/secure-session'; import { FastifyInstance, FastifyRequest } from 'fastify'; +import fp from 'fastify-plugin'; import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from '../../config/admin'; import { PROD } from '../../config/env'; @@ -99,12 +100,13 @@ export default async (fastify: FastifyInstance) => { return user.id; }); adminPassport.registerUserDeserializer( - async (uuid: string, req: AdminRequest): Promise<{ admin: AdminUser | undefined }> => { - req.log.info('uuuid', uuid); + async (uuid: string, req: AdminRequest): Promise => { + req.log.info({ uuid }, 'Deserialize admin github id'); const admin = await adminRepository.get(db, uuid); + req.log.info({ admin }, 'Resolved github id to admin user'); - return { admin }; + return admin; }, ); @@ -134,35 +136,37 @@ export default async (fastify: FastifyInstance) => { }); // create a scope where if the user is not authenticated they get redirected to the login page - fastify.register(async (authenticatedAdmin) => { - // this redirects all unauthenticated requests to the login - authenticatedAdmin.addHook('preHandler', (request: AdminRequest, reply, done) => { - if (!request.isAuthenticated()) { - reply.redirect('/admin/login'); - } else { - done(); - } - }); - - // return the admin home, for the moment it is a bit bare - authenticatedAdmin.get('/admin', async (request: AdminRequest, reply) => { - request.log.info(request.admin); - reply.type('text/html').send( - `Hello, ${request.admin?.githubName || 'admin'}! + fastify.register( + fp(async (authenticatedAdmin) => { + // this redirects all unauthenticated requests to the login + authenticatedAdmin.addHook('preHandler', (request: AdminRequest, reply, done) => { + if (!request.isAuthenticated()) { + reply.redirect('/admin/login'); + } else { + done(); + } + }); + + // return the admin home, for the moment it is a bit bare + authenticatedAdmin.get('/admin', async (request: AdminRequest, reply) => { + request.log.info(request.admin); + reply.type('text/html').send( + `Hello, ${request.admin?.githubName || 'admin'}! Logout
Queue Dashboard `, - ); - }); - - authenticatedAdmin.get('/admin/logout', async (request: AdminRequest, reply) => { - await request.logout(); - reply.redirect('/admin/login'); - }); - - // register the queue Dashboard for BullMQ - // warning inside this module it registers the path as absolute, - // so we should beware that when moving the registration we should also update the absolute paths - authenticatedAdmin.register(queueDashboardPlugin); - }); + ); + }); + + authenticatedAdmin.get('/admin/logout', async (request: AdminRequest, reply) => { + await request.logout(); + reply.redirect('/admin/login'); + }); + + // register the queue Dashboard for BullMQ + // warning inside this module it registers the path as absolute, + // so we should beware that when moving the registration we should also update the absolute paths + authenticatedAdmin.register(queueDashboardPlugin); + }), + ); }; diff --git a/src/plugins/database.ts b/src/plugins/database.ts index 360b9cb49d..a05c6f885b 100644 --- a/src/plugins/database.ts +++ b/src/plugins/database.ts @@ -4,9 +4,9 @@ import type { FastifyPluginAsync } from 'fastify'; import { client } from '../drizzle/db'; -const plugin: FastifyPluginAsync = async (_fastify) => { +const databasePlugin: FastifyPluginAsync = async (_fastify) => { // connect drizzle to database await client.connect(); }; - -export default plugin; +export { databasePlugin }; +export default databasePlugin; diff --git a/src/plugins/meta.ts b/src/plugins/meta.ts index 7cb35737dd..2039ce8096 100644 --- a/src/plugins/meta.ts +++ b/src/plugins/meta.ts @@ -83,7 +83,7 @@ const health = { }, } as const satisfies FastifySchema; -const plugin: FastifyPluginAsyncTypebox = async (fastify) => { +const metaPlugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get('/health', { schema: health }, async (_, reply) => { // allow request cross origin reply.header('Access-Control-Allow-Origin', '*'); @@ -188,4 +188,5 @@ const getSearchStatusCheck = async (search: SearchService): Promise { +const openapiPlugin = async function (instance: FastifyInstance): Promise { await instance.register(swaggerPlugin, { openapi: { openapi: '3.1.0', @@ -90,4 +90,7 @@ export default async function (instance: FastifyInstance): Promise { return swaggerObject; }, }); -} +}; + +export { openapiPlugin }; +export default openapiPlugin; diff --git a/src/services/auth/plugins/passport/plugin.test.ts b/src/services/auth/plugins/passport/plugin.test.ts index 7096e10f50..169bb2fdf3 100644 --- a/src/services/auth/plugins/passport/plugin.test.ts +++ b/src/services/auth/plugins/passport/plugin.test.ts @@ -3,21 +3,28 @@ import { StatusCodes } from 'http-status-codes'; import { sign, verify } from 'jsonwebtoken'; import { v4 } from 'uuid'; -import type { FastifyInstance, PassportUser } from 'fastify'; +import { type FastifyInstance, type PassportUser, fastify } from 'fastify'; +import fp from 'fastify-plugin'; import { HttpMethod } from '@graasp/sdk'; -import build from '../../../../../test/app'; import { seedFromJson } from '../../../../../test/mocks/seed'; +import { coreApp } from '../../../../app'; import { APPS_JWT_SECRET, EMAIL_CHANGE_JWT_SECRET, JWT_SECRET, PASSWORD_RESET_JWT_SECRET, } from '../../../../config/secrets'; -import { resolveDependency } from '../../../../di/utils'; +import { registerDependencies } from '../../../../di/container'; +import { resetDependencies, resolveDependency } from '../../../../di/utils'; import { db } from '../../../../drizzle/db'; import { ItemRaw, MemberRaw } from '../../../../drizzle/types'; +import { databasePlugin } from '../../../../plugins/database'; +import { metaPlugin } from '../../../../plugins/meta'; +import { openapiPlugin } from '../../../../plugins/swagger'; +import { schemaRegisterPlugin } from '../../../../plugins/typebox'; +import ajvFormats from '../../../../schemas/ajvFormats'; import { assertIsDefined } from '../../../../utils/assertions'; import { assertIsMember, assertIsMemberForTest } from '../../../authentication'; import { expectItem } from '../../../item/test/fixtures/items'; @@ -68,7 +75,36 @@ describe('Passport Plugin', () => { let preHandler: jest.Mock; beforeAll(async () => { - ({ app } = await build()); + resetDependencies(); + app = fastify({ + disableRequestLogging: true, + logger: { + transport: { + target: 'pino-pretty', + }, + level: 'info', + }, + ajv: { + customOptions: { + coerceTypes: 'array', + discriminator: true, + allowUnionTypes: true, + }, + plugins: [ajvFormats], + }, + }); + await app.register(fp(openapiPlugin)); + await app.register(fp(schemaRegisterPlugin)); + + // db should be registered before the dependencies. + await app.register(fp(databasePlugin)); + + // register some dependencies manually + registerDependencies(app.log); + + await app.register(fp(metaPlugin)); + // register the core app + app.register(fp(coreApp)); handler = jest.fn(); preHandler = jest.fn(); diff --git a/src/services/auth/plugins/passport/preHandlers.test.ts b/src/services/auth/plugins/passport/preHandlers.test.ts index 2add663952..e824948dab 100644 --- a/src/services/auth/plugins/passport/preHandlers.test.ts +++ b/src/services/auth/plugins/passport/preHandlers.test.ts @@ -1,9 +1,19 @@ import { StatusCodes } from 'http-status-codes'; import type { FastifyInstance, PassportUser } from 'fastify'; +import Fastify from 'fastify'; +import fp from 'fastify-plugin'; -import build, { mockAuthenticate, unmockAuthenticate } from '../../../../../test/app'; +import { mockAuthenticate, unmockAuthenticate } from '../../../../../test/app'; import { seedFromJson } from '../../../../../test/mocks/seed'; +import { coreApp } from '../../../../app'; +import { registerDependencies } from '../../../../di/container'; +import { resetDependencies } from '../../../../di/utils'; +import { databasePlugin } from '../../../../plugins/database'; +import { metaPlugin } from '../../../../plugins/meta'; +import { openapiPlugin } from '../../../../plugins/swagger'; +import { schemaRegisterPlugin } from '../../../../plugins/typebox'; +import ajvFormats from '../../../../schemas/ajvFormats'; import type { MinimalMember } from '../../../../types'; import { asDefined, assertIsDefined } from '../../../../utils/assertions'; import { assertIsMember } from '../../../authentication'; @@ -12,8 +22,8 @@ import { isAuthenticated, matchOne } from './preHandlers'; // TODO: this tests fails now because passport registration has been moved to inside the core registration and we can not register a route there, // all top level routes do not use passport. -// move this test closer to matchone -// other prehandlers are tested in plugin.test.ts +// move this test closer to matchOne +// other preHandlers are tested in plugin.test.ts describe('matchOne', () => { let app: FastifyInstance; let member: MinimalMember; @@ -29,7 +39,36 @@ describe('matchOne', () => { } beforeAll(async () => { - ({ app } = await build()); + resetDependencies(); + app = Fastify({ + disableRequestLogging: true, + logger: { + transport: { + target: 'pino-pretty', + }, + level: 'info', + }, + ajv: { + customOptions: { + coerceTypes: 'array', + discriminator: true, + allowUnionTypes: true, + }, + plugins: [ajvFormats], + }, + }); + await app.register(fp(openapiPlugin)); + await app.register(fp(schemaRegisterPlugin)); + + // db should be registered before the dependencies. + await app.register(fp(databasePlugin)); + + // register some dependencies manually + registerDependencies(app.log); + + await app.register(fp(metaPlugin)); + // register the core app + app.register(fp(coreApp)); handler = jest.fn(); preHandler = jest.fn(async () => {}); @@ -60,7 +99,7 @@ describe('matchOne', () => { it('No Whitelist', async () => { handler.mockImplementation(shouldBeActor(member)); const response = await app.inject({ path: MOCKED_ROUTE }); - console.log(await response.json()); + // console.log(await response.json()); expect(handler).toHaveBeenCalledTimes(1); expect(response.statusCode).toBe(StatusCodes.OK); }); From e59111fe38984290dd571311eae659b895232f3c Mon Sep 17 00:00:00 2001 From: spaenleh Date: Tue, 8 Jul 2025 06:58:08 +0000 Subject: [PATCH 12/13] fix: remove fp --- src/plugins/admin/admin.plugin.ts | 65 +++++++++++++++---------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts index 80bceeca90..e7c8c8e050 100644 --- a/src/plugins/admin/admin.plugin.ts +++ b/src/plugins/admin/admin.plugin.ts @@ -5,7 +5,6 @@ import { createError } from '@fastify/error'; import { Authenticator } from '@fastify/passport'; import { fastifySecureSession } from '@fastify/secure-session'; import { FastifyInstance, FastifyRequest } from 'fastify'; -import fp from 'fastify-plugin'; import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from '../../config/admin'; import { PROD } from '../../config/env'; @@ -83,10 +82,9 @@ export default async (fastify: FastifyInstance) => { } // only allow users that are present in the admin table by their username if (await adminRepository.isAdmin(db, githubId)) { - console.debug('user is an allowed admin'); + console.debug(`user ${username}(${githubId}) is an allowed admin`); // update info stored in the table await adminRepository.update(db, { githubId, githubName: username }); - // You can add admin checks here return done(null, profile); } console.debug('user is not an allowed admin', profile); @@ -113,6 +111,7 @@ export default async (fastify: FastifyInstance) => { fastify.get( '/admin/auth/github', { + // this is the route that is used to start the login process for github. It relies on the strategy to do the work, the handler is empty. preValidation: adminPassport.authenticate(GITHUB_OAUTH_STRATEGY, { scope: ['user:email'] }), }, async (_request: AdminRequest, _reply) => {}, @@ -136,37 +135,37 @@ export default async (fastify: FastifyInstance) => { }); // create a scope where if the user is not authenticated they get redirected to the login page - fastify.register( - fp(async (authenticatedAdmin) => { - // this redirects all unauthenticated requests to the login - authenticatedAdmin.addHook('preHandler', (request: AdminRequest, reply, done) => { - if (!request.isAuthenticated()) { - reply.redirect('/admin/login'); - } else { - done(); - } - }); - - // return the admin home, for the moment it is a bit bare - authenticatedAdmin.get('/admin', async (request: AdminRequest, reply) => { - request.log.info(request.admin); - reply.type('text/html').send( - `Hello, ${request.admin?.githubName || 'admin'}! + fastify.register(async (authenticatedAdmin) => { + // this redirects all unauthenticated requests to the login + authenticatedAdmin.addHook('preHandler', (request: AdminRequest, reply, done) => { + if (!request.isAuthenticated()) { + reply.redirect('/admin/login'); + } else { + done(); + } + }); + + // return the admin home, for the moment it is a bit bare + authenticatedAdmin.get('/admin', async (request: AdminRequest, reply) => { + request.log.info(request.admin); + reply.type('text/html').send( + `Hello, ${request.admin?.githubName || 'admin'}! Logout
Queue Dashboard `, - ); - }); - - authenticatedAdmin.get('/admin/logout', async (request: AdminRequest, reply) => { - await request.logout(); - reply.redirect('/admin/login'); - }); - - // register the queue Dashboard for BullMQ - // warning inside this module it registers the path as absolute, - // so we should beware that when moving the registration we should also update the absolute paths - authenticatedAdmin.register(queueDashboardPlugin); - }), - ); + ); + }); + + authenticatedAdmin.get('/admin/logout', async (request: AdminRequest, reply) => { + await request.logout(); + // remove session cookie + request.session.delete(); + reply.redirect('/admin/login'); + }); + + // register the queue Dashboard for BullMQ + // warning inside this module it registers the path as absolute, + // so we should beware that when moving the registration we should also update the absolute paths + authenticatedAdmin.register(queueDashboardPlugin); + }); }; From 8972ffb48a8c14c409d31b62c74e54ed6a7c49fa Mon Sep 17 00:00:00 2001 From: spaenleh Date: Mon, 21 Jul 2025 14:12:48 +0000 Subject: [PATCH 13/13] chore(test): does not work --- package.json | 2 + src/config/db.ts | 12 ++ src/config/location.ts | 41 +++++ src/drizzle/db.ts | 2 +- src/fastify.ts | 3 +- src/plugins/admin/admin.plugin.spec.ts | 146 +++++++++++++++++ src/plugins/admin/admin.plugin.ts | 2 +- .../auth/plugins/passport/preHandlers.test.ts | 2 - src/utils/config.ts | 49 ------ yarn.lock | 148 ++++++++++++++++++ 10 files changed, 353 insertions(+), 54 deletions(-) create mode 100644 src/config/db.ts create mode 100644 src/config/location.ts create mode 100644 src/plugins/admin/admin.plugin.spec.ts diff --git a/package.json b/package.json index f6bf3251a3..06857c2c65 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "@types/passport-local": "^1", "@types/pg": "8.15.4", "@types/sanitize-html": "2.16.0", + "@types/supertest": "6.0.3", "@types/uuid": "10.0.0", "@types/ws": "8.18.1", "@types/yazl": "3.3.0", @@ -170,6 +171,7 @@ "nodemon": "3.1.9", "pino-pretty": "13.0.0", "prettier": "3.6.2", + "supertest": "7.1.3", "ts-jest": "29.4.0", "ts-node": "10.9.2", "typescript": "5.8.3", diff --git a/src/config/db.ts b/src/config/db.ts new file mode 100644 index 0000000000..aa4e9e2350 --- /dev/null +++ b/src/config/db.ts @@ -0,0 +1,12 @@ +import { getEnv } from './env'; + +getEnv(); + +///////////////////////////////////// +// Database Environement Variables // +///////////////////////////////////// +// Can be undefined, so tests can run without setting it. +export const DB_CONNECTION_POOL_SIZE: number = +process.env.DB_CONNECTION_POOL_SIZE! || 10; +export const DB_READ_REPLICA_CONNECTIONS: string[] = process.env.DB_READ_REPLICA_CONNECTIONS + ? process.env.DB_READ_REPLICA_CONNECTIONS?.split(',') + : []; diff --git a/src/config/location.ts b/src/config/location.ts new file mode 100644 index 0000000000..95ffba99ed --- /dev/null +++ b/src/config/location.ts @@ -0,0 +1,41 @@ +import { getEnv } from './env'; + +getEnv(); + +export const PROTOCOL = process.env.PROTOCOL || 'http'; +export const HOSTNAME = process.env.HOSTNAME || 'localhost'; +/** + * Host address the server listen on, default to 0.0.0.0 to bind to all addresses. + */ +export const HOST_LISTEN_ADDRESS = process.env.HOST_LISTEN_ADDRESS || '0.0.0.0'; + +export const PORT = process.env.PORT ? +process.env.PORT : 3000; +export const HOST = `${PROTOCOL}://${HOSTNAME}:${PORT}`; /** + * Public url is the url where the server is hosted. Mostly used to set the cookie on the right domain + * Warning for PUBLIC_URL: + * make sure that process.env.PUBLIC_URL / HOST have the format ${PROTOCOL}://${HOSTNAME}:${PORT} + * See the following example where the format is only ${HOSTNAME}:${PORT} in which case + * it interprets the hostname as protocol and the port as the pathname. Using the complete URL + * scheme fixes that + * + * $ node + * Welcome to Node.js v16.20.1. + * Type ".help" for more information. + * > new URL('localhost:3000') + * URL { + * href: 'localhost:3000', + * origin: 'null', + * protocol: 'localhost:', + * username: '', + * password: '', + * host: '', + * hostname: '', + * port: '', + * pathname: '3000', + * search: '', + * searchParams: URLSearchParams {}, + * hash: '' + * } + * > + */ +export const PUBLIC_URL = new URL(process.env.PUBLIC_URL ?? HOST); diff --git a/src/drizzle/db.ts b/src/drizzle/db.ts index 1eb06c0a82..c25a54c57d 100644 --- a/src/drizzle/db.ts +++ b/src/drizzle/db.ts @@ -2,7 +2,7 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { withReplicas } from 'drizzle-orm/pg-core'; import { Pool } from 'pg'; -import { DB_CONNECTION_POOL_SIZE, DB_READ_REPLICA_CONNECTIONS } from '../utils/config'; +import { DB_CONNECTION_POOL_SIZE, DB_READ_REPLICA_CONNECTIONS } from '../config/db'; import * as relations from './relations'; import * as schema from './schema'; diff --git a/src/fastify.ts b/src/fastify.ts index 8c346e0004..644f979fbb 100644 --- a/src/fastify.ts +++ b/src/fastify.ts @@ -4,10 +4,11 @@ import { fastify } from 'fastify'; import registerAppPlugins from './app'; import { DEV, NODE_ENV, PROD } from './config/env'; +import { HOST_LISTEN_ADDRESS, PORT } from './config/location'; import { client } from './drizzle/db'; import ajvFormats from './schemas/ajvFormats'; import { initSentry } from './sentry'; -import { APP_VERSION, CORS_ORIGIN_REGEX, HOST_LISTEN_ADDRESS, PORT } from './utils/config'; +import { APP_VERSION, CORS_ORIGIN_REGEX } from './utils/config'; import { GREETING } from './utils/constants'; export const instance = fastify({ diff --git a/src/plugins/admin/admin.plugin.spec.ts b/src/plugins/admin/admin.plugin.spec.ts new file mode 100644 index 0000000000..e846a0a30f --- /dev/null +++ b/src/plugins/admin/admin.plugin.spec.ts @@ -0,0 +1,146 @@ +import nock from 'nock'; +import supertest from 'supertest'; +import type TestAgent from 'supertest/lib/agent'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { FastifyInstance, fastify } from 'fastify'; + +import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from '../../config/admin'; +import { db } from '../../drizzle/db'; +import { adminsTable } from '../../drizzle/schema'; +import adminPlugin from './admin.plugin'; + +export function generateGithubId(min: number = 1000, max: number = 999999999): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +describe('GitHub OAuth', () => { + let app: FastifyInstance; + let agent: TestAgent; + + beforeEach(async () => { + app = fastify(); + app.register(adminPlugin); + await app.ready(); + agent = supertest.agent(app.server); + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should complete the normal OAuth flow', async () => { + const githubId = generateGithubId(); + const githubIdString = githubId.toString(); + // Simulate GitHub callback with code + let code = ''; + let redirectionURL = ''; + // Mock GitHub token exchange + nock('https://github.com') + .get('/login/oauth/authorize') + .query((query) => { + redirectionURL = query.redirect_uri?.toString() ?? ''; + return query.client_id === GITHUB_CLIENT_ID; + }) + .reply(302, undefined, { Location: redirectionURL }); + + // Mock GitHub token exchange + nock('https://github.com') + .post('/login/oauth/access_token', (body) => { + code = body.code; + return body.client_id === GITHUB_CLIENT_ID && body.client_secret === GITHUB_CLIENT_SECRET; + }) + .reply(200, { + access_token: 'mock-access-token', + token_type: 'bearer', + scope: 'user:email', + }); + + // Mock GitHub user API + nock('https://api.github.com').get('/user').reply(200, { + id: githubId, + login: 'testuser', + }); + + // add an admin inside the database + await db + .insert(adminsTable) + .values({ githubId: githubIdString, githubName: `testuser${githubId}` }) + .onConflictDoNothing(); + // User clicks the login link + const res1 = await agent.get('/admin/auth/github').redirects(5); + expect(res1).toMatch(/^https:\/\/github.com\/login\/oauth\/authorize/); + + nock.isDone(); + + // Callback + const res2 = await agent.get('/admin/auth/github/callback').query({ code }).redirects(4); + console.log(res2.text); + // use is authenticated + const res3 = await agent.get('/admin'); + expect(res3.status).toBe(200); + expect(res3.text).toContain('Hello, testuser'); + }); + + it('should handle user cancelling login (access_denied)', async () => { + const res = await agent + .get('/auth/github/callback') + .query({ error: 'access_denied', error_description: 'User denied access' }) + .redirects(0); + + expect(res.status).toBe(401); + expect(res.text).toBe('Login failed'); + }); + + it('should handle invalid GitHub response (missing access_token)', async () => { + const code = 'testcode123'; + + nock('https://github.com') + .post('/login/oauth/access_token') + .reply(200, { token_type: 'bearer', scope: 'user:email' }); // No access_token + + const res = await agent.get('/admin/auth/github/callback').query({ code }).redirects(0); + + expect(res.status).toBe(401); + expect(res.text).toBe('Login failed'); + }); + + it('should handle state mismatch (CSRF)', async () => { + // Simulate state mismatch by not setting session state + const code = 'testcode123'; + const state = 'wrongstate'; + + nock('https://github.com') + .post('/login/oauth/access_token') + .reply(200, { access_token: 'mock-access-token', token_type: 'bearer', scope: 'user:email' }); + + nock('https://api.github.com').get('/user').reply(200, { id: 123, login: 'testuser' }); + + // No session state set, so passport should fail + const res = await agent.get('/admin/auth/github/callback').query({ code, state }).redirects(0); + + expect(res.status).toBe(401); + expect(res.text).toBe('Login failed'); + }); + + it('should handle missing code parameter', async () => { + const res = await agent.get('/admin/auth/github/callback').redirects(2); + + expect(res.status).toBe(401); + expect(res.text).toBe('Login failed'); + }); + + it.skip('should handle GitHub server error', async () => { + const code = 'testcode123'; + + nock('https://github.com') + .post('/login/oauth/access_token') + .reply(500, { error: 'server_error' }); + + const res = await agent.get('/admin/auth/github/callback').query({ code }).redirects(0); + + expect(res.status).toBe(401); + expect(res.text).toBe('Login failed'); + }); +}); diff --git a/src/plugins/admin/admin.plugin.ts b/src/plugins/admin/admin.plugin.ts index e7c8c8e050..4ece1130a5 100644 --- a/src/plugins/admin/admin.plugin.ts +++ b/src/plugins/admin/admin.plugin.ts @@ -8,12 +8,12 @@ import { FastifyInstance, FastifyRequest } from 'fastify'; import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from '../../config/admin'; import { PROD } from '../../config/env'; +import { PUBLIC_URL } from '../../config/location'; import { ADMIN_SESSION_EXPIRATION_IN_SECONDS, ADMIN_SESSION_SECRET_KEY, } from '../../config/secrets'; import { db } from '../../drizzle/db'; -import { PUBLIC_URL } from '../../utils/config'; import { queueDashboardPlugin } from '../../workers/dashboard.controller'; import { AdminRepository, AdminUser } from './admin.repository'; diff --git a/src/services/auth/plugins/passport/preHandlers.test.ts b/src/services/auth/plugins/passport/preHandlers.test.ts index e824948dab..711871313f 100644 --- a/src/services/auth/plugins/passport/preHandlers.test.ts +++ b/src/services/auth/plugins/passport/preHandlers.test.ts @@ -20,8 +20,6 @@ import { assertIsMember } from '../../../authentication'; import { validatedMemberAccountRole } from '../../../member/strategies/validatedMemberAccountRole'; import { isAuthenticated, matchOne } from './preHandlers'; -// TODO: this tests fails now because passport registration has been moved to inside the core registration and we can not register a route there, -// all top level routes do not use passport. // move this test closer to matchOne // other preHandlers are tested in plugin.test.ts describe('matchOne', () => { diff --git a/src/utils/config.ts b/src/utils/config.ts index bc06168efa..c3ef4cc91f 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -27,16 +27,6 @@ export const ALLOWED_ORIGINS = [new URL(CLIENT_HOST).origin, new URL(LIBRARY_HOS // Add the hosts of the different clients ClientManager.getInstance().setHost(CLIENT_HOST).addHost(Context.Library, LIBRARY_HOST); -export const PROTOCOL = process.env.PROTOCOL || 'http'; -export const HOSTNAME = process.env.HOSTNAME || 'localhost'; -/** - * Host address the server listen on, default to 0.0.0.0 to bind to all addresses. - */ -export const HOST_LISTEN_ADDRESS = process.env.HOST_LISTEN_ADDRESS || '0.0.0.0'; - -export const PORT = process.env.PORT ? +process.env.PORT : 3000; -export const HOST = `${PROTOCOL}://${HOSTNAME}:${PORT}`; - if (!process.env.COOKIE_DOMAIN) { throw new Error('COOKIE_DOMAIN is undefined'); } @@ -44,36 +34,6 @@ if (!process.env.COOKIE_DOMAIN) { export const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN; export const CORS_ORIGIN_REGEX = process.env.CORS_ORIGIN_REGEX; -/** - * Public url is the url where the server is hosted. Mostly used to set the cookie on the right domain - * Warning for PUBLIC_URL: - * make sure that process.env.PUBLIC_URL / HOST have the format ${PROTOCOL}://${HOSTNAME}:${PORT} - * See the following example where the format is only ${HOSTNAME}:${PORT} in which case - * it interprets the hostname as protocol and the port as the pathname. Using the complete URL - * scheme fixes that - * - * $ node - * Welcome to Node.js v16.20.1. - * Type ".help" for more information. - * > new URL('localhost:3000') - * URL { - * href: 'localhost:3000', - * origin: 'null', - * protocol: 'localhost:', - * username: '', - * password: '', - * host: '', - * hostname: '', - * port: '', - * pathname: '3000', - * search: '', - * searchParams: URLSearchParams {}, - * hash: '' - * } - * > - */ -export const PUBLIC_URL = new URL(process.env.PUBLIC_URL ?? HOST); - /** * GRAASP FILE STORAGE CONFIG */ @@ -271,12 +231,3 @@ export const SENTRY_TRACES_SAMPLE_RATE: number = +process.env.SENTRY_TRACES_SAMP export const JEST_WORKER_ID: number = +process.env.JEST_WORKER_ID! || 1; export const CI: boolean = process.env.CI === 'true'; export const AUTO_RUN_MIGRATIONS: boolean = (process.env.AUTO_RUN_MIGRATIONS ?? 'true') === 'true'; - -///////////////////////////////////// -// Database Environement Variables // -///////////////////////////////////// -// Can be undefined, so tests can run without setting it. In production, TypeORM will throw an exception if not defined. -export const DB_CONNECTION_POOL_SIZE: number = +process.env.DB_CONNECTION_POOL_SIZE! || 10; -export const DB_READ_REPLICA_CONNECTIONS: string[] = process.env.DB_READ_REPLICA_CONNECTIONS - ? process.env.DB_READ_REPLICA_CONNECTIONS?.split(',') - : []; diff --git a/yarn.lock b/yarn.lock index 697819e65e..b7dc636ad5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3226,6 +3226,13 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:^1.1.5": + version: 1.8.0 + resolution: "@noble/hashes@npm:1.8.0" + checksum: 10/474b7f56bc6fb2d5b3a42132561e221b0ea4f91e590f4655312ca13667840896b34195e2b53b7f097ec080a1fdd3b58d902c2a8d0fbdf51d2e238b53808a177e + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -3304,6 +3311,15 @@ __metadata: languageName: node linkType: hard +"@paralleldrive/cuid2@npm:^2.2.2": + version: 2.2.2 + resolution: "@paralleldrive/cuid2@npm:2.2.2" + dependencies: + "@noble/hashes": "npm:^1.1.5" + checksum: 10/40ee269d6e47b4fed7706a2e4da7c27c3c668ebc969110d6d112277b6b16a67cce0503b53b9943f2c55035a72d225f77ea5541e03396d6429eec9252137a53b7 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -4395,6 +4411,13 @@ __metadata: languageName: node linkType: hard +"@types/cookiejar@npm:^2.1.5": + version: 2.1.5 + resolution: "@types/cookiejar@npm:2.1.5" + checksum: 10/04d5990e87b6387532d15a87d9ec9b2eb783039291193863751dcfd7fc723a3b3aa30ce4c06b03975cba58632e933772f1ff031af23eaa3ac7f94e71afa6e073 + languageName: node + linkType: hard + "@types/deep-eql@npm:*": version: 4.0.2 resolution: "@types/deep-eql@npm:4.0.2" @@ -4577,6 +4600,13 @@ __metadata: languageName: node linkType: hard +"@types/methods@npm:^1.1.4": + version: 1.1.4 + resolution: "@types/methods@npm:1.1.4" + checksum: 10/ad2a7178486f2fd167750f3eb920ab032a947ff2e26f55c86670a6038632d790b46f52e5b6ead5823f1e53fc68028f1e9ddd15cfead7903e04517c88debd72b1 + languageName: node + linkType: hard + "@types/mime@npm:3.0.4": version: 3.0.4 resolution: "@types/mime@npm:3.0.4" @@ -4826,6 +4856,28 @@ __metadata: languageName: node linkType: hard +"@types/superagent@npm:^8.1.0": + version: 8.1.9 + resolution: "@types/superagent@npm:8.1.9" + dependencies: + "@types/cookiejar": "npm:^2.1.5" + "@types/methods": "npm:^1.1.4" + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: 10/6d9687b0bc3d693b900ef76000b02437a70879c3219b28606879c086d786bb1e48429813e72e32dd0aafc94c053a78a2aa8be67c45bc8e6b968ca62d6d5cc554 + languageName: node + linkType: hard + +"@types/supertest@npm:6.0.3": + version: 6.0.3 + resolution: "@types/supertest@npm:6.0.3" + dependencies: + "@types/methods": "npm:^1.1.4" + "@types/superagent": "npm:^8.1.0" + checksum: 10/6ec05eb591c97bc856b0e78c12f5bec10545f3a749688f34232d189797a506d971bc95931718eb57b378d8513f6d2d12462383e6d68455fa72df35c19de6e89e + languageName: node + linkType: hard + "@types/uuid@npm:10.0.0": version: 10.0.0 resolution: "@types/uuid@npm:10.0.0" @@ -5606,6 +5658,13 @@ __metadata: languageName: node linkType: hard +"asap@npm:^2.0.0": + version: 2.0.6 + resolution: "asap@npm:2.0.6" + checksum: 10/b244c0458c571945e4b3be0b14eb001bea5596f9868cc50cc711dc03d58a7e953517d3f0dad81ccde3ff37d1f074701fa76a6f07d41aaa992d7204a37b915dda + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -6291,6 +6350,13 @@ __metadata: languageName: node linkType: hard +"component-emitter@npm:^1.3.0": + version: 1.3.1 + resolution: "component-emitter@npm:1.3.1" + checksum: 10/94550aa462c7bd5a61c1bc480e28554aa306066930152d1b1844a0dd3845d4e5db7e261ddec62ae184913b3e59b55a2ad84093b9d3596a8f17c341514d6c483d + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -6401,6 +6467,13 @@ __metadata: languageName: node linkType: hard +"cookiejar@npm:^2.1.4": + version: 2.1.4 + resolution: "cookiejar@npm:2.1.4" + checksum: 10/4a184f5a0591df8b07d22a43ea5d020eacb4572c383e853a33361a99710437eaa0971716c688684075bbf695b484f5872e9e3f562382e46858716cb7fc8ce3f4 + languageName: node + linkType: hard + "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -6773,6 +6846,16 @@ __metadata: languageName: node linkType: hard +"dezalgo@npm:^1.0.4": + version: 1.0.4 + resolution: "dezalgo@npm:1.0.4" + dependencies: + asap: "npm:^2.0.0" + wrappy: "npm:1" + checksum: 10/895389c6aead740d2ab5da4d3466d20fa30f738010a4d3f4dcccc9fc645ca31c9d10b7e1804ae489b1eb02c7986f9f1f34ba132d409b043082a86d9a4e745624 + languageName: node + linkType: hard + "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -8279,6 +8362,17 @@ __metadata: languageName: node linkType: hard +"formidable@npm:^3.5.4": + version: 3.5.4 + resolution: "formidable@npm:3.5.4" + dependencies: + "@paralleldrive/cuid2": "npm:^2.2.2" + dezalgo: "npm:^1.0.4" + once: "npm:^1.4.0" + checksum: 10/4645e6ce3d8bbefd3dd873dcd6211362da3bf8a04c8426d7f454c238be0142975f02e5bdbc792fdbd2be493fdcf5442fe01d9a246bd8c3fd8e779738290cc630 + languageName: node + linkType: hard + "fs-extra@npm:11.3.0, fs-extra@npm:^11.0.0": version: 11.3.0 resolution: "fs-extra@npm:11.3.0" @@ -8707,6 +8801,7 @@ __metadata: "@types/passport-local": "npm:^1" "@types/pg": "npm:8.15.4" "@types/sanitize-html": "npm:2.16.0" + "@types/supertest": "npm:6.0.3" "@types/uuid": "npm:10.0.0" "@types/ws": "npm:8.18.1" "@types/yazl": "npm:3.3.0" @@ -8769,6 +8864,7 @@ __metadata: secure-json-parse: "npm:2.7.0" sharp: "npm:0.33.5" striptags: "npm:3.2.0" + supertest: "npm:7.1.3" tmp-promise: "npm:3.0.3" ts-jest: "npm:29.4.0" ts-node: "npm:10.9.2" @@ -10895,6 +10991,13 @@ __metadata: languageName: node linkType: hard +"methods@npm:^1.1.2": + version: 1.1.2 + resolution: "methods@npm:1.1.2" + checksum: 10/a385dd974faa34b5dd021b2bbf78c722881bf6f003bfe6d391d7da3ea1ed625d1ff10ddd13c57531f628b3e785be38d3eed10ad03cebd90b76932413df9a1820 + languageName: node + linkType: hard + "micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" @@ -10921,6 +11024,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:2.6.0": + version: 2.6.0 + resolution: "mime@npm:2.6.0" + bin: + mime: cli.js + checksum: 10/7da117808b5cd0203bb1b5e33445c330fe213f4d8ee2402a84d62adbde9716ca4fb90dd6d9ab4e77a4128c6c5c24a9c4c9f6a4d720b095b1b342132d02dba58d + languageName: node + linkType: hard + "mime@npm:3.0.0, mime@npm:^3": version: 3.0.0 resolution: "mime@npm:3.0.0" @@ -12393,6 +12505,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.11.0": + version: 6.14.0 + resolution: "qs@npm:6.14.0" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10/a60e49bbd51c935a8a4759e7505677b122e23bf392d6535b8fc31c1e447acba2c901235ecb192764013cd2781723dc1f61978b5fdd93cc31d7043d31cdc01974 + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -13696,6 +13817,33 @@ __metadata: languageName: node linkType: hard +"superagent@npm:^10.2.2": + version: 10.2.2 + resolution: "superagent@npm:10.2.2" + dependencies: + component-emitter: "npm:^1.3.0" + cookiejar: "npm:^2.1.4" + debug: "npm:^4.3.4" + fast-safe-stringify: "npm:^2.1.1" + form-data: "npm:^4.0.0" + formidable: "npm:^3.5.4" + methods: "npm:^1.1.2" + mime: "npm:2.6.0" + qs: "npm:^6.11.0" + checksum: 10/e89ae49163df0db50e6a77316a7304a16640df11a8d2219bef11e69f59c74e54c16670ec250c2ab59f06887f71bb5d6e5933f735b931cbdbe34ba490d78b5d70 + languageName: node + linkType: hard + +"supertest@npm:7.1.3": + version: 7.1.3 + resolution: "supertest@npm:7.1.3" + dependencies: + methods: "npm:^1.1.2" + superagent: "npm:^10.2.2" + checksum: 10/d148d05ed52e2cd487c483aae8721bf83f2611eb630d847b1cb37b3f1b09c499aa6b6446d3739c1db2459df1668bf089870437f71e57fc39c7194f51bdac6119 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0, supports-color@npm:^5.5.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0"