mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
Controllers: New validation + auto OpenAPI (#173)
* updated controllers to use custom json schema and added auto openapi specs * fix data routes parsing body * added schema exports to core * added swagger link to Admin, switched use-search
This commit is contained in:
18
app/build.ts
18
app/build.ts
@@ -1,5 +1,6 @@
|
|||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
|
import pkg from "./package.json" with { type: "json" };
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const watch = args.includes("--watch");
|
const watch = args.includes("--watch");
|
||||||
@@ -9,7 +10,7 @@ const sourcemap = args.includes("--sourcemap");
|
|||||||
const clean = args.includes("--clean");
|
const clean = args.includes("--clean");
|
||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
console.log("Cleaning dist (w/o static)");
|
console.info("Cleaning dist (w/o static)");
|
||||||
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,11 +22,11 @@ function buildTypes() {
|
|||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types built");
|
console.info("Types built");
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types aliased");
|
console.info("Types aliased");
|
||||||
types_running = false;
|
types_running = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -47,10 +48,10 @@ if (types && !watch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function banner(title: string) {
|
function banner(title: string) {
|
||||||
console.log("");
|
console.info("");
|
||||||
console.log("=".repeat(40));
|
console.info("=".repeat(40));
|
||||||
console.log(title.toUpperCase());
|
console.info(title.toUpperCase());
|
||||||
console.log("-".repeat(40));
|
console.info("-".repeat(40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
@@ -65,6 +66,9 @@ async function buildApi() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
|
define: {
|
||||||
|
__version: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
entry: [
|
entry: [
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
"src/core/index.ts",
|
"src/core/index.ts",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"@hono/swagger-ui": "^0.5.1",
|
||||||
"@libsql/client": "^0.15.2",
|
"@libsql/client": "^0.15.2",
|
||||||
"@mantine/core": "^7.17.1",
|
"@mantine/core": "^7.17.1",
|
||||||
"@mantine/hooks": "^7.17.1",
|
"@mantine/hooks": "^7.17.1",
|
||||||
@@ -64,7 +65,6 @@
|
|||||||
"json-schema-form-react": "^0.0.2",
|
"json-schema-form-react": "^0.0.2",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.0.11",
|
|
||||||
"kysely": "^0.27.6",
|
"kysely": "^0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
@@ -99,6 +99,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
|
"jsonv-ts": "^0.0.14-alpha.6",
|
||||||
"kysely-d1": "^0.3.0",
|
"kysely-d1": "^0.3.0",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
||||||
import { tbValidator as tb } from "core";
|
|
||||||
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
||||||
import { DataPermissions } from "data";
|
import { DataPermissions } from "data";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { Controller, type ServerEnv } from "modules/Controller";
|
import { Controller, type ServerEnv } from "modules/Controller";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import { describeRoute, jsc, s } from "core/object/schema";
|
||||||
const { Type } = tbbox;
|
|
||||||
|
|
||||||
export type AuthActionResponse = {
|
export type AuthActionResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -14,10 +12,6 @@ export type AuthActionResponse = {
|
|||||||
errors?: any;
|
errors?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
const booleanLike = Type.Transform(Type.String())
|
|
||||||
.Decode((v) => v === "1")
|
|
||||||
.Encode((v) => (v ? "1" : "0"));
|
|
||||||
|
|
||||||
export class AuthController extends Controller {
|
export class AuthController extends Controller {
|
||||||
constructor(private auth: AppAuth) {
|
constructor(private auth: AppAuth) {
|
||||||
super();
|
super();
|
||||||
@@ -56,6 +50,10 @@ export class AuthController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/create",
|
"/create",
|
||||||
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
||||||
|
describeRoute({
|
||||||
|
summary: "Create a new user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await this.auth.authenticator.getBody(c);
|
const body = await this.auth.authenticator.getBody(c);
|
||||||
@@ -93,9 +91,16 @@ export class AuthController extends Controller {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
hono.get("create/schema.json", async (c) => {
|
hono.get(
|
||||||
return c.json(create.schema);
|
"create/schema.json",
|
||||||
});
|
describeRoute({
|
||||||
|
summary: "Get the schema for creating a user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(create.schema);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainHono.route(`/${name}/actions`, hono);
|
mainHono.route(`/${name}/actions`, hono);
|
||||||
@@ -104,42 +109,54 @@ export class AuthController extends Controller {
|
|||||||
override getController() {
|
override getController() {
|
||||||
const { auth } = this.middlewares;
|
const { auth } = this.middlewares;
|
||||||
const hono = this.create();
|
const hono = this.create();
|
||||||
const strategies = this.auth.authenticator.getStrategies();
|
|
||||||
|
|
||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
hono.get(
|
||||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
"/me",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Get the current user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
auth(),
|
||||||
|
async (c) => {
|
||||||
|
const claims = c.get("auth")?.user;
|
||||||
|
if (claims) {
|
||||||
|
const { data: user } = await this.userRepo.findId(claims.id);
|
||||||
|
return c.json({ user });
|
||||||
|
}
|
||||||
|
|
||||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
return c.json({ user: null }, 403);
|
||||||
this.registerStrategyActions(strategy, hono);
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
hono.get("/me", auth(), async (c) => {
|
hono.get(
|
||||||
const claims = c.get("auth")?.user;
|
"/logout",
|
||||||
if (claims) {
|
describeRoute({
|
||||||
const { data: user } = await this.userRepo.findId(claims.id);
|
summary: "Logout the current user",
|
||||||
return c.json({ user });
|
tags: ["auth"],
|
||||||
}
|
}),
|
||||||
|
auth(),
|
||||||
|
async (c) => {
|
||||||
|
await this.auth.authenticator.logout(c);
|
||||||
|
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||||
|
return c.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ user: null }, 403);
|
const referer = c.req.header("referer");
|
||||||
});
|
if (referer) {
|
||||||
|
return c.redirect(referer);
|
||||||
|
}
|
||||||
|
|
||||||
hono.get("/logout", auth(), async (c) => {
|
return c.redirect("/");
|
||||||
await this.auth.authenticator.logout(c);
|
},
|
||||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
);
|
||||||
return c.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const referer = c.req.header("referer");
|
|
||||||
if (referer) {
|
|
||||||
return c.redirect(referer);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.redirect("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/strategies",
|
"/strategies",
|
||||||
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
|
describeRoute({
|
||||||
|
summary: "Get the available authentication strategies",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { include_disabled } = c.req.valid("query");
|
const { include_disabled } = c.req.valid("query");
|
||||||
const { strategies, basepath } = this.auth.toJSON(false);
|
const { strategies, basepath } = this.auth.toJSON(false);
|
||||||
@@ -157,6 +174,15 @@ export class AuthController extends Controller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const strategies = this.auth.authenticator.getStrategies();
|
||||||
|
|
||||||
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
|
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
||||||
|
|
||||||
|
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||||
|
this.registerStrategyActions(strategy, hono);
|
||||||
|
}
|
||||||
|
|
||||||
return hono.all("*", (c) => c.notFound());
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export function isDebug(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVersion(): string {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error - this is a global variable in dev
|
||||||
|
return __version;
|
||||||
|
} catch (e) {
|
||||||
|
return "0.0.0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const envs = {
|
const envs = {
|
||||||
// used in $console to determine the log level
|
// used in $console to determine the log level
|
||||||
cli_log_level: {
|
cli_log_level: {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export {
|
|||||||
} from "./object/query/query";
|
} from "./object/query/query";
|
||||||
export { Registry, type Constructor } from "./registry/Registry";
|
export { Registry, type Constructor } from "./registry/Registry";
|
||||||
export { getFlashMessage } from "./server/flash";
|
export { getFlashMessage } from "./server/flash";
|
||||||
|
export { s, jsc, describeRoute } from "./object/schema";
|
||||||
|
|
||||||
export * from "./console";
|
export * from "./console";
|
||||||
export * from "./events";
|
export * from "./events";
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { mergeObject } from "core/utils";
|
import { mergeObject } from "core/utils";
|
||||||
|
|
||||||
export { jsc, type Options, type Hook } from "./validator";
|
//export { jsc, type Options, type Hook } from "./validator";
|
||||||
import * as s from "jsonv-ts";
|
import * as s from "jsonv-ts";
|
||||||
|
|
||||||
|
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
||||||
|
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
|
||||||
|
|
||||||
export { s };
|
export { s };
|
||||||
|
|
||||||
export class InvalidSchemaError extends Error {
|
export class InvalidSchemaError extends Error {
|
||||||
@@ -21,6 +24,12 @@ export class InvalidSchemaError extends Error {
|
|||||||
export type ParseOptions = {
|
export type ParseOptions = {
|
||||||
withDefaults?: boolean;
|
withDefaults?: boolean;
|
||||||
coerse?: boolean;
|
coerse?: boolean;
|
||||||
|
clone?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneSchema = <S extends s.TSchema>(schema: S): S => {
|
||||||
|
const json = schema.toJSON();
|
||||||
|
return s.fromSchema(json) as S;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parse<S extends s.TAnySchema>(
|
export function parse<S extends s.TAnySchema>(
|
||||||
@@ -28,7 +37,7 @@ export function parse<S extends s.TAnySchema>(
|
|||||||
v: unknown,
|
v: unknown,
|
||||||
opts: ParseOptions = {},
|
opts: ParseOptions = {},
|
||||||
): s.StaticCoerced<S> {
|
): s.StaticCoerced<S> {
|
||||||
const schema = _schema as unknown as s.TSchema;
|
const schema = (opts.clone ? cloneSchema(_schema as any) : _schema) as s.TSchema;
|
||||||
const value = opts.coerse !== false ? schema.coerce(v) : v;
|
const value = opts.coerse !== false ? schema.coerce(v) : v;
|
||||||
const result = schema.validate(value, {
|
const result = schema.validate(value, {
|
||||||
shortCircuit: true,
|
shortCircuit: true,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { $console, isDebug, tbValidator as tb } from "core";
|
import { $console, isDebug } from "core";
|
||||||
import { StringEnum } from "core/utils";
|
|
||||||
import * as tbbox from "@sinclair/typebox";
|
|
||||||
import {
|
import {
|
||||||
DataPermissions,
|
DataPermissions,
|
||||||
type EntityData,
|
type EntityData,
|
||||||
@@ -13,10 +11,10 @@ import {
|
|||||||
import type { Handler } from "hono/types";
|
import type { Handler } from "hono/types";
|
||||||
import type { ModuleBuildContext } from "modules";
|
import type { ModuleBuildContext } from "modules";
|
||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import { jsc, s } from "core/object/schema";
|
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import type { AppDataConfig } from "../data-schema";
|
import type { AppDataConfig } from "../data-schema";
|
||||||
const { Type } = tbbox;
|
import { omitKeys } from "core/utils";
|
||||||
|
|
||||||
export class DataController extends Controller {
|
export class DataController extends Controller {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -72,6 +70,7 @@ export class DataController extends Controller {
|
|||||||
override getController() {
|
override getController() {
|
||||||
const { permission, auth } = this.middlewares;
|
const { permission, auth } = this.middlewares;
|
||||||
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
|
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
|
||||||
|
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||||
|
|
||||||
// @todo: sample implementation how to augment handler with additional info
|
// @todo: sample implementation how to augment handler with additional info
|
||||||
function handler<HH extends Handler>(name: string, h: HH): any {
|
function handler<HH extends Handler>(name: string, h: HH): any {
|
||||||
@@ -84,6 +83,10 @@ export class DataController extends Controller {
|
|||||||
// info
|
// info
|
||||||
hono.get(
|
hono.get(
|
||||||
"/",
|
"/",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Retrieve data configuration",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
handler("data info", (c) => {
|
handler("data info", (c) => {
|
||||||
// sample implementation
|
// sample implementation
|
||||||
return c.json(this.em.toJSON());
|
return c.json(this.em.toJSON());
|
||||||
@@ -91,49 +94,75 @@ export class DataController extends Controller {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// sync endpoint
|
// sync endpoint
|
||||||
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
|
hono.get(
|
||||||
const force = c.req.query("force") === "1";
|
"/sync",
|
||||||
const drop = c.req.query("drop") === "1";
|
permission(DataPermissions.databaseSync),
|
||||||
//console.log("force", force);
|
describeRoute({
|
||||||
const tables = await this.em.schema().introspect();
|
summary: "Sync database schema",
|
||||||
//console.log("tables", tables);
|
tags: ["data"],
|
||||||
const changes = await this.em.schema().sync({
|
}),
|
||||||
force,
|
jsc(
|
||||||
drop,
|
"query",
|
||||||
});
|
s.partialObject({
|
||||||
return c.json({ tables: tables.map((t) => t.name), changes });
|
force: s.boolean(),
|
||||||
});
|
drop: s.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { force, drop } = c.req.valid("query");
|
||||||
|
//console.log("force", force);
|
||||||
|
const tables = await this.em.schema().introspect();
|
||||||
|
//console.log("tables", tables);
|
||||||
|
const changes = await this.em.schema().sync({
|
||||||
|
force,
|
||||||
|
drop,
|
||||||
|
});
|
||||||
|
return c.json({ tables: tables.map((t) => t.name), changes });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema endpoints
|
* Schema endpoints
|
||||||
*/
|
*/
|
||||||
// read entity schema
|
// read entity schema
|
||||||
hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
|
hono.get(
|
||||||
const $id = `${this.config.basepath}/schema.json`;
|
"/schema.json",
|
||||||
const schemas = Object.fromEntries(
|
permission(DataPermissions.entityRead),
|
||||||
this.em.entities.map((e) => [
|
describeRoute({
|
||||||
e.name,
|
summary: "Retrieve data schema",
|
||||||
{
|
tags: ["data"],
|
||||||
$ref: `${this.config.basepath}/schemas/${e.name}`,
|
}),
|
||||||
},
|
async (c) => {
|
||||||
]),
|
const $id = `${this.config.basepath}/schema.json`;
|
||||||
);
|
const schemas = Object.fromEntries(
|
||||||
return c.json({
|
this.em.entities.map((e) => [
|
||||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
e.name,
|
||||||
$id,
|
{
|
||||||
properties: schemas,
|
$ref: `${this.config.basepath}/schemas/${e.name}`,
|
||||||
});
|
},
|
||||||
});
|
]),
|
||||||
|
);
|
||||||
|
return c.json({
|
||||||
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
$id,
|
||||||
|
properties: schemas,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// read schema
|
// read schema
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schemas/:entity/:context?",
|
"/schemas/:entity/:context?",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
tb(
|
describeRoute({
|
||||||
|
summary: "Retrieve entity schema",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
|
jsc(
|
||||||
"param",
|
"param",
|
||||||
Type.Object({
|
s.object({
|
||||||
entity: Type.String(),
|
entity: entitiesEnum,
|
||||||
context: Type.Optional(StringEnum(["create", "update"])),
|
context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -162,30 +191,39 @@ export class DataController extends Controller {
|
|||||||
/**
|
/**
|
||||||
* Info endpoints
|
* Info endpoints
|
||||||
*/
|
*/
|
||||||
hono.get("/info/:entity", async (c) => {
|
hono.get(
|
||||||
const { entity } = c.req.param();
|
"/info/:entity",
|
||||||
if (!this.entityExists(entity)) {
|
permission(DataPermissions.entityRead),
|
||||||
return this.notFound(c);
|
describeRoute({
|
||||||
}
|
summary: "Retrieve entity info",
|
||||||
const _entity = this.em.entity(entity);
|
tags: ["data"],
|
||||||
const fields = _entity.fields.map((f) => f.name);
|
}),
|
||||||
const $rels = (r: any) =>
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
r.map((r: any) => ({
|
async (c) => {
|
||||||
entity: r.other(_entity).entity.name,
|
const { entity } = c.req.param();
|
||||||
ref: r.other(_entity).reference,
|
if (!this.entityExists(entity)) {
|
||||||
}));
|
return this.notFound(c);
|
||||||
|
}
|
||||||
|
const _entity = this.em.entity(entity);
|
||||||
|
const fields = _entity.fields.map((f) => f.name);
|
||||||
|
const $rels = (r: any) =>
|
||||||
|
r.map((r: any) => ({
|
||||||
|
entity: r.other(_entity).entity.name,
|
||||||
|
ref: r.other(_entity).reference,
|
||||||
|
}));
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
name: _entity.name,
|
name: _entity.name,
|
||||||
fields,
|
fields,
|
||||||
relations: {
|
relations: {
|
||||||
all: $rels(this.em.relations.relationsOf(_entity)),
|
all: $rels(this.em.relations.relationsOf(_entity)),
|
||||||
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
|
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
|
||||||
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
|
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
|
||||||
target: $rels(this.em.relations.targetRelationsOf(_entity)),
|
target: $rels(this.em.relations.targetRelationsOf(_entity)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return hono.all("*", (c) => c.notFound());
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
@@ -194,10 +232,7 @@ export class DataController extends Controller {
|
|||||||
const { permission } = this.middlewares;
|
const { permission } = this.middlewares;
|
||||||
const hono = this.create();
|
const hono = this.create();
|
||||||
|
|
||||||
const definedEntities = this.em.entities.map((e) => e.name);
|
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||||
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
|
|
||||||
.Decode(Number.parseInt)
|
|
||||||
.Encode(String);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function endpoints
|
* Function endpoints
|
||||||
@@ -206,14 +241,19 @@ export class DataController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/:entity/fn/count",
|
"/:entity/fn/count",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc("param", s.object({ entity: s.string() })),
|
describeRoute({
|
||||||
|
summary: "Count entities",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
|
jsc("json", repoQuery.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = (await c.req.json()) as any;
|
const where = c.req.valid("json") as any;
|
||||||
const result = await this.em.repository(entity).count(where);
|
const result = await this.em.repository(entity).count(where);
|
||||||
return c.json({ entity, count: result.count });
|
return c.json({ entity, count: result.count });
|
||||||
},
|
},
|
||||||
@@ -223,14 +263,19 @@ export class DataController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/:entity/fn/exists",
|
"/:entity/fn/exists",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc("param", s.object({ entity: s.string() })),
|
describeRoute({
|
||||||
|
summary: "Check if entity exists",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
|
jsc("json", repoQuery.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = c.req.json() as any;
|
const where = c.req.valid("json") as any;
|
||||||
const result = await this.em.repository(entity).exists(where);
|
const result = await this.em.repository(entity).exists(where);
|
||||||
return c.json({ entity, exists: result.exists });
|
return c.json({ entity, exists: result.exists });
|
||||||
},
|
},
|
||||||
@@ -240,11 +285,29 @@ export class DataController extends Controller {
|
|||||||
* Read endpoints
|
* Read endpoints
|
||||||
*/
|
*/
|
||||||
// read many
|
// read many
|
||||||
|
const saveRepoQuery = s.partialObject({
|
||||||
|
...omitKeys(repoQuery.properties, ["with"]),
|
||||||
|
sort: s.string({ default: "id" }),
|
||||||
|
select: s.array(s.string()),
|
||||||
|
join: s.array(s.string()),
|
||||||
|
});
|
||||||
|
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
|
||||||
|
...(schemaToSpec(saveRepoQuery, "query").parameters?.filter(
|
||||||
|
// @ts-ignore
|
||||||
|
(p) => pick.includes(p.name),
|
||||||
|
) as any),
|
||||||
|
];
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Read many",
|
||||||
|
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc("param", s.object({ entity: s.string() })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("query", repoQuery),
|
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
@@ -260,15 +323,20 @@ export class DataController extends Controller {
|
|||||||
// read one
|
// read one
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Read one",
|
||||||
|
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc(
|
jsc(
|
||||||
"param",
|
"param",
|
||||||
s.object({
|
s.object({
|
||||||
entity: s.string(),
|
entity: entitiesEnum,
|
||||||
id: s.string(),
|
id: s.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
jsc("query", repoQuery),
|
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
@@ -284,16 +352,21 @@ export class DataController extends Controller {
|
|||||||
// read many by reference
|
// read many by reference
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:entity/:id/:reference",
|
"/:entity/:id/:reference",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Read many by reference",
|
||||||
|
parameters: saveRepoQueryParams(),
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc(
|
jsc(
|
||||||
"param",
|
"param",
|
||||||
s.object({
|
s.object({
|
||||||
entity: s.string(),
|
entity: entitiesEnum,
|
||||||
id: s.string(),
|
id: s.string(),
|
||||||
reference: s.string(),
|
reference: s.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
jsc("query", repoQuery),
|
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id, reference } = c.req.valid("param");
|
const { entity, id, reference } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
@@ -310,17 +383,33 @@ export class DataController extends Controller {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// func query
|
// func query
|
||||||
|
const fnQuery = s.partialObject({
|
||||||
|
...saveRepoQuery.properties,
|
||||||
|
with: s.object({}),
|
||||||
|
});
|
||||||
hono.post(
|
hono.post(
|
||||||
"/:entity/query",
|
"/:entity/query",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Query entities",
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: fnQuery.toJSON(),
|
||||||
|
example: fnQuery.template({ withOptional: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc("param", s.object({ entity: s.string() })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", repoQuery),
|
jsc("json", repoQuery, { skipOpenAPI: true }),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const options = (await c.req.valid("json")) as RepoQuery;
|
const options = (await c.req.json()) as RepoQuery;
|
||||||
const result = await this.em.repository(entity).findMany(options);
|
const result = await this.em.repository(entity).findMany(options);
|
||||||
|
|
||||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||||
@@ -333,10 +422,13 @@ export class DataController extends Controller {
|
|||||||
// insert one
|
// insert one
|
||||||
hono.post(
|
hono.post(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Insert one or many",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityCreate),
|
permission(DataPermissions.entityCreate),
|
||||||
jsc("param", s.object({ entity: s.string() })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
||||||
//tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
@@ -357,8 +449,12 @@ export class DataController extends Controller {
|
|||||||
// update many
|
// update many
|
||||||
hono.patch(
|
hono.patch(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Update many",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate),
|
||||||
jsc("param", s.object({ entity: s.string() })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc(
|
jsc(
|
||||||
"json",
|
"json",
|
||||||
s.object({
|
s.object({
|
||||||
@@ -384,8 +480,13 @@ export class DataController extends Controller {
|
|||||||
// update one
|
// update one
|
||||||
hono.patch(
|
hono.patch(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Update one",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate),
|
||||||
jsc("param", s.object({ entity: s.string(), id: s.number() })),
|
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
|
||||||
|
jsc("json", s.object({})),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
@@ -401,8 +502,12 @@ export class DataController extends Controller {
|
|||||||
// delete one
|
// delete one
|
||||||
hono.delete(
|
hono.delete(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Delete one",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete),
|
||||||
jsc("param", s.object({ entity: s.string(), id: s.number() })),
|
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
@@ -417,15 +522,19 @@ export class DataController extends Controller {
|
|||||||
// delete many
|
// delete many
|
||||||
hono.delete(
|
hono.delete(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Delete many",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete),
|
||||||
jsc("param", s.object({ entity: s.string() })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", repoQuery.properties.where),
|
jsc("json", repoQuery.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const where = c.req.valid("json") as RepoQuery["where"];
|
const where = (await c.req.json()) as RepoQuery["where"];
|
||||||
const result = await this.em.mutator(entity).deleteWhere(where);
|
const result = await this.em.mutator(entity).deleteWhere(where);
|
||||||
|
|
||||||
return c.json(this.mutatorResult(result));
|
return c.json(this.mutatorResult(result));
|
||||||
|
|||||||
@@ -10,18 +10,6 @@ const stringIdentifier = s.string({
|
|||||||
// allow "id", "id,title" – but not "id," or "not allowed"
|
// allow "id", "id,title" – but not "id," or "not allowed"
|
||||||
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
|
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
|
||||||
});
|
});
|
||||||
const numberOrString = <N extends s.UnionSchema>(c: N = {} as N) =>
|
|
||||||
s.anyOf([s.number(), s.string()], {
|
|
||||||
...c,
|
|
||||||
coerse: function (this: s.TSchema, v): number {
|
|
||||||
if (typeof v === "string") {
|
|
||||||
const n = Number.parseInt(v);
|
|
||||||
if (Number.isNaN(n)) return this.default ?? 10;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
return v as number;
|
|
||||||
},
|
|
||||||
}) as unknown as s.TSchemaInOut<number | string, number>;
|
|
||||||
const stringArray = s.anyOf(
|
const stringArray = s.anyOf(
|
||||||
[
|
[
|
||||||
stringIdentifier,
|
stringIdentifier,
|
||||||
@@ -75,6 +63,13 @@ const sort = s.anyOf([s.string(), sortSchema], {
|
|||||||
// filter
|
// filter
|
||||||
const where = s.anyOf([s.string(), s.object({})], {
|
const where = s.anyOf([s.string(), s.object({})], {
|
||||||
default: {},
|
default: {},
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
attribute: {
|
||||||
|
$eq: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
coerce: (value: unknown) => {
|
coerce: (value: unknown) => {
|
||||||
const q = typeof value === "string" ? JSON.parse(value) : value;
|
const q = typeof value === "string" ? JSON.parse(value) : value;
|
||||||
return WhereBuilder.convert(q);
|
return WhereBuilder.convert(q);
|
||||||
@@ -132,8 +127,8 @@ const withSchema = <In, Out = In>(self: s.TSchema): s.TSchemaInOut<In, Out> =>
|
|||||||
// REPO QUERY
|
// REPO QUERY
|
||||||
export const repoQuery = s.recursive((self) =>
|
export const repoQuery = s.recursive((self) =>
|
||||||
s.partialObject({
|
s.partialObject({
|
||||||
limit: numberOrString({ default: 10 }),
|
limit: s.number({ default: 10 }),
|
||||||
offset: numberOrString({ default: 0 }),
|
offset: s.number({ default: 0 }),
|
||||||
sort,
|
sort,
|
||||||
where,
|
where,
|
||||||
select: stringArray,
|
select: stringArray,
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import { DataPermissions } from "data";
|
|||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import type { AppMedia } from "../AppMedia";
|
import type { AppMedia } from "../AppMedia";
|
||||||
import { MediaField } from "../MediaField";
|
import { MediaField } from "../MediaField";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import { jsc, s, describeRoute } from "core/object/schema";
|
||||||
const { Type } = tbbox;
|
|
||||||
|
|
||||||
const booleanLike = Type.Transform(Type.String())
|
|
||||||
.Decode((v) => v === "1")
|
|
||||||
.Encode((v) => (v ? "1" : "0"));
|
|
||||||
|
|
||||||
export class MediaController extends Controller {
|
export class MediaController extends Controller {
|
||||||
constructor(private readonly media: AppMedia) {
|
constructor(private readonly media: AppMedia) {
|
||||||
@@ -31,90 +26,165 @@ export class MediaController extends Controller {
|
|||||||
// @todo: implement range requests
|
// @todo: implement range requests
|
||||||
const { auth, permission } = this.middlewares;
|
const { auth, permission } = this.middlewares;
|
||||||
const hono = this.create().use(auth());
|
const hono = this.create().use(auth());
|
||||||
|
const entitiesEnum = this.getEntitiesEnum(this.media.em);
|
||||||
|
|
||||||
// get files list (temporary)
|
// get files list (temporary)
|
||||||
hono.get("/files", permission(MediaPermissions.listFiles), async (c) => {
|
hono.get(
|
||||||
const files = await this.getStorageAdapter().listObjects();
|
"/files",
|
||||||
return c.json(files);
|
describeRoute({
|
||||||
});
|
summary: "Get the list of files",
|
||||||
|
tags: ["media"],
|
||||||
|
}),
|
||||||
|
permission(MediaPermissions.listFiles),
|
||||||
|
async (c) => {
|
||||||
|
const files = await this.getStorageAdapter().listObjects();
|
||||||
|
return c.json(files);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// get file by name
|
// get file by name
|
||||||
// @todo: implement more aggressive cache? (configurable)
|
// @todo: implement more aggressive cache? (configurable)
|
||||||
hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => {
|
hono.get(
|
||||||
const { filename } = c.req.param();
|
"/file/:filename",
|
||||||
if (!filename) {
|
describeRoute({
|
||||||
throw new Error("No file name provided");
|
summary: "Get a file by name",
|
||||||
}
|
tags: ["media"],
|
||||||
|
}),
|
||||||
|
permission(MediaPermissions.readFile),
|
||||||
|
async (c) => {
|
||||||
|
const { filename } = c.req.param();
|
||||||
|
if (!filename) {
|
||||||
|
throw new Error("No file name provided");
|
||||||
|
}
|
||||||
|
|
||||||
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
|
await this.getStorage().emgr.emit(
|
||||||
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
|
new StorageEvents.FileAccessEvent({ name: filename }),
|
||||||
|
);
|
||||||
|
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
|
||||||
|
|
||||||
const headers = new Headers(res.headers);
|
const headers = new Headers(res.headers);
|
||||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
|
||||||
return new Response(res.body, {
|
return new Response(res.body, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// delete a file by name
|
// delete a file by name
|
||||||
hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => {
|
hono.delete(
|
||||||
const { filename } = c.req.param();
|
"/file/:filename",
|
||||||
if (!filename) {
|
describeRoute({
|
||||||
throw new Error("No file name provided");
|
summary: "Delete a file by name",
|
||||||
}
|
tags: ["media"],
|
||||||
await this.getStorage().deleteFile(filename);
|
}),
|
||||||
|
permission(MediaPermissions.deleteFile),
|
||||||
|
async (c) => {
|
||||||
|
const { filename } = c.req.param();
|
||||||
|
if (!filename) {
|
||||||
|
throw new Error("No file name provided");
|
||||||
|
}
|
||||||
|
await this.getStorage().deleteFile(filename);
|
||||||
|
|
||||||
return c.json({ message: "File deleted" });
|
return c.json({ message: "File deleted" });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
|
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
if (isDebug()) {
|
if (isDebug()) {
|
||||||
hono.post("/inspect", async (c) => {
|
hono.post(
|
||||||
const file = await getFileFromContext(c);
|
"/inspect",
|
||||||
return c.json({
|
describeRoute({
|
||||||
type: file?.type,
|
summary: "Inspect a file",
|
||||||
name: file?.name,
|
tags: ["media"],
|
||||||
size: file?.size,
|
}),
|
||||||
});
|
async (c) => {
|
||||||
});
|
const file = await getFileFromContext(c);
|
||||||
|
return c.json({
|
||||||
|
type: file?.type,
|
||||||
|
name: file?.name,
|
||||||
|
size: file?.size,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
file: {
|
||||||
|
type: "string",
|
||||||
|
format: "binary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["file"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"application/octet-stream": {
|
||||||
|
schema: {
|
||||||
|
type: "string",
|
||||||
|
format: "binary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
// upload file
|
// upload file
|
||||||
// @todo: add required type for "upload endpoints"
|
// @todo: add required type for "upload endpoints"
|
||||||
hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => {
|
hono.post(
|
||||||
const reqname = c.req.param("filename");
|
"/upload/:filename?",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Upload a file",
|
||||||
|
tags: ["media"],
|
||||||
|
requestBody,
|
||||||
|
}),
|
||||||
|
jsc("param", s.object({ filename: s.string().optional() })),
|
||||||
|
permission(MediaPermissions.uploadFile),
|
||||||
|
async (c) => {
|
||||||
|
const reqname = c.req.param("filename");
|
||||||
|
|
||||||
const body = await getFileFromContext(c);
|
const body = await getFileFromContext(c);
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
|
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
if (body.size > maxSize) {
|
if (body.size > maxSize) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: `Max size (${maxSize} bytes) exceeded` },
|
{ error: `Max size (${maxSize} bytes) exceeded` },
|
||||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = reqname ?? getRandomizedFilename(body as File);
|
const filename = reqname ?? getRandomizedFilename(body as File);
|
||||||
const res = await this.getStorage().uploadFile(body, filename);
|
const res = await this.getStorage().uploadFile(body, filename);
|
||||||
|
|
||||||
return c.json(res, HttpStatus.CREATED);
|
return c.json(res, HttpStatus.CREATED);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// add upload file to entity
|
// add upload file to entity
|
||||||
// @todo: add required type for "upload endpoints"
|
// @todo: add required type for "upload endpoints"
|
||||||
hono.post(
|
hono.post(
|
||||||
"/entity/:entity/:id/:field",
|
"/entity/:entity/:id/:field",
|
||||||
tb(
|
describeRoute({
|
||||||
"query",
|
summary: "Add a file to an entity",
|
||||||
Type.Object({
|
tags: ["media"],
|
||||||
overwrite: Type.Optional(booleanLike),
|
requestBody,
|
||||||
|
}),
|
||||||
|
jsc(
|
||||||
|
"param",
|
||||||
|
s.object({
|
||||||
|
entity: entitiesEnum,
|
||||||
|
id: s.number(),
|
||||||
|
field: s.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||||
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const entity_name = c.req.param("entity");
|
const entity_name = c.req.param("entity");
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { App } from "App";
|
|||||||
import { type Context, type Env, Hono } from "hono";
|
import { type Context, type Env, Hono } from "hono";
|
||||||
import * as middlewares from "modules/middlewares";
|
import * as middlewares from "modules/middlewares";
|
||||||
import type { SafeUser } from "auth";
|
import type { SafeUser } from "auth";
|
||||||
|
import type { EntityManager } from "data";
|
||||||
|
import { s } from "core/object/schema";
|
||||||
|
|
||||||
export type ServerEnv = Env & {
|
export type ServerEnv = Env & {
|
||||||
Variables: {
|
Variables: {
|
||||||
@@ -46,4 +48,9 @@ export class Controller {
|
|||||||
|
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getEntitiesEnum(em: EntityManager<any>) {
|
||||||
|
const entities = em.entities.map((e) => e.name);
|
||||||
|
return entities.length > 0 ? s.string({ enum: entities }) : s.string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import {
|
|||||||
import { getRuntimeKey } from "core/utils";
|
import { getRuntimeKey } from "core/utils";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import { openAPISpecs } from "jsonv-ts/hono";
|
||||||
const { Type } = tbbox;
|
import { swaggerUI } from "@hono/swagger-ui";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MODULE_NAMES,
|
MODULE_NAMES,
|
||||||
type ModuleConfigs,
|
type ModuleConfigs,
|
||||||
@@ -24,12 +23,8 @@ import {
|
|||||||
getDefaultConfig,
|
getDefaultConfig,
|
||||||
} from "modules/ModuleManager";
|
} from "modules/ModuleManager";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { generateOpenAPI } from "modules/server/openapi";
|
import { jsc, s, describeRoute } from "core/object/schema";
|
||||||
|
import { getVersion } from "core/env";
|
||||||
const booleanLike = Type.Transform(Type.String())
|
|
||||||
.Decode((v) => v === "1")
|
|
||||||
.Encode((v) => (v ? "1" : "0"));
|
|
||||||
|
|
||||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||||
success: true;
|
success: true;
|
||||||
module: Key;
|
module: Key;
|
||||||
@@ -61,20 +56,27 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.use(permission(SystemPermissions.configRead));
|
hono.use(permission(SystemPermissions.configRead));
|
||||||
|
|
||||||
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
|
hono.get(
|
||||||
// @ts-expect-error "fetch" is private
|
"/raw",
|
||||||
return c.json(await this.app.modules.fetch());
|
describeRoute({
|
||||||
});
|
summary: "Get the raw config",
|
||||||
|
tags: ["system"],
|
||||||
|
}),
|
||||||
|
permission([SystemPermissions.configReadSecrets]),
|
||||||
|
async (c) => {
|
||||||
|
// @ts-expect-error "fetch" is private
|
||||||
|
return c.json(await this.app.modules.fetch());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:module?",
|
"/:module?",
|
||||||
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
|
describeRoute({
|
||||||
tb(
|
summary: "Get the config for a module",
|
||||||
"query",
|
tags: ["system"],
|
||||||
Type.Object({
|
}),
|
||||||
secrets: Type.Optional(booleanLike),
|
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
|
||||||
}),
|
jsc("query", s.object({ secrets: s.boolean().optional() })),
|
||||||
),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
// @todo: allow secrets if authenticated user is admin
|
// @todo: allow secrets if authenticated user is admin
|
||||||
const { secrets } = c.req.valid("query");
|
const { secrets } = c.req.valid("query");
|
||||||
@@ -119,12 +121,7 @@ export class SystemController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/set/:module",
|
"/set/:module",
|
||||||
permission(SystemPermissions.configWrite),
|
permission(SystemPermissions.configWrite),
|
||||||
tb(
|
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
|
||||||
"query",
|
|
||||||
Type.Object({
|
|
||||||
force: Type.Optional(booleanLike),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const module = c.req.param("module") as any;
|
const module = c.req.param("module") as any;
|
||||||
const { force } = c.req.valid("query");
|
const { force } = c.req.valid("query");
|
||||||
@@ -230,13 +227,17 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schema/:module?",
|
"/schema/:module?",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Get the schema for a module",
|
||||||
|
tags: ["system"],
|
||||||
|
}),
|
||||||
permission(SystemPermissions.schemaRead),
|
permission(SystemPermissions.schemaRead),
|
||||||
tb(
|
jsc(
|
||||||
"query",
|
"query",
|
||||||
Type.Object({
|
s.partialObject({
|
||||||
config: Type.Optional(booleanLike),
|
config: s.boolean(),
|
||||||
secrets: Type.Optional(booleanLike),
|
secrets: s.boolean(),
|
||||||
fresh: Type.Optional(booleanLike),
|
fresh: s.boolean(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -274,13 +275,11 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.post(
|
hono.post(
|
||||||
"/build",
|
"/build",
|
||||||
tb(
|
describeRoute({
|
||||||
"query",
|
summary: "Build the app",
|
||||||
Type.Object({
|
tags: ["system"],
|
||||||
sync: Type.Optional(booleanLike),
|
}),
|
||||||
fetch: Type.Optional(booleanLike),
|
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const options = c.req.valid("query") as Record<string, boolean>;
|
const options = c.req.valid("query") as Record<string, boolean>;
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
||||||
@@ -293,25 +292,44 @@ export class SystemController extends Controller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
hono.get("/ping", (c) => c.json({ pong: true }));
|
hono.get(
|
||||||
|
"/ping",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Ping the server",
|
||||||
|
tags: ["system"],
|
||||||
|
}),
|
||||||
|
(c) => c.json({ pong: true }),
|
||||||
|
);
|
||||||
|
|
||||||
hono.get("/info", (c) =>
|
hono.get(
|
||||||
c.json({
|
"/info",
|
||||||
version: c.get("app")?.version(),
|
describeRoute({
|
||||||
runtime: getRuntimeKey(),
|
summary: "Get the server info",
|
||||||
timezone: {
|
tags: ["system"],
|
||||||
name: getTimezone(),
|
}),
|
||||||
offset: getTimezoneOffset(),
|
(c) =>
|
||||||
local: datetimeStringLocal(),
|
c.json({
|
||||||
utc: datetimeStringUTC(),
|
version: c.get("app")?.version(),
|
||||||
|
runtime: getRuntimeKey(),
|
||||||
|
timezone: {
|
||||||
|
name: getTimezone(),
|
||||||
|
offset: getTimezoneOffset(),
|
||||||
|
local: datetimeStringLocal(),
|
||||||
|
utc: datetimeStringUTC(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
hono.get(
|
||||||
|
"/openapi.json",
|
||||||
|
openAPISpecs(this.ctx.server, {
|
||||||
|
info: {
|
||||||
|
title: "bknd API",
|
||||||
|
version: getVersion(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
|
||||||
hono.get("/openapi.json", async (c) => {
|
|
||||||
const config = getDefaultConfig();
|
|
||||||
return c.json(generateOpenAPI(config));
|
|
||||||
});
|
|
||||||
|
|
||||||
return hono.all("*", (c) => c.notFound());
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
|
|||||||
) {
|
) {
|
||||||
const searchString = useWouterSearch();
|
const searchString = useWouterSearch();
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
let value = (defaultValue ? parse(schema, defaultValue as any) : {}) as s.StaticCoerced<Schema>;
|
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
|
||||||
|
const value = parse(schema, initial, {
|
||||||
if (searchString.length > 0) {
|
withDefaults: true,
|
||||||
value = parse(schema, decodeSearch(searchString));
|
clone: true,
|
||||||
//console.log("search:decode", value);
|
}) as s.StaticCoerced<Schema>;
|
||||||
}
|
|
||||||
|
|
||||||
// @todo: add option to set multiple keys at once
|
// @todo: add option to set multiple keys at once
|
||||||
function set<Key extends keyof s.StaticCoerced<Schema>>(
|
function set<Key extends keyof s.StaticCoerced<Schema>>(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||||
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
import { IconApi, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
TbDatabase,
|
TbDatabase,
|
||||||
TbFingerprint,
|
TbFingerprint,
|
||||||
@@ -159,6 +159,11 @@ function UserMenu() {
|
|||||||
|
|
||||||
const items: DropdownItem[] = [
|
const items: DropdownItem[] = [
|
||||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
|
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
|
||||||
|
{
|
||||||
|
label: "OpenAPI",
|
||||||
|
onClick: () => window.open("/api/system/swagger", "_blank"),
|
||||||
|
icon: IconApi,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.auth.enabled) {
|
if (config.auth.enabled) {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { defineConfig } from "vite";
|
|||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
import { devServerConfig } from "./src/adapter/vite/dev-server-config";
|
import { devServerConfig } from "./src/adapter/vite/dev-server-config";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import pkg from "./package.json" with { type: "json" };
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
define: {
|
define: {
|
||||||
__isDev: process.env.NODE_ENV === "production" ? "0" : "1",
|
__isDev: process.env.NODE_ENV === "production" ? "0" : "1",
|
||||||
|
__version: JSON.stringify(pkg.version),
|
||||||
},
|
},
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
publicDir: "./src/ui/assets",
|
publicDir: "./src/ui/assets",
|
||||||
|
|||||||
7
bun.lock
7
bun.lock
@@ -34,6 +34,7 @@
|
|||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"@hono/swagger-ui": "^0.5.1",
|
||||||
"@libsql/client": "^0.15.2",
|
"@libsql/client": "^0.15.2",
|
||||||
"@mantine/core": "^7.17.1",
|
"@mantine/core": "^7.17.1",
|
||||||
"@mantine/hooks": "^7.17.1",
|
"@mantine/hooks": "^7.17.1",
|
||||||
@@ -49,7 +50,6 @@
|
|||||||
"json-schema-form-react": "^0.0.2",
|
"json-schema-form-react": "^0.0.2",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.0.11",
|
|
||||||
"kysely": "^0.27.6",
|
"kysely": "^0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
|
"jsonv-ts": "^0.0.14-alpha.6",
|
||||||
"kysely-d1": "^0.3.0",
|
"kysely-d1": "^0.3.0",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
@@ -640,6 +641,8 @@
|
|||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.13.8", "", { "peerDependencies": { "hono": "^4" } }, "sha512-fsn8ucecsAXUoVxrUil0m13kOEq4mkX4/4QozCqmY+HpGfKl74OYSn8JcMA8GnG0ClfdRI4/ZSeG7zhFaVg+wg=="],
|
"@hono/node-server": ["@hono/node-server@1.13.8", "", { "peerDependencies": { "hono": "^4" } }, "sha512-fsn8ucecsAXUoVxrUil0m13kOEq4mkX4/4QozCqmY+HpGfKl74OYSn8JcMA8GnG0ClfdRI4/ZSeG7zhFaVg+wg=="],
|
||||||
|
|
||||||
|
"@hono/swagger-ui": ["@hono/swagger-ui@0.5.1", "", { "peerDependencies": { "hono": "*" } }, "sha512-XpUCfszLJ9b1rtFdzqOSHfdg9pfBiC2J5piEjuSanYpDDTIwpMz0ciiv5N3WWUaQpz9fEgH8lttQqL41vIFuDA=="],
|
||||||
|
|
||||||
"@hono/typebox-validator": ["@hono/typebox-validator@0.3.2", "", { "peerDependencies": { "@sinclair/typebox": ">=0.31.15 <1", "hono": ">=3.9.0" } }, "sha512-MIxYk80vtuFnkvbNreMubZ/vLoNCCQivLH8n3vNDY5dFNsZ12BFaZV3FmsLJHGibNMMpmkO6y4w5gNWY4KzSdg=="],
|
"@hono/typebox-validator": ["@hono/typebox-validator@0.3.2", "", { "peerDependencies": { "@sinclair/typebox": ">=0.31.15 <1", "hono": ">=3.9.0" } }, "sha512-MIxYk80vtuFnkvbNreMubZ/vLoNCCQivLH8n3vNDY5dFNsZ12BFaZV3FmsLJHGibNMMpmkO6y4w5gNWY4KzSdg=="],
|
||||||
|
|
||||||
"@hono/vite-dev-server": ["@hono/vite-dev-server@0.19.0", "", { "dependencies": { "@hono/node-server": "^1.12.0", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw=="],
|
"@hono/vite-dev-server": ["@hono/vite-dev-server@0.19.0", "", { "dependencies": { "@hono/node-server": "^1.12.0", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw=="],
|
||||||
@@ -2518,7 +2521,7 @@
|
|||||||
|
|
||||||
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
||||||
|
|
||||||
"jsonv-ts": ["jsonv-ts@0.0.11", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-W5WC6iwQvOuB0gRaAW9jAQKqT56pXjTA7XCjjAXZIM92/VBVNczTmV7iPtClqV1Zpgy4CtzaUsOJj4kWNeB5YQ=="],
|
"jsonv-ts": ["jsonv-ts@0.0.14-alpha.6", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-pwMpjEbNtyq8Xi6QBXuQ8dOZm7WQAEwvCPu3vVf9b3aU2KRHW+cfTPqO53U01YYdjWSSRkqaTKcLSiYdfwBYRA=="],
|
||||||
|
|
||||||
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
|
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user