From 70d629227a932471677e84d1b96d27955e444e8a Mon Sep 17 00:00:00 2001 From: Wanjohi <71614375+wanjohiryan@users.noreply.github.com> Date: Tue, 6 May 2025 07:26:59 +0300 Subject: [PATCH] =?UTF-8?q?=E2=AD=90=20feat:=20New=20account=20system=20wi?= =?UTF-8?q?th=20improved=20team=20management=20(#273)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Description ## Summary by CodeRabbit - **New Features** - Introduced comprehensive account management with combined user and team info. - Added advanced, context-aware logging utilities. - Implemented invite code generation for teams with uniqueness guarantees. - Expanded example data for users, teams, subscriptions, sessions, and games. - **Enhancements** - Refined user, team, member, and Steam account schemas for richer data and validation. - Streamlined user creation, login acknowledgment, and error handling. - Improved API authentication and unified actor context management. - Added persistent shared temporary volume support to API and auth services. - Enhanced Steam account management with create, update, and event notifications. - Refined team listing and serialization integrating Steam accounts as members. - Simplified event, context, and logging systems. - Updated API and auth middleware for better token handling and actor provisioning. - **Bug Fixes** - Fixed multiline log output to prefix each line with log level. - **Removals** - Removed machine and subscription management features, including schemas and DB tables. - Disabled machine-based authentication and removed related subject schemas. - Removed deprecated fields and legacy logic from member and team management. - Removed legacy event and error handling related to teams and members. - **Chores** - Reorganized and cleaned exports across utility and API modules. - Updated database schemas for users, teams, members, and Steam accounts. - Improved internal code structure, imports, and error messaging. - Moved logger patching to earlier initialization for consistent logging. --- infra/api.ts | 51 ++- infra/auth.ts | 47 ++- infra/secret.ts | 15 +- packages/core/src/account/index.ts | 47 +++ packages/core/src/actor.ts | 257 +++++++------- packages/core/src/common.ts | 1 - packages/core/src/context.ts | 8 +- packages/core/src/drizzle/index.ts | 1 - packages/core/src/drizzle/transaction.ts | 4 +- packages/core/src/event.ts | 19 +- packages/core/src/examples.ts | 217 ++++++++---- packages/core/src/machine/index.ts | 155 -------- packages/core/src/machine/machine.sql.ts | 40 --- packages/core/src/member/index.ts | 108 ++---- packages/core/src/member/member.sql.ts | 34 +- packages/core/src/polar/index.ts | 71 +--- packages/core/src/realtime/index.ts | 4 +- packages/core/src/steam/index.ts | 267 ++++++++++---- packages/core/src/steam/steam.sql.ts | 74 ++-- packages/core/src/subscription/index.ts | 192 ---------- .../core/src/subscription/subscription.sql.ts | 31 -- packages/core/src/task/task.sql.todo | 20 -- packages/core/src/team/index.ts | 261 ++++++-------- packages/core/src/team/team.sql.ts | 39 +- packages/core/src/user/index.ts | 332 +++++++----------- packages/core/src/user/user.sql.ts | 25 +- packages/core/src/utils/id.ts | 13 +- packages/core/src/utils/index.ts | 4 +- packages/core/src/utils/invite.ts | 32 ++ packages/core/src/utils/log.ts | 76 ++++ packages/functions/src/api/account.ts | 38 +- packages/functions/src/api/index.ts | 4 +- packages/functions/src/api/utils/auth.ts | 33 +- packages/functions/src/api/utils/index.ts | 5 +- packages/functions/src/auth/index.ts | 128 ++----- packages/functions/src/auth/utils/discord.ts | 4 +- packages/functions/src/auth/utils/github.ts | 4 +- packages/functions/src/subjects.ts | 4 - packages/functions/src/utils/patch-logger.ts | 9 +- 39 files changed, 1194 insertions(+), 1480 deletions(-) create mode 100644 packages/core/src/account/index.ts delete mode 100644 packages/core/src/machine/index.ts delete mode 100644 packages/core/src/machine/machine.sql.ts delete mode 100644 packages/core/src/subscription/index.ts delete mode 100644 packages/core/src/subscription/subscription.sql.ts delete mode 100644 packages/core/src/task/task.sql.todo create mode 100644 packages/core/src/utils/invite.ts create mode 100644 packages/core/src/utils/log.ts diff --git a/infra/api.ts b/infra/api.ts index cdb19cd1..50ccbf82 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -1,19 +1,19 @@ import { bus } from "./bus"; import { auth } from "./auth"; import { domain } from "./dns"; -import { secret } from "./secret"; import { cluster } from "./cluster"; import { postgres } from "./postgres"; +import { secret, steamEncryptionKey } from "./secret"; -export const api = new sst.aws.Service("Api", { +export const apiService = new sst.aws.Service("Api", { cluster, cpu: $app.stage === "production" ? "2 vCPU" : undefined, memory: $app.stage === "production" ? "4 GB" : undefined, - command: ["bun", "run", "./src/api/index.ts"], link: [ bus, auth, postgres, + steamEncryptionKey, secret.PolarSecret, secret.PolarWebhookSecret, secret.NestriFamilyMonthly, @@ -22,12 +22,10 @@ export const api = new sst.aws.Service("Api", { secret.NestriProMonthly, secret.NestriProYearly, ], + command: ["bun", "run", "./src/api/index.ts"], image: { dockerfile: "packages/functions/Containerfile", }, - environment: { - NO_COLOR: "1", - }, loadBalancer: { rules: [ { @@ -37,9 +35,9 @@ export const api = new sst.aws.Service("Api", { ], }, dev: { + url: "http://localhost:3001", command: "bun dev:api", directory: "packages/functions", - url: "http://localhost:3001", }, scaling: $app.stage === "production" @@ -48,16 +46,49 @@ export const api = new sst.aws.Service("Api", { max: 10, } : undefined, + // For persisting actor state + transform: { + taskDefinition: (args) => { + const volumes = $output(args.volumes).apply(v => { + const next = [...v, { + name: "shared-tmp", + dockerVolumeConfiguration: { + scope: "shared", + driver: "local" + } + }]; + + return next; + }) + + // "containerDefinitions" is a JSON string, parse first + let containers = $jsonParse(args.containerDefinitions); + + containers = containers.apply((containerDefinitions) => { + containerDefinitions[0].mountPoints = [ + ...(containerDefinitions[0].mountPoints ?? []), + { + sourceVolume: "shared-tmp", + containerPath: "/tmp" + }, + ] + return containerDefinitions; + }); + + args.volumes = volumes + args.containerDefinitions = $jsonStringify(containers); + } + } }); -export const apiRoute = new sst.aws.Router("ApiRoute", { +export const api = !$dev ? new sst.aws.Router("ApiRoute", { routes: { // I think api.url should work all the same - "/*": api.nodes.loadBalancer.dnsName, + "/*": apiService.nodes.loadBalancer.dnsName, }, domain: { name: "api." + domain, dns: sst.cloudflare.dns(), }, -}) \ No newline at end of file +}) : apiService \ No newline at end of file diff --git a/infra/auth.ts b/infra/auth.ts index 085ebc12..3b7d1c68 100644 --- a/infra/auth.ts +++ b/infra/auth.ts @@ -1,11 +1,10 @@ import { bus } from "./bus"; import { domain } from "./dns"; -import { secret } from "./secret"; import { cluster } from "./cluster"; import { postgres } from "./postgres"; +import { secret, steamEncryptionKey } from "./secret"; -//FIXME: Use a shared /tmp folder -export const auth = new sst.aws.Service("Auth", { +export const authService = new sst.aws.Service("Auth", { cluster, cpu: $app.stage === "production" ? "1 vCPU" : undefined, memory: $app.stage === "production" ? "2 GB" : undefined, @@ -14,6 +13,7 @@ export const auth = new sst.aws.Service("Auth", { bus, postgres, secret.PolarSecret, + steamEncryptionKey, secret.GithubClientID, secret.DiscordClientID, secret.GithubClientSecret, @@ -24,7 +24,7 @@ export const auth = new sst.aws.Service("Auth", { }, environment: { NO_COLOR: "1", - STORAGE: $dev ? "/tmp/persist.json" : "/mnt/efs/persist.json" + STORAGE: "/tmp/persist.json" }, loadBalancer: { rules: [ @@ -52,15 +52,48 @@ export const auth = new sst.aws.Service("Auth", { max: 10, } : undefined, + //For temporarily persisting the persist.json + transform: { + taskDefinition: (args) => { + const volumes = $output(args.volumes).apply(v => { + const next = [...v, { + name: "shared-tmp", + dockerVolumeConfiguration: { + scope: "shared", + driver: "local" + } + }]; + + return next; + }) + + // "containerDefinitions" is a JSON string, parse first + let containers = $jsonParse(args.containerDefinitions); + + containers = containers.apply((containerDefinitions) => { + containerDefinitions[0].mountPoints = [ + ...(containerDefinitions[0].mountPoints ?? []), + { + sourceVolume: "shared-tmp", + containerPath: "/tmp" + } + ] + return containerDefinitions; + }); + + args.volumes = volumes + args.containerDefinitions = $jsonStringify(containers); + } + } }); -export const authRoute = new sst.aws.Router("AuthRoute", { +export const auth = !$dev ? new sst.aws.Router("AuthRoute", { routes: { // I think auth.url should work all the same - "/*": auth.nodes.loadBalancer.dnsName, + "/*": authService.nodes.loadBalancer.dnsName, }, domain: { name: "auth." + domain, dns: sst.cloudflare.dns(), }, -}) \ No newline at end of file +}) : authService \ No newline at end of file diff --git a/infra/secret.ts b/infra/secret.ts index 39d24673..c8256a87 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -14,4 +14,17 @@ export const secret = { NestriFamilyYearly: new sst.Secret("NestriFamilyYearly"), }; -export const allSecrets = Object.values(secret); \ No newline at end of file +export const allSecrets = Object.values(secret); + +sst.Linkable.wrap(random.RandomString, (resource) => ({ + properties: { + value: resource.result, + }, +})); + +export const steamEncryptionKey = new random.RandomString( + "SteamEncryptionKey", + { + length: 32, + }, +); \ No newline at end of file diff --git a/packages/core/src/account/index.ts b/packages/core/src/account/index.ts new file mode 100644 index 00000000..4f542098 --- /dev/null +++ b/packages/core/src/account/index.ts @@ -0,0 +1,47 @@ +import { z } from "zod" +import { User } from "../user"; +import { Team } from "../team"; +import { Actor } from "../actor"; +import { Examples } from "../examples"; +import { ErrorCodes, VisibleError } from "../error"; + +export namespace Account { + export const Info = + User.Info + .extend({ + teams: Team.Info + .array() + .openapi({ + description: "The teams that this user is part of", + example: [Examples.Team] + }) + }) + .openapi({ + ref: "Account", + description: "Represents an account's information stored on Nestri", + example: { ...Examples.User, teams: [Examples.Team] }, + }); + + export type Info = z.infer; + + export const list = async (): Promise => { + const [userResult, teamsResult] = + await Promise.allSettled([ + User.fromID(Actor.userID()), + Team.list() + ]) + + if (userResult.status === "rejected" || !userResult.value) + throw new VisibleError( + "not_found", + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + "User not found", + ); + + return { + ...userResult.value, + teams: teamsResult.status === "rejected" ? [] : teamsResult.value + } + } + +} \ No newline at end of file diff --git a/packages/core/src/actor.ts b/packages/core/src/actor.ts index 32b1e8e5..c2f84bd2 100644 --- a/packages/core/src/actor.ts +++ b/packages/core/src/actor.ts @@ -1,142 +1,129 @@ -import { z } from "zod"; -import { eq } from "./drizzle"; -import { ErrorCodes, VisibleError } from "./error"; +import { Log } from "./utils"; import { createContext } from "./context"; -import { UserFlags, userTable } from "./user/user.sql"; -import { useTransaction } from "./drizzle/transaction"; +import { ErrorCodes, VisibleError } from "./error"; -export const PublicActor = z.object({ - type: z.literal("public"), - properties: z.object({}), -}); -export type PublicActor = z.infer; - -export const UserActor = z.object({ - type: z.literal("user"), - properties: z.object({ - userID: z.string(), - email: z.string().nonempty(), - }), -}); -export type UserActor = z.infer; - -export const MemberActor = z.object({ - type: z.literal("member"), - properties: z.object({ - memberID: z.string(), - teamID: z.string(), - }), -}); -export type MemberActor = z.infer; - -export const SystemActor = z.object({ - type: z.literal("system"), - properties: z.object({ - teamID: z.string(), - }), -}); -export type SystemActor = z.infer; - -export const MachineActor = z.object({ - type: z.literal("machine"), - properties: z.object({ - fingerprint: z.string(), - machineID: z.string(), - }), -}); -export type MachineActor = z.infer; - -export const Actor = z.discriminatedUnion("type", [ - MemberActor, - UserActor, - PublicActor, - SystemActor, - MachineActor -]); -export type Actor = z.infer; - -export const ActorContext = createContext("actor"); - -export const useActor = ActorContext.use; -export const withActor = ActorContext.with; - -/** - * Retrieves the user ID of the current actor. - * - * This function accesses the actor context and returns the `userID` if the current - * actor is of type "user". If the actor is not a user, it throws a `VisibleError` - * with an authentication error code, indicating that the caller is not authorized - * to access user-specific resources. - * - * @throws {VisibleError} When the current actor is not of type "user". - */ -export function useUserID() { - const actor = ActorContext.use(); - if (actor.type === "user") return actor.properties.userID; - throw new VisibleError( - "authentication", - ErrorCodes.Authentication.UNAUTHORIZED, - `You don't have permission to access this resource`, - ); -} - -/** - * Retrieves the properties of the current user actor. - * - * This function obtains the current actor from the context and returns its properties if the actor is identified as a user. - * If the actor is not of type "user", it throws a {@link VisibleError} with an authentication error code, - * indicating that the user is not authorized to access user-specific resources. - * - * @returns The properties of the current user actor, typically including user-specific details such as userID and email. - * @throws {VisibleError} If the current actor is not a user. - */ -export function useUser() { - const actor = ActorContext.use(); - if (actor.type === "user") return actor.properties; - throw new VisibleError( - "authentication", - ErrorCodes.Authentication.UNAUTHORIZED, - `You don't have permission to access this resource`, - ); -} - -export function assertActor(type: T) { - const actor = useActor(); - if (actor.type !== type) { - throw new Error(`Expected actor type ${type}, got ${actor.type}`); +export namespace Actor { + + export interface User { + type: "user"; + properties: { + userID: string; + email: string; + }; } - return actor as Extract; -} + export interface System { + type: "system"; + properties: { + teamID: string; + }; + } -/** - * Returns the current actor's team ID. - * - * @returns The team ID associated with the current actor. - * @throws {VisibleError} If the current actor does not have a {@link teamID} property. - */ -export function useTeam() { - const actor = useActor(); - if ("teamID" in actor.properties) return actor.properties.teamID; - throw new VisibleError( - "authentication", - ErrorCodes.Authentication.UNAUTHORIZED, - `Expected actor to have teamID` - ); -} + export interface Machine { + type: "machine"; + properties: { + machineID: string; + fingerprint: string; + }; + } -/** - * Returns the fingerprint of the current actor if the actor has a machine identity. - * - * @returns The fingerprint of the current machine actor. - * @throws {VisibleError} If the current actor does not have a machine identity. - */ -export function useMachine() { - const actor = useActor(); - if ("machineID" in actor.properties) return actor.properties.fingerprint; - throw new VisibleError( - "authentication", - ErrorCodes.Authentication.UNAUTHORIZED, - `Expected actor to have fingerprint` - ); + export interface Token { + type: "steam"; + properties: { + steamID: bigint; + }; + } + + export interface Public { + type: "public"; + properties: {}; + } + + export type Info = User | Public | Token | System | Machine; + + export const Context = createContext(); + + export function userID() { + const actor = Context.use(); + if ("userID" in actor.properties) return actor.properties.userID; + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + `You don't have permission to access this resource.`, + ); + } + + export function steamID() { + const actor = Context.use(); + if ("steamID" in actor.properties) return actor.properties.steamID; + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + `You don't have permission to access this resource.`, + ); + } + + export function user() { + const actor = Context.use(); + if (actor.type == "user") return actor.properties; + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + `You don't have permission to access this resource.`, + ); + } + + export function teamID() { + const actor = Context.use(); + if ("teamID" in actor.properties) return actor.properties.teamID; + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + `You don't have permission to access this resource.`, + ); + } + + export function fingerprint() { + const actor = Context.use(); + if ("fingerprint" in actor.properties) return actor.properties.fingerprint; + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + `You don't have permission to access this resource.`, + ); + } + + export function use() { + try { + return Context.use(); + } catch { + return { type: "public", properties: {} } as Public; + } + } + + export function assert(type: T) { + const actor = use(); + if (actor.type !== type) + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + `Actor is not "${type}"`, + ); + return actor as Extract; + } + + export function provide< + T extends Info["type"], + Next extends (...args: any) => any, + >(type: T, properties: Extract["properties"], fn: Next) { + return Context.provide({ type, properties } as any, () => + Log.provide( + { + actor: type, + ...properties, + }, + fn, + ), + ); + } } \ No newline at end of file diff --git a/packages/core/src/common.ts b/packages/core/src/common.ts index 9ce2b53b..e827472b 100644 --- a/packages/core/src/common.ts +++ b/packages/core/src/common.ts @@ -1,5 +1,4 @@ import { sql } from "drizzle-orm"; -import { z } from "zod"; import "zod-openapi/extend"; export namespace Common { diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index c1ca8e64..d19af4cc 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,17 +1,17 @@ import { AsyncLocalStorage } from "node:async_hooks"; -export function createContext(name: string) { +export function createContext() { const storage = new AsyncLocalStorage(); return { use() { const result = storage.getStore(); if (!result) { - throw new Error("Context not provided: " + name); + throw new Error("No context available"); } return result; }, - with(value: T, fn: () => R) { - return storage.run(value, fn); + provide(value: T, fn: () => R) { + return storage.run(value, fn); }, }; } \ No newline at end of file diff --git a/packages/core/src/drizzle/index.ts b/packages/core/src/drizzle/index.ts index d4865785..48e3901e 100644 --- a/packages/core/src/drizzle/index.ts +++ b/packages/core/src/drizzle/index.ts @@ -1,4 +1,3 @@ -export * from "drizzle-orm"; import { Resource } from "sst"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; diff --git a/packages/core/src/drizzle/transaction.ts b/packages/core/src/drizzle/transaction.ts index 91734a8f..6a5cbb69 100644 --- a/packages/core/src/drizzle/transaction.ts +++ b/packages/core/src/drizzle/transaction.ts @@ -20,7 +20,7 @@ type TxOrDb = Transaction | typeof db; const TransactionContext = createContext<{ tx: Transaction; effects: (() => void | Promise)[]; -}>("TransactionContext"); +}>(); export async function useTransaction(callback: (trx: TxOrDb) => Promise) { try { @@ -51,7 +51,7 @@ export async function createTransaction( const effects: (() => void | Promise)[] = []; const result = await db.transaction( async (tx) => { - return TransactionContext.with({ tx, effects }, () => callback(tx)); + return TransactionContext.provide({ tx, effects }, () => callback(tx)); }, { isolationLevel: isolationLevel || "read committed", diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 9b15dc47..a2a79606 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,23 +1,12 @@ -import { useActor } from "./actor"; -import { event as sstEvent } from "sst/event"; +import { Actor } from "./actor"; +import { event } from "sst/event"; import { ZodValidator } from "sst/event/validator"; -export const createEvent = sstEvent.builder({ +export const createEvent = event.builder({ validator: ZodValidator, metadata() { return { - actor: useActor(), - }; - }, -}); - -import { openevent } from "@openauthjs/openevent/event"; -export { publish } from "@openauthjs/openevent/publisher/drizzle"; - -export const event = openevent({ - metadata() { - return { - actor: useActor(), + actor: Actor.use(), }; }, }); \ No newline at end of file diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 47739c30..66ede8fc 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -1,76 +1,30 @@ import { prefixes } from "./utils"; + export namespace Examples { export const Id = (prefix: keyof typeof prefixes) => `${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`; - export const Steam = { - id: Id("steam"), - userID: Id("user"), - countryCode: "KE", - steamID: 74839300282033, - limitation: { - isLimited: false, - isBanned: false, - isLocked: false, - isAllowedToInviteFriends: false, - }, - lastGame: { - gameID: 2531310, - gameName: "The Last of Us™ Part II Remastered", - }, - personaName: "John", - username: "johnsteamaccount", - steamEmail: "john@example.com", - avatarUrl: "https://avatars.akamai.steamstatic.com/XXXXXXXXXXXX_full.jpg", - } - export const User = { - id: Id("user"), - name: "John Doe", - email: "john@example.com", - discriminator: 47, + id: Id("user"),// Primary key + name: "John Doe", // Name (not null) + email: "johndoe@example.com",// Unique email or login (not null) avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png", - polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4", - steamAccounts: [Steam] - }; - - export const Product = { - id: Id("product"), - name: "RTX 4090", - description: "Ideal for dedicated gamers who crave more flexibility and social gaming experiences.", - tokensPerHour: 20, + lastLogin: new Date("2025-04-26T20:11:08.155Z"), + polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4" } - export const Subscription = { - tokens: 100, - id: Id("subscription"), - userID: Id("user"), - teamID: Id("team"), - planType: "pro" as const, // free, pro, family, enterprise - standing: "new" as const, // new, good, overdue, cancelled - polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4", - polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4", - } - - export const Member = { - id: Id("member"), - email: "john@example.com", - teamID: Id("team"), - role: "admin" as const, - timeSeen: new Date("2025-02-23T13:39:52.249Z"), - } - - export const Team = { - id: Id("team"), - name: "John Does' Team", - slug: "john_doe", - subscriptions: [Subscription], - members: [Member] + export const GPUType = { + id: Id("gpu"), + type: "hosted" as const, //or BYOG - Bring Your Own GPU + name: "RTX 4090" as const, // or RTX 3090, Intel Arc + performanceTier: 3, + maxResolution: "4k" } export const Machine = { id: Id("machine"), - userID: Id("user"), + ownerID: User.id, //or null if hosted + gpuID: GPUType.id, // or hosted country: "Kenya", countryCode: "KE", timezone: "Africa/Nairobi", @@ -78,4 +32,147 @@ export namespace Examples { fingerprint: "fc27f428f9ca47d4b41b707ae0c62090", } + export const SteamAccount = { + status: "online" as const, //offline,dnd(do not disturb) or playing + id: "74839300282033",// Primary key + userID: User.id,// | null FK to User (null if not linked) + name: "JD The 65th", + username: "jdoe", + realName: "John Doe", + steamMemberSince: new Date("2010-01-26T21:00:00.000Z"), + avatarHash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19", + accountStatus: "new" as const, //active or pending + limitations: { + isLimited: false, + isTradeBanned: false, + isVacBanned: false, + visibilityState: 3, + privacyState: "public" as const, + }, + profileUrl: "The65thJD", //"https://steamcommunity.com/id/XXXXXXXXXXXXXXXX/", + lastSyncedAt: new Date("2025-04-26T20:11:08.155Z") + }; + + export const Team = { + id: Id("team"),// Primary key + name: "John's Console", // Team name (not null, unique) + ownerID: User.id, // FK to User who owns/created the team + slug: SteamAccount.profileUrl.toLowerCase(), + maxMembers: 3, + inviteCode: "xwydjf", + members: [SteamAccount] + }; + + export const Member = { + id: Id("member"), + userID: User.id,//FK to Users (member) + steamID: SteamAccount.id, // FK to the Steam Account this member is used + teamID: Team.id,// FK to Teams + role: "adult" as const, // Role on the team, adult or child + }; + + export const ProductVariant = { + id: Id("variant"), + productID: Id("product"),// the product this variant is under + type: "fixed" as const, // or yearly or monthly, + price: 1999, + minutesPerDay: 3600, + polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4" + } + + export const Product = { + id: Id("product"), + name: "Pro", + description: "For gamers who want to play on a better GPU and with 2 more friends", + maxMembers: Team.maxMembers,// Total number of people who can share this sub + isActive: true, + order: 2, + variants: [ProductVariant] + } + + export const Subscription = { + id: Id("subscription"), + teamID: Team.id, + standing: "active" as const, //incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid + ownerID: User.id, + price: ProductVariant.price, + productVariantID: ProductVariant.id, + polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4", + } + + export const SubscriptionUsage = { + id: Id("usage"), + machineID: Machine.id, // machine this session was used on + memberID: Member.id, // the team member who used it + subscriptionID: Subscription.id, + sessionID: Id("session"), + minutesUsed: 20, // Minutes used on the session + } + + export const Session = { + id: Id("session"), + memberID: Member.id, + machineID: Machine.id, + startTime: new Date("2025-02-23T23:39:52.249Z"), + endTime: null, // null if session is ongoing + gameID: Id("game"), + status: "active" as const, // active, completed, crashed + } + + export const GameGenre = { + type: "genre" as const, + slug: "action", + name: "Action" + } + + export const GameTag = { + type: "tag" as const, + slug: "single-player", + name: "Single Player" + } + + export const GameRating = { + body: "ESRB" as const, // or PEGI + age: 16, + descriptors: ["Blood", "Violence", "Strong Language"], + } + + export const DevelopmentTeam = { + type: "developer" as const, + name: "Remedy Entertainment", + slug: "remedy_entertainment", + } + + export const Game = { + id: Id("game"), + appID: 870780, + name: "Control Ultimate Edition", + slug: "control-ultimate-edition", + tags: [GameTag], // Examples; Multiplayer, Family Sharing, Free To Play, Full Controller Support, In Game Purchases, Native Linux, Proton Compatibility Max (3), Proton Compatibility Mid (2), Proton Compatibility Low (1) + genres: [GameGenre], // Examples; Action, Adventure, + website: "https://controlgame.com", + legalNotice: "Control © Remedy Entertainment Plc 2019. The Remedy, Northlight and Control logos are trademarks of Remedy Entertainment Plc. 505 Games and the 505 Games logo are trademarks of 505 Games SpA, and may be registered in the United States and other countries. All rights reserved.", + releaseDate: new Date("27 Aug, 2020"), + description: "Winner of over 80 awards, Control is a visually stunning third-person action-adventure that will keep you on the edge of your seat.", + ratings: [GameRating], + publishers: [{ ...DevelopmentTeam, type: "publisher" as const }], + developers: [DevelopmentTeam], + } + + export const image = { + type: "screenshot" as const, // or square, vertical, horizontal, movie + hash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19", + gameID: Game.id, + extractedColors: [{}] + } + + // export const Machine = { + // id: Id("machine"), + // userID: Id("user"), + // country: "Kenya", + // countryCode: "KE", + // timezone: "Africa/Nairobi", + // location: { latitude: 36.81550, longitude: -1.28410 }, + // fingerprint: "fc27f428f9ca47d4b41b707ae0c62090", + // } } \ No newline at end of file diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts deleted file mode 100644 index bd93f3cf..00000000 --- a/packages/core/src/machine/index.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { z } from "zod"; -import { Common } from "../common"; -import { createID, fn } from "../utils"; -import { Examples } from "../examples"; -import { machineTable } from "./machine.sql"; -import { getTableColumns, eq, sql, and, isNull } from "../drizzle"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; - -export namespace Machine { - export const Info = z - .object({ - id: z.string().openapi({ - description: Common.IdDescription, - example: Examples.Machine.id, - }), - // userID: z.string().nullable().openapi({ - // description: "The userID of the user who owns this machine, in the case of BYOG", - // example: Examples.Machine.userID - // }), - country: z.string().openapi({ - description: "The fullname of the country this machine is running in", - example: Examples.Machine.country - }), - fingerprint: z.string().openapi({ - description: "The fingerprint of this machine, deduced from the host machine's machine id - /etc/machine-id", - example: Examples.Machine.fingerprint - }), - location: z.object({ longitude: z.number(), latitude: z.number() }).openapi({ - description: "This is the 2d location of this machine, they might not be accurate", - example: Examples.Machine.location - }), - countryCode: z.string().openapi({ - description: "This is the 2 character country code of the country this machine [ISO 3166-1 alpha-2] ", - example: Examples.Machine.countryCode - }), - timezone: z.string().openapi({ - description: "The IANA timezone formatted string of the timezone of the location where the machine is running", - example: Examples.Machine.timezone - }) - }) - .openapi({ - ref: "Machine", - description: "Represents a hosted or BYOG machine connected to Nestri", - example: Examples.Machine, - }); - - export type Info = z.infer; - - export const create = fn(Info.partial({ id: true }), async (input) => - createTransaction(async (tx) => { - const id = input.id ?? createID("machine"); - await tx.insert(machineTable).values({ - id, - country: input.country, - timezone: input.timezone, - fingerprint: input.fingerprint, - countryCode: input.countryCode, - // userID: input.userID, - location: { x: input.location.longitude, y: input.location.latitude }, - }) - - // await afterTx(() => - // bus.publish(Resource.Bus, Events.Created, { - // teamID: id, - // }), - // ); - return id; - }) - ) - - // export const fromUserID = fn(z.string(), async (userID) => - // useTransaction(async (tx) => - // tx - // .select() - // .from(machineTable) - // .where(and(eq(machineTable.userID, userID), isNull(machineTable.timeDeleted))) - // .then((rows) => rows.map(serialize)) - // ) - // ) - - // export const list = fn(z.void(), async () => - // useTransaction(async (tx) => - // tx - // .select() - // .from(machineTable) - // // Show only hosted machines, not BYOG machines - // .where(and(isNull(machineTable.userID), isNull(machineTable.timeDeleted))) - // .then((rows) => rows.map(serialize)) - // ) - // ) - - export const fromID = fn(Info.shape.id, async (id) => - useTransaction(async (tx) => - tx - .select() - .from(machineTable) - .where(and(eq(machineTable.id, id), isNull(machineTable.timeDeleted))) - .then((rows) => rows.map(serialize).at(0)) - ) - ) - - export const fromFingerprint = fn(Info.shape.fingerprint, async (fingerprint) => - useTransaction(async (tx) => - tx - .select() - .from(machineTable) - .where(and(eq(machineTable.fingerprint, fingerprint), isNull(machineTable.timeDeleted))) - .execute() - .then((rows) => rows.map(serialize).at(0)) - ) - ) - - export const remove = fn(Info.shape.id, (id) => - useTransaction(async (tx) => { - await tx - .update(machineTable) - .set({ - timeDeleted: sql`now()`, - }) - .where(and(eq(machineTable.id, id))) - .execute(); - return id; - }), - ); - - export const fromLocation = fn(Info.shape.location, async (location) => - useTransaction(async (tx) => { - const sqlDistance = sql`location <-> point(${location.longitude}, ${location.latitude})`; - return tx - .select({ - ...getTableColumns(machineTable), - distance: sql`round((${sqlDistance})::numeric, 2)` - }) - .from(machineTable) - .where(isNull(machineTable.timeDeleted)) - .orderBy(sqlDistance) - .limit(3) - .then((rows) => rows.map(serialize)) - }) - ) - - export function serialize( - input: typeof machineTable.$inferSelect, - ): z.infer { - return { - id: input.id, - // userID: input.userID, - country: input.country, - timezone: input.timezone, - fingerprint: input.fingerprint, - countryCode: input.countryCode, - location: { latitude: input.location.y, longitude: input.location.x }, - }; - } -} \ No newline at end of file diff --git a/packages/core/src/machine/machine.sql.ts b/packages/core/src/machine/machine.sql.ts deleted file mode 100644 index 1303d7ff..00000000 --- a/packages/core/src/machine/machine.sql.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { } from "drizzle-orm/postgres-js"; -import { timestamps, id, ulid } from "../drizzle/types"; -import { - text, - varchar, - pgTable, - uniqueIndex, - point, - primaryKey, -} from "drizzle-orm/pg-core"; - -export const machineTable = pgTable( - "machine", - { - ...id, - ...timestamps, - // userID: ulid("user_id"), - country: text('country').notNull(), - timezone: text('timezone').notNull(), - location: point('location', { mode: 'xy' }).notNull(), - fingerprint: varchar('fingerprint', { length: 32 }).notNull(), - countryCode: varchar('country_code', { length: 2 }).notNull(), - // provider: text("provider").notNull(), - // gpuType: text("gpu_type").notNull(), - // storage: numeric("storage").notNull(), - // ipaddress: text("ipaddress").notNull(), - // gpuNumber: integer("gpu_number").notNull(), - // computePrice: numeric("compute_price").notNull(), - // driverVersion: integer("driver_version").notNull(), - // operatingSystem: text("operating_system").notNull(), - // fingerprint: varchar("fingerprint", { length: 32 }).notNull(), - // externalID: varchar("external_id", { length: 255 }).notNull(), - // cudaVersion: numeric("cuda_version", { precision: 4, scale: 2 }).notNull(), - }, - (table) => [ - // uniqueIndex("external_id").on(table.externalID), - uniqueIndex("machine_fingerprint").on(table.fingerprint), - // primaryKey({ columns: [table.userID, table.id], }), - ], -); \ No newline at end of file diff --git a/packages/core/src/member/index.ts b/packages/core/src/member/index.ts index 1e2cedec..dec53961 100644 --- a/packages/core/src/member/index.ts +++ b/packages/core/src/member/index.ts @@ -1,14 +1,10 @@ import { z } from "zod"; -import { Resource } from "sst"; -import { bus } from "sst/aws/bus"; -import { useTeam } from "../actor"; +import { Actor } from "../actor"; import { Common } from "../common"; -import { createID, fn } from "../utils"; -import { createEvent } from "../event"; import { Examples } from "../examples"; -import { memberTable, role } from "./member.sql"; -import { and, eq, sql, asc, isNull } from "../drizzle"; -import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; +import { createID, fn } from "../utils"; +import { memberTable, RoleEnum } from "./member.sql"; +import { createTransaction } from "../drizzle/transaction"; export namespace Member { export const Info = z @@ -17,107 +13,53 @@ export namespace Member { description: Common.IdDescription, example: Examples.Member.id, }), - timeSeen: z.date().nullable().or(z.undefined()).openapi({ - description: "The last time this team member was active", - example: Examples.Member.timeSeen - }), teamID: z.string().openapi({ - description: "The unique id of the team this member is on", + description: "Associated team identifier for this membership", example: Examples.Member.teamID }), - role: z.enum(role).openapi({ - description: "The role of this team member", + role: z.enum(RoleEnum.enumValues).openapi({ + description: "Assigned permission role within the team", example: Examples.Member.role }), - email: z.string().openapi({ - description: "The email of this team member", - example: Examples.Member.email - }) + steamID: z.string().openapi({ + description: "Steam platform identifier for Steam account integration", + example: Examples.Member.steamID + }), + userID: z.string().nullable().openapi({ + description: "Optional associated user account identifier", + example: Examples.Member.userID + }), }) .openapi({ ref: "Member", - description: "Represents a team member on Nestri", + description: "Team membership entity defining user roles and platform connections", example: Examples.Member, }); export type Info = z.infer; - export const Events = { - Created: createEvent( - "member.created", - z.object({ - memberID: Info.shape.id, - }), - ), - Updated: createEvent( - "member.updated", - z.object({ - memberID: Info.shape.id, - }), - ), - }; - export const create = fn( - Info.pick({ email: true, id: true }) + Info .partial({ id: true, - }) - .extend({ - first: z.boolean().optional(), + userID: true, + teamID: true }), (input) => createTransaction(async (tx) => { const id = input.id ?? createID("member"); await tx.insert(memberTable).values({ id, - teamID: useTeam(), - email: input.email, - role: input.first ? "owner" : "member", - timeSeen: input.first ? sql`now()` : null, + role: input.role, + userID: input.userID, + steamID: input.steamID, + teamID: input.teamID ?? Actor.teamID(), }) - await afterTx(() => - async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }), - ); return id; }), ); - export const remove = fn(Info.shape.id, (id) => - useTransaction(async (tx) => { - await tx - .update(memberTable) - .set({ - timeDeleted: sql`now()`, - }) - .where(and(eq(memberTable.id, id), eq(memberTable.teamID, useTeam()))) - .execute(); - return id; - }), - ); - - export const fromEmail = fn(z.string(), async (email) => - useTransaction(async (tx) => - tx - .select() - .from(memberTable) - .where(and(eq(memberTable.email, email), isNull(memberTable.timeDeleted))) - .orderBy(asc(memberTable.timeCreated)) - .then((rows) => rows.map(serialize).at(0)) - ) - ) - - export const fromID = fn(z.string(), async (id) => - useTransaction(async (tx) => - tx - .select() - .from(memberTable) - .where(and(eq(memberTable.id, id), isNull(memberTable.timeDeleted))) - .orderBy(asc(memberTable.timeCreated)) - .then((rows) => rows.map(serialize).at(0)) - ), - ) - /** * Converts a raw member database row into a standardized {@link Member.Info} object. * @@ -130,9 +72,9 @@ export namespace Member { return { id: input.id, role: input.role, - email: input.email, + userID: input.userID, teamID: input.teamID, - timeSeen: input.timeSeen + steamID: input.steamID }; } diff --git a/packages/core/src/member/member.sql.ts b/packages/core/src/member/member.sql.ts index 6b0acfc8..5728c2b9 100644 --- a/packages/core/src/member/member.sql.ts +++ b/packages/core/src/member/member.sql.ts @@ -1,21 +1,33 @@ -import { teamIndexes } from "../team/team.sql"; -import { timestamps, utc, teamID } from "../drizzle/types"; -import { index, pgTable, text, uniqueIndex, varchar } from "drizzle-orm/pg-core"; +import { isNotNull } from "drizzle-orm"; +import { userTable } from "../user/user.sql"; +import { steamTable } from "../steam/steam.sql"; +import { timestamps, teamID, ulid } from "../drizzle/types"; +import { bigint, pgEnum, pgTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/pg-core"; -export const role = ["admin", "member", "owner"] as const; +export const RoleEnum = pgEnum("member_role", ["child", "adult"]) export const memberTable = pgTable( - "member", + "members", { ...teamID, ...timestamps, - role: text("role", { enum: role }).notNull(), - timeSeen: utc("time_seen"), - email: varchar("email", { length: 255 }).notNull(), + userID: ulid("user_id") + .references(() => userTable.id, { + onDelete: "cascade" + }), + steamID: varchar("steam_id", { length: 255 }) + .notNull() + .references(() => steamTable.id, { + onDelete: "cascade", + onUpdate: "restrict" + }), + role: RoleEnum("role").notNull(), }, (table) => [ - ...teamIndexes(table), - index("email_global").on(table.email), - uniqueIndex("member_email").on(table.teamID, table.email), + primaryKey({ columns: [table.id, table.teamID] }), + uniqueIndex("idx_member_steam_id").on(table.teamID, table.steamID), + uniqueIndex("idx_member_user_id") + .on(table.teamID, table.userID) + .where(isNotNull(table.userID)) ], ); \ No newline at end of file diff --git a/packages/core/src/polar/index.ts b/packages/core/src/polar/index.ts index a462c715..1d885c40 100644 --- a/packages/core/src/polar/index.ts +++ b/packages/core/src/polar/index.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import { fn } from "../utils"; import { Resource } from "sst"; -import { useTeam, useUserID } from "../actor"; import { Polar as PolarSdk } from "@polar-sh/sdk"; import { validateEvent } from "@polar-sh/sdk/webhooks"; -import { PlanType } from "../subscription/subscription.sql"; -const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" }); -const planType = z.enum(PlanType) +const polar = new PolarSdk({ + accessToken: Resource.PolarSecret.value, + server: Resource.App.stage !== "production" ? "sandbox" : "production" +}); + export namespace Polar { export const client = polar; @@ -16,7 +17,7 @@ export namespace Polar { const customers = await client.customers.list({ email }) if (customers.result.items.length === 0) { - return await client.customers.create({ email }) + return await client.customers.create({ email}) } else { return customers.result.items[0] } @@ -28,18 +29,18 @@ export namespace Polar { } }) - const getProductIDs = (plan: z.infer) => { - switch (plan) { - case "free": - return [Resource.NestriFreeMonthly.value] - case "pro": - return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value] - case "family": - return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value] - default: - return [Resource.NestriFreeMonthly.value] - } - } + // const getProductIDs = (plan: z.infer) => { + // switch (plan) { + // case "free": + // return [Resource.NestriFreeMonthly.value] + // case "pro": + // return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value] + // case "family": + // return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value] + // default: + // return [Resource.NestriFreeMonthly.value] + // } + // } export const createPortal = fn( z.string(), @@ -53,44 +54,10 @@ export namespace Polar { ) //TODO: Implement this - export const handleWebhook = async(payload: ReturnType) => { + export const handleWebhook = async (payload: ReturnType) => { switch (payload.type) { case "subscription.created": const teamID = payload.data.metadata.teamID } } - - export const createCheckout = fn( - z - .object({ - planType: z.enum(PlanType), - customerEmail: z.string(), - successUrl: z.string(), - customerID: z.string(), - allowDiscountCodes: z.boolean(), - teamID: z.string() - }) - .partial({ - customerEmail: true, - allowDiscountCodes: true, - customerID: true, - teamID: true - }), - async (input) => { - const productIDs = getProductIDs(input.planType) - - const checkoutUrl = - await client.checkouts.create({ - products: productIDs, - customerEmail: input.customerEmail ?? useUserID(), - successUrl: `${input.successUrl}?checkout={CHECKOUT_ID}`, - allowDiscountCodes: input.allowDiscountCodes ?? false, - customerId: input.customerID, - customerMetadata: { - teamID: input.teamID ?? useTeam() - } - }) - - return checkoutUrl.url - }) } \ No newline at end of file diff --git a/packages/core/src/realtime/index.ts b/packages/core/src/realtime/index.ts index d49c83a1..bd22e2bb 100644 --- a/packages/core/src/realtime/index.ts +++ b/packages/core/src/realtime/index.ts @@ -2,14 +2,14 @@ import { IoTDataPlaneClient, PublishCommand, } from "@aws-sdk/client-iot-data-plane"; -import { useMachine } from "../actor"; +import { Actor } from "../actor"; import { Resource } from "sst"; export namespace Realtime { const client = new IoTDataPlaneClient({}); export async function publish(message: any, subTopic?: string) { - const fingerprint = useMachine(); + const fingerprint = Actor.assert("machine").properties.fingerprint; let topic = `${Resource.App.name}/${Resource.App.stage}/${fingerprint}/`; if (subTopic) topic = `${topic}${subTopic}`; diff --git a/packages/core/src/steam/index.ts b/packages/core/src/steam/index.ts index 24f266bd..aa4dbc60 100644 --- a/packages/core/src/steam/index.ts +++ b/packages/core/src/steam/index.ts @@ -1,101 +1,224 @@ import { z } from "zod"; +import { fn } from "../utils"; +import { Resource } from "sst"; +import { Actor } from "../actor"; +import { bus } from "sst/aws/bus"; import { Common } from "../common"; +import { createEvent } from "../event"; import { Examples } from "../examples"; -import { createID, fn } from "../utils"; -import { useUser, useUserID } from "../actor"; -import { eq, and, isNull, sql } from "../drizzle"; -import { steamTable, AccountLimitation, LastGame } from "./steam.sql"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; +import { eq, and, isNull, desc } from "drizzle-orm"; +import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; +import { steamTable, StatusEnum, AccountStatusEnum, Limitations } from "./steam.sql"; export namespace Steam { export const Info = z .object({ id: z.string().openapi({ description: Common.IdDescription, - example: Examples.Steam.id, + example: Examples.SteamAccount.id }), - avatarUrl: z.string().openapi({ - description: "The avatar url of this Steam account", - example: Examples.Steam.avatarUrl + avatarHash: z.string().openapi({ + description: "The Steam avatar hash that this account owns", + example: Examples.SteamAccount.avatarHash }), - steamEmail: z.string().openapi({ - description: "The email regisered with this Steam account", - example: Examples.Steam.steamEmail + status: z.enum(StatusEnum.enumValues).openapi({ + description: "The current connection status of this Steam account", + example: Examples.SteamAccount.status }), - steamID: z.number().openapi({ - description: "The Steam ID this Steam account", - example: Examples.Steam.steamID + accountStatus: z.enum(AccountStatusEnum.enumValues).openapi({ + description: "The current status of this Steam account", + example: Examples.SteamAccount.accountStatus }), - limitation: AccountLimitation.openapi({ - description: " The limitations of this Steam account", - example: Examples.Steam.limitation + userID: z.string().nullable().openapi({ + description: "The user id of which account owns this steam account", + example: Examples.SteamAccount.userID }), - lastGame: LastGame.openapi({ - description: "The last game played on this Steam account", - example: Examples.Steam.lastGame + profileUrl: z.string().nullable().openapi({ + description: "The steam community url of this account", + example: Examples.SteamAccount.profileUrl }), - userID: z.string().openapi({ - description: "The unique id of the user who owns this steam account", - example: Examples.Steam.userID + username: z.string() + .regex(/^[a-z0-9]{1,32}$/, "The Steam username is not slug friendly") + .nullable() + .openapi({ + description: "The unique username of this account", + example: Examples.SteamAccount.username + }) + .default("unknown"), + realName: z.string().openapi({ + description: "The real name behind of this Steam account", + example: Examples.SteamAccount.realName }), - username: z.string().openapi({ - description: "The unique username of this steam user", - example: Examples.Steam.username + name: z.string().openapi({ + description: "The name used by this account", + example: Examples.SteamAccount.name }), - personaName: z.string().openapi({ - description: "The last recorded persona name used by this account", - example: Examples.Steam.personaName + lastSyncedAt: z.date().openapi({ + description: "The last time this account was synced to Steam", + example: Examples.SteamAccount.lastSyncedAt }), - countryCode: z.string().openapi({ - description: "The country this account is connected from", - example: Examples.Steam.countryCode + limitations: Limitations.openapi({ + description: "The limitations bestowed on this Steam account by Steam", + example: Examples.SteamAccount.limitations + }), + steamMemberSince: z.date().openapi({ + description: "When this Steam community account was created", + example: Examples.SteamAccount.steamMemberSince }) }) .openapi({ ref: "Steam", description: "Represents a steam user's information stored on Nestri", - example: Examples.Steam, + example: Examples.SteamAccount, }); export type Info = z.infer; + export const Events = { + Created: createEvent( + "steam_account.created", + z.object({ + steamID: Info.shape.id, + userID: Info.shape.userID + }), + ), + Updated: createEvent( + "steam_account.updated", + z.object({ + steamID: Info.shape.id, + userID: Info.shape.userID + }), + ) + }; + export const create = fn( - Info.partial({ - id: true, - userID: true, - }), + Info + .extend({ + useUser: z.boolean(), + }) + .partial({ + useUser: true, + userID: true, + status: true, + accountStatus: true, + lastSyncedAt: true + }), (input) => createTransaction(async (tx) => { - const id = input.id ?? createID("steam"); - const user = useUser() - await tx.insert(steamTable).values({ - id, - lastSeen: sql`now()`, - userID: input.userID ?? user.userID, - countryCode: input.countryCode, - username: input.username, - steamID: input.steamID, - lastGame: input.lastGame, - limitation: input.limitation, - steamEmail: input.steamEmail, - avatarUrl: input.avatarUrl, - personaName: input.personaName, - }) - return id; + const accounts = + await tx + .select() + .from(steamTable) + .where( + and( + eq(steamTable.id, input.id), + isNull(steamTable.timeDeleted) + ) + ) + .execute() + .then((rows) => rows.map(serialize)) + + // Update instead of create + if (accounts.length > 0) return null + + const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : null; + await tx + .insert(steamTable) + .values({ + userID, + id: input.id, + name: input.name, + realName: input.realName, + profileUrl: input.profileUrl, + avatarHash: input.avatarHash, + steamMemberSince: input.steamMemberSince, + limitations: input.limitations, + status: input.status ?? "offline", + username: input.username ?? "unknown", + accountStatus: input.accountStatus ?? "new", + lastSyncedAt: input.lastSyncedAt ?? Common.utc(), + }) + + await afterTx(async () => + bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id }) + ); + + return input.id }), ); + export const update = fn( + Info + .extend({ + useUser: z.boolean(), + }) + .partial({ + useUser: true, + userID: true, + status: true, + lastSyncedAt: true, + avatarHash: true, + username: true, + realName: true, + limitations: true, + accountStatus: true, + name: true, + profileUrl: true, + steamMemberSince: true, + }), + async (input) => + useTransaction(async (tx) => { + const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : undefined; + await tx + .update(steamTable) + .set({ + userID, + id: input.id, + name: input.name, + realName: input.realName, + profileUrl: input.profileUrl, + avatarHash: input.avatarHash, + limitations: input.limitations, + status: input.status ?? "offline", + username: input.username ?? "unknown", + steamMemberSince: input.steamMemberSince, + accountStatus: input.accountStatus ?? "new", + lastSyncedAt: input.lastSyncedAt ?? Common.utc(), + }) + .where(eq(steamTable.id, input.id)); + + await afterTx(async () => + bus.publish(Resource.Bus, Events.Updated, { userID: userID ?? null, steamID: input.id }) + ); + }) + ) + export const fromUserID = fn( - z.string(), + z.string().min(1), (userID) => useTransaction((tx) => tx .select() .from(steamTable) .where(and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted))) + .orderBy(desc(steamTable.timeCreated)) .execute() - .then((rows) => rows.map(serialize).at(0)), - ), + .then((rows) => rows.map(serialize)) + ) + ) + + export const fromSteamID = fn( + z.string(), + (steamID) => + useTransaction((tx) => + tx + .select() + .from(steamTable) + .where(and(eq(steamTable.id, steamID), isNull(steamTable.timeDeleted))) + .orderBy(desc(steamTable.timeCreated)) + .execute() + .then((rows) => rows.map(serialize).at(0)) + ) ) export const list = () => @@ -103,34 +226,28 @@ export namespace Steam { tx .select() .from(steamTable) - .where(and(eq(steamTable.userID, useUserID()), isNull(steamTable.timeDeleted))) + .where(and(eq(steamTable.userID, Actor.userID()), isNull(steamTable.timeDeleted))) + .orderBy(desc(steamTable.timeCreated)) .execute() - .then((rows) => rows.map(serialize)), + .then((rows) => rows.map(serialize)) ) - /** - * Serializes a raw Steam table record into a standardized Info object. - * - * This function maps the fields from a database record (retrieved from the Steam table) to the - * corresponding properties defined in the Info schema. - * - * @param input - A raw record from the Steam table containing user information. - * @returns An object conforming to the Info schema. - */ export function serialize( input: typeof steamTable.$inferSelect, ): z.infer { return { id: input.id, + name: input.name, userID: input.userID, - countryCode: input.countryCode, + status: input.status, username: input.username, - avatarUrl: input.avatarUrl, - personaName: input.personaName, - steamEmail: input.steamEmail, - steamID: input.steamID, - limitation: input.limitation, - lastGame: input.lastGame, + realName: input.realName, + avatarHash: input.avatarHash, + limitations: input.limitations, + lastSyncedAt: input.lastSyncedAt, + accountStatus: input.accountStatus, + steamMemberSince: input.steamMemberSince, + profileUrl: input.profileUrl ? `https://steamcommunity.com/id/${input.profileUrl}` : null, }; } diff --git a/packages/core/src/steam/steam.sql.ts b/packages/core/src/steam/steam.sql.ts index e9193871..7b04e515 100644 --- a/packages/core/src/steam/steam.sql.ts +++ b/packages/core/src/steam/steam.sql.ts @@ -1,45 +1,61 @@ import { z } from "zod"; import { userTable } from "../user/user.sql"; -import { id, timestamps, ulid, utc } from "../drizzle/types"; -import { index, pgTable, integer, uniqueIndex, varchar, text, json } from "drizzle-orm/pg-core"; +import { timestamps, ulid, utc } from "../drizzle/types"; +import { pgTable, varchar, text, bigint, pgEnum, json, unique } from "drizzle-orm/pg-core"; -export const LastGame = z.object({ - gameID: z.number(), - gameName: z.string() -}); +export const AccountStatusEnum = pgEnum("steam_account_status", ["new", "pending", "active"]) +export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"]) -export const AccountLimitation = z.object({ - isLimited: z.boolean().nullable(), - isBanned: z.boolean().nullable(), - isLocked: z.boolean().nullable(), - isAllowedToInviteFriends: z.boolean().nullable(), -}); +export const Limitations = z.object({ + isLimited: z.boolean(), + isTradeBanned: z.boolean(), + isVacBanned: z.boolean(), + visibilityState: z.number(), + privacyState: z.enum(["public", "private"]), +}) -export type LastGame = z.infer; -export type AccountLimitation = z.infer; +export type Limitations = z.infer; export const steamTable = pgTable( - "steam", + "steam_accounts", { - ...id, ...timestamps, + id: varchar("steam_id", { length: 255 }) + .primaryKey() + .notNull(), userID: ulid("user_id") - .notNull() .references(() => userTable.id, { onDelete: "cascade", }), - lastSeen: utc("last_seen").notNull(), - steamID: integer("steam_id").notNull(), - avatarUrl: text("avatar_url").notNull(), - lastGame: json("last_game").$type().notNull(), + status: StatusEnum("status").notNull(), + lastSyncedAt: utc("last_synced_at").notNull(), + steamMemberSince: utc("member_since").notNull(), + name: varchar("name", { length: 255 }).notNull(), + profileUrl: varchar("profileUrl", { length: 255 }), username: varchar("username", { length: 255 }).notNull(), - countryCode: varchar('country_code', { length: 2 }).notNull(), - steamEmail: varchar("steam_email", { length: 255 }).notNull(), - personaName: varchar("persona_name", { length: 255 }).notNull(), - limitation: json("limitation").$type().notNull(), + realName: varchar("real_name", { length: 255 }).notNull(), + accountStatus: AccountStatusEnum("account_status").notNull(), + avatarHash: varchar("avatar_hash", { length: 255 }).notNull(), + limitations: json("limitations").$type().notNull(), }, (table) => [ - uniqueIndex("steam_id").on(table.steamID), - index("steam_user_id").on(table.userID), - ], -); \ No newline at end of file + unique("idx_steam_username").on(table.username) + ] +); + +// export const steamCredentialsTable = pgTable( +// "steam_account_credentials", +// { +// ...timestamps, +// refreshToken: text("refresh_token") +// .notNull(), +// expiry: utc("expiry").notNull(), +// id: bigint("steam_id", { mode: "bigint" }) +// .notNull() +// .primaryKey() +// .references(() => steamTable.id, { +// onDelete: "cascade" +// }), +// username: varchar("username", { length: 255 }).notNull(), +// } +// ) \ No newline at end of file diff --git a/packages/core/src/subscription/index.ts b/packages/core/src/subscription/index.ts deleted file mode 100644 index 4deab2c6..00000000 --- a/packages/core/src/subscription/index.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { z } from "zod"; -import { Common } from "../common"; -import { Examples } from "../examples"; -import { createID, fn } from "../utils"; -import { eq, and, isNull } from "../drizzle"; -import { useTeam, useUserID } from "../actor"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; -import { PlanType, Standing, subscriptionTable } from "./subscription.sql"; - -export namespace Subscription { - export const Info = z.object({ - id: z.string().openapi({ - description: Common.IdDescription, - example: Examples.Subscription.id, - }), - polarSubscriptionID: z.string().nullable().or(z.undefined()).openapi({ - description: "The unique id of the plan this subscription is on", - example: Examples.Subscription.polarSubscriptionID, - }), - teamID: z.string().openapi({ - description: "The unique id of the team this subscription is for", - example: Examples.Subscription.teamID, - }), - userID: z.string().openapi({ - description: "The unique id of the user who is paying this subscription", - example: Examples.Subscription.userID, - }), - polarProductID: z.string().nullable().or(z.undefined()).openapi({ - description: "The unique id of the product this subscription is for", - example: Examples.Subscription.polarProductID, - }), - tokens: z.number().openapi({ - description: "The number of tokens this subscription has left", - example: Examples.Subscription.tokens, - }), - planType: z.enum(PlanType).openapi({ - description: "The type of plan this subscription is for", - example: Examples.Subscription.planType, - }), - standing: z.enum(Standing).openapi({ - description: "The standing of this subscription", - example: Examples.Subscription.standing, - }), - }).openapi({ - ref: "Subscription", - description: "Represents a subscription on Nestri", - example: Examples.Subscription - }); - - export type Info = z.infer; - - export const create = fn( - Info - .partial({ - teamID: true, - userID: true, - id: true, - standing: true, - planType: true, - polarProductID: true, - polarSubscriptionID: true, - }), - (input) => - createTransaction(async (tx) => { - const id = input.id ?? createID("subscription"); - - await tx.insert(subscriptionTable).values({ - id, - tokens: input.tokens, - polarProductID: input.polarProductID ?? null, - polarSubscriptionID: input.polarSubscriptionID ?? null, - standing: input.standing ?? "new", - planType: input.planType ?? "free", - userID: input.userID ?? useUserID(), - teamID: input.teamID ?? useTeam(), - }); - - return id; - }) - ) - - export const setPolarProductID = fn( - Info.pick({ - id: true, - polarProductID: true, - }), - (input) => - useTransaction(async (tx) => - tx.update(subscriptionTable) - .set({ - polarProductID: input.polarProductID, - }) - .where(eq(subscriptionTable.id, input.id)) - ) - ) - - export const setPolarSubscriptionID = fn( - Info.pick({ - id: true, - polarSubscriptionID: true, - }), - (input) => - useTransaction(async (tx) => - tx.update(subscriptionTable) - .set({ - polarSubscriptionID: input.polarSubscriptionID, - }) - .where(eq(subscriptionTable.id, input.id)) - ) - ) - - export const fromID = fn(z.string(), async (id) => - useTransaction(async (tx) => - tx - .select() - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.id, id), - isNull(subscriptionTable.timeDeleted) - ) - ) - .orderBy(subscriptionTable.timeCreated) - .then((rows) => rows.map(serialize)) - ) - ) - export const fromTeamID = fn(z.string(), async (teamID) => - useTransaction(async (tx) => - tx - .select() - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.teamID, teamID), - isNull(subscriptionTable.timeDeleted) - ) - ) - .orderBy(subscriptionTable.timeCreated) - .then((rows) => rows.map(serialize)) - ) - ) - - export const fromUserID = fn(z.string(), async (userID) => - useTransaction(async (tx) => - tx - .select() - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.userID, userID), - isNull(subscriptionTable.timeDeleted) - ) - ) - .orderBy(subscriptionTable.timeCreated) - .then((rows) => rows.map(serialize)) - ) - ) - export const remove = fn(Info.shape.id, (id) => - useTransaction(async (tx) => - tx - .update(subscriptionTable) - .set({ - timeDeleted: Common.now(), - }) - .where(eq(subscriptionTable.id, id)) - .execute() - ) - ) - - /** - * Converts a raw subscription database record into a structured {@link Info} object. - * - * @param input - The subscription record retrieved from the database. - * @returns The subscription data formatted according to the {@link Info} schema. - */ - export function serialize( - input: typeof subscriptionTable.$inferSelect - ): z.infer { - return { - id: input.id, - userID: input.userID, - teamID: input.teamID, - standing: input.standing, - planType: input.planType, - tokens: input.tokens, - polarProductID: input.polarProductID, - polarSubscriptionID: input.polarSubscriptionID, - }; - } - - -} \ No newline at end of file diff --git a/packages/core/src/subscription/subscription.sql.ts b/packages/core/src/subscription/subscription.sql.ts deleted file mode 100644 index a630d0e4..00000000 --- a/packages/core/src/subscription/subscription.sql.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { teamTable } from "../team/team.sql"; -import { ulid, userID, timestamps } from "../drizzle/types"; -import { index, integer, pgTable, primaryKey, text, uniqueIndex, varchar } from "drizzle-orm/pg-core"; - -export const Standing = ["new", "good", "overdue", "cancelled"] as const; -export const PlanType = ["free", "pro", "family", "enterprise"] as const; - -export const subscriptionTable = pgTable( - "subscription", - { - ...userID, - ...timestamps, - teamID: ulid("team_id") - .references(() => teamTable.id, { onDelete: "cascade" }) - .notNull(), - standing: text("standing", { enum: Standing }) - .notNull(), - planType: text("plan_type", { enum: PlanType }) - .notNull(), - tokens: integer("tokens").notNull(), - polarProductID: varchar("product_id", { length: 255 }), - polarSubscriptionID: varchar("subscription_id", { length: 255 }), - }, - (table) => [ - uniqueIndex("subscription_id").on(table.id), - index("subscription_user_id").on(table.userID), - primaryKey({ - columns: [table.id, table.teamID] - }), - ] -) \ No newline at end of file diff --git a/packages/core/src/task/task.sql.todo b/packages/core/src/task/task.sql.todo deleted file mode 100644 index 7454c89d..00000000 --- a/packages/core/src/task/task.sql.todo +++ /dev/null @@ -1,20 +0,0 @@ -import { id, timestamps } from "../drizzle/types"; -import { pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core"; - -//This represents a task created on a machine for running a game -//Add billing info here? -//Add who owns the task here -// Add the session ID here -//Add which machine owns this task - -export const taskTable = pgTable( - "task", - { - ...id, - ...timestamps, - fingerprint: varchar('fingerprint', { length: 32 }).notNull(), - }, - (table) => [ - uniqueIndex("task_fingerprint").on(table.fingerprint), - ], -); \ No newline at end of file diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index e8798922..dc6a50cd 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -1,18 +1,15 @@ import { z } from "zod"; +import { Steam } from "../steam"; +import { Actor } from "../actor"; import { Common } from "../common"; -import { Member } from "../member"; import { teamTable } from "./team.sql"; import { Examples } from "../examples"; -import { assertActor } from "../actor"; -import { createEvent } from "../event"; -import { createID, fn } from "../utils"; -import { Subscription } from "../subscription"; -import { and, eq, sql, isNull } from "../drizzle"; +import { and, eq, isNull } from "drizzle-orm"; +import { steamTable } from "../steam/steam.sql"; +import { createID, fn, Invite } from "../utils"; import { memberTable } from "../member/member.sql"; -import { ErrorCodes, VisibleError } from "../error"; -import { groupBy, map, pipe, values } from "remeda"; -import { subscriptionTable } from "../subscription/subscription.sql"; -import { createTransaction, useTransaction } from "../drizzle/transaction"; +import { groupBy, pipe, values, map } from "remeda"; +import { createTransaction, useTransaction, type Transaction } from "../drizzle/transaction"; export namespace Team { export const Info = z @@ -21,198 +18,144 @@ export namespace Team { description: Common.IdDescription, example: Examples.Team.id, }), - // Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this) - slug: z.string().regex(/^[a-z0-9\-]+$/, "Use a URL friendly name.").openapi({ - description: "The unique and url-friendly slug of this team", + slug: z.string().regex(/^[a-z0-9-]{1,32}$/, "Use a URL friendly name.").openapi({ + description: "URL-friendly unique username (lowercase alphanumeric with hyphens)", example: Examples.Team.slug }), name: z.string().openapi({ - description: "The name of this team", + description: "Display name of the team", example: Examples.Team.name }), - members: Member.Info.array().openapi({ - description: "The members of this team", + ownerID: z.string().openapi({ + description: "Unique identifier of the team owner", + example: Examples.Team.ownerID + }), + maxMembers: z.number().openapi({ + description: "Maximum allowed team members based on subscription tier", + example: Examples.Team.maxMembers + }), + inviteCode: z.string().openapi({ + description: "Unique invitation code used for adding new team members", + example: Examples.Team.inviteCode + }), + members: Steam.Info.array().openapi({ + description: "All the team members in this team", example: Examples.Team.members - }), - subscriptions: Subscription.Info.array().openapi({ - description: "The subscriptions of this team", - example: Examples.Team.subscriptions - }), + }) }) .openapi({ ref: "Team", - description: "Represents a team on Nestri", + description: "Team entity containing core team information and settings", example: Examples.Team, }); export type Info = z.infer; - export const Events = { - Created: createEvent( - "team.created", - z.object({ - teamID: z.string().nonempty(), - }), - ), - }; + /** + * Generates a unique team invite code + * @param length The length of the invite code + * @param maxAttempts Maximum number of attempts to generate a unique code + * @returns A promise resolving to a unique invite code + */ + async function createUniqueTeamInviteCode( + tx: Transaction, + length: number = 8, + maxAttempts: number = 5 + ): Promise { + let attempts = 0; - export class TeamExistsError extends VisibleError { - constructor(slug: string) { - super( - "already_exists", - ErrorCodes.Validation.TEAM_ALREADY_EXISTS, - `There is already a team named "${slug}"` - ); + while (attempts < maxAttempts) { + const code = Invite.generateCode(length); + + const teams = + await tx + .select() + .from(teamTable) + .where(eq(teamTable.inviteCode, code)) + .execute() + + if (teams.length === 0) { + return code; + } + + attempts++; } + + // If we've exceeded max attempts, add timestamp to ensure uniqueness + const timestampSuffix = Date.now().toString(36).slice(-4); + const baseCode = Invite.generateCode(length - 4); + return baseCode + timestampSuffix; } export const create = fn( - Info.pick({ slug: true, id: true, name: true, }).partial({ - id: true, - }), (input) => - createTransaction(async (tx) => { - const id = input.id ?? createID("team"); - const result = await tx.insert(teamTable).values({ - id, - slug: input.slug, - name: input.name + Info + .omit({ members: true }) + .partial({ + id: true, + inviteCode: true, + maxMembers: true, + ownerID: true + }), + async (input) => + createTransaction(async (tx) => { + const inviteCode = await createUniqueTeamInviteCode(tx) + const id = input.id ?? createID("team"); + await tx + .insert(teamTable) + .values({ + id, + inviteCode, + slug: input.slug, + name: input.name, + ownerID: input.ownerID ?? Actor.userID(), + maxMembers: input.maxMembers ?? 1, + }) + + return id; }) - .onConflictDoNothing({ target: teamTable.slug }) - - if (result.count === 0) throw new TeamExistsError(input.slug); - - return id; - }) ) - //TODO: "Delete" subscription and member(s) as well - export const remove = fn(Info.shape.id, (input) => - useTransaction(async (tx) => { - const account = assertActor("user"); - const row = await tx - .select({ - teamID: memberTable.teamID, - }) - .from(memberTable) - .where( - and( - eq(memberTable.teamID, input), - eq(memberTable.email, account.properties.email), - ), - ) - .execute() - .then((rows) => rows.at(0)); - if (!row) return; - await tx - .update(teamTable) - .set({ - timeDeleted: sql`now()`, - }) - .where(eq(teamTable.id, row.teamID)); - }), - ); - - export const list = fn(z.void(), () => { - const actor = assertActor("user"); - return useTransaction(async (tx) => + export const list = () => + useTransaction(async (tx) => tx - .select() + .select({ + steam_accounts: steamTable, + teams: teamTable + }) .from(teamTable) - .leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id)) .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) + .innerJoin(steamTable, eq(memberTable.steamID, steamTable.id)) .where( and( - eq(memberTable.email, actor.properties.email), + eq(memberTable.userID, Actor.userID()), isNull(memberTable.timeDeleted), + isNull(steamTable.timeDeleted), isNull(teamTable.timeDeleted), ), ) .execute() .then((rows) => serialize(rows)) ) - }); - export const fromID = fn(z.string().min(1), async (id) => - useTransaction(async (tx) => - tx - .select() - .from(teamTable) - .leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id)) - .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) - .where( - and( - eq(teamTable.id, id), - isNull(memberTable.timeDeleted), - isNull(teamTable.timeDeleted), - ), - ) - .execute() - .then((rows) => serialize(rows).at(0)) - ), - ); - - export const fromSlug = fn(z.string().min(1), async (slug) => - useTransaction(async (tx) => - tx - .select() - .from(teamTable) - .leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id)) - .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) - .where( - and( - eq(teamTable.slug, slug), - isNull(memberTable.timeDeleted), - isNull(teamTable.timeDeleted), - ), - ) - .execute() - .then((rows) => serialize(rows).at(0)) - ), - ); - - /** - * Transforms an array of team, subscription, and member records into structured team objects. - * - * Groups input rows by team ID and constructs an array of team objects, each including its associated members and subscriptions. - * - * @param input - Array of objects containing team, subscription, and member data. - * @returns An array of team objects with their members and subscriptions. - */ export function serialize( - input: { team: typeof teamTable.$inferSelect, subscription: typeof subscriptionTable.$inferInsert | null, member: typeof memberTable.$inferInsert | null }[], + input: { teams: typeof teamTable.$inferSelect; steam_accounts: typeof steamTable.$inferSelect | null }[] ): z.infer[] { - console.log("serialize", input) return pipe( input, - groupBy((row) => row.team.id), + groupBy((row) => row.teams.id), values(), map((group) => ({ - name: group[0].team.name, - id: group[0].team.id, - slug: group[0].team.slug, - subscriptions: !group[0].subscription ? - [] : - group.map((row) => ({ - planType: row.subscription!.planType, - polarProductID: row.subscription!.polarProductID, - polarSubscriptionID: row.subscription!.polarSubscriptionID, - standing: row.subscription!.standing, - tokens: row.subscription!.tokens, - teamID: row.subscription!.teamID, - userID: row.subscription!.userID, - id: row.subscription!.id, - })), + id: group[0].teams.id, + slug: group[0].teams.slug, + name: group[0].teams.name, + ownerID: group[0].teams.ownerID, + maxMembers: group[0].teams.maxMembers, + inviteCode: group[0].teams.inviteCode, members: - !group[0].member ? + !group[0].steam_accounts ? [] : - group.map((row) => ({ - id: row.member!.id, - email: row.member!.email, - role: row.member!.role, - teamID: row.member!.teamID, - timeSeen: row.member!.timeSeen, - })) + group.map((item) => Steam.serialize(item.steam_accounts!)) })), - ); + ) } } \ No newline at end of file diff --git a/packages/core/src/team/team.sql.ts b/packages/core/src/team/team.sql.ts index 1281817d..37d55105 100644 --- a/packages/core/src/team/team.sql.ts +++ b/packages/core/src/team/team.sql.ts @@ -1,28 +1,35 @@ -import { timestamps, id } from "../drizzle/types"; +import { timestamps, id, ulid } from "../drizzle/types"; import { varchar, pgTable, - primaryKey, + bigint, + unique, uniqueIndex, } from "drizzle-orm/pg-core"; +import { userTable } from "../user/user.sql"; +import { steamTable } from "../steam/steam.sql"; export const teamTable = pgTable( - "team", + "teams", { ...id, ...timestamps, name: varchar("name", { length: 255 }).notNull(), - slug: varchar("slug", { length: 255 }).notNull(), + ownerID: ulid("owner_id") + .notNull() + .references(() => userTable.id, { + onDelete: "cascade" + }), + inviteCode: varchar("invite_code", { length: 10 }).notNull(), + slug: varchar("slug", { length: 255 }) + .notNull() + .references(() => steamTable.username, { + onDelete: "cascade" + }), + maxMembers: bigint("max_members", { mode: "number" }).notNull(), }, - (table) => [ - uniqueIndex("slug").on(table.slug) - ], -); - -export function teamIndexes(table: any) { - return [ - primaryKey({ - columns: [table.teamID, table.id], - }), - ]; -} \ No newline at end of file + (team) => [ + uniqueIndex("idx_team_slug").on(team.slug), + unique("idx_team_invite_code").on(team.inviteCode) + ] +); \ No newline at end of file diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index 362742b2..1d2771dd 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -1,66 +1,62 @@ import { z } from "zod"; -import { Team } from "../team"; +import { Resource } from "sst"; import { bus } from "sst/aws/bus"; -import { Steam } from "../steam"; import { Common } from "../common"; +import { createEvent } from "../event"; import { Polar } from "../polar/index"; import { createID, fn } from "../utils"; import { userTable } from "./user.sql"; -import { createEvent } from "../event"; import { Examples } from "../examples"; -import { Resource } from "sst/resource"; -import { teamTable } from "../team/team.sql"; -import { steamTable } from "../steam/steam.sql"; -import { assertActor, withActor } from "../actor"; -import { memberTable } from "../member/member.sql"; -import { pipe, groupBy, values, map } from "remeda"; -import { and, eq, isNull, asc, sql } from "../drizzle"; -import { subscriptionTable } from "../subscription/subscription.sql"; +import { and, eq, isNull, asc} from "drizzle-orm"; +import { ErrorCodes, VisibleError } from "../error"; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; - export namespace User { - const MAX_ATTEMPTS = 50; - export const Info = z .object({ id: z.string().openapi({ description: Common.IdDescription, example: Examples.User.id, }), - name: z.string().openapi({ - description: "The user's unique username", - example: Examples.User.name, + name: z.string().regex(/^[a-zA-Z ]{1,32}$/, "Use a friendly name.").openapi({ + description: "The name of this account", + example: Examples.User.name }), - polarCustomerID: z.string().or(z.null()).openapi({ - description: "The polar customer id for this user", + polarCustomerID: z.string().nullable().openapi({ + description: "Associated Polar.sh customer identifier", example: Examples.User.polarCustomerID, }), + avatarUrl: z.string().url().nullable().openapi({ + description: "The url to the profile picture", + example: Examples.User.avatarUrl + }), email: z.string().openapi({ - description: "The email address of this user", + description: "Primary email address for user notifications and authentication", example: Examples.User.email, }), - avatarUrl: z.string().or(z.null()).openapi({ - description: "The url to the profile picture.", - example: Examples.User.name, - }), - discriminator: z.string().or(z.number()).openapi({ - description: "The (number) discriminator for this user", - example: Examples.User.discriminator, - }), - steamAccounts: Steam.Info.array().openapi({ - description: "The steam accounts for this user", - example: Examples.User.steamAccounts, - }), + lastLogin: z.date().openapi({ + description: "Timestamp of user's most recent authentication", + example: Examples.User.lastLogin + }) }) .openapi({ ref: "User", - description: "Represents a user on Nestri", + description: "User account entity with core identification and authentication details", example: Examples.User, }); export type Info = z.infer; + export class UserExistsError extends VisibleError { + constructor(username: string) { + super( + "already_exists", + ErrorCodes.Validation.ALREADY_EXISTS, + `A user with this email ${username} already exists` + ); + } + } + export const Events = { Created: createEvent( "user.created", @@ -68,187 +64,129 @@ export namespace User { userID: Info.shape.id, }), ), - Updated: createEvent( - "user.updated", - z.object({ - userID: Info.shape.id, + }; + + export const create = fn( + Info + .omit({ + lastLogin: true, + polarCustomerID: true, + }).partial({ + avatarUrl: true, + id: true }), - ), - }; + async (input) => { + const userID = createID("user") - export const sanitizeUsername = (username: string): string => { - // Remove spaces and numbers - return username.replace(/[\s0-9]/g, ''); - }; + const customer = await Polar.fromUserEmail(input.email) - export const generateDiscriminator = (): string => { - return Math.floor(Math.random() * 100).toString().padStart(2, '0'); - }; + const id = input.id ?? userID; - export const isValidDiscriminator = (discriminator: string): boolean => { - return /^\d{2}$/.test(discriminator); - }; + await createTransaction(async (tx) => { + const result = await tx + .insert(userTable) + .values({ + id, + avatarUrl: input.avatarUrl, + email: input.email, + name: input.name, + polarCustomerID: customer?.id, + lastLogin: Common.utc() + }) + .onConflictDoNothing({ + target: [userTable.email] + }) - export const findAvailableDiscriminator = fn(z.string(), async (input) => { - const username = sanitizeUsername(input); + if (result.count === 0) { + throw new UserExistsError(input.email) + } - for (let i = 0; i < MAX_ATTEMPTS; i++) { - const discriminator = generateDiscriminator(); + await afterTx(async () => + bus.publish(Resource.Bus, Events.Created, { userID: id }) + ); + }) - const users = await useTransaction(async (tx) => + return id; + }) + + export const fromEmail = fn( + Info.shape.email.min(1), + async (email) => + useTransaction(async (tx) => tx .select() .from(userTable) - .where(and(eq(userTable.name, username), eq(userTable.discriminator, Number(discriminator)))) + .where( + and( + eq(userTable.email, email), + isNull(userTable.timeDeleted) + ) + ) + .orderBy(asc(userTable.timeCreated)) + .execute() + .then(rows => rows.map(serialize).at(0)) ) - - if (users.length === 0) { - return discriminator; - } - } - - return null; - }) - - export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true, steamAccounts: true }).partial({ avatarUrl: true, id: true }), async (input) => { - const userID = createID("user") - - //FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake - - const customer = await Polar.fromUserEmail(input.email) - console.log("customer", customer) - - const name = sanitizeUsername(input.name); - - // Generate a random available discriminator - const discriminator = await findAvailableDiscriminator(name); - - if (!discriminator) { - console.error("No available discriminators for this username ") - return null - } - - createTransaction(async (tx) => { - const id = input.id ?? userID; - await tx.insert(userTable).values({ - id, - name: input.name, - avatarUrl: input.avatarUrl, - email: input.email, - discriminator: Number(discriminator), - polarCustomerID: customer?.id - }) - await afterTx(() => - withActor({ - type: "user", - properties: { - userID: id, - email: input.email - }, - }, - async () => bus.publish(Resource.Bus, Events.Created, { userID: id }), - ) - ); - }) - - return userID; - }) - - export const fromEmail = fn(z.string(), async (email) => - useTransaction(async (tx) => - tx - .select() - .from(userTable) - .leftJoin(steamTable, eq(userTable.id, steamTable.userID)) - .where(and(eq(userTable.email, email), isNull(userTable.timeDeleted))) - .orderBy(asc(userTable.timeCreated)) - .then((rows => serialize(rows).at(0))) - ) ) - export const fromID = fn(z.string(), (id) => - useTransaction(async (tx) => - tx - .select() - .from(userTable) - .leftJoin(steamTable, eq(userTable.id, steamTable.userID)) - .where(and(eq(userTable.id, id), isNull(userTable.timeDeleted), isNull(steamTable.timeDeleted))) - .orderBy(asc(userTable.timeCreated)) - .then((rows) => serialize(rows).at(0)) - ), + export const fromID = fn( + Info.shape.id.min(1), + (id) => + useTransaction(async (tx) => + tx + .select() + .from(userTable) + .where( + and( + eq(userTable.id, id), + isNull(userTable.timeDeleted) + ) + ) + .orderBy(asc(userTable.timeCreated)) + .execute() + .then(rows => rows.map(serialize).at(0)) + ), ) - export const remove = fn(Info.shape.id, (id) => - useTransaction(async (tx) => { - await tx - .update(userTable) - .set({ - timeDeleted: sql`now()`, - }) - .where(and(eq(userTable.id, id))) - .execute(); - return id; - }), + export const remove = fn( + Info.shape.id.min(1), + (id) => + useTransaction(async (tx) => { + await tx + .update(userTable) + .set({ + timeDeleted: Common.utc(), + }) + .where(and(eq(userTable.id, id))) + .execute(); + return id; + }), ); - /** - * Converts an array of user and Steam account records into structured user objects with associated Steam accounts. - * - * @param input - An array of objects containing user data and optional Steam account data. - * @returns An array of user objects, each including a list of their associated Steam accounts. - */ - export function serialize( - input: { user: typeof userTable.$inferSelect; steam: typeof steamTable.$inferSelect | null }[], - ): z.infer[] { - return pipe( - input, - groupBy((row) => row.user.id), - values(), - map((group) => ({ - ...group[0].user, - steamAccounts: !group[0].steam ? - [] : - group.map((row) => ({ - id: row.steam!.id, - lastSeen: row.steam!.lastSeen, - countryCode: row.steam!.countryCode, - username: row.steam!.username, - steamID: row.steam!.steamID, - lastGame: row.steam!.lastGame, - limitation: row.steam!.limitation, - steamEmail: row.steam!.steamEmail, - userID: row.steam!.userID, - personaName: row.steam!.personaName, - avatarUrl: row.steam!.avatarUrl, - })), - })), - ) - } + export const acknowledgeLogin = fn( + Info.shape.id, + (id) => + useTransaction(async (tx) => + tx + .update(userTable) + .set({ + lastLogin: Common.utc(), + }) + .where(and(eq(userTable.id, id))) + .execute() - /** - * Retrieves the list of teams that the current user belongs to. - * - * @returns An array of team information objects representing the user's active team memberships. - * - * @remark Only teams and memberships that have not been deleted are included in the result. - */ - export function teams() { - const actor = assertActor("user"); - return useTransaction(async (tx) => - tx - .select() - .from(teamTable) - .leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id)) - .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) - .where( - and( - eq(memberTable.email, actor.properties.email), - isNull(memberTable.timeDeleted), - isNull(teamTable.timeDeleted), - ), - ) - .execute() - .then((rows) => Team.serialize(rows)) - ) + ), + ) + + export function serialize( + input: typeof userTable.$inferSelect + ): z.infer { + return { + id: input.id, + name: input.name, + email: input.email, + avatarUrl: input.avatarUrl, + lastLogin: input.lastLogin, + polarCustomerID: input.polarCustomerID, + } } } \ No newline at end of file diff --git a/packages/core/src/user/user.sql.ts b/packages/core/src/user/user.sql.ts index 51204e13..d45cb10e 100644 --- a/packages/core/src/user/user.sql.ts +++ b/packages/core/src/user/user.sql.ts @@ -1,27 +1,18 @@ -import { z } from "zod"; -import { id, timestamps } from "../drizzle/types"; -import { integer, pgTable, text, uniqueIndex, varchar, json } from "drizzle-orm/pg-core"; - -// Whether this user is part of the Nestri Team, comes with privileges -export const UserFlags = z.object({ - team: z.boolean().optional(), -}); - -export type UserFlags = z.infer; +import { id, timestamps, utc } from "../drizzle/types"; +import { pgTable, text, unique, varchar } from "drizzle-orm/pg-core"; export const userTable = pgTable( - "user", + "users", { ...id, ...timestamps, - avatarUrl: text("avatar_url"), - name: varchar("name", { length: 255 }).notNull(), - discriminator: integer("discriminator").notNull(), email: varchar("email", { length: 255 }).notNull(), - polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(), - // flags: json("flags").$type().default({}), + avatarUrl: text("avatar_url"), + lastLogin: utc("last_login").notNull(), + name: varchar("name", { length: 255 }).notNull(), + polarCustomerID: varchar("polar_customer_id", { length: 255 }), }, (user) => [ - uniqueIndex("user_email").on(user.email), + unique("idx_user_email").on(user.email), ] ); \ No newline at end of file diff --git a/packages/core/src/utils/id.ts b/packages/core/src/utils/id.ts index 09b9832f..6fcf8c80 100644 --- a/packages/core/src/utils/id.ts +++ b/packages/core/src/utils/id.ts @@ -3,13 +3,18 @@ import { ulid } from "ulid"; export const prefixes = { user: "usr", team: "tem", - task: "tsk", + product: "prd", + session: "ses", machine: "mch", member: "mbr", - steam: "stm", + variant: "var", + gpu: "gpu", + game: "gme", + usage: "usg", subscription: "sub", - invite: "inv", - product: "prd", + // task: "tsk", + // invite: "inv", + // product: "prd", } as const; /** diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 16ec78f3..3a4511b3 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,2 +1,4 @@ export * from "./fn" -export * from "./id" \ No newline at end of file +export * from "./log" +export * from "./id" +export * from "./invite" \ No newline at end of file diff --git a/packages/core/src/utils/invite.ts b/packages/core/src/utils/invite.ts new file mode 100644 index 00000000..b781b977 --- /dev/null +++ b/packages/core/src/utils/invite.ts @@ -0,0 +1,32 @@ +export namespace Invite { + /** + * Generates a random invite code for teams + * @param length The length of the invite code (default: 8) + * @returns A string containing alphanumeric characters (excluding confusing characters) + */ + export function generateCode(length: number = 8): string { + // Use only unambiguous characters (no 0/O, 1/l/I confusion) + const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let result = ''; + + // Create a Uint32Array of the required length for randomness + const randomValues = new Uint32Array(length); + + // Fill with cryptographically strong random values if available + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(randomValues); + } else { + // Fallback for environments without crypto + for (let i = 0; i < length; i++) { + randomValues[i] = Math.floor(Math.random() * 2 ** 32); + } + } + + // Use the random values to select characters + for (let i = 0; i < length; i++) { + result += characters.charAt(randomValues[i] % characters.length); + } + + return result; + } +} \ No newline at end of file diff --git a/packages/core/src/utils/log.ts b/packages/core/src/utils/log.ts new file mode 100644 index 00000000..a4619bbb --- /dev/null +++ b/packages/core/src/utils/log.ts @@ -0,0 +1,76 @@ +import { createContext } from "../context"; + +export namespace Log { + const ctx = createContext<{ + tags: Record; + }>(); + + export function create(tags?: Record) { + tags = tags || {}; + + const result = { + info(msg: string, extra?: Record) { + const prefix = Object.entries({ + ...use().tags, + ...tags, + ...extra, + }) + .map(([key, value]) => `${key}=${value}`) + .join(" "); + console.log(prefix, msg); + return result; + }, + warn(msg: string, extra?: Record) { + const prefix = Object.entries({ + ...use().tags, + ...tags, + ...extra, + }) + .map(([key, value]) => `${key}=${value}`) + .join(" "); + console.warn(prefix, msg); + return result; + }, + error(error: Error) { + const prefix = Object.entries({ + ...use().tags, + ...tags, + }) + .map(([key, value]) => `${key}=${value}`) + .join(" "); + console.error(prefix, error); + return result; + }, + tag(key: string, value: string) { + // Immutable update: return a fresh logger with updated tags + return Log.create({ ...tags, [key]: value }); + }, + clone() { + return Log.create({ ...tags }); + }, + }; + + return result; + } + + export function provide(tags: Record, cb: () => R) { + const existing = use(); + return ctx.provide( + { + tags: { + ...existing.tags, + ...tags, + }, + }, + cb, + ); + } + + function use() { + try { + return ctx.use(); + } catch (e) { + return { tags: {} }; + } + } +} \ No newline at end of file diff --git a/packages/functions/src/api/account.ts b/packages/functions/src/api/account.ts index 5fafa8ea..e213b468 100644 --- a/packages/functions/src/api/account.ts +++ b/packages/functions/src/api/account.ts @@ -1,13 +1,9 @@ -import { z } from "zod"; import { Hono } from "hono"; -import { notPublic } from "./utils/auth"; +import { notPublic } from "./utils"; import { describeRoute } from "hono-openapi"; -import { User } from "@nestri/core/user/index"; -import { Team } from "@nestri/core/team/index"; -import { assertActor } from "@nestri/core/actor"; import { Examples } from "@nestri/core/examples"; import { ErrorResponses, Result } from "./utils"; -import { ErrorCodes, VisibleError } from "@nestri/core/error"; +import { Account } from "@nestri/core/account/index"; export namespace AccountApi { export const route = new Hono() @@ -22,10 +18,7 @@ export namespace AccountApi { content: { "application/json": { schema: Result( - z.object({ - ...User.Info.shape, - teams: Team.Info.array(), - }).openapi({ + Account.Info.openapi({ description: "User account information", example: { ...Examples.User, teams: [Examples.Team] } }) @@ -34,27 +27,14 @@ export namespace AccountApi { }, description: "User account details" }, + 400: ErrorResponses[400], 404: ErrorResponses[404], - 429: ErrorResponses[429] + 429: ErrorResponses[429], } }), - async (c) => { - const actor = assertActor("user"); - const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()]) - - if (!currentUser) - throw new VisibleError( - "not_found", - ErrorCodes.NotFound.RESOURCE_NOT_FOUND, - "User not found", - ); - - return c.json({ - data: { - ...currentUser, - teams, - } - }, 200); - }, + async (c) => + c.json({ + data: await Account.list() + }, 200) ) } \ No newline at end of file diff --git a/packages/functions/src/api/index.ts b/packages/functions/src/api/index.ts index 7756c781..07a8b4b9 100644 --- a/packages/functions/src/api/index.ts +++ b/packages/functions/src/api/index.ts @@ -9,6 +9,8 @@ import { patchLogger } from "../utils/patch-logger"; import { HTTPException } from "hono/http-exception"; import { ErrorCodes, VisibleError } from "@nestri/core/error"; +patchLogger(); + export const app = new Hono(); app .use(logger()) @@ -85,8 +87,6 @@ app.get( }), ); -patchLogger(); - export default { port: 3001, idleTimeout: 255, diff --git a/packages/functions/src/api/utils/auth.ts b/packages/functions/src/api/utils/auth.ts index 5d0ac6ea..796a5c76 100644 --- a/packages/functions/src/api/utils/auth.ts +++ b/packages/functions/src/api/utils/auth.ts @@ -1,7 +1,7 @@ import { Resource } from "sst"; import { subjects } from "../../subjects"; +import { Actor } from "@nestri/core/actor"; import { type MiddlewareHandler } from "hono"; -import { useActor, withActor } from "@nestri/core/actor"; import { createClient } from "@openauthjs/openauth/client"; import { ErrorCodes, VisibleError } from "@nestri/core/error"; @@ -11,7 +11,7 @@ const client = createClient({ }); export const notPublic: MiddlewareHandler = async (c, next) => { - const actor = useActor(); + const actor = Actor.use(); if (actor.type === "public") throw new VisibleError( "authentication", @@ -22,9 +22,8 @@ export const notPublic: MiddlewareHandler = async (c, next) => { }; export const auth: MiddlewareHandler = async (c, next) => { - const authHeader = - c.req.query("authorization") ?? c.req.header("authorization"); - if (!authHeader) return withActor({ type: "public", properties: {} }, next); + const authHeader = c.req.header("authorization"); + if (!authHeader) return Actor.provide("public", {}, next); const match = authHeader.match(/^Bearer (.+)$/); if (!match) { throw new VisibleError( @@ -44,20 +43,24 @@ export const auth: MiddlewareHandler = async (c, next) => { } if (result.subject.type === "user") { + const user = { ...result.subject.properties } const teamID = c.req.header("x-nestri-team"); - if (!teamID) return withActor(result.subject, next); - return withActor( + if (!teamID) { + return Actor.provide("user", { + ...user + }, next); + } + return Actor.provide( + "system", { - type: "system", - properties: { - teamID, - }, + teamID }, async () => - withActor( - result.subject, - next, - ) + Actor.provide("user", { + ...user + }, next) ); } + + return Actor.provide("public", {}, next); }; \ No newline at end of file diff --git a/packages/functions/src/api/utils/index.ts b/packages/functions/src/api/utils/index.ts index 40ff5bde..c8d1cd70 100644 --- a/packages/functions/src/api/utils/index.ts +++ b/packages/functions/src/api/utils/index.ts @@ -1,3 +1,4 @@ -export * from "./validator"; +export * from "./auth"; +export * from "./error"; export * from "./result"; -export * from "./error"; \ No newline at end of file +export * from "./validator"; \ No newline at end of file diff --git a/packages/functions/src/auth/index.ts b/packages/functions/src/auth/index.ts index 242c25b2..4ca9d78e 100644 --- a/packages/functions/src/auth/index.ts +++ b/packages/functions/src/auth/index.ts @@ -1,40 +1,22 @@ import { Resource } from "sst" +import { type Env } from "hono"; +import { PasswordUI } from "./ui"; import { logger } from "hono/logger"; import { subjects } from "../subjects" -import { Select, PasswordUI } from "./ui"; import { issuer } from "@openauthjs/openauth"; import { User } from "@nestri/core/user/index" -// import { Email } from "@nestri/core/email/index"; -// import { Machine } from "@nestri/core/machine/index" +import { Email } from "@nestri/core/email/index"; import { patchLogger } from "../utils/patch-logger"; import { handleDiscord, handleGithub } from "./utils"; import { MemoryStorage } from "@openauthjs/openauth/storage/memory"; -// import { type Provider } from "@openauthjs/openauth/provider/provider" import { DiscordAdapter, PasswordAdapter, GithubAdapter } from "./adapters"; -type OauthUser = { - primary: { - email: any; - primary: any; - verified: any; - }; - avatar: any; - username: any; -} - -console.log("STORAGE", process.env.STORAGE) +patchLogger(); const app = issuer({ - select: Select({ - providers: { - machine: { - hide: true - } - } - }), - //TODO: Create our own Storage + //TODO: Create our own Storage (?) storage: MemoryStorage({ - persist: process.env.STORAGE //"/tmp/persist.json", + persist: process.env.STORAGE }), theme: { title: "Nestri | Auth", @@ -67,35 +49,20 @@ const app = issuer({ password: PasswordAdapter( PasswordUI({ sendCode: async (email, code) => { - console.log("email & code:", email, code) - // await Email.send( - // "auth", - // email, - // `Nestri code: ${code}`, - // `Your Nestri login code is ${code}`, - // ) + // Do not debug show code in production + if (Resource.App.stage != "production") { + console.log("email & code:", email, code) + } + await Email.send( + "auth", + email, + `Nestri code: ${code}`, + `Your Nestri login code is ${code}`, + ) }, }), ), - // machine: { - // type: "machine", - // async client(input) { - // // FIXME: Do we really need this? - // // if (input.clientSecret !== Resource.AuthFingerprintKey.value) { - // // throw new Error("Invalid authorization token"); - // // } - // const fingerprint = input.params.fingerprint; - // if (!fingerprint) { - // throw new Error("Hostname is required"); - // } - - // return { - // fingerprint, - // }; - // }, - // init() { } - // } as Provider<{ fingerprint: string; }>, }, allow: async (input) => { const url = new URL(input.redirectURI); @@ -105,48 +72,6 @@ const app = issuer({ return false; }, success: async (ctx, value, req) => { - // I dunno what i broke... will check later - // if (value.provider === "machine") { - // const countryCode = req.headers.get('CloudFront-Viewer-Country') || 'Unknown' - // const country = req.headers.get('CloudFront-Viewer-Country-Name') || 'Unknown' - // const latitude = Number(req.headers.get('CloudFront-Viewer-Latitude')) || 0 - // const longitude = Number(req.headers.get('CloudFront-Viewer-Longitude')) || 0 - // const timezone = req.headers.get('CloudFront-Viewer-Time-Zone') || 'Unknown' - // const fingerprint = value.fingerprint - - // const existing = await Machine.fromFingerprint(fingerprint) - // if (!existing) { - // const machineID = await Machine.create({ - // countryCode, - // country, - // fingerprint, - // timezone, - // location: { - // latitude, - // longitude - // }, - // //FIXME: Make this better - // // userID: null - // }) - // return ctx.subject("machine", { - // machineID, - // fingerprint - // }); - // } - - // return ctx.subject("machine", { - // machineID: existing.id, - // fingerprint - // }); - // } - - // TODO: This works, so use this while registering the task - // console.log("country_code", req.headers.get('CloudFront-Viewer-Country')) - // console.log("country_name", req.headers.get('CloudFront-Viewer-Country-Name')) - // console.log("latitude", req.headers.get('CloudFront-Viewer-Latitude')) - // console.log("longitude", req.headers.get('CloudFront-Viewer-Longitude')) - // console.log("timezone", req.headers.get('CloudFront-Viewer-Time-Zone')) - if (value.provider === "password") { const email = value.email const username = value.username @@ -165,21 +90,23 @@ const app = issuer({ userID, email }, { - subject: email + subject: userID }); } else if (matching) { + await User.acknowledgeLogin(matching.id) + //Sign In return ctx.subject("user", { userID: matching.id, email }, { - subject: email + subject: matching.id }); } } - let user = undefined as OauthUser | undefined; + let user; if (value.provider === "github") { const access = value.tokenset.access; @@ -200,7 +127,7 @@ const app = issuer({ const userID = await User.create({ email: user.primary.email, name: user.username, - avatarUrl: user.avatar + avatarUrl: user.avatar, }); if (!userID) throw new Error("Error creating user"); @@ -209,15 +136,17 @@ const app = issuer({ userID, email: user.primary.email }, { - subject: user.primary.email + subject: userID }); } else { + await User.acknowledgeLogin(matching.id) + //Sign In return await ctx.subject("user", { userID: matching.id, email: user.primary.email }, { - subject: user.primary.email + subject: matching.id }); } @@ -231,13 +160,12 @@ const app = issuer({ }, }).use(logger()) -patchLogger(); export default { port: 3002, idleTimeout: 255, - fetch: (req: Request) => - app.fetch(req, undefined, { + fetch: (req: Request, env: Env) => + app.fetch(req, env, { waitUntil: (fn) => fn, passThroughOnException: () => { }, }), diff --git a/packages/functions/src/auth/utils/discord.ts b/packages/functions/src/auth/utils/discord.ts index 25f7daa4..d6ca09b1 100644 --- a/packages/functions/src/auth/utils/discord.ts +++ b/packages/functions/src/auth/utils/discord.ts @@ -14,7 +14,7 @@ export const handleDiscord = async (accessKey: string) => { } const user = await response.json(); - // console.log("raw user", user) + if (!user.verified) { throw new Error("Email not verified"); } @@ -28,7 +28,7 @@ export const handleDiscord = async (accessKey: string) => { avatar: user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` : null, - username: user.global_name ?? user.username + username: user.global_name ?? user.username, }; } catch (error) { console.error('Discord OAuth error:', error); diff --git a/packages/functions/src/auth/utils/github.ts b/packages/functions/src/auth/utils/github.ts index af689bd7..81b655f9 100644 --- a/packages/functions/src/auth/utils/github.ts +++ b/packages/functions/src/auth/utils/github.ts @@ -1,8 +1,6 @@ import fetch from "node-fetch"; export const handleGithub = async (accessKey: string) => { - console.log("acceskey", accessKey) - const headers = { Authorization: `token ${accessKey}`, Accept: "application/vnd.github.v3+json", @@ -33,7 +31,7 @@ export const handleGithub = async (accessKey: string) => { return { primary: { email, primary, verified }, avatar: user.avatar_url, - username: user.name ?? user.login + username: user.name ?? user.login, }; } catch (error) { console.error('GitHub OAuth error:', error); diff --git a/packages/functions/src/subjects.ts b/packages/functions/src/subjects.ts index 040e61eb..c2bab322 100644 --- a/packages/functions/src/subjects.ts +++ b/packages/functions/src/subjects.ts @@ -5,9 +5,5 @@ export const subjects = createSubjects({ user: z.object({ email: z.string(), userID: z.string(), - }), - machine: z.object({ - fingerprint: z.string(), - machineID: z.string(), }) }) \ No newline at end of file diff --git a/packages/functions/src/utils/patch-logger.ts b/packages/functions/src/utils/patch-logger.ts index dd2a2049..0c451481 100644 --- a/packages/functions/src/utils/patch-logger.ts +++ b/packages/functions/src/utils/patch-logger.ts @@ -15,9 +15,12 @@ export function patchLogger() { const log = (level: "INFO" | "WARN" | "TRACE" | "DEBUG" | "ERROR") => (msg: string, ...rest: any[]) => { - let line = `${level}\t${format(msg, ...rest)}`; - line = line.replace(/\n/g, "\r"); - process.stdout.write(line + "\n"); + let formattedMessage = format(msg, ...rest); + // Split by newlines, prefix each line with the level, and join back + const lines = formattedMessage.split('\n'); + const prefixedLines = lines.map(line => `${level}\t${line}`); + const output = prefixedLines.join('\n'); + process.stdout.write(output + '\n'); }; console.log = log("INFO"); console.warn = log("WARN");