diff --git a/.bruno/bruno.json b/.bruno/bruno.json new file mode 100644 index 0000000..b2c9a3a --- /dev/null +++ b/.bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "Valkyr", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/.bruno/environments/localhost.bru b/.bruno/environments/localhost.bru new file mode 100644 index 0000000..1f2c42d --- /dev/null +++ b/.bruno/environments/localhost.bru @@ -0,0 +1,3 @@ +vars { + url: http://localhost:8370/api/v1 +} diff --git a/.bruno/identity/Get.bru b/.bruno/identity/Get.bru new file mode 100644 index 0000000..1637c3d --- /dev/null +++ b/.bruno/identity/Get.bru @@ -0,0 +1,19 @@ +meta { + name: Get + type: http + seq: 2 +} + +get { + url: {{url}}/identity/:id + body: none + auth: inherit +} + +params:path { + id: +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/Roles.bru b/.bruno/identity/Roles.bru new file mode 100644 index 0000000..d658976 --- /dev/null +++ b/.bruno/identity/Roles.bru @@ -0,0 +1,32 @@ +meta { + name: Roles + type: http + seq: 4 +} + +put { + url: {{url}}/identity/:id/roles + body: json + auth: inherit +} + +params:path { + id: +} + +body:json { + [ + { + "type": "add", + "roles": [] + }, + { + "type": "remove", + "roles": [] + } + ] +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/Update.bru b/.bruno/identity/Update.bru new file mode 100644 index 0000000..552cde0 --- /dev/null +++ b/.bruno/identity/Update.bru @@ -0,0 +1,43 @@ +meta { + name: Update + type: http + seq: 4 +} + +put { + url: {{url}}/identity/:id + body: json + auth: inherit +} + +params:path { + id: +} + +body:json { + [ + { + "type": "add", + "key": "", + "value": "" + }, + { + "type": "push", + "key": "", + "values": "" + }, + { + "type": "pop", + "key": "", + "values": "" + }, + { + "type": "remove", + "key": "" + } + ] +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/folder.bru b/.bruno/identity/folder.bru new file mode 100644 index 0000000..a89bdf5 --- /dev/null +++ b/.bruno/identity/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Identity + seq: 1 +} + +auth { + mode: inherit +} diff --git a/.bruno/identity/login/code.bru b/.bruno/identity/login/code.bru new file mode 100644 index 0000000..e96d53b --- /dev/null +++ b/.bruno/identity/login/code.bru @@ -0,0 +1,29 @@ +meta { + name: Code + type: http + seq: 3 +} + +post { + url: {{url}}/identity/login/code + body: json + auth: inherit +} + +body:json { + { + "email": "john.doe@fixture.none", + "otp": "" + } +} + +script:post-response { + const cookies = res.getHeader('set-cookie'); + if (cookies) { + bru.setVar("cookie", cookies.join('; ')); + } +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/login/email.bru b/.bruno/identity/login/email.bru new file mode 100644 index 0000000..7025c71 --- /dev/null +++ b/.bruno/identity/login/email.bru @@ -0,0 +1,21 @@ +meta { + name: Email + type: http + seq: 2 +} + +post { + url: {{url}}/identity/login/email + body: json + auth: inherit +} + +body:json { + { + "email": "john.doe@fixture.none" + } +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/login/folder.bru b/.bruno/identity/login/folder.bru new file mode 100644 index 0000000..9b12963 --- /dev/null +++ b/.bruno/identity/login/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Login + seq: 3 +} + +auth { + mode: inherit +} diff --git a/.bruno/identity/login/sudo.bru b/.bruno/identity/login/sudo.bru new file mode 100644 index 0000000..21f8712 --- /dev/null +++ b/.bruno/identity/login/sudo.bru @@ -0,0 +1,21 @@ +meta { + name: Sudo + type: http + seq: 1 +} + +post { + url: {{url}}/identities/login/sudo + body: json + auth: inherit +} + +body:json { + { + "email": "john.doe@fixture.none" + } +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/me.bru b/.bruno/identity/me.bru new file mode 100644 index 0000000..58fdd69 --- /dev/null +++ b/.bruno/identity/me.bru @@ -0,0 +1,15 @@ +meta { + name: Me + type: http + seq: 1 +} + +get { + url: {{url}}/identity/me + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/.bruno/workspace/create.bru b/.bruno/workspace/create.bru new file mode 100644 index 0000000..10b7829 --- /dev/null +++ b/.bruno/workspace/create.bru @@ -0,0 +1,21 @@ +meta { + name: Create + type: http + seq: 1 +} + +post { + url: {{url}}/workspace + body: json + auth: inherit +} + +body:json { + { + "name": "" + } +} + +settings { + encodeUrl: true +} diff --git a/.bruno/workspace/folder.bru b/.bruno/workspace/folder.bru new file mode 100644 index 0000000..46b85fa --- /dev/null +++ b/.bruno/workspace/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Workspace + seq: 2 +} + +auth { + mode: inherit +} diff --git a/.gitignore b/.gitignore index b512c09..7625c09 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +.volumes node_modules \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index b2bb4f6..a6ffc5d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,19 @@ { + "biome.enabled": true, "deno.enable": true, + "deno.lint": false, + "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" + }, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..398d47c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM denoland/deno:2.5.1 + +ENV TZ=UTC +ENV PORT=8370 + +EXPOSE 8370 + +WORKDIR /app + +COPY api/ ./api/ +COPY relay/ ./relay/ +COPY .npmrc . +COPY deno-docker.json ./deno.json + +RUN deno install --allow-scripts + +CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index ed5c97f..0000000 --- a/LICENSE +++ /dev/null @@ -1,16 +0,0 @@ -MIT License - -Copyright 2025 Christoffer Rødvik. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -Commercial use is permitted, provided the Software is not sold, relicensed, or distributed as a stand-alone solution, whether in original or minimally modified form. -Use as part of a larger work, integrated product, or service is allowed. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index a9c7246..bd2198e 100644 --- a/README.md +++ b/README.md @@ -1,87 +1 @@ -

- -

- -# Relay - -Relay is a full stack protocol for communicating between client and server. It is also built around the major HTTP methods allowing for creating public API endpoints. - -## Quick Start - -For this quick start guide we assume the following project setup: - -``` - api/ - relay/ - web/ -``` - -### Relay - -First we want to set up our relay space, from the structure above lets start by defining our route. - -```ts -import { route } from "@valkyr/relay"; -import z from "zod"; - -export default route - .post("/users") - .body( - z.object({ - name: z.string(), - email: z.string().check(z.email()), - }) - ) - .response(z.string()); -``` - -After creating our first route we mount it onto our relay instance. - -```ts -import { Relay } from "@valkyr/relay"; - -import route from "./path/to/route.ts"; - -export const relay = new Relay([ - route -]); -``` - -We have now finished defining our initial relay setup which we can now utilize in our `api` and `web` spaces. - -### API - -To be able to successfully execute our user create route we need to attach a handler in our `api`. Lets start off by defining our handler. - -```ts -import { UnprocessableContentError } from "@valkyr/relay"; - -import { relay } from "~project/relay/mod.ts"; - -relay - .route("POST", "/users") - .handle(async ({ name, email }) => { - const user = await db.users.insert({ name, email }); - if (user === undefined) { - return new UnprocessableContentError(); - } - return user.id; - }); -``` - -We now have a `POST` handler for the `/users` path. - -### Web - -Now that we have both our relay and api ready to recieve requests we can trigger a user creation request in our web application. - -```ts -import { relay } from "~project/relay/mod.ts" - -const userId = await relay.post("/users", { - name: "John Doe", - email: "john.doe@fixture.none" -}); - -console.log(userId); // => string -``` +# Boilerplate \ No newline at end of file diff --git a/adapters/http.ts b/adapters/http.ts deleted file mode 100644 index 17a7a6c..0000000 --- a/adapters/http.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { RequestInput } from "../libraries/relay.ts"; -import { RelayAdapter } from "../mod.ts"; - -export const http: RelayAdapter = { - async fetch({ method, url, search, body }: RequestInput) { - const res = await fetch(`${url}${search}`, { method, body }); - const data = await res.text(); - if (res.status >= 400) { - throw new Error(data); - } - if (res.headers.get("content-type")?.includes("json")) { - return JSON.parse(data); - } - return data; - }, -}; diff --git a/api/config.ts b/api/config.ts new file mode 100644 index 0000000..7cfec2a --- /dev/null +++ b/api/config.ts @@ -0,0 +1,12 @@ +import { getEnvironmentVariable } from "@platform/config"; +import z from "zod"; + +export const config = { + name: "@valkyr/boilerplate", + host: getEnvironmentVariable({ key: "API_HOST", type: z.ipv4(), fallback: "0.0.0.0" }), + port: getEnvironmentVariable({ + key: "API_PORT", + type: z.coerce.number(), + fallback: "8370", + }), +}; diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..9cd602a --- /dev/null +++ b/api/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "scripts": { + "start": "deno --allow-all --watch-hmr=routes/ server.ts" + }, + "dependencies": { + "@modules/identity": "workspace:*", + "@module/workspace": "workspace:*", + "@platform/config": "workspace:*", + "zod": "4.1.12" + } +} diff --git a/api/server.ts b/api/server.ts new file mode 100644 index 0000000..060ded3 --- /dev/null +++ b/api/server.ts @@ -0,0 +1,73 @@ +import { logger } from "@platform/logger"; +import { context } from "@platform/relay"; +import { Api } from "@platform/server/api.ts"; +import server from "@platform/server/server.ts"; +import socket from "@platform/socket/server.ts"; +import { storage } from "@platform/storage"; + +import { config } from "./config.ts"; +import session from "./session.ts"; + +const log = logger.prefix("Server"); + +/* + |-------------------------------------------------------------------------------- + | Bootstrap + |-------------------------------------------------------------------------------- + */ + +// ### Platform + +await server.bootstrap(); +await socket.bootstrap(); +await session.bootstrap(); + +// ### Modules + +// await workspace.bootstrap(); + +/* + |-------------------------------------------------------------------------------- + | Service + |-------------------------------------------------------------------------------- + */ + +const api = new Api([ + /*...identity.routes, ...workspace.routes*/ +]); + +/* + |-------------------------------------------------------------------------------- + | Server + |-------------------------------------------------------------------------------- + */ + +Deno.serve( + { + port: config.port, + hostname: config.host, + onListen({ port, hostname }) { + logger.prefix("Server").info(`Listening at http://${hostname}:${port}`); + }, + }, + async (request) => + storage.run({}, async () => { + const url = new URL(request.url); + + // ### Storage Context + // Resolve storage context for all dependent modules. + + await server.resolve(request); + await socket.resolve(); + await session.resolve(request); + + // ### Fetch + // Execute fetch against the api instance. + + return api.fetch(request).finally(() => { + log.info( + `${request.method} ${url.pathname} [${((Date.now() - context.info.start) / 1000).toLocaleString()} seconds]`, + ); + }); + }), +); diff --git a/api/session.ts b/api/session.ts new file mode 100644 index 0000000..bba990d --- /dev/null +++ b/api/session.ts @@ -0,0 +1,111 @@ +import { context, UnauthorizedError } from "@platform/relay"; +import { storage } from "@platform/storage"; + +const IDENTITY_RESOLVE_HEADER = "x-identity-resolver"; + +export default { + bootstrap: async () => { + bootstrapSessionContext(); + }, + + resolve: async (request: Request) => { + await resolvePrincipalSession(request); + }, +}; + +function bootstrapSessionContext() { + Object.defineProperties(context, { + /** + * TODO ... + */ + isAuthenticated: { + get() { + return storage.getStore()?.principal !== undefined; + }, + }, + + /** + * TODO ... + */ + session: { + get() { + const session = storage.getStore()?.session; + if (session === undefined) { + throw new UnauthorizedError(); + } + return session; + }, + }, + + /** + * TODO ... + */ + principal: { + get() { + const principal = storage.getStore()?.principal; + if (principal === undefined) { + throw new UnauthorizedError(); + } + return principal; + }, + }, + + /** + * TODO ... + */ + access: { + get() { + const access = storage.getStore()?.access; + if (access === undefined) { + throw new UnauthorizedError(); + } + return access; + }, + }, + }); +} + +async function resolvePrincipalSession(request: Request) { + // ### Resolver + // Check if the incoming request is tagged as a resolver check. + // If it is a resolver we break out of the session resolution + // to avoid an infinite resolution loop. + + const isResolver = request.headers.get(IDENTITY_RESOLVE_HEADER) !== null; + if (isResolver) { + return; + } + + // ### Cookie + // Check for the existence of cookie to pass onto the session + // resolver. + + const cookie = request.headers.get("cookie"); + if (cookie === null) { + return; + } + + // ### Session + // Fetch session from identity module and tag it as a resolution + // call so it can break out of a resolution loop. + + const session = await getPrincipalSession({ + headers: new Headers({ + cookie, + [IDENTITY_RESOLVE_HEADER]: "true", + }), + }); + + // ### Populate Context + // On successfull resolution we build the request identity context. + + if (session !== undefined) { + const context = storage.getStore(); + if (context === undefined) { + return; + } + context.session = session.session; + context.principal = session.principal; + context.access = identity.access; + } +} diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..c7782ba --- /dev/null +++ b/apps/README.md @@ -0,0 +1 @@ +# Apps \ No newline at end of file diff --git a/apps/react/.gitignore b/apps/react/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/react/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/react/.npmrc b/apps/react/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/apps/react/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/apps/react/README.md b/apps/react/README.md new file mode 100644 index 0000000..7959ce4 --- /dev/null +++ b/apps/react/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/apps/react/components.json b/apps/react/components.json new file mode 100644 index 0000000..c3a0a52 --- /dev/null +++ b/apps/react/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/libraries/utils", + "ui": "@/components/ui", + "lib": "@/libraries", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/react/index.html b/apps/react/index.html new file mode 100644 index 0000000..f9f08cc --- /dev/null +++ b/apps/react/index.html @@ -0,0 +1,14 @@ + + + + + + + + Vite + React + TS + + +
+ + + diff --git a/apps/react/package.json b/apps/react/package.json new file mode 100644 index 0000000..396ec5d --- /dev/null +++ b/apps/react/package.json @@ -0,0 +1,56 @@ +{ + "name": "react", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@module/account": "workspace:*", + "@platform/relay": "workspace:*", + "@platform/spec": "workspace:*", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "@tabler/icons-react": "3.35.0", + "@tanstack/react-query": "5.89.0", + "@tanstack/react-router": "1.131.47", + "@valkyr/db": "npm:@jsr/valkyr__db@2.0.0", + "@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1", + "@zitadel/react": "1.1.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "fast-equals": "5.2.2", + "lucide-react": "^0.554.0", + "react": "19.1.1", + "react-dom": "19.1.1", + "tailwind-merge": "^3.4.0", + "tailwindcss": "4.1.13", + "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "1.4.0", + "zod": "4.1.12" + }, + "devDependencies": { + "@eslint/js": "9.35.0", + "@tailwindcss/vite": "4.1.13", + "@tanstack/react-router-devtools": "1.131.47", + "@types/react": "19.1.13", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "4.7.0", + "eslint": "9.35.0", + "eslint-plugin-react-hooks": "5.2.0", + "eslint-plugin-react-refresh": "0.4.20", + "globals": "16.4.0", + "typescript": "5.9.2", + "typescript-eslint": "8.44.0", + "vite": "7.1.6" + } +} diff --git a/apps/react/public/vite.svg b/apps/react/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/react/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react/src/adapters/http.ts b/apps/react/src/adapters/http.ts new file mode 100644 index 0000000..33fa288 --- /dev/null +++ b/apps/react/src/adapters/http.ts @@ -0,0 +1,273 @@ +import { + assertServerErrorResponse, + type RelayAdapter, + type RelayInput, + type RelayResponse, + ServerError, + type ServerErrorResponse, + type ServerErrorType, +} from "@platform/relay"; + +/** + * HttpAdapter provides a unified transport layer for Relay. + * + * It supports sending JSON objects, nested structures, arrays, and file uploads + * via FormData. The adapter automatically detects the payload type and formats + * the request accordingly. Responses are normalized into `RelayResponse`. + * + * @example + * ```ts + * const adapter = new HttpAdapter({ url: "https://api.example.com" }); + * + * // Sending JSON data + * const jsonResponse = await adapter.send({ + * method: "POST", + * endpoint: "/users", + * body: { name: "Alice", age: 30 }, + * }); + * + * // Sending files and nested objects + * const formResponse = await adapter.send({ + * method: "POST", + * endpoint: "/upload", + * body: { + * user: { name: "Bob", avatar: fileInput.files[0] }, + * documents: [fileInput.files[1], fileInput.files[2]], + * }, + * }); + * ``` + */ +export class HttpAdapter implements RelayAdapter { + /** + * Instantiate a new HttpAdapter instance. + * + * @param options - Adapter options. + */ + constructor(readonly options: HttpAdapterOptions) {} + + /** + * Override the initial url value set by instantiator. + */ + set url(value: string) { + this.options.url = value; + } + + /** + * Retrieve the URL value from options object. + */ + get url() { + return this.options.url; + } + + /** + * Return the full URL from given endpoint. + * + * @param endpoint - Endpoint to get url for. + */ + getUrl(endpoint: string): string { + return `${this.url}${endpoint}`; + } + + async send({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise { + const init: RequestInit = { method, headers }; + + // ### Before Request + // If any before request hooks has been defined, we run them here passing in the + // request headers for further modification. + + await this.#beforeRequest(headers); + + // ### Body + + if (body !== undefined) { + const type = this.#getRequestFormat(body); + if (type === "form-data") { + headers.delete("content-type"); + init.body = this.#getFormData(body); + } + if (type === "json") { + headers.set("content-type", "application/json"); + init.body = JSON.stringify(body); + } + } + + // ### Response + + return this.request(`${endpoint}${query}`, init); + } + + /** + * Send a fetch request using the given fetch options and returns + * a relay formatted response. + * + * @param endpoint - Which endpoint to submit request to. + * @param init - Request init details to submit with the request. + */ + async request(endpoint: string, init?: RequestInit): Promise { + return this.#toResponse(await fetch(this.getUrl(endpoint), init)); + } + + /** + * Run before request operations. + * + * @param headers - Headers to pass to hooks. + */ + async #beforeRequest(headers: Headers) { + if (this.options.hooks?.beforeRequest !== undefined) { + for (const hook of this.options.hooks.beforeRequest) { + await hook(headers); + } + } + } + + /** + * Determine the parser method required for the request. + * + * @param body - Request body. + */ + #getRequestFormat(body: unknown): "form-data" | "json" { + if (containsFile(body) === true) { + return "form-data"; + } + return "json"; + } + + /** + * Get FormData instance for the given body. + * + * @param body - Request body. + */ + #getFormData(data: Record, formData = new FormData(), parentKey?: string): FormData { + for (const key in data) { + const value = data[key]; + if (value === undefined || value === null) continue; + + const formKey = parentKey ? `${parentKey}[${key}]` : key; + + if (value instanceof File) { + formData.append(formKey, value, value.name); + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + if (item instanceof File) { + formData.append(`${formKey}[${index}]`, item, item.name); + } else if (typeof item === "object") { + this.#getFormData(item as Record, formData, `${formKey}[${index}]`); + } else { + formData.append(`${formKey}[${index}]`, String(item)); + } + }); + } else if (typeof value === "object") { + this.#getFormData(value as Record, formData, formKey); + } else { + formData.append(formKey, String(value)); + } + } + + return formData; + } + + /** + * Convert a fetch response to a compliant relay response. + * + * @param response - Fetch response to convert. + */ + async #toResponse(response: Response): Promise { + const type = response.headers.get("content-type"); + + // ### Content Type + // Ensure that the server responds with a 'content-type' definition. We should + // always expect the server to respond with a type. + + if (type === null) { + return { + result: "error", + error: { + status: response.status, + message: "Missing 'content-type' in header returned from server.", + }, + }; + } + + // ### Empty Response + // If the response comes back with empty response status 204 we simply return a + // empty success. + + if (response.status === 204) { + return { + result: "success", + data: null, + }; + } + + // ### JSON + // If the 'content-type' contains 'json' we treat it as a 'json' compliant response + // and attempt to resolve it as such. + + if (type.includes("json") === true) { + const parsed = await response.json(); + if ("data" in parsed) { + return { + result: "success", + data: parsed.data, + }; + } + if ("error" in parsed) { + return { + result: "error", + error: this.#toError(parsed), + }; + } + return { + result: "error", + error: { + status: response.status, + message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.", + }, + }; + } + + return { + result: "error", + error: { + status: response.status, + message: "Unsupported 'content-type' in header returned from server.", + }, + }; + } + + #toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] { + if (assertServerErrorResponse(candidate)) { + return ServerError.fromJSON({ type: "relay", ...candidate.error }); + } + if (typeof candidate === "string") { + return { + status, + message: candidate, + }; + } + return { + status, + message: "Unsupported 'error' returned from server.", + }; + } +} + +function containsFile(value: unknown): boolean { + if (value instanceof File) { + return true; + } + if (Array.isArray(value)) { + return value.some(containsFile); + } + if (typeof value === "object" && value !== null) { + return Object.values(value).some(containsFile); + } + return false; +} + +export type HttpAdapterOptions = { + url: string; + hooks?: { + beforeRequest?: ((headers: Headers) => Promise)[]; + }; +}; diff --git a/apps/react/src/assets/react.svg b/apps/react/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/react/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react/src/components/app-sidebar.tsx b/apps/react/src/components/app-sidebar.tsx new file mode 100644 index 0000000..3952ec0 --- /dev/null +++ b/apps/react/src/components/app-sidebar.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { + IconCamera, + IconChartBar, + IconDashboard, + IconDatabase, + IconFileAi, + IconFileDescription, + IconFileWord, + IconFolder, + IconHelp, + IconInnerShadowTop, + IconListDetails, + IconReport, + IconSearch, + IconSettings, + IconUsers, +} from "@tabler/icons-react"; +import type * as React from "react"; + +import { NavDocuments } from "@/components/nav-documents"; +import { NavMain } from "@/components/nav-main"; +import { NavSecondary } from "@/components/nav-secondary"; +import { NavUser } from "@/components/nav-user"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +const data = { + user: { + name: "shadcn", + email: "m@example.com", + avatar: "/avatars/shadcn.jpg", + }, + navMain: [ + { + title: "Dashboard", + url: "#", + icon: IconDashboard, + }, + { + title: "Lifecycle", + url: "#", + icon: IconListDetails, + }, + { + title: "Analytics", + url: "#", + icon: IconChartBar, + }, + { + title: "Projects", + url: "#", + icon: IconFolder, + }, + { + title: "Team", + url: "#", + icon: IconUsers, + }, + ], + navClouds: [ + { + title: "Capture", + icon: IconCamera, + isActive: true, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Proposal", + icon: IconFileDescription, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Prompts", + icon: IconFileAi, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + ], + navSecondary: [ + { + title: "Settings", + url: "#", + icon: IconSettings, + }, + { + title: "Get Help", + url: "#", + icon: IconHelp, + }, + { + title: "Search", + url: "#", + icon: IconSearch, + }, + ], + documents: [ + { + name: "Data Library", + url: "#", + icon: IconDatabase, + }, + { + name: "Reports", + url: "#", + icon: IconReport, + }, + { + name: "Word Assistant", + url: "#", + icon: IconFileWord, + }, + ], +}; + +export function AppSidebar({ ...props }: React.ComponentProps) { + return ( + + + + + + + + Acme Inc. + + + + + + + + + + + + + + + ); +} diff --git a/apps/react/src/components/nav-documents.tsx b/apps/react/src/components/nav-documents.tsx new file mode 100644 index 0000000..972a543 --- /dev/null +++ b/apps/react/src/components/nav-documents.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { type Icon, IconDots, IconFolder, IconShare3, IconTrash } from "@tabler/icons-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +export function NavDocuments({ + items, +}: { + items: { + name: string; + url: string; + icon: Icon; + }[]; +}) { + const { isMobile } = useSidebar(); + + return ( + + Documents + + {items.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + Open + + + + Share + + + + + Delete + + + + + ))} + + + + More + + + + + ); +} diff --git a/apps/react/src/components/nav-main.tsx b/apps/react/src/components/nav-main.tsx new file mode 100644 index 0000000..ac0332f --- /dev/null +++ b/apps/react/src/components/nav-main.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { type Icon, IconCirclePlusFilled, IconMail } from "@tabler/icons-react"; + +import { Button } from "@/components/ui/button"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export function NavMain({ + items, +}: { + items: { + title: string; + url: string; + icon?: Icon; + }[]; +}) { + return ( + + + + + + + Quick Create + + + + + + {items.map((item) => ( + + + {item.icon && } + {item.title} + + + ))} + + + + ); +} diff --git a/apps/react/src/components/nav-secondary.tsx b/apps/react/src/components/nav-secondary.tsx new file mode 100644 index 0000000..7690c2d --- /dev/null +++ b/apps/react/src/components/nav-secondary.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { Icon } from "@tabler/icons-react"; +import type * as React from "react"; + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string; + url: string; + icon: Icon; + }[]; +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ); +} diff --git a/apps/react/src/components/nav-user.controller.ts b/apps/react/src/components/nav-user.controller.ts new file mode 100644 index 0000000..9735a71 --- /dev/null +++ b/apps/react/src/components/nav-user.controller.ts @@ -0,0 +1,51 @@ +import { Controller } from "../libraries/controller.ts"; +import { type User as ZitadelUser, zitadel } from "../services/zitadel.ts"; + +export class NavUserController extends Controller<{ + user?: User; +}> { + async onInit() { + return { + user: await this.#getAuthenticatedUser(), + }; + } + + async #getAuthenticatedUser(): Promise { + const user = await zitadel.userManager.getUser(); + if (user !== null) { + return getUserProfile(user); + } + } + + authorize() { + zitadel.authorize(); + } + + signout() { + zitadel.signout(); + } +} + +function getUserProfile({ profile }: ZitadelUser): User { + const user: User = { name: "Unknown", email: "unknown@acme.none", avatar: "" }; + if (profile.name) { + user.name = profile.name; + } else if (profile.given_name && profile.family_name) { + user.name = `${profile.given_name} ${profile.family_name}`; + } else if (profile.given_name) { + user.name = profile.given_name; + } + if (profile.email) { + user.email = profile.email; + } + if (profile.picture !== undefined) { + user.avatar = profile.picture; + } + return user; +} + +type User = { + name: string; + email: string; + avatar: string; +}; diff --git a/apps/react/src/components/nav-user.tsx b/apps/react/src/components/nav-user.tsx new file mode 100644 index 0000000..80fefc8 --- /dev/null +++ b/apps/react/src/components/nav-user.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { IconCreditCard, IconDotsVertical, IconLogout, IconNotification, IconUserCircle } from "@tabler/icons-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"; +import { useController } from "@/libraries/controller.ts"; + +import { NavUserController } from "./nav-user.controller.ts"; + +export function NavUser() { + const [{ user }, loading, { authorize, signout }] = useController(NavUserController); + const { isMobile } = useSidebar(); + + console.log({authorize}) + + if (loading === true || user === undefined) { + return ( + + + + + + + + CN + +
+ Guest + guest@fixture.none +
+ +
+
+ + +
+ + + CN + +
+ Guest + guest@fixture.none +
+
+
+ + authorize()}> + + Sign in + +
+
+
+
+ ); + } + + return ( + + + + + + + + CN + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + {user.email} +
+
+
+ + + + + Account + + + + Billing + + + + Notifications + + + + signout()}> + + Log out + +
+
+
+
+ ); +} diff --git a/apps/react/src/components/site-header.tsx b/apps/react/src/components/site-header.tsx new file mode 100644 index 0000000..c21931f --- /dev/null +++ b/apps/react/src/components/site-header.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { SidebarTrigger } from "@/components/ui/sidebar"; + +export function SiteHeader() { + return ( +
+
+ + +

Documents

+
+ +
+
+
+ ); +} diff --git a/apps/react/src/components/theme-provider.tsx b/apps/react/src/components/theme-provider.tsx new file mode 100644 index 0000000..4d676bd --- /dev/null +++ b/apps/react/src/components/theme-provider.tsx @@ -0,0 +1,67 @@ +import { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "dark" | "light" | "system"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove("light", "dark"); + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }; + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); + + return context; +}; diff --git a/apps/react/src/components/ui/avatar.tsx b/apps/react/src/components/ui/avatar.tsx new file mode 100644 index 0000000..35db151 --- /dev/null +++ b/apps/react/src/components/ui/avatar.tsx @@ -0,0 +1,38 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import * as React from "react"; + +import { cn } from "../../libraries/utils.ts"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/react/src/components/ui/button.tsx b/apps/react/src/components/ui/button.tsx new file mode 100644 index 0000000..f4f67f1 --- /dev/null +++ b/apps/react/src/components/ui/button.tsx @@ -0,0 +1,47 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "../../libraries/utils.ts"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ; + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/react/src/components/ui/dropdown-menu.tsx b/apps/react/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..696a682 --- /dev/null +++ b/apps/react/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,180 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; + +import { cn } from "../../libraries/utils.ts"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/react/src/components/ui/input.tsx b/apps/react/src/components/ui/input.tsx new file mode 100644 index 0000000..22e9562 --- /dev/null +++ b/apps/react/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "@/libraries/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/react/src/components/ui/scroll-area.tsx b/apps/react/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cade2ee --- /dev/null +++ b/apps/react/src/components/ui/scroll-area.tsx @@ -0,0 +1,38 @@ +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; +import * as React from "react"; + +import { cn } from "@/libraries/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/react/src/components/ui/separator.tsx b/apps/react/src/components/ui/separator.tsx new file mode 100644 index 0000000..c1c3644 --- /dev/null +++ b/apps/react/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; + +import { cn } from "@/libraries/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/react/src/components/ui/sheet.tsx b/apps/react/src/components/ui/sheet.tsx new file mode 100644 index 0000000..f377113 --- /dev/null +++ b/apps/react/src/components/ui/sheet.tsx @@ -0,0 +1,103 @@ +"use client"; + +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/libraries/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }; diff --git a/apps/react/src/components/ui/sidebar.tsx b/apps/react/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..ef84fe8 --- /dev/null +++ b/apps/react/src/components/ui/sidebar.tsx @@ -0,0 +1,643 @@ +"use client"; + +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { PanelLeft } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/libraries/utils"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +}); +SidebarProvider.displayName = "SidebarProvider"; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + } +>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +}); +Sidebar.displayName = "Sidebar"; + +const SidebarTrigger = React.forwardRef, React.ComponentProps>( + ({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); + }, +); +SidebarTrigger.displayName = "SidebarTrigger"; + +const SidebarRail = React.forwardRef>( + ({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + * ); + * } + * ``` + */ +export function useController Controller>( + ControllerClass: TController, + props?: InstanceType["props"], +): [InstanceType["state"], boolean, ControllerActions>] { + const [state, setState] = useState({}); + const [loading, setLoading] = useState(true); + + const controllerRef = useRef | null>(null); + const actionsRef = useRef> | null>(null); + const propsRef = useRef(props); + + // Resolve only once after creation + useMemo(() => { + const instance = (ControllerClass as any).make(setState, setLoading); + controllerRef.current = instance; + actionsRef.current = instance.toActions(); + + instance.$resolve(props || {}); + + return () => { + instance.$destroy(); + }; + }, [controllerRef]); + + // Resolve on props change + useEffect(() => { + if (propsRef.current !== props) { + propsRef.current = props; + controllerRef.current?.$resolve(props || {}); + } + }, [props]); + + return [state, loading, actionsRef.current!]; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type ControllerActions = { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? K extends `$${string}` | `_${string}` | `#${string}` | "constructor" + ? never + : T[K] + : never; +}; diff --git a/apps/react/src/libraries/form.ts b/apps/react/src/libraries/form.ts new file mode 100644 index 0000000..665f595 --- /dev/null +++ b/apps/react/src/libraries/form.ts @@ -0,0 +1,269 @@ +import z, { type ZodObject, type ZodRawShape } from "zod"; + +export class Form>> { + readonly schema: ZodObject; + + readonly inputs: Partial = {}; + + #debounce: FormDebounce = { + validate: {}, + }; + + #defaults: Partial; + #errors: FormErrors = {}; + #elements: Record = {}; + + #onChange?: OnChangeCallback; + #onProcessing?: OnProcessingCallback; + #onError?: OnErrorCallback; + #onSubmit?: OnSubmitCallback; + #onResponse?: OnResponseCallback; + + /* + |-------------------------------------------------------------------------------- + | Constructor + |-------------------------------------------------------------------------------- + */ + + constructor(schema: TSchema, defaults: Partial = {}) { + this.schema = z.object(schema); + this.#defaults = defaults; + this.#bindMethods(); + this.#setDefaults(); + this.#setSubmit(); + } + + #bindMethods() { + this.register = this.register.bind(this); + this.set = this.set.bind(this); + this.get = this.get.bind(this); + this.validate = this.validate.bind(this); + this.submit = this.submit.bind(this); + } + + #setDefaults() { + for (const key in this.#defaults) { + this.inputs[key] = this.#defaults[key] ?? ("" as any); + } + } + + #setSubmit() { + if ((this.constructor as any).submit !== undefined) { + this.onSubmit((this.constructor as any).submit); + } + } + + /* + |-------------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------------- + */ + + get isValid(): boolean { + return Object.keys(this.#getFormErrors()).length === 0; + } + + get hasError() { + return Object.keys(this.errors).length !== 0; + } + + get errors(): FormErrors { + return this.#errors; + } + + set errors(value: FormErrors) { + this.#errors = value; + this.#onError?.(value); + } + + /** + * Register a input element with the form. This registers form related methods and a + * reference to the element itself that can be utilized by the form. + * + * @param name - Name of the input field. + */ + register(name: TKey) { + return { + name, + ref: (element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null) => { + if (element !== null) { + this.#elements[name as string] = element; + } + }, + defaultValue: this.get(name), + onChange: ({ target: { value } }: any) => { + this.set(name, value); + }, + }; + } + + /* + |-------------------------------------------------------------------------------- + | Registrars + |-------------------------------------------------------------------------------- + */ + + onChange(callback: OnChangeCallback): this { + this.#onChange = callback; + return this; + } + + onProcessing(callback: OnProcessingCallback): this { + this.#onProcessing = callback; + return this; + } + + onError(callback: OnErrorCallback): this { + this.#onError = callback; + return this; + } + + onSubmit(callback: OnSubmitCallback): this { + this.#onSubmit = callback; + return this; + } + + onResponse(callback: OnResponseCallback): this { + this.#onResponse = callback; + return this; + } + + /* + |-------------------------------------------------------------------------------- + | Data + |-------------------------------------------------------------------------------- + */ + + /** + * Set the value of an input field. + * + * @param name - Name of the input field. + * @param value - Value to set. + */ + set(name: TKey, value: TInputs[TKey]): void { + this.inputs[name] = value; + this.#onChange?.(name, value); + clearTimeout(this.#debounce.validate[name]); + this.#debounce.validate[name] = setTimeout(() => { + this.validate(name); + }, 200); + } + + /** + * Get the current input values or a specific input value. + * + * @param name - Name of the input field. _(Optional)_ + */ + get(): Partial; + get(name: TKey): TInputs[TKey] | undefined; + get(name?: TKey): Partial | TInputs[TKey] | undefined { + if (name === undefined) { + return { ...this.inputs }; + } + return this.inputs[name]; + } + + /** + * Reset form back to its default values. + */ + reset() { + for (const key in this.inputs) { + const value = this.#defaults[key] ?? ""; + (this.inputs as any)[key] = value; + if (this.#elements[key] !== undefined) { + (this.#elements as any)[key].value = value; + } + } + } + + /* + |-------------------------------------------------------------------------------- + | Submission + |-------------------------------------------------------------------------------- + */ + + async submit(event: any) { + event.preventDefault?.(); + this.#onProcessing?.(true); + this.validate(); + if (this.hasError === false) { + try { + const response = await this.#onSubmit?.(this.schema.parse(this.inputs) as TInputs); + this.#onResponse?.(undefined, response); + } catch (error) { + this.#onResponse?.(error, undefined as any); + } + } + this.#onProcessing?.(false); + this.reset(); + } + + validate(name?: keyof TInputs) { + if (name !== undefined) { + this.#validateInput(name); + } else { + this.#validateForm(); + } + } + + #validateForm(): void { + this.errors = this.#getFormErrors(); + } + + #validateInput(name: keyof TInputs): void { + const errors = this.#getFormErrors(); + let hasChanges = false; + if (errors[name] === undefined && this.errors[name] !== undefined) { + delete this.errors[name]; + hasChanges = true; + } + if (errors[name] !== undefined && this.errors[name] !== errors[name]) { + this.errors[name] = errors[name]; + hasChanges = true; + } + if (hasChanges === true) { + this.#onError?.({ ...this.errors }); + } + } + + #getFormErrors(): FormErrors { + const result = this.schema.safeParse(this.inputs); + if (result.success === false) { + throw result.error.flatten; + // return result.error.details.reduce>( + // (error, next) => ({ + // ...error, + // [next.path[0]]: next.message, + // }), + // {}, + // ); + } + return {}; + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type OnChangeCallback = (name: TKey, value: TInputs[TKey]) => void; + +type OnProcessingCallback = (value: boolean) => void; + +type OnErrorCallback = (errors: FormErrors) => void; + +type OnSubmitCallback = (inputs: TInputs) => Promise; + +type OnResponseCallback = (err: Error, res: Response) => void; + +type FormDebounce = { + validate: { + [TKey in keyof TInputs]?: any; + }; +}; + +type FormErrors = { + [TKey in keyof TInputs]?: string; +}; diff --git a/apps/react/src/libraries/utils.ts b/apps/react/src/libraries/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/apps/react/src/libraries/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/react/src/main.tsx b/apps/react/src/main.tsx new file mode 100644 index 0000000..9f22d46 --- /dev/null +++ b/apps/react/src/main.tsx @@ -0,0 +1,29 @@ +import "./index.css"; + +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { ThemeProvider } from "./components/theme-provider.tsx"; +import { routeTree } from "./routes.tsx"; + +const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +const rootElement = document.getElementById("root"); +if (rootElement === null) { + throw new Error("Failed to retrieve root element"); +} + +createRoot(rootElement).render( + + + + + , +); diff --git a/apps/react/src/routes.tsx b/apps/react/src/routes.tsx new file mode 100644 index 0000000..394bb73 --- /dev/null +++ b/apps/react/src/routes.tsx @@ -0,0 +1,30 @@ +import { createRootRoute, createRoute } from "@tanstack/react-router"; + +import { AppView } from "./views/app.view.tsx"; +import { CallbackView } from "./views/auth/callback.view.tsx"; +import { DashboardView } from "./views/dashboard/dashboard.view.tsx"; + +const root = createRootRoute(); + +const callback = createRoute({ + getParentRoute: () => root, + path: "/callback", + component: CallbackView, +}); + +const app = createRoute({ + id: "app", + getParentRoute: () => root, + component: AppView, +}); + +const dashboard = createRoute({ + getParentRoute: () => app, + path: "/", + component: DashboardView, +}); + +root.addChildren([app, callback]); +app.addChildren([dashboard]); + +export const routeTree = root; diff --git a/apps/react/src/services/api.ts b/apps/react/src/services/api.ts new file mode 100644 index 0000000..29060e7 --- /dev/null +++ b/apps/react/src/services/api.ts @@ -0,0 +1,15 @@ +import { account } from "@module/account/client"; +import { makeClient } from "@platform/relay"; + +import { HttpAdapter } from "../adapters/http.ts"; + +export const api = makeClient( + { + adapter: new HttpAdapter({ + url: window.location.origin, + }), + }, + { + account, + }, +); diff --git a/apps/react/src/services/zitadel.ts b/apps/react/src/services/zitadel.ts new file mode 100644 index 0000000..50abd02 --- /dev/null +++ b/apps/react/src/services/zitadel.ts @@ -0,0 +1,14 @@ +import { createZitadelAuth, type ZitadelConfig } from "@zitadel/react"; + +const config: ZitadelConfig = { + authority: "https://auth.valkyrjs.com", + client_id: "347982179092987909", + redirect_uri: "http://localhost:5173/callback", + post_logout_redirect_uri: "http://localhost:5173", + response_type: "code", + scope: "openid profile email", +}; + +export const zitadel = createZitadelAuth(config); + +export type User = NonNullable>>; diff --git a/apps/react/src/stores/database.ts b/apps/react/src/stores/database.ts new file mode 100644 index 0000000..15e9561 --- /dev/null +++ b/apps/react/src/stores/database.ts @@ -0,0 +1,14 @@ +import { IndexedDatabase } from "@valkyr/db"; + +import type { Todo } from "./todo.ts"; +import type { TodoItem } from "./todo-item.ts"; +import type { User } from "./user.ts"; + +export const db = new IndexedDatabase<{ + todos: Todo; + todoItems: TodoItem; + users: User; +}>({ + name: "app:valkyr", + registrars: [{ name: "todos", indexes: [["name", { unique: true }]] }, { name: "todoItems" }, { name: "users" }], +}); diff --git a/apps/react/src/stores/todo-item.ts b/apps/react/src/stores/todo-item.ts new file mode 100644 index 0000000..8c1719a --- /dev/null +++ b/apps/react/src/stores/todo-item.ts @@ -0,0 +1,9 @@ +import z from "zod"; + +export const TodoItemSchema = z.object({ + id: z.string(), + task: z.string(), + completedAt: z.string(), +}); + +export type TodoItem = z.infer; diff --git a/apps/react/src/stores/todo.ts b/apps/react/src/stores/todo.ts new file mode 100644 index 0000000..2fad59d --- /dev/null +++ b/apps/react/src/stores/todo.ts @@ -0,0 +1,11 @@ +import z from "zod"; + +import { TodoItemSchema } from "./todo-item.ts"; + +export const TodoSchema = z.object({ + id: z.string(), + name: z.string(), + items: z.array(TodoItemSchema).default([]), +}); + +export type Todo = z.infer; diff --git a/apps/react/src/stores/user.ts b/apps/react/src/stores/user.ts new file mode 100644 index 0000000..071f4db --- /dev/null +++ b/apps/react/src/stores/user.ts @@ -0,0 +1,11 @@ +import { ContactSchema } from "@spec/schemas/contact.ts"; +import { NameSchema } from "@spec/schemas/name.ts"; +import z from "zod"; + +export const UserSchema = z.object({ + id: z.string(), + name: NameSchema, + contact: ContactSchema, +}); + +export type User = z.infer; diff --git a/apps/react/src/theme.css b/apps/react/src/theme.css new file mode 100644 index 0000000..fd549d6 --- /dev/null +++ b/apps/react/src/theme.css @@ -0,0 +1,105 @@ +body { + @apply overscroll-none bg-transparent; +} + +:root { + --font-sans: var(--font-inter); + --header-height: calc(var(--spacing) * 12 + 1px); +} + +.theme-scaled { + @media (min-width: 1024px) { + --radius: 0.6rem; + --text-lg: 1.05rem; + --text-base: 0.85rem; + --text-sm: 0.8rem; + --spacing: 0.222222rem; + } + + [data-slot="card"] { + --spacing: 0.16rem; + } + + [data-slot="select-trigger"], + [data-slot="toggle-group-item"] { + --spacing: 0.222222rem; + } +} + +.theme-default, +.theme-default-scaled { + --primary: var(--color-neutral-600); + --primary-foreground: var(--color-neutral-50); + + @variant dark { + --primary: var(--color-neutral-500); + --primary-foreground: var(--color-neutral-50); + } +} + +.theme-blue, +.theme-blue-scaled { + --primary: var(--color-blue-600); + --primary-foreground: var(--color-blue-50); + + @variant dark { + --primary: var(--color-blue-500); + --primary-foreground: var(--color-blue-50); + } +} + +.theme-green, +.theme-green-scaled { + --primary: var(--color-lime-600); + --primary-foreground: var(--color-lime-50); + + @variant dark { + --primary: var(--color-lime-600); + --primary-foreground: var(--color-lime-50); + } +} + +.theme-amber, +.theme-amber-scaled { + --primary: var(--color-amber-600); + --primary-foreground: var(--color-amber-50); + + @variant dark { + --primary: var(--color-amber-500); + --primary-foreground: var(--color-amber-50); + } +} + +.theme-mono, +.theme-mono-scaled { + --font-sans: var(--font-mono); + --primary: var(--color-neutral-600); + --primary-foreground: var(--color-neutral-50); + + @variant dark { + --primary: var(--color-neutral-500); + --primary-foreground: var(--color-neutral-50); + } + + .rounded-xs, + .rounded-sm, + .rounded-md, + .rounded-lg, + .rounded-xl { + @apply !rounded-none; + border-radius: 0; + } + + .shadow-xs, + .shadow-sm, + .shadow-md, + .shadow-lg, + .shadow-xl { + @apply !shadow-none; + } + + [data-slot="toggle-group"], + [data-slot="toggle-group-item"] { + @apply !rounded-none !shadow-none; + } +} diff --git a/apps/react/src/views/app.controller.ts b/apps/react/src/views/app.controller.ts new file mode 100644 index 0000000..b5c1fc7 --- /dev/null +++ b/apps/react/src/views/app.controller.ts @@ -0,0 +1,25 @@ +import { Controller } from "../libraries/controller.ts"; +import { zitadel } from "../services/zitadel.ts"; + +export class AppController extends Controller<{ + authenticated: boolean; +}> { + async onInit() { + return { + authenticated: await this.#getAuthenticatedState(), + }; + } + + async #getAuthenticatedState(): Promise { + const user = await zitadel.userManager.getUser(); + if (user === null) { + zitadel.authorize(); + return false; + } + return true; + } + + signout() { + zitadel.signout(); + } +} diff --git a/apps/react/src/views/app.view.tsx b/apps/react/src/views/app.view.tsx new file mode 100644 index 0000000..8b83ee8 --- /dev/null +++ b/apps/react/src/views/app.view.tsx @@ -0,0 +1,40 @@ +import { Outlet } from "@tanstack/react-router"; + +import { AppSidebar } from "@/components/app-sidebar.tsx"; +import { SiteHeader } from "@/components/site-header.tsx"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar.tsx"; +import { useController } from "@/libraries/controller.ts"; + +import { AppController } from "./app.controller.ts"; + +export function AppView() { + const [{ authenticated }, loading] = useController(AppController); + if (loading === true) { + return
Loading ...
; + } + if (authenticated === false) { + return
Unauthenticated
; + } + return ( + + + + +
+
+
+ +
+
+
+
+
+ ); +} diff --git a/apps/react/src/views/auth/callback.view.tsx b/apps/react/src/views/auth/callback.view.tsx new file mode 100644 index 0000000..2f1bec9 --- /dev/null +++ b/apps/react/src/views/auth/callback.view.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { zitadel } from "../../services/zitadel.ts"; + +export function CallbackView() { + const navigate = useNavigate(); + useEffect(() => { + async function handleCallback() { + try { + const user = await zitadel.userManager.signinRedirectCallback(); + if (user) { + navigate({ to: "/", replace: true }); + } else { + navigate({ to: "/", replace: true }); + } + } catch (error) { + console.error("Callback error", error); + navigate({ to: "/", replace: true }); + } + } + handleCallback(); + }, [navigate]); + + return null; +} diff --git a/apps/react/src/views/auth/login.controller.ts b/apps/react/src/views/auth/login.controller.ts new file mode 100644 index 0000000..b1d0a55 --- /dev/null +++ b/apps/react/src/views/auth/login.controller.ts @@ -0,0 +1,29 @@ +import { Controller } from "../../libraries/controller.ts"; +import { type User, zitadel } from "../../services/zitadel.ts"; + +export class LoginController extends Controller<{ + user?: User; +}> { + async onInit() { + return { + user: await this.#getAuthenticationState(), + }; + } + + async #getAuthenticationState(): Promise { + return zitadel.userManager.getUser().then((user) => { + if (user === null) { + return undefined; + } + return user; + }); + } + + login() { + zitadel.authorize(); + } + + logout() { + zitadel.signout(); + } +} diff --git a/apps/react/src/views/auth/login.view.tsx b/apps/react/src/views/auth/login.view.tsx new file mode 100644 index 0000000..10c3d21 --- /dev/null +++ b/apps/react/src/views/auth/login.view.tsx @@ -0,0 +1,14 @@ +import { useController } from "../../libraries/controller.ts"; +import { LoginController } from "./login.controller.ts"; + +export function LoginView() { + const [{ user }, { login, logout }] = useController(LoginController); + return ( +
+ + {user !== undefined ?
{JSON.stringify(user, null, 2)}
: null} +
+ ); +} diff --git a/apps/react/src/views/dashboard/dashboard.view.tsx b/apps/react/src/views/dashboard/dashboard.view.tsx new file mode 100644 index 0000000..6d0cf26 --- /dev/null +++ b/apps/react/src/views/dashboard/dashboard.view.tsx @@ -0,0 +1,3 @@ +export function DashboardView() { + return
Dashboard
; +} diff --git a/apps/react/src/vite-env.d.ts b/apps/react/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/react/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/react/tailwind.config.js b/apps/react/tailwind.config.js new file mode 100644 index 0000000..5e76857 --- /dev/null +++ b/apps/react/tailwind.config.js @@ -0,0 +1,9 @@ +module.exports = { + theme: { + extend: { + fontFamily: { + sans: ["Inter", "sans-serif"], + }, + }, + }, +}; diff --git a/apps/react/tsconfig.app.json b/apps/react/tsconfig.app.json new file mode 100644 index 0000000..b02fee3 --- /dev/null +++ b/apps/react/tsconfig.app.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + // "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": ["src"] +} diff --git a/apps/react/tsconfig.json b/apps/react/tsconfig.json new file mode 100644 index 0000000..2b78387 --- /dev/null +++ b/apps/react/tsconfig.json @@ -0,0 +1,10 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/apps/react/tsconfig.node.json b/apps/react/tsconfig.node.json new file mode 100644 index 0000000..99f7cb0 --- /dev/null +++ b/apps/react/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + // "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/react/vite.config.ts b/apps/react/vite.config.ts new file mode 100644 index 0000000..366f799 --- /dev/null +++ b/apps/react/vite.config.ts @@ -0,0 +1,20 @@ +import path from "node:path" +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + proxy: { + "/api/v1": { + target: "http://localhost:8370", + }, + }, + }, +}); diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..e01dce7 --- /dev/null +++ b/biome.json @@ -0,0 +1,45 @@ +{ + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 120, + "attributePosition": "auto" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noConfusingVoidType": "off", + "noExplicitAny": "off" + }, + "complexity": { + "noBannedTypes": "off" + } + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": { + "level": "on", + "options": { + "groups": [ + [":BUN:", ":NODE:"], + ":BLANK_LINE:", + ":PACKAGE:", + ":BLANK_LINE:", + [":ALIAS:"], + ":BLANK_LINE:", + ":PATH:" + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/cerbos.yaml b/cerbos.yaml new file mode 100644 index 0000000..9df1d0f --- /dev/null +++ b/cerbos.yaml @@ -0,0 +1,13 @@ +server: + adminAPI: + enabled: true + adminCredentials: + username: cerbos + passwordHash: JDJ5JDEwJDc5VzBkQ0NUWHFTT3N1OW9xZkx5ZC43M0tuM0JBSTU0dVRsMVBkOEtuYVBCaWFzVXk5d0phCgo= + httpListenAddr: ":3592" + grpcListenAddr: ":3593" + +storage: + driver: "sqlite3" + sqlite3: + dsn: "file:/tmp/cerbos.sqlite?mode=rwc&cache=shared&_fk=true" diff --git a/deno.json b/deno.json index 9a19b9a..5d147e6 100644 --- a/deno.json +++ b/deno.json @@ -1,23 +1,43 @@ { - "name": "@valkyr/relay", - "version": "0.1.0", - "exports": { - ".": "./mod.ts" - }, - "publish": { - "exclude": [ - ".github", - ".vscode", - ".gitignore", - "tests" - ] - }, + "unstable": ["fmt-component"], + "nodeModulesDir": "auto", + "workspace": [ + "api", + "apps/react", + "modules/account", + "modules/tenant", + "platform/cerbos", + "platform/config", + "platform/database", + "platform/logger", + "platform/relay", + "platform/routes", + "platform/server", + "platform/socket", + "platform/spec", + "platform/storage", + "platform/vault" + ], "tasks": { - "check": "deno check ./mod.ts", - "lint": "npx eslint -c eslint.config.mjs .", - "test": "deno test --allow-all", - "test:publish": "deno publish --dry-run", - "ncu": "npx ncu -u -p npm" - }, - "nodeModulesDir": "auto" + "api": { + "command": "cd ./api && deno run start", + "description": "Start api server instance." + }, + "react": { + "command": "cd ./apps/react && deno run dev", + "description": "Start react application instance." + }, + "check": { + "command": "deno run -A npm:@biomejs/biome check --write ./api ./apps/react/src ./modules ./platform", + "description": "Format, lint, and organize imports of the entire project." + }, + "test": { + "command": "deno test --allow-all", + "description": "Runs all defined tests across the entire project." + }, + "ncu": { + "command": "npx ncu -u -p npm", + "description": "Updates all the dependencies in package.json to their latest versions." + } + } } diff --git a/deno.lock b/deno.lock index a54e3c5..769b4a3 100644 --- a/deno.lock +++ b/deno.lock @@ -1,17 +1,408 @@ { - "version": "4", + "version": "5", "specifiers": { - "npm:@jsr/std__assert@1.0.12": "1.0.12", - "npm:@jsr/std__testing@1.0.11": "1.0.11", - "npm:eslint-plugin-simple-import-sort@12.1.1": "12.1.1_eslint@9.24.0", - "npm:eslint@9.24.0": "9.24.0", - "npm:prettier@3.5.3": "3.5.3", - "npm:typescript-eslint@8.30.1": "8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3", - "npm:zod@next": "4.0.0-beta.20250417T043022" + "npm:@biomejs/biome@*": "2.2.4", + "npm:@biomejs/biome@2.2.4": "2.2.4", + "npm:@cerbos/core@0.25.1": "0.25.1_@bufbuild+protobuf@2.10.1", + "npm:@cerbos/http@0.23.3": "0.23.3_@bufbuild+protobuf@2.10.1_@cerbos+api@0.2.0", + "npm:@eslint/js@9.35.0": "9.35.0", + "npm:@jsr/std__assert@1.0.14": "1.0.14", + "npm:@jsr/std__dotenv@0.225.5": "0.225.5", + "npm:@jsr/std__testing@1.0.15": "1.0.15", + "npm:@jsr/valkyr__db@2.0.0": "2.0.0", + "npm:@jsr/valkyr__event-emitter@1.0.1": "1.0.1", + "npm:@jsr/valkyr__event-store@2.0.1": "2.0.1", + "npm:@jsr/valkyr__json-rpc@1.1.0": "1.1.0", + "npm:@radix-ui/react-avatar@^1.1.11": "1.1.11_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-dialog@^1.1.15": "1.1.15_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-dropdown-menu@^2.1.16": "2.1.16_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-scroll-area@^1.2.10": "1.2.10_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-separator@^1.1.8": "1.1.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-slot@^1.2.4": "1.2.4_@types+react@19.1.13_react@19.1.1", + "npm:@radix-ui/react-tooltip@^1.2.8": "1.2.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@tabler/icons-react@3.35.0": "3.35.0_react@19.1.1", + "npm:@tailwindcss/vite@4.1.13": "4.1.13_vite@7.1.6__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", + "npm:@tanstack/react-query@5.89.0": "5.89.0_react@19.1.1", + "npm:@tanstack/react-router-devtools@1.131.47": "1.131.47_@tanstack+react-router@1.131.47__react@19.1.1__react-dom@19.1.1___react@19.1.1_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@tanstack/react-router@1.131.47": "1.131.47_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@types/node@*": "24.2.0", + "npm:@types/react-dom@19.1.9": "19.1.9_@types+react@19.1.13", + "npm:@types/react@19.1.13": "19.1.13", + "npm:@vitejs/plugin-react@4.7.0": "4.7.0_vite@7.1.6__@types+node@24.2.0__picomatch@4.0.3_@babel+core@7.28.4_@types+node@24.2.0", + "npm:@zitadel/react@1.1.0": "1.1.0", + "npm:class-variance-authority@~0.7.1": "0.7.1", + "npm:clsx@^2.1.1": "2.1.1", + "npm:eslint-plugin-react-hooks@5.2.0": "5.2.0_eslint@9.35.0", + "npm:eslint-plugin-react-refresh@0.4.20": "0.4.20_eslint@9.35.0", + "npm:eslint@9.35.0": "9.35.0", + "npm:fast-equals@5.2.2": "5.2.2", + "npm:globals@16.4.0": "16.4.0", + "npm:jose@6.1.0": "6.1.0", + "npm:lucide-react@0.554": "0.554.0_react@19.1.1", + "npm:nanoid@5.1.5": "5.1.5", + "npm:path-to-regexp@8": "8.3.0", + "npm:postgres@3.4.7": "3.4.7", + "npm:react-dom@19.1.1": "19.1.1_react@19.1.1", + "npm:react@19.1.1": "19.1.1", + "npm:tailwind-merge@^3.4.0": "3.4.0", + "npm:tailwindcss-animate@^1.0.7": "1.0.7_tailwindcss@4.1.13", + "npm:tailwindcss@4.1.13": "4.1.13", + "npm:tw-animate-css@1.4.0": "1.4.0", + "npm:typescript-eslint@8.44.0": "8.44.0_eslint@9.35.0_typescript@5.9.2_@typescript-eslint+parser@8.44.0__eslint@9.35.0__typescript@5.9.2", + "npm:typescript@5.9.2": "5.9.2", + "npm:vite@7.1.6": "7.1.6_@types+node@24.2.0_picomatch@4.0.3", + "npm:zod@4.1.12": "4.1.12" }, "npm": { - "@eslint-community/eslint-utils@4.6.1_eslint@9.24.0": { - "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", + "@babel/code-frame@7.27.1": { + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dependencies": [ + "@babel/helper-validator-identifier", + "js-tokens", + "picocolors" + ] + }, + "@babel/compat-data@7.28.4": { + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==" + }, + "@babel/core@7.28.4": { + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-compilation-targets", + "@babel/helper-module-transforms", + "@babel/helpers", + "@babel/parser", + "@babel/template", + "@babel/traverse", + "@babel/types", + "@jridgewell/remapping", + "convert-source-map", + "debug", + "gensync", + "json5", + "semver@6.3.1" + ] + }, + "@babel/generator@7.28.3": { + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping", + "jsesc" + ] + }, + "@babel/helper-compilation-targets@7.27.2": { + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dependencies": [ + "@babel/compat-data", + "@babel/helper-validator-option", + "browserslist", + "lru-cache", + "semver@6.3.1" + ] + }, + "@babel/helper-globals@7.28.0": { + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==" + }, + "@babel/helper-module-imports@7.27.1": { + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dependencies": [ + "@babel/traverse", + "@babel/types" + ] + }, + "@babel/helper-module-transforms@7.28.3_@babel+core@7.28.4": { + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dependencies": [ + "@babel/core", + "@babel/helper-module-imports", + "@babel/helper-validator-identifier", + "@babel/traverse" + ] + }, + "@babel/helper-plugin-utils@7.27.1": { + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==" + }, + "@babel/helper-string-parser@7.27.1": { + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" + }, + "@babel/helper-validator-identifier@7.27.1": { + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==" + }, + "@babel/helper-validator-option@7.27.1": { + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==" + }, + "@babel/helpers@7.28.4": { + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dependencies": [ + "@babel/template", + "@babel/types" + ] + }, + "@babel/parser@7.28.4": { + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dependencies": [ + "@babel/types" + ], + "bin": true + }, + "@babel/plugin-transform-react-jsx-self@7.27.1_@babel+core@7.28.4": { + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-transform-react-jsx-source@7.27.1_@babel+core@7.28.4": { + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/template@7.27.2": { + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dependencies": [ + "@babel/code-frame", + "@babel/parser", + "@babel/types" + ] + }, + "@babel/traverse@7.28.4": { + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-globals", + "@babel/parser", + "@babel/template", + "@babel/types", + "debug" + ] + }, + "@babel/types@7.28.4": { + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dependencies": [ + "@babel/helper-string-parser", + "@babel/helper-validator-identifier" + ] + }, + "@biomejs/biome@2.2.4": { + "integrity": "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==", + "optionalDependencies": [ + "@biomejs/cli-darwin-arm64", + "@biomejs/cli-darwin-x64", + "@biomejs/cli-linux-arm64", + "@biomejs/cli-linux-arm64-musl", + "@biomejs/cli-linux-x64", + "@biomejs/cli-linux-x64-musl", + "@biomejs/cli-win32-arm64", + "@biomejs/cli-win32-x64" + ], + "bin": true + }, + "@biomejs/cli-darwin-arm64@2.2.4": { + "integrity": "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@biomejs/cli-darwin-x64@2.2.4": { + "integrity": "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@biomejs/cli-linux-arm64-musl@2.2.4": { + "integrity": "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@biomejs/cli-linux-arm64@2.2.4": { + "integrity": "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@biomejs/cli-linux-x64-musl@2.2.4": { + "integrity": "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@biomejs/cli-linux-x64@2.2.4": { + "integrity": "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@biomejs/cli-win32-arm64@2.2.4": { + "integrity": "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@biomejs/cli-win32-x64@2.2.4": { + "integrity": "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@bufbuild/protobuf@2.10.1": { + "integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==" + }, + "@cerbos/api@0.2.0": { + "integrity": "sha512-p3kAfmgz0WwxXBJ8Dt1vugV/QjQoPtF5b1R+h16YnUPZ6O4YL8D9gjz5WQRg/0FllodyaEtJlrMPxGEvJetkIw==", + "dependencies": [ + "@bufbuild/protobuf" + ] + }, + "@cerbos/core@0.25.1_@bufbuild+protobuf@2.10.1": { + "integrity": "sha512-aPi8IqqgGHq9xyoBk6dYAKQ1U1athW0YZfI+7lzxxwpLlNFdZ9EwJLhaRSUFgYpUS2TBWDtX094Yn1kgB1esCQ==", + "dependencies": [ + "@bufbuild/protobuf", + "@cerbos/api", + "uuid" + ] + }, + "@cerbos/http@0.23.3_@bufbuild+protobuf@2.10.1_@cerbos+api@0.2.0": { + "integrity": "sha512-yf8s4v+T4sf/ZiLorHpXhdStOr+q5XEjF2m/yvpcR7E/7e5eGCr5yEov9NIgfRQg1HGW8h+B6CIFBl9amSsaGw==", + "dependencies": [ + "@bufbuild/protobuf", + "@cerbos/api", + "@cerbos/core", + "qs" + ] + }, + "@esbuild/aix-ppc64@0.25.10": { + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.25.10": { + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.25.10": { + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.25.10": { + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.25.10": { + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.25.10": { + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.25.10": { + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.25.10": { + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.25.10": { + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.25.10": { + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.25.10": { + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.25.10": { + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.25.10": { + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.25.10": { + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.25.10": { + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.25.10": { + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.25.10": { + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.25.10": { + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.25.10": { + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.25.10": { + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.25.10": { + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.25.10": { + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.25.10": { + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.25.10": { + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.25.10": { + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.25.10": { + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@eslint-community/eslint-utils@4.9.0_eslint@9.35.0": { + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dependencies": [ "eslint", "eslint-visitor-keys@3.4.3" @@ -20,25 +411,19 @@ "@eslint-community/regexpp@4.12.1": { "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==" }, - "@eslint/config-array@0.20.0": { - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "@eslint/config-array@0.21.0": { + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dependencies": [ "@eslint/object-schema", "debug", "minimatch@3.1.2" ] }, - "@eslint/config-helpers@0.2.1": { - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==" + "@eslint/config-helpers@0.3.1": { + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==" }, - "@eslint/core@0.12.0": { - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", - "dependencies": [ - "@types/json-schema" - ] - }, - "@eslint/core@0.13.0": { - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "@eslint/core@0.15.2": { + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dependencies": [ "@types/json-schema" ] @@ -49,72 +434,147 @@ "ajv", "debug", "espree", - "globals", - "ignore", + "globals@14.0.0", + "ignore@5.3.2", "import-fresh", "js-yaml", "minimatch@3.1.2", "strip-json-comments" ] }, - "@eslint/js@9.24.0": { - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==" + "@eslint/js@9.35.0": { + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==" }, "@eslint/object-schema@2.1.6": { "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==" }, - "@eslint/plugin-kit@0.2.8": { - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "@eslint/plugin-kit@0.3.5": { + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dependencies": [ - "@eslint/core@0.13.0", + "@eslint/core", "levn" ] }, + "@floating-ui/core@1.7.3": { + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dependencies": [ + "@floating-ui/utils" + ] + }, + "@floating-ui/dom@1.7.4": { + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dependencies": [ + "@floating-ui/core", + "@floating-ui/utils" + ] + }, + "@floating-ui/react-dom@2.1.6_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "dependencies": [ + "@floating-ui/dom", + "react", + "react-dom" + ] + }, + "@floating-ui/utils@0.2.10": { + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, "@humanfs/core@0.19.1": { "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==" }, - "@humanfs/node@0.16.6": { - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "@humanfs/node@0.16.7": { + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dependencies": [ "@humanfs/core", - "@humanwhocodes/retry@0.3.1" + "@humanwhocodes/retry" ] }, "@humanwhocodes/module-importer@1.0.1": { "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" }, - "@humanwhocodes/retry@0.3.1": { - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==" + "@humanwhocodes/retry@0.4.3": { + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==" }, - "@humanwhocodes/retry@0.4.2": { - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==" + "@isaacs/fs-minipass@4.0.1": { + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dependencies": [ + "minipass" + ] }, - "@jsr/std__assert@1.0.12": { - "integrity": "sha512-9pmgjJhuljZCmLlbvsRV6aLT5+YCmhX/yIjaWYav7R7Vup2DOLAgpUOs4JkzRbwn7fdKYrwHT8+DjqPr7Ti8mg==", + "@jridgewell/gen-mapping@0.3.13": { + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.31": { + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@jsr/std__assert@1.0.14": { + "integrity": "sha512-BcjBimpxuy7mXjWo7sZ3TtPitx91w3UqssyY92RmJIuoMGYywZRGxaxqK9/oybljbZbZpPOSrkgQI9wKpgZ9vQ==", "dependencies": [ "@jsr/std__internal" - ] + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.14.tgz" }, - "@jsr/std__async@1.0.12": { - "integrity": "sha512-NUaSOcwMetVeVkIqet2Ammy2A5YxG8ViFxryBbTaC4h7l/cgAkU59U3zF58ek4Y8HZ0Nx5De7qBptPfp62kcgw==" + "@jsr/std__async@1.0.14": { + "integrity": "sha512-aIG8W3TOmW+lKdAJA5w56qASu9EiUmBXbhW6eAlSEUBid+KVESGqQygFFg+awt/c8K+qobVM6M/u3SbIy0NyUQ==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__async/1.0.14.tgz" }, - "@jsr/std__data-structures@1.0.6": { - "integrity": "sha512-Ejc8mHLuoYxXLu2zPquvqijdgQ19OV+1DdVDrLc/Cg+tiuGh4Dq2FSnLiPINh4lO1AJ3XcZcYPx38RxdsZcCOg==" - }, - "@jsr/std__fs@1.0.16": { - "integrity": "sha512-xnqp8XqEFN+ttkERg9GG+AxyipSd+rfCquLPviF5ZSwN6oCV1TM0ZNoKHXNk/EJAsz28YjF4sfgdJt8XwTV2UQ==", + "@jsr/std__data-structures@1.0.9": { + "integrity": "sha512-+mT4Nll6fx+CPNqrlC+huhIOYNSMS+KUdJ4B8NujiQrh/bq++ds5PXpEsfV5EPR+YuWcuDGG0P1DE+Rednd7Wg==", "dependencies": [ + "@jsr/std__assert" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__data-structures/1.0.9.tgz" + }, + "@jsr/std__dotenv@0.225.5": { + "integrity": "sha512-qrBt3wfQgvXbjo+Up6lyzBGxk0IPhDqW9Jx7CJQUQpsxqhoqnBmD8gn0Mt8i+RHHI9uZFCO+FP122ClAC8yljg==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__dotenv/0.225.5.tgz" + }, + "@jsr/std__fs@1.0.19": { + "integrity": "sha512-TEjyE8g+46jPlu7dJHLrwc8NMGl8zfG+JjWxyNQyDbxP0RtqZ4JmYZfR9vy4RWYWJQbLpw6Kbt2n+K/2zAO/JA==", + "dependencies": [ + "@jsr/std__internal", "@jsr/std__path" - ] + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.19.tgz" }, - "@jsr/std__internal@1.0.6": { - "integrity": "sha512-1NLtCx9XAL44nt56gzmRSCgXjIthHVzK62fTkJdq8/XsP7eN9a21AZDpc0EGJ/cgvmmOB52UGh46OuKrrY7eVg==" + "@jsr/std__internal@1.0.10": { + "integrity": "sha512-fmD6yKep/sMnB2yPQU/REZG7Z4N9SZwcUBNnceo4QkXk67l3JEfxHoROQ/YHeVSOmq6x55Ra6nuMjz2ib3nj3g==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.10.tgz" }, - "@jsr/std__path@1.0.8": { - "integrity": "sha512-eNBGlh/8ZVkMxtFH4bwIzlAeKoHYk5in4wrBZhi20zMdOiuX4QozP4+19mIXBT2lzHDjhuVLyECbhFeR304iDg==" + "@jsr/std__net@1.0.6": { + "integrity": "sha512-mh27Fw4UMCjGSIMoOhjia5cS5fNP9M9DZYhGB7EYSZNnzf/eguFiarii/W4oDwYMmnxCMouUzhc6Y7jFuwTzcg==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__net/1.0.6.tgz" }, - "@jsr/std__testing@1.0.11": { - "integrity": "sha512-pqQDYtIsaDf+x4NHQ+WiixRJ8DfhgFQRdlHWWssFAzIYwleR+VHLTNlgsgg+AH3mIIR+gTkBmKk21hTkM/WbMQ==", + "@jsr/std__path@1.1.2": { + "integrity": "sha512-5hkOR1s5M7am02Bn9KS+SNMNwUSivz7t7/w2HBhFIfO7Eh8+mWilaZ+1tdanV9aaSHr4c99Zo4Da+cCSuzUOdA==", + "dependencies": [ + "@jsr/std__internal" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.2.tgz" + }, + "@jsr/std__testing@1.0.15": { + "integrity": "sha512-NgQuXxTEG4ecbh2fzYbkJWJoBgPXwbv6bdsrAYSOeLpX2d+TROEzpErbWQXHi/yxZy/FNn9IF548ZDAqMZxi/g==", "dependencies": [ "@jsr/std__assert", "@jsr/std__async", @@ -122,6 +582,59 @@ "@jsr/std__fs", "@jsr/std__internal", "@jsr/std__path" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.15.tgz" + }, + "@jsr/valkyr__db@2.0.0": { + "integrity": "sha512-0gIauba+vQW6ssqMACLO1Z/METlhzoX+y4t9Sawh/IafQ986Rgvp6gCI+WArp7vbsO5hpItixrqjkxnnNC+h5g==", + "dependencies": [ + "bson", + "idb", + "mingo", + "rxjs" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__db/2.0.0.tgz" + }, + "@jsr/valkyr__event-emitter@1.0.1": { + "integrity": "sha512-mre5tWJddz8LylSQWuLOw3zgIxd2JmhGRV46jKXNPCGzY2NKJwGGT9H7SBw36RV4dW7jnnH2U1aCJkh8IS/pzA==", + "dependencies": [ + "eventemitter3" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-emitter/1.0.1.tgz" + }, + "@jsr/valkyr__event-store@2.0.1": { + "integrity": "sha512-OvSPX0XH5+oS4zQh1O8J7JvsCoH5pBFNuJ1PdNA5B0OascrSWUqpxNEmytOtJhZuhfYzdvyOU1yNEvSI84D5wg==", + "dependencies": [ + "@jsr/valkyr__db", + "@jsr/valkyr__testcontainers", + "mongodb", + "postgres", + "zod" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-store/2.0.1.tgz" + }, + "@jsr/valkyr__json-rpc@1.1.0": { + "integrity": "sha512-i1dwWLI29i5mqRvS2NbI3jUyw8uZuO71hJRvT5+sGAexG8RmQJP4N+ETJkxq0RNwNAGGG1bocuzdqnawa2ahIA==", + "dependencies": [ + "@jsr/valkyr__event-emitter" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__json-rpc/1.1.0.tgz" + }, + "@jsr/valkyr__testcontainers@2.0.2": { + "integrity": "sha512-YnmfraYFr3msoUGrIFeElm03nbQqXOaPu0QUT6JI3w6/mIYpVfzPxghkB7gn2RIc81QgrqjwKJE/AL3dltlR1w==", + "dependencies": [ + "@jsr/std__async", + "@jsr/std__fs", + "@jsr/std__net", + "mongodb", + "postgres" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__testcontainers/2.0.2.tgz" + }, + "@mongodb-js/saslprep@1.3.0": { + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", + "dependencies": [ + "sparse-bitfield" ] }, "@nodelib/fs.scandir@2.1.5": { @@ -141,14 +654,882 @@ "fastq" ] }, - "@types/estree@1.0.7": { - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" + "@radix-ui/number@1.1.1": { + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "@radix-ui/primitive@1.1.3": { + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "@radix-ui/react-arrow@1.1.7_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dependencies": [ + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-avatar@1.1.11_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "dependencies": [ + "@radix-ui/react-context@1.1.3_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-primitive@2.1.4_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-is-hydrated", + "@radix-ui/react-use-layout-effect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-collection@1.1.7_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-compose-refs@1.1.2_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-context@1.1.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-dialog@1.1.15_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-dismissable-layer", + "@radix-ui/react-focus-guards", + "@radix-ui/react-focus-scope", + "@radix-ui/react-id", + "@radix-ui/react-portal", + "@radix-ui/react-presence", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-use-controllable-state", + "@types/react", + "@types/react-dom", + "aria-hidden", + "react", + "react-dom", + "react-remove-scroll" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-direction@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-dismissable-layer@1.1.11_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-escape-keydown", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-dropdown-menu@2.1.16_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-id", + "@radix-ui/react-menu", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-controllable-state", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-focus-guards@1.1.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-focus-scope@1.1.7_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-id@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": [ + "@radix-ui/react-use-layout-effect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-menu@2.1.16_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-collection", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-direction", + "@radix-ui/react-dismissable-layer", + "@radix-ui/react-focus-guards", + "@radix-ui/react-focus-scope", + "@radix-ui/react-id", + "@radix-ui/react-popper", + "@radix-ui/react-portal", + "@radix-ui/react-presence", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-roving-focus", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@types/react", + "@types/react-dom", + "aria-hidden", + "react", + "react-dom", + "react-remove-scroll" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-popper@1.2.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dependencies": [ + "@floating-ui/react-dom", + "@radix-ui/react-arrow", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-layout-effect", + "@radix-ui/react-use-rect", + "@radix-ui/react-use-size", + "@radix-ui/rect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-portal@1.1.9_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dependencies": [ + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-layout-effect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-presence@1.1.5_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@radix-ui/react-use-layout-effect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": [ + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-primitive@2.1.4_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "dependencies": [ + "@radix-ui/react-slot@1.2.4_@types+react@19.1.13_react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-roving-focus@1.1.11_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-collection", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-direction", + "@radix-ui/react-id", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-controllable-state", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-scroll-area@1.2.10_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "dependencies": [ + "@radix-ui/number", + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-direction", + "@radix-ui/react-presence", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-layout-effect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-separator@1.1.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "dependencies": [ + "@radix-ui/react-primitive@2.1.4_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-slot@1.2.4_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-tooltip@1.2.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-dismissable-layer", + "@radix-ui/react-id", + "@radix-ui/react-popper", + "@radix-ui/react-portal", + "@radix-ui/react-presence", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-use-controllable-state", + "@radix-ui/react-visually-hidden", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-use-callback-ref@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-controllable-state@1.2.2_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": [ + "@radix-ui/react-use-effect-event", + "@radix-ui/react-use-layout-effect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-effect-event@0.0.2_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": [ + "@radix-ui/react-use-layout-effect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-escape-keydown@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dependencies": [ + "@radix-ui/react-use-callback-ref", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-is-hydrated@0.1.0_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "dependencies": [ + "@types/react", + "react", + "use-sync-external-store" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-layout-effect@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-rect@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dependencies": [ + "@radix-ui/rect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-size@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dependencies": [ + "@radix-ui/react-use-layout-effect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-visually-hidden@1.2.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dependencies": [ + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/rect@1.1.1": { + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, + "@rolldown/pluginutils@1.0.0-beta.27": { + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==" + }, + "@rollup/rollup-android-arm-eabi@4.52.0": { + "integrity": "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A==", + "os": ["android"], + "cpu": ["arm"] + }, + "@rollup/rollup-android-arm64@4.52.0": { + "integrity": "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-arm64@4.52.0": { + "integrity": "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-x64@4.52.0": { + "integrity": "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rollup/rollup-freebsd-arm64@4.52.0": { + "integrity": "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@rollup/rollup-freebsd-x64@4.52.0": { + "integrity": "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-arm-gnueabihf@4.52.0": { + "integrity": "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm-musleabihf@4.52.0": { + "integrity": "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm64-gnu@4.52.0": { + "integrity": "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-arm64-musl@4.52.0": { + "integrity": "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-loong64-gnu@4.52.0": { + "integrity": "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-ppc64-gnu@4.52.0": { + "integrity": "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-riscv64-gnu@4.52.0": { + "integrity": "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-riscv64-musl@4.52.0": { + "integrity": "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-s390x-gnu@4.52.0": { + "integrity": "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rollup/rollup-linux-x64-gnu@4.52.0": { + "integrity": "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-x64-musl@4.52.0": { + "integrity": "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-openharmony-arm64@4.52.0": { + "integrity": "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-arm64-msvc@4.52.0": { + "integrity": "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-ia32-msvc@4.52.0": { + "integrity": "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@rollup/rollup-win32-x64-gnu@4.52.0": { + "integrity": "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rollup/rollup-win32-x64-msvc@4.52.0": { + "integrity": "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@tabler/icons-react@3.35.0_react@19.1.1": { + "integrity": "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==", + "dependencies": [ + "@tabler/icons", + "react" + ] + }, + "@tabler/icons@3.35.0": { + "integrity": "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==" + }, + "@tailwindcss/node@4.1.13": { + "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "dependencies": [ + "@jridgewell/remapping", + "enhanced-resolve", + "jiti", + "lightningcss", + "magic-string", + "source-map-js", + "tailwindcss" + ] + }, + "@tailwindcss/oxide-android-arm64@4.1.13": { + "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-darwin-arm64@4.1.13": { + "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-darwin-x64@4.1.13": { + "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-freebsd-x64@4.1.13": { + "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13": { + "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@tailwindcss/oxide-linux-arm64-gnu@4.1.13": { + "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-linux-arm64-musl@4.1.13": { + "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-linux-x64-gnu@4.1.13": { + "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-linux-x64-musl@4.1.13": { + "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-wasm32-wasi@4.1.13": { + "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "cpu": ["wasm32"] + }, + "@tailwindcss/oxide-win32-arm64-msvc@4.1.13": { + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-win32-x64-msvc@4.1.13": { + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide@4.1.13": { + "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "dependencies": [ + "detect-libc", + "tar" + ], + "optionalDependencies": [ + "@tailwindcss/oxide-android-arm64", + "@tailwindcss/oxide-darwin-arm64", + "@tailwindcss/oxide-darwin-x64", + "@tailwindcss/oxide-freebsd-x64", + "@tailwindcss/oxide-linux-arm-gnueabihf", + "@tailwindcss/oxide-linux-arm64-gnu", + "@tailwindcss/oxide-linux-arm64-musl", + "@tailwindcss/oxide-linux-x64-gnu", + "@tailwindcss/oxide-linux-x64-musl", + "@tailwindcss/oxide-wasm32-wasi", + "@tailwindcss/oxide-win32-arm64-msvc", + "@tailwindcss/oxide-win32-x64-msvc" + ], + "scripts": true + }, + "@tailwindcss/vite@4.1.13_vite@7.1.6__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0": { + "integrity": "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==", + "dependencies": [ + "@tailwindcss/node", + "@tailwindcss/oxide", + "tailwindcss", + "vite" + ] + }, + "@tanstack/history@1.131.2": { + "integrity": "sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw==" + }, + "@tanstack/query-core@5.89.0": { + "integrity": "sha512-joFV1MuPhSLsKfTzwjmPDrp8ENfZ9N23ymFu07nLfn3JCkSHy0CFgsyhHTJOmWaumC/WiNIKM0EJyduCF/Ih/Q==" + }, + "@tanstack/react-query@5.89.0_react@19.1.1": { + "integrity": "sha512-SXbtWSTSRXyBOe80mszPxpEbaN4XPRUp/i0EfQK1uyj3KCk/c8FuPJNIRwzOVe/OU3rzxrYtiNabsAmk1l714A==", + "dependencies": [ + "@tanstack/query-core", + "react" + ] + }, + "@tanstack/react-router-devtools@1.131.47_@tanstack+react-router@1.131.47__react@19.1.1__react-dom@19.1.1___react@19.1.1_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-lbDUXLvShxC0cjIjzAAUtd+VzLPDJfiEAACykvGnW5dywBD/w20z7Hd8Jx8l/LrYOCI+EY8C6+0UxlnVqF5bdQ==", + "dependencies": [ + "@tanstack/react-router", + "@tanstack/router-devtools-core", + "react", + "react-dom" + ] + }, + "@tanstack/react-router@1.131.47_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-yS5rUPjCvWRg+CZRnY7irKiNZEhLeIsNlwuhIdnIX2K7jU9gOc7pOIT8JI2Vo6IAhh/Mr+7aILKLPYxwTRnS5A==", + "dependencies": [ + "@tanstack/history", + "@tanstack/react-store", + "@tanstack/router-core", + "isbot", + "react", + "react-dom", + "tiny-invariant", + "tiny-warning" + ] + }, + "@tanstack/react-store@0.7.7_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "dependencies": [ + "@tanstack/store", + "react", + "react-dom", + "use-sync-external-store" + ] + }, + "@tanstack/router-core@1.131.47_seroval@1.3.2": { + "integrity": "sha512-ixwowt//SLvnuMoInSxSNCJ41J3S53FLgw8tu5MyXftZ9d7cVOnHoAuSOhKNJNyBDTC2JODC3w/4EH3KDMj6ew==", + "dependencies": [ + "@tanstack/history", + "@tanstack/store", + "cookie-es", + "seroval", + "seroval-plugins", + "tiny-invariant", + "tiny-warning" + ] + }, + "@tanstack/router-devtools-core@1.131.47_@tanstack+router-core@1.131.47__seroval@1.3.2_solid-js@1.9.9__seroval@1.3.2_tiny-invariant@1.3.3": { + "integrity": "sha512-XKeTfZcy5RmlPUUYkidIeK/KIfjSWo1cFp0P9L+LleclbVa6pkIfjocSHqUiHM5wGlxkbC5EzZfLBqs2xTinuA==", + "dependencies": [ + "@tanstack/router-core", + "clsx", + "goober", + "solid-js", + "tiny-invariant" + ] + }, + "@tanstack/store@0.7.7": { + "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==" + }, + "@types/babel__core@7.20.5": { + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@types/babel__generator", + "@types/babel__template", + "@types/babel__traverse" + ] + }, + "@types/babel__generator@7.27.0": { + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/babel__template@7.4.4": { + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dependencies": [ + "@babel/parser", + "@babel/types" + ] + }, + "@types/babel__traverse@7.28.0": { + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, "@types/json-schema@7.0.15": { "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, - "@typescript-eslint/eslint-plugin@8.30.1_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3_eslint@9.24.0_typescript@5.8.3": { - "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types" + ] + }, + "@types/react-dom@19.1.9_@types+react@19.1.13": { + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dependencies": [ + "@types/react" + ] + }, + "@types/react@19.1.13": { + "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "dependencies": [ + "csstype" + ] + }, + "@types/webidl-conversions@7.0.3": { + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "@types/whatwg-url@11.0.5": { + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": [ + "@types/webidl-conversions" + ] + }, + "@typescript-eslint/eslint-plugin@8.44.0_@typescript-eslint+parser@8.44.0__eslint@9.35.0__typescript@5.9.2_eslint@9.35.0_typescript@5.9.2": { + "integrity": "sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==", "dependencies": [ "@eslint-community/regexpp", "@typescript-eslint/parser", @@ -158,14 +1539,14 @@ "@typescript-eslint/visitor-keys", "eslint", "graphemer", - "ignore", + "ignore@7.0.5", "natural-compare", "ts-api-utils", "typescript" ] }, - "@typescript-eslint/parser@8.30.1_eslint@9.24.0_typescript@5.8.3": { - "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", + "@typescript-eslint/parser@8.44.0_eslint@9.35.0_typescript@5.9.2": { + "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", "dependencies": [ "@typescript-eslint/scope-manager", "@typescript-eslint/types", @@ -176,16 +1557,32 @@ "typescript" ] }, - "@typescript-eslint/scope-manager@8.30.1": { - "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", + "@typescript-eslint/project-service@8.44.0_typescript@5.9.2": { + "integrity": "sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==", + "dependencies": [ + "@typescript-eslint/tsconfig-utils", + "@typescript-eslint/types", + "debug", + "typescript" + ] + }, + "@typescript-eslint/scope-manager@8.44.0": { + "integrity": "sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==", "dependencies": [ "@typescript-eslint/types", "@typescript-eslint/visitor-keys" ] }, - "@typescript-eslint/type-utils@8.30.1_eslint@9.24.0_typescript@5.8.3": { - "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", + "@typescript-eslint/tsconfig-utils@8.44.0_typescript@5.9.2": { + "integrity": "sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==", "dependencies": [ + "typescript" + ] + }, + "@typescript-eslint/type-utils@8.44.0_eslint@9.35.0_typescript@5.9.2": { + "integrity": "sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==", + "dependencies": [ + "@typescript-eslint/types", "@typescript-eslint/typescript-estree", "@typescript-eslint/utils", "debug", @@ -194,25 +1591,27 @@ "typescript" ] }, - "@typescript-eslint/types@8.30.1": { - "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==" + "@typescript-eslint/types@8.44.0": { + "integrity": "sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==" }, - "@typescript-eslint/typescript-estree@8.30.1_typescript@5.8.3": { - "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", + "@typescript-eslint/typescript-estree@8.44.0_typescript@5.9.2": { + "integrity": "sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==", "dependencies": [ + "@typescript-eslint/project-service", + "@typescript-eslint/tsconfig-utils", "@typescript-eslint/types", "@typescript-eslint/visitor-keys", "debug", "fast-glob", "is-glob", "minimatch@9.0.5", - "semver", + "semver@7.7.2", "ts-api-utils", "typescript" ] }, - "@typescript-eslint/utils@8.30.1_eslint@9.24.0_typescript@5.8.3": { - "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", + "@typescript-eslint/utils@8.44.0_eslint@9.35.0_typescript@5.9.2": { + "integrity": "sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==", "dependencies": [ "@eslint-community/eslint-utils", "@typescript-eslint/scope-manager", @@ -222,24 +1621,40 @@ "typescript" ] }, - "@typescript-eslint/visitor-keys@8.30.1": { - "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", + "@typescript-eslint/visitor-keys@8.44.0": { + "integrity": "sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==", "dependencies": [ "@typescript-eslint/types", - "eslint-visitor-keys@4.2.0" + "eslint-visitor-keys@4.2.1" ] }, - "@zod/core@0.6.2": { - "integrity": "sha512-KdH7bT0BRG1CvJ1LWH8oyNnkvLpjVZ5qVGpRu7Vq8WsFTKRDWfdr3rFfBYh8atZJSWDgD0ibhOyff1AyRvG1DA==" + "@vitejs/plugin-react@4.7.0_vite@7.1.6__@types+node@24.2.0__picomatch@4.0.3_@babel+core@7.28.4_@types+node@24.2.0": { + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dependencies": [ + "@babel/core", + "@babel/plugin-transform-react-jsx-self", + "@babel/plugin-transform-react-jsx-source", + "@rolldown/pluginutils", + "@types/babel__core", + "react-refresh", + "vite" + ] }, - "acorn-jsx@5.3.2_acorn@8.14.1": { + "@zitadel/react@1.1.0": { + "integrity": "sha512-aMad1iZNpsZgEtUvSIyjCt1TdCzg++OMg3GwdPbFhnHTHwQMoSRB9IrYuD0grHK0TqU7yx283iJO5te2ToRWtA==", + "dependencies": [ + "oidc-client-ts" + ] + }, + "acorn-jsx@5.3.2_acorn@8.15.0": { "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dependencies": [ "acorn" ] }, - "acorn@8.14.1": { - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==" + "acorn@8.15.0": { + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "bin": true }, "ajv@6.12.6": { "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", @@ -259,18 +1674,28 @@ "argparse@2.0.1": { "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "aria-hidden@1.2.6": { + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dependencies": [ + "tslib" + ] + }, "balanced-match@1.0.2": { "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "brace-expansion@1.1.11": { - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "baseline-browser-mapping@2.8.6": { + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "bin": true + }, + "brace-expansion@1.1.12": { + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dependencies": [ "balanced-match", "concat-map" ] }, - "brace-expansion@2.0.1": { - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "brace-expansion@2.0.2": { + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dependencies": [ "balanced-match" ] @@ -281,9 +1706,40 @@ "fill-range" ] }, + "browserslist@4.26.2": { + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dependencies": [ + "baseline-browser-mapping", + "caniuse-lite", + "electron-to-chromium", + "node-releases", + "update-browserslist-db" + ], + "bin": true + }, + "bson@6.10.4": { + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==" + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "call-bound@1.0.4": { + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": [ + "call-bind-apply-helpers", + "get-intrinsic" + ] + }, "callsites@3.1.0": { "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" }, + "caniuse-lite@1.0.30001743": { + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==" + }, "chalk@4.1.2": { "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": [ @@ -291,6 +1747,18 @@ "supports-color" ] }, + "chownr@3.0.0": { + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" + }, + "class-variance-authority@0.7.1": { + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": [ + "clsx" + ] + }, + "clsx@2.1.1": { + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, "color-convert@2.0.1": { "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": [ @@ -303,6 +1771,12 @@ "concat-map@0.0.1": { "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "convert-source-map@2.0.0": { + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "cookie-es@1.2.2": { + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==" + }, "cross-spawn@7.0.6": { "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": [ @@ -311,8 +1785,14 @@ "which" ] }, - "debug@4.4.0": { - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "crypto-js@4.2.0": { + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, + "csstype@3.1.3": { + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": [ "ms" ] @@ -320,17 +1800,95 @@ "deep-is@0.1.4": { "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "detect-libc@2.1.0": { + "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==" + }, + "detect-node-es@1.1.0": { + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "electron-to-chromium@1.5.222": { + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==" + }, + "enhanced-resolve@5.18.3": { + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dependencies": [ + "graceful-fs", + "tapable" + ] + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.1": { + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": [ + "es-errors" + ] + }, + "esbuild@0.25.10": { + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, "escape-string-regexp@4.0.0": { "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, - "eslint-plugin-simple-import-sort@12.1.1_eslint@9.24.0": { - "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==", + "eslint-plugin-react-hooks@5.2.0_eslint@9.35.0": { + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dependencies": [ "eslint" ] }, - "eslint-scope@8.3.0": { - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "eslint-plugin-react-refresh@0.4.20_eslint@9.35.0": { + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dependencies": [ + "eslint" + ] + }, + "eslint-scope@8.4.0": { + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dependencies": [ "esrecurse", "estraverse" @@ -339,23 +1897,23 @@ "eslint-visitor-keys@3.4.3": { "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" }, - "eslint-visitor-keys@4.2.0": { - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==" + "eslint-visitor-keys@4.2.1": { + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==" }, - "eslint@9.24.0": { - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "eslint@9.35.0": { + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dependencies": [ "@eslint-community/eslint-utils", "@eslint-community/regexpp", "@eslint/config-array", "@eslint/config-helpers", - "@eslint/core@0.12.0", + "@eslint/core", "@eslint/eslintrc", "@eslint/js", "@eslint/plugin-kit", "@humanfs/node", "@humanwhocodes/module-importer", - "@humanwhocodes/retry@0.4.2", + "@humanwhocodes/retry", "@types/estree", "@types/json-schema", "ajv", @@ -364,7 +1922,7 @@ "debug", "escape-string-regexp", "eslint-scope", - "eslint-visitor-keys@4.2.0", + "eslint-visitor-keys@4.2.1", "espree", "esquery", "esutils", @@ -372,7 +1930,7 @@ "file-entry-cache", "find-up", "glob-parent@6.0.2", - "ignore", + "ignore@5.3.2", "imurmurhash", "is-glob", "json-stable-stringify-without-jsonify", @@ -380,14 +1938,15 @@ "minimatch@3.1.2", "natural-compare", "optionator" - ] + ], + "bin": true }, - "espree@10.3.0_acorn@8.14.1": { - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "espree@10.4.0_acorn@8.15.0": { + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dependencies": [ "acorn", "acorn-jsx", - "eslint-visitor-keys@4.2.0" + "eslint-visitor-keys@4.2.1" ] }, "esquery@1.6.0": { @@ -408,9 +1967,15 @@ "esutils@2.0.3": { "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, + "eventemitter3@5.0.1": { + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "fast-equals@5.2.2": { + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==" + }, "fast-glob@3.3.3": { "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dependencies": [ @@ -433,6 +1998,15 @@ "reusify" ] }, + "fdir@6.5.0_picomatch@4.0.3": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dependencies": [ + "picomatch@4.0.3" + ], + "optionalPeers": [ + "picomatch@4.0.3" + ] + }, "file-entry-cache@8.0.0": { "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dependencies": [ @@ -462,6 +2036,42 @@ "flatted@3.3.3": { "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "gensync@1.0.0-beta.2": { + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-nonce@1.0.1": { + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, "glob-parent@5.1.2": { "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dependencies": [ @@ -477,15 +2087,45 @@ "globals@14.0.0": { "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" }, + "globals@16.4.0": { + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==" + }, + "goober@2.1.16_csstype@3.1.3": { + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "dependencies": [ + "csstype" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "graceful-fs@4.2.11": { + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, "graphemer@1.4.0": { "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" }, "has-flag@4.0.0": { "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "hasown@2.0.2": { + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": [ + "function-bind" + ] + }, + "idb@8.0.3": { + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==" + }, "ignore@5.3.2": { "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" }, + "ignore@7.0.5": { + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==" + }, "import-fresh@3.3.1": { "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dependencies": [ @@ -508,14 +2148,32 @@ "is-number@7.0.0": { "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, + "isbot@5.1.30": { + "integrity": "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA==" + }, "isexe@2.0.0": { "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "jiti@2.5.1": { + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "bin": true + }, + "jose@6.1.0": { + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==" + }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, "js-yaml@4.1.0": { "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dependencies": [ "argparse" - ] + ], + "bin": true + }, + "jsesc@3.1.0": { + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": true }, "json-buffer@3.0.1": { "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" @@ -526,6 +2184,13 @@ "json-stable-stringify-without-jsonify@1.0.1": { "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, + "json5@2.2.3": { + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": true + }, + "jwt-decode@3.1.2": { + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "keyv@4.5.4": { "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dependencies": [ @@ -539,6 +2204,74 @@ "type-check" ] }, + "lightningcss-darwin-arm64@1.30.1": { + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-x64@1.30.1": { + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "lightningcss-freebsd-x64@1.30.1": { + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "lightningcss-linux-arm-gnueabihf@1.30.1": { + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "os": ["linux"], + "cpu": ["arm"] + }, + "lightningcss-linux-arm64-gnu@1.30.1": { + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-arm64-musl@1.30.1": { + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-x64-gnu@1.30.1": { + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-linux-x64-musl@1.30.1": { + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-win32-arm64-msvc@1.30.1": { + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "lightningcss-win32-x64-msvc@1.30.1": { + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "lightningcss@1.30.1": { + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dependencies": [ + "detect-libc" + ], + "optionalDependencies": [ + "lightningcss-darwin-arm64", + "lightningcss-darwin-x64", + "lightningcss-freebsd-x64", + "lightningcss-linux-arm-gnueabihf", + "lightningcss-linux-arm64-gnu", + "lightningcss-linux-arm64-musl", + "lightningcss-linux-x64-gnu", + "lightningcss-linux-x64-musl", + "lightningcss-win32-arm64-msvc", + "lightningcss-win32-x64-msvc" + ] + }, "locate-path@6.0.0": { "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dependencies": [ @@ -548,6 +2281,30 @@ "lodash.merge@4.6.2": { "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "lru-cache@5.1.1": { + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": [ + "yallist@3.1.1" + ] + }, + "lucide-react@0.554.0_react@19.1.1": { + "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==", + "dependencies": [ + "react" + ] + }, + "magic-string@0.30.19": { + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "memory-pager@1.5.0": { + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "merge2@1.4.1": { "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" }, @@ -555,27 +2312,75 @@ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": [ "braces", - "picomatch" + "picomatch@2.3.1" ] }, + "mingo@6.6.1": { + "integrity": "sha512-KC6b1ODYoSdYu5fBm+SzQb7fa4ARmGwfa3Cf9F7U+2mnfD4Zhf89qQgO1cPTtaJ68w3ntIT5dVujgF52HvN7+g==" + }, "minimatch@3.1.2": { "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dependencies": [ - "brace-expansion@1.1.11" + "brace-expansion@1.1.12" ] }, "minimatch@9.0.5": { "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": [ - "brace-expansion@2.0.1" + "brace-expansion@2.0.2" + ] + }, + "minipass@7.1.2": { + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" + }, + "minizlib@3.1.0": { + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dependencies": [ + "minipass" + ] + }, + "mongodb-connection-string-url@3.0.2": { + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dependencies": [ + "@types/whatwg-url", + "whatwg-url" + ] + }, + "mongodb@6.20.0": { + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "dependencies": [ + "@mongodb-js/saslprep", + "bson", + "mongodb-connection-string-url" ] }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "nanoid@3.3.11": { + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": true + }, + "nanoid@5.1.5": { + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "bin": true + }, "natural-compare@1.4.0": { "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, + "node-releases@2.0.21": { + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==" + }, + "object-inspect@1.13.4": { + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "oidc-client-ts@2.4.1": { + "integrity": "sha512-IxlGMsbkZPsHJGCliWT3LxjUcYzmiN21656n/Zt2jDncZlBFc//cd8WqFF0Lt681UT3AImM57E6d4N53ziTCYA==", + "dependencies": [ + "crypto-js", + "jwt-decode" + ] + }, "optionator@0.9.4": { "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dependencies": [ @@ -611,35 +2416,165 @@ "path-key@3.1.1": { "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" }, + "path-to-regexp@8.3.0": { + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, "picomatch@2.3.1": { "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, + "picomatch@4.0.3": { + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + }, + "postcss@8.5.6": { + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dependencies": [ + "nanoid@3.3.11", + "picocolors", + "source-map-js" + ] + }, + "postgres@3.4.7": { + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==" + }, "prelude-ls@1.2.1": { "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, - "prettier@3.5.3": { - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==" - }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, + "qs@6.14.0": { + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": [ + "side-channel" + ] + }, "queue-microtask@1.2.3": { "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, + "react-dom@19.1.1_react@19.1.1": { + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "dependencies": [ + "react", + "scheduler" + ] + }, + "react-refresh@0.17.0": { + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==" + }, + "react-remove-scroll-bar@2.3.8_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": [ + "@types/react", + "react", + "react-style-singleton", + "tslib" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "react-remove-scroll@2.7.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "dependencies": [ + "@types/react", + "react", + "react-remove-scroll-bar", + "react-style-singleton", + "tslib", + "use-callback-ref", + "use-sidecar" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "react-style-singleton@2.2.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": [ + "@types/react", + "get-nonce", + "react", + "tslib" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "react@19.1.1": { + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==" + }, "resolve-from@4.0.0": { "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, "reusify@1.1.0": { "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" }, + "rollup@4.52.0": { + "integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==", + "dependencies": [ + "@types/estree" + ], + "optionalDependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-freebsd-arm64", + "@rollup/rollup-freebsd-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-loong64-gnu", + "@rollup/rollup-linux-ppc64-gnu", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-riscv64-musl", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-openharmony-arm64", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-gnu", + "@rollup/rollup-win32-x64-msvc", + "fsevents" + ], + "bin": true + }, "run-parallel@1.2.0": { "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dependencies": [ "queue-microtask" ] }, - "semver@7.7.1": { - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" + "rxjs@7.8.2": { + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dependencies": [ + "tslib" + ] + }, + "scheduler@0.26.0": { + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" + }, + "semver@6.3.1": { + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": true + }, + "semver@7.7.2": { + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "bin": true + }, + "seroval-plugins@1.3.3_seroval@1.3.2": { + "integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==", + "dependencies": [ + "seroval" + ] + }, + "seroval@1.3.2": { + "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==" }, "shebang-command@2.0.0": { "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", @@ -650,6 +2585,59 @@ "shebang-regex@3.0.0": { "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, + "side-channel-list@1.0.0": { + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": [ + "es-errors", + "object-inspect" + ] + }, + "side-channel-map@1.0.1": { + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect" + ] + }, + "side-channel-weakmap@1.0.2": { + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect", + "side-channel-map" + ] + }, + "side-channel@1.1.0": { + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": [ + "es-errors", + "object-inspect", + "side-channel-list", + "side-channel-map", + "side-channel-weakmap" + ] + }, + "solid-js@1.9.9_seroval@1.3.2": { + "integrity": "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==", + "dependencies": [ + "csstype", + "seroval", + "seroval-plugins" + ] + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "sparse-bitfield@3.0.3": { + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": [ + "memory-pager" + ] + }, "strip-json-comments@3.1.1": { "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, @@ -659,36 +2647,100 @@ "has-flag" ] }, + "tailwind-merge@3.4.0": { + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==" + }, + "tailwindcss-animate@1.0.7_tailwindcss@4.1.13": { + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dependencies": [ + "tailwindcss" + ] + }, + "tailwindcss@4.1.13": { + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==" + }, + "tapable@2.2.3": { + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==" + }, + "tar@7.4.4": { + "integrity": "sha512-O1z7ajPkjTgEgmTGz0v9X4eqeEXTDREPTO77pVC1Nbs86feBU1Zhdg+edzavPmYW1olxkwsqA2v4uOw6E8LeDg==", + "dependencies": [ + "@isaacs/fs-minipass", + "chownr", + "minipass", + "minizlib", + "yallist@5.0.0" + ] + }, + "tiny-invariant@1.3.3": { + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "tiny-warning@1.0.3": { + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "tinyglobby@0.2.15_picomatch@4.0.3": { + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": [ + "fdir", + "picomatch@4.0.3" + ] + }, "to-regex-range@5.0.1": { "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dependencies": [ "is-number" ] }, - "ts-api-utils@2.1.0_typescript@5.8.3": { + "tr46@5.1.1": { + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": [ + "punycode" + ] + }, + "ts-api-utils@2.1.0_typescript@5.9.2": { "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dependencies": [ "typescript" ] }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "tw-animate-css@1.4.0": { + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==" + }, "type-check@0.4.0": { "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dependencies": [ "prelude-ls" ] }, - "typescript-eslint@8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3": { - "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", + "typescript-eslint@8.44.0_eslint@9.35.0_typescript@5.9.2_@typescript-eslint+parser@8.44.0__eslint@9.35.0__typescript@5.9.2": { + "integrity": "sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==", "dependencies": [ "@typescript-eslint/eslint-plugin", "@typescript-eslint/parser", + "@typescript-eslint/typescript-estree", "@typescript-eslint/utils", "eslint", "typescript" ] }, - "typescript@5.8.3": { - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==" + "typescript@5.9.2": { + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "bin": true + }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + }, + "update-browserslist-db@1.1.3_browserslist@4.26.2": { + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dependencies": [ + "browserslist", + "escalade", + "picocolors" + ], + "bin": true }, "uri-js@4.4.1": { "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", @@ -696,36 +2748,234 @@ "punycode" ] }, + "use-callback-ref@1.3.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": [ + "@types/react", + "react", + "tslib" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "use-sidecar@1.1.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": [ + "@types/react", + "detect-node-es", + "react", + "tslib" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "use-sync-external-store@1.5.0_react@19.1.1": { + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "dependencies": [ + "react" + ] + }, + "uuid@13.0.0": { + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "bin": true + }, + "vite@7.1.6_@types+node@24.2.0_picomatch@4.0.3": { + "integrity": "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==", + "dependencies": [ + "@types/node", + "esbuild", + "fdir", + "picomatch@4.0.3", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node" + ], + "bin": true + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "whatwg-url@14.2.0": { + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, "which@2.0.2": { "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ "isexe" - ] + ], + "bin": true }, "word-wrap@1.2.5": { "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" }, + "yallist@3.1.1": { + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "yallist@5.0.0": { + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" + }, "yocto-queue@0.1.0": { "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, - "zod@4.0.0-beta.20250417T043022": { - "integrity": "sha512-zjfYudLXPgHvRdCWzy/iJqhB6suE8tBqnGubbFHSkMvcknI4iexEP53QCO13FoC/EIALseuZReVykCY8yd/skA==", - "dependencies": [ - "@zod/core" - ] + "zod@4.1.12": { + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==" } }, "workspace": { "packageJson": { "dependencies": [ - "npm:@jsr/std__assert@1.0.12", - "npm:@jsr/std__testing@1.0.11", - "npm:eslint-plugin-simple-import-sort@12.1.1", - "npm:eslint@9.24.0", - "npm:prettier@3.5.3", - "npm:typescript-eslint@8.30.1", - "npm:zod@next" + "npm:@biomejs/biome@2.2.4", + "npm:@jsr/std__assert@1.0.14", + "npm:@jsr/std__testing@1.0.15" ] + }, + "members": { + "api": { + "packageJson": { + "dependencies": [ + "npm:zod@4.1.12" + ] + } + }, + "apps/react": { + "packageJson": { + "dependencies": [ + "npm:@eslint/js@9.35.0", + "npm:@jsr/valkyr__db@2.0.0", + "npm:@jsr/valkyr__event-emitter@1.0.1", + "npm:@radix-ui/react-avatar@^1.1.11", + "npm:@radix-ui/react-dialog@^1.1.15", + "npm:@radix-ui/react-dropdown-menu@^2.1.16", + "npm:@radix-ui/react-scroll-area@^1.2.10", + "npm:@radix-ui/react-separator@^1.1.8", + "npm:@radix-ui/react-slot@^1.2.4", + "npm:@radix-ui/react-tooltip@^1.2.8", + "npm:@tabler/icons-react@3.35.0", + "npm:@tailwindcss/vite@4.1.13", + "npm:@tanstack/react-query@5.89.0", + "npm:@tanstack/react-router-devtools@1.131.47", + "npm:@tanstack/react-router@1.131.47", + "npm:@types/react-dom@19.1.9", + "npm:@types/react@19.1.13", + "npm:@vitejs/plugin-react@4.7.0", + "npm:@zitadel/react@1.1.0", + "npm:class-variance-authority@~0.7.1", + "npm:clsx@^2.1.1", + "npm:eslint-plugin-react-hooks@5.2.0", + "npm:eslint-plugin-react-refresh@0.4.20", + "npm:eslint@9.35.0", + "npm:fast-equals@5.2.2", + "npm:globals@16.4.0", + "npm:lucide-react@0.554", + "npm:react-dom@19.1.1", + "npm:react@19.1.1", + "npm:tailwind-merge@^3.4.0", + "npm:tailwindcss-animate@^1.0.7", + "npm:tailwindcss@4.1.13", + "npm:tw-animate-css@1.4.0", + "npm:typescript-eslint@8.44.0", + "npm:typescript@5.9.2", + "npm:vite@7.1.6", + "npm:zod@4.1.12" + ] + } + }, + "modules/account": { + "packageJson": { + "dependencies": [ + "npm:zod@4.1.12" + ] + } + }, + "platform/cerbos": { + "packageJson": { + "dependencies": [ + "npm:@cerbos/core@0.25.1", + "npm:@cerbos/http@0.23.3" + ] + } + }, + "platform/config": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__dotenv@0.225.5", + "npm:zod@4.1.12" + ] + } + }, + "platform/database": { + "packageJson": { + "dependencies": [ + "npm:postgres@3.4.7", + "npm:zod@4.1.12" + ] + } + }, + "platform/logger": { + "packageJson": { + "dependencies": [ + "npm:@jsr/valkyr__event-store@2.0.1", + "npm:zod@4.1.12" + ] + } + }, + "platform/relay": { + "packageJson": { + "dependencies": [ + "npm:path-to-regexp@8", + "npm:zod@4.1.12" + ] + } + }, + "platform/routes": { + "packageJson": { + "dependencies": [ + "npm:zod@4.1.12" + ] + } + }, + "platform/server": { + "packageJson": { + "dependencies": [ + "npm:@jsr/valkyr__json-rpc@1.1.0", + "npm:zod@4.1.12" + ] + } + }, + "platform/socket": { + "packageJson": { + "dependencies": [ + "npm:@jsr/valkyr__json-rpc@1.1.0" + ] + } + }, + "platform/spec": { + "packageJson": { + "dependencies": [ + "npm:zod@4.1.12" + ] + } + }, + "platform/vault": { + "packageJson": { + "dependencies": [ + "npm:jose@6.1.0", + "npm:nanoid@5.1.5" + ] + } + } } } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fb5a455 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +networks: + server: + name: server + +volumes: + mongo: + driver: local + +services: + + # MongoDB + # -------------------------------------------------------------------------------- + # Used by event store and read store for managing and reading application data. + + mongo: + restart: unless-stopped + image: mongo:8 + container_name: boilerplate_mongo + ports: + - 6017:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: password + volumes: + - mongo:/data/db + networks: + - server + + # Cerbos + # -------------------------------------------------------------------------------- + # Policy engine for application access control. + + cerbos: + restart: unless-stopped + image: ghcr.io/cerbos/cerbos:latest + container_name: boilerplate_cerbos + command: ["server", "--config=/config.yaml"] + ports: + - 6592:3592 + - 6593:3593 + - 6594:3594 + volumes: + - ./cerbos.yaml:/config.yaml + networks: + - server diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index ff5356c..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import simpleImportSort from "eslint-plugin-simple-import-sort"; -import tseslint from "typescript-eslint"; - -export default [ - ...tseslint.configs.recommended, - { - plugins: { - "simple-import-sort": simpleImportSort, - }, - rules: { - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - }, - }, - { - files: ["**/*.ts"], - rules: { - "@typescript-eslint/ban-ts-comment": ["error", { - "ts-expect-error": "allow-with-description", - minimumDescriptionLength: 10, - }], - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": ["error", { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - }], - }, - }, -]; diff --git a/libraries/action.ts b/libraries/action.ts deleted file mode 100644 index 5a69064..0000000 --- a/libraries/action.ts +++ /dev/null @@ -1,63 +0,0 @@ -import z, { ZodObject, ZodRawShape } from "zod"; - -export class Action { - constructor(readonly state: TActionState) {} - - /** - * Input object required by the action to fulfill its function. - * - * @param input - Schema defining the input requirements of the action. - */ - input(input: TInput): Action & { input: ZodObject }> { - return new Action({ ...this.state, input: z.object(input) as any }); - } - - /** - * Output object defining the result shape of the action. - * - * @param output - Schema defining the result shape. - */ - output(output: TOutput): Action & { output: ZodObject }> { - return new Action({ ...this.state, output: z.object(output) as any }); - } - - /** - * Add handler method to the action. - * - * @param handle - Handler method. - */ - handle>( - handle: THandleFn, - ): Action & { handle: THandleFn }> { - return new Action({ ...this.state, handle }); - } -} - -/* - |-------------------------------------------------------------------------------- - | Factory - |-------------------------------------------------------------------------------- - */ - -export const action = { - make(name: string) { - return new Action({ name }); - }, -}; - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -type ActionState = { - name: string; - input?: ZodObject; - output?: ZodObject; - handle?: ActionHandlerFn; -}; - -type ActionHandlerFn = TInput extends ZodObject - ? (input: z.infer) => TOutput extends ZodObject ? Promise> : Promise - : () => TOutput extends ZodObject ? Promise> : Promise; diff --git a/libraries/errors.ts b/libraries/errors.ts deleted file mode 100644 index 9fd6683..0000000 --- a/libraries/errors.ts +++ /dev/null @@ -1,227 +0,0 @@ -export abstract class RelayError extends Error { - constructor( - message: string, - readonly status: number, - readonly data?: D, - ) { - super(message); - } - - toJSON() { - return { - status: this.status, - message: this.message, - data: this.data, - }; - } -} - -export class BadRequestError extends RelayError { - /** - * Instantiate a new BadRequestError. - * - * The **HTTP 400 Bad Request** response status code indicates that the server - * cannot or will not process the request due to something that is perceived to - * be a client error. - * - * @param data - Optional data to send with the error. - */ - constructor(message = "Bad Request", data?: D) { - super(message, 400, data); - } -} - -export class UnauthorizedError extends RelayError { - /** - * Instantiate a new UnauthorizedError. - * - * The **HTTP 401 Unauthorized** response status code indicates that the client - * request has not been completed because it lacks valid authentication - * credentials for the requested resource. - * - * This status code is sent with an HTTP WWW-Authenticate response header that - * contains information on how the client can request for the resource again after - * prompting the user for authentication credentials. - * - * This status code is similar to the **403 Forbidden** status code, except that - * in situations resulting in this status code, user authentication can allow - * access to the resource. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 - * - * @param message - Optional message to send with the error. Default: "Unauthorized". - * @param data - Optional data to send with the error. - */ - constructor(message = "Unauthorized", data?: D) { - super(message, 401, data); - } -} - -export class ForbiddenError extends RelayError { - /** - * Instantiate a new ForbiddenError. - * - * The **HTTP 403 Forbidden** response status code indicates that the server - * understands the request but refuses to authorize it. - * - * This status is similar to **401**, but for the **403 Forbidden** status code - * re-authenticating makes no difference. The access is permanently forbidden and - * tied to the application logic, such as insufficient rights to a resource. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403 - * - * @param message - Optional message to send with the error. Default: "Forbidden". - * @param data - Optional data to send with the error. - */ - constructor(message = "Forbidden", data?: D) { - super(message, 403, data); - } -} - -export class NotFoundError extends RelayError { - /** - * Instantiate a new NotFoundError. - * - * The **HTTP 404 Not Found** response status code indicates that the server - * cannot find the requested resource. Links that lead to a 404 page are often - * called broken or dead links and can be subject to link rot. - * - * A 404 status code only indicates that the resource is missing: not whether the - * absence is temporary or permanent. If a resource is permanently removed, - * use the **410 _(Gone)_** status instead. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 - * - * @param message - Optional message to send with the error. Default: "Not Found". - * @param data - Optional data to send with the error. - */ - constructor(message = "Not Found", data?: D) { - super(message, 404, data); - } -} - -export class NotAcceptableError extends RelayError { - /** - * Instantiate a new NotAcceptableError. - * - * The **HTTP 406 Not Acceptable** client error response code indicates that the - * server cannot produce a response matching the list of acceptable values - * defined in the request, and that the server is unwilling to supply a default - * representation. - * - * @param message - Optional message to send with the error. Default: "Not Acceptable". - * @param data - Optional data to send with the error. - */ - constructor(message = "Not Acceptable", data?: D) { - super(message, 406, data); - } -} - -export class ConflictError extends RelayError { - /** - * Instantiate a new ConflictError. - * - * The **HTTP 409 Conflict** response status code indicates a request conflict - * with the current state of the target resource. - * - * Conflicts are most likely to occur in response to a PUT request. For example, - * you may get a 409 response when uploading a file that is older than the - * existing one on the server, resulting in a version control conflict. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409 - * - * @param message - Optional message to send with the error. Default: "Conflict". - * @param data - Optional data to send with the error. - */ - constructor(message = "Conflict", data?: D) { - super(message, 409, data); - } -} - -export class GoneError extends RelayError { - /** - * Instantiate a new GoneError. - * - * The **HTTP 410 Gone** indicates that the target resource is no longer - * available at the origin server and that this condition is likely to be - * permanent. A 410 response is cacheable by default. - * - * Clients should not repeat requests for resources that return a 410 response, - * and website owners should remove or replace links that return this code. If - * server owners don't know whether this condition is temporary or permanent, - * a 404 status code should be used instead. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/410 - * - * @param message - Optional message to send with the error. Default: "Gone". - * @param data - Optional data to send with the error. - */ - constructor(message = "Gone", data?: D) { - super(message, 410, data); - } -} - -export class UnprocessableContentError extends RelayError { - /** - * Instantiate a new UnprocessableContentError. - * - * The **HTTP 422 Unprocessable Content** client error response status code - * indicates that the server understood the content type of the request entity, - * and the syntax of the request entity was correct, but it was unable to - * process the contained instructions. - * - * Clients that receive a 422 response should expect that repeating the request - * without modification will fail with the same error. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422 - * - * @param message - Optional message to send with the error. Default: "Unprocessable Content". - * @param data - Optional data to send with the error. - */ - constructor(message = "Unprocessable Content", data?: D) { - super(message, 422, data); - } -} - -export class InternalServerError extends RelayError { - /** - * Instantiate a new InternalServerError. - * - * The **HTTP 500 Internal Server Error** server error response code indicates that - * the server encountered an unexpected condition that prevented it from fulfilling - * the request. - * - * This error response is a generic "catch-all" response. Usually, this indicates - * the server cannot find a better 5xx error code to response. Sometimes, server - * administrators log error responses like the 500 status code with more details - * about the request to prevent the error from happening again in the future. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 - * - * @param message - Optional message to send with the error. Default: "Internal Server Error". - * @param data - Optional data to send with the error. - */ - constructor(message = "Internal Server Error", data?: D) { - super(message, 500, data); - } -} - -export class ServiceUnavailableError extends RelayError { - /** - * Instantiate a new ServiceUnavailableError. - * - * The **HTTP 503 Service Unavailable** server error response status code indicates - * that the server is not ready to handle the request. - * - * This response should be used for temporary conditions and the Retry-After HTTP header - * should contain the estimated time for the recovery of the service, if possible. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503 - * - * @param message - Optional message to send with the error. Default: "Service Unavailable". - * @param data - Optional data to send with the error. - */ - constructor(message = "Service Unavailable", data?: D) { - super(message, 503, data); - } -} diff --git a/libraries/relay.ts b/libraries/relay.ts deleted file mode 100644 index 75b0526..0000000 --- a/libraries/relay.ts +++ /dev/null @@ -1,449 +0,0 @@ -import z, { ZodType } from "zod"; - -import { BadRequestError, NotFoundError, RelayError } from "./errors.ts"; -import { Route, RouteMethod } from "./route.ts"; - -const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; - -export class Relay { - /** - * Route maps funneling registered routes to the specific methods supported by - * the relay instance. - */ - readonly routes: Routes = { - POST: [], - GET: [], - PUT: [], - PATCH: [], - DELETE: [], - }; - - /** - * List of paths in the '${method} ${path}' format allowing us to quickly throw - * errors if a duplicate route path is being added. - */ - readonly #paths = new Set(); - - /** - * Route index in the '${method} ${path}' format allowing for quick access to - * a specific route. - */ - readonly #index = new Map(); - - /** - * Instantiate a new Relay instance. - * - * @param config - Relay configuration to apply to the instance. - * @param routes - Routes to register with the instance. - */ - constructor( - readonly config: RelayConfig, - routes: TRoutes, - ) { - const methods: (keyof typeof this.routes)[] = []; - for (const route of routes) { - this.#validateRoutePath(route); - this.routes[route.method].push(route); - methods.push(route.method); - this.#index.set(`${route.method} ${route.path}`, route); - } - for (const method of methods) { - this.routes[method].sort(byStaticPriority); - } - } - - /* - |-------------------------------------------------------------------------------- - | Agnostic - |-------------------------------------------------------------------------------- - */ - - /** - * Retrieve a route for the given method/path combination which can be further extended - * for serving incoming third party requests. - * - * @param method - Method the route is registered for. - * @param path - Path the route is registered under. - * - * @examples - * - * ```ts - * const relay = new Relay([ - * route - * .post("/users") - * .body( - * z.object({ - * name: z.object({ family: z.string(), given: z.string() }), - * email: z.string().check(z.email()), - * }) - * ) - * ]); - * - * relay - * .route("POST", "/users") - * .actions([hasSessionUser, hasAccess("users", "create")]) - * .handle(async ({ name, email, sessionUserId }) => { - * // await db.users.insert({ name, email, createdBy: sessionUserId }); - * }) - * ``` - */ - route< - TMethod extends RouteMethod, - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(method: TMethod, path: TPath): TRoute { - const route = this.#index.get(`${method} ${path}`); - if (route === undefined) { - throw new Error(`Relay > Route not found at '${method} ${path}' index`); - } - return route as TRoute; - } - - /* - |-------------------------------------------------------------------------------- - | Client - |-------------------------------------------------------------------------------- - */ - - /** - * Send a "POST" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async post< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("POST", path, args) as RelayResponse; - } - - /** - * Send a "GET" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async get< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("GET", path, args) as RelayResponse; - } - - /** - * Send a "PUT" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async put< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("PUT", path, args) as RelayResponse; - } - - /** - * Send a "PATCH" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async patch< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("PATCH", path, args) as RelayResponse; - } - - /** - * Send a "DELETE" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async delete< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("DELETE", path, args) as RelayResponse; - } - - /* - |-------------------------------------------------------------------------------- - | Server - |-------------------------------------------------------------------------------- - */ - - /** - * Handle a incoming fetch request. - * - * @param request - Fetch request to pass to a route handler. - */ - async handle(request: Request) { - const url = new URL(request.url); - - const matched = this.#resolve(request.method, request.url); - if (matched === undefined) { - return toResponse( - new NotFoundError(`Invalid routing path provided for ${request.url}`, { - method: request.method, - url: request.url, - }), - ); - } - - const { route, params } = matched; - - // ### Context - // Context is passed to every route handler and provides a suite of functionality - // and request data. - - const context = { - ...params, - ...toSearch(url.searchParams), - }; - - // ### Params - // If the route has params we want to coerce the values to the expected types. - - if (route.state.params !== undefined) { - const result = await route.state.params.safeParseAsync(context.params); - if (result.success === false) { - return toResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error))); - } - context.params = result.data; - } - - // ### Query - // If the route has a query schema we need to validate and parse the query. - - if (route.state.search !== undefined) { - const result = await route.state.search.safeParseAsync(context.query ?? {}); - if (result.success === false) { - return toResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error))); - } - context.query = result.data; - } - - // ### Body - // If the route has a body schema we need to validate and parse the body. - - const body: Record = {}; - - if (route.state.body !== undefined) { - const result = await route.state.body.safeParseAsync(body); - if (result.success === false) { - return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error))); - } - context.body = result.data; - } - - // ### Actions - // Run through all assigned actions for the route. - - if (route.state.actions !== undefined) { - for (const action of route.state.actions) { - const result = (await action.state.input?.safeParseAsync(context)) ?? { success: true, data: {} }; - if (result.success === false) { - return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error))); - } - const output = (await action.state.handle?.(result.data)) ?? {}; - for (const key in output) { - context[key] = output[key]; - } - } - } - - // ### Handler - // Execute the route handler and apply the result. - - return toResponse(await route.state.handle?.(context).catch((error) => error)); - } - - /** - * Attempt to resolve a route based on the given method and pathname. - * - * @param method - HTTP method. - * @param url - HTTP request url. - */ - #resolve(method: string, url: string): ResolvedRoute | undefined { - this.#assertMethod(method); - for (const route of this.routes[method]) { - if (route.match(url) === true) { - return { route, params: route.getParsedParams(url) }; - } - } - } - - #validateRoutePath(route: Route): void { - const path = `${route.method} ${route.path}`; - if (this.#paths.has(path)) { - throw new Error(`Router > Path ${path} already exists`); - } - this.#paths.add(path); - } - - async #send(method: RouteMethod, url: string, args: any[]) { - const route = this.route(method, url); - - // ### Input - - const input: RequestInput = { method, url, search: "" }; - - let index = 0; // argument incrementor - - if (route.state.params !== undefined) { - const params = args[index++] as { [key: string]: string }; - for (const key in params) { - input.url = input.url.replace(`:${key}`, params[key]); - } - } - - if (route.state.search !== undefined) { - const search = args[index++] as { [key: string]: string }; - const pieces: string[] = []; - for (const key in search) { - pieces.push(`${key}=${search[key]}`); - } - if (pieces.length > 0) { - input.search = `?${pieces.join("&")}`; - } - } - - if (route.state.body !== undefined) { - input.body = JSON.stringify(args[index++]); - } - - // ### Fetch - - const data = await this.config.adapter.fetch(input); - if (route.state.output !== undefined) { - return route.state.output.parse(data); - } - return data; - } - - #assertMethod(method: string): asserts method is RouteMethod { - if (!SUPPORTED_MEHODS.includes(method)) { - throw new Error(`Router > Unsupported method '${method}'`); - } - } -} - -/* - |-------------------------------------------------------------------------------- - | Helpers - |-------------------------------------------------------------------------------- - */ - -/** - * Sorting method for routes to ensure that static properties takes precedence - * for when a route is matched against incoming requests. - * - * @param a - Route A - * @param b - Route B - */ -function byStaticPriority(a: Route, b: Route) { - const aSegments = a.path.split("/"); - const bSegments = b.path.split("/"); - - const maxLength = Math.max(aSegments.length, bSegments.length); - - for (let i = 0; i < maxLength; i++) { - const aSegment = aSegments[i] || ""; - const bSegment = bSegments[i] || ""; - - const isADynamic = aSegment.startsWith(":"); - const isBDynamic = bSegment.startsWith(":"); - - if (isADynamic !== isBDynamic) { - return isADynamic ? 1 : -1; - } - - if (isADynamic === false && aSegment !== bSegment) { - return aSegment.localeCompare(bSegment); - } - } - - return a.path.localeCompare(b.path); -} - -/** - * Resolve and return query object from the provided search parameters, or undefined - * if the search parameters does not have any entries. - * - * @param searchParams - Search params to create a query object from. - */ -function toSearch(searchParams: URLSearchParams): object | undefined { - if (searchParams.size === 0) { - return undefined; - } - const result: Record = {}; - for (const [key, value] of searchParams.entries()) { - result[key] = value; - } - return result; -} - -/** - * Takes a server side request result and returns a fetch Response. - * - * @param result - Result to send back as a Response. - */ -function toResponse(result: object | RelayError | Response | void): Response { - if (result instanceof Response) { - return result; - } - if (result instanceof RelayError) { - return new Response(result.message, { - status: result.status, - }); - } - if (result === undefined) { - return new Response(null, { status: 204 }); - } - return new Response(JSON.stringify(result), { - status: 200, - headers: { - "content-type": "application/json", - }, - }); -} - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -type Routes = { - POST: Route[]; - GET: Route[]; - PUT: Route[]; - PATCH: Route[]; - DELETE: Route[]; -}; - -type ResolvedRoute = { - route: Route; - params: any; -}; - -type RelayResponse = TRoute["state"]["output"] extends ZodType ? z.infer : void; - -type RelayConfig = { - adapter: RelayAdapter; -}; - -export type RelayAdapter = { - fetch(input: RequestInput): Promise; -}; - -export type RequestInput = { - method: RouteMethod; - url: string; - search: string; - body?: string; -}; diff --git a/libraries/route.ts b/libraries/route.ts deleted file mode 100644 index dbbc0e7..0000000 --- a/libraries/route.ts +++ /dev/null @@ -1,332 +0,0 @@ -import z, { ZodObject, ZodRawShape, ZodType } from "zod"; - -import { Action } from "./action.ts"; - -export class Route { - #pattern?: URLPattern; - - declare readonly args: RouteArgs; - declare readonly context: RouteContext; - - constructor(readonly state: TRouteState) {} - - /** - * HTTP Method - */ - get method(): RouteMethod { - return this.state.method; - } - - /** - * URL pattern of the route. - */ - get pattern(): URLPattern { - if (this.#pattern === undefined) { - this.#pattern = new URLPattern({ pathname: this.path }); - } - return this.#pattern; - } - - /** - * URL path - */ - get path(): string { - return this.state.path; - } - - /** - * Check if the provided URL matches the route pattern. - * - * @param url - HTTP request.url - */ - match(url: string): boolean { - return this.pattern.test(url); - } - - /** - * Extract parameters from the provided URL based on the route pattern. - * - * @param url - HTTP request.url - */ - getParsedParams(url: string): TRouteState["params"] extends ZodObject ? z.infer : object { - const params = this.pattern.exec(url)?.pathname.groups; - if (params === undefined) { - return {}; - } - return this.state.params?.parse(params) ?? params; - } - - /** - * Params allows for custom casting of URL parameters. If a parameter does not - * have a corresponding zod schema the default param type is "string". - * - * @param params - URL params. - * - * @examples - * - * ```ts - * route - * .post("/foo/:bar") - * .params({ - * bar: z.number({ coerce: true }) - * }) - * .handle(async ({ params: { bar } }) => { - * console.log(typeof bar); // => number - * }); - * ``` - */ - params(params: TParams): Route & { params: ZodObject }> { - return new Route({ ...this.state, params }) as any; - } - - /** - * Search allows for custom casting of URL search parameters. If a parameter does - * not have a corresponding zod schema the default param type is "string". - * - * @param search - URL search arguments. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .search({ - * bar: z.number({ coerce: true }) - * }) - * .handle(async ({ search: { bar } }) => { - * console.log(typeof bar); // => number - * }); - * ``` - */ - search(search: TSearch): Route & { search: ZodObject }> { - return new Route({ ...this.state, search }) as any; - } - - /** - * Shape of the body this route expects to receive. This is used by all - * mutator routes and has no effect when defined on "GET" methods. - * - * @param body - Body the route expects. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .body( - * z.object({ - * bar: z.number() - * }) - * ) - * .handle(async ({ bar }) => { - * console.log(typeof bar); // => number - * }); - * ``` - */ - body(body: TBody): Route & { body: TBody }> { - return new Route({ ...this.state, body }); - } - - /** - * List of route level middleware action to execute before running the - * route handler. - * - * @param actions - Actions to execute on this route. - * - * @examples - * - * ```ts - * const hasFooBar = action - * .make("hasFooBar") - * .response(z.object({ foobar: z.number() })) - * .handle(async () => { - * return { - * foobar: 1, - * }; - * }); - * - * route - * .post("/foo") - * .actions([hasFooBar]) - * .handle(async ({ foobar }) => { - * console.log(typeof foobar); // => number - * }); - * ``` - */ - actions(actions: TAction[]): Route & { actions: TAction[] }> { - return new Route({ ...this.state, actions }); - } - - /** - * Shape of the response this route produces. This is used by the transform - * tools to ensure the client receives parsed data. - * - * @param response - Response shape of the route. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .response( - * z.object({ - * bar: z.number() - * }) - * ) - * .handle(async () => { - * return { - * bar: 1 - * } - * }); - * ``` - */ - response(output: TResponse): Route & { output: TResponse }> { - return new Route({ ...this.state, output }); - } - - /** - * Server handler callback method. - * - * @param handle - Handle function to trigger when the route is executed. - */ - handle>(handle: THandleFn): Route & { handle: THandleFn }> { - return new Route({ ...this.state, handle }); - } -} - -/* - |-------------------------------------------------------------------------------- - | Factories - |-------------------------------------------------------------------------------- - */ - -/** - * Route factories allowing for easy generation of relay compliant routes. - */ -export const route = { - /** - * Create a new "POST" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .body( - * z.object({ bar: z.string() }) - * ); - * ``` - */ - post(path: TPath) { - return new Route({ method: "POST", path }); - }, - - /** - * Create a new "GET" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route.get("/foo"); - * ``` - */ - get(path: TPath) { - return new Route({ method: "GET", path }); - }, - - /** - * Create a new "PUT" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route - * .put("/foo") - * .body( - * z.object({ bar: z.string() }) - * ); - * ``` - */ - put(path: TPath) { - return new Route({ method: "PUT", path }); - }, - - /** - * Create a new "PATCH" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route - * .patch("/foo") - * .body( - * z.object({ bar: z.string() }) - * ); - * ``` - */ - patch(path: TPath) { - return new Route({ method: "PATCH", path }); - }, - - /** - * Create a new "DELETE" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route.delete("/foo"); - * ``` - */ - delete(path: TPath) { - return new Route({ method: "DELETE", path }); - }, -}; - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -type RouteState = { - method: RouteMethod; - path: string; - params?: ZodObject; - search?: ZodObject; - body?: ZodObject; - actions?: Array; - output?: ZodType; - handle?: HandleFn; -}; - -export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; - -export type HandleFn = (context: TContext) => TResponse extends ZodType ? Promise> : Promise; - -type RouteContext = (TRouteState["params"] extends ZodObject ? z.infer : object) & - (TRouteState["search"] extends ZodObject ? z.infer : object) & - (TRouteState["body"] extends ZodObject ? z.infer : object) & - (TRouteState["actions"] extends Array ? UnionToIntersection> : object); - -type RouteArgs = [ - ...TupleIfZod, - ...TupleIfZod, - ...TupleIfZod, -]; - -type TupleIfZod = TState extends ZodObject ? [z.infer] : []; - -type MergeAction> = - TActions[number] extends Action ? (TActionState["output"] extends ZodObject ? z.infer : object) : object; - -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; diff --git a/mod.ts b/mod.ts deleted file mode 100644 index e965e70..0000000 --- a/mod.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./libraries/action.ts"; -export * from "./libraries/errors.ts"; -export * from "./libraries/relay.ts"; -export * from "./libraries/route.ts"; diff --git a/modules/account/client.ts b/modules/account/client.ts new file mode 100644 index 0000000..8982b52 --- /dev/null +++ b/modules/account/client.ts @@ -0,0 +1,4 @@ +export const account = { + create: (await import("./routes/create/spec.ts")).default, + get: (await import("./routes/get/spec.ts")).default, +}; diff --git a/modules/account/package.json b/modules/account/package.json new file mode 100644 index 0000000..dd5612c --- /dev/null +++ b/modules/account/package.json @@ -0,0 +1,14 @@ +{ + "name": "@module/account", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./server": "./server.ts", + "./client": "./client.ts" + }, + "dependencies": { + "@platform/relay": "workspace:*", + "zod": "4.1.12" + } +} diff --git a/modules/account/routes/create/handle.ts b/modules/account/routes/create/handle.ts new file mode 100644 index 0000000..23a8dc8 --- /dev/null +++ b/modules/account/routes/create/handle.ts @@ -0,0 +1,5 @@ +import route from "./spec.ts"; + +export default route.handle(async ({ body }) => { + console.log(body); +}); diff --git a/modules/account/routes/create/spec.ts b/modules/account/routes/create/spec.ts new file mode 100644 index 0000000..b58c38a --- /dev/null +++ b/modules/account/routes/create/spec.ts @@ -0,0 +1,13 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route.post("/api/v1/account").body( + z.strictObject({ + tenantId: z.uuid().describe("Tenant identifier the account belongs to"), + userId: z.uuid().describe("User identifier the account belongs to"), + account: z.strictObject({ + type: z.string().describe("Type of account being created"), + number: z.number().describe("Unique account identifier to create for the account"), + }), + }), +); diff --git a/modules/account/routes/get/handle.ts b/modules/account/routes/get/handle.ts new file mode 100644 index 0000000..1cd205b --- /dev/null +++ b/modules/account/routes/get/handle.ts @@ -0,0 +1,5 @@ +import route from "./spec.ts"; + +export default route.handle(async ({ params }) => { + console.log(params); +}); diff --git a/modules/account/routes/get/spec.ts b/modules/account/routes/get/spec.ts new file mode 100644 index 0000000..f6ec5f1 --- /dev/null +++ b/modules/account/routes/get/spec.ts @@ -0,0 +1,6 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route.get("/api/v1/account/:number").params({ + number: z.number().describe("Account number to retrieve"), +}); diff --git a/modules/account/server.ts b/modules/account/server.ts new file mode 100644 index 0000000..e69de29 diff --git a/modules/tenant/package.json b/modules/tenant/package.json new file mode 100644 index 0000000..627d3fb --- /dev/null +++ b/modules/tenant/package.json @@ -0,0 +1,6 @@ +{ + "name": "@module/tenant", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/package.json b/package.json index c7dee05..4b1db4c 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,7 @@ { - "dependencies": { - "zod": "next" - }, "devDependencies": { - "@std/assert": "npm:@jsr/std__assert@1.0.12", - "@std/testing": "npm:@jsr/std__testing@1.0.11", - "eslint": "9.24.0", - "eslint-plugin-simple-import-sort": "12.1.1", - "prettier": "3.5.3", - "typescript-eslint": "8.30.1" + "@std/assert": "npm:@jsr/std__assert@1.0.14", + "@std/testing": "npm:@jsr/std__testing@1.0.15", + "@biomejs/biome": "2.2.4" } } diff --git a/platform/cerbos/package.json b/platform/cerbos/package.json new file mode 100644 index 0000000..bb00cc1 --- /dev/null +++ b/platform/cerbos/package.json @@ -0,0 +1,10 @@ +{ + "name": "@platform/cerbos", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@cerbos/core": "0.25.1", + "@cerbos/http": "0.23.3" + } +} diff --git a/platform/config/dotenv.ts b/platform/config/dotenv.ts new file mode 100644 index 0000000..833af0f --- /dev/null +++ b/platform/config/dotenv.ts @@ -0,0 +1,10 @@ +import { load } from "@std/dotenv"; + +const env = await load(); + +/** + * TODO ... + */ +export function getDotEnvVariable(key: string): string { + return env[key] ?? Deno.env.get(key); +} diff --git a/platform/config/environment.ts b/platform/config/environment.ts new file mode 100644 index 0000000..6832b46 --- /dev/null +++ b/platform/config/environment.ts @@ -0,0 +1,51 @@ +import { load } from "@std/dotenv"; +import type { ZodType, z } from "zod"; + +import { InvalidEnvironmentKeyError } from "./errors.ts"; +import { getServiceEnvironment, type ServiceEnvironment } from "./service.ts"; + +const env = await load(); + +/** + * TODO ... + */ +export function getEnvironmentVariable({ + key, + type, + envFallback, + fallback, +}: { + key: string; + type: TType; + envFallback?: EnvironmentFallback; + fallback?: string; +}): z.infer { + const serviceEnv = getServiceEnvironment(); + const providedValue = env[key] ?? Deno.env.get(key); + const fallbackValue = typeof envFallback === "object" ? (envFallback[serviceEnv] ?? fallback) : fallback; + const toBeUsed = providedValue ?? fallbackValue; + try { + if (typeof toBeUsed === "string" && (toBeUsed.trim().startsWith("{") || toBeUsed.trim().startsWith("["))) { + return type.parse(JSON.parse(toBeUsed)); + } + return type.parse(toBeUsed); + } catch (error) { + throw new InvalidEnvironmentKeyError(key, { + cause: error, + }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type EnvironmentFallback = Partial> & { + testing?: string; + local?: string; + stg?: string; + demo?: string; + prod?: string; +}; diff --git a/platform/config/errors.ts b/platform/config/errors.ts new file mode 100644 index 0000000..14013e4 --- /dev/null +++ b/platform/config/errors.ts @@ -0,0 +1,22 @@ +import { SERVICE_ENV } from "./service.ts"; + +export class InvalidServiceEnvironmentError extends Error { + readonly code = "INVALID_SERVICE_ENVIRONMENT"; + + constructor(value: string) { + super( + `@platform/config requested invalid service environment, expected '${SERVICE_ENV.join(", ")}' got '${value}'.`, + ); + } +} + +export class InvalidEnvironmentKeyError extends Error { + readonly code = "INVALID_ENVIRONMENT_KEY"; + + constructor( + key: string, + readonly details: unknown, + ) { + super(`@platform/config invalid environment key '${key}' provided.`); + } +} diff --git a/platform/config/mod.ts b/platform/config/mod.ts new file mode 100644 index 0000000..e538104 --- /dev/null +++ b/platform/config/mod.ts @@ -0,0 +1,4 @@ +export * from "./dotenv.ts"; +export * from "./environment.ts"; +export * from "./errors.ts"; +export * from "./service.ts"; diff --git a/platform/config/package.json b/platform/config/package.json new file mode 100644 index 0000000..7688e55 --- /dev/null +++ b/platform/config/package.json @@ -0,0 +1,14 @@ +{ + "name": "@platform/config", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./mod.ts", + "exports": { + ".": "./mod.ts" + }, + "dependencies": { + "@std/dotenv": "npm:@jsr/std__dotenv@0.225.5", + "zod": "4.1.12" + } +} diff --git a/platform/config/service.ts b/platform/config/service.ts new file mode 100644 index 0000000..b7bf33d --- /dev/null +++ b/platform/config/service.ts @@ -0,0 +1,19 @@ +import { getDotEnvVariable } from "./dotenv.ts"; + +export const SERVICE_ENV = ["testing", "local", "stg", "demo", "prod"] as const; + +/** + * TODO ... + */ +export function getServiceEnvironment(): ServiceEnvironment { + const value = getDotEnvVariable("SERVICE_ENV"); + if (value === undefined) { + return "local"; + } + if ((SERVICE_ENV as unknown as string[]).includes(value) === false) { + throw new Error(`Config Exception: Invalid env ${value} provided`); + } + return value as ServiceEnvironment; +} + +export type ServiceEnvironment = (typeof SERVICE_ENV)[number]; diff --git a/platform/database/client.ts b/platform/database/client.ts new file mode 100644 index 0000000..54c3ddb --- /dev/null +++ b/platform/database/client.ts @@ -0,0 +1,121 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import postgres, { type Options, type Sql, type TransactionSql } from "postgres"; +import type { ZodType } from "zod"; + +import { takeAll, takeOne } from "./parser.ts"; + +const storage = new AsyncLocalStorage(); + +/* + |-------------------------------------------------------------------------------- + | Database + |-------------------------------------------------------------------------------- + */ + +export class Client { + /** + * Cached SQL instance. + */ + #db?: Sql; + + /** + * Instantiate a new Database accessor wrapper. + * + * @param db - Dependency container token to retrieve. + */ + constructor(readonly config: Options<{}>) {} + + /** + * SQL instance to perform queries against. + */ + get sql(): Sql { + const tx = storage.getStore(); + if (tx !== undefined) { + return tx; + } + return this.#getResolvedInstance(); + } + + /** + * SQL instance which ignores any potential transaction established + * in instance scope. + */ + get direct(): Sql { + return this.#getResolvedInstance(); + } + + /** + * Retrieves cached SQL instance or attempts to create and return + * a new instance. + */ + #getResolvedInstance(): Sql { + if (this.#db === undefined) { + this.#db = postgres(this.config); + } + return this.#db; + } + + /** + * Initiates a SQL transaction by wrapping a new db instance with a + * new transaction instance. + * + * @example + * ```ts + * import { db } from "@optio/database/client.ts"; + * + * db.begin(async (tx) => { + * tx`SELECT ...` + * }); + * ``` + */ + begin(cb: (tx: TransactionSql) => TResponse | Promise): Promise> { + return this.direct.begin((tx) => storage.run(tx, () => cb(tx))); + } + + /** + * Closes SQL connection if it has been instantiated. + */ + async close(): Promise { + if (this.#db !== undefined) { + await this.#db.end(); + this.#db = undefined; + } + } + + /** + * Returns a schema pepared querying object allowing for a one or many + * response based on the query used. + * + * @param schema - Zod schema to parse. + */ + schema(schema: TSchema) { + return { + /** + * Executes a sql query and parses the result with the given schema. + * + * @param sql - Template string SQL value. + */ + one: (strings: TemplateStringsArray, ...values: any[]) => this.sql(strings, ...values).then(takeOne(schema)), + + /** + * Executes a sql query and parses the resulting list with the given schema. + * + * @param sql - Template string SQL value. + */ + many: (strings: TemplateStringsArray, ...values: any[]) => this.sql(strings, ...values).then(takeAll(schema)), + }; + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type UnwrapPromiseArray = T extends any[] + ? { + [k in keyof T]: T[k] extends Promise ? R : T[k]; + } + : T; diff --git a/platform/database/config.ts b/platform/database/config.ts new file mode 100644 index 0000000..250c17d --- /dev/null +++ b/platform/database/config.ts @@ -0,0 +1,27 @@ +import { getEnvironmentVariable } from "@platform/config/environment.ts"; +import z from "zod"; + +export const config = { + xtdb: { + host: getEnvironmentVariable({ + key: "DB_XTDB_HOST", + type: z.string(), + fallback: "localhost", + }), + port: getEnvironmentVariable({ + key: "DB_XTDB_PORT", + type: z.coerce.number(), + fallback: "5432", + }), + user: getEnvironmentVariable({ + key: "DB_XTDB_USER", + type: z.string(), + fallback: "xtdb", + }), + pass: getEnvironmentVariable({ + key: "DB_XTDB_PASSWORD", + type: z.string(), + fallback: "xtdb", + }), + }, +}; diff --git a/platform/database/package.json b/platform/database/package.json new file mode 100644 index 0000000..1cf1d1c --- /dev/null +++ b/platform/database/package.json @@ -0,0 +1,11 @@ +{ + "name": "@platform/database", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@platform/config": "workspace:*", + "postgres": "3.4.7", + "zod": "4.1.12" + } +} diff --git a/platform/database/parser.ts b/platform/database/parser.ts new file mode 100644 index 0000000..daebf75 --- /dev/null +++ b/platform/database/parser.ts @@ -0,0 +1,29 @@ +import type z from "zod"; +import type { ZodType } from "zod"; + +/** + * Takes a single record from a list of database rows. + * + * @param rows - List of rows to retrieve record from. + */ +export function takeOne( + schema: TSchema, +): (records: unknown[]) => z.output | undefined { + return (records: unknown[]) => { + if (records[0] === undefined) { + return undefined; + } + return schema.parse(records[0]); + }; +} + +/** + * Takes all records from a list of database rows and validates each one. + * + * @param schema - Zod schema to validate each record against. + */ +export function takeAll(schema: TSchema): (records: unknown[]) => z.output[] { + return (records: unknown[]) => { + return records.map((record) => schema.parse(record)); + }; +} diff --git a/platform/logger/chalk.ts b/platform/logger/chalk.ts new file mode 100644 index 0000000..5ea6277 --- /dev/null +++ b/platform/logger/chalk.ts @@ -0,0 +1,42 @@ +import type { HexValue } from "./color/hex.ts"; +import { type BGColor, type Color, hexToBgColor, hexToColor, type Modifier, styles } from "./color/styles.ts"; + +export const chalk = { + color(hex: HexValue): (value: string) => string { + const color = hexToColor(hex); + return (value: string) => `${color}${value}${styles.modifier.reset}`; + }, + bgColor(hex: HexValue): (value: string) => string { + const color = hexToBgColor(hex); + return (value: string) => `${color}${value}${styles.modifier.reset}`; + }, +} as Chalk; + +for (const key in styles.modifier) { + chalk[key as Modifier] = (value: string) => toModifiedValue(key as Modifier, value); +} + +for (const key in styles.color) { + chalk[key as Color] = (value: string) => toColorValue(key as Color, value); +} + +for (const key in styles.bgColor) { + chalk[key as BGColor] = (value: string) => toBGColorValue(key as BGColor, value); +} + +function toModifiedValue(key: Modifier, value: string): string { + return `${styles.modifier[key]}${value}${styles.modifier.reset}`; +} + +function toColorValue(key: Color, value: string): string { + return `${styles.color[key]}${value}${styles.modifier.reset}`; +} + +function toBGColorValue(key: BGColor, value: string): string { + return `${styles.bgColor[key]}${value}${styles.modifier.reset}`; +} + +type Chalk = Record string> & { + color(hex: HexValue): (value: string) => string; + bgColor(hex: HexValue): (value: string) => string; +}; diff --git a/platform/logger/color/hex.ts b/platform/logger/color/hex.ts new file mode 100644 index 0000000..8ace2db --- /dev/null +++ b/platform/logger/color/hex.ts @@ -0,0 +1,28 @@ +import { rgbToAnsi256 } from "./rgb.ts"; + +/** + * Convert provided hex value to closest 256-Color value. + * + * @param hex - Hex to convert. + */ +export function hexToAnsi256(hex: HexValue) { + const { r, g, b } = hexToRGB(hex); + return rgbToAnsi256(r, g, b); +} + +/** + * Take a hex value and return its RGB values. + * + * @param hex - Hex to convert to RGB + * @returns + */ +export function hexToRGB(hex: HexValue): { r: number; g: number; b: number } { + return { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + }; +} + +export type HexValue = + `#${string | number}${string | number}${string | number}${string | number}${string | number}${string | number}`; diff --git a/platform/logger/color/rgb.ts b/platform/logger/color/rgb.ts new file mode 100644 index 0000000..f28f096 --- /dev/null +++ b/platform/logger/color/rgb.ts @@ -0,0 +1,24 @@ +/** + * Convert RGB to the nearest 256-color ANSI value + * + * @param r - Red value. + * @param g - Green value. + * @param b - Blue value. + */ +export function rgbToAnsi256(r: number, g: number, b: number): number { + if (r === g && g === b) { + if (r < 8) return 16; + if (r > 248) return 231; + return Math.round(((r - 8) / 247) * 24) + 232; + } + + // Map RGB to 6×6×6 color cube (16–231) + const conv = (val: number) => Math.round(val / 51); + const ri = conv(r); + const gi = conv(g); + const bi = conv(b); + + return 16 + 36 * ri + 6 * gi + bi; +} + +export type RGB = { r: number; g: number; b: number }; diff --git a/platform/logger/color/styles.ts b/platform/logger/color/styles.ts new file mode 100644 index 0000000..9a81ee8 --- /dev/null +++ b/platform/logger/color/styles.ts @@ -0,0 +1,76 @@ +import { type HexValue, hexToAnsi256 } from "./hex.ts"; +import { toEscapeSequence } from "./utilities.ts"; + +export const styles = { + modifier: { + reset: toEscapeSequence(0), // Reset to normal + bold: toEscapeSequence(1), // Bold text + dim: toEscapeSequence(2), // Dim text + italic: toEscapeSequence(3), // Italic text + underline: toEscapeSequence(4), // Underlined text + overline: toEscapeSequence(53), // Overline text + inverse: toEscapeSequence(7), // Inverse + hidden: toEscapeSequence(8), // Hidden text + strikethrough: toEscapeSequence(9), // Strikethrough + }, + + color: { + black: toEscapeSequence(30), // Black color + red: toEscapeSequence(31), // Red color + green: toEscapeSequence(32), // Green color + yellow: toEscapeSequence(33), // Yellow color + blue: toEscapeSequence(34), // Blue color + magenta: toEscapeSequence(35), // Magenta color + cyan: toEscapeSequence(36), // Cyan color + white: toEscapeSequence(37), // White color + orange: hexToColor("#FFA500"), + + // Bright colors + blackBright: toEscapeSequence(90), + gray: toEscapeSequence(90), // Alias for blackBright + grey: toEscapeSequence(90), // Alias for blackBright + redBright: toEscapeSequence(91), + greenBright: toEscapeSequence(92), + yellowBright: toEscapeSequence(93), + blueBright: toEscapeSequence(94), + magentaBright: toEscapeSequence(95), + cyanBright: toEscapeSequence(96), + whiteBright: toEscapeSequence(97), + }, + + bgColor: { + bgBlack: toEscapeSequence(40), + bgRed: toEscapeSequence(41), + bgGreen: toEscapeSequence(42), + bgYellow: toEscapeSequence(43), + bgBlue: toEscapeSequence(44), + bgMagenta: toEscapeSequence(45), + bgCyan: toEscapeSequence(46), + bgWhite: toEscapeSequence(47), + bgOrange: hexToBgColor("#FFA500"), + + // Bright background colors + bgBlackBright: toEscapeSequence(100), + bgGray: toEscapeSequence(100), // Alias for bgBlackBright + bgGrey: toEscapeSequence(100), // Alias for bgBlackBright + bgRedBright: toEscapeSequence(101), + bgGreenBright: toEscapeSequence(102), + bgYellowBright: toEscapeSequence(103), + bgBlueBright: toEscapeSequence(104), + bgMagentaBright: toEscapeSequence(105), + bgCyanBright: toEscapeSequence(106), + bgWhiteBright: toEscapeSequence(107), + }, +}; + +export function hexToColor(hex: HexValue): string { + return toEscapeSequence(`38;5;${hexToAnsi256(hex)}`); // Foreground color +} + +export function hexToBgColor(hex: HexValue): string { + return toEscapeSequence(`48;5;${hexToAnsi256(hex)}`); // Background color +} + +export type Modifier = keyof typeof styles.modifier; +export type Color = keyof typeof styles.color; +export type BGColor = keyof typeof styles.bgColor; diff --git a/platform/logger/color/utilities.ts b/platform/logger/color/utilities.ts new file mode 100644 index 0000000..50a9fbd --- /dev/null +++ b/platform/logger/color/utilities.ts @@ -0,0 +1,3 @@ +export function toEscapeSequence(value: string | number): `\x1b[${string}m` { + return `\x1b[${value}m`; +} diff --git a/platform/logger/config.ts b/platform/logger/config.ts new file mode 100644 index 0000000..1cf4947 --- /dev/null +++ b/platform/logger/config.ts @@ -0,0 +1,13 @@ +import { getEnvironmentVariable } from "@platform/config/environment.ts"; +import z from "zod"; + +export const config = { + level: getEnvironmentVariable({ + key: "LOG_LEVEL", + type: z.string(), + fallback: "info", + envFallback: { + local: "debug", + }, + }), +}; diff --git a/platform/logger/format/event-store.ts b/platform/logger/format/event-store.ts new file mode 100644 index 0000000..a78b827 --- /dev/null +++ b/platform/logger/format/event-store.ts @@ -0,0 +1,19 @@ +import { EventValidationError } from "@valkyr/event-store"; + +import type { Level } from "../level.ts"; +import { getTracedAt } from "../stack.ts"; + +export function toEventStoreLog(arg: any, level: Level): any { + if (arg instanceof EventValidationError) { + const obj: any = { + origin: "EventStore", + message: arg.message, + at: getTracedAt(arg.stack, "/api/domains"), + data: arg.errors, + }; + if (level === "debug") { + obj.stack = arg.stack; + } + return obj; + } +} diff --git a/platform/logger/format/server.ts b/platform/logger/format/server.ts new file mode 100644 index 0000000..c8a7584 --- /dev/null +++ b/platform/logger/format/server.ts @@ -0,0 +1,18 @@ +import { ServerError } from "@platform/relay"; + +import type { Level } from "../level.ts"; +import { getTracedAt } from "../stack.ts"; + +export function toServerLog(arg: any, level: Level): any { + if (arg instanceof ServerError) { + const obj: any = { + message: arg.message, + data: arg.data, + at: getTracedAt(arg.stack, "/api/domains"), + }; + if (level === "debug") { + obj.stack = arg.stack; + } + return obj; + } +} diff --git a/platform/logger/level.ts b/platform/logger/level.ts new file mode 100644 index 0000000..095af5b --- /dev/null +++ b/platform/logger/level.ts @@ -0,0 +1,8 @@ +export const logLevel = { + debug: 0, + info: 1, + warning: 2, + error: 3, +}; + +export type Level = "debug" | "error" | "warning" | "info"; diff --git a/platform/logger/logger.ts b/platform/logger/logger.ts new file mode 100644 index 0000000..0a8f115 --- /dev/null +++ b/platform/logger/logger.ts @@ -0,0 +1,95 @@ +import { chalk } from "./chalk.ts"; +import { type Level, logLevel } from "./level.ts"; + +export class Logger { + #level: Level = "info"; + #config: Config; + + constructor(config: Config) { + this.#config = config; + } + + get #prefix(): [string?] { + if (this.#config.prefix !== undefined) { + return [chalk.bold(chalk.green(this.#config.prefix))]; + } + return []; + } + + /** + * Set the highest logging level in the order of debug, info, warn, error. + * + * When value is 'info', info, warn and error will be logged and debug + * will be ignored. + * + * @param value Highest log level. + */ + level(value: Level): this { + this.#level = value; + return this; + } + + /** + * Returns a new logger instance with the given name as prefix. + * + * @param name - Prefix name. + */ + prefix(name: string): Logger { + return new Logger({ prefix: name, loggers: this.#config.loggers }).level(this.#level); + } + + /** + * Emit a debug message to terminal. + */ + debug(...args: any[]) { + if (this.#isLevelEnabled(0)) { + console.log(new Date(), chalk.bold("Debug"), ...this.#prefix, ...args.map(this.#toFormattedArg)); + } + } + + /** + * Emit a info message to terminal. + */ + info(...args: any[]) { + if (this.#isLevelEnabled(1)) { + console.log(new Date(), chalk.bold(chalk.blue("Info")), ...this.#prefix, ...args.map(this.#toFormattedArg)); + } + } + + /** + * Emit a warning message to terminal. + */ + warn(...args: any[]) { + if (this.#isLevelEnabled(2)) { + console.log(new Date(), chalk.bold(chalk.orange("Warning")), ...this.#prefix, ...args.map(this.#toFormattedArg)); + } + } + + /** + * Emit a errpr message to terminal. + */ + error(...args: any[]) { + if (this.#isLevelEnabled(3)) { + console.log(new Date(), chalk.bold(chalk.red("Error")), ...this.#prefix, ...args.map(this.#toFormattedArg)); + } + } + + #isLevelEnabled(level: 0 | 1 | 2 | 3): boolean { + return level >= logLevel[this.#level]; + } + + #toFormattedArg = (arg: any): string => { + for (const logger of this.#config.loggers) { + const res = logger(arg, this.#level); + if (res !== undefined) { + return res; + } + } + return arg; + }; +} + +type Config = { + prefix?: string; + loggers: ((arg: any, level: Level) => any)[]; +}; diff --git a/platform/logger/mod.ts b/platform/logger/mod.ts new file mode 100644 index 0000000..9fd6623 --- /dev/null +++ b/platform/logger/mod.ts @@ -0,0 +1,7 @@ +import { toEventStoreLog } from "./format/event-store.ts"; +import { toServerLog } from "./format/server.ts"; +import { Logger } from "./logger.ts"; + +export const logger = new Logger({ + loggers: [toServerLog, toEventStoreLog], +}); diff --git a/platform/logger/package.json b/platform/logger/package.json new file mode 100644 index 0000000..6d9c1db --- /dev/null +++ b/platform/logger/package.json @@ -0,0 +1,15 @@ +{ + "name": "@platform/logger", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./mod.ts", + "exports": { + ".": "./mod.ts" + }, + "dependencies": { + "@platform/config": "workspace:*", + "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1", + "zod": "4.1.12" + } +} diff --git a/platform/logger/stack.ts b/platform/logger/stack.ts new file mode 100644 index 0000000..f6d4501 --- /dev/null +++ b/platform/logger/stack.ts @@ -0,0 +1,20 @@ +/** + * Fetch the most closest relevant error from the local code base so it can + * be more easily traced to its source. + * + * @param stack - Error stack. + * @param search - Relevant stack line search value. + */ +export function getTracedAt(stack: string | undefined, search: string): string | undefined { + if (stack === undefined) { + return undefined; + } + const firstMatch = stack.split("\n").find((line) => line.includes(search)); + if (firstMatch === undefined) { + return undefined; + } + return firstMatch + .replace(/^.*?(file:\/\/\/)/, "$1") + .replace(/\)$/, "") + .trim(); +} diff --git a/platform/relay/adapters/http.ts b/platform/relay/adapters/http.ts new file mode 100644 index 0000000..125b209 --- /dev/null +++ b/platform/relay/adapters/http.ts @@ -0,0 +1,302 @@ +import { + assertServerErrorResponse, + type RelayAdapter, + type RelayInput, + type RelayResponse, + type ServerErrorResponse, +} from "../libraries/adapter.ts"; +import { ServerError, type ServerErrorType } from "../libraries/errors.ts"; + +/** + * HttpAdapter provides a unified transport layer for Relay. + * + * It supports sending JSON objects, nested structures, arrays, and file uploads + * via FormData. The adapter automatically detects the payload type and formats + * the request accordingly. Responses are normalized into `RelayResponse`. + * + * @example + * ```ts + * const adapter = new HttpAdapter({ url: "https://api.example.com" }); + * + * // Sending JSON data + * const jsonResponse = await adapter.send({ + * method: "POST", + * endpoint: "/users", + * body: { name: "Alice", age: 30 }, + * }); + * + * // Sending files and nested objects + * const formResponse = await adapter.send({ + * method: "POST", + * endpoint: "/upload", + * body: { + * user: { name: "Bob", avatar: fileInput.files[0] }, + * documents: [fileInput.files[1], fileInput.files[2]], + * }, + * }); + * ``` + */ +export class HttpAdapter implements RelayAdapter { + /** + * Instantiate a new HttpAdapter instance. + * + * @param options - Adapter options. + */ + constructor(readonly options: HttpAdapterOptions) {} + + /** + * Override the initial url value set by instantiator. + */ + set url(value: string) { + this.options.url = value; + } + + /** + * Retrieve the URL value from options object. + */ + get url() { + return this.options.url; + } + + /** + * Return the full URL from given endpoint. + * + * @param endpoint - Endpoint to get url for. + */ + getUrl(endpoint: string): string { + return `${this.url}${endpoint}`; + } + + async send({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise { + const init: RequestInit = { method, headers }; + + // ### Before Request + // If any before request hooks has been defined, we run them here passing in the + // request headers for further modification. + + await this.#beforeRequest(headers); + + // ### Body + + if (body !== undefined) { + const type = this.#getRequestFormat(body); + if (type === "form-data") { + headers.delete("content-type"); + init.body = this.#getFormData(body); + } + if (type === "json") { + headers.set("content-type", "application/json"); + init.body = JSON.stringify(body); + } + } + + // ### Response + + return this.request(`${endpoint}${query}`, init); + } + + /** + * Send a fetch request using the given fetch options and returns + * a relay formatted response. + * + * @param endpoint - Which endpoint to submit request to. + * @param init - Request init details to submit with the request. + */ + async request(endpoint: string, init?: RequestInit): Promise { + return this.#toResponse(await fetch(this.getUrl(endpoint), init)); + } + + /** + * Run before request operations. + * + * @param headers - Headers to pass to hooks. + */ + async #beforeRequest(headers: Headers) { + if (this.options.hooks?.beforeRequest !== undefined) { + for (const hook of this.options.hooks.beforeRequest) { + await hook(headers); + } + } + } + + /** + * Determine the parser method required for the request. + * + * @param body - Request body. + */ + #getRequestFormat(body: unknown): "form-data" | "json" { + if (body instanceof FormData) { + return "form-data"; + } + if (containsFile(body) === true) { + return "form-data"; + } + return "json"; + } + + /** + * Get FormData instance for the given body. + * + * @param body - Request body. + */ + #getFormData(data: Record, formData = new FormData(), parentKey?: string): FormData { + for (const key in data) { + const value = data[key]; + if (value === undefined || value === null) continue; + + const formKey = parentKey ? `${parentKey}[${key}]` : key; + + if (value instanceof File) { + formData.append(formKey, value, value.name); + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + if (item instanceof File) { + formData.append(`${formKey}[${index}]`, item, item.name); + } else if (typeof item === "object") { + this.#getFormData(item as Record, formData, `${formKey}[${index}]`); + } else { + formData.append(`${formKey}[${index}]`, String(item)); + } + }); + } else if (typeof value === "object") { + this.#getFormData(value as Record, formData, formKey); + } else { + formData.append(formKey, String(value)); + } + } + + return formData; + } + + /** + * Convert a fetch response to a compliant relay response. + * + * @param response - Fetch response to convert. + */ + async #toResponse(response: Response): Promise { + const type = response.headers.get("content-type"); + + // ### Content Type + // Ensure that the server responds with a 'content-type' definition. We should + // always expect the server to respond with a type. + + if (type === null) { + return { + result: "error", + headers: response.headers, + error: { + code: "CONTENT_TYPE_MISSING", + status: response.status, + message: "Missing 'content-type' in header returned from server.", + }, + }; + } + + // ### Empty Response + // If the response comes back with empty response status 204 we simply return a + // empty success. + + if (response.status === 204) { + return { + result: "success", + headers: response.headers, + data: null, + }; + } + + // ### JSON + // If the 'content-type' contains 'json' we treat it as a 'json' compliant response + // and attempt to resolve it as such. + + if (type.includes("json") === true) { + const parsed = await response.json(); + if ("data" in parsed) { + return { + result: "success", + headers: response.headers, + data: parsed.data, + }; + } + if ("error" in parsed) { + return { + result: "error", + headers: response.headers, + error: this.#toError(parsed), + }; + } + return { + result: "error", + headers: response.headers, + error: { + code: "INVALID_SERVER_RESPONSE", + status: response.status, + message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.", + }, + }; + } + + // ### Error + // If the 'content-type' is not a JSON response from the API then we check if the + // response status is an error code. + + if (response.status >= 400) { + return { + result: "error", + headers: response.headers, + error: { + code: "SERVER_ERROR_RESPONSE", + status: response.status, + message: await response.text(), + }, + }; + } + + // ### Success + // If the 'content-type' is not a JSON response from the API and the request is not + // an error we simply return the pure response in the data key. + + return { + result: "success", + headers: response.headers, + data: response, + }; + } + + #toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] { + if (assertServerErrorResponse(candidate)) { + return ServerError.fromJSON(candidate.error); + } + if (typeof candidate === "string") { + return { + code: "ERROR", + status, + message: candidate, + }; + } + return { + code: "UNSUPPORTED_SERVER_ERROR", + status, + message: "Unsupported 'error' returned from server.", + }; + } +} + +function containsFile(value: unknown): boolean { + if (value instanceof File) { + return true; + } + if (Array.isArray(value)) { + return value.some(containsFile); + } + if (typeof value === "object" && value !== null) { + return Object.values(value).some(containsFile); + } + return false; +} + +export type HttpAdapterOptions = { + url: string; + hooks?: { + beforeRequest?: ((headers: Headers) => Promise)[]; + }; +}; diff --git a/platform/relay/libraries/adapter.ts b/platform/relay/libraries/adapter.ts new file mode 100644 index 0000000..b909243 --- /dev/null +++ b/platform/relay/libraries/adapter.ts @@ -0,0 +1,89 @@ +import z from "zod"; + +import type { ServerErrorType } from "./errors.ts"; +import type { RouteMethod } from "./route.ts"; + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +const ServerErrorResponseSchema = z.object({ + error: z.object({ + code: z.any(), + status: z.number(), + message: z.string(), + data: z.any().optional(), + }), +}); + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +/** + * Check if the given candidate is a valid relay error response. + * + * @param candidate - Candidate to check. + */ +export function assertServerErrorResponse(candidate: unknown): candidate is ServerErrorResponse { + return ServerErrorResponseSchema.safeParse(candidate).success; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type RelayAdapter = { + readonly url: string; + + /** + * Return the full URL from given endpoint. + * + * @param endpoint - Endpoint to get url for. + */ + getUrl(endpoint: string): string; + + /** + * Send a request to the configured relay url. + * + * @param input - Request input parameters. + * @param publicKey - Key to encrypt the payload with. + */ + send(input: RelayInput, publicKey?: string): Promise; + + /** + * Sends a fetch request using the given options and returns a + * raw response. + * + * @param options - Relay request options. + */ + request(input: RequestInfo | URL, init?: RequestInit): Promise; +}; + +export type RelayInput = { + method: RouteMethod; + endpoint: string; + query?: string; + body?: Record; + headers?: Headers; +}; + +export type RelayResponse = + | { + result: "success"; + headers: Headers; + data: TData; + } + | { + result: "error"; + headers: Headers; + error: TError; + }; + +export type ServerErrorResponse = z.infer; diff --git a/platform/relay/libraries/client.ts b/platform/relay/libraries/client.ts new file mode 100644 index 0000000..e53007a --- /dev/null +++ b/platform/relay/libraries/client.ts @@ -0,0 +1,198 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ + +import type { ZodObject, ZodType } from "zod"; + +import type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts"; +import { Route, type RouteFn, type Routes } from "./route.ts"; + +/** + * Factory method for generating a new relay client instance. + * + * @param config - Client configuration. + * @param procedures - Map of routes to make available to the client. + */ +export function makeClient(config: Config, routes: TRoutes): RelayClient { + const client: any = { + getUrl: config.adapter.getUrl.bind(config.adapter), + request: config.adapter.request.bind(config.adapter), + }; + for (const key in routes) { + const route = routes[key]; + if (route instanceof Route) { + client[key] = getRouteFn(route, config); + } else if (typeof route === "function") { + client[key] = route; + } else { + client[key] = getNestedRoute(config, route); + } + } + return client; +} + +/* + |-------------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------------- + */ + +function getNestedRoute(config: Config, routes: TRoutes): RelayClient { + const nested: any = {}; + for (const key in routes) { + const route = routes[key]; + if (route instanceof Route) { + nested[key] = getRouteFn(route, config); + } else if (typeof route === "function") { + nested[key] = route; + } else { + nested[key] = getNestedRoute(config, route); + } + } + return nested; +} + +function getRouteFn(route: Route, { adapter }: Config) { + return async (options: any = {}) => { + const input: RelayInput = { + method: route.state.method, + endpoint: route.state.path, + query: "", + }; + + // ### Params + // Prepare request parameters by replacing :param notations with the + // parameter argument provided. + + if (route.state.params !== undefined) { + const params = await toParsedArgs( + route.state.params, + options.params, + `Invalid 'params' passed to ${route.state.path} handler.`, + ); + for (const key in params) { + input.endpoint = input.endpoint.replace(`:${key}`, encodeURIComponent(params[key])); + } + } + + // ### Query + // Prepare request query by looping through the query argument and + // creating a query string to pass onto the fetch request. + + if (route.state.query !== undefined) { + const query = await toParsedArgs( + route.state.query, + options.query, + `Invalid 'query' passed to ${route.state.path} handler.`, + ); + const pieces: string[] = []; + for (const key in query) { + pieces.push(`${encodeURIComponent(key)}=${encodeURIComponent(query[key])}`); + } + if (pieces.length > 0) { + input.query = `?${pieces.join("&")}`; + } + } + + // ### Body + // Attach the body to the input which is handled internally based on the + // type of fetch body is submitted. + + if (route.state.body !== undefined) { + input.body = await toParsedArgs( + route.state.body, + options.body, + `Invalid 'body' passed to ${route.state.path} handler.`, + ); + } + + // ### Request Init + // List of request init options that we can extract and forward to the + // request adapter. + + if (options.headers !== undefined) { + input.headers = new Headers(options.headers); + } + + // ### Fetch + + const response = await adapter.send(input); + + if ("data" in response && route.state.response !== undefined) { + response.data = route.state.response.parse(response.data); + } + + return response; + }; +} + +async function toParsedArgs( + zod: ZodType, + args: unknown, + msg: string, +): Promise> { + const result = await zod.safeParseAsync(args); + if (result.success === false) { + throw new Error(msg); + } + return result.data as Record; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type RelayClient = RelayRequest & RelayRoutes; + +type RelayRequest = { + url: string; + getUrl: (endpoint: string) => string; + request: (input: RequestInfo | URL, init?: RequestInit) => Promise>; +}; + +type RelayRoutes = { + [TKey in keyof TRoutes]: TRoutes[TKey] extends Route + ? ClientRoute + : TRoutes[TKey] extends RouteFn + ? TRoutes[TKey] + : TRoutes[TKey] extends Routes + ? RelayRoutes + : never; +}; + +type ClientRoute = HasPayload extends true + ? ( + payload: Prettify< + (TRoute["state"]["params"] extends ZodObject ? { params: TRoute["$params"] } : {}) & + (TRoute["state"]["query"] extends ZodObject ? { query: TRoute["$query"] } : {}) & + (TRoute["state"]["body"] extends ZodType ? { body: TRoute["$body"] } : {}) & { + headers?: HeadersInit; + } + >, + ) => RouteResponse + : (payload?: { headers: HeadersInit }) => RouteResponse; + +type HasPayload = TRoute["state"]["params"] extends ZodObject + ? true + : TRoute["state"]["query"] extends ZodObject + ? true + : TRoute["state"]["body"] extends ZodType + ? true + : false; + +type RouteResponse = Promise, RouteErrors>> & { + $params: TRoute["$params"]; + $query: TRoute["$query"]; + $body: TRoute["$body"]; + $response: TRoute["$response"]; +}; + +type RouteOutput = TRoute["state"]["response"] extends ZodType ? TRoute["$response"] : null; + +type RouteErrors = InstanceType; + +type Config = { + adapter: RelayAdapter; +}; + +type Prettify = { [K in keyof T]: T[K] } & {}; diff --git a/platform/relay/libraries/context.ts b/platform/relay/libraries/context.ts new file mode 100644 index 0000000..2fe5065 --- /dev/null +++ b/platform/relay/libraries/context.ts @@ -0,0 +1,5 @@ +export interface ServerContext { + id: string; +} + +export const context: ServerContext = {} as any; diff --git a/platform/relay/libraries/errors.ts b/platform/relay/libraries/errors.ts new file mode 100644 index 0000000..b7dd0da --- /dev/null +++ b/platform/relay/libraries/errors.ts @@ -0,0 +1,437 @@ +import type { ZodError } from "zod"; + +export abstract class ServerError extends Error { + abstract readonly code: string; + + constructor( + message: string, + readonly status: number, + readonly data?: TData, + ) { + super(message); + } + + /** + * Converts a server delivered JSON error to its native instance. + * + * @param error - Error JSON. + */ + static fromJSON(error: ServerErrorJSON): ServerErrorType { + switch (error.code) { + case "BAD_REQUEST": + return new BadRequestError(error.message, error.data); + case "UNAUTHORIZED": + return new UnauthorizedError(error.message, error.data); + case "FORBIDDEN": + return new ForbiddenError(error.message, error.data); + case "NOT_FOUND": + return new NotFoundError(error.message, error.data); + case "METHOD_NOT_ALLOWED": + return new MethodNotAllowedError(error.message, error.data); + case "NOT_ACCEPTABLE": + return new NotAcceptableError(error.message, error.data); + case "CONFLICT": + return new ConflictError(error.message, error.data); + case "GONE": + return new GoneError(error.message, error.data); + case "UNSUPPORTED_MEDIA_TYPE": + return new UnsupportedMediaTypeError(error.message, error.data); + case "UNPROCESSABLE_CONTENT": + return new UnprocessableContentError(error.message, error.data); + case "VALIDATION": + return new ValidationError(error.message, error.data); + case "INTERNAL_SERVER": + return new InternalServerError(error.message, error.data); + case "NOT_IMPLEMENTED": + return new NotImplementedError(error.message, error.data); + case "SERVICE_UNAVAILABLE": + return new ServiceUnavailableError(error.message, error.data); + default: + return new InternalServerError(error.message, error.data); + } + } + + /** + * Convert error instance to a JSON object. + */ + toJSON(): ServerErrorJSON { + return { + code: this.code as ServerErrorJSON["code"], + status: this.status, + message: this.message, + data: this.data, + }; + } +} + +export class BadRequestError extends ServerError { + readonly code = "BAD_REQUEST"; + + /** + * Instantiate a new BadRequestError. + * + * The **HTTP 400 Bad Request** response status code indicates that the server + * cannot or will not process the request due to something that is perceived to + * be a client error. + * + * @param message - the message that describes the error. Default: "Bad Request". + * @param data - Optional data to send with the error. + */ + constructor(message = "Bad Request", data?: TData) { + super(message, 400, data); + } +} + +export class UnauthorizedError extends ServerError { + readonly code = "UNAUTHORIZED"; + + /** + * Instantiate a new UnauthorizedError. + * + * The **HTTP 401 Unauthorized** response status code indicates that the client + * request has not been completed because it lacks valid authentication + * credentials for the requested resource. + * + * This status code is sent with an HTTP WWW-Authenticate response header that + * contains information on how the client can request for the resource again after + * prompting the user for authentication credentials. + * + * This status code is similar to the **403 Forbidden** status code, except that + * in situations resulting in this status code, user authentication can allow + * access to the resource. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 + * + * @param message - Optional message to send with the error. Default: "Unauthorized". + * @param data - Optional data to send with the error. + */ + constructor(message = "Unauthorized", data?: TData) { + super(message, 401, data); + } +} + +export class ForbiddenError extends ServerError { + readonly code = "FORBIDDEN"; + + /** + * Instantiate a new ForbiddenError. + * + * The **HTTP 403 Forbidden** response status code indicates that the server + * understands the request but refuses to authorize it. + * + * This status is similar to **401**, but for the **403 Forbidden** status code + * re-authenticating makes no difference. The access is permanently forbidden and + * tied to the application logic, such as insufficient rights to a resource. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403 + * + * @param message - Optional message to send with the error. Default: "Forbidden". + * @param data - Optional data to send with the error. + */ + constructor(message = "Forbidden", data?: TData) { + super(message, 403, data); + } +} + +export class NotFoundError extends ServerError { + readonly code = "NOT_FOUND"; + + /** + * Instantiate a new NotFoundError. + * + * The **HTTP 404 Not Found** response status code indicates that the server + * cannot find the requested resource. Links that lead to a 404 page are often + * called broken or dead links and can be subject to link rot. + * + * A 404 status code only indicates that the resource is missing: not whether the + * absence is temporary or permanent. If a resource is permanently removed, + * use the **410 _(Gone)_** status instead. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 + * + * @param message - Optional message to send with the error. Default: "Not Found". + * @param data - Optional data to send with the error. + */ + constructor(message = "Not Found", data?: TData) { + super(message, 404, data); + } +} + +export class MethodNotAllowedError extends ServerError { + readonly code = "METHOD_NOT_ALLOWED"; + + /** + * Instantiate a new MethodNotAllowedError. + * + * The **HTTP 405 Method Not Allowed** response code indicates that the + * request method is known by the server but is not supported by the target resource. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405 + * + * @param message - Optional message to send with the error. Default: "Method Not Allowed". + * @param data - Optional data to send with the error. + */ + constructor(message = "Method Not Allowed", data?: TData) { + super(message, 405, data); + } +} + +export class NotAcceptableError extends ServerError { + readonly code = "NOT_ACCEPTABLE"; + + /** + * Instantiate a new NotAcceptableError. + * + * The **HTTP 406 Not Acceptable** client error response code indicates that the + * server cannot produce a response matching the list of acceptable values + * defined in the request, and that the server is unwilling to supply a default + * representation. + * + * @param message - Optional message to send with the error. Default: "Not Acceptable". + * @param data - Optional data to send with the error. + */ + constructor(message = "Not Acceptable", data?: TData) { + super(message, 406, data); + } +} + +export class ConflictError extends ServerError { + readonly code = "CONFLICT"; + + /** + * Instantiate a new ConflictError. + * + * The **HTTP 409 Conflict** response status code indicates a request conflict + * with the current state of the target resource. + * + * Conflicts are most likely to occur in response to a PUT request. For example, + * you may get a 409 response when uploading a file that is older than the + * existing one on the server, resulting in a version control conflict. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409 + * + * @param message - Optional message to send with the error. Default: "Conflict". + * @param data - Optional data to send with the error. + */ + constructor(message = "Conflict", data?: TData) { + super(message, 409, data); + } +} + +export class GoneError extends ServerError { + readonly code = "GONE"; + + /** + * Instantiate a new GoneError. + * + * The **HTTP 410 Gone** indicates that the target resource is no longer + * available at the origin server and that this condition is likely to be + * permanent. A 410 response is cacheable by default. + * + * Clients should not repeat requests for resources that return a 410 response, + * and website owners should remove or replace links that return this code. If + * server owners don't know whether this condition is temporary or permanent, + * a 404 status code should be used instead. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/410 + * + * @param message - Optional message to send with the error. Default: "Gone". + * @param data - Optional data to send with the error. + */ + constructor(message = "Gone", data?: TData) { + super(message, 410, data); + } +} + +export class UnsupportedMediaTypeError extends ServerError { + readonly code = "UNSUPPORTED_MEDIA_TYPE"; + + /** + * Instantiate a new UnsupportedMediaTypeError. + * + * The **HTTP 415 Unsupported Media Type** response code indicates that the + * server refuses to accept the request because the payload format is in an unsupported format. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415 + * + * @param message - Optional message to send with the error. Default: "Unsupported Media Type". + * @param data - Optional data to send with the error. + */ + constructor(message = "Unsupported Media Type", data?: TData) { + super(message, 415, data); + } +} + +export class UnprocessableContentError extends ServerError { + readonly code = "UNPROCESSABLE_CONTENT"; + + /** + * Instantiate a new UnprocessableContentError. + * + * The **HTTP 422 Unprocessable Content** client error response status code + * indicates that the server understood the content type of the request entity, + * and the syntax of the request entity was correct, but it was unable to + * process the contained instructions. + * + * Clients that receive a 422 response should expect that repeating the request + * without modification will fail with the same error. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422 + * + * @param message - Optional message to send with the error. Default: "Unprocessable Content". + * @param data - Optional data to send with the error. + */ + constructor(message = "Unprocessable Content", data?: TData) { + super(message, 422, data); + } +} + +export class ValidationError extends ServerError { + readonly code = "VALIDATION"; + + /** + * Instantiate a new ValidationError. + * + * This indicates that the server understood the request, but the content + * failed semantic validation against the expected schema. + * + * @param message - Optional message to send with the error. Default: "Validation Failed". + * @param data - Data with validation failure details. + */ + constructor(message = "Validation Failed", data: TData) { + super(message, 422, data); + } + + /** + * Instantiate a new ValidationError. + * + * This indicates that the server understood the request, but the content + * failed semantic validation against the expected schema. + * + * @param zodError - The original ZodError instance. + * @param source - The source of the validation error. + * @param message - Optional override for the main error message. + */ + static fromZod(zodError: ZodError, source: ErrorSource, message?: string) { + return new ValidationError(message, { + details: zodError.issues.map((issue) => { + return { + source: source, + code: issue.code, + field: issue.path.join("."), + message: issue.message, + }; + }), + } satisfies ValidationErrorData); + } +} + +export class InternalServerError extends ServerError { + readonly code = "INTERNAL_SERVER"; + + /** + * Instantiate a new InternalServerError. + * + * The **HTTP 500 Internal Server Error** server error response code indicates that + * the server encountered an unexpected condition that prevented it from fulfilling + * the request. + * + * This error response is a generic "catch-all" response. Usually, this indicates + * the server cannot find a better 5xx error code to response. Sometimes, server + * administrators log error responses like the 500 status code with more details + * about the request to prevent the error from happening again in the future. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 + * + * @param message - Optional message to send with the error. Default: "Internal Server Error". + * @param data - Optional data to send with the error. + */ + constructor(message = "Internal Server Error", data?: TData) { + super(message, 500, data); + } +} + +export class NotImplementedError extends ServerError { + readonly code = "NOT_IMPLEMENTED"; + + /** + * Instantiate a new NotImplementedError. + * + * The **HTTP 501 Not Implemented** server error response status code means that + * the server does not support the functionality required to fulfill the request. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/501 + * + * @param message - Optional message to send with the error. Default: "Service Unavailable". + * @param data - Optional data to send with the error. + */ + constructor(message = "Not Implemented", data?: TData) { + super(message, 501, data); + } +} + +export class ServiceUnavailableError extends ServerError { + readonly code = "SERVICE_UNAVAILABLE"; + + /** + * Instantiate a new ServiceUnavailableError. + * + * The **HTTP 503 Service Unavailable** server error response status code indicates + * that the server is not ready to handle the request. + * + * This response should be used for temporary conditions and the Retry-After HTTP header + * should contain the estimated time for the recovery of the service, if possible. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503 + * + * @param message - Optional message to send with the error. Default: "Service Unavailable". + * @param data - Optional data to send with the error. + */ + constructor(message = "Service Unavailable", data?: TData) { + super(message, 503, data); + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type ServerErrorJSON = { + code: ServerErrorType["code"]; + status: number; + message: string; + data?: any; +}; + +export type ServerErrorClass = typeof ServerError; + +export type ServerErrorType = + | BadRequestError + | UnauthorizedError + | ForbiddenError + | NotFoundError + | MethodNotAllowedError + | NotAcceptableError + | ConflictError + | GoneError + | UnsupportedMediaTypeError + | UnprocessableContentError + | NotImplementedError + | ServiceUnavailableError + | ValidationError + | InternalServerError; + +export type ErrorSource = "body" | "query" | "params" | "client"; + +type ValidationErrorData = { + details: ValidationErrorDetail[]; +}; + +type ValidationErrorDetail = { + source: ErrorSource; + code: string; + field: string; + message: string; +}; diff --git a/platform/relay/libraries/procedure.ts b/platform/relay/libraries/procedure.ts new file mode 100644 index 0000000..a9b2ca0 --- /dev/null +++ b/platform/relay/libraries/procedure.ts @@ -0,0 +1,242 @@ +import type z from "zod"; +import type { ZodType } from "zod"; + +import type { ServerContext } from "./context.ts"; +import type { ServerError, ServerErrorClass } from "./errors.ts"; +import type { RouteAccess } from "./route.ts"; + +export class Procedure { + readonly type = "procedure" as const; + + declare readonly $params: TState["params"] extends ZodType ? z.input : never; + declare readonly $response: TState["response"] extends ZodType ? z.output : never; + + /** + * Instantiate a new Procedure instance. + * + * @param state - Procedure state. + */ + constructor(readonly state: TState) {} + + /** + * Procedure method value. + */ + get method(): State["method"] { + return this.state.method; + } + + /** + * Access level of the procedure which acts as the first barrier of entry + * to ensure that requests are valid. + * + * By default on the server the lack of access definition will result + * in an error as all procedures needs an access definition. + * + * @param access - Access level of the procedure. + * + * @examples + * + * ```ts + * procedure + * .method("users:create") + * .access("public") + * .handle(async () => { + * // ... + * }); + * + * procedure + * .method("users:get-by-id") + * .access("session") + * .params(z.string()) + * .handle(async (userId, context) => { + * if (userId !== context.session.userId) { + * return new ForbiddenError("Cannot read other users details."); + * } + * }); + * + * procedure + * .method("users:update") + * .access([resource("users", "update")]) + * .params(z.array(z.string(), z.object({ name: z.string() }))) + * .handle(async ([userId, payload], context) => { + * if (userId !== context.session.userId) { + * return new ForbiddenError("Cannot update other users details."); + * } + * console.log(userId, payload); // => string, { name: string } + * }); + * ``` + */ + access(access: TAccess): Procedure & { access: TAccess }> { + return new Procedure({ ...this.state, access: access as TAccess }); + } + + /** + * Defines the payload forwarded to the handler. + * + * @param params - Method payload. + * + * @examples + * + * ```ts + * procedure + * .method("users:create") + * .access([resource("users", "create")]) + * .params(z.object({ + * name: z.string(), + * email: z.email(), + * })) + * .handle(async ({ name, email }, context) => { + * return { name, email, createdBy: context.session.userId }; + * }); + * ``` + */ + params(params: TParams): Procedure & { params: TParams }> { + return new Procedure({ ...this.state, params }); + } + + /** + * Instances of the possible error responses this procedure produces. + * + * @param errors - Error shapes of the procedure. + * + * @examples + * + * ```ts + * procedure + * .method("users:list") + * .errors([ + * BadRequestError + * ]) + * .handle(async () => { + * return new BadRequestError(); + * }); + * ``` + */ + errors(errors: TErrors): Procedure & { errors: TErrors }> { + return new Procedure({ ...this.state, errors }); + } + + /** + * Shape of the success response this procedure produces. This is used by the transform + * tools to ensure the client receives parsed data. + * + * @param response - Response shape of the procedure. + * + * @examples + * + * ```ts + * procedure + * .post("users:list") + * .response( + * z.array( + * z.object({ + * name: z.string() + * }), + * ) + * ) + * .handle(async () => { + * return [{ name: "John Doe" }]; + * }); + * ``` + */ + response( + response: TResponse, + ): Procedure & { response: TResponse }> { + return new Procedure({ ...this.state, response }); + } + + /** + * Server handler callback method. + * + * Handler receives the params, query, body, actions in order of definition. + * So if your route has params, and body the route handle method will + * receive (params, body) as arguments. + * + * @param handle - Handle function to trigger when the route is executed. + * + * @examples + * + * ```ts + * procedure + * .method("users:list") + * .response( + * z.array( + * z.object({ + * name: z.string() + * }), + * ) + * ) + * .handle(async () => { + * return [{ name: "John Doe" }]; + * }); + * ``` + */ + handle, TState["response"]>>( + handle: THandleFn, + ): Procedure & { handle: THandleFn }> { + return new Procedure({ ...this.state, handle }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +/** + * Route factories allowing for easy generation of relay compliant routes. + */ +export const procedure: { + /** + * Create a new procedure with given method name. + * + * @param method Name of the procedure used to match requests against. + * + * @examples + * + * ```ts + * procedure + * .method("users:get-by-id") + * .params( + * z.string().describe("Users unique identifier") + * ); + * ``` + */ + method(method: TMethod): Procedure<{ method: TMethod }>; +} = { + method(method: TMethod) { + return new Procedure({ method }); + }, +}; + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type Procedures = { + [key: string]: Procedures | Procedure; +}; + +type State = { + method: string; + access?: RouteAccess; + params?: ZodType; + errors?: ServerErrorClass[]; + response?: ZodType; + handle?: HandleFn; +}; + +type HandleFn = any[], TResponse = any> = ( + ...args: TArgs +) => TResponse extends ZodType + ? Promise | Response | ServerError> + : Promise; + +type ServerArgs = HasInputArgs extends true + ? [z.output, ServerContext] + : [ServerContext]; + +type HasInputArgs = TState["params"] extends ZodType ? true : false; diff --git a/platform/relay/libraries/route.ts b/platform/relay/libraries/route.ts new file mode 100644 index 0000000..2ea687e --- /dev/null +++ b/platform/relay/libraries/route.ts @@ -0,0 +1,469 @@ +import { type MatchFunction, match } from "path-to-regexp"; +import z, { type ZodObject, type ZodRawShape, type ZodType } from "zod"; + +import type { ServerContext } from "./context.ts"; +import { ServerError, type ServerErrorClass } from "./errors.ts"; + +export class Route { + readonly type = "route" as const; + + declare readonly $params: TState["params"] extends ZodObject ? z.input : never; + declare readonly $query: TState["query"] extends ZodObject ? z.input : never; + declare readonly $body: TState["body"] extends ZodType ? z.input : never; + declare readonly $response: TState["response"] extends ZodType ? z.output : never; + + #matchFn?: MatchFunction; + + /** + * Instantiate a new Route instance. + * + * @param state - Route state. + */ + constructor(readonly state: TState) {} + + /** + * HTTP Method + */ + get method(): RouteMethod { + return this.state.method; + } + + /** + * URL pattern of the route. + */ + get matchFn(): MatchFunction { + if (this.#matchFn === undefined) { + this.#matchFn = match(this.path); + } + return this.#matchFn; + } + + /** + * URL path + */ + get path(): string { + return this.state.path; + } + + /** + * Check if the provided URL matches the route pattern. + * + * @param url - HTTP request.url + */ + match(url: string): boolean { + return this.matchFn(url) !== false; + } + + /** + * Extract parameters from the provided URL based on the route pattern. + * + * @param url - HTTP request.url + */ + getParsedParams : object>( + url: string, + ): TParams { + const result = match(this.path)(url); + if (result === false) { + return {} as TParams; + } + return result.params as TParams; + } + + /** + * Set the meta data for this route which can be used in e.g. OpenAPI generation + * + * @param meta - Meta object + * + * @examples + * + * ```ts + * route.post("/foo").meta({ description: "Super route" }); + * ``` + */ + meta(meta: TRouteMeta): Route & { meta: TRouteMeta }>> { + return new Route({ ...this.state, meta }); + } + + /** + * Access level of the route which acts as the first barrier of entry + * to ensure that requests are valid. + * + * By default on the server the lack of access definition will result + * in an error as all routes needs an access definition. + * + * @param access - Access level of the route. + * + * @examples + * + * ```ts + * const hasFooBar = action + * .make("hasFooBar") + * .response(z.object({ foobar: z.number() })) + * .handle(async () => { + * return { + * foobar: 1, + * }; + * }); + * + * // ### Public Endpoint + * + * route + * .post("/foo") + * .access("public") + * .handle(async ({ foobar }) => { + * console.log(typeof foobar); // => number + * }); + * + * // ### Require Session + * + * route + * .post("/foo") + * .access("session") + * .handle(async ({ foobar }) => { + * console.log(typeof foobar); // => number + * }); + * + * // ### Require Session & Resource Assignment + * + * route + * .post("/foo") + * .access([resource("foo", "create")]) + * .handle(async ({ foobar }) => { + * console.log(typeof foobar); // => number + * }); + * ``` + */ + access(access: TAccess): Route & { access: TAccess }>> { + return new Route({ ...this.state, access: access as TAccess }); + } + + /** + * Params allows for custom casting of URL parameters. If a parameter does not + * have a corresponding zod schema the default param type is "string". + * + * @param params - URL params. + * + * @examples + * + * ```ts + * route + * .post("/foo/:bar") + * .params({ + * bar: z.coerce.number() + * }) + * .handle(async ({ bar }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + params( + params: TParams, + ): Route & { params: ZodObject }>> { + return new Route({ ...this.state, params: z.object(params) as any }); + } + + /** + * Search allows for custom casting of URL query parameters. If a parameter does + * not have a corresponding zod schema the default param type is "string". + * + * @param query - URL query arguments. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .query({ + * bar: z.number({ coerce: true }) + * }) + * .handle(async ({ bar }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + query( + query: TQuery, + ): Route & { query: ZodObject }>> { + return new Route({ ...this.state, query: z.object(query) as any }); + } + + /** + * Shape of the body this route expects to receive. This is used by all + * mutator routes and has no effect when defined on "GET" methods. + * + * @param body - Body the route expects. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .body( + * z.object({ + * bar: z.number() + * }) + * ) + * .handle(async ({ body: { bar } }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + body(body: TBody): Route & { body: TBody }>> { + return new Route({ ...this.state, body }); + } + + /** + * Instances of the possible error responses this route produces. + * + * @param errors - Error shapes of the route. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .errors([ + * BadRequestError + * ]) + * .handle(async () => { + * return new BadRequestError(); + * }); + * ``` + */ + errors( + errors: TErrors, + ): Route & { errors: TErrors }>> { + return new Route({ ...this.state, errors }); + } + + /** + * Shape of the success response this route produces. This is used by the transform + * tools to ensure the client receives parsed data. + * + * @param response - Response shape of the route. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .response( + * z.object({ + * bar: z.number() + * }) + * ) + * .handle(async () => { + * return { + * bar: 1 + * } + * }); + * ``` + */ + response( + response: TResponse, + ): Route & { response: TResponse }>> { + return new Route({ ...this.state, response }); + } + + /** + * Server handler callback method. + * + * Handler receives the params, query, body, actions in order of definition. + * So if your route has params, and body the route handle method will + * receive (params, body) as arguments. + * + * @param handle - Handle function to trigger when the route is executed. + * + * @examples + * + * ```ts + * relay + * .post("/api/v1/foo/:bar") + * .params({ bar: z.string() }) + * .body(z.tuple([z.string(), z.number()])) + * .handle(async ({ bar }, [ "string", number ]) => {}); + * ``` + */ + handle, TState["response"]>>( + handle: THandleFn, + ): Route & { handle: THandleFn }> { + return new Route({ ...this.state, handle }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +/** + * Route factories allowing for easy generation of relay compliant routes. + */ +export const route: { + post( + path: TPath, + ): Route<{ method: "POST"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; + get( + path: TPath, + ): Route<{ method: "GET"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; + put( + path: TPath, + ): Route<{ method: "PUT"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; + patch( + path: TPath, + ): Route<{ method: "PATCH"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; + delete( + path: TPath, + ): Route<{ method: "DELETE"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; +} = { + /** + * Create a new "POST" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + post(path: TPath) { + return new Route({ method: "POST", path, content: "json", errors: [ServerError] }); + }, + + /** + * Create a new "GET" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route.get("/foo"); + * ``` + */ + get(path: TPath) { + return new Route({ method: "GET", path, content: "json", errors: [ServerError] }); + }, + + /** + * Create a new "PUT" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .put("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + put(path: TPath) { + return new Route({ method: "PUT", path, content: "json", errors: [ServerError] }); + }, + + /** + * Create a new "PATCH" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .patch("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + patch(path: TPath) { + return new Route({ method: "PATCH", path, content: "json", errors: [ServerError] }); + }, + + /** + * Create a new "DELETE" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route.delete("/foo"); + * ``` + */ + delete(path: TPath) { + return new Route({ method: "DELETE", path, content: "json", errors: [ServerError] }); + }, +}; + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type Routes = { + [key: string]: Routes | Route | RouteFn; +}; + +export type RouteFn = (...args: any[]) => any; + +type RouteState = { + method: RouteMethod; + path: string; + meta?: RouteMeta; + access?: RouteAccess; + params?: ZodObject; + query?: ZodObject; + body?: ZodType; + errors: ServerErrorClass[]; + response?: ZodType; + handle?: HandleFn; +}; + +export type RouteMeta = { + openapi?: "internal" | "external"; + description?: string; + summary?: string; + tags?: string[]; +} & Record; + +export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; + +export type RouteAccess = "public" | "authenticated"; + +type HandleFn = any[], TResponse = any> = ( + ...args: TArgs +) => TResponse extends ZodType + ? Promise | Response | ServerError> + : Promise; + +type ServerArgs = HasInputArgs extends true + ? [ + (TState["params"] extends ZodObject ? { params: z.output } : unknown) & + (TState["query"] extends ZodObject ? { query: z.output } : unknown) & + (TState["body"] extends ZodType ? { body: z.output } : unknown), + ServerContext, + ] + : [ServerContext]; + +type HasInputArgs = TState["params"] extends ZodObject + ? true + : TState["query"] extends ZodObject + ? true + : TState["body"] extends ZodType + ? true + : false; + +type Prettify = { + [K in keyof T]: T[K]; +} & {}; diff --git a/platform/relay/mod.ts b/platform/relay/mod.ts new file mode 100644 index 0000000..a676d10 --- /dev/null +++ b/platform/relay/mod.ts @@ -0,0 +1,7 @@ +export * from "./adapters/http.ts"; +export * from "./libraries/adapter.ts"; +export * from "./libraries/client.ts"; +export * from "./libraries/context.ts"; +export * from "./libraries/errors.ts"; +export * from "./libraries/procedure.ts"; +export * from "./libraries/route.ts"; diff --git a/platform/relay/package.json b/platform/relay/package.json new file mode 100644 index 0000000..4699bcc --- /dev/null +++ b/platform/relay/package.json @@ -0,0 +1,17 @@ +{ + "name": "@platform/relay", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./mod.ts", + "exports": { + ".": "./mod.ts" + }, + "dependencies": { + "@platform/auth": "workspace:*", + "@platform/socket": "workspace:*", + "@platform/supertokens": "workspace:*", + "path-to-regexp": "8", + "zod": "4.1.12" + } +} diff --git a/platform/routes/package.json b/platform/routes/package.json new file mode 100644 index 0000000..adc419e --- /dev/null +++ b/platform/routes/package.json @@ -0,0 +1,10 @@ +{ + "name": "@platform/routes", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@platform/relay": "workspace:*", + "zod": "4.1.12" + } +} diff --git a/platform/routes/session/search.ts b/platform/routes/session/search.ts new file mode 100644 index 0000000..b5b4e3c --- /dev/null +++ b/platform/routes/session/search.ts @@ -0,0 +1,8 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route.post("/api/v1/sessions/search").query({ + offset: z.number().min(0).default(0), + limit: z.number().min(10).max(100).default(100), + asc: z.boolean().default(true), +}); diff --git a/platform/server/api.ts b/platform/server/api.ts new file mode 100644 index 0000000..67dd055 --- /dev/null +++ b/platform/server/api.ts @@ -0,0 +1,400 @@ +import { logger } from "@platform/logger"; +import { + BadRequestError, + context, + InternalServerError, + NotFoundError, + NotImplementedError, + type Route, + type RouteMethod, + ServerError, + type ServerErrorResponse, + UnauthorizedError, + ValidationError, +} from "@platform/relay"; + +const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + +export class Api { + readonly #index = new Map(); + + /** + * Route maps funneling registered routes to the specific methods supported by + * the relay instance. + */ + readonly routes: Routes = { + POST: [], + GET: [], + PUT: [], + PATCH: [], + DELETE: [], + }; + + /** + * List of paths in the '${method} ${path}' format allowing us to quickly throw + * errors if a duplicate route path is being added. + */ + readonly #paths = new Set(); + + /** + * Instantiate a new Api instance. + * + * @param routes - Initial list of routes to register with the api. + */ + constructor(routes: Route[] = []) { + this.register(routes); + } + + /** + * Register relays with the API instance allowing for decoupled registration + * of server side handling of relay contracts. + * + * @param routes - Relays to register with the instance. + */ + register(routes: Route[]): this { + const methods: (keyof typeof this.routes)[] = []; + for (const route of routes) { + const path = `${route.method} ${route.path}`; + if (this.#paths.has(path)) { + throw new Error(`Router > Path ${path} already exists`); + } + this.#paths.add(path); + this.routes[route.method].push(route); + methods.push(route.method); + this.#index.set(`${route.method} ${route.path}`, route); + logger.prefix("API").info(`Registered ${route.method} ${route.path}`); + } + for (const method of methods) { + this.routes[method].sort(byStaticPriority); + } + return this; + } + + /** + * Executes request and returns a `Response` instance. + * + * @param request - REST request to pass to a route handler. + */ + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // ### Route + // Locate a route matching the incoming request method and path. + + const resolved = this.#getResolvedRoute(request.method, url.pathname); + if (resolved === undefined) { + return toResponse( + new NotFoundError(`Invalid routing path provided for ${request.url}`, { + method: request.method, + url: request.url, + }), + request, + ); + } + + // ### Handle + // Execute request and return a response. + + const response = await this.#getRouteResponse(resolved, request).catch((error) => + this.#getErrorResponse(error, request), + ); + + return response; + } + + /** + * Attempt to resolve a route based on the given method and pathname. + * + * @param method - HTTP method. + * @param url - HTTP request url. + */ + #getResolvedRoute(method: string, url: string): ResolvedRoute | undefined { + assertMethod(method); + for (const route of this.routes[method]) { + if (route.match(url) === true) { + return { route, params: route.getParsedParams(url) }; + } + } + } + + /** + * Resolve the request on the given route and return a `Response` instance. + * + * @param resolved - Route and paramter details resolved for the request. + * @param request - Request instance to resolve. + */ + async #getRouteResponse({ route, params }: ResolvedRoute, request: Request): Promise { + const url = new URL(request.url); + + // ### Args + // Arguments is passed to every route handler and provides a suite of functionality + // and request data. + + const args: any[] = []; + + // ### Input + // Generate route input which contains a map fo params, query, and/or body. If + // none of these are present then the input is not added to the final argument + // context of the handler. + + const input: { + params?: object; + query?: object; + body?: unknown; + } = { + params: undefined, + query: undefined, + body: undefined, + }; + + // ### Access + // Check the access requirements of the route and run any additional checks + // if nessesary before proceeding with further request handling. + // 1. All routes needs access assignment, else we consider it an internal error. + // 2. If access requires a session we throw Unauthorized if the request is not authenticated. + // 3. If access is an array of access resources, we check that each resources can be + // accessed by the request. + + if (route.state.access === undefined) { + return toResponse( + new InternalServerError(`Route '${route.method} ${route.path}' is missing access assignment.`), + request, + ); + } + + if (route.state.access === "authenticated" && context.isAuthenticated === false) { + return toResponse(new UnauthorizedError(), request); + } + + // ### Params + // If the route has params we want to coerce the values to the expected types. + + if (route.state.params !== undefined) { + const result = await route.state.params.safeParseAsync(params); + if (result.success === false) { + return toResponse(ValidationError.fromZod(result.error, "params", "Invalid request params"), request); + } + input.params = result.data; + } + + // ### Query + // If the route has a query schema we need to validate and parse the query. + + if (route.state.query !== undefined) { + const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {}); + if (result.success === false) { + return toResponse(ValidationError.fromZod(result.error, "query", "Invalid request query"), request); + } + input.query = result.data; + } + + // ### Body + // If the route has a body schema we need to validate and parse the body. + + if (route.state.body !== undefined) { + const body = await this.#getRequestBody(request); + const result = await route.state.body.safeParseAsync(body); + if (result.success === false) { + return toResponse(ValidationError.fromZod(result.error, "body", "Invalid request body"), request); + } + input.body = result.data; + } + + if (input.params !== undefined || input.query !== undefined || input.body !== undefined) { + args.push(input); + } + + // ### Context + // Request context pass to every route as the last argument. + + args.push(context); + + // ### Handler + // Execute the route handler and apply the result. + + if (route.state.handle === undefined) { + return toResponse(new NotImplementedError(`Path '${route.method} ${route.path}' is not implemented.`), request); + } + + return toResponse(await route.state.handle(...args), request); + } + + #getErrorResponse(error: unknown, request: Request): Response { + if (error instanceof ServerError) { + return toResponse(error, request); + } + logger.error(error); + if (error instanceof Error) { + return toResponse(new InternalServerError(error.message), request); + } + return toResponse(new InternalServerError(), request); + } + + /** + * Resolves request body and returns it. + * + * @param request - Request to resolve body from. + * @param files - Files to populate if present. + */ + async #getRequestBody(request: Request): Promise> { + let body: Record = {}; + + const type = request.headers.get("content-type"); + if (!type || request.method === "GET") { + return body; + } + + if (type.includes("json")) { + body = await request.json(); + } + + if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) { + try { + const formData = await request.formData(); + for (const [name, value] of Array.from(formData.entries())) { + body[name] = value; + } + } catch (error) { + logger.error(error); + throw new BadRequestError(`Malformed FormData`, { error }); + } + } + + return body; + } +} + +/* + |-------------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------------- + */ + +/** + * Assert that the given method string is a valid routing method. + * + * @param candidate - Method candidate. + */ +function assertMethod(candidate: string): asserts candidate is RouteMethod { + if (!SUPPORTED_MEHODS.includes(candidate)) { + throw new Error(`Router > Unsupported method '${candidate}'`); + } +} + +/** + * Sorting method for routes to ensure that static properties takes precedence + * for when a route is matched against incoming requests. + * + * @param a - Route A + * @param b - Route B + */ +function byStaticPriority(a: Route, b: Route) { + const aSegments = a.path.split("/"); + const bSegments = b.path.split("/"); + + const maxLength = Math.max(aSegments.length, bSegments.length); + + for (let i = 0; i < maxLength; i++) { + const aSegment = aSegments[i] || ""; + const bSegment = bSegments[i] || ""; + + const isADynamic = aSegment.startsWith(":"); + const isBDynamic = bSegment.startsWith(":"); + + if (isADynamic !== isBDynamic) { + return isADynamic ? 1 : -1; + } + + if (isADynamic === false && aSegment !== bSegment) { + return aSegment.localeCompare(bSegment); + } + } + + return a.path.localeCompare(b.path); +} + +/** + * Resolve and return query object from the provided search parameters, or undefined + * if the search parameters does not have any entries. + * + * @param searchParams - Search params to create a query object from. + */ +function toQuery(searchParams: URLSearchParams): object | undefined { + if (searchParams.size === 0) { + return undefined; + } + const result: Record = {}; + for (const [key, value] of searchParams.entries()) { + result[key] = value; + } + return result; +} + +/** + * Takes a server side request result and returns a fetch Response. + * + * @param result - Result to send back as a Response. + * @param request - Request instance. + */ +export function toResponse(result: unknown, request: Request): Response { + const method = request.method; + + if (result instanceof Response) { + if (method === "HEAD") { + return new Response(null, { + status: result.status, + statusText: result.statusText, + headers: new Headers(result.headers), + }); + } + return result; + } + if (result instanceof ServerError) { + const body = JSON.stringify({ + error: { + code: result.code, + status: result.status, + message: result.message, + data: result.data, + }, + } satisfies ServerErrorResponse); + + return new Response(method === "HEAD" ? null : body, { + statusText: result.message || "Internal Server Error", + status: result.status || 500, + headers: { + "content-type": "application/json", + }, + }); + } + + const body = JSON.stringify({ + data: result ?? null, + }); + + return new Response(method === "HEAD" ? null : body, { + status: 200, + headers: { + "content-type": "application/json", + }, + }); +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type Routes = { + POST: Route[]; + GET: Route[]; + PUT: Route[]; + PATCH: Route[]; + DELETE: Route[]; +}; + +type ResolvedRoute = { + route: Route; + params: any; +}; diff --git a/platform/server/modules.ts b/platform/server/modules.ts new file mode 100644 index 0000000..9a4765d --- /dev/null +++ b/platform/server/modules.ts @@ -0,0 +1,40 @@ +import { Route } from "@platform/relay"; + +/** + * Resolve and return all routes that has been created under any 'routes' + * folders that can be found under the given path. + * + * If the filter is empty, all paths are resolved, otherwise only paths + * declared in the array is resolved. + * + * @param path - Path to resolve routes from. + * @param filter - List of modules to include. + * @param routes - List of routes that has been resolved. + */ +export async function resolveRoutes(path: string, routes: Route[] = []): Promise { + for await (const entry of Deno.readDir(path)) { + if (entry.isDirectory === true) { + await loadRoutes(`${path}/${entry.name}`, routes, [name]); + } + } + return routes; +} + +async function loadRoutes(path: string, routes: Route[], modules: string[]): Promise { + for await (const entry of Deno.readDir(path)) { + if (entry.isDirectory === true) { + await loadRoutes(`${path}/${entry.name}`, routes, [...modules, entry.name]); + } else { + if (!entry.name.endsWith(".ts") || entry.name.endsWith("i9n.ts")) { + continue; + } + const { default: route } = (await import(`${path}/${entry.name}`)) as { default: Route }; + if (route instanceof Route === false) { + throw new Error( + `Router Violation: Could not load '${path}/${entry.name}' as it does not export a default Route instance.`, + ); + } + routes.push(route); + } + } +} diff --git a/platform/server/package.json b/platform/server/package.json new file mode 100644 index 0000000..f7a3345 --- /dev/null +++ b/platform/server/package.json @@ -0,0 +1,16 @@ +{ + "name": "@platform/server", + "version": "0.0.0", + "private": true, + "type": "module", + "types": "types.d.ts", + "dependencies": { + "@platform/auth": "workspace:*", + "@platform/logger": "workspace:*", + "@platform/relay": "workspace:*", + "@platform/socket": "workspace:*", + "@platform/storage": "workspace:*", + "@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0", + "zod": "4.1.12" + } +} diff --git a/platform/server/server.ts b/platform/server/server.ts new file mode 100644 index 0000000..7fac0ff --- /dev/null +++ b/platform/server/server.ts @@ -0,0 +1,69 @@ +import "./types.ts"; + +import { context, InternalServerError } from "@platform/relay"; +import { getStorageContext, storage } from "@platform/storage"; + +export default { + /** + * TODO ... + */ + bootstrap: async (): Promise => { + Object.defineProperties(context, { + /** + * TODO ... + */ + request: { + get() { + const request = storage.getStore()?.request; + if (request === undefined) { + throw new InternalServerError("Storage missing 'request' assignment."); + } + return request; + }, + }, + + /** + * TODO ... + */ + response: { + get() { + const response = storage.getStore()?.response; + if (response === undefined) { + throw new InternalServerError("Storage missing 'response' assignment."); + } + return response; + }, + }, + + /** + * TODO ... + */ + info: { + get() { + const info = storage.getStore()?.info; + if (info === undefined) { + throw new InternalServerError("Storage missing 'info' assignment."); + } + return info; + }, + }, + }); + }, + + /** + * TODO ... + */ + resolve: async (request: Request): Promise => { + const context = getStorageContext(); + context.request = { + headers: request.headers, + }; + context.response = { + headers: new Headers(), + }; + context.info = { + method: request.url, + start: Date.now(), + }; + }, +}; diff --git a/platform/server/socket.ts b/platform/server/socket.ts new file mode 100644 index 0000000..9c71129 --- /dev/null +++ b/platform/server/socket.ts @@ -0,0 +1,50 @@ +import { logger } from "@platform/logger"; +import { context } from "@platform/relay"; +import { storage } from "@platform/storage"; +import { toJsonRpc } from "@valkyr/json-rpc"; + +import type { Api } from "./api.ts"; + +/** + * TODO ... + */ +export function upgradeWebSocket(request: Request, _api: Api) { + const { socket, response } = Deno.upgradeWebSocket(request); + + socket.addEventListener("open", () => { + logger.prefix("Socket").info("socket connected", {}); + context.sockets.add(socket); + }); + + socket.addEventListener("close", () => { + logger.prefix("Socket").info("socket disconnected", {}); + context.sockets.del(socket); + }); + + socket.addEventListener("message", (event) => { + if (event.data === "ping") { + return; + } + + const message = toJsonRpc(event.data); + + logger.prefix("Socket").info(message); + + storage.run({}, async () => { + // api + // .send(body) + // .then((response) => { + // if (response !== undefined) { + // logger.info({ response }); + // socket.send(JSON.stringify(response)); + // } + // }) + // .catch((error) => { + // logger.info({ error }); + // socket.send(JSON.stringify(error)); + // }); + }); + }); + + return response; +} diff --git a/platform/server/types.ts b/platform/server/types.ts new file mode 100644 index 0000000..1fe0d91 --- /dev/null +++ b/platform/server/types.ts @@ -0,0 +1,45 @@ +import "@platform/relay"; +import "@platform/storage"; + +declare module "@platform/storage" { + interface StorageContext { + /** + * TODO ... + */ + request?: { + headers: Headers; + }; + + /** + * TODO ... + */ + response?: { + headers: Headers; + }; + + /** + * TODO ... + */ + info?: { + method: string; + start: number; + end?: number; + }; + } +} + +declare module "@platform/relay" { + interface ServerContext { + request: { + headers: Headers; + }; + response: { + headers: Headers; + }; + info: { + method: string; + start: number; + end?: number; + }; + } +} diff --git a/platform/socket/channels.ts b/platform/socket/channels.ts new file mode 100644 index 0000000..bd6ba61 --- /dev/null +++ b/platform/socket/channels.ts @@ -0,0 +1,81 @@ +import type { Params } from "@valkyr/json-rpc"; + +import { SocketRegistry } from "./sockets.ts"; + +export class Channels { + readonly #channels = new Map(); + + /** + * Add a new channel. + * + * @param channel + */ + add(channel: string): this { + this.#channels.set(channel, new SocketRegistry()); + return this; + } + + /** + * Deletes a channel. + * + * @param channel + */ + del(channel: string): this { + this.#channels.delete(channel); + return this; + } + + /** + * Add socket to the given channel. If the channel does not exist it is + * automatically created. + * + * @param channel - Channel to add socket to. + * @param socket - Socket to add to the channel. + */ + join(channel: string, socket: WebSocket): this { + const sockets = this.#channels.get(channel); + if (sockets === undefined) { + this.#channels.set(channel, new SocketRegistry().add(socket)); + } else { + sockets.add(socket); + } + return this; + } + + /** + * Remove a socket from the given channel. + * + * @param channel - Channel to leave. + * @param socket - Socket to remove from the channel. + */ + leave(channel: string, socket: WebSocket): this { + this.#channels.get(channel)?.del(socket); + return this; + } + + /** + * Sends a JSON-RPC notification to all sockets in given channel. + * + * @param channel - Channel to emit method to. + * @param method - Method to send the notification to. + * @param params - Message data to send to the clients. + */ + notify(channel: string, method: string, params: Params): this { + this.#channels.get(channel)?.notify(method, params); + return this; + } + + /** + * Transmits data to all registered WebSocket connections in the given channel. + * Data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView. + * + * @param channel - Channel to emit message to. + * @param data - Data to send to each connected socket in the channel. + */ + send(channel: string, data: string | ArrayBufferLike | Blob | ArrayBufferView): this { + this.#channels.get(channel)?.send(data); + return this; + } +} + +export const channels = new Channels(); diff --git a/platform/socket/package.json b/platform/socket/package.json new file mode 100644 index 0000000..8b2c18e --- /dev/null +++ b/platform/socket/package.json @@ -0,0 +1,14 @@ +{ + "name": "@platform/socket", + "version": "0.0.0", + "private": true, + "type": "module", + "types": "types.d.ts", + "dependencies": { + "@platform/auth": "workspace:*", + "@platform/logger": "workspace:*", + "@platform/relay": "workspace:*", + "@platform/storage": "workspace:*", + "@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0" + } +} diff --git a/platform/socket/server.ts b/platform/socket/server.ts new file mode 100644 index 0000000..e8546ea --- /dev/null +++ b/platform/socket/server.ts @@ -0,0 +1,38 @@ +import "./types.d.ts"; + +import { context, InternalServerError } from "@platform/relay"; +import { getStorageContext, storage } from "@platform/storage"; + +import { SocketRegistry } from "./sockets.ts"; + +export const sockets = new SocketRegistry(); + +export default { + /** + * TODO ... + */ + bootstrap: async (): Promise => { + Object.defineProperties(context, { + /** + * TODO ... + */ + sockets: { + get() { + const sockets = storage.getStore()?.sockets; + if (sockets === undefined) { + throw new InternalServerError("Sockets not defined."); + } + return sockets; + }, + }, + }); + }, + + /** + * TODO ... + */ + resolve: async (): Promise => { + const context = getStorageContext(); + context.sockets = sockets; + }, +}; diff --git a/platform/socket/sockets.ts b/platform/socket/sockets.ts new file mode 100644 index 0000000..73e268c --- /dev/null +++ b/platform/socket/sockets.ts @@ -0,0 +1,49 @@ +import type { Params } from "@valkyr/json-rpc"; + +export class SocketRegistry { + readonly #sockets = new Set(); + + /** + * Add a socket to the pool. + * + * @param socket - WebSocket to add. + */ + add(socket: WebSocket): this { + this.#sockets.add(socket); + return this; + } + + /** + * Remove a socket from the pool. + * + * @param socket - WebSocket to remove. + */ + del(socket: WebSocket): this { + this.#sockets.delete(socket); + return this; + } + + /** + * Sends a JSON-RPC notification to all connected sockets. + * + * @param method - Method to send the notification to. + * @param params - Message data to send to the clients. + */ + notify(method: string, params: Params): this { + this.send(JSON.stringify({ jsonrpc: "2.0", method, params })); + return this; + } + + /** + * Transmits data to all registered WebSocket connections. Data can be a string, + * a Blob, an ArrayBuffer, or an ArrayBufferView. + * + * @param data - Data to send to each connected socket. + */ + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): this { + this.#sockets.forEach((socket) => { + socket.send(data); + }); + return this; + } +} diff --git a/platform/socket/types.d.ts b/platform/socket/types.d.ts new file mode 100644 index 0000000..d00509f --- /dev/null +++ b/platform/socket/types.d.ts @@ -0,0 +1,22 @@ +import "@platform/relay"; +import "@platform/storage"; + +import { SocketRegistry } from "./sockets.ts"; + +declare module "@platform/storage" { + interface StorageContext { + /** + * TODO ... + */ + sockets?: SocketRegistry; + } +} + +declare module "@platform/relay" { + export interface ServerContext { + /** + * TODO ... + */ + sockets: SocketRegistry; + } +} diff --git a/platform/spec/audit/actor.ts b/platform/spec/audit/actor.ts new file mode 100644 index 0000000..a72d94e --- /dev/null +++ b/platform/spec/audit/actor.ts @@ -0,0 +1,17 @@ +import z from "zod"; + +import { AuditUserSchema, AuditUserType } from "./user.ts"; + +export const AuditActorSchema = z.object({ + user: AuditUserSchema, +}); + +export const auditors = { + system: AuditActorSchema.parse({ + user: { + typeId: AuditUserType.System, + }, + }), +}; + +export type AuditActor = z.infer; diff --git a/platform/spec/audit/user.ts b/platform/spec/audit/user.ts new file mode 100644 index 0000000..77c6b4b --- /dev/null +++ b/platform/spec/audit/user.ts @@ -0,0 +1,17 @@ +import z from "zod"; + +export enum AuditUserType { + Unknown = 0, + Identity = 1, + System = 2, + Service = 3, + Other = 99, +} + +export const AuditUserSchema = z.object({ + typeId: z.enum(AuditUserType).describe("The account type identifier."), + uid: z + .string() + .optional() + .describe("The unique user identifier. For example, the Windows user SID, ActiveDirectory DN or AWS user ARN."), +}); diff --git a/platform/spec/auth/errors.ts b/platform/spec/auth/errors.ts new file mode 100644 index 0000000..37a807b --- /dev/null +++ b/platform/spec/auth/errors.ts @@ -0,0 +1,7 @@ +import { BadRequestError } from "@platform/relay"; + +export class AuthenticationStrategyPayloadError extends BadRequestError { + constructor() { + super("Provided authentication payload is not recognized."); + } +} diff --git a/platform/spec/auth/strategies.ts b/platform/spec/auth/strategies.ts new file mode 100644 index 0000000..a113cda --- /dev/null +++ b/platform/spec/auth/strategies.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +export const PasskeyStrategySchema = z.object({ + type: z.literal("passkey").describe("Authentication strategy type for WebAuthn/Passkey"), + id: z.string().describe("Base64URL encoded credential ID"), + rawId: z.string().describe("Raw credential ID as base64URL encoded string"), + response: z + .object({ + clientDataJSON: z.string().describe("Base64URL encoded client data JSON"), + authenticatorData: z.string().describe("Base64URL encoded authenticator data"), + signature: z.string().optional().describe("Signature for authentication responses"), + userHandle: z.string().optional().describe("Optional user handle identifier"), + attestationObject: z.string().optional().describe("Attestation object for registration responses"), + }) + .describe("WebAuthn response data"), + clientExtensionResults: z + .record(z.string(), z.unknown()) + .default({}) + .describe("Results from WebAuthn extension inputs"), + authenticatorAttachment: z + .enum(["platform", "cross-platform"]) + .optional() + .describe("Type of authenticator used (platform or cross-platform)"), +}); + +export const EmailStrategySchema = z.object({ + type: z.literal("email").describe("Authentication strategy type for email"), + email: z.email().describe("User's email address for authentication"), +}); + +export const PasswordStrategySchema = z.object({ + type: z.literal("password").describe("Authentication strategy type for password"), + alias: z.string().describe("User alias (username or email)"), + password: z.string().describe("User's password"), +}); + +export const StrategySchema = z + .union([PasskeyStrategySchema, EmailStrategySchema, PasswordStrategySchema]) + .describe("Union of all available authentication strategy schemas"); + +export type PasskeyStrategy = z.infer; +export type EmailStrategy = z.infer; +export type PasswordStrategy = z.infer; +export type Strategy = z.infer; diff --git a/platform/spec/package.json b/platform/spec/package.json new file mode 100644 index 0000000..680b12a --- /dev/null +++ b/platform/spec/package.json @@ -0,0 +1,11 @@ +{ + "name": "@platform/spec", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@platform/models": "workspace:*", + "@platform/relay": "workspace:*", + "zod": "4.1.12" + } +} diff --git a/platform/storage/package.json b/platform/storage/package.json new file mode 100644 index 0000000..6ed0af8 --- /dev/null +++ b/platform/storage/package.json @@ -0,0 +1,13 @@ +{ + "name": "@platform/storage", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./storage.ts", + "exports": { + ".": "./storage.ts" + }, + "dependencies": { + "@platform/relay": "workspace:*" + } +} diff --git a/platform/storage/storage.ts b/platform/storage/storage.ts new file mode 100644 index 0000000..cf4843f --- /dev/null +++ b/platform/storage/storage.ts @@ -0,0 +1,22 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { InternalServerError } from "@platform/relay"; + +export const storage = new AsyncLocalStorage(); + +/** + * TODO ... + */ +export function getStorageContext(): StorageContext { + const store = storage.getStore(); + if (store === undefined) { + throw new InternalServerError( + "Storage 'store' missing, make sure to resolve within a 'node:async_hooks' wrapped context.", + ); + } + return store; +} + +export interface StorageContext { + id: string; +} diff --git a/platform/vault/hmac.ts b/platform/vault/hmac.ts new file mode 100644 index 0000000..b3b4bc5 --- /dev/null +++ b/platform/vault/hmac.ts @@ -0,0 +1,54 @@ +/** + * Hash a value with given secret. + * + * @param value - Value to hash. + * @param secret - Secret to hash the value against. + */ +export async function hash(value: string, secret: string): Promise { + const key = await getImportKey(secret, ["sign"]); + const encoder = new TextEncoder(); + const valueData = encoder.encode(value); + const signature = await crypto.subtle.sign("HMAC", key, valueData); + return bufferToHex(signature); +} + +/** + * Verify that the given value results in the expected hash using the provided secret. + * + * @param value - Value to verify. + * @param expectedHash - Expected hash value. + * @param secret - Secret used to hash the value. + */ +export async function verify(value: string, expectedHash: string, secret: string): Promise { + const key = await getImportKey(secret, ["verify"]); + const encoder = new TextEncoder(); + const valueData = encoder.encode(value); + const signature = hexToBuffer(expectedHash); + return crypto.subtle.verify("HMAC", key, signature, valueData); +} + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +async function getImportKey(secret: string, usages: KeyUsage[]): Promise { + const encoder = new TextEncoder(); + const keyData = encoder.encode(secret); + return crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: { name: "SHA-256" } }, false, usages); +} + +function bufferToHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function hexToBuffer(hex: string): ArrayBuffer { + const match = hex.match(/.{1,2}/g); + if (match === null) { + return new Uint8Array().buffer; + } + return new Uint8Array(match.map((byte) => parseInt(byte, 16))).buffer; +} diff --git a/platform/vault/key-pair.ts b/platform/vault/key-pair.ts new file mode 100644 index 0000000..7c42368 --- /dev/null +++ b/platform/vault/key-pair.ts @@ -0,0 +1,134 @@ +import * as Jose from "jose"; + +export class KeyPair { + readonly #public: PublicKey; + readonly #private: PrivateKey; + readonly #algorithm: string; + + constructor({ publicKey, privateKey }: Jose.GenerateKeyPairResult, algorithm: string) { + this.#public = new PublicKey(publicKey); + this.#private = new PrivateKey(privateKey); + this.#algorithm = algorithm; + } + + get public() { + return this.#public; + } + + get private() { + return this.#private; + } + + get algorithm() { + return this.#algorithm; + } + + async toJSON() { + return { + publicKey: await this.public.toString(), + privateKey: await this.private.toString(), + }; + } +} + +export class PublicKey { + readonly #key: Jose.CryptoKey; + + constructor(key: Jose.CryptoKey) { + this.#key = key; + } + + get key(): Jose.CryptoKey { + return this.#key; + } + + async toString() { + return Jose.exportSPKI(this.#key); + } +} + +export class PrivateKey { + readonly #key: Jose.CryptoKey; + + constructor(key: Jose.CryptoKey) { + this.#key = key; + } + + get key(): Jose.CryptoKey { + return this.#key; + } + + async toString() { + return Jose.exportPKCS8(this.#key); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +/** + * Create a new key pair using the provided algorithm. + * + * @param algorithm - Algorithm to use for key generation. + * + * @returns new key pair instance + */ +export async function createKeyPair(algorithm: string): Promise { + return new KeyPair(await Jose.generateKeyPair(algorithm, { extractable: true }), algorithm); +} + +/** + * Loads a keypair from a previously exported keypair into a new KeyPair instance. + * + * @param keyPair - KeyPair to load into a new keyPair instance. + * @param algorithm - Algorithm to use for key generation. + * + * @returns new key pair instance + */ +export async function loadKeyPair({ publicKey, privateKey }: ExportedKeyPair, algorithm: string): Promise { + return new KeyPair( + { + publicKey: await importPublicKey(publicKey, algorithm), + privateKey: await importPrivateKey(privateKey, algorithm), + }, + algorithm, + ); +} + +/** + * Get a new Jose.KeyLike instance from a public key string. + * + * @param publicKey - Public key string. + * @param algorithm - Algorithm to used for key generation. + * + * @returns new Jose.KeyLike instance + */ +export async function importPublicKey(publicKey: string, algorithm: string): Promise { + return Jose.importSPKI(publicKey, algorithm, { extractable: true }); +} + +/** + * get a new Jose.KeyLike instance from a private key string. + * + * @param privateKey - Private key string. + * @param algorithm - Algorithm to used for key generation. + * + * @returns new Jose.KeyLike instance + */ +export async function importPrivateKey(privateKey: string, algorithm: string): Promise { + return Jose.importPKCS8(privateKey, algorithm, { extractable: true }); +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type ExportedKeyPair = { + publicKey: string; + privateKey: string; +}; diff --git a/platform/vault/package.json b/platform/vault/package.json new file mode 100644 index 0000000..92c4474 --- /dev/null +++ b/platform/vault/package.json @@ -0,0 +1,10 @@ +{ + "name": "@platform/vault", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "jose": "6.1.0", + "nanoid": "5.1.5" + } +} diff --git a/platform/vault/vault.ts b/platform/vault/vault.ts new file mode 100644 index 0000000..b69d4d9 --- /dev/null +++ b/platform/vault/vault.ts @@ -0,0 +1,91 @@ +import * as Jose from "jose"; + +import { + createKeyPair, + type ExportedKeyPair, + importPrivateKey, + importPublicKey, + type KeyPair, + loadKeyPair, +} from "./key-pair.ts"; + +/* + |-------------------------------------------------------------------------------- + | Security Settings + |-------------------------------------------------------------------------------- + */ + +const VAULT_ALGORITHM = "ECDH-ES+A256KW"; +const VAULT_ENCRYPTION = "A256GCM"; + +/* + |-------------------------------------------------------------------------------- + | Vault + |-------------------------------------------------------------------------------- + */ + +export class Vault { + #keyPair: KeyPair; + + constructor(keyPair: KeyPair) { + this.#keyPair = keyPair; + } + + get keys() { + return this.#keyPair; + } + + /** + * Enecrypt the given value with the vaults key pair. + * + * @param value - Value to encrypt. + */ + async encrypt | unknown[] | string>(value: T): Promise { + const text = new TextEncoder().encode(JSON.stringify(value)); + return new Jose.CompactEncrypt(text) + .setProtectedHeader({ + alg: VAULT_ALGORITHM, + enc: VAULT_ENCRYPTION, + }) + .encrypt(this.#keyPair.public.key); + } + + /** + * Decrypts the given cypher text with the vaults key pair. + * + * @param cypherText - String to decrypt. + */ + async decrypt(cypherText: string): Promise { + const { plaintext } = await Jose.compactDecrypt(cypherText, this.#keyPair.private.key); + return JSON.parse(new TextDecoder().decode(plaintext)); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +export async function createVault(): Promise { + return new Vault(await createKeyPair(VAULT_ALGORITHM)); +} + +export async function importVault(keyPair: ExportedKeyPair): Promise { + return new Vault(await loadKeyPair(keyPair, VAULT_ALGORITHM)); +} + +export async function encrypt | unknown[] | string>(value: T, publicKey: string) { + const text = new TextEncoder().encode(JSON.stringify(value)); + return new Jose.CompactEncrypt(text) + .setProtectedHeader({ + alg: VAULT_ALGORITHM, + enc: VAULT_ENCRYPTION, + }) + .encrypt(await importPublicKey(publicKey, VAULT_ALGORITHM)); +} + +export async function decrypt(cypherText: string, privateKey: string): Promise { + const { plaintext } = await Jose.compactDecrypt(cypherText, await importPrivateKey(privateKey, VAULT_ALGORITHM)); + return JSON.parse(new TextDecoder().decode(plaintext)); +} diff --git a/policy.json b/policy.json new file mode 100644 index 0000000..0e8eb06 --- /dev/null +++ b/policy.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://api.cerbos.dev/v0.47.0/cerbos/policy/v1/Policy.schema.json", + "apiVersion": "api.cerbos.dev/v1", + "resourcePolicy": { + "resource": "workspace", + "version": "1", + "rules": [ + { + "actions": ["create"], + "effect": "EFFECT_ALLOW", + "roles": ["super"] + } + ] + } +} \ No newline at end of file diff --git a/tests/mocks/actions.ts b/tests/mocks/actions.ts deleted file mode 100644 index 036904f..0000000 --- a/tests/mocks/actions.ts +++ /dev/null @@ -1,13 +0,0 @@ -import z from "zod"; - -import { action } from "../../libraries/action.ts"; - -export const addTwoNumbers = action - .make("addTwoNumbers") - .input({ a: z.number(), b: z.number() }) - .output({ added: z.number() }) - .handle(async ({ a, b }) => { - return { - added: a + b, - }; - }); diff --git a/tests/mocks/relay.ts b/tests/mocks/relay.ts deleted file mode 100644 index b660c0c..0000000 --- a/tests/mocks/relay.ts +++ /dev/null @@ -1,23 +0,0 @@ -import z from "zod"; - -import { http } from "../../adapters/http.ts"; -import { Relay } from "../../libraries/relay.ts"; -import { route } from "../../libraries/route.ts"; -import { UserSchema } from "./user.ts"; - -export const relay = new Relay({ adapter: http }, [ - route - .post("/users") - .body(UserSchema.omit({ id: true })) - .response(z.string()), - route.get("/users").response(z.array(UserSchema)), - route - .get("/users/:userId") - .params({ userId: z.string().check(z.uuid()) }) - .response(UserSchema.or(z.undefined())), - route - .put("/users/:userId") - .params({ userId: z.string().check(z.uuid()) }) - .body(UserSchema.omit({ id: true })), - route.delete("/users/:userId").params({ userId: z.string().check(z.uuid()) }), -]); diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts deleted file mode 100644 index 564f15a..0000000 --- a/tests/mocks/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { relay } from "./relay.ts"; -import { User } from "./user.ts"; - -export let users: User[] = []; - -relay.route("POST", "/users").handle(async ({ name, email }) => { - const id = crypto.randomUUID(); - users.push({ id, name, email }); - return id; -}); - -relay.route("GET", "/users").handle(async () => { - return users; -}); - -relay.route("GET", "/users/:userId").handle(async ({ userId }) => { - return users.find((user) => user.id === userId); -}); - -relay.route("PUT", "/users/:userId").handle(async ({ userId, name, email }) => { - for (const user of users) { - if (user.id === userId) { - user.name = name; - user.email = email; - break; - } - } -}); - -relay.route("DELETE", "/users/:userId").handle(async ({ userId }) => { - users = users.filter((user) => user.id === userId); -}); diff --git a/tests/mocks/user.ts b/tests/mocks/user.ts deleted file mode 100644 index e9cc50f..0000000 --- a/tests/mocks/user.ts +++ /dev/null @@ -1,9 +0,0 @@ -import z from "zod"; - -export const UserSchema = z.object({ - id: z.string().check(z.uuid()), - name: z.string(), - email: z.string().check(z.email()), -}); - -export type User = z.infer; diff --git a/tests/route.test.ts b/tests/route.test.ts deleted file mode 100644 index 800dbba..0000000 --- a/tests/route.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { describe, it } from "@std/testing/bdd"; - -import { relay } from "./mocks/relay.ts"; - -describe("Relay", () => { - it("should create a new user", async () => { - const userId = await relay.post("/users", { name: "John Doe", email: "john.doe@fixture.none" }); - - console.log({ userId }); - - assertEquals(typeof userId, "string"); - }); -});