feat(www): Add logic to the homepage and Steam integration (#258)

## Description
<!-- Briefly describe the purpose and scope of your changes -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Upgraded API and authentication services with dynamic scaling,
enhanced load balancing, and real-time interaction endpoints.
- Introduced new commands to streamline local development and container
builds.
- Added new endpoints for retrieving Steam account information and
managing connections.
- Implemented a QR code authentication interface for Steam, enhancing
user login experiences.

- **Database Updates**
- Rolled out comprehensive schema migrations that improve data integrity
and indexing.
- Introduced new tables for managing Steam user credentials and machine
information.

- **UI Enhancements**
- Added refreshed animated assets and an improved QR code login flow for
a more engaging experience.
	- Introduced new styled components for displaying friends and games.

- **Maintenance**
- Completed extensive refactoring and configuration updates to optimize
performance and development workflows.
- Updated logging configurations and improved error handling mechanisms.
	- Streamlined resource definitions in the configuration files.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Wanjohi
2025-04-13 14:30:45 +03:00
committed by GitHub
parent 8394bb4259
commit f408ec56cb
103 changed files with 12755 additions and 2053 deletions

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { eq } from "./drizzle";
import { VisibleError } from "./error";
import { ErrorCodes, VisibleError } from "./error";
import { createContext } from "./context";
import { UserFlags, userTable } from "./user/user.sql";
import { useTransaction } from "./drizzle/transaction";
@@ -60,11 +60,42 @@ export const ActorContext = createContext<Actor>("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(
"unauthorized",
"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`,
);
}
@@ -90,6 +121,17 @@ export function useMachine() {
throw new Error(`Expected actor to have fingerprint`);
}
/**
* Asserts that the current user possesses the specified flag.
*
* This function executes a database transaction that queries the user table for the current user's flags.
* If the flags are missing, it throws a {@link VisibleError} with the code {@link ErrorCodes.Validation.MISSING_REQUIRED_FIELD}
* and a message indicating that the required flag is absent.
*
* @param flag - The name of the user flag to verify.
*
* @throws {VisibleError} If the user's flag is missing.
*/
export async function assertUserFlag(flag: keyof UserFlags) {
return useTransaction((tx) =>
tx
@@ -100,7 +142,8 @@ export async function assertUserFlag(flag: keyof UserFlags) {
const flags = rows[0]?.flags;
if (!flags)
throw new VisibleError(
"user.flags",
"not_found",
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
"Actor does not have " + flag + " flag",
);
}),

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import "zod-openapi/extend";
export module Common {
export namespace Common {
export const IdDescription = `Unique object identifier.
The format and length of IDs may change over time.`;
}

View File

@@ -17,6 +17,15 @@ export const teamID = {
},
};
export const userID = {
get id() {
return ulid("id").notNull();
},
get userID() {
return ulid("user_id").notNull();
},
};
export const utc = (name: string) =>
rawTs(name, {
withTimezone: true,

View File

@@ -1,5 +1,5 @@
import { prefixes } from "./utils";
export module Examples {
export namespace Examples {
export const Id = (prefix: keyof typeof prefixes) =>
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
@@ -31,8 +31,30 @@ export module Examples {
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
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 Machine = {
id: Id("machine"),
userID: Id("user"),
country: "Kenya",
countryCode: "KE",
timezone: "Africa/Nairobi",

View File

@@ -6,13 +6,17 @@ import { machineTable } from "./machine.sql";
import { getTableColumns, eq, sql, and, isNull } from "../drizzle";
import { createTransaction, useTransaction } from "../drizzle/transaction";
export module Machine {
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
@@ -42,7 +46,7 @@ export module Machine {
export type Info = z.infer<typeof Info>;
export const create = fn(Info.partial({ id: true }), async (input) =>
export const create = fn(Info.partial({ id: true }), async (input) =>
createTransaction(async (tx) => {
const id = input.id ?? createID("machine");
await tx.insert(machineTable).values({
@@ -51,6 +55,7 @@ export module Machine {
timezone: input.timezone,
fingerprint: input.fingerprint,
countryCode: input.countryCode,
userID: input.userID,
location: { x: input.location.longitude, y: input.location.latitude },
})
@@ -63,12 +68,23 @@ export module Machine {
})
)
export const list = fn(z.void(), async () =>
useTransaction(async (tx) =>
export const fromUserID = fn(z.string(), async (userID) =>
useTransaction(async (tx) =>
tx
.select()
.from(machineTable)
.where(isNull(machineTable.timeDeleted))
.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))
)
)
@@ -116,7 +132,7 @@ export module Machine {
distance: sql`round((${sqlDistance})::numeric, 2)`
})
.from(machineTable)
.where(isNull(machineTable.timeDeleted)) //Should have a status update
.where(isNull(machineTable.timeDeleted))
.orderBy(sqlDistance)
.limit(3)
.then((rows) => rows.map(serialize))
@@ -128,6 +144,7 @@ export module Machine {
): z.infer<typeof Info> {
return {
id: input.id,
userID: input.userID,
country: input.country,
timezone: input.timezone,
fingerprint: input.fingerprint,

View File

@@ -1,11 +1,12 @@
import { } from "drizzle-orm/postgres-js";
import { timestamps, id } from "../drizzle/types";
import { timestamps, id, ulid } from "../drizzle/types";
import {
text,
varchar,
pgTable,
uniqueIndex,
point,
primaryKey,
} from "drizzle-orm/pg-core";
export const machineTable = pgTable(
@@ -13,6 +14,7 @@ export const machineTable = pgTable(
{
...id,
...timestamps,
userID: ulid("user_id"),
country: text('country').notNull(),
timezone: text('timezone').notNull(),
location: point('location', { mode: 'xy' }).notNull(),
@@ -32,6 +34,7 @@ export const machineTable = pgTable(
},
(table) => [
// uniqueIndex("external_id").on(table.externalID),
uniqueIndex("machine_fingerprint").on(table.fingerprint)
uniqueIndex("machine_fingerprint").on(table.fingerprint),
primaryKey({ columns: [table.userID, table.id], }),
],
);

View File

@@ -10,7 +10,7 @@ import { memberTable } from "./member.sql";
import { and, eq, sql, asc, isNull } from "../drizzle";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export module Member {
export namespace Member {
export const Info = z
.object({
id: z.string().openapi({

View File

@@ -10,7 +10,7 @@ import { useTransaction } from "../drizzle/transaction";
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
export module Polar {
export namespace Polar {
export const client = polar;
export const Info = z.object({

View File

@@ -2,10 +2,10 @@ import {
IoTDataPlaneClient,
PublishCommand,
} from "@aws-sdk/client-iot-data-plane";
import {useMachine} from "../actor";
import {Resource} from "sst";
import { useMachine } from "../actor";
import { Resource } from "sst";
export module Realtime {
export namespace Realtime {
const client = new IoTDataPlaneClient({});
export async function publish(message: any, subTopic?: string) {

View File

@@ -0,0 +1,137 @@
import { z } from "zod";
import { Common } from "../common";
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";
export namespace Steam {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Steam.id,
}),
avatarUrl: z.string().openapi({
description: "The avatar url of this Steam account",
example: Examples.Steam.avatarUrl
}),
steamEmail: z.string().openapi({
description: "The email regisered with this Steam account",
example: Examples.Steam.steamEmail
}),
steamID: z.number().openapi({
description: "The Steam ID this Steam account",
example: Examples.Steam.steamID
}),
limitation: AccountLimitation.openapi({
description: " The limitations of this Steam account",
example: Examples.Steam.limitation
}),
lastGame: LastGame.openapi({
description: "The last game played on this Steam account",
example: Examples.Steam.lastGame
}),
userID: z.string().openapi({
description: "The unique id of the user who owns this steam account",
example: Examples.Steam.userID
}),
username: z.string().openapi({
description: "The unique username of this steam user",
example: Examples.Steam.username
}),
personaName: z.string().openapi({
description: "The last recorded persona name used by this account",
example: Examples.Steam.personaName
}),
countryCode: z.string().openapi({
description: "The country this account is connected from",
example: Examples.Steam.countryCode
})
})
.openapi({
ref: "Steam",
description: "Represents a steam user's information stored on Nestri",
example: Examples.Steam,
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.partial({
id: true,
userID: 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;
}),
);
export const fromUserID = fn(
z.string(),
(userID) =>
useTransaction((tx) =>
tx
.select()
.from(steamTable)
.where(and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted)))
.execute()
.then((rows) => rows.map(serialize).at(0)),
),
)
export const list = () =>
useTransaction((tx) =>
tx
.select()
.from(steamTable)
.where(and(eq(steamTable.userID, useUserID()), isNull(steamTable.timeDeleted)))
.execute()
.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<typeof Info> {
return {
id: input.id,
userID: input.userID,
countryCode: input.countryCode,
username: input.username,
avatarUrl: input.avatarUrl,
personaName: input.personaName,
steamEmail: input.steamEmail,
steamID: input.steamID,
limitation: input.limitation,
lastGame: input.lastGame,
};
}
}

View File

@@ -0,0 +1,58 @@
import { z } from "zod";
import { timestamps, userID, utc } from "../drizzle/types";
import { index, pgTable, integer, uniqueIndex, varchar, text, primaryKey, json } from "drizzle-orm/pg-core";
// public string Username { get; set; } = string.Empty;
// public ulong SteamId { get; set; }
// public string Email { get; set; } = string.Empty;
// public string Country { get; set; } = string.Empty;
// public string PersonaName { get; set; } = string.Empty;
// public string AvatarUrl { get; set; } = string.Empty;
// public bool IsLimited { get; set; }
// public bool IsLocked { get; set; }
// public bool IsBanned { get; set; }
// public bool IsAllowedToInviteFriends { get; set; }
// public ulong GameId { get; set; }
// public string GamePlayingName { get; set; } = string.Empty;
// public DateTime LastLogOn { get; set; }
// public DateTime LastLogOff { get; set; }
// public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
export const LastGame = z.object({
gameID: z.number(),
gameName: z.string()
});
export const AccountLimitation = z.object({
isLimited: z.boolean().nullable(),
isBanned: z.boolean().nullable(),
isLocked: z.boolean().nullable(),
isAllowedToInviteFriends: z.boolean().nullable(),
});
export type LastGame = z.infer<typeof LastGame>;
export type AccountLimitation = z.infer<typeof AccountLimitation>;
export const steamTable = pgTable(
"steam",
{
...userID,
...timestamps,
lastSeen: utc("time_seen"),
steamID: integer("steam_id").notNull(),
avatarUrl: text("avatar_url").notNull(),
lastGame: json("last_game").$type<LastGame>().notNull(),
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<AccountLimitation>().notNull(),
},
(table) => [
primaryKey({
columns: [table.userID, table.id],
}),
uniqueIndex("steam_email").on(table.userID, table.steamEmail),
],
);

View File

@@ -12,7 +12,7 @@ import { memberTable } from "../member/member.sql";
import { ErrorCodes, VisibleError } from "../error";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export module Team {
export namespace Team {
export const Info = z
.object({
id: z.string().openapi({

View File

@@ -15,7 +15,7 @@ import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export module User {
export namespace User {
const MAX_ATTEMPTS = 50;
export const Info = z

View File

@@ -19,7 +19,7 @@ export const userTable = pgTable(
discriminator: integer("discriminator").notNull(),
email: varchar("email", { length: 255 }).notNull(),
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
flags: json("flags").$type<UserFlags>().default({}),
// flags: json("flags").$type<UserFlags>().default({}),
},
(user) => [
uniqueIndex("user_email").on(user.email),

View File

@@ -6,8 +6,19 @@ export const prefixes = {
task: "tsk",
machine: "mch",
member: "mbr",
steam: "stm",
} as const;
/**
* Generates a unique identifier by concatenating a predefined prefix with a ULID.
*
* Given a key from the predefined prefixes mapping (e.g., "user", "team", "member", "steam"),
* this function retrieves the corresponding prefix and combines it with a ULID using an underscore
* as a separator. The resulting identifier is formatted as "prefix_ulid".
*
* @param prefix - A key from the prefixes mapping.
* @returns A unique identifier string.
*/
export function createID(prefix: keyof typeof prefixes): string {
return [prefixes[prefix], ulid()].join("_");
}