first commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# sbi-client
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@repo/sbi-client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"module": "index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export { createMethodsFromSession } from './methods'
|
||||
export {
|
||||
SBI_SERVER_ERROR_MESSAGES,
|
||||
SbiServerError,
|
||||
getSbiServerErrorMessage,
|
||||
} from './methods/error-map'
|
||||
export type { SbiServerErrorOptions } from './methods/error-map'
|
||||
export { loginWithPasskey } from './session'
|
||||
export type {
|
||||
LoginWithPasskeyOptions,
|
||||
PasskeyLoginResponse,
|
||||
PlaintextStoredWebAuthnCredential,
|
||||
SbiClientOptions,
|
||||
StoredWebAuthnCredential,
|
||||
StoredWebAuthnCredentialSecret,
|
||||
WebAuthnAlgorithm,
|
||||
WebAuthnJwk,
|
||||
WebAuthnTransport,
|
||||
WebAuthnUserVerification,
|
||||
ChartPeriod,
|
||||
ChartPrice,
|
||||
IssueChart,
|
||||
IssueSearchItem,
|
||||
IssueSearchResult,
|
||||
IssueSearchStatus,
|
||||
} from './types'
|
||||
export type * from './methods/types'
|
||||
@@ -0,0 +1,134 @@
|
||||
export const SBI_SERVER_ERROR_MESSAGES: Record<string, string> = {
|
||||
ME0010: 'No data is available.',
|
||||
E_0001:
|
||||
'Access is currently unstable. Try logging in again later or use the desktop website or HYPER SBI.',
|
||||
E_0002: 'Communication failed. Check your network connection and try again.',
|
||||
E_0003: 'You were logged out because the session was inactive for 60 minutes. Sign in again.',
|
||||
E_0004: 'User name is required.',
|
||||
E_0005: 'Login password is required.',
|
||||
E_0007: 'The trading password is incorrect.',
|
||||
E_0008: 'The requested issue does not exist.',
|
||||
E_0009:
|
||||
'Login to this app is restricted. Remove the restriction from the SBI Securities website settings.',
|
||||
E_0010:
|
||||
'More than 1,000 cash position records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0011:
|
||||
'More than 1,000 cash position records cannot be displayed in this app. Try the all-records view.',
|
||||
E_0012: 'Order quantity is required.',
|
||||
E_0013: 'Order price is required.',
|
||||
E_0014: 'Stop order trigger condition is required.',
|
||||
E_0015: 'Trading password is required.',
|
||||
E_0016:
|
||||
'More than 1,000 margin position records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0017:
|
||||
'More than 1,000 margin position records cannot be displayed in this app. Try the all-records view.',
|
||||
E_0018: 'Issue name or issue code is required.',
|
||||
E_0019: 'There are no sellable cash shares.',
|
||||
E_0020: 'Failed to sync the watchlist. Sign in again to display the latest watchlist.',
|
||||
E_0021: 'No matching issues were found. Change the search criteria and try again.',
|
||||
E_0022:
|
||||
'More than 1,000 order inquiry records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0023:
|
||||
'More than 1,000 open order records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0024:
|
||||
'More than 1,000 same-day execution records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0025: 'No data is available.',
|
||||
E_0026: 'There are no closeable long margin positions.',
|
||||
E_0027: 'There are no closeable short margin positions.',
|
||||
E_0028: 'Watchlist name is required.',
|
||||
E_0029: 'Too many issues matched, so all issues cannot be displayed.',
|
||||
E_0030: 'An unexpected error occurred while fetching board prices.',
|
||||
E_0031: 'The session timed out.',
|
||||
E_0032: 'An unexpected error occurred.',
|
||||
E_0033: 'A required parameter is missing.',
|
||||
E_0034: 'Settings could not be retrieved.',
|
||||
E_0035: 'A connection or read timeout occurred.',
|
||||
E_0036:
|
||||
'Order reception timed out. The order may already have been submitted; check order inquiry.',
|
||||
E_0038: 'There are no margin positions available for stock receipt.',
|
||||
E_0039: 'There are no margin positions available for stock delivery.',
|
||||
E_0040: 'The order quantity exceeds the maximum input quantity.',
|
||||
E_0041: 'Failed to update the watchlist. It will be synced with the latest watchlist.',
|
||||
E_0042: 'Failed to sync the watchlist. Sign in again to display the latest watchlist.',
|
||||
E_0043: 'The selected index cannot be displayed on a chart.',
|
||||
E_0044: 'The selected issue cannot be traded on the PTS market.',
|
||||
E_0045: 'Margin trade type is not selected.',
|
||||
E_0046: 'The selected value is invalid. Select it again.',
|
||||
E_0047: 'The order quantity is invalid. Enter a valid half-width numeric quantity.',
|
||||
E_0048:
|
||||
'Buying power may be insufficient to order all theme component issues. Return to order input to change quantities.',
|
||||
E_0049:
|
||||
'The NISA investment limit may be insufficient to order all theme component issues. Return to order input to change quantities.',
|
||||
E_0050: 'A search error occurred.',
|
||||
E_0051: 'No target data is available.',
|
||||
E_0052: 'No details match the conditions.',
|
||||
E_0053: 'Failed to retrieve registered issues. The registered issue feature is unavailable.',
|
||||
E_0054: 'No watchlist is registered. Create one from the edit button.',
|
||||
E_0055:
|
||||
'The registered issue limit is 50. Delete an issue from notification settings before adding another.',
|
||||
E_0056: 'The registered issue limit is 50. Delete an issue before adding another.',
|
||||
E_0057: 'This issue is already registered.',
|
||||
E_0058: 'No matching issue was found.',
|
||||
E_0059: 'No issue is registered. Add an issue from the edit icon.',
|
||||
E_0060: 'OCO1 price is required.',
|
||||
E_0061: 'OCO2 stop order trigger condition is required.',
|
||||
E_0062: 'OCO2 price is required.',
|
||||
E_0063: 'IFD2 price is required.',
|
||||
E_0064: 'IFD2 stop order trigger condition is required.',
|
||||
E_0065: 'Order quantity is invalid.',
|
||||
E_0066: 'Order price is invalid.',
|
||||
E_0067: 'Stop order trigger condition is invalid.',
|
||||
E_0068: 'OCO1 price is invalid.',
|
||||
E_0069: 'OCO2 stop order trigger condition is invalid.',
|
||||
E_0070: 'OCO2 price is invalid.',
|
||||
E_0071: 'IFD2 price is invalid.',
|
||||
E_0072: 'IFD2 stop order trigger condition is invalid.',
|
||||
E_0073: 'Target value is required.',
|
||||
E_0074: 'Password reset is required. Complete the reset procedure on the website.',
|
||||
E_0075: 'An error occurred. Wait a while and try again.',
|
||||
E_0076:
|
||||
'Registering a new FIDO smartphone authentication credential will disable the previous one.',
|
||||
E_0077: 'Signed in with FIDO smartphone authentication.',
|
||||
E_0078:
|
||||
'FIDO smartphone authentication is not complete. Scan the QR code with the SBI Securities Smart App on another device, complete authentication, and try again.',
|
||||
E_0079: 'An error occurred. Start again from login.',
|
||||
E_0080: 'Login from an invalid device was detected. The app will exit.',
|
||||
E_0081:
|
||||
'Identity verification is not complete. Call the authentication phone number from the registered phone number.',
|
||||
E_0082: 'The request expired. Start the procedure again.',
|
||||
E_0083: 'An error occurred. Wait a while and try again.',
|
||||
}
|
||||
|
||||
export type SbiServerErrorOptions = {
|
||||
code: string
|
||||
status?: string
|
||||
serverMessage?: string
|
||||
trCode?: string
|
||||
requestUrl?: string
|
||||
}
|
||||
|
||||
export class SbiServerError extends Error {
|
||||
readonly code: string
|
||||
readonly status?: string
|
||||
readonly serverMessage?: string
|
||||
readonly englishMessage: string
|
||||
readonly trCode?: string
|
||||
readonly requestUrl?: string
|
||||
|
||||
constructor(options: SbiServerErrorOptions) {
|
||||
const englishMessage = getSbiServerErrorMessage(options.code)
|
||||
const serverMessage = options.serverMessage ? ` Server message: ${options.serverMessage}` : ''
|
||||
super(`SBI server error ${options.code}: ${englishMessage}${serverMessage}`)
|
||||
Object.setPrototypeOf(this, new.target.prototype)
|
||||
this.name = 'SbiServerError'
|
||||
this.code = options.code
|
||||
this.status = options.status
|
||||
this.serverMessage = options.serverMessage
|
||||
this.englishMessage = englishMessage
|
||||
this.trCode = options.trCode
|
||||
this.requestUrl = options.requestUrl
|
||||
}
|
||||
}
|
||||
|
||||
export const getSbiServerErrorMessage = (code: string) =>
|
||||
SBI_SERVER_ERROR_MESSAGES[code] ?? `SBI server returned error code ${code}.`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,517 @@
|
||||
import type {
|
||||
AccountProfile,
|
||||
AccountType,
|
||||
Board,
|
||||
BuyingPower,
|
||||
CashPositionList,
|
||||
ChartPeriod,
|
||||
DepositType,
|
||||
DomesticMarket,
|
||||
IssueCode,
|
||||
IssueChart,
|
||||
IssueSearchResult,
|
||||
MarginPositionList,
|
||||
MarginTradeSide,
|
||||
MarketCode,
|
||||
MarketIndex,
|
||||
NewsList,
|
||||
OrderId,
|
||||
OrderKind,
|
||||
OrderList,
|
||||
OrderPreview,
|
||||
OrderReceipt,
|
||||
OrderStatus,
|
||||
PositionId,
|
||||
ProfitLossSummary,
|
||||
Quote,
|
||||
Ranking,
|
||||
ThemeId,
|
||||
ThemeInvestmentList,
|
||||
TradeSide,
|
||||
Watchlist,
|
||||
} from '../types'
|
||||
|
||||
export type PagingOptions = {
|
||||
/** Start index for the result list. Defaults to the first item when omitted. */
|
||||
index?: number
|
||||
/** Maximum number of items to fetch. Uses the implementation default when omitted. */
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type DateRangeOptions = {
|
||||
/** Start date for the inquiry range. */
|
||||
from?: string
|
||||
/** End date for the inquiry range. */
|
||||
to?: string
|
||||
}
|
||||
|
||||
export type IssueOptions = {
|
||||
/** Issue code to request. */
|
||||
issueCode: IssueCode
|
||||
/** Market code to request. */
|
||||
market?: MarketCode
|
||||
}
|
||||
|
||||
export type MarketIssueBoardPollingOptions = IssueOptions & {
|
||||
/** Poll interval in seconds. Defaults to 5 seconds. */
|
||||
intervalSeconds?: number
|
||||
/** Stops the polling iterator when aborted. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type IssueChartOptions = IssueOptions & {
|
||||
/** Chart period. Defaults to daily candles. */
|
||||
period?: ChartPeriod
|
||||
/** Candle unit. Minute charts accept 1, 5, 10, or 15. Other periods use 1. */
|
||||
unit?: number
|
||||
/** Number of historical prices to request. Defaults to 120. */
|
||||
count?: number
|
||||
}
|
||||
|
||||
export type IssueSearchOptions = {
|
||||
/** Search text, such as an issue code, name, or keyword. */
|
||||
query: string
|
||||
/** Filters returned issues by market code on the client side. */
|
||||
market?: MarketCode
|
||||
/** Maximum number of returned issues after client-side filtering. */
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type CashPositionOptions = PagingOptions & {
|
||||
/** Filters cash positions by issue code. */
|
||||
issueCode?: IssueCode
|
||||
/** Filters cash positions by market code. */
|
||||
market?: MarketCode
|
||||
/** Filters cash positions by account type. */
|
||||
accountType?: AccountType
|
||||
}
|
||||
|
||||
export type MarginPositionOptions = PagingOptions & {
|
||||
/** Filters margin positions by issue code. */
|
||||
issueCode?: IssueCode
|
||||
/** Filters margin positions by market code. */
|
||||
market?: MarketCode
|
||||
/** Filters margin positions by short or long side. */
|
||||
side?: MarginTradeSide
|
||||
/** Filters margin positions by account type. */
|
||||
accountType?: AccountType
|
||||
}
|
||||
|
||||
export type OrderInquiryOptions = PagingOptions &
|
||||
DateRangeOptions & {
|
||||
/** Filters order inquiry results by issue code. */
|
||||
issueCode?: IssueCode
|
||||
/** Filters order inquiry results by market code. */
|
||||
market?: MarketCode
|
||||
/** Filters order inquiry results by order status. */
|
||||
status?: OrderStatus
|
||||
}
|
||||
|
||||
export type BoardOptions = IssueOptions & {
|
||||
/** Account type used when requesting board-order information. */
|
||||
accountType?: AccountType
|
||||
/** Trading action used when requesting board-order information. */
|
||||
side?:
|
||||
| 'cashBuy'
|
||||
| 'cashSell'
|
||||
| 'marginOpen'
|
||||
| 'marginOpenBuy'
|
||||
| 'marginOpenSell'
|
||||
| 'marginClose'
|
||||
| 'marginCloseBuy'
|
||||
| 'marginCloseSell'
|
||||
}
|
||||
|
||||
export type StockOrderBaseOptions = {
|
||||
/** Issue code to order. */
|
||||
issueCode: IssueCode
|
||||
/** Market code to order on. */
|
||||
market: MarketCode
|
||||
/** Previous market code sent with SOR orders. Defaults to the value returned at login. */
|
||||
sorLastMarket?: MarketCode
|
||||
/** Buy or sell side for the order. */
|
||||
side: TradeSide
|
||||
/** Account type used for the order. */
|
||||
accountType?: AccountType
|
||||
/** Order quantity. */
|
||||
quantity: number
|
||||
/** Deposit type used for the order. */
|
||||
depositType?: DepositType
|
||||
}
|
||||
|
||||
export type CashOrderPriceCondition =
|
||||
| 'limit'
|
||||
| 'limitAtOpen'
|
||||
| 'limitAtClose'
|
||||
| 'limitIoc'
|
||||
| 'market'
|
||||
| 'marketAtOpen'
|
||||
| 'marketAtClose'
|
||||
| 'marketIoc'
|
||||
| 'funari'
|
||||
|
||||
export type CashOrderTerm = 'day' | 'week' | 'date'
|
||||
|
||||
export type CashOrderTriggerZone = 'above' | 'below'
|
||||
|
||||
export type CashOrderMethod = 'normal' | 'stop' | 'oco'
|
||||
|
||||
export type StandardCashOrderOptions = StockOrderBaseOptions & {
|
||||
/** Order price for limit and other price-based orders. */
|
||||
price?: number
|
||||
/** Order kind, such as market or limit. */
|
||||
kind?: Exclude<OrderKind, 's'>
|
||||
/** APK/MTS execution condition, such as 指値, 寄指, IOC成, or 不成. */
|
||||
priceCondition?: CashOrderPriceCondition
|
||||
/** Order validity. `date` requires `orderDate` in yyyyMMdd or yyyy-MM-dd format. */
|
||||
orderTerm?: CashOrderTerm
|
||||
/** Explicit validity date used when `orderTerm` is `date`. */
|
||||
orderDate?: string
|
||||
/** Special order method. `stop` sends SLO and `oco` sends OCO. */
|
||||
orderMethod?: CashOrderMethod
|
||||
/** Stop trigger direction used by stop/OCO orders. */
|
||||
triggerZone?: CashOrderTriggerZone
|
||||
/** Stop trigger price used by stop/OCO orders. */
|
||||
triggerPrice?: number
|
||||
/** Secondary execution condition used by OCO orders. */
|
||||
secondaryPriceCondition?: CashOrderPriceCondition
|
||||
/** Secondary order price used by OCO price-based conditions. */
|
||||
secondaryPrice?: number
|
||||
}
|
||||
|
||||
export type SKabuOrderOptions = StockOrderBaseOptions & {
|
||||
/** Places the cash order as an S-kabu order. S-kabu cannot specify a price. */
|
||||
kind: 's'
|
||||
/** S-kabu cannot specify a price. */
|
||||
price?: never
|
||||
}
|
||||
|
||||
export type CashOrderOptions = StandardCashOrderOptions | SKabuOrderOptions
|
||||
|
||||
export type MarginOpenOrderOptions = StandardCashOrderOptions
|
||||
|
||||
export type ActualDeliveryKind = 'genbiki' | 'genwatashi'
|
||||
|
||||
export type ActualDeliveryOrderOptions = {
|
||||
/** Issue code to deliver. */
|
||||
issueCode: IssueCode
|
||||
/** Market code for the issue. */
|
||||
market: MarketCode
|
||||
/** Account type used for the order. */
|
||||
accountType?: AccountType
|
||||
/** Order quantity. */
|
||||
quantity: number
|
||||
/** Deposit type used for the order. */
|
||||
depositType?: DepositType
|
||||
/** Order price for price-based actual-delivery requests. */
|
||||
price?: number
|
||||
/** Actual-delivery action: `genbiki` for 現引, `genwatashi` for 現渡. */
|
||||
kind: ActualDeliveryKind
|
||||
/** Position ID to deliver. */
|
||||
positionId?: PositionId
|
||||
}
|
||||
|
||||
export type PlaceCashOrderOptions = CashOrderOptions & {
|
||||
/** Confirmation ID returned by the confirmation step. */
|
||||
confirmationId?: string
|
||||
/** Explicitly allows sending a live order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type PlaceMarginOpenOrderOptions = MarginOpenOrderOptions & {
|
||||
/** Confirmation ID returned by the confirmation step. */
|
||||
confirmationId?: string
|
||||
/** Explicitly allows sending a live margin open order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type PlaceActualDeliveryOrderOptions = ActualDeliveryOrderOptions & {
|
||||
/** Confirmation ID returned by the confirmation step. */
|
||||
confirmationId?: string
|
||||
/** Explicitly allows sending a live actual-delivery order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type OrderCorrectionOptions = {
|
||||
/** Order ID to correct. */
|
||||
orderId: OrderId
|
||||
/** Corrected order quantity. */
|
||||
quantity?: number
|
||||
/** Corrected order price. */
|
||||
price?: number
|
||||
}
|
||||
|
||||
export type PlaceOrderCorrectionOptions = OrderCorrectionOptions & {
|
||||
/** Explicitly allows sending a live correction request. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type OrderCancelOptions = {
|
||||
/** Order number shown in order inquiry. */
|
||||
orderNumber: string
|
||||
/** Original order ID shown in order inquiry. */
|
||||
orderId?: OrderId
|
||||
/** Original trade ID code. Defaults to cash stock when omitted. */
|
||||
tradeId?: string
|
||||
/** Additional cancel flag used by the mobile MTS route. */
|
||||
cancelType?: string
|
||||
}
|
||||
|
||||
export type PlaceOrderCancelOptions = OrderCancelOptions & {
|
||||
/** Trading password used by SBI to submit the cancellation. */
|
||||
tradePassword?: string
|
||||
/** Explicitly allows sending a live cancellation request. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type MarginCloseOrderOptions = StandardCashOrderOptions & {
|
||||
/** Position ID to close. */
|
||||
positionId?: PositionId
|
||||
}
|
||||
|
||||
export type PlaceMarginCloseOrderOptions = MarginCloseOrderOptions & {
|
||||
/** Explicitly allows sending a live margin close order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type MarginCloseSummaryOrderOptions = MarginCloseOrderOptions
|
||||
|
||||
export type PlaceMarginCloseSummaryOrderOptions = MarginCloseSummaryOrderOptions & {
|
||||
/** Explicitly allows sending a live margin close summary order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type IfdOrderOptions = StandardCashOrderOptions & {
|
||||
/** Product to use for the first IFD leg. Defaults to cash. */
|
||||
tradeType?: 'cash' | 'marginOpen'
|
||||
}
|
||||
|
||||
export type PlaceIfdOrderOptions = IfdOrderOptions & {
|
||||
/** Confirmation ID returned by the confirmation step. */
|
||||
confirmationId?: string
|
||||
/** Explicitly allows sending a live IFD order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type ThemeInvestmentOrderOptions = {
|
||||
/** Theme ID for the theme investment order. */
|
||||
themeId: ThemeId
|
||||
/** Buy or sell side for the order. */
|
||||
side: TradeSide
|
||||
/** Order amount for the theme investment order. */
|
||||
amount?: number
|
||||
}
|
||||
|
||||
export type PlaceThemeInvestmentOrderOptions = ThemeInvestmentOrderOptions & {
|
||||
/** Explicitly allows sending a live theme investment order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type AccountPowerOptions = {
|
||||
/** Fetches margin-account collateral details. Disable this for accounts without margin trading. */
|
||||
includeMarginAccount?: boolean
|
||||
}
|
||||
|
||||
export interface SbiClientMethodSession {
|
||||
/** Returns the current authenticated session profile. */
|
||||
profile(): Promise<AccountProfile>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodAccountPower {
|
||||
/** Fetches buying power, margin buying power, withdrawable amount, and related account power values. */
|
||||
buyingPower(options?: AccountPowerOptions): Promise<BuyingPower>
|
||||
/** Fetches the collateral ratio and related margin collateral details. */
|
||||
collateralRatio(options?: AccountPowerOptions): Promise<BuyingPower>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodAccountPositions {
|
||||
/** Fetches cash positions. */
|
||||
cash(options?: CashPositionOptions): Promise<CashPositionList>
|
||||
/** Fetches the alternate cash-position list used by the mobile app. */
|
||||
cashDetail(options?: CashPositionOptions): Promise<CashPositionList>
|
||||
/** Fetches cash positions for a specific issue. */
|
||||
cashForIssue(options: IssueOptions): Promise<CashPositionList>
|
||||
/** Fetches margin positions. */
|
||||
margin(options?: MarginPositionOptions): Promise<MarginPositionList>
|
||||
/** Fetches the alternate margin-position list used by the mobile app. */
|
||||
marginDetail(options?: MarginPositionOptions): Promise<MarginPositionList>
|
||||
/** Fetches margin positions for a specific issue. */
|
||||
marginForIssue(options: IssueOptions): Promise<MarginPositionList>
|
||||
/** Fetches margin positions for a specific issue aggregated by issue. */
|
||||
marginSummaryForIssue(options: IssueOptions): Promise<MarginPositionList>
|
||||
/** Fetches individual margin positions for a specific issue. */
|
||||
marginDetailsForIssue(options: IssueOptions): Promise<MarginPositionList>
|
||||
/** Fetches margin positions available for close orders. */
|
||||
closeableMargin(options: MarginPositionOptions): Promise<MarginPositionList>
|
||||
/** Fetches margin positions available for stock delivery. */
|
||||
deliverableMargin(options: MarginPositionOptions): Promise<MarginPositionList>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodAccountProfitLoss {
|
||||
/** Fetches the unrealized profit and loss summary for cash and margin positions. */
|
||||
unrealized(): Promise<ProfitLossSummary>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodAccount {
|
||||
/** Returns the current account profile. */
|
||||
profile(): Promise<AccountProfile>
|
||||
/** Methods for fetching buying power and collateral information. */
|
||||
power: SbiClientMethodAccountPower
|
||||
/** Methods for fetching cash and margin positions. */
|
||||
positions: SbiClientMethodAccountPositions
|
||||
/** Methods for fetching profit and loss information. */
|
||||
profitLoss: SbiClientMethodAccountProfitLoss
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarketIssue {
|
||||
/** Searches domestic issues by code, name, or keyword. */
|
||||
search(options: IssueSearchOptions): Promise<IssueSearchResult>
|
||||
/** Fetches issue suggestions for partial input. */
|
||||
suggest(options: IssueSearchOptions): Promise<IssueSearchResult>
|
||||
/** Fetches prices accepted as order input for an issue. */
|
||||
allowedPrices(options: IssueOptions): Promise<Quote>
|
||||
/** Fetches board information for an issue. */
|
||||
board(options: IssueOptions): Promise<Board>
|
||||
/** Polls board information for an issue using the same endpoint as `board`. */
|
||||
pollBoard(options: MarketIssueBoardPollingOptions): AsyncIterableIterator<Board>
|
||||
/** Fetches historical chart prices for an issue. */
|
||||
chart(options: IssueChartOptions): Promise<IssueChart>
|
||||
/** Fetches open orders for an issue. */
|
||||
openOrders(options: IssueOptions): Promise<OrderList>
|
||||
/** Fetches board and issue information useful before placing an order. */
|
||||
tradingInfo(options: BoardOptions): Promise<Board>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarketIndex {
|
||||
/** Fetches major market indexes. */
|
||||
major(): Promise<MarketIndex[]>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarketRanking {
|
||||
/** Fetches market rankings. */
|
||||
market(): Promise<Ranking>
|
||||
/** Fetches sector rankings. */
|
||||
sector(): Promise<Ranking>
|
||||
/** Fetches SBI-provided rankings. */
|
||||
sbi(): Promise<Ranking>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarket {
|
||||
/** Methods for fetching issue quotes, boards, and order-related market information. */
|
||||
issue: SbiClientMethodMarketIssue
|
||||
/** Methods for fetching market index information. */
|
||||
index: SbiClientMethodMarketIndex
|
||||
/** Fetches the domestic market overview. */
|
||||
overview(): Promise<DomesticMarket>
|
||||
/** Methods for fetching ranking information. */
|
||||
ranking: SbiClientMethodMarketRanking
|
||||
}
|
||||
|
||||
export interface SbiClientMethodNews {
|
||||
/** Fetches news items. */
|
||||
list(): Promise<NewsList>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodWatchlist {
|
||||
/** Fetches registered watchlists. */
|
||||
list(): Promise<Watchlist[]>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodOrderInquiry {
|
||||
/** Fetches orders executed today. */
|
||||
executionsToday(options?: OrderInquiryOptions): Promise<OrderList>
|
||||
/** Fetches open or recently active orders. */
|
||||
open(options?: OrderInquiryOptions): Promise<OrderList>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodCashOrder {
|
||||
/** Estimates a cash order without submitting a live order. */
|
||||
estimate(options: CashOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live cash order. Requires `allowTrading: true`. */
|
||||
place(options: PlaceCashOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a cash order correction without submitting a live correction. */
|
||||
estimateCorrection(options: OrderCorrectionOptions): Promise<OrderPreview>
|
||||
/** Estimates the mobile correction-confirmation route without submitting a live correction. */
|
||||
estimateCorrectionConfirm(options: OrderCorrectionOptions): Promise<OrderPreview>
|
||||
/** Places a live cash order correction. Requires `allowTrading: true`. */
|
||||
placeCorrection(options: PlaceOrderCorrectionOptions): Promise<OrderReceipt>
|
||||
/** Estimates a cash order cancellation without submitting a live cancellation. */
|
||||
estimateCancel(options: OrderCancelOptions): Promise<OrderPreview>
|
||||
/** Places a live cash order cancellation. Requires `allowTrading: true`. */
|
||||
placeCancel(options: PlaceOrderCancelOptions): Promise<OrderReceipt>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarginOrder {
|
||||
/** Estimates a margin-open order without submitting a live order. */
|
||||
estimateOpen(options: MarginOpenOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live margin-open order. Requires `allowTrading: true`. */
|
||||
open(options: PlaceMarginOpenOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a margin close order without submitting a live order. */
|
||||
estimateClose(options: MarginCloseOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live margin close order. Requires `allowTrading: true`. */
|
||||
close(options: PlaceMarginCloseOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a margin close order by position summary without submitting a live order. */
|
||||
estimateCloseSummary(options: MarginCloseOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live margin close order by position summary. Requires `allowTrading: true`. */
|
||||
closeSummary(options: PlaceMarginCloseOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a mobile margin close summary order without submitting a live order. */
|
||||
estimateSummary(options: MarginCloseSummaryOrderOptions): Promise<OrderPreview>
|
||||
/** Places a mobile margin close summary order. Requires `allowTrading: true`. */
|
||||
placeSummary(options: PlaceMarginCloseSummaryOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a genbiki/genwatashi actual-delivery order without submitting a live order. */
|
||||
estimateActualDelivery(options: ActualDeliveryOrderOptions): Promise<OrderPreview>
|
||||
/** Places a genbiki/genwatashi actual-delivery order. Requires `allowTrading: true`. */
|
||||
actualDelivery(options: PlaceActualDeliveryOrderOptions): Promise<OrderReceipt>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodIfdOrder {
|
||||
/** Estimates an IFD order without submitting a live order. */
|
||||
estimate(options: IfdOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live IFD order. Requires `allowTrading: true`. */
|
||||
place(options: PlaceIfdOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates an IFD order correction without submitting a live correction. */
|
||||
estimateCorrection(options: OrderCorrectionOptions): Promise<OrderPreview>
|
||||
/** Places a live IFD order correction. Requires `allowTrading: true`. */
|
||||
placeCorrection(options: PlaceOrderCorrectionOptions): Promise<OrderReceipt>
|
||||
/** Estimates an IFD order cancellation without submitting a live cancellation. */
|
||||
estimateCancel(options: OrderCorrectionOptions): Promise<OrderPreview>
|
||||
/** Places a live IFD order cancellation. Requires `allowTrading: true`. */
|
||||
placeCancel(options: PlaceOrderCorrectionOptions): Promise<OrderReceipt>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodThemeInvestmentOrder {
|
||||
/** Fetches theme investment holdings or order targets. */
|
||||
list(): Promise<ThemeInvestmentList>
|
||||
/** Estimates a theme investment order without submitting a live order. */
|
||||
estimate(options: ThemeInvestmentOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live theme investment order. Requires `allowTrading: true`. */
|
||||
place(options: PlaceThemeInvestmentOrderOptions): Promise<OrderReceipt>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodOrders {
|
||||
/** Methods for order inquiries. */
|
||||
inquiry: SbiClientMethodOrderInquiry
|
||||
/** Methods for estimating, placing, and correcting cash orders. */
|
||||
cash: SbiClientMethodCashOrder
|
||||
/** Methods for estimating and placing margin orders. */
|
||||
margin: SbiClientMethodMarginOrder
|
||||
/** Methods for estimating, placing, and correcting IFD orders. */
|
||||
ifd: SbiClientMethodIfdOrder
|
||||
/** Methods for estimating and placing theme investment orders. */
|
||||
themeInvestment: SbiClientMethodThemeInvestmentOrder
|
||||
}
|
||||
|
||||
export interface SbiClientMethods {
|
||||
/** Session-related methods. */
|
||||
session: SbiClientMethodSession
|
||||
/** Methods for account profile, buying power, positions, and profit and loss. */
|
||||
account: SbiClientMethodAccount
|
||||
/** Methods for market overviews, issues, indexes, and rankings. */
|
||||
market: SbiClientMethodMarket
|
||||
/** News methods. */
|
||||
news: SbiClientMethodNews
|
||||
/** Watchlist methods. */
|
||||
watchlist: SbiClientMethodWatchlist
|
||||
/** Methods for order history, estimates, live placement, and corrections. */
|
||||
orders: SbiClientMethodOrders
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import {
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
generateKeyPairSync,
|
||||
privateDecrypt,
|
||||
sign,
|
||||
constants,
|
||||
} from 'node:crypto'
|
||||
import { mkdtempSync, writeFileSync, unlinkSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createMethodsFromSession, registerDeviceId } from '../methods'
|
||||
import type {
|
||||
AccountProfile,
|
||||
LoginWithPasskeyOptions,
|
||||
PasskeyLoginResponse,
|
||||
PlaintextStoredWebAuthnCredential,
|
||||
SbiClientOptions,
|
||||
SbiSession,
|
||||
} from '../types'
|
||||
import type { SbiClientMethods } from '../methods/types'
|
||||
|
||||
type PasskeyLoginStart = {
|
||||
url: string
|
||||
privateKeyPem: string
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
type CredentialRequest = {
|
||||
challenge: string
|
||||
rpId: string
|
||||
csrfToken?: string
|
||||
}
|
||||
|
||||
type SbiEndpointConfig = {
|
||||
authBaseUrl: string
|
||||
mtsBaseUrl: string
|
||||
izanagiBaseUrl?: string
|
||||
}
|
||||
|
||||
export const loginWithPasskey = async (
|
||||
options: LoginWithPasskeyOptions,
|
||||
clientOptions: SbiClientOptions = {},
|
||||
): Promise<SbiClientMethods> => {
|
||||
const session = await createPasskeySession(options, clientOptions)
|
||||
return createMethodsFromSession(session)
|
||||
}
|
||||
|
||||
export const createPasskeySession = async (
|
||||
options: LoginWithPasskeyOptions,
|
||||
clientOptions: SbiClientOptions = {},
|
||||
): Promise<SbiSession> => {
|
||||
const endpoints = resolveSbiEndpointConfig(options)
|
||||
const started = startPasskeyLogin(endpoints.authBaseUrl)
|
||||
const jar = new CookieJar()
|
||||
const headers = defaultBrowserHeaders()
|
||||
|
||||
const entry = await fetchWithCookies(started.url, {
|
||||
jar,
|
||||
headers: {
|
||||
...headers,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'upgrade-insecure-requests': '1',
|
||||
},
|
||||
})
|
||||
const entryText = await entry.text()
|
||||
const csrfToken = extractCsrfToken(entryText)
|
||||
|
||||
const challengeUrl = new URL('/api/fido2/auth/challenge', started.url)
|
||||
challengeUrl.searchParams.set('cccid', 'kabu-app')
|
||||
const challengeResponse = await fetchWithCookies(challengeUrl, {
|
||||
jar,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
origin: new URL(started.url).origin,
|
||||
referer: started.url,
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
},
|
||||
})
|
||||
assertOk(challengeResponse, 'passkey challenge')
|
||||
|
||||
const challengeJson = await challengeResponse.json()
|
||||
const credentialRequest = normalizeCredentialRequest(challengeJson, options.passkeyCredential)
|
||||
const assertion = createWebAuthnAssertion(options.passkeyCredential, credentialRequest)
|
||||
const csrf = credentialRequest.csrfToken ?? csrfToken
|
||||
if (!csrf) throw new Error('missing CSRF token for passkey authentication')
|
||||
|
||||
const authUrl = new URL('/fido2/auth', started.url)
|
||||
authUrl.searchParams.set('cccid', 'kabu-app')
|
||||
const authResponse = await fetchWithCookies(authUrl, {
|
||||
jar,
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
...headers,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
origin: new URL(started.url).origin,
|
||||
referer: started.url,
|
||||
'upgrade-insecure-requests': '1',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
_csrf: csrf,
|
||||
id: assertion.id,
|
||||
rawId: assertion.rawId,
|
||||
clientDataJSON: assertion.clientDataJSON,
|
||||
authenticatorData: assertion.authenticatorData,
|
||||
signature: assertion.signature,
|
||||
userHandle: assertion.userHandle,
|
||||
type: 'public-key',
|
||||
}),
|
||||
})
|
||||
|
||||
const channelUrl = new URL(
|
||||
authResponse.headers.get('location') ?? '/sso/channel?cccid=kabu-app',
|
||||
started.url,
|
||||
)
|
||||
const channelResponse = await fetchWithCookies(channelUrl, {
|
||||
jar,
|
||||
headers: {
|
||||
...headers,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
referer: authUrl.toString(),
|
||||
'upgrade-insecure-requests': '1',
|
||||
},
|
||||
})
|
||||
assertOk(channelResponse, 'passkey callback')
|
||||
|
||||
const channelHtml = await channelResponse.text()
|
||||
const callbackUrl = extractCallbackUrl(channelHtml)
|
||||
if (!callbackUrl) throw new Error('passkey callback token was not found in sso/channel response')
|
||||
|
||||
const loginResponse = await finishPasskeyLogin({
|
||||
callbackUrl,
|
||||
privateKeyPem: started.privateKeyPem,
|
||||
mtsBaseUrl: endpoints.mtsBaseUrl,
|
||||
})
|
||||
const profile = parsePasskeyLoginProfile(loginResponse)
|
||||
const session: SbiSession = {
|
||||
mtsBaseUrl: endpoints.mtsBaseUrl,
|
||||
izanagiBaseUrl: endpoints.izanagiBaseUrl,
|
||||
profile,
|
||||
loginResponse,
|
||||
tradePassword: clientOptions.tradePassword,
|
||||
tradeAuthentication: clientOptions.tradeAuthentication,
|
||||
}
|
||||
if (clientOptions.deviceId) {
|
||||
await registerDeviceId(session, clientOptions.deviceId)
|
||||
session.deviceIdRegistered = true
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
const resolveSbiEndpointConfig = (options: LoginWithPasskeyOptions): SbiEndpointConfig => {
|
||||
const authBaseUrl = options.authBaseUrl ?? process.env.SBI_AUTH_BASE_URL
|
||||
const mtsBaseUrl = options.mtsBaseUrl ?? process.env.SBI_MTS_BASE_URL
|
||||
const izanagiBaseUrl = options.izanagiBaseUrl ?? process.env.SBI_IZANAGI_BASE_URL
|
||||
|
||||
if (!authBaseUrl) throw new Error('SBI_AUTH_BASE_URL is required')
|
||||
if (!mtsBaseUrl) throw new Error('SBI_MTS_BASE_URL is required')
|
||||
|
||||
return { authBaseUrl, mtsBaseUrl, izanagiBaseUrl: optionalUrl(izanagiBaseUrl) }
|
||||
}
|
||||
|
||||
const optionalUrl = (value: string | undefined) => {
|
||||
if (!value) return undefined
|
||||
return new URL(value).toString()
|
||||
}
|
||||
|
||||
const startPasskeyLogin = (authBaseUrl: string): PasskeyLoginStart => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicExponent: 0x10001,
|
||||
publicKeyEncoding: { type: 'spki', format: 'der' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
})
|
||||
|
||||
const publicKeyParam = base64Url(publicKey, true)
|
||||
const url = new URL(authBaseUrl)
|
||||
url.searchParams.set('channel', 'kabu-app')
|
||||
url.searchParams.set('pk', publicKeyParam)
|
||||
url.searchParams.set('ap', 'true')
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
privateKeyPem: privateKey,
|
||||
publicKey: publicKeyParam,
|
||||
}
|
||||
}
|
||||
|
||||
const finishPasskeyLogin = async (options: {
|
||||
callbackUrl: string
|
||||
privateKeyPem: string
|
||||
mtsBaseUrl: string
|
||||
}): Promise<PasskeyLoginResponse> => {
|
||||
const encryptedToken = extractEncryptedToken(options.callbackUrl)
|
||||
if (!encryptedToken) {
|
||||
throw new Error('callbackUrl must contain token=...')
|
||||
}
|
||||
|
||||
const accessToken = decryptPasskeyToken(encryptedToken, options.privateKeyPem)
|
||||
const requestUrl = new URL('/mtsmobile/ssologingate', options.mtsBaseUrl)
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
KIND: 'L',
|
||||
TOKEN: accessToken,
|
||||
}),
|
||||
})
|
||||
|
||||
const body = await response.arrayBuffer()
|
||||
const text = decodeShiftJis(body)
|
||||
|
||||
return {
|
||||
type: 'passkey-login-response',
|
||||
requestUrl: requestUrl.toString(),
|
||||
status: response.status,
|
||||
body,
|
||||
text,
|
||||
header: parseMtsHeader(text),
|
||||
}
|
||||
}
|
||||
|
||||
const extractEncryptedToken = (callbackUrl: string) => {
|
||||
const url = new URL(callbackUrl)
|
||||
if (url.searchParams.get('cmd') === 'pwlogin') return undefined
|
||||
return url.searchParams.get('token') ?? undefined
|
||||
}
|
||||
|
||||
const parsePasskeyLoginProfile = (response: PasskeyLoginResponse): AccountProfile => {
|
||||
const buffer = Buffer.from(response.body)
|
||||
const header = response.header
|
||||
let offset = 70
|
||||
|
||||
const loginStatusRaw = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
|
||||
if (loginStatusRaw === '7') {
|
||||
const trId = readShiftJisField(buffer, offset, 20)
|
||||
offset += 20
|
||||
const txId = readShiftJisField(buffer, offset, 36)
|
||||
offset += 36
|
||||
const actionToken = readShiftJisField(buffer, offset, 36)
|
||||
offset += 36
|
||||
const securityAuthenticationResponseCode = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const fidoResponseCode = readShiftJisField(buffer, offset, 4)
|
||||
|
||||
return {
|
||||
session: {
|
||||
sessionId: header?.sessionId ?? '',
|
||||
loginType: 'passkey',
|
||||
resultCode: header?.resultCode ?? '',
|
||||
},
|
||||
loginStatus: mapLoginStatus(loginStatusRaw),
|
||||
loginType: 'passkey',
|
||||
securityAuthenticationResponseCode: emptyToUndefined(securityAuthenticationResponseCode),
|
||||
fidoResponseCode: emptyToUndefined(fidoResponseCode),
|
||||
trId: emptyToUndefined(trId),
|
||||
txId: emptyToUndefined(txId),
|
||||
actionToken: emptyToUndefined(actionToken),
|
||||
}
|
||||
}
|
||||
|
||||
const butenCode = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const accountNumber = readShiftJisField(buffer, offset, 7)
|
||||
offset += 7
|
||||
const nextField = readShiftJisField(buffer, offset, 2)
|
||||
if (isSpecificAccountTypeRaw(nextField)) {
|
||||
const specificAccountTypeRaw = nextField
|
||||
offset += 2
|
||||
const marginAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const userId = readShiftJisField(buffer, offset, 32)
|
||||
offset += 32
|
||||
const nisaAccountRaw = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const jrNisaAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const jrNisaSpecificRaw = readShiftJisField(buffer, offset, 2)
|
||||
offset += 2
|
||||
const jrNisaSeigen = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const sorDefaultCode = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const sorLastMarket = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const sorLastMarketJrNisa = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const importantNoticeFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const noticeCount = parseIntOrUndefined(readShiftJisField(buffer, offset, 8))
|
||||
offset += 8
|
||||
const restrictedTradeFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const deficitMessageFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const deficitMessage = readShiftJisField(buffer, offset, 1500)
|
||||
offset += 1500
|
||||
const referenceableMaintenanceFlag = readShiftJisField(buffer, offset, 1)
|
||||
|
||||
return {
|
||||
session: {
|
||||
sessionId: header?.sessionId ?? '',
|
||||
loginType: 'passkey',
|
||||
resultCode: header?.resultCode ?? '',
|
||||
},
|
||||
branchCode: emptyToUndefined(butenCode),
|
||||
butenCode: emptyToUndefined(butenCode),
|
||||
accountNumber: emptyToUndefined(accountNumber),
|
||||
userId: emptyToUndefined(userId),
|
||||
loginStatus: mapLoginStatus(loginStatusRaw),
|
||||
loginType: 'passkey',
|
||||
accountType: mapSpecificAccountToAccountType(specificAccountTypeRaw),
|
||||
specificAccountType: mapSpecificAccountType(specificAccountTypeRaw),
|
||||
hasMarginAccount: marginAccount === '1',
|
||||
marginAccount: emptyToUndefined(marginAccount),
|
||||
nisa: {
|
||||
enabled: nisaAccountRaw !== '0' && nisaAccountRaw !== '',
|
||||
tradePermitted: nisaAccountRaw === '1' || nisaAccountRaw === '2' || nisaAccountRaw === '3',
|
||||
juniorEnabled: jrNisaAccount === '1',
|
||||
accountType: mapIsaAccountType(nisaAccountRaw),
|
||||
jrNisaAccount: emptyToUndefined(jrNisaAccount),
|
||||
jrNisaSpecific: mapSpecificAccountType(jrNisaSpecificRaw),
|
||||
jrNisaSeigen: emptyToUndefined(jrNisaSeigen),
|
||||
},
|
||||
sor: {
|
||||
defaultEnabled: sorDefaultCode === '1',
|
||||
defaultCode: emptyToUndefined(sorDefaultCode),
|
||||
lastMarket: emptyToUndefined(sorLastMarket),
|
||||
juniorNisaLastMarket: emptyToUndefined(sorLastMarketJrNisa),
|
||||
},
|
||||
notices: {
|
||||
hasImportantNotice: importantNoticeFlag === '1',
|
||||
importantNoticeFlag: emptyToUndefined(importantNoticeFlag),
|
||||
count: noticeCount,
|
||||
},
|
||||
restrictions: {
|
||||
tradeRestricted: restrictedTradeFlag === '1',
|
||||
restrictedTradeFlag: emptyToUndefined(restrictedTradeFlag),
|
||||
},
|
||||
deficit: {
|
||||
hasMessage: deficitMessageFlag === '1',
|
||||
messageFlag: emptyToUndefined(deficitMessageFlag),
|
||||
message: emptyToUndefined(deficitMessage),
|
||||
},
|
||||
maintenance: {
|
||||
referenceable: referenceableMaintenanceFlag === '1',
|
||||
referenceableMaintenanceFlag: emptyToUndefined(referenceableMaintenanceFlag),
|
||||
},
|
||||
}
|
||||
}
|
||||
const expireDate = readShiftJisField(buffer, offset, 8)
|
||||
offset += 8
|
||||
const lastLoginDate = readShiftJisField(buffer, offset, 8)
|
||||
offset += 8
|
||||
const lastLoginTime = readShiftJisField(buffer, offset, 6)
|
||||
offset += 6
|
||||
const corporateFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const specificAccountTypeRaw = readShiftJisField(buffer, offset, 2)
|
||||
offset += 2
|
||||
const marginAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const commissionPlan = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const userId = readShiftJisField(buffer, offset, 32)
|
||||
offset += 32
|
||||
const deficitMessage = readShiftJisField(buffer, offset, 1500)
|
||||
offset += 1500
|
||||
const tradingPassword = readShiftJisField(buffer, offset, 32)
|
||||
offset += 32
|
||||
const importantNoticeFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const restrictedTradeFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const noticeCount = parseIntOrUndefined(readShiftJisField(buffer, offset, 8))
|
||||
offset += 8
|
||||
const restrictedMessage = readShiftJisField(buffer, offset, 500)
|
||||
offset += 500
|
||||
const fxShareCol = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const deficitMessageFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const nisaAccountRaw = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const jrNisaAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const jrNisaSpecificRaw = readShiftJisField(buffer, offset, 2)
|
||||
offset += 2
|
||||
const jrNisaSeigen = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const fullTerm = readShiftJisField(buffer, offset, 8)
|
||||
offset += 8
|
||||
const fullAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
offset += 8
|
||||
offset += 1
|
||||
const sorDefaultCode = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const sorLastMarket = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const sorLastMarketJrNisa = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const securityAuthenticationResponseCode = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const fidoResponseCode = readShiftJisField(buffer, offset, 4)
|
||||
offset += 4
|
||||
const referenceableMaintenanceFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const passkeyStatus = readShiftJisField(buffer, offset, 1)
|
||||
|
||||
return {
|
||||
session: {
|
||||
sessionId: header?.sessionId ?? '',
|
||||
loginType: 'passkey',
|
||||
resultCode: header?.resultCode ?? '',
|
||||
},
|
||||
branchCode: emptyToUndefined(butenCode),
|
||||
butenCode: emptyToUndefined(butenCode),
|
||||
accountNumber: emptyToUndefined(accountNumber),
|
||||
userId: emptyToUndefined(userId),
|
||||
loginStatus: mapLoginStatus(loginStatusRaw),
|
||||
loginType: 'passkey',
|
||||
accountType: mapSpecificAccountToAccountType(specificAccountTypeRaw),
|
||||
specificAccountType: mapSpecificAccountType(specificAccountTypeRaw),
|
||||
hasMarginAccount: marginAccount === '1',
|
||||
marginAccount: emptyToUndefined(marginAccount),
|
||||
corporateFlag: emptyToUndefined(corporateFlag),
|
||||
commissionPlan: emptyToUndefined(commissionPlan),
|
||||
expireDate: emptyToUndefined(expireDate),
|
||||
lastLoginDate: emptyToUndefined(lastLoginDate),
|
||||
lastLoginTime: emptyToUndefined(lastLoginTime),
|
||||
tradingPassword: emptyToUndefined(tradingPassword),
|
||||
fxShareCol: emptyToUndefined(fxShareCol),
|
||||
fullTerm: emptyToUndefined(fullTerm),
|
||||
fullAccount: emptyToUndefined(fullAccount),
|
||||
securityAuthenticationResponseCode: emptyToUndefined(securityAuthenticationResponseCode),
|
||||
fidoResponseCode: emptyToUndefined(fidoResponseCode),
|
||||
passkeyStatus: emptyToUndefined(passkeyStatus),
|
||||
nisa: {
|
||||
enabled: nisaAccountRaw !== '0' && nisaAccountRaw !== '',
|
||||
tradePermitted: nisaAccountRaw === '1' || nisaAccountRaw === '2' || nisaAccountRaw === '3',
|
||||
juniorEnabled: jrNisaAccount === '1',
|
||||
accountType: mapIsaAccountType(nisaAccountRaw),
|
||||
jrNisaAccount: emptyToUndefined(jrNisaAccount),
|
||||
jrNisaSpecific: mapSpecificAccountType(jrNisaSpecificRaw),
|
||||
jrNisaSeigen: emptyToUndefined(jrNisaSeigen),
|
||||
},
|
||||
sor: {
|
||||
defaultEnabled: sorDefaultCode === '1',
|
||||
defaultCode: emptyToUndefined(sorDefaultCode),
|
||||
lastMarket: emptyToUndefined(sorLastMarket),
|
||||
juniorNisaLastMarket: emptyToUndefined(sorLastMarketJrNisa),
|
||||
},
|
||||
notices: {
|
||||
hasImportantNotice: importantNoticeFlag === '1',
|
||||
importantNoticeFlag: emptyToUndefined(importantNoticeFlag),
|
||||
count: noticeCount,
|
||||
},
|
||||
restrictions: {
|
||||
tradeRestricted: restrictedTradeFlag === '1',
|
||||
restrictedTradeFlag: emptyToUndefined(restrictedTradeFlag),
|
||||
message: emptyToUndefined(restrictedMessage),
|
||||
},
|
||||
deficit: {
|
||||
hasMessage: deficitMessageFlag === '1',
|
||||
messageFlag: emptyToUndefined(deficitMessageFlag),
|
||||
message: emptyToUndefined(deficitMessage),
|
||||
},
|
||||
maintenance: {
|
||||
referenceable: referenceableMaintenanceFlag === '1',
|
||||
referenceableMaintenanceFlag: emptyToUndefined(referenceableMaintenanceFlag),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const readShiftJisField = (buffer: Buffer, offset: number, length: number) => {
|
||||
if (offset >= buffer.length) return ''
|
||||
return decodeShiftJis(buffer.subarray(offset, Math.min(offset + length, buffer.length)))
|
||||
.replaceAll('\u0000', '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const emptyToUndefined = (value: string) => (value.length > 0 ? value : undefined)
|
||||
|
||||
const parseIntOrUndefined = (value: string) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
const mapLoginStatus = (value: string): AccountProfile['loginStatus'] => {
|
||||
switch (value) {
|
||||
case '0':
|
||||
return 'success'
|
||||
case '1':
|
||||
return 'invalidUser'
|
||||
case '2':
|
||||
return 'tradeForbidden'
|
||||
case '5':
|
||||
return 'locked'
|
||||
case '6':
|
||||
return 'fidoAuthorizationIncorrect'
|
||||
case '7':
|
||||
return 'fidoAuthorization'
|
||||
case '8':
|
||||
return 'passwordChangeRequired'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const mapSpecificAccountType = (value: string): AccountProfile['specificAccountType'] => {
|
||||
switch (value) {
|
||||
case '00':
|
||||
return 'withHolding'
|
||||
case '01':
|
||||
return 'withoutHolding'
|
||||
case '10':
|
||||
return 'nonSpecific'
|
||||
case '-':
|
||||
return 'notApply'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const isSpecificAccountTypeRaw = (value: string) =>
|
||||
value === '00' || value === '01' || value === '10' || value === '-'
|
||||
|
||||
const mapSpecificAccountToAccountType = (value: string): AccountProfile['accountType'] => {
|
||||
switch (mapSpecificAccountType(value)) {
|
||||
case 'withHolding':
|
||||
case 'withoutHolding':
|
||||
return 'specific'
|
||||
case 'nonSpecific':
|
||||
return 'general'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const mapIsaAccountType = (value: string): NonNullable<AccountProfile['nisa']>['accountType'] => {
|
||||
switch (value) {
|
||||
case '0':
|
||||
return 'nisaTradeForbidden'
|
||||
case '1':
|
||||
return 'oldNisaTradePermitted'
|
||||
case '2':
|
||||
return 'newNisaTradePermitted'
|
||||
case '3':
|
||||
return 'nisaTradePermitted'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const defaultBrowserHeaders = () => ({
|
||||
'accept-language': 'ja,en-US;q=0.9,en;q=0.8',
|
||||
'cache-control': 'no-cache',
|
||||
pragma: 'no-cache',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36',
|
||||
})
|
||||
|
||||
class CookieJar {
|
||||
#cookies = new Map<string, string>()
|
||||
|
||||
apply(response: Response) {
|
||||
const getSetCookie = (response.headers as Headers & { getSetCookie?: () => string[] })
|
||||
.getSetCookie
|
||||
const values = getSetCookie
|
||||
? getSetCookie.call(response.headers)
|
||||
: splitSetCookie(response.headers.get('set-cookie'))
|
||||
for (const value of values) {
|
||||
const pair = value.split(';', 1)[0]
|
||||
if (!pair) continue
|
||||
const index = pair.indexOf('=')
|
||||
if (index <= 0) continue
|
||||
this.#cookies.set(pair.slice(0, index), pair.slice(index + 1))
|
||||
}
|
||||
}
|
||||
|
||||
header() {
|
||||
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join('; ')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWithCookies = async (input: string | URL, init: RequestInit & { jar: CookieJar }) => {
|
||||
const cookie = init.jar.header()
|
||||
const headers = new Headers(init.headers)
|
||||
if (cookie) headers.set('cookie', cookie)
|
||||
const response = await fetch(input, { ...init, headers })
|
||||
init.jar.apply(response)
|
||||
return response
|
||||
}
|
||||
|
||||
const splitSetCookie = (header: string | null) => {
|
||||
if (!header) return []
|
||||
return header.split(/,(?=\s*[^;,=\s]+=[^;,]*)/g).map((value) => value.trim())
|
||||
}
|
||||
|
||||
const assertOk = (response: Response, label: string) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${label} failed: HTTP ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
const extractCsrfToken = (html: string) => {
|
||||
const patterns = [
|
||||
/<meta[^>]+name=["']_csrf["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']_csrf["']/i,
|
||||
/<input[^>]+name=["']_csrf["'][^>]+value=["']([^"']+)["']/i,
|
||||
/["']_csrf["']\s*:\s*["']([^"']+)["']/i,
|
||||
/csrfToken["']?\s*[:=]\s*["']([^"']+)["']/i,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern)
|
||||
if (match?.[1]) return htmlDecode(match[1])
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizeCredentialRequest = (
|
||||
challengeJson: unknown,
|
||||
credential: PlaintextStoredWebAuthnCredential,
|
||||
): CredentialRequest => {
|
||||
const root = challengeJson as Record<string, unknown>
|
||||
const data =
|
||||
pickObject(root.data) ??
|
||||
pickObject(root.publicKey) ??
|
||||
pickObject(root.publicKeyCredentialRequestOptions) ??
|
||||
root
|
||||
const publicKey =
|
||||
pickObject(data.publicKey) ?? pickObject(data.publicKeyCredentialRequestOptions) ?? data
|
||||
const challenge = pickString(publicKey.challenge) ?? pickString(data.challenge)
|
||||
if (!challenge) throw new Error('passkey challenge response did not include challenge')
|
||||
|
||||
return {
|
||||
challenge,
|
||||
rpId: pickString(publicKey.rpId) ?? credential.rpId,
|
||||
csrfToken:
|
||||
pickString(root.csrfToken) ??
|
||||
pickString(root._csrf) ??
|
||||
pickString(data.csrfToken) ??
|
||||
pickString(data._csrf) ??
|
||||
pickString(publicKey.csrfToken) ??
|
||||
pickString(publicKey._csrf),
|
||||
}
|
||||
}
|
||||
|
||||
const pickObject = (value: unknown) => {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
const pickString = (value: unknown) => {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
const createWebAuthnAssertion = (
|
||||
credential: PlaintextStoredWebAuthnCredential,
|
||||
request: CredentialRequest,
|
||||
) => {
|
||||
const clientDataJSON = Buffer.from(
|
||||
JSON.stringify({
|
||||
type: 'webauthn.get',
|
||||
challenge: request.challenge,
|
||||
origin: credential.origin,
|
||||
crossOrigin: false,
|
||||
}),
|
||||
)
|
||||
const signCount =
|
||||
credential.authenticator.signCount > 0 ? credential.authenticator.signCount + 1 : 0
|
||||
const authenticatorData = Buffer.concat([
|
||||
createHash('sha256').update(request.rpId).digest(),
|
||||
Buffer.from([assertionFlags(credential)]),
|
||||
uint32be(signCount),
|
||||
])
|
||||
const signedData = Buffer.concat([
|
||||
authenticatorData,
|
||||
createHash('sha256').update(clientDataJSON).digest(),
|
||||
])
|
||||
const key = createPrivateKey({
|
||||
key: credential.secretPlaintext.privateKey.jwk,
|
||||
format: 'jwk',
|
||||
})
|
||||
const signature = sign('sha256', signedData, key)
|
||||
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
rawId: credential.credentialId,
|
||||
clientDataJSON: base64Url(clientDataJSON),
|
||||
authenticatorData: base64Url(authenticatorData),
|
||||
signature: base64Url(signature),
|
||||
userHandle: credential.userHandle ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const assertionFlags = (credential: PlaintextStoredWebAuthnCredential) => {
|
||||
let flags = 0x01
|
||||
if (credential.authenticator.userVerification !== 'discouraged') flags |= 0x04
|
||||
if (credential.authenticator.backupEligible) flags |= 0x08
|
||||
if (credential.authenticator.backupState) flags |= 0x10
|
||||
return flags
|
||||
}
|
||||
|
||||
const uint32be = (value: number) => {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeUInt32BE(value >>> 0, 0)
|
||||
return buffer
|
||||
}
|
||||
|
||||
const extractCallbackUrl = (html: string) => {
|
||||
const match =
|
||||
html.match(/sbikabu2:\\\/\\\/auth\\\/callback\?token=[^"'<\\]+/) ??
|
||||
html.match(/sbikabu2:\/\/auth\/callback\?token=[^"'<\\]+/)
|
||||
if (!match) return undefined
|
||||
return match[0].replaceAll('\\/', '/')
|
||||
}
|
||||
|
||||
const htmlDecode = (value: string) => {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const decryptPasskeyToken = (encryptedToken: string, privateKeyPem: string) => {
|
||||
const encrypted = base64UrlToBuffer(encryptedToken)
|
||||
const openssl = decryptWithOpenSsl(encrypted, privateKeyPem)
|
||||
if (openssl) return openssl.toString('utf8')
|
||||
|
||||
return rsaOaepSha256Mgf1Sha1Decrypt(encrypted, privateKeyPem).toString('utf8')
|
||||
}
|
||||
|
||||
const decryptWithOpenSsl = (encrypted: Buffer, privateKeyPem: string) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sbi-passkey-'))
|
||||
const keyPath = join(dir, 'private.pem')
|
||||
try {
|
||||
writeFileSync(keyPath, privateKeyPem, { mode: 0o600 })
|
||||
const result = spawnSync(
|
||||
'openssl',
|
||||
[
|
||||
'pkeyutl',
|
||||
'-decrypt',
|
||||
'-inkey',
|
||||
keyPath,
|
||||
'-pkeyopt',
|
||||
'rsa_padding_mode:oaep',
|
||||
'-pkeyopt',
|
||||
'rsa_oaep_md:sha256',
|
||||
'-pkeyopt',
|
||||
'rsa_mgf1_md:sha1',
|
||||
],
|
||||
{ input: encrypted },
|
||||
)
|
||||
if (result.status !== 0) return undefined
|
||||
return result.stdout
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(keyPath)
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const base64Url = (data: Buffer, keepPadding = false) => {
|
||||
const encoded = data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
return keepPadding ? encoded : encoded.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
const base64UrlToBuffer = (value: string) => {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return Buffer.from(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='), 'base64')
|
||||
}
|
||||
|
||||
const rsaOaepSha256Mgf1Sha1Decrypt = (encrypted: Buffer, privateKeyPem: string) => {
|
||||
const encodedMessage = privateDecrypt(
|
||||
{
|
||||
key: privateKeyPem,
|
||||
padding: constants.RSA_NO_PADDING,
|
||||
},
|
||||
encrypted,
|
||||
)
|
||||
return oaepUnpad(encodedMessage, 'sha256', 'sha1')
|
||||
}
|
||||
|
||||
const oaepUnpad = (encodedMessage: Buffer, labelHashName: 'sha256', mgfHashName: 'sha1') => {
|
||||
const labelHash = createHash(labelHashName).update(Buffer.alloc(0)).digest()
|
||||
const hashLength = labelHash.length
|
||||
|
||||
if (encodedMessage.length < 2 * hashLength + 2 || encodedMessage[0] !== 0) {
|
||||
throw new Error('invalid OAEP block')
|
||||
}
|
||||
|
||||
const maskedSeed = encodedMessage.subarray(1, 1 + hashLength)
|
||||
const maskedDb = encodedMessage.subarray(1 + hashLength)
|
||||
const seedMask = mgf1(maskedDb, hashLength, mgfHashName)
|
||||
const seed = xor(maskedSeed, seedMask)
|
||||
const dbMask = mgf1(seed, maskedDb.length, mgfHashName)
|
||||
const db = xor(maskedDb, dbMask)
|
||||
|
||||
if (!db.subarray(0, hashLength).equals(labelHash)) {
|
||||
throw new Error('invalid OAEP label hash')
|
||||
}
|
||||
|
||||
let index = hashLength
|
||||
while (index < db.length && db[index] === 0) index++
|
||||
if (db[index] !== 1) {
|
||||
throw new Error('invalid OAEP delimiter')
|
||||
}
|
||||
return db.subarray(index + 1)
|
||||
}
|
||||
|
||||
const mgf1 = (seed: Buffer, length: number, hashName: 'sha1') => {
|
||||
const chunks: Buffer[] = []
|
||||
for (let counter = 0; Buffer.concat(chunks).length < length; counter++) {
|
||||
const c = Buffer.alloc(4)
|
||||
c.writeUInt32BE(counter, 0)
|
||||
chunks.push(createHash(hashName).update(seed).update(c).digest())
|
||||
}
|
||||
return Buffer.concat(chunks).subarray(0, length)
|
||||
}
|
||||
|
||||
const xor = (left: Buffer, right: Buffer) => {
|
||||
if (left.length !== right.length) throw new Error('xor length mismatch')
|
||||
const out = Buffer.alloc(left.length)
|
||||
for (let i = 0; i < left.length; i++) out[i] = left[i]! ^ right[i]!
|
||||
return out
|
||||
}
|
||||
|
||||
const decodeShiftJis = (body: ArrayBuffer | Uint8Array) => {
|
||||
try {
|
||||
return new TextDecoder('shift-jis' as ConstructorParameters<typeof TextDecoder>[0]).decode(body)
|
||||
} catch {
|
||||
return (
|
||||
body instanceof ArrayBuffer
|
||||
? Buffer.from(body)
|
||||
: Buffer.from(body.buffer, body.byteOffset, body.byteLength)
|
||||
).toString('binary')
|
||||
}
|
||||
}
|
||||
|
||||
const parseMtsHeader = (text: string) => {
|
||||
if (text.length < 70) return null
|
||||
return {
|
||||
sessionId: text.slice(6, 34).trim(),
|
||||
trCode: text.slice(34, 39).trim(),
|
||||
resultCode: text.slice(45, 51).trim(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
export type WebAuthnAlgorithm = -7 | -257
|
||||
|
||||
export type WebAuthnUserVerification = 'required' | 'preferred' | 'discouraged'
|
||||
|
||||
export type WebAuthnTransport = 'ble' | 'hybrid' | 'internal' | 'nfc' | 'usb'
|
||||
|
||||
export type WebAuthnJwk = {
|
||||
kty: string
|
||||
crv?: string
|
||||
x?: string
|
||||
y?: string
|
||||
d?: string
|
||||
n?: string
|
||||
e?: string
|
||||
key_ops?: string[]
|
||||
ext?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type StoredWebAuthnCredentialSecret = {
|
||||
privateKey: {
|
||||
format: 'jwk'
|
||||
jwk: WebAuthnJwk
|
||||
}
|
||||
cosePrivateKey?: string
|
||||
registration?: {
|
||||
attestationObject?: string
|
||||
clientDataJSON?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type StoredWebAuthnCredential = {
|
||||
version: 1
|
||||
kind: 'webauthn-credential'
|
||||
provider: 'sbi-sec'
|
||||
rpId: string
|
||||
origin: string
|
||||
credentialId: string
|
||||
userHandle?: string
|
||||
alg: WebAuthnAlgorithm
|
||||
publicKey: {
|
||||
format: 'jwk'
|
||||
jwk: WebAuthnJwk
|
||||
}
|
||||
authenticator: {
|
||||
aaguid?: string
|
||||
signCount: number
|
||||
discoverable: boolean
|
||||
userVerification: WebAuthnUserVerification
|
||||
transports?: WebAuthnTransport[]
|
||||
backupEligible?: boolean
|
||||
backupState?: boolean
|
||||
}
|
||||
secret: {
|
||||
encrypted: true
|
||||
format: 'jwe-like-v1'
|
||||
kdf: {
|
||||
name: 'argon2id' | 'scrypt'
|
||||
salt: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
cipher: {
|
||||
name: 'AES-256-GCM'
|
||||
nonce: string
|
||||
aad: string
|
||||
ciphertext: string
|
||||
tag: string
|
||||
}
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type PlaintextStoredWebAuthnCredential = Omit<StoredWebAuthnCredential, 'secret'> & {
|
||||
label?: string
|
||||
secretPlaintext: StoredWebAuthnCredentialSecret
|
||||
}
|
||||
|
||||
export type PasskeyLoginResponse = {
|
||||
type: 'passkey-login-response'
|
||||
requestUrl: string
|
||||
status: number
|
||||
body: ArrayBuffer
|
||||
text: string
|
||||
header: {
|
||||
sessionId: string
|
||||
trCode: string
|
||||
resultCode: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SbiSession = {
|
||||
mtsBaseUrl: string
|
||||
izanagiBaseUrl?: string
|
||||
profile: AccountProfile
|
||||
loginResponse: PasskeyLoginResponse
|
||||
tradePassword?: string
|
||||
deviceIdRegistered?: boolean
|
||||
tradeAuthentication?: SbiTradeAuthenticationOptions
|
||||
}
|
||||
|
||||
export type LoginWithPasskeyOptions = {
|
||||
passkeyCredential: PlaintextStoredWebAuthnCredential
|
||||
authBaseUrl?: string
|
||||
mtsBaseUrl?: string
|
||||
izanagiBaseUrl?: string
|
||||
}
|
||||
|
||||
export type SbiClientOptions = {
|
||||
tradePassword?: string
|
||||
deviceId?: string
|
||||
tradeAuthentication?: SbiTradeAuthenticationOptions
|
||||
}
|
||||
|
||||
export type SbiTradeAuthenticationRequest = {
|
||||
type: 'phone'
|
||||
telNo?: string
|
||||
phoneNo?: string
|
||||
sbiCallNo?: string
|
||||
authLimitTime?: string
|
||||
}
|
||||
|
||||
export type SbiTradeAuthenticationOptions = {
|
||||
onRequired?: (request: SbiTradeAuthenticationRequest) => void | Promise<void>
|
||||
confirmAttempts?: number
|
||||
confirmIntervalMs?: number
|
||||
}
|
||||
|
||||
export type IssueCode = string
|
||||
export type MarketCode = string
|
||||
export type OrderId = string
|
||||
export type WatchlistId = string
|
||||
export type PositionId = string
|
||||
export type ThemeId = string
|
||||
|
||||
export type CurrencyAmount = {
|
||||
value: number | null
|
||||
text: string
|
||||
currency: 'JPY'
|
||||
}
|
||||
|
||||
export type PercentValue = {
|
||||
value: number | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SignedTextValue = {
|
||||
value: number | null
|
||||
text: string
|
||||
sign?: 'positive' | 'negative' | 'zero'
|
||||
}
|
||||
|
||||
export type AccountType = 'general' | 'specific' | 'nisa' | 'juniorNisa' | 'unknown'
|
||||
export type DepositType = 'general' | 'specific' | 'nisa' | 'juniorNisa' | 'unknown'
|
||||
export type TradeSide = 'buy' | 'sell'
|
||||
export type MarginTradeSide = 'buy' | 'sell'
|
||||
export type OrderStatus = 'open' | 'executed' | 'cancelled' | 'expired' | 'rejected' | 'unknown'
|
||||
export type OrderKind = 'market' | 'limit' | 'stop' | 'oco' | 'ifd' | 'ifdo' | 's' | 'unknown'
|
||||
export type LoginStatus =
|
||||
| 'success'
|
||||
| 'invalidUser'
|
||||
| 'tradeForbidden'
|
||||
| 'locked'
|
||||
| 'fidoAuthorizationIncorrect'
|
||||
| 'fidoAuthorization'
|
||||
| 'passwordChangeRequired'
|
||||
| 'unknown'
|
||||
export type LoginType = 'passkey' | 'password' | 'unknown'
|
||||
export type SpecificAccountType =
|
||||
| 'withHolding'
|
||||
| 'withoutHolding'
|
||||
| 'nonSpecific'
|
||||
| 'notApply'
|
||||
| 'unknown'
|
||||
export type IsaAccountType =
|
||||
| 'nisaTradeForbidden'
|
||||
| 'oldNisaTradePermitted'
|
||||
| 'newNisaTradePermitted'
|
||||
| 'nisaTradePermitted'
|
||||
| 'unknown'
|
||||
|
||||
export type IssueRef = {
|
||||
code: IssueCode
|
||||
market?: MarketCode
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type IssueSearchItem = IssueRef & {
|
||||
extract?: string
|
||||
extractWord?: string
|
||||
boldFrom?: string
|
||||
boldTo?: string
|
||||
hitString?: string
|
||||
}
|
||||
|
||||
export type IssueSearchStatus = 'success' | 'searchError' | 'tooManyResults' | 'unknown'
|
||||
|
||||
export type IssueSearchResult = {
|
||||
status?: string
|
||||
statusText: IssueSearchStatus
|
||||
issues: IssueSearchItem[]
|
||||
}
|
||||
|
||||
export type ChartPeriod = 'minute' | 'day' | 'week' | 'month'
|
||||
|
||||
export type ChartPrice = {
|
||||
dateTime: string
|
||||
open: CurrencyAmount
|
||||
high: CurrencyAmount
|
||||
low: CurrencyAmount
|
||||
close: CurrencyAmount
|
||||
volume?: number | null
|
||||
}
|
||||
|
||||
export type IssueChart = {
|
||||
issue: IssueRef
|
||||
period: ChartPeriod
|
||||
unit: number
|
||||
prices: ChartPrice[]
|
||||
previousClose?: CurrencyAmount
|
||||
currentPrice?: CurrencyAmount
|
||||
highPrice?: CurrencyAmount
|
||||
lowPrice?: CurrencyAmount
|
||||
latestDateTime?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
sessionId: string
|
||||
loginType: LoginType
|
||||
resultCode: string
|
||||
}
|
||||
|
||||
export type AccountProfile = {
|
||||
session: SessionInfo
|
||||
branchCode?: string
|
||||
butenCode?: string
|
||||
accountNumber?: string
|
||||
userId?: string
|
||||
loginStatus?: LoginStatus
|
||||
loginType?: LoginType
|
||||
accountType?: AccountType
|
||||
specificAccountType?: SpecificAccountType
|
||||
hasMarginAccount?: boolean
|
||||
marginAccount?: string
|
||||
corporateFlag?: string
|
||||
commissionPlan?: string
|
||||
expireDate?: string
|
||||
lastLoginDate?: string
|
||||
lastLoginTime?: string
|
||||
tradingPassword?: string
|
||||
fxShareCol?: string
|
||||
fullTerm?: string
|
||||
fullAccount?: string
|
||||
securityAuthenticationResponseCode?: string
|
||||
fidoResponseCode?: string
|
||||
passkeyStatus?: string
|
||||
trId?: string
|
||||
txId?: string
|
||||
actionToken?: string
|
||||
nisa?: {
|
||||
enabled: boolean
|
||||
tradePermitted?: boolean
|
||||
juniorEnabled?: boolean
|
||||
accountType?: IsaAccountType
|
||||
jrNisaAccount?: string
|
||||
jrNisaSpecific?: SpecificAccountType
|
||||
jrNisaSeigen?: string
|
||||
}
|
||||
sor?: {
|
||||
defaultEnabled?: boolean
|
||||
defaultCode?: string
|
||||
lastMarket?: MarketCode
|
||||
juniorNisaLastMarket?: MarketCode
|
||||
}
|
||||
notices?: {
|
||||
hasImportantNotice?: boolean
|
||||
importantNoticeFlag?: string
|
||||
count?: number
|
||||
}
|
||||
restrictions?: {
|
||||
tradeRestricted?: boolean
|
||||
restrictedTradeFlag?: string
|
||||
message?: string
|
||||
}
|
||||
deficit?: {
|
||||
hasMessage?: boolean
|
||||
messageFlag?: string
|
||||
message?: string
|
||||
}
|
||||
maintenance?: {
|
||||
referenceable?: boolean
|
||||
referenceableMaintenanceFlag?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type BuyingPower = {
|
||||
cashBuyingPower?: CurrencyAmount
|
||||
marginBuyingPower?: CurrencyAmount
|
||||
withdrawableAmount?: CurrencyAmount
|
||||
collateralValue?: CurrencyAmount
|
||||
collateralRatio?: PercentValue
|
||||
sbiHybridDepositBalance?: CurrencyAmount
|
||||
noticeMessage?: string
|
||||
records?: CollateralRatioRecord[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type CollateralRatioRecord = {
|
||||
marginRequirements?: CurrencyAmount
|
||||
referenceMarginRequirements?: CurrencyAmount
|
||||
collateralRatioCash?: CurrencyAmount
|
||||
substituteSecuritiesValuationAmount?: CurrencyAmount
|
||||
unsettledPositionLoss?: SignedTextValue
|
||||
unsettledPositionLossFlag?: string
|
||||
settlementLoss?: SignedTextValue
|
||||
settlementLossFlag?: string
|
||||
paymentExpenses?: SignedTextValue
|
||||
paymentExpensesFlag?: string
|
||||
actualCollateral?: CurrencyAmount
|
||||
positionAmount?: CurrencyAmount
|
||||
sbiHybridDepositBalance?: CurrencyAmount
|
||||
minimumCollateral?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type CashPosition = {
|
||||
issue: IssueRef
|
||||
accountType?: AccountType
|
||||
depositType?: DepositType
|
||||
depositTypeCode?: string
|
||||
depositTypeText?: string
|
||||
quantity: number | null
|
||||
availableQuantity?: number | null
|
||||
unexecutedOrderQuantity?: number | null
|
||||
averagePrice?: CurrencyAmount
|
||||
purchasePrice?: CurrencyAmount
|
||||
/** Current unit price, not multiplied by quantity. */
|
||||
currentPrice?: CurrencyAmount
|
||||
priceText?: string
|
||||
/** Total position valuation amount. For cash positions this is quantity-adjusted. */
|
||||
marketValue?: CurrencyAmount
|
||||
presentValueFlag?: string
|
||||
/** Raw SBI valuation amount. Kept for source compatibility; prefer `marketValue` for totals. */
|
||||
valuationPrice?: CurrencyAmount
|
||||
valuationPriceChange?: SignedTextValue
|
||||
valuationPriceChangeRate?: PercentValue
|
||||
valuationPriceChangeFlag?: string
|
||||
profitLoss?: SignedTextValue
|
||||
profitLossRate?: PercentValue
|
||||
profitLossFlag?: string
|
||||
holdingCategory?: string
|
||||
accountInformation?: string
|
||||
}
|
||||
|
||||
export type CashPositionList = {
|
||||
positions: CashPosition[]
|
||||
index?: number
|
||||
totalCount?: number
|
||||
totalMarketValue?: CurrencyAmount
|
||||
totalProfitLoss?: SignedTextValue
|
||||
totalProfitLossRate?: PercentValue
|
||||
totalProfitLossFlag?: string
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type MarginPosition = {
|
||||
id?: PositionId
|
||||
issue: IssueRef
|
||||
side: MarginTradeSide
|
||||
sideText?: string
|
||||
accountType?: AccountType
|
||||
tradeKind?: string
|
||||
quantity: number | null
|
||||
availableCloseQuantity?: number | null
|
||||
unexecutedOrderQuantity?: number | null
|
||||
openPrice?: CurrencyAmount
|
||||
openAmount?: CurrencyAmount
|
||||
/** Current unit price or rate, not multiplied by quantity. */
|
||||
currentPrice?: CurrencyAmount
|
||||
rate?: CurrencyAmount
|
||||
/** Total position valuation amount when provided by SBI. */
|
||||
marketValue?: CurrencyAmount
|
||||
presentValueFlag?: string
|
||||
/** Raw SBI valuation amount. Prefer `marketValue` when calculating totals. */
|
||||
valuationPrice?: CurrencyAmount
|
||||
valuationPriceChange?: SignedTextValue
|
||||
valuationPriceChangeRate?: PercentValue
|
||||
valuationPriceChangeFlag?: string
|
||||
profitLoss?: SignedTextValue
|
||||
profitLossRate?: PercentValue
|
||||
profitLossFlag?: string
|
||||
openDate?: string
|
||||
dueDate?: string
|
||||
dueDateCode?: string
|
||||
dueDateText?: string
|
||||
depositTypeText?: string
|
||||
cost?: CurrencyAmount
|
||||
commission?: CurrencyAmount
|
||||
managementFee?: CurrencyAmount
|
||||
nameTransferFee?: CurrencyAmount
|
||||
interest?: CurrencyAmount
|
||||
backwardation?: CurrencyAmount
|
||||
collateralRatio?: PercentValue
|
||||
bargainMarketCode?: string
|
||||
bargainMarket?: string
|
||||
}
|
||||
|
||||
export type MarginPositionList = {
|
||||
positions: MarginPosition[]
|
||||
index?: number
|
||||
totalCount?: number
|
||||
totalMarketValue?: CurrencyAmount
|
||||
totalProfitLoss?: SignedTextValue
|
||||
totalProfitLossRate?: PercentValue
|
||||
totalProfitLossFlag?: string
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type ProfitLossSummary = {
|
||||
cash?: SignedTextValue
|
||||
margin?: SignedTextValue
|
||||
total?: SignedTextValue
|
||||
totalRate?: PercentValue
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type Quote = {
|
||||
issue: IssueRef
|
||||
price?: CurrencyAmount
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
changeFlag?: string
|
||||
open?: CurrencyAmount
|
||||
high?: CurrencyAmount
|
||||
low?: CurrencyAmount
|
||||
previousClose?: CurrencyAmount
|
||||
volume?: number | null
|
||||
timestamp?: string
|
||||
nominalPrices?: CurrencyAmount[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type BoardPriceLevel = {
|
||||
price: CurrencyAmount
|
||||
quantity?: number | null
|
||||
}
|
||||
|
||||
export type Board = {
|
||||
issue: IssueRef
|
||||
bids: BoardPriceLevel[]
|
||||
asks: BoardPriceLevel[]
|
||||
quote?: Quote
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type MarketIndex = {
|
||||
code?: string
|
||||
categoryCode?: string
|
||||
name: string
|
||||
value?: number | null
|
||||
valueText?: string
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
colorFlag?: string
|
||||
timestamp?: string
|
||||
open?: CurrencyAmount
|
||||
high?: CurrencyAmount
|
||||
low?: CurrencyAmount
|
||||
previousClose?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type DomesticMarket = {
|
||||
status?: string
|
||||
indexes: MarketIndex[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type RankingItem = {
|
||||
rank: number
|
||||
issue: IssueRef
|
||||
value?: number | string | null
|
||||
values?: Array<number | string | null>
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
exchangeName?: string
|
||||
colorFlag?: string
|
||||
}
|
||||
|
||||
export type Ranking = {
|
||||
items: RankingItem[]
|
||||
category?: string
|
||||
updatedAt?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type NewsItem = {
|
||||
id?: string
|
||||
title: string
|
||||
source?: string
|
||||
publishedAt?: string
|
||||
url?: string
|
||||
summary?: string
|
||||
storyDate?: string
|
||||
storyTime?: string
|
||||
processedDate?: string
|
||||
takeTime?: string
|
||||
pnac?: string
|
||||
}
|
||||
|
||||
export type NewsList = {
|
||||
items: NewsItem[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type WatchlistItem = {
|
||||
issue: IssueRef
|
||||
sortOrder?: number
|
||||
memo?: string
|
||||
quote?: Quote
|
||||
}
|
||||
|
||||
export type Watchlist = {
|
||||
id: WatchlistId
|
||||
name: string
|
||||
items: WatchlistItem[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type Order = {
|
||||
id: OrderId
|
||||
issue: IssueRef
|
||||
side: TradeSide
|
||||
sideText?: string
|
||||
status: OrderStatus
|
||||
statusText?: string
|
||||
executionStatus?: string
|
||||
executionStatusText?: string
|
||||
kind?: OrderKind
|
||||
accountType?: AccountType
|
||||
depositType?: DepositType
|
||||
depositTypeCode?: string
|
||||
depositTypeText?: string
|
||||
quantity?: number | null
|
||||
unexecutedQuantity?: number | null
|
||||
executedQuantity?: number | null
|
||||
price?: CurrencyAmount
|
||||
executedPrice?: CurrencyAmount
|
||||
orderedAt?: string
|
||||
expiresAt?: string
|
||||
orderNumber?: string
|
||||
tradeId?: string
|
||||
exchangeCode?: string
|
||||
accountInformation?: string
|
||||
}
|
||||
|
||||
export type OrderList = {
|
||||
orders: Order[]
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type OrderPreview = {
|
||||
issue: IssueRef
|
||||
side: TradeSide
|
||||
quantity?: number
|
||||
price?: CurrencyAmount
|
||||
estimatedAmount?: CurrencyAmount
|
||||
commission?: CurrencyAmount
|
||||
tax?: CurrencyAmount
|
||||
warnings: string[]
|
||||
confirmationId?: string
|
||||
message?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type OrderReceipt = {
|
||||
accepted: boolean
|
||||
orderId?: OrderId
|
||||
acceptedAt?: string
|
||||
message?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type ThemeInvestment = {
|
||||
id: ThemeId
|
||||
name: string
|
||||
issues: IssueRef[]
|
||||
minimumAmount?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type ThemeInvestmentList = {
|
||||
themes: ThemeInvestment[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type SbiMethodError = {
|
||||
status?: string
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user