feat: introduce mnie

This commit is contained in:
Shotaro Nakamura
2026-06-21 21:37:07 +09:00
parent 614ea8a992
commit fed3ac1b61
104 changed files with 1815 additions and 218 deletions
+13 -13
View File
@@ -2,21 +2,21 @@
# Local development
PORT=8787
CSBIE_SERVER_PORT=8787
CSBIE_UI_DEV_PORT=5173
CSBIE_DATABASE_PATH=./data/csbie.sqlite
CSBIE_KEYRING_BACKEND=platform
CSBIE_KEYRING_SQLITE_PATH=./data/csbie.keyring.sqlite
CSBIE_KEYRING_SECRET=
CSBIE_ORIGIN=http://127.0.0.1:5173
CSBIE_CORS_ORIGIN=http://127.0.0.1:5173
CSBIE_SESSION_COOKIE=csbie_session
CSBIE_RP_NAME=CSBIE
CSBIE_RP_ID=127.0.0.1
MNIE_SERVER_PORT=8787
MNIE_UI_DEV_PORT=5173
MNIE_DATABASE_PATH=./data/mnie-app.sqlite
MNIE_KEYRING_BACKEND=platform
MNIE_KEYRING_SQLITE_PATH=./data/mnie-app.keyring.sqlite
MNIE_KEYRING_SECRET=
MNIE_ORIGIN=http://127.0.0.1:5173
MNIE_CORS_ORIGIN=http://127.0.0.1:5173
MNIE_SESSION_COOKIE=mnie_session
MNIE_RP_NAME=MNIE
MNIE_RP_ID=127.0.0.1
# Initial owner passkey setup. Prefer the hash form outside throwaway local dev.
CSBIE_SETUP_PASSWORD=
CSBIE_SETUP_PASSWORD_HASH=
MNIE_SETUP_PASSWORD=
MNIE_SETUP_PASSWORD_HASH=
# SBI endpoints and local SBI session options. Values are loaded at runtime only.
SBI_AUTH_BASE_URL=
+23 -12
View File
@@ -1,14 +1,25 @@
# cSBI
# Mnie
[![技術者倫理 遵守済み](https://img.shields.io/badge/%E6%8A%80%E8%A1%93%E8%80%85%E5%80%AB%E7%90%86-%E9%81%B5%E5%AE%88%E6%B8%88%E3%81%BF-0a0a0a?style=for-the-badge&labelColor=ffffff)](https://技術者倫理.com)
A client for SBI-compatible servers. This project is not intended for use with a real SBI server.
Self-host finance management for running your own portfolio, trading, API key, OAuth, MCP, and CLI workflows.
## cSBI SDK
## Mnie SDK
## cSBIe
`@repo/mnie-sdk` connects to a Mnie server with an API key and exposes typed finance methods from `@repo/mnie-types`.
cSBIe is a web ui and server for managing SBI-compatible API.
## Apps
- `apps/mnie-app`: bundled server plus UI runtime
- `apps/mnie-server`: API, OAuth, MCP, RPC, storage, and provider session management
- `apps/mnie-ui`: web UI
## Packages
- `packages/mnie-types`: shared finance SDK interfaces
- `packages/mnie-sdk`: API-key SDK for Mnie
- `packages/mnie-cli`: CLI using `mnie-sdk`
- `packages/client-sbi`: SBI provider client
### Install
@@ -16,15 +27,15 @@ Before running, setup .env, refering `.env.example`.
#### With docker
Docker uses `CSBIE_KEYRING_BACKEND=sqlite` because platform keyrings are not available in
headless containers. Set `CSBIE_KEYRING_SECRET` in `.env` before saving SBI passkeys.
Docker uses `MNIE_KEYRING_BACKEND=sqlite` because platform keyrings are not available in
headless containers. Set `MNIE_KEYRING_SECRET` in `.env` before saving SBI passkeys.
docker-compose.yml:
```yaml
services:
csbie:
image: git.yutakobayashi.com/nakasyou/csbi:latest
mnie:
image: git.yutakobayashi.com/nakasyou/mnie:latest
ports:
- '18787:8787'
env_file:
@@ -36,15 +47,15 @@ services:
or
```bash
docker run --rm -it --name csbie \
docker run --rm -it --name mnie \
-p 18787:8787 \
--env-file .env \
-v "$PWD/data:/app/data" \
git.yutakobayashi.com/nakasyou/csbi:latest
git.yutakobayashi.com/nakasyou/mnie:latest
```
#### With Nix
```bash
nix run 'git+https://git.yutakobayashi.com/nakasyou/csbi.git#csbie'
nix run 'git+https://git.yutakobayashi.com/nakasyou/mnie.git#mnie'
```
-26
View File
@@ -1,26 +0,0 @@
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock tsconfig.json vite.config.ts ./
COPY packages/sbi-client/package.json packages/sbi-client/package.json
COPY apps/csbie/package.json apps/csbie/package.json
COPY apps/csbie-server/package.json apps/csbie-server/package.json
COPY apps/csbie-ui/package.json apps/csbie-ui/package.json
RUN bun install --frozen-lockfile
FROM deps AS build
COPY . .
RUN bun --filter @repo/csbie build
FROM oven/bun:1-slim AS runtime
ENV NODE_ENV=production
ENV CSBIE_DATABASE_PATH=/app/data/csbie.sqlite
ENV CSBIE_KEYRING_BACKEND=sqlite
ENV CSBIE_KEYRING_SQLITE_PATH=/app/data/csbie.keyring.sqlite
WORKDIR /app
COPY --from=build /app/apps/csbie/dist ./apps/csbie/dist
COPY --from=build /app/apps/csbie-ui/dist ./apps/csbie-ui/dist
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring@*/node_modules/@napi-rs/keyring ./node_modules/@napi-rs/keyring
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring-linux-x64-gnu@*/node_modules/@napi-rs/keyring-linux-x64-gnu ./node_modules/@napi-rs/keyring-linux-x64-gnu
WORKDIR /app/apps/csbie/dist
EXPOSE 8787
CMD ["bun", "index.js"]
+29
View File
@@ -0,0 +1,29 @@
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock tsconfig.json vite.config.ts ./
COPY packages/client-sbi/package.json packages/client-sbi/package.json
COPY packages/mnie-types/package.json packages/mnie-types/package.json
COPY packages/mnie-sdk/package.json packages/mnie-sdk/package.json
COPY packages/mnie-cli/package.json packages/mnie-cli/package.json
COPY apps/mnie-app/package.json apps/mnie-app/package.json
COPY apps/mnie-server/package.json apps/mnie-server/package.json
COPY apps/mnie-ui/package.json apps/mnie-ui/package.json
RUN bun install --frozen-lockfile
FROM deps AS build
COPY . .
RUN bun --filter @repo/mnie-app build
FROM oven/bun:1-slim AS runtime
ENV NODE_ENV=production
ENV MNIE_DATABASE_PATH=/app/data/mnie-app.sqlite
ENV MNIE_KEYRING_BACKEND=sqlite
ENV MNIE_KEYRING_SQLITE_PATH=/app/data/mnie-app.keyring.sqlite
WORKDIR /app
COPY --from=build /app/apps/mnie-app/dist ./apps/mnie-app/dist
COPY --from=build /app/apps/mnie-ui/dist ./apps/mnie-ui/dist
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring@*/node_modules/@napi-rs/keyring ./node_modules/@napi-rs/keyring
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring-linux-x64-gnu@*/node_modules/@napi-rs/keyring-linux-x64-gnu ./node_modules/@napi-rs/keyring-linux-x64-gnu
WORKDIR /app/apps/mnie-app/dist
EXPOSE 8787
CMD ["bun", "index.js"]
@@ -1,16 +1,16 @@
{
"name": "@repo/csbie",
"name": "@repo/mnie-app",
"private": true,
"type": "module",
"scripts": {
"clean": "rm -rf dist ../csbie-ui/dist",
"clean": "rm -rf dist ../mnie-ui/dist",
"dev": "bun --env-file=../../.env src/dev.ts",
"build": "bun --filter @repo/csbie-ui build && bun build src/index.ts --target=bun --outdir=dist --external @napi-rs/keyring",
"build": "bun --filter @repo/mnie-ui build && bun build src/index.ts --target=bun --outdir=dist --external @napi-rs/keyring",
"start": "bun --env-file=../../.env dist/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@repo/csbie-server": "workspace:*",
"@repo/mnie-server": "workspace:*",
"hono": "^4.8.3"
},
"devDependencies": {
@@ -1,10 +1,10 @@
const uiPort = Number(process.env.CSBIE_UI_DEV_PORT ?? 5173)
const serverPort = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const uiPort = Number(process.env.MNIE_UI_DEV_PORT ?? 5173)
const serverPort = Number(process.env.PORT ?? process.env.MNIE_SERVER_PORT ?? 8787)
const uiOrigin = `http://127.0.0.1:${uiPort}`
const serverEntry = new URL('../../csbie-server/src/index.ts', import.meta.url).pathname
const serverEntry = new URL('../../mnie-server/src/index.ts', import.meta.url).pathname
const ui = Bun.spawn({
cmd: ['bun', '--filter', '@repo/csbie-ui', 'dev'],
cmd: ['bun', '--filter', '@repo/mnie-ui', 'dev'],
stdout: 'inherit',
stderr: 'inherit',
stdin: 'inherit',
@@ -19,8 +19,8 @@ const server = Bun.spawn({
env: {
...process.env,
PORT: String(serverPort),
CSBIE_ORIGIN: uiOrigin,
CSBIE_CORS_ORIGIN: uiOrigin,
MNIE_ORIGIN: uiOrigin,
MNIE_CORS_ORIGIN: uiOrigin,
},
})
@@ -1,15 +1,15 @@
import { existsSync } from 'node:fs'
import { extname, isAbsolute, join, relative, resolve } from 'node:path'
import { Hono } from 'hono'
import { createServerApp } from '@repo/csbie-server/app'
import { loadConfig } from '@repo/csbie-server/config'
import { createDb } from '@repo/csbie-server/db'
import { createServerApp } from '@repo/mnie-server/app'
import { loadConfig } from '@repo/mnie-server/config'
import { createDb } from '@repo/mnie-server/db'
const config = loadConfig()
const db = createDb(config.databasePath)
const api = createServerApp(db, config)
const app = new Hono()
const uiDist = resolve(import.meta.dir, '../../csbie-ui/dist')
const uiDist = resolve(import.meta.dir, '../../mnie-ui/dist')
const contentTypes: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
@@ -63,4 +63,4 @@ const server = Bun.serve({
websocket: api.websocket,
})
console.log(`csbie listening on http://localhost:${server.port}`)
console.log(`mnie listening on http://localhost:${server.port}`)
@@ -5,6 +5,6 @@ export default defineConfig({
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.CSBIE_DATABASE_PATH ?? './data/csbie.sqlite',
url: process.env.MNIE_DATABASE_PATH ?? './data/mnie-app.sqlite',
},
})
@@ -1,5 +1,5 @@
{
"name": "@repo/csbie-server",
"name": "@repo/mnie-server",
"private": true,
"type": "module",
"exports": {
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/keyring": "^1.2.0",
"@repo/sbi-client": "workspace:*",
"@repo/client-sbi": "workspace:*",
"@simplewebauthn/server": "^13.1.2",
"drizzle-orm": "^0.44.2",
"hono": "^4.8.3",
@@ -48,8 +48,8 @@ export const createServerApp = (db: Db, config: ServerConfig) => {
issuerUrl: new URL(config.origin),
baseUrl: new URL(config.origin),
resourceServerUrl: new URL('/api/mcp', config.origin),
resourceName: 'CSBIE MCP',
scopesSupported: ['mcp'],
resourceName: 'Mnie finance management',
scopesSupported: ['read', 'write', 'trade', 'mcp'],
provider: oauthProvider,
authorizationOptions: { rateLimit: false },
tokenOptions: { rateLimit: false },
@@ -32,19 +32,19 @@ const optionalUrl = (value: string | undefined) => {
}
export const loadConfig = (): ServerConfig => {
const port = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const databasePath = resolve(process.env.CSBIE_DATABASE_PATH ?? './data/csbie.sqlite')
const port = Number(process.env.PORT ?? process.env.MNIE_SERVER_PORT ?? 8787)
const databasePath = resolve(process.env.MNIE_DATABASE_PATH ?? './data/mnie-app.sqlite')
mkdirSync(dirname(databasePath), { recursive: true })
const origin = process.env.CSBIE_ORIGIN ?? `http://localhost:${port}`
const rpId = process.env.CSBIE_RP_ID ?? new URL(origin).hostname
const origin = process.env.MNIE_ORIGIN ?? `http://localhost:${port}`
const rpId = process.env.MNIE_RP_ID ?? new URL(origin).hostname
return {
port,
databasePath,
corsOrigin: process.env.CSBIE_CORS_ORIGIN ?? origin,
sessionCookieName: process.env.CSBIE_SESSION_COOKIE ?? 'csbie_session',
rpName: process.env.CSBIE_RP_NAME ?? 'CSBIE',
corsOrigin: process.env.MNIE_CORS_ORIGIN ?? origin,
sessionCookieName: process.env.MNIE_SESSION_COOKIE ?? 'mnie_session',
rpName: process.env.MNIE_RP_NAME ?? 'MNIE',
rpId,
origin,
authBaseUrl: optionalUrl(process.env.SBI_AUTH_BASE_URL),
@@ -24,4 +24,5 @@ export type AuthContext =
type: 'apiKey'
authenticated: true
apiKeyId: string
scopes: string[]
}
@@ -50,6 +50,7 @@ export const apiKeys = sqliteTable(
maxOrderPriceJpy: integer('max_order_price_jpy'),
maxOrderAmountJpy: integer('max_order_amount_jpy'),
allowedMethods: text('allowed_methods', { mode: 'json' }).$type<string[] | null>(),
scopes: text('scopes', { mode: 'json' }).$type<string[] | null>(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }),
revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }),
@@ -14,4 +14,4 @@ const server = Bun.serve({
websocket,
})
console.log(`csbie-server listening on http://localhost:${server.port}`)
console.log(`mnie-server listening on http://localhost:${server.port}`)
@@ -1,7 +1,7 @@
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import type { MiddlewareHandler } from 'hono'
import type { PlaintextStoredWebAuthnCredential } from '@repo/sbi-client'
import type { PlaintextStoredWebAuthnCredential } from '@repo/client-sbi'
import type { AppBindings } from '../context'
import { sbiPasskeys } from '../db/schema'
import {
@@ -74,6 +74,21 @@ const passkeyForCredential = async (db: AppBindings['Variables']['db'], credenti
return row
}
const loopbackHosts = new Set(['localhost', '127.0.0.1'])
const expectedWebAuthnOrigins = (origin: string) => {
const url = new URL(origin)
if (!loopbackHosts.has(url.hostname)) return origin
return [...loopbackHosts].map((hostname) => {
const candidate = new URL(origin)
candidate.hostname = hostname
return candidate.origin
})
}
const expectedWebAuthnRpIds = (rpId: string) =>
loopbackHosts.has(rpId) ? [...loopbackHosts] : rpId
export const createAuthRoutes = () => {
const app = new Hono<AppBindings>()
@@ -125,8 +140,8 @@ export const createAuthRoutes = () => {
const verification = await verifyRegistrationResponse({
response: response as never,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpId,
expectedOrigin: expectedWebAuthnOrigins(config.origin),
expectedRPID: expectedWebAuthnRpIds(config.rpId),
requireUserVerification: true,
})
@@ -186,8 +201,8 @@ export const createAuthRoutes = () => {
const verification = await verifyAuthenticationResponse({
response: response as never,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpId,
expectedOrigin: expectedWebAuthnOrigins(config.origin),
expectedRPID: expectedWebAuthnRpIds(config.rpId),
credential: {
id: passkey.credentialId,
publicKey: Buffer.from(passkey.publicKey, 'base64url'),
@@ -25,6 +25,9 @@ const textResult = (value: unknown) => ({
const requireAuthenticated = (auth: AuthContext) => {
if (!auth.authenticated) throw new Error('unauthorized')
if (auth.type === 'apiKey' && !auth.scopes.includes('mcp')) {
throw new Error('missing OAuth scope: mcp')
}
}
const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000
@@ -835,7 +838,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
const auth = c.get('auth')
const server = new McpServer({
name: 'csbie',
name: 'mnie',
version: '0.1.0',
})
@@ -869,6 +872,10 @@ const createMcpServer = (c: Context<AppBindings>) => {
requireAuthenticated(auth)
if (auth.type === 'apiKey') {
const requiredScope = isTradingMethod(method) ? 'trade' : 'read'
if (!auth.scopes.includes(requiredScope)) {
throw new Error(`missing OAuth scope: ${requiredScope}`)
}
await assertApiKeyMethodAllowed(db, auth.apiKeyId, method)
}
@@ -943,7 +950,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
}
server.registerTool(
'csbie-get',
'mnie-get',
{
title: 'Get SBI Data',
description: `Read SBI data through a small abstract action API. This tool never places, corrects, cancels, or otherwise changes real orders. ${getActionDescription}`,
@@ -958,7 +965,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
return textResult({
getActions,
changeActions,
changeTool: 'csbie-request-change',
changeTool: 'mnie-request-change',
confirmTool: 'confirm-request',
})
}
@@ -976,7 +983,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
)
server.registerTool(
'csbie-request-change',
'mnie-request-change',
{
title: 'Create SBI Change Request',
description: `Prepare a real SBI change by running the corresponding estimate/preview and returning a UUID. This tool never submits the change; pass the UUID to confirm-request. ${changeActionDescription}`,
@@ -999,7 +1006,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
description:
'Submit a previously prepared SBI change request by UUID. The UUID expires shortly, is single-use, and is bound to the same authenticated caller.',
inputSchema: {
uuid: z.string().uuid().describe('UUID returned by csbie-request-change'),
uuid: z.string().uuid().describe('UUID returned by mnie-request-change'),
},
},
async ({ uuid }) => {
@@ -7,6 +7,7 @@ import type { ApiKeySettings } from '../security/api-keys'
import { createOAuthAuthorizationCode } from '../security/oauth-provider'
const loopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]'])
const supportedScopes = new Set(['read', 'write', 'trade', 'mcp'])
const redirectUriAllowed = (redirectUri: string, registeredUris: string[]) => {
if (registeredUris.includes(redirectUri)) return true
@@ -62,6 +63,9 @@ export const createOAuthRoutes = () => {
if (!body.clientId || !body.redirectUri || !body.codeChallenge) {
return c.json({ error: 'clientId, redirectUri and codeChallenge are required' }, 400)
}
const requestedScopes = body.scope?.split(' ').filter(Boolean) ?? []
const unsupportedScope = requestedScopes.find((scope) => !supportedScopes.has(scope))
if (unsupportedScope) return c.json({ error: `unsupported scope: ${unsupportedScope}` }, 400)
const [client] = await c
.get('db')
@@ -78,7 +82,7 @@ export const createOAuthRoutes = () => {
clientId: body.clientId,
redirectUri: body.redirectUri,
codeChallenge: body.codeChallenge,
scopes: body.scope?.split(' ').filter(Boolean) ?? [],
scopes: requestedScopes.length ? requestedScopes : ['read'],
resource: body.resource,
apiKeySettings: body.settings,
})
@@ -1,4 +1,4 @@
import type { SbiClientMethods } from '@repo/sbi-client'
import type { SbiClientMethods } from '@repo/client-sbi'
export const RPC_METHODS = [
'session.profile',
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'
import { loginWithPasskey } from '@repo/sbi-client'
import type { SbiClientMethods, SbiClientOptions } from '@repo/sbi-client'
import { loginWithPasskey } from '@repo/client-sbi'
import type { SbiClientMethods, SbiClientOptions } from '@repo/client-sbi'
import type { ServerConfig } from '../config'
import type { Db } from '../db'
import { sbiPasskeys } from '../db/schema'
@@ -1,4 +1,4 @@
import type { MarketCode, SbiClientMethods } from '@repo/sbi-client'
import type { MarketCode, SbiClientMethods } from '@repo/client-sbi'
import { createBunWebSocket } from 'hono/bun'
import type { WSContext } from 'hono/ws'
import { randomUUID } from 'node:crypto'
@@ -22,6 +22,7 @@ type RpcSocketState = {
client?: SbiClientMethods
sbiPasskeyId?: string
apiKeyId?: string
scopes?: string[]
boardPollingSubscriptions: Map<string, AbortController>
}
@@ -103,6 +104,12 @@ const stopBoardPollingSubscriptions = (state: RpcSocketState) => {
}
}
const assertScope = (state: RpcSocketState, scope: 'read' | 'trade') => {
if (!state.apiKeyId) return
const scopes = state.scopes ?? ['read', 'write', 'trade', 'mcp']
if (!scopes.includes(scope)) throw new Error(`missing OAuth scope: ${scope}`)
}
const subscribeBoardPolling = async (
db: Db,
state: RpcSocketState,
@@ -110,6 +117,7 @@ const subscribeBoardPolling = async (
request: JsonRpcRequest,
) => {
if (!state.client) return error(request.id, 4001, 'SBI session is not connected')
assertScope(state, 'read')
if (state.apiKeyId) {
await assertApiKeyMethodAllowed(db, state.apiKeyId, 'market.issue.board')
@@ -159,6 +167,7 @@ const handleRpc = async (
}
if (request.method === 'sbi.connect') {
assertScope(state, 'read')
const passkeyId =
request.params && typeof request.params === 'object'
? (request.params as { passkeyId?: string }).passkeyId
@@ -190,6 +199,7 @@ const handleRpc = async (
}
if (!state.client) return error(request.id, 4001, 'SBI session is not connected')
assertScope(state, isTradingMethod(request.method) ? 'trade' : 'read')
if (state.apiKeyId) {
await assertApiKeyMethodAllowed(db, state.apiKeyId, request.method)
@@ -228,6 +238,7 @@ export const createRpcWebSocket = (db: Db, config: ServerConfig) => {
const auth = c.get('auth')
const state: RpcSocketState = {
apiKeyId: auth.type === 'apiKey' ? auth.apiKeyId : undefined,
scopes: auth.type === 'apiKey' ? auth.scopes : undefined,
boardPollingSubscriptions: new Map(),
}
@@ -10,6 +10,7 @@ export type ApiKeySettings = {
maxOrderPriceJpy?: number | null
maxOrderAmountJpy?: number | null
allowedMethods?: string[] | null
scopes?: string[] | null
}
const normalizeLimit = (value: unknown) => {
@@ -31,6 +32,12 @@ export const normalizeApiKeySettings = (settings: ApiKeySettings = {}) => ({
: settings.allowedMethods?.length
? [...new Set(settings.allowedMethods)].sort()
: null,
scopes:
settings.scopes === undefined
? null
: settings.scopes?.length
? [...new Set(settings.scopes)].sort()
: null,
})
export const listApiKeys = async (db: Db) =>
@@ -44,6 +51,7 @@ export const listApiKeys = async (db: Db) =>
maxOrderPriceJpy: apiKeys.maxOrderPriceJpy,
maxOrderAmountJpy: apiKeys.maxOrderAmountJpy,
allowedMethods: apiKeys.allowedMethods,
scopes: apiKeys.scopes,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
revokedAt: apiKeys.revokedAt,
@@ -53,7 +61,7 @@ export const listApiKeys = async (db: Db) =>
.orderBy(apiKeys.createdAt)
export const createApiKey = async (db: Db, label: string, settings: ApiKeySettings = {}) => {
const token = `csbie_${randomToken()}`
const token = `mnie_${randomToken()}`
const now = new Date()
const row = {
id: randomId('key'),
@@ -22,7 +22,12 @@ const readQueryApiKey = (request: Request) => {
const apiKeyAuth = async (db: Db, token: string) => {
const apiKey = await verifyApiKey(db, token)
return apiKey
? ({ type: 'apiKey', authenticated: true, apiKeyId: apiKey.id } satisfies AuthContext)
? ({
type: 'apiKey',
authenticated: true,
apiKeyId: apiKey.id,
scopes: apiKey.scopes?.length ? apiKey.scopes : ['read', 'write', 'trade', 'mcp'],
} satisfies AuthContext)
: ({ type: 'none', authenticated: false } satisfies AuthContext)
}
@@ -3,7 +3,7 @@ import { mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { Database } from 'bun:sqlite'
const SERVICE = 'csbie'
const SERVICE = 'mnie'
const SQLITE_BACKEND = 'sqlite'
const PLATFORM_BACKEND = 'platform'
@@ -15,9 +15,9 @@ let keytar: Keytar | undefined
let sqlite: Database | undefined
const keyringBackend = (): KeyringBackend => {
const backend = process.env.CSBIE_KEYRING_BACKEND ?? PLATFORM_BACKEND
const backend = process.env.MNIE_KEYRING_BACKEND ?? PLATFORM_BACKEND
if (backend === PLATFORM_BACKEND || backend === SQLITE_BACKEND) return backend
throw new Error(`unsupported CSBIE_KEYRING_BACKEND: ${backend}`)
throw new Error(`unsupported MNIE_KEYRING_BACKEND: ${backend}`)
}
const loadKeytar = async () => {
@@ -27,15 +27,15 @@ const loadKeytar = async () => {
const sqlitePath = () =>
resolve(
process.env.CSBIE_KEYRING_SQLITE_PATH ??
process.env.CSBIE_DATABASE_PATH?.replace(/\.sqlite$/u, '.keyring.sqlite') ??
'./data/csbie.keyring.sqlite',
process.env.MNIE_KEYRING_SQLITE_PATH ??
process.env.MNIE_DATABASE_PATH?.replace(/\.sqlite$/u, '.keyring.sqlite') ??
'./data/mnie-app.keyring.sqlite',
)
const sqliteKey = () => {
const secret = process.env.CSBIE_KEYRING_SECRET
const secret = process.env.MNIE_KEYRING_SECRET
if (!secret) {
throw new Error('CSBIE_KEYRING_SECRET is required when CSBIE_KEYRING_BACKEND=sqlite')
throw new Error('MNIE_KEYRING_SECRET is required when MNIE_KEYRING_BACKEND=sqlite')
}
return createHash('sha256').update(secret).digest()
}
@@ -16,6 +16,7 @@ import { randomToken, sha256 } from './crypto'
const CODE_TTL_MS = 10 * 60 * 1000
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000
const DEFAULT_SCOPES = ['read', 'write', 'trade', 'mcp'] as const
export const createOAuthAuthorizationCode = async (
db: Db,
@@ -54,7 +55,11 @@ export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthSe
registerClient: async (
client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
) => {
const clientInfo = client as OAuthClientInformationFull
const clientInfo = {
...client,
client_id: randomToken(),
client_id_issued_at: Math.floor(Date.now() / 1000),
} satisfies OAuthClientInformationFull
await db
.insert(oauthClients)
.values({
@@ -117,14 +122,13 @@ export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthSe
.delete(oauthAuthorizationCodes)
.where(eq(oauthAuthorizationCodes.code, authorizationCode))
const key = await createApiKey(
db,
`OAuth: ${client.client_name ?? client.client_id}`,
(code.apiKeySettings ?? {}) as ApiKeySettings,
)
const refreshToken = `csbie_refresh_${randomToken()}`
const now = new Date()
const scopes = code.scopes
const key = await createApiKey(db, `OAuth: ${client.client_name ?? client.client_id}`, {
...((code.apiKeySettings ?? {}) as ApiKeySettings),
scopes,
})
const refreshToken = `mnie_refresh_${randomToken()}`
const now = new Date()
await db.insert(oauthRefreshTokens).values({
tokenHash: sha256(refreshToken),
clientId: client.client_id,
@@ -157,12 +161,15 @@ export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthSe
)
if (!token || token.expiresAt <= new Date()) throw new Error('refresh token expired')
const newAccess = await createApiKey(db, `OAuth: ${client.client_name ?? client.client_id}`)
const nextScopes = scopes ?? token.scopes
const newAccess = await createApiKey(db, `OAuth: ${client.client_name ?? client.client_id}`, {
scopes: nextScopes,
})
return {
access_token: newAccess.token,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_SECONDS,
scope: (scopes ?? token.scopes).join(' '),
scope: nextScopes.join(' '),
refresh_token: refreshToken,
} satisfies OAuthTokens
},
@@ -173,7 +180,7 @@ export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthSe
return {
token,
clientId: apiKey.id,
scopes: ['mcp'],
scopes: apiKey.scopes?.length ? apiKey.scopes : [...DEFAULT_SCOPES],
expiresAt: Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS,
} satisfies AuthInfo
},
@@ -1,10 +1,10 @@
import { sha256, safeEqual } from './crypto'
export const verifySetupPassword = (password: string) => {
const hash = process.env.CSBIE_SETUP_PASSWORD_HASH
const hash = process.env.MNIE_SETUP_PASSWORD_HASH
if (hash) return safeEqual(sha256(password), hash)
const plaintext = process.env.CSBIE_SETUP_PASSWORD
const plaintext = process.env.MNIE_SETUP_PASSWORD
if (plaintext) return safeEqual(password, plaintext)
return false
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cSBIe</title>
<title>Mnie</title>
</head>
<body>
<div id="app"></div>
@@ -1,5 +1,5 @@
{
"name": "@repo/csbie-ui",
"name": "@repo/mnie-ui",
"private": true,
"type": "module",
"scripts": {
@@ -25,6 +25,7 @@ const router = useRouter()
const activeTab = computed<RouteName>(() => {
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
})
const isOAuthRoute = computed(() => route.name === 'oauthAuthorize')
const showAuthGate = computed(() => true)
const decodedRouteParam = (value: string) => {
try {
@@ -208,10 +209,10 @@ onMounted(async () => {
<template>
<main :class="ui.appShell">
<AppSidebar :active-tab="activeTab" @navigate="navigate" />
<AppSidebar v-if="!isOAuthRoute" :active-tab="activeTab" @navigate="navigate" />
<section :class="ui.workspace">
<AppHeader v-if="activeTab !== 'settings'" :active-tab="activeTab" />
<AppHeader v-if="!isOAuthRoute && activeTab !== 'settings'" :active-tab="activeTab" />
<OAuthApprovalPanel
v-if="oauthApproval.active && status.authenticated"
@@ -12,6 +12,7 @@ export type ApiKey = {
maxOrderPriceJpy?: number | null
maxOrderAmountJpy?: number | null
allowedMethods?: string[] | null
scopes?: string[] | null
createdAt: string
lastUsedAt?: string | null
revokedAt?: string | null
@@ -26,6 +27,7 @@ export type ApiKeySettings = Pick<
| 'maxOrderPriceJpy'
| 'maxOrderAmountJpy'
| 'allowedMethods'
| 'scopes'
>
export type SbiPasskey = {

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

@@ -92,6 +92,14 @@ const tradingMethods = [
const tradingMethodSet = new Set<string>(tradingMethods)
const readMethods = rpcMethods.filter((method) => !tradingMethodSet.has(method))
const scopes = ['read', 'write', 'trade', 'mcp'] as const
const toggleScope = (scope: string) => {
const current = settings.value.scopes ?? []
settings.value.scopes = current.includes(scope)
? current.filter((candidate) => candidate !== scope)
: [...current, scope].sort()
}
const setMethods = (methods: readonly string[] | null) => {
settings.value.allowedMethods = methods ? [...methods] : null
@@ -116,6 +124,7 @@ const ui = {
input:
'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition focus:border-[#a8c7fa]',
permissions: 'border-t border-[#33383f] pt-3',
scopeGrid: 'grid grid-cols-2 gap-2 md:grid-cols-4',
summary: 'cursor-pointer font-black text-[#e3e3e9]',
actions: 'mt-3 flex flex-wrap gap-2',
button:
@@ -129,6 +138,18 @@ const ui = {
<template>
<div :class="ui.root">
<div :class="ui.scopeGrid">
<label v-for="scope in scopes" :key="scope" :class="ui.methodToggle">
<input
:class="ui.checkbox"
type="checkbox"
:checked="settings.scopes?.includes(scope)"
@change="toggleScope(scope)"
/>
<span>{{ scope }}</span>
</label>
</div>
<div :class="[ui.limitGrid, compact && ui.limitGridCompact]">
<label :class="ui.label">
1時間 取引上限
@@ -17,7 +17,7 @@ const items = sidebarItems
<template>
<aside :class="ui.sidebar">
<div :class="ui.brandMark" aria-label="CSBIE">
<div :class="ui.brandMark" aria-label="MNIE">
<Activity class="h-8 w-8" :stroke-width="2.75" aria-hidden="true" />
</div>
<nav :class="ui.navStack" aria-label="Main">
@@ -7,4 +7,5 @@ export const defaultApiKeyPolicy = (): ApiKeySettings => ({
maxOrderPriceJpy: null,
maxOrderAmountJpy: null,
allowedMethods: null,
scopes: ['read'],
})
@@ -39,6 +39,8 @@ export const useOAuthApproval = () => {
state: url.searchParams.get('state') ?? '',
resource: url.searchParams.get('resource') ?? '',
}
const requestedScopes = oauthApproval.value.scope.split(' ').filter(Boolean)
oauthSettings.value.scopes = requestedScopes.length ? requestedScopes : ['read']
if (!oauthApproval.value.clientId) return
const response = await fetch(`/api/oauth/client/${oauthApproval.value.clientId}`, {
@@ -261,10 +261,10 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const reportDataError = (message: string, cause?: unknown) => {
if (cause) {
console.error(`[csbie-ui] データ取得エラー: ${message}`, cause)
console.error(`[mnie-ui] データ取得エラー: ${message}`, cause)
return
}
console.error(`[csbie-ui] データ取得エラー: ${message}`)
console.error(`[mnie-ui] データ取得エラー: ${message}`)
}
const selectedStock = computed(() => {
@@ -2,8 +2,8 @@ import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
const serverPort = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const serverOrigin = process.env.CSBIE_SERVER_ORIGIN ?? `http://127.0.0.1:${serverPort}`
const serverPort = Number(process.env.PORT ?? process.env.MNIE_SERVER_PORT ?? 8787)
const serverOrigin = process.env.MNIE_SERVER_ORIGIN ?? `http://127.0.0.1:${serverPort}`
const proxyToServer = {
target: serverOrigin,
changeOrigin: true,
@@ -13,11 +13,11 @@ const proxyToServer = {
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173),
port: Number(process.env.MNIE_UI_DEV_PORT ?? 5173),
strictPort: true,
hmr: {
host: '127.0.0.1',
clientPort: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173),
clientPort: Number(process.env.MNIE_UI_DEV_PORT ?? 5173),
},
proxy: {
'/api': {
+58 -14
View File
@@ -12,10 +12,10 @@
"typescript": "^5",
},
},
"apps/csbie": {
"name": "@repo/csbie",
"apps/mnie-app": {
"name": "@repo/mnie-app",
"dependencies": {
"@repo/csbie-server": "workspace:*",
"@repo/mnie-server": "workspace:*",
"hono": "^4.8.3",
},
"devDependencies": {
@@ -23,13 +23,13 @@
"typescript": "^5",
},
},
"apps/csbie-server": {
"name": "@repo/csbie-server",
"apps/mnie-server": {
"name": "@repo/mnie-server",
"dependencies": {
"@hono/mcp": "^0.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/keyring": "^1.2.0",
"@repo/sbi-client": "workspace:*",
"@repo/client-sbi": "workspace:*",
"@simplewebauthn/server": "^13.1.2",
"drizzle-orm": "^0.44.2",
"hono": "^4.8.3",
@@ -41,8 +41,8 @@
"typescript": "^5",
},
},
"apps/csbie-ui": {
"name": "@repo/csbie-ui",
"apps/mnie-ui": {
"name": "@repo/mnie-ui",
"dependencies": {
"@simplewebauthn/browser": "^13.1.2",
"d3": "^7.9.0",
@@ -61,9 +61,10 @@
"vue-tsc": "^3",
},
},
"packages/sbi-client": {
"name": "@repo/sbi-client",
"packages/client-sbi": {
"name": "@repo/client-sbi",
"dependencies": {
"@repo/mnie-types": "workspace:*",
"iconv-lite": "^0.7.2",
},
"devDependencies": {
@@ -73,6 +74,43 @@
"typescript": "^5",
},
},
"packages/mnie-cli": {
"name": "@repo/mnie-cli",
"bin": {
"mnie": "./src/index.ts",
},
"dependencies": {
"@napi-rs/keyring": "^1.2.0",
"@repo/mnie-sdk": "workspace:*",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
"packages/mnie-sdk": {
"name": "@repo/mnie-sdk",
"dependencies": {
"@repo/mnie-types": "workspace:*",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
"packages/mnie-types": {
"name": "@repo/mnie-types",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="],
@@ -319,13 +357,19 @@
"@polka/url": ["@polka/[email protected]", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@repo/csbie": ["@repo/csbie@workspace:apps/csbie"],
"@repo/client-sbi": ["@repo/client-sbi@workspace:packages/client-sbi"],
"@repo/csbie-server": ["@repo/csbie-server@workspace:apps/csbie-server"],
"@repo/mnie-app": ["@repo/mnie-app@workspace:apps/mnie-app"],
"@repo/csbie-ui": ["@repo/csbie-ui@workspace:apps/csbie-ui"],
"@repo/mnie-cli": ["@repo/mnie-cli@workspace:packages/mnie-cli"],
"@repo/sbi-client": ["@repo/sbi-client@workspace:packages/sbi-client"],
"@repo/mnie-sdk": ["@repo/mnie-sdk@workspace:packages/mnie-sdk"],
"@repo/mnie-server": ["@repo/mnie-server@workspace:apps/mnie-server"],
"@repo/mnie-types": ["@repo/mnie-types@workspace:packages/mnie-types"],
"@repo/mnie-ui": ["@repo/mnie-ui@workspace:apps/mnie-ui"],
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
+3 -3
View File
@@ -1,8 +1,8 @@
services:
csbie:
image: git.yutakobayashi.com/nakasyou/csbi:latest
mnie:
image: git.yutakobayashi.com/nakasyou/mnie:latest
ports:
- '${CSBIE_HOST_PORT:-18787}:8787'
- '${MNIE_HOST_PORT:-18787}:8787'
env_file:
- .env
volumes:
+35 -35
View File
@@ -1,5 +1,5 @@
{
description = "cSBI development and runtime helpers";
description = "Mnie development and runtime helpers";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -40,7 +40,7 @@
let
pkgs = import nixpkgs { inherit system; };
composeUp = pkgs.writeShellApplication {
name = "csbi-compose-up";
name = "mnie-compose-up";
runtimeInputs = [
pkgs.docker-compose
];
@@ -49,17 +49,17 @@
'';
};
nativeRun = pkgs.writeShellApplication {
name = "csbi-native-run";
name = "mnie-native-run";
runtimeInputs = [
pkgs.bun
pkgs.coreutils
];
text = ''
env_file="''${CSBIE_ENV_FILE:-$PWD/.env}"
data_dir="''${CSBIE_DATA_DIR:-$PWD/data}"
env_file="''${MNIE_ENV_FILE:-$PWD/.env}"
data_dir="''${MNIE_DATA_DIR:-$PWD/data}"
if [ ! -f "$env_file" ]; then
echo "CSBIE_ENV_FILE does not exist: $env_file" >&2
echo "MNIE_ENV_FILE does not exist: $env_file" >&2
exit 1
fi
@@ -74,30 +74,30 @@
cd "$source_dir"
bun install --frozen-lockfile --linker hoisted
bun --filter @repo/csbie build
bun --filter @repo/mnie-app build
export CSBIE_DATABASE_PATH="''${CSBIE_DATABASE_PATH:-$data_dir/csbie.sqlite}"
exec bun --env-file="$env_file" apps/csbie/dist/index.js "$@"
export MNIE_DATABASE_PATH="''${MNIE_DATABASE_PATH:-$data_dir/mnie-app.sqlite}"
exec bun --env-file="$env_file" apps/mnie-app/dist/index.js "$@"
'';
};
in
{
default = {
type = "app";
program = "${composeUp}/bin/csbi-compose-up";
meta.description = "Run cSBI with docker-compose";
program = "${composeUp}/bin/mnie-compose-up";
meta.description = "Run Mnie with docker-compose";
};
compose-up = {
type = "app";
program = "${composeUp}/bin/csbi-compose-up";
meta.description = "Run cSBI with docker-compose";
program = "${composeUp}/bin/mnie-compose-up";
meta.description = "Run Mnie with docker-compose";
};
csbie = {
mnie = {
type = "app";
program = "${nativeRun}/bin/csbi-native-run";
meta.description = "Build and run cSBI directly with Bun";
program = "${nativeRun}/bin/mnie-native-run";
meta.description = "Build and run Mnie directly with Bun";
};
}
@@ -111,19 +111,19 @@
...
}:
let
cfg = config.services.csbie;
cfg = config.services.mnie;
nativeRun = pkgs.writeShellApplication {
name = "csbi-native-run";
name = "mnie-native-run";
runtimeInputs = [
pkgs.bun
pkgs.coreutils
];
text = ''
env_file="''${CSBIE_ENV_FILE:-$PWD/.env}"
data_dir="''${CSBIE_DATA_DIR:-$PWD/data}"
env_file="''${MNIE_ENV_FILE:-$PWD/.env}"
data_dir="''${MNIE_DATA_DIR:-$PWD/data}"
if [ ! -f "$env_file" ]; then
echo "CSBIE_ENV_FILE does not exist: $env_file" >&2
echo "MNIE_ENV_FILE does not exist: $env_file" >&2
exit 1
fi
@@ -138,16 +138,16 @@
cd "$source_dir"
bun install --frozen-lockfile --linker hoisted
bun --filter @repo/csbie build
bun --filter @repo/mnie-app build
export CSBIE_DATABASE_PATH="''${CSBIE_DATABASE_PATH:-$data_dir/csbie.sqlite}"
exec bun --env-file="$env_file" apps/csbie/dist/index.js "$@"
export MNIE_DATABASE_PATH="''${MNIE_DATABASE_PATH:-$data_dir/mnie-app.sqlite}"
exec bun --env-file="$env_file" apps/mnie-app/dist/index.js "$@"
'';
};
in
{
options.services.csbie = {
enable = lib.mkEnableOption "cSBI";
options.services.mnie = {
enable = lib.mkEnableOption "Mnie";
runtime = lib.mkOption {
type = lib.types.enum [
@@ -160,7 +160,7 @@
image = lib.mkOption {
type = lib.types.str;
default = "git.yutakobayashi.com/nakasyou/csbi:latest";
default = "git.yutakobayashi.com/nakasyou/mnie:latest";
description = "Container image to run when runtime is docker.";
};
@@ -171,14 +171,14 @@
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/csbie";
default = "/var/lib/mnie-app";
description = "Host directory mounted at /app/data.";
};
port = lib.mkOption {
type = lib.types.port;
default = 18787;
description = "Host port exposed for cSBI.";
description = "Host port exposed for Mnie.";
};
};
@@ -191,7 +191,7 @@
(lib.mkIf (cfg.runtime == "docker") {
virtualisation.oci-containers.backend = lib.mkDefault "docker";
virtualisation.oci-containers.containers.csbie = {
virtualisation.oci-containers.containers.mnie = {
image = cfg.image;
ports = [
"${toString cfg.port}:8787"
@@ -206,8 +206,8 @@
})
(lib.mkIf (cfg.runtime == "native") {
systemd.services.csbie = {
description = "cSBI";
systemd.services.mnie = {
description = "Mnie";
wantedBy = [
"multi-user.target"
];
@@ -218,8 +218,8 @@
"network-online.target"
];
environment = {
CSBIE_ENV_FILE = toString cfg.envFile;
CSBIE_DATA_DIR = cfg.dataDir;
MNIE_ENV_FILE = toString cfg.envFile;
MNIE_DATA_DIR = cfg.dataDir;
PORT = toString cfg.port;
};
serviceConfig = {
@@ -228,7 +228,7 @@
RestartSec = "5s";
};
script = ''
exec ${nativeRun}/bin/csbi-native-run
exec ${nativeRun}/bin/mnie-native-run
'';
};
})
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "csbi",
"name": "mnie",
"private": true,
"workspaces": [
"apps/*",
+5
View File
@@ -0,0 +1,5 @@
# client-sbi
SBI provider client for Mnie.
The public finance method contract is exported from `@repo/mnie-types`; this package contains SBI-specific login, parsing, and transport code.
@@ -1,5 +1,5 @@
{
"name": "@repo/sbi-client",
"name": "@repo/client-sbi",
"private": true,
"type": "module",
"module": "index.ts",
@@ -15,6 +15,7 @@
"typecheck": "tsc"
},
"dependencies": {
"@repo/mnie-types": "workspace:*",
"iconv-lite": "^0.7.2"
},
"devDependencies": {
@@ -6,6 +6,7 @@ export {
} from './methods/error-map'
export type { SbiServerErrorOptions } from './methods/error-map'
export { loginWithPasskey } from './session'
export type * from '@repo/mnie-types'
export type {
LoginWithPasskeyOptions,
PasskeyLoginResponse,
+1
View File
@@ -0,0 +1 @@
export type * from '@repo/mnie-types'
@@ -207,7 +207,7 @@ const resolveForeignStockEndpointConfig = (
restUrl: optionalUrl(restUrl) ?? requiredDerivedUrl(baseUrl, '/rest/'),
graphqlBffUrl: optionalUrl(graphqlBffUrl) ?? requiredDerivedUrl(baseUrl, '/graphql/bff'),
graphqlIntUrl: optionalUrl(graphqlIntUrl) ?? requiredDerivedUrl(baseUrl, '/graphql/int'),
userAgent: userAgent || 'SBIFStockAndroid/1.6.10(csbi/0)',
userAgent: userAgent || 'SBIFStockAndroid/1.6.10(mnie/0)',
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@repo/mnie-cli",
"private": true,
"bin": {
"mnie": "./src/index.ts"
},
"type": "module",
"scripts": {
"clean": "rm -rf dist",
"typecheck": "tsc"
},
"dependencies": {
"@napi-rs/keyring": "^1.2.0",
"@repo/mnie-sdk": "workspace:*"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env bun
import { createHash, randomBytes } from 'node:crypto'
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { createMnieClient } from '@repo/mnie-sdk'
type Profile = {
origin: string
apiKeyStorage: 'file' | 'keyring'
apiKey?: string
keyringAccount?: string
}
type ProfilesFile = {
defaultProfile?: string
profiles: Record<string, Profile>
}
const SERVICE = 'mnie-cli'
const configDir = join(homedir(), '.mnie-cli')
const configPath = join(configDir, 'profiles.json')
const help = `mnie cli
Usage:
mnie --help
mnie profile add <name> --origin <origin> --api-key <key> [--storage file|keyring]
mnie profile list
mnie profile use <name>
mnie rpc methods [--profile <name>]
mnie rpc call <method> [json-params] [--profile <name>] [--passkey-id <id>]
mnie login --origin <origin> [--profile <name>] [--scopes <scopes>] [--storage file|keyring]
Examples:
mnie profile add local --origin http://127.0.0.1:8787 --api-key mnie_xxx
mnie rpc call account.profile --passkey-id sbi_xxx
`
const parseOptions = (args: string[]) => {
const positionals: string[] = []
const options: Record<string, string | true> = {}
for (let index = 0; index < args.length; index++) {
const arg = args[index]!
if (!arg.startsWith('--')) {
positionals.push(arg)
continue
}
const key = arg.slice(2)
const next = args[index + 1]
if (!next || next.startsWith('--')) {
options[key] = true
continue
}
options[key] = next
index++
}
return { positionals, options }
}
const option = (options: Record<string, string | true>, key: string) => {
const value = options[key]
return typeof value === 'string' ? value : undefined
}
const isString = (value: string | undefined): value is string => typeof value === 'string'
const loadProfiles = async (): Promise<ProfilesFile> => {
try {
return JSON.parse(await readFile(configPath, 'utf8')) as ProfilesFile
} catch (cause) {
if (cause && typeof cause === 'object' && 'code' in cause && cause.code === 'ENOENT') {
return { profiles: {} }
}
throw cause
}
}
const saveProfiles = async (profiles: ProfilesFile) => {
await mkdir(dirname(configPath), { recursive: true })
await writeFile(configPath, `${JSON.stringify(profiles, null, 2)}\n`, { mode: 0o600 })
await chmod(configPath, 0o600)
}
const keyring = async () => import('@napi-rs/keyring/keytar')
const saveApiKey = async (
name: string,
origin: string,
apiKey: string,
storage: 'file' | 'keyring',
) => {
if (storage === 'file') return { apiKey, apiKeyStorage: storage } as const
const account = `profile:${name}`
const { setPassword } = await keyring()
await setPassword(SERVICE, account, JSON.stringify({ origin, apiKey }))
return { keyringAccount: account, apiKeyStorage: storage } as const
}
const readApiKey = async (name: string, profile: Profile) => {
if (profile.apiKeyStorage === 'file') {
if (!profile.apiKey) throw new Error(`profile ${name} has no file api key`)
return profile.apiKey
}
if (!profile.keyringAccount) throw new Error(`profile ${name} has no keyring account`)
const { getPassword } = await keyring()
const payload = await getPassword(SERVICE, profile.keyringAccount)
if (!payload) throw new Error(`profile ${name} api key was not found in keyring`)
return (JSON.parse(payload) as { apiKey: string }).apiKey
}
const requireProfile = async (name?: string) => {
const profiles = await loadProfiles()
const selected = name ?? profiles.defaultProfile
if (!selected) throw new Error('profile is required')
const profile = profiles.profiles[selected]
if (!profile) throw new Error(`profile not found: ${selected}`)
return { name: selected, profile, apiKey: await readApiKey(selected, profile) }
}
const printJson = (value: unknown) => {
console.log(JSON.stringify(value, null, 2))
}
const addProfile = async (args: string[]) => {
const { positionals, options } = parseOptions(args)
const name = positionals[0]
const origin = option(options, 'origin')
const apiKey = option(options, 'api-key')
const storage = option(options, 'storage') ?? 'file'
if (!name || !origin || !apiKey)
throw new Error('profile add requires name, --origin and --api-key')
if (storage !== 'file' && storage !== 'keyring')
throw new Error('--storage must be file or keyring')
const profiles = await loadProfiles()
profiles.profiles[name] = {
origin: new URL(origin).origin,
...(await saveApiKey(name, new URL(origin).origin, apiKey, storage)),
}
profiles.defaultProfile ??= name
await saveProfiles(profiles)
printJson({ ok: true, profile: name })
}
const login = async (args: string[]) => {
const { options } = parseOptions(args)
const origin = option(options, 'origin')
if (!origin) throw new Error('login requires --origin')
const profileName = option(options, 'profile') ?? 'default'
const scopes = option(options, 'scopes') ?? 'mcp read write trade'
const storage = option(options, 'storage') ?? 'keyring'
if (storage !== 'file' && storage !== 'keyring')
throw new Error('--storage must be file or keyring')
const callback = await listenForOAuthCallback()
const redirectUri = `http://127.0.0.1:${callback.port}/callback`
const verifier = randomBytes(32).toString('base64url')
const challenge = createHash('sha256').update(verifier).digest('base64url')
const client = await registerOAuthClient(new URL(origin).origin, redirectUri)
const authorize = new URL('/authorize', origin)
authorize.searchParams.set('response_type', 'code')
authorize.searchParams.set('client_id', client.client_id)
authorize.searchParams.set('redirect_uri', redirectUri)
authorize.searchParams.set('code_challenge', challenge)
authorize.searchParams.set('code_challenge_method', 'S256')
authorize.searchParams.set('scope', scopes)
console.error(`Open this URL to approve Mnie CLI:\n${authorize.toString()}`)
openBrowser(authorize.toString())
const code = await callback.code
const tokens = await exchangeCode(new URL(origin).origin, {
clientId: client.client_id,
redirectUri,
code,
verifier,
})
const profiles = await loadProfiles()
profiles.profiles[profileName] = {
origin: new URL(origin).origin,
...(await saveApiKey(profileName, new URL(origin).origin, tokens.access_token, storage)),
}
profiles.defaultProfile = profileName
await saveProfiles(profiles)
printJson({ ok: true, profile: profileName, scope: tokens.scope })
}
const listenForOAuthCallback = async () => {
let resolveCode!: (code: string) => void
let rejectCode!: (error: Error) => void
const code = new Promise<string>((resolve, reject) => {
resolveCode = resolve
rejectCode = reject
})
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(request) {
const url = new URL(request.url)
const error = url.searchParams.get('error')
const value = url.searchParams.get('code')
queueMicrotask(() => server.stop(true))
if (error) {
rejectCode(new Error(error))
return new Response('Mnie login failed. You can close this tab.', { status: 400 })
}
if (!value) {
rejectCode(new Error('oauth callback did not include a code'))
return new Response('Mnie login failed. You can close this tab.', { status: 400 })
}
resolveCode(value)
return new Response('Mnie login complete. You can close this tab.')
},
})
return { port: server.port, code }
}
const registerOAuthClient = async (origin: string, redirectUri: string) => {
const response = await fetch(new URL('/register', origin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
client_name: 'Mnie CLI',
redirect_uris: [redirectUri],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
}),
})
if (!response.ok) throw new Error(await response.text())
return response.json() as Promise<{ client_id: string }>
}
const exchangeCode = async (
origin: string,
options: { clientId: string; redirectUri: string; code: string; verifier: string },
) => {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: options.clientId,
redirect_uri: options.redirectUri,
code: options.code,
code_verifier: options.verifier,
})
const response = await fetch(new URL('/token', origin), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
})
if (!response.ok) throw new Error(await response.text())
return response.json() as Promise<{ access_token: string; scope?: string }>
}
const openBrowser = (url: string) => {
const command =
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
Bun.spawn({ cmd: [command, ...args], stdout: 'ignore', stderr: 'ignore' })
}
const rpc = async (args: string[]) => {
const { positionals, options } = parseOptions(args)
const { name, profile, apiKey } = await requireProfile(option(options, 'profile'))
const client = createMnieClient({ origin: profile.origin, apiKey })
try {
if (positionals[0] === 'methods') {
printJson(await client.methods())
return
}
if (positionals[0] !== 'call' || !positionals[1])
throw new Error('rpc requires methods or call')
const passkeyId = option(options, 'passkey-id')
if (passkeyId) await client.connect({ passkeyId })
const params = positionals[2] ? JSON.parse(positionals[2]) : undefined
const method = positionals[1]
const result = await method.split('.').reduce<unknown>((target, key) => {
if (!target || (typeof target !== 'object' && typeof target !== 'function')) return undefined
return (target as Record<string, unknown>)[key]
}, client)
if (typeof result !== 'function') throw new Error(`method is not callable: ${method}`)
printJson(await result(params))
} finally {
client.close()
console.error(`profile: ${name}`)
}
}
const main = async () => {
const [command, subcommand, ...rest] = Bun.argv.slice(2)
if (!command || command === '--help' || command === '-h') {
console.log(help)
return
}
if (command === 'profile' && subcommand === 'add') return addProfile(rest)
if (command === 'profile' && subcommand === 'list') return printJson(await loadProfiles())
if (command === 'profile' && subcommand === 'use') {
const [name] = rest
if (!name) throw new Error('profile use requires a name')
const profiles = await loadProfiles()
if (!profiles.profiles[name]) throw new Error(`profile not found: ${name}`)
profiles.defaultProfile = name
await saveProfiles(profiles)
return printJson({ ok: true, profile: name })
}
if (command === 'rpc') return rpc([subcommand, ...rest].filter(isString))
if (command === 'login') return login([subcommand, ...rest].filter(isString))
throw new Error(`unknown command: ${command}`)
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@repo/mnie-sdk",
"private": true,
"type": "module",
"module": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"clean": "rm -rf dist",
"typecheck": "tsc"
},
"dependencies": {
"@repo/mnie-types": "workspace:*"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}
+113
View File
@@ -0,0 +1,113 @@
import type { SbiClientMethods } from '@repo/mnie-types'
export type MnieSdkOptions = {
origin: string
apiKey: string
WebSocket?: typeof WebSocket
}
export type JsonRpcError = {
code: number
message: string
}
export type MnieClient = SbiClientMethods & {
connect(options: { passkeyId: string }): Promise<{ connected: true; passkeyId: string }>
methods(): Promise<string[]>
close(): void
}
type JsonRpcResponse = {
jsonrpc?: '2.0'
id?: string | number | null
result?: unknown
error?: JsonRpcError
}
type PendingCall = {
resolve: (value: unknown) => void
reject: (error: Error) => void
}
const websocketUrl = (origin: string, apiKey: string) => {
const url = new URL('/api/ws', origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.searchParams.set('key', apiKey)
return url.toString()
}
class MnieJsonRpcClient {
readonly #socket: WebSocket
readonly #pending = new Map<string, PendingCall>()
#nextId = 1
#opened: Promise<void>
constructor(options: MnieSdkOptions) {
const WebSocketCtor = options.WebSocket ?? WebSocket
this.#socket = new WebSocketCtor(websocketUrl(options.origin, options.apiKey))
this.#opened = new Promise((resolve, reject) => {
this.#socket.addEventListener('open', () => resolve(), { once: true })
this.#socket.addEventListener(
'error',
() => reject(new Error('failed to connect to Mnie RPC')),
{ once: true },
)
})
this.#socket.addEventListener('message', (event) => this.#handleMessage(event))
this.#socket.addEventListener('close', () => this.#rejectPending('Mnie RPC socket closed'))
}
close() {
this.#socket.close()
}
async call(method: string, params?: unknown) {
await this.#opened
const id = String(this.#nextId++)
const response = new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject })
})
this.#socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
return response
}
#handleMessage(event: MessageEvent) {
const message = JSON.parse(String(event.data)) as JsonRpcResponse
if (message.id == null) return
const pending = this.#pending.get(String(message.id))
if (!pending) return
this.#pending.delete(String(message.id))
if (message.error) {
pending.reject(new Error(message.error.message))
return
}
pending.resolve(message.result)
}
#rejectPending(message: string) {
for (const pending of this.#pending.values()) pending.reject(new Error(message))
this.#pending.clear()
}
}
const methodProxy = (rpc: MnieJsonRpcClient, path: string[] = []): unknown =>
new Proxy(() => undefined, {
get(_target, property) {
if (property === 'then') return undefined
if (property === 'connect') {
return (params: { passkeyId: string }) => rpc.call('sbi.connect', params)
}
if (property === 'methods') return () => rpc.call('rpc.methods')
if (property === 'close') return () => rpc.close()
if (typeof property !== 'string') return undefined
return methodProxy(rpc, [...path, property])
},
apply(_target, _thisArg, args) {
return rpc.call(path.join('.'), args.length > 1 ? args : args[0])
},
})
export const createMnieClient = (options: MnieSdkOptions) =>
methodProxy(new MnieJsonRpcClient(options)) as MnieClient
export type * from '@repo/mnie-types'
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@repo/mnie-types",
"private": true,
"type": "module",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"clean": "rm -rf dist",
"typecheck": "tsc"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}
+2
View File
@@ -0,0 +1,2 @@
export type * from './types'
export type * from './methods'
@@ -40,7 +40,7 @@ import type {
TradeRecordList,
TradeSide,
Watchlist,
} from '../types'
} from './types'
export type PagingOptions = {
/** Start index for the result list. Defaults to the first item when omitted. */
+951
View File
@@ -0,0 +1,951 @@
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
accessToken?: string
header: {
sessionId: string
trCode: string
resultCode: string
} | null
}
export type ForeignStockEndpointConfig = {
baseUrl?: string
restUrl: string
graphqlBffUrl: string
graphqlIntUrl: string
userAgent?: string
}
export type ForeignStockSession = {
endpoints: ForeignStockEndpointConfig
ssoToken?: string
sessionId?: string
accountId?: string
marketPriceHash?: string
candleHash?: string
loginAuthenticated?: boolean
}
export type MainSiteAuthCache = {
baseUrl: string
assetsUrl: string
cookieHeader: string
authenticatedAt: string
}
export type MainSiteSession = {
baseUrl?: string
etGatePath?: string
assetsValuationsPath?: string
exchangeOrderInputPath?: string
exchangeOrderPasswordPath?: string
exchangeOrderConfirmPath?: string
exchangeOrderCompletePath?: string
auth?: MainSiteAuthCache
authPromise?: Promise<MainSiteAuthCache>
}
export type SbiSession = {
mtsBaseUrl: string
izanagiBaseUrl?: string
foreignStock?: ForeignStockSession
mainSite?: MainSiteSession
profile: AccountProfile
loginResponse: PasskeyLoginResponse
tradePassword?: string
deviceIdRegistered?: boolean
tradeAuthentication?: SbiTradeAuthenticationOptions
}
export type LoginWithPasskeyOptions = {
passkeyCredential: PlaintextStoredWebAuthnCredential
authBaseUrl?: string
mtsBaseUrl?: string
izanagiBaseUrl?: string
foreignStockBaseUrl?: string
usStockBaseUrl?: string
foreignStockRestUrl?: string
foreignStockGraphqlBffUrl?: string
foreignStockGraphqlIntUrl?: string
foreignStockUserAgent?: string
mainSiteBaseUrl?: string
mainSiteEtGatePath?: string
mainSiteAssetsValuationsPath?: string
mainSiteExchangeOrderInputPath?: string
mainSiteExchangeOrderPasswordPath?: string
mainSiteExchangeOrderConfirmPath?: string
mainSiteExchangeOrderCompletePath?: 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 DomesticMarketCode = 'XTKS' | 'XNGO' | 'XFKA' | 'XSAP'
export type SKabuMarketCode = 'STK'
export type UsStockMarketCode = 'XNAS' | 'XNYS' | 'ARCX'
export type MarketCode = DomesticMarketCode | SKabuMarketCode | UsStockMarketCode
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' | 'USD'
}
export type PercentValue = {
value: number | null
text: string
}
export type SignedTextValue = {
value: number | null
text: string
sign?: 'positive' | 'negative' | 'zero'
}
export type AccountAssetsValuationSummary = {
assetsErrorType: unknown
valuation: number | null
netChange: number | null
percentChange: number | null
monthOnMonth: number | null
monthOnMonthRatio: unknown
profitLoss: number | null
profitLossRate: number | null
acquisitionCost: number | null
}
export type AccountAssetsValuationDetail = AccountAssetsValuationSummary & {
category: string
compositionRatio: number | null
}
export type AccountAssetsValuations = {
fetchedAt: string
summary: AccountAssetsValuationSummary
summaryWithoutDeposit: AccountAssetsValuationSummary
summaryWithoutIdeco?: AccountAssetsValuationSummary
summaryWithoutDepositAndIdeco?: AccountAssetsValuationSummary
summaryDetails: AccountAssetsValuationDetail[]
summaryDetailsWithoutDeposit: AccountAssetsValuationDetail[]
summaryDetailsWithoutIdeco: AccountAssetsValuationDetail[]
summaryDetailsWithoutDepositAndIdeco: AccountAssetsValuationDetail[]
}
export type AccountType =
| 'general'
| 'specific'
| 'growthInvestment'
| 'nisa'
| 'juniorNisa'
| 'unknown'
export type DepositType =
| 'general'
| 'specific'
| 'growthInvestment'
| '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
orderSubNo?: string
tradeId?: string
exchangeCode?: string
accountInformation?: string
cancelable?: boolean
correctable?: boolean
}
export type OrderList = {
orders: Order[]
hasMore?: boolean
error?: SbiMethodError
}
export type TradeRecord = {
id: string
issue: IssueRef
tradeRecordTypeCode?: string
tradeCurrencyCode?: string
listedSecuritiesStatus?: string
orderPriceKindCode?: string
accountType?: AccountType
settlementCurrencyCode?: string
amount?: CurrencyAmount
quantity?: number | null
price?: CurrencyAmount
tradeDate?: string
valueDate?: string
marginCloseLimitType?: string
}
export type TradeRecordList = {
records: TradeRecord[]
hasMore?: boolean
error?: SbiMethodError
}
export type OrderCorrectionPreOrderDetail = {
exchangeName?: string
marketLoanKbn?: string
marketIppanLoanKbn?: string
currentPrice?: CurrencyAmount
tradeColorFlag?: string
priceTick?: string
priceTickText?: string
tradeTime?: string
changeText?: string
volumeText?: string
}
export type OrderCorrectionPreOrder = {
issue: IssueRef
tradeTitle?: string
buyingPowerTotal?: CurrencyAmount
controlledStockCode?: string
hasTradeWarning?: boolean
deficitMessageFlag?: string
deficitMessage?: string
details: OrderCorrectionPreOrderDetail[]
orderNumber?: string
orderId?: string
primaryOrderMethod?: string
primaryTriggerZone?: string
primaryTriggerPrice?: number | null
status?: string
statusText?: string
tradeId?: string
tradeName?: string
quantity?: number | null
quantityText?: string
orderLimit?: string
orderLimitText?: string
priceSteps: StockOrderPreOrderPriceStep[]
sessionRange?: string
inputDateText?: string
primaryOrderTerm?: string
nonSpecificTradeText?: string
marketName?: string
rbeOrderStatus?: string
priceCondition?: string
price?: number | null
priceAmount?: CurrencyAmount
exchangeName?: string
transId?: string
ptsDayNightFlag?: string
smallTickFlag?: string
juniorBuyingPowerTotal?: CurrencyAmount
secondaryPriceCondition?: string
secondaryPrice?: number | null
secondaryPriceAmount?: CurrencyAmount
autoOrderKind?: string
autoOrderNumber?: string
autoOrderInputDate?: string
secondaryOrderMethod?: string
secondaryTriggerZone?: string
secondaryTriggerPrice?: number | null
secondaryOrderCondition?: string
secondaryLimitPrice?: number | null
secondaryLimitPriceAmount?: CurrencyAmount
secondaryOrderTerm?: string
secondaryOcoPriceCondition?: string
secondaryOcoPrice?: number | null
secondaryOcoPriceAmount?: CurrencyAmount
exchangeList?: string
}
export type OrderPreview = {
issue: IssueRef
side: TradeSide
quantity?: number
price?: CurrencyAmount
estimatedAmount?: CurrencyAmount
commission?: CurrencyAmount
tax?: CurrencyAmount
warnings: string[]
confirmationId?: string
message?: string
correction?: OrderCorrectionPreOrder
error?: SbiMethodError
}
export type StockOrderPreOrderPriceStep = {
from?: CurrencyAmount
to?: CurrencyAmount
}
export type StockOrderPreOrderPaymentLimit = {
text?: string
code?: string
}
export type StockOrderPreOrderMarginTradeType =
| 'standard'
| 'generalBuy'
| 'generalSellShort'
| 'generalSellInventoryLimited'
| 'generalSellInventoryUnlimited'
| 'day'
| 'hyper'
export type StockOrderPreOrder = {
issue: IssueRef
tradeTitle?: string
buyingPowerTotal?: CurrencyAmount
controlledStockCode?: string
hasTradeWarning?: boolean
market?: MarketCode
exchangeList?: string
exchangeListName?: string
exchangeListIndexFlag?: string
marketLoanKbn?: string
marketIppanLoanKbn?: string
currentPrice?: CurrencyAmount
tradeColorFlag?: string
priceTick?: string
priceTickText?: string
tradeTime?: string
changeText?: string
volume?: number | null
lotSize?: number | null
priceSteps: StockOrderPreOrderPriceStep[]
sessionRange?: string
basePrice?: CurrencyAmount
orderTerms: string[]
orderTermDates: string[]
paymentLimits: StockOrderPreOrderPaymentLimit[]
nonSpecificTradeText?: string
paymentLimitText?: string
acquisitionPrice?: CurrencyAmount
position?: number | null
unexecutedQuantity?: number | null
lotSize2?: number | null
ptsDayNightFlag?: string
sorServiceType?: string
nisa?: {
serviceKbn?: string
buyLimit?: CurrencyAmount
growthServiceKbn?: string
juniorServiceKbn?: string
juniorBuyLimit?: CurrencyAmount
juniorBuyingPowerTotal?: CurrencyAmount
}
smallTickFlag?: string
margin?: {
tradeTypes?: StockOrderPreOrderMarginTradeType[]
ippanShort?: string
ippanLong?: string
dayBuy?: string
daySell?: string
premiumShortSelling?: string
premiumFee?: CurrencyAmount
ippanPaymentLimit?: string
positionStatus?: string
}
sKabu?: {
code?: string
available?: boolean
}
deficitMessageFlag?: string
deficitMessage?: string
error?: SbiMethodError
}
export type OrderReceipt = {
accepted: boolean
orderId?: OrderId
acceptedAt?: string
message?: string
error?: SbiMethodError
}
export type ExchangeOrderSide = 'buy' | 'sell'
export type ExchangeSpecificMethod = 'foreign' | 'domestic'
export type ExchangeAccountKind = 'GENERAL' | 'JR_NISA'
export type ExchangeSellMethod = 'SELL_PART' | 'SELL_ALL'
export type ExchangeOrderPreview = {
currencyCode: string
currencyName?: string
side: ExchangeOrderSide
exchangeType?: string
accountKind?: ExchangeAccountKind
specificMethod?: ExchangeSpecificMethod
sellMethod?: ExchangeSellMethod | null
tradeQuantity?: string
orderAmount?: string
exchangeRate?: string
netAmount?: string
valueDate?: string
rateDateTime?: string
warningMessage?: string | null
isMaintenance?: boolean
csrfToken: string
}
export type ExchangeOrderReceipt = {
accepted: boolean
currencyCode?: string
side?: ExchangeOrderSide
message?: string
warningMessage?: string | null
rawTitle?: string
}
export type ExchangeRateInfo = {
currencyCode: string
side: ExchangeOrderSide
referenceExchangeRate?: string
computeExchangeRate?: string
basePrice?: string
exchangeTradeType?: string
updateTime?: string
buyPossibleAmount?: string
sellPossibleAmount?: string
buyUnit?: string
sellUnit?: string
buyLimitMin?: string
buyLimitMax?: string
sellLimitMin?: string
sellLimitMax?: string
raw: Record<string, unknown>
}
export type ThemeInvestment = {
id: ThemeId
name: string
issues: ThemeInvestmentIssue[]
minimumAmount?: CurrencyAmount
}
export type ThemeInvestmentIssue = IssueRef & {
controlledStockCode?: string
hasTradeWarning?: boolean
nisaServiceKbn?: string
juniorNisaServiceKbn?: string
growthNisaServiceKbn?: string
sKabuCode?: string
sKabuAvailable?: boolean
lotSize?: number | null
currentPrice?: CurrencyAmount
tradeColorFlag?: string
priceTick?: string
priceTickText?: string
tradeTime?: string
}
export type ThemeInvestmentList = {
themes: ThemeInvestment[]
buyingPowerTotal?: CurrencyAmount
isaBuyLimit?: CurrencyAmount
juniorNisaBuyLimit?: CurrencyAmount
buyingPowerTotalJuniorNisa?: CurrencyAmount
deficitMessage?: string
deficitMessageFlag?: string
error?: SbiMethodError
}
export type SbiMethodError = {
status?: string
code?: string
message?: string
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}

Some files were not shown because too many files have changed in this diff Show More