Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion destiny/destiny.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class DestinyController {
*
* @param {string} displayName
* @param {number} membershipType
* @returns {Promise<import('../destiny/destiny.service.js').CurrentUser>}
* @returns {Promise<import('../destiny/destiny.service.js').CurrentUser | undefined>}
*/
async getCurrentUser(displayName, membershipType) {
const currentUser = await this.users.getUserByDisplayName(displayName, membershipType);
Expand Down
57 changes: 45 additions & 12 deletions destiny/destiny.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
*/
import { stringify } from 'qs';
import { get, post } from '../helpers/bungie.request.js';
import supportedMembershipTypes from '../helpers/bungie.membershipTypes.js';
import DestinyError from './destiny.error.js';
import configuration from '../helpers/config.js';
import log from '../helpers/log.js';

const {
bungie: { apiKey, host, clientId, clientSecret },
Expand Down Expand Up @@ -52,16 +54,16 @@ const {
* @typedef {Object} DestinyMembership
* @property {string} displayName
* @property {string} membershipId
* @property {number} membershipType - Platform: 1 Xbox, 2 PSN, 3 Steam, etc.
* @property {number} [crossSaveOverride] - The membershipType that owns cross-saved data
* @property {number} membershipType - A Bungie platform value; see `helpers/bungie.membershipTypes.js`.
* @property {number} [crossSaveOverride] - The membershipType that owns cross-saved data, or 0 when cross save is off
*/

/**
* The current user, flattened to the fields this application stores.
* @typedef {Object} CurrentUser
* @property {string} displayName
* @property {string} membershipId
* @property {number} membershipType
* @property {SupportedMembershipType} membershipType
* @property {string} [profilePicturePath]
*/

Expand All @@ -73,6 +75,7 @@ const {
* @property {{ membershipId: string, membershipType: number }} [characterBase]
*/

/** @typedef {import('../helpers/bungie.membershipTypes.js').SupportedMembershipType} SupportedMembershipType */
/** @typedef {import('./destiny.cache.js').DestinyManifest} DestinyManifest */
/** @typedef {import('./destiny.cache.js').ManifestResult} ManifestResult */

Expand Down Expand Up @@ -260,8 +263,13 @@ class DestinyService {
/**
* Get the current user based on the Bungie access token.
*
* Resolves undefined when the account has nothing this application can sign
* in - no Destiny memberships at all, or none on a supported platform. That
* is a client outcome, not a failure: `users/user.routes.js` turns it into
* the same 404 an unknown user gets, having created nothing.
*
* @param {string} accessToken
* @returns {Promise<CurrentUser>}
* @returns {Promise<CurrentUser | undefined>}
*/
async getCurrentUser(accessToken) {
const options = {
Expand All @@ -284,8 +292,13 @@ class DestinyService {
}

const { destinyMemberships, bungieNetUser: { profilePicturePath } = {} } = user;
const { displayName, membershipId, membershipType } =
this.#getPreferredMembership(destinyMemberships);
const membership = this.#getPreferredMembership(destinyMemberships);

if (!membership) {
return undefined;
}

const { displayName, membershipId, membershipType } = membership;

return {
displayName,
Expand Down Expand Up @@ -314,17 +327,37 @@ class DestinyService {
}

/**
* Pick the membership that owns cross-saved data, falling back to the first.
* The membership the player actually plays on: either the one cross save
* points at, or an account that never enabled it. Every membership on a
* cross-saved account carries the owner's `membershipType`, so the owner is
* the one that names itself.
*
* @param {DestinyMembership[]} memberships
* @returns {DestinyMembership}
* @returns {(DestinyMembership & { membershipType: SupportedMembershipType }) | undefined}
* undefined when nothing here is playable
*/
#getPreferredMembership(memberships) {
const [{ crossSaveOverride }] = memberships;
const membership = memberships.find(
({ crossSaveOverride, membershipType }) =>
!crossSaveOverride || crossSaveOverride === membershipType,
);

if (!membership) {
log.info({ memberships: memberships.length }, 'No playable Destiny membership');

return undefined;
}

const membershipType = /** @type {SupportedMembershipType} */ (membership.membershipType);

if (!supportedMembershipTypes.includes(membershipType)) {
log.info({ membershipType }, 'Destiny membership is on an unsupported platform');

return undefined;
}

return (
memberships.find(({ membershipType }) => membershipType === crossSaveOverride) ||
memberships[0]
return /** @type {DestinyMembership & { membershipType: SupportedMembershipType }} */ (
membership
);
}
}
Expand Down
99 changes: 99 additions & 0 deletions destiny/destiny.service.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,105 @@ describe('DestinyService', () => {
});
});

describe('when cross save is enabled', () => {
it('should return the membership that owns the data', async () => {
const owner = {
crossSaveOverride: 3,
displayName: chance.word(),
membershipId: '3',
membershipType: 3,
};

get.mockImplementation(() =>
Promise.resolve({
ErrorCode: 1,
Response: {
destinyMemberships: [
{
crossSaveOverride: 3,
displayName: chance.word(),
membershipId: '2',
membershipType: 2,
},
owner,
],
},
}),
);

await expect(destinyService.getCurrentUser(chance.hash())).resolves.toEqual({
displayName: owner.displayName,
membershipId: owner.membershipId,
membershipType: owner.membershipType,
profilePicturePath: undefined,
});
});
});

describe('when cross save is off', () => {
it('should return the only membership', async () => {
const membership = {
crossSaveOverride: 0,
displayName: chance.word(),
membershipId: '6',
membershipType: 6,
};

get.mockImplementation(() =>
Promise.resolve({
ErrorCode: 1,
Response: { destinyMemberships: [membership] },
}),
);

await expect(destinyService.getCurrentUser(chance.hash())).resolves.toEqual({
displayName: membership.displayName,
membershipId: membership.membershipId,
membershipType: membership.membershipType,
profilePicturePath: undefined,
});
});
});

describe('when the account has no Destiny memberships', () => {
it('should resolve undefined rather than throw', async () => {
get.mockImplementation(() =>
Promise.resolve({
ErrorCode: 1,
Response: { destinyMemberships: [] },
}),
);

await expect(
destinyService.getCurrentUser(chance.hash()),
).resolves.toBeUndefined();
});
});

describe('when the playable membership is on an unsupported platform', () => {
it('should resolve undefined', async () => {
get.mockImplementation(() =>
Promise.resolve({
ErrorCode: 1,
Response: {
destinyMemberships: [
{
crossSaveOverride: 0,
displayName: chance.word(),
membershipId: '5',
membershipType: 5,
},
],
},
}),
);

await expect(
destinyService.getCurrentUser(chance.hash()),
).resolves.toBeUndefined();
});
});

describe('when ErrorCode is not 1', () => {
it('should throw carrying the error details from the response', async () => {
get.mockImplementation(() =>
Expand Down
27 changes: 27 additions & 0 deletions helpers/bungie.membershipTypes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// @ts-check
/**
* The Bungie platform values this application signs users in on, a subset of
* Bungie's `BungieMembershipType` enum.
*
* Deliberately not every value the enum defines: 0 (None) and 254 (BungieNext)
* are not playable platforms, 4 (Blizzard) was migrated to Steam in 2019, 5
* (Stadia) retired with the service in 2023, and 10 (Demon) is internal. A
* membership on one of those is not something a Destiny player can log into
* today, so it is treated as no playable membership rather than accepted and
* stored.
*
* @type {readonly [1, 2, 3, 6]}
*/
const supportedMembershipTypes = /** @type {const} */ ([
1, // Xbox
2, // PlayStation Network
3, // Steam
6, // Epic Games Store
]);

/**
* One of the platform values above.
* @typedef {(typeof supportedMembershipTypes)[number]} SupportedMembershipType
*/

export default supportedMembershipTypes;
13 changes: 13 additions & 0 deletions mcp/mcp.routes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { Router } from 'express';
import { StatusCodes } from 'http-status-codes';
import { createId } from '@paralleldrive/cuid2';
import { LRUCache as LruCache } from 'lru-cache';
import authorizeUser from '../authorization/authorization.middleware.js';
Expand Down Expand Up @@ -57,6 +58,18 @@ const routes = ({ destinyController }) => {
administrator.displayName,
administrator.membershipType,
);

if (!user) {
log.error(
{ displayName: administrator.displayName },
'The configured administrator has no playable Destiny membership.',
);

return res
.status(StatusCodes.SERVICE_UNAVAILABLE)
.send('Failed to initialize MCP session');
}

const server = createMcpServer({
destinyController,
user,
Expand Down
51 changes: 50 additions & 1 deletion users/user.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -512,16 +512,65 @@ class UserController {
membershipType,
profilePicturePath,
};
const destinyGhostUser = /** @type {MutableUser | undefined} */ (
let destinyGhostUser = /** @type {MutableUser | undefined} */ (
await this.users.getUserByMembershipId(/** @type {string} */ (user.membershipId))
);

/**
* Moving the membership that owns cross-saved data changes both the
* platform membership id and the platform, so the lookup above misses
* and the player would sign in as a stranger: a second document, in a
* second Cosmos partition, with their registration stranded on the
* first. The Bungie.net membership id on the token is the one
* identifier that survives the change.
*/
if (!destinyGhostUser) {
destinyGhostUser = /** @type {MutableUser | undefined} */ (
await this.users.getUserByBungieMembershipId(bungie.membership_id)
);
}

if (!destinyGhostUser) {
return await this.users
.createAnonymousUser(/** @type {AnonymousUser} */ (user))
.then(() => user);
}

/**
* A record marked for a move whose successor was never created. The
* player is signing in on the platform they were already on, so the
* move is moot and the mark has to come off - while it is there, every
* lookup but the Bungie-id one skips this record.
*/
if (
destinyGhostUser.movedTo !== undefined &&
destinyGhostUser.membershipType === user.membershipType
) {
await this.users.clearPlatformMove(
/** @type {import('../helpers/documents.js').CosmosDocument<User>} */ (
/** @type {unknown} */ (destinyGhostUser)
),
);

delete destinyGhostUser.movedTo;
}

/**
* `membershipType` is the partition key, so a changed platform is a
* move rather than an update - `updateUser` would look the document up
* under the new platform, find nothing, and throw.
*/
if (destinyGhostUser.membershipType !== user.membershipType) {
return await this.users
.movePlatform(
/** @type {import('../helpers/documents.js').CosmosDocument<User>} */ (
/** @type {unknown} */ (destinyGhostUser)
),
user,
)
.then(() => user);
}

Object.assign(destinyGhostUser, user);

return (
Expand Down
Loading
Loading