mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
⭐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:
@@ -30,7 +30,7 @@ export namespace Actor {
|
||||
export interface Token {
|
||||
type: "steam";
|
||||
properties: {
|
||||
steamID: bigint;
|
||||
steamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
20
packages/core/src/credentials/credentials.sql.ts
Normal file
20
packages/core/src/credentials/credentials.sql.ts
Normal 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(),
|
||||
}
|
||||
)
|
||||
115
packages/core/src/credentials/index.ts
Normal file
115
packages/core/src/credentials/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
packages/core/src/friend/friend.sql.ts
Normal file
25
packages/core/src/friend/friend.sql.ts
Normal 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]
|
||||
}),
|
||||
]
|
||||
);
|
||||
166
packages/core/src/friend/index.ts
Normal file
166
packages/core/src/friend/index.ts
Normal 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
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
// }
|
||||
// )
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
user: "usr",
|
||||
credentials:"crd",
|
||||
team: "tem",
|
||||
product: "prd",
|
||||
session: "ses",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./fn"
|
||||
export * from "./log"
|
||||
export * from "./id"
|
||||
export * from "./invite"
|
||||
export * from "./invite"
|
||||
export * from "./token"
|
||||
58
packages/core/src/utils/token.ts
Normal file
58
packages/core/src/utils/token.ts
Normal 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');
|
||||
});
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user