commit 20434dcd11ca7dc686ec9a3b2fd6c7ab01b78890 Author: Shotaro Nakamura Date: Thu Jun 18 22:59:53 2026 +0900 first commit 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 0000000..e1cbfed Binary files /dev/null and b/apps/csbie-ui/src/assets/search-empty.png differ 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'], + }, + }, + }, +})