Files
netris-nestri/packages/core/src/steam/index.ts
Wanjohi 1b61d2251e fix(core): hold the connection cap on the path a settings screen uses
Connecting a Steam account wrote the row itself, so the limit on how many one
person may connect was enforced on the sign-in path and nowhere else — and
this is the path the settings screen calls, which makes it the one that would
have gone over. It now resolves who is asking and hands over to the single
place the rule lives.

Two things fall out of that. A Steam account already connected to somebody
else is a conflict rather than a silent success returning the other person's
row id, and a Steam id of the wrong shape is refused before a lookup.
2026-09-05 00:03:53 +03:00

229 lines
6.6 KiB
TypeScript

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 { Identity } from '../user/identity.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 {
/**
* Connect a Steam account to whoever is asking.
*
* Works out who that is and then hands over to the one place the rules
* live. It used to write the row itself, which meant the cap on how many
* accounts one person may connect held on the sign-in path and not on
* this one — and this is the path a settings screen uses, so it is the
* one that would have been over the limit.
*/
export const link = fn(
z.object({
steamId: z.string(),
profile: z.record(z.string(), z.unknown()).nullable().optional(),
userId: z.string().optional()
}),
async (input) => {
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'
);
}
return Identity.linkSteam({
userId: uid,
steamId: input.steamId,
profile: input.profile
});
}
);
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'
);
}
);
}