feat: Add Steam account linking with team creation (#274)

## 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**
- Introduced a real-time Steam login flow using QR codes and server-sent
events (SSE) for team creation and authentication.
- Added Steam account and friend management, including secure credential
storage and friend list synchronization.
- Integrated Steam login endpoints into the API, enabling QR code-based
login and automated team setup.

- **Improvements**
- Enhanced data security by implementing encrypted storage for sensitive
tokens.
- Updated database schema to support Steam accounts, teams, memberships,
and social connections.
- Refined type definitions and consolidated account-related information
for improved consistency.

- **Bug Fixes**
  - Fixed trade ban status representation for Steam accounts.

- **Chores**
- Removed legacy C# Steam authentication service and related
configuration files.
  - Updated and cleaned up package dependencies and development tooling.
  - Streamlined type declaration files and resource definitions.

- **Style**
- Redesigned the team creation page UI with a modern, animated QR code
login interface.

- **Documentation**
  - Updated OpenAPI documentation for new Steam login endpoints.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wanjohi
2025-05-09 01:13:44 +03:00
committed by GitHub
parent 70d629227a
commit 7e69af977b
51 changed files with 2332 additions and 2805 deletions

View File

@@ -4,7 +4,8 @@
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
"@types/bun": "latest",
"@types/steamcommunity": "^3.43.8"
},
"scripts": {
"dev:auth": "bun run --watch ./src/auth/index.ts",
@@ -15,9 +16,12 @@
},
"dependencies": {
"@actor-core/bun": "^0.8.0",
"@nestri/core":"workspace:",
"@nestri/core": "workspace:",
"actor-core": "^0.8.0",
"hono": "^4.7.8",
"hono-openapi": "^0.4.8"
"hono-openapi": "^0.4.8",
"steam-session": "*",
"steamcommunity": "^3.48.6",
"steamid": "^2.1.0"
}
}

View File

@@ -1,3 +1,4 @@
import "zod-openapi/extend";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
@@ -8,6 +9,7 @@ import { openAPISpecs } from "hono-openapi";
import { patchLogger } from "../utils/patch-logger";
import { HTTPException } from "hono/http-exception";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { SteamApi } from "./steam";
patchLogger();
@@ -24,6 +26,7 @@ app
const routes = app
.get("/", (c) => c.text("Hello World!"))
.route("/realtime", Realtime.route)
.route("/steam", SteamApi.route)
.route("/account", AccountApi.route)
.onError((error, c) => {
if (error instanceof VisibleError) {

View File

@@ -0,0 +1,199 @@
import { z } from "zod";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { Actor } from "@nestri/core/actor";
import SteamCommunity from "steamcommunity";
import { describeRoute } from "hono-openapi";
import { Steam } from "@nestri/core/steam/index";
import { ErrorResponses, validator } from "./utils";
import { Credentials } from "@nestri/core/credentials/index";
import { LoginSession, EAuthTokenPlatformType } from "steam-session";
import { Team } from "@nestri/core/team/index";
import { Member } from "@nestri/core/member/index";
import type CSteamUser from "steamcommunity/classes/CSteamUser";
export namespace SteamApi {
export const route = new Hono()
.get("/login",
describeRoute({
tags: ["Steam"],
summary: "Login to Steam using QR code",
description: "Login to Steam using a QR code sent using Server Sent Events",
responses: {
400: ErrorResponses[400],
429: ErrorResponses[429],
}
}),
validator(
"header",
z.object({
"accept": z.string()
.refine((v) =>
v.toLowerCase()
.includes("text/event-stream")
)
.openapi({
description: "Client must accept Server Sent Events",
example: "text/event-stream"
})
})
),
(c) => {
const currentUser = Actor.user()
return streamSSE(c, async (stream) => {
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
session.loginTimeout = 30000; //30 seconds is typically when the url expires
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "connected to steam" })
})
const challenge = await session.startWithQR();
await stream.writeSSE({
event: 'challenge_url',
data: JSON.stringify({ url: challenge.qrChallengeUrl })
})
return new Promise((resolve, reject) => {
session.on('remoteInteraction', async () => {
await stream.writeSSE({
event: 'remote_interaction',
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
})
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
})
});
session.on('timeout', async () => {
console.log('This login attempt has timed out.');
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Your session timed out" }),
})
await stream.writeSSE({
event: 'timed_out',
data: JSON.stringify({ success: false }),
})
await stream.close()
reject("Authentication timed out")
});
session.on('error', async (err) => {
// This should ordinarily not happen. This only happens in case there's some kind of unexpected error while
// polling, e.g. the network connection goes down or Steam chokes on something.
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Recieved an error while authenticating" }),
})
await stream.writeSSE({
event: 'error',
data: JSON.stringify({ message: err.message }),
})
await stream.close()
reject(err.message)
});
session.on('authenticated', async () => {
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Login successful" })
})
await stream.writeSSE({
event: 'login_success',
data: JSON.stringify({ success: true, })
})
const username = session.accountName;
const accessToken = session.accessToken;
const refreshToken = session.refreshToken;
const steamID = session.steamID.toString();
const cookies = await session.getWebCookies();
// Get user information
const community = new SteamCommunity();
community.setCookies(cookies);
const user = await new Promise((res, rej) => {
community.getSteamUser(session.steamID, async (error, user) => {
if (!error) {
res(user)
} else {
rej(error)
}
})
}) as CSteamUser
const wasAdded =
await Steam.create({
username,
id: steamID,
name: user.name,
realName: user.realName,
userID: currentUser.userID,
avatarHash: user.avatarHash,
steamMemberSince: user.memberSince,
profileUrl: user.customURL?.trim() || null,
limitations: {
isLimited: user.isLimitedAccount,
isVacBanned: user.vacBanned,
privacyState: user.privacyState as any,
visibilityState: Number(user.visibilityState),
tradeBanState: user.tradeBanState.toLowerCase() as any,
}
})
// Does not matter if the user is already there or has just been created, just store the credentials
await Credentials.create({ refreshToken, id: steamID, username })
if (!!wasAdded) {
// create a team
const teamID = await Team.create({
slug: username,
name: `${user.name.split(" ")[0]}'s Team`,
ownerID: currentUser.userID,
})
await Actor.provide(
"system",
{ teamID },
async () => {
await Member.create({
role: "adult",
userID: currentUser.userID,
steamID
})
})
}
await stream.writeSSE({
event: 'team_slug',
data: JSON.stringify({ username })
})
//TODO: Get game library
await stream.close()
resolve()
})
})
})
}
)
}

View File

@@ -1,6 +1,6 @@
import { Resource } from "sst"
import { type Env } from "hono";
import { PasswordUI } from "./ui";
import { PasswordUI, Select } from "./ui";
import { logger } from "hono/logger";
import { subjects } from "../subjects"
import { issuer } from "@openauthjs/openauth";
@@ -15,6 +15,7 @@ patchLogger();
const app = issuer({
//TODO: Create our own Storage (?)
select: Select(),
storage: MemoryStorage({
persist: process.env.STORAGE
}),

View File

@@ -1,35 +1,67 @@
import SteamID from "steamid"
import { bus } from "sst/aws/bus";
import SteamCommunity from "steamcommunity";
import { User } from "@nestri/core/user/index";
import { Email } from "@nestri/core/email/index"
import { useActor } from "@nestri/core/actor";
// import { Stripe } from "@nestri/core/stripe";
// import { Template } from "@nestri/core/email/template";
// import { EmailOctopus } from "@nestri/core/email-octopus";
import { Steam } from "@nestri/core/steam/index";
import { Friend } from "@nestri/core/friend/index";
import { Credentials } from "@nestri/core/credentials/index";
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
export const handler = bus.subscriber(
[User.Events.Updated, User.Events.Created],
[Credentials.Events.New],
async (event) => {
console.log(event.type, event.properties, event.metadata);
switch (event.type) {
// case "order.created": {
// await Shippo.createShipment(event.properties.orderID);
// await Template.sendOrderConfirmation(event.properties.orderID);
// await EmailOctopus.addToCustomersList(event.properties.orderID);
// break;
// }
case "user.created": {
console.log("Send email here")
// const actor = useActor()
// if (actor.type !== "user") throw new Error("User actor is needed here")
// await Email.send(
// "welcome",
// actor.properties.email,
// `Welcome to Nestri`,
// `Welcome to Nestri`,
// )
// await Stripe.syncUser(event.properties.userID);
// // await EmailOctopus.addToMarketingList(event.properties.userID);
// break;
case "new_credentials.added": {
const input = event.properties
const credentials = await Credentials.getByID(input.steamID)
if (credentials) {
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
session.refreshToken = credentials.refreshToken;
const cookies = await session.getWebCookies()
const community = new SteamCommunity()
community.setCookies(cookies);
//FIXME: use a promise as promises inside callbacks are not awaited
community.getFriendsList((error, allFriends) => {
if (!error) {
const friends = Object.entries(allFriends);
for (const [id, nonce] of friends) {
const friendID = new SteamID(id);
community.getSteamUser(friendID, async (error, user) => {
if (!error) {
const wasAdded =
await Steam.create({
id: friendID.toString(),
name: user.name,
realName: user.realName,
avatarHash: user.avatarHash,
steamMemberSince: user.memberSince,
profileUrl: user.customURL?.trim() || null,
limitations: {
isLimited: user.isLimitedAccount,
isVacBanned: user.vacBanned,
tradeBanState: user.tradeBanState.toLowerCase() as any,
privacyState: user.privacyState as any,
visibilityState: Number(user.visibilityState)
}
})
if (!wasAdded) {
console.log(`steam user ${friendID.toString()} already exists`)
}
await Friend.add({ friendSteamID: friendID.toString(), steamID: input.steamID })
}
})
}
}
});
}
break;
}
}
},

View File

@@ -3,116 +3,7 @@
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
declare module "sst" {
export interface Resource {
"Api": {
"service": string
"type": "sst.aws.Service"
"url": string
}
"Auth": {
"service": string
"type": "sst.aws.Service"
"url": string
}
"Bus": {
"arn": string
"name": string
"type": "sst.aws.Bus"
}
"Database": {
"clusterArn": string
"database": string
"host": string
"password": string
"port": number
"reader": string
"secretArn": string
"type": "sst.aws.Aurora"
"username": string
}
"DatabaseMigrator": {
"name": string
"type": "sst.aws.Function"
}
"DiscordClientID": {
"type": "sst.sst.Secret"
"value": string
}
"DiscordClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"Email": {
"configSet": string
"sender": string
"type": "sst.aws.Email"
}
"GithubClientID": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"NestriFamilyMonthly": {
"type": "sst.sst.Secret"
"value": string
}
"NestriFamilyYearly": {
"type": "sst.sst.Secret"
"value": string
}
"NestriFreeMonthly": {
"type": "sst.sst.Secret"
"value": string
}
"NestriProMonthly": {
"type": "sst.sst.Secret"
"value": string
}
"NestriProYearly": {
"type": "sst.sst.Secret"
"value": string
}
"PolarSecret": {
"type": "sst.sst.Secret"
"value": string
}
"PolarWebhookSecret": {
"type": "sst.sst.Secret"
"value": string
}
"Realtime": {
"authorizer": string
"endpoint": string
"type": "sst.aws.Realtime"
}
"Storage": {
"name": string
"type": "sst.aws.Bucket"
}
"VPC": {
"bastion": string
"type": "sst.aws.Vpc"
}
"Web": {
"type": "sst.aws.StaticSite"
"url": string
}
"Zero": {
"service": string
"type": "sst.aws.Service"
"url": string
}
"ZeroPermissions": {
"name": string
"type": "sst.aws.Function"
}
}
}
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}