mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
231
packages/core/src/steam/index.ts
Normal file
231
packages/core/src/steam/index.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Actor } from '../actor.js';
|
||||
import { Database } from '../db/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { Fingerprint } from '../user/fingerprint.js';
|
||||
import { User } from '../user/index.js';
|
||||
import { LinkedAccount } from '../user/linked-account.js';
|
||||
|
||||
const STEAM_ID_RE = /^\d{17}$/;
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const e = err as { code?: string; cause?: { code?: string } };
|
||||
return e?.code === '23505' || e?.cause?.code === '23505';
|
||||
}
|
||||
|
||||
type SshIdentityInput = {
|
||||
fingerprint: string;
|
||||
steamId: string;
|
||||
username?: string;
|
||||
profile?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function resolveSshIdentityOnce(
|
||||
input: SshIdentityInput
|
||||
): Promise<{ userID: string; linkedAccountID: string }> {
|
||||
const steamLink = await LinkedAccount.findByProvider({
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId
|
||||
});
|
||||
|
||||
const fingerprintRow = await Fingerprint.findByFingerprint(input.fingerprint);
|
||||
const sshLink = await LinkedAccount.findSshByFingerprint(input.fingerprint);
|
||||
|
||||
if (steamLink) {
|
||||
const canonicalUserID = steamLink.userId;
|
||||
|
||||
if (input.profile) {
|
||||
await LinkedAccount.updateProfile({ id: steamLink.id, profile: input.profile });
|
||||
}
|
||||
|
||||
if (!fingerprintRow) {
|
||||
// Case A: existing Steam account, new SSH fingerprint.
|
||||
await Fingerprint.create({
|
||||
id: Identifier.ascending('userFingerprint'),
|
||||
userId: canonicalUserID,
|
||||
fingerprint: input.fingerprint,
|
||||
name: input.username ?? null
|
||||
});
|
||||
} else if (fingerprintRow.userId === canonicalUserID) {
|
||||
// Case D: same canonical user.
|
||||
await Fingerprint.touchLastSeen(fingerprintRow.id);
|
||||
} else {
|
||||
// Case E: fingerprint currently owned by a different user (device migration).
|
||||
const oldSteam = await LinkedAccount.findSteamByUser(fingerprintRow.userId);
|
||||
if (oldSteam) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
`SSH key is already linked to Steam account ${oldSteam.providerAccountId}; refusing to switch accounts`
|
||||
);
|
||||
}
|
||||
const otherLinks = (await LinkedAccount.listByUser(fingerprintRow.userId)).filter(
|
||||
(l) => l.provider !== 'ssh' || l.providerAccountId !== input.fingerprint
|
||||
);
|
||||
if (otherLinks.length > 0) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'SSH key belongs to a user with other identities; refusing to merge'
|
||||
);
|
||||
}
|
||||
await Fingerprint.repoint({ fingerprint: input.fingerprint, userId: canonicalUserID });
|
||||
if (sshLink) {
|
||||
await LinkedAccount.repoint({ id: sshLink.id, userId: canonicalUserID });
|
||||
}
|
||||
}
|
||||
|
||||
if (!sshLink) {
|
||||
await LinkedAccount.create({
|
||||
id: Identifier.ascending('linkedAccount'),
|
||||
userId: canonicalUserID,
|
||||
provider: 'ssh',
|
||||
providerAccountId: input.fingerprint,
|
||||
profile: null
|
||||
});
|
||||
}
|
||||
|
||||
return { userID: canonicalUserID, linkedAccountID: steamLink.id };
|
||||
}
|
||||
|
||||
if (fingerprintRow) {
|
||||
// Case B: new Steam account, existing fingerprint.
|
||||
const currentUserID = fingerprintRow.userId;
|
||||
const existingSteam = await LinkedAccount.findSteamByUser(currentUserID);
|
||||
if (existingSteam) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
`User is already linked to Steam account ${existingSteam.providerAccountId}`
|
||||
);
|
||||
}
|
||||
await Fingerprint.touchLastSeen(fingerprintRow.id);
|
||||
|
||||
const newSteamLinkID = Identifier.ascending('linkedAccount');
|
||||
await LinkedAccount.create({
|
||||
id: newSteamLinkID,
|
||||
userId: currentUserID,
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId,
|
||||
profile: input.profile ?? null
|
||||
});
|
||||
if (!sshLink) {
|
||||
await LinkedAccount.create({
|
||||
id: Identifier.ascending('linkedAccount'),
|
||||
userId: currentUserID,
|
||||
provider: 'ssh',
|
||||
providerAccountId: input.fingerprint,
|
||||
profile: null
|
||||
});
|
||||
}
|
||||
return { userID: currentUserID, linkedAccountID: newSteamLinkID };
|
||||
}
|
||||
|
||||
// Case C: new Steam account, new SSH fingerprint.
|
||||
const newUserID = Identifier.ascending('user');
|
||||
const displayName = input.username ?? `player_${input.fingerprint.slice(0, 8)}`;
|
||||
await User.create({
|
||||
id: newUserID,
|
||||
name: displayName,
|
||||
email: undefined,
|
||||
emailVerified: false,
|
||||
image: null
|
||||
});
|
||||
|
||||
await Fingerprint.create({
|
||||
id: Identifier.ascending('userFingerprint'),
|
||||
userId: newUserID,
|
||||
fingerprint: input.fingerprint,
|
||||
name: input.username ?? null
|
||||
});
|
||||
|
||||
await LinkedAccount.create({
|
||||
id: Identifier.ascending('linkedAccount'),
|
||||
userId: newUserID,
|
||||
provider: 'ssh',
|
||||
providerAccountId: input.fingerprint,
|
||||
profile: null
|
||||
});
|
||||
|
||||
const newSteamLinkID = Identifier.ascending('linkedAccount');
|
||||
await LinkedAccount.create({
|
||||
id: newSteamLinkID,
|
||||
userId: newUserID,
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId,
|
||||
profile: input.profile ?? null
|
||||
});
|
||||
|
||||
return { userID: newUserID, linkedAccountID: newSteamLinkID };
|
||||
}
|
||||
|
||||
export namespace Steam {
|
||||
export const link = fn(
|
||||
z.object({
|
||||
steamId: z.string(),
|
||||
profile: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
userId: z.string().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.transaction(async () => {
|
||||
const existing = await LinkedAccount.findByProvider({
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId
|
||||
});
|
||||
if (existing) {
|
||||
return existing.id;
|
||||
}
|
||||
const actor = Actor.use();
|
||||
const uid =
|
||||
input.userId ??
|
||||
(actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined);
|
||||
if (!uid) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Cannot link Steam account without a user ID'
|
||||
);
|
||||
}
|
||||
const id = Identifier.ascending('linkedAccount');
|
||||
await LinkedAccount.create({
|
||||
id,
|
||||
userId: uid,
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId,
|
||||
profile: input.profile ?? null
|
||||
});
|
||||
return id;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const resolveSshIdentity = fn(
|
||||
z.object({
|
||||
fingerprint: z.string().min(1),
|
||||
steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID'),
|
||||
username: z.string().optional(),
|
||||
profile: z.record(z.string(), z.unknown()).nullable().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
return await Database.transaction(async () => resolveSshIdentityOnce(input));
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err) && attempt < 2) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
'Failed to resolve SSH identity'
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
184
packages/core/src/steam/resolve.test.ts
Normal file
184
packages/core/src/steam/resolve.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { testDb } from '../db/test.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { Fingerprint } from '../user/fingerprint.js';
|
||||
import { User } from '../user/index.js';
|
||||
import { LinkedAccount } from '../user/linked-account.js';
|
||||
import { Steam } from './index.js';
|
||||
|
||||
const sql = testDb();
|
||||
|
||||
const createdUserIDs: string[] = [];
|
||||
|
||||
function steamID(n: number): string {
|
||||
return String(76561197960287930n + BigInt(n));
|
||||
}
|
||||
|
||||
function fingerprint(n: number): string {
|
||||
return `aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:${String(n).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function resolve(fpr: string, sid: string) {
|
||||
return Steam.resolveSshIdentity({ fingerprint: fpr, steamId: sid });
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
if (createdUserIDs.length > 0) {
|
||||
await sql`delete from "user" where id in ${sql(createdUserIDs)}`;
|
||||
createdUserIDs.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function track(userID: string) {
|
||||
createdUserIDs.push(userID);
|
||||
return userID;
|
||||
}
|
||||
|
||||
async function countSteamLinks(steamId: string): Promise<number> {
|
||||
const rows = await sql`
|
||||
select count(*)::int as n from linked_account
|
||||
where provider = 'steam' and provider_account_id = ${steamId}
|
||||
`;
|
||||
return rows[0]?.n;
|
||||
}
|
||||
|
||||
async function countFingerprints(fpr: string): Promise<number> {
|
||||
const rows =
|
||||
await sql`select count(*)::int as n from user_fingerprint where fingerprint = ${fpr}`;
|
||||
return rows[0]?.n;
|
||||
}
|
||||
|
||||
describe('Steam.resolveSshIdentity', () => {
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await sql.end();
|
||||
});
|
||||
|
||||
test('1. new fingerprint + new Steam ID creates one user', async () => {
|
||||
await cleanup();
|
||||
const result = await resolve(fingerprint(1), steamID(1));
|
||||
|
||||
expect(result.userID).toMatch(/^usr_/);
|
||||
track(result.userID);
|
||||
|
||||
const user = await User.fromID(result.userID);
|
||||
expect(user).not.toBeNull();
|
||||
|
||||
const links = await LinkedAccount.listByUser(result.userID);
|
||||
expect(links.map((l) => l.provider).sort()).toEqual(['ssh', 'steam']);
|
||||
expect(await countFingerprints(fingerprint(1))).toBe(1);
|
||||
expect(await countSteamLinks(steamID(1))).toBe(1);
|
||||
});
|
||||
|
||||
test('2. same fingerprint + same Steam ID returns the same user', async () => {
|
||||
const first = await resolve(fingerprint(2), steamID(2));
|
||||
track(first.userID);
|
||||
const second = await resolve(fingerprint(2), steamID(2));
|
||||
|
||||
expect(second.userID).toBe(first.userID);
|
||||
expect(second.linkedAccountID).toBe(first.linkedAccountID);
|
||||
expect(await countFingerprints(fingerprint(2))).toBe(1);
|
||||
expect(await countSteamLinks(steamID(2))).toBe(1);
|
||||
});
|
||||
|
||||
test('3. new fingerprint + same Steam ID returns the first user', async () => {
|
||||
const first = await resolve(fingerprint(3), steamID(3));
|
||||
track(first.userID);
|
||||
const second = await resolve(fingerprint(4), steamID(3));
|
||||
|
||||
expect(second.userID).toBe(first.userID);
|
||||
expect(second.linkedAccountID).toBe(first.linkedAccountID);
|
||||
track(second.userID);
|
||||
expect(await countFingerprints(fingerprint(4))).toBe(1);
|
||||
expect(await countSteamLinks(steamID(3))).toBe(1);
|
||||
});
|
||||
|
||||
test('4. same fingerprint + different Steam ID is rejected', async () => {
|
||||
await resolve(fingerprint(5), steamID(5));
|
||||
expect(resolve(fingerprint(5), steamID(6))).rejects.toMatchObject({
|
||||
type: 'forbidden'
|
||||
});
|
||||
});
|
||||
|
||||
test('5. existing Steam ID reassigns a provisional fingerprint user', async () => {
|
||||
const canonical = await resolve(fingerprint(7), steamID(7));
|
||||
track(canonical.userID);
|
||||
|
||||
const provisionalUserID = track(Identifier.ascending('user'));
|
||||
await User.create({
|
||||
id: provisionalUserID,
|
||||
name: 'provisional',
|
||||
email: undefined,
|
||||
emailVerified: false,
|
||||
image: null
|
||||
});
|
||||
const fpr = fingerprint(8);
|
||||
await Fingerprint.create({
|
||||
id: Identifier.ascending('userFingerprint'),
|
||||
userId: provisionalUserID,
|
||||
fingerprint: fpr,
|
||||
name: null
|
||||
});
|
||||
await LinkedAccount.create({
|
||||
id: Identifier.ascending('linkedAccount'),
|
||||
userId: provisionalUserID,
|
||||
provider: 'ssh',
|
||||
providerAccountId: fpr,
|
||||
profile: null
|
||||
});
|
||||
|
||||
const result = await resolve(fpr, steamID(7));
|
||||
|
||||
expect(result.userID).toBe(canonical.userID);
|
||||
|
||||
const row = await Fingerprint.findByFingerprint(fpr);
|
||||
expect(row?.userId).toBe(canonical.userID);
|
||||
const sshLink = await LinkedAccount.findSshByFingerprint(fpr);
|
||||
expect(sshLink?.userId).toBe(canonical.userID);
|
||||
});
|
||||
|
||||
test('5b. reassignment is rejected when the provisional user has a different Steam account', async () => {
|
||||
await resolve(fingerprint(9), steamID(9));
|
||||
expect(resolve(fingerprint(9), steamID(10))).rejects.toMatchObject({
|
||||
type: 'forbidden'
|
||||
});
|
||||
});
|
||||
|
||||
test('6. two concurrent new fingerprints for one Steam ID create one Steam link', async () => {
|
||||
await cleanup();
|
||||
const [a, b] = await Promise.all([
|
||||
resolve(fingerprint(11), steamID(11)),
|
||||
resolve(fingerprint(12), steamID(11))
|
||||
]);
|
||||
|
||||
expect(a.userID).toBe(b.userID);
|
||||
track(a.userID);
|
||||
expect(await countSteamLinks(steamID(11))).toBe(1);
|
||||
expect(await countFingerprints(fingerprint(11))).toBe(1);
|
||||
expect(await countFingerprints(fingerprint(12))).toBe(1);
|
||||
});
|
||||
|
||||
test('7. malformed request without Steam ID is rejected', () => {
|
||||
expect(
|
||||
Steam.resolveSshIdentity.schema.safeParse({ fingerprint: fingerprint(13) }).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
Steam.resolveSshIdentity.schema.safeParse({
|
||||
fingerprint: fingerprint(13),
|
||||
steamId: 'not-a-steam-id'
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('8. last-seen is updated on repeat login', async () => {
|
||||
const first = await resolve(fingerprint(14), steamID(14));
|
||||
track(first.userID);
|
||||
const before = await Fingerprint.findByFingerprint(fingerprint(14));
|
||||
expect(before?.lastSeen).toBeNull();
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
await resolve(fingerprint(14), steamID(14));
|
||||
const after = await Fingerprint.findByFingerprint(fingerprint(14));
|
||||
expect(after?.lastSeen).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user