From d6502765865604c4dd9865348599e219922a3f61 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Tue, 30 Jan 2024 13:53:03 +0100 Subject: [PATCH] Implement new wasp/auth API (#1691) --- .../Generator/templates/sdk/auth/index.ts | 6 + .../Generator/templates/sdk/auth/session.ts | 8 +- .../Generator/templates/sdk/auth/types.ts | 2 +- .../Generator/templates/sdk/auth/useAuth.ts | 4 +- .../data/Generator/templates/sdk/auth/user.ts | 10 +- .../data/Generator/templates/sdk/package.json | 9 +- .../templates/sdk/server/_types/index.ts | 4 +- .../Generator/templates/sdk/server/utils.ts | 4 +- .../templates/sdk/server/webSocket/index.ts | 4 +- .../templates/server/src/routes/apis/index.ts | 4 +- .../Generator/templates/server/src/utils.ts | 4 +- .../.wasp/out/sdk/wasp/api/index.ts | 108 +++++++ .../.wasp/out/sdk/wasp/auth/helpers/user.ts | 14 + .../.wasp/out/sdk/wasp/auth/logout.ts | 17 + .../.wasp/out/sdk/wasp/auth/types.ts | 2 + .../.wasp/out/sdk/wasp/auth/useAuth.ts | 38 +++ .../.wasp/out/sdk/wasp/auth/user.ts | 23 ++ .../.wasp/out/sdk/wasp/auth/utils.ts | 302 ++++++++++++++++++ .../.wasp/out/sdk/wasp/operations/index.ts | 22 ++ .../.wasp/out/sdk/wasp/package.json | 116 +++++++ .../.wasp/out/sdk/wasp/server/_types/index.ts | 99 ++++++ .../out/sdk/wasp/server/actions/index.ts | 50 +++ .../out/sdk/wasp/server/queries/index.ts | 14 + .../.wasp/out/sdk/wasp/server/utils.ts | 67 ++++ .../examples/todo-typescript/src/MainPage.tsx | 5 +- .../todo-typescript/src/Todo.test.tsx | 4 +- .../todo-typescript/src/websocket/index.ts | 26 +- 27 files changed, 919 insertions(+), 47 deletions(-) create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/index.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/helpers/user.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/logout.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/types.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/useAuth.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/user.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/utils.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/index.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/package.json create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/index.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/index.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/index.ts create mode 100644 waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/utils.ts diff --git a/waspc/data/Generator/templates/sdk/auth/index.ts b/waspc/data/Generator/templates/sdk/auth/index.ts index 354fbe542..56d03e4d8 100644 --- a/waspc/data/Generator/templates/sdk/auth/index.ts +++ b/waspc/data/Generator/templates/sdk/auth/index.ts @@ -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' diff --git a/waspc/data/Generator/templates/sdk/auth/session.ts b/waspc/data/Generator/templates/sdk/auth/session.ts index 487dca204..a077b41a0 100644 --- a/waspc/data/Generator/templates/sdk/auth/session.ts +++ b/waspc/data/Generator/templates/sdk/auth/session.ts @@ -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 { } 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 { +async function getUser(userId: {= userEntityUpper =}['id']): Promise { const user = await prisma.{= userEntityLower =} .findUnique({ where: { id: userId }, diff --git a/waspc/data/Generator/templates/sdk/auth/types.ts b/waspc/data/Generator/templates/sdk/auth/types.ts index f9f079a57..03d33b501 100644 --- a/waspc/data/Generator/templates/sdk/auth/types.ts +++ b/waspc/data/Generator/templates/sdk/auth/types.ts @@ -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' diff --git a/waspc/data/Generator/templates/sdk/auth/useAuth.ts b/waspc/data/Generator/templates/sdk/auth/useAuth.ts index bf2d7e63e..c7b02273c 100644 --- a/waspc/data/Generator/templates/sdk/auth/useAuth.ts +++ b/waspc/data/Generator/templates/sdk/auth/useAuth.ts @@ -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 { + async function getMe(): Promise { try { const response = await api.get(getMeRoute.path) diff --git a/waspc/data/Generator/templates/sdk/auth/user.ts b/waspc/data/Generator/templates/sdk/auth/user.ts index a7ac86827..0de50de6d 100644 --- a/waspc/data/Generator/templates/sdk/auth/user.ts +++ b/waspc/data/Generator/templates/sdk/auth/user.ts @@ -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 ); diff --git a/waspc/data/Generator/templates/sdk/package.json b/waspc/data/Generator/templates/sdk/package.json index 8b56044e8..847d6a1b8 100644 --- a/waspc/data/Generator/templates/sdk/package.json +++ b/waspc/data/Generator/templates/sdk/package.json @@ -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 diff --git a/waspc/data/Generator/templates/sdk/server/_types/index.ts b/waspc/data/Generator/templates/sdk/server/_types/index.ts index c00ecce15..a1b438e2a 100644 --- a/waspc/data/Generator/templates/sdk/server/_types/index.ts +++ b/waspc/data/Generator/templates/sdk/server/_types/index.ts @@ -86,7 +86,7 @@ type Context = Expand<{ }> {=# isAuthEnabled =} -type ContextWithUser = Expand & { user?: SanitizedUser }> +type ContextWithUser = Expand & { 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 | OAuthProviderData }> -export type SanitizedUser = {= userEntityName =} & { +export type AuthUser = {= userEntityName =} & { {= authFieldOnUserEntityName =}: {= authEntityName =} & { {= identitiesFieldOnAuthEntityName =}: DeserializedAuthIdentity[] } | null diff --git a/waspc/data/Generator/templates/sdk/server/utils.ts b/waspc/data/Generator/templates/sdk/server/utils.ts index 6ca262dec..9bae69db1 100644 --- a/waspc/data/Generator/templates/sdk/server/utils.ts +++ b/waspc/data/Generator/templates/sdk/server/utils.ts @@ -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 =} } diff --git a/waspc/data/Generator/templates/sdk/server/webSocket/index.ts b/waspc/data/Generator/templates/sdk/server/webSocket/index.ts index f40a15d01..82a044185 100644 --- a/waspc/data/Generator/templates/sdk/server/webSocket/index.ts +++ b/waspc/data/Generator/templates/sdk/server/webSocket/index.ts @@ -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 =} } diff --git a/waspc/data/Generator/templates/server/src/routes/apis/index.ts b/waspc/data/Generator/templates/server/src/routes/apis/index.ts index 6a1b1dcd0..1e6939c62 100644 --- a/waspc/data/Generator/templates/server/src/routes/apis/index.ts +++ b/waspc/data/Generator/templates/server/src/routes/apis/index.ts @@ -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[0]{=# usesAuth =} & { user: SanitizedUser }{=/ usesAuth =}, + req: Parameters[0]{=# usesAuth =} & { user: AuthUser }{=/ usesAuth =}, res: Parameters[1], ) => { const context = { diff --git a/waspc/data/Generator/templates/server/src/utils.ts b/waspc/data/Generator/templates/server/src/utils.ts index 6ca262dec..9bae69db1 100644 --- a/waspc/data/Generator/templates/server/src/utils.ts +++ b/waspc/data/Generator/templates/server/src/utils.ts @@ -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 =} } diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/index.ts new file mode 100644 index 000000000..d066bd544 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/index.ts @@ -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 + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/helpers/user.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/helpers/user.ts new file mode 100644 index 000000000..259a4c34b --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/helpers/user.ts @@ -0,0 +1,14 @@ +import { setSessionId } from 'wasp/client/api' +import { invalidateAndRemoveQueries } from 'wasp/operations/resources' + +export async function initSession(sessionId: string): Promise { + 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() +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/logout.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/logout.ts new file mode 100644 index 000000000..7f40b0cbf --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/logout.ts @@ -0,0 +1,17 @@ +import { api, removeLocalUserData } from 'wasp/client/api' +import { invalidateAndRemoveQueries } from 'wasp/operations/resources' + +export default async function logout(): Promise { + 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() + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/types.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/types.ts new file mode 100644 index 000000000..03d33b501 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/types.ts @@ -0,0 +1,2 @@ +// todo(filip): turn into a proper import/path +export type { AuthUser, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types' diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/useAuth.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/useAuth.ts new file mode 100644 index 000000000..47a629387 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/useAuth.ts @@ -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 { + 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 +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/user.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/user.ts new file mode 100644 index 000000000..0de50de6d --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/user.ts @@ -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 + ); +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/utils.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/utils.ts new file mode 100644 index 000000000..4f08b8d55 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/utils.ts @@ -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 { + 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( + providerId: ProviderId, + existingProviderData: PossibleProviderData[PN], + providerDataUpdates: Partial, +): Promise { + // 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(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 { + return prisma.auth.findFirst({ where, include: { user: true }}); +} + +export async function createUser( + providerId: ProviderId, + serializedProviderData?: string, + userFields?: PossibleUserFields, +): Promise { + 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(token: string): Promise { + 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 { + 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> { + const { + password: _password, + ...sanitizedData + } = data; + const result: Record = {}; + + 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( + 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( + providerData: PossibleProviderData[PN], +): Promise { + return serializeProviderData( + await sanitizeProviderData(providerData) + ); +} + +function serializeProviderData(providerData: PossibleProviderData[PN]): string { + return JSON.stringify(providerData); +} + +async function sanitizeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + 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 }) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/index.ts new file mode 100644 index 000000000..8ef076ee1 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/index.ts @@ -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}` } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/package.json b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/package.json new file mode 100644 index 000000000..8e954a849 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/package.json @@ -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" +} +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/index.ts new file mode 100644 index 000000000..fa27d07d0 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/index.ts @@ -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 = + Operation + +export type Action = + Operation + +export type AuthenticatedQuery = + AuthenticatedOperation + +export type AuthenticatedAction = + AuthenticatedOperation + +type AuthenticatedOperation = ( + args: Input, + context: ContextWithUser, +) => Output | Promise + +export type AuthenticatedApi< + Entities extends _Entity[], + Params extends ExpressParams, + ResBody, + ReqBody, + ReqQuery extends ExpressQuery, + Locals extends Record +> = ( + req: Request, + res: Response, + context: ContextWithUser, +) => void + +type Operation = ( + args: Input, + context: Context, +) => Output | Promise + +export type Api< + Entities extends _Entity[], + Params extends ExpressParams, + ResBody, + ReqBody, + ReqQuery extends ExpressQuery, + Locals extends Record +> = ( + req: Request, + res: Response, + context: Context, +) => void + +type EntityMap = { + [EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName] +} + +export type PrismaDelegate = { + "User": typeof prisma.user, + "Task": typeof prisma.task, +} + +type Context = Expand<{ + entities: Expand> +}> + +type ContextWithUser = Expand & { 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 & { + providerData: Omit | Omit | OAuthProviderData +}> + +export type AuthUser = User & { + auth: Auth & { + identities: DeserializedAuthIdentity[] + } | null +} + +export type { ProviderName } from 'wasp/auth/utils' diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/index.ts new file mode 100644 index 000000000..54c224ba2 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/index.ts @@ -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, + }, + }) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/index.ts new file mode 100644 index 000000000..cbfb76d35 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/index.ts @@ -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, + }, + }) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/utils.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/utils.ts new file mode 100644 index 000000000..d7fe31499 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/utils.ts @@ -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 => 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 { + 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) + } +} diff --git a/waspc/examples/todo-typescript/src/MainPage.tsx b/waspc/examples/todo-typescript/src/MainPage.tsx index 582eb2527..9e89cbf44 100644 --- a/waspc/examples/todo-typescript/src/MainPage.tsx +++ b/waspc/examples/todo-typescript/src/MainPage.tsx @@ -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() diff --git a/waspc/examples/todo-typescript/src/Todo.test.tsx b/waspc/examples/todo-typescript/src/Todo.test.tsx index 47dee23f7..151554bce 100644 --- a/waspc/examples/todo-typescript/src/Todo.test.tsx +++ b/waspc/examples/todo-typescript/src/Todo.test.tsx @@ -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) diff --git a/waspc/examples/todo-typescript/src/websocket/index.ts b/waspc/examples/todo-typescript/src/websocket/index.ts index 502856a82..8640fd3dc 100644 --- a/waspc/examples/todo-typescript/src/websocket/index.ts +++ b/waspc/examples/todo-typescript/src/websocket/index.ts @@ -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 {}