api keys & better validator #6
23 changed files with 223 additions and 150 deletions
|
@ -33,6 +33,6 @@
|
|||
"mime": "^4.0.4",
|
||||
"minio": "^7.1.4",
|
||||
"svelte-sonner": "^0.3.27",
|
||||
"validator": "^13.12.0"
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
{#if visible}
|
||||
<div
|
||||
transition:slide
|
||||
class="w-[12rem] z-10 h-fit translate-y-12 py-2 bg-crust rounded-lg origin-top right-0 absolute transition-all shadow-md translate-x-3.5"
|
||||
class="w-[12rem] z-10 h-fit translate-y-11 py-2 bg-crust rounded-lg origin-top right-0 absolute transition-all shadow-md translate-x-4 outline outline-1 outline-surface0"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<script>
|
||||
// @ts-ignore
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Mail, SquareAsterisk, LogIn, Undo } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
@ -35,6 +36,7 @@
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}).catch((_) => toast.error(_.message));
|
||||
// @ts-ignore
|
||||
const body = await response.json().catch((_) => toast.error(_.message));
|
||||
|
||||
if (!body?.success) {
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password })
|
||||
}).catch((_) => toast.error(_.message));
|
||||
// @ts-ignore
|
||||
const body = await response.json().catch((_) => toast.error(_.message));
|
||||
|
||||
if (!body?.success) {
|
||||
|
|
|
@ -47,7 +47,7 @@
|
|||
<DropdownButton href="/settings">
|
||||
<p class="my-auto">Settings</p>
|
||||
</DropdownButton>
|
||||
<DropdownButton href="/api/auth/logout">
|
||||
<DropdownButton href="/api/v1/auth/logout">
|
||||
<p class="my-auto text-red-500">Logout</p>
|
||||
</DropdownButton>
|
||||
</Dropdown>
|
||||
|
|
|
@ -1,18 +1,22 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { CircleAlert, Check } from 'lucide-svelte';
|
||||
|
||||
export let type, name, id, placeholder, value, required;
|
||||
export let type, name, id, placeholder, value: string, required;
|
||||
</script>
|
||||
|
||||
<!-- insane that i have to do this because -->
|
||||
<!-- 'type' attribute cannot be dynamic if input uses two-way binding -->
|
||||
<!--
|
||||
insane that i have to do this because
|
||||
'type' attr cannot be dynamic if input
|
||||
uses two-way binding
|
||||
-->
|
||||
|
||||
{#if type === 'username'}
|
||||
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
|
||||
<div class="py-0.5 pr-1 border-r-2 border-overlay2">
|
||||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
|
||||
type="username"
|
||||
{name}
|
||||
{id}
|
||||
|
@ -20,12 +24,6 @@
|
|||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === 'email'}
|
||||
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
|
||||
|
@ -33,7 +31,7 @@
|
|||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
|
||||
type="email"
|
||||
{name}
|
||||
{id}
|
||||
|
@ -41,12 +39,6 @@
|
|||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === 'password'}
|
||||
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
|
||||
|
@ -54,7 +46,7 @@
|
|||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
|
||||
type="password"
|
||||
{name}
|
||||
{id}
|
||||
|
@ -62,11 +54,5 @@
|
|||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { X } from 'lucide-svelte';
|
||||
|
||||
export let key;
|
||||
export let key: { id: string };
|
||||
|
||||
function remove() {
|
||||
fetch('/api/v1/keys', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ key: key.id })
|
||||
}).then(() => invalidateAll());
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
|
@ -9,7 +17,7 @@
|
|||
<tt class="text-md">{key.id}</tt>
|
||||
</th>
|
||||
<th class="flex">
|
||||
<button class="m-auto hover:text-red">
|
||||
<button class="m-auto hover:text-red" on:click={remove}>
|
||||
<X></X>
|
||||
</button>
|
||||
</th>
|
||||
|
|
|
@ -161,3 +161,14 @@ export async function createUserApiKey(userId: number, permissions: number, expi
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUserApiKey(userId: number, id: string) {
|
||||
if (!userId || !id) return false;
|
||||
|
||||
return await prisma.aPIKey.delete({
|
||||
where: {
|
||||
userId,
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
import validator from 'validator';
|
||||
import { MAIL_WHITELIST } from '$lib/config';
|
||||
|
||||
// https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||
|
||||
export function email(input) {
|
||||
return validator.isEmail(input, { host_whitelist: MAIL_WHITELIST });
|
||||
}
|
42
src/lib/server/validator.ts
Normal file
42
src/lib/server/validator.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { z } from 'zod';
|
||||
import { findUser } from './database';
|
||||
|
||||
const INTERNAL_email = z.string().email('Invalid email address.');
|
||||
|
||||
export const email = INTERNAL_email.parse;
|
||||
|
||||
export const emailAndNotUsed = INTERNAL_email.refine(async (_) => {
|
||||
return !Boolean(await findUser({ email: _ }));
|
||||
}, 'Email is already being used.').parseAsync;
|
||||
|
||||
const INTERNAL_username = z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters.')
|
||||
.max(16, 'Usernames must be no more than 16 characters.')
|
||||
.regex(
|
||||
new RegExp(/^[A-z0-9\_\-\.]+$/g),
|
||||
'Usernames must be alphanumeric with dashes, underscores, and periods.'
|
||||
);
|
||||
|
||||
export const username = INTERNAL_username.parse;
|
||||
|
||||
export const usernameAndNotUsed = INTERNAL_username.refine(async (_) => {
|
||||
return !Boolean(await findUser({ username: _ }));
|
||||
}, 'Username is already being used.').parseAsync;
|
||||
|
||||
export const password = z
|
||||
.string()
|
||||
.min(6, 'Passwords must be longer than 6 characters.')
|
||||
.max(128, 'You do not need a password longer than 128 fucking characters.').parse;
|
||||
|
||||
export const embedTitle = z
|
||||
.string()
|
||||
.max(256, 'Title must not be longer than 256 characters.').parse;
|
||||
|
||||
export const embedDescription = z
|
||||
.string()
|
||||
.max(2000, 'Description must not be longer than 2000 characters.').parse;
|
||||
|
||||
export const embedColor = z
|
||||
.number()
|
||||
.max(parseInt('ffffff', 16), 'Color must be less than 16777215.').parse;
|
|
@ -1,9 +1,21 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { Info, X, Plus } from 'lucide-svelte';
|
||||
import Button from '$lib/components/Inputs/Button.svelte';
|
||||
import ListedApiKey from '$lib/components/ListedAPIKey.svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
export let data;
|
||||
|
||||
let username: string, email: string;
|
||||
let title: string, description: string, color: string;
|
||||
let opassword: string, npassword: string, cpassword: string;
|
||||
let newPostsPublic: boolean, encryptUploads: boolean;
|
||||
|
||||
async function createApiKey() {
|
||||
fetch('/api/v1/keys', {
|
||||
method: 'POST'
|
||||
}).then(() => invalidateAll());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 mx-auto xl:grid-cols-3 w-fit">
|
||||
|
@ -21,6 +33,8 @@
|
|||
name="username"
|
||||
id="username"
|
||||
placeholder="cirro"
|
||||
maxlength="16"
|
||||
bind:value={username}
|
||||
/>
|
||||
<p class="text-sm text-surface2">
|
||||
Your username is used to identify you around the site. You can change it at any time.
|
||||
|
@ -36,6 +50,7 @@
|
|||
name="email"
|
||||
id="email"
|
||||
placeholder="c*******@madhouselabs.net"
|
||||
bind:value={email}
|
||||
/>
|
||||
<p class="text-sm text-surface2">
|
||||
Changing your email may require you to verify ownership.
|
||||
|
@ -50,7 +65,7 @@
|
|||
<div class="flex flex-col gap-2 p-2 max-w-lg rounded-lg bg-crust">
|
||||
<p class="flex text-xl font-bold">
|
||||
API Keys
|
||||
<button class="my-auto ml-auto hover:text-blue">
|
||||
<button class="my-auto ml-auto hover:text-blue" on:click={createApiKey}>
|
||||
<Plus></Plus>
|
||||
</button>
|
||||
</p>
|
||||
|
@ -89,23 +104,25 @@
|
|||
name="title"
|
||||
id="title"
|
||||
placeholder={`{{ file }}`}
|
||||
maxlength="256"
|
||||
bind:value={title}
|
||||
/>
|
||||
<p class="text-sm text-surface2">The title shown on the embed.</p>
|
||||
<p class="text-sm text-surface2">The title shown on the embed. Max 256 characters.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-lg font-bold" for="description">Desciption</label>
|
||||
<textarea
|
||||
class="px-2 py-1 h-48 rounded-lg ring-1 transition-all bg-mantle ring-surface2 focus-visible:outline-none focus-visible:outline-overlay0"
|
||||
class="px-2 py-1 h-48 rounded-lg ring-1 transition-all resize-none bg-mantle ring-surface2 focus-visible:outline-none focus-visible:outline-overlay0"
|
||||
name="description"
|
||||
id="description"
|
||||
placeholder={`Uploaded by {{ username }} at {{ time }}`}
|
||||
maxlength="2000"
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
|
||||
<p class="text-sm text-surface2">
|
||||
The description of the embed. Can have up to 2000 characters.
|
||||
</p>
|
||||
<p class="text-sm text-surface2">The description of the embed. Max 2000 characters.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
/** @type {import('./$types').RequestHandler} */
|
||||
export function GET() {
|
||||
return new Response('OK');
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { COOKIE } from '$lib/config';
|
||||
import { findUser, createSession } from '$lib/server/database';
|
||||
import { email } from '$lib/server/validator';
|
||||
import { verifyHash } from '$lib/server/crypto';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function POST(event) {
|
||||
const { request, cookies } = event;
|
||||
const body = await request.json();
|
||||
|
||||
if (!body?.email || !email(body?.email))
|
||||
return json({ error: 'Invalid email.' }, { status: 400 });
|
||||
|
||||
if (!body?.password || body?.password.length > 128 || body?.password.length < 6)
|
||||
return json({ error: 'Invalid password.' }, { status: 400 });
|
||||
|
||||
const user = await findUser({ email: body?.email });
|
||||
if (!user) return json({ error: 'User record not found.' }, { status: 401 });
|
||||
|
||||
if (!(await verifyHash(user.password, body?.password)))
|
||||
return json({ error: 'User record not found.' }, { status: 401 });
|
||||
|
||||
const session = await createSession(user.id);
|
||||
|
||||
cookies.set(COOKIE, session.id, { path: '/' });
|
||||
|
||||
return json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
47
src/routes/api/v1/auth/login/+server.ts
Normal file
47
src/routes/api/v1/auth/login/+server.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { COOKIE } from '$lib/config';
|
||||
import { findUser, createSession } from '$lib/server/database';
|
||||
import { email } from '$lib/server/validator';
|
||||
import { verifyHash } from '$lib/server/crypto';
|
||||
import * as validator from '$lib/server/validator';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export async function POST({ request, cookies }) {
|
||||
const body = (await request.json()) as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const email = validator.email(body.email);
|
||||
const password = validator.password(body.password);
|
||||
|
||||
const user = await findUser({ email: email });
|
||||
if (!user) return json({ error: 'User record not found.' }, { status: 401 });
|
||||
|
||||
if (!(await verifyHash(user.password, password)))
|
||||
return json({ error: 'User record not found.' }, { status: 401 });
|
||||
|
||||
const session = await createSession(user.id);
|
||||
|
||||
cookies.set(COOKIE, session.id, { path: '/' });
|
||||
|
||||
return json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ZodError) {
|
||||
return json({ error: e.errors[0].message }, { status: 400 });
|
||||
} else {
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,9 +2,11 @@ import { redirect } from '@sveltejs/kit';
|
|||
import { getSession, deleteSession } from '$lib/server/database';
|
||||
import { COOKIE } from '$lib/config';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function GET({ cookies }) {
|
||||
const session = await getSession(cookies.get(COOKIE));
|
||||
const cookie = cookies.get(COOKIE);
|
||||
if (!cookie) return redirect(302, '/');
|
||||
|
||||
const session = await getSession(cookie);
|
||||
if (!session) {
|
||||
cookies.delete(COOKIE, { path: '/' });
|
||||
return redirect(302, '/');
|
|
@ -1,45 +0,0 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { COOKIE } from '$lib/config';
|
||||
import { createUser, createSession, findUser } from '$lib/server/database';
|
||||
import { email } from '$lib/server/validator';
|
||||
|
||||
export async function POST(event) {
|
||||
const { request, cookies } = event;
|
||||
const body = await request.json();
|
||||
|
||||
if (!body?.username || body?.username.length > 16 || body?.username.length < 3)
|
||||
return json({ error: 'Invalid username.' }, { status: 400 });
|
||||
|
||||
if (!body?.email || !email(body?.email))
|
||||
return json({ error: 'Invalid email.' }, { status: 400 });
|
||||
|
||||
if (!body?.password || body?.password.length > 128 || body?.password.length < 6)
|
||||
return json({ error: 'Invalid password.' }, { status: 400 });
|
||||
|
||||
const usernameTaken = !!(await findUser({ username: body?.username }));
|
||||
if (usernameTaken) return json({ error: 'That username is taken.' }, { status: 400 });
|
||||
|
||||
const emailUsed = !!(await findUser({ email: body?.email }));
|
||||
if (emailUsed)
|
||||
return json({ error: 'That email has been used too many times.' }, { status: 400 });
|
||||
|
||||
const user = await createUser(body?.username, body?.email, body?.password).catch((e) => {});
|
||||
if (!user) return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
|
||||
const session = await createSession(user.id);
|
||||
if (!session) return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
|
||||
cookies.set(COOKIE, session.id, { path: '/' });
|
||||
|
||||
return json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
42
src/routes/api/v1/auth/register/+server.ts
Normal file
42
src/routes/api/v1/auth/register/+server.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { COOKIE } from '$lib/config';
|
||||
import { createUser, createSession } from '$lib/server/database';
|
||||
import * as validator from '$lib/server/validator';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export async function POST({ request, cookies }) {
|
||||
const body = (await request.json()) as {
|
||||
username?: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const username = await validator.usernameAndNotUsed(body.username);
|
||||
const email = await validator.emailAndNotUsed(body.email);
|
||||
const password = validator.password(body.password);
|
||||
|
||||
const user = await createUser(username, email, password);
|
||||
const session = await createSession(user.id);
|
||||
|
||||
cookies.set(COOKIE, session.id, { path: '/' });
|
||||
|
||||
return json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ZodError) {
|
||||
return json({ error: e.errors[0].message }, { status: 400 });
|
||||
} else {
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { API_KEY_PERMISSIONS } from '$lib/config.js';
|
||||
import { createUserApiKey, getUserApiKeys } from '$lib/server/database';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createUserApiKey, deleteUserApiKey, getUserApiKeys } from '$lib/server/database';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
|
||||
export async function GET({ locals }) {
|
||||
return json(await getUserApiKeys(locals.user.id));
|
||||
|
@ -15,3 +15,15 @@ export async function POST({ locals }) {
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function DELETE({ locals, request }) {
|
||||
const body = (await request.json().catch(() => {})) as { key?: string };
|
||||
if (!body?.key) error(400, { status: 400, message: 'Missing "key" value.' });
|
||||
if (!locals.user) error(401, { status: 401, message: 'Unauthorized' });
|
||||
|
||||
return json(
|
||||
await deleteUserApiKey(locals.user.id, body.key).catch((_) =>
|
||||
error(400, { status: 400, message: 'API key does not exist.' })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { bytesToHumanReadable } from '$lib';
|
||||
import prisma from '$lib/server/database';
|
||||
import minio, { USAGE } from '$lib/server/minio';
|
||||
import { USAGE } from '$lib/server/minio';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
/** @type {import("@sveltejs/kit").RequestHandler} */
|
||||
export async function GET() {
|
||||
return json({
|
||||
users: await prisma.user.count({}),
|
|
@ -1,8 +0,0 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function GET(event) {
|
||||
const { request, cookies, locals } = event;
|
||||
|
||||
return json(locals?.user);
|
||||
}
|
5
src/routes/api/v1/user/+server.ts
Normal file
5
src/routes/api/v1/user/+server.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export async function GET({ locals }) {
|
||||
return json(locals.user);
|
||||
}
|
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
10
yarn.lock
10
yarn.lock
|
@ -1787,11 +1787,6 @@ util@^0.12.3:
|
|||
is-typed-array "^1.1.3"
|
||||
which-typed-array "^1.1.2"
|
||||
|
||||
validator@^13.12.0:
|
||||
version "13.12.0"
|
||||
resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f"
|
||||
integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==
|
||||
|
||||
vite@^5.3.5:
|
||||
version "5.3.5"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-5.3.5.tgz#b847f846fb2b6cb6f6f4ed50a830186138cb83d8"
|
||||
|
@ -1870,3 +1865,8 @@ yaml@^2.3.4:
|
|||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.0.tgz#c6165a721cf8000e91c36490a41d7be25176cf5d"
|
||||
integrity sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==
|
||||
|
||||
zod@^3.23.8:
|
||||
version "3.23.8"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
||||
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
||||
|
|
Loading…
Reference in a new issue