feat: move perf,logs and user into sqlite

This commit is contained in:
max_richter 2025-01-05 18:03:59 +01:00
parent 1937ef55bb
commit bf7d88a588
25 changed files with 524 additions and 145 deletions

1
.gitignore vendored
View File

@ -6,5 +6,6 @@
.env.local
data/
data-dev/
_fresh/
node_modules/

View File

@ -14,3 +14,9 @@ deno task start
```
This will watch the project directory and restart as necessary.
## FIX
```
sed -i -e 's/"deno"/"no-deno"/' node_modules/@libsql/client/package.json
```

View File

@ -3,7 +3,8 @@
"nodeModulesDir": "auto",
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"start": "deno run --env-file -A --watch=static/,routes/ dev.ts",
"db": "deno run --env-file -A npm:drizzle-kit",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
@ -25,11 +26,13 @@
"@islands/": "./islands/",
"@lib": "./lib",
"@lib/": "./lib/",
"@libsql/client": "npm:@libsql/client@^0.14.0",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"@std/dotenv": "jsr:@std/dotenv@^0.225.3",
"@std/http": "jsr:@std/http@^1.0.12",
"@std/yaml": "jsr:@std/yaml@^1.0.5",
"drizzle-kit": "npm:drizzle-kit@^0.30.1",
"drizzle-orm": "npm:drizzle-orm@^0.38.3",
"preact": "https://esm.sh/preact@10.22.0",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.2",
"preact/": "https://esm.sh/preact@10.22.0/",
@ -38,9 +41,12 @@
"tailwindcss/": "npm:/tailwindcss@^3.4.17/",
"tailwindcss/plugin": "npm:/tailwindcss@^3.4.17/plugin.js",
"camelcase-css": "npm:camelcase-css",
"tsx": "npm:tsx@^4.19.2",
"typesense": "https://raw.githubusercontent.com/bradenmacdonald/typesense-deno/main/mod.ts",
"yaml": "https://deno.land/std@0.197.0/yaml/mod.ts",
"zod": "https://deno.land/x/zod@v3.21.4/mod.ts"
"zod": "https://deno.land/x/zod@v3.21.4/mod.ts",
"fs": "https://deno.land/std/fs/mod.ts",
"imagemagick": "https://deno.land/x/imagemagick_deno@0.0.31/mod.ts"
},
"scopes": {
"https://deno.land/x/emoji/": {

18
drizzle.config.ts Normal file
View File

@ -0,0 +1,18 @@
import path from "node:path";
import { defineConfig } from "drizzle-kit";
const DATA_DIR = Deno.env.has("DATA_DIR")
? path.resolve(Deno.env.get("DATA_DIR")!)
: path.resolve(Deno.cwd(), "data");
// const DB_FILE = "file://" + path.resolve(DATA_DIR, "db.sqlite");
const DB_FILE = "file:data-dev/db.sqlite";
export default defineConfig({
out: "./drizzle",
schema: "./lib/sqlite/schema.ts",
dialect: "turso",
dbCredentials: {
url: DB_FILE,
},
});

View File

@ -0,0 +1,20 @@
CREATE TABLE `performance` (
`path` text NOT NULL,
`search` text,
`time` integer NOT NULL,
`created_at` integer DEFAULT (current_timestamp)
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer DEFAULT (current_timestamp),
`expires_at` integer NOT NULL,
`user_id` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer DEFAULT (current_timestamp) NOT NULL,
`email` text NOT NULL,
`name` text NOT NULL
);

View File

@ -0,0 +1 @@
ALTER TABLE `performance` ALTER COLUMN "created_at" TO "created_at" integer DEFAULT (STRFTIME('%s', 'now') * 1000);

View File

@ -0,0 +1,135 @@
{
"version": "6",
"dialect": "sqlite",
"id": "49f64916-b687-40c5-a37f-de031c1641f1",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"performance": {
"name": "performance",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"search": {
"name": "search",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,135 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3890bdbe-0c06-4619-a35f-e1380ac8f42f",
"prevId": "49f64916-b687-40c5-a37f-de031c1641f1",
"tables": {
"performance": {
"name": "performance",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"search": {
"name": "search",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(STRFTIME('%s', 'now') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1736093081790,
"tag": "0000_dashing_sunspot",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1736093851600,
"tag": "0001_classy_justin_hammer",
"breakpoints": true
}
]
}

37
lib/cache/logs.ts vendored
View File

@ -1,37 +0,0 @@
import { createLogger } from "@lib/log.ts";
import * as cache from "@lib/cache/cache.ts";
import { getTimeCacheKey, parseTimeCacheKey } from "@lib/string.ts";
const log = createLogger("");
log.addEventListener("log", (data) => {
cache.set(`log:${getTimeCacheKey()}`, JSON.stringify(data.detail), {
noLog: true,
});
});
export type Log = {
scope: string;
level: number;
date: Date;
args: unknown[];
};
export async function getLogs() {
const d = new Date();
const year = d.getFullYear();
const month = d.getMonth().toString();
const day = d.getDate().toString();
const keys = await cache.keys(
`log:${year}:${month}:${day}:*`,
);
const logs = await Promise.all(
keys.map(async (key) => {
const date = parseTimeCacheKey(key);
return { ...JSON.parse(await cache.get<string>(key)), date } as Log;
}),
);
return logs.sort((a, b) => a.date.getTime() > b.date.getTime() ? -1 : 1);
}

View File

@ -1,18 +0,0 @@
import z from "https://deno.land/x/zod@v3.21.4/index.ts";
import { createSchema } from "@lib/db/createSchema.ts";
const UserSchema = z.object({
id: z.string().optional().default(() => crypto.randomUUID()),
createdAt: z.date().default(() => new Date()),
email: z.string().email(),
name: z.string(),
});
export const userDB = createSchema("user", UserSchema);
const SessionSchema = z.object({
id: z.string().default(() => crypto.randomUUID()),
createdAt: z.date().default(() => new Date()),
expiresAt: z.date().default(() => new Date()),
userId: z.string(),
});
export const sessionDB = createSchema("session", SessionSchema);

View File

@ -1,32 +0,0 @@
import { z } from "zod";
import * as cache from "@lib/cache/cache.ts";
export function createSchema<T extends z.ZodSchema>(name: string, schema: T) {
type Data = z.infer<T>;
return {
async create(input: Omit<Data, "id">): Promise<Data> {
const data = schema.safeParse(input);
if (data.success) {
const d = data.data;
const id = d["id"];
if (!id) return d;
await cache.set(`${name}:${id}`, JSON.stringify(d));
return d;
}
return null;
},
async findAll(): Promise<Data[]> {
const keys = await cache.keys(`${name}:*`);
return Promise.all(keys.map((k) => {
return cache.get<string>(k);
})).then((values) => values.map((v) => JSON.parse(v || "null")));
},
async find(id: string) {
const k = await cache.get<string>(`${name}:${id}`);
return JSON.parse(k || "null") as Data | null;
},
delete(id: string) {
return cache.del(`${name}:${id}`);
},
};
}

View File

@ -1,4 +1,4 @@
import "@std/dotenv/load";
import path from "node:path";
export const SILVERBULLET_SERVER = Deno.env.get("SILVERBULLET_SERVER");
export const REDIS_HOST = Deno.env.get("REDIS_HOST");
@ -8,7 +8,10 @@ export const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
export const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY");
export const GITEA_SERVER = Deno.env.get("GITEA_SERVER");
export const GITEA_CLIENT_ID = Deno.env.get("GITEA_CLIENT_ID");
export const GITEA_CLIENT_ID = Deno.env.get("GITEA_CLIENT_ID")!;
if (!GITEA_CLIENT_ID) {
throw new Error("GITEA_CLIENT_ID is required");
}
export const GITEA_CLIENT_SECRET = Deno.env.get("GITEA_CLIENT_SECRET");
export const GITEA_REDIRECT_URL = Deno.env.get("GITEA_REDIRECT_URL");
@ -23,5 +26,9 @@ export const TYPESENSE_URL = Deno.env.get("TYPESENSE_URL") ||
"http://localhost:8108";
export const TYPESENSE_API_KEY = Deno.env.get("TYPESENSE_API_KEY");
export const DATA_DIR = Deno.env.has("DATA_DIR")
? path.resolve(Deno.env.get("DATA_DIR")!)
: path.resolve(Deno.cwd(), "data");
export const LOG_LEVEL: string = Deno.env.get("LOG_LEVEL") ||
"warn";

View File

@ -1,30 +1,30 @@
import { MiddlewareHandlerContext } from "$fresh/server.ts";
import { FreshContext } from "$fresh/server.ts";
class DomainError extends Error {
status = 500;
render?: (ctx: MiddlewareHandlerContext) => void;
render?: (ctx: FreshContext) => void;
constructor(public statusText = "Internal Server Error") {
super();
}
}
class NotFoundError extends DomainError {
status = 404;
constructor(public statusText = "Not Found") {
override status = 404;
constructor(public override statusText = "Not Found") {
super();
}
}
class BadRequestError extends DomainError {
status = 400;
constructor(public statusText = "Bad Request") {
override status = 400;
constructor(public override statusText = "Bad Request") {
super();
}
}
class AccessDeniedError extends DomainError {
status = 403;
constructor(public statusText = "Access Denied") {
override status = 403;
constructor(public override statusText = "Access Denied") {
super();
}
}

View File

@ -3,7 +3,7 @@ import { useEffect, useRef } from "preact/hooks";
export function useEventListener<T extends Event>(
eventName: string,
handler: (event: T) => void,
element: Window | HTMLElement = globalThis,
element: typeof globalThis | HTMLElement = globalThis,
) {
// Create a ref that stores handler
const savedHandler = useRef<(event: Event) => void>();

View File

@ -23,9 +23,9 @@ const logFuncs = {
} as const;
let longestScope = 0;
let logLevel = (_LOG_LEVEL && _LOG_LEVEL in logMap && logMap[_LOG_LEVEL]) ??
LOG_LEVEL.WARN;
let logLevel = _LOG_LEVEL && _LOG_LEVEL in logMap && _LOG_LEVEL in logMap
? logMap[_LOG_LEVEL]
: LOG_LEVEL.WARN;
const ee = new EventEmitter<{
log: { level: LOG_LEVEL; scope: string; args: unknown[] };
}>();

66
lib/logs.ts Normal file
View File

@ -0,0 +1,66 @@
import { createLogger } from "@lib/log.ts";
import { join } from "node:path";
import { DATA_DIR } from "@lib/env.ts";
import { ensureDir } from "fs";
function getLogFileName() {
const d = new Date();
const year = d.getFullYear();
const month = (d.getMonth() + 1).toString().padStart(2, "0"); // Ensure two digits
const day = d.getDate().toString().padStart(2, "0"); // Ensure two digits
return `${year}-${month}-${day}.log`;
}
const LOG_DIR = join(DATA_DIR, "logs");
// Ensure the log directory exists
await ensureDir(LOG_DIR);
const log = createLogger("");
log.addEventListener("log", async (data) => {
const logEntry = JSON.stringify(data.detail);
const logFilePath = join(LOG_DIR, getLogFileName());
// Append the log entry to the file (creating it if it doesn't exist)
await Deno.writeTextFile(
logFilePath,
new Date().toISOString() + " | " + logEntry + "\n",
{ append: true },
);
});
export type Log = {
scope: string;
level: number;
date: Date;
args: unknown[];
};
export async function getLogs() {
const logFilePath = join(LOG_DIR, getLogFileName());
try {
// Read the log file content
const logFileContent = await Deno.readTextFile(logFilePath);
// Split by lines and parse logs
const logs: Log[] = logFileContent
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => {
const [date, ...rest] = line.split(" | ");
const parsed = JSON.parse(rest.join(" | ")) as Log;
return {
...parsed,
date: new Date(date),
} as Log;
});
console.log(logs);
// Return the logs sorted by date
return logs.sort((a, b) => a.date.getTime() - b.date.getTime());
} catch (error) {
// If file does not exist, return an empty array
return [];
}
}

View File

@ -1,5 +1,6 @@
import * as cache from "@lib/cache/cache.ts";
import { getTimeCacheKey } from "@lib/string.ts";
import { db } from "@lib/sqlite/sqlite.ts";
import { performanceTable } from "@lib/sqlite/schema.ts";
import { between } from "drizzle-orm/sql";
export type PerformancePoint = {
path: string;
@ -16,40 +17,29 @@ export type PerformanceRes = {
}[];
};
export const savePerformance = (url: string, milliseconds: number) => {
const cacheKey = getTimeCacheKey();
export const savePerformance = async (url: string, seconds: number) => {
const u = new URL(url);
if (u.pathname.includes("_frsh/")) return;
u.searchParams.delete("__frsh_c");
cache.set(
`performance:${cacheKey}`,
JSON.stringify({
console.log("Saving performance", u.pathname, u.search, seconds);
const res = await db.insert(performanceTable).values({
path: decodeURIComponent(u.pathname),
search: u.search,
time: Math.floor(milliseconds * 1000),
}),
);
time: Math.floor(seconds * 1000),
});
console.log({ res });
};
export async function getPerformances(): Promise<PerformanceRes> {
const d = new Date();
const year = d.getFullYear();
const month = d.getMonth().toString();
const day = d.getDay().toString();
const now = new Date();
const startOfDay = new Date(now.setHours(0, 0, 0, 0));
const endOfDay = new Date(now.setHours(23, 59, 59, 999));
const keys = await cache.keys(
`performance:${year}:${month}:${day}:*`,
);
console.log(`performance:${year}:${month}:${day}:*`);
const performances = await Promise.all(
keys.map(async (key) =>
JSON.parse(await cache.get<string>(key)) as PerformancePoint
),
const performances = await db.select().from(performanceTable).where(
between(performanceTable.createdAt, startOfDay, endOfDay),
);
console.log({ performances });
let maximum = 0;

45
lib/sqlite/schema.ts Normal file
View File

@ -0,0 +1,45 @@
import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm/sql";
export const userTable = sqliteTable("user", {
id: text()
.primaryKey(),
createdAt: integer("created_at", { mode: "timestamp" })
.default(sql`(current_timestamp)`)
.notNull(),
email: text()
.notNull(),
name: text()
.notNull(),
});
export const sessionTable = sqliteTable("session", {
id: text("id")
.primaryKey(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(
sql`(current_timestamp)`,
),
expiresAt: integer("expires_at", { mode: "timestamp" })
.notNull(),
userId: text("user_id")
.notNull(),
});
export const performanceTable = sqliteTable("performance", {
path: text().notNull(),
search: text(),
time: int().notNull(),
createdAt: integer("created_at", {
mode: "timestamp_ms",
}).default(sql`(STRFTIME('%s', 'now') * 1000)`),
});
export const imageTable = sqliteTable("image", {
createdAt: integer("created_at", { mode: "timestamp" }).default(
sql`(current_timestamp)`,
),
path: text().notNull(),
average: text().notNull(),
blurhash: text().notNull(),
mime: text().notNull(),
});

14
lib/sqlite/sqlite.ts Normal file
View File

@ -0,0 +1,14 @@
import { drizzle } from "drizzle-orm/libsql/node";
import { DATA_DIR } from "@lib/env.ts";
import path from "node:path";
// const DB_FILE = "file://" + path.resolve(DATA_DIR, "db.sqlite");
const DB_FILE = "file:data-dev/db.sqlite";
// You can specify any property from the libsql connection options
export const db = drizzle({
logger: true,
connection: {
url: DB_FILE,
},
});

View File

@ -4,8 +4,6 @@
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
import "@std/dotenv/load";
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";

View File

@ -1,14 +1,14 @@
//routes/middleware-error-handler/_middleware.ts
import { MiddlewareHandlerContext } from "$fresh/server.ts";
import { FreshContext } from "$fresh/server.ts";
import { DomainError } from "@lib/errors.ts";
import { getCookies } from "@std/http/cookie";
import { verify } from "https://deno.land/x/djwt@v2.2/mod.ts";
import * as cache from "@lib/cache/performance.ts";
import * as perf from "@lib/performance.ts";
import { JWT_SECRET } from "@lib/env.ts";
export async function handler(
req: Request,
ctx: MiddlewareHandlerContext,
ctx: FreshContext,
) {
try {
performance.mark("a");
@ -29,7 +29,7 @@ export async function handler(
const resp = await ctx.next();
performance.mark("b");
const b = performance.measure("a->b", "a", "b");
cache.savePerformance(req.url, b.duration);
perf.savePerformance(req.url, b.duration);
return resp;
} catch (error) {
console.error("Error", error);

View File

@ -1,7 +1,7 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { Handlers, PageProps } from "$fresh/server.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { getLogs, Log } from "@lib/cache/logs.ts";
import { getLogs, Log } from "@lib/logs.ts";
import { formatDate } from "@lib/string.ts";
import { renderMarkdown } from "@lib/documents.ts";

View File

@ -1,6 +1,6 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getPerformances, PerformanceRes } from "@lib/cache/performance.ts";
import { getPerformances, PerformanceRes } from "@lib/performance.ts";
import { AccessDeniedError } from "@lib/errors.ts";
export const handler: Handlers = {

View File

@ -4,9 +4,11 @@ import { oauth2Client } from "@lib/auth.ts";
import { getCookies, setCookie } from "@std/http/cookie";
import { codeChallengeMap } from "./login.ts";
import { GITEA_SERVER, JWT_SECRET, SESSION_DURATION } from "@lib/env.ts";
import { userDB } from "@lib/db.ts";
import { GiteaOauthUser } from "@lib/types.ts";
import { BadRequestError } from "@lib/errors.ts";
import { db } from "@lib/sqlite/sqlite.ts";
import { userTable } from "@lib/sqlite/schema.ts";
import { eq } from "drizzle-orm";
export const handler: Handlers = {
async GET(request) {
@ -38,15 +40,17 @@ export const handler: Handlers = {
const oauthUser = await userResponse.json() as GiteaOauthUser;
const allUsers = await userDB.findAll();
let user = allUsers.find((u) => u.name === oauthUser.name);
let user = await db.select().from(userTable).where(
eq(userTable.name, oauthUser.name),
).limit(1).then((users) => users[0]);
if (!user) {
user = await userDB.create({
createdAt: new Date(),
const res = await db.insert(userTable).values({
id: crypto.randomUUID(),
email: oauthUser.email,
name: oauthUser.name,
});
}).returning();
user = res[0];
}
const jwt = await create({ alg: "HS512", type: "JWT" }, {