This commit is contained in:
Konsti Wohlwend 2024-07-21 18:31:42 -07:00 committed by GitHub
parent 78c5f971af
commit 7cca092c82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 229 additions and 17 deletions

View File

@ -28,6 +28,7 @@
"Proxied",
"reqs",
"stackframe",
"typecheck",
"typehack",
"Uncapitalize",
"Whitespaces",

View File

@ -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")
);

View File

@ -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

View File

@ -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";

View File

@ -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,
});

View 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,
},
});
}

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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

View File

@ -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');
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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