diff --git a/.vscode/settings.json b/.vscode/settings.json index c9d910cda..52ac2cf38 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,7 @@ "Proxied", "reqs", "stackframe", + "typecheck", "typehack", "Uncapitalize", "Whitespaces", diff --git a/apps/backend/prisma/migrations/20240722004703_events/migration.sql b/apps/backend/prisma/migrations/20240722004703_events/migration.sql new file mode 100644 index 000000000..109c5d55c --- /dev/null +++ b/apps/backend/prisma/migrations/20240722004703_events/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "Event" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "isWide" BOOLEAN NOT NULL, + "eventStartedAt" TIMESTAMP(3) NOT NULL, + "eventEndedAt" TIMESTAMP(3) NOT NULL, + "systemEventTypeIds" TEXT[], + "data" JSONB NOT NULL, + + CONSTRAINT "Event_pkey" PRIMARY KEY ("id") +); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index adb2ee89b..4111123b2 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -538,3 +538,24 @@ enum StandardOAuthProviderType { } //#endregion + + +//#region Events + +model Event { + id String @id @default(uuid()) @db.Uuid + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // if isWide == false, then eventEndedAt is always equal to eventStartedAt + isWide Boolean + eventStartedAt DateTime + eventEndedAt DateTime + + // TODO: add event_type, and at least one of either system_event_type or event_type is always set + systemEventTypeIds String[] + data Json +} + +//#endregion diff --git a/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx b/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx index dda5d105d..c4eeb35a4 100644 --- a/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx +++ b/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx @@ -1,4 +1,4 @@ -import { createAuthTokens, encodeAccessToken } from "@/lib/tokens"; +import { createAuthTokens, generateAccessToken } from "@/lib/tokens"; import { prismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; diff --git a/apps/backend/src/app/api/v1/auth/sessions/current/refresh/route.tsx b/apps/backend/src/app/api/v1/auth/sessions/current/refresh/route.tsx index 76d52c185..1b062db06 100644 --- a/apps/backend/src/app/api/v1/auth/sessions/current/refresh/route.tsx +++ b/apps/backend/src/app/api/v1/auth/sessions/current/refresh/route.tsx @@ -1,4 +1,4 @@ -import { encodeAccessToken } from "@/lib/tokens"; +import { generateAccessToken } from "@/lib/tokens"; import { prismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -41,7 +41,7 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.RefreshTokenNotFoundOrExpired(); } - const accessToken = await encodeAccessToken({ + const accessToken = await generateAccessToken({ projectId: sessionObj.projectId, userId: sessionObj.projectUserId, }); diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx new file mode 100644 index 000000000..6307b9ecb --- /dev/null +++ b/apps/backend/src/lib/events.tsx @@ -0,0 +1,130 @@ +import { urlSchema, yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { HTTP_METHODS } from "@stackframe/stack-shared/dist/utils/http"; +import * as yup from "yup"; +import { UnionToIntersection } from "@stackframe/stack-shared/dist/utils/types"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { prismaClient } from "@/prisma-client"; + +type EventType = { + id: string, + dataSchema: yup.Schema, + inherits: EventType[], +}; + +type SystemEventTypeBase = EventType & { + id: `$${string}`, +}; + +const ProjectEventType = { + id: "$project", + dataSchema: yupObject({ + projectId: yupString().required(), + }), + inherits: [], +} as const satisfies SystemEventTypeBase; + +const ProjectActivityEventType = { + id: "$project-activity", + dataSchema: yupObject({}), + inherits: [ProjectEventType], +} as const satisfies SystemEventTypeBase; + +const UserActivityEventType = { + id: "$user-activity", + dataSchema: yupObject({ + userId: yupString().uuid().required(), + }), + inherits: [ProjectActivityEventType], +} as const satisfies SystemEventTypeBase; + +const ApiRequestEventType = { + id: "$api-request", + dataSchema: yupObject({ + method: yupString().oneOf(HTTP_METHODS).required(), + url: urlSchema.required(), + body: yupMixed().nullable().optional(), + headers: yupObject().required(), + }), + inherits: [ + ProjectEventType, + ], +} as const satisfies SystemEventTypeBase; + +export const SystemEventTypes = stripEventTypeSuffixFromKeys({ + ProjectEventType, + ProjectActivityEventType, + UserActivityEventType, + ApiRequestEventType, +} as const); +const systemEventTypesById = new Map(Object.values(SystemEventTypes).map(eventType => [eventType.id, eventType])); + +function stripEventTypeSuffixFromKeys>(t: T): { [K in keyof T as K extends `${infer Key}EventType` ? Key : never]: T[K] } { + return Object.fromEntries(Object.entries(t).map(([key, value]) => [key.replace(/EventType$/, ""), value])) as any; +} + +type DataOfMany = UnionToIntersection : never>; // distributive conditional. See: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + +type DataOf = + & yup.InferType + & DataOfMany; + +export async function logEvent( + eventTypes: T, + data: DataOfMany, + options: { + time?: Date | { start: Date, end: Date }, + } = {} +) { + let timeOrTimeRange = options.time ?? new Date(); + const timeRange = "start" in timeOrTimeRange && "end" in timeOrTimeRange ? timeOrTimeRange : { start: timeOrTimeRange, end: timeOrTimeRange }; + const isWide = timeOrTimeRange === timeRange; + + // assert all event types are valid + for (const eventType of eventTypes) { + if (eventType.id.startsWith("$")) { + if (!systemEventTypesById.has(eventType.id as any)) { + throw new StackAssertionError(`Invalid system event type: ${eventType.id}`, { eventType }); + } + } else { + throw new StackAssertionError(`Non-system event types are not supported yet`, { eventType }); + } + } + + + // select all events in the inheritance chain + const allEventTypes = new Set(); + const addEventType = (eventType: EventType) => { + if (allEventTypes.has(eventType)) { + return; + } + allEventTypes.add(eventType); + eventType.inherits.forEach(addEventType); + }; + eventTypes.forEach(addEventType); + + + // validate & transform data + const originalData = data; + for (const eventType of allEventTypes) { + try { + data = await eventType.dataSchema.validate(data, { strict: true, stripUnknown: false }); + } catch (error) { + if (error instanceof yup.ValidationError) { + throw new StackAssertionError(`Invalid event data for event type: ${eventType.id}`, { eventType, data, error, originalData, originalEventTypes: eventTypes }, { cause: error }); + } + throw error; + } + } + + + // log event + await prismaClient.event.create({ + data: { + systemEventTypeIds: [...allEventTypes].map(eventType => eventType.id), + data: data as any, + isWide, + eventStartedAt: timeRange.start, + eventEndedAt: timeRange.end, + }, + }); +} diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index afcf7a735..d7bc87f68 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -5,6 +5,7 @@ import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/ import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; import { decryptJWT, encryptJWT } from '@stackframe/stack-shared/dist/utils/jwt'; import { JOSEError, JWTExpired } from 'jose/errors'; +import { SystemEventTypes, logEvent } from './events'; export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]+$/); @@ -49,13 +50,15 @@ export async function decodeAccessToken(accessToken: string) { return await accessTokenSchema.validate(decoded); } -export async function encodeAccessToken({ +export async function generateAccessToken({ projectId, userId, }: { projectId: string, userId: string, }) { + await logEvent([SystemEventTypes.UserActivity], { projectId, userId }); + // TODO: pass the scope and some other information down to the token return await encryptJWT({ projectId, userId }, getEnvVariable("STACK_ACCESS_TOKEN_EXPIRATION_TIME", "1h")); } @@ -68,7 +71,7 @@ export async function createAuthTokens({ projectUserId: string, }) { const refreshToken = generateSecureRandomString(); - const accessToken = await encodeAccessToken({ + const accessToken = await generateAccessToken({ projectId, userId: projectUserId, }); diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index f4d96a4f7..5af08b318 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -2,7 +2,7 @@ import { AuthorizationCode, AuthorizationCodeModel, Client, Falsey, RefreshToken import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { prismaClient } from "@/prisma-client"; -import { decodeAccessToken, encodeAccessToken } from "@/lib/tokens"; +import { decodeAccessToken, generateAccessToken } from "@/lib/tokens"; import { validateRedirectUrl } from "@/lib/redirect-urls"; import { checkApiKeySet } from "@/lib/api-keys"; import { getProject } from "@/lib/projects"; @@ -72,7 +72,7 @@ export class OAuthModel implements AuthorizationCodeModel { async generateAccessToken(client: Client, user: User, scope: string[]): Promise { assertScopeIsValid(scope); - return await encodeAccessToken({ + return await generateAccessToken({ projectId: client.id, userId: user.id, }); diff --git a/apps/dashboard/prisma/schema.prisma b/apps/dashboard/prisma/schema.prisma index 9d2cee02d..4111123b2 100644 --- a/apps/dashboard/prisma/schema.prisma +++ b/apps/dashboard/prisma/schema.prisma @@ -64,7 +64,7 @@ model ProjectDomain { domain String handlerPath String - projectConfig ProjectConfig @relation(fields: [projectConfigId], references: [id]) + projectConfig ProjectConfig @relation(fields: [projectConfigId], references: [id], onDelete: Cascade) @@unique([projectConfigId, domain]) } @@ -420,7 +420,7 @@ model ApiKeySet { model EmailServiceConfig { projectConfigId String @id @db.Uuid - projectConfig ProjectConfig @relation(fields: [projectConfigId], references: [id]) + projectConfig ProjectConfig @relation(fields: [projectConfigId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -439,7 +439,7 @@ enum EmailTemplateType { model EmailTemplate { projectConfigId String @db.Uuid - emailServiceConfig EmailServiceConfig @relation(fields: [projectConfigId], references: [projectConfigId]) + emailServiceConfig EmailServiceConfig @relation(fields: [projectConfigId], references: [projectConfigId], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -453,14 +453,14 @@ model EmailTemplate { model ProxiedEmailServiceConfig { projectConfigId String @id @db.Uuid - emailServiceConfig EmailServiceConfig @relation(fields: [projectConfigId], references: [projectConfigId]) + emailServiceConfig EmailServiceConfig @relation(fields: [projectConfigId], references: [projectConfigId], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model StandardEmailServiceConfig { projectConfigId String @id @db.Uuid - emailServiceConfig EmailServiceConfig @relation(fields: [projectConfigId], references: [projectConfigId]) + emailServiceConfig EmailServiceConfig @relation(fields: [projectConfigId], references: [projectConfigId], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -538,3 +538,24 @@ enum StandardOAuthProviderType { } //#endregion + + +//#region Events + +model Event { + id String @id @default(uuid()) @db.Uuid + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // if isWide == false, then eventEndedAt is always equal to eventStartedAt + isWide Boolean + eventStartedAt DateTime + eventEndedAt DateTime + + // TODO: add event_type, and at least one of either system_event_type or event_type is always set + systemEventTypeIds String[] + data Json +} + +//#endregion diff --git a/apps/dashboard/src/lib/tokens.tsx b/apps/dashboard/src/lib/tokens.tsx index e633fd0b5..97ea4e8d8 100644 --- a/apps/dashboard/src/lib/tokens.tsx +++ b/apps/dashboard/src/lib/tokens.tsx @@ -57,6 +57,20 @@ export async function encodeAccessToken({ projectId: string, userId: string, }) { + const date = new Date(); + await prismaClient.event.create({ + data: { + systemEventTypeIds: ["$project", "$user-activity", "$project-activity"], + data: { + projectId, + userId, + }, + isWide: false, + eventStartedAt: date, + eventEndedAt: date, + }, + }); + return await encryptJWT({ projectId, userId }, process.env.STACK_ACCESS_TOKEN_EXPIRATION_TIME || '1h'); } diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 40b8a65d0..6d7b9cb9f 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -7,7 +7,8 @@ "test:watch": "vitest watch", "test": "vitest run", "lint": "eslint --ext .tsx,.ts .", - "clean": "rimraf dist && rimraf node_modules" + "clean": "rimraf dist && rimraf node_modules", + "typecheck": "tsc --noEmit" }, "dependencies": { "dotenv": "^16.4.5", diff --git a/apps/oauth-mock-server/package.json b/apps/oauth-mock-server/package.json index b2162bda1..28d23e44a 100644 --- a/apps/oauth-mock-server/package.json +++ b/apps/oauth-mock-server/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "start": "tsx src/index.ts", - "dev": "tsx watch --clear-screen=false src/index.ts" + "dev": "tsx watch --clear-screen=false src/index.ts", + "typecheck": "tsc --noEmit" }, "dependencies": { "@types/oidc-provider": "^8.5.1", diff --git a/examples/cjs-test/package.json b/examples/cjs-test/package.json index 5c1ebef9a..2de77342f 100644 --- a/examples/cjs-test/package.json +++ b/examples/cjs-test/package.json @@ -6,7 +6,8 @@ "dev": "next dev --port 8110", "build": "next build", "start": "next start --port 8110", - "lint": "next lint" + "lint": "next lint", + "typecheck": "tsc --noEmit" }, "dependencies": { "next": "14.2.3", diff --git a/examples/middleware/package.json b/examples/middleware/package.json index 7f76b7997..f2a6cd3dc 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -6,7 +6,8 @@ "dev": "next dev --port 8112", "build": "next build", "start": "next start --port 8112", - "lint": "next lint" + "lint": "next lint", + "typecheck": "tsc --noEmit" }, "dependencies": { "next": "^14.2", diff --git a/examples/partial-prerendering/package.json b/examples/partial-prerendering/package.json index 93b9b880e..2ac015a96 100644 --- a/examples/partial-prerendering/package.json +++ b/examples/partial-prerendering/package.json @@ -6,7 +6,8 @@ "dev": "next dev --port 8109", "build": "next build", "start": "next start --port 8109", - "lint": "next lint" + "lint": "next lint", + "typecheck": "tsc --noEmit" }, "dependencies": { "next": "14.3.0-canary.26", diff --git a/packages/stack-shared/src/utils/types.tsx b/packages/stack-shared/src/utils/types.tsx index 09d39dd5c..c8aab1b75 100644 --- a/packages/stack-shared/src/utils/types.tsx +++ b/packages/stack-shared/src/utils/types.tsx @@ -2,3 +2,7 @@ export type IsAny = 0 extends (1 & T) ? true : false; export type isNullish = T extends null | undefined ? true : false; export type NullishCoalesce = T extends null | undefined ? U : T; + +// distributive conditional type magic. See: https://stackoverflow.com/a/50375286 +export type UnionToIntersection = + (U extends any ? (x: U)=>void : never) extends ((x: infer I)=>void) ? I : never