Coral 3.0.3

This commit is contained in:
Samuel Elliott
2025-07-22 01:33:03 +01:00
parent 2959c923bb
commit 57ce3c21de
11 changed files with 1181 additions and 471 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -311,6 +311,7 @@ export type FResult = {
f: string;
user?: {na_id: string; coral_user_id?: string;};
result: unknown;
encrypt_request_result?: Uint8Array;
} & ({
provider: 'flapg';
result: FlapgApiResponse;
@@ -328,6 +329,15 @@ interface ZncaApiOptions {
platform?: string;
version?: string;
user?: {na_id: string; coral_user_id?: string;};
encrypt_request?: EncryptRequestOptions;
}
interface EncryptRequestOptions {
url: string;
parameter: AccountLoginParameter | AccountTokenParameter | WebServiceTokenParameter;
}
export async function createZncaApi(options?: ZncaApiOptions) {
return getPreferredZncaApiFromEnvironment(options) ?? await getDefaultZncaApi(options);
}
export function getPreferredZncaApiFromEnvironment(options?: ZncaApiOptions): ZncaApi | null;

View File

@@ -198,6 +198,8 @@ const na_client_settings = {
].join(' '),
};
export type NintendoAccountUserMoon = NintendoAccountUser;
export class NintendoAccountSessionAuthorisationMoon extends NintendoAccountSessionAuthorisation {
protected constructor(
authorise_url: string,
@@ -220,7 +222,7 @@ export class NintendoAccountSessionAuthorisationMoon extends NintendoAccountSess
export interface MoonAuthData {
nintendoAccountToken: NintendoAccountToken;
user: NintendoAccountUser;
user: NintendoAccountUserMoon;
znma_version: string;
znma_build: string;
znma_useragent: string;

View File

@@ -11,6 +11,8 @@ const debug = createDebug('nxapi:api:na');
export class NintendoAccountSessionAuthorisation {
readonly scope: string;
result: NintendoAccountSessionAuthorisationResult | null = null;
protected constructor(
readonly client_id: string,
scope: string | string[],
@@ -20,13 +22,23 @@ export class NintendoAccountSessionAuthorisation {
readonly redirect_uri = 'npf' + client_id + '://auth',
) {
this.scope = typeof scope === 'string' ? scope : scope.join(' ');
Object.defineProperty(this, 'result', {enumerable: false});
}
async getSessionToken(code: string, state?: string): Promise<HasResponse<NintendoAccountSessionToken, Response>>
async getSessionToken(params: URLSearchParams): Promise<HasResponse<NintendoAccountSessionToken, Response>>
async getSessionToken(code: string | URLSearchParams | null, state?: string | null) {
if (this.result) {
throw new Error('NintendoAccountSessionAuthorisation already completed');
}
let result_state = state;
if (code instanceof URLSearchParams) {
if (code.get('state') !== this.state) {
const result_state = code.get('state');
if (result_state !== this.state) {
throw new TypeError('Invalid state');
}
@@ -46,6 +58,8 @@ export class NintendoAccountSessionAuthorisation {
throw new TypeError('Invalid code');
}
Object.defineProperty(this, 'result', {value: {state: result_state, code}, writable: false});
return getNintendoAccountSessionToken(code, this.verifier, this.client_id);
}
@@ -63,6 +77,11 @@ export class NintendoAccountSessionAuthorisation {
}
}
interface NintendoAccountSessionAuthorisationResult {
state: string | null | undefined;
code: string;
}
export class NintendoAccountSessionAuthorisationError extends Error {
constructor(readonly code: string, message?: string) {
super(message);
@@ -182,7 +201,7 @@ export async function getNintendoAccountToken(token: string, client_id: string)
return defineResponse(nintendoAccountToken, response);
}
export async function getNintendoAccountUser(token: NintendoAccountToken) {
export async function getNintendoAccountUser<S extends NintendoAccountScope>(token: NintendoAccountToken) {
debug('Getting Nintendo Account user info');
const [signal, cancel] = timeoutSignal();
@@ -201,7 +220,7 @@ export async function getNintendoAccountUser(token: NintendoAccountToken) {
throw await NintendoAccountErrorResponse.fromResponse(response, '[na] Non-200 status code');
}
const user = await response.json() as NintendoAccountUser | NintendoAccountError;
const user = await response.json() as NintendoAccountUser<S> | NintendoAccountError;
if ('errorCode' in user) {
throw new NintendoAccountErrorResponse('[na] ' + user.detail, response, user);
@@ -221,7 +240,7 @@ export interface NintendoAccountSessionTokenJwtPayload extends JwtPayload {
typ: 'session_token';
iss: 'https://accounts.nintendo.com';
/** Unknown - scopes the token is valid for? */
'st:scp': number[];
'st:scp': NintendoAccountJwtScope[];
/** Subject (Nintendo Account ID) */
sub: string;
exp: number;
@@ -231,7 +250,7 @@ export interface NintendoAccountSessionTokenJwtPayload extends JwtPayload {
}
export interface NintendoAccountToken {
scope: string[];
scope: NintendoAccountScope[];
token_type: 'Bearer';
id_token: string;
access_token?: string;
@@ -246,7 +265,7 @@ export interface NintendoAccountIdTokenJwtPayload extends JwtPayload {
aud: string;
iss: 'https://accounts.nintendo.com';
jti: string;
at_hash: string; // ??
at_hash: string;
typ: 'id_token';
country: string;
}
@@ -258,7 +277,7 @@ export interface NintendoAccountAccessTokenJwtPayload extends JwtPayload {
sub: string;
iat: number;
'ac:grt': number; // ??
'ac:scp': number[]; // ??
'ac:scp': NintendoAccountJwtScope[];
exp: number;
/** Audience (client ID) */
aud: string;
@@ -329,70 +348,121 @@ export enum NintendoAccountJwtScope {
// 'userNotificationMessage:anyClients:write' = -1,
}
export interface NintendoAccountUser {
emailOptedIn: boolean;
language: string;
country: string;
timezone: {
name: string;
id: string;
utcOffsetSeconds: number;
utcOffset: string;
};
region: null;
export interface NintendoAccountUser<S extends NintendoAccountScope = never> {
id: string;
nickname: string;
clientFriendsOptedIn: boolean;
mii: Mii | null;
iconUri: string;
/** requires scope user.screenName */
screenName: NintendoAccountScope.USER_SCREENNAME extends S ? string : undefined;
/** requires scope user.birthday */
birthday: NintendoAccountScope.USER_BIRTHDAY extends S ? string : undefined;
isChild: boolean;
eachEmailOptedIn: {
survey: {
updatedAt: number;
optedIn: boolean;
};
deals: {
updatedAt: number;
optedIn: boolean;
};
};
gender: NintendoAccountGender;
/** requires scope user.email */
email: NintendoAccountScope.USER_EMAIL extends S ? string : undefined;
emailVerified: boolean;
phoneNumberEnabled: boolean;
/** requires scope user.links[].id/user.links.*.id */
links:
NintendoAccountScope.USER_LINKS extends S ?
Partial<Record<NintendoAccountLinkType, NintendoAccountLink | null>> | null :
Partial<Record<NintendoAccountLinkTypes<S>, NintendoAccountLink | null>> | null | undefined;
country: string;
region: null;
language: string;
timezone: NintendoAccountTimezone;
/** requires scope user.mii */
mii: NintendoAccountScope.USER_MII extends S ? Mii | null : undefined;
/** requires scope user.mii */
candidateMiis: NintendoAccountScope.USER_MII extends S ? unknown[] : undefined;
emailOptedIn: boolean;
emailOptedInUpdatedAt: number;
eachEmailOptedIn: Record<NintendoAccountEmailType, NintendoAccountEmailOptedIn>;
clientFriendsOptedIn: boolean;
clientFriendsOptedInUpdatedAt: number;
analyticsOptedIn: boolean;
analyticsOptedInUpdatedAt: number;
analyticsPermissions: Record<NintendoAccountAnalyticsType, NintendoAccountAnalyticsPermission>;
createdAt: number;
updatedAt: number;
candidateMiis: unknown[];
}
enum NintendoAccountGender {
UNKNOWN = 'unknown',
FEMALE = 'female',
MALE = 'male',
}
enum NintendoAccountLinkType {
NINTENDO_NETWORK = 'nintendoNetwork',
APPLE = 'apple',
GOOGLE = 'google',
}
interface NintendoAccountLink {
id: string;
createdAt: number;
emailVerified: boolean;
analyticsPermissions: {
internalAnalysis: {
updatedAt: number;
permitted: boolean;
};
targetMarketing: {
updatedAt: number;
permitted: boolean;
};
};
emailOptedInUpdatedAt: number;
birthday: string;
screenName: string;
gender: string;
analyticsOptedInUpdatedAt: number;
analyticsOptedIn: boolean;
clientFriendsOptedInUpdatedAt: number;
}
type NintendoAccountLinkScope = {
[NintendoAccountScope.USER_LINKS_NNID]: NintendoAccountLinkType.NINTENDO_NETWORK;
};
type NintendoAccountLinkTypes<S extends NintendoAccountScope = NintendoAccountScope.USER_LINKS> =
S extends keyof NintendoAccountLinkScope ? NintendoAccountLinkScope[S] : never;
interface NintendoAccountTimezone {
id: string;
name: string;
utcOffset: string;
utcOffsetSeconds: number;
}
enum NintendoAccountEmailType {
SURVEY = 'survey',
DEALS = 'deals',
}
interface NintendoAccountEmailOptedIn {
optedIn: boolean;
updatedAt: number;
}
enum NintendoAccountAnalyticsType {
INTERNAL_ANALYSIS = 'internalAnalysis',
TARGET_MARKETING = 'targetMarketing',
}
interface NintendoAccountAnalyticsPermission {
updatedAt: number;
permitted: boolean;
}
export interface Mii {
favoriteColor: string;
id: string;
updatedAt: number;
clientId: '1cfe3a55ed8924d9';
type: 'profile';
favoriteColor: MiiColour;
coreData: {
'4': string;
};
clientId: '1cfe3a55ed8924d9';
imageUriTemplate: string;
storeData: {
'3': string;
};
imageUriTemplate: string;
imageOrigin: string;
etag: string;
type: 'profile';
updatedAt: number;
}
export enum MiiColour {
RED = 'red',
ORANGE = 'orange',
YELLOW = 'yellow',
YELLOWGREEN = 'yellowgreen',
GREEN = 'green',
BLUE = 'blue',
SKYBLUE = 'skyblue',
PINK = 'pink',
PURPLE = 'purple',
BROWN = 'brown',
WHITE = 'white',
BLACK = 'black',
}
export interface NintendoAccountAuthError {

View File

@@ -55,10 +55,10 @@ export class ErrorResponse<T = unknown> extends Error {
});
}
static async fromResponse(response: UndiciResponse, message: string) {
static async fromResponse<T>(response: UndiciResponse, message: string) {
const body = await response.arrayBuffer();
return new this(message, response, body);
return new this<T>(message, response, body);
}
}

View File

@@ -1,7 +1,7 @@
import { fetch, Response } from 'undici';
import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl } from './coral-types.js';
import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl, WebService_4, Media, Announcements_4, Friend_4 } from './coral-types.js';
import { defineResponse, ErrorResponse, ResponseSymbol } from './util.js';
import { CoralApiInterface, CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, ResponseDataSymbol, Result } from './coral.js';
import { AbstractCoralApi, CoralApiInterface, CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, RequestFlagAddPlatformSymbol, RequestFlagAddProductVersionSymbol, RequestFlagNoParameterSymbol, RequestFlagRequestIdSymbol, RequestFlags, ResponseDataSymbol, Result } from './coral.js';
import { NintendoAccountToken, NintendoAccountUser } from './na.js';
import { SavedToken } from '../common/auth/coral.js';
import createDebug from '../util/debug.js';
@@ -10,18 +10,25 @@ import { getAdditionalUserAgents, getUserAgent } from '../util/useragent.js';
const debug = createDebug('nxapi:api:znc-proxy');
export default class ZncProxyApi implements CoralApiInterface {
export default class ZncProxyApi extends AbstractCoralApi implements CoralApiInterface {
constructor(
private url: string,
private url: URL | string,
// ZncApi uses the NSO token (valid for a few hours)
// ZncProxyApi uses the Nintendo Account session token (valid for two years)
public token: string,
public useragent = getAdditionalUserAgents()
) {}
) {
super();
}
async fetchProxyApi<T = unknown>(url: URL | string, method = 'GET', body?: string, headers?: object) {
if (typeof url === 'string' && url.startsWith('/')) url = url.substring(1);
const base_url = typeof this.url === 'string' ? new URL(this.url) : this.url;
if (typeof this.url === 'string' && !base_url.pathname.endsWith('/')) base_url.pathname += '/';
async fetch<T = unknown>(url: string, method = 'GET', body?: string, headers?: object) {
const [signal, cancel] = timeoutSignal();
const response = await fetch(this.url + url, {
const response = await fetch(new URL(url, this.url), {
method,
headers: Object.assign({
'Authorization': 'na ' + this.token,
@@ -31,7 +38,8 @@ export default class ZncProxyApi implements CoralApiInterface {
signal,
}).finally(cancel);
debug('fetch %s %s, response %s', method, url, response.status);
const debug_url = typeof url === 'string' ? '/' + url : url.toString();
debug('fetch %s %s, response %s', method, debug_url, response.status);
if (!response.ok) {
throw await ZncProxyErrorResponse.fromResponse(response, '[zncproxy] Non-2xx status code');
@@ -42,70 +50,97 @@ export default class ZncProxyApi implements CoralApiInterface {
return defineResponse(data, response);
}
async call<T = unknown>(url: string, parameter = {}): Promise<Result<T>> {
throw new Error('Not supported in ZncProxyApi');
async call<T extends {}, R extends {}>(url: string, parameter: R & Partial<RequestFlags> = {} as R): Promise<Result<T>> {
const options: [string, unknown][] = [];
if (parameter[RequestFlagAddPlatformSymbol]) options.push(['add_platform', true]);
if (parameter[RequestFlagAddProductVersionSymbol]) options.push(['add_version', true]);
if (parameter[RequestFlagNoParameterSymbol]) options.push(['no_parameter', true]);
if (RequestFlagRequestIdSymbol in parameter) options.push(['request_id', parameter[RequestFlagRequestIdSymbol]]);
const result = await this.fetchProxyApi<{result: T}>('call', 'POST', JSON.stringify({
url,
parameter,
options: options.length ? Object.fromEntries(options) : undefined,
}));
return createResult(result, result.result);
}
async getAnnouncements() {
const result = await this.fetch<{announcements: Announcements}>('/announcements');
const result = await this.fetchProxyApi<{announcements: Announcements_4}>('announcements');
return createResult(result, result.announcements);
}
async getFriendList() {
const result = await this.fetch<{friends: Friend[]}>('/friends');
return createResult(result, result);
const result = await this.fetchProxyApi<{friends: Friend_4[]; extract_ids?: string[]}>('friends');
return createResult(result, Object.assign(result, {
extractFriendsIds: result.extract_ids ?? result.friends.slice(0, 10).map(f => f.nsaId),
}));
}
async addFavouriteFriend(nsa_id: string) {
const result = await this.fetch('/friend/' + nsa_id, 'POST', JSON.stringify({
const result = await this.fetchProxyApi('friend/' + nsa_id, 'PATCH', JSON.stringify({
isFavoriteFriend: true,
}));
return createResult(result, {});
}
async removeFavouriteFriend(nsa_id: string) {
const result = await this.fetch('/friend/' + nsa_id, 'POST', JSON.stringify({
const result = await this.fetchProxyApi('friend/' + nsa_id, 'PATCH', JSON.stringify({
isFavoriteFriend: false,
}));
return createResult(result, {});
}
async getWebServices() {
const result = await this.fetch<{webservices: WebService[]}>('/webservices');
const result = await this.fetchProxyApi<{webservices: WebService_4[]}>('webservices');
return createResult(result, result.webservices);
}
async getChats() {
const result = await this.fetchProxyApi<{chats: unknown[]}>('chats');
return createResult(result, result.chats);
}
async getMedia() {
const result = await this.fetchProxyApi<{media: Media[]}>('media');
return createResult(result, result);
}
async getActiveEvent() {
const result = await this.fetch<{activeevent: ActiveEvent}>('/activeevent');
const result = await this.fetchProxyApi<{activeevent: ActiveEvent}>('activeevent');
return createResult(result, result.activeevent);
}
async getEvent(id: number) {
const result = await this.fetch<{event: Event}>('/event/' + id);
const result = await this.fetchProxyApi<{event: Event}>('event/' + id);
return createResult(result, result.event);
}
async getUser(id: number) {
const result = await this.fetch<{user: User}>('/user/' + id);
const result = await this.fetchProxyApi<{user: User}>('user/' + id);
return createResult(result, result.user);
}
async getUserByFriendCode(friend_code: string, hash?: string) {
const result = await this.fetch<{user: FriendCodeUser}>('/friendcode/' + friend_code);
const result = await this.fetchProxyApi<{user: FriendCodeUser}>('friendcode/' + friend_code);
return createResult(result, result.user);
}
async sendFriendRequest(nsa_id: string): Promise<Result<{}>> {
throw new Error('Not supported in ZncProxyApi');
}
// async sendFriendRequest(nsa_id: string): Promise<Result<{}>> {
// throw new Error('Not supported in ZncProxyApi');
// }
async getCurrentUser() {
const result = await this.fetch<{user: CurrentUser}>('/user');
const result = await this.fetchProxyApi<{user: CurrentUser}>('user');
return createResult(result, result.user);
}
async getFriendCodeUrl() {
const result = await this.fetch<{friendcode: FriendCodeUrl}>('/friendcode');
const result = await this.fetchProxyApi<{friendcode: FriendCodeUrl}>('friendcode');
return createResult(result, result.friendcode);
}
@@ -118,14 +153,14 @@ export default class ZncProxyApi implements CoralApiInterface {
});
}
async updateCurrentUserPermissions(
to: PresencePermissions, from: PresencePermissions, etag: string
): Promise<Result<{}>> {
throw new Error('Not supported in ZncProxyApi');
}
// async updateCurrentUserPermissions(
// to: PresencePermissions, from: PresencePermissions, etag: string
// ): Promise<Result<{}>> {
// throw new Error('Not supported in ZncProxyApi');
// }
async getWebServiceToken(id: number) {
const result = await this.fetch<{token: WebServiceToken}>('/webservice/' + id + '/token');
const result = await this.fetchProxyApi<{token: WebServiceToken}>('webservice/' + id + '/token');
return createResult(result, result.token);
}
@@ -140,8 +175,8 @@ export default class ZncProxyApi implements CoralApiInterface {
}
async renewToken() {
const data = await this.fetch<SavedToken>('/auth');
data.proxy_url = this.url;
const data = await this.fetchProxyApi<SavedToken>('auth');
data.proxy_url = this.url.toString();
return data;
}
@@ -157,7 +192,7 @@ export default class ZncProxyApi implements CoralApiInterface {
static async createWithSessionToken(url: string, token: string) {
const nso = new this(url, token);
const data = await nso.fetch<SavedToken>('/auth');
const data = await nso.fetchProxyApi<SavedToken>('auth');
data.proxy_url = url;
return {nso, data};
@@ -190,6 +225,8 @@ export interface AuthToken {
created_at: number;
}
export interface AuthPolicy {
api?: boolean;
announcements?: boolean;
list_friends?: boolean;
list_friends_presence?: boolean;