From 20434dcd11ca7dc686ec9a3b2fd6c7ab01b78890 Mon Sep 17 00:00:00 2001 From: Shotaro Nakamura Date: Thu, 18 Jun 2026 22:59:53 +0900 Subject: [PATCH] first commit --- .env.example | 23 + .gitignore | 63 + AGENTS.md | 10 + README.md | 5 + apps/csbie-server/drizzle.config.ts | 10 + apps/csbie-server/package.json | 31 + apps/csbie-server/src/app.ts | 67 + apps/csbie-server/src/config.ts | 42 + apps/csbie-server/src/context.ts | 27 + apps/csbie-server/src/db/index.ts | 152 + apps/csbie-server/src/db/schema.ts | 102 + apps/csbie-server/src/index.ts | 17 + apps/csbie-server/src/routes/admin.ts | 108 + apps/csbie-server/src/routes/auth.ts | 218 ++ apps/csbie-server/src/routes/mcp.ts | 604 ++++ apps/csbie-server/src/routes/oauth.ts | 90 + apps/csbie-server/src/rpc/methods.ts | 112 + apps/csbie-server/src/rpc/sbi-session.ts | 33 + apps/csbie-server/src/rpc/ws.ts | 253 ++ apps/csbie-server/src/security/api-keys.ts | 86 + apps/csbie-server/src/security/crypto.ts | 13 + apps/csbie-server/src/security/http-auth.ts | 47 + apps/csbie-server/src/security/keyring.ts | 17 + .../src/security/oauth-provider.ts | 191 ++ .../src/security/sbi-credentials.ts | 11 + apps/csbie-server/src/security/sessions.ts | 44 + apps/csbie-server/src/security/setup.ts | 11 + .../csbie-server/src/security/trade-limits.ts | 117 + apps/csbie-server/tsconfig.json | 10 + apps/csbie-ui/index.html | 12 + apps/csbie-ui/package.json | 26 + apps/csbie-ui/src/App.vue | 310 ++ apps/csbie-ui/src/api.ts | 112 + apps/csbie-ui/src/assets/search-empty.png | Bin 0 -> 109331 bytes .../src/components/ApiKeyPolicyEditor.vue | 185 ++ .../src/components/layout/AppHeader.vue | 30 + .../src/components/layout/AppSidebar.vue | 39 + apps/csbie-ui/src/components/ui/Spinner.vue | 28 + apps/csbie-ui/src/components/ui/UiButton.vue | 43 + apps/csbie-ui/src/components/ui/UiCard.vue | 21 + apps/csbie-ui/src/components/ui/UiField.vue | 88 + apps/csbie-ui/src/components/ui/UiModal.vue | 18 + .../src/components/ui/UiSegmented.vue | 31 + apps/csbie-ui/src/constants/nav.ts | 21 + apps/csbie-ui/src/constants/trade.ts | 85 + apps/csbie-ui/src/features/auth/AuthGate.vue | 57 + .../src/features/auth/api-key-policy.ts | 10 + .../src/features/auth/useAuthAdmin.ts | 131 + .../src/features/history/HistoryView.vue | 74 + .../src/features/oauth/OAuthApprovalPanel.vue | 39 + .../src/features/oauth/useOAuthApproval.ts | 78 + .../src/features/orders/OrderDialogs.vue | 205 ++ .../src/features/portfolio/PortfolioView.vue | 191 ++ .../src/features/search/SearchDialog.vue | 115 + .../src/features/settings/SettingsView.vue | 168 + .../src/features/trade/RealtimePriceChart.vue | 225 ++ .../csbie-ui/src/features/trade/TradeView.vue | 546 ++++ .../src/features/trading/trading-data.ts | 301 ++ .../src/features/trading/useTradingSession.ts | 899 ++++++ apps/csbie-ui/src/main.ts | 6 + apps/csbie-ui/src/router.ts | 20 + apps/csbie-ui/src/style.css | 14 + apps/csbie-ui/src/styles/ui.ts | 162 + apps/csbie-ui/src/types/trading.ts | 102 + apps/csbie-ui/src/utils/format.ts | 14 + apps/csbie-ui/tsconfig.json | 9 + apps/csbie-ui/vite.config.ts | 35 + apps/csbie/Dockerfile | 21 + apps/csbie/package.json | 20 + apps/csbie/src/dev.ts | 37 + apps/csbie/src/index.ts | 55 + apps/csbie/tsconfig.json | 10 + bun.lock | 996 ++++++ package.json | 32 + packages/sbi-client/README.md | 15 + packages/sbi-client/package.json | 26 + packages/sbi-client/src/index.ts | 27 + packages/sbi-client/src/methods/error-map.ts | 134 + packages/sbi-client/src/methods/index.ts | 2870 +++++++++++++++++ packages/sbi-client/src/methods/types.ts | 517 +++ packages/sbi-client/src/session/index.ts | 865 +++++ packages/sbi-client/src/types.ts | 602 ++++ packages/sbi-client/tsconfig.json | 29 + scripts/check-env.ts | 51 + scripts/mock-create.js | 298 ++ tools/analyze-sbi-apk.ts | 307 ++ tsconfig.json | 29 + vite.config.ts | 45 + 88 files changed, 13950 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 apps/csbie-server/drizzle.config.ts create mode 100644 apps/csbie-server/package.json create mode 100644 apps/csbie-server/src/app.ts create mode 100644 apps/csbie-server/src/config.ts create mode 100644 apps/csbie-server/src/context.ts create mode 100644 apps/csbie-server/src/db/index.ts create mode 100644 apps/csbie-server/src/db/schema.ts create mode 100644 apps/csbie-server/src/index.ts create mode 100644 apps/csbie-server/src/routes/admin.ts create mode 100644 apps/csbie-server/src/routes/auth.ts create mode 100644 apps/csbie-server/src/routes/mcp.ts create mode 100644 apps/csbie-server/src/routes/oauth.ts create mode 100644 apps/csbie-server/src/rpc/methods.ts create mode 100644 apps/csbie-server/src/rpc/sbi-session.ts create mode 100644 apps/csbie-server/src/rpc/ws.ts create mode 100644 apps/csbie-server/src/security/api-keys.ts create mode 100644 apps/csbie-server/src/security/crypto.ts create mode 100644 apps/csbie-server/src/security/http-auth.ts create mode 100644 apps/csbie-server/src/security/keyring.ts create mode 100644 apps/csbie-server/src/security/oauth-provider.ts create mode 100644 apps/csbie-server/src/security/sbi-credentials.ts create mode 100644 apps/csbie-server/src/security/sessions.ts create mode 100644 apps/csbie-server/src/security/setup.ts create mode 100644 apps/csbie-server/src/security/trade-limits.ts create mode 100644 apps/csbie-server/tsconfig.json create mode 100644 apps/csbie-ui/index.html create mode 100644 apps/csbie-ui/package.json create mode 100644 apps/csbie-ui/src/App.vue create mode 100644 apps/csbie-ui/src/api.ts create mode 100644 apps/csbie-ui/src/assets/search-empty.png create mode 100644 apps/csbie-ui/src/components/ApiKeyPolicyEditor.vue create mode 100644 apps/csbie-ui/src/components/layout/AppHeader.vue create mode 100644 apps/csbie-ui/src/components/layout/AppSidebar.vue create mode 100644 apps/csbie-ui/src/components/ui/Spinner.vue create mode 100644 apps/csbie-ui/src/components/ui/UiButton.vue create mode 100644 apps/csbie-ui/src/components/ui/UiCard.vue create mode 100644 apps/csbie-ui/src/components/ui/UiField.vue create mode 100644 apps/csbie-ui/src/components/ui/UiModal.vue create mode 100644 apps/csbie-ui/src/components/ui/UiSegmented.vue create mode 100644 apps/csbie-ui/src/constants/nav.ts create mode 100644 apps/csbie-ui/src/constants/trade.ts create mode 100644 apps/csbie-ui/src/features/auth/AuthGate.vue create mode 100644 apps/csbie-ui/src/features/auth/api-key-policy.ts create mode 100644 apps/csbie-ui/src/features/auth/useAuthAdmin.ts create mode 100644 apps/csbie-ui/src/features/history/HistoryView.vue create mode 100644 apps/csbie-ui/src/features/oauth/OAuthApprovalPanel.vue create mode 100644 apps/csbie-ui/src/features/oauth/useOAuthApproval.ts create mode 100644 apps/csbie-ui/src/features/orders/OrderDialogs.vue create mode 100644 apps/csbie-ui/src/features/portfolio/PortfolioView.vue create mode 100644 apps/csbie-ui/src/features/search/SearchDialog.vue create mode 100644 apps/csbie-ui/src/features/settings/SettingsView.vue create mode 100644 apps/csbie-ui/src/features/trade/RealtimePriceChart.vue create mode 100644 apps/csbie-ui/src/features/trade/TradeView.vue create mode 100644 apps/csbie-ui/src/features/trading/trading-data.ts create mode 100644 apps/csbie-ui/src/features/trading/useTradingSession.ts create mode 100644 apps/csbie-ui/src/main.ts create mode 100644 apps/csbie-ui/src/router.ts create mode 100644 apps/csbie-ui/src/style.css create mode 100644 apps/csbie-ui/src/styles/ui.ts create mode 100644 apps/csbie-ui/src/types/trading.ts create mode 100644 apps/csbie-ui/src/utils/format.ts create mode 100644 apps/csbie-ui/tsconfig.json create mode 100644 apps/csbie-ui/vite.config.ts create mode 100644 apps/csbie/Dockerfile create mode 100644 apps/csbie/package.json create mode 100644 apps/csbie/src/dev.ts create mode 100644 apps/csbie/src/index.ts create mode 100644 apps/csbie/tsconfig.json create mode 100644 bun.lock create mode 100644 package.json create mode 100644 packages/sbi-client/README.md create mode 100644 packages/sbi-client/package.json create mode 100644 packages/sbi-client/src/index.ts create mode 100644 packages/sbi-client/src/methods/error-map.ts create mode 100644 packages/sbi-client/src/methods/index.ts create mode 100644 packages/sbi-client/src/methods/types.ts create mode 100644 packages/sbi-client/src/session/index.ts create mode 100644 packages/sbi-client/src/types.ts create mode 100644 packages/sbi-client/tsconfig.json create mode 100644 scripts/check-env.ts create mode 100644 scripts/mock-create.js create mode 100644 tools/analyze-sbi-apk.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d4534c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copy to .env for local development. Do not commit real values. + +# Local development +PORT=8787 +CSBIE_SERVER_PORT=8787 +CSBIE_UI_DEV_PORT=5173 +CSBIE_DATABASE_PATH=./data/csbie.sqlite +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 + +# Initial owner passkey setup. Prefer the hash form outside throwaway local dev. +CSBIE_SETUP_PASSWORD= +CSBIE_SETUP_PASSWORD_HASH= + +# SBI endpoints and local SBI session options. Values are loaded at runtime only. +SBI_AUTH_BASE_URL= +SBI_MTS_BASE_URL= +SBI_IZANAGI_BASE_URL= +SBI_TRADE_PASSWORD= +SBI_DEVICE_ID= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..884a658 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz +*.zip +*.tar +*.tar.gz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env* +!.env.example + +# credentials and captured sessions +*.har +*.pem +*.key +*.p12 +*.pfx +*.keystore +passkey*.json +session*.json + +# third-party/proprietary app artifacts +tunnel-client +*.apk +*.aab +*.dex +*.jar +*.class +apktool/ +jadx/ +decoded/ +analysis/ + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IDEs +.idea +.vscode + +# Finder (MacOS) folder config +.DS_Store + +workspace/ + +tmp + +data/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a41aa17 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +## Rules + +- Run `bun fmt` and `bun typecheck` before finishing. +- Do not hardcode real endpoint + +## Hints + +- 技術的に不可能なことはできないと応答する。代わりに fallback を使ったりはしない。相談する。 +- fallback は控える。エラーを積極的に出す。 +- 実際の passkey.json を利用して、SDK を実際に実行して直ったか確認する diff --git a/README.md b/README.md new file mode 100644 index 0000000..8612f16 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# cSBI + +[![技術者倫理 遵守済み](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. diff --git a/apps/csbie-server/drizzle.config.ts b/apps/csbie-server/drizzle.config.ts new file mode 100644 index 0000000..283c02a --- /dev/null +++ b/apps/csbie-server/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: process.env.CSBIE_DATABASE_PATH ?? './data/csbie.sqlite', + }, +}) diff --git a/apps/csbie-server/package.json b/apps/csbie-server/package.json new file mode 100644 index 0000000..c18674e --- /dev/null +++ b/apps/csbie-server/package.json @@ -0,0 +1,31 @@ +{ + "name": "@repo/csbie-server", + "private": true, + "type": "module", + "exports": { + "./app": "./src/app.ts", + "./config": "./src/config.ts", + "./db": "./src/db/index.ts" + }, + "scripts": { + "dev": "bun --env-file=../../.env --watch src/index.ts", + "clean": "rm -rf dist drizzle", + "typecheck": "tsc", + "db:push": "bun --env-file=../../.env drizzle-kit push" + }, + "dependencies": { + "@hono/mcp": "^0.3.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "^1.2.0", + "@repo/sbi-client": "workspace:*", + "@simplewebauthn/server": "^13.1.2", + "drizzle-orm": "^0.44.2", + "hono": "^4.8.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^0.31.1", + "typescript": "^5" + } +} diff --git a/apps/csbie-server/src/app.ts b/apps/csbie-server/src/app.ts new file mode 100644 index 0000000..5a120a9 --- /dev/null +++ b/apps/csbie-server/src/app.ts @@ -0,0 +1,67 @@ +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { secureHeaders } from 'hono/secure-headers' +import { mcpAuthRouter } from '@hono/mcp' +import type { ServerConfig } from './config' +import type { AppBindings } from './context' +import type { Db } from './db' +import { createOAuthServerProvider } from './security/oauth-provider' +import { authenticateRequest } from './security/http-auth' +import { createAdminRoutes } from './routes/admin' +import { createAuthRoutes } from './routes/auth' +import { createMcpRoutes } from './routes/mcp' +import { createOAuthRoutes } from './routes/oauth' +import { createRpcWebSocket } from './rpc/ws' + +export const createServerApp = (db: Db, config: ServerConfig) => { + const app = new Hono() + const rpcWebSocket = createRpcWebSocket(db, config) + const oauthProvider = createOAuthServerProvider(db, config) + + app.use('*', async (c, next) => { + const auth = await authenticateRequest(db, config, c.req.raw) + c.set('db', db) + c.set('config', config) + c.set('auth', auth) + c.set('authenticated', auth.authenticated) + await next() + }) + + app.use( + '*', + secureHeaders({ + crossOriginEmbedderPolicy: false, + }), + ) + app.use( + '*', + cors({ + origin: config.corsOrigin, + credentials: true, + }), + ) + + app.get('/health', (c) => c.json({ ok: true })) + app.route( + '/', + mcpAuthRouter({ + issuerUrl: new URL(config.origin), + baseUrl: new URL(config.origin), + resourceServerUrl: new URL('/api/mcp', config.origin), + resourceName: 'CSBIE MCP', + scopesSupported: ['mcp'], + provider: oauthProvider, + authorizationOptions: { rateLimit: false }, + tokenOptions: { rateLimit: false }, + clientRegistrationOptions: { rateLimit: false }, + revocationOptions: { rateLimit: false }, + }), + ) + app.get('/ws', rpcWebSocket.upgradeWebSocket) + app.route('/auth', createAuthRoutes()) + app.route('/admin', createAdminRoutes()) + app.route('/oauth', createOAuthRoutes()) + app.route('/mcp', createMcpRoutes()) + + return { app, websocket: rpcWebSocket.websocket } +} diff --git a/apps/csbie-server/src/config.ts b/apps/csbie-server/src/config.ts new file mode 100644 index 0000000..ddf2582 --- /dev/null +++ b/apps/csbie-server/src/config.ts @@ -0,0 +1,42 @@ +import { mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +export type ServerConfig = { + port: number + databasePath: string + corsOrigin: string + sessionCookieName: string + rpName: string + rpId: string + origin: string + authBaseUrl?: string + mtsBaseUrl?: string + izanagiBaseUrl?: string +} + +const optionalUrl = (value: string | undefined) => { + if (!value) return undefined + return new URL(value).toString() +} + +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') + 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 + + 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', + rpId, + origin, + authBaseUrl: optionalUrl(process.env.SBI_AUTH_BASE_URL), + mtsBaseUrl: optionalUrl(process.env.SBI_MTS_BASE_URL), + izanagiBaseUrl: optionalUrl(process.env.SBI_IZANAGI_BASE_URL), + } +} diff --git a/apps/csbie-server/src/context.ts b/apps/csbie-server/src/context.ts new file mode 100644 index 0000000..fb667fa --- /dev/null +++ b/apps/csbie-server/src/context.ts @@ -0,0 +1,27 @@ +import type { ServerConfig } from './config' +import type { Db } from './db' + +export type AppBindings = { + Variables: { + db: Db + config: ServerConfig + authenticated: boolean + auth: AuthContext + } +} + +export type AuthContext = + | { + type: 'none' + authenticated: false + } + | { + type: 'session' + authenticated: true + sessionId: string + } + | { + type: 'apiKey' + authenticated: true + apiKeyId: string + } diff --git a/apps/csbie-server/src/db/index.ts b/apps/csbie-server/src/db/index.ts new file mode 100644 index 0000000..6ee0c4e --- /dev/null +++ b/apps/csbie-server/src/db/index.ts @@ -0,0 +1,152 @@ +import { Database } from 'bun:sqlite' +import { drizzle } from 'drizzle-orm/bun-sqlite' +import * as schema from './schema' + +export type Db = ReturnType + +export const createDb = (path: string) => { + const sqlite = new Database(path, { create: true, strict: true }) + sqlite.run('PRAGMA journal_mode = WAL') + sqlite.run('PRAGMA foreign_keys = ON') + sqlite.run(` + CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS user_passkeys ( + id TEXT PRIMARY KEY, + credential_id TEXT NOT NULL, + public_key TEXT NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS passkey_challenges ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + challenge TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS sbi_passkeys ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + keyring_account TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + max_trades_per_hour INTEGER, + max_trades_per_6_hours INTEGER, + max_trades_per_day INTEGER, + max_order_price_jpy INTEGER, + max_order_amount_jpy INTEGER, + allowed_methods TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + revoked_at INTEGER + ) + `) + const apiKeyColumns = sqlite + .query<{ name: string }, []>('PRAGMA table_info(api_keys)') + .all() + .map((column) => column.name) + for (const [name, type] of [ + ['max_trades_per_hour', 'INTEGER'], + ['max_trades_per_6_hours', 'INTEGER'], + ['max_trades_per_day', 'INTEGER'], + ['max_order_price_jpy', 'INTEGER'], + ['max_order_amount_jpy', 'INTEGER'], + ['allowed_methods', 'TEXT'], + ] as const) { + if (!apiKeyColumns.includes(name)) sqlite.run(`ALTER TABLE api_keys ADD COLUMN ${name} ${type}`) + } + sqlite.run(` + CREATE TABLE IF NOT EXISTS api_key_trade_usage ( + api_key_id TEXT NOT NULL, + window TEXT NOT NULL, + hour_bucket TEXT NOT NULL, + trade_count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (api_key_id, window, hour_bucket) + ) + `) + const tradeUsageColumns = sqlite + .query<{ name: string }, []>('PRAGMA table_info(api_key_trade_usage)') + .all() + .map((column) => column.name) + if (!tradeUsageColumns.includes('window')) { + sqlite.run('DROP TABLE api_key_trade_usage') + sqlite.run(` + CREATE TABLE api_key_trade_usage ( + api_key_id TEXT NOT NULL, + window TEXT NOT NULL, + hour_bucket TEXT NOT NULL, + trade_count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (api_key_id, window, hour_bucket) + ) + `) + } + sqlite.run(` + CREATE TABLE IF NOT EXISTS oauth_clients ( + id TEXT PRIMARY KEY, + client TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + scopes TEXT NOT NULL, + resource TEXT, + api_key_settings TEXT, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + `) + const oauthCodeColumns = sqlite + .query<{ name: string }, []>('PRAGMA table_info(oauth_authorization_codes)') + .all() + .map((column) => column.name) + if (!oauthCodeColumns.includes('api_key_settings')) { + sqlite.run('ALTER TABLE oauth_authorization_codes ADD COLUMN api_key_settings TEXT') + } + sqlite.run(` + CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + api_key_id TEXT NOT NULL, + scopes TEXT NOT NULL, + resource TEXT, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + created_at INTEGER NOT NULL + ) + `) + + return drizzle(sqlite, { schema }) +} diff --git a/apps/csbie-server/src/db/schema.ts b/apps/csbie-server/src/db/schema.ts new file mode 100644 index 0000000..35d6fa1 --- /dev/null +++ b/apps/csbie-server/src/db/schema.ts @@ -0,0 +1,102 @@ +import { integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' + +export const appState = sqliteTable('app_state', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}) + +export const userPasskeys = sqliteTable('user_passkeys', { + id: text('id').primaryKey(), + credentialId: text('credential_id').notNull(), + publicKey: text('public_key').notNull(), + counter: integer('counter').notNull().default(0), + transports: text('transports', { mode: 'json' }).$type(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}) + +export const passkeyChallenges = sqliteTable('passkey_challenges', { + id: text('id').primaryKey(), + kind: text('kind', { enum: ['registration', 'authentication'] }).notNull(), + challenge: text('challenge').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}) + +export const sessions = sqliteTable('sessions', { + id: text('id').primaryKey(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}) + +export const sbiPasskeys = sqliteTable('sbi_passkeys', { + id: text('id').primaryKey(), + label: text('label').notNull(), + keyringAccount: text('keyring_account').notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}) + +export const apiKeys = sqliteTable( + 'api_keys', + { + id: text('id').primaryKey(), + label: text('label').notNull(), + tokenHash: text('token_hash').notNull(), + maxTradesPerHour: integer('max_trades_per_hour'), + maxTradesPer6Hours: integer('max_trades_per_6_hours'), + maxTradesPerDay: integer('max_trades_per_day'), + maxOrderPriceJpy: integer('max_order_price_jpy'), + maxOrderAmountJpy: integer('max_order_amount_jpy'), + allowedMethods: text('allowed_methods', { mode: 'json' }).$type(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }), + revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }), + }, + (table) => [uniqueIndex('api_keys_token_hash_unique').on(table.tokenHash)], +) + +export const apiKeyTradeUsage = sqliteTable( + 'api_key_trade_usage', + { + apiKeyId: text('api_key_id').notNull(), + window: text('window', { enum: ['1h', '3h', '1d'] }).notNull(), + hourBucket: text('hour_bucket').notNull(), + tradeCount: integer('trade_count').notNull().default(0), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), + }, + (table) => [primaryKey({ columns: [table.apiKeyId, table.window, table.hourBucket] })], +) + +export const oauthClients = sqliteTable('oauth_clients', { + id: text('id').primaryKey(), + client: text('client', { mode: 'json' }).$type>().notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}) + +export const oauthAuthorizationCodes = sqliteTable('oauth_authorization_codes', { + code: text('code').primaryKey(), + clientId: text('client_id').notNull(), + redirectUri: text('redirect_uri').notNull(), + codeChallenge: text('code_challenge').notNull(), + scopes: text('scopes', { mode: 'json' }).$type().notNull(), + resource: text('resource'), + apiKeySettings: text('api_key_settings', { mode: 'json' }).$type | null>(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}) + +export const oauthRefreshTokens = sqliteTable('oauth_refresh_tokens', { + tokenHash: text('token_hash').primaryKey(), + clientId: text('client_id').notNull(), + apiKeyId: text('api_key_id').notNull(), + scopes: text('scopes', { mode: 'json' }).$type().notNull(), + resource: text('resource'), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}) diff --git a/apps/csbie-server/src/index.ts b/apps/csbie-server/src/index.ts new file mode 100644 index 0000000..e23d946 --- /dev/null +++ b/apps/csbie-server/src/index.ts @@ -0,0 +1,17 @@ +import { loadConfig } from './config' +import { createDb } from './db' +import { createServerApp } from './app' + +const config = loadConfig() +const db = createDb(config.databasePath) +const { app, websocket } = createServerApp(db, config) + +const server = Bun.serve({ + port: config.port, + fetch(request, server) { + return app.fetch(request, { server }) + }, + websocket, +}) + +console.log(`csbie-server listening on http://localhost:${server.port}`) diff --git a/apps/csbie-server/src/routes/admin.ts b/apps/csbie-server/src/routes/admin.ts new file mode 100644 index 0000000..600d8cc --- /dev/null +++ b/apps/csbie-server/src/routes/admin.ts @@ -0,0 +1,108 @@ +import { eq } from 'drizzle-orm' +import { Hono } from 'hono' +import type { MiddlewareHandler } from 'hono' +import type { PlaintextStoredWebAuthnCredential } from '@repo/sbi-client' +import type { AppBindings } from '../context' +import { sbiPasskeys } from '../db/schema' +import { + createApiKey, + listApiKeys, + revokeApiKey, + updateApiKeySettings, + type ApiKeySettings, +} from '../security/api-keys' +import { randomId } from '../security/crypto' +import { deleteSecret, saveSecret } from '../security/keyring' + +export type StoredSbiPasskeySecret = { + credential: PlaintextStoredWebAuthnCredential + tradePassword?: string + deviceId?: string +} + +const requireAuth: MiddlewareHandler = async (c, next) => { + if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401) + await next() +} + +export const createAdminRoutes = () => { + const app = new Hono() + app.use('*', requireAuth) + + app.get('/api-keys', async (c) => c.json({ apiKeys: await listApiKeys(c.get('db')) })) + + app.post('/api-keys', async (c) => { + const { label, settings } = await c.req.json<{ label?: string; settings?: ApiKeySettings }>() + if (!label?.trim()) return c.json({ error: 'label is required' }, 400) + const key = await createApiKey(c.get('db'), label.trim(), settings) + return c.json({ apiKey: key }, 201) + }) + + app.patch('/api-keys/:id/settings', async (c) => { + const body = await c.req.json() + await updateApiKeySettings(c.get('db'), c.req.param('id'), body) + return c.json({ ok: true }) + }) + + app.delete('/api-keys/:id', async (c) => { + await revokeApiKey(c.get('db'), c.req.param('id')) + return c.json({ ok: true }) + }) + + app.get('/sbi-passkeys', async (c) => { + const rows = await c.get('db').select().from(sbiPasskeys).orderBy(sbiPasskeys.createdAt) + return c.json({ + passkeys: rows.map(({ keyringAccount: _keyringAccount, ...row }) => ({ + ...row, + keyringAccount: undefined, + })), + }) + }) + + app.post('/sbi-passkeys', async (c) => { + const body = await c.req.json<{ + label?: string + credential?: PlaintextStoredWebAuthnCredential + tradePassword?: string + deviceId?: string + }>() + if (!body.label?.trim() || !body.credential) { + return c.json({ error: 'label and credential are required' }, 400) + } + + const now = new Date() + const id = randomId('sbi') + const keyringAccount = `sbi-passkey:${id}` + await saveSecret(keyringAccount, { + credential: body.credential, + tradePassword: body.tradePassword, + deviceId: body.deviceId, + } satisfies StoredSbiPasskeySecret) + await c.get('db').insert(sbiPasskeys).values({ + id, + label: body.label.trim(), + keyringAccount, + createdAt: now, + updatedAt: now, + }) + + return c.json( + { passkey: { id, label: body.label.trim(), createdAt: now, updatedAt: now } }, + 201, + ) + }) + + app.delete('/sbi-passkeys/:id', async (c) => { + const db = c.get('db') + const [row] = await db + .select() + .from(sbiPasskeys) + .where(eq(sbiPasskeys.id, c.req.param('id'))) + if (!row) return c.json({ error: 'not found' }, 404) + await deleteSecret(row.keyringAccount) + await db.delete(sbiPasskeys).where(eq(sbiPasskeys.id, row.id)) + return c.json({ ok: true }) + }) + + return app +} diff --git a/apps/csbie-server/src/routes/auth.ts b/apps/csbie-server/src/routes/auth.ts new file mode 100644 index 0000000..b78e09b --- /dev/null +++ b/apps/csbie-server/src/routes/auth.ts @@ -0,0 +1,218 @@ +import { + type AuthenticatorTransportFuture, + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from '@simplewebauthn/server' +import { eq } from 'drizzle-orm' +import { Hono } from 'hono' +import type { AppBindings } from '../context' +import { appState, passkeyChallenges, userPasskeys } from '../db/schema' +import { randomId } from '../security/crypto' +import { + clearSessionCookie, + createSession, + setSessionCookie, + verifySessionCookie, +} from '../security/sessions' +import { verifySetupPassword } from '../security/setup' + +type PasskeyRow = typeof userPasskeys.$inferSelect + +const getConfigured = async (db: AppBindings['Variables']['db']) => { + const [row] = await db.select().from(appState).where(eq(appState.key, 'configured')).limit(1) + return row?.value === 'true' +} + +const saveChallenge = async ( + db: AppBindings['Variables']['db'], + kind: 'registration' | 'authentication', + challenge: string, +) => { + const now = new Date() + const id = randomId('chal') + await db.insert(passkeyChallenges).values({ + id, + kind, + challenge, + createdAt: now, + expiresAt: new Date(now.getTime() + 5 * 60 * 1000), + }) + return id +} + +const consumeChallenge = async ( + db: AppBindings['Variables']['db'], + id: string, + kind: 'registration' | 'authentication', +) => { + const [row] = await db + .select() + .from(passkeyChallenges) + .where(eq(passkeyChallenges.id, id)) + .limit(1) + if (!row || row.kind !== kind || row.expiresAt < new Date()) throw new Error('challenge expired') + await db.delete(passkeyChallenges).where(eq(passkeyChallenges.id, id)) + return row.challenge +} + +const toCredentialDescriptor = (row: PasskeyRow) => ({ + id: row.credentialId, + transports: row.transports?.filter(isAuthenticatorTransport), +}) + +const isAuthenticatorTransport = (value: string): value is AuthenticatorTransportFuture => + ['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb'].includes(value) + +const passkeyForCredential = async (db: AppBindings['Variables']['db'], credentialId: string) => { + const [row] = await db + .select() + .from(userPasskeys) + .where(eq(userPasskeys.credentialId, credentialId)) + .limit(1) + return row +} + +export const createAuthRoutes = () => { + const app = new Hono() + + app.get('/status', async (c) => { + const db = c.get('db') + return c.json({ + configured: await getConfigured(db), + authenticated: await verifySessionCookie(c, db, c.get('config')), + }) + }) + + app.post('/setup/options', async (c) => { + const db = c.get('db') + if (await getConfigured(db)) return c.json({ error: 'already configured' }, 409) + + const { password } = await c.req.json<{ password?: string }>() + if (!password || !verifySetupPassword(password)) { + return c.json({ error: 'invalid setup password' }, 401) + } + + const config = c.get('config') + const options = await generateRegistrationOptions({ + rpName: config.rpName, + rpID: config.rpId, + userName: 'owner', + userDisplayName: 'Owner', + attestationType: 'none', + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'required', + }, + }) + const challengeId = await saveChallenge(db, 'registration', options.challenge) + return c.json({ options, challengeId }) + }) + + app.post('/setup/verify', async (c) => { + const db = c.get('db') + if (await getConfigured(db)) return c.json({ error: 'already configured' }, 409) + + const { challengeId, response } = await c.req.json<{ + challengeId?: string + response?: unknown + }>() + if (!challengeId || !response) return c.json({ error: 'missing registration response' }, 400) + + const expectedChallenge = await consumeChallenge(db, challengeId, 'registration') + const config = c.get('config') + const verification = await verifyRegistrationResponse({ + response: response as never, + expectedChallenge, + expectedOrigin: config.origin, + expectedRPID: config.rpId, + requireUserVerification: true, + }) + + if (!verification.verified || !verification.registrationInfo) { + return c.json({ error: 'registration failed' }, 400) + } + + const now = new Date() + const credential = verification.registrationInfo.credential + await db.insert(userPasskeys).values({ + id: randomId('upk'), + credentialId: credential.id, + publicKey: Buffer.from(credential.publicKey).toString('base64url'), + counter: credential.counter, + transports: (response as { response?: { transports?: string[] } }).response?.transports, + createdAt: now, + updatedAt: now, + }) + await db + .insert(appState) + .values({ key: 'configured', value: 'true', updatedAt: now }) + .onConflictDoUpdate({ target: appState.key, set: { value: 'true', updatedAt: now } }) + + const session = await createSession(db) + setSessionCookie(c, config, session.id, session.expiresAt) + return c.json({ ok: true }) + }) + + app.post('/login/options', async (c) => { + const db = c.get('db') + const rows = await db.select().from(userPasskeys) + if (rows.length === 0) return c.json({ error: 'not configured' }, 409) + + const options = await generateAuthenticationOptions({ + rpID: c.get('config').rpId, + allowCredentials: rows.map(toCredentialDescriptor), + userVerification: 'required', + }) + const challengeId = await saveChallenge(db, 'authentication', options.challenge) + return c.json({ options, challengeId }) + }) + + app.post('/login/verify', async (c) => { + const db = c.get('db') + const { challengeId, response } = await c.req.json<{ + challengeId?: string + response?: { id?: string } + }>() + if (!challengeId || !response?.id) + return c.json({ error: 'missing authentication response' }, 400) + + const passkey = await passkeyForCredential(db, response.id) + if (!passkey) return c.json({ error: 'unknown passkey' }, 401) + + const expectedChallenge = await consumeChallenge(db, challengeId, 'authentication') + const config = c.get('config') + const verification = await verifyAuthenticationResponse({ + response: response as never, + expectedChallenge, + expectedOrigin: config.origin, + expectedRPID: config.rpId, + credential: { + id: passkey.credentialId, + publicKey: Buffer.from(passkey.publicKey, 'base64url'), + counter: passkey.counter, + transports: passkey.transports?.filter(isAuthenticatorTransport), + }, + requireUserVerification: true, + }) + + if (!verification.verified) return c.json({ error: 'authentication failed' }, 401) + + await db + .update(userPasskeys) + .set({ counter: verification.authenticationInfo.newCounter, updatedAt: new Date() }) + .where(eq(userPasskeys.id, passkey.id)) + + const session = await createSession(db) + setSessionCookie(c, config, session.id, session.expiresAt) + return c.json({ ok: true }) + }) + + app.post('/logout', async (c) => { + await clearSessionCookie(c, c.get('db'), c.get('config')) + return c.json({ ok: true }) + }) + + return app +} diff --git a/apps/csbie-server/src/routes/mcp.ts b/apps/csbie-server/src/routes/mcp.ts new file mode 100644 index 0000000..552ace6 --- /dev/null +++ b/apps/csbie-server/src/routes/mcp.ts @@ -0,0 +1,604 @@ +import { Hono } from 'hono' +import type { Context } from 'hono' +import { randomUUID } from 'node:crypto' +import { StreamableHTTPTransport } from '@hono/mcp' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { eq } from 'drizzle-orm' +import * as z from 'zod/v4' +import type { AppBindings, AuthContext } from '../context' +import { sbiPasskeys } from '../db/schema' +import { + invokeSbiMethod, + isCashOrderMethod, + isTradingMethod, + RPC_METHODS, + type RpcMethod, +} from '../rpc/methods' +import { connectSbi } from '../rpc/sbi-session' +import type { StoredSbiPasskeySecret } from './admin' +import { + assertAndConsumeApiKeyTradeLimits, + assertApiKeyMethodAllowed, +} from '../security/trade-limits' +import { readSecret } from '../security/keyring' +import { effectiveSbiDeviceId, effectiveSbiTradePassword } from '../security/sbi-credentials' + +const jsonText = (value: unknown) => JSON.stringify(value, null, 2) + +const textResult = (value: unknown) => ({ + content: [{ type: 'text' as const, text: typeof value === 'string' ? value : jsonText(value) }], +}) + +const requireAuthenticated = (auth: AuthContext) => { + if (!auth.authenticated) throw new Error('unauthorized') +} + +const toolNameForMethod = (method: RpcMethod) => `csbie_sbi_${method.replaceAll('.', '_')}` + +const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000 + +type OrderSubmitTicket = { + passkeyId: string + estimateMethod: RpcMethod + submitMethod: RpcMethod + params: unknown + confirmationId?: string + authKey: string + expiresAt: Date +} + +const orderSubmitTickets = new Map() + +const authKey = (auth: AuthContext) => { + if (auth.type === 'apiKey') return `apiKey:${auth.apiKeyId}` + if (auth.type === 'session') return `session:${auth.sessionId}` + return 'none' +} + +const cleanupExpiredOrderSubmitTickets = (now = new Date()) => { + for (const [uuid, ticket] of orderSubmitTickets) { + if (ticket.expiresAt <= now) orderSubmitTickets.delete(uuid) + } +} + +const orderSubmitMethodByEstimateMethod = { + 'orders.cash.estimate': 'orders.cash.place', + 'orders.cash.estimateCorrection': 'orders.cash.placeCorrection', + 'orders.cash.estimateCorrectionConfirm': 'orders.cash.placeCorrection', + 'orders.cash.estimateCancel': 'orders.cash.placeCancel', + 'orders.margin.estimateOpen': 'orders.margin.open', + 'orders.margin.estimateClose': 'orders.margin.close', + 'orders.margin.estimateCloseSummary': 'orders.margin.closeSummary', + 'orders.margin.estimateSummary': 'orders.margin.placeSummary', + 'orders.margin.estimateActualDelivery': 'orders.margin.actualDelivery', + 'orders.ifd.estimate': 'orders.ifd.place', + 'orders.ifd.estimateCorrection': 'orders.ifd.placeCorrection', + 'orders.ifd.estimateCancel': 'orders.ifd.placeCancel', + 'orders.themeInvestment.estimate': 'orders.themeInvestment.place', +} as const satisfies Partial> + +const submitMethodForEstimateMethod = (method: RpcMethod) => + orderSubmitMethodByEstimateMethod[method as keyof typeof orderSubmitMethodByEstimateMethod] + +const isDirectOrderSubmitMethod = (method: RpcMethod) => isTradingMethod(method) + +const mcpExposedRpcMethods = RPC_METHODS.filter((method) => !isDirectOrderSubmitMethod(method)) + +const orderSubmitParams = (value: unknown, confirmationId?: string) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return { allowTrading: true } + return { + ...value, + ...(confirmationId ? { confirmationId } : {}), + allowTrading: true, + } +} + +const confirmationIdFromPreview = (value: unknown) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const confirmationId = (value as Record).confirmationId + return typeof confirmationId === 'string' && confirmationId ? confirmationId : undefined +} + +const accountTypeSchema = z.enum(['general', 'specific', 'nisa', 'juniorNisa', 'unknown']) +const depositTypeSchema = z.enum(['general', 'specific', 'nisa', 'juniorNisa', 'unknown']) +const tradeSideSchema = z.enum(['buy', 'sell']) +const marketCodeSchema = z.string().min(1).describe('SBI market code') +const issueCodeSchema = z.string().min(1).describe('Issue code') +const orderIdSchema = z.string().min(1).describe('Order id') +const positionIdSchema = z.string().min(1).describe('Position id') + +const pagingSchema = { + index: z.number().int().min(0).optional().describe('Start index for the result list'), + limit: z.number().int().positive().optional().describe('Maximum number of items to fetch'), +} + +const issueOptionsSchema = z.object({ + issueCode: issueCodeSchema, + market: marketCodeSchema.optional(), +}) + +const issueChartOptionsSchema = issueOptionsSchema.extend({ + period: z.enum(['minute', 'day', 'week', 'month']).optional().describe('Chart period'), + unit: z + .number() + .int() + .positive() + .optional() + .describe('Candle unit. Minute charts accept 1, 5, 10, or 15; other periods use 1'), + count: z + .number() + .int() + .positive() + .max(9999) + .optional() + .describe('Number of historical prices to request'), +}) + +const issueSearchOptionsSchema = z.object({ + query: z.string().min(1).describe('Search text, such as an issue code, name, or keyword'), + market: marketCodeSchema.optional().describe('Client-side market code filter'), + limit: z.number().int().positive().optional().describe('Maximum number of returned issues'), +}) + +const cashPositionOptionsSchema = z.object({ + ...pagingSchema, + issueCode: issueCodeSchema.optional(), + market: marketCodeSchema.optional(), + accountType: accountTypeSchema.optional(), +}) + +const marginPositionOptionsSchema = z.object({ + ...pagingSchema, + issueCode: issueCodeSchema.optional(), + market: marketCodeSchema.optional(), + side: tradeSideSchema.optional(), + accountType: accountTypeSchema.optional(), +}) + +const orderInquiryOptionsSchema = z.object({ + ...pagingSchema, + from: z.string().optional().describe('Start date for the inquiry range'), + to: z.string().optional().describe('End date for the inquiry range'), + issueCode: issueCodeSchema.optional(), + market: marketCodeSchema.optional(), + status: z.enum(['open', 'executed', 'cancelled', 'expired', 'rejected', 'unknown']).optional(), +}) + +const boardOptionsSchema = issueOptionsSchema.extend({ + accountType: accountTypeSchema.optional(), + side: z + .enum([ + 'cashBuy', + 'cashSell', + 'marginOpen', + 'marginOpenBuy', + 'marginOpenSell', + 'marginClose', + 'marginCloseBuy', + 'marginCloseSell', + ]) + .optional(), +}) + +const stockOrderBaseSchema = z.object({ + issueCode: issueCodeSchema, + market: marketCodeSchema, + side: tradeSideSchema, + accountType: accountTypeSchema.optional(), + quantity: z.number().positive().describe('Order quantity'), + depositType: depositTypeSchema.optional(), +}) + +const cashOrderPriceConditionSchema = z.enum([ + 'limit', + 'limitAtOpen', + 'limitAtClose', + 'limitIoc', + 'market', + 'marketAtOpen', + 'marketAtClose', + 'marketIoc', + 'funari', +]) + +const cashOrderSchema = stockOrderBaseSchema.extend({ + price: z.number().positive().optional().describe('Order price for price-based orders'), + kind: z.enum(['market', 'limit', 'stop', 'oco', 'ifd', 'ifdo', 's', 'unknown']).optional(), + priceCondition: cashOrderPriceConditionSchema + .optional() + .describe('APK/MTS execution condition for cash orders'), + orderTerm: z.enum(['day', 'week', 'date']).optional().describe('Order validity term'), + orderDate: z + .string() + .optional() + .describe('Validity date used when orderTerm is date, in yyyyMMdd or yyyy-MM-dd format'), + orderMethod: z.enum(['normal', 'stop', 'oco']).optional().describe('Special order method'), + triggerZone: z.enum(['above', 'below']).optional().describe('Stop trigger direction'), + triggerPrice: z.number().positive().optional().describe('Stop trigger price'), + secondaryPriceCondition: cashOrderPriceConditionSchema + .optional() + .describe('Secondary execution condition for OCO orders'), + secondaryPrice: z.number().positive().optional().describe('Secondary price for OCO orders'), + sorLastMarket: marketCodeSchema + .optional() + .describe('Previous market code sent with SOR orders; defaults to login profile'), +}) + +const placeCashOrderSchema = cashOrderSchema.extend({ + confirmationId: z + .string() + .optional() + .describe('Confirmation ID returned by the confirmation step'), + allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live order'), +}) + +const orderCorrectionSchema = z.object({ + orderId: orderIdSchema, + quantity: z.number().positive().optional().describe('Corrected order quantity'), + price: z.number().positive().optional().describe('Corrected order price'), +}) + +const placeOrderCorrectionSchema = orderCorrectionSchema.extend({ + allowTrading: z + .literal(true) + .optional() + .describe('Explicitly allows sending a live correction request'), +}) + +const orderCancelSchema = z.object({ + orderNumber: z.string().min(1).describe('Order number shown in order inquiry'), + orderId: orderIdSchema.optional().describe('Original order id shown in order inquiry'), + tradeId: z.string().min(1).optional().describe('Original trade id code'), + cancelType: z.string().min(1).optional().describe('Additional cancel flag'), +}) + +const placeOrderCancelSchema = orderCancelSchema.extend({ + tradePassword: z.string().optional().describe('Trading password used by SBI'), + allowTrading: z + .literal(true) + .optional() + .describe('Explicitly allows sending a live cancellation request'), +}) + +const marginOpenOrderSchema = cashOrderSchema + +const placeMarginOpenOrderSchema = marginOpenOrderSchema.extend({ + confirmationId: z + .string() + .optional() + .describe('Confirmation ID returned by the confirmation step'), + allowTrading: z + .literal(true) + .optional() + .describe('Explicitly allows sending a live margin open order'), +}) + +const marginCloseOrderSchema = cashOrderSchema.extend({ + positionId: positionIdSchema.optional().describe('Position ID to close'), +}) + +const placeMarginCloseOrderSchema = marginCloseOrderSchema.extend({ + allowTrading: z + .literal(true) + .optional() + .describe('Explicitly allows sending a live margin close order'), +}) + +const actualDeliveryOrderSchema = z.object({ + issueCode: issueCodeSchema, + market: marketCodeSchema, + accountType: accountTypeSchema.optional(), + quantity: z.number().positive().describe('Order quantity'), + depositType: depositTypeSchema.optional(), + price: z.number().positive().optional().describe('Order price for price-based requests'), + kind: z.enum(['genbiki', 'genwatashi']), + positionId: positionIdSchema.optional().describe('Position ID to deliver'), +}) + +const placeActualDeliveryOrderSchema = actualDeliveryOrderSchema.extend({ + confirmationId: z + .string() + .optional() + .describe('Confirmation ID returned by the confirmation step'), + allowTrading: z + .literal(true) + .optional() + .describe('Explicitly allows sending a live actual-delivery order'), +}) + +const ifdOrderSchema = cashOrderSchema.extend({ + tradeType: z + .enum(['cash', 'marginOpen']) + .optional() + .describe('Product to use for the first IFD leg'), +}) + +const placeIfdOrderSchema = ifdOrderSchema.extend({ + confirmationId: z + .string() + .optional() + .describe('Confirmation ID returned by the confirmation step'), + allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live IFD order'), +}) + +const themeInvestmentOrderSchema = z.object({ + themeId: z.string().min(1).describe('Theme ID for the theme investment order'), + side: tradeSideSchema, + amount: z.number().positive().optional().describe('Order amount for the theme investment order'), +}) + +const placeThemeInvestmentOrderSchema = themeInvestmentOrderSchema.extend({ + allowTrading: z + .literal(true) + .optional() + .describe('Explicitly allows sending a live theme investment order'), +}) + +const methodParamSchemas = { + 'session.profile': undefined, + 'account.profile': undefined, + 'account.power.buyingPower': undefined, + 'account.power.collateralRatio': undefined, + 'account.positions.cash': cashPositionOptionsSchema.optional(), + 'account.positions.cashDetail': cashPositionOptionsSchema.optional(), + 'account.positions.cashForIssue': issueOptionsSchema, + 'account.positions.margin': marginPositionOptionsSchema.optional(), + 'account.positions.marginDetail': marginPositionOptionsSchema.optional(), + 'account.positions.marginForIssue': issueOptionsSchema, + 'account.positions.marginSummaryForIssue': issueOptionsSchema, + 'account.positions.marginDetailsForIssue': issueOptionsSchema, + 'account.positions.closeableMargin': marginPositionOptionsSchema, + 'account.positions.deliverableMargin': marginPositionOptionsSchema, + 'account.profitLoss.unrealized': undefined, + 'market.issue.search': issueSearchOptionsSchema, + 'market.issue.suggest': issueSearchOptionsSchema, + 'market.issue.allowedPrices': issueOptionsSchema, + 'market.issue.board': issueOptionsSchema, + 'market.issue.chart': issueChartOptionsSchema, + 'market.issue.openOrders': issueOptionsSchema, + 'market.issue.tradingInfo': boardOptionsSchema, + 'market.index.major': undefined, + 'market.overview': undefined, + 'market.ranking.market': undefined, + 'market.ranking.sector': undefined, + 'market.ranking.sbi': undefined, + 'news.list': undefined, + 'watchlist.list': undefined, + 'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(), + 'orders.inquiry.open': orderInquiryOptionsSchema.optional(), + 'orders.cash.estimate': cashOrderSchema, + 'orders.cash.place': placeCashOrderSchema, + 'orders.cash.estimateCorrection': orderCorrectionSchema, + 'orders.cash.estimateCorrectionConfirm': orderCorrectionSchema, + 'orders.cash.placeCorrection': placeOrderCorrectionSchema, + 'orders.cash.estimateCancel': orderCancelSchema, + 'orders.cash.placeCancel': placeOrderCancelSchema, + 'orders.margin.estimateOpen': marginOpenOrderSchema, + 'orders.margin.open': placeMarginOpenOrderSchema, + 'orders.margin.estimateClose': marginCloseOrderSchema, + 'orders.margin.close': placeMarginCloseOrderSchema, + 'orders.margin.estimateCloseSummary': marginCloseOrderSchema, + 'orders.margin.closeSummary': placeMarginCloseOrderSchema, + 'orders.margin.estimateSummary': marginCloseOrderSchema, + 'orders.margin.placeSummary': placeMarginCloseOrderSchema, + 'orders.margin.estimateActualDelivery': actualDeliveryOrderSchema, + 'orders.margin.actualDelivery': placeActualDeliveryOrderSchema, + 'orders.ifd.estimate': ifdOrderSchema, + 'orders.ifd.place': placeIfdOrderSchema, + 'orders.ifd.estimateCorrection': orderCorrectionSchema, + 'orders.ifd.placeCorrection': placeOrderCorrectionSchema, + 'orders.ifd.estimateCancel': orderCorrectionSchema, + 'orders.ifd.placeCancel': placeOrderCorrectionSchema, + 'orders.themeInvestment.list': undefined, + 'orders.themeInvestment.estimate': themeInvestmentOrderSchema, + 'orders.themeInvestment.place': placeThemeInvestmentOrderSchema, +} satisfies Record + +const createMcpServer = (c: Context) => { + const db = c.get('db') + const config = c.get('config') + const auth = c.get('auth') + + const server = new McpServer({ + name: 'csbie', + version: '0.1.0', + }) + + server.registerTool( + 'csbie_sbi_methods', + { + title: 'List SBI RPC Methods', + description: 'List SBI client methods exposed through CSBIE.', + inputSchema: {}, + }, + async () => { + requireAuthenticated(auth) + return textResult({ + methods: mcpExposedRpcMethods, + submitTool: 'csbie_sbi_submit_order', + }) + }, + ) + + server.registerTool( + 'csbie_sbi_passkeys', + { + title: 'List SBI Passkeys', + description: 'List saved SBI passkey profiles. Secret material is never returned.', + inputSchema: {}, + }, + async () => { + requireAuthenticated(auth) + const rows = await db + .select({ + id: sbiPasskeys.id, + label: sbiPasskeys.label, + keyringAccount: sbiPasskeys.keyringAccount, + createdAt: sbiPasskeys.createdAt, + updatedAt: sbiPasskeys.updatedAt, + }) + .from(sbiPasskeys) + .orderBy(sbiPasskeys.createdAt) + const passkeys = await Promise.all( + rows.map(async ({ keyringAccount, ...row }) => { + const secret = await readSecret(keyringAccount) + const hasDeviceId = Boolean(effectiveSbiDeviceId(secret)) + const hasTradePassword = Boolean(effectiveSbiTradePassword(secret)) + return { + ...row, + hasTradePassword, + hasDeviceId, + cashOrderReady: hasTradePassword && hasDeviceId, + } + }), + ) + return textResult({ passkeys }) + }, + ) + + const callSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => { + requireAuthenticated(auth) + + if (auth.type === 'apiKey') { + await assertApiKeyMethodAllowed(db, auth.apiKeyId, method) + } + + if (isTradingMethod(method)) { + const tradingParams = params as { allowTrading?: boolean } | undefined + if (!tradingParams?.allowTrading) throw new Error('trading methods require allowTrading') + if (auth.type === 'apiKey') { + await assertAndConsumeApiKeyTradeLimits({ + db, + apiKeyId: auth.apiKeyId, + params, + }) + } + } + + if (isCashOrderMethod(method)) { + const [passkey] = await db + .select({ keyringAccount: sbiPasskeys.keyringAccount }) + .from(sbiPasskeys) + .where(eq(sbiPasskeys.id, passkeyId)) + .limit(1) + if (!passkey) throw new Error('SBI passkey not found') + const secret = await readSecret(passkey.keyringAccount) + if (!effectiveSbiDeviceId(secret)) { + throw new Error( + 'orders.cash methods require an SBI deviceId registered with F1131. This passkey has no saved deviceId, so MCP cannot complete SBI trade authentication for cash order estimates or orders.', + ) + } + if (!effectiveSbiTradePassword(secret)) { + throw new Error( + 'orders.cash methods require a saved SBI tradePassword. This passkey has no saved tradePassword, so MCP cannot complete cash order estimates or orders.', + ) + } + } + + const client = await connectSbi(db, config, passkeyId) + const result = await invokeSbiMethod(client, method, params) + const submitMethod = submitMethodForEstimateMethod(method) + if (!submitMethod) return textResult(result) + + cleanupExpiredOrderSubmitTickets() + const uuid = randomUUID() + const expiresAt = new Date(Date.now() + ORDER_SUBMIT_TICKET_TTL_MS) + const confirmationId = confirmationIdFromPreview(result) + orderSubmitTickets.set(uuid, { + passkeyId, + estimateMethod: method, + submitMethod, + params, + confirmationId, + authKey: authKey(auth), + expiresAt, + }) + return textResult({ + preview: result, + submit: { + uuid, + tool: 'csbie_sbi_submit_order', + expiresAt: expiresAt.toISOString(), + }, + }) + } + + server.registerTool( + 'csbie_sbi_submit_order', + { + title: 'Submit Estimated SBI Order', + description: + 'Submit the same SBI order as a previous MCP estimate result by UUID. The UUID expires shortly and is bound to the same authenticated caller.', + inputSchema: { + uuid: z.string().uuid().describe('UUID returned by an order estimate tool'), + }, + }, + async ({ uuid }) => { + requireAuthenticated(auth) + cleanupExpiredOrderSubmitTickets() + + const ticket = orderSubmitTickets.get(uuid) + if (!ticket) throw new Error('order submit uuid not found or expired') + if (ticket.authKey !== authKey(auth)) { + throw new Error('order submit uuid was created by a different authenticated caller') + } + + orderSubmitTickets.delete(uuid) + return callSbiMethod( + ticket.submitMethod, + ticket.passkeyId, + orderSubmitParams(ticket.params, ticket.confirmationId), + ) + }, + ) + + for (const method of mcpExposedRpcMethods) { + const paramsSchema = methodParamSchemas[method] + server.registerTool( + toolNameForMethod(method), + { + title: `Call ${method}`, + description: `Connect with one saved SBI passkey and call ${method}. API key method permissions and trading limits are enforced.`, + inputSchema: { + passkeyId: z.string().describe('Saved SBI passkey id from csbie_sbi_passkeys'), + ...(paramsSchema + ? { + params: paramsSchema.describe(`${method} params`), + } + : {}), + }, + }, + async ({ passkeyId, params }) => callSbiMethod(method, passkeyId, params), + ) + } + + return server +} + +export const createMcpRoutes = () => { + const app = new Hono() + + app.all('/', async (c) => { + if (!c.get('authenticated')) { + const resourceMetadata = new URL('/.well-known/oauth-protected-resource/api/mcp', c.req.url) + c.header('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadata.toString()}"`) + return c.json({ error: 'unauthorized' }, 401) + } + + const transport = new StreamableHTTPTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }) + const server = createMcpServer(c) + + try { + await server.connect(transport) + return await transport.handleRequest(c) + } finally { + await server.close() + await transport.close() + } + }) + + return app +} diff --git a/apps/csbie-server/src/routes/oauth.ts b/apps/csbie-server/src/routes/oauth.ts new file mode 100644 index 0000000..28ec5d1 --- /dev/null +++ b/apps/csbie-server/src/routes/oauth.ts @@ -0,0 +1,90 @@ +import { eq } from 'drizzle-orm' +import { Hono } from 'hono' +import type { AppBindings } from '../context' +import { oauthClients } from '../db/schema' +import type { ApiKeySettings } from '../security/api-keys' +import { createOAuthAuthorizationCode } from '../security/oauth-provider' + +const loopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]']) + +const redirectUriAllowed = (redirectUri: string, registeredUris: string[]) => { + if (registeredUris.includes(redirectUri)) return true + let requested: URL + try { + requested = new URL(redirectUri) + } catch { + return false + } + if (!loopbackHosts.has(requested.hostname)) return false + return registeredUris.some((registeredUri) => { + try { + const registered = new URL(registeredUri) + return ( + registered.protocol === requested.protocol && + registered.hostname === requested.hostname && + registered.pathname === requested.pathname && + registered.search === requested.search + ) + } catch { + return false + } + }) +} + +export const createOAuthRoutes = () => { + const app = new Hono() + + app.get('/client/:id', async (c) => { + if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401) + const [client] = await c + .get('db') + .select() + .from(oauthClients) + .where(eq(oauthClients.id, c.req.param('id'))) + if (!client) return c.json({ error: 'client not found' }, 404) + return c.json({ client: client.client }) + }) + + app.post('/approve', async (c) => { + if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401) + const body = await c.req.json<{ + clientId?: string + redirectUri?: string + codeChallenge?: string + scope?: string + state?: string + resource?: string + settings?: ApiKeySettings + }>() + if (!body.clientId || !body.redirectUri || !body.codeChallenge) { + return c.json({ error: 'clientId, redirectUri and codeChallenge are required' }, 400) + } + + const [client] = await c + .get('db') + .select() + .from(oauthClients) + .where(eq(oauthClients.id, body.clientId)) + const redirectUris = + (client?.client as { redirect_uris?: string[] } | undefined)?.redirect_uris ?? [] + if (!redirectUriAllowed(body.redirectUri, redirectUris)) { + return c.json({ error: 'redirectUri is not registered for this client' }, 400) + } + + const code = await createOAuthAuthorizationCode(c.get('db'), { + clientId: body.clientId, + redirectUri: body.redirectUri, + codeChallenge: body.codeChallenge, + scopes: body.scope?.split(' ').filter(Boolean) ?? [], + resource: body.resource, + apiKeySettings: body.settings, + }) + + const redirectUrl = new URL(body.redirectUri) + redirectUrl.searchParams.set('code', code) + if (body.state) redirectUrl.searchParams.set('state', body.state) + return c.json({ redirectTo: redirectUrl.toString() }) + }) + + return app +} diff --git a/apps/csbie-server/src/rpc/methods.ts b/apps/csbie-server/src/rpc/methods.ts new file mode 100644 index 0000000..4292389 --- /dev/null +++ b/apps/csbie-server/src/rpc/methods.ts @@ -0,0 +1,112 @@ +import type { SbiClientMethods } from '@repo/sbi-client' + +export const RPC_METHODS = [ + 'session.profile', + 'account.profile', + 'account.power.buyingPower', + 'account.power.collateralRatio', + 'account.positions.cash', + 'account.positions.cashDetail', + 'account.positions.cashForIssue', + 'account.positions.margin', + 'account.positions.marginDetail', + 'account.positions.marginForIssue', + 'account.positions.marginSummaryForIssue', + 'account.positions.marginDetailsForIssue', + 'account.positions.closeableMargin', + 'account.positions.deliverableMargin', + 'account.profitLoss.unrealized', + 'market.issue.search', + 'market.issue.suggest', + 'market.issue.allowedPrices', + 'market.issue.board', + 'market.issue.chart', + 'market.issue.openOrders', + 'market.issue.tradingInfo', + 'market.index.major', + 'market.overview', + 'market.ranking.market', + 'market.ranking.sector', + 'market.ranking.sbi', + 'news.list', + 'watchlist.list', + 'orders.inquiry.executionsToday', + 'orders.inquiry.open', + 'orders.cash.estimate', + 'orders.cash.place', + 'orders.cash.estimateCorrection', + 'orders.cash.estimateCorrectionConfirm', + 'orders.cash.placeCorrection', + 'orders.cash.estimateCancel', + 'orders.cash.placeCancel', + 'orders.margin.estimateOpen', + 'orders.margin.open', + 'orders.margin.estimateClose', + 'orders.margin.close', + 'orders.margin.estimateCloseSummary', + 'orders.margin.closeSummary', + 'orders.margin.estimateSummary', + 'orders.margin.placeSummary', + 'orders.margin.estimateActualDelivery', + 'orders.margin.actualDelivery', + 'orders.ifd.estimate', + 'orders.ifd.place', + 'orders.ifd.estimateCorrection', + 'orders.ifd.placeCorrection', + 'orders.ifd.estimateCancel', + 'orders.ifd.placeCancel', + 'orders.themeInvestment.list', + 'orders.themeInvestment.estimate', + 'orders.themeInvestment.place', +] as const + +export type RpcMethod = (typeof RPC_METHODS)[number] + +const methodSet = new Set(RPC_METHODS) +const tradingMethods = new Set([ + 'orders.cash.place', + 'orders.cash.placeCorrection', + 'orders.cash.placeCancel', + 'orders.margin.open', + 'orders.margin.close', + 'orders.margin.closeSummary', + 'orders.margin.placeSummary', + 'orders.margin.actualDelivery', + 'orders.ifd.place', + 'orders.ifd.placeCorrection', + 'orders.ifd.placeCancel', + 'orders.themeInvestment.place', +]) + +const cashOrderMethods = new Set([ + 'orders.cash.estimate', + 'orders.cash.place', + 'orders.cash.estimateCorrection', + 'orders.cash.estimateCorrectionConfirm', + 'orders.cash.placeCorrection', + 'orders.cash.estimateCancel', + 'orders.cash.placeCancel', +]) + +export const isRpcMethod = (method: string): method is RpcMethod => methodSet.has(method) + +export const isTradingMethod = (method: string) => tradingMethods.has(method) + +export const isCashOrderMethod = (method: string) => cashOrderMethods.has(method) + +export const invokeSbiMethod = async ( + client: SbiClientMethods, + method: RpcMethod, + params: unknown, +) => { + const target = method.split('.').reduce((value, key) => { + if (!value || typeof value !== 'object') return undefined + return (value as Record)[key] + }, client) + + if (typeof target !== 'function') throw new Error(`RPC method not callable: ${method}`) + + if (Array.isArray(params)) return target(...params) + if (params === undefined || params === null) return target() + return target(params) +} diff --git a/apps/csbie-server/src/rpc/sbi-session.ts b/apps/csbie-server/src/rpc/sbi-session.ts new file mode 100644 index 0000000..1d13d9c --- /dev/null +++ b/apps/csbie-server/src/rpc/sbi-session.ts @@ -0,0 +1,33 @@ +import { eq } from 'drizzle-orm' +import { loginWithPasskey } from '@repo/sbi-client' +import type { SbiClientMethods, SbiClientOptions } from '@repo/sbi-client' +import type { ServerConfig } from '../config' +import type { Db } from '../db' +import { sbiPasskeys } from '../db/schema' +import type { StoredSbiPasskeySecret } from '../routes/admin' +import { readSecret } from '../security/keyring' +import { effectiveSbiDeviceId, effectiveSbiTradePassword } from '../security/sbi-credentials' + +export const connectSbi = async ( + db: Db, + config: ServerConfig, + passkeyId: string, +): Promise => { + const [row] = await db.select().from(sbiPasskeys).where(eq(sbiPasskeys.id, passkeyId)).limit(1) + if (!row) throw new Error('SBI passkey not found') + + const secret = await readSecret(row.keyringAccount) + const clientOptions: SbiClientOptions = { + tradePassword: effectiveSbiTradePassword(secret), + deviceId: effectiveSbiDeviceId(secret), + } + return loginWithPasskey( + { + passkeyCredential: secret.credential, + authBaseUrl: config.authBaseUrl, + mtsBaseUrl: config.mtsBaseUrl, + izanagiBaseUrl: config.izanagiBaseUrl, + }, + clientOptions, + ) +} diff --git a/apps/csbie-server/src/rpc/ws.ts b/apps/csbie-server/src/rpc/ws.ts new file mode 100644 index 0000000..a13879b --- /dev/null +++ b/apps/csbie-server/src/rpc/ws.ts @@ -0,0 +1,253 @@ +import type { SbiClientMethods } from '@repo/sbi-client' +import { createBunWebSocket } from 'hono/bun' +import type { WSContext } from 'hono/ws' +import { randomUUID } from 'node:crypto' +import type { ServerConfig } from '../config' +import type { Db } from '../db' +import { + assertAndConsumeApiKeyTradeLimits, + assertApiKeyMethodAllowed, +} from '../security/trade-limits' +import { invokeSbiMethod, isRpcMethod, isTradingMethod, RPC_METHODS } from './methods' +import { connectSbi } from './sbi-session' + +type JsonRpcRequest = { + jsonrpc?: '2.0' + id?: string | number | null + method?: string + params?: unknown +} + +type RpcSocketState = { + client?: SbiClientMethods + sbiPasskeyId?: string + apiKeyId?: string + boardPollingSubscriptions: Map +} + +type BoardPollingParams = { + issueCode: string + market?: string + intervalSeconds?: number +} + +const BOARD_POLLING_METHODS = [ + 'market.issue.pollBoard.subscribe', + 'market.issue.pollBoard.unsubscribe', +] as const + +const send = (ws: WSContext, payload: unknown) => { + ws.send(JSON.stringify(payload)) +} + +const result = (id: JsonRpcRequest['id'], value: unknown) => ({ + jsonrpc: '2.0', + id: id ?? null, + result: value, +}) + +const error = (id: JsonRpcRequest['id'], code: number, message: string) => ({ + jsonrpc: '2.0', + id: id ?? null, + error: { code, message }, +}) + +const notification = (method: string, params: unknown) => ({ + jsonrpc: '2.0', + method, + params, +}) + +const parseBoardPollingParams = (params: unknown): BoardPollingParams => { + if (!params || typeof params !== 'object' || Array.isArray(params)) { + throw new Error('pollBoard params are required') + } + + const value = params as Record + if (typeof value.issueCode !== 'string' || !value.issueCode) { + throw new Error('issueCode is required') + } + if (value.market != null && typeof value.market !== 'string') { + throw new Error('market must be a string') + } + if ( + value.intervalSeconds != null && + (typeof value.intervalSeconds !== 'number' || + !Number.isFinite(value.intervalSeconds) || + value.intervalSeconds <= 0) + ) { + throw new Error('intervalSeconds must be a positive finite number') + } + + return { + issueCode: value.issueCode, + market: typeof value.market === 'string' ? value.market : undefined, + intervalSeconds: typeof value.intervalSeconds === 'number' ? value.intervalSeconds : undefined, + } +} + +const stopBoardPollingSubscription = (state: RpcSocketState, subscriptionId: string) => { + const controller = state.boardPollingSubscriptions.get(subscriptionId) + if (!controller) return false + state.boardPollingSubscriptions.delete(subscriptionId) + controller.abort(new Error('market issue board polling unsubscribed')) + return true +} + +const stopBoardPollingSubscriptions = (state: RpcSocketState) => { + for (const subscriptionId of state.boardPollingSubscriptions.keys()) { + stopBoardPollingSubscription(state, subscriptionId) + } +} + +const subscribeBoardPolling = async ( + db: Db, + state: RpcSocketState, + ws: WSContext, + request: JsonRpcRequest, +) => { + if (!state.client) return error(request.id, 4001, 'SBI session is not connected') + + if (state.apiKeyId) { + await assertApiKeyMethodAllowed(db, state.apiKeyId, 'market.issue.board') + } + + const params = parseBoardPollingParams(request.params) + const subscriptionId = randomUUID() + const controller = new AbortController() + state.boardPollingSubscriptions.set(subscriptionId, controller) + + void (async () => { + try { + for await (const board of state.client!.market.issue.pollBoard({ + ...params, + signal: controller.signal, + })) { + if (!state.boardPollingSubscriptions.has(subscriptionId)) return + send(ws, notification('market.issue.pollBoard.update', { subscriptionId, board })) + } + } catch (cause) { + if (!controller.signal.aborted) { + send( + ws, + notification('market.issue.pollBoard.error', { + subscriptionId, + message: cause instanceof Error ? cause.message : 'pollBoard failed', + }), + ) + } + } finally { + state.boardPollingSubscriptions.delete(subscriptionId) + } + })() + + return result(request.id, { subscriptionId }) +} + +const handleRpc = async ( + db: Db, + config: ServerConfig, + state: RpcSocketState, + ws: WSContext, + request: JsonRpcRequest, +) => { + if (request.method === 'rpc.methods') { + return result(request.id, [...RPC_METHODS, ...BOARD_POLLING_METHODS]) + } + + if (request.method === 'sbi.connect') { + const passkeyId = + request.params && typeof request.params === 'object' + ? (request.params as { passkeyId?: string }).passkeyId + : undefined + if (!passkeyId) throw new Error('passkeyId is required') + stopBoardPollingSubscriptions(state) + state.client = await connectSbi(db, config, passkeyId) + state.sbiPasskeyId = passkeyId + return result(request.id, { connected: true, passkeyId }) + } + + if (request.method === 'market.issue.pollBoard.subscribe') { + return subscribeBoardPolling(db, state, ws, request) + } + + if (request.method === 'market.issue.pollBoard.unsubscribe') { + const subscriptionId = + request.params && typeof request.params === 'object' + ? (request.params as { subscriptionId?: string }).subscriptionId + : undefined + if (!subscriptionId) throw new Error('subscriptionId is required') + return result(request.id, { + unsubscribed: stopBoardPollingSubscription(state, subscriptionId), + }) + } + + if (!request.method || !isRpcMethod(request.method)) { + return error(request.id, -32601, 'method not found') + } + + if (!state.client) return error(request.id, 4001, 'SBI session is not connected') + + if (state.apiKeyId) { + await assertApiKeyMethodAllowed(db, state.apiKeyId, request.method) + } + + if (isTradingMethod(request.method)) { + const params = request.params as { allowTrading?: boolean } | undefined + if (!params?.allowTrading) + return error(request.id, 4002, 'trading methods require allowTrading') + if (state.apiKeyId) { + await assertAndConsumeApiKeyTradeLimits({ + db, + apiKeyId: state.apiKeyId, + params: request.params, + }) + } + } + + return result(request.id, await invokeSbiMethod(state.client, request.method, request.params)) +} + +export const createRpcWebSocket = (db: Db, config: ServerConfig) => { + const { upgradeWebSocket, websocket } = createBunWebSocket() + + return { + websocket, + upgradeWebSocket: upgradeWebSocket(async (c) => { + if (!c.get('authenticated')) { + return { + onOpen(_event, ws) { + ws.close(1008, 'unauthorized') + }, + } + } + + const auth = c.get('auth') + const state: RpcSocketState = { + apiKeyId: auth.type === 'apiKey' ? auth.apiKeyId : undefined, + boardPollingSubscriptions: new Map(), + } + + return { + onOpen(_event, ws) { + send(ws, result(null, { connected: true, methods: ['rpc.methods', 'sbi.connect'] })) + }, + async onMessage(event, ws) { + let request: JsonRpcRequest | undefined + try { + request = JSON.parse(String(event.data)) as JsonRpcRequest + send(ws, await handleRpc(db, config, state, ws, request)) + } catch (cause) { + send( + ws, + error(request?.id, -32603, cause instanceof Error ? cause.message : 'internal error'), + ) + } + }, + onClose() { + stopBoardPollingSubscriptions(state) + }, + } + }), + } +} diff --git a/apps/csbie-server/src/security/api-keys.ts b/apps/csbie-server/src/security/api-keys.ts new file mode 100644 index 0000000..8829a9b --- /dev/null +++ b/apps/csbie-server/src/security/api-keys.ts @@ -0,0 +1,86 @@ +import { and, eq, isNull } from 'drizzle-orm' +import type { Db } from '../db' +import { apiKeys } from '../db/schema' +import { randomId, randomToken, sha256 } from './crypto' + +export type ApiKeySettings = { + maxTradesPerHour?: number | null + maxTradesPer6Hours?: number | null + maxTradesPerDay?: number | null + maxOrderPriceJpy?: number | null + maxOrderAmountJpy?: number | null + allowedMethods?: string[] | null +} + +const normalizeLimit = (value: unknown) => { + if (value === undefined || value === null || value === '') return null + const number = Number(value) + if (!Number.isFinite(number) || number < 0) throw new Error('limit must be a positive number') + return Math.floor(number) +} + +export const normalizeApiKeySettings = (settings: ApiKeySettings = {}) => ({ + maxTradesPerHour: normalizeLimit(settings.maxTradesPerHour), + maxTradesPer6Hours: normalizeLimit(settings.maxTradesPer6Hours), + maxTradesPerDay: normalizeLimit(settings.maxTradesPerDay), + maxOrderPriceJpy: normalizeLimit(settings.maxOrderPriceJpy), + maxOrderAmountJpy: normalizeLimit(settings.maxOrderAmountJpy), + allowedMethods: + settings.allowedMethods === undefined + ? null + : settings.allowedMethods?.length + ? [...new Set(settings.allowedMethods)].sort() + : null, +}) + +export const listApiKeys = async (db: Db) => + db + .select({ + id: apiKeys.id, + label: apiKeys.label, + maxTradesPerHour: apiKeys.maxTradesPerHour, + maxTradesPer6Hours: apiKeys.maxTradesPer6Hours, + maxTradesPerDay: apiKeys.maxTradesPerDay, + maxOrderPriceJpy: apiKeys.maxOrderPriceJpy, + maxOrderAmountJpy: apiKeys.maxOrderAmountJpy, + allowedMethods: apiKeys.allowedMethods, + createdAt: apiKeys.createdAt, + lastUsedAt: apiKeys.lastUsedAt, + revokedAt: apiKeys.revokedAt, + }) + .from(apiKeys) + .orderBy(apiKeys.createdAt) + +export const createApiKey = async (db: Db, label: string, settings: ApiKeySettings = {}) => { + const token = `csbie_${randomToken()}` + const now = new Date() + const row = { + id: randomId('key'), + label, + tokenHash: sha256(token), + ...normalizeApiKeySettings(settings), + createdAt: now, + } + await db.insert(apiKeys).values(row) + return { ...row, token, tokenHash: undefined } +} + +export const updateApiKeySettings = async (db: Db, id: string, settings: ApiKeySettings) => { + await db.update(apiKeys).set(normalizeApiKeySettings(settings)).where(eq(apiKeys.id, id)) +} + +export const revokeApiKey = async (db: Db, id: string) => { + await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id)) +} + +export const verifyApiKey = async (db: Db, token: string) => { + const tokenHash = sha256(token) + const [row] = await db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.tokenHash, tokenHash), isNull(apiKeys.revokedAt))) + .limit(1) + if (!row) return undefined + await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id)) + return row +} diff --git a/apps/csbie-server/src/security/crypto.ts b/apps/csbie-server/src/security/crypto.ts new file mode 100644 index 0000000..d52f201 --- /dev/null +++ b/apps/csbie-server/src/security/crypto.ts @@ -0,0 +1,13 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' + +export const randomId = (prefix: string) => `${prefix}_${randomBytes(18).toString('base64url')}` + +export const randomToken = () => randomBytes(32).toString('base64url') + +export const sha256 = (value: string) => createHash('sha256').update(value).digest('hex') + +export const safeEqual = (left: string, right: string) => { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} diff --git a/apps/csbie-server/src/security/http-auth.ts b/apps/csbie-server/src/security/http-auth.ts new file mode 100644 index 0000000..3bf5aaf --- /dev/null +++ b/apps/csbie-server/src/security/http-auth.ts @@ -0,0 +1,47 @@ +import { eq } from 'drizzle-orm' +import type { ServerConfig } from '../config' +import type { Db } from '../db' +import { sessions } from '../db/schema' +import { verifyApiKey } from './api-keys' +import type { AuthContext } from '../context' + +const readCookie = (header: string | null, name: string) => { + if (!header) return undefined + for (const part of header.split(';')) { + const [key, ...value] = part.trim().split('=') + if (key === name) return decodeURIComponent(value.join('=')) + } + return undefined +} + +const readQueryApiKey = (request: Request) => { + const key = new URL(request.url).searchParams.get('key')?.trim() + return key || undefined +} + +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: 'none', authenticated: false } satisfies AuthContext) +} + +export const authenticateRequest = async (db: Db, config: ServerConfig, request: Request) => { + const authorization = request.headers.get('authorization') + if (authorization?.startsWith('Bearer ')) { + return apiKeyAuth(db, authorization.slice('Bearer '.length)) + } + + const queryApiKey = readQueryApiKey(request) + if (queryApiKey) { + return apiKeyAuth(db, queryApiKey) + } + + const sessionId = readCookie(request.headers.get('cookie'), config.sessionCookieName) + if (!sessionId) return { type: 'none', authenticated: false } satisfies AuthContext + const [session] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1) + if (!session || session.expiresAt <= new Date()) { + return { type: 'none', authenticated: false } satisfies AuthContext + } + return { type: 'session', authenticated: true, sessionId } satisfies AuthContext +} diff --git a/apps/csbie-server/src/security/keyring.ts b/apps/csbie-server/src/security/keyring.ts new file mode 100644 index 0000000..6d49a5a --- /dev/null +++ b/apps/csbie-server/src/security/keyring.ts @@ -0,0 +1,17 @@ +import { getPassword, setPassword, deletePassword } from '@napi-rs/keyring/keytar' + +const SERVICE = 'csbie' + +export const saveSecret = async (account: string, secret: unknown) => { + await setPassword(SERVICE, account, JSON.stringify(secret)) +} + +export const readSecret = async (account: string): Promise => { + const secret = await getPassword(SERVICE, account) + if (!secret) throw new Error(`secret not found: ${account}`) + return JSON.parse(secret) as T +} + +export const deleteSecret = async (account: string) => { + await deletePassword(SERVICE, account) +} diff --git a/apps/csbie-server/src/security/oauth-provider.ts b/apps/csbie-server/src/security/oauth-provider.ts new file mode 100644 index 0000000..51096cb --- /dev/null +++ b/apps/csbie-server/src/security/oauth-provider.ts @@ -0,0 +1,191 @@ +import { and, eq, isNull } from 'drizzle-orm' +import type { Context } from 'hono' +import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js' +import type { + OAuthClientInformationFull, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js' +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js' +import type { ServerConfig } from '../config' +import type { AppBindings } from '../context' +import type { Db } from '../db' +import { apiKeys, oauthAuthorizationCodes, oauthClients, oauthRefreshTokens } from '../db/schema' +import { createApiKey, type ApiKeySettings, verifyApiKey } from './api-keys' +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 + +export const createOAuthAuthorizationCode = async ( + db: Db, + options: { + clientId: string + redirectUri: string + codeChallenge: string + scopes: string[] + resource?: string + apiKeySettings?: ApiKeySettings + }, +) => { + const now = new Date() + const code = `mcp_code_${randomToken()}` + await db.insert(oauthAuthorizationCodes).values({ + code, + clientId: options.clientId, + redirectUri: options.redirectUri, + codeChallenge: options.codeChallenge, + scopes: options.scopes, + resource: options.resource, + apiKeySettings: (options.apiKeySettings ?? null) as Record | null, + createdAt: now, + expiresAt: new Date(now.getTime() + CODE_TTL_MS), + }) + return code +} + +export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthServerProvider => ({ + get clientsStore() { + return { + getClient: async (clientId: string) => { + const [row] = await db.select().from(oauthClients).where(eq(oauthClients.id, clientId)) + return row?.client as OAuthClientInformationFull | undefined + }, + registerClient: async ( + client: Omit, + ) => { + const clientInfo = client as OAuthClientInformationFull + await db + .insert(oauthClients) + .values({ + id: clientInfo.client_id, + client: clientInfo as unknown as Record, + createdAt: new Date(), + }) + .onConflictDoUpdate({ + target: oauthClients.id, + set: { client: clientInfo as unknown as Record }, + }) + return clientInfo + }, + } + }, + + authorize: async (client, params, c: Context) => { + const approvalUrl = new URL('/oauth/authorize', config.origin) + approvalUrl.searchParams.set('client_id', client.client_id) + approvalUrl.searchParams.set('redirect_uri', params.redirectUri) + approvalUrl.searchParams.set('code_challenge', params.codeChallenge) + if (params.state) approvalUrl.searchParams.set('state', params.state) + if (params.scopes?.length) approvalUrl.searchParams.set('scope', params.scopes.join(' ')) + if (params.resource) approvalUrl.searchParams.set('resource', params.resource.href) + + const auth = c.get('auth') + if (!auth?.authenticated || auth.type !== 'session') { + approvalUrl.searchParams.set('login_required', '1') + c.res = c.redirect(approvalUrl.toString(), 302) + return + } + + c.res = c.redirect(approvalUrl.toString(), 302) + }, + + challengeForAuthorizationCode: async (_client, authorizationCode) => { + const [row] = await db + .select() + .from(oauthAuthorizationCodes) + .where(eq(oauthAuthorizationCodes.code, authorizationCode)) + if (!row || row.expiresAt <= new Date()) throw new Error('authorization code expired') + return row.codeChallenge + }, + + exchangeAuthorizationCode: async ( + client, + authorizationCode, + _codeVerifier, + redirectUri, + resource, + ) => { + const [code] = await db + .select() + .from(oauthAuthorizationCodes) + .where(eq(oauthAuthorizationCodes.code, authorizationCode)) + if (!code || code.expiresAt <= new Date()) throw new Error('authorization code expired') + if (code.clientId !== client.client_id) throw new Error('authorization code client mismatch') + if (redirectUri && code.redirectUri !== redirectUri) throw new Error('redirect_uri mismatch') + await db + .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 + await db.insert(oauthRefreshTokens).values({ + tokenHash: sha256(refreshToken), + clientId: client.client_id, + apiKeyId: key.id, + scopes, + resource: resource?.href ?? code.resource, + createdAt: now, + expiresAt: new Date(now.getTime() + REFRESH_TOKEN_TTL_MS), + }) + + return { + access_token: key.token, + token_type: 'Bearer', + expires_in: ACCESS_TOKEN_TTL_SECONDS, + refresh_token: refreshToken, + scope: scopes.join(' '), + } satisfies OAuthTokens + }, + + exchangeRefreshToken: async (client, refreshToken, scopes, _resource) => { + const [token] = await db + .select() + .from(oauthRefreshTokens) + .where( + and( + eq(oauthRefreshTokens.tokenHash, sha256(refreshToken)), + eq(oauthRefreshTokens.clientId, client.client_id), + isNull(oauthRefreshTokens.revokedAt), + ), + ) + if (!token || token.expiresAt <= new Date()) throw new Error('refresh token expired') + + const newAccess = await createApiKey(db, `OAuth: ${client.client_name ?? client.client_id}`) + return { + access_token: newAccess.token, + token_type: 'Bearer', + expires_in: ACCESS_TOKEN_TTL_SECONDS, + scope: (scopes ?? token.scopes).join(' '), + refresh_token: refreshToken, + } satisfies OAuthTokens + }, + + verifyAccessToken: async (token) => { + const apiKey = await verifyApiKey(db, token) + if (!apiKey) throw new Error('invalid access token') + return { + token, + clientId: apiKey.id, + scopes: ['mcp'], + expiresAt: Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS, + } satisfies AuthInfo + }, + + revokeToken: async (_client, request) => { + await db + .update(apiKeys) + .set({ revokedAt: new Date() }) + .where(eq(apiKeys.tokenHash, sha256(request.token))) + await db + .update(oauthRefreshTokens) + .set({ revokedAt: new Date() }) + .where(eq(oauthRefreshTokens.tokenHash, sha256(request.token))) + }, +}) diff --git a/apps/csbie-server/src/security/sbi-credentials.ts b/apps/csbie-server/src/security/sbi-credentials.ts new file mode 100644 index 0000000..3535afc --- /dev/null +++ b/apps/csbie-server/src/security/sbi-credentials.ts @@ -0,0 +1,11 @@ +import type { StoredSbiPasskeySecret } from '../routes/admin' + +const nonEmpty = (value: string | undefined) => value?.trim() || undefined + +export const effectiveSbiDeviceId = (secret: StoredSbiPasskeySecret) => + nonEmpty(secret.deviceId) ?? nonEmpty(process.env.SBI_DEVICE_ID) + +export const effectiveSbiTradePassword = (secret: StoredSbiPasskeySecret) => + nonEmpty(secret.tradePassword) ?? nonEmpty(process.env.SBI_TRADE_PASSWORD) + +export const hasNonEmptySecretValue = (value: string | undefined) => Boolean(nonEmpty(value)) diff --git a/apps/csbie-server/src/security/sessions.ts b/apps/csbie-server/src/security/sessions.ts new file mode 100644 index 0000000..fd11424 --- /dev/null +++ b/apps/csbie-server/src/security/sessions.ts @@ -0,0 +1,44 @@ +import { eq, lt } from 'drizzle-orm' +import type { Context } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import type { ServerConfig } from '../config' +import type { Db } from '../db' +import { sessions } from '../db/schema' +import { randomId } from './crypto' + +const SESSION_DAYS = 30 + +export const createSession = async (db: Db) => { + const now = new Date() + const expiresAt = new Date(now.getTime() + SESSION_DAYS * 24 * 60 * 60 * 1000) + const id = randomId('ses') + await db.insert(sessions).values({ id, createdAt: now, expiresAt }) + return { id, expiresAt } +} + +export const setSessionCookie = (c: Context, config: ServerConfig, id: string, expires: Date) => { + setCookie(c, config.sessionCookieName, id, { + httpOnly: true, + sameSite: 'Lax', + secure: config.origin.startsWith('https://'), + path: '/', + expires, + }) +} + +export const clearSessionCookie = async (c: Context, db: Db, config: ServerConfig) => { + const id = getCookie(c, config.sessionCookieName) + if (id) await db.delete(sessions).where(eq(sessions.id, id)) + deleteCookie(c, config.sessionCookieName, { path: '/' }) +} + +export const verifySessionCookie = async (c: Context, db: Db, config: ServerConfig) => { + const id = getCookie(c, config.sessionCookieName) + if (!id) return false + const [session] = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1) + return Boolean(session && session.expiresAt > new Date()) +} + +export const pruneSessions = async (db: Db) => { + await db.delete(sessions).where(lt(sessions.expiresAt, new Date())) +} diff --git a/apps/csbie-server/src/security/setup.ts b/apps/csbie-server/src/security/setup.ts new file mode 100644 index 0000000..0e694c2 --- /dev/null +++ b/apps/csbie-server/src/security/setup.ts @@ -0,0 +1,11 @@ +import { sha256, safeEqual } from './crypto' + +export const verifySetupPassword = (password: string) => { + const hash = process.env.CSBIE_SETUP_PASSWORD_HASH + if (hash) return safeEqual(sha256(password), hash) + + const plaintext = process.env.CSBIE_SETUP_PASSWORD + if (plaintext) return safeEqual(password, plaintext) + + return false +} diff --git a/apps/csbie-server/src/security/trade-limits.ts b/apps/csbie-server/src/security/trade-limits.ts new file mode 100644 index 0000000..5433089 --- /dev/null +++ b/apps/csbie-server/src/security/trade-limits.ts @@ -0,0 +1,117 @@ +import { and, eq } from 'drizzle-orm' +import type { Db } from '../db' +import { apiKeyTradeUsage, apiKeys } from '../db/schema' +import type { RpcMethod } from '../rpc/methods' + +type WindowName = '1h' | '3h' | '1d' + +type TradeLimitInput = { + apiKeyId: string + params: unknown +} + +const windowBucket = (date: Date, window: WindowName) => { + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + if (window === '1d') return `${year}-${month}-${day}` + + const hour = date.getUTCHours() + const bucketHour = window === '3h' ? Math.floor(hour / 3) * 3 : hour + return `${year}-${month}-${day}T${String(bucketHour).padStart(2, '0')}` +} + +const numberFromParams = (params: unknown, key: string) => { + if (!params || typeof params !== 'object' || Array.isArray(params)) return undefined + const value = (params as Record)[key] + if (value === undefined || value === null || value === '') return undefined + const number = Number(value) + return Number.isFinite(number) ? number : undefined +} + +const assertPriceLimits = ( + params: unknown, + settings: { + maxOrderPriceJpy: number | null + maxOrderAmountJpy: number | null + }, +) => { + const price = numberFromParams(params, 'price') + const quantity = numberFromParams(params, 'quantity') + + if (settings.maxOrderPriceJpy != null && price != null && price > settings.maxOrderPriceJpy) { + throw new Error(`order price exceeds API key limit (${settings.maxOrderPriceJpy} JPY)`) + } + + if (settings.maxOrderAmountJpy == null) return + if (price == null || quantity == null) { + throw new Error('order amount limit requires price and quantity in trading params') + } + + const amount = price * quantity + if (amount > settings.maxOrderAmountJpy) { + throw new Error(`order amount exceeds API key limit (${settings.maxOrderAmountJpy} JPY)`) + } +} + +const checkAndConsumeWindow = async ( + db: Db, + apiKeyId: string, + window: WindowName, + limit: number | null, + now: Date, +) => { + if (limit == null) return + const hourBucket = windowBucket(now, window) + const where = and( + eq(apiKeyTradeUsage.apiKeyId, apiKeyId), + eq(apiKeyTradeUsage.window, window), + eq(apiKeyTradeUsage.hourBucket, hourBucket), + ) + const [usage] = await db.select().from(apiKeyTradeUsage).where(where).limit(1) + const currentCount = usage?.tradeCount ?? 0 + if (currentCount >= limit) throw new Error(`${window} trade limit exceeded for API key`) + + if (usage) { + await db + .update(apiKeyTradeUsage) + .set({ tradeCount: currentCount + 1, updatedAt: now }) + .where(where) + return + } + + await db.insert(apiKeyTradeUsage).values({ + apiKeyId, + window, + hourBucket, + tradeCount: 1, + updatedAt: now, + }) +} + +export const assertAndConsumeApiKeyTradeLimits = async ({ + db, + apiKeyId, + params, +}: TradeLimitInput & { db: Db }) => { + const [key] = await db.select().from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1) + if (!key || key.revokedAt) throw new Error('API key is not active') + + assertPriceLimits(params, { + maxOrderPriceJpy: key.maxOrderPriceJpy, + maxOrderAmountJpy: key.maxOrderAmountJpy, + }) + + const now = new Date() + await checkAndConsumeWindow(db, apiKeyId, '1h', key.maxTradesPerHour, now) + await checkAndConsumeWindow(db, apiKeyId, '3h', key.maxTradesPer6Hours, now) + await checkAndConsumeWindow(db, apiKeyId, '1d', key.maxTradesPerDay, now) +} + +export const assertApiKeyMethodAllowed = async (db: Db, apiKeyId: string, method: RpcMethod) => { + const [key] = await db.select().from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1) + if (!key || key.revokedAt) throw new Error('API key is not active') + if (key.allowedMethods === null || key.allowedMethods === undefined) return + if (key.allowedMethods.includes(method)) return + throw new Error(`API key is not allowed to call ${method}`) +} diff --git a/apps/csbie-server/tsconfig.json b/apps/csbie-server/tsconfig.json new file mode 100644 index 0000000..49cc21b --- /dev/null +++ b/apps/csbie-server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["bun"], + "rootDir": "src", + "outDir": "dist", + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/csbie-ui/index.html b/apps/csbie-ui/index.html new file mode 100644 index 0000000..af51719 --- /dev/null +++ b/apps/csbie-ui/index.html @@ -0,0 +1,12 @@ + + + + + + CSBIE + + +
+ + + diff --git a/apps/csbie-ui/package.json b/apps/csbie-ui/package.json new file mode 100644 index 0000000..fab453d --- /dev/null +++ b/apps/csbie-ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "@repo/csbie-ui", + "private": true, + "type": "module", + "scripts": { + "clean": "rm -rf dist", + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@simplewebauthn/browser": "^13.1.2", + "lucide-vue-next": "^1.0.0", + "motion-v": "^2.3.0", + "tailwindcss": "^4.1.10", + "vite": "^8", + "vue": "^3.5.16", + "vue-router": "^5.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "@vitejs/plugin-vue": "^6.0.7", + "typescript": "^5", + "vue-tsc": "^3" + } +} diff --git a/apps/csbie-ui/src/App.vue b/apps/csbie-ui/src/App.vue new file mode 100644 index 0000000..e877783 --- /dev/null +++ b/apps/csbie-ui/src/App.vue @@ -0,0 +1,310 @@ + + + diff --git a/apps/csbie-ui/src/api.ts b/apps/csbie-ui/src/api.ts new file mode 100644 index 0000000..d555a8c --- /dev/null +++ b/apps/csbie-ui/src/api.ts @@ -0,0 +1,112 @@ +export type AuthStatus = { + configured: boolean + authenticated: boolean +} + +export type ApiKey = { + id: string + label: string + maxTradesPerHour?: number | null + maxTradesPer6Hours?: number | null + maxTradesPerDay?: number | null + maxOrderPriceJpy?: number | null + maxOrderAmountJpy?: number | null + allowedMethods?: string[] | null + createdAt: string + lastUsedAt?: string | null + revokedAt?: string | null + token?: string +} + +export type ApiKeySettings = Pick< + ApiKey, + | 'maxTradesPerHour' + | 'maxTradesPer6Hours' + | 'maxTradesPerDay' + | 'maxOrderPriceJpy' + | 'maxOrderAmountJpy' + | 'allowedMethods' +> + +export type SbiPasskey = { + id: string + label: string + createdAt: string + updatedAt: string +} + +const request = async (path: string, init?: RequestInit): Promise => { + const response = await fetch(`/api${path}`, { + credentials: 'include', + headers: { + 'content-type': 'application/json', + ...init?.headers, + }, + ...init, + }) + if (!response.ok) throw new Error((await response.text()) || response.statusText) + return response.json() as Promise +} + +export const getStatus = () => request('/auth/status') + +export const createSetupOptions = (password: string) => + request<{ options: unknown; challengeId: string }>('/auth/setup/options', { + method: 'POST', + body: JSON.stringify({ password }), + }) + +export const verifySetup = (challengeId: string, response: unknown) => + request<{ ok: true }>('/auth/setup/verify', { + method: 'POST', + body: JSON.stringify({ challengeId, response }), + }) + +export const createLoginOptions = () => + request<{ options: unknown; challengeId: string }>('/auth/login/options', { + method: 'POST', + }) + +export const verifyLogin = (challengeId: string, response: unknown) => + request<{ ok: true }>('/auth/login/verify', { + method: 'POST', + body: JSON.stringify({ challengeId, response }), + }) + +export const createApiKey = (label: string, settings?: ApiKeySettings) => + request<{ apiKey: ApiKey }>('/admin/api-keys', { + method: 'POST', + body: JSON.stringify({ label, settings }), + }) + +export const listApiKeys = () => request<{ apiKeys: ApiKey[] }>('/admin/api-keys') + +export const updateApiKeySettings = (id: string, settings: ApiKeySettings) => + request<{ ok: true }>(`/admin/api-keys/${id}/settings`, { + method: 'PATCH', + body: JSON.stringify(settings), + }) + +export const revokeApiKey = (id: string) => + request<{ ok: true }>(`/admin/api-keys/${id}`, { method: 'DELETE' }) + +export const listSbiPasskeys = () => request<{ passkeys: SbiPasskey[] }>('/admin/sbi-passkeys') + +export const saveSbiPasskey = (payload: { + label: string + credential: unknown + tradePassword?: string + deviceId?: string +}) => + request<{ passkey: SbiPasskey }>('/admin/sbi-passkeys', { + method: 'POST', + body: JSON.stringify(payload), + }) + +export const deleteSbiPasskey = (id: string) => + request<{ ok: true }>(`/admin/sbi-passkeys/${id}`, { method: 'DELETE' }) + +export const createRpcSocket = () => { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' + return new WebSocket(`${protocol}//${location.host}/api/ws`) +} diff --git a/apps/csbie-ui/src/assets/search-empty.png b/apps/csbie-ui/src/assets/search-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..e1cbfedd5da7c0bb38cf0bbc101067c4410c25d1 GIT binary patch literal 109331 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 E0OXZR3;+NC literal 0 HcmV?d00001 diff --git a/apps/csbie-ui/src/components/ApiKeyPolicyEditor.vue b/apps/csbie-ui/src/components/ApiKeyPolicyEditor.vue new file mode 100644 index 0000000..6a4a176 --- /dev/null +++ b/apps/csbie-ui/src/components/ApiKeyPolicyEditor.vue @@ -0,0 +1,185 @@ + + + diff --git a/apps/csbie-ui/src/components/layout/AppHeader.vue b/apps/csbie-ui/src/components/layout/AppHeader.vue new file mode 100644 index 0000000..c6cc7e0 --- /dev/null +++ b/apps/csbie-ui/src/components/layout/AppHeader.vue @@ -0,0 +1,30 @@ + + + diff --git a/apps/csbie-ui/src/components/layout/AppSidebar.vue b/apps/csbie-ui/src/components/layout/AppSidebar.vue new file mode 100644 index 0000000..90fcfba --- /dev/null +++ b/apps/csbie-ui/src/components/layout/AppSidebar.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/csbie-ui/src/components/ui/Spinner.vue b/apps/csbie-ui/src/components/ui/Spinner.vue new file mode 100644 index 0000000..08f2f21 --- /dev/null +++ b/apps/csbie-ui/src/components/ui/Spinner.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/csbie-ui/src/components/ui/UiButton.vue b/apps/csbie-ui/src/components/ui/UiButton.vue new file mode 100644 index 0000000..5b6e302 --- /dev/null +++ b/apps/csbie-ui/src/components/ui/UiButton.vue @@ -0,0 +1,43 @@ + + + diff --git a/apps/csbie-ui/src/components/ui/UiCard.vue b/apps/csbie-ui/src/components/ui/UiCard.vue new file mode 100644 index 0000000..0dfff92 --- /dev/null +++ b/apps/csbie-ui/src/components/ui/UiCard.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/csbie-ui/src/components/ui/UiField.vue b/apps/csbie-ui/src/components/ui/UiField.vue new file mode 100644 index 0000000..eef0467 --- /dev/null +++ b/apps/csbie-ui/src/components/ui/UiField.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/csbie-ui/src/components/ui/UiModal.vue b/apps/csbie-ui/src/components/ui/UiModal.vue new file mode 100644 index 0000000..1da0413 --- /dev/null +++ b/apps/csbie-ui/src/components/ui/UiModal.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/csbie-ui/src/components/ui/UiSegmented.vue b/apps/csbie-ui/src/components/ui/UiSegmented.vue new file mode 100644 index 0000000..0135231 --- /dev/null +++ b/apps/csbie-ui/src/components/ui/UiSegmented.vue @@ -0,0 +1,31 @@ + + + diff --git a/apps/csbie-ui/src/constants/nav.ts b/apps/csbie-ui/src/constants/nav.ts new file mode 100644 index 0000000..f92982c --- /dev/null +++ b/apps/csbie-ui/src/constants/nav.ts @@ -0,0 +1,21 @@ +import { ArrowLeftRight, History, Settings, WalletCards } from 'lucide-vue-next' +import type { Component } from 'vue' +import type { RouteName } from '../router' + +export type PageMeta = { + title: string + icon: Component +} + +export const pageMeta: Record = { + portfolio: { title: 'ポートフォリオ', icon: WalletCards }, + trade: { title: '取引', icon: ArrowLeftRight }, + history: { title: '取引履歴', icon: History }, + settings: { title: '設定', icon: Settings }, +} + +export const sidebarItems: Array<{ name: RouteName; label: string; icon: Component }> = [ + { name: 'portfolio', label: '資産一覧', icon: pageMeta.portfolio.icon }, + { name: 'trade', label: '取引', icon: pageMeta.trade.icon }, + { name: 'settings', label: '設定', icon: pageMeta.settings.icon }, +] diff --git a/apps/csbie-ui/src/constants/trade.ts b/apps/csbie-ui/src/constants/trade.ts new file mode 100644 index 0000000..e02cdc6 --- /dev/null +++ b/apps/csbie-ui/src/constants/trade.ts @@ -0,0 +1,85 @@ +import type { + CashOrderAccountType, + CashOrderMarket, + CashOrderMethod, + CashOrderPriceCondition, + CashOrderTerm, + CashOrderTriggerZone, + OrderKind, + TradeSide, +} from '../types/trading' + +export const tradeSideOptions: Array<{ label: string; value: TradeSide; tone: 'buy' | 'sell' }> = [ + { label: '購入', value: 'buy', tone: 'buy' }, + { label: '売却', value: 'sell', tone: 'sell' }, +] + +export const orderKindOptions: Array<{ label: string; value: OrderKind }> = [ + { label: '通常単元', value: 'standard' }, + { label: 'S株', value: 's' }, +] + +export const cashOrderAccountTypeOptions: Array<{ + label: string + value: CashOrderAccountType +}> = [ + { label: '特定', value: 'specific' }, + { label: '一般', value: 'general' }, +] + +export const cashOrderMarketOptions: Array<{ + label: string + value: CashOrderMarket +}> = [ + { label: '自動', value: 'auto' }, + { label: '東証', value: 'TKY' }, + { label: 'SOR', value: 'SOR' }, + { label: 'PTS', value: 'PTS' }, + { label: 'PTS(X)', value: 'PTX' }, +] + +export const sKabuOrderMarketOptions: Array<{ + label: string + value: CashOrderMarket +}> = [{ label: 'S株', value: 'STK' }] + +export const cashOrderPriceConditionOptions: Array<{ + label: string + value: CashOrderPriceCondition +}> = [ + { label: '成行', value: 'market' }, + { label: '指値', value: 'limit' }, + { label: '寄成', value: 'marketAtOpen' }, + { label: '寄指', value: 'limitAtOpen' }, + { label: '引成', value: 'marketAtClose' }, + { label: '引指', value: 'limitAtClose' }, + { label: 'IOC成', value: 'marketIoc' }, + { label: 'IOC指', value: 'limitIoc' }, + { label: '不成', value: 'funari' }, +] + +export const cashOrderTermOptions: Array<{ + label: string + value: CashOrderTerm +}> = [ + { label: '当日中', value: 'day' }, + { label: '今週中', value: 'week' }, + { label: '日付指定', value: 'date' }, +] + +export const cashOrderMethodOptions: Array<{ + label: string + value: CashOrderMethod +}> = [ + { label: '通常', value: 'normal' }, + { label: '逆指値', value: 'stop' }, + { label: 'OCO', value: 'oco' }, +] + +export const cashOrderTriggerZoneOptions: Array<{ + label: string + value: CashOrderTriggerZone +}> = [ + { label: '以上', value: 'above' }, + { label: '以下', value: 'below' }, +] diff --git a/apps/csbie-ui/src/features/auth/AuthGate.vue b/apps/csbie-ui/src/features/auth/AuthGate.vue new file mode 100644 index 0000000..97e2e8b --- /dev/null +++ b/apps/csbie-ui/src/features/auth/AuthGate.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/csbie-ui/src/features/auth/api-key-policy.ts b/apps/csbie-ui/src/features/auth/api-key-policy.ts new file mode 100644 index 0000000..ce0ac13 --- /dev/null +++ b/apps/csbie-ui/src/features/auth/api-key-policy.ts @@ -0,0 +1,10 @@ +import type { ApiKeySettings } from '../../api' + +export const defaultApiKeyPolicy = (): ApiKeySettings => ({ + maxTradesPerHour: 10, + maxTradesPer6Hours: 30, + maxTradesPerDay: 80, + maxOrderPriceJpy: null, + maxOrderAmountJpy: null, + allowedMethods: null, +}) diff --git a/apps/csbie-ui/src/features/auth/useAuthAdmin.ts b/apps/csbie-ui/src/features/auth/useAuthAdmin.ts new file mode 100644 index 0000000..859c031 --- /dev/null +++ b/apps/csbie-ui/src/features/auth/useAuthAdmin.ts @@ -0,0 +1,131 @@ +import { startAuthentication, startRegistration } from '@simplewebauthn/browser' +import { ref } from 'vue' +import { + createApiKey, + createLoginOptions, + createSetupOptions, + deleteSbiPasskey, + getStatus, + listApiKeys, + listSbiPasskeys, + saveSbiPasskey, + updateApiKeySettings, + verifyLogin, + verifySetup, + type ApiKey, + type AuthStatus, + type SbiPasskey, +} from '../../api' +import { defaultApiKeyPolicy } from './api-key-policy' + +export const useAuthAdmin = () => { + const status = ref({ configured: false, authenticated: false }) + const apiKeys = ref([]) + const sbiPasskeys = ref([]) + const selectedPasskeyId = ref('') + const setupPassword = ref('') + const authBusy = ref(false) + const apiKeyLabel = ref('Fine-grained API key') + const newApiKeySettings = ref(defaultApiKeyPolicy()) + const newApiToken = ref('') + const sbiLabel = ref('Main profile') + const sbiCredentialJson = ref('') + const tradePassword = ref('') + const sbiDeviceId = ref('') + + const refresh = async (options?: { autoConnect?: boolean; connect?: () => void }) => { + status.value = await getStatus() + if (status.value.authenticated) { + apiKeys.value = (await listApiKeys()).apiKeys + sbiPasskeys.value = (await listSbiPasskeys()).passkeys + selectedPasskeyId.value ||= sbiPasskeys.value[0]?.id ?? '' + if (options?.autoConnect && selectedPasskeyId.value) options.connect?.() + } + } + + const addApiKey = async () => { + const { apiKey } = await createApiKey(apiKeyLabel.value, newApiKeySettings.value) + newApiToken.value = apiKey.token ?? '' + await refresh() + } + + const saveApiKeySettings = async (key: ApiKey) => { + await updateApiKeySettings(key.id, { + maxTradesPerHour: key.maxTradesPerHour, + maxTradesPer6Hours: key.maxTradesPer6Hours, + maxTradesPerDay: key.maxTradesPerDay, + maxOrderPriceJpy: key.maxOrderPriceJpy, + maxOrderAmountJpy: key.maxOrderAmountJpy, + allowedMethods: key.allowedMethods, + }) + await refresh() + } + + const setupOwnerPasskey = async () => { + authBusy.value = true + try { + const { options, challengeId } = await createSetupOptions(setupPassword.value) + const response = await startRegistration({ optionsJSON: options as never }) + await verifySetup(challengeId, response) + setupPassword.value = '' + await refresh() + } finally { + authBusy.value = false + } + } + + const loginWithPasskey = async () => { + authBusy.value = true + try { + const { options, challengeId } = await createLoginOptions() + const response = await startAuthentication({ optionsJSON: options as never }) + await verifyLogin(challengeId, response) + await refresh() + } finally { + authBusy.value = false + } + } + + const addSbiPasskey = async () => { + const { passkey } = await saveSbiPasskey({ + label: sbiLabel.value, + credential: JSON.parse(sbiCredentialJson.value), + tradePassword: tradePassword.value || undefined, + deviceId: sbiDeviceId.value || undefined, + }) + selectedPasskeyId.value = passkey.id + sbiCredentialJson.value = '' + tradePassword.value = '' + sbiDeviceId.value = '' + await refresh() + } + + const removeSbiPasskey = async (id: string) => { + await deleteSbiPasskey(id) + if (selectedPasskeyId.value === id) selectedPasskeyId.value = '' + await refresh() + } + + return { + status, + apiKeys, + sbiPasskeys, + selectedPasskeyId, + setupPassword, + authBusy, + apiKeyLabel, + newApiKeySettings, + newApiToken, + sbiLabel, + sbiCredentialJson, + tradePassword, + sbiDeviceId, + refresh, + addApiKey, + saveApiKeySettings, + setupOwnerPasskey, + loginWithPasskey, + addSbiPasskey, + removeSbiPasskey, + } +} diff --git a/apps/csbie-ui/src/features/history/HistoryView.vue b/apps/csbie-ui/src/features/history/HistoryView.vue new file mode 100644 index 0000000..f1b3ca0 --- /dev/null +++ b/apps/csbie-ui/src/features/history/HistoryView.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/csbie-ui/src/features/oauth/OAuthApprovalPanel.vue b/apps/csbie-ui/src/features/oauth/OAuthApprovalPanel.vue new file mode 100644 index 0000000..d8d1c6f --- /dev/null +++ b/apps/csbie-ui/src/features/oauth/OAuthApprovalPanel.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/csbie-ui/src/features/oauth/useOAuthApproval.ts b/apps/csbie-ui/src/features/oauth/useOAuthApproval.ts new file mode 100644 index 0000000..fd17690 --- /dev/null +++ b/apps/csbie-ui/src/features/oauth/useOAuthApproval.ts @@ -0,0 +1,78 @@ +import { ref } from 'vue' +import type { ApiKeySettings } from '../../api' +import { defaultApiKeyPolicy } from '../auth/api-key-policy' + +export type OAuthApprovalState = { + active: boolean + clientId: string + clientName: string + redirectUri: string + codeChallenge: string + scope: string + state: string + resource: string +} + +export const useOAuthApproval = () => { + const oauthApproval = ref({ + active: false, + clientId: '', + clientName: 'MCP client', + redirectUri: '', + codeChallenge: '', + scope: '', + state: '', + resource: '', + }) + const oauthSettings = ref(defaultApiKeyPolicy()) + + const loadOAuthApproval = async () => { + const url = new URL(location.href) + if (url.pathname !== '/oauth/authorize') return + oauthApproval.value = { + active: true, + clientId: url.searchParams.get('client_id') ?? '', + clientName: url.searchParams.get('client_id') ?? 'MCP client', + redirectUri: url.searchParams.get('redirect_uri') ?? '', + codeChallenge: url.searchParams.get('code_challenge') ?? '', + scope: url.searchParams.get('scope') ?? '', + state: url.searchParams.get('state') ?? '', + resource: url.searchParams.get('resource') ?? '', + } + if (!oauthApproval.value.clientId) return + + const response = await fetch(`/api/oauth/client/${oauthApproval.value.clientId}`, { + credentials: 'include', + }) + if (!response.ok) return + const { client } = (await response.json()) as { client?: { client_name?: string } } + oauthApproval.value.clientName = client?.client_name ?? oauthApproval.value.clientName + } + + const approveOAuth = async () => { + const response = await fetch('/api/oauth/approve', { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + clientId: oauthApproval.value.clientId, + redirectUri: oauthApproval.value.redirectUri, + codeChallenge: oauthApproval.value.codeChallenge, + scope: oauthApproval.value.scope, + state: oauthApproval.value.state, + resource: oauthApproval.value.resource || undefined, + settings: oauthSettings.value, + }), + }) + if (!response.ok) throw new Error(await response.text()) + const { redirectTo } = (await response.json()) as { redirectTo: string } + location.href = redirectTo + } + + return { + oauthApproval, + oauthSettings, + loadOAuthApproval, + approveOAuth, + } +} diff --git a/apps/csbie-ui/src/features/orders/OrderDialogs.vue b/apps/csbie-ui/src/features/orders/OrderDialogs.vue new file mode 100644 index 0000000..05a487d --- /dev/null +++ b/apps/csbie-ui/src/features/orders/OrderDialogs.vue @@ -0,0 +1,205 @@ + + + diff --git a/apps/csbie-ui/src/features/portfolio/PortfolioView.vue b/apps/csbie-ui/src/features/portfolio/PortfolioView.vue new file mode 100644 index 0000000..bcee69a --- /dev/null +++ b/apps/csbie-ui/src/features/portfolio/PortfolioView.vue @@ -0,0 +1,191 @@ + + + diff --git a/apps/csbie-ui/src/features/search/SearchDialog.vue b/apps/csbie-ui/src/features/search/SearchDialog.vue new file mode 100644 index 0000000..0b462c9 --- /dev/null +++ b/apps/csbie-ui/src/features/search/SearchDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/csbie-ui/src/features/settings/SettingsView.vue b/apps/csbie-ui/src/features/settings/SettingsView.vue new file mode 100644 index 0000000..f37988e --- /dev/null +++ b/apps/csbie-ui/src/features/settings/SettingsView.vue @@ -0,0 +1,168 @@ + + + diff --git a/apps/csbie-ui/src/features/trade/RealtimePriceChart.vue b/apps/csbie-ui/src/features/trade/RealtimePriceChart.vue new file mode 100644 index 0000000..89cd77f --- /dev/null +++ b/apps/csbie-ui/src/features/trade/RealtimePriceChart.vue @@ -0,0 +1,225 @@ + + + diff --git a/apps/csbie-ui/src/features/trade/TradeView.vue b/apps/csbie-ui/src/features/trade/TradeView.vue new file mode 100644 index 0000000..7862d1b --- /dev/null +++ b/apps/csbie-ui/src/features/trade/TradeView.vue @@ -0,0 +1,546 @@ + + + diff --git a/apps/csbie-ui/src/features/trading/trading-data.ts b/apps/csbie-ui/src/features/trading/trading-data.ts new file mode 100644 index 0000000..b9171e3 --- /dev/null +++ b/apps/csbie-ui/src/features/trading/trading-data.ts @@ -0,0 +1,301 @@ +import type { + OrderPreview, + OrderRow, + Position, + RealtimePricePoint, + Stock, +} from '../../types/trading' +import { currency } from '../../utils/format' + +export type RecordLike = Record + +export const emptyStock: Stock = { + code: '', + name: '未選択', + symbol: '', + country: '日本', + market: '', + sector: '', + price: 0, + change: 0, + changeAmount: 0, + volume: 0, + open: 0, + high: 0, + low: 0, + prevClose: 0, + sShare: false, + history: [0, 0], + box: { min: 0, q1: 0, median: 0, q3: 0, max: 0 }, +} + +export const isRecord = (value: unknown): value is RecordLike => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +export const asRecord = (value: unknown): RecordLike => (isRecord(value) ? value : {}) + +export const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []) + +export const numberValue = (value: unknown, fallback = 0): number => { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string') { + const normalized = value.replace(/[^\d.-]/g, '') + if (!normalized) return fallback + const parsed = Number(normalized) + if (Number.isFinite(parsed)) return parsed + } + const record = asRecord(value) + if ('value' in record) return numberValue(record.value, fallback) + if ('text' in record) return numberValue(record.text, fallback) + return fallback +} + +const nullableNumberValue = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string') { + const normalized = value.replace(/[^\d.-]/g, '') + if (!normalized) return null + const parsed = Number(normalized) + return Number.isFinite(parsed) ? parsed : null + } + const record = asRecord(value) + if ('value' in record) return nullableNumberValue(record.value) + if ('text' in record) return nullableNumberValue(record.text) + return null +} + +export const textValue = (value: unknown, fallback = ''): string => + typeof value === 'string' && value ? value : fallback + +export const issueFrom = (value: unknown) => { + const issue = asRecord(value) + return { + code: textValue(issue.code, textValue(issue.issueCode)), + market: textValue(issue.market, textValue(issue.marketCode, '')), + name: textValue( + issue.name, + textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))), + ), + } +} + +const quoteFrom = (value: unknown) => { + const quote = asRecord(value) + return { + price: numberValue(quote.price), + change: numberValue(quote.change), + changeRate: numberValue(quote.changeRate), + open: numberValue(quote.open), + high: numberValue(quote.high), + low: numberValue(quote.low), + prevClose: numberValue(quote.previousClose), + volume: numberValue(quote.volume), + } +} + +const historyFromQuote = (quote: ReturnType) => { + const base = quote.prevClose || quote.open || quote.price + const low = quote.low || Math.min(base, quote.price) + const high = quote.high || Math.max(base, quote.price) + return [base, quote.open || base, low, (low + high) / 2, high, quote.price] +} + +const boxFromHistory = (history: number[]) => { + const sorted = [...history].sort((a, b) => a - b) + const at = (pct: number) => sorted[Math.floor((sorted.length - 1) * pct)] ?? 0 + return { min: at(0), q1: at(0.25), median: at(0.5), q3: at(0.75), max: at(1) } +} + +export const stockFromIssue = (issue: ReturnType): Stock => ({ + code: issue.code, + name: issue.name || issue.code, + symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '', + country: '日本', + market: issue.market, + sector: '', + price: 0, + change: 0, + changeAmount: 0, + volume: 0, + open: 0, + high: 0, + low: 0, + prevClose: 0, + sShare: true, + history: [0, 0], + box: { min: 0, q1: 0, median: 0, q3: 0, max: 0 }, +}) + +export const stockFromPosition = (position: Position): Stock => { + const price = position.quantity ? Math.round(position.marketValue / position.quantity) : 0 + return { + ...stockFromIssue({ + code: position.code, + market: position.market, + name: position.name, + }), + price, + history: [price, price], + box: { min: price, q1: price, median: price, q3: price, max: price }, + } +} + +export const stockFromBoard = ( + value: unknown, + fallbackIssue?: ReturnType, +): Stock => { + const board = asRecord(value) + const quoteRecord = asRecord(board.quote) + const boardIssue = issueFrom(board.issue) + const quoteIssue = issueFrom(quoteRecord.issue) + const issue = { + code: quoteIssue.code || boardIssue.code || fallbackIssue?.code || '', + market: quoteIssue.market || boardIssue.market || fallbackIssue?.market || '', + name: quoteIssue.name || boardIssue.name || fallbackIssue?.name || '', + } + const quote = quoteFrom(quoteRecord) + const codeAsNumber = Number(issue.code) + const quotePrice = quote.price === codeAsNumber ? 0 : quote.price + const price = quotePrice || quote.prevClose || quote.open + const history = historyFromQuote({ ...quote, price }) + const hasValidQuote = price > 0 + return { + code: issue.code, + name: issue.name, + symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '', + country: '日本', + market: issue.market, + sector: '', + price, + change: hasValidQuote ? quote.changeRate : 0, + changeAmount: hasValidQuote ? quote.change : 0, + volume: hasValidQuote ? quote.volume : 0, + open: hasValidQuote ? quote.open : 0, + high: hasValidQuote ? quote.high : 0, + low: hasValidQuote ? quote.low : 0, + prevClose: hasValidQuote ? quote.prevClose : 0, + sShare: true, + history, + box: boxFromHistory(history), + } +} + +const chartDateTimeToIso = (value: string) => { + const digits = value.replace(/\D/g, '') + if (digits.length < 8) return '' + const year = Number(digits.slice(0, 4)) + const month = Number(digits.slice(4, 6)) + const day = Number(digits.slice(6, 8)) + const hour = Number(digits.slice(8, 10) || '0') + const minute = Number(digits.slice(10, 12) || '0') + const date = new Date(year, month - 1, day, hour, minute) + return Number.isNaN(date.getTime()) ? '' : date.toISOString() +} + +export const pricePointsFromIssueChart = (value: unknown): RealtimePricePoint[] => + asArray(asRecord(value).prices) + .map((entry) => { + const price = asRecord(entry) + const at = chartDateTimeToIso(textValue(price.dateTime)) + return { at, price: numberValue(price.close) } + }) + .filter((point) => point.at && point.price > 0) + +export const positionFromApi = (value: unknown): Position | null => { + const item = asRecord(value) + const issue = issueFrom(item.issue) + if (!issue.code) return null + const quantity = numberValue(item.quantity) + const avgPrice = numberValue(item.averagePrice ?? item.purchasePrice) + const profitLoss = numberValue(item.profitLoss) + const costBasis = avgPrice * quantity + const profitLossRate = + numberValue(item.profitLossRate) || (costBasis ? (profitLoss / costBasis) * 100 : 0) + return { + code: issue.code, + name: issue.name, + market: issue.market, + quantity, + avgPrice, + marketValue: numberValue(item.marketValue ?? item.valuationPrice), + profitLoss, + profitLossRate, + type: textValue(item.depositTypeText) || undefined, + } +} + +const orderStatusText = (status: string): OrderRow['status'] => { + if (status === 'open') return '注文中' + if (status === 'cancelled' || status === 'expired' || status === 'rejected') return '取消済' + return '約定済' +} + +export const orderFromApi = (value: unknown): OrderRow | null => { + const item = asRecord(value) + const issue = issueFrom(item.issue) + if (!issue.code) return null + const side = item.side === 'sell' ? 'sell' : 'buy' + const kind = item.kind === 's' ? 's' : 'standard' + const status = orderStatusText(textValue(item.status, 'executed')) + const executedQuantity = nullableNumberValue(item.executedQuantity) + const orderedQuantity = nullableNumberValue(item.quantity) + const unexecutedQuantity = nullableNumberValue(item.unexecutedQuantity) + const quantity = + status === '約定済' + ? (executedQuantity ?? orderedQuantity ?? unexecutedQuantity) + : (orderedQuantity ?? unexecutedQuantity ?? executedQuantity) + const price = + status === '約定済' + ? (nullableNumberValue(item.executedPrice) ?? nullableNumberValue(item.price)) + : (nullableNumberValue(item.price) ?? nullableNumberValue(item.executedPrice)) + return { + id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)), + date: textValue(item.orderedAt, textValue(item.expiresAt)), + stock: issue.name || issue.code, + side, + kind, + quantity, + price, + status, + orderNumber: textValue(item.orderNumber), + tradeId: textValue(item.tradeId), + } +} + +export const orderHistoryKey = (order: OrderRow) => + [order.orderNumber || order.id, order.tradeId, order.stock, order.date].filter(Boolean).join(':') + +export const orderQuantityText = (order: OrderRow) => + typeof order.quantity === 'number' ? `${order.quantity}株` : '数量不明' + +export const orderAmountText = (order: OrderRow) => + typeof order.price === 'number' && typeof order.quantity === 'number' + ? currency(order.price * order.quantity) + : '約定代金不明' + +export const orderHistoryResultNotice = (value: unknown) => { + const error = asRecord(asRecord(value).error) + const code = textValue(error.code, textValue(error.status)) + if (!code) return '' + const message = textValue(error.message) + return message ? `${code}: ${message}` : code +} + +export const isOrderPreview = (value: unknown): value is OrderPreview => { + if (!value || typeof value !== 'object') return false + const preview = value as Partial + return ( + typeof preview.issue === 'object' && + preview.issue !== null && + typeof preview.issue.code === 'string' && + typeof preview.issue.market === 'string' && + typeof preview.side === 'string' && + typeof preview.quantity === 'number' && + Array.isArray(preview.warnings) + ) +} + +export const fulfilledValues = (results: Array>): T[] => + results + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map((result) => result.value) diff --git a/apps/csbie-ui/src/features/trading/useTradingSession.ts b/apps/csbie-ui/src/features/trading/useTradingSession.ts new file mode 100644 index 0000000..bf96476 --- /dev/null +++ b/apps/csbie-ui/src/features/trading/useTradingSession.ts @@ -0,0 +1,899 @@ +import { computed, ref, watch, type Ref } from 'vue' +import { createRpcSocket } from '../../api' +import type { + ChartMode, + CashOrderAccountType, + CashOrderMarket, + CashOrderMethod, + CashOrderPriceCondition, + CashOrderTerm, + CashOrderTriggerZone, + JsonRpcResponse, + OrderKind, + OrderPreview, + OrderRow, + Position, + RealtimePricePoint, + RpcMessage, + Stock, + TradeSide, +} from '../../types/trading' +import { + asArray, + asRecord, + emptyStock, + fulfilledValues, + isOrderPreview, + issueFrom, + numberValue, + orderFromApi, + orderHistoryKey, + orderHistoryResultNotice, + pricePointsFromIssueChart, + positionFromApi, + stockFromBoard, + stockFromIssue, + stockFromPosition, + textValue, + type RecordLike, +} from './trading-data' + +type RpcResolver = { + resolve: (value: unknown) => void + reject: (reason: Error) => void +} + +const priceBasedCashOrderConditions = new Set([ + 'limit', + 'limitAtOpen', + 'limitAtClose', + 'limitIoc', + 'funari', +]) + +const cashOrderPriceConditionRequiresPrice = (condition: CashOrderPriceCondition) => + priceBasedCashOrderConditions.has(condition) + +export const useTradingSession = (selectedPasskeyId: Ref) => { + const selectedStockCode = ref('') + const viewedStockCodes = ref([]) + const tradeSide = ref('buy') + const orderKind = ref('s') + const cashOrderAccountType = ref('specific') + const cashOrderMarket = ref('STK') + const cashOrderPriceCondition = ref('market') + const cashOrderTerm = ref('day') + const cashOrderDateInput = ref('') + const cashOrderMethod = ref('normal') + const cashOrderTriggerZone = ref('above') + const cashOrderTriggerPriceInput = ref('') + const cashOrderSecondaryPriceCondition = ref('limit') + const cashOrderSecondaryPriceInput = ref('') + const quantityInput = ref('') + const priceInput = ref('') + const chartMode = ref('line') + const showSearch = ref(false) + const searchQuery = ref('') + const countryFilter = ref('all') + const marketFilter = ref('all') + const showEstimateDialog = ref(false) + const showOrderDialog = ref(false) + const pendingCashEstimateId = ref(null) + const lastCashEstimate = ref(null) + const lastCashEstimateKey = ref('') + const ws = ref(null) + const rpcPending = new Map() + const sbiConnected = ref(false) + const dataLoading = ref(false) + const searchLoading = ref(false) + const buyingPower = ref(0) + const holdingsMarketValue = ref(0) + const totalProfitLoss = ref(0) + const totalProfitLossRate = ref(0) + const orders = ref([]) + const orderHistoryLoaded = ref(false) + const orderHistoryNotice = ref('') + const positions = ref([]) + const stocks = ref([]) + const historicalPricePoints = ref([]) + const realtimePricePoints = ref([]) + const pricePolling = ref(false) + let rpcId = 0 + let boardPollingSubscriptionId = '' + let boardPollingRequestId = 0 + let chartHistoryRequestId = 0 + + const maxRealtimePricePoints = 120 + const maxChartPricePoints = 240 + + const errorMessage = (cause: unknown, fallback: string) => + cause instanceof Error ? cause.message : fallback + + const reportDataError = (message: string, cause?: unknown) => { + if (cause) { + console.error(`[csbie-ui] データ取得エラー: ${message}`, cause) + return + } + console.error(`[csbie-ui] データ取得エラー: ${message}`) + } + + const selectedStock = computed( + () => + stocks.value.find((stock) => stock.code === selectedStockCode.value) ?? + stocks.value[0] ?? + emptyStock, + ) + const socketReady = computed(() => ws.value?.readyState === WebSocket.OPEN) + const connected = computed(() => sbiConnected.value && socketReady.value) + const orderQuantity = computed(() => Number(quantityInput.value || 0)) + const orderPrice = computed(() => Number(priceInput.value || selectedStock.value.price)) + const cashOrderPrimaryRequiresPrice = computed(() => + cashOrderPriceConditionRequiresPrice(cashOrderPriceCondition.value), + ) + const cashOrderSecondaryRequiresPrice = computed(() => + cashOrderPriceConditionRequiresPrice(cashOrderSecondaryPriceCondition.value), + ) + const cashOrderTriggerPrice = computed(() => Number(cashOrderTriggerPriceInput.value || 0)) + const cashOrderSecondaryPrice = computed(() => Number(cashOrderSecondaryPriceInput.value || 0)) + const resolvedCashOrderMarket = computed(() => { + if (orderKind.value === 's') return 'STK' + return cashOrderMarket.value === 'auto' ? selectedStock.value.market : cashOrderMarket.value + }) + const estimatedAmount = computed(() => Math.max(0, orderQuantity.value * orderPrice.value)) + const hasQuote = (stock: Stock) => stock.price > 0 + const hasAccountSummary = computed( + () => + connected.value || + Boolean(positions.value.length) || + orderHistoryLoaded.value || + holdingsMarketValue.value > 0 || + buyingPower.value > 0, + ) + const showPortfolioSpinner = computed(() => dataLoading.value || !hasAccountSummary.value) + const cashOrderKey = computed(() => + JSON.stringify({ + issueCode: selectedStock.value.code, + market: resolvedCashOrderMarket.value, + side: tradeSide.value, + quantity: orderQuantity.value, + kind: orderKind.value, + accountType: cashOrderAccountType.value, + priceCondition: orderKind.value === 's' ? undefined : cashOrderPriceCondition.value, + price: + orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value + ? orderPrice.value + : undefined, + orderTerm: orderKind.value === 's' ? undefined : cashOrderTerm.value, + orderDate: + orderKind.value !== 's' && cashOrderTerm.value === 'date' + ? cashOrderDateInput.value + : undefined, + orderMethod: orderKind.value === 's' ? undefined : cashOrderMethod.value, + triggerZone: + orderKind.value !== 's' && cashOrderMethod.value !== 'normal' + ? cashOrderTriggerZone.value + : undefined, + triggerPrice: + orderKind.value !== 's' && cashOrderMethod.value !== 'normal' + ? cashOrderTriggerPrice.value + : undefined, + secondaryPriceCondition: + orderKind.value !== 's' && cashOrderMethod.value === 'oco' + ? cashOrderSecondaryPriceCondition.value + : undefined, + secondaryPrice: + orderKind.value !== 's' && + cashOrderMethod.value === 'oco' && + cashOrderSecondaryRequiresPrice.value + ? cashOrderSecondaryPrice.value + : undefined, + }), + ) + const canRequestCashEstimate = computed(() => { + if (!connected.value || !selectedStock.value.code || orderQuantity.value <= 0) return false + if (orderKind.value === 's') return true + if (cashOrderPrimaryRequiresPrice.value && orderPrice.value <= 0) return false + if (cashOrderTerm.value === 'date' && !cashOrderDateInput.value) return false + if (cashOrderMethod.value !== 'normal' && cashOrderTriggerPrice.value <= 0) return false + if ( + cashOrderMethod.value === 'oco' && + cashOrderSecondaryRequiresPrice.value && + cashOrderSecondaryPrice.value <= 0 + ) { + return false + } + return true + }) + const canPlaceCashOrder = computed( + () => + canRequestCashEstimate.value && + Boolean(lastCashEstimate.value) && + lastCashEstimateKey.value === cashOrderKey.value, + ) + const countries = computed(() => [...new Set(stocks.value.map((stock) => stock.country))]) + const markets = computed(() => [ + ...new Set(stocks.value.map((stock) => stock.market).filter(Boolean)), + ]) + const stockByCode = computed(() => new Map(stocks.value.map((stock) => [stock.code, stock]))) + const viewedStocks = computed(() => + viewedStockCodes.value + .map((code) => stockByCode.value.get(code)) + .filter((stock): stock is Stock => Boolean(stock)), + ) + const filteredStocks = computed(() => { + const query = searchQuery.value.trim().toLowerCase() + const matchesFilters = (stock: Stock) => { + const matchesCountry = countryFilter.value === 'all' || stock.country === countryFilter.value + const matchesMarket = marketFilter.value === 'all' || stock.market === marketFilter.value + return matchesCountry && matchesMarket + } + + if (!query) return viewedStocks.value.filter(matchesFilters) + + return stocks.value.filter((stock) => { + const matchesQuery = + stock.name.toLowerCase().includes(query) || + stock.code.includes(query) || + stock.symbol.toLowerCase().includes(query) + return matchesQuery && matchesFilters(stock) + }) + }) + const selectedPosition = computed(() => + positions.value.find((position) => position.code === selectedStock.value.code), + ) + const recentOrders = computed(() => orders.value.slice(0, 2)) + const totalAssetValue = computed(() => holdingsMarketValue.value + buyingPower.value) + const stockAssetRatio = computed(() => { + if (!totalAssetValue.value) return 0 + return (holdingsMarketValue.value / totalAssetValue.value) * 100 + }) + const cashAssetRatio = computed(() => { + if (!totalAssetValue.value) return 0 + return (buyingPower.value / totalAssetValue.value) * 100 + }) + const chartPricePoints = computed(() => + [...historicalPricePoints.value, ...realtimePricePoints.value].slice(-maxChartPricePoints), + ) + const boxPlotStyle = computed(() => { + const box = selectedStock.value.box + const range = Math.max(1, box.max - box.min) + const toPct = (value: number) => ((value - box.min) / range) * 100 + return { + min: `${toPct(box.min)}%`, + q1: `${toPct(box.q1)}%`, + median: `${toPct(box.median)}%`, + q3: `${toPct(box.q3)}%`, + max: `${toPct(box.max)}%`, + } + }) + + const recordViewedStock = (code: string) => { + if (!code) return + viewedStockCodes.value = [code, ...viewedStockCodes.value.filter((entry) => entry !== code)] + } + + const selectStock = (stock: Stock) => { + selectedStockCode.value = stock.code + recordViewedStock(stock.code) + showSearch.value = false + lastCashEstimate.value = null + lastCashEstimateKey.value = '' + } + + const rejectPendingRpc = (reason: Error) => { + for (const pending of rpcPending.values()) pending.reject(reason) + rpcPending.clear() + } + + const appendRealtimePricePoint = (price: number, at = new Date()) => { + if (!Number.isFinite(price) || price <= 0) return + realtimePricePoints.value = [ + ...realtimePricePoints.value, + { at: at.toISOString(), price }, + ].slice(-maxRealtimePricePoints) + } + + const stopBoardPolling = () => { + const subscriptionId = boardPollingSubscriptionId + boardPollingSubscriptionId = '' + boardPollingRequestId += 1 + pricePolling.value = false + if (subscriptionId) { + call('market.issue.pollBoard.unsubscribe', { subscriptionId }) + } + } + + const handleBoardPollingUpdate = (params: unknown) => { + const payload = asRecord(params) + const subscriptionId = textValue(payload.subscriptionId) + if (!subscriptionId || subscriptionId !== boardPollingSubscriptionId) return + + const stock = stockFromBoard(payload.board, { + code: selectedStock.value.code, + market: selectedStock.value.market, + name: selectedStock.value.name, + }) + if (stock.code && stock.code !== selectedStock.value.code) return + + mergeStocks([stock]) + appendRealtimePricePoint(stock.price) + } + + const handleRpcMessage = (data: string) => { + let response: JsonRpcResponse + try { + response = JSON.parse(data) as JsonRpcResponse + } catch { + return + } + if (response.method === 'market.issue.pollBoard.update') { + handleBoardPollingUpdate(response.params) + return + } + if (response.method === 'market.issue.pollBoard.error') { + const payload = asRecord(response.params) + if (textValue(payload.subscriptionId) === boardPollingSubscriptionId) { + pricePolling.value = false + reportDataError(textValue(payload.message, '価格ポーリングに失敗しました')) + } + return + } + if (typeof response.id !== 'number') return + const pending = rpcPending.get(response.id) + if (!pending) return + rpcPending.delete(response.id) + if (response.error) { + pending.reject(new Error(response.error.message || 'RPC request failed')) + } else { + pending.resolve(response.result) + } + } + + const call = (method: string, params?: unknown) => { + const socket = ws.value + if (!socket || socket.readyState !== WebSocket.OPEN) return undefined + const payload: RpcMessage = { id: ++rpcId, method, params } + socket.send(JSON.stringify({ jsonrpc: '2.0', ...payload })) + return payload.id + } + + const rpcCall = async (method: string, params?: unknown): Promise => { + const id = call(method, params) + if (!id) throw new Error('SBI session is not connected') + return new Promise((resolve, reject) => { + rpcPending.set(id, { + resolve: (value) => resolve(value as T), + reject, + }) + }) + } + + const rpcCallOptional = async ( + method: string, + params?: unknown, + timeoutMs = 8_000, + ): Promise => { + const id = call(method, params) + if (!id) throw new Error('SBI session is not connected') + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + rpcPending.delete(id) + reject(new Error(`${method} timed out`)) + }, timeoutMs) + rpcPending.set(id, { + resolve: (value) => { + window.clearTimeout(timeout) + resolve(value as T) + }, + reject: (reason) => { + window.clearTimeout(timeout) + reject(reason) + }, + }) + }) + } + + const mergeStocks = (nextStocks: Stock[]) => { + const merged = new Map(stocks.value.map((stock) => [stock.code, stock])) + for (const stock of nextStocks) { + if (!stock.code) continue + const current = merged.get(stock.code) + merged.set(stock.code, { + ...current, + ...stock, + name: stock.name || current?.name || stock.code, + price: stock.price || current?.price || 0, + change: stock.change || current?.change || 0, + changeAmount: stock.changeAmount || current?.changeAmount || 0, + history: stock.price ? stock.history : (current?.history ?? stock.history), + box: stock.price ? stock.box : (current?.box ?? stock.box), + }) + } + stocks.value = [...merged.values()] + if (!selectedStockCode.value) { + const nextCode = stocks.value[0]?.code ?? '' + if (nextCode) { + selectedStockCode.value = nextCode + recordViewedStock(nextCode) + } + } + } + + const loadOrderHistoryFromSdk = async () => { + orderHistoryLoaded.value = false + orderHistoryNotice.value = '' + const [openOrdersResult, executionsTodayResult] = await Promise.allSettled([ + rpcCallOptional('orders.inquiry.open'), + rpcCallOptional('orders.inquiry.executionsToday'), + ]) + if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') { + throw openOrdersResult.reason + } + const nextOrders = fulfilledValues([openOrdersResult, executionsTodayResult]) + .flatMap((orderList) => asArray(orderList.orders)) + .map(orderFromApi) + .filter((order): order is OrderRow => Boolean(order)) + + const deduped = new Map() + for (const order of nextOrders) deduped.set(orderHistoryKey(order), order) + orders.value = [...deduped.values()] + orderHistoryLoaded.value = true + if (!orders.value.length) { + const notices = fulfilledValues([openOrdersResult, executionsTodayResult]) + .map(orderHistoryResultNotice) + .filter(Boolean) + orderHistoryNotice.value = [...new Set(notices)].join(' / ') + } + } + + const loadTradingData = async () => { + dataLoading.value = true + try { + const cashPositions = await rpcCallOptional( + 'account.positions.cash', + undefined, + 15_000, + ) + const nextPositions = asArray(cashPositions.positions) + .map(positionFromApi) + .filter((position): position is Position => Boolean(position)) + positions.value = nextPositions + mergeStocks(nextPositions.map(stockFromPosition)) + const nextHoldingsMarketValue = numberValue( + cashPositions.totalMarketValue, + nextPositions.reduce((sum, position) => sum + position.marketValue, 0), + ) + holdingsMarketValue.value = nextHoldingsMarketValue + totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss) + totalProfitLossRate.value = numberValue(cashPositions.totalProfitLossRate) + + const [orderHistoryResult, powerResult] = await Promise.allSettled([ + loadOrderHistoryFromSdk(), + rpcCallOptional('account.power.buyingPower'), + ]) + + if (powerResult.status === 'fulfilled') { + buyingPower.value = numberValue( + powerResult.value.cashBuyingPower ?? powerResult.value.withdrawableAmount, + ) + } + if (orderHistoryResult.status === 'rejected') { + reportDataError( + errorMessage(orderHistoryResult.reason, '取引履歴の取得に失敗しました'), + orderHistoryResult.reason, + ) + } + + const boards = await Promise.allSettled( + nextPositions.slice(0, 20).map((position) => + rpcCallOptional( + 'market.issue.board', + { + issueCode: position.code, + }, + 8_000, + ), + ), + ) + mergeStocks( + boards.flatMap((result, index) => + result.status === 'fulfilled' + ? [ + stockFromBoard(result.value, { + code: nextPositions[index]?.code ?? '', + market: nextPositions[index]?.market ?? '', + name: nextPositions[index]?.name ?? '', + }), + ] + : [], + ), + ) + } finally { + dataLoading.value = false + } + } + + const connect = () => { + const previousSocket = ws.value + rejectPendingRpc(new Error('RPC socket reconnecting')) + stopBoardPolling() + previousSocket?.close() + sbiConnected.value = false + dataLoading.value = true + if (!selectedPasskeyId.value) { + dataLoading.value = false + reportDataError('SBIパスキーを選択してください') + return + } + const socket = createRpcSocket() + socket.addEventListener('open', async () => { + try { + await rpcCall('sbi.connect', { passkeyId: selectedPasskeyId.value }) + sbiConnected.value = true + await loadTradingData() + } catch (cause) { + sbiConnected.value = false + reportDataError(errorMessage(cause, '接続に失敗しました'), cause) + socket.close() + } finally { + dataLoading.value = false + } + }) + socket.addEventListener('message', (event) => handleRpcMessage(String(event.data))) + socket.addEventListener('error', () => { + if (ws.value !== socket) return + reportDataError('SBI接続に失敗しました') + }) + socket.addEventListener('close', () => { + if (ws.value !== socket) return + rejectPendingRpc(new Error('RPC socket closed')) + boardPollingSubscriptionId = '' + chartHistoryRequestId += 1 + historicalPricePoints.value = [] + realtimePricePoints.value = [] + pricePolling.value = false + sbiConnected.value = false + dataLoading.value = false + }) + ws.value = socket + } + + const startBoardPolling = async () => { + stopBoardPolling() + realtimePricePoints.value = [] + + const stock = selectedStock.value + if (!connected.value || !stock.code) return + appendRealtimePricePoint(stock.price) + + const requestId = ++boardPollingRequestId + try { + const subscribed = await rpcCall('market.issue.pollBoard.subscribe', { + issueCode: stock.code, + market: stock.market || undefined, + }) + if (requestId !== boardPollingRequestId) { + const staleSubscriptionId = textValue(subscribed.subscriptionId) + if (staleSubscriptionId) { + call('market.issue.pollBoard.unsubscribe', { subscriptionId: staleSubscriptionId }) + } + return + } + boardPollingSubscriptionId = textValue(subscribed.subscriptionId) + pricePolling.value = Boolean(boardPollingSubscriptionId) + } catch (cause) { + if (requestId === boardPollingRequestId) { + pricePolling.value = false + reportDataError(errorMessage(cause, '価格ポーリングの開始に失敗しました'), cause) + } + } + } + + const loadSelectedStockChart = async () => { + historicalPricePoints.value = [] + const stock = selectedStock.value + if (!connected.value || !stock.code) return + + const requestId = ++chartHistoryRequestId + try { + const chart = await rpcCall('market.issue.chart', { + issueCode: stock.code, + market: stock.market || undefined, + period: 'day', + count: 120, + }) + if (requestId !== chartHistoryRequestId) return + historicalPricePoints.value = pricePointsFromIssueChart(chart) + } catch (cause) { + if (requestId === chartHistoryRequestId) { + reportDataError(errorMessage(cause, '価格履歴の取得に失敗しました'), cause) + } + } + } + + const searchIssues = async (query: string) => { + if (!connected.value || query.trim().length < 2) return + const result = await rpcCall('market.issue.search', { query, limit: 12 }) + const issues = asArray(result.issues) + .map(issueFrom) + .filter((issue) => issue.code) + mergeStocks(issues.map(stockFromIssue)) + const boards = await Promise.allSettled( + issues.map((issue) => + rpcCall('market.issue.board', { + issueCode: issue.code, + market: issue.market || undefined, + }).then((board) => stockFromBoard(board, issue)), + ), + ) + mergeStocks( + boards + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map((result) => result.value), + ) + } + + const estimateCashOrder = async () => { + if (!canRequestCashEstimate.value) return + lastCashEstimate.value = null + lastCashEstimateKey.value = '' + pendingCashEstimateId.value = null + const preview = await rpcCall('orders.cash.estimate', cashOrderParams()) + if (isOrderPreview(preview)) { + lastCashEstimate.value = preview + lastCashEstimateKey.value = cashOrderKey.value + showEstimateDialog.value = true + } + } + + const cashOrderParams = () => ({ + issueCode: selectedStock.value.code, + market: resolvedCashOrderMarket.value || undefined, + side: tradeSide.value, + quantity: orderQuantity.value, + kind: orderKind.value === 's' ? 's' : undefined, + accountType: cashOrderAccountType.value, + price: + orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined, + priceCondition: orderKind.value !== 's' ? cashOrderPriceCondition.value : undefined, + orderTerm: orderKind.value !== 's' ? cashOrderTerm.value : undefined, + orderDate: + orderKind.value !== 's' && cashOrderTerm.value === 'date' + ? cashOrderDateInput.value + : undefined, + orderMethod: orderKind.value !== 's' ? cashOrderMethod.value : undefined, + triggerZone: + orderKind.value !== 's' && cashOrderMethod.value !== 'normal' + ? cashOrderTriggerZone.value + : undefined, + triggerPrice: + orderKind.value !== 's' && cashOrderMethod.value !== 'normal' + ? cashOrderTriggerPrice.value + : undefined, + secondaryPriceCondition: + orderKind.value !== 's' && cashOrderMethod.value === 'oco' + ? cashOrderSecondaryPriceCondition.value + : undefined, + secondaryPrice: + orderKind.value !== 's' && + cashOrderMethod.value === 'oco' && + cashOrderSecondaryRequiresPrice.value + ? cashOrderSecondaryPrice.value + : undefined, + }) + + const askPlaceOrder = () => { + if (!canPlaceCashOrder.value) return + showEstimateDialog.value = false + showOrderDialog.value = true + } + + const placeCashOrder = async () => { + if (!canPlaceCashOrder.value || !lastCashEstimate.value) return + const receipt = await rpcCall('orders.cash.place', { + ...cashOrderParams(), + confirmationId: lastCashEstimate.value.confirmationId, + allowTrading: true, + }) + orders.value = [ + { + id: textValue(receipt.orderId, `ord-${Date.now()}`), + date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')), + stock: selectedStock.value.name, + side: tradeSide.value, + kind: orderKind.value, + quantity: orderQuantity.value, + price: orderPrice.value, + status: '注文中', + }, + ...orders.value, + ] + showOrderDialog.value = false + await loadTradingData() + } + + const cancelOrder = async (order: OrderRow) => { + await rpcCall('orders.cash.placeCancel', { + orderNumber: order.orderNumber || order.id, + orderId: order.id, + tradeId: order.tradeId || undefined, + allowTrading: true, + }) + order.status = '取消済' + await loadTradingData() + } + + const downloadCsv = () => { + const header = ['code', 'name', 'symbol', 'market', 'price'].join(',') + const rows = selectedStock.value.history.map((price) => + [ + selectedStock.value.code, + selectedStock.value.name, + selectedStock.value.symbol, + selectedStock.value.market, + price, + ] + .map((value) => `"${String(value).replaceAll('"', '""')}"`) + .join(','), + ) + const blob = new Blob([[header, ...rows].join('\n')], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `${selectedStock.value.code}-history.csv` + anchor.click() + URL.revokeObjectURL(url) + } + + const openTradeForStock = (stock: Stock, navigate: () => void) => { + selectStock(stock) + navigate() + } + + const openTradeForPosition = (code: string, navigate: () => void) => { + const stock = stocks.value.find((candidate) => candidate.code === code) + openTradeForStock(stock ?? selectedStock.value, navigate) + } + + let searchTimer: ReturnType | undefined + let searchRequestId = 0 + watch(orderKind, (kind) => { + if (kind === 's') { + cashOrderMarket.value = 'STK' + cashOrderPriceCondition.value = 'market' + cashOrderTerm.value = 'day' + cashOrderDateInput.value = '' + cashOrderMethod.value = 'normal' + cashOrderTriggerPriceInput.value = '' + cashOrderSecondaryPriceInput.value = '' + priceInput.value = '' + return + } + if (cashOrderMarket.value === 'STK') { + cashOrderMarket.value = 'auto' + } + }) + + watch(cashOrderPriceCondition, (condition) => { + if (!cashOrderPriceConditionRequiresPrice(condition)) priceInput.value = '' + }) + + watch(cashOrderSecondaryPriceCondition, (condition) => { + if (!cashOrderPriceConditionRequiresPrice(condition)) cashOrderSecondaryPriceInput.value = '' + }) + + watch(cashOrderTerm, (term) => { + if (term !== 'date') cashOrderDateInput.value = '' + }) + + watch(cashOrderMethod, (method) => { + if (method === 'normal') { + cashOrderTriggerPriceInput.value = '' + cashOrderSecondaryPriceInput.value = '' + return + } + if (method === 'stop') cashOrderSecondaryPriceInput.value = '' + }) + + watch(searchQuery, (query) => { + clearTimeout(searchTimer) + const trimmed = query.trim() + if (trimmed.length < 2 || !connected.value) { + searchLoading.value = false + return + } + searchLoading.value = true + searchTimer = setTimeout(async () => { + const requestId = ++searchRequestId + try { + await searchIssues(query) + } catch (cause) { + reportDataError(errorMessage(cause, '銘柄検索に失敗しました'), cause) + } finally { + if (requestId === searchRequestId) { + searchLoading.value = false + } + } + }, 350) + }) + + watch( + [connected, () => selectedStock.value.code, () => selectedStock.value.market], + () => { + void loadSelectedStockChart() + void startBoardPolling() + }, + { immediate: true }, + ) + + return { + selectedStockCode, + tradeSide, + orderKind, + cashOrderAccountType, + cashOrderMarket, + cashOrderPriceCondition, + cashOrderTerm, + cashOrderDateInput, + cashOrderMethod, + cashOrderTriggerZone, + cashOrderTriggerPriceInput, + cashOrderSecondaryPriceCondition, + cashOrderSecondaryPriceInput, + quantityInput, + priceInput, + chartMode, + showSearch, + searchQuery, + countryFilter, + marketFilter, + showEstimateDialog, + showOrderDialog, + lastCashEstimate, + connected, + dataLoading, + searchLoading, + buyingPower, + holdingsMarketValue, + totalProfitLoss, + totalProfitLossRate, + orders, + orderHistoryLoaded, + orderHistoryNotice, + positions, + realtimePricePoints, + chartPricePoints, + pricePolling, + selectedStock, + orderQuantity, + orderPrice, + cashOrderPrimaryRequiresPrice, + cashOrderTriggerPrice, + cashOrderSecondaryPrice, + estimatedAmount, + showPortfolioSpinner, + canRequestCashEstimate, + canPlaceCashOrder, + countries, + markets, + viewedStocks, + filteredStocks, + selectedPosition, + recentOrders, + totalAssetValue, + stockAssetRatio, + cashAssetRatio, + boxPlotStyle, + hasQuote, + selectStock, + connect, + loadTradingData, + estimateCashOrder, + askPlaceOrder, + placeCashOrder, + cancelOrder, + downloadCsv, + openTradeForStock, + openTradeForPosition, + } +} diff --git a/apps/csbie-ui/src/main.ts b/apps/csbie-ui/src/main.ts new file mode 100644 index 0000000..bb813d5 --- /dev/null +++ b/apps/csbie-ui/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' +import { router } from './router' + +createApp(App).use(router).mount('#app') diff --git a/apps/csbie-ui/src/router.ts b/apps/csbie-ui/src/router.ts new file mode 100644 index 0000000..cb3d942 --- /dev/null +++ b/apps/csbie-ui/src/router.ts @@ -0,0 +1,20 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const RouteStub = { template: '' } + +export const routeNames = ['portfolio', 'trade', 'history', 'settings'] as const +export type RouteName = (typeof routeNames)[number] + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', redirect: '/portfolio' }, + { path: '/portfolio', name: 'portfolio', component: RouteStub }, + { path: '/trade', name: 'trade', component: RouteStub }, + { path: '/history', name: 'history', component: RouteStub }, + { path: '/api-keys', redirect: '/settings' }, + { path: '/settings', name: 'settings', component: RouteStub }, + { path: '/oauth/authorize', name: 'oauthAuthorize', component: RouteStub }, + { path: '/:pathMatch(.*)*', redirect: '/portfolio' }, + ], +}) diff --git a/apps/csbie-ui/src/style.css b/apps/csbie-ui/src/style.css new file mode 100644 index 0000000..0d85fc2 --- /dev/null +++ b/apps/csbie-ui/src/style.css @@ -0,0 +1,14 @@ +@import 'tailwindcss'; + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + min-width: 320px; + overflow: hidden; + background: #101418; +} diff --git a/apps/csbie-ui/src/styles/ui.ts b/apps/csbie-ui/src/styles/ui.ts new file mode 100644 index 0000000..1f328d2 --- /dev/null +++ b/apps/csbie-ui/src/styles/ui.ts @@ -0,0 +1,162 @@ +export const ui = { + appShell: 'grid h-dvh overflow-hidden grid-cols-[7rem_minmax(0,1fr)] bg-[#101418] text-[#e3e3e9]', + sidebar: 'flex h-full min-h-0 flex-col items-center border-r border-[#2f3338] bg-[#191c20] py-4', + brandMark: + 'grid h-14 w-14 place-items-center rounded-[18px] bg-[#a8c7fa] text-3xl font-black text-[#0b305f] shadow-lg shadow-black/20', + navStack: 'mt-10 grid w-full gap-4 px-2', + navButton: + 'grid min-h-16 place-items-center gap-1 rounded-[20px] bg-transparent p-1 text-xs font-semibold text-[#c3c7cf] transition hover:bg-[#22272e]', + navButtonActive: 'text-[#d3e3fd]', + navIcon: 'grid h-8 w-16 place-items-center rounded-full text-[#c3c7cf]', + navIconActive: 'bg-[#263141] text-[#d3e3fd]', + workspace: 'flex min-h-0 min-w-0 flex-col gap-7 overflow-y-auto px-8 pt-8 pb-8', + topbar: 'flex shrink-0 items-center justify-between gap-4', + tradeTopbar: 'hidden', + + authPanel: 'grid justify-center pt-12', + panel: + 'grid content-start gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-lg shadow-black/15', + loginPanel: 'w-[28rem]', + panelHead: 'flex items-center justify-between gap-3', + eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]', + dashboardGrid: + 'grid min-h-0 flex-1 grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] grid-rows-[auto_minmax(0,1fr)] items-stretch gap-6 overflow-hidden', + metricPanel: + 'grid min-h-40 content-center gap-3 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15', + assetOverviewPanel: + 'grid min-h-40 grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] items-center gap-x-7 gap-y-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15', + assetOverviewHead: 'grid gap-2', + assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]', + assetBreakdownPanel: 'grid gap-3', + assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]', + metricLabel: 'text-xs font-extrabold text-[#9aa0a9]', + metricValue: 'text-3xl font-black', + positive: 'text-[#40dba2]', + negative: 'text-[#ffb4ab]', + miniProgress: 'block h-2 w-full overflow-hidden rounded-full bg-[#33383f]', + miniProgressBar: 'block h-full w-[12%] rounded-full bg-[#a8c7fa]', + assetBreakdownBar: 'flex h-3 w-full overflow-hidden rounded-full bg-[#33383f]', + assetBreakdownStocks: 'block h-full bg-[#a8c7fa]', + assetBreakdownCash: 'block h-full bg-[#40dba2]', + assetBreakdownRows: 'grid gap-2', + assetBreakdownRow: 'flex items-center justify-between gap-4', + assetBreakdownLabel: 'inline-flex items-center gap-2 text-sm font-semibold text-[#c3c7cf]', + assetBreakdownSwatch: 'block h-2.5 w-2.5 shrink-0 rounded-full', + assetBreakdownSwatchStocks: 'bg-[#a8c7fa]', + assetBreakdownSwatchCash: 'bg-[#40dba2]', + assetBreakdownMeta: 'grid justify-items-end gap-0.5 text-right', + assetBreakdownAmount: 'text-sm font-black text-[#e3e3e9]', + assetBreakdownRatio: 'text-xs font-semibold text-[#8f949d]', + holdingsPanel: + 'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15', + holdingsBody: 'flex min-h-0 flex-1 flex-col overflow-hidden', + holdingsRows: 'grid min-h-0 flex-1 content-start overflow-y-auto', + holdingsHead: + 'grid grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d]', + holdingRow: + 'grid min-h-16 grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 rounded-2xl bg-transparent px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930]', + typePill: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs text-[#d3e3fd]', + muted: 'text-[#8f949d]', + portfolioHistory: + 'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15', + historyList: 'flex min-h-0 flex-1 flex-col overflow-hidden', + historyRows: 'grid min-h-0 flex-1 content-start gap-7 overflow-y-auto', + emptyState: 'flex flex-1 items-center justify-center py-8 text-center', + miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3', + tradeLayout: 'trade-layout grid w-full min-h-[calc(100dvh-6rem)] grid-cols-5 items-stretch gap-5', + watchlist: + 'order-3 pl-2 col-span-1 grid h-full min-h-[calc(100dvh-6rem)] content-start overflow-y-auto border-l border-[#30343a] bg-[#101418] shadow-none', + watchSearch: + 'm-3 inline-flex min-h-11 items-center gap-2 rounded-full bg-[#111418] px-4 text-left font-medium text-[#9aa0a9] outline outline-1 outline-[#33383f] transition hover:bg-[#22272e] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]', + watchRow: + 'grid min-h-18 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-l-2 border-transparent bg-transparent px-4 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#d3e3fd]', + watchRowActive: 'border-[#c3c7cf] bg-[#242930]', + centerStack: 'order-1 col-span-3 grid content-start gap-5', + tradeSeparator: 'order-3 hidden', + stockPanel: + 'grid min-h-72 content-start gap-4 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none', + stockTitle: 'flex items-start justify-between gap-3', + stockHoldingNote: + 'inline-flex w-fit items-center rounded-full border border-[#2f343b] bg-[#111418] px-4 py-2 text-sm font-semibold text-[#c3c7cf]', + quoteBox: 'grid justify-items-end gap-1', + periodTabs: 'flex justify-end gap-3', + periodButton: 'min-h-7 rounded-full bg-transparent px-3 text-xs font-bold text-[#8f949d]', + periodButtonActive: 'bg-[#d3e3fd] text-[#102033]', + chartActions: 'flex items-center justify-between gap-4', + smallTabs: 'grid grid-cols-2 gap-1 rounded-full bg-[#111418] p-1', + smallTab: 'min-h-8 rounded-full bg-transparent px-3 text-xs font-bold text-[#9aa0a9]', + smallTabActive: 'bg-[#263141] text-[#d3e3fd]', + chartBox: 'mt-1 h-40 overflow-hidden rounded-[20px] bg-[#111418]', + chartLine: 'fill-none stroke-[#40dba2] stroke-[3]', + boxplot: 'grid min-h-36 content-center gap-5 rounded-[24px] bg-[#111418] p-7', + boxplotScale: 'flex justify-between text-xs text-[#8f949d]', + boxplotTrack: + 'relative h-20 before:absolute before:inset-x-0 before:top-10 before:h-0.5 before:bg-[#4a5058]', + whisker: 'absolute top-9 h-3 border-x-2 border-[#c3c7cf]', + box: 'absolute top-6 h-9 rounded-xl border-2 border-[#a8c7fa] bg-[#a8c7fa]/25', + median: 'absolute top-4 h-12 border-l-4 border-[#fdd663]', + infoTabPanel: 'grid gap-0 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none', + infoTabList: 'flex gap-6 border-b border-[#33383f]', + infoTabButton: + 'relative min-h-10 px-1 text-sm font-black text-[#8f949d] transition hover:text-[#d3e3fd] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d3e3fd]', + infoTabButtonActive: 'text-[#d3e3fd]', + infoTabIndicator: 'absolute inset-x-0 bottom-[-1px] block h-0.5 rounded-full bg-[#d3e3fd]', + infoTabBody: 'overflow-hidden pt-4', + detailGrid: 'grid grid-cols-3 gap-3', + detailItem: 'grid min-h-16 gap-1 rounded-[14px] bg-[#111418] p-3', + holdingEmpty: + 'rounded-[16px] border border-[#2f343b] bg-[#111418] px-4 py-3 text-sm font-semibold text-[#8f949d]', + ticketPanel: + 'order-2 col-span-1 flex h-full min-h-0 flex-col gap-4 overflow-hidden border-0 bg-[#101418] p-6 shadow-none', + ticketTop: 'shrink-0', + ticketScroll: 'min-h-0 flex-1 overflow-y-auto pr-1', + ticketScrollInner: 'grid content-start gap-3', + ticketBottom: 'grid shrink-0 gap-3', + ticketBox: 'grid grid-cols-1 gap-3', + advancedOptions: 'group bg-transparent p-0', + advancedSummary: + 'flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 rounded-[16px] bg-[#111418] px-4 text-sm font-extrabold text-[#d3e3fd] transition hover:bg-[#182029] marker:hidden [&::-webkit-details-marker]:hidden', + advancedSummaryIconWrap: 'grid h-4 w-4 place-items-center', + advancedSummaryIcon: 'h-4 w-4', + advancedBody: 'grid gap-2 pt-3', + advancedLabel: 'text-xs font-extrabold text-[#9aa0a9]', + label: 'grid gap-2 text-xs font-extrabold text-[#9aa0a9]', + input: + 'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition placeholder:text-[#747982] focus:border-[#a8c7fa]', + estimateSummary: + 'flex items-center justify-between rounded-[18px] border border-[#2f343b] bg-[#111418] px-5 py-4', + holdingNote: + 'rounded-[16px] border border-[#2f343b] bg-[#111418] px-4 py-3 text-sm font-semibold text-[#c3c7cf]', + actions: 'grid gap-3', + primaryButton: + 'inline-flex min-h-10 items-center justify-center gap-2 rounded-full bg-[#a8c7fa] px-5 font-extrabold text-[#102033] shadow-sm shadow-black/20 transition hover:bg-[#d3e3fd] disabled:opacity-50', + dangerButton: + 'inline-flex min-h-10 items-center justify-center gap-2 rounded-full bg-[#ffb4ab] px-5 font-extrabold text-[#690005] shadow-sm shadow-black/20 transition hover:bg-[#ffd8d3] disabled:opacity-50', + ghostButton: + 'inline-flex min-h-10 items-center justify-center gap-2 rounded-full border border-[#4a5058] bg-transparent px-5 font-extrabold text-[#d3e3fd] transition hover:bg-[#263141]', + list: 'grid gap-2', + orderRow: + 'grid grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4', + statusBadge: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs font-black text-[#d3e3fd]', + pendingBadge: 'bg-[#4a3720] text-[#ffddb3]', + apiLayout: 'grid grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] gap-7', + settingsLayout: 'grid gap-7', + row: 'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4', + rowActions: 'flex gap-2', + keyRow: 'grid gap-3 rounded-[24px] bg-[#111418] p-4', + profileRow: + 'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4', + searchOverlay: 'fixed inset-0 z-20 bg-[#101418]/75 backdrop-blur-sm', + searchSheet: + 'mx-auto mt-4 grid max-h-[85vh] w-[48rem] gap-3 overflow-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24]/85 p-4 shadow-2xl shadow-black/35', + searchInputRow: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3', + filterRow: 'grid grid-cols-2 gap-3', + searchResults: 'grid gap-2', + searchLoading: 'flex min-h-32 flex-col items-center justify-center gap-3 py-8 text-[#9aa0a8]', + searchResult: + 'flex min-h-16 items-center justify-between gap-4 rounded-[20px] border border-transparent bg-[#111418] p-4 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]', + searchResultActive: 'border-[#5f666f] bg-[#242930]', + confirmList: 'grid gap-2', + confirmRow: 'flex justify-between gap-3 border-b border-[#33383f] pb-2', + dialogNote: 'leading-7 text-[#c3c7cf]', +} as const diff --git a/apps/csbie-ui/src/types/trading.ts b/apps/csbie-ui/src/types/trading.ts new file mode 100644 index 0000000..9e839da --- /dev/null +++ b/apps/csbie-ui/src/types/trading.ts @@ -0,0 +1,102 @@ +export type TradeSide = 'buy' | 'sell' +export type OrderKind = 'standard' | 's' +export type CashOrderAccountType = 'specific' | 'general' +export type CashOrderMarket = 'auto' | 'TKY' | 'SOR' | 'PTS' | 'PTX' | 'STK' +export type CashOrderPriceCondition = + | 'limit' + | 'limitAtOpen' + | 'limitAtClose' + | 'limitIoc' + | 'market' + | 'marketAtOpen' + | 'marketAtClose' + | 'marketIoc' + | 'funari' +export type CashOrderTerm = 'day' | 'week' | 'date' +export type CashOrderMethod = 'normal' | 'stop' | 'oco' +export type CashOrderTriggerZone = 'above' | 'below' +export type ChartMode = 'line' | 'box' + +export type RpcMessage = { + id: number + method: string + params?: unknown +} + +export type OrderPreview = { + issue: { + code: string + market: string + } + side: string + quantity: number + warnings: string[] + confirmationId?: string + message?: string +} + +export type JsonRpcResponse = { + id?: number + method?: string + params?: unknown + result?: unknown + error?: { + message?: string + } +} + +export type RealtimePricePoint = { + at: string + price: number +} + +export type Stock = { + code: string + name: string + symbol: string + country: string + market: string + sector: string + price: number + change: number + changeAmount: number + volume: number + open: number + high: number + low: number + prevClose: number + sShare: boolean + history: number[] + box: { + min: number + q1: number + median: number + q3: number + max: number + } +} + +export type OrderRow = { + id: string + date: string + stock: string + side: TradeSide + kind: OrderKind + quantity: number | null + price: number | null + status: '注文中' | '約定済' | '取消済' + orderNumber?: string + tradeId?: string +} + +export type Position = { + code: string + name: string + market: string + quantity: number + avgPrice: number + marketValue: number + profitLoss: number + profitLossRate: number + type?: string +} diff --git a/apps/csbie-ui/src/utils/format.ts b/apps/csbie-ui/src/utils/format.ts new file mode 100644 index 0000000..119a59e --- /dev/null +++ b/apps/csbie-ui/src/utils/format.ts @@ -0,0 +1,14 @@ +export const currency = (value: number) => + new Intl.NumberFormat('ja-JP', { + style: 'currency', + currency: 'JPY', + maximumFractionDigits: 0, + }).format(value) + +export const number = (value: number) => new Intl.NumberFormat('ja-JP').format(value) + +export const signedCurrency = (value: number) => + `${value >= 0 ? '+' : '-'}${currency(Math.abs(value))}` + +export const signedPercent = (value: number, fractionDigits = 2) => + `${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits)}%` diff --git a/apps/csbie-ui/tsconfig.json b/apps/csbie-ui/tsconfig.json new file mode 100644 index 0000000..c4ef22e --- /dev/null +++ b/apps/csbie-ui/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "jsx": "preserve" + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/apps/csbie-ui/vite.config.ts b/apps/csbie-ui/vite.config.ts new file mode 100644 index 0000000..a87d568 --- /dev/null +++ b/apps/csbie-ui/vite.config.ts @@ -0,0 +1,35 @@ +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 proxyToServer = { + target: serverOrigin, + changeOrigin: true, + ws: true, +} + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + server: { + port: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173), + strictPort: true, + hmr: { + host: '127.0.0.1', + clientPort: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173), + }, + proxy: { + '/api': { + ...proxyToServer, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + '/.well-known': proxyToServer, + '/authorize': proxyToServer, + '/token': proxyToServer, + '/register': proxyToServer, + '/revoke': proxyToServer, + '/oauth': proxyToServer, + }, + }, +}) diff --git a/apps/csbie/Dockerfile b/apps/csbie/Dockerfile new file mode 100644 index 0000000..9376118 --- /dev/null +++ b/apps/csbie/Dockerfile @@ -0,0 +1,21 @@ +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 +WORKDIR /app +ENV NODE_ENV=production +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 ./node_modules +EXPOSE 8787 +CMD ["bun", "apps/csbie/dist/index.js"] diff --git a/apps/csbie/package.json b/apps/csbie/package.json new file mode 100644 index 0000000..0e289c0 --- /dev/null +++ b/apps/csbie/package.json @@ -0,0 +1,20 @@ +{ + "name": "@repo/csbie", + "private": true, + "type": "module", + "scripts": { + "clean": "rm -rf dist ../csbie-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", + "start": "bun --env-file=../../.env dist/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@repo/csbie-server": "workspace:*", + "hono": "^4.8.3" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5" + } +} diff --git a/apps/csbie/src/dev.ts b/apps/csbie/src/dev.ts new file mode 100644 index 0000000..b07b3a6 --- /dev/null +++ b/apps/csbie/src/dev.ts @@ -0,0 +1,37 @@ +const uiPort = Number(process.env.CSBIE_UI_DEV_PORT ?? 5173) +const serverPort = Number(process.env.PORT ?? process.env.CSBIE_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 ui = Bun.spawn({ + cmd: ['bun', '--filter', '@repo/csbie-ui', 'dev'], + stdout: 'inherit', + stderr: 'inherit', + stdin: 'inherit', + env: process.env, +}) + +const server = Bun.spawn({ + cmd: ['bun', '--watch', serverEntry], + stdout: 'inherit', + stderr: 'inherit', + stdin: 'inherit', + env: { + ...process.env, + PORT: String(serverPort), + CSBIE_ORIGIN: uiOrigin, + CSBIE_CORS_ORIGIN: uiOrigin, + }, +}) + +const shutdown = () => { + ui.kill() + server.kill() +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) + +const appExit = await server.exited +shutdown() +process.exit(appExit) diff --git a/apps/csbie/src/index.ts b/apps/csbie/src/index.ts new file mode 100644 index 0000000..16f0ec1 --- /dev/null +++ b/apps/csbie/src/index.ts @@ -0,0 +1,55 @@ +import { existsSync } from 'node:fs' +import { extname, join } 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' + +const config = loadConfig() +const db = createDb(config.databasePath) +const api = createServerApp(db, config) +const app = new Hono() +const uiDist = join(import.meta.dir, '../../csbie-ui/dist') + +const contentTypes: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +} + +app.route('/api', api.app) +app.route('/', api.app) + +const serveUi = async (request: Request) => { + const url = new URL(request.url) + const pathname = decodeURIComponent(url.pathname) + const relativePath = pathname === '/' ? 'index.html' : pathname.slice(1) + const filePath = join(uiDist, relativePath) + const resolvedPath = existsSync(filePath) ? filePath : join(uiDist, 'index.html') + const file = Bun.file(resolvedPath) + const headers = new Headers() + headers.set('content-type', contentTypes[extname(resolvedPath)] ?? 'application/octet-stream') + return new Response(file, { headers }) +} + +const server = Bun.serve({ + port: config.port, + fetch(request, server) { + const url = new URL(request.url) + if ( + url.pathname.startsWith('/api/') || + url.pathname.startsWith('/.well-known/') || + url.pathname.startsWith('/oauth/') || + ['/authorize', '/token', '/register', '/revoke'].includes(url.pathname) + ) { + return app.fetch(request, { server }) + } + return serveUi(request) + }, + websocket: api.websocket, +}) + +console.log(`csbie listening on http://localhost:${server.port}`) diff --git a/apps/csbie/tsconfig.json b/apps/csbie/tsconfig.json new file mode 100644 index 0000000..49cc21b --- /dev/null +++ b/apps/csbie/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["bun"], + "rootDir": "src", + "outDir": "dist", + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..1516c6c --- /dev/null +++ b/bun.lock @@ -0,0 +1,996 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "csbi", + "devDependencies": { + "@types/bun": "latest", + "vite-plus": "^0.1.24", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + "apps/csbie": { + "name": "@repo/csbie", + "dependencies": { + "@repo/csbie-server": "workspace:*", + "hono": "^4.8.3", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "apps/csbie-server": { + "name": "@repo/csbie-server", + "dependencies": { + "@hono/mcp": "^0.3.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "^1.2.0", + "@repo/sbi-client": "workspace:*", + "@simplewebauthn/server": "^13.1.2", + "drizzle-orm": "^0.44.2", + "hono": "^4.8.3", + "zod": "^4.4.3", + }, + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^0.31.1", + "typescript": "^5", + }, + }, + "apps/csbie-ui": { + "name": "@repo/csbie-ui", + "dependencies": { + "@simplewebauthn/browser": "^13.1.2", + "lucide-vue-next": "^1.0.0", + "motion-v": "^2.3.0", + "tailwindcss": "^4.1.10", + "vite": "^8", + "vue": "^3.5.16", + "vue-router": "^5.1.0", + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "@vitejs/plugin-vue": "^6.0.7", + "typescript": "^5", + "vue-tsc": "^3", + }, + }, + "packages/sbi-client": { + "name": "@repo/sbi-client", + "dependencies": { + "iconv-lite": "^0.7.2", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@babel/generator": ["@babel/generator@8.0.0", "", { "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=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0", "", {}, "sha512-kXxQVZHNOctSJJsqzmcbPSCEkM6oHNnDIkua7g9RCO9xRHj2eCiKvRx2KPdfWR9QxcGWnK/oArrtunmie3rL9g=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + + "@hono/mcp": ["@hono/mcp@0.3.0", "", { "dependencies": { "pkce-challenge": "^5.0.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.1", "hono": "*", "hono-rate-limiter": "^0.5.3", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-UhSWFbZwRxHBdptOn2r1HyB8Vt6gU+BL3AbTis2t8TBW69ZhG4soJQ09yEL/t/ymxszJnWp5Rq6tOXXOjuK1qg=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.3.0", "", { "os": "linux", "cpu": "none" }, "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.3.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + + "@oxc-project/runtime": ["@oxc-project/runtime@0.133.0", "", {}, "sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ=="], + + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw=="], + + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.23.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.23.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.23.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.23.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.23.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.67.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="], + + "@oxlint/plugins": ["@oxlint/plugins@1.61.0", "", {}, "sha512-nkOyZEF1vH527CkdQtOp1HMrVFEM4ResURvI2JFeGoup+h+43J/k/FgdOR9b9Isxg+Yae7qVDa7y3nssE8b3TQ=="], + + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-rsa": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pfx": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@repo/csbie": ["@repo/csbie@workspace:apps/csbie"], + + "@repo/csbie-server": ["@repo/csbie-server@workspace:apps/csbie-server"], + + "@repo/csbie-ui": ["@repo/csbie-ui@workspace:apps/csbie-ui"], + + "@repo/sbi-client": ["@repo/sbi-client@workspace:packages/sbi-client"], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@13.3.1", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], + + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + + "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.7", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg=="], + + "@voidzero-dev/vite-plus-core": ["@voidzero-dev/vite-plus-core@0.1.24", "", { "dependencies": { "@oxc-project/runtime": "=0.133.0", "@oxc-project/types": "=0.133.0", "lightningcss": "^1.30.2", "postcss": "^8.5.6" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.1", "@tsdown/exe": "0.22.1", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.8", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "unrun": "*", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "unrun", "yaml"] }, "sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ=="], + + "@voidzero-dev/vite-plus-darwin-arm64": ["@voidzero-dev/vite-plus-darwin-arm64@0.1.24", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hpo9W9piSFlEsJzGkwzfDXhJGrnYByxHXF7NVQZ7g+SLOprddtlfTeM8t+gq9dxcuq0RzM8ddMAhDQP/K3fZQA=="], + + "@voidzero-dev/vite-plus-darwin-x64": ["@voidzero-dev/vite-plus-darwin-x64@0.1.24", "", { "os": "darwin", "cpu": "x64" }, "sha512-SwnnnZrEFBiU5iKlh/CZAVwn0RFt/Udrvt3kFLtdRxMtN5bKaqTFVA2H8Y/FPCWp1QX9bs4V9ZIAeXAk06zLkw=="], + + "@voidzero-dev/vite-plus-linux-arm64-gnu": ["@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.24", "", { "os": "linux", "cpu": "arm64" }, "sha512-ImM3eqDki4DpRuHjW6dEh4St8zvbcfOMR7KQZJX42ArriCLQ/QdaYhDRRbcDi27XsOBqRxm2eqUUEymPrYIHpA=="], + + "@voidzero-dev/vite-plus-linux-arm64-musl": ["@voidzero-dev/vite-plus-linux-arm64-musl@0.1.24", "", { "os": "linux", "cpu": "arm64" }, "sha512-gj4mzbob/ls8Zs7iTuF9Gr0EFFF7tdpDiPxDPBkH8tJP5OkHABlzWUwJhU+9xxcUbTaXqpHDw68Mil7jm5dpMg=="], + + "@voidzero-dev/vite-plus-linux-x64-gnu": ["@voidzero-dev/vite-plus-linux-x64-gnu@0.1.24", "", { "os": "linux", "cpu": "x64" }, "sha512-x7IYK7lI+WuF1n3jSzEYU6FgJxPX/R0rDmTTsOutooGGCU7uShZvfZqIoiTXK0eFnJU5ij5BfBgenenUfsaT/A=="], + + "@voidzero-dev/vite-plus-linux-x64-musl": ["@voidzero-dev/vite-plus-linux-x64-musl@0.1.24", "", { "os": "linux", "cpu": "x64" }, "sha512-JCy2w0eSVUlWQlggK5T47MnL+j0o4EY7hLskINVI8gi+aixQF4xnYBDobz0lbxkqz3/IfiLyXUx6TcU3thcsGQ=="], + + "@voidzero-dev/vite-plus-test": ["@voidzero-dev/vite-plus-test@0.1.24", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@voidzero-dev/vite-plus-core": "0.1.24", "es-module-lexer": "^1.7.0", "obug": "^2.1.1", "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", "sirv": "^3.0.2", "std-env": "^4.0.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "ws": "^8.18.3" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"] }, "sha512-9NiG6UadG0iOaPL1AMsO5sDKkx6MADHw4/mMOmHWZUhhUwqzfVtnnptMK37vD71e6KyR7yAscx19FrtOWWtjvA=="], + + "@voidzero-dev/vite-plus-win32-arm64-msvc": ["@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.24", "", { "os": "win32", "cpu": "arm64" }, "sha512-G+/lhLKVjyn3FmgXX8jeWgq7RcE5O1kdR7QyFayQOdlMX/ZRkvUwQD7bFaqhKzgJM6Oj3a1FH3HQPYk5QOYuCQ=="], + + "@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.1.24", "", { "os": "win32", "cpu": "x64" }, "sha512-b0e5XohEV1w/RdzAtv8/Hm6tvHPXouPtBNsljjW/lDJZq3NCLND5s6lqe8H4IenrgmKSoqakHWtlqJqM36cFbw=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vue-macros/common": ["@vue-macros/common@3.1.2", "", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.38", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.38", "@vue/compiler-dom": "3.5.38", "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA=="], + + "@vue/devtools-api": ["@vue/devtools-api@8.1.3", "", { "dependencies": { "@vue/devtools-kit": "^8.1.3" } }, "sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg=="], + + "@vue/devtools-kit": ["@vue/devtools-kit@8.1.3", "", { "dependencies": { "@vue/devtools-shared": "^8.1.3", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA=="], + + "@vue/devtools-shared": ["@vue/devtools-shared@8.1.3", "", {}, "sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA=="], + + "@vue/language-core": ["@vue/language-core@3.3.5", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.38", "", { "dependencies": { "@vue/shared": "3.5.38" } }, "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.38", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.38", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/runtime-core": "3.5.38", "@vue/shared": "3.5.38", "csstype": "^3.2.3" } }, "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.38", "", { "dependencies": { "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "vue": "3.5.38" } }, "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw=="], + + "@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], + + "@vueuse/core": ["@vueuse/core@14.3.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.3.0", "@vueuse/shared": "14.3.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw=="], + + "@vueuse/metadata": ["@vueuse/metadata@14.3.0", "", {}, "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw=="], + + "@vueuse/shared": ["@vueuse/shared@14.3.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="], + + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="], + + "ast-walker-scope": ["ast-walker-scope@0.9.0", "", { "dependencies": { "@babel/parser": "^7.29.2", "@babel/types": "^7.29.0", "ast-kit": "^2.2.0" } }, "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A=="], + + "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], + + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + + "hono-rate-limiter": ["hono-rate-limiter@0.5.3", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" }, "optionalPeers": ["unstorage"] }, "sha512-M0DxbVMpPELEzLi0AJg1XyBHLGJXz7GySjsPoK+gc5YeeBsdGDGe+2RvVuCAv8ydINiwlbxqYMNxUEyYfRji/A=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], + + "lucide-vue-next": ["lucide-vue-next@1.0.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + + "motion-v": ["motion-v@2.3.0", "", { "dependencies": { "framer-motion": "^12.40.0", "hey-listen": "^1.0.8", "motion-dom": "^12.40.0", "motion-utils": "^12.39.0" }, "peerDependencies": { "@vueuse/core": ">=10.0.0", "vue": ">=3.0.0" } }, "sha512-J0CCfXtICCni9RjotDUBOs57xNpYI9yyBSohEOxaRHrmjwOtlw291fhRu/mdgEdSasys96R028YDDOAtWBbRaA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="], + + "oxlint": ["oxlint@1.67.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.67.0", "@oxlint/binding-android-arm64": "1.67.0", "@oxlint/binding-darwin-arm64": "1.67.0", "@oxlint/binding-darwin-x64": "1.67.0", "@oxlint/binding-freebsd-x64": "1.67.0", "@oxlint/binding-linux-arm-gnueabihf": "1.67.0", "@oxlint/binding-linux-arm-musleabihf": "1.67.0", "@oxlint/binding-linux-arm64-gnu": "1.67.0", "@oxlint/binding-linux-arm64-musl": "1.67.0", "@oxlint/binding-linux-ppc64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-musl": "1.67.0", "@oxlint/binding-linux-s390x-gnu": "1.67.0", "@oxlint/binding-linux-x64-gnu": "1.67.0", "@oxlint/binding-linux-x64-musl": "1.67.0", "@oxlint/binding-openharmony-arm64": "1.67.0", "@oxlint/binding-win32-arm64-msvc": "1.67.0", "@oxlint/binding-win32-ia32-msvc": "1.67.0", "@oxlint/binding-win32-x64-msvc": "1.67.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pixelmatch": ["pixelmatch@7.2.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + + "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], + + "unplugin-utils": ["unplugin-utils@0.3.1", "", { "dependencies": { "pathe": "^2.0.3", "picomatch": "^4.0.3" } }, "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + + "vite-plus": ["vite-plus@0.1.24", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@oxlint/plugins": "=1.61.0", "@voidzero-dev/vite-plus-core": "0.1.24", "@voidzero-dev/vite-plus-test": "0.1.24", "oxfmt": "=0.52.0", "oxlint": "=1.67.0", "oxlint-tsgolint": "=0.23.0" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.1.24", "@voidzero-dev/vite-plus-darwin-x64": "0.1.24", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.1.24", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.1.24", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.1.24", "@voidzero-dev/vite-plus-linux-x64-musl": "0.1.24", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.1.24", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.1.24" }, "bin": { "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint", "vp": "bin/vp" } }, "sha512-b3fr6WtCiEhetjuzW/4KcEMOAMuZxoxZATWaXKmPzOLf1upG+pzKJOFZTb94D6wiPBlwcjxoaUtF7C3uAN+VjQ=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + + "vue": ["vue@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/compiler-sfc": "3.5.38", "@vue/runtime-dom": "3.5.38", "@vue/server-renderer": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A=="], + + "vue-router": ["vue-router@5.1.0", "", { "dependencies": { "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.1.2", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.9.0" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34", "pinia": "^3.0.4", "vite": "^7.0.0 || ^8.0.0", "vue": "^3.5.34" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g=="], + + "vue-tsc": ["vue-tsc@3.3.5", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.5" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@babel/generator/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + + "@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "ast-walker-scope/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "ast-walker-scope/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "ast-walker-scope/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c423e12 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "csbi", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], + "type": "module", + "module": "index.ts", + "scripts": { + "analyze:apk": "bun tools/analyze-sbi-apk.ts --jadx workspace/decoded/jadx --apktool workspace/decoded/apktool --out workspace/analysis", + "build": "vp run app:build", + "check": "vp run verify", + "dev": "vp run app:dev", + "db:push": "vp run server:db:push", + "env:check": "vp run env:doctor", + "fmt": "vp check --fix", + "typecheck": "vp run typecheck:all" + }, + "devDependencies": { + "@types/bun": "latest", + "vite-plus": "^0.1.24" + }, + "peerDependencies": { + "typescript": "^5" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=22.12.0" + }, + "packageManager": "bun@1.3.3" +} diff --git a/packages/sbi-client/README.md b/packages/sbi-client/README.md new file mode 100644 index 0000000..663957e --- /dev/null +++ b/packages/sbi-client/README.md @@ -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. diff --git a/packages/sbi-client/package.json b/packages/sbi-client/package.json new file mode 100644 index 0000000..1dd786c --- /dev/null +++ b/packages/sbi-client/package.json @@ -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" + } +} diff --git a/packages/sbi-client/src/index.ts b/packages/sbi-client/src/index.ts new file mode 100644 index 0000000..7ac504a --- /dev/null +++ b/packages/sbi-client/src/index.ts @@ -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' diff --git a/packages/sbi-client/src/methods/error-map.ts b/packages/sbi-client/src/methods/error-map.ts new file mode 100644 index 0000000..6785725 --- /dev/null +++ b/packages/sbi-client/src/methods/error-map.ts @@ -0,0 +1,134 @@ +export const SBI_SERVER_ERROR_MESSAGES: Record = { + 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}.` diff --git a/packages/sbi-client/src/methods/index.ts b/packages/sbi-client/src/methods/index.ts new file mode 100644 index 0000000..708763f --- /dev/null +++ b/packages/sbi-client/src/methods/index.ts @@ -0,0 +1,2870 @@ +import { createHash } from 'node:crypto' +import * as iconv from 'iconv-lite' +import type { + AccountType, + Board, + BoardPriceLevel, + BuyingPower, + CashPosition, + CashPositionList, + ChartPeriod, + ChartPrice, + CurrencyAmount, + DomesticMarket, + IssueChart, + IssueRef, + IssueSearchItem, + IssueSearchResult, + IssueSearchStatus, + MarginPosition, + MarginPositionList, + MarketIndex, + NewsItem, + NewsList, + Order, + OrderList, + OrderPreview, + OrderReceipt, + OrderStatus, + PercentValue, + Quote, + Ranking, + RankingItem, + SbiMethodError, + SbiSession, + SbiTradeAuthenticationRequest, + SignedTextValue, + ThemeInvestmentList, + TradeSide, + Watchlist, +} from '../types' +import type { + AccountPowerOptions, + ActualDeliveryOrderOptions, + BoardOptions, + CashOrderMethod, + CashOrderOptions, + CashOrderPriceCondition, + CashOrderTerm, + CashOrderTriggerZone, + CashPositionOptions, + IfdOrderOptions, + IssueChartOptions, + IssueSearchOptions, + IssueOptions, + MarginCloseOrderOptions, + MarginOpenOrderOptions, + MarketIssueBoardPollingOptions, + MarginPositionOptions, + OrderCancelOptions, + OrderCorrectionOptions, + OrderInquiryOptions, + PlaceCashOrderOptions, + PlaceOrderCancelOptions, + SbiClientMethods, + ThemeInvestmentOrderOptions, +} from './types' +import { SbiServerError } from './error-map' + +const COMM_GATE_PATH = '/mtsmobile/commgate' +const ISSUE_SEARCH_PATH = '/api/jStockSearchGP.jsp' +const ISSUE_SUGGEST_PATH = '/api/jStockSuggestGP.jsp' +const IZANAGI_HASH_SEEDS = [ + '161df8abb44fb3e3ce17', + 'SBISecurities', + '035d6b8afcffcd304180', + '6be0753c74', +] as const +const MTS_HEADER_BYTES = 70 +const DEFAULT_PAGE_INDEX = 0 +const DEFAULT_PAGE_LIMIT = 999 +const DEFAULT_MARKET_POLL_INTERVAL_SECONDS = 5 +const DEFAULT_CHART_COUNT = 120 +const CHART_PERIOD_MTS_CODES = { + minute: '1', + day: '2', + week: '3', + month: '4', +} as const satisfies Record +const CHART_DEFAULT_UNITS = { + minute: 1, + day: 1, + week: 1, + month: 1, +} as const satisfies Record +const CHART_MINUTE_UNITS = new Set([1, 5, 10, 15]) + +type MtsHeader = { + sessionId: string + trCode: string + resultCode: string + notification: string + lastExecutionTime: string + maintenance: string +} + +type MtsResponse = { + status: number + requestUrl: string + header: MtsHeader + buffer: Buffer + text: string +} + +type FixedField = { + width: number + value?: string | number | null + align?: 'left' | 'right' + pad?: string +} + +type OrderPreviewInput = { + issueCode: string + market?: string + side: TradeSide + quantity?: number + price?: number +} + +type CashPreOrderInfo = { + issueCode?: string + market?: string + issueName?: string +} + +const cashCorrectionPreviewInput = { + issueCode: '', + market: '', + side: 'buy', +} satisfies OrderPreviewInput + +type TradeAuthenticationStatus = 'success' | 'needDial' | 'excessivelyRequested' | 'unexpected' + +type TradeAuthenticationInfo = { + status: TradeAuthenticationStatus + statusCode: string + telNo?: string + phoneNo?: string + faxNo?: string + sbiCallNo?: string + authLimitTime?: string +} + +type TradeAuthenticationConfirmStatus = + | 'success' + | 'authNotComplete' + | 'expired' + | 'excessivelyRequested' + | 'unexpected' + +const abortError = (signal: AbortSignal) => { + const reason = signal.reason + if (reason instanceof Error) return reason + return new Error(typeof reason === 'string' ? reason : 'market issue board polling aborted') +} + +const waitForPollingInterval = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError(signal)) + return + } + + let timeout: ReturnType | undefined + const cleanup = () => { + if (timeout) clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + } + const onAbort = () => { + cleanup() + reject(signal ? abortError(signal) : new Error('market issue board polling aborted')) + } + + timeout = setTimeout(() => { + cleanup() + resolve() + }, ms) + signal?.addEventListener('abort', onAbort, { once: true }) + }) + +const pollingIntervalMs = (intervalSeconds = DEFAULT_MARKET_POLL_INTERVAL_SECONDS) => { + if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) { + throw new Error('intervalSeconds must be a positive finite number') + } + return intervalSeconds * 1000 +} + +async function* pollMarketIssueBoard( + fetchBoard: () => Promise, + options: MarketIssueBoardPollingOptions, +): AsyncIterableIterator { + const intervalMs = pollingIntervalMs(options.intervalSeconds) + + while (!options.signal?.aborted) { + yield await fetchBoard() + await waitForPollingInterval(intervalMs, options.signal) + } + + if (options.signal?.aborted) throw abortError(options.signal) +} + +const debugMts = (label: string, value: unknown) => { + if (process.env.SBI_CLIENT_DEBUG_MTS !== '1') return + console.error(`[sbi-client:mts] ${label}`, JSON.stringify(value, null, 2)) +} + +type IzanagiIssueSearchItem = { + stockName?: string | null + stockCode?: string | null + mkt?: string | null + extract?: string | null + extractWord?: string | null + boldFrom?: string | null + boldTo?: string | null + hitString?: string | null +} + +type IzanagiIssueSearchResponse = { + status?: string | null + stocks?: IzanagiIssueSearchItem[] | null +} + +export const registerDeviceId = async (session: SbiSession, deviceId: string) => { + await callMts(session, 'F1131', fixedTrin([{ width: 36, value: deviceId }])) +} + +export const createMethodsFromSession = (session: SbiSession): SbiClientMethods => { + const client: SbiClientMethods = { + session: { + profile: async () => session.profile, + }, + account: { + profile: async () => session.profile, + power: { + buyingPower: async (options) => fetchAccountPower(session, options), + collateralRatio: async (options) => + fetchAccountPower(session, { includeMarginAccount: true, ...options }), + }, + positions: { + cash: async (options) => + parseCashPositions( + await callMts(session, 'F2631', listAccountTrin(session, options)), + options, + ), + cashDetail: async (options) => + parseCashPositions( + await callMts(session, 'F2632', listAccountTrin(session, options)), + options, + ), + cashForIssue: async (options) => { + const list = parseCashPositions( + await callMts(session, 'F2602', issuePositionTrin(session, options)), + options, + ) + return filterCashPositions(list, options) + }, + margin: async (options) => + parseMarginPositions( + await callMts( + session, + 'F2633', + listAccountTrin(session, options, sideCode(options?.side)), + ), + options, + ), + marginDetail: async (options) => + parseMarginPositions( + await callMts( + session, + 'F2634', + listAccountTrin(session, options, sideCode(options?.side)), + ), + options, + ), + marginForIssue: async (options) => { + const list = parseMarginPositions( + await callMts(session, 'F2606', issuePositionTrin(session, options)), + options, + ) + return filterMarginPositions(list, options) + }, + marginSummaryForIssue: async (options) => { + const list = parseMarginPositions( + await callMts(session, 'F2615', issuePositionTrin(session, options)), + options, + ) + return filterMarginPositions(list, options) + }, + marginDetailsForIssue: async (options) => { + const list = parseMarginPositions( + await callMts(session, 'F1752', issuePositionTrin(session, options)), + options, + ) + return filterMarginPositions(list, options) + }, + closeableMargin: async (options) => + parseMarginPositions( + await callMts(session, 'F2698', marginCloseListTrin(session, options)), + options, + ), + deliverableMargin: async (options) => + parseMarginPositions( + await callMts(session, 'F2608', marginCloseListTrin(session, options)), + options, + ), + }, + profitLoss: { + unrealized: async () => { + const cash = await client.account.positions.cash() + const margin = await client.account.positions.margin() + return { + cash: cash.totalProfitLoss, + margin: margin.totalProfitLoss, + total: addSignedTextValues(cash.totalProfitLoss, margin.totalProfitLoss), + } + }, + }, + }, + market: { + issue: { + search: async (options) => + callIssueSearch(session, ISSUE_SEARCH_PATH, 'inputWord', options), + suggest: async (options) => callIssueSearch(session, ISSUE_SUGGEST_PATH, 'term', options), + allowedPrices: async (options) => + parseAllowedPrices(await callMts(session, 'F1112', issueTrin(options)), options), + board: async (options) => + parseBoardLike(await callMts(session, 'F1207', issueTrin(options)), options), + pollBoard: (options) => + pollMarketIssueBoard( + async () => + parseBoardLike(await callMts(session, 'F1207', issueTrin(options)), options), + options, + ), + chart: async (options) => + parseIssueChart( + await callMtsReturningHeaderError(session, 'F1851', issueChartTrin(options)), + options, + ), + openOrders: async (options) => + parseOrdersLoose( + await callMtsReturningHeaderError(session, 'F2504', openOrdersTrin(session, options)), + options, + ), + tradingInfo: async (options) => + parseBoardLike( + await callMts(session, tradingInfoTrCode(options), tradingInfoTrin(session, options)), + options, + ), + }, + index: { + major: async () => parseMajorIndexes(await callMts(session, 'F1414')), + }, + overview: async () => parseMarketOverview(await callMts(session, 'F1412')), + ranking: { + market: async () => + parseRanking(await callMts(session, 'F1502', marketRankingTrin()), 'market'), + sector: async () => parseRanking(await callMts(session, 'F1405'), 'sector'), + sbi: async () => + parseSbiRanking( + await callMts( + session, + 'F1406', + fixedTrin([ + { width: 1, value: '1' }, + { width: 1, value: '1' }, + ]), + ), + ), + }, + }, + news: { + list: async () => parseNews(await callMts(session, 'F1418', newsListTrin())), + }, + watchlist: { + list: async () => parseWatchlists(await callMts(session, 'F1202', watchlistTrin())), + }, + orders: { + inquiry: { + executionsToday: async (options) => + parseOrdersLoose( + await callMtsReturningHeaderError(session, 'F2503', orderInquiryTrin(session, options)), + options, + ), + open: async (options) => + parseOrdersLoose( + await callMtsReturningHeaderError(session, 'F2511', recentOrdersTrin(session, options)), + options, + ), + }, + cash: { + estimate: async (options) => { + assertCashOrderOptions(options) + await prepareCashOrder(session, options) + return parseOrderPreview( + await callMtsReturningHeaderError( + session, + cashConfirmTrCode(options), + cashOrderTrin(session, options), + ), + options, + ) + }, + place: async (options) => { + assertTradingAllowed(options, 'orders.cash.place') + assertCashOrderOptions(options) + await prepareCashOrder(session, options) + return parseOrderReceipt( + await callMtsReturningHeaderError( + session, + cashReceptionTrCode(options), + cashOrderTrin(session, options), + ), + ) + }, + estimateCorrection: async (options) => + parseOrderPreview( + await callMts(session, 'F2301', orderCorrectionTrin(session, options)), + cashCorrectionPreviewInput, + ), + estimateCorrectionConfirm: async (options) => + parseOrderPreview( + await callMts(session, 'F2302', orderCorrectionTrin(session, options)), + cashCorrectionPreviewInput, + ), + placeCorrection: async (options) => { + assertTradingAllowed(options, 'orders.cash.placeCorrection') + return parseOrderReceipt( + await callMts(session, 'F2302', orderCorrectionTrin(session, options)), + ) + }, + estimateCancel: async (options) => + parseOrderPreview( + await callMts(session, 'F2311', orderCancelPreOrderTrin(session, options)), + cashCorrectionPreviewInput, + ), + placeCancel: async (options) => { + assertTradingAllowed(options, 'orders.cash.placeCancel') + return parseOrderReceipt( + await callMts(session, 'F2302', orderCancelSubmitTrin(session, options)), + ) + }, + }, + margin: { + estimateOpen: async (options) => + parseOrderPreview( + await callMtsReturningHeaderError( + session, + marginOpenConfirmTrCode(options), + marginOpenOrderTrin(session, options), + ), + options, + ), + open: async (options) => { + assertTradingAllowed(options, 'orders.margin.open') + return parseOrderReceipt( + await callMtsReturningHeaderError( + session, + marginOpenReceptionTrCode(options), + marginOpenOrderTrin(session, options), + ), + ) + }, + estimateClose: async (options) => + parseOrderPreview( + await callMts( + session, + marginCloseConfirmTrCode(options), + orderConfirmTrin(session, options), + ), + options, + ), + close: async (options) => { + assertTradingAllowed(options, 'orders.margin.close') + return parseOrderReceipt( + await callMts( + session, + marginCloseReceptionTrCode(options), + orderConfirmTrin(session, options), + ), + ) + }, + estimateCloseSummary: async (options) => + parseOrderPreview( + await callMts( + session, + marginCloseConfirmTrCode(options), + orderConfirmTrin(session, options), + ), + options, + ), + closeSummary: async (options) => { + assertTradingAllowed(options, 'orders.margin.closeSummary') + return parseOrderReceipt( + await callMts( + session, + marginCloseReceptionTrCode(options), + orderConfirmTrin(session, options), + ), + ) + }, + estimateSummary: async (options) => + parseOrderPreview( + await callMts( + session, + marginCloseSummaryConfirmTrCode(options), + marginSummaryOrderTrin(session, options), + ), + options, + ), + placeSummary: async (options) => { + assertTradingAllowed(options, 'orders.margin.placeSummary') + return parseOrderReceipt( + await callMts( + session, + marginCloseSummaryReceptionTrCode(options), + marginSummaryOrderTrin(session, options), + ), + ) + }, + estimateActualDelivery: async (options) => + parseOrderPreview( + await callMts( + session, + actualDeliveryConfirmTrCode(options), + actualDeliveryOrderTrin(session, options), + ), + { ...options, side: actualDeliverySide(options) }, + ), + actualDelivery: async (options) => { + assertTradingAllowed(options, 'orders.margin.actualDelivery') + return parseOrderReceipt( + await callMts( + session, + actualDeliveryReceptionTrCode(options), + actualDeliveryOrderTrin(session, options), + ), + ) + }, + }, + ifd: { + estimate: async (options) => + parseOrderPreview( + await callMts(session, ifdConfirmTrCode(options), ifdOrderTrin(session, options)), + options, + ), + place: async (options) => { + assertTradingAllowed(options, 'orders.ifd.place') + return parseOrderReceipt( + await callMts(session, ifdReceptionTrCode(options), ifdOrderTrin(session, options)), + ) + }, + estimateCorrection: async (options) => + parseOrderPreview( + await callMts(session, 'F2331', orderCorrectionTrin(session, options)), + cashCorrectionPreviewInput, + ), + placeCorrection: async (options) => { + assertTradingAllowed(options, 'orders.ifd.placeCorrection') + return parseOrderReceipt( + await callMts(session, 'F2332', orderCorrectionTrin(session, options)), + ) + }, + estimateCancel: async (options) => + parseOrderPreview( + await callMts(session, 'F2331', orderCorrectionTrin(session, options)), + cashCorrectionPreviewInput, + ), + placeCancel: async (options) => { + assertTradingAllowed(options, 'orders.ifd.placeCancel') + return parseOrderReceipt( + await callMts(session, 'F2332', orderCorrectionTrin(session, options)), + ) + }, + }, + themeInvestment: { + list: async () => parseThemeInvestmentList(await callMts(session, 'F1750')), + estimate: async (options) => + parseThemeOrderPreview( + await callMts(session, 'F1904', themeOrderTrin(session, options)), + options, + ), + place: async (options) => { + assertTradingAllowed(options, 'orders.themeInvestment.place') + return parseOrderReceipt( + await callMts(session, 'F1905', themeOrderTrin(session, options)), + ) + }, + }, + }, + } + return client +} + +const callMts = async (session: SbiSession, trCode: string, trin = ''): Promise => { + return callMtsInternal(session, trCode, trin, true) +} + +const callMtsReturningHeaderError = async (session: SbiSession, trCode: string, trin = '') => + callMtsInternal(session, trCode, trin, false) + +const callIssueSearch = async ( + session: SbiSession, + path: string, + queryParam: 'inputWord' | 'term', + options: IssueSearchOptions, +): Promise => { + const query = options.query.trim() + if (!query) throw new Error('issue search query is required') + + if (!session.izanagiBaseUrl) throw new Error('SBI_IZANAGI_BASE_URL is required') + + const requestUrl = new URL(path, session.izanagiBaseUrl) + for (const [key, value] of Object.entries(izanagiCommonParams(session))) { + requestUrl.searchParams.set(key, value) + } + requestUrl.searchParams.set(queryParam, query) + + const response = await fetch(requestUrl, { + method: 'GET', + headers: { + accept: 'application/json', + 'user-agent': 'okhttp/4.12.0', + }, + }) + const text = await response.text() + if (!response.ok) { + throw new Error(`issue search request failed: ${response.status}`) + } + + const json = JSON.parse(text) as IzanagiIssueSearchResponse + return parseIssueSearchResponse(json, options) +} + +const izanagiCommonParams = (session: SbiSession): Record => { + const butenCode = session.profile.butenCode ?? '' + const accountNumber = session.profile.accountNumber ?? '' + const id = sha256Hex(`${butenCode}-${accountNumber}`) + return { + id, + hash: sha256Hex(id + IZANAGI_HASH_SEEDS.join('')), + channel: '7', + charset: 'U', + } +} + +const sha256Hex = (value: string) => createHash('sha256').update(value).digest('hex') + +const parseIssueSearchResponse = ( + response: IzanagiIssueSearchResponse, + options: IssueSearchOptions, +): IssueSearchResult => { + const market = options.market + const limit = options.limit + let issues = (response.stocks ?? []) + .map(toIssueSearchItem) + .filter((issue): issue is IssueSearchItem => Boolean(issue)) + + if (market) issues = issues.filter((issue) => issue.market === market) + if (limit !== undefined) issues = issues.slice(0, limit) + + return { + status: emptyJsonString(response.status), + statusText: issueSearchStatus(response.status), + issues, + } +} + +const toIssueSearchItem = (item: IzanagiIssueSearchItem): IssueSearchItem | undefined => { + const code = emptyJsonString(item.stockCode) + if (!code) return undefined + + return { + code, + market: emptyJsonString(item.mkt), + name: emptyJsonString(item.stockName), + extract: emptyJsonString(item.extract), + extractWord: emptyJsonString(item.extractWord), + boldFrom: emptyJsonString(item.boldFrom), + boldTo: emptyJsonString(item.boldTo), + hitString: emptyJsonString(item.hitString), + } +} + +const emptyJsonString = (value?: string | null) => emptyToUndefined(value ?? undefined) + +const issueSearchStatus = (status?: string | null): IssueSearchStatus => { + if (status === '0') return 'success' + if (status === '2') return 'searchError' + if (status === '4') return 'tooManyResults' + return 'unknown' +} + +const fetchAccountPower = async ( + session: SbiSession, + options?: AccountPowerOptions, +): Promise => { + if (options?.includeMarginAccount) { + return parseCollateralRatio( + await callMtsReturningHeaderError(session, 'F2611', accountTrin(session)), + ) + } + return parseAccountPower( + await callMtsReturningHeaderError(session, 'F2609', accountPowerTrin(session)), + ) +} + +const callMtsInternal = async ( + session: SbiSession, + trCode: string, + trin: string, + throwOnHeaderError: boolean, +): Promise => { + const requestUrl = new URL(COMM_GATE_PATH, session.mtsBaseUrl) + const response = await fetch(requestUrl, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'user-agent': 'okhttp/4.12.0', + }, + body: encodeMtsForm({ + SID: session.profile.session.sessionId, + TRCODE: trCode, + FSTIME: ' ', + TRIN: trin, + }), + }) + const body = await response.arrayBuffer() + const buffer = Buffer.from(body) + const text = decodeShiftJis(buffer) + const header = parseMtsHeader(buffer) + if (header.sessionId) session.profile.session.sessionId = header.sessionId + if (header.trCode && header.trCode !== trCode) { + throw new Error(`unexpected MTS TRCODE: expected ${trCode}, got ${header.trCode}`) + } + if (throwOnHeaderError) throwIfMtsHeaderError(header, requestUrl.toString()) + return { + status: response.status, + requestUrl: requestUrl.toString(), + header, + buffer, + text, + } +} + +const parseMtsHeader = (buffer: Buffer): MtsHeader => { + let offset = 6 + const sessionId = readShiftJisField(buffer, offset, 28) + offset += 28 + const trCode = readShiftJisField(buffer, offset, 5) + offset += 5 + offset += 6 + const resultCode = readShiftJisField(buffer, offset, 6) + offset += 6 + const notification = readShiftJisField(buffer, offset, 1) + offset += 1 + const lastExecutionTime = readShiftJisField(buffer, offset, 9) + offset += 9 + const maintenance = readShiftJisField(buffer, offset, 5) + return { sessionId, trCode, resultCode, notification, lastExecutionTime, maintenance } +} + +const parseCollateralRatio = (response: MtsResponse): BuyingPower => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { records: [], error: headerError } + const reader = readerFor(response) + reader.skip(18) + reader.skip(1) + const noticeMessage = reader.text(300) + const recordCount = reader.int(2) ?? 0 + const records = Array.from({ length: recordCount }, () => ({ + marginRequirements: yen(reader.text(9)), + referenceMarginRequirements: (reader.skip(1), yen(reader.text(9))), + collateralRatioCash: (reader.skip(1), yen(reader.text(17))), + substituteSecuritiesValuationAmount: yen(reader.text(17)), + unsettledPositionLoss: signed(reader.text(17), reader.text(1)), + unsettledPositionLossFlag: lastReadFlag(reader), + settlementLoss: signed(reader.text(17), reader.text(1)), + settlementLossFlag: lastReadFlag(reader), + paymentExpenses: signed(reader.text(17), reader.text(1)), + paymentExpensesFlag: lastReadFlag(reader), + actualCollateral: yen(reader.text(17)), + positionAmount: yen(reader.text(17)), + sbiHybridDepositBalance: yen(reader.text(17)), + minimumCollateral: (reader.skip(1), yen(reader.text(17))), + })) + const error = parseTrailingError(reader, response) + const first = records[0] + return { + noticeMessage: emptyToUndefined(noticeMessage), + records, + cashBuyingPower: first?.collateralRatioCash, + marginBuyingPower: first?.actualCollateral, + collateralValue: first?.substituteSecuritiesValuationAmount, + collateralRatio: first?.collateralRatioCash + ? percent(first.collateralRatioCash.text) + : undefined, + sbiHybridDepositBalance: first?.sbiHybridDepositBalance, + error, + } +} + +const parseAccountPower = (response: MtsResponse): BuyingPower => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { records: [], error: headerError } + const reader = readerFor(response) + reader.skip(12) + const marginCallFlag = reader.text(1) + const marginCallMessage = reader.text(1500) + const cashBuyingPower = yen(reader.text(17)) + const marginBuyingPower = yen(reader.text(17)) + const genbikiAvailableAmount = yen(reader.text(17)) + reader.skip(17) + const collateralValue = yen(reader.text(17)) + reader.skip(17) + const actualCollateral = yen(reader.text(17)) + const positionAmount = yen(reader.text(17)) + const marginRequirements = percent(reader.text(9)) + const sbiHybridDepositBalance = yen(reader.text(17)) + reader.skip(9) + reader.skip(19) + reader.skip(7) + reader.skip(7) + reader.skip(7) + reader.skip(7) + reader.skip(7) + const noticeMessage = reader.text(110) + reader.skip(1) + reader.skip(1) + reader.skip(12) + reader.skip(6) + reader.skip(1) + reader.skip(12) + reader.skip(6) + reader.skip(1) + reader.skip(17) + reader.skip(1) + reader.skip(12) + reader.skip(6) + reader.skip(12) + reader.skip(6) + const error = parseTrailingError(reader, response) + return { + cashBuyingPower, + marginBuyingPower, + withdrawableAmount: genbikiAvailableAmount, + collateralValue, + collateralRatio: marginRequirements, + sbiHybridDepositBalance, + noticeMessage: emptyToUndefined(noticeMessage), + records: [ + { substituteSecuritiesValuationAmount: collateralValue, actualCollateral, positionAmount }, + ], + error: + error ?? (marginCallFlag ? { code: marginCallFlag, message: marginCallMessage } : undefined), + } +} + +const parseCashPositions = ( + response: MtsResponse, + options?: CashPositionOptions, +): CashPositionList => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { positions: [], error: headerError } + const reader = readerFor(response) + reader.skip(12) + reader.skip(12) + const index = reader.int(3) + const totalCount = reader.int(3) + const recordCount = reader.int(4) ?? 0 + const positions: CashPosition[] = [] + for (let i = 0; i < recordCount; i++) { + const code = reader.text(5) + const market = reader.text(3) + const issueName = reader.text(40) + const depositTypeCode = reader.text(1) + const depositTypeText = reader.text(8) + const quantityText = reader.text(16) + const unactualyQuantity = reader.text(18) + const profitLossText = reader.text(16) + const profitLossRateText = reader.text(11) + const profitLossFlag = reader.text(1) + const priceText = reader.text(11) + reader.skip(11) + const presentValueFlag = reader.text(1) + reader.skip(30) + reader.skip(1) + reader.skip(2) + reader.skip(5) + reader.skip(25) + reader.skip(11) + reader.skip(8) + reader.skip(8) + reader.skip(1) + reader.skip(9) + reader.skip(21) + reader.skip(36) + const purchasePriceText = reader.text(16) + const valuationPriceText = reader.text(15) + reader.skip(30) + const valuationPriceChangeText = reader.text(30) + const valuationPriceChangeFlag = reader.text(1) + reader.skip(1) + reader.skip(1) + const holdingCategory = reader.text(4) + reader.skip(6) + const accountInformation = reader.text(20) + + const marketValue = yen(valuationPriceText) + const position: CashPosition = { + issue: { code, market: emptyToUndefined(market), name: extractIssueName(issueName) }, + accountType: mapAccountType(depositTypeCode), + depositType: mapDepositType(depositTypeCode), + depositTypeCode: emptyToUndefined(depositTypeCode), + depositTypeText: emptyToUndefined(depositTypeText), + quantity: parseNumber(quantityText), + availableQuantity: subtractNullable( + parseNumber(quantityText), + parseNumberFromParentheses(unactualyQuantity), + ), + unexecutedOrderQuantity: parseNumberFromParentheses(unactualyQuantity), + averagePrice: yen(purchasePriceText), + purchasePrice: yen(purchasePriceText), + currentPrice: yen(priceText), + priceText: emptyToUndefined(priceText), + marketValue, + presentValueFlag: emptyToUndefined(presentValueFlag), + valuationPrice: yen(valuationPriceText), + valuationPriceChange: signed( + beforeParentheses(valuationPriceChangeText), + valuationPriceChangeFlag, + ), + valuationPriceChangeRate: percent(inParentheses(valuationPriceChangeText) ?? ''), + valuationPriceChangeFlag: emptyToUndefined(valuationPriceChangeFlag), + profitLoss: signed(profitLossText, profitLossFlag), + profitLossRate: percent(profitLossRateText), + profitLossFlag: emptyToUndefined(profitLossFlag), + holdingCategory: emptyToUndefined(holdingCategory), + accountInformation: emptyToUndefined(accountInformation), + } + positions.push(position) + } + const totalProfitLossText = reader.text(17) + const totalProfitLossRateText = reader.text(11) + const totalProfitLossFlag = reader.text(1) + const error = parseTrailingError(reader, response) + const filtered = filterCashPositions( + { + positions, + index: index ?? undefined, + totalCount: totalCount ?? undefined, + totalMarketValue: sumCurrencyAmounts(positions.map((position) => position.marketValue)), + totalProfitLoss: signed(totalProfitLossText, totalProfitLossFlag), + totalProfitLossRate: percent(totalProfitLossRateText), + totalProfitLossFlag: emptyToUndefined(totalProfitLossFlag), + hasMore: + totalCount != null && index != null ? index + positions.length < totalCount : undefined, + error, + }, + options, + ) + return filtered +} + +const parseMarginPositions = ( + response: MtsResponse, + options?: MarginPositionOptions, +): MarginPositionList => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { positions: [], error: headerError } + const reader = readerFor(response) + reader.skip(12) + reader.skip(12) + const index = reader.int(3) + const totalCount = reader.int(3) + const recordCount = reader.int(4) ?? 0 + const positions: MarginPosition[] = [] + for (let i = 0; i < recordCount; i++) { + const code = reader.text(5) + const market = reader.text(3) + const issueName = reader.text(40) + const tradeKind = reader.text(1) + const sideText = reader.text(4) + const quantityText = reader.text(16) + const unactualyQuantity = reader.text(18) + const profitLossText = reader.text(16) + const profitLossRateText = reader.text(11) + const profitLossFlag = reader.text(1) + const rateText = reader.text(12) + const presentValueText = reader.text(11) + const presentValueFlag = reader.text(1) + reader.skip(30) + reader.skip(1) + reader.skip(2) + reader.skip(5) + reader.skip(25) + reader.skip(11) + reader.skip(8) + reader.skip(8) + const dueDateCode = reader.text(1) + const depositTypeText = reader.text(9) + const dueDateText = reader.text(21) + reader.skip(36) + const openAmountText = reader.text(15) + const valuationPriceText = reader.text(15) + reader.skip(30) + const valuationPriceChangeText = reader.text(30) + const valuationPriceChangeFlag = reader.text(1) + const costText = reader.text(15) + const commissionText = reader.text(15) + const managementFeeText = reader.text(15) + const nameTransferFeeText = reader.text(15) + const interestText = reader.text(11) + const backwardationText = reader.text(11) + const collateralRatioText = reader.text(15) + const maybeBargainMarketCode = reader.remaining >= 3 ? reader.text(3) : '' + const maybeBargainMarket = reader.remaining >= 10 ? reader.text(10) : '' + positions.push({ + id: `${code}:${market}:${i}`, + issue: { code, market: emptyToUndefined(market), name: extractIssueName(issueName) }, + side: mapSide(tradeKind), + sideText: emptyToUndefined(sideText), + tradeKind: emptyToUndefined(tradeKind), + quantity: parseNumber(quantityText), + availableCloseQuantity: subtractNullable( + parseNumber(quantityText), + parseNumberFromParentheses(unactualyQuantity), + ), + unexecutedOrderQuantity: parseNumberFromParentheses(unactualyQuantity), + openPrice: yen(openAmountText), + openAmount: yen(openAmountText), + currentPrice: yen(rateText), + rate: yen(rateText), + marketValue: yen(presentValueText), + presentValueFlag: emptyToUndefined(presentValueFlag), + valuationPrice: yen(valuationPriceText), + valuationPriceChange: signed( + beforeParentheses(valuationPriceChangeText), + valuationPriceChangeFlag, + ), + valuationPriceChangeRate: percent(inParentheses(valuationPriceChangeText) ?? ''), + valuationPriceChangeFlag: emptyToUndefined(valuationPriceChangeFlag), + profitLoss: signed(profitLossText, profitLossFlag), + profitLossRate: percent(profitLossRateText), + profitLossFlag: emptyToUndefined(profitLossFlag), + dueDateCode: emptyToUndefined(dueDateCode), + dueDateText: emptyToUndefined(dueDateText), + depositTypeText: emptyToUndefined(depositTypeText), + cost: yen(costText), + commission: yen(commissionText), + managementFee: yen(managementFeeText), + nameTransferFee: yen(nameTransferFeeText), + interest: yen(interestText), + backwardation: yen(backwardationText), + collateralRatio: percent(collateralRatioText), + bargainMarketCode: emptyToUndefined(maybeBargainMarketCode), + bargainMarket: emptyToUndefined(maybeBargainMarket), + }) + } + const totalProfitLossText = reader.text(16) + const totalProfitLossRateText = reader.text(11) + const totalProfitLossFlag = reader.text(1) + const error = parseTrailingError(reader, response) + return filterMarginPositions( + { + positions, + index: index ?? undefined, + totalCount: totalCount ?? undefined, + totalProfitLoss: signed(totalProfitLossText, totalProfitLossFlag), + totalProfitLossRate: percent(totalProfitLossRateText), + totalProfitLossFlag: emptyToUndefined(totalProfitLossFlag), + hasMore: + totalCount != null && index != null ? index + positions.length < totalCount : undefined, + error, + }, + options, + ) +} + +const parseAllowedPrices = (response: MtsResponse, options: IssueOptions): Quote => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { issue: { code: options.issueCode, market: options.market }, error: headerError } + const reader = readerFor(response) + const high = reader.text(11) + const low = reader.text(11) + reader.skip(1) + const count = reader.int(10) ?? 0 + const nominalPrices = Array.from({ length: count }, () => yen(reader.text(11))) + return { + issue: { code: options.issueCode, market: options.market }, + high: yen(high), + low: yen(low), + nominalPrices, + } +} + +const parseMajorIndexes = (response: MtsResponse): MarketIndex[] => { + if (methodErrorFromHeader(response) || response.buffer.length <= MTS_HEADER_BYTES) return [] + const reader = readerFor(response) + reader.skip(9) + reader.skip(6) + reader.skip(6) + const count = reader.int(4) ?? 0 + return Array.from({ length: count }, () => { + reader.skip(1) + const categoryCode = reader.text(2) + const code = reader.text(4) + const name = reader.text(40) + const timestamp = reader.text(11) + const valueText = reader.text(23) + reader.skip(2) + const changeText = reader.text(12) + const changeRateText = reader.text(11) + const colorFlag = reader.text(1) + reader.skip(1) + const open = firstColonValue(reader.text(16)) + const high = reader.text(16) + const low = reader.text(16) + const previousClose = reader.text(16) + return { + code: emptyToUndefined(code), + categoryCode: emptyToUndefined(categoryCode), + name, + value: parseNumber(valueText), + valueText, + change: signed(changeText, colorFlag), + changeRate: percent(changeRateText), + colorFlag: emptyToUndefined(colorFlag), + timestamp: emptyToUndefined(timestamp), + open: yen(open), + high: yen(high), + low: yen(low), + previousClose: yen(previousClose), + } + }) +} + +const parseMarketOverview = (response: MtsResponse): DomesticMarket => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { indexes: [], error: headerError } + const reader = readerFor(response) + reader.skip(4) + reader.skip(6) + reader.skip(8) + const count = reader.int(4) ?? 0 + const indexes: MarketIndex[] = [] + for (let i = 0; i < count; i++) { + const code = reader.text(2) + const name = reader.text(10) + const timestamp = reader.text(11) + const value1 = reader.text(11) + reader.skip(6) + const value2 = reader.text(11) + reader.skip(6) + reader.skip(8) + const previousClose = reader.text(35) + reader.skip(37) + reader.skip(26) + const change = reader.text(38) + const colorFlag = reader.text(1) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(11) + indexes.push({ + code: emptyToUndefined(code), + name, + value: parseNumber(value1) ?? parseNumber(value2), + valueText: emptyToUndefined(value1) ?? emptyToUndefined(value2), + change: signed(change, colorFlag), + colorFlag: emptyToUndefined(colorFlag), + timestamp: emptyToUndefined(timestamp), + previousClose: yen(previousClose), + }) + } + return { indexes } +} + +const parseRanking = (response: MtsResponse, category: string): Ranking => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { category, items: [], error: headerError } + const reader = readerFor(response) + const count = safeCount(reader, 4) + const items: RankingItem[] = [] + for (let i = 0; i < count && reader.remaining >= 20; i++) { + const rankText = reader.text(3) + const code = reader.text(5) + const market = reader.text(3) + const name = reader.text(20) + const exchangeName = reader.remaining >= 9 ? reader.text(9) : '' + const values = Array.from({ length: 6 }, () => (reader.remaining >= 15 ? reader.text(15) : '')) + const colorFlag = reader.remaining >= 1 ? reader.text(1) : '' + items.push({ + rank: parseNumber(rankText) ?? i + 1, + issue: { code, market: emptyToUndefined(market), name: emptyToUndefined(name) }, + value: parseNumber(values[0] ?? '') ?? emptyToUndefined(values[0] ?? ''), + values: values.map((value) => parseNumber(value) ?? emptyToUndefined(value) ?? null), + exchangeName: emptyToUndefined(exchangeName), + colorFlag: emptyToUndefined(colorFlag), + }) + } + return { category, items } +} + +const parseSbiRanking = (response: MtsResponse): Ranking => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { category: 'sbi', items: [], error: headerError } + const reader = readerFor(response) + reader.skip(14) + reader.skip(4) + reader.skip(8) + const count = reader.int(4) ?? 0 + const items: RankingItem[] = [] + for (let i = 0; i < count; i++) { + const rankText = reader.text(3) + const name = reader.text(20) + const exchangeName = reader.text(9) + const valueText = reader.text(15) + const code = reader.text(5) + const market = reader.text(3) + items.push({ + rank: parseNumber(rankText) ?? i + 1, + issue: { code, market: emptyToUndefined(market), name: emptyToUndefined(name) }, + value: parseNumber(valueText) ?? emptyToUndefined(valueText), + exchangeName: emptyToUndefined(exchangeName), + }) + } + return { category: 'sbi', items } +} + +const parseNews = (response: MtsResponse): NewsList => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { items: [], error: headerError } + const reader = readerFor(response) + const count = reader.int(4) ?? 0 + const items: NewsItem[] = [] + for (let i = 0; i < count && reader.remaining >= 134; i++) { + const pnac = reader.text(20) + const storyDate = reader.text(8) + const storyTime = reader.text(6) + const processedDate = reader.text(5) + const takeTime = reader.text(5) + const title = reader.text(60) + const seq = reader.text(30) + const pnacId = emptyToUndefined(pnac) + items.push({ + id: [pnacId, storyDate, storyTime].filter(Boolean).join(':') || emptyToUndefined(seq), + title: title || `${storyDate} ${storyTime}`, + publishedAt: joinDateTime(storyDate, storyTime), + storyDate: emptyToUndefined(storyDate), + storyTime: emptyToUndefined(storyTime), + processedDate: emptyToUndefined(processedDate), + takeTime: emptyToUndefined(takeTime), + pnac: pnacId, + source: pnacId, + }) + } + return { items } +} + +const parseWatchlists = (_response: MtsResponse): Watchlist[] => [] + +const parseBoardLike = (response: MtsResponse, options: IssueOptions): Board => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) { + return { + issue: { code: options.issueCode, market: options.market }, + bids: [], + asks: [], + error: headerError, + } + } + const { quote, bids, asks } = parseBoardResponse(response, options) + return { + issue: quote.issue, + bids, + asks, + quote, + } +} + +const parseIssueChart = (response: MtsResponse, options: IssueChartOptions): IssueChart => { + const { period, unit } = normalizedIssueChartOptions(options) + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) { + return { + issue: { code: options.issueCode, market: options.market }, + period, + unit, + prices: [], + error: headerError, + } + } + + const reader = readerFor(response) + const code = reader.text(5) + const name = reader.text(20) + const market = reader.text(3) + reader.skip(30) + reader.skip(30) + reader.skip(1) + reader.skip(4) + reader.skip(4) + reader.skip(1) + const previousClose = yen(reader.text(11)) + const currentPrice = yen(reader.text(11)) + reader.skip(1) + reader.skip(2) + reader.skip(1) + reader.skip(5) + reader.skip(25) + const highPrice = yen(reader.text(11)) + const lowPrice = yen(reader.text(11)) + const recordCount = reader.int(4) ?? 0 + const prices: ChartPrice[] = [] + + for (let i = 0; i < recordCount && reader.remaining >= 71; i++) { + prices.push({ + dateTime: reader.text(12), + open: chartYen(reader.text(11)), + high: chartYen(reader.text(11)), + low: chartYen(reader.text(11)), + close: chartYen(reader.text(11)), + volume: parseNumber(reader.text(15)), + }) + } + + const latestDateTime = emptyToUndefined(prices[0]?.dateTime) + const technicalCount = reader.remaining >= 4 ? (reader.int(4) ?? 0) : 0 + reader.skip(Math.min(reader.remaining, technicalCount * 11)) + const validCount = reader.remaining >= 4 ? reader.int(4) : null + const validPrices = + validCount != null && validCount > 0 && validCount < prices.length + ? prices.slice(prices.length - validCount) + : prices + + return { + issue: { + code: emptyToUndefined(code) ?? options.issueCode, + market: emptyToUndefined(market) ?? options.market, + name: emptyToUndefined(name), + }, + period, + unit, + prices: validPrices.filter((price) => price.dateTime && price.close.value != null).reverse(), + previousClose, + currentPrice, + highPrice, + lowPrice, + latestDateTime, + } +} + +const parseBoardResponse = ( + response: MtsResponse, + options: IssueOptions, +): { quote: Quote; bids: BoardPriceLevel[]; asks: BoardPriceLevel[] } => { + const reader = readerFor(response) + const code = reader.text(5) + const name = reader.text(20) + const market = reader.text(3) + reader.skip(30) + reader.skip(30) + reader.skip(1) + reader.skip(1) + reader.skip(4) + reader.skip(4) + const priceText = reader.text(11) + const priceFlag = reader.text(1) + reader.skip(2) + reader.skip(1) + reader.skip(1) + const timestamp = reader.text(5) + const changeText = reader.text(25) + const volumeText = reader.text(11) + reader.skip(13) + reader.skip(20) + reader.skip(30) + const openText = priceBeforeTimestamp(reader.text(18)) + const highText = priceBeforeTimestamp(reader.text(18)) + const lowText = priceBeforeTimestamp(reader.text(18)) + const previousCloseText = priceBeforeTimestamp(reader.text(18)) + reader.skip(15) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(12) + reader.skip(11) + reader.skip(12) + reader.skip(18) + reader.skip(18) + reader.skip(21) + reader.skip(21) + reader.skip(17) + reader.skip(6) + reader.skip(8) + reader.skip(8) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(12) + reader.skip(1) + reader.skip(12) + reader.skip(1) + reader.skip(12) + reader.skip(1) + reader.skip(12) + reader.skip(1) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(11) + reader.skip(15) + reader.skip(11) + + const bids = Array.from({ length: 8 }, () => reader.text(11)) + const asks = Array.from({ length: 8 }, () => reader.text(11)) + const bidSizes = [reader.text(13), ...Array.from({ length: 7 }, () => reader.text(11))] + const askSizes = [reader.text(13), ...Array.from({ length: 7 }, () => reader.text(11))] + reader.skip(11) + reader.skip(11) + reader.skip(15) + reader.skip(15) + bids.push(reader.text(11), reader.text(11)) + asks.push(reader.text(11), reader.text(11)) + bidSizes.push(reader.text(11), reader.text(11)) + askSizes.push(reader.text(11), reader.text(11)) + + const issue = { + code: emptyToUndefined(code) ?? options.issueCode, + market: emptyToUndefined(market) ?? options.market, + name: emptyToUndefined(name), + } + + return { + quote: { + issue, + price: yen(priceText), + change: signed(beforeParentheses(changeText), priceFlag), + changeRate: percent(inParentheses(changeText) ?? ''), + changeFlag: emptyToUndefined(priceFlag), + open: yen(openText), + high: yen(highText), + low: yen(lowText), + previousClose: yen(previousCloseText), + volume: parseNumber(volumeText), + timestamp: emptyToUndefined(timestamp), + }, + bids: boardPriceLevels(bids, bidSizes), + asks: boardPriceLevels(asks, askSizes), + } +} + +const boardPriceLevels = (prices: string[], quantities: string[]): BoardPriceLevel[] => + prices.flatMap((price, index) => + parseNumber(price) == null + ? [] + : [{ price: yen(price), quantity: parseNumber(quantities[index] ?? '') }], + ) + +const parseOrdersLoose = ( + response: MtsResponse, + options?: OrderInquiryOptions | IssueOptions, +): OrderList => { + if (response.header.trCode === 'F2503') return parseTodayExecutedOrders(response, options) + if (response.header.trCode === 'F2511') return parseRecentOrders(response, options) + if (response.header.trCode === 'F2504') return parseIssueOpenOrders(response, options) + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { orders: [], error: headerError } + const text = response.text.slice(MTS_HEADER_BYTES) + const chunks = text.match(/.{1,260}/gs) ?? [] + const orders: Order[] = chunks + .map((chunk, index) => { + const optionIssueCode = options && 'issueCode' in options ? (options.issueCode ?? '') : '' + const optionMarket = options && 'market' in options ? options.market : undefined + const code = chunk.match(/\b[0-9A-Z]{4,5}\b/)?.[0] ?? optionIssueCode + const id = chunk.match(/\b[0-9]{6,}\b/)?.[0] ?? `${response.header.trCode}-${index}` + const sideText = chunk.includes('売') ? 'sell' : 'buy' + return { + id, + issue: { code, market: optionMarket }, + side: sideText as TradeSide, + status: mapOrderStatus(chunk), + quantity: parseNumber(chunk.match(/[0-9,]+株/)?.[0] ?? ''), + price: yen(chunk.match(/[0-9,]+円/)?.[0] ?? ''), + } + }) + .filter((order) => order.issue.code || order.id) + return { orders } +} + +const parseTodayExecutedOrders = ( + response: MtsResponse, + options?: OrderInquiryOptions | IssueOptions, +): OrderList => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { orders: [], error: headerError } + const reader = readerFor(response) + reader.skip(3) + reader.skip(3) + const recordCount = reader.int(4) ?? 0 + const orders: Order[] = [] + for (let i = 0; i < recordCount; i++) { + const code = reader.text(5) + const market = reader.text(3) + const issueName = reader.text(35) + const orderNumber = reader.text(6) + const orderId = reader.text(7) + reader.text(1) + const statusText = reader.text(8) + const tradeId = reader.text(1) + const tradeIdText = reader.text(6) + reader.text(8) + const quantityText = reader.text(15) + reader.skip(10) + const averagePriceText = reader.text(15) + reader.skip(11) + reader.skip(1) + reader.skip(1) + reader.skip(2) + reader.skip(5) + reader.skip(25) + reader.skip(11) + const depositTypeText = reader.text(20) + const tradeDate = reader.text(8) + const settlementDateText = reader.text(14) + const commissionText = reader.text(20) + const taxText = reader.text(18) + reader.skip(2) + const daytradeTotalText = reader.text(40) + const exchangeName = reader.text(40) + reader.skip(10) + const accountInformation = reader.text(20) + const depositTypeCode = reader.text(1) + const issue = { + code, + market: emptyToUndefined(market), + name: extractIssueName(issueName), + } + orders.push({ + id: orderId || orderNumber || `${response.header.trCode}-${i}`, + issue, + side: mapSide(tradeId), + sideText: emptyToUndefined(tradeIdText), + status: 'executed', + statusText: emptyToUndefined(statusText), + depositType: mapDepositType(depositTypeCode), + depositTypeCode: emptyToUndefined(depositTypeCode), + depositTypeText: emptyToUndefined(depositTypeText), + quantity: parseNumber(quantityText), + executedQuantity: parseNumber(quantityText), + price: yen(averagePriceText), + executedPrice: yen(averagePriceText), + orderedAt: emptyToUndefined(tradeDate), + expiresAt: emptyToUndefined(settlementDateText), + orderNumber: emptyToUndefined(orderNumber), + tradeId: emptyToUndefined(tradeId), + exchangeCode: emptyToUndefined(exchangeName), + accountInformation: emptyToUndefined(accountInformation), + commission: yen(commissionText), + tax: yen(taxText), + daytradeTotal: yen(daytradeTotalText), + } as Order) + } + const error = parseTrailingError(reader, response) + return filterOrders({ orders, error }, options) +} + +const parseRecentOrders = ( + response: MtsResponse, + options?: OrderInquiryOptions | IssueOptions, +): OrderList => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { orders: [], error: headerError } + const reader = readerFor(response) + reader.skip(3) + reader.skip(3) + const recordCount = reader.int(4) ?? 0 + const orders: Order[] = [] + for (let i = 0; i < recordCount; i++) { + const code = reader.text(5) + const market = reader.text(3) + const issueName = reader.text(35) + const orderNumber = reader.text(6) + const orderId = reader.text(7) + const statusCode = reader.text(1) + const statusText = reader.text(8) + const tradeId = reader.text(1) + const tradeIdText = reader.text(6) + const paymentLimitText = reader.text(8) + const quantityText = reader.text(15) + const unexecutedQuantityText = reader.text(10) + reader.skip(15) + const priceText = reader.text(11) + reader.skip(1) + reader.skip(1) + reader.skip(2) + reader.skip(5) + reader.skip(25) + reader.skip(11) + const depositTypeText = reader.text(25) + const inputDate = reader.text(8) + const primaryOrderTermText = reader.text(14) + reader.skip(20) + reader.skip(18) + reader.skip(2) + reader.skip(40) + reader.skip(40) + const exchangeCode = reader.text(10) + const actualQuantityText = reader.text(8) + const executionStatus = reader.text(1) + const executionStatusText = reader.text(8) + const accountInformation = reader.text(20) + const depositTypeCode = reader.text(1) + reader.skip(3) + reader.skip(1) + reader.skip(1) + const primaryLimitPrice = reader.text(10) + reader.skip(1) + reader.skip(10) + reader.skip(1) + reader.skip(15) + reader.skip(4) + reader.skip(7) + reader.skip(14) + reader.skip(3) + reader.skip(1) + reader.skip(10) + reader.skip(1) + reader.skip(10) + reader.skip(8) + reader.skip(1) + reader.skip(10) + reader.skip(1) + const status = + mapOrderStatusCode(statusCode) ?? mapOrderStatus(`${statusText} ${executionStatusText}`) + orders.push({ + id: orderId || orderNumber || `${response.header.trCode}-${i}`, + issue: { + code, + market: emptyToUndefined(market), + name: extractIssueName(issueName), + }, + side: mapSide(tradeId), + sideText: emptyToUndefined(tradeIdText), + status, + statusText: emptyToUndefined(statusText), + executionStatus: emptyToUndefined(executionStatus), + executionStatusText: emptyToUndefined(executionStatusText), + depositType: mapDepositType(depositTypeCode), + depositTypeCode: emptyToUndefined(depositTypeCode), + depositTypeText: emptyToUndefined(depositTypeText), + quantity: parseNumber(quantityText), + unexecutedQuantity: parseNumber(unexecutedQuantityText), + executedQuantity: parseNumber(actualQuantityText), + price: yen(priceText || primaryLimitPrice), + orderedAt: emptyToUndefined(inputDate), + expiresAt: emptyToUndefined(primaryOrderTermText || paymentLimitText), + orderNumber: emptyToUndefined(orderNumber), + tradeId: emptyToUndefined(tradeId), + exchangeCode: emptyToUndefined(exchangeCode), + accountInformation: emptyToUndefined(accountInformation), + }) + } + const error = parseTrailingError(reader, response) + return filterOrders({ orders, error }, options) +} + +const parseIssueOpenOrders = ( + response: MtsResponse, + options?: OrderInquiryOptions | IssueOptions, +): OrderList => { + const headerError = methodErrorFromHeader(response) + if (headerError || response.buffer.length <= MTS_HEADER_BYTES) + return { orders: [], error: headerError } + const reader = readerFor(response) + const firstRecordCount = reader.int(4) ?? 0 + for (let i = 0; i < firstRecordCount; i++) { + reader.skip(1) + reader.skip(10) + reader.skip(10) + } + reader.skip(11) + reader.skip(1) + const recordCount = reader.int(4) ?? 0 + const optionIssueCode = options && 'issueCode' in options ? (options.issueCode ?? '') : '' + const optionMarket = options && 'market' in options ? options.market : undefined + const orders: Order[] = [] + for (let i = 0; i < recordCount; i++) { + const orderNumber = reader.text(6) + const orderId = reader.text(7) + const statusCode = reader.text(1) + const statusText = reader.text(8) + const tradeId = reader.text(1) + const tradeIdText = reader.text(6) + const paymentLimitText = reader.text(16) + const quantityText = reader.text(15) + const unexecutedQuantityText = reader.text(10) + reader.skip(15) + const inputDate = reader.text(8) + const primaryOrderTermText = reader.text(14) + reader.skip(3) + reader.skip(1) + reader.skip(1) + const primaryLimitPrice = reader.text(10) + reader.skip(1) + reader.skip(10) + reader.skip(1) + reader.skip(15) + reader.skip(4) + reader.skip(7) + reader.skip(14) + reader.skip(3) + reader.skip(1) + reader.skip(10) + reader.skip(1) + reader.skip(10) + reader.skip(8) + reader.skip(1) + reader.skip(10) + reader.skip(1) + orders.push({ + id: orderId || orderNumber || `${response.header.trCode}-${i}`, + issue: { code: optionIssueCode, market: optionMarket }, + side: mapSide(tradeId), + sideText: emptyToUndefined(tradeIdText), + status: mapOrderStatusCode(statusCode) ?? mapOrderStatus(statusText), + statusText: emptyToUndefined(statusText), + quantity: parseNumber(quantityText), + unexecutedQuantity: parseNumber(unexecutedQuantityText), + price: yen(primaryLimitPrice), + orderedAt: emptyToUndefined(inputDate), + expiresAt: emptyToUndefined(primaryOrderTermText || paymentLimitText), + orderNumber: emptyToUndefined(orderNumber), + tradeId: emptyToUndefined(tradeId), + }) + } + const error = parseTrailingError(reader, response) + return filterOrders({ orders, error }, options) +} + +const filterOrders = (list: OrderList, options?: OrderInquiryOptions | IssueOptions): OrderList => { + if (!options || !('issueCode' in options)) return list + return { + ...list, + orders: list.orders.filter((order) => matchesIssue(order.issue, options)), + } +} + +const parseOrderPreview = (response: MtsResponse, options: OrderPreviewInput): OrderPreview => ({ + issue: { code: options.issueCode, market: options.market }, + side: options.side, + quantity: 'quantity' in options ? options.quantity : undefined, + price: 'price' in options && options.price != null ? yen(String(options.price)) : undefined, + warnings: collectMessages(response.text), + confirmationId: response.header.lastExecutionTime || undefined, + message: collectMessages(response.text).join('\n') || undefined, + error: methodErrorFromHeader(response), +}) + +const parseThemeOrderPreview = ( + response: MtsResponse, + options: ThemeInvestmentOrderOptions, +): OrderPreview => ({ + issue: { code: options.themeId, name: options.themeId }, + side: options.side, + quantity: options.amount, + warnings: collectMessages(response.text), + confirmationId: response.header.lastExecutionTime || undefined, + message: collectMessages(response.text).join('\n') || undefined, + error: methodErrorFromHeader(response), +}) + +const parseOrderReceipt = (response: MtsResponse): OrderReceipt => { + const error = methodErrorFromHeader(response) + return { + accepted: !error?.code || error.code === '000000', + orderId: response.text.slice(MTS_HEADER_BYTES).match(/\b[0-9]{6,}\b/)?.[0], + acceptedAt: response.header.lastExecutionTime || undefined, + message: collectMessages(response.text).join('\n') || undefined, + error, + } +} + +const parseThemeInvestmentList = (_response: MtsResponse): ThemeInvestmentList => ({ themes: [] }) + +const assertTradingAllowed = (options: { allowTrading?: true }, name: string) => { + if (options.allowTrading !== true) { + throw new Error( + `${name} requires allowTrading: true because it can place or modify a real order`, + ) + } +} + +const assertCashOrderOptions = (options: CashOrderOptions) => { + if (options.kind === 's') { + const optionRecord = options as Record + const unsupportedFields = [ + 'price', + 'priceCondition', + 'orderTerm', + 'orderDate', + 'orderMethod', + 'triggerZone', + 'triggerPrice', + 'secondaryPriceCondition', + 'secondaryPrice', + 'sorLastMarket', + ].filter((key) => key in optionRecord && optionRecord[key] != null) + if (unsupportedFields.length) { + throw new Error(`orders.cash with kind: "s" cannot specify ${unsupportedFields.join(', ')}`) + } + if (options.market !== 'STK') { + throw new Error('orders.cash with kind: "s" requires market: "STK"') + } + if (!Number.isInteger(options.quantity)) { + throw new Error('orders.cash with kind: "s" requires an integer quantity') + } + return + } + + const priceCondition = cashOrderPriceCondition(options) + if (cashOrderPriceConditionRequiresPrice(priceCondition) && options.price == null) { + throw new Error(`orders.cash priceCondition: "${priceCondition}" requires price`) + } + if (!cashOrderPriceConditionRequiresPrice(priceCondition) && options.price != null) { + throw new Error(`orders.cash priceCondition: "${priceCondition}" cannot specify price`) + } + if (options.orderTerm === 'date') normalizeOrderDate(options.orderDate) + if (options.orderTerm !== 'date' && options.orderDate) { + throw new Error('orders.cash orderDate requires orderTerm: "date"') + } + + const orderMethod = cashOrderMethod(options) + const hasTrigger = options.triggerZone != null || options.triggerPrice != null + const hasSecondary = options.secondaryPriceCondition != null || options.secondaryPrice != null + if (orderMethod === 'normal') { + if (hasTrigger) + throw new Error('orders.cash trigger fields require orderMethod: "stop" or "oco"') + if (hasSecondary) throw new Error('orders.cash secondary fields require orderMethod: "oco"') + } + if (orderMethod === 'stop' || orderMethod === 'oco') { + if (!options.triggerZone) { + throw new Error(`orders.cash orderMethod: "${orderMethod}" requires triggerZone`) + } + if (options.triggerPrice == null) { + throw new Error(`orders.cash orderMethod: "${orderMethod}" requires triggerPrice`) + } + } + if (orderMethod === 'stop' && hasSecondary) { + throw new Error('orders.cash orderMethod: "stop" cannot specify secondary fields') + } + if (orderMethod === 'oco') { + if (!options.secondaryPriceCondition) { + throw new Error('orders.cash orderMethod: "oco" requires secondaryPriceCondition') + } + if ( + cashOrderPriceConditionRequiresPrice(options.secondaryPriceCondition) && + options.secondaryPrice == null + ) { + throw new Error('orders.cash orderMethod: "oco" requires secondaryPrice') + } + if ( + !cashOrderPriceConditionRequiresPrice(options.secondaryPriceCondition) && + options.secondaryPrice != null + ) { + throw new Error( + `orders.cash secondaryPriceCondition: "${options.secondaryPriceCondition}" cannot specify secondaryPrice`, + ) + } + } +} + +const prepareCashOrder = async (session: SbiSession, options: CashOrderOptions) => { + const preOrder = await callMts( + session, + cashPreOrderTrCode(options), + cashPreOrderTrin(session, options), + ) + const preOrderInfo = parseCashPreOrderInfo(preOrder) + await ensureTradeAuthenticated(session, options, preOrderInfo) +} + +const parseCashPreOrderInfo = (response: MtsResponse): CashPreOrderInfo => { + if (methodErrorFromHeader(response) || response.buffer.length <= MTS_HEADER_BYTES) return {} + const reader = readerFor(response) + reader.skip(20) + reader.skip(25) + reader.skip(1) + reader.skip(1) + reader.skip(1500) + const issueCode = reader.text(5) + const market = reader.text(3) + const issueName = reader.text(30) + return { + issueCode: emptyToUndefined(issueCode), + market: emptyToUndefined(market), + issueName: emptyToUndefined(stripIssueCodePrefix(issueName, issueCode)), + } +} + +const ensureTradeAuthenticated = async ( + session: SbiSession, + options: CashOrderOptions, + preOrderInfo: CashPreOrderInfo, +) => { + if (!session.deviceIdRegistered) { + throw new Error( + 'orders.cash requires deviceId in loginWithPasskey options; SBI returns trade authentication status 99 without F1131 device registration', + ) + } + const trin = tradeAuthenticationTrin(options, preOrderInfo) + debugMts('F1135 request', { + preOrderInfo, + value: trin.trimEnd(), + bytes: shiftJisByteLength(trin.trimEnd()), + }) + const response = await callMts(session, 'F1135', trin) + const authentication = parseTradeAuthentication(response) + debugMts('F1135 response', authentication) + if (authentication.status === 'success') return + if (authentication.status === 'needDial') { + await handlePhoneTradeAuthentication(session, authentication) + return + } + if (authentication.status === 'excessivelyRequested') { + throw new Error('trade authentication was requested too many times; retry later') + } + throw new Error(`trade authentication failed with status ${authentication.statusCode}`) +} + +const handlePhoneTradeAuthentication = async ( + session: SbiSession, + authentication: TradeAuthenticationInfo, +) => { + const request = tradeAuthenticationRequest(authentication) + if (!session.tradeAuthentication?.onRequired) { + const destination = request.sbiCallNo ?? request.phoneNo ?? request.telNo + throw new Error( + [ + 'trade authentication requires phone verification before order confirmation', + destination ? `call: ${destination}` : undefined, + request.authLimitTime ? `limit: ${request.authLimitTime}` : undefined, + ] + .filter(Boolean) + .join(', '), + ) + } + + await session.tradeAuthentication.onRequired(request) + await confirmPhoneTradeAuthentication(session) +} + +const tradeAuthenticationRequest = ( + authentication: TradeAuthenticationInfo, +): SbiTradeAuthenticationRequest => ({ + type: 'phone', + telNo: authentication.telNo, + phoneNo: authentication.phoneNo, + sbiCallNo: authentication.sbiCallNo, + authLimitTime: authentication.authLimitTime, +}) + +const confirmPhoneTradeAuthentication = async (session: SbiSession) => { + const attempts = session.tradeAuthentication?.confirmAttempts ?? 1 + const intervalMs = session.tradeAuthentication?.confirmIntervalMs ?? 1000 + let lastStatus: TradeAuthenticationConfirmStatus = 'unexpected' + let lastStatusCode = '' + + for (let index = 0; index < attempts; index += 1) { + if (index > 0) await sleep(intervalMs) + const response = await callMts(session, 'F1136') + lastStatusCode = readTradeAuthenticationConfirmStatusCode(response) + lastStatus = tradeAuthenticationConfirmStatus(lastStatusCode) + debugMts('F1136 response', { status: lastStatus, statusCode: lastStatusCode }) + if (lastStatus === 'success') return + if (lastStatus !== 'authNotComplete') break + } + + if (lastStatus === 'authNotComplete') { + throw new Error('trade authentication phone verification is not complete') + } + if (lastStatus === 'expired') throw new Error('trade authentication phone verification expired') + if (lastStatus === 'excessivelyRequested') { + throw new Error('trade authentication phone verification was requested too many times') + } + throw new Error(`trade authentication confirmation failed with status ${lastStatusCode}`) +} + +const parseTradeAuthentication = (response: MtsResponse): TradeAuthenticationInfo => { + const reader = readerFor(response) + const telNo = reader.text(20) + const phoneNo = reader.text(20) + const faxNo = reader.text(20) + const sbiCallNo = reader.text(20) + const authLimitTime = reader.text(14) + const statusCode = reader.text(2) + return { + status: tradeAuthenticationStatus(statusCode), + statusCode, + telNo: emptyToUndefined(telNo), + phoneNo: emptyToUndefined(phoneNo), + faxNo: emptyToUndefined(faxNo), + sbiCallNo: emptyToUndefined(sbiCallNo), + authLimitTime: emptyToUndefined(authLimitTime), + } +} + +const readTradeAuthenticationConfirmStatusCode = (response: MtsResponse) => + readerFor(response).text(2) + +const tradeAuthenticationStatus = (statusCode: string): TradeAuthenticationStatus => { + if (statusCode === '00') return 'success' + if (statusCode === '11') return 'needDial' + if (statusCode === '13') return 'excessivelyRequested' + return 'unexpected' +} + +const tradeAuthenticationConfirmStatus = (statusCode: string): TradeAuthenticationConfirmStatus => { + if (statusCode === '00') return 'success' + if (statusCode === '11') return 'authNotComplete' + if (statusCode === '12') return 'expired' + if (statusCode === '13') return 'excessivelyRequested' + return 'unexpected' +} + +const tradeAuthenticationTrin = (options: CashOrderOptions, preOrderInfo: CashPreOrderInfo) => + fixedTrin([ + { + width: 256, + value: + [ + preOrderInfo.issueCode ?? options.issueCode, + String(options.quantity), + tradeAuthenticationTradeName(options), + preOrderInfo.issueName ?? '', + ].join(',') + ';', + }, + ]) + +const tradeAuthenticationTradeName = (options: CashOrderOptions) => { + return options.side === 'sell' ? '現物売' : '現物買' +} + +const stripIssueCodePrefix = (name: string, issueCode?: string) => { + const trimmedName = name.trim() + const trimmedCode = issueCode?.trim() + if (!trimmedCode) return trimmedName + return trimmedName.replace(new RegExp(`^${escapeRegExp(trimmedCode)}[\\s ]+`), '') +} + +const escapeRegExp = (value: string) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') + +const readerFor = (response: MtsResponse) => makeFixedReader(response.buffer, MTS_HEADER_BYTES) + +const makeFixedReader = (buffer: Buffer, initialOffset = 0) => { + let offset = initialOffset + let lastFlag: string | undefined + return { + text: (width: number) => { + const text = readShiftJisField(buffer, offset, width) + offset += width + lastFlag = text.length === 1 ? text : lastFlag + return text + }, + int: (width: number) => + parseNumber(readShiftJisField(buffer, offset, width), () => { + offset += width + }), + skip: (width: number) => { + offset += width + }, + get remaining() { + return Math.max(0, buffer.length - offset) + }, + get lastFlag() { + return lastFlag + }, + } +} + +const fixedTrin = (fields: FixedField[]) => + fields.map((field) => padField(field.value, field.width, field.align, field.pad)).join('') + +const accountTrin = (session: SbiSession) => + fixedTrin([ + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + ]) + +const accountPowerTrin = (session: SbiSession) => + fixedTrin([ + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 1, value: session.profile.marginAccount ?? '' }, + ]) + +const listAccountTrin = ( + session: SbiSession, + options?: CashPositionOptions | MarginPositionOptions | IssueOptions, + extra?: string, +) => + fixedTrin([ + { width: 3, value: optionsWithPaging(hasPaging(options) ? options : undefined).index }, + { width: 3, value: optionsWithPaging(hasPaging(options) ? options : undefined).limit }, + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + ...(extra !== undefined ? [{ width: 1, value: extra }] : []), + ]) + +const marginCloseListTrin = (session: SbiSession, options: MarginPositionOptions) => + listAccountTrin(session, options, sideCode(options.side)) + + fixedTrin([ + { width: 5, value: options.issueCode }, + { width: 3, value: options.market }, + ]) + +const issuePositionTrin = (session: SbiSession, options: IssueOptions) => + fixedTrin([ + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 5, value: options.issueCode }, + { width: 3, value: options.market }, + ]) + +const issueTrin = (options: IssueOptions) => + fixedTrin([ + { width: 5, value: options.issueCode }, + { width: 80, value: '' }, + { width: 3, value: options.market }, + { width: 1, value: '' }, + ]) + +const normalizedIssueChartOptions = (options: IssueChartOptions) => { + const period = options.period ?? 'day' + const unit = options.unit ?? CHART_DEFAULT_UNITS[period] + const count = options.count ?? DEFAULT_CHART_COUNT + + if (!(period in CHART_PERIOD_MTS_CODES)) { + throw new Error('period must be minute, day, week, or month') + } + if (!Number.isInteger(unit) || unit <= 0) { + throw new Error('unit must be a positive integer') + } + if (period === 'minute' && !CHART_MINUTE_UNITS.has(unit)) { + throw new Error('minute chart unit must be 1, 5, 10, or 15') + } + if (period !== 'minute' && unit !== 1) { + throw new Error('day, week, and month chart unit must be 1') + } + if (!Number.isInteger(count) || count <= 0 || count > 9999) { + throw new Error('count must be an integer between 1 and 9999') + } + + return { period, unit, count } +} + +const issueChartTrin = (options: IssueChartOptions) => { + const { period, unit, count } = normalizedIssueChartOptions(options) + return fixedTrin([ + { width: 5, value: options.issueCode }, + { width: 80, value: '' }, + { width: 3, value: options.market }, + { width: 1, value: CHART_PERIOD_MTS_CODES[period] }, + { width: 2, value: unit }, + { width: 8, value: '' }, + { width: 8, value: '' }, + { width: 4, value: count }, + ]) +} + +const newsListTrin = () => + fixedTrin([ + { width: 3, value: '' }, + { width: 10, value: '' }, + { width: 8, value: '' }, + { width: 6, value: '' }, + { width: 30, value: '' }, + ]) + +const marketRankingTrin = () => + fixedTrin([ + { width: 2, value: '01' }, + { width: 2, value: '01' }, + { width: 4, value: '0000' }, + { width: 1, value: '0' }, + { width: 4, value: '0' }, + { width: 4, value: '999' }, + ]) + +const tradingInfoTrCode = (options: BoardOptions) => { + if (options.side === 'cashSell') return 'F2108' + if (options.side === 'marginOpenSell') return 'F2114' + if (options.side === 'marginOpen' || options.side === 'marginOpenBuy') return 'F2109' + if (options.side === 'marginCloseSell') return 'F2212' + if (options.side === 'marginClose' || options.side === 'marginCloseBuy') return 'F2208' + return 'F2107' +} + +const tradingInfoTrin = (session: SbiSession, options: BoardOptions) => + fixedTrin([ + { width: 5, value: options.issueCode }, + { width: 80, value: '' }, + { width: 3, value: options.market }, + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 1, value: session.profile.marginAccount ?? '' }, + { width: 1, value: depositTypeCode(options.accountType) }, + { width: 1, value: '' }, + { width: 1, value: '' }, + ]) + +const openOrdersTrin = (session: SbiSession, options: IssueOptions) => + fixedTrin([ + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 1, value: '1' }, + { width: 5, value: options.issueCode }, + { width: 3, value: options.market }, + { width: 1, value: '' }, + { width: 1, value: '1' }, + ]) + +const watchlistTrin = () => fixedTrin([{ width: 4, value: '0' }]) + +const orderInquiryTrin = (session: SbiSession, options?: OrderInquiryOptions) => + fixedTrin([ + { width: 3, value: optionsWithPaging(options).index }, + { width: 3, value: optionsWithPaging(options).limit }, + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 1, value: session.profile.marginAccount ?? '' }, + { width: 1, value: '' }, + ]) + +const recentOrdersTrin = (session: SbiSession, options?: OrderInquiryOptions) => + fixedTrin([ + { width: 3, value: optionsWithPaging(options).index }, + { width: 3, value: optionsWithPaging(options).limit }, + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 1, value: '1' }, + { width: 6, value: '' }, + { width: 1, value: '' }, + { width: 1, value: '' }, + ]) + +const orderPreviewTrin = (session: SbiSession, options: StockOrderTrinOptions) => + fixedTrin([ + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 5, value: options.issueCode }, + { width: 3, value: options.market }, + { width: 1, value: sideCode(options.side) }, + ]) + +const cashOrderTrin = (session: SbiSession, options: CashOrderOptions) => + appCashOrderTrin(session, options) + +const cashPreOrderTrin = (session: SbiSession, options: CashOrderOptions) => + fixedTrin([ + { width: 5, value: options.issueCode }, + { width: 80, value: '' }, + { width: 3, value: options.market }, + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 1, value: session.profile.marginAccount ?? '' }, + { width: 1, value: cashPreOrderDepositType(options) }, + { width: 2, value: '' }, + ]) + +const cashPreOrderDepositType = (options: CashOrderOptions) => + options.side === 'sell' ? orderDepositTypeCode(options.accountType) : '' + +const appCashOrderTrin = (session: SbiSession, options: CashOrderOptions) => { + if (!session.tradePassword) { + throw new Error('orders.cash requires tradePassword in loginWithPasskey options') + } + const accountType = options.accountType ?? session.profile.accountType + const standardOptions = options.kind === 's' ? undefined : options + const priceCondition = cashOrderPriceCondition(options) + const isPriceBased = cashOrderPriceConditionRequiresPrice(priceCondition) + return fixedTrin([ + { width: 32, value: mtsTradePassword(session.tradePassword) }, + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 5, value: options.issueCode }, + { width: 3, value: orderMarketCode(options) }, + { width: 8, value: options.quantity }, + { width: 1, value: cashOrderPriceConditionCode(priceCondition) }, + { width: 10, value: isPriceBased ? options.price : '' }, + { width: 1, value: orderDepositTypeCode(accountType) }, + { width: 1, value: '0' }, + { width: 8, value: cashOrderTermCode(options) }, + { width: 2, value: '00' }, + { width: 3, value: cashOrderMethodCode(cashOrderMethod(options)) }, + { width: 1, value: triggerZoneCode(standardOptions?.triggerZone) }, + { width: 10, value: standardOptions?.triggerPrice ?? '' }, + { width: 1, value: '2' }, + { width: 1, value: '' }, + { width: 2, value: '' }, + { width: 1, value: secondaryPriceConditionCode(options) }, + { + width: 10, + value: secondaryPriceConditionRequiresPrice(options) ? standardOptions?.secondaryPrice : '', + align: 'right', + pad: secondaryPriceConditionRequiresPrice(options) ? '0' : ' ', + }, + { width: 3, value: sorLastMarketCode(session, options) }, + ]) +} + +const orderMarketCode = (options: CashOrderOptions) => options.market + +const CASH_ORDER_PRICE_CONDITION_CODES = { + limit: '', + limitAtOpen: 'Z', + limitAtClose: 'I', + limitIoc: 'P', + market: 'N', + marketAtOpen: 'Y', + marketAtClose: 'H', + marketIoc: 'O', + funari: 'F', +} as const satisfies Record + +const PRICE_BASED_CASH_ORDER_CONDITIONS = new Set([ + 'limit', + 'limitAtOpen', + 'limitAtClose', + 'limitIoc', + 'funari', +]) + +const cashOrderPriceCondition = (options: CashOrderOptions): CashOrderPriceCondition => { + if (options.kind === 's') return 'market' + if (options.priceCondition) return options.priceCondition + if (options.kind === 'limit' || options.price != null) return 'limit' + return 'market' +} + +const cashOrderPriceConditionCode = (value: CashOrderPriceCondition) => + CASH_ORDER_PRICE_CONDITION_CODES[value] + +const cashOrderPriceConditionRequiresPrice = (value: CashOrderPriceCondition) => + PRICE_BASED_CASH_ORDER_CONDITIONS.has(value) + +const CASH_ORDER_TERM_CODES = { + day: '', + week: 'WEEKLY', +} as const satisfies Record, string> + +const cashOrderTermCode = (options: CashOrderOptions) => { + if (options.kind === 's') return '' + const term = options.orderTerm ?? 'day' + if (term === 'date') return normalizeOrderDate(options.orderDate) + return CASH_ORDER_TERM_CODES[term] +} + +const normalizeOrderDate = (value: string | undefined) => { + const date = value?.replace(/\D/g, '') ?? '' + if (date.length !== 8) throw new Error('orders.cash orderDate must be yyyyMMdd or yyyy-MM-dd') + return date +} + +const CASH_ORDER_METHOD_CODES = { + normal: '', + stop: 'SLO', + oco: 'OCO', +} as const satisfies Record + +const cashOrderMethod = (options: CashOrderOptions): CashOrderMethod => { + if (options.kind === 's') return 'normal' + if (options.orderMethod) return options.orderMethod + if (options.kind === 'stop') return 'stop' + if (options.kind === 'oco') return 'oco' + return 'normal' +} + +const cashOrderMethodCode = (value: CashOrderMethod) => CASH_ORDER_METHOD_CODES[value] + +const TRIGGER_ZONE_CODES = { + above: '0', + below: '1', +} as const satisfies Record + +const triggerZoneCode = (value: CashOrderTriggerZone | undefined) => + value ? TRIGGER_ZONE_CODES[value] : '' + +const secondaryPriceConditionCode = (options: CashOrderOptions) => { + if (options.kind === 's') return '' + return options.secondaryPriceCondition + ? cashOrderPriceConditionCode(options.secondaryPriceCondition) + : '' +} + +const secondaryPriceConditionRequiresPrice = (options: CashOrderOptions) => + options.kind !== 's' && + options.secondaryPriceCondition != null && + cashOrderPriceConditionRequiresPrice(options.secondaryPriceCondition) + +const sorLastMarketCode = (session: SbiSession, options: CashOrderOptions) => { + if (options.market !== 'SOR') return '' + if (options.sorLastMarket) return options.sorLastMarket + if (options.accountType === 'juniorNisa') return session.profile.sor?.juniorNisaLastMarket ?? '' + return session.profile.sor?.lastMarket ?? '' +} + +type StockOrderTrinOptions = { + issueCode: string + market: string + side: TradeSide + quantity?: number + price?: number + confirmationId?: string + positionId?: string +} + +const orderConfirmTrin = (session: SbiSession, options: StockOrderTrinOptions) => + orderPreviewTrin(session, options) + + fixedTrin([ + { width: 16, value: options.quantity, align: 'right', pad: '0' }, + { width: 11, value: options.price ?? '', align: 'right', pad: '0' }, + { width: 20, value: mtsTradePassword(session.tradePassword) }, + { width: 32, value: (options as PlaceCashOrderOptions).confirmationId ?? '' }, + { width: 32, value: (options as MarginCloseOrderOptions).positionId ?? '' }, + ]) + +const marginOpenOrderTrin = (session: SbiSession, options: MarginOpenOrderOptions) => + orderConfirmTrin(session, options) + +const marginSummaryOrderTrin = (session: SbiSession, options: MarginCloseOrderOptions) => + orderConfirmTrin(session, options) + +const actualDeliveryOrderTrin = (session: SbiSession, options: ActualDeliveryOrderOptions) => + orderConfirmTrin(session, { ...options, side: actualDeliverySide(options) }) + +const ifdOrderTrin = (session: SbiSession, options: IfdOrderOptions) => + orderConfirmTrin(session, options) + +const orderCorrectionTrin = (session: SbiSession, options: OrderCorrectionOptions) => + fixedTrin([ + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 20, value: options.orderId }, + { width: 16, value: options.quantity ?? '', align: 'right', pad: '0' }, + { width: 11, value: options.price ?? '', align: 'right', pad: '0' }, + { width: 20, value: mtsTradePassword(session.tradePassword) }, + ]) + +const orderCancelPreOrderTrin = (session: SbiSession, options: OrderCancelOptions) => + fixedTrin([ + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 6, value: options.orderNumber }, + { width: 1, value: options.tradeId ?? '' }, + { width: 1, value: session.profile.marginAccount ?? '' }, + { width: 1, value: options.cancelType ?? '' }, + ]) + +const orderCancelSubmitTrin = (session: SbiSession, options: PlaceOrderCancelOptions) => + fixedTrin([ + { width: 32, value: mtsTradePassword(options.tradePassword ?? session.tradePassword) }, + { width: 3, value: session.profile.butenCode ?? session.profile.branchCode }, + { width: 7, value: session.profile.accountNumber }, + { width: 1, value: 'P' }, + { width: 6, value: options.orderNumber }, + { width: 7, value: options.orderId ?? '' }, + { width: 5, value: '' }, + { width: 3, value: '' }, + { width: 1, value: options.tradeId ?? '' }, + { width: 8, value: '' }, + { width: 1, value: '' }, + { width: 10, value: '' }, + { width: 12, value: '' }, + { width: 1, value: '' }, + { width: 3, value: '' }, + { width: 1, value: '' }, + { width: 10, value: '' }, + { width: 1, value: '' }, + { width: 1, value: '2' }, + { width: 1, value: '' }, + { width: 10, value: '', align: 'right', pad: ' ' }, + ]) + +const themeOrderTrin = (session: SbiSession, options: ThemeInvestmentOrderOptions) => + fixedTrin([ + { width: 20, value: options.themeId }, + { width: 1, value: sideCode(options.side) }, + { width: 16, value: options.amount ?? '', align: 'right', pad: '0' }, + { width: 20, value: mtsTradePassword(session.tradePassword) }, + ]) + +const cashPreOrderTrCode = (options: CashOrderOptions) => + options.side === 'sell' ? 'F2102' : 'F2101' + +const cashConfirmTrCode = (options: CashOrderOptions) => { + if (options.kind === 'ifd') return 'F2151' + return options.side === 'sell' ? 'F2124' : 'F2104' +} + +const cashReceptionTrCode = (options: PlaceCashOrderOptions) => { + if (options.kind === 'ifd') return 'F2161' + return options.side === 'sell' ? 'F2135' : 'F2105' +} + +const marginOpenConfirmTrCode = (options: MarginOpenOrderOptions) => + options.side === 'sell' ? 'F2126' : 'F2125' + +const marginOpenReceptionTrCode = (options: MarginOpenOrderOptions) => + options.side === 'sell' ? 'F2137' : 'F2136' + +const marginCloseConfirmTrCode = (options: MarginCloseOrderOptions) => + options.side === 'sell' ? 'F2292' : 'F2282' + +const marginCloseReceptionTrCode = (options: MarginCloseOrderOptions) => + options.side === 'sell' ? 'F2293' : 'F2283' + +const marginCloseSummaryConfirmTrCode = (options: MarginCloseOrderOptions) => + options.side === 'sell' ? 'F2262' : 'F2242' + +const marginCloseSummaryReceptionTrCode = (options: MarginCloseOrderOptions) => + options.side === 'sell' ? 'F2263' : 'F2243' + +const actualDeliveryConfirmTrCode = (options: ActualDeliveryOrderOptions) => + options.kind === 'genwatashi' ? 'F2225' : 'F2205' + +const actualDeliveryReceptionTrCode = (options: ActualDeliveryOrderOptions) => + options.kind === 'genwatashi' ? 'F2236' : 'F2206' + +const actualDeliverySide = (options: ActualDeliveryOrderOptions): TradeSide => + options.kind === 'genwatashi' ? 'sell' : 'buy' + +const ifdConfirmTrCode = (options: IfdOrderOptions) => { + if (options.tradeType === 'marginOpen') return options.side === 'sell' ? 'F2154' : 'F2153' + return options.side === 'sell' ? 'F2152' : 'F2151' +} + +const ifdReceptionTrCode = (options: IfdOrderOptions) => { + if (options.tradeType === 'marginOpen') return options.side === 'sell' ? 'F2164' : 'F2163' + return options.side === 'sell' ? 'F2162' : 'F2161' +} + +const padField = ( + value: string | number | null | undefined, + width: number, + align: 'left' | 'right' = 'left', + pad = ' ', +) => { + const clipped = clipShiftJisField(String(value ?? ''), width) + const padding = pad.repeat(Math.max(0, width - shiftJisByteLength(clipped))) + return align === 'right' ? `${padding}${clipped}` : `${clipped}${padding}` +} + +const clipShiftJisField = (value: string, width: number) => { + let clipped = '' + let length = 0 + for (const char of value) { + const charLength = shiftJisByteLength(char) + if (length + charLength > width) break + clipped += char + length += charLength + } + return clipped +} + +const shiftJisByteLength = (value: string) => iconv.encode(value, 'Shift_JIS').length + +const encodeMtsForm = (fields: Record) => + Object.entries(fields) + .map(([key, value]) => `${formEncodeShiftJis(key)}=${formEncodeShiftJis(value)}`) + .join('&') + +const formEncodeShiftJis = (value: string) => { + let encoded = '' + for (const char of value) { + const charCode = char.codePointAt(0) + if (char === ' ') { + encoded += '+' + } else if (charCode != null && isJavaUrlEncoderSafeAscii(charCode)) { + encoded += char + } else { + for (const byte of iconv.encode(char, 'Shift_JIS')) { + encoded += `%${byte.toString(16).toUpperCase().padStart(2, '0')}` + } + } + } + return encoded +} + +const isJavaUrlEncoderSafeAscii = (charCode: number) => + (charCode >= 0x30 && charCode <= 0x39) || + (charCode >= 0x41 && charCode <= 0x5a) || + (charCode >= 0x61 && charCode <= 0x7a) || + charCode === 0x2d || + charCode === 0x2e || + charCode === 0x2a || + charCode === 0x5f + +const optionsWithPaging = (options?: { index?: number; limit?: number }) => ({ + index: options?.index ?? DEFAULT_PAGE_INDEX, + limit: options?.limit ?? DEFAULT_PAGE_LIMIT, +}) + +const hasPaging = (options: unknown): options is { index?: number; limit?: number } => + typeof options === 'object' && options !== null && ('index' in options || 'limit' in options) + +const filterCashPositions = ( + list: CashPositionList, + options?: IssueOptions | CashPositionOptions, +): CashPositionList => ({ + ...list, + positions: list.positions.filter((position) => matchesIssue(position.issue, options)), +}) + +const filterMarginPositions = ( + list: MarginPositionList, + options?: IssueOptions | MarginPositionOptions, +): MarginPositionList => ({ + ...list, + positions: list.positions.filter((position) => matchesIssue(position.issue, options)), +}) + +const matchesIssue = ( + issue: IssueRef, + options?: IssueOptions | CashPositionOptions | MarginPositionOptions, +) => + (!options?.issueCode || issue.code === options.issueCode) && + (!options?.market || issue.market === options.market) + +const parseTrailingError = ( + reader: ReturnType, + response: MtsResponse, +): SbiMethodError | undefined => { + if (reader.remaining < 207) return undefined + const status = reader.text(1) + const code = reader.text(6) + const message = reader.text(200) + const error = { + status: emptyToUndefined(status), + code: emptyToUndefined(code), + message: emptyToUndefined(message), + } + throwIfMethodError(error, response) + return error +} + +const throwIfMtsHeaderError = (header: MtsHeader, requestUrl: string) => { + if (!header.resultCode || header.resultCode === '000000') return + throw new SbiServerError({ + code: header.resultCode, + trCode: header.trCode, + requestUrl, + }) +} + +const throwIfMethodError = (error: SbiMethodError, response: MtsResponse) => { + if (!error.code || error.code === '000000') return + throw new SbiServerError({ + code: error.code, + status: error.status, + serverMessage: error.message, + trCode: response.header.trCode, + requestUrl: response.requestUrl, + }) +} + +const methodErrorFromHeader = (response: MtsResponse): SbiMethodError | undefined => + response.header.resultCode && response.header.resultCode !== '000000' + ? { code: response.header.resultCode } + : undefined + +const readShiftJisField = (buffer: Buffer, offset: number, width: number) => + decodeShiftJis(buffer.subarray(offset, offset + width)).trim() + +const decodeShiftJis = (value: Buffer | ArrayBuffer | Uint8Array) => + new TextDecoder('shift_jis' as never).decode( + value instanceof Buffer ? value : new Uint8Array(value), + ) + +const parseNumber = (text: string, after?: () => void) => { + after?.() + const normalized = text + .replace(/[,\s円株%]/g, '') + .replace(/^△/, '-') + .replace(/[()]/g, '') + if (!normalized || normalized === '-' || normalized === '--') return null + const number = Number(normalized) + return Number.isFinite(number) ? number : null +} + +const yen = (text: string): CurrencyAmount => ({ + value: parseNumber(text), + text: text.trim(), + currency: 'JPY', +}) + +const chartYen = (text: string): CurrencyAmount => { + const value = parseNumber(text) + return { + value: value == null ? null : value * 0.01, + text: text.trim(), + currency: 'JPY', + } +} + +const percent = (text: string): PercentValue => ({ value: parseNumber(text), text: text.trim() }) + +const signed = (text: string, flag?: string): SignedTextValue => { + const value = parseNumber(text) + return { + value, + text: text.trim(), + sign: flagToSign(flag, value), + } +} + +const flagToSign = (flag: string | undefined, value: number | null): SignedTextValue['sign'] => { + if (value === 0) return 'zero' + if (flag === '1' || flag === '+' || (value != null && value > 0)) return 'positive' + if (flag === '2' || flag === '-' || (value != null && value < 0)) return 'negative' + return undefined +} + +const addSignedTextValues = ( + left?: SignedTextValue, + right?: SignedTextValue, +): SignedTextValue | undefined => { + if (left?.value == null && right?.value == null) return undefined + const value = (left?.value ?? 0) + (right?.value ?? 0) + return { value, text: String(value), sign: flagToSign(undefined, value) } +} + +const sumCurrencyAmounts = ( + amounts: Array, +): CurrencyAmount | undefined => { + const values = amounts + .map((amount) => amount?.value) + .filter((value): value is number => typeof value === 'number') + if (values.length === 0) return undefined + const total = values.reduce((sum, value) => sum + value, 0) + return { + value: total, + text: total.toLocaleString('ja-JP'), + currency: 'JPY', + } +} + +const emptyToUndefined = (value: string | undefined) => { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const extractIssueName = (value: string) => { + const parts = value.trim().split(/\s+/) + return parts.length > 1 ? parts.slice(1).join(' ') : emptyToUndefined(value) +} + +const subtractNullable = (left: number | null, right: number | null) => { + if (left == null) return null + return left - (right ?? 0) +} + +const parseNumberFromParentheses = (value: string) => parseNumber(inParentheses(value) ?? '') +const inParentheses = (value: string) => value.match(/\(([^)]*)\)/)?.[1] +const beforeParentheses = (value: string) => value.split('(')[0] ?? value +const firstColonValue = (value: string) => value.split(':')[0] ?? value +const priceBeforeTimestamp = (value: string) => beforeParentheses(firstColonValue(value)) +const lastReadFlag = (reader: ReturnType) => reader.lastFlag + +const safeCount = (reader: ReturnType, width: number) => { + const count = reader.int(width) + if (count == null || count < 0 || count > 1000) return 0 + return count +} + +const collectMessages = (text: string) => + text + .slice(MTS_HEADER_BYTES) + .replaceAll('\u0000', '\n') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && /[ぁ-んァ-ン一-龯A-Za-z]/.test(line)) + .slice(0, 20) + +const joinDateTime = (date: string, time: string) => { + const d = date.replace(/\D/g, '') + const t = time.replace(/\D/g, '') + if (d.length !== 8) return undefined + return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}${t.length >= 4 ? `T${t.slice(0, 2)}:${t.slice(2, 4)}:00` : ''}` +} + +const mapAccountType = (value: string | undefined) => { + if (value === '1') return 'specific' + if (value === '2') return 'nisa' + if (value === '3') return 'juniorNisa' + if (value === '0') return 'general' + return 'unknown' +} + +const mapDepositType = (value: string | undefined) => mapAccountType(value) + +const depositTypeCode = (value: BoardOptions['accountType']) => { + if (value === 'specific') return '1' + if (value === 'nisa') return '2' + if (value === 'juniorNisa') return '3' + if (value === 'general') return '0' + return '' +} + +const orderDepositTypeCode = (value: AccountType | undefined) => { + if (value === 'specific') return '0' + if (value === 'general') return '1' + if (value === 'nisa') return '4' + if (value === 'juniorNisa') return '5' + return '' +} + +const mtsTradePassword = (value: string | undefined) => value?.replaceAll('¥', '\\') ?? '' + +const mapSide = (value: string | undefined): TradeSide => + value && ['1', '3', '5', '7', '9', 'A', 'D'].includes(value) ? 'buy' : 'sell' +const sideCode = (side?: TradeSide) => (side === 'sell' ? '2' : side === 'buy' ? '1' : '') + +const mapOrderStatusCode = (value: string | undefined): OrderStatus | undefined => { + if (!value) return undefined + if (['0', '1', '2', '7', '8', '9', 'A'].includes(value)) return 'open' + if (value === '3') return 'cancelled' + if (value === '4') return 'expired' + if (value === '5') return 'executed' + if (value === '6') return 'rejected' + return undefined +} + +const mapOrderStatus = (text: string): OrderStatus => { + if (/約定|全部/.test(text)) return 'executed' + if (/取消/.test(text)) return 'cancelled' + if (/失効/.test(text)) return 'expired' + if (/拒否|エラー/.test(text)) return 'rejected' + if (/受付|注文|未約定/.test(text)) return 'open' + return 'unknown' +} + +export type { SbiClientMethods } diff --git a/packages/sbi-client/src/methods/types.ts b/packages/sbi-client/src/methods/types.ts new file mode 100644 index 0000000..1e63939 --- /dev/null +++ b/packages/sbi-client/src/methods/types.ts @@ -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 + /** 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 +} + +export interface SbiClientMethodAccountPower { + /** Fetches buying power, margin buying power, withdrawable amount, and related account power values. */ + buyingPower(options?: AccountPowerOptions): Promise + /** Fetches the collateral ratio and related margin collateral details. */ + collateralRatio(options?: AccountPowerOptions): Promise +} + +export interface SbiClientMethodAccountPositions { + /** Fetches cash positions. */ + cash(options?: CashPositionOptions): Promise + /** Fetches the alternate cash-position list used by the mobile app. */ + cashDetail(options?: CashPositionOptions): Promise + /** Fetches cash positions for a specific issue. */ + cashForIssue(options: IssueOptions): Promise + /** Fetches margin positions. */ + margin(options?: MarginPositionOptions): Promise + /** Fetches the alternate margin-position list used by the mobile app. */ + marginDetail(options?: MarginPositionOptions): Promise + /** Fetches margin positions for a specific issue. */ + marginForIssue(options: IssueOptions): Promise + /** Fetches margin positions for a specific issue aggregated by issue. */ + marginSummaryForIssue(options: IssueOptions): Promise + /** Fetches individual margin positions for a specific issue. */ + marginDetailsForIssue(options: IssueOptions): Promise + /** Fetches margin positions available for close orders. */ + closeableMargin(options: MarginPositionOptions): Promise + /** Fetches margin positions available for stock delivery. */ + deliverableMargin(options: MarginPositionOptions): Promise +} + +export interface SbiClientMethodAccountProfitLoss { + /** Fetches the unrealized profit and loss summary for cash and margin positions. */ + unrealized(): Promise +} + +export interface SbiClientMethodAccount { + /** Returns the current account profile. */ + profile(): Promise + /** 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 + /** Fetches issue suggestions for partial input. */ + suggest(options: IssueSearchOptions): Promise + /** Fetches prices accepted as order input for an issue. */ + allowedPrices(options: IssueOptions): Promise + /** Fetches board information for an issue. */ + board(options: IssueOptions): Promise + /** Polls board information for an issue using the same endpoint as `board`. */ + pollBoard(options: MarketIssueBoardPollingOptions): AsyncIterableIterator + /** Fetches historical chart prices for an issue. */ + chart(options: IssueChartOptions): Promise + /** Fetches open orders for an issue. */ + openOrders(options: IssueOptions): Promise + /** Fetches board and issue information useful before placing an order. */ + tradingInfo(options: BoardOptions): Promise +} + +export interface SbiClientMethodMarketIndex { + /** Fetches major market indexes. */ + major(): Promise +} + +export interface SbiClientMethodMarketRanking { + /** Fetches market rankings. */ + market(): Promise + /** Fetches sector rankings. */ + sector(): Promise + /** Fetches SBI-provided rankings. */ + sbi(): Promise +} + +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 + /** Methods for fetching ranking information. */ + ranking: SbiClientMethodMarketRanking +} + +export interface SbiClientMethodNews { + /** Fetches news items. */ + list(): Promise +} + +export interface SbiClientMethodWatchlist { + /** Fetches registered watchlists. */ + list(): Promise +} + +export interface SbiClientMethodOrderInquiry { + /** Fetches orders executed today. */ + executionsToday(options?: OrderInquiryOptions): Promise + /** Fetches open or recently active orders. */ + open(options?: OrderInquiryOptions): Promise +} + +export interface SbiClientMethodCashOrder { + /** Estimates a cash order without submitting a live order. */ + estimate(options: CashOrderOptions): Promise + /** Places a live cash order. Requires `allowTrading: true`. */ + place(options: PlaceCashOrderOptions): Promise + /** Estimates a cash order correction without submitting a live correction. */ + estimateCorrection(options: OrderCorrectionOptions): Promise + /** Estimates the mobile correction-confirmation route without submitting a live correction. */ + estimateCorrectionConfirm(options: OrderCorrectionOptions): Promise + /** Places a live cash order correction. Requires `allowTrading: true`. */ + placeCorrection(options: PlaceOrderCorrectionOptions): Promise + /** Estimates a cash order cancellation without submitting a live cancellation. */ + estimateCancel(options: OrderCancelOptions): Promise + /** Places a live cash order cancellation. Requires `allowTrading: true`. */ + placeCancel(options: PlaceOrderCancelOptions): Promise +} + +export interface SbiClientMethodMarginOrder { + /** Estimates a margin-open order without submitting a live order. */ + estimateOpen(options: MarginOpenOrderOptions): Promise + /** Places a live margin-open order. Requires `allowTrading: true`. */ + open(options: PlaceMarginOpenOrderOptions): Promise + /** Estimates a margin close order without submitting a live order. */ + estimateClose(options: MarginCloseOrderOptions): Promise + /** Places a live margin close order. Requires `allowTrading: true`. */ + close(options: PlaceMarginCloseOrderOptions): Promise + /** Estimates a margin close order by position summary without submitting a live order. */ + estimateCloseSummary(options: MarginCloseOrderOptions): Promise + /** Places a live margin close order by position summary. Requires `allowTrading: true`. */ + closeSummary(options: PlaceMarginCloseOrderOptions): Promise + /** Estimates a mobile margin close summary order without submitting a live order. */ + estimateSummary(options: MarginCloseSummaryOrderOptions): Promise + /** Places a mobile margin close summary order. Requires `allowTrading: true`. */ + placeSummary(options: PlaceMarginCloseSummaryOrderOptions): Promise + /** Estimates a genbiki/genwatashi actual-delivery order without submitting a live order. */ + estimateActualDelivery(options: ActualDeliveryOrderOptions): Promise + /** Places a genbiki/genwatashi actual-delivery order. Requires `allowTrading: true`. */ + actualDelivery(options: PlaceActualDeliveryOrderOptions): Promise +} + +export interface SbiClientMethodIfdOrder { + /** Estimates an IFD order without submitting a live order. */ + estimate(options: IfdOrderOptions): Promise + /** Places a live IFD order. Requires `allowTrading: true`. */ + place(options: PlaceIfdOrderOptions): Promise + /** Estimates an IFD order correction without submitting a live correction. */ + estimateCorrection(options: OrderCorrectionOptions): Promise + /** Places a live IFD order correction. Requires `allowTrading: true`. */ + placeCorrection(options: PlaceOrderCorrectionOptions): Promise + /** Estimates an IFD order cancellation without submitting a live cancellation. */ + estimateCancel(options: OrderCorrectionOptions): Promise + /** Places a live IFD order cancellation. Requires `allowTrading: true`. */ + placeCancel(options: PlaceOrderCorrectionOptions): Promise +} + +export interface SbiClientMethodThemeInvestmentOrder { + /** Fetches theme investment holdings or order targets. */ + list(): Promise + /** Estimates a theme investment order without submitting a live order. */ + estimate(options: ThemeInvestmentOrderOptions): Promise + /** Places a live theme investment order. Requires `allowTrading: true`. */ + place(options: PlaceThemeInvestmentOrderOptions): Promise +} + +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 +} diff --git a/packages/sbi-client/src/session/index.ts b/packages/sbi-client/src/session/index.ts new file mode 100644 index 0000000..92e3587 --- /dev/null +++ b/packages/sbi-client/src/session/index.ts @@ -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 => { + const session = await createPasskeySession(options, clientOptions) + return createMethodsFromSession(session) +} + +export const createPasskeySession = async ( + options: LoginWithPasskeyOptions, + clientOptions: SbiClientOptions = {}, +): Promise => { + 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 => { + 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['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() + + 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 = [ + /]+name=["']_csrf["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+name=["']_csrf["']/i, + /]+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 + 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) + : 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[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(), + } +} diff --git a/packages/sbi-client/src/types.ts b/packages/sbi-client/src/types.ts new file mode 100644 index 0000000..4295a7f --- /dev/null +++ b/packages/sbi-client/src/types.ts @@ -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 + } + cipher: { + name: 'AES-256-GCM' + nonce: string + aad: string + ciphertext: string + tag: string + } + } + createdAt: string + updatedAt: string +} + +export type PlaintextStoredWebAuthnCredential = Omit & { + 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 + 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 + 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 +} diff --git a/packages/sbi-client/tsconfig.json b/packages/sbi-client/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/packages/sbi-client/tsconfig.json @@ -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 + } +} diff --git a/scripts/check-env.ts b/scripts/check-env.ts new file mode 100644 index 0000000..82bc6c7 --- /dev/null +++ b/scripts/check-env.ts @@ -0,0 +1,51 @@ +const requiredForSetup = ['CSBIE_SETUP_PASSWORD', 'CSBIE_SETUP_PASSWORD_HASH'] as const + +const runtimeDefaults = { + PORT: '8787', + CSBIE_DATABASE_PATH: './data/csbie.sqlite', + CSBIE_ORIGIN: 'http://127.0.0.1:5173', + CSBIE_RP_ID: '127.0.0.1', +} + +const optionalUrls = ['SBI_AUTH_BASE_URL', 'SBI_MTS_BASE_URL', 'CSBIE_ORIGIN', 'CSBIE_CORS_ORIGIN'] + +const present = (key: string) => Boolean(process.env[key]?.trim()) + +const validateUrl = (key: string) => { + const value = process.env[key] + if (!value) return undefined + try { + new URL(value) + return undefined + } catch { + return `${key} must be a valid URL` + } +} + +const messages: string[] = [] + +for (const [key, fallback] of Object.entries(runtimeDefaults)) { + if (!present(key)) messages.push(`${key} is not set; runtime default is ${fallback}`) +} + +for (const key of optionalUrls) { + const message = validateUrl(key) + if (message) messages.push(message) +} + +if (!requiredForSetup.some(present)) { + messages.push( + 'Set CSBIE_SETUP_PASSWORD or CSBIE_SETUP_PASSWORD_HASH before first owner passkey setup', + ) +} + +if (!present('SBI_AUTH_BASE_URL') || !present('SBI_MTS_BASE_URL')) { + messages.push('Set SBI_AUTH_BASE_URL and SBI_MTS_BASE_URL before connecting an SBI session') +} + +if (messages.length === 0) { + console.log('env:check ok') +} else { + console.log('env:check notices:') + for (const message of messages) console.log(`- ${message}`) +} diff --git a/scripts/mock-create.js b/scripts/mock-create.js new file mode 100644 index 0000000..4dfc462 --- /dev/null +++ b/scripts/mock-create.js @@ -0,0 +1,298 @@ +;(() => { + if (!globalThis.navigator?.credentials?.create) { + throw new Error('navigator.credentials.create is not available on this page') + } + + if (navigator.credentials.__sbiClientMockCreateInstalled) { + console.warn('[sbi-client] mock navigator.credentials.create is already installed') + return + } + + const originalCreate = navigator.credentials.create.bind(navigator.credentials) + const textEncoder = new TextEncoder() + const credentialLabel = 'csbi' + + const base64Url = (value) => { + const bytes = + value instanceof ArrayBuffer + ? new Uint8Array(value) + : new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') + } + + const fromBase64Url = (value) => { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes + } + + const toBytes = (value) => { + if (value instanceof ArrayBuffer) return new Uint8Array(value) + if (ArrayBuffer.isView(value)) + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + if (Array.isArray(value)) return new Uint8Array(value) + if (typeof value === 'string') return fromBase64Url(value) + throw new Error('unsupported binary value') + } + + const concat = (...parts) => { + const length = parts.reduce((sum, part) => sum + part.length, 0) + const out = new Uint8Array(length) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out + } + + const sha256 = async (value) => new Uint8Array(await crypto.subtle.digest('SHA-256', value)) + + const randomBytes = (length) => { + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + return bytes + } + + const cborUInt = (major, value) => { + if (value < 24) return Uint8Array.of((major << 5) | value) + if (value < 0x100) return Uint8Array.of((major << 5) | 24, value) + if (value < 0x10000) return Uint8Array.of((major << 5) | 25, value >> 8, value & 0xff) + return Uint8Array.of( + (major << 5) | 26, + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ) + } + + const cborEncode = (value) => { + if (value === null) return Uint8Array.of(0xf6) + if (value === false) return Uint8Array.of(0xf4) + if (value === true) return Uint8Array.of(0xf5) + if (typeof value === 'number') { + if (!Number.isInteger(value)) throw new Error('CBOR encoder only supports integers') + return value >= 0 ? cborUInt(0, value) : cborUInt(1, -1 - value) + } + if (typeof value === 'string') { + const bytes = textEncoder.encode(value) + return concat(cborUInt(3, bytes.length), bytes) + } + if (value instanceof Uint8Array) return concat(cborUInt(2, value.length), value) + if (Array.isArray(value)) return concat(cborUInt(4, value.length), ...value.map(cborEncode)) + if (value && typeof value === 'object') { + const entries = value instanceof Map ? [...value.entries()] : Object.entries(value) + const encoded = entries.flatMap(([key, item]) => [cborEncode(key), cborEncode(item)]) + return concat(cborUInt(5, entries.length), ...encoded) + } + throw new Error('unsupported CBOR value') + } + + const derToRawP256 = (spki) => { + const bytes = new Uint8Array(spki) + const marker = Uint8Array.of(0x03, 0x42, 0x00, 0x04) + for (let i = 0; i <= bytes.length - marker.length - 64; i++) { + if (marker.every((byte, index) => bytes[i + index] === byte)) { + return bytes.slice(i + marker.length, i + marker.length + 64) + } + } + throw new Error('could not extract P-256 public key from SPKI') + } + + const selectAlgorithm = (params = []) => { + if (params.some((param) => param.type === 'public-key' && param.alg === -7)) return -7 + throw new Error('mock-create currently supports ES256 only') + } + + const makeCredential = async (publicKey) => { + const alg = selectAlgorithm(publicKey.pubKeyCredParams) + const rpId = publicKey.rp?.id || location.hostname + const origin = location.origin + const challenge = toBytes(publicKey.challenge) + const credentialId = randomBytes(32) + const userHandle = publicKey.user?.id ? toBytes(publicKey.user.id) : undefined + + const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]) + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey) + const rawPublicKey = derToRawP256(spki) + const cosePublicKey = cborEncode( + new Map([ + [1, 2], + [3, alg], + [-1, 1], + [-2, rawPublicKey.slice(0, 32)], + [-3, rawPublicKey.slice(32, 64)], + ]), + ) + + const clientDataJSON = textEncoder.encode( + JSON.stringify({ + type: 'webauthn.create', + challenge: base64Url(challenge), + origin, + crossOrigin: false, + }), + ) + + const rpIdHash = await sha256(textEncoder.encode(rpId)) + const flags = Uint8Array.of(0x45) + const signCount = Uint8Array.of(0, 0, 0, 0) + const aaguid = new Uint8Array(16) + const credentialIdLength = Uint8Array.of( + (credentialId.length >> 8) & 0xff, + credentialId.length & 0xff, + ) + const authenticatorData = concat( + rpIdHash, + flags, + signCount, + aaguid, + credentialIdLength, + credentialId, + cosePublicKey, + ) + const attestationObject = cborEncode( + new Map([ + ['fmt', 'none'], + ['attStmt', new Map()], + ['authData', authenticatorData], + ]), + ) + + const now = new Date().toISOString() + const vaultObject = { + version: 1, + kind: 'webauthn-credential', + provider: 'sbi-sec', + label: credentialLabel, + rpId, + origin, + credentialId: base64Url(credentialId), + userHandle: userHandle ? base64Url(userHandle) : undefined, + alg, + publicKey: { + format: 'jwk', + jwk: publicJwk, + }, + authenticator: { + aaguid: '00000000-0000-0000-0000-000000000000', + signCount: 0, + discoverable: + publicKey.residentKey === 'required' || + publicKey.authenticatorSelection?.residentKey === 'required', + userVerification: + publicKey.userVerification || + publicKey.authenticatorSelection?.userVerification || + 'preferred', + transports: ['internal'], + backupEligible: false, + backupState: false, + }, + secretPlaintext: { + privateKey: { + format: 'jwk', + jwk: privateJwk, + }, + registration: { + attestationObject: base64Url(attestationObject), + clientDataJSON: base64Url(clientDataJSON), + }, + }, + createdAt: now, + updatedAt: now, + } + + const credential = { + id: base64Url(credentialId), + rawId: credentialId.buffer.slice( + credentialId.byteOffset, + credentialId.byteOffset + credentialId.byteLength, + ), + type: 'public-key', + authenticatorAttachment: 'platform', + response: { + attestationObject: attestationObject.buffer.slice( + attestationObject.byteOffset, + attestationObject.byteOffset + attestationObject.byteLength, + ), + clientDataJSON: clientDataJSON.buffer.slice( + clientDataJSON.byteOffset, + clientDataJSON.byteOffset + clientDataJSON.byteLength, + ), + getAuthenticatorData: () => + authenticatorData.buffer.slice( + authenticatorData.byteOffset, + authenticatorData.byteOffset + authenticatorData.byteLength, + ), + getPublicKey: () => spki, + getPublicKeyAlgorithm: () => alg, + getTransports: () => ['internal'], + }, + getClientExtensionResults: () => ({}), + toJSON: () => ({ + id: base64Url(credentialId), + rawId: base64Url(credentialId), + type: 'public-key', + authenticatorAttachment: 'platform', + response: { + attestationObject: base64Url(attestationObject), + clientDataJSON: base64Url(clientDataJSON), + authenticatorData: base64Url(authenticatorData), + publicKey: base64Url(new Uint8Array(spki)), + publicKeyAlgorithm: alg, + transports: ['internal'], + }, + clientExtensionResults: {}, + }), + } + + return { credential, vaultObject } + } + + const openJsonTab = (value) => { + const json = JSON.stringify(value, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const tab = window.open(url, '_blank', 'noopener,noreferrer') + if (!tab) { + console.warn('[sbi-client] could not open JSON tab; popup may have been blocked') + URL.revokeObjectURL(url) + return + } + window.setTimeout(() => URL.revokeObjectURL(url), 60_000) + } + + navigator.credentials.create = async (options) => { + if (!options?.publicKey) return originalCreate(options) + + const { credential, vaultObject } = await makeCredential(options.publicKey) + globalThis.__sbiClientLastCreatedCredential = vaultObject + console.log('[sbi-client] created mock WebAuthn credential') + console.log(JSON.stringify(vaultObject, null, 2)) + openJsonTab(vaultObject) + return credential + } + + Object.defineProperty(navigator.credentials, '__sbiClientMockCreateInstalled', { + value: true, + enumerable: false, + }) + + globalThis.__sbiClientRestoreCreate = () => { + navigator.credentials.create = originalCreate + delete globalThis.__sbiClientRestoreCreate + console.log('[sbi-client] restored native navigator.credentials.create') + } + + console.log('[sbi-client] installed mock navigator.credentials.create') +})() diff --git a/tools/analyze-sbi-apk.ts b/tools/analyze-sbi-apk.ts new file mode 100644 index 0000000..774ef0d --- /dev/null +++ b/tools/analyze-sbi-apk.ts @@ -0,0 +1,307 @@ +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' + +type ApiClass = { + file: string + packageName: string | null + className: string | null + extendsName: string | null + responseType: string | null + trCodes: string[] + paths: string[] + method: string | null + requestParams: string[] + fixedFields: Array<{ line: number; width: number; value: string }> + responseReads: Array<{ line: number; setter: string | null; width: number }> +} + +type Finding = { + generatedAt: string + input: { + jadx: string + apktool: string + } + app: { + packageName: string | null + appClass: string | null + networkSecurityConfig: string | null + cleartextTrafficPermitted: boolean | null + deepLinks: string[] + permissions: string[] + nativeLibs: string[] + } + urls: string[] + resourceStrings: Record + api: { + baseUrls: string[] + appConfigUrls: string[] + mts: { + endpointPath: string + loginPath: string + logoutPath: string + contentType: string + encoding: string + classes: ApiClass[] + } + otherClasses: ApiClass[] + } + notes: string[] +} + +const args = new Map() +for (let i = 2; i < process.argv.length; i += 2) { + args.set(process.argv[i], process.argv[i + 1]) +} + +const jadxRoot = args.get('--jadx') ?? 'workspace/decoded/jadx' +const apktoolRoot = args.get('--apktool') ?? 'workspace/decoded/apktool' +const outDir = args.get('--out') ?? 'workspace/analysis' + +function walk(dir: string, predicate = (_path: string) => true): string[] { + const result: string[] = [] + for (const entry of readdirSync(dir)) { + const path = join(dir, entry) + const st = statSync(path) + if (st.isDirectory()) result.push(...walk(path, predicate)) + else if (predicate(path)) result.push(path) + } + return result +} + +function unique(items: T[]): T[] { + return [...new Set(items)] +} + +function lineNo(text: string, index: number): number { + return text.slice(0, index).split('\n').length +} + +function readMaybe(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +function matchAll(re: RegExp, text: string): RegExpExecArray[] { + const out: RegExpExecArray[] = [] + let m: RegExpExecArray | null + while ((m = re.exec(text))) out.push(m) + return out +} + +function extractUrls(text: string): string[] { + return unique( + matchAll(/https?:\/\/[^\s"'<>),;]+/g, text) + .map((m) => m[0].replace(/\\u0026/g, '&').replace(/[\\"]+$/g, '')) + .sort(), + ) +} + +function parseManifest(text: string) { + const packageName = text.match(/\bpackage="([^"]+)"/)?.[1] ?? null + const appTag = text.match(/]+>/)?.[0] ?? '' + const appClass = appTag.match(/android:name="([^"]+)"/)?.[1] ?? null + const networkSecurityConfig = + text.match(/android:networkSecurityConfig="@xml\/([^"]+)"/)?.[1] ?? null + const permissions = unique( + matchAll(/]+android:name="([^"]+)"/g, text).map((m) => m[1]), + ) + const deepLinks = unique( + matchAll(/]*\/>/g, text) + .map((m) => { + const tag = m[0] + const scheme = tag.match(/android:scheme="([^"]+)"/)?.[1] + const host = tag.match(/android:host="([^"]+)"/)?.[1] + return scheme ? `${scheme}${host ? `://${host}` : ':'}` : null + }) + .filter((s): s is string => Boolean(s)), + ) + return { packageName, appClass, networkSecurityConfig, permissions, deepLinks } +} + +function parseStringsXml(text: string): Record { + const strings: Record = {} + for (const m of matchAll(/([\s\S]*?)<\/string>/g, text)) { + const value = m[2].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + if (/https?:\/\/|token|api|firebase|sender|bucket|project|url/i.test(`${m[1]} ${value}`)) { + strings[m[1]] = value + } + } + return strings +} + +function parseApiClass(path: string, text: string): ApiClass { + const packageName = text.match(/^package\s+([^;]+);/m)?.[1] ?? null + const cls = text.match(/\bclass\s+([A-Za-z0-9_$]+)(?:\s+extends\s+([A-Za-z0-9_$]+))?/) + const responseType = text.match(/mo562b\(([A-Za-z0-9_.$]+)\.class\)/)?.[1] ?? null + const paths = unique( + matchAll(/mo2914w\(\)[\s\S]*?return\s+"([^"]+)"/g, text) + .map((m) => m[1]) + .filter((s) => s.startsWith('/')), + ) + const trCodes = unique(matchAll(/return\s+"([A-Z][0-9]{4})"/g, text).map((m) => m[1])) + const method = text.match(/mo2912u\(\)[\s\S]*?return\s+"(GET|POST|PUT|DELETE)"/)?.[1] ?? null + const requestParams = unique(matchAll(/m2906n\("([^"]+)"/g, text).map((m) => m[1])) + const fixedFields = matchAll(/m904(?:7H|8I|9J)\((\d+),\s*([^)]+)\)/g, text).map((m) => ({ + line: lineNo(text, m.index), + width: Number(m[1]), + value: m[2].trim(), + })) + const responseReads = matchAll(/(?:(set[A-Za-z0-9_]+)\()?m9051V\((\d+)\)/g, text).map((m) => ({ + line: lineNo(text, m.index), + setter: m[1] ?? null, + width: Number(m[2]), + })) + + return { + file: relative(process.cwd(), path), + packageName, + className: cls?.[1] ?? null, + extendsName: cls?.[2] ?? null, + responseType, + trCodes, + paths, + method, + requestParams, + fixedFields, + responseReads, + } +} + +function toMarkdown(finding: Finding): string { + const lines: string[] = [] + lines.push('# SBI APK API analysis') + lines.push('') + lines.push(`Generated: ${finding.generatedAt}`) + lines.push('') + lines.push('## App') + lines.push('') + lines.push(`- Package: \`${finding.app.packageName ?? 'unknown'}\``) + lines.push(`- Application: \`${finding.app.appClass ?? 'unknown'}\``) + lines.push(`- Cleartext traffic: \`${finding.app.cleartextTrafficPermitted}\``) + lines.push(`- Network security config: \`${finding.app.networkSecurityConfig ?? 'none'}\``) + lines.push(`- Deep links: ${finding.app.deepLinks.map((s) => `\`${s}\``).join(', ')}`) + lines.push(`- Native libs: ${finding.app.nativeLibs.map((s) => `\`${s}\``).join(', ')}`) + lines.push('') + lines.push('## Important URLs') + lines.push('') + for (const url of finding.urls) lines.push(`- ${url}`) + lines.push('') + lines.push('## MTS protocol') + lines.push('') + lines.push(`- Base URL default: \`${finding.api.baseUrls[0] ?? 'unknown'}\``) + lines.push(`- AppConfig: \`${finding.api.appConfigUrls[0] ?? 'unknown'}\``) + lines.push(`- Main endpoint: \`POST ${finding.api.mts.endpointPath}\``) + lines.push(`- Login endpoint: \`POST ${finding.api.mts.loginPath}\``) + lines.push(`- Logout endpoint: \`POST ${finding.api.mts.logoutPath}\``) + lines.push( + `- Content-Type: \`${finding.api.mts.contentType}\`, encoding: \`${finding.api.mts.encoding}\``, + ) + lines.push('') + lines.push( + 'MTS requests are form-encoded. Common fields are `SID`, `TRCODE`, `FSTIME`, and `TRIN`; `TRIN` is a Shift_JIS fixed-width concatenation built by `m9047H/m9049J`.', + ) + lines.push('') + lines.push('## MTS classes and TRCODEs') + lines.push('') + lines.push('| Class | Response | TRCODE(s) | Request widths | Response reads | File |') + lines.push('|---|---|---:|---:|---:|---|') + for (const c of finding.api.mts.classes) { + lines.push( + `| \`${c.className ?? ''}\` | \`${c.responseType ?? ''}\` | ${c.trCodes.map((x) => `\`${x}\``).join(', ')} | ${c.fixedFields.length} | ${c.responseReads.length} | \`${c.file}\` |`, + ) + } + lines.push('') + lines.push('## Resource strings') + lines.push('') + for (const [k, v] of Object.entries(finding.resourceStrings)) lines.push(`- \`${k}\`: \`${v}\``) + lines.push('') + lines.push('## Notes') + lines.push('') + for (const note of finding.notes) lines.push(`- ${note}`) + lines.push('') + return lines.join('\n') +} + +const manifestText = readMaybe(join(apktoolRoot, 'AndroidManifest.xml')) ?? '' +const manifest = parseManifest(manifestText) +const networkXml = manifest.networkSecurityConfig + ? readMaybe(join(apktoolRoot, 'res/xml', `${manifest.networkSecurityConfig}.xml`)) + : null +const cleartext = networkXml?.includes('cleartextTrafficPermitted="false"') + ? false + : networkXml?.includes('cleartextTrafficPermitted="true"') + ? true + : null +const strings = parseStringsXml(readMaybe(join(apktoolRoot, 'res/values/strings.xml')) ?? '') +const nativeLibs = walk(join(apktoolRoot, 'lib'), (p) => p.endsWith('.so')) + .map((p) => relative(join(apktoolRoot, 'lib'), p)) + .sort() + +const javaFiles = walk(join(jadxRoot, 'sources'), (p) => p.endsWith('.java')) +const allJavaText = javaFiles.map((p) => readFileSync(p, 'utf8')).join('\n') +const c1209a = readMaybe(join(jadxRoot, 'sources/p109L4/C1209a.java')) ?? '' +const apiFiles = javaFiles.filter( + (p) => p.includes('/jp/co/sbisec/sbikabu/api/') || p.includes('/p118M4/'), +) +const parsed = apiFiles.map((p) => parseApiClass(p, readFileSync(p, 'utf8'))) +const mtsClasses = parsed + .filter((c) => c.trCodes.length > 0 || c.paths.some((p) => p.includes('mtsmobile'))) + .sort((a, b) => + (a.trCodes[0] ?? a.className ?? '').localeCompare(b.trCodes[0] ?? b.className ?? ''), + ) + +const urls = unique([ + ...extractUrls(allJavaText), + ...extractUrls(Object.values(strings).join('\n')), + ...extractUrls( + readMaybe(join(apktoolRoot, 'assets/fraudalert/FraudAlertSDK_Setting_Prod.json')) ?? '', + ), +]).sort() + +const finding: Finding = { + generatedAt: new Date().toISOString(), + input: { jadx: jadxRoot, apktool: apktoolRoot }, + app: { + packageName: manifest.packageName, + appClass: manifest.appClass, + networkSecurityConfig: manifest.networkSecurityConfig, + cleartextTrafficPermitted: cleartext, + deepLinks: manifest.deepLinks, + permissions: manifest.permissions, + nativeLibs, + }, + urls, + resourceStrings: strings, + api: { + baseUrls: extractUrls(c1209a), + appConfigUrls: extractUrls(c1209a).filter((u) => u.endsWith('/AppConfig.json')), + mts: { + endpointPath: '/mtsmobile/commgate', + loginPath: '/mtsmobile/loginesgate', + logoutPath: '/mtsmobile/logoutgate', + contentType: 'application/x-www-form-urlencoded', + encoding: 'Shift_JIS', + classes: mtsClasses, + }, + otherClasses: parsed.filter( + (c) => !mtsClasses.includes(c) && (c.paths.length > 0 || c.responseType), + ), + }, + notes: [ + 'Eversafe and FraudAlert SDKs are present. Eversafe includes trustkit/pinning code and native libraries.', + 'MTS API classes are obfuscated, but response class names and TRCODE constants are recoverable from jadx output.', + 'The production AppConfig can override MTS and related URLs at runtime via AppConfigResponse.AppConfig.mtsURL.', + ], +} + +mkdirSync(outDir, { recursive: true }) +writeFileSync(join(outDir, 'sbi-apk-api-analysis.json'), `${JSON.stringify(finding, null, 2)}\n`) +writeFileSync(join(outDir, 'sbi-apk-api-analysis.md'), toMarkdown(finding)) + +console.log(`Wrote ${join(outDir, 'sbi-apk-api-analysis.json')}`) +console.log(`Wrote ${join(outDir, 'sbi-apk-api-analysis.md')}`) +console.log(`MTS classes: ${finding.api.mts.classes.length}`) +console.log(`URLs: ${finding.urls.length}`) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -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 + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a92327c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from 'vite-plus' + +export default defineConfig({ + fmt: { + singleQuote: true, + semi: false, + }, + run: { + cache: true, + tasks: { + 'app:dev': { + command: 'vp run @repo/csbie#dev', + cache: false, + dependsOn: ['env:doctor'], + }, + 'app:build': { + command: 'vp run @repo/csbie#build', + dependsOn: ['typecheck:all'], + }, + 'app:start': { + command: 'vp run @repo/csbie#start', + cache: false, + }, + 'server:db:push': { + command: 'vp run @repo/csbie-server#db:push', + cache: false, + dependsOn: ['env:doctor'], + }, + 'env:doctor': { + command: 'bun --env-file=.env scripts/check-env.ts', + cache: false, + }, + 'typecheck:all': { + command: 'vp run --filter "./apps/*" --filter "./packages/*" typecheck', + }, + clean: { + command: 'vp run --filter "./apps/*" --filter "./packages/*" clean', + cache: false, + }, + verify: { + command: ['vp check', 'vp run typecheck:all', 'vp run app:build'], + }, + }, + }, +})