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

@@ -30,7 +30,7 @@ export namespace Actor {
export interface Token {
type: "steam";
properties: {
steamID: bigint;
steamID: string;
};
}

View File

@@ -0,0 +1,20 @@
import { steamTable } from "../steam/steam.sql";
import { pgTable, varchar } from "drizzle-orm/pg-core";
import { encryptedText, timestamps, utc } from "../drizzle/types";
export const steamCredentialsTable = pgTable(
"steam_account_credentials",
{
...timestamps,
id: varchar("steam_id", { length: 255 })
.notNull()
.primaryKey()
.references(() => steamTable.id, {
onDelete: "cascade"
}),
refreshToken: encryptedText("refresh_token")
.notNull(),
expiry: utc("expiry").notNull(),
username: varchar("username", { length: 255 }).notNull(),
}
)

View File

@@ -0,0 +1,115 @@
import { z } from "zod";
import { createID, fn } from "../utils";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { createEvent } from "../event";
import { eq, and, isNull, gt } from "drizzle-orm";
import { createSelectSchema } from "drizzle-zod";
import { steamCredentialsTable } from "./credentials.sql";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Credentials {
export const Info = createSelectSchema(steamCredentialsTable)
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
.extend({
accessToken: z.string(),
cookies: z.string().array()
})
export type Info = z.infer<typeof Info>;
export const Events = {
New: createEvent(
"new_credentials.added",
z.object({
steamID: Info.shape.id,
}),
),
};
export const create = fn(
Info
.omit({ accessToken: true, cookies: true, expiry: true }),
(input) => {
const part = input.refreshToken.split('.')[1] as string
const payload = JSON.parse(Buffer.from(part, 'base64').toString());
return createTransaction(async (tx) => {
const id = input.id
await tx
.insert(steamCredentialsTable)
.values({
id,
username: input.username,
refreshToken: input.refreshToken,
expiry: new Date(payload.exp * 1000),
})
// await afterTx(async () =>
// await bus.publish(Resource.Bus, Events.New, { steamID: input.id })
// );
return id
})
});
export const getByID = fn(
Info.shape.id,
(id) =>
useTransaction(async (tx) => {
const now = new Date()
const credential = await tx
.select()
.from(steamCredentialsTable)
.where(
and(
eq(steamCredentialsTable.id, id),
isNull(steamCredentialsTable.timeDeleted),
gt(steamCredentialsTable.expiry, now)
)
)
.execute()
.then(rows => rows.at(0));
if (!credential) return null;
return serialize(credential);
})
);
// export const getBySteamID = fn(
// Info.shape.steamID,
// (steamID) =>
// useTransaction(async (tx) => {
// const now = new Date()
// const credential = await tx
// .select()
// .from(steamCredentialsTable)
// .where(
// and(
// eq(steamCredentialsTable.steamID, steamID),
// isNull(steamCredentialsTable.timeDeleted),
// gt(steamCredentialsTable.expiry, now)
// )
// )
// .execute()
// .then(rows => rows.at(0));
// if (!credential) return null;
// return serialize(credential);
// })
// );
export function serialize(
input: typeof steamCredentialsTable.$inferSelect,
) {
return {
id: input.id,
expiry: input.expiry,
username: input.username,
refreshToken: input.refreshToken,
};
}
}

View File

@@ -1,5 +1,5 @@
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
import { teamTable } from "../team/team.sql";
import { Token } from "../utils";
import { char, customType, timestamp as rawTs } from "drizzle-orm/pg-core";
export const ulid = (name: string) => char(name, { length: 26 + 4 });
@@ -33,6 +33,19 @@ export const utc = (name: string) =>
// mode: "date"
});
export const encryptedText =
customType<{ data: string; driverData: string; }>({
dataType() {
return 'text';
},
fromDriver(val) {
return Token.decrypt(val);
},
toDriver(val) {
return Token.encrypt(val);
},
});
export const timestamps = {
timeCreated: utc("time_created").notNull().defaultNow(),
timeUpdated: utc("time_updated").notNull().defaultNow(),

View File

@@ -44,7 +44,7 @@ export namespace Examples {
accountStatus: "new" as const, //active or pending
limitations: {
isLimited: false,
isTradeBanned: false,
tradeBanState: "none" as const,
isVacBanned: false,
visibilityState: 3,
privacyState: "public" as const,

View File

@@ -0,0 +1,25 @@
import { timestamps, } from "../drizzle/types";
import { steamTable } from "../steam/steam.sql";
import { pgTable,primaryKey, varchar } from "drizzle-orm/pg-core";
export const friendTable = pgTable(
"friends_list",
{
...timestamps,
steamID: varchar("steam_id", { length: 255 })
.notNull()
.references(() => steamTable.id, {
onDelete: "cascade"
}),
friendSteamID: varchar("friend_steam_id", { length: 255 })
.notNull()
.references(() => steamTable.id, {
onDelete: "cascade"
}),
},
(table) => [
primaryKey({
columns: [table.steamID, table.friendSteamID]
}),
]
);

View File

@@ -0,0 +1,166 @@
import { z } from "zod";
import { fn } from "../utils";
import { User } from "../user";
import { Steam } from "../steam";
import { Actor } from "../actor";
import { Examples } from "../examples";
import { friendTable } from "./friend.sql";
import { userTable } from "../user/user.sql";
import { steamTable } from "../steam/steam.sql";
import { createSelectSchema } from "drizzle-zod";
import { and, eq, isNull, sql } from "drizzle-orm";
import { groupBy, map, pipe, values } from "remeda";
import { createTransaction, useTransaction } from "../drizzle/transaction";
import { ErrorCodes, VisibleError } from "../error";
export namespace Friend {
export const Info = Steam.Info
.extend({
user: User.Info.nullable().openapi({
description: "The user account that owns this Steam account",
example: Examples.User
})
})
.openapi({
ref: "Friend",
description: "Represents a friend's information stored on Nestri",
example: { ...Examples.SteamAccount, user: Examples.User },
});
export const InputInfo = createSelectSchema(friendTable)
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
export type InputInfo = z.infer<typeof InputInfo>;
export const add = fn(
InputInfo.partial({ steamID: true }),
async (input) =>
createTransaction(async (tx) => {
const steamID = input.steamID ?? Actor.steamID()
if (steamID === input.friendSteamID) {
throw new VisibleError(
"forbidden",
ErrorCodes.Validation.INVALID_PARAMETER,
"Cannot add yourself as a friend"
);
}
await tx
.insert(friendTable)
.values({
steamID,
friendSteamID: input.friendSteamID
})
.onConflictDoUpdate({
target: [friendTable.steamID, friendTable.friendSteamID],
set: { timeDeleted: null }
})
return steamID
}),
)
export const end = fn(
InputInfo,
(input) =>
useTransaction(async (tx) =>
tx
.update(friendTable)
.set({ timeDeleted: sql`now()` })
.where(
and(
eq(friendTable.steamID, input.steamID),
eq(friendTable.friendSteamID, input.friendSteamID),
)
)
)
)
export const list = async () =>
useTransaction(async (tx) => {
const userSteamAccounts =
await tx
.select()
.from(steamTable)
.where(eq(steamTable.userID, Actor.userID()))
.execute();
if (userSteamAccounts.length === 0) {
return []; // User has no steam accounts
}
const friendPromises =
userSteamAccounts.map(async (steamAccount) => {
return await fromSteamID(steamAccount.id)
})
return (await Promise.all(friendPromises)).flat()
})
export const fromSteamID = fn(
InputInfo.shape.steamID,
(steamID) =>
useTransaction(async (tx) =>
tx
.select({
steam: steamTable,
user: userTable
})
.from(friendTable)
.innerJoin(
steamTable,
eq(friendTable.friendSteamID, steamTable.id)
)
.leftJoin(
userTable,
eq(steamTable.userID, userTable.id)
)
.where(
and(
eq(friendTable.steamID, steamID),
isNull(friendTable.timeDeleted)
)
)
.orderBy(friendTable.timeCreated)
.limit(100)
.execute()
.then((rows) => serialize(rows))
)
)
export const areFriends = fn(
InputInfo,
(input) =>
useTransaction(async (tx) => {
const result = await tx
.select()
.from(friendTable)
.where(
and(
eq(friendTable.steamID, input.steamID),
eq(friendTable.friendSteamID, input.friendSteamID),
isNull(friendTable.timeDeleted)
)
)
.limit(1)
.execute()
return result.length > 0
})
)
export function serialize(
input: { user: typeof userTable.$inferSelect | null; steam: typeof steamTable.$inferSelect }[],
): z.infer<typeof Info>[] {
return pipe(
input,
groupBy((row) => row.steam.id.toString()),
values(),
map((group) => ({
...Steam.serialize(group[0].steam),
user: group[0].user ? User.serialize(group[0].user!) : null
}))
)
}
}

View File

@@ -7,8 +7,9 @@ import { Common } from "../common";
import { createEvent } from "../event";
import { Examples } from "../examples";
import { eq, and, isNull, desc } from "drizzle-orm";
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
import { steamTable, StatusEnum, AccountStatusEnum, Limitations } from "./steam.sql";
import { teamTable } from "../team/team.sql";
export namespace Steam {
export const Info = z
@@ -25,10 +26,6 @@ export namespace Steam {
description: "The current connection status of this Steam account",
example: Examples.SteamAccount.status
}),
accountStatus: z.enum(AccountStatusEnum.enumValues).openapi({
description: "The current status of this Steam account",
example: Examples.SteamAccount.accountStatus
}),
userID: z.string().nullable().openapi({
description: "The user id of which account owns this steam account",
example: Examples.SteamAccount.userID
@@ -45,7 +42,7 @@ export namespace Steam {
example: Examples.SteamAccount.username
})
.default("unknown"),
realName: z.string().openapi({
realName: z.string().nullable().openapi({
description: "The real name behind of this Steam account",
example: Examples.SteamAccount.realName
}),
@@ -100,7 +97,6 @@ export namespace Steam {
useUser: true,
userID: true,
status: true,
accountStatus: true,
lastSyncedAt: true
}),
(input) =>
@@ -131,67 +127,65 @@ export namespace Steam {
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",
steamMemberSince: input.steamMemberSince,
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
})
await afterTx(async () =>
bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
);
// 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));
// TODO: This needs to be handled better, as it has the potential to turn unnecessary fields into `null`
// export const update = fn(
// Info
// .extend({
// useUser: z.boolean(),
// })
// .partial({
// useUser: true,
// userID: true,
// status: true,
// name: true,
// lastSyncedAt: true,
// avatarHash: true,
// username: true,
// realName: true,
// limitations: 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,
// 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 })
);
})
)
// await afterTx(async () =>
// bus.publish(Resource.Bus, Events.Updated, { userID: userID ?? null, steamID: input.id })
// );
// })
// )
export const fromUserID = fn(
z.string().min(1),
@@ -245,7 +239,6 @@ export namespace Steam {
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,
};

View File

@@ -1,17 +1,16 @@
import { z } from "zod";
import { userTable } from "../user/user.sql";
import { timestamps, ulid, utc } from "../drizzle/types";
import { pgTable, varchar, text, bigint, pgEnum, json, unique } from "drizzle-orm/pg-core";
import { pgTable, varchar, pgEnum, json, unique } from "drizzle-orm/pg-core";
export const AccountStatusEnum = pgEnum("steam_account_status", ["new", "pending", "active"])
export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"])
export const Limitations = z.object({
isLimited: z.boolean(),
isTradeBanned: z.boolean(),
tradeBanState: z.enum(["none", "probation", "banned"]),
isVacBanned: z.boolean(),
visibilityState: z.number(),
privacyState: z.enum(["public", "private"]),
privacyState: z.enum(["public", "private", "friendsfriendsonly", "friendsonly"]),
})
export type Limitations = z.infer<typeof Limitations>;
@@ -29,33 +28,15 @@ export const steamTable = pgTable(
}),
status: StatusEnum("status").notNull(),
lastSyncedAt: utc("last_synced_at").notNull(),
realName: varchar("real_name", { length: 255 }),
steamMemberSince: utc("member_since").notNull(),
name: varchar("name", { length: 255 }).notNull(),
profileUrl: varchar("profileUrl", { length: 255 }),
profileUrl: varchar("profile_url", { length: 255 }),
username: varchar("username", { length: 255 }).notNull(),
realName: varchar("real_name", { length: 255 }).notNull(),
accountStatus: AccountStatusEnum("account_status").notNull(),
avatarHash: varchar("avatar_hash", { length: 255 }).notNull(),
limitations: json("limitations").$type<Limitations>().notNull(),
},
(table) => [
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(),
// }
// )
);

View File

@@ -100,10 +100,6 @@ export namespace User {
if (result.count === 0) {
throw new UserExistsError(input.email)
}
await afterTx(async () =>
bus.publish(Resource.Bus, Events.Created, { userID: id })
);
})
return id;

View File

@@ -2,6 +2,7 @@ import { ulid } from "ulid";
export const prefixes = {
user: "usr",
credentials:"crd",
team: "tem",
product: "prd",
session: "ses",

View File

@@ -1,4 +1,5 @@
export * from "./fn"
export * from "./log"
export * from "./id"
export * from "./invite"
export * from "./invite"
export * from "./token"

View File

@@ -0,0 +1,58 @@
import { z } from 'zod';
import { fn } from './fn';
import crypto from 'crypto';
import { Resource } from 'sst';
// This is a 32-character random ASCII string
const rawKey = Resource.SteamEncryptionKey.value;
// Turn it into exactly 32 bytes via UTF-8
const key = Buffer.from(rawKey, 'utf8');
if (key.length !== 32) {
throw new Error(
`SteamEncryptionKey must be exactly 32 bytes; got ${key.length}`
);
}
const ENCRYPTION_IV_LENGTH = 12; // 96 bits for GCM
export namespace Token {
export const encrypt = fn(
z.string().min(4),
(token) => {
const iv = crypto.randomBytes(ENCRYPTION_IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([
cipher.update(token, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return ['v1', iv.toString('hex'), tag.toString('hex'), ciphertext.toString('hex')].join(':');
});
export const decrypt = fn(
z.string().min(4),
(data) => {
const [version, ivHex, tagHex, ciphertextHex] = data.split(':');
if (version !== 'v1' || !ivHex || !tagHex || !ciphertextHex) {
throw new Error('Invalid token format');
}
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const ciphertext = Buffer.from(ciphertextHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return plaintext.toString('utf8');
});
}