1
0
mirror of synced 2026-02-03 18:01:02 -05:00

Compare commits

...

7 Commits

Author SHA1 Message Date
Brandon Bayer
d5e5ca87ef change protect to return authenticated session ctx 2020-10-02 21:03:45 -04:00
Brandon Bayer
03f860ee6f Merge branch 'canary' into protect 2020-10-02 20:58:49 -04:00
Brandon Bayer
1e3d306eb5 Merge branch 'canary' into protect 2020-10-02 20:46:51 -04:00
Brandon Bayer
0656c94885 more fixes 2020-10-02 18:31:09 -04:00
Brandon Bayer
d1f2e624e9 more 2020-10-02 18:19:24 -04:00
Brandon Bayer
e4d646a643 more stuff 2020-10-02 18:16:40 -04:00
Brandon Bayer
a8a8325176 change templates 2020-10-02 18:00:59 -04:00
30 changed files with 487 additions and 154 deletions

View File

@@ -1,6 +1,5 @@
import {Suspense} from "react"
import {Head, Link, useSession, useRouterQuery, useMutation, invoke} from "blitz"
import getUser from "app/users/queries/getUser"
import trackView from "app/users/mutations/trackView"
import Layout from "app/layouts/Layout"
import {useCurrentUser} from "app/hooks/useCurrentUser"

View File

@@ -0,0 +1,23 @@
import React from "react"
type UserFormProps = {
initialValues: any
onSubmit: React.FormEventHandler<HTMLFormElement>
}
const UserForm = ({initialValues, onSubmit}: UserFormProps) => {
return (
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit(event)
}}
>
<div>Put your form fields here. But for now, just click submit</div>
<div>{JSON.stringify(initialValues)}</div>
<button>Submit</button>
</form>
)
}
export default UserForm

View File

@@ -0,0 +1,16 @@
import {protect} from "blitz"
import db from "db"
import * as z from "zod"
export default protect(
{
schema: z.object({
name: z.string(),
}),
},
async function createUser(input, {session}) {
const user = await db.user.create({data: input})
return user
},
)

View File

@@ -0,0 +1,16 @@
import {protect} from "blitz"
import db from "db"
import * as z from "zod"
export default protect(
{
schema: z.object({
id: z.number(),
}),
},
async function deleteUser({id}, {session}) {
const user = await db.user.delete({where: {id}})
return user
},
)

View File

@@ -0,0 +1,17 @@
import {protect} from "blitz"
import db from "db"
import * as z from "zod"
export default protect(
{
schema: z.object({
id: z.number(),
name: z.string(),
}),
},
async function updateUser({id, ...data}, {session}) {
const user = await db.user.update({where: {id}, data})
return user
},
)

View File

@@ -0,0 +1,54 @@
import React, {Suspense} from "react"
import Layout from "app/layouts/Layout"
import {Link, useRouter, useQuery, useParam, BlitzPage} from "blitz"
import getUser from "app/users/queries/getUser"
import deleteUser from "app/users/mutations/deleteUser"
export const User = () => {
const router = useRouter()
const userId = useParam("userId", "number")
const [user] = useQuery(getUser, {id: userId})
return (
<div>
<h1>User {user.id}</h1>
<pre>{JSON.stringify(user, null, 2)}</pre>
<Link href="/users/[userId]/edit" as={`/users/${user.id}/edit`}>
<a>Edit</a>
</Link>
<button
type="button"
onClick={async () => {
if (window.confirm("This will be deleted")) {
await deleteUser({id: user.id})
router.push("/users")
}
}}
>
Delete
</button>
</div>
)
}
const ShowUserPage: BlitzPage = () => {
return (
<div>
<p>
<Link href="/users">
<a>Users</a>
</Link>
</p>
<Suspense fallback={<div>Loading...</div>}>
<User />
</Suspense>
</div>
)
}
ShowUserPage.getLayout = (page) => <Layout title={"User"}>{page}</Layout>
export default ShowUserPage

View File

@@ -0,0 +1,58 @@
import React, {Suspense} from "react"
import Layout from "app/layouts/Layout"
import {Link, useRouter, useQuery, useMutation, useParam, BlitzPage} from "blitz"
import getUser from "app/users/queries/getUser"
import updateUser from "app/users/mutations/updateUser"
import UserForm from "app/users/components/UserForm"
export const EditUser = () => {
const router = useRouter()
const userId = useParam("userId", "number")
const [user, {mutate}] = useQuery(getUser, {id: userId})
const [updateUserMutation] = useMutation(updateUser)
return (
<div>
<h1>Edit User {user.id}</h1>
<pre>{JSON.stringify(user)}</pre>
<UserForm
initialValues={user}
onSubmit={async () => {
try {
const updated = await updateUserMutation({
id: user.id,
name: "MyNewName",
})
await mutate(updated)
alert("Success!" + JSON.stringify(updated))
router.push("/users/[userId]", `/users/${updated.id}`)
} catch (error) {
console.log(error)
alert("Error creating user " + JSON.stringify(error, null, 2))
}
}}
/>
</div>
)
}
const EditUserPage: BlitzPage = () => {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<EditUser />
</Suspense>
<p>
<Link href="/users">
<a>Users</a>
</Link>
</p>
</div>
)
}
EditUserPage.getLayout = (page) => <Layout title={"Edit User"}>{page}</Layout>
export default EditUserPage

View File

@@ -0,0 +1,60 @@
import React, {Suspense} from "react"
import Layout from "app/layouts/Layout"
import {Link, usePaginatedQuery, useRouter, BlitzPage} from "blitz"
import getUsers from "app/users/queries/getUsers"
const ITEMS_PER_PAGE = 100
export const UsersList = () => {
const router = useRouter()
const page = Number(router.query.page) || 0
const [{users, hasMore}] = usePaginatedQuery(getUsers, {
orderBy: {id: "asc"},
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
})
const goToPreviousPage = () => router.push({query: {page: page - 1}})
const goToNextPage = () => router.push({query: {page: page + 1}})
return (
<div>
<ul>
{users.map((user) => (
<li key={user.id}>
<Link href="/users/[userId]" as={`/users/${user.id}`}>
<a>{user.name}</a>
</Link>
</li>
))}
</ul>
<button disabled={page === 0} onClick={goToPreviousPage}>
Previous
</button>
<button disabled={!hasMore} onClick={goToNextPage}>
Next
</button>
</div>
)
}
const UsersPage: BlitzPage = () => {
return (
<div>
<p>
<Link href="/users/new">
<a>Create User</a>
</Link>
</p>
<Suspense fallback={<div>Loading...</div>}>
<UsersList />
</Suspense>
</div>
)
}
UsersPage.getLayout = (page) => <Layout title={"Users"}>{page}</Layout>
export default UsersPage

View File

@@ -0,0 +1,39 @@
import React from "react"
import Layout from "app/layouts/Layout"
import {Link, useRouter, useMutation, BlitzPage} from "blitz"
import createUser from "app/users/mutations/createUser"
import UserForm from "app/users/components/UserForm"
const NewUserPage: BlitzPage = () => {
const router = useRouter()
const [createUserMutation] = useMutation(createUser)
return (
<div>
<h1>Create New User</h1>
<UserForm
initialValues={{}}
onSubmit={async () => {
try {
const user = await createUserMutation({name: "MyName"})
alert("Success!" + JSON.stringify(user))
router.push("/users/[userId]", `/users/${user.id}`)
} catch (error) {
alert("Error creating user " + JSON.stringify(error, null, 2))
}
}}
/>
<p>
<Link href="/users">
<a>Users</a>
</Link>
</p>
</div>
)
}
NewUserPage.getLayout = (page) => <Layout title={"Create New User"}>{page}</Layout>
export default NewUserPage

View File

@@ -1,19 +1,12 @@
import {Ctx, NotFoundError} from "blitz"
import db, {FindOneUserArgs} from "db"
import {protect, NotFoundError} from "blitz"
import db, {FindFirstUserArgs} from "db"
type GetUserInput = {
where: FindOneUserArgs["where"]
}
type GetUserInput = FindFirstUserArgs["where"]
export default async function getUser({where}: GetUserInput, ctx: Ctx) {
ctx.session.authorize()
console.log(ctx.session.userId)
export default protect({}, async function getUser(input: GetUserInput, {session}) {
const user = await db.user.findFirst({where: input})
const user = await db.user.findOne({where})
if (!user) throw new NotFoundError()
if (!user) throw new NotFoundError(`User with id ${where.id} does not exist`)
const {hashedPassword, ...rest} = user
return rest
}
return user
})

View File

@@ -1,28 +1,29 @@
import {Ctx} from "blitz"
import {protect} from "blitz"
import db, {FindManyUserArgs} from "db"
type GetUsersInput = {
where?: FindManyUserArgs["where"]
orderBy?: FindManyUserArgs["orderBy"]
cursor?: FindManyUserArgs["cursor"]
take?: FindManyUserArgs["take"]
skip?: FindManyUserArgs["skip"]
}
type GetUsersInput = Pick<FindManyUserArgs, "orderBy" | "skip" | "take">
export default async function getUsers(
{where, orderBy, cursor, take, skip}: GetUsersInput,
ctx: Ctx,
export default protect({}, async function getUsers(
{orderBy, skip = 0, take}: GetUsersInput,
{session},
) {
ctx.session.authorize(["admin", "user"])
const users = await db.user.findMany({
where,
select: {id: true},
where: {
// add your selection criteria here
},
orderBy,
cursor,
take,
skip,
})
return users
}
const count = await db.user.count()
const hasMore = typeof take === "number" ? skip + take < count : false
const nextPage = hasMore ? {take, skip: skip + take!} : null
return {
users,
nextPage,
hasMore,
count,
}
})

View File

@@ -43,3 +43,27 @@ model Session {
publicData String?
privateData String?
}
model User {
id Int @default(autoincrement()) @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id Int @default(autoincrement()) @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id Int @default(autoincrement()) @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id Int @default(autoincrement()) @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -15,6 +15,9 @@
"browserslist": [
"defaults"
],
"prisma": {
"schema": "db/schema.prisma"
},
"prettier": {
"semi": false,
"printWidth": 100,
@@ -33,8 +36,8 @@
]
},
"dependencies": {
"@prisma/cli": "2.4.1",
"@prisma/client": "2.4.1",
"@prisma/cli": "2.8.0",
"@prisma/client": "2.8.0",
"blitz": "0.24.0-canary.0",
"final-form": "4.20.1",
"passport-auth0": "1.3.3",
@@ -45,7 +48,7 @@
"react-error-boundary": "2.3.1",
"react-final-form": "6.5.1",
"secure-password": "4.0.0",
"zod": "1.10.0"
"zod": "1.11.9"
},
"devDependencies": {
"@cypress/skip-test": "2.5.0",

View File

@@ -134,7 +134,8 @@
"tsdx": "0.13.3",
"tslib": "1.11.1",
"typescript": "4.0.3",
"wait-on": "4.0.2"
"wait-on": "4.0.2",
"zod": "1.11.9"
},
"husky": {
"hooks": {

View File

@@ -0,0 +1,28 @@
import {Ctx} from "./middleware"
import {AuthenticatedSessionContext} from "./supertokens"
import {ZodSchema, infer as zInfer} from "zod"
export type ProtectArgs<T> = {schema?: T; authorize?: boolean | unknown}
interface AuthenticatedCtx extends Ctx {
session: AuthenticatedSessionContext
}
export const protect = <T extends ZodSchema<any, any>, U = zInfer<T>>(
{schema, authorize = true}: ProtectArgs<T>,
resolver: (args: U, ctx: AuthenticatedCtx) => any,
) => {
return (rawInput: U, ctx: Ctx) => {
if (authorize) {
const authorizeInput: any[] = ["superadmin", "SUPERADMIN"]
if (Array.isArray(authorize)) {
authorizeInput.push(...authorize)
} else if (typeof authorize !== "boolean") {
authorizeInput.push(authorize)
}
;(ctx as any).session.authorize(authorizeInput)
}
const input = schema ? schema.parse(rawInput) : rawInput
return resolver(input, ctx as AuthenticatedCtx)
}
}

View File

@@ -5,6 +5,7 @@ export * from "./use-query-hooks"
export {useMutation} from "./use-mutation"
export {invoke, invokeWithMiddleware} from "./invoke"
export {getQueryKey, invalidateQuery} from "./utils/react-query-utils"
export {protect} from "./authorization"
export * from "./use-params"
export * from "./rpc"
export * from "./with-router"

View File

@@ -1,15 +1,15 @@
import { Ctx } from "blitz"
import { protect } from "blitz"
import { authenticateUser } from "app/auth/auth-utils"
import { LoginInput, LoginInputType } from "../validations"
export default async function login(input: LoginInputType, { session }: Ctx) {
// This throws an error if input is invalid
const { email, password } = LoginInput.parse(input)
import { LoginInput } from "../validations"
export default protect({ schema: LoginInput, authorize: false }, async function login(
{ email, password },
{ session }
) {
// This throws an error if credentials are invalid
const user = await authenticateUser(email, password)
await session.create({ userId: user.id, roles: [user.role] })
return user
}
})

View File

@@ -1,12 +1,12 @@
import { Ctx } from "blitz"
import { protect } from "blitz"
import db from "db"
import { hashPassword } from "app/auth/auth-utils"
import { SignupInput, SignupInputType } from "app/auth/validations"
export default async function signup(input: SignupInputType, { session }: Ctx) {
// This throws an error if input is invalid
const { email, password } = SignupInput.parse(input)
import { SignupInput } from "app/auth/validations"
export default protect({ schema: SignupInput, authorize: false }, async function signup(
{ email, password },
{ session }
) {
const hashedPassword = await hashPassword(password)
const user = await db.user.create({
data: { email: email.toLowerCase(), hashedPassword, role: "user" },
@@ -16,4 +16,4 @@ export default async function signup(input: SignupInputType, { session }: Ctx) {
await session.create({ userId: user.id, roles: [user.role] })
return user
}
})

View File

@@ -4,10 +4,8 @@ export const SignupInput = z.object({
email: z.string().email(),
password: z.string().min(10).max(100),
})
export type SignupInputType = z.infer<typeof SignupInput>
export const LoginInput = z.object({
email: z.string().email(),
password: z.string(),
})
export type LoginInputType = z.infer<typeof LoginInput>

View File

@@ -19,7 +19,7 @@ model User {
name String?
email String @unique
hashedPassword String?
role String @default("user")
role String
sessions Session[]
}

View File

@@ -1,34 +1,34 @@
import {Ctx} from "blitz"
import db, {__ModelName__CreateArgs} from "db"
import {protect} from "blitz"
import db from "db"
import * as z from "zod"
if (process.env.parentModel) {
type Create__ModelName__Input = {
data: Omit<__ModelName__CreateArgs["data"], "__parentModel__">
__parentModelId__: number
}
export default protect(
{
schema: z.object({
__parentModelId__: z.number(),
name: z.string(),
}),
},
async function create__ModelName__({__parentModelId__, ...input}, {session}) {
const __modelName__ = await db.__modelName__.create({
data: {...input, __parentModel__: {connect: {id: __parentModelId__}}},
})
return __modelName__
},
)
} else {
type Create__ModelName__Input = Pick<__ModelName__CreateArgs, "data">
}
if (process.env.parentModel) {
export default async function create__ModelName__(
{data, __parentModelId__}: Create__ModelName__Input,
ctx: Ctx,
) {
ctx.session.authorize()
const __modelName__ = await db.__modelName__.create({
data: {...data, __parentModel__: {connect: {id: __parentModelId__}}},
})
return __modelName__
}
} else {
export default async function create__ModelName__({data}: Create__ModelName__Input, ctx: Ctx) {
ctx.session.authorize()
const __modelName__ = await db.__modelName__.create({data})
return __modelName__
}
export default protect(
{
schema: z.object({
name: z.string(),
}),
},
async function create__ModelName__(input, {session}) {
const __modelName__ = await db.__modelName__.create({data: input})
return __modelName__
},
)
}

View File

@@ -1,12 +1,16 @@
import {Ctx} from "blitz"
import db, {__ModelName__DeleteArgs} from "db"
import {protect} from "blitz"
import db from "db"
import * as z from "zod"
type Delete__ModelName__Input = Pick<__ModelName__DeleteArgs, "where">
export default protect(
{
schema: z.object({
id: z.number(),
}),
},
async function delete__ModelName__({id}, {session}) {
const __modelName__ = await db.__modelName__.delete({where: {id}})
export default async function delete__ModelName__({where}: Delete__ModelName__Input, ctx: Ctx) {
ctx.session.authorize()
const __modelName__ = await db.__modelName__.delete({where})
return __modelName__
}
return __modelName__
},
)

View File

@@ -1,28 +1,17 @@
import {Ctx} from "blitz"
import db, {__ModelName__UpdateArgs} from "db"
import {protect} from "blitz"
import db from "db"
import * as z from "zod"
if (process.env.parentModel) {
type Update__ModelName__Input = {
where: __ModelName__UpdateArgs["where"]
data: Omit<__ModelName__UpdateArgs["data"], "__parentModel__">
__parentModelId__: number
}
} else {
type Update__ModelName__Input = Pick<__ModelName__UpdateArgs, "where" | "data">
}
export default protect(
{
schema: z.object({
id: z.number(),
name: z.string(),
}),
},
async function update__ModelName__({id, ...data}, {session}) {
const __modelName__ = await db.__modelName__.update({where: {id}, data})
export default async function update__ModelName__(
{where, data}: Update__ModelName__Input,
ctx: Ctx,
) {
ctx.session.authorize()
if (process.env.parentModel) {
// Don't allow updating
delete (data as any).__parentModel__
}
const __modelName__ = await db.__modelName__.update({where, data})
return __modelName__
}
return __modelName__
},
)

View File

@@ -10,7 +10,7 @@ export const __ModelName__ = () => {
if (process.env.parentModel) {
const __parentModelId__ = useParam("__parentModelId__", "number")
}
const [__modelName__] = useQuery(get__ModelName__, {where: {id: __modelId__}})
const [__modelName__] = useQuery(get__ModelName__, {id: __modelId__})
return (
<div>
@@ -38,7 +38,7 @@ export const __ModelName__ = () => {
type="button"
onClick={async () => {
if (window.confirm("This will be deleted")) {
await delete__ModelName__({where: {id: __modelName__.id}})
await delete__ModelName__({id: __modelName__.id})
if (process.env.parentModel) {
router.push(
"/__parentModels__/__parentModelParam__/__modelNames__",

View File

@@ -11,7 +11,7 @@ export const Edit__ModelName__ = () => {
if (process.env.parentModel) {
const __parentModelId__ = useParam("__parentModelId__", "number")
}
const [__modelName__, {mutate}] = useQuery(get__ModelName__, {where: {id: __modelId__}})
const [__modelName__, {mutate}] = useQuery(get__ModelName__, {id: __modelId__})
const [update__ModelName__Mutation] = useMutation(update__ModelName__)
return (
@@ -24,8 +24,8 @@ export const Edit__ModelName__ = () => {
onSubmit={async () => {
try {
const updated = await update__ModelName__Mutation({
where: {id: __modelName__.id},
data: {name: "MyNewName"},
id: __modelName__.id,
name: "MyNewName",
})
await mutate(updated)
alert("Success!" + JSON.stringify(updated))

View File

@@ -24,9 +24,7 @@ const New__ModelName__Page: BlitzPage = () => {
onSubmit={async () => {
try {
const __modelName__ = await create__ModelName__Mutation(
process.env.parentModel
? {data: {name: "MyName"}, __parentModelId__}
: {data: {name: "MyName"}},
process.env.parentModel ? {name: "MyName", __parentModelId__} : {name: "MyName"},
)
alert("Success!" + JSON.stringify(__modelName__))
router.push(

View File

@@ -1,14 +1,15 @@
import {Ctx, NotFoundError} from "blitz"
import {protect, NotFoundError} from "blitz"
import db, {FindFirst__ModelName__Args} from "db"
type Get__ModelName__Input = Pick<FindFirst__ModelName__Args, "where">
type Get__ModelName__Input = FindFirst__ModelName__Args["where"]
export default async function get__ModelName__({where}: Get__ModelName__Input, ctx: Ctx) {
ctx.session.authorize()
const __modelName__ = await db.__modelName__.findFirst({where})
export default protect({}, async function get__ModelName__(
input: Get__ModelName__Input,
{session},
) {
const __modelName__ = await db.__modelName__.findFirst({where: input})
if (!__modelName__) throw new NotFoundError()
return __modelName__
}
})

View File

@@ -1,16 +1,16 @@
import {Ctx} from "blitz"
import {protect} from "blitz"
import db, {FindMany__ModelName__Args} from "db"
type Get__ModelNames__Input = Pick<FindMany__ModelName__Args, "where" | "orderBy" | "skip" | "take">
type Get__ModelNames__Input = Pick<FindMany__ModelName__Args, "orderBy" | "skip" | "take">
export default async function get__ModelNames__(
{where, orderBy, skip = 0, take}: Get__ModelNames__Input,
ctx: Ctx,
export default protect({}, async function get__ModelNames__(
{orderBy, skip = 0, take}: Get__ModelNames__Input,
{session},
) {
ctx.session.authorize()
const __modelNames__ = await db.__modelName__.findMany({
where,
where: {
// add your selection criteria here
},
orderBy,
take,
skip,
@@ -26,4 +26,4 @@ export default async function get__ModelNames__(
hasMore,
count,
}
}
})

View File

@@ -1,10 +1,8 @@
import {Ctx} from "blitz"
import {protect, NotFoundError} from "blitz"
import db from "db"
export default async function __rawInput__(input, ctx: Ctx) {
ctx.session.authorize()
export default protect({}, async function __rawInput__(input, {session}) {
// Do your stuff :)
return
}
})

View File

@@ -3475,6 +3475,11 @@
resolved "https://registry.yarnpkg.com/@prisma/cli/-/cli-2.4.1.tgz#95f6cae48ff19c6177bb9f85816b27e1ffe5af53"
integrity sha512-vAOBnouBgCYndXmTcGxanfmhVWUCpwr3akBXiQH+ZKzTYf/pwSsWYpeaXNQfVtUEJEQ0kumfLGdq6ZXnfX//aA==
"@prisma/cli@2.8.0":
version "2.8.0"
resolved "https://registry.yarnpkg.com/@prisma/cli/-/cli-2.8.0.tgz#919d7f66023affa76d14823212b62a8512cfd37d"
integrity sha512-Kg1C47d75jdEIMmJif8TMlv/2Ihx08E1qWp0euwoZhjd807HGnjgC9tJYjTfkdf+NMJSAUbvoPXKInEX0HoOMw==
"@prisma/client@2.4.1":
version "2.4.1"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.4.1.tgz#840d0905e9d05d84313e333a3e0370df3b9815fe"
@@ -3482,6 +3487,13 @@
dependencies:
pkg-up "^3.1.0"
"@prisma/client@2.8.0":
version "2.8.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.8.0.tgz#a0f7247786c9b6ee804437acf8215854c5eb3946"
integrity sha512-5+GzRTkPnmv4OEV2tB8kwQt/xLLxBR/daJBcMt6pnnonJvrREsu0tSTdz2LJNPaj3kTT0fSS/OaeGMMdfVYSpw==
dependencies:
pkg-up "^3.1.0"
"@prisma/debug@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-2.6.0.tgz#7623862e310f7b30d64481643f693742ce562ee0"
@@ -19852,7 +19864,7 @@ zip-stream@^3.0.1:
compress-commons "^3.0.0"
readable-stream "^3.6.0"
zod@1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/zod/-/zod-1.10.0.tgz#175286f09d480ac604014245d7215f67959b0d7a"
integrity sha512-u06UhgHEUzLN26qDI7Ei9sqWdiQjQnrjEJtAXmAHF3fH35sOkPmdAl4FNZOgsigv2Jki6yrx5wivQyYXB4B+aA==
zod@1.11.9:
version "1.11.9"
resolved "https://registry.yarnpkg.com/zod/-/zod-1.11.9.tgz#c6c503804d394f8ef009d44ce464bbf8aa3c1bd6"
integrity sha512-qZjs9DkvPYHOiOUdAtNcxOC0u5cv7tx9DCmlNZN0MxWeFvgqyr3XkXFqUlaSpmTiZ4A4YVkB2s1Zw2ENJ9/fSg==