mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
A single secret that turned any request into an operator was the only credential several routes accepted, and it had no caller left: the device pairing it existed for is on hold, and nothing in this tree or any client sent it. What remained was a key that bypassed authentication entirely, required to boot, and checked by nobody. Every route behind it had a better answer available: - Library and game sync move to host credentials. Both took a `userId` in the body, which meant one secret could write into anybody's library. A host now says which of its enrolled users a batch is for, and that claim is checked against the Steam sign-ins it actually holds — one box carries several people's accounts, so the pair is the unit. - Download-state reporting narrows to hosts alone, and the body that could name a different host is gone. Which host is reporting comes from its own credentials, and a body that still names one is refused rather than ignored. - Linking a Steam account is always for the caller. - Creating a game by hand is deleted; syncing already upserts the catalogue. - Reading the waitlist is deleted. Every address on it belongs to someone who has not agreed to anything, and answering it over HTTP made that list something a leaked key could drain. - The pairing-code routes are deleted with the flow they served. The domain module and its table stay, so returning to it is a route file rather than a migration. Nothing in the API now accepts a credential that stands for more than one caller: every request resolves to a specific user or a specific host, which is what lets a route say "the caller's own library" and mean it. BREAKING CHANGE: the `x-nestri-admin-token` header is no longer accepted and `ADMIN_SHARED_SECRET` is no longer read. `POST /games`, `GET /waitlist` and the `/pairing-code` routes are gone; `POST /games/sync` and `POST /library/sync` now require host credentials and take `userId` in the body; `POST /steam/link` no longer accepts `userId`; `POST /games/download-state` no longer accepts `hostId`.
142 lines
3.8 KiB
TypeScript
142 lines
3.8 KiB
TypeScript
import { Actor } from '@nestri/core/actor';
|
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
|
import { Examples } from '@nestri/core/examples';
|
|
import { Steam } from '@nestri/core/steam/index';
|
|
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
|
import { Hono } from 'hono';
|
|
import { describeRoute } from 'hono-openapi';
|
|
import { z } from 'zod';
|
|
|
|
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
|
|
|
export namespace SteamApi {
|
|
export const route = new Hono()
|
|
.use(notPublic)
|
|
.get(
|
|
'/linked',
|
|
describeRoute({
|
|
tags: ['Steam'],
|
|
summary: 'Get your linked Steam account',
|
|
description: 'The Steam account linked to the authenticated user, or null if none.',
|
|
responses: {
|
|
200: {
|
|
content: {
|
|
'application/json': {
|
|
schema: Result(
|
|
z.union([LinkedAccount.Info, z.null()]).meta({
|
|
description: 'The linked Steam account, or null',
|
|
example: Examples.LinkedAccount
|
|
})
|
|
)
|
|
}
|
|
},
|
|
description: 'Linked Steam account'
|
|
},
|
|
401: ErrorResponses[401],
|
|
429: ErrorResponses[429]
|
|
}
|
|
}),
|
|
async (c) => {
|
|
const linked = await LinkedAccount.findSteamByUser(Actor.userID);
|
|
return c.json({ data: linked ? LinkedAccount.serialize(linked) : null });
|
|
}
|
|
)
|
|
.post(
|
|
'/unlink',
|
|
describeRoute({
|
|
tags: ['Steam'],
|
|
summary: 'Unlink your Steam account',
|
|
description: 'Detach the Steam account from the authenticated user.',
|
|
responses: {
|
|
200: {
|
|
content: {
|
|
'application/json': {
|
|
schema: Result(z.object({ unlinked: z.boolean() }))
|
|
}
|
|
},
|
|
description: 'Steam account unlinked'
|
|
},
|
|
401: ErrorResponses[401],
|
|
404: ErrorResponses[404],
|
|
429: ErrorResponses[429]
|
|
}
|
|
}),
|
|
async (c) => {
|
|
const linked = await LinkedAccount.findSteamByUser(Actor.userID);
|
|
if (!linked) {
|
|
throw new VisibleError(
|
|
'not_found',
|
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
|
'No Steam account is linked to this user'
|
|
);
|
|
}
|
|
await LinkedAccount.remove(linked.id);
|
|
return c.json({ data: { unlinked: true } });
|
|
}
|
|
)
|
|
.post(
|
|
'/link',
|
|
describeRoute({
|
|
tags: ['Steam'],
|
|
summary: 'Link a Steam account',
|
|
description: 'Link a Steam account to the calling user.',
|
|
responses: {
|
|
200: {
|
|
content: {
|
|
'application/json': {
|
|
schema: Result(
|
|
z.object({
|
|
linkedAccountId: z.string().meta({
|
|
description: 'The ID of the linked account',
|
|
example: Examples.LinkedAccount.id
|
|
}),
|
|
steamId: z.string().meta({
|
|
description: 'The Steam ID that was linked',
|
|
example: '76561197960287930'
|
|
})
|
|
})
|
|
)
|
|
}
|
|
},
|
|
description: 'Steam account linked'
|
|
},
|
|
400: ErrorResponses[400],
|
|
401: ErrorResponses[401],
|
|
403: ErrorResponses[403],
|
|
429: ErrorResponses[429]
|
|
}
|
|
}),
|
|
validator(
|
|
'json',
|
|
z.object({
|
|
steamId: z.string().min(1).meta({
|
|
description: 'Steam ID to link',
|
|
example: '76561197960287930'
|
|
}),
|
|
profile: z
|
|
.record(z.string(), z.unknown())
|
|
.optional()
|
|
.meta({
|
|
description: 'Steam profile data',
|
|
example: { personaname: 'Player', avatarfull: 'https://...' }
|
|
})
|
|
})
|
|
),
|
|
async (c) => {
|
|
const body = c.req.valid('json');
|
|
|
|
// Linking is always for the caller. It once accepted a `userId`,
|
|
// which meant one credential could attach a Steam account to any
|
|
// user \u2014 and a linked account is how a library is reached.
|
|
const linkedAccountID = await Steam.link({
|
|
steamId: body.steamId,
|
|
profile: body.profile,
|
|
userId: Actor.userID
|
|
});
|
|
return c.json({
|
|
data: { linkedAccountId: linkedAccountID, steamId: body.steamId }
|
|
});
|
|
}
|
|
);
|
|
}
|