mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Events (#142)
This commit is contained in:
parent
78c5f971af
commit
7cca092c82
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -28,6 +28,7 @@
|
||||
"Proxied",
|
||||
"reqs",
|
||||
"stackframe",
|
||||
"typecheck",
|
||||
"typehack",
|
||||
"Uncapitalize",
|
||||
"Whitespaces",
|
||||
|
||||
@ -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")
|
||||
);
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
130
apps/backend/src/lib/events.tsx
Normal file
130
apps/backend/src/lib/events.tsx
Normal file
@ -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<any>,
|
||||
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 extends Record<`${string}EventType`, unknown>>(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<T extends EventType[]> = UnionToIntersection<T extends unknown ? DataOf<T[number]> : never>; // distributive conditional. See: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
|
||||
|
||||
type DataOf<T extends EventType> =
|
||||
& yup.InferType<T["dataSchema"]>
|
||||
& DataOfMany<T["inherits"]>;
|
||||
|
||||
export async function logEvent<T extends EventType[]>(
|
||||
eventTypes: T,
|
||||
data: DataOfMany<T>,
|
||||
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<EventType>();
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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<string> {
|
||||
assertScopeIsValid(scope);
|
||||
return await encodeAccessToken({
|
||||
return await generateAccessToken({
|
||||
projectId: client.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -2,3 +2,7 @@ export type IsAny<T> = 0 extends (1 & T) ? true : false;
|
||||
export type isNullish<T> = T extends null | undefined ? true : false;
|
||||
|
||||
export type NullishCoalesce<T, U> = T extends null | undefined ? U : T;
|
||||
|
||||
// distributive conditional type magic. See: https://stackoverflow.com/a/50375286
|
||||
export type UnionToIntersection<U> =
|
||||
(U extends any ? (x: U)=>void : never) extends ((x: infer I)=>void) ? I : never
|
||||
|
||||
Loading…
Reference in New Issue
Block a user