mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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.
This commit is contained in:
@@ -6,6 +6,7 @@ import { ErrorCodes, VisibleError } from '../error.js';
|
|||||||
import { fn } from '../fn.js';
|
import { fn } from '../fn.js';
|
||||||
import { Identifier } from '../id.js';
|
import { Identifier } from '../id.js';
|
||||||
import { Fingerprint } from '../user/fingerprint.js';
|
import { Fingerprint } from '../user/fingerprint.js';
|
||||||
|
import { Identity } from '../user/identity.js';
|
||||||
import { User } from '../user/index.js';
|
import { User } from '../user/index.js';
|
||||||
import { LinkedAccount } from '../user/linked-account.js';
|
import { LinkedAccount } from '../user/linked-account.js';
|
||||||
|
|
||||||
@@ -163,6 +164,15 @@ async function resolveSshIdentityOnce(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace Steam {
|
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(
|
export const link = fn(
|
||||||
z.object({
|
z.object({
|
||||||
steamId: z.string(),
|
steamId: z.string(),
|
||||||
@@ -170,34 +180,21 @@ export namespace Steam {
|
|||||||
userId: z.string().optional()
|
userId: z.string().optional()
|
||||||
}),
|
}),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
return Database.transaction(async () => {
|
const actor = Actor.use();
|
||||||
const existing = await LinkedAccount.findByProvider({
|
const uid =
|
||||||
provider: 'steam',
|
input.userId ??
|
||||||
providerAccountId: input.steamId
|
(actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined);
|
||||||
});
|
if (!uid) {
|
||||||
if (existing) {
|
throw new VisibleError(
|
||||||
return existing.id;
|
'forbidden',
|
||||||
}
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||||
const actor = Actor.use();
|
'Cannot link Steam account without a user ID'
|
||||||
const uid =
|
);
|
||||||
input.userId ??
|
}
|
||||||
(actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined);
|
return Identity.linkSteam({
|
||||||
if (!uid) {
|
userId: uid,
|
||||||
throw new VisibleError(
|
steamId: input.steamId,
|
||||||
'forbidden',
|
profile: input.profile
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { Actor } from '../actor.js';
|
||||||
import { testDb } from '../db/test.js';
|
import { testDb } from '../db/test.js';
|
||||||
import { Identifier } from '../id.js';
|
import { Identifier } from '../id.js';
|
||||||
|
import { Steam } from '../steam/index.js';
|
||||||
import { Identity } from './identity.js';
|
import { Identity } from './identity.js';
|
||||||
import { User } from './index.js';
|
import { User } from './index.js';
|
||||||
import { LinkedAccount } from './linked-account.js';
|
import { LinkedAccount } from './linked-account.js';
|
||||||
@@ -187,6 +189,27 @@ describe('Identity.linkSteam', () => {
|
|||||||
expect(thrown.type).toBe('already_exists');
|
expect(thrown.type).toBe('already_exists');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the cap holds on the path the settings screen uses', async () => {
|
||||||
|
const { userID } = await Identity.fromVerifiedEmail({ email: email(7) });
|
||||||
|
track(userID);
|
||||||
|
for (let n = 50; n < 54; n++) {
|
||||||
|
await Identity.linkSteam({ userId: userID, steamId: steamID(n) });
|
||||||
|
}
|
||||||
|
|
||||||
|
let thrown: any = null;
|
||||||
|
await Actor.with({ type: 'user', properties: { userID, linkedAccountID: '' } }, async () => {
|
||||||
|
try {
|
||||||
|
await Steam.link({ steamId: steamID(54) });
|
||||||
|
} catch (err) {
|
||||||
|
thrown = err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(thrown).not.toBeNull();
|
||||||
|
expect(thrown.code).toBe('invalid_state');
|
||||||
|
expect(await Identity.listSteam(userID)).toHaveLength(Identity.MAX_STEAM_ACCOUNTS);
|
||||||
|
});
|
||||||
|
|
||||||
test('a legacy user is claimed by attaching an email, and keeps its Steam link', async () => {
|
test('a legacy user is claimed by attaching an email, and keeps its Steam link', async () => {
|
||||||
const legacy = await legacySteamUser(40);
|
const legacy = await legacySteamUser(40);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user