Implement new wasp/auth API (#1691)

This commit is contained in:
Mihovil Ilakovac 2024-01-30 13:53:03 +01:00 committed by Filip Sodić
parent ec9241b780
commit d650276586
27 changed files with 919 additions and 47 deletions

View File

@ -1 +1,7 @@
export { defineUserSignupFields } from './providers/types.js';
// PUBLIC
export type { AuthUser } from '../server/_types'
// PUBLIC
export { getEmail, getUsername, getFirstProviderUserId, findUserIdentity } from './user.js'

View File

@ -2,7 +2,7 @@
import { Request as ExpressRequest } from "express";
import { type {= userEntityUpper =} } from "wasp/entities"
import { type SanitizedUser } from 'wasp/server/_types'
import { type AuthUser } from 'wasp/auth'
import { auth } from "./lucia.js";
import type { Session } from "lucia";
@ -19,7 +19,7 @@ export async function createSession(authId: string): Promise<Session> {
}
export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{
user: SanitizedUser | null,
user: AuthUser | null,
session: Session | null,
}> {
const authorizationHeader = req.headers["authorization"];
@ -43,7 +43,7 @@ export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Pro
}
export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{
user: SanitizedUser | null,
user: AuthUser | null,
session: Session | null,
}> {
const { session, user: authEntity } = await auth.validateSession(sessionId);
@ -61,7 +61,7 @@ export async function getSessionAndUserFromSessionId(sessionId: string): Promise
}
}
async function getUser(userId: {= userEntityUpper =}['id']): Promise<SanitizedUser> {
async function getUser(userId: {= userEntityUpper =}['id']): Promise<AuthUser> {
const user = await prisma.{= userEntityLower =}
.findUnique({
where: { id: userId },

View File

@ -1,2 +1,2 @@
// todo(filip): turn into a proper import/path
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/'
export type { AuthUser, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types'

View File

@ -3,7 +3,7 @@ import { deserialize as superjsonDeserialize } from 'superjson'
import { useQuery } from 'wasp/rpc'
import { api, handleApiError } from 'wasp/client/api'
import { HttpMethod } from 'wasp/types'
import type { User } from './types'
import type { AuthUser } from './types'
import { addMetadataToQuery } from 'wasp/rpc/queries'
export const getMe = createUserGetter()
@ -15,7 +15,7 @@ export default function useAuth(queryFnArgs?: unknown, config?: any) {
function createUserGetter() {
const getMeRelativePath = 'auth/me'
const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` }
async function getMe(): Promise<User | null> {
async function getMe(): Promise<AuthUser | null> {
try {
const response = await api.get(getMeRoute.path)

View File

@ -1,14 +1,14 @@
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
import type { AuthUser, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null {
export function getEmail(user: AuthUser): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
}
export function getUsername(user: User): string | null {
export function getUsername(user: AuthUser): string | null {
return findUserIdentity(user, "username")?.providerUserId ?? null;
}
export function getFirstProviderUserId(user?: User): string | null {
export function getFirstProviderUserId(user?: AuthUser): string | null {
if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) {
return null;
}
@ -16,7 +16,7 @@ export function getFirstProviderUserId(user?: User): string | null {
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
export function findUserIdentity(user: AuthUser, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);

View File

@ -37,10 +37,6 @@
"./rpc/queryClient": "./dist/rpc/queryClient.js",
{=! Used by users, documented. =}
"./types": "./dist/types/index.js",
{=! Used by user, documented. =}
"./auth": "./dist/auth/index.js",
{=! Used by users, documented. =}
"./auth/types": "./dist/auth/types.js",
{=! Used by users, documented. =}
"./auth/login": "./dist/auth/login.js",
{=! Used by users, documented. =}
@ -50,8 +46,6 @@
{=! Used by users, documented. =}
"./auth/useAuth": "./dist/auth/useAuth.js",
{=! Used by users, documented. =}
"./auth/user": "./dist/auth/user.js",
{=! Used by users, documented. =}
"./auth/email": "./dist/auth/email/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/helpers/user": "./dist/auth/helpers/user.js",
@ -150,7 +144,8 @@
"./server/api": "./dist/server/api/index.js",
{=! Public: { api } =}
{=! Private: [sdk] =}
"./client/api": "./dist/api/index.js"
"./client/api": "./dist/api/index.js",
"./auth": "./dist/auth/index.js"
},
{=!
TypeScript doesn't care about the redirects we define above in "exports" field; those

View File

@ -86,7 +86,7 @@ type Context<Entities extends _Entity[]> = Expand<{
}>
{=# isAuthEnabled =}
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: SanitizedUser }>
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: AuthUser }>
// TODO: This type must match the logic in auth/session.js (if we remove the
// password field from the object there, we must do the same here). Ideally,
@ -97,7 +97,7 @@ export type DeserializedAuthIdentity = Expand<Omit<{= authIdentityEntityName =},
providerData: Omit<EmailProviderData, 'password'> | Omit<UsernameProviderData, 'password'> | OAuthProviderData
}>
export type SanitizedUser = {= userEntityName =} & {
export type AuthUser = {= userEntityName =} & {
{= authFieldOnUserEntityName =}: {= authEntityName =} & {
{= identitiesFieldOnAuthEntityName =}: DeserializedAuthIdentity[]
} | null

View File

@ -7,12 +7,12 @@ import { dirname } from 'path'
import { fileURLToPath } from 'url'
{=# isAuthEnabled =}
import { type SanitizedUser } from 'wasp/server/_types/index.js'
import { type AuthUser } from 'wasp/auth'
{=/ isAuthEnabled =}
type RequestWithExtraFields = Request & {
{=# isAuthEnabled =}
user?: SanitizedUser;
user?: AuthUser;
sessionId?: string;
{=/ isAuthEnabled =}
}

View File

@ -5,7 +5,7 @@ import { EventsMap, DefaultEventsMap } from '@socket.io/component-emitter'
import { prisma } from 'wasp/server'
{=# isAuthEnabled =}
import { type SanitizedUser } from 'wasp/server/_types/index.js'
import { type AuthUser } from 'wasp/auth'
{=/ isAuthEnabled =}
{=& userWebSocketFn.importStatement =}
@ -33,7 +33,7 @@ export type WebSocketDefinition<
export interface WaspSocketData {
{=# isAuthEnabled =}
user?: SanitizedUser
user?: AuthUser
{=/ isAuthEnabled =}
}

View File

@ -5,7 +5,7 @@ import { handleRejection } from 'wasp/server/utils'
import { MiddlewareConfigFn, globalMiddlewareConfigForExpress } from '../../middleware/index.js'
{=# isAuthEnabled =}
import auth from 'wasp/core/auth'
import { type SanitizedUser } from 'wasp/server/_types'
import { type AuthUser } from 'wasp/auth'
{=/ isAuthEnabled =}
{=# apiNamespaces =}
@ -45,7 +45,7 @@ router.{= routeMethod =}(
{=/ usesAuth =}
handleRejection(
(
req: Parameters<typeof {= importIdentifier =}>[0]{=# usesAuth =} & { user: SanitizedUser }{=/ usesAuth =},
req: Parameters<typeof {= importIdentifier =}>[0]{=# usesAuth =} & { user: AuthUser }{=/ usesAuth =},
res: Parameters<typeof {= importIdentifier =}>[1],
) => {
const context = {

View File

@ -7,12 +7,12 @@ import { dirname } from 'path'
import { fileURLToPath } from 'url'
{=# isAuthEnabled =}
import { type SanitizedUser } from 'wasp/server/_types/index.js'
import { type AuthUser } from 'wasp/auth'
{=/ isAuthEnabled =}
type RequestWithExtraFields = Request & {
{=# isAuthEnabled =}
user?: SanitizedUser;
user?: AuthUser;
sessionId?: string;
{=/ isAuthEnabled =}
}

View File

@ -0,0 +1,108 @@
import axios, { type AxiosError } from 'axios'
import config from 'wasp/core/config'
import { storage } from 'wasp/core/storage'
import { apiEventsEmitter } from './events.js'
// PUBLIC API
export const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
// PRIVATE API (sdk)
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
// PRIVATE API (sdk)
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
// PRIVATE API (sdk)
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
// PRIVATE API (sdk)
export function removeLocalUserData(): void {
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})
// PRIVATE API (sdk)
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API
* error has been formatted as implemented by HttpError on the server.
*/
export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
if (error?.response) {
// If error came from HTTP response, we capture most informative message
// and also add .statusCode information to it.
// If error had JSON response, we assume it is of format { message, data } and
// add that info to the error.
// TODO: We might want to use HttpError here instead of just Error, since
// HttpError is also used on server to throw errors like these.
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
} else {
// If any other error, we just propagate it.
throw error
}
}
class WaspHttpError extends Error {
statusCode: number
data: unknown
constructor (statusCode: number, message: string, data: unknown) {
super(message)
this.statusCode = statusCode
this.data = data
}
}

View File

@ -0,0 +1,14 @@
import { setSessionId } from 'wasp/client/api'
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
export async function initSession(sessionId: string): Promise<void> {
setSessionId(sessionId)
// We need to invalidate queries after login in order to get the correct user
// data in the React components (using `useAuth`).
// Redirects after login won't work properly without this.
// TODO(filip): We are currently removing all the queries, but we should
// remove only non-public, user-dependent queries - public queries are
// expected not to change in respect to the currently logged in user.
await invalidateAndRemoveQueries()
}

View File

@ -0,0 +1,17 @@
import { api, removeLocalUserData } from 'wasp/client/api'
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
export default async function logout(): Promise<void> {
try {
await api.post('/auth/logout')
} finally {
// Even if the logout request fails, we still want to remove the local user data
// in case the logout failed because of a network error and the user walked away
// from the computer.
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
}
}

View File

@ -0,0 +1,2 @@
// todo(filip): turn into a proper import/path
export type { AuthUser, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types'

View File

@ -0,0 +1,38 @@
import { deserialize as superjsonDeserialize } from 'superjson'
import { useQuery } from 'wasp/rpc'
import { api, handleApiError } from 'wasp/client/api'
import { HttpMethod } from 'wasp/types'
import type { AuthUser } from './types'
import { addMetadataToQuery } from 'wasp/rpc/queries'
export const getMe = createUserGetter()
export default function useAuth(queryFnArgs?: unknown, config?: any) {
return useQuery(getMe, queryFnArgs, config)
}
function createUserGetter() {
const getMeRelativePath = 'auth/me'
const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` }
async function getMe(): Promise<AuthUser | null> {
try {
const response = await api.get(getMeRoute.path)
return superjsonDeserialize(response.data)
} catch (error) {
if (error.response?.status === 401) {
return null
} else {
handleApiError(error)
}
}
}
addMetadataToQuery(getMe, {
relativeQueryPath: getMeRelativePath,
queryRoute: getMeRoute,
entitiesUsed: ['User'],
})
return getMe
}

View File

@ -0,0 +1,23 @@
import type { AuthUser, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: AuthUser): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
}
export function getUsername(user: AuthUser): string | null {
return findUserIdentity(user, "username")?.providerUserId ?? null;
}
export function getFirstProviderUserId(user?: AuthUser): string | null {
if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) {
return null;
}
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: AuthUser, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);
}

View File

@ -0,0 +1,302 @@
import { hashPassword } from './password.js'
import { verify } from './jwt.js'
import AuthError from 'wasp/core/AuthError'
import HttpError from 'wasp/core/HttpError'
import { prisma } from 'wasp/server'
import { sleep } from 'wasp/server/utils'
import {
type User,
type Auth,
type AuthIdentity,
} from 'wasp/entities'
import { Prisma } from '@prisma/client';
import { throwValidationError } from './validation.js'
import { type UserSignupFields, type PossibleUserFields } from './providers/types.js'
export type EmailProviderData = {
hashedPassword: string;
isEmailVerified: boolean;
emailVerificationSentAt: string | null;
passwordResetSentAt: string | null;
}
export type UsernameProviderData = {
hashedPassword: string;
}
export type OAuthProviderData = {}
/**
* This type is used for type-level programming e.g. to enumerate
* all possible provider data types.
*
* The keys of this type are the names of the providers and the values
* are the types of the provider data.
*/
export type PossibleProviderData = {
email: EmailProviderData;
username: UsernameProviderData;
google: OAuthProviderData;
github: OAuthProviderData;
}
export type ProviderName = keyof PossibleProviderData
export const contextWithUserEntity = {
entities: {
User: prisma.user
}
}
export const authConfig = {
failureRedirectPath: "/login",
successRedirectPath: "/",
}
/**
* ProviderId uniquely identifies an auth identity e.g.
* "email" provider with user id "test@test.com" or
* "google" provider with user id "1234567890".
*
* We use this type to avoid passing the providerName and providerUserId
* separately. Also, we can normalize the providerUserId to make sure it's
* consistent across different DB operations.
*/
export type ProviderId = {
providerName: ProviderName;
providerUserId: string;
}
export function createProviderId(providerName: ProviderName, providerUserId: string): ProviderId {
return {
providerName,
providerUserId: providerUserId.toLowerCase(),
}
}
export async function findAuthIdentity(providerId: ProviderId): Promise<AuthIdentity | null> {
return prisma.authIdentity.findUnique({
where: {
providerName_providerUserId: providerId,
}
});
}
/**
* Updates the provider data for the given auth identity.
*
* This function performs data sanitization and serialization.
* Sanitization is done by hashing the password, so this function
* expects the password received in the `providerDataUpdates`
* **not to be hashed**.
*/
export async function updateAuthIdentityProviderData<PN extends ProviderName>(
providerId: ProviderId,
existingProviderData: PossibleProviderData[PN],
providerDataUpdates: Partial<PossibleProviderData[PN]>,
): Promise<AuthIdentity> {
// We are doing the sanitization here only on updates to avoid
// hashing the password multiple times.
const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates);
const newProviderData = {
...existingProviderData,
...sanitizedProviderDataUpdates,
}
const serializedProviderData = await serializeProviderData<PN>(newProviderData);
return prisma.authIdentity.update({
where: {
providerName_providerUserId: providerId,
},
data: { providerData: serializedProviderData },
});
}
type FindAuthWithUserResult = Auth & {
user: User
}
export async function findAuthWithUserBy(
where: Prisma.AuthWhereInput
): Promise<FindAuthWithUserResult> {
return prisma.auth.findFirst({ where, include: { user: true }});
}
export async function createUser(
providerId: ProviderId,
serializedProviderData?: string,
userFields?: PossibleUserFields,
): Promise<User & {
auth: Auth
}> {
return prisma.user.create({
data: {
// Using any here to prevent type errors when userFields are not
// defined. We want Prisma to throw an error in that case.
...(userFields ?? {} as any),
auth: {
create: {
identities: {
create: {
providerName: providerId.providerName,
providerUserId: providerId.providerUserId,
providerData: serializedProviderData,
},
},
}
},
},
// We need to include the Auth entity here because we need `authId`
// to be able to create a session.
include: {
auth: true,
},
})
}
export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> {
return prisma.user.deleteMany({ where: { auth: {
id: authId,
} } })
}
export async function verifyToken<T = unknown>(token: string): Promise<T> {
return verify(token);
}
// If an user exists, we don't want to leak information
// about it. Pretending that we're doing some work
// will make it harder for an attacker to determine
// if a user exists or not.
// NOTE: Attacker measuring time to response can still determine
// if a user exists or not. We'll be able to avoid it when
// we implement e-mail sending via jobs.
export async function doFakeWork(): Promise<unknown> {
const timeToWork = Math.floor(Math.random() * 1000) + 1000;
return sleep(timeToWork);
}
export function rethrowPossibleAuthError(e: unknown): void {
if (e instanceof AuthError) {
throwValidationError(e.message);
}
// Prisma code P2002 is for unique constraint violations.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new HttpError(422, 'Save failed', {
message: `user with the same identity already exists`,
})
}
if (e instanceof Prisma.PrismaClientValidationError) {
// NOTE: Logging the error since this usually means that there are
// required fields missing in the request, we want the developer
// to know about it.
console.error(e)
throw new HttpError(422, 'Save failed', {
message: 'there was a database error'
})
}
// Prisma code P2021 is for missing table errors.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2021') {
// NOTE: Logging the error since this usually means that the database
// migrations weren't run, we want the developer to know about it.
console.error(e)
console.info('🐝 This error can happen if you did\'t run the database migrations.')
throw new HttpError(500, 'Save failed', {
message: `there was a database error`,
})
}
// Prisma code P2003 is for foreign key constraint failure
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') {
console.error(e)
console.info(`🐝 This error can happen if you have some relation on your User entity
but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull".
Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`)
throw new HttpError(500, 'Save failed', {
message: `there was a database error`,
})
}
throw e
}
export async function validateAndGetUserFields(
data: {
[key: string]: unknown
},
userSignupFields?: UserSignupFields,
): Promise<Record<string, any>> {
const {
password: _password,
...sanitizedData
} = data;
const result: Record<string, any> = {};
if (!userSignupFields) {
return result;
}
for (const [field, getFieldValue] of Object.entries(userSignupFields)) {
try {
const value = await getFieldValue(sanitizedData)
result[field] = value
} catch (e) {
throwValidationError(e.message)
}
}
return result;
}
export function deserializeAndSanitizeProviderData<PN extends ProviderName>(
providerData: string,
{ shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {},
): PossibleProviderData[PN] {
// NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON.
let data = JSON.parse(providerData) as PossibleProviderData[PN];
if (providerDataHasPasswordField(data) && shouldRemovePasswordField) {
delete data.hashedPassword;
}
return data;
}
export async function sanitizeAndSerializeProviderData<PN extends ProviderName>(
providerData: PossibleProviderData[PN],
): Promise<string> {
return serializeProviderData(
await sanitizeProviderData(providerData)
);
}
function serializeProviderData<PN extends ProviderName>(providerData: PossibleProviderData[PN]): string {
return JSON.stringify(providerData);
}
async function sanitizeProviderData<PN extends ProviderName>(
providerData: PossibleProviderData[PN],
): Promise<PossibleProviderData[PN]> {
const data = {
...providerData,
};
if (providerDataHasPasswordField(data)) {
data.hashedPassword = await hashPassword(data.hashedPassword);
}
return data;
}
function providerDataHasPasswordField(
providerData: PossibleProviderData[keyof PossibleProviderData],
): providerData is { hashedPassword: string } {
return 'hashedPassword' in providerData;
}
export function throwInvalidCredentialsError(message?: string): void {
throw new HttpError(401, 'Invalid credentials', { message })
}

View File

@ -0,0 +1,22 @@
import { api, handleApiError } from 'wasp/client/api'
import { HttpMethod } from 'wasp/types'
import {
serialize as superjsonSerialize,
deserialize as superjsonDeserialize,
} from 'superjson'
export type OperationRoute = { method: HttpMethod, path: string }
export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
try {
const superjsonArgs = superjsonSerialize(args)
const response = await api.post(operationRoute.path, superjsonArgs)
return superjsonDeserialize(response.data)
} catch (error) {
handleApiError(error)
}
}
export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
}

View File

@ -0,0 +1,116 @@
{
"name": "wasp",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist"
},
"exports": {
"./core/HttpError": "./dist/core/HttpError.js",
"./core/AuthError": "./dist/core/AuthError.js",
"./core/config": "./dist/core/config.js",
"./core/stitches.config": "./dist/core/stitches.config.js",
"./core/storage": "./dist/core/storage.js",
"./core/auth": "./dist/core/auth.js",
"./rpc": "./dist/rpc/index.js",
"./rpc/queries": "./dist/rpc/queries/index.js",
"./rpc/queries/core": "./dist/rpc/queries/core.js",
"./rpc/actions": "./dist/rpc/actions/index.js",
"./rpc/actions/core": "./dist/rpc/actions/core.js",
"./rpc/queryClient": "./dist/rpc/queryClient.js",
"./types": "./dist/types/index.js",
"./auth/login": "./dist/auth/login.js",
"./auth/logout": "./dist/auth/logout.js",
"./auth/signup": "./dist/auth/signup.js",
"./auth/useAuth": "./dist/auth/useAuth.js",
"./auth/email": "./dist/auth/email/index.js",
"./auth/helpers/user": "./dist/auth/helpers/user.js",
"./auth/session": "./dist/auth/session.js",
"./auth/providers/types": "./dist/auth/providers/types.js",
"./auth/utils": "./dist/auth/utils.js",
"./auth/password": "./dist/auth/password.js",
"./auth/jwt": "./dist/auth/jwt.js",
"./auth/validation": "./dist/auth/validation.js",
"./auth/forms/Login": "./dist/auth/forms/Login.jsx",
"./auth/forms/Signup": "./dist/auth/forms/Signup.jsx",
"./auth/forms/VerifyEmail": "./dist/auth/forms/VerifyEmail.jsx",
"./auth/forms/ForgotPassword": "./dist/auth/forms/ForgotPassword.jsx",
"./auth/forms/ResetPassword": "./dist/auth/forms/ResetPassword.jsx",
"./auth/forms/internal/Form": "./dist/auth/forms/internal/Form.jsx",
"./auth/helpers/*": "./dist/auth/helpers/*.jsx",
"./auth/pages/createAuthRequiredPage": "./dist/auth/pages/createAuthRequiredPage.jsx",
"./api/events": "./dist/api/events.js",
"./operations": "./dist/operations/index.js",
"./ext-src/*": "./dist/ext-src/*.js",
"./operations/*": "./dist/operations/*",
"./universal/url": "./dist/universal/url.js",
"./universal/types": "./dist/universal/types.js",
"./universal/validators": "./dist/universal/validators.js",
"./server/middleware": "./dist/server/middleware/index.js",
"./server/utils": "./dist/server/utils.js",
"./server/actions": "./dist/server/actions/index.js",
"./server/queries": "./dist/server/queries/index.js",
"./server/auth/email": "./dist/server/auth/email/index.js",
"./dbSeed/types": "./dist/dbSeed/types.js",
"./test": "./dist/test/index.js",
"./test/*": "./dist/test/*.js",
"./crud/*": "./dist/crud/*.js",
"./server/crud/*": "./dist/server/crud/*",
"./email": "./dist/email/index.js",
"./email/core/types": "./dist/email/core/types.js",
"./server/auth/email/utils": "./dist/server/auth/email/utils.js",
"./jobs/*": "./dist/jobs/*.js",
"./jobs/pgBoss/types": "./dist/jobs/pgBoss/types.js",
"./router": "./dist/router/index.js",
"./server/webSocket": "./dist/server/webSocket/index.js",
"./webSocket": "./dist/webSocket/index.js",
"./webSocket/WebSocketProvider": "./dist/webSocket/WebSocketProvider.jsx",
"./server/types": "./dist/server/types/index.js",
"./server": "./dist/server/index.js",
"./server/api": "./dist/server/api/index.js",
"./client/api": "./dist/api/index.js",
"./auth": "./dist/auth/index.js"
},
"typesVersions": {
"*": {
"client/api": ["api/index.ts"]
}
},
"license": "ISC",
"include": [
"src/**/*"
],
"dependencies": {"@prisma/client": "4.16.2",
"prisma": "4.16.2",
"@tanstack/react-query": "^4.29.0",
"axios": "^1.4.0",
"express": "~4.18.1",
"jsonwebtoken": "^8.5.1",
"mitt": "3.0.0",
"react": "^18.2.0",
"lodash.merge": "^4.6.2",
"react-router-dom": "^5.3.3",
"react-hook-form": "^7.45.4",
"secure-password": "^4.0.0",
"superjson": "^1.12.2",
"@types/express-serve-static-core": "^4.17.13",
"@stitches/react": "^1.2.8",
"lucia": "^3.0.0-beta.14",
"@lucia-auth/adapter-prisma": "^4.0.0-beta.9",
"socket.io": "^4.6.1",
"socket.io-client": "^4.6.1",
"@socket.io/component-emitter": "^4.0.0",
"vitest": "^1.2.1",
"@vitest/ui": "^1.2.1",
"jsdom": "^21.1.1",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.3.0",
"msw": "^1.1.0"
},
"devDependencies": {"@tsconfig/node18": "latest"
}
}

View File

@ -0,0 +1,99 @@
import { type Expand } from 'wasp/universal/types';
import { type Request, type Response } from 'express'
import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
import { prisma } from 'wasp/server'
import {
type User,
type Auth,
type AuthIdentity,
} from "wasp/entities"
import {
type EmailProviderData,
type UsernameProviderData,
type OAuthProviderData,
} from 'wasp/auth/utils'
import { type _Entity } from "./taggedEntities"
import { type Payload } from "./serialization";
export * from "./taggedEntities"
export * from "./serialization"
export type Query<Entities extends _Entity[], Input extends Payload, Output extends Payload> =
Operation<Entities, Input, Output>
export type Action<Entities extends _Entity[], Input extends Payload, Output extends Payload> =
Operation<Entities, Input, Output>
export type AuthenticatedQuery<Entities extends _Entity[], Input extends Payload, Output extends Payload> =
AuthenticatedOperation<Entities, Input, Output>
export type AuthenticatedAction<Entities extends _Entity[], Input extends Payload, Output extends Payload> =
AuthenticatedOperation<Entities, Input, Output>
type AuthenticatedOperation<Entities extends _Entity[], Input extends Payload, Output extends Payload> = (
args: Input,
context: ContextWithUser<Entities>,
) => Output | Promise<Output>
export type AuthenticatedApi<
Entities extends _Entity[],
Params extends ExpressParams,
ResBody,
ReqBody,
ReqQuery extends ExpressQuery,
Locals extends Record<string, any>
> = (
req: Request<Params, ResBody, ReqBody, ReqQuery, Locals>,
res: Response<ResBody, Locals>,
context: ContextWithUser<Entities>,
) => void
type Operation<Entities extends _Entity[], Input, Output> = (
args: Input,
context: Context<Entities>,
) => Output | Promise<Output>
export type Api<
Entities extends _Entity[],
Params extends ExpressParams,
ResBody,
ReqBody,
ReqQuery extends ExpressQuery,
Locals extends Record<string, any>
> = (
req: Request<Params, ResBody, ReqBody, ReqQuery, Locals>,
res: Response<ResBody, Locals>,
context: Context<Entities>,
) => void
type EntityMap<Entities extends _Entity[]> = {
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
}
export type PrismaDelegate = {
"User": typeof prisma.user,
"Task": typeof prisma.task,
}
type Context<Entities extends _Entity[]> = Expand<{
entities: Expand<EntityMap<Entities>>
}>
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: AuthUser }>
// TODO: This type must match the logic in auth/session.js (if we remove the
// password field from the object there, we must do the same here). Ideally,
// these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
export type DeserializedAuthIdentity = Expand<Omit<AuthIdentity, 'providerData'> & {
providerData: Omit<EmailProviderData, 'password'> | Omit<UsernameProviderData, 'password'> | OAuthProviderData
}>
export type AuthUser = User & {
auth: Auth & {
identities: DeserializedAuthIdentity[]
} | null
}
export type { ProviderName } from 'wasp/auth/utils'

View File

@ -0,0 +1,50 @@
import { prisma } from 'wasp/server'
import { createTask as createTask_ext } from 'wasp/ext-src/task/actions.js'
import { updateTask as updateTask_ext } from 'wasp/ext-src/task/actions.js'
import { deleteTasks as deleteTasks_ext } from 'wasp/ext-src/task/actions.js'
import { send as send_ext } from 'wasp/ext-src/user/customEmailSending.js'
export type CreateTask = typeof createTask_ext
export const createTask = async (args, context) => {
return (createTask_ext as any)(args, {
...context,
entities: {
Task: prisma.task,
},
})
}
export type UpdateTask = typeof updateTask_ext
export const updateTask = async (args, context) => {
return (updateTask_ext as any)(args, {
...context,
entities: {
Task: prisma.task,
},
})
}
export type DeleteTasks = typeof deleteTasks_ext
export const deleteTasks = async (args, context) => {
return (deleteTasks_ext as any)(args, {
...context,
entities: {
Task: prisma.task,
},
})
}
export type CustomEmailSending = typeof send_ext
export const customEmailSending = async (args, context) => {
return (send_ext as any)(args, {
...context,
entities: {
User: prisma.user,
},
})
}

View File

@ -0,0 +1,14 @@
import { prisma } from 'wasp/server'
import { getTasks as getTasks_ext } from 'wasp/ext-src/task/queries.js'
export type GetTasks = typeof getTasks_ext
export const getTasks = async (args, context) => {
return (getTasks_ext as any)(args, {
...context,
entities: {
Task: prisma.task,
},
})
}

View File

@ -0,0 +1,67 @@
import crypto from 'crypto'
import { Request, Response, NextFunction } from 'express'
import { readdir } from 'fs'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { type AuthUser } from 'wasp/auth'
type RequestWithExtraFields = Request & {
user?: AuthUser;
sessionId?: string;
}
/**
* Decorator for async express middleware that handles promise rejections.
* @param {Func} middleware - Express middleware function.
* @returns Express middleware that is exactly the same as the given middleware but,
* if given middleware returns promise, reject of that promise will be correctly handled,
* meaning that error will be forwarded to next().
*/
export const handleRejection = (
middleware: (
req: RequestWithExtraFields,
res: Response,
next: NextFunction
) => any
) =>
async (req: RequestWithExtraFields, res: Response, next: NextFunction) => {
try {
await middleware(req, res, next)
} catch (error) {
next(error)
}
}
export const sleep = (ms: number): Promise<unknown> => new Promise((r) => setTimeout(r, ms))
export function getDirPathFromFileUrl(fileUrl: string): string {
return fileURLToPath(dirname(fileUrl))
}
export async function importJsFilesFromDir(
pathToDir: string,
whitelistedFileNames: string[] | null = null
): Promise<any[]> {
return new Promise((resolve, reject) => {
readdir(pathToDir, async (err, files) => {
if (err) {
return reject(err)
}
const importPromises = files
.filter((file) => file.endsWith('.js') && isWhitelistedFileName(file))
.map((file) => import(`${pathToDir}/${file}`))
resolve(Promise.all(importPromises))
})
})
function isWhitelistedFileName(fileName: string) {
// No whitelist means all files are whitelisted
if (!Array.isArray(whitelistedFileNames)) {
return true
}
return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName)
}
}

View File

@ -11,8 +11,7 @@ import {
} from 'wasp/rpc/actions'
import waspLogo from './waspLogo.png'
import type { Task } from 'wasp/entities'
import type { User } from 'wasp/auth/types'
import { getFirstProviderUserId } from 'wasp/auth/user'
import { AuthUser, getFirstProviderUserId } from 'wasp/auth'
import { Link } from 'react-router-dom'
import { Tasks } from 'wasp/crud/Tasks'
// import login from 'wasp/auth/login'
@ -20,7 +19,7 @@ import { Tasks } from 'wasp/crud/Tasks'
import useAuth from 'wasp/auth/useAuth'
import { Todo } from './Todo'
export const MainPage = ({ user }: { user: User }) => {
export const MainPage = ({ user }: { user: AuthUser }) => {
const { data: tasks, isLoading, error } = useQuery(getTasks)
const { data: userAgain } = useAuth()

View File

@ -5,7 +5,7 @@ import { mockServer, renderInContext } from 'wasp/test'
import { getTasks } from 'wasp/rpc/queries'
import { Todo, areThereAnyTasks } from './Todo'
import { MainPage } from './MainPage'
import type { User } from 'wasp/auth/types'
import type { AuthUser } from 'wasp/auth'
import { getMe } from 'wasp/auth/useAuth'
import { Tasks } from 'wasp/crud/Tasks'
@ -54,7 +54,7 @@ const mockUser = {
],
},
address: '',
} satisfies User
} satisfies AuthUser
test('handles mock data', async () => {
mockQuery(getTasks, mockTasks)

View File

@ -1,26 +1,26 @@
import { WebSocketDefinition } from "wasp/server/webSocket";
import { getFirstProviderUserId } from "wasp/auth/user";
import { WebSocketDefinition } from 'wasp/server/webSocket'
import { getFirstProviderUserId } from 'wasp/auth'
export const webSocketFn: WebSocketDefinition<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents
> = (io, context) => {
io.on("connection", (socket) => {
const username = getFirstProviderUserId(socket.data.user) ?? "Unknown";
console.log("a user connected: ", username);
io.on('connection', (socket) => {
const username = getFirstProviderUserId(socket.data.user) ?? 'Unknown'
console.log('a user connected: ', username)
socket.on("chatMessage", async (msg) => {
console.log("message: ", msg);
io.emit("chatMessage", { id: "random", username, text: msg });
});
});
};
socket.on('chatMessage', async (msg) => {
console.log('message: ', msg)
io.emit('chatMessage', { id: 'random', username, text: msg })
})
})
}
interface ServerToClientEvents {
chatMessage: (msg: { id: string; username: string; text: string }) => void;
chatMessage: (msg: { id: string; username: string; text: string }) => void
}
interface ClientToServerEvents {
chatMessage: (msg: string) => void;
chatMessage: (msg: string) => void
}
interface InterServerEvents {}