first commit
This commit is contained in:
@@ -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=
|
||||
+63
@@ -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/
|
||||
@@ -0,0 +1,10 @@
|
||||
## Rules
|
||||
|
||||
- Run `bun fmt` and `bun typecheck` before finishing.
|
||||
- Do not hardcode real endpoint
|
||||
|
||||
## Hints
|
||||
|
||||
- 技術的に不可能なことはできないと応答する。代わりに fallback を使ったりはしない。相談する。
|
||||
- fallback は控える。エラーを積極的に出す。
|
||||
- 実際の passkey.json を利用して、SDK を実際に実行して直ったか確認する
|
||||
@@ -0,0 +1,5 @@
|
||||
# cSBI
|
||||
|
||||
[](https://技術者倫理.com)
|
||||
|
||||
A client for SBI-compatible servers. This project is not intended for use with a real SBI server.
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<AppBindings>()
|
||||
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 }
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<typeof createDb>
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -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<string[] | undefined>(),
|
||||
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<string[] | null>(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }),
|
||||
revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }),
|
||||
},
|
||||
(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<Record<string, unknown>>().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<string[]>().notNull(),
|
||||
resource: text('resource'),
|
||||
apiKeySettings: text('api_key_settings', { mode: 'json' }).$type<Record<
|
||||
string,
|
||||
unknown
|
||||
> | 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<string[]>().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(),
|
||||
})
|
||||
@@ -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}`)
|
||||
@@ -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<AppBindings> = async (c, next) => {
|
||||
if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401)
|
||||
await next()
|
||||
}
|
||||
|
||||
export const createAdminRoutes = () => {
|
||||
const app = new Hono<AppBindings>()
|
||||
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<ApiKeySettings>()
|
||||
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
|
||||
}
|
||||
@@ -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<AppBindings>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<string, OrderSubmitTicket>()
|
||||
|
||||
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<Record<RpcMethod, RpcMethod>>
|
||||
|
||||
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<string, unknown>).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<RpcMethod, z.ZodType | undefined>
|
||||
|
||||
const createMcpServer = (c: Context<AppBindings>) => {
|
||||
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<StoredSbiPasskeySecret>(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<StoredSbiPasskeySecret>(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<AppBindings>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<AppBindings>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<string>(RPC_METHODS)
|
||||
const tradingMethods = new Set<string>([
|
||||
'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<string>([
|
||||
'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<unknown>((value, key) => {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
return (value as Record<string, unknown>)[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)
|
||||
}
|
||||
@@ -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<SbiClientMethods> => {
|
||||
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<StoredSbiPasskeySecret>(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,
|
||||
)
|
||||
}
|
||||
@@ -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<string, AbortController>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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)
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <T>(account: string): Promise<T> => {
|
||||
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)
|
||||
}
|
||||
@@ -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<string, unknown> | 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<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
||||
) => {
|
||||
const clientInfo = client as OAuthClientInformationFull
|
||||
await db
|
||||
.insert(oauthClients)
|
||||
.values({
|
||||
id: clientInfo.client_id,
|
||||
client: clientInfo as unknown as Record<string, unknown>,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: oauthClients.id,
|
||||
set: { client: clientInfo as unknown as Record<string, unknown> },
|
||||
})
|
||||
return clientInfo
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
authorize: async (client, params, c: Context<AppBindings>) => {
|
||||
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)))
|
||||
},
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, unknown>)[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}`)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CSBIE</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { AnimatePresence } from 'motion-v'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { revokeApiKey } from './api'
|
||||
import AppHeader from './components/layout/AppHeader.vue'
|
||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||
import SearchDialog from './features/search/SearchDialog.vue'
|
||||
import AuthGate from './features/auth/AuthGate.vue'
|
||||
import { useAuthAdmin } from './features/auth/useAuthAdmin'
|
||||
import HistoryView from './features/history/HistoryView.vue'
|
||||
import OAuthApprovalPanel from './features/oauth/OAuthApprovalPanel.vue'
|
||||
import { useOAuthApproval } from './features/oauth/useOAuthApproval'
|
||||
import OrderDialogs from './features/orders/OrderDialogs.vue'
|
||||
import PortfolioView from './features/portfolio/PortfolioView.vue'
|
||||
import SettingsView from './features/settings/SettingsView.vue'
|
||||
import TradeView from './features/trade/TradeView.vue'
|
||||
import { useTradingSession } from './features/trading/useTradingSession'
|
||||
import { routeNames, type RouteName } from './router'
|
||||
import { ui } from './styles/ui'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = computed<RouteName>(() => {
|
||||
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
|
||||
})
|
||||
const showAuthGate = computed(() => true)
|
||||
const navigate = (name: RouteName) => router.push({ name })
|
||||
|
||||
const {
|
||||
status,
|
||||
apiKeys,
|
||||
sbiPasskeys,
|
||||
selectedPasskeyId,
|
||||
setupPassword,
|
||||
authBusy,
|
||||
apiKeyLabel,
|
||||
newApiKeySettings,
|
||||
newApiToken,
|
||||
sbiLabel,
|
||||
sbiCredentialJson,
|
||||
tradePassword,
|
||||
sbiDeviceId,
|
||||
refresh,
|
||||
addApiKey,
|
||||
saveApiKeySettings,
|
||||
setupOwnerPasskey,
|
||||
loginWithPasskey,
|
||||
addSbiPasskey,
|
||||
removeSbiPasskey,
|
||||
} = useAuthAdmin()
|
||||
|
||||
const {
|
||||
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,
|
||||
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,
|
||||
} = useTradingSession(selectedPasskeyId)
|
||||
|
||||
const { oauthApproval, oauthSettings, loadOAuthApproval, approveOAuth } = useOAuthApproval()
|
||||
|
||||
const refreshAndMaybeConnect = () =>
|
||||
refresh({
|
||||
autoConnect: true,
|
||||
connect: () => {
|
||||
if (!connected.value) connect()
|
||||
},
|
||||
})
|
||||
|
||||
const addSbiPasskeyAndConnect = async () => {
|
||||
await addSbiPasskey()
|
||||
connect()
|
||||
}
|
||||
|
||||
const revokeAndRefresh = async (id: string) => {
|
||||
await revokeApiKey(id)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await refreshAndMaybeConnect()
|
||||
} catch {
|
||||
status.value = { configured: false, authenticated: false }
|
||||
}
|
||||
await loadOAuthApproval()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main :class="ui.appShell">
|
||||
<AppSidebar :active-tab="activeTab" @navigate="navigate" />
|
||||
|
||||
<section :class="ui.workspace">
|
||||
<AppHeader :active-tab="activeTab" />
|
||||
|
||||
<OAuthApprovalPanel
|
||||
v-if="oauthApproval.active && status.authenticated"
|
||||
v-model:settings="oauthSettings"
|
||||
:approval="oauthApproval"
|
||||
@approve="approveOAuth"
|
||||
/>
|
||||
|
||||
<AuthGate
|
||||
v-else-if="showAuthGate && !status.authenticated"
|
||||
v-model:setup-password="setupPassword"
|
||||
:status="status"
|
||||
:auth-busy="authBusy"
|
||||
@login="loginWithPasskey"
|
||||
@setup="setupOwnerPasskey"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<PortfolioView
|
||||
v-if="activeTab === 'portfolio'"
|
||||
:show-portfolio-spinner="showPortfolioSpinner"
|
||||
:total-asset-value="totalAssetValue"
|
||||
:buying-power="buyingPower"
|
||||
:holdings-market-value="holdingsMarketValue"
|
||||
:total-profit-loss="totalProfitLoss"
|
||||
:total-profit-loss-rate="totalProfitLossRate"
|
||||
:stock-asset-ratio="stockAssetRatio"
|
||||
:cash-asset-ratio="cashAssetRatio"
|
||||
:positions="positions"
|
||||
:recent-orders="recentOrders"
|
||||
:data-loading="dataLoading"
|
||||
:connected="connected"
|
||||
:order-history-loaded="orderHistoryLoaded"
|
||||
:order-history-notice="orderHistoryNotice"
|
||||
@connect="connect"
|
||||
@open-position="(code) => openTradeForPosition(code, () => void navigate('trade'))"
|
||||
/>
|
||||
|
||||
<TradeView
|
||||
v-if="activeTab === 'trade'"
|
||||
v-model:trade-side="tradeSide"
|
||||
v-model:order-kind="orderKind"
|
||||
v-model:cash-order-account-type="cashOrderAccountType"
|
||||
v-model:cash-order-market="cashOrderMarket"
|
||||
v-model:cash-order-price-condition="cashOrderPriceCondition"
|
||||
v-model:cash-order-term="cashOrderTerm"
|
||||
v-model:cash-order-date-input="cashOrderDateInput"
|
||||
v-model:cash-order-method="cashOrderMethod"
|
||||
v-model:cash-order-trigger-zone="cashOrderTriggerZone"
|
||||
v-model:cash-order-trigger-price-input="cashOrderTriggerPriceInput"
|
||||
v-model:cash-order-secondary-price-condition="cashOrderSecondaryPriceCondition"
|
||||
v-model:cash-order-secondary-price-input="cashOrderSecondaryPriceInput"
|
||||
v-model:quantity-input="quantityInput"
|
||||
v-model:price-input="priceInput"
|
||||
v-model:chart-mode="chartMode"
|
||||
:viewed-stocks="viewedStocks"
|
||||
:selected-stock="selectedStock"
|
||||
:selected-position="selectedPosition"
|
||||
:connected="connected"
|
||||
:order-quantity="orderQuantity"
|
||||
:estimated-amount="estimatedAmount"
|
||||
:can-request-cash-estimate="canRequestCashEstimate"
|
||||
:can-place-cash-order="canPlaceCashOrder"
|
||||
:realtime-price-points="chartPricePoints"
|
||||
:price-polling="pricePolling"
|
||||
:box-plot-style="boxPlotStyle"
|
||||
:has-quote="hasQuote"
|
||||
@open-search="showSearch = true"
|
||||
@select-stock="selectStock"
|
||||
@download-csv="downloadCsv"
|
||||
@estimate="estimateCashOrder"
|
||||
@confirm-order="askPlaceOrder"
|
||||
/>
|
||||
|
||||
<HistoryView
|
||||
v-if="activeTab === 'history'"
|
||||
:orders="orders"
|
||||
:connected="connected"
|
||||
:data-loading="dataLoading"
|
||||
:order-history-loaded="orderHistoryLoaded"
|
||||
:order-history-notice="orderHistoryNotice"
|
||||
@refresh="loadTradingData"
|
||||
@cancel="cancelOrder"
|
||||
/>
|
||||
|
||||
<SettingsView
|
||||
v-if="activeTab === 'settings'"
|
||||
v-model:api-key-label="apiKeyLabel"
|
||||
v-model:new-api-key-settings="newApiKeySettings"
|
||||
v-model:new-api-token="newApiToken"
|
||||
v-model:sbi-label="sbiLabel"
|
||||
v-model:sbi-credential-json="sbiCredentialJson"
|
||||
v-model:trade-password="tradePassword"
|
||||
v-model:sbi-device-id="sbiDeviceId"
|
||||
v-model:selected-passkey-id="selectedPasskeyId"
|
||||
:api-keys="apiKeys"
|
||||
:sbi-passkeys="sbiPasskeys"
|
||||
@add-api-key="addApiKey"
|
||||
@refresh="refresh"
|
||||
@save-api-key-settings="saveApiKeySettings"
|
||||
@revoke-api-key="revokeAndRefresh"
|
||||
@add-sbi-passkey="addSbiPasskeyAndConnect"
|
||||
@connect="connect"
|
||||
@remove-sbi-passkey="removeSbiPasskey"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<AnimatePresence>
|
||||
<SearchDialog
|
||||
v-if="showSearch"
|
||||
key="search-dialog"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:country-filter="countryFilter"
|
||||
v-model:market-filter="marketFilter"
|
||||
:stocks="filteredStocks"
|
||||
:selected-stock-code="selectedStockCode"
|
||||
:countries="countries"
|
||||
:markets="markets"
|
||||
:loading="searchLoading"
|
||||
@close="showSearch = false"
|
||||
@select="(stock) => openTradeForStock(stock, () => void navigate('trade'))"
|
||||
/>
|
||||
</AnimatePresence>
|
||||
|
||||
<OrderDialogs
|
||||
:estimate="lastCashEstimate"
|
||||
:show-estimate="showEstimateDialog"
|
||||
:show-order="showOrderDialog"
|
||||
:stock-name="selectedStock.name"
|
||||
:side="tradeSide"
|
||||
:kind="orderKind"
|
||||
:account-type="cashOrderAccountType"
|
||||
:market="cashOrderMarket"
|
||||
:price-condition="cashOrderPriceCondition"
|
||||
:price="cashOrderPrimaryRequiresPrice ? orderPrice : 0"
|
||||
:order-term="cashOrderTerm"
|
||||
:order-date="cashOrderDateInput"
|
||||
:order-method="cashOrderMethod"
|
||||
:trigger-zone="cashOrderTriggerZone"
|
||||
:trigger-price="cashOrderTriggerPrice"
|
||||
:secondary-price-condition="cashOrderSecondaryPriceCondition"
|
||||
:secondary-price="cashOrderSecondaryPrice"
|
||||
:quantity="orderQuantity"
|
||||
:amount="estimatedAmount"
|
||||
@close-estimate="showEstimateDialog = false"
|
||||
@proceed="askPlaceOrder"
|
||||
@close-order="showOrderDialog = false"
|
||||
@place="placeCashOrder"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
@@ -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 <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
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<T>
|
||||
}
|
||||
|
||||
export const getStatus = () => request<AuthStatus>('/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`)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import type { ApiKeySettings } from '../api'
|
||||
|
||||
defineProps<{
|
||||
compact?: boolean
|
||||
permissionsOpen?: boolean
|
||||
}>()
|
||||
|
||||
const settings = defineModel<ApiKeySettings>({ required: true })
|
||||
|
||||
const rpcMethods = [
|
||||
'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
|
||||
|
||||
const tradingMethods = [
|
||||
'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',
|
||||
] as const
|
||||
|
||||
const tradingMethodSet = new Set<string>(tradingMethods)
|
||||
const readMethods = rpcMethods.filter((method) => !tradingMethodSet.has(method))
|
||||
|
||||
const setMethods = (methods: readonly string[] | null) => {
|
||||
settings.value.allowedMethods = methods ? [...methods] : null
|
||||
}
|
||||
|
||||
const toggleMethod = (method: string) => {
|
||||
const current = settings.value.allowedMethods ?? [...rpcMethods]
|
||||
settings.value.allowedMethods = current.includes(method)
|
||||
? current.filter((candidate) => candidate !== method)
|
||||
: [...current, method].sort()
|
||||
}
|
||||
|
||||
const methodAllowed = (method: string) => {
|
||||
return settings.value.allowedMethods == null || settings.value.allowedMethods.includes(method)
|
||||
}
|
||||
|
||||
const ui = {
|
||||
root: 'grid gap-3',
|
||||
limitGrid: 'grid grid-cols-3 gap-3',
|
||||
limitGridCompact: 'grid-cols-5',
|
||||
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 focus:border-[#a8c7fa]',
|
||||
permissions: 'border-t border-[#33383f] pt-3',
|
||||
summary: 'cursor-pointer font-black text-[#e3e3e9]',
|
||||
actions: 'mt-3 flex flex-wrap gap-2',
|
||||
button:
|
||||
'min-h-9 rounded-full bg-[#263141] px-4 text-sm font-extrabold text-[#d3e3fd] transition hover:bg-[#303b4d]',
|
||||
methodGrid: 'mt-3 grid grid-cols-2 gap-2',
|
||||
methodToggle:
|
||||
'flex min-h-10 items-center gap-2 rounded-[16px] bg-[#111418] p-3 text-xs text-[#c3c7cf]',
|
||||
checkbox: 'h-4 min-h-4 w-4',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="ui.root">
|
||||
<div :class="[ui.limitGrid, compact && ui.limitGridCompact]">
|
||||
<label :class="ui.label">
|
||||
1時間 取引上限
|
||||
<input v-model.number="settings.maxTradesPerHour" :class="ui.input" type="number" min="0" />
|
||||
</label>
|
||||
<label :class="ui.label">
|
||||
3時間 取引上限
|
||||
<input
|
||||
v-model.number="settings.maxTradesPer6Hours"
|
||||
:class="ui.input"
|
||||
type="number"
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
<label :class="ui.label">
|
||||
1日 取引上限
|
||||
<input v-model.number="settings.maxTradesPerDay" :class="ui.input" type="number" min="0" />
|
||||
</label>
|
||||
<label :class="ui.label">
|
||||
1注文 最大価格
|
||||
<input v-model.number="settings.maxOrderPriceJpy" :class="ui.input" type="number" min="0" />
|
||||
</label>
|
||||
<label :class="ui.label">
|
||||
1注文 最大取引価格
|
||||
<input
|
||||
v-model.number="settings.maxOrderAmountJpy"
|
||||
:class="ui.input"
|
||||
type="number"
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details :class="ui.permissions" :open="permissionsOpen">
|
||||
<summary :class="ui.summary">
|
||||
権限:
|
||||
{{
|
||||
settings.allowedMethods == null ? 'すべて' : `${settings.allowedMethods.length} methods`
|
||||
}}
|
||||
</summary>
|
||||
<div :class="ui.actions">
|
||||
<button :class="ui.button" type="button" @click="setMethods(null)">すべて</button>
|
||||
<button :class="ui.button" type="button" @click="setMethods(readMethods)">参照のみ</button>
|
||||
<button :class="ui.button" type="button" @click="setMethods(tradingMethods)">
|
||||
取引のみ
|
||||
</button>
|
||||
</div>
|
||||
<div :class="ui.methodGrid">
|
||||
<label v-for="method in rpcMethods" :key="method" :class="ui.methodToggle">
|
||||
<input
|
||||
:class="ui.checkbox"
|
||||
type="checkbox"
|
||||
:checked="methodAllowed(method)"
|
||||
@change="toggleMethod(method)"
|
||||
/>
|
||||
<span>{{ method }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { pageMeta } from '../../constants/nav'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { RouteName } from '../../router'
|
||||
|
||||
const props = defineProps<{
|
||||
activeTab: RouteName
|
||||
}>()
|
||||
|
||||
const meta = computed(() => pageMeta[props.activeTab])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header :class="[ui.topbar, activeTab === 'trade' && ui.tradeTopbar]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="grid h-11 w-11 place-items-center rounded-2xl bg-[#263141] text-[#d3e3fd]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<component :is="meta.icon" class="h-5 w-5" :stroke-width="2.4" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 class="text-3xl font-black tracking-normal text-[#e3e3e9]">
|
||||
{{ meta.title }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity } from 'lucide-vue-next'
|
||||
import { sidebarItems } from '../../constants/nav'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { RouteName } from '../../router'
|
||||
|
||||
defineProps<{
|
||||
activeTab: RouteName
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [name: RouteName]
|
||||
}>()
|
||||
|
||||
const items = sidebarItems
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="ui.sidebar">
|
||||
<div :class="ui.brandMark" aria-label="CSBIE">
|
||||
<Activity class="h-8 w-8" :stroke-width="2.75" aria-hidden="true" />
|
||||
</div>
|
||||
<nav :class="ui.navStack" aria-label="Main">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.name"
|
||||
:class="[ui.navButton, activeTab === item.name && ui.navButtonActive]"
|
||||
type="button"
|
||||
@click="emit('navigate', item.name)"
|
||||
>
|
||||
<span :class="[ui.navIcon, activeTab === item.name && ui.navIconActive]">
|
||||
<component :is="item.icon" class="h-5 w-5" :stroke-width="2.4" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="flex-1"></div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from 'lucide-vue-next'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}>(),
|
||||
{
|
||||
label: '読み込み中',
|
||||
size: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
const sizeClass = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoaderCircle
|
||||
:class="['inline-block animate-spin text-current', sizeClass[size]]"
|
||||
role="status"
|
||||
:aria-label="label"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'danger' | 'ghost' | 'plain'
|
||||
size?: 'sm' | 'md'
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(),
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
type: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
const variantClass = {
|
||||
primary:
|
||||
'bg-[#a8c7fa] text-[#102033] shadow-sm shadow-black/20 hover:bg-[#d3e3fd] active:bg-[#c2d7f4]',
|
||||
danger:
|
||||
'bg-[#ffb4ab] text-[#690005] shadow-sm shadow-black/20 hover:bg-[#ffd8d3] active:bg-[#ffc9c2]',
|
||||
ghost:
|
||||
'border border-[#4a5058] bg-transparent text-[#d3e3fd] hover:bg-[#263141] active:bg-[#303b4d]',
|
||||
plain: 'bg-transparent text-[#c3c7cf] hover:bg-[#22272e] hover:text-[#e3e3e9]',
|
||||
}
|
||||
|
||||
const sizeClass = {
|
||||
sm: 'min-h-8 px-4 text-xs',
|
||||
md: 'min-h-10 px-5 text-sm',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:class="[
|
||||
'rounded-full font-extrabold outline-none transition disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'inline-flex items-center justify-center gap-2',
|
||||
variantClass[variant],
|
||||
sizeClass[size],
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
padded?: boolean
|
||||
}>(),
|
||||
{
|
||||
padded: true,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
:class="[
|
||||
'rounded-[28px] border border-[#30343a] bg-[#1b1f24] shadow-lg shadow-black/15',
|
||||
padded && 'p-6',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, useTemplateRef } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
as?: 'input' | 'textarea' | 'select'
|
||||
label?: string
|
||||
modelValue?: string | number
|
||||
type?: string
|
||||
placeholder?: string
|
||||
autocomplete?: string
|
||||
spellcheck?: boolean
|
||||
readonly?: boolean
|
||||
disabled?: boolean
|
||||
min?: string | number
|
||||
autofocus?: boolean
|
||||
}>(),
|
||||
{
|
||||
as: 'input',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
autocomplete: undefined,
|
||||
spellcheck: undefined,
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
min: undefined,
|
||||
autofocus: false,
|
||||
},
|
||||
)
|
||||
|
||||
const controlRef = useTemplateRef<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||
'control',
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.autofocus) return
|
||||
nextTick(() => controlRef.value?.focus())
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const controlClass =
|
||||
'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] disabled:cursor-not-allowed disabled:opacity-60'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="grid gap-2 text-xs font-extrabold text-[#9aa0a9]">
|
||||
<span v-if="label">{{ label }}</span>
|
||||
<textarea
|
||||
v-if="as === 'textarea'"
|
||||
ref="control"
|
||||
:class="[controlClass, 'min-h-32 resize-y py-3']"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:spellcheck="spellcheck"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
<select
|
||||
v-else-if="as === 'select'"
|
||||
ref="control"
|
||||
:class="controlClass"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<slot />
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
ref="control"
|
||||
:class="controlClass"
|
||||
:value="modelValue"
|
||||
:type="type"
|
||||
:min="min"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:spellcheck="spellcheck"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
eyebrow?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-20 grid place-items-center bg-[#101418]/75 backdrop-blur-sm">
|
||||
<section
|
||||
class="grid w-[28rem] gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-2xl shadow-black/35"
|
||||
>
|
||||
<p v-if="eyebrow" class="text-xs font-black uppercase text-[#9aa0a9]">{{ eyebrow }}</p>
|
||||
<h2 class="text-lg font-bold text-[#e3e3e9]">{{ title }}</h2>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
options: Array<{ label: string; value: T; tone?: 'buy' | 'sell' }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: T]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid min-h-12 grid-cols-2 gap-1 rounded-full bg-[#111418] p-1 outline outline-1 outline-[#33383f]"
|
||||
>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:class="[
|
||||
'min-h-10 rounded-full bg-transparent text-sm font-bold text-[#9aa0a9] transition hover:bg-[#22272e]',
|
||||
modelValue === option.value && option.tone === 'buy' && '!bg-[#40dba2] !text-[#003824]',
|
||||
modelValue === option.value && option.tone === 'sell' && '!bg-[#ffb4ab] !text-[#690005]',
|
||||
modelValue === option.value && !option.tone && '!bg-[#263141] !text-[#d3e3fd]',
|
||||
]"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<RouteName, PageMeta> = {
|
||||
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 },
|
||||
]
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { LogIn, UserPlus } from 'lucide-vue-next'
|
||||
import type { AuthStatus } from '../../api'
|
||||
import { ui } from '../../styles/ui'
|
||||
|
||||
defineProps<{
|
||||
status: AuthStatus
|
||||
authBusy: boolean
|
||||
}>()
|
||||
|
||||
const setupPassword = defineModel<string>('setupPassword', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
login: []
|
||||
setup: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="ui.authPanel">
|
||||
<article :class="[ui.panel, ui.loginPanel]">
|
||||
<div>
|
||||
<p :class="ui.eyebrow">{{ status.configured ? 'Login' : 'Initial setup' }}</p>
|
||||
<h2>{{ status.configured ? 'パスキーでログイン' : '初期セットアップ' }}</h2>
|
||||
</div>
|
||||
<label v-if="!status.configured" :class="ui.label">
|
||||
セットアップパスワード
|
||||
<input
|
||||
v-model="setupPassword"
|
||||
:class="ui.input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="status.configured"
|
||||
:class="ui.primaryButton"
|
||||
type="button"
|
||||
:disabled="authBusy"
|
||||
@click="emit('login')"
|
||||
>
|
||||
<LogIn class="h-4 w-4" aria-hidden="true" />
|
||||
ログイン
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:class="ui.primaryButton"
|
||||
type="button"
|
||||
:disabled="authBusy"
|
||||
@click="emit('setup')"
|
||||
>
|
||||
<UserPlus class="h-4 w-4" aria-hidden="true" />
|
||||
登録
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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<AuthStatus>({ configured: false, authenticated: false })
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const sbiPasskeys = ref<SbiPasskey[]>([])
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { Ban, RefreshCw } from 'lucide-vue-next'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { OrderRow } from '../../types/trading'
|
||||
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
|
||||
|
||||
defineProps<{
|
||||
orders: OrderRow[]
|
||||
connected: boolean
|
||||
dataLoading: boolean
|
||||
orderHistoryLoaded: boolean
|
||||
orderHistoryNotice: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
cancel: [order: OrderRow]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="ui.panel">
|
||||
<div :class="ui.panelHead">
|
||||
<h2>取引履歴</h2>
|
||||
<button
|
||||
:class="ui.ghostButton"
|
||||
type="button"
|
||||
:disabled="!connected || dataLoading"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" aria-hidden="true" />
|
||||
履歴を更新
|
||||
</button>
|
||||
</div>
|
||||
<div :class="ui.list">
|
||||
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
|
||||
<span>
|
||||
<strong>{{ order.stock }}</strong>
|
||||
<small>{{ order.date }} / {{ order.kind === 's' ? 'S株' : '通常単元' }}</small>
|
||||
</span>
|
||||
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
|
||||
{{ order.side === 'buy' ? '購入' : '売却' }}
|
||||
</span>
|
||||
<span>{{ orderQuantityText(order) }}</span>
|
||||
<span>{{ orderAmountText(order) }}</span>
|
||||
<span :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
|
||||
{{ order.status }}
|
||||
</span>
|
||||
<button
|
||||
v-if="order.status === '注文中'"
|
||||
:class="ui.ghostButton"
|
||||
type="button"
|
||||
@click="emit('cancel', order)"
|
||||
>
|
||||
<Ban class="h-4 w-4" aria-hidden="true" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
|
||||
<Spinner />
|
||||
</div>
|
||||
<p v-else-if="!orders.length" :class="[ui.muted, 'py-8 text-center']">
|
||||
{{
|
||||
orderHistoryLoaded
|
||||
? orderHistoryNotice
|
||||
? `SBI SDK は取引履歴なしを返しました (${orderHistoryNotice})`
|
||||
: '該当する注文履歴はありません'
|
||||
: '注文履歴はまだ取得されていません'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ShieldCheck } from 'lucide-vue-next'
|
||||
import type { ApiKeySettings } from '../../api'
|
||||
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { OAuthApprovalState } from './useOAuthApproval'
|
||||
|
||||
defineProps<{
|
||||
approval: OAuthApprovalState
|
||||
}>()
|
||||
|
||||
const settings = defineModel<ApiKeySettings>('settings', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="ui.authPanel">
|
||||
<article :class="ui.panel">
|
||||
<div :class="ui.panelHead">
|
||||
<div>
|
||||
<p :class="ui.eyebrow">OAuth approval</p>
|
||||
<h2>{{ approval.clientName }}</h2>
|
||||
</div>
|
||||
<button :class="ui.primaryButton" type="button" @click="emit('approve')">
|
||||
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
|
||||
承認
|
||||
</button>
|
||||
</div>
|
||||
<label :class="ui.label">
|
||||
Redirect URI
|
||||
<input v-model="approval.redirectUri" :class="ui.input" readonly />
|
||||
</label>
|
||||
<ApiKeyPolicyEditor v-model="settings" permissions-open />
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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<OAuthApprovalState>({
|
||||
active: false,
|
||||
clientId: '',
|
||||
clientName: 'MCP client',
|
||||
redirectUri: '',
|
||||
codeChallenge: '',
|
||||
scope: '',
|
||||
state: '',
|
||||
resource: '',
|
||||
})
|
||||
const oauthSettings = ref<ApiKeySettings>(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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, Send, ShieldCheck } from 'lucide-vue-next'
|
||||
import UiButton from '../../components/ui/UiButton.vue'
|
||||
import UiModal from '../../components/ui/UiModal.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type {
|
||||
CashOrderAccountType,
|
||||
CashOrderMarket,
|
||||
CashOrderMethod,
|
||||
CashOrderPriceCondition,
|
||||
CashOrderTerm,
|
||||
CashOrderTriggerZone,
|
||||
OrderKind,
|
||||
OrderPreview,
|
||||
TradeSide,
|
||||
} from '../../types/trading'
|
||||
import { currency } from '../../utils/format'
|
||||
|
||||
defineProps<{
|
||||
estimate: OrderPreview | null
|
||||
showEstimate: boolean
|
||||
showOrder: boolean
|
||||
stockName: string
|
||||
side: TradeSide
|
||||
kind: OrderKind
|
||||
accountType: CashOrderAccountType
|
||||
market: CashOrderMarket
|
||||
priceCondition: CashOrderPriceCondition
|
||||
price: number
|
||||
orderTerm: CashOrderTerm
|
||||
orderDate: string
|
||||
orderMethod: CashOrderMethod
|
||||
triggerZone: CashOrderTriggerZone
|
||||
triggerPrice: number
|
||||
secondaryPriceCondition: CashOrderPriceCondition
|
||||
secondaryPrice: number
|
||||
quantity: number
|
||||
amount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
closeEstimate: []
|
||||
proceed: []
|
||||
closeOrder: []
|
||||
place: []
|
||||
}>()
|
||||
|
||||
const priceConditionLabel = (condition: CashOrderPriceCondition) =>
|
||||
({
|
||||
limit: '指値',
|
||||
limitAtOpen: '寄指',
|
||||
limitAtClose: '引指',
|
||||
limitIoc: 'IOC指',
|
||||
market: '成行',
|
||||
marketAtOpen: '寄成',
|
||||
marketAtClose: '引成',
|
||||
marketIoc: 'IOC成',
|
||||
funari: '不成',
|
||||
})[condition]
|
||||
|
||||
const orderTermLabel = (term: CashOrderTerm, date: string) =>
|
||||
term === 'day' ? '当日中' : term === 'week' ? '今週中' : date
|
||||
|
||||
const orderMethodLabel = (method: CashOrderMethod) =>
|
||||
method === 'normal' ? '通常' : method === 'stop' ? '逆指値' : 'OCO'
|
||||
|
||||
const triggerZoneLabel = (zone: CashOrderTriggerZone) => (zone === 'above' ? '以上' : '以下')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiModal v-if="showEstimate && estimate" eyebrow="見積" :title="stockName">
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>売買</dt>
|
||||
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>数量</dt>
|
||||
<dd>{{ quantity }}株</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>預り区分</dt>
|
||||
<dd>{{ accountType === 'specific' ? '特定' : '一般' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文市場</dt>
|
||||
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
|
||||
</div>
|
||||
<template v-if="kind !== 's'">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>執行条件</dt>
|
||||
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
|
||||
</div>
|
||||
<div v-if="price > 0" :class="ui.confirmRow">
|
||||
<dt>注文価格</dt>
|
||||
<dd>{{ currency(price) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>有効期限</dt>
|
||||
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>特殊注文</dt>
|
||||
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
|
||||
</div>
|
||||
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
|
||||
<dt>逆指値</dt>
|
||||
<dd>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</dd>
|
||||
</div>
|
||||
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
|
||||
<dt>OCO条件</dt>
|
||||
<dd>
|
||||
{{ priceConditionLabel(secondaryPriceCondition) }}
|
||||
<template v-if="secondaryPrice > 0"> {{ currency(secondaryPrice) }}</template>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>概算</dt>
|
||||
<dd>{{ currency(amount) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p :class="ui.dialogNote">
|
||||
{{ estimate.message ?? estimate.warnings.join(' ') }}
|
||||
</p>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="emit('closeEstimate')">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
戻る
|
||||
</UiButton>
|
||||
<UiButton @click="emit('proceed')">
|
||||
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
|
||||
注文確認へ
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
|
||||
<UiModal v-if="showOrder" eyebrow="注文確認" :title="stockName">
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>区分</dt>
|
||||
<dd>{{ kind === 's' ? 'S株' : '通常単元' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>売買</dt>
|
||||
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>数量</dt>
|
||||
<dd>{{ quantity }}株</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>預り区分</dt>
|
||||
<dd>{{ accountType === 'specific' ? '特定' : '一般' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文市場</dt>
|
||||
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
|
||||
</div>
|
||||
<template v-if="kind !== 's'">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>執行条件</dt>
|
||||
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
|
||||
</div>
|
||||
<div v-if="price > 0" :class="ui.confirmRow">
|
||||
<dt>注文価格</dt>
|
||||
<dd>{{ currency(price) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>有効期限</dt>
|
||||
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>特殊注文</dt>
|
||||
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
|
||||
</div>
|
||||
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
|
||||
<dt>逆指値</dt>
|
||||
<dd>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</dd>
|
||||
</div>
|
||||
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
|
||||
<dt>OCO条件</dt>
|
||||
<dd>
|
||||
{{ priceConditionLabel(secondaryPriceCondition) }}
|
||||
<template v-if="secondaryPrice > 0"> {{ currency(secondaryPrice) }}</template>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>概算</dt>
|
||||
<dd>{{ currency(amount) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="emit('closeOrder')">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
戻る
|
||||
</UiButton>
|
||||
<UiButton variant="danger" @click="emit('place')">
|
||||
<Send class="h-4 w-4" aria-hidden="true" />
|
||||
発注
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</template>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { Plug } from 'lucide-vue-next'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { OrderRow, Position } from '../../types/trading'
|
||||
import { currency, signedCurrency, signedPercent } from '../../utils/format'
|
||||
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
|
||||
|
||||
defineProps<{
|
||||
showPortfolioSpinner: boolean
|
||||
totalAssetValue: number
|
||||
buyingPower: number
|
||||
holdingsMarketValue: number
|
||||
totalProfitLoss: number
|
||||
totalProfitLossRate: number
|
||||
stockAssetRatio: number
|
||||
cashAssetRatio: number
|
||||
positions: Position[]
|
||||
recentOrders: OrderRow[]
|
||||
dataLoading: boolean
|
||||
connected: boolean
|
||||
orderHistoryLoaded: boolean
|
||||
orderHistoryNotice: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
connect: []
|
||||
openPosition: [code: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="ui.dashboardGrid">
|
||||
<article :class="ui.assetOverviewPanel">
|
||||
<div :class="ui.assetOverviewHead">
|
||||
<span :class="ui.metricLabel">総資産価値</span>
|
||||
<strong :class="ui.metricValue">
|
||||
<template v-if="!showPortfolioSpinner">{{ currency(totalAssetValue) }}</template>
|
||||
<Spinner v-else size="lg" />
|
||||
</strong>
|
||||
<small :class="ui.assetOverviewSubtext">
|
||||
余力:
|
||||
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</small>
|
||||
</div>
|
||||
<div :class="ui.assetBreakdownPanel">
|
||||
<span :class="ui.assetBreakdownTitle">内訳</span>
|
||||
<i :class="ui.assetBreakdownBar" aria-hidden="true">
|
||||
<b :class="ui.assetBreakdownStocks" :style="{ width: `${stockAssetRatio}%` }"></b>
|
||||
<b :class="ui.assetBreakdownCash" :style="{ width: `${cashAssetRatio}%` }"></b>
|
||||
</i>
|
||||
<div :class="ui.assetBreakdownRows">
|
||||
<div :class="ui.assetBreakdownRow">
|
||||
<span :class="ui.assetBreakdownLabel">
|
||||
<i :class="[ui.assetBreakdownSwatch, ui.assetBreakdownSwatchStocks]"></i>
|
||||
株式
|
||||
</span>
|
||||
<span :class="ui.assetBreakdownMeta">
|
||||
<strong :class="ui.assetBreakdownAmount">
|
||||
<template v-if="!showPortfolioSpinner">{{
|
||||
currency(holdingsMarketValue)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</strong>
|
||||
<small :class="ui.assetBreakdownRatio">
|
||||
<template v-if="!showPortfolioSpinner">{{ stockAssetRatio.toFixed(1) }}%</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<div :class="ui.assetBreakdownRow">
|
||||
<span :class="ui.assetBreakdownLabel">
|
||||
<i :class="[ui.assetBreakdownSwatch, ui.assetBreakdownSwatchCash]"></i>
|
||||
余力
|
||||
</span>
|
||||
<span :class="ui.assetBreakdownMeta">
|
||||
<strong :class="ui.assetBreakdownAmount">
|
||||
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</strong>
|
||||
<small :class="ui.assetBreakdownRatio">
|
||||
<template v-if="!showPortfolioSpinner">{{ cashAssetRatio.toFixed(1) }}%</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article :class="ui.metricPanel">
|
||||
<span :class="ui.metricLabel">通算評価損益</span>
|
||||
<strong :class="[ui.metricValue, totalProfitLoss >= 0 ? ui.positive : ui.negative]">
|
||||
<template v-if="!showPortfolioSpinner">
|
||||
{{ signedCurrency(totalProfitLoss) }}
|
||||
<small> · {{ signedPercent(totalProfitLossRate) }}</small>
|
||||
</template>
|
||||
<Spinner v-else size="lg" />
|
||||
</strong>
|
||||
</article>
|
||||
|
||||
<article :class="ui.holdingsPanel">
|
||||
<div :class="ui.panelHead">
|
||||
<h2>保有銘柄</h2>
|
||||
<button
|
||||
:class="ui.ghostButton"
|
||||
type="button"
|
||||
:disabled="dataLoading"
|
||||
@click="emit('connect')"
|
||||
>
|
||||
<Plug class="h-4 w-4" aria-hidden="true" />
|
||||
{{ connected ? '再取得' : '接続' }}
|
||||
</button>
|
||||
</div>
|
||||
<div :class="ui.holdingsBody">
|
||||
<div :class="ui.holdingsHead">
|
||||
<span>銘柄</span>
|
||||
<span>タイプ</span>
|
||||
<span>数量</span>
|
||||
<span>評価額</span>
|
||||
<span>評価損益</span>
|
||||
</div>
|
||||
<div v-if="positions.length" :class="ui.holdingsRows">
|
||||
<button
|
||||
v-for="position in positions"
|
||||
:key="position.code"
|
||||
:class="ui.holdingRow"
|
||||
type="button"
|
||||
@click="emit('openPosition', position.code)"
|
||||
>
|
||||
<span class="grid gap-1">
|
||||
<strong>{{ position.name }}</strong>
|
||||
<small>{{ position.code }}</small>
|
||||
</span>
|
||||
<b :class="ui.typePill">{{
|
||||
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
|
||||
}}</b>
|
||||
<span>{{ position.quantity }}</span>
|
||||
<span :class="ui.muted">{{ currency(position.marketValue) }}</span>
|
||||
<span
|
||||
class="grid justify-items-end gap-0.5"
|
||||
:class="position.profitLoss >= 0 ? ui.positive : ui.negative"
|
||||
>
|
||||
<strong>{{ signedCurrency(position.profitLoss) }}</strong>
|
||||
<small>{{ signedPercent(position.profitLossRate) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
|
||||
<Spinner />
|
||||
</div>
|
||||
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると保有銘柄を表示します</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article :class="ui.portfolioHistory">
|
||||
<h2>取引履歴</h2>
|
||||
<div :class="ui.historyList">
|
||||
<div v-if="recentOrders.length" :class="ui.historyRows">
|
||||
<div v-for="order in recentOrders" :key="order.id" :class="ui.miniOrder">
|
||||
<span class="grid gap-1">
|
||||
<strong>{{ order.stock }}</strong>
|
||||
<small
|
||||
>{{ order.side === 'buy' ? '買付' : '売却' }}({{
|
||||
order.kind === 's' ? 'S株' : '単元'
|
||||
}}) ・ {{ orderQuantityText(order) }}</small
|
||||
>
|
||||
</span>
|
||||
<span class="grid justify-items-end gap-1">
|
||||
<small>{{ order.date.slice(5, 10) }}</small>
|
||||
<strong>{{ orderAmountText(order) }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
|
||||
<Spinner />
|
||||
</div>
|
||||
<p v-else :class="[ui.muted, ui.emptyState]">
|
||||
{{
|
||||
orderHistoryLoaded
|
||||
? orderHistoryNotice
|
||||
? `SBI SDK は取引履歴なしを返しました (${orderHistoryNotice})`
|
||||
: '該当する取引履歴はありません'
|
||||
: '取引履歴はまだ取得されていません'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { motion } from 'motion-v'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import UiButton from '../../components/ui/UiButton.vue'
|
||||
import UiField from '../../components/ui/UiField.vue'
|
||||
import searchEmptyImage from '../../assets/search-empty.png'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { Stock } from '../../types/trading'
|
||||
import { currency } from '../../utils/format'
|
||||
|
||||
const overlayTransition = { duration: 0.2, ease: 'easeOut' } as const
|
||||
const sheetTransition = { type: 'spring', damping: 28, stiffness: 320 } as const
|
||||
|
||||
defineProps<{
|
||||
stocks: Stock[]
|
||||
selectedStockCode: string
|
||||
countries: string[]
|
||||
markets: string[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||
const countryFilter = defineModel<string>('countryFilter', { required: true })
|
||||
const marketFilter = defineModel<string>('marketFilter', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
select: [stock: Stock]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<motion.div
|
||||
:class="ui.searchOverlay"
|
||||
:initial="{ opacity: 0 }"
|
||||
:animate="{ opacity: 1 }"
|
||||
:exit="{ opacity: 0 }"
|
||||
:transition="overlayTransition"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<motion.section
|
||||
:class="ui.searchSheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="銘柄検索"
|
||||
:initial="{ opacity: 0, y: -20, scale: 0.98 }"
|
||||
:animate="{ opacity: 1, y: 0, scale: 1 }"
|
||||
:exit="{ opacity: 0, y: -16, scale: 0.98 }"
|
||||
:transition="sheetTransition"
|
||||
>
|
||||
<div :class="ui.searchInputRow">
|
||||
<UiField v-model="searchQuery" autofocus placeholder="銘柄名、コード、シンボル" />
|
||||
<UiButton variant="ghost" @click="emit('close')">
|
||||
<X class="h-4 w-4" aria-hidden="true" />
|
||||
閉じる
|
||||
</UiButton>
|
||||
</div>
|
||||
<div :class="ui.filterRow">
|
||||
<UiField v-model="countryFilter" as="select">
|
||||
<option value="all">すべての国</option>
|
||||
<option v-for="country in countries" :key="country" :value="country">
|
||||
{{ country }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField v-model="marketFilter" as="select">
|
||||
<option value="all">すべての市場</option>
|
||||
<option v-for="marketName in markets" :key="marketName" :value="marketName">
|
||||
{{ marketName }}
|
||||
</option>
|
||||
</UiField>
|
||||
</div>
|
||||
<div :class="ui.searchResults">
|
||||
<div v-if="loading" :class="ui.searchLoading" role="status" aria-live="polite">
|
||||
<Spinner />
|
||||
<span>検索中...</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!stocks.length"
|
||||
:class="[ui.muted, 'grid justify-items-center gap-3 py-8 text-center']"
|
||||
>
|
||||
<img
|
||||
:src="searchEmptyImage"
|
||||
alt=""
|
||||
class="h-32 w-32 object-contain opacity-75"
|
||||
loading="lazy"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>ありません</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="stock in stocks"
|
||||
:key="stock.code"
|
||||
:class="[ui.searchResult, selectedStockCode === stock.code && ui.searchResultActive]"
|
||||
type="button"
|
||||
:aria-current="selectedStockCode === stock.code ? 'true' : undefined"
|
||||
@click="emit('select', stock)"
|
||||
>
|
||||
<span class="grid gap-1">
|
||||
<strong>{{ stock.name }}</strong>
|
||||
<small>{{ stock.code }} / {{ stock.country }} / {{ stock.market }}</small>
|
||||
</span>
|
||||
<span>
|
||||
<template v-if="stock.price > 0">{{ currency(stock.price) }}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</motion.section>
|
||||
</motion.div>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { Ban, KeyRound, Plug, RefreshCw, Save, Trash2 } from 'lucide-vue-next'
|
||||
import type { ApiKey, ApiKeySettings, SbiPasskey } from '../../api'
|
||||
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
|
||||
defineProps<{
|
||||
apiKeys: ApiKey[]
|
||||
sbiPasskeys: SbiPasskey[]
|
||||
}>()
|
||||
|
||||
const apiKeyLabel = defineModel<string>('apiKeyLabel', { required: true })
|
||||
const newApiKeySettings = defineModel<ApiKeySettings>('newApiKeySettings', { required: true })
|
||||
const newApiToken = defineModel<string>('newApiToken', { required: true })
|
||||
const sbiLabel = defineModel<string>('sbiLabel', { required: true })
|
||||
const sbiCredentialJson = defineModel<string>('sbiCredentialJson', { required: true })
|
||||
const tradePassword = defineModel<string>('tradePassword', { required: true })
|
||||
const sbiDeviceId = defineModel<string>('sbiDeviceId', { required: true })
|
||||
const selectedPasskeyId = defineModel<string>('selectedPasskeyId', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
addApiKey: []
|
||||
refresh: []
|
||||
saveApiKeySettings: [key: ApiKey]
|
||||
revokeApiKey: [id: string]
|
||||
addSbiPasskey: []
|
||||
connect: []
|
||||
removeSbiPasskey: [id: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="ui.settingsLayout">
|
||||
<div :class="ui.apiLayout">
|
||||
<article :class="ui.panel">
|
||||
<div :class="ui.panelHead">
|
||||
<div>
|
||||
<p :class="ui.eyebrow">Fine-grained</p>
|
||||
<h2>API Key 発行</h2>
|
||||
</div>
|
||||
<button :class="ui.primaryButton" type="button" @click="emit('addApiKey')">
|
||||
<KeyRound class="h-4 w-4" aria-hidden="true" />
|
||||
発行
|
||||
</button>
|
||||
</div>
|
||||
<label :class="ui.label">
|
||||
名前
|
||||
<input v-model="apiKeyLabel" :class="ui.input" placeholder="例: trade-bot-readonly" />
|
||||
</label>
|
||||
<ApiKeyPolicyEditor v-model="newApiKeySettings" permissions-open />
|
||||
<textarea v-if="newApiToken" v-model="newApiToken" :class="ui.input" readonly></textarea>
|
||||
</article>
|
||||
|
||||
<article :class="ui.panel">
|
||||
<div :class="ui.panelHead">
|
||||
<h2>発行済みキー</h2>
|
||||
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
|
||||
<RefreshCw class="h-4 w-4" aria-hidden="true" />
|
||||
更新
|
||||
</button>
|
||||
</div>
|
||||
<div :class="ui.list">
|
||||
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
|
||||
<div :class="ui.row">
|
||||
<span>{{ key.label }}</span>
|
||||
<div :class="ui.rowActions">
|
||||
<button
|
||||
:class="ui.primaryButton"
|
||||
type="button"
|
||||
title="Save settings"
|
||||
@click="emit('saveApiKeySettings', key)"
|
||||
>
|
||||
<Save class="h-4 w-4" aria-hidden="true" />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
:class="ui.dangerButton"
|
||||
type="button"
|
||||
title="Revoke"
|
||||
@click="emit('revokeApiKey', key.id)"
|
||||
>
|
||||
<Ban class="h-4 w-4" aria-hidden="true" />
|
||||
失効
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ApiKeyPolicyEditor
|
||||
:model-value="key"
|
||||
compact
|
||||
@update:model-value="Object.assign(key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div :class="ui.apiLayout">
|
||||
<article :class="ui.panel">
|
||||
<div :class="ui.panelHead">
|
||||
<div>
|
||||
<p :class="ui.eyebrow">Profiles</p>
|
||||
<h2>パスキー設定</h2>
|
||||
</div>
|
||||
<button :class="ui.primaryButton" type="button" @click="emit('addSbiPasskey')">
|
||||
<Save class="h-4 w-4" aria-hidden="true" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<label :class="ui.label">
|
||||
名前
|
||||
<input v-model="sbiLabel" :class="ui.input" placeholder="例: 個人メイン" />
|
||||
</label>
|
||||
<label :class="ui.label">
|
||||
パスキー JSON
|
||||
<textarea
|
||||
v-model="sbiCredentialJson"
|
||||
:class="ui.input"
|
||||
spellcheck="false"
|
||||
placeholder="{ ... }"
|
||||
></textarea>
|
||||
</label>
|
||||
<div :class="ui.ticketBox">
|
||||
<label :class="ui.label">
|
||||
取引パスワード
|
||||
<input v-model="tradePassword" :class="ui.input" type="password" autocomplete="off" />
|
||||
</label>
|
||||
<label :class="ui.label">
|
||||
デバイスキー
|
||||
<input v-model="sbiDeviceId" :class="ui.input" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article :class="ui.panel">
|
||||
<div :class="ui.panelHead">
|
||||
<h2>プロフィール切り替え</h2>
|
||||
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
|
||||
<Plug class="h-4 w-4" aria-hidden="true" />
|
||||
接続
|
||||
</button>
|
||||
</div>
|
||||
<div :class="ui.list">
|
||||
<label v-for="passkey in sbiPasskeys" :key="passkey.id" :class="ui.profileRow">
|
||||
<input
|
||||
v-model="selectedPasskeyId"
|
||||
type="radio"
|
||||
:value="passkey.id"
|
||||
@change="emit('connect')"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ passkey.label }}</strong>
|
||||
<small>{{ new Date(passkey.createdAt).toLocaleString() }}</small>
|
||||
</span>
|
||||
<button
|
||||
:class="ui.dangerButton"
|
||||
type="button"
|
||||
title="Delete passkey"
|
||||
@click.prevent="emit('removeSbiPasskey', passkey.id)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" aria-hidden="true" />
|
||||
削除
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import Chart from 'chart.js/auto'
|
||||
import { Minus, Plus, RotateCcw } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import type { RealtimePricePoint } from '../../types/trading'
|
||||
|
||||
const props = defineProps<{
|
||||
points: RealtimePricePoint[]
|
||||
stockName: string
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const scroller = ref<HTMLDivElement | null>(null)
|
||||
const zoomScale = ref(1)
|
||||
let chart: Chart<'line', number[], string> | undefined
|
||||
let wasScrolledToEnd = true
|
||||
|
||||
const minChartWidth = 720
|
||||
const minZoom = 0.6
|
||||
const maxZoom = 3
|
||||
const zoomStep = 0.2
|
||||
const basePointSpacing = 10
|
||||
const pointSpacing = computed(() => basePointSpacing * zoomScale.value)
|
||||
|
||||
const labelFor = (point: RealtimePricePoint) => {
|
||||
const date = new Date(point.at)
|
||||
if (date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0) {
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const labels = computed(() => props.points.map(labelFor))
|
||||
|
||||
const prices = computed(() => props.points.map((point) => point.price))
|
||||
|
||||
const chartWidth = computed(() => {
|
||||
const width = Math.max(minChartWidth, props.points.length * pointSpacing.value + 72)
|
||||
return `max(100%, ${width}px)`
|
||||
})
|
||||
|
||||
const rememberScrollPosition = () => {
|
||||
const element = scroller.value
|
||||
if (!element) return
|
||||
wasScrolledToEnd =
|
||||
element.scrollLeft + element.clientWidth >= element.scrollWidth - pointSpacing.value
|
||||
}
|
||||
|
||||
const scrollToEndIfNeeded = async () => {
|
||||
if (!wasScrolledToEnd) return
|
||||
await nextTick()
|
||||
const element = scroller.value
|
||||
if (!element) return
|
||||
element.scrollLeft = element.scrollWidth
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chart) return
|
||||
rememberScrollPosition()
|
||||
chart.data.labels = labels.value
|
||||
const dataset = chart.data.datasets[0]
|
||||
if (dataset) dataset.data = prices.value
|
||||
chart.update('none')
|
||||
chart.resize()
|
||||
void scrollToEndIfNeeded()
|
||||
}
|
||||
|
||||
const setZoomScale = (value: number) => {
|
||||
rememberScrollPosition()
|
||||
zoomScale.value = Math.min(maxZoom, Math.max(minZoom, value))
|
||||
}
|
||||
|
||||
const zoomOut = () => setZoomScale(zoomScale.value - zoomStep)
|
||||
|
||||
const zoomIn = () => setZoomScale(zoomScale.value + zoomStep)
|
||||
|
||||
const resetZoom = () => setZoomScale(1)
|
||||
|
||||
const setZoomScaleFromInput = (event: Event) => {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
setZoomScale(value)
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
event.preventDefault()
|
||||
setZoomScale(zoomScale.value + (event.deltaY < 0 ? zoomStep : -zoomStep))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!canvas.value) return
|
||||
chart = new Chart(canvas.value, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels.value,
|
||||
datasets: [
|
||||
{
|
||||
data: prices.value,
|
||||
borderColor: '#40dba2',
|
||||
backgroundColor: 'rgba(64, 219, 162, 0.14)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
pointBackgroundColor: '#d3e3fd',
|
||||
pointBorderColor: '#40dba2',
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 4,
|
||||
tension: 0.25,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (item) => `${(item.parsed.y ?? 0).toLocaleString('ja-JP')}円`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(143, 148, 157, 0.12)' },
|
||||
ticks: { color: '#8f949d', maxTicksLimit: 5 },
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(143, 148, 157, 0.12)' },
|
||||
ticks: {
|
||||
color: '#8f949d',
|
||||
callback: (value) =>
|
||||
typeof value === 'number' ? value.toLocaleString('ja-JP') : value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
void scrollToEndIfNeeded()
|
||||
})
|
||||
|
||||
watch(() => props.points, updateChart, { deep: true })
|
||||
watch(chartWidth, () => {
|
||||
chart?.resize()
|
||||
void scrollToEndIfNeeded()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chart?.destroy()
|
||||
chart = undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<div
|
||||
ref="scroller"
|
||||
class="h-full w-full overflow-x-auto overflow-y-hidden"
|
||||
@scroll="rememberScrollPosition"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="h-full" :style="{ width: chartWidth }">
|
||||
<canvas
|
||||
ref="canvas"
|
||||
class="h-full w-full"
|
||||
:aria-label="`${stockName} realtime price chart`"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="active && !points.length"
|
||||
class="absolute inset-0 grid place-items-center text-[#8f949d]"
|
||||
>
|
||||
<Spinner />
|
||||
</span>
|
||||
<div
|
||||
class="absolute right-2 top-2 flex items-center gap-1 rounded-md border border-[#2d3440] bg-[#111418]/90 px-1.5 py-1 shadow-sm"
|
||||
>
|
||||
<button
|
||||
class="grid h-6 w-6 place-items-center rounded text-[#d3e3fd] hover:bg-[#1d232b]"
|
||||
type="button"
|
||||
aria-label="縮小"
|
||||
@click="zoomOut"
|
||||
>
|
||||
<Minus class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<input
|
||||
:value="zoomScale"
|
||||
class="h-6 w-24 accent-[#40dba2]"
|
||||
type="range"
|
||||
:min="minZoom"
|
||||
:max="maxZoom"
|
||||
:step="zoomStep"
|
||||
aria-label="グラフ縮尺"
|
||||
@input="setZoomScaleFromInput"
|
||||
/>
|
||||
<button
|
||||
class="grid h-6 w-6 place-items-center rounded text-[#d3e3fd] hover:bg-[#1d232b]"
|
||||
type="button"
|
||||
aria-label="拡大"
|
||||
@click="zoomIn"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="grid h-6 w-6 place-items-center rounded text-[#8f949d] hover:bg-[#1d232b] hover:text-[#d3e3fd]"
|
||||
type="button"
|
||||
aria-label="縮尺を戻す"
|
||||
@click="resetZoom"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,546 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, Download, FileCheck2, Search, ShieldCheck } from 'lucide-vue-next'
|
||||
import { AnimatePresence, LayoutGroup, motion } from 'motion-v'
|
||||
import { computed, ref } from 'vue'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import UiButton from '../../components/ui/UiButton.vue'
|
||||
import UiField from '../../components/ui/UiField.vue'
|
||||
import UiSegmented from '../../components/ui/UiSegmented.vue'
|
||||
import {
|
||||
cashOrderAccountTypeOptions,
|
||||
cashOrderMarketOptions,
|
||||
cashOrderMethodOptions,
|
||||
cashOrderPriceConditionOptions,
|
||||
cashOrderTermOptions,
|
||||
cashOrderTriggerZoneOptions,
|
||||
orderKindOptions,
|
||||
sKabuOrderMarketOptions,
|
||||
tradeSideOptions,
|
||||
} from '../../constants/trade'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type {
|
||||
ChartMode,
|
||||
CashOrderAccountType,
|
||||
CashOrderMarket,
|
||||
CashOrderMethod,
|
||||
CashOrderPriceCondition,
|
||||
CashOrderTerm,
|
||||
CashOrderTriggerZone,
|
||||
OrderKind,
|
||||
Position,
|
||||
RealtimePricePoint,
|
||||
Stock,
|
||||
TradeSide,
|
||||
} from '../../types/trading'
|
||||
import { currency } from '../../utils/format'
|
||||
import RealtimePriceChart from './RealtimePriceChart.vue'
|
||||
|
||||
type StockInfoTab = 'detail' | 'holding'
|
||||
|
||||
defineProps<{
|
||||
viewedStocks: Stock[]
|
||||
selectedStock: Stock
|
||||
selectedPosition?: Position
|
||||
connected: boolean
|
||||
orderQuantity: number
|
||||
estimatedAmount: number
|
||||
canRequestCashEstimate: boolean
|
||||
canPlaceCashOrder: boolean
|
||||
realtimePricePoints: RealtimePricePoint[]
|
||||
pricePolling: boolean
|
||||
boxPlotStyle: {
|
||||
min: string
|
||||
q1: string
|
||||
median: string
|
||||
q3: string
|
||||
max: string
|
||||
}
|
||||
hasQuote: (stock: Stock) => boolean
|
||||
}>()
|
||||
|
||||
const tradeSide = defineModel<TradeSide>('tradeSide', { required: true })
|
||||
const orderKind = defineModel<OrderKind>('orderKind', { required: true })
|
||||
const cashOrderAccountType = defineModel<CashOrderAccountType>('cashOrderAccountType', {
|
||||
required: true,
|
||||
})
|
||||
const cashOrderMarket = defineModel<CashOrderMarket>('cashOrderMarket', {
|
||||
required: true,
|
||||
})
|
||||
const cashOrderPriceCondition = defineModel<CashOrderPriceCondition>('cashOrderPriceCondition', {
|
||||
required: true,
|
||||
})
|
||||
const cashOrderTerm = defineModel<CashOrderTerm>('cashOrderTerm', { required: true })
|
||||
const cashOrderDateInput = defineModel<string>('cashOrderDateInput', { required: true })
|
||||
const cashOrderMethod = defineModel<CashOrderMethod>('cashOrderMethod', { required: true })
|
||||
const cashOrderTriggerZone = defineModel<CashOrderTriggerZone>('cashOrderTriggerZone', {
|
||||
required: true,
|
||||
})
|
||||
const cashOrderTriggerPriceInput = defineModel<string>('cashOrderTriggerPriceInput', {
|
||||
required: true,
|
||||
})
|
||||
const cashOrderSecondaryPriceCondition = defineModel<CashOrderPriceCondition>(
|
||||
'cashOrderSecondaryPriceCondition',
|
||||
{
|
||||
required: true,
|
||||
},
|
||||
)
|
||||
const cashOrderSecondaryPriceInput = defineModel<string>('cashOrderSecondaryPriceInput', {
|
||||
required: true,
|
||||
})
|
||||
const quantityInput = defineModel<string>('quantityInput', { required: true })
|
||||
const priceInput = defineModel<string>('priceInput', { required: true })
|
||||
const chartMode = defineModel<ChartMode>('chartMode', { required: true })
|
||||
const activeStockInfoTab = ref<StockInfoTab>('detail')
|
||||
const advancedOptionsOpen = ref(false)
|
||||
const orderMarketOptions = computed(() =>
|
||||
orderKind.value === 's' ? sKabuOrderMarketOptions : cashOrderMarketOptions,
|
||||
)
|
||||
const primaryPriceConditionRequiresPrice = computed(() =>
|
||||
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
|
||||
cashOrderPriceCondition.value,
|
||||
),
|
||||
)
|
||||
const secondaryPriceConditionRequiresPrice = computed(() =>
|
||||
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
|
||||
cashOrderSecondaryPriceCondition.value,
|
||||
),
|
||||
)
|
||||
const tabTransition = { type: 'spring', damping: 28, stiffness: 360 } as const
|
||||
const tabContentTransition = { duration: 0.18, ease: 'easeOut' } as const
|
||||
const advancedTransition = { duration: 0.18, ease: 'easeOut' } as const
|
||||
|
||||
const emit = defineEmits<{
|
||||
openSearch: []
|
||||
selectStock: [stock: Stock]
|
||||
downloadCsv: []
|
||||
estimate: []
|
||||
confirmOrder: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="ui.tradeLayout">
|
||||
<article :class="ui.watchlist">
|
||||
<button :class="ui.watchSearch" type="button" @click="emit('openSearch')">
|
||||
<Search class="h-4 w-4" aria-hidden="true" />
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
v-for="stock in viewedStocks"
|
||||
:key="stock.code"
|
||||
:class="[ui.watchRow, selectedStock.code === stock.code && ui.watchRowActive]"
|
||||
type="button"
|
||||
:aria-current="selectedStock.code === stock.code ? 'true' : undefined"
|
||||
@click="emit('selectStock', stock)"
|
||||
>
|
||||
<span class="grid gap-1">
|
||||
<strong>{{ stock.name }}</strong>
|
||||
<small>{{ stock.symbol }}</small>
|
||||
</span>
|
||||
<span class="grid justify-items-end gap-1">
|
||||
<strong>
|
||||
<template v-if="hasQuote(stock)">{{ currency(stock.price) }}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</strong>
|
||||
<small v-if="hasQuote(stock)" :class="stock.change >= 0 ? ui.positive : ui.negative">
|
||||
{{ stock.change >= 0 ? '+' : '' }}{{ stock.change }}%
|
||||
</small>
|
||||
<Spinner v-else size="sm" />
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="!viewedStocks.length" :class="[ui.muted, 'p-5 text-sm']">
|
||||
SBIに接続するか検索すると銘柄を表示します
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div :class="ui.centerStack">
|
||||
<article :class="ui.stockPanel">
|
||||
<div :class="ui.stockTitle">
|
||||
<div>
|
||||
<p :class="ui.eyebrow">{{ selectedStock.symbol }}</p>
|
||||
<h2>{{ selectedStock.name }}</h2>
|
||||
</div>
|
||||
<div :class="ui.quoteBox">
|
||||
<strong>
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currency(selectedStock.price)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</strong>
|
||||
<small
|
||||
v-if="hasQuote(selectedStock)"
|
||||
:class="selectedStock.change >= 0 ? ui.positive : ui.negative"
|
||||
>
|
||||
{{ selectedStock.change >= 0 ? '+' : '' }}{{ selectedStock.change }}%
|
||||
</small>
|
||||
<Spinner v-else size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="ui.periodTabs">
|
||||
<button :class="[ui.periodButton, ui.periodButtonActive]" type="button">1D</button>
|
||||
<button :class="ui.periodButton" type="button">1W</button>
|
||||
<button :class="ui.periodButton" type="button">1M</button>
|
||||
</div>
|
||||
|
||||
<div :class="ui.chartActions">
|
||||
<div :class="ui.smallTabs">
|
||||
<button
|
||||
:class="[ui.smallTab, chartMode === 'line' && ui.smallTabActive]"
|
||||
type="button"
|
||||
@click="chartMode = 'line'"
|
||||
>
|
||||
推移
|
||||
</button>
|
||||
<button
|
||||
:class="[ui.smallTab, chartMode === 'box' && ui.smallTabActive]"
|
||||
type="button"
|
||||
@click="chartMode = 'box'"
|
||||
>
|
||||
箱ひげ
|
||||
</button>
|
||||
</div>
|
||||
<button :class="ui.ghostButton" type="button" @click="emit('downloadCsv')">
|
||||
<Download class="h-4 w-4" aria-hidden="true" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="chartMode === 'line'" :class="ui.chartBox">
|
||||
<RealtimePriceChart
|
||||
v-if="hasQuote(selectedStock)"
|
||||
:points="realtimePricePoints"
|
||||
:stock-name="selectedStock.name"
|
||||
:active="pricePolling"
|
||||
/>
|
||||
<span v-else class="grid h-full place-items-center text-[#8f949d]">
|
||||
<Spinner />
|
||||
</span>
|
||||
</div>
|
||||
<div v-else :class="ui.boxplot">
|
||||
<div :class="ui.boxplotScale">
|
||||
<span>
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currency(selectedStock.box.min)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</span>
|
||||
<span>
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currency(selectedStock.box.max)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hasQuote(selectedStock)" :class="ui.boxplotTrack">
|
||||
<i
|
||||
:class="ui.whisker"
|
||||
:style="{
|
||||
left: boxPlotStyle.min,
|
||||
width: `calc(${boxPlotStyle.max} - ${boxPlotStyle.min})`,
|
||||
}"
|
||||
></i>
|
||||
<i
|
||||
:class="ui.box"
|
||||
:style="{
|
||||
left: boxPlotStyle.q1,
|
||||
width: `calc(${boxPlotStyle.q3} - ${boxPlotStyle.q1})`,
|
||||
}"
|
||||
></i>
|
||||
<i :class="ui.median" :style="{ left: boxPlotStyle.median }"></i>
|
||||
</div>
|
||||
<div v-else class="grid min-h-20 place-items-center text-[#8f949d]">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<LayoutGroup id="stock-info-tabs">
|
||||
<article :class="ui.infoTabPanel">
|
||||
<div :class="ui.infoTabList" role="tablist" aria-label="銘柄別情報">
|
||||
<button
|
||||
id="stock-detail-tab"
|
||||
:class="[ui.infoTabButton, activeStockInfoTab === 'detail' && ui.infoTabButtonActive]"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeStockInfoTab === 'detail'"
|
||||
aria-controls="stock-detail-panel"
|
||||
@click="activeStockInfoTab = 'detail'"
|
||||
>
|
||||
詳細
|
||||
<motion.span
|
||||
v-if="activeStockInfoTab === 'detail'"
|
||||
layout-id="stock-info-tab-indicator"
|
||||
:class="ui.infoTabIndicator"
|
||||
:transition="tabTransition"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
id="stock-holding-tab"
|
||||
:class="[
|
||||
ui.infoTabButton,
|
||||
activeStockInfoTab === 'holding' && ui.infoTabButtonActive,
|
||||
]"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeStockInfoTab === 'holding'"
|
||||
aria-controls="stock-holding-panel"
|
||||
@click="activeStockInfoTab = 'holding'"
|
||||
>
|
||||
保有
|
||||
<motion.span
|
||||
v-if="activeStockInfoTab === 'holding'"
|
||||
layout-id="stock-info-tab-indicator"
|
||||
:class="ui.infoTabIndicator"
|
||||
:transition="tabTransition"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="ui.infoTabBody">
|
||||
<AnimatePresence mode="wait" :initial="false">
|
||||
<motion.dl
|
||||
v-if="activeStockInfoTab === 'detail'"
|
||||
id="stock-detail-panel"
|
||||
key="detail"
|
||||
:class="ui.detailGrid"
|
||||
role="tabpanel"
|
||||
aria-labelledby="stock-detail-tab"
|
||||
:initial="{ opacity: 0, y: 8 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:exit="{ opacity: 0, y: -8 }"
|
||||
:transition="tabContentTransition"
|
||||
>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">国</dt>
|
||||
<dd class="font-bold">{{ selectedStock.country }}</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">市場</dt>
|
||||
<dd class="font-bold">{{ selectedStock.market }}</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">業種</dt>
|
||||
<dd class="font-bold">{{ selectedStock.sector }}</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">始値</dt>
|
||||
<dd class="font-bold">
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currency(selectedStock.open)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">高値</dt>
|
||||
<dd class="font-bold">
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currency(selectedStock.high)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">安値</dt>
|
||||
<dd class="font-bold">
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currency(selectedStock.low)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</dd>
|
||||
</div>
|
||||
</motion.dl>
|
||||
|
||||
<motion.div
|
||||
v-else
|
||||
id="stock-holding-panel"
|
||||
key="holding"
|
||||
role="tabpanel"
|
||||
aria-labelledby="stock-holding-tab"
|
||||
:initial="{ opacity: 0, y: 8 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:exit="{ opacity: 0, y: -8 }"
|
||||
:transition="tabContentTransition"
|
||||
>
|
||||
<div v-if="selectedPosition" :class="ui.holdingNote">
|
||||
保有 {{ selectedPosition.quantity }}株 / 平均
|
||||
{{ currency(selectedPosition.avgPrice) }}
|
||||
</div>
|
||||
<p v-else :class="ui.holdingEmpty">保有なし</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</article>
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
|
||||
<div :class="ui.tradeSeparator" aria-hidden="true"></div>
|
||||
|
||||
<article :class="ui.ticketPanel">
|
||||
<div :class="ui.ticketTop">
|
||||
<UiSegmented v-model="tradeSide" :options="tradeSideOptions" />
|
||||
</div>
|
||||
<div :class="ui.ticketScroll">
|
||||
<div :class="ui.ticketScrollInner">
|
||||
<UiSegmented v-model="orderKind" :options="orderKindOptions" />
|
||||
<div :class="ui.ticketBox">
|
||||
<UiField
|
||||
v-model="quantityInput"
|
||||
label="発注株数"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="株数を入力"
|
||||
/>
|
||||
<details
|
||||
:class="ui.advancedOptions"
|
||||
@toggle="advancedOptionsOpen = ($event.target as HTMLDetailsElement).open"
|
||||
>
|
||||
<summary :class="ui.advancedSummary">
|
||||
<span>Advanced Options</span>
|
||||
<motion.span
|
||||
:class="ui.advancedSummaryIconWrap"
|
||||
:animate="{ rotate: advancedOptionsOpen ? 180 : 0 }"
|
||||
:transition="advancedTransition"
|
||||
>
|
||||
<ChevronDown :class="ui.advancedSummaryIcon" aria-hidden="true" />
|
||||
</motion.span>
|
||||
</summary>
|
||||
<motion.div
|
||||
:class="ui.advancedBody"
|
||||
:initial="{ opacity: 0, y: -4 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="advancedTransition"
|
||||
>
|
||||
<span :class="ui.advancedLabel">預り区分</span>
|
||||
<UiSegmented
|
||||
v-model="cashOrderAccountType"
|
||||
:options="cashOrderAccountTypeOptions"
|
||||
/>
|
||||
<UiField
|
||||
v-model="cashOrderMarket"
|
||||
as="select"
|
||||
label="注文市場"
|
||||
:disabled="orderKind === 's'"
|
||||
>
|
||||
<option
|
||||
v-for="option in orderMarketOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<template v-if="orderKind !== 's'">
|
||||
<UiField v-model="cashOrderPriceCondition" as="select" label="執行条件">
|
||||
<option
|
||||
v-for="option in cashOrderPriceConditionOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-if="primaryPriceConditionRequiresPrice"
|
||||
v-model="priceInput"
|
||||
label="注文価格"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="価格を入力"
|
||||
/>
|
||||
<UiField v-model="cashOrderTerm" as="select" label="有効期限">
|
||||
<option
|
||||
v-for="option in cashOrderTermOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-if="cashOrderTerm === 'date'"
|
||||
v-model="cashOrderDateInput"
|
||||
label="指定日"
|
||||
type="date"
|
||||
/>
|
||||
<UiField v-model="cashOrderMethod" as="select" label="特殊注文">
|
||||
<option
|
||||
v-for="option in cashOrderMethodOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<template v-if="cashOrderMethod !== 'normal'">
|
||||
<UiField v-model="cashOrderTriggerZone" as="select" label="逆指値条件">
|
||||
<option
|
||||
v-for="option in cashOrderTriggerZoneOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-model="cashOrderTriggerPriceInput"
|
||||
label="逆指値価格"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="価格を入力"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="cashOrderMethod === 'oco'">
|
||||
<UiField
|
||||
v-model="cashOrderSecondaryPriceCondition"
|
||||
as="select"
|
||||
label="OCO執行条件"
|
||||
>
|
||||
<option
|
||||
v-for="option in cashOrderPriceConditionOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-if="secondaryPriceConditionRequiresPrice"
|
||||
v-model="cashOrderSecondaryPriceInput"
|
||||
label="OCO価格"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="価格を入力"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</motion.div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="ui.ticketBottom">
|
||||
<div :class="ui.estimateSummary">
|
||||
<span>概算金額</span>
|
||||
<strong>{{ currency(estimatedAmount) }}</strong>
|
||||
</div>
|
||||
<div :class="ui.actions">
|
||||
<UiButton type="button" :disabled="!canRequestCashEstimate" @click="emit('estimate')">
|
||||
<FileCheck2 class="h-4 w-4" aria-hidden="true" />
|
||||
見積
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="danger"
|
||||
type="button"
|
||||
:disabled="!canPlaceCashOrder"
|
||||
@click="emit('confirmOrder')"
|
||||
>
|
||||
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
|
||||
注文確認
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,301 @@
|
||||
import type {
|
||||
OrderPreview,
|
||||
OrderRow,
|
||||
Position,
|
||||
RealtimePricePoint,
|
||||
Stock,
|
||||
} from '../../types/trading'
|
||||
import { currency } from '../../utils/format'
|
||||
|
||||
export type RecordLike = Record<string, unknown>
|
||||
|
||||
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<typeof quoteFrom>) => {
|
||||
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<typeof issueFrom>): 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<typeof issueFrom>,
|
||||
): 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<OrderPreview>
|
||||
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 = <T>(results: Array<PromiseSettledResult<T>>): T[] =>
|
||||
results
|
||||
.filter((result): result is PromiseFulfilledResult<T> => result.status === 'fulfilled')
|
||||
.map((result) => result.value)
|
||||
@@ -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<CashOrderPriceCondition>([
|
||||
'limit',
|
||||
'limitAtOpen',
|
||||
'limitAtClose',
|
||||
'limitIoc',
|
||||
'funari',
|
||||
])
|
||||
|
||||
const cashOrderPriceConditionRequiresPrice = (condition: CashOrderPriceCondition) =>
|
||||
priceBasedCashOrderConditions.has(condition)
|
||||
|
||||
export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
||||
const selectedStockCode = ref('')
|
||||
const viewedStockCodes = ref<string[]>([])
|
||||
const tradeSide = ref<TradeSide>('buy')
|
||||
const orderKind = ref<OrderKind>('s')
|
||||
const cashOrderAccountType = ref<CashOrderAccountType>('specific')
|
||||
const cashOrderMarket = ref<CashOrderMarket>('STK')
|
||||
const cashOrderPriceCondition = ref<CashOrderPriceCondition>('market')
|
||||
const cashOrderTerm = ref<CashOrderTerm>('day')
|
||||
const cashOrderDateInput = ref('')
|
||||
const cashOrderMethod = ref<CashOrderMethod>('normal')
|
||||
const cashOrderTriggerZone = ref<CashOrderTriggerZone>('above')
|
||||
const cashOrderTriggerPriceInput = ref('')
|
||||
const cashOrderSecondaryPriceCondition = ref<CashOrderPriceCondition>('limit')
|
||||
const cashOrderSecondaryPriceInput = ref('')
|
||||
const quantityInput = ref('')
|
||||
const priceInput = ref('')
|
||||
const chartMode = ref<ChartMode>('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<number | null>(null)
|
||||
const lastCashEstimate = ref<OrderPreview | null>(null)
|
||||
const lastCashEstimateKey = ref('')
|
||||
const ws = ref<WebSocket | null>(null)
|
||||
const rpcPending = new Map<number, RpcResolver>()
|
||||
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<OrderRow[]>([])
|
||||
const orderHistoryLoaded = ref(false)
|
||||
const orderHistoryNotice = ref('')
|
||||
const positions = ref<Position[]>([])
|
||||
const stocks = ref<Stock[]>([])
|
||||
const historicalPricePoints = ref<RealtimePricePoint[]>([])
|
||||
const realtimePricePoints = ref<RealtimePricePoint[]>([])
|
||||
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 <T>(method: string, params?: unknown): Promise<T> => {
|
||||
const id = call(method, params)
|
||||
if (!id) throw new Error('SBI session is not connected')
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
rpcPending.set(id, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const rpcCallOptional = async <T>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
timeoutMs = 8_000,
|
||||
): Promise<T> => {
|
||||
const id = call(method, params)
|
||||
if (!id) throw new Error('SBI session is not connected')
|
||||
return new Promise<T>((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<RecordLike>('orders.inquiry.open'),
|
||||
rpcCallOptional<RecordLike>('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<string, OrderRow>()
|
||||
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<RecordLike>(
|
||||
'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<RecordLike>('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<RecordLike>(
|
||||
'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<RecordLike>('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<RecordLike>('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<RecordLike>('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<RecordLike>('market.issue.board', {
|
||||
issueCode: issue.code,
|
||||
market: issue.market || undefined,
|
||||
}).then((board) => stockFromBoard(board, issue)),
|
||||
),
|
||||
)
|
||||
mergeStocks(
|
||||
boards
|
||||
.filter((result): result is PromiseFulfilledResult<Stock> => 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<unknown>('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<RecordLike>('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<typeof setTimeout> | 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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const RouteStub = { template: '<span />' }
|
||||
|
||||
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' },
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
overflow: hidden;
|
||||
background: #101418;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}%`
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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<string, string> = {
|
||||
'.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}`)
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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/[email protected]", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-kXxQVZHNOctSJJsqzmcbPSCEkM6oHNnDIkua7g9RCO9xRHj2eCiKvRx2KPdfWR9QxcGWnK/oArrtunmie3rL9g=="],
|
||||
|
||||
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/[email protected]", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/[email protected]", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
|
||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/[email protected]", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@hexagon/base64": ["@hexagon/[email protected]", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
|
||||
|
||||
"@hono/mcp": ["@hono/[email protected]", "", { "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/[email protected]", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@levischuck/tiny-cbor": ["@levischuck/[email protected]", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="],
|
||||
|
||||
"@napi-rs/keyring-darwin-x64": ["@napi-rs/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw=="],
|
||||
|
||||
"@napi-rs/keyring-freebsd-x64": ["@napi-rs/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw=="],
|
||||
|
||||
"@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ=="],
|
||||
|
||||
"@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg=="],
|
||||
|
||||
"@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw=="],
|
||||
|
||||
"@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ=="],
|
||||
|
||||
"@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A=="],
|
||||
|
||||
"@napi-rs/keyring-linux-x64-musl": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw=="],
|
||||
|
||||
"@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw=="],
|
||||
|
||||
"@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ=="],
|
||||
|
||||
"@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "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/[email protected]", "", {}, "sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/[email protected]", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ=="],
|
||||
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA=="],
|
||||
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw=="],
|
||||
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg=="],
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw=="],
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw=="],
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="],
|
||||
|
||||
"@oxlint/plugins": ["@oxlint/[email protected]", "", {}, "sha512-nkOyZEF1vH527CkdQtOp1HMrVFEM4ResURvI2JFeGoup+h+43J/k/FgdOR9b9Isxg+Yae7qVDa7y3nssE8b3TQ=="],
|
||||
|
||||
"@peculiar/asn1-android": ["@peculiar/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="],
|
||||
|
||||
"@peculiar/asn1-x509": ["@peculiar/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
|
||||
|
||||
"@peculiar/x509": ["@peculiar/[email protected]", "", { "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/[email protected]", "", {}, "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/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/[email protected]", "", { "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/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@simplewebauthn/browser": ["@simplewebauthn/[email protected]", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="],
|
||||
|
||||
"@simplewebauthn/server": ["@simplewebauthn/[email protected]", "", { "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/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/[email protected]", "", { "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/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/chai": ["@types/[email protected]", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/[email protected]", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/jsesc": ["@types/[email protected]", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/[email protected]", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hpo9W9piSFlEsJzGkwzfDXhJGrnYByxHXF7NVQZ7g+SLOprddtlfTeM8t+gq9dxcuq0RzM8ddMAhDQP/K3fZQA=="],
|
||||
|
||||
"@voidzero-dev/vite-plus-darwin-x64": ["@voidzero-dev/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-SwnnnZrEFBiU5iKlh/CZAVwn0RFt/Udrvt3kFLtdRxMtN5bKaqTFVA2H8Y/FPCWp1QX9bs4V9ZIAeXAk06zLkw=="],
|
||||
|
||||
"@voidzero-dev/vite-plus-linux-arm64-gnu": ["@voidzero-dev/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-ImM3eqDki4DpRuHjW6dEh4St8zvbcfOMR7KQZJX42ArriCLQ/QdaYhDRRbcDi27XsOBqRxm2eqUUEymPrYIHpA=="],
|
||||
|
||||
"@voidzero-dev/vite-plus-linux-arm64-musl": ["@voidzero-dev/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-gj4mzbob/ls8Zs7iTuF9Gr0EFFF7tdpDiPxDPBkH8tJP5OkHABlzWUwJhU+9xxcUbTaXqpHDw68Mil7jm5dpMg=="],
|
||||
|
||||
"@voidzero-dev/vite-plus-linux-x64-gnu": ["@voidzero-dev/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-x7IYK7lI+WuF1n3jSzEYU6FgJxPX/R0rDmTTsOutooGGCU7uShZvfZqIoiTXK0eFnJU5ij5BfBgenenUfsaT/A=="],
|
||||
|
||||
"@voidzero-dev/vite-plus-linux-x64-musl": ["@voidzero-dev/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-JCy2w0eSVUlWQlggK5T47MnL+j0o4EY7hLskINVI8gi+aixQF4xnYBDobz0lbxkqz3/IfiLyXUx6TcU3thcsGQ=="],
|
||||
|
||||
"@voidzero-dev/vite-plus-test": ["@voidzero-dev/[email protected]", "", { "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/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-G+/lhLKVjyn3FmgXX8jeWgq7RcE5O1kdR7QyFayQOdlMX/ZRkvUwQD7bFaqhKzgJM6Oj3a1FH3HQPYk5QOYuCQ=="],
|
||||
|
||||
"@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-b0e5XohEV1w/RdzAtv8/Hm6tvHPXouPtBNsljjW/lDJZq3NCLND5s6lqe8H4IenrgmKSoqakHWtlqJqM36cFbw=="],
|
||||
|
||||
"@volar/language-core": ["@volar/[email protected]", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
||||
"@volar/source-map": ["@volar/[email protected]", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
|
||||
"@volar/typescript": ["@volar/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/[email protected]", "", { "dependencies": { "@vue/devtools-kit": "^8.1.3" } }, "sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg=="],
|
||||
|
||||
"@vue/devtools-kit": ["@vue/[email protected]", "", { "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/[email protected]", "", {}, "sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA=="],
|
||||
|
||||
"@vue/language-core": ["@vue/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@vue/shared": "3.5.38" } }, "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/[email protected]", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "vue": "3.5.38" } }, "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw=="],
|
||||
|
||||
"@vue/shared": ["@vue/[email protected]", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/[email protected]", "", { "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/[email protected]", "", {}, "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/[email protected]", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="],
|
||||
|
||||
"accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
|
||||
"ajv": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"alien-signals": ["[email protected]", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||
|
||||
"asn1js": ["[email protected]", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
|
||||
|
||||
"assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-kit": ["[email protected]", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
|
||||
|
||||
"ast-walker-scope": ["[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.2", "@babel/types": "^7.29.0", "ast-kit": "^2.2.0" } }, "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A=="],
|
||||
|
||||
"birpc": ["[email protected]", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"body-parser": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bytes": ["[email protected]", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"confbox": ["[email protected]", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
|
||||
|
||||
"content-disposition": ["[email protected]", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["[email protected]", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["[email protected]", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["[email protected]", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["[email protected]", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["[email protected]", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"drizzle-kit": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["[email protected]", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||
|
||||
"entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["[email protected]", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"es-object-atoms": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"etag": ["[email protected]", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["[email protected]", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["[email protected]", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"express": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||
|
||||
"exsolve": ["[email protected]", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["[email protected]", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"finalhandler": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"framer-motion": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["[email protected]", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"gopd": ["[email protected]", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-symbols": ["[email protected]", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hey-listen": ["[email protected]", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
|
||||
|
||||
"hono": ["[email protected]", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
|
||||
|
||||
"hono-rate-limiter": ["[email protected]", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" }, "optionalPeers": ["unstorage"] }, "sha512-M0DxbVMpPELEzLi0AJg1XyBHLGJXz7GySjsPoK+gc5YeeBsdGDGe+2RvVuCAv8ydINiwlbxqYMNxUEyYfRji/A=="],
|
||||
|
||||
"hookable": ["[email protected]", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"http-errors": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["[email protected]", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"ipaddr.js": ["[email protected]", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["[email protected]", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"jose": ["[email protected]", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["[email protected]", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["[email protected]", "", { "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": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"local-pkg": ["[email protected]", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="],
|
||||
|
||||
"lucide-vue-next": ["[email protected]", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magic-string-ast": ["[email protected]", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
|
||||
|
||||
"math-intrinsics": ["[email protected]", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["[email protected]", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["[email protected]", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["[email protected]", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mlly": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="],
|
||||
|
||||
"motion-utils": ["[email protected]", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"motion-v": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"muggle-string": ["[email protected]", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"negotiator": ["[email protected]", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["[email protected]", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"obug": ["[email protected]", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="],
|
||||
|
||||
"on-finished": ["[email protected]", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"oxfmt": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-browserify": ["[email protected]", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["[email protected]", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pathe": ["[email protected]", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["[email protected]", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pixelmatch": ["[email protected]", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg=="],
|
||||
|
||||
"pkce-challenge": ["[email protected]", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"pkg-types": ["[email protected]", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
|
||||
|
||||
"pngjs": ["[email protected]", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"proxy-addr": ["[email protected]", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"pvtsutils": ["[email protected]", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||
|
||||
"pvutils": ["[email protected]", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
|
||||
|
||||
"qs": ["[email protected]", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"quansync": ["[email protected]", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
"range-parser": ["[email protected]", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["[email protected]", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"readdirp": ["[email protected]", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"reflect-metadata": ["[email protected]", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
||||
|
||||
"require-from-string": ["[email protected]", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve-pkg-maps": ["[email protected]", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rolldown": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scule": ["[email protected]", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
|
||||
|
||||
"send": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["[email protected]", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"source-map-support": ["[email protected]", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"statuses": ["[email protected]", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"std-env": ["[email protected]", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="],
|
||||
|
||||
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinybench": ["[email protected]", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["[email protected]", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tinypool": ["[email protected]", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"toidentifier": ["[email protected]", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"totalist": ["[email protected]", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["[email protected]", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
||||
|
||||
"tsyringe": ["[email protected]", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
|
||||
|
||||
"type-is": ["[email protected]", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["[email protected]", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"unpipe": ["[email protected]", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"unplugin": ["[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
|
||||
|
||||
"unplugin-utils": ["[email protected]", "", { "dependencies": { "pathe": "^2.0.3", "picomatch": "^4.0.3" } }, "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog=="],
|
||||
|
||||
"vary": ["[email protected]", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vite": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["[email protected]", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"yaml": ["[email protected]", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zod": ["[email protected]", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@babel/generator/@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="],
|
||||
|
||||
"@babel/parser/@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"ast-walker-scope/@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"body-parser/content-type": ["[email protected]", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"mlly/pkg-types": ["[email protected]", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"tsx/esbuild": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||
|
||||
"type-is/content-type": ["[email protected]", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"ast-walker-scope/@babel/types/@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"ast-walker-scope/@babel/types/@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"mlly/pkg-types/confbox": ["[email protected]", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
}
|
||||
}
|
||||
@@ -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": "[email protected]"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# sbi-client
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@repo/sbi-client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"module": "index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export { createMethodsFromSession } from './methods'
|
||||
export {
|
||||
SBI_SERVER_ERROR_MESSAGES,
|
||||
SbiServerError,
|
||||
getSbiServerErrorMessage,
|
||||
} from './methods/error-map'
|
||||
export type { SbiServerErrorOptions } from './methods/error-map'
|
||||
export { loginWithPasskey } from './session'
|
||||
export type {
|
||||
LoginWithPasskeyOptions,
|
||||
PasskeyLoginResponse,
|
||||
PlaintextStoredWebAuthnCredential,
|
||||
SbiClientOptions,
|
||||
StoredWebAuthnCredential,
|
||||
StoredWebAuthnCredentialSecret,
|
||||
WebAuthnAlgorithm,
|
||||
WebAuthnJwk,
|
||||
WebAuthnTransport,
|
||||
WebAuthnUserVerification,
|
||||
ChartPeriod,
|
||||
ChartPrice,
|
||||
IssueChart,
|
||||
IssueSearchItem,
|
||||
IssueSearchResult,
|
||||
IssueSearchStatus,
|
||||
} from './types'
|
||||
export type * from './methods/types'
|
||||
@@ -0,0 +1,134 @@
|
||||
export const SBI_SERVER_ERROR_MESSAGES: Record<string, string> = {
|
||||
ME0010: 'No data is available.',
|
||||
E_0001:
|
||||
'Access is currently unstable. Try logging in again later or use the desktop website or HYPER SBI.',
|
||||
E_0002: 'Communication failed. Check your network connection and try again.',
|
||||
E_0003: 'You were logged out because the session was inactive for 60 minutes. Sign in again.',
|
||||
E_0004: 'User name is required.',
|
||||
E_0005: 'Login password is required.',
|
||||
E_0007: 'The trading password is incorrect.',
|
||||
E_0008: 'The requested issue does not exist.',
|
||||
E_0009:
|
||||
'Login to this app is restricted. Remove the restriction from the SBI Securities website settings.',
|
||||
E_0010:
|
||||
'More than 1,000 cash position records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0011:
|
||||
'More than 1,000 cash position records cannot be displayed in this app. Try the all-records view.',
|
||||
E_0012: 'Order quantity is required.',
|
||||
E_0013: 'Order price is required.',
|
||||
E_0014: 'Stop order trigger condition is required.',
|
||||
E_0015: 'Trading password is required.',
|
||||
E_0016:
|
||||
'More than 1,000 margin position records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0017:
|
||||
'More than 1,000 margin position records cannot be displayed in this app. Try the all-records view.',
|
||||
E_0018: 'Issue name or issue code is required.',
|
||||
E_0019: 'There are no sellable cash shares.',
|
||||
E_0020: 'Failed to sync the watchlist. Sign in again to display the latest watchlist.',
|
||||
E_0021: 'No matching issues were found. Change the search criteria and try again.',
|
||||
E_0022:
|
||||
'More than 1,000 order inquiry records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0023:
|
||||
'More than 1,000 open order records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0024:
|
||||
'More than 1,000 same-day execution records cannot be displayed in this app. Check the desktop website.',
|
||||
E_0025: 'No data is available.',
|
||||
E_0026: 'There are no closeable long margin positions.',
|
||||
E_0027: 'There are no closeable short margin positions.',
|
||||
E_0028: 'Watchlist name is required.',
|
||||
E_0029: 'Too many issues matched, so all issues cannot be displayed.',
|
||||
E_0030: 'An unexpected error occurred while fetching board prices.',
|
||||
E_0031: 'The session timed out.',
|
||||
E_0032: 'An unexpected error occurred.',
|
||||
E_0033: 'A required parameter is missing.',
|
||||
E_0034: 'Settings could not be retrieved.',
|
||||
E_0035: 'A connection or read timeout occurred.',
|
||||
E_0036:
|
||||
'Order reception timed out. The order may already have been submitted; check order inquiry.',
|
||||
E_0038: 'There are no margin positions available for stock receipt.',
|
||||
E_0039: 'There are no margin positions available for stock delivery.',
|
||||
E_0040: 'The order quantity exceeds the maximum input quantity.',
|
||||
E_0041: 'Failed to update the watchlist. It will be synced with the latest watchlist.',
|
||||
E_0042: 'Failed to sync the watchlist. Sign in again to display the latest watchlist.',
|
||||
E_0043: 'The selected index cannot be displayed on a chart.',
|
||||
E_0044: 'The selected issue cannot be traded on the PTS market.',
|
||||
E_0045: 'Margin trade type is not selected.',
|
||||
E_0046: 'The selected value is invalid. Select it again.',
|
||||
E_0047: 'The order quantity is invalid. Enter a valid half-width numeric quantity.',
|
||||
E_0048:
|
||||
'Buying power may be insufficient to order all theme component issues. Return to order input to change quantities.',
|
||||
E_0049:
|
||||
'The NISA investment limit may be insufficient to order all theme component issues. Return to order input to change quantities.',
|
||||
E_0050: 'A search error occurred.',
|
||||
E_0051: 'No target data is available.',
|
||||
E_0052: 'No details match the conditions.',
|
||||
E_0053: 'Failed to retrieve registered issues. The registered issue feature is unavailable.',
|
||||
E_0054: 'No watchlist is registered. Create one from the edit button.',
|
||||
E_0055:
|
||||
'The registered issue limit is 50. Delete an issue from notification settings before adding another.',
|
||||
E_0056: 'The registered issue limit is 50. Delete an issue before adding another.',
|
||||
E_0057: 'This issue is already registered.',
|
||||
E_0058: 'No matching issue was found.',
|
||||
E_0059: 'No issue is registered. Add an issue from the edit icon.',
|
||||
E_0060: 'OCO1 price is required.',
|
||||
E_0061: 'OCO2 stop order trigger condition is required.',
|
||||
E_0062: 'OCO2 price is required.',
|
||||
E_0063: 'IFD2 price is required.',
|
||||
E_0064: 'IFD2 stop order trigger condition is required.',
|
||||
E_0065: 'Order quantity is invalid.',
|
||||
E_0066: 'Order price is invalid.',
|
||||
E_0067: 'Stop order trigger condition is invalid.',
|
||||
E_0068: 'OCO1 price is invalid.',
|
||||
E_0069: 'OCO2 stop order trigger condition is invalid.',
|
||||
E_0070: 'OCO2 price is invalid.',
|
||||
E_0071: 'IFD2 price is invalid.',
|
||||
E_0072: 'IFD2 stop order trigger condition is invalid.',
|
||||
E_0073: 'Target value is required.',
|
||||
E_0074: 'Password reset is required. Complete the reset procedure on the website.',
|
||||
E_0075: 'An error occurred. Wait a while and try again.',
|
||||
E_0076:
|
||||
'Registering a new FIDO smartphone authentication credential will disable the previous one.',
|
||||
E_0077: 'Signed in with FIDO smartphone authentication.',
|
||||
E_0078:
|
||||
'FIDO smartphone authentication is not complete. Scan the QR code with the SBI Securities Smart App on another device, complete authentication, and try again.',
|
||||
E_0079: 'An error occurred. Start again from login.',
|
||||
E_0080: 'Login from an invalid device was detected. The app will exit.',
|
||||
E_0081:
|
||||
'Identity verification is not complete. Call the authentication phone number from the registered phone number.',
|
||||
E_0082: 'The request expired. Start the procedure again.',
|
||||
E_0083: 'An error occurred. Wait a while and try again.',
|
||||
}
|
||||
|
||||
export type SbiServerErrorOptions = {
|
||||
code: string
|
||||
status?: string
|
||||
serverMessage?: string
|
||||
trCode?: string
|
||||
requestUrl?: string
|
||||
}
|
||||
|
||||
export class SbiServerError extends Error {
|
||||
readonly code: string
|
||||
readonly status?: string
|
||||
readonly serverMessage?: string
|
||||
readonly englishMessage: string
|
||||
readonly trCode?: string
|
||||
readonly requestUrl?: string
|
||||
|
||||
constructor(options: SbiServerErrorOptions) {
|
||||
const englishMessage = getSbiServerErrorMessage(options.code)
|
||||
const serverMessage = options.serverMessage ? ` Server message: ${options.serverMessage}` : ''
|
||||
super(`SBI server error ${options.code}: ${englishMessage}${serverMessage}`)
|
||||
Object.setPrototypeOf(this, new.target.prototype)
|
||||
this.name = 'SbiServerError'
|
||||
this.code = options.code
|
||||
this.status = options.status
|
||||
this.serverMessage = options.serverMessage
|
||||
this.englishMessage = englishMessage
|
||||
this.trCode = options.trCode
|
||||
this.requestUrl = options.requestUrl
|
||||
}
|
||||
}
|
||||
|
||||
export const getSbiServerErrorMessage = (code: string) =>
|
||||
SBI_SERVER_ERROR_MESSAGES[code] ?? `SBI server returned error code ${code}.`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,517 @@
|
||||
import type {
|
||||
AccountProfile,
|
||||
AccountType,
|
||||
Board,
|
||||
BuyingPower,
|
||||
CashPositionList,
|
||||
ChartPeriod,
|
||||
DepositType,
|
||||
DomesticMarket,
|
||||
IssueCode,
|
||||
IssueChart,
|
||||
IssueSearchResult,
|
||||
MarginPositionList,
|
||||
MarginTradeSide,
|
||||
MarketCode,
|
||||
MarketIndex,
|
||||
NewsList,
|
||||
OrderId,
|
||||
OrderKind,
|
||||
OrderList,
|
||||
OrderPreview,
|
||||
OrderReceipt,
|
||||
OrderStatus,
|
||||
PositionId,
|
||||
ProfitLossSummary,
|
||||
Quote,
|
||||
Ranking,
|
||||
ThemeId,
|
||||
ThemeInvestmentList,
|
||||
TradeSide,
|
||||
Watchlist,
|
||||
} from '../types'
|
||||
|
||||
export type PagingOptions = {
|
||||
/** Start index for the result list. Defaults to the first item when omitted. */
|
||||
index?: number
|
||||
/** Maximum number of items to fetch. Uses the implementation default when omitted. */
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type DateRangeOptions = {
|
||||
/** Start date for the inquiry range. */
|
||||
from?: string
|
||||
/** End date for the inquiry range. */
|
||||
to?: string
|
||||
}
|
||||
|
||||
export type IssueOptions = {
|
||||
/** Issue code to request. */
|
||||
issueCode: IssueCode
|
||||
/** Market code to request. */
|
||||
market?: MarketCode
|
||||
}
|
||||
|
||||
export type MarketIssueBoardPollingOptions = IssueOptions & {
|
||||
/** Poll interval in seconds. Defaults to 5 seconds. */
|
||||
intervalSeconds?: number
|
||||
/** Stops the polling iterator when aborted. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type IssueChartOptions = IssueOptions & {
|
||||
/** Chart period. Defaults to daily candles. */
|
||||
period?: ChartPeriod
|
||||
/** Candle unit. Minute charts accept 1, 5, 10, or 15. Other periods use 1. */
|
||||
unit?: number
|
||||
/** Number of historical prices to request. Defaults to 120. */
|
||||
count?: number
|
||||
}
|
||||
|
||||
export type IssueSearchOptions = {
|
||||
/** Search text, such as an issue code, name, or keyword. */
|
||||
query: string
|
||||
/** Filters returned issues by market code on the client side. */
|
||||
market?: MarketCode
|
||||
/** Maximum number of returned issues after client-side filtering. */
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type CashPositionOptions = PagingOptions & {
|
||||
/** Filters cash positions by issue code. */
|
||||
issueCode?: IssueCode
|
||||
/** Filters cash positions by market code. */
|
||||
market?: MarketCode
|
||||
/** Filters cash positions by account type. */
|
||||
accountType?: AccountType
|
||||
}
|
||||
|
||||
export type MarginPositionOptions = PagingOptions & {
|
||||
/** Filters margin positions by issue code. */
|
||||
issueCode?: IssueCode
|
||||
/** Filters margin positions by market code. */
|
||||
market?: MarketCode
|
||||
/** Filters margin positions by short or long side. */
|
||||
side?: MarginTradeSide
|
||||
/** Filters margin positions by account type. */
|
||||
accountType?: AccountType
|
||||
}
|
||||
|
||||
export type OrderInquiryOptions = PagingOptions &
|
||||
DateRangeOptions & {
|
||||
/** Filters order inquiry results by issue code. */
|
||||
issueCode?: IssueCode
|
||||
/** Filters order inquiry results by market code. */
|
||||
market?: MarketCode
|
||||
/** Filters order inquiry results by order status. */
|
||||
status?: OrderStatus
|
||||
}
|
||||
|
||||
export type BoardOptions = IssueOptions & {
|
||||
/** Account type used when requesting board-order information. */
|
||||
accountType?: AccountType
|
||||
/** Trading action used when requesting board-order information. */
|
||||
side?:
|
||||
| 'cashBuy'
|
||||
| 'cashSell'
|
||||
| 'marginOpen'
|
||||
| 'marginOpenBuy'
|
||||
| 'marginOpenSell'
|
||||
| 'marginClose'
|
||||
| 'marginCloseBuy'
|
||||
| 'marginCloseSell'
|
||||
}
|
||||
|
||||
export type StockOrderBaseOptions = {
|
||||
/** Issue code to order. */
|
||||
issueCode: IssueCode
|
||||
/** Market code to order on. */
|
||||
market: MarketCode
|
||||
/** Previous market code sent with SOR orders. Defaults to the value returned at login. */
|
||||
sorLastMarket?: MarketCode
|
||||
/** Buy or sell side for the order. */
|
||||
side: TradeSide
|
||||
/** Account type used for the order. */
|
||||
accountType?: AccountType
|
||||
/** Order quantity. */
|
||||
quantity: number
|
||||
/** Deposit type used for the order. */
|
||||
depositType?: DepositType
|
||||
}
|
||||
|
||||
export type CashOrderPriceCondition =
|
||||
| 'limit'
|
||||
| 'limitAtOpen'
|
||||
| 'limitAtClose'
|
||||
| 'limitIoc'
|
||||
| 'market'
|
||||
| 'marketAtOpen'
|
||||
| 'marketAtClose'
|
||||
| 'marketIoc'
|
||||
| 'funari'
|
||||
|
||||
export type CashOrderTerm = 'day' | 'week' | 'date'
|
||||
|
||||
export type CashOrderTriggerZone = 'above' | 'below'
|
||||
|
||||
export type CashOrderMethod = 'normal' | 'stop' | 'oco'
|
||||
|
||||
export type StandardCashOrderOptions = StockOrderBaseOptions & {
|
||||
/** Order price for limit and other price-based orders. */
|
||||
price?: number
|
||||
/** Order kind, such as market or limit. */
|
||||
kind?: Exclude<OrderKind, 's'>
|
||||
/** APK/MTS execution condition, such as 指値, 寄指, IOC成, or 不成. */
|
||||
priceCondition?: CashOrderPriceCondition
|
||||
/** Order validity. `date` requires `orderDate` in yyyyMMdd or yyyy-MM-dd format. */
|
||||
orderTerm?: CashOrderTerm
|
||||
/** Explicit validity date used when `orderTerm` is `date`. */
|
||||
orderDate?: string
|
||||
/** Special order method. `stop` sends SLO and `oco` sends OCO. */
|
||||
orderMethod?: CashOrderMethod
|
||||
/** Stop trigger direction used by stop/OCO orders. */
|
||||
triggerZone?: CashOrderTriggerZone
|
||||
/** Stop trigger price used by stop/OCO orders. */
|
||||
triggerPrice?: number
|
||||
/** Secondary execution condition used by OCO orders. */
|
||||
secondaryPriceCondition?: CashOrderPriceCondition
|
||||
/** Secondary order price used by OCO price-based conditions. */
|
||||
secondaryPrice?: number
|
||||
}
|
||||
|
||||
export type SKabuOrderOptions = StockOrderBaseOptions & {
|
||||
/** Places the cash order as an S-kabu order. S-kabu cannot specify a price. */
|
||||
kind: 's'
|
||||
/** S-kabu cannot specify a price. */
|
||||
price?: never
|
||||
}
|
||||
|
||||
export type CashOrderOptions = StandardCashOrderOptions | SKabuOrderOptions
|
||||
|
||||
export type MarginOpenOrderOptions = StandardCashOrderOptions
|
||||
|
||||
export type ActualDeliveryKind = 'genbiki' | 'genwatashi'
|
||||
|
||||
export type ActualDeliveryOrderOptions = {
|
||||
/** Issue code to deliver. */
|
||||
issueCode: IssueCode
|
||||
/** Market code for the issue. */
|
||||
market: MarketCode
|
||||
/** Account type used for the order. */
|
||||
accountType?: AccountType
|
||||
/** Order quantity. */
|
||||
quantity: number
|
||||
/** Deposit type used for the order. */
|
||||
depositType?: DepositType
|
||||
/** Order price for price-based actual-delivery requests. */
|
||||
price?: number
|
||||
/** Actual-delivery action: `genbiki` for 現引, `genwatashi` for 現渡. */
|
||||
kind: ActualDeliveryKind
|
||||
/** Position ID to deliver. */
|
||||
positionId?: PositionId
|
||||
}
|
||||
|
||||
export type PlaceCashOrderOptions = CashOrderOptions & {
|
||||
/** Confirmation ID returned by the confirmation step. */
|
||||
confirmationId?: string
|
||||
/** Explicitly allows sending a live order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type PlaceMarginOpenOrderOptions = MarginOpenOrderOptions & {
|
||||
/** Confirmation ID returned by the confirmation step. */
|
||||
confirmationId?: string
|
||||
/** Explicitly allows sending a live margin open order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type PlaceActualDeliveryOrderOptions = ActualDeliveryOrderOptions & {
|
||||
/** Confirmation ID returned by the confirmation step. */
|
||||
confirmationId?: string
|
||||
/** Explicitly allows sending a live actual-delivery order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type OrderCorrectionOptions = {
|
||||
/** Order ID to correct. */
|
||||
orderId: OrderId
|
||||
/** Corrected order quantity. */
|
||||
quantity?: number
|
||||
/** Corrected order price. */
|
||||
price?: number
|
||||
}
|
||||
|
||||
export type PlaceOrderCorrectionOptions = OrderCorrectionOptions & {
|
||||
/** Explicitly allows sending a live correction request. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type OrderCancelOptions = {
|
||||
/** Order number shown in order inquiry. */
|
||||
orderNumber: string
|
||||
/** Original order ID shown in order inquiry. */
|
||||
orderId?: OrderId
|
||||
/** Original trade ID code. Defaults to cash stock when omitted. */
|
||||
tradeId?: string
|
||||
/** Additional cancel flag used by the mobile MTS route. */
|
||||
cancelType?: string
|
||||
}
|
||||
|
||||
export type PlaceOrderCancelOptions = OrderCancelOptions & {
|
||||
/** Trading password used by SBI to submit the cancellation. */
|
||||
tradePassword?: string
|
||||
/** Explicitly allows sending a live cancellation request. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type MarginCloseOrderOptions = StandardCashOrderOptions & {
|
||||
/** Position ID to close. */
|
||||
positionId?: PositionId
|
||||
}
|
||||
|
||||
export type PlaceMarginCloseOrderOptions = MarginCloseOrderOptions & {
|
||||
/** Explicitly allows sending a live margin close order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type MarginCloseSummaryOrderOptions = MarginCloseOrderOptions
|
||||
|
||||
export type PlaceMarginCloseSummaryOrderOptions = MarginCloseSummaryOrderOptions & {
|
||||
/** Explicitly allows sending a live margin close summary order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type IfdOrderOptions = StandardCashOrderOptions & {
|
||||
/** Product to use for the first IFD leg. Defaults to cash. */
|
||||
tradeType?: 'cash' | 'marginOpen'
|
||||
}
|
||||
|
||||
export type PlaceIfdOrderOptions = IfdOrderOptions & {
|
||||
/** Confirmation ID returned by the confirmation step. */
|
||||
confirmationId?: string
|
||||
/** Explicitly allows sending a live IFD order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type ThemeInvestmentOrderOptions = {
|
||||
/** Theme ID for the theme investment order. */
|
||||
themeId: ThemeId
|
||||
/** Buy or sell side for the order. */
|
||||
side: TradeSide
|
||||
/** Order amount for the theme investment order. */
|
||||
amount?: number
|
||||
}
|
||||
|
||||
export type PlaceThemeInvestmentOrderOptions = ThemeInvestmentOrderOptions & {
|
||||
/** Explicitly allows sending a live theme investment order. */
|
||||
allowTrading?: true
|
||||
}
|
||||
|
||||
export type AccountPowerOptions = {
|
||||
/** Fetches margin-account collateral details. Disable this for accounts without margin trading. */
|
||||
includeMarginAccount?: boolean
|
||||
}
|
||||
|
||||
export interface SbiClientMethodSession {
|
||||
/** Returns the current authenticated session profile. */
|
||||
profile(): Promise<AccountProfile>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodAccountPower {
|
||||
/** Fetches buying power, margin buying power, withdrawable amount, and related account power values. */
|
||||
buyingPower(options?: AccountPowerOptions): Promise<BuyingPower>
|
||||
/** Fetches the collateral ratio and related margin collateral details. */
|
||||
collateralRatio(options?: AccountPowerOptions): Promise<BuyingPower>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodAccountPositions {
|
||||
/** Fetches cash positions. */
|
||||
cash(options?: CashPositionOptions): Promise<CashPositionList>
|
||||
/** Fetches the alternate cash-position list used by the mobile app. */
|
||||
cashDetail(options?: CashPositionOptions): Promise<CashPositionList>
|
||||
/** Fetches cash positions for a specific issue. */
|
||||
cashForIssue(options: IssueOptions): Promise<CashPositionList>
|
||||
/** Fetches margin positions. */
|
||||
margin(options?: MarginPositionOptions): Promise<MarginPositionList>
|
||||
/** Fetches the alternate margin-position list used by the mobile app. */
|
||||
marginDetail(options?: MarginPositionOptions): Promise<MarginPositionList>
|
||||
/** Fetches margin positions for a specific issue. */
|
||||
marginForIssue(options: IssueOptions): Promise<MarginPositionList>
|
||||
/** Fetches margin positions for a specific issue aggregated by issue. */
|
||||
marginSummaryForIssue(options: IssueOptions): Promise<MarginPositionList>
|
||||
/** Fetches individual margin positions for a specific issue. */
|
||||
marginDetailsForIssue(options: IssueOptions): Promise<MarginPositionList>
|
||||
/** Fetches margin positions available for close orders. */
|
||||
closeableMargin(options: MarginPositionOptions): Promise<MarginPositionList>
|
||||
/** Fetches margin positions available for stock delivery. */
|
||||
deliverableMargin(options: MarginPositionOptions): Promise<MarginPositionList>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodAccountProfitLoss {
|
||||
/** Fetches the unrealized profit and loss summary for cash and margin positions. */
|
||||
unrealized(): Promise<ProfitLossSummary>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodAccount {
|
||||
/** Returns the current account profile. */
|
||||
profile(): Promise<AccountProfile>
|
||||
/** Methods for fetching buying power and collateral information. */
|
||||
power: SbiClientMethodAccountPower
|
||||
/** Methods for fetching cash and margin positions. */
|
||||
positions: SbiClientMethodAccountPositions
|
||||
/** Methods for fetching profit and loss information. */
|
||||
profitLoss: SbiClientMethodAccountProfitLoss
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarketIssue {
|
||||
/** Searches domestic issues by code, name, or keyword. */
|
||||
search(options: IssueSearchOptions): Promise<IssueSearchResult>
|
||||
/** Fetches issue suggestions for partial input. */
|
||||
suggest(options: IssueSearchOptions): Promise<IssueSearchResult>
|
||||
/** Fetches prices accepted as order input for an issue. */
|
||||
allowedPrices(options: IssueOptions): Promise<Quote>
|
||||
/** Fetches board information for an issue. */
|
||||
board(options: IssueOptions): Promise<Board>
|
||||
/** Polls board information for an issue using the same endpoint as `board`. */
|
||||
pollBoard(options: MarketIssueBoardPollingOptions): AsyncIterableIterator<Board>
|
||||
/** Fetches historical chart prices for an issue. */
|
||||
chart(options: IssueChartOptions): Promise<IssueChart>
|
||||
/** Fetches open orders for an issue. */
|
||||
openOrders(options: IssueOptions): Promise<OrderList>
|
||||
/** Fetches board and issue information useful before placing an order. */
|
||||
tradingInfo(options: BoardOptions): Promise<Board>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarketIndex {
|
||||
/** Fetches major market indexes. */
|
||||
major(): Promise<MarketIndex[]>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarketRanking {
|
||||
/** Fetches market rankings. */
|
||||
market(): Promise<Ranking>
|
||||
/** Fetches sector rankings. */
|
||||
sector(): Promise<Ranking>
|
||||
/** Fetches SBI-provided rankings. */
|
||||
sbi(): Promise<Ranking>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarket {
|
||||
/** Methods for fetching issue quotes, boards, and order-related market information. */
|
||||
issue: SbiClientMethodMarketIssue
|
||||
/** Methods for fetching market index information. */
|
||||
index: SbiClientMethodMarketIndex
|
||||
/** Fetches the domestic market overview. */
|
||||
overview(): Promise<DomesticMarket>
|
||||
/** Methods for fetching ranking information. */
|
||||
ranking: SbiClientMethodMarketRanking
|
||||
}
|
||||
|
||||
export interface SbiClientMethodNews {
|
||||
/** Fetches news items. */
|
||||
list(): Promise<NewsList>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodWatchlist {
|
||||
/** Fetches registered watchlists. */
|
||||
list(): Promise<Watchlist[]>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodOrderInquiry {
|
||||
/** Fetches orders executed today. */
|
||||
executionsToday(options?: OrderInquiryOptions): Promise<OrderList>
|
||||
/** Fetches open or recently active orders. */
|
||||
open(options?: OrderInquiryOptions): Promise<OrderList>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodCashOrder {
|
||||
/** Estimates a cash order without submitting a live order. */
|
||||
estimate(options: CashOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live cash order. Requires `allowTrading: true`. */
|
||||
place(options: PlaceCashOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a cash order correction without submitting a live correction. */
|
||||
estimateCorrection(options: OrderCorrectionOptions): Promise<OrderPreview>
|
||||
/** Estimates the mobile correction-confirmation route without submitting a live correction. */
|
||||
estimateCorrectionConfirm(options: OrderCorrectionOptions): Promise<OrderPreview>
|
||||
/** Places a live cash order correction. Requires `allowTrading: true`. */
|
||||
placeCorrection(options: PlaceOrderCorrectionOptions): Promise<OrderReceipt>
|
||||
/** Estimates a cash order cancellation without submitting a live cancellation. */
|
||||
estimateCancel(options: OrderCancelOptions): Promise<OrderPreview>
|
||||
/** Places a live cash order cancellation. Requires `allowTrading: true`. */
|
||||
placeCancel(options: PlaceOrderCancelOptions): Promise<OrderReceipt>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodMarginOrder {
|
||||
/** Estimates a margin-open order without submitting a live order. */
|
||||
estimateOpen(options: MarginOpenOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live margin-open order. Requires `allowTrading: true`. */
|
||||
open(options: PlaceMarginOpenOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a margin close order without submitting a live order. */
|
||||
estimateClose(options: MarginCloseOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live margin close order. Requires `allowTrading: true`. */
|
||||
close(options: PlaceMarginCloseOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a margin close order by position summary without submitting a live order. */
|
||||
estimateCloseSummary(options: MarginCloseOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live margin close order by position summary. Requires `allowTrading: true`. */
|
||||
closeSummary(options: PlaceMarginCloseOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a mobile margin close summary order without submitting a live order. */
|
||||
estimateSummary(options: MarginCloseSummaryOrderOptions): Promise<OrderPreview>
|
||||
/** Places a mobile margin close summary order. Requires `allowTrading: true`. */
|
||||
placeSummary(options: PlaceMarginCloseSummaryOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates a genbiki/genwatashi actual-delivery order without submitting a live order. */
|
||||
estimateActualDelivery(options: ActualDeliveryOrderOptions): Promise<OrderPreview>
|
||||
/** Places a genbiki/genwatashi actual-delivery order. Requires `allowTrading: true`. */
|
||||
actualDelivery(options: PlaceActualDeliveryOrderOptions): Promise<OrderReceipt>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodIfdOrder {
|
||||
/** Estimates an IFD order without submitting a live order. */
|
||||
estimate(options: IfdOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live IFD order. Requires `allowTrading: true`. */
|
||||
place(options: PlaceIfdOrderOptions): Promise<OrderReceipt>
|
||||
/** Estimates an IFD order correction without submitting a live correction. */
|
||||
estimateCorrection(options: OrderCorrectionOptions): Promise<OrderPreview>
|
||||
/** Places a live IFD order correction. Requires `allowTrading: true`. */
|
||||
placeCorrection(options: PlaceOrderCorrectionOptions): Promise<OrderReceipt>
|
||||
/** Estimates an IFD order cancellation without submitting a live cancellation. */
|
||||
estimateCancel(options: OrderCorrectionOptions): Promise<OrderPreview>
|
||||
/** Places a live IFD order cancellation. Requires `allowTrading: true`. */
|
||||
placeCancel(options: PlaceOrderCorrectionOptions): Promise<OrderReceipt>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodThemeInvestmentOrder {
|
||||
/** Fetches theme investment holdings or order targets. */
|
||||
list(): Promise<ThemeInvestmentList>
|
||||
/** Estimates a theme investment order without submitting a live order. */
|
||||
estimate(options: ThemeInvestmentOrderOptions): Promise<OrderPreview>
|
||||
/** Places a live theme investment order. Requires `allowTrading: true`. */
|
||||
place(options: PlaceThemeInvestmentOrderOptions): Promise<OrderReceipt>
|
||||
}
|
||||
|
||||
export interface SbiClientMethodOrders {
|
||||
/** Methods for order inquiries. */
|
||||
inquiry: SbiClientMethodOrderInquiry
|
||||
/** Methods for estimating, placing, and correcting cash orders. */
|
||||
cash: SbiClientMethodCashOrder
|
||||
/** Methods for estimating and placing margin orders. */
|
||||
margin: SbiClientMethodMarginOrder
|
||||
/** Methods for estimating, placing, and correcting IFD orders. */
|
||||
ifd: SbiClientMethodIfdOrder
|
||||
/** Methods for estimating and placing theme investment orders. */
|
||||
themeInvestment: SbiClientMethodThemeInvestmentOrder
|
||||
}
|
||||
|
||||
export interface SbiClientMethods {
|
||||
/** Session-related methods. */
|
||||
session: SbiClientMethodSession
|
||||
/** Methods for account profile, buying power, positions, and profit and loss. */
|
||||
account: SbiClientMethodAccount
|
||||
/** Methods for market overviews, issues, indexes, and rankings. */
|
||||
market: SbiClientMethodMarket
|
||||
/** News methods. */
|
||||
news: SbiClientMethodNews
|
||||
/** Watchlist methods. */
|
||||
watchlist: SbiClientMethodWatchlist
|
||||
/** Methods for order history, estimates, live placement, and corrections. */
|
||||
orders: SbiClientMethodOrders
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import {
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
generateKeyPairSync,
|
||||
privateDecrypt,
|
||||
sign,
|
||||
constants,
|
||||
} from 'node:crypto'
|
||||
import { mkdtempSync, writeFileSync, unlinkSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createMethodsFromSession, registerDeviceId } from '../methods'
|
||||
import type {
|
||||
AccountProfile,
|
||||
LoginWithPasskeyOptions,
|
||||
PasskeyLoginResponse,
|
||||
PlaintextStoredWebAuthnCredential,
|
||||
SbiClientOptions,
|
||||
SbiSession,
|
||||
} from '../types'
|
||||
import type { SbiClientMethods } from '../methods/types'
|
||||
|
||||
type PasskeyLoginStart = {
|
||||
url: string
|
||||
privateKeyPem: string
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
type CredentialRequest = {
|
||||
challenge: string
|
||||
rpId: string
|
||||
csrfToken?: string
|
||||
}
|
||||
|
||||
type SbiEndpointConfig = {
|
||||
authBaseUrl: string
|
||||
mtsBaseUrl: string
|
||||
izanagiBaseUrl?: string
|
||||
}
|
||||
|
||||
export const loginWithPasskey = async (
|
||||
options: LoginWithPasskeyOptions,
|
||||
clientOptions: SbiClientOptions = {},
|
||||
): Promise<SbiClientMethods> => {
|
||||
const session = await createPasskeySession(options, clientOptions)
|
||||
return createMethodsFromSession(session)
|
||||
}
|
||||
|
||||
export const createPasskeySession = async (
|
||||
options: LoginWithPasskeyOptions,
|
||||
clientOptions: SbiClientOptions = {},
|
||||
): Promise<SbiSession> => {
|
||||
const endpoints = resolveSbiEndpointConfig(options)
|
||||
const started = startPasskeyLogin(endpoints.authBaseUrl)
|
||||
const jar = new CookieJar()
|
||||
const headers = defaultBrowserHeaders()
|
||||
|
||||
const entry = await fetchWithCookies(started.url, {
|
||||
jar,
|
||||
headers: {
|
||||
...headers,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'upgrade-insecure-requests': '1',
|
||||
},
|
||||
})
|
||||
const entryText = await entry.text()
|
||||
const csrfToken = extractCsrfToken(entryText)
|
||||
|
||||
const challengeUrl = new URL('/api/fido2/auth/challenge', started.url)
|
||||
challengeUrl.searchParams.set('cccid', 'kabu-app')
|
||||
const challengeResponse = await fetchWithCookies(challengeUrl, {
|
||||
jar,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
origin: new URL(started.url).origin,
|
||||
referer: started.url,
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
},
|
||||
})
|
||||
assertOk(challengeResponse, 'passkey challenge')
|
||||
|
||||
const challengeJson = await challengeResponse.json()
|
||||
const credentialRequest = normalizeCredentialRequest(challengeJson, options.passkeyCredential)
|
||||
const assertion = createWebAuthnAssertion(options.passkeyCredential, credentialRequest)
|
||||
const csrf = credentialRequest.csrfToken ?? csrfToken
|
||||
if (!csrf) throw new Error('missing CSRF token for passkey authentication')
|
||||
|
||||
const authUrl = new URL('/fido2/auth', started.url)
|
||||
authUrl.searchParams.set('cccid', 'kabu-app')
|
||||
const authResponse = await fetchWithCookies(authUrl, {
|
||||
jar,
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
...headers,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
origin: new URL(started.url).origin,
|
||||
referer: started.url,
|
||||
'upgrade-insecure-requests': '1',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
_csrf: csrf,
|
||||
id: assertion.id,
|
||||
rawId: assertion.rawId,
|
||||
clientDataJSON: assertion.clientDataJSON,
|
||||
authenticatorData: assertion.authenticatorData,
|
||||
signature: assertion.signature,
|
||||
userHandle: assertion.userHandle,
|
||||
type: 'public-key',
|
||||
}),
|
||||
})
|
||||
|
||||
const channelUrl = new URL(
|
||||
authResponse.headers.get('location') ?? '/sso/channel?cccid=kabu-app',
|
||||
started.url,
|
||||
)
|
||||
const channelResponse = await fetchWithCookies(channelUrl, {
|
||||
jar,
|
||||
headers: {
|
||||
...headers,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
referer: authUrl.toString(),
|
||||
'upgrade-insecure-requests': '1',
|
||||
},
|
||||
})
|
||||
assertOk(channelResponse, 'passkey callback')
|
||||
|
||||
const channelHtml = await channelResponse.text()
|
||||
const callbackUrl = extractCallbackUrl(channelHtml)
|
||||
if (!callbackUrl) throw new Error('passkey callback token was not found in sso/channel response')
|
||||
|
||||
const loginResponse = await finishPasskeyLogin({
|
||||
callbackUrl,
|
||||
privateKeyPem: started.privateKeyPem,
|
||||
mtsBaseUrl: endpoints.mtsBaseUrl,
|
||||
})
|
||||
const profile = parsePasskeyLoginProfile(loginResponse)
|
||||
const session: SbiSession = {
|
||||
mtsBaseUrl: endpoints.mtsBaseUrl,
|
||||
izanagiBaseUrl: endpoints.izanagiBaseUrl,
|
||||
profile,
|
||||
loginResponse,
|
||||
tradePassword: clientOptions.tradePassword,
|
||||
tradeAuthentication: clientOptions.tradeAuthentication,
|
||||
}
|
||||
if (clientOptions.deviceId) {
|
||||
await registerDeviceId(session, clientOptions.deviceId)
|
||||
session.deviceIdRegistered = true
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
const resolveSbiEndpointConfig = (options: LoginWithPasskeyOptions): SbiEndpointConfig => {
|
||||
const authBaseUrl = options.authBaseUrl ?? process.env.SBI_AUTH_BASE_URL
|
||||
const mtsBaseUrl = options.mtsBaseUrl ?? process.env.SBI_MTS_BASE_URL
|
||||
const izanagiBaseUrl = options.izanagiBaseUrl ?? process.env.SBI_IZANAGI_BASE_URL
|
||||
|
||||
if (!authBaseUrl) throw new Error('SBI_AUTH_BASE_URL is required')
|
||||
if (!mtsBaseUrl) throw new Error('SBI_MTS_BASE_URL is required')
|
||||
|
||||
return { authBaseUrl, mtsBaseUrl, izanagiBaseUrl: optionalUrl(izanagiBaseUrl) }
|
||||
}
|
||||
|
||||
const optionalUrl = (value: string | undefined) => {
|
||||
if (!value) return undefined
|
||||
return new URL(value).toString()
|
||||
}
|
||||
|
||||
const startPasskeyLogin = (authBaseUrl: string): PasskeyLoginStart => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicExponent: 0x10001,
|
||||
publicKeyEncoding: { type: 'spki', format: 'der' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
})
|
||||
|
||||
const publicKeyParam = base64Url(publicKey, true)
|
||||
const url = new URL(authBaseUrl)
|
||||
url.searchParams.set('channel', 'kabu-app')
|
||||
url.searchParams.set('pk', publicKeyParam)
|
||||
url.searchParams.set('ap', 'true')
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
privateKeyPem: privateKey,
|
||||
publicKey: publicKeyParam,
|
||||
}
|
||||
}
|
||||
|
||||
const finishPasskeyLogin = async (options: {
|
||||
callbackUrl: string
|
||||
privateKeyPem: string
|
||||
mtsBaseUrl: string
|
||||
}): Promise<PasskeyLoginResponse> => {
|
||||
const encryptedToken = extractEncryptedToken(options.callbackUrl)
|
||||
if (!encryptedToken) {
|
||||
throw new Error('callbackUrl must contain token=...')
|
||||
}
|
||||
|
||||
const accessToken = decryptPasskeyToken(encryptedToken, options.privateKeyPem)
|
||||
const requestUrl = new URL('/mtsmobile/ssologingate', options.mtsBaseUrl)
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
KIND: 'L',
|
||||
TOKEN: accessToken,
|
||||
}),
|
||||
})
|
||||
|
||||
const body = await response.arrayBuffer()
|
||||
const text = decodeShiftJis(body)
|
||||
|
||||
return {
|
||||
type: 'passkey-login-response',
|
||||
requestUrl: requestUrl.toString(),
|
||||
status: response.status,
|
||||
body,
|
||||
text,
|
||||
header: parseMtsHeader(text),
|
||||
}
|
||||
}
|
||||
|
||||
const extractEncryptedToken = (callbackUrl: string) => {
|
||||
const url = new URL(callbackUrl)
|
||||
if (url.searchParams.get('cmd') === 'pwlogin') return undefined
|
||||
return url.searchParams.get('token') ?? undefined
|
||||
}
|
||||
|
||||
const parsePasskeyLoginProfile = (response: PasskeyLoginResponse): AccountProfile => {
|
||||
const buffer = Buffer.from(response.body)
|
||||
const header = response.header
|
||||
let offset = 70
|
||||
|
||||
const loginStatusRaw = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
|
||||
if (loginStatusRaw === '7') {
|
||||
const trId = readShiftJisField(buffer, offset, 20)
|
||||
offset += 20
|
||||
const txId = readShiftJisField(buffer, offset, 36)
|
||||
offset += 36
|
||||
const actionToken = readShiftJisField(buffer, offset, 36)
|
||||
offset += 36
|
||||
const securityAuthenticationResponseCode = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const fidoResponseCode = readShiftJisField(buffer, offset, 4)
|
||||
|
||||
return {
|
||||
session: {
|
||||
sessionId: header?.sessionId ?? '',
|
||||
loginType: 'passkey',
|
||||
resultCode: header?.resultCode ?? '',
|
||||
},
|
||||
loginStatus: mapLoginStatus(loginStatusRaw),
|
||||
loginType: 'passkey',
|
||||
securityAuthenticationResponseCode: emptyToUndefined(securityAuthenticationResponseCode),
|
||||
fidoResponseCode: emptyToUndefined(fidoResponseCode),
|
||||
trId: emptyToUndefined(trId),
|
||||
txId: emptyToUndefined(txId),
|
||||
actionToken: emptyToUndefined(actionToken),
|
||||
}
|
||||
}
|
||||
|
||||
const butenCode = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const accountNumber = readShiftJisField(buffer, offset, 7)
|
||||
offset += 7
|
||||
const nextField = readShiftJisField(buffer, offset, 2)
|
||||
if (isSpecificAccountTypeRaw(nextField)) {
|
||||
const specificAccountTypeRaw = nextField
|
||||
offset += 2
|
||||
const marginAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const userId = readShiftJisField(buffer, offset, 32)
|
||||
offset += 32
|
||||
const nisaAccountRaw = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const jrNisaAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const jrNisaSpecificRaw = readShiftJisField(buffer, offset, 2)
|
||||
offset += 2
|
||||
const jrNisaSeigen = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const sorDefaultCode = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const sorLastMarket = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const sorLastMarketJrNisa = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const importantNoticeFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const noticeCount = parseIntOrUndefined(readShiftJisField(buffer, offset, 8))
|
||||
offset += 8
|
||||
const restrictedTradeFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const deficitMessageFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const deficitMessage = readShiftJisField(buffer, offset, 1500)
|
||||
offset += 1500
|
||||
const referenceableMaintenanceFlag = readShiftJisField(buffer, offset, 1)
|
||||
|
||||
return {
|
||||
session: {
|
||||
sessionId: header?.sessionId ?? '',
|
||||
loginType: 'passkey',
|
||||
resultCode: header?.resultCode ?? '',
|
||||
},
|
||||
branchCode: emptyToUndefined(butenCode),
|
||||
butenCode: emptyToUndefined(butenCode),
|
||||
accountNumber: emptyToUndefined(accountNumber),
|
||||
userId: emptyToUndefined(userId),
|
||||
loginStatus: mapLoginStatus(loginStatusRaw),
|
||||
loginType: 'passkey',
|
||||
accountType: mapSpecificAccountToAccountType(specificAccountTypeRaw),
|
||||
specificAccountType: mapSpecificAccountType(specificAccountTypeRaw),
|
||||
hasMarginAccount: marginAccount === '1',
|
||||
marginAccount: emptyToUndefined(marginAccount),
|
||||
nisa: {
|
||||
enabled: nisaAccountRaw !== '0' && nisaAccountRaw !== '',
|
||||
tradePermitted: nisaAccountRaw === '1' || nisaAccountRaw === '2' || nisaAccountRaw === '3',
|
||||
juniorEnabled: jrNisaAccount === '1',
|
||||
accountType: mapIsaAccountType(nisaAccountRaw),
|
||||
jrNisaAccount: emptyToUndefined(jrNisaAccount),
|
||||
jrNisaSpecific: mapSpecificAccountType(jrNisaSpecificRaw),
|
||||
jrNisaSeigen: emptyToUndefined(jrNisaSeigen),
|
||||
},
|
||||
sor: {
|
||||
defaultEnabled: sorDefaultCode === '1',
|
||||
defaultCode: emptyToUndefined(sorDefaultCode),
|
||||
lastMarket: emptyToUndefined(sorLastMarket),
|
||||
juniorNisaLastMarket: emptyToUndefined(sorLastMarketJrNisa),
|
||||
},
|
||||
notices: {
|
||||
hasImportantNotice: importantNoticeFlag === '1',
|
||||
importantNoticeFlag: emptyToUndefined(importantNoticeFlag),
|
||||
count: noticeCount,
|
||||
},
|
||||
restrictions: {
|
||||
tradeRestricted: restrictedTradeFlag === '1',
|
||||
restrictedTradeFlag: emptyToUndefined(restrictedTradeFlag),
|
||||
},
|
||||
deficit: {
|
||||
hasMessage: deficitMessageFlag === '1',
|
||||
messageFlag: emptyToUndefined(deficitMessageFlag),
|
||||
message: emptyToUndefined(deficitMessage),
|
||||
},
|
||||
maintenance: {
|
||||
referenceable: referenceableMaintenanceFlag === '1',
|
||||
referenceableMaintenanceFlag: emptyToUndefined(referenceableMaintenanceFlag),
|
||||
},
|
||||
}
|
||||
}
|
||||
const expireDate = readShiftJisField(buffer, offset, 8)
|
||||
offset += 8
|
||||
const lastLoginDate = readShiftJisField(buffer, offset, 8)
|
||||
offset += 8
|
||||
const lastLoginTime = readShiftJisField(buffer, offset, 6)
|
||||
offset += 6
|
||||
const corporateFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const specificAccountTypeRaw = readShiftJisField(buffer, offset, 2)
|
||||
offset += 2
|
||||
const marginAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const commissionPlan = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const userId = readShiftJisField(buffer, offset, 32)
|
||||
offset += 32
|
||||
const deficitMessage = readShiftJisField(buffer, offset, 1500)
|
||||
offset += 1500
|
||||
const tradingPassword = readShiftJisField(buffer, offset, 32)
|
||||
offset += 32
|
||||
const importantNoticeFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const restrictedTradeFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const noticeCount = parseIntOrUndefined(readShiftJisField(buffer, offset, 8))
|
||||
offset += 8
|
||||
const restrictedMessage = readShiftJisField(buffer, offset, 500)
|
||||
offset += 500
|
||||
const fxShareCol = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const deficitMessageFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const nisaAccountRaw = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const jrNisaAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const jrNisaSpecificRaw = readShiftJisField(buffer, offset, 2)
|
||||
offset += 2
|
||||
const jrNisaSeigen = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const fullTerm = readShiftJisField(buffer, offset, 8)
|
||||
offset += 8
|
||||
const fullAccount = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
offset += 8
|
||||
offset += 1
|
||||
const sorDefaultCode = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const sorLastMarket = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const sorLastMarketJrNisa = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const securityAuthenticationResponseCode = readShiftJisField(buffer, offset, 3)
|
||||
offset += 3
|
||||
const fidoResponseCode = readShiftJisField(buffer, offset, 4)
|
||||
offset += 4
|
||||
const referenceableMaintenanceFlag = readShiftJisField(buffer, offset, 1)
|
||||
offset += 1
|
||||
const passkeyStatus = readShiftJisField(buffer, offset, 1)
|
||||
|
||||
return {
|
||||
session: {
|
||||
sessionId: header?.sessionId ?? '',
|
||||
loginType: 'passkey',
|
||||
resultCode: header?.resultCode ?? '',
|
||||
},
|
||||
branchCode: emptyToUndefined(butenCode),
|
||||
butenCode: emptyToUndefined(butenCode),
|
||||
accountNumber: emptyToUndefined(accountNumber),
|
||||
userId: emptyToUndefined(userId),
|
||||
loginStatus: mapLoginStatus(loginStatusRaw),
|
||||
loginType: 'passkey',
|
||||
accountType: mapSpecificAccountToAccountType(specificAccountTypeRaw),
|
||||
specificAccountType: mapSpecificAccountType(specificAccountTypeRaw),
|
||||
hasMarginAccount: marginAccount === '1',
|
||||
marginAccount: emptyToUndefined(marginAccount),
|
||||
corporateFlag: emptyToUndefined(corporateFlag),
|
||||
commissionPlan: emptyToUndefined(commissionPlan),
|
||||
expireDate: emptyToUndefined(expireDate),
|
||||
lastLoginDate: emptyToUndefined(lastLoginDate),
|
||||
lastLoginTime: emptyToUndefined(lastLoginTime),
|
||||
tradingPassword: emptyToUndefined(tradingPassword),
|
||||
fxShareCol: emptyToUndefined(fxShareCol),
|
||||
fullTerm: emptyToUndefined(fullTerm),
|
||||
fullAccount: emptyToUndefined(fullAccount),
|
||||
securityAuthenticationResponseCode: emptyToUndefined(securityAuthenticationResponseCode),
|
||||
fidoResponseCode: emptyToUndefined(fidoResponseCode),
|
||||
passkeyStatus: emptyToUndefined(passkeyStatus),
|
||||
nisa: {
|
||||
enabled: nisaAccountRaw !== '0' && nisaAccountRaw !== '',
|
||||
tradePermitted: nisaAccountRaw === '1' || nisaAccountRaw === '2' || nisaAccountRaw === '3',
|
||||
juniorEnabled: jrNisaAccount === '1',
|
||||
accountType: mapIsaAccountType(nisaAccountRaw),
|
||||
jrNisaAccount: emptyToUndefined(jrNisaAccount),
|
||||
jrNisaSpecific: mapSpecificAccountType(jrNisaSpecificRaw),
|
||||
jrNisaSeigen: emptyToUndefined(jrNisaSeigen),
|
||||
},
|
||||
sor: {
|
||||
defaultEnabled: sorDefaultCode === '1',
|
||||
defaultCode: emptyToUndefined(sorDefaultCode),
|
||||
lastMarket: emptyToUndefined(sorLastMarket),
|
||||
juniorNisaLastMarket: emptyToUndefined(sorLastMarketJrNisa),
|
||||
},
|
||||
notices: {
|
||||
hasImportantNotice: importantNoticeFlag === '1',
|
||||
importantNoticeFlag: emptyToUndefined(importantNoticeFlag),
|
||||
count: noticeCount,
|
||||
},
|
||||
restrictions: {
|
||||
tradeRestricted: restrictedTradeFlag === '1',
|
||||
restrictedTradeFlag: emptyToUndefined(restrictedTradeFlag),
|
||||
message: emptyToUndefined(restrictedMessage),
|
||||
},
|
||||
deficit: {
|
||||
hasMessage: deficitMessageFlag === '1',
|
||||
messageFlag: emptyToUndefined(deficitMessageFlag),
|
||||
message: emptyToUndefined(deficitMessage),
|
||||
},
|
||||
maintenance: {
|
||||
referenceable: referenceableMaintenanceFlag === '1',
|
||||
referenceableMaintenanceFlag: emptyToUndefined(referenceableMaintenanceFlag),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const readShiftJisField = (buffer: Buffer, offset: number, length: number) => {
|
||||
if (offset >= buffer.length) return ''
|
||||
return decodeShiftJis(buffer.subarray(offset, Math.min(offset + length, buffer.length)))
|
||||
.replaceAll('\u0000', '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const emptyToUndefined = (value: string) => (value.length > 0 ? value : undefined)
|
||||
|
||||
const parseIntOrUndefined = (value: string) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
const mapLoginStatus = (value: string): AccountProfile['loginStatus'] => {
|
||||
switch (value) {
|
||||
case '0':
|
||||
return 'success'
|
||||
case '1':
|
||||
return 'invalidUser'
|
||||
case '2':
|
||||
return 'tradeForbidden'
|
||||
case '5':
|
||||
return 'locked'
|
||||
case '6':
|
||||
return 'fidoAuthorizationIncorrect'
|
||||
case '7':
|
||||
return 'fidoAuthorization'
|
||||
case '8':
|
||||
return 'passwordChangeRequired'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const mapSpecificAccountType = (value: string): AccountProfile['specificAccountType'] => {
|
||||
switch (value) {
|
||||
case '00':
|
||||
return 'withHolding'
|
||||
case '01':
|
||||
return 'withoutHolding'
|
||||
case '10':
|
||||
return 'nonSpecific'
|
||||
case '-':
|
||||
return 'notApply'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const isSpecificAccountTypeRaw = (value: string) =>
|
||||
value === '00' || value === '01' || value === '10' || value === '-'
|
||||
|
||||
const mapSpecificAccountToAccountType = (value: string): AccountProfile['accountType'] => {
|
||||
switch (mapSpecificAccountType(value)) {
|
||||
case 'withHolding':
|
||||
case 'withoutHolding':
|
||||
return 'specific'
|
||||
case 'nonSpecific':
|
||||
return 'general'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const mapIsaAccountType = (value: string): NonNullable<AccountProfile['nisa']>['accountType'] => {
|
||||
switch (value) {
|
||||
case '0':
|
||||
return 'nisaTradeForbidden'
|
||||
case '1':
|
||||
return 'oldNisaTradePermitted'
|
||||
case '2':
|
||||
return 'newNisaTradePermitted'
|
||||
case '3':
|
||||
return 'nisaTradePermitted'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const defaultBrowserHeaders = () => ({
|
||||
'accept-language': 'ja,en-US;q=0.9,en;q=0.8',
|
||||
'cache-control': 'no-cache',
|
||||
pragma: 'no-cache',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36',
|
||||
})
|
||||
|
||||
class CookieJar {
|
||||
#cookies = new Map<string, string>()
|
||||
|
||||
apply(response: Response) {
|
||||
const getSetCookie = (response.headers as Headers & { getSetCookie?: () => string[] })
|
||||
.getSetCookie
|
||||
const values = getSetCookie
|
||||
? getSetCookie.call(response.headers)
|
||||
: splitSetCookie(response.headers.get('set-cookie'))
|
||||
for (const value of values) {
|
||||
const pair = value.split(';', 1)[0]
|
||||
if (!pair) continue
|
||||
const index = pair.indexOf('=')
|
||||
if (index <= 0) continue
|
||||
this.#cookies.set(pair.slice(0, index), pair.slice(index + 1))
|
||||
}
|
||||
}
|
||||
|
||||
header() {
|
||||
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join('; ')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWithCookies = async (input: string | URL, init: RequestInit & { jar: CookieJar }) => {
|
||||
const cookie = init.jar.header()
|
||||
const headers = new Headers(init.headers)
|
||||
if (cookie) headers.set('cookie', cookie)
|
||||
const response = await fetch(input, { ...init, headers })
|
||||
init.jar.apply(response)
|
||||
return response
|
||||
}
|
||||
|
||||
const splitSetCookie = (header: string | null) => {
|
||||
if (!header) return []
|
||||
return header.split(/,(?=\s*[^;,=\s]+=[^;,]*)/g).map((value) => value.trim())
|
||||
}
|
||||
|
||||
const assertOk = (response: Response, label: string) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${label} failed: HTTP ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
const extractCsrfToken = (html: string) => {
|
||||
const patterns = [
|
||||
/<meta[^>]+name=["']_csrf["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']_csrf["']/i,
|
||||
/<input[^>]+name=["']_csrf["'][^>]+value=["']([^"']+)["']/i,
|
||||
/["']_csrf["']\s*:\s*["']([^"']+)["']/i,
|
||||
/csrfToken["']?\s*[:=]\s*["']([^"']+)["']/i,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern)
|
||||
if (match?.[1]) return htmlDecode(match[1])
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizeCredentialRequest = (
|
||||
challengeJson: unknown,
|
||||
credential: PlaintextStoredWebAuthnCredential,
|
||||
): CredentialRequest => {
|
||||
const root = challengeJson as Record<string, unknown>
|
||||
const data =
|
||||
pickObject(root.data) ??
|
||||
pickObject(root.publicKey) ??
|
||||
pickObject(root.publicKeyCredentialRequestOptions) ??
|
||||
root
|
||||
const publicKey =
|
||||
pickObject(data.publicKey) ?? pickObject(data.publicKeyCredentialRequestOptions) ?? data
|
||||
const challenge = pickString(publicKey.challenge) ?? pickString(data.challenge)
|
||||
if (!challenge) throw new Error('passkey challenge response did not include challenge')
|
||||
|
||||
return {
|
||||
challenge,
|
||||
rpId: pickString(publicKey.rpId) ?? credential.rpId,
|
||||
csrfToken:
|
||||
pickString(root.csrfToken) ??
|
||||
pickString(root._csrf) ??
|
||||
pickString(data.csrfToken) ??
|
||||
pickString(data._csrf) ??
|
||||
pickString(publicKey.csrfToken) ??
|
||||
pickString(publicKey._csrf),
|
||||
}
|
||||
}
|
||||
|
||||
const pickObject = (value: unknown) => {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
const pickString = (value: unknown) => {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
const createWebAuthnAssertion = (
|
||||
credential: PlaintextStoredWebAuthnCredential,
|
||||
request: CredentialRequest,
|
||||
) => {
|
||||
const clientDataJSON = Buffer.from(
|
||||
JSON.stringify({
|
||||
type: 'webauthn.get',
|
||||
challenge: request.challenge,
|
||||
origin: credential.origin,
|
||||
crossOrigin: false,
|
||||
}),
|
||||
)
|
||||
const signCount =
|
||||
credential.authenticator.signCount > 0 ? credential.authenticator.signCount + 1 : 0
|
||||
const authenticatorData = Buffer.concat([
|
||||
createHash('sha256').update(request.rpId).digest(),
|
||||
Buffer.from([assertionFlags(credential)]),
|
||||
uint32be(signCount),
|
||||
])
|
||||
const signedData = Buffer.concat([
|
||||
authenticatorData,
|
||||
createHash('sha256').update(clientDataJSON).digest(),
|
||||
])
|
||||
const key = createPrivateKey({
|
||||
key: credential.secretPlaintext.privateKey.jwk,
|
||||
format: 'jwk',
|
||||
})
|
||||
const signature = sign('sha256', signedData, key)
|
||||
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
rawId: credential.credentialId,
|
||||
clientDataJSON: base64Url(clientDataJSON),
|
||||
authenticatorData: base64Url(authenticatorData),
|
||||
signature: base64Url(signature),
|
||||
userHandle: credential.userHandle ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const assertionFlags = (credential: PlaintextStoredWebAuthnCredential) => {
|
||||
let flags = 0x01
|
||||
if (credential.authenticator.userVerification !== 'discouraged') flags |= 0x04
|
||||
if (credential.authenticator.backupEligible) flags |= 0x08
|
||||
if (credential.authenticator.backupState) flags |= 0x10
|
||||
return flags
|
||||
}
|
||||
|
||||
const uint32be = (value: number) => {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeUInt32BE(value >>> 0, 0)
|
||||
return buffer
|
||||
}
|
||||
|
||||
const extractCallbackUrl = (html: string) => {
|
||||
const match =
|
||||
html.match(/sbikabu2:\\\/\\\/auth\\\/callback\?token=[^"'<\\]+/) ??
|
||||
html.match(/sbikabu2:\/\/auth\/callback\?token=[^"'<\\]+/)
|
||||
if (!match) return undefined
|
||||
return match[0].replaceAll('\\/', '/')
|
||||
}
|
||||
|
||||
const htmlDecode = (value: string) => {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const decryptPasskeyToken = (encryptedToken: string, privateKeyPem: string) => {
|
||||
const encrypted = base64UrlToBuffer(encryptedToken)
|
||||
const openssl = decryptWithOpenSsl(encrypted, privateKeyPem)
|
||||
if (openssl) return openssl.toString('utf8')
|
||||
|
||||
return rsaOaepSha256Mgf1Sha1Decrypt(encrypted, privateKeyPem).toString('utf8')
|
||||
}
|
||||
|
||||
const decryptWithOpenSsl = (encrypted: Buffer, privateKeyPem: string) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sbi-passkey-'))
|
||||
const keyPath = join(dir, 'private.pem')
|
||||
try {
|
||||
writeFileSync(keyPath, privateKeyPem, { mode: 0o600 })
|
||||
const result = spawnSync(
|
||||
'openssl',
|
||||
[
|
||||
'pkeyutl',
|
||||
'-decrypt',
|
||||
'-inkey',
|
||||
keyPath,
|
||||
'-pkeyopt',
|
||||
'rsa_padding_mode:oaep',
|
||||
'-pkeyopt',
|
||||
'rsa_oaep_md:sha256',
|
||||
'-pkeyopt',
|
||||
'rsa_mgf1_md:sha1',
|
||||
],
|
||||
{ input: encrypted },
|
||||
)
|
||||
if (result.status !== 0) return undefined
|
||||
return result.stdout
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(keyPath)
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const base64Url = (data: Buffer, keepPadding = false) => {
|
||||
const encoded = data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
return keepPadding ? encoded : encoded.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
const base64UrlToBuffer = (value: string) => {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return Buffer.from(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='), 'base64')
|
||||
}
|
||||
|
||||
const rsaOaepSha256Mgf1Sha1Decrypt = (encrypted: Buffer, privateKeyPem: string) => {
|
||||
const encodedMessage = privateDecrypt(
|
||||
{
|
||||
key: privateKeyPem,
|
||||
padding: constants.RSA_NO_PADDING,
|
||||
},
|
||||
encrypted,
|
||||
)
|
||||
return oaepUnpad(encodedMessage, 'sha256', 'sha1')
|
||||
}
|
||||
|
||||
const oaepUnpad = (encodedMessage: Buffer, labelHashName: 'sha256', mgfHashName: 'sha1') => {
|
||||
const labelHash = createHash(labelHashName).update(Buffer.alloc(0)).digest()
|
||||
const hashLength = labelHash.length
|
||||
|
||||
if (encodedMessage.length < 2 * hashLength + 2 || encodedMessage[0] !== 0) {
|
||||
throw new Error('invalid OAEP block')
|
||||
}
|
||||
|
||||
const maskedSeed = encodedMessage.subarray(1, 1 + hashLength)
|
||||
const maskedDb = encodedMessage.subarray(1 + hashLength)
|
||||
const seedMask = mgf1(maskedDb, hashLength, mgfHashName)
|
||||
const seed = xor(maskedSeed, seedMask)
|
||||
const dbMask = mgf1(seed, maskedDb.length, mgfHashName)
|
||||
const db = xor(maskedDb, dbMask)
|
||||
|
||||
if (!db.subarray(0, hashLength).equals(labelHash)) {
|
||||
throw new Error('invalid OAEP label hash')
|
||||
}
|
||||
|
||||
let index = hashLength
|
||||
while (index < db.length && db[index] === 0) index++
|
||||
if (db[index] !== 1) {
|
||||
throw new Error('invalid OAEP delimiter')
|
||||
}
|
||||
return db.subarray(index + 1)
|
||||
}
|
||||
|
||||
const mgf1 = (seed: Buffer, length: number, hashName: 'sha1') => {
|
||||
const chunks: Buffer[] = []
|
||||
for (let counter = 0; Buffer.concat(chunks).length < length; counter++) {
|
||||
const c = Buffer.alloc(4)
|
||||
c.writeUInt32BE(counter, 0)
|
||||
chunks.push(createHash(hashName).update(seed).update(c).digest())
|
||||
}
|
||||
return Buffer.concat(chunks).subarray(0, length)
|
||||
}
|
||||
|
||||
const xor = (left: Buffer, right: Buffer) => {
|
||||
if (left.length !== right.length) throw new Error('xor length mismatch')
|
||||
const out = Buffer.alloc(left.length)
|
||||
for (let i = 0; i < left.length; i++) out[i] = left[i]! ^ right[i]!
|
||||
return out
|
||||
}
|
||||
|
||||
const decodeShiftJis = (body: ArrayBuffer | Uint8Array) => {
|
||||
try {
|
||||
return new TextDecoder('shift-jis' as ConstructorParameters<typeof TextDecoder>[0]).decode(body)
|
||||
} catch {
|
||||
return (
|
||||
body instanceof ArrayBuffer
|
||||
? Buffer.from(body)
|
||||
: Buffer.from(body.buffer, body.byteOffset, body.byteLength)
|
||||
).toString('binary')
|
||||
}
|
||||
}
|
||||
|
||||
const parseMtsHeader = (text: string) => {
|
||||
if (text.length < 70) return null
|
||||
return {
|
||||
sessionId: text.slice(6, 34).trim(),
|
||||
trCode: text.slice(34, 39).trim(),
|
||||
resultCode: text.slice(45, 51).trim(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
export type WebAuthnAlgorithm = -7 | -257
|
||||
|
||||
export type WebAuthnUserVerification = 'required' | 'preferred' | 'discouraged'
|
||||
|
||||
export type WebAuthnTransport = 'ble' | 'hybrid' | 'internal' | 'nfc' | 'usb'
|
||||
|
||||
export type WebAuthnJwk = {
|
||||
kty: string
|
||||
crv?: string
|
||||
x?: string
|
||||
y?: string
|
||||
d?: string
|
||||
n?: string
|
||||
e?: string
|
||||
key_ops?: string[]
|
||||
ext?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type StoredWebAuthnCredentialSecret = {
|
||||
privateKey: {
|
||||
format: 'jwk'
|
||||
jwk: WebAuthnJwk
|
||||
}
|
||||
cosePrivateKey?: string
|
||||
registration?: {
|
||||
attestationObject?: string
|
||||
clientDataJSON?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type StoredWebAuthnCredential = {
|
||||
version: 1
|
||||
kind: 'webauthn-credential'
|
||||
provider: 'sbi-sec'
|
||||
rpId: string
|
||||
origin: string
|
||||
credentialId: string
|
||||
userHandle?: string
|
||||
alg: WebAuthnAlgorithm
|
||||
publicKey: {
|
||||
format: 'jwk'
|
||||
jwk: WebAuthnJwk
|
||||
}
|
||||
authenticator: {
|
||||
aaguid?: string
|
||||
signCount: number
|
||||
discoverable: boolean
|
||||
userVerification: WebAuthnUserVerification
|
||||
transports?: WebAuthnTransport[]
|
||||
backupEligible?: boolean
|
||||
backupState?: boolean
|
||||
}
|
||||
secret: {
|
||||
encrypted: true
|
||||
format: 'jwe-like-v1'
|
||||
kdf: {
|
||||
name: 'argon2id' | 'scrypt'
|
||||
salt: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
cipher: {
|
||||
name: 'AES-256-GCM'
|
||||
nonce: string
|
||||
aad: string
|
||||
ciphertext: string
|
||||
tag: string
|
||||
}
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type PlaintextStoredWebAuthnCredential = Omit<StoredWebAuthnCredential, 'secret'> & {
|
||||
label?: string
|
||||
secretPlaintext: StoredWebAuthnCredentialSecret
|
||||
}
|
||||
|
||||
export type PasskeyLoginResponse = {
|
||||
type: 'passkey-login-response'
|
||||
requestUrl: string
|
||||
status: number
|
||||
body: ArrayBuffer
|
||||
text: string
|
||||
header: {
|
||||
sessionId: string
|
||||
trCode: string
|
||||
resultCode: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SbiSession = {
|
||||
mtsBaseUrl: string
|
||||
izanagiBaseUrl?: string
|
||||
profile: AccountProfile
|
||||
loginResponse: PasskeyLoginResponse
|
||||
tradePassword?: string
|
||||
deviceIdRegistered?: boolean
|
||||
tradeAuthentication?: SbiTradeAuthenticationOptions
|
||||
}
|
||||
|
||||
export type LoginWithPasskeyOptions = {
|
||||
passkeyCredential: PlaintextStoredWebAuthnCredential
|
||||
authBaseUrl?: string
|
||||
mtsBaseUrl?: string
|
||||
izanagiBaseUrl?: string
|
||||
}
|
||||
|
||||
export type SbiClientOptions = {
|
||||
tradePassword?: string
|
||||
deviceId?: string
|
||||
tradeAuthentication?: SbiTradeAuthenticationOptions
|
||||
}
|
||||
|
||||
export type SbiTradeAuthenticationRequest = {
|
||||
type: 'phone'
|
||||
telNo?: string
|
||||
phoneNo?: string
|
||||
sbiCallNo?: string
|
||||
authLimitTime?: string
|
||||
}
|
||||
|
||||
export type SbiTradeAuthenticationOptions = {
|
||||
onRequired?: (request: SbiTradeAuthenticationRequest) => void | Promise<void>
|
||||
confirmAttempts?: number
|
||||
confirmIntervalMs?: number
|
||||
}
|
||||
|
||||
export type IssueCode = string
|
||||
export type MarketCode = string
|
||||
export type OrderId = string
|
||||
export type WatchlistId = string
|
||||
export type PositionId = string
|
||||
export type ThemeId = string
|
||||
|
||||
export type CurrencyAmount = {
|
||||
value: number | null
|
||||
text: string
|
||||
currency: 'JPY'
|
||||
}
|
||||
|
||||
export type PercentValue = {
|
||||
value: number | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SignedTextValue = {
|
||||
value: number | null
|
||||
text: string
|
||||
sign?: 'positive' | 'negative' | 'zero'
|
||||
}
|
||||
|
||||
export type AccountType = 'general' | 'specific' | 'nisa' | 'juniorNisa' | 'unknown'
|
||||
export type DepositType = 'general' | 'specific' | 'nisa' | 'juniorNisa' | 'unknown'
|
||||
export type TradeSide = 'buy' | 'sell'
|
||||
export type MarginTradeSide = 'buy' | 'sell'
|
||||
export type OrderStatus = 'open' | 'executed' | 'cancelled' | 'expired' | 'rejected' | 'unknown'
|
||||
export type OrderKind = 'market' | 'limit' | 'stop' | 'oco' | 'ifd' | 'ifdo' | 's' | 'unknown'
|
||||
export type LoginStatus =
|
||||
| 'success'
|
||||
| 'invalidUser'
|
||||
| 'tradeForbidden'
|
||||
| 'locked'
|
||||
| 'fidoAuthorizationIncorrect'
|
||||
| 'fidoAuthorization'
|
||||
| 'passwordChangeRequired'
|
||||
| 'unknown'
|
||||
export type LoginType = 'passkey' | 'password' | 'unknown'
|
||||
export type SpecificAccountType =
|
||||
| 'withHolding'
|
||||
| 'withoutHolding'
|
||||
| 'nonSpecific'
|
||||
| 'notApply'
|
||||
| 'unknown'
|
||||
export type IsaAccountType =
|
||||
| 'nisaTradeForbidden'
|
||||
| 'oldNisaTradePermitted'
|
||||
| 'newNisaTradePermitted'
|
||||
| 'nisaTradePermitted'
|
||||
| 'unknown'
|
||||
|
||||
export type IssueRef = {
|
||||
code: IssueCode
|
||||
market?: MarketCode
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type IssueSearchItem = IssueRef & {
|
||||
extract?: string
|
||||
extractWord?: string
|
||||
boldFrom?: string
|
||||
boldTo?: string
|
||||
hitString?: string
|
||||
}
|
||||
|
||||
export type IssueSearchStatus = 'success' | 'searchError' | 'tooManyResults' | 'unknown'
|
||||
|
||||
export type IssueSearchResult = {
|
||||
status?: string
|
||||
statusText: IssueSearchStatus
|
||||
issues: IssueSearchItem[]
|
||||
}
|
||||
|
||||
export type ChartPeriod = 'minute' | 'day' | 'week' | 'month'
|
||||
|
||||
export type ChartPrice = {
|
||||
dateTime: string
|
||||
open: CurrencyAmount
|
||||
high: CurrencyAmount
|
||||
low: CurrencyAmount
|
||||
close: CurrencyAmount
|
||||
volume?: number | null
|
||||
}
|
||||
|
||||
export type IssueChart = {
|
||||
issue: IssueRef
|
||||
period: ChartPeriod
|
||||
unit: number
|
||||
prices: ChartPrice[]
|
||||
previousClose?: CurrencyAmount
|
||||
currentPrice?: CurrencyAmount
|
||||
highPrice?: CurrencyAmount
|
||||
lowPrice?: CurrencyAmount
|
||||
latestDateTime?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
sessionId: string
|
||||
loginType: LoginType
|
||||
resultCode: string
|
||||
}
|
||||
|
||||
export type AccountProfile = {
|
||||
session: SessionInfo
|
||||
branchCode?: string
|
||||
butenCode?: string
|
||||
accountNumber?: string
|
||||
userId?: string
|
||||
loginStatus?: LoginStatus
|
||||
loginType?: LoginType
|
||||
accountType?: AccountType
|
||||
specificAccountType?: SpecificAccountType
|
||||
hasMarginAccount?: boolean
|
||||
marginAccount?: string
|
||||
corporateFlag?: string
|
||||
commissionPlan?: string
|
||||
expireDate?: string
|
||||
lastLoginDate?: string
|
||||
lastLoginTime?: string
|
||||
tradingPassword?: string
|
||||
fxShareCol?: string
|
||||
fullTerm?: string
|
||||
fullAccount?: string
|
||||
securityAuthenticationResponseCode?: string
|
||||
fidoResponseCode?: string
|
||||
passkeyStatus?: string
|
||||
trId?: string
|
||||
txId?: string
|
||||
actionToken?: string
|
||||
nisa?: {
|
||||
enabled: boolean
|
||||
tradePermitted?: boolean
|
||||
juniorEnabled?: boolean
|
||||
accountType?: IsaAccountType
|
||||
jrNisaAccount?: string
|
||||
jrNisaSpecific?: SpecificAccountType
|
||||
jrNisaSeigen?: string
|
||||
}
|
||||
sor?: {
|
||||
defaultEnabled?: boolean
|
||||
defaultCode?: string
|
||||
lastMarket?: MarketCode
|
||||
juniorNisaLastMarket?: MarketCode
|
||||
}
|
||||
notices?: {
|
||||
hasImportantNotice?: boolean
|
||||
importantNoticeFlag?: string
|
||||
count?: number
|
||||
}
|
||||
restrictions?: {
|
||||
tradeRestricted?: boolean
|
||||
restrictedTradeFlag?: string
|
||||
message?: string
|
||||
}
|
||||
deficit?: {
|
||||
hasMessage?: boolean
|
||||
messageFlag?: string
|
||||
message?: string
|
||||
}
|
||||
maintenance?: {
|
||||
referenceable?: boolean
|
||||
referenceableMaintenanceFlag?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type BuyingPower = {
|
||||
cashBuyingPower?: CurrencyAmount
|
||||
marginBuyingPower?: CurrencyAmount
|
||||
withdrawableAmount?: CurrencyAmount
|
||||
collateralValue?: CurrencyAmount
|
||||
collateralRatio?: PercentValue
|
||||
sbiHybridDepositBalance?: CurrencyAmount
|
||||
noticeMessage?: string
|
||||
records?: CollateralRatioRecord[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type CollateralRatioRecord = {
|
||||
marginRequirements?: CurrencyAmount
|
||||
referenceMarginRequirements?: CurrencyAmount
|
||||
collateralRatioCash?: CurrencyAmount
|
||||
substituteSecuritiesValuationAmount?: CurrencyAmount
|
||||
unsettledPositionLoss?: SignedTextValue
|
||||
unsettledPositionLossFlag?: string
|
||||
settlementLoss?: SignedTextValue
|
||||
settlementLossFlag?: string
|
||||
paymentExpenses?: SignedTextValue
|
||||
paymentExpensesFlag?: string
|
||||
actualCollateral?: CurrencyAmount
|
||||
positionAmount?: CurrencyAmount
|
||||
sbiHybridDepositBalance?: CurrencyAmount
|
||||
minimumCollateral?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type CashPosition = {
|
||||
issue: IssueRef
|
||||
accountType?: AccountType
|
||||
depositType?: DepositType
|
||||
depositTypeCode?: string
|
||||
depositTypeText?: string
|
||||
quantity: number | null
|
||||
availableQuantity?: number | null
|
||||
unexecutedOrderQuantity?: number | null
|
||||
averagePrice?: CurrencyAmount
|
||||
purchasePrice?: CurrencyAmount
|
||||
/** Current unit price, not multiplied by quantity. */
|
||||
currentPrice?: CurrencyAmount
|
||||
priceText?: string
|
||||
/** Total position valuation amount. For cash positions this is quantity-adjusted. */
|
||||
marketValue?: CurrencyAmount
|
||||
presentValueFlag?: string
|
||||
/** Raw SBI valuation amount. Kept for source compatibility; prefer `marketValue` for totals. */
|
||||
valuationPrice?: CurrencyAmount
|
||||
valuationPriceChange?: SignedTextValue
|
||||
valuationPriceChangeRate?: PercentValue
|
||||
valuationPriceChangeFlag?: string
|
||||
profitLoss?: SignedTextValue
|
||||
profitLossRate?: PercentValue
|
||||
profitLossFlag?: string
|
||||
holdingCategory?: string
|
||||
accountInformation?: string
|
||||
}
|
||||
|
||||
export type CashPositionList = {
|
||||
positions: CashPosition[]
|
||||
index?: number
|
||||
totalCount?: number
|
||||
totalMarketValue?: CurrencyAmount
|
||||
totalProfitLoss?: SignedTextValue
|
||||
totalProfitLossRate?: PercentValue
|
||||
totalProfitLossFlag?: string
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type MarginPosition = {
|
||||
id?: PositionId
|
||||
issue: IssueRef
|
||||
side: MarginTradeSide
|
||||
sideText?: string
|
||||
accountType?: AccountType
|
||||
tradeKind?: string
|
||||
quantity: number | null
|
||||
availableCloseQuantity?: number | null
|
||||
unexecutedOrderQuantity?: number | null
|
||||
openPrice?: CurrencyAmount
|
||||
openAmount?: CurrencyAmount
|
||||
/** Current unit price or rate, not multiplied by quantity. */
|
||||
currentPrice?: CurrencyAmount
|
||||
rate?: CurrencyAmount
|
||||
/** Total position valuation amount when provided by SBI. */
|
||||
marketValue?: CurrencyAmount
|
||||
presentValueFlag?: string
|
||||
/** Raw SBI valuation amount. Prefer `marketValue` when calculating totals. */
|
||||
valuationPrice?: CurrencyAmount
|
||||
valuationPriceChange?: SignedTextValue
|
||||
valuationPriceChangeRate?: PercentValue
|
||||
valuationPriceChangeFlag?: string
|
||||
profitLoss?: SignedTextValue
|
||||
profitLossRate?: PercentValue
|
||||
profitLossFlag?: string
|
||||
openDate?: string
|
||||
dueDate?: string
|
||||
dueDateCode?: string
|
||||
dueDateText?: string
|
||||
depositTypeText?: string
|
||||
cost?: CurrencyAmount
|
||||
commission?: CurrencyAmount
|
||||
managementFee?: CurrencyAmount
|
||||
nameTransferFee?: CurrencyAmount
|
||||
interest?: CurrencyAmount
|
||||
backwardation?: CurrencyAmount
|
||||
collateralRatio?: PercentValue
|
||||
bargainMarketCode?: string
|
||||
bargainMarket?: string
|
||||
}
|
||||
|
||||
export type MarginPositionList = {
|
||||
positions: MarginPosition[]
|
||||
index?: number
|
||||
totalCount?: number
|
||||
totalMarketValue?: CurrencyAmount
|
||||
totalProfitLoss?: SignedTextValue
|
||||
totalProfitLossRate?: PercentValue
|
||||
totalProfitLossFlag?: string
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type ProfitLossSummary = {
|
||||
cash?: SignedTextValue
|
||||
margin?: SignedTextValue
|
||||
total?: SignedTextValue
|
||||
totalRate?: PercentValue
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type Quote = {
|
||||
issue: IssueRef
|
||||
price?: CurrencyAmount
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
changeFlag?: string
|
||||
open?: CurrencyAmount
|
||||
high?: CurrencyAmount
|
||||
low?: CurrencyAmount
|
||||
previousClose?: CurrencyAmount
|
||||
volume?: number | null
|
||||
timestamp?: string
|
||||
nominalPrices?: CurrencyAmount[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type BoardPriceLevel = {
|
||||
price: CurrencyAmount
|
||||
quantity?: number | null
|
||||
}
|
||||
|
||||
export type Board = {
|
||||
issue: IssueRef
|
||||
bids: BoardPriceLevel[]
|
||||
asks: BoardPriceLevel[]
|
||||
quote?: Quote
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type MarketIndex = {
|
||||
code?: string
|
||||
categoryCode?: string
|
||||
name: string
|
||||
value?: number | null
|
||||
valueText?: string
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
colorFlag?: string
|
||||
timestamp?: string
|
||||
open?: CurrencyAmount
|
||||
high?: CurrencyAmount
|
||||
low?: CurrencyAmount
|
||||
previousClose?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type DomesticMarket = {
|
||||
status?: string
|
||||
indexes: MarketIndex[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type RankingItem = {
|
||||
rank: number
|
||||
issue: IssueRef
|
||||
value?: number | string | null
|
||||
values?: Array<number | string | null>
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
exchangeName?: string
|
||||
colorFlag?: string
|
||||
}
|
||||
|
||||
export type Ranking = {
|
||||
items: RankingItem[]
|
||||
category?: string
|
||||
updatedAt?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type NewsItem = {
|
||||
id?: string
|
||||
title: string
|
||||
source?: string
|
||||
publishedAt?: string
|
||||
url?: string
|
||||
summary?: string
|
||||
storyDate?: string
|
||||
storyTime?: string
|
||||
processedDate?: string
|
||||
takeTime?: string
|
||||
pnac?: string
|
||||
}
|
||||
|
||||
export type NewsList = {
|
||||
items: NewsItem[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type WatchlistItem = {
|
||||
issue: IssueRef
|
||||
sortOrder?: number
|
||||
memo?: string
|
||||
quote?: Quote
|
||||
}
|
||||
|
||||
export type Watchlist = {
|
||||
id: WatchlistId
|
||||
name: string
|
||||
items: WatchlistItem[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type Order = {
|
||||
id: OrderId
|
||||
issue: IssueRef
|
||||
side: TradeSide
|
||||
sideText?: string
|
||||
status: OrderStatus
|
||||
statusText?: string
|
||||
executionStatus?: string
|
||||
executionStatusText?: string
|
||||
kind?: OrderKind
|
||||
accountType?: AccountType
|
||||
depositType?: DepositType
|
||||
depositTypeCode?: string
|
||||
depositTypeText?: string
|
||||
quantity?: number | null
|
||||
unexecutedQuantity?: number | null
|
||||
executedQuantity?: number | null
|
||||
price?: CurrencyAmount
|
||||
executedPrice?: CurrencyAmount
|
||||
orderedAt?: string
|
||||
expiresAt?: string
|
||||
orderNumber?: string
|
||||
tradeId?: string
|
||||
exchangeCode?: string
|
||||
accountInformation?: string
|
||||
}
|
||||
|
||||
export type OrderList = {
|
||||
orders: Order[]
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type OrderPreview = {
|
||||
issue: IssueRef
|
||||
side: TradeSide
|
||||
quantity?: number
|
||||
price?: CurrencyAmount
|
||||
estimatedAmount?: CurrencyAmount
|
||||
commission?: CurrencyAmount
|
||||
tax?: CurrencyAmount
|
||||
warnings: string[]
|
||||
confirmationId?: string
|
||||
message?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type OrderReceipt = {
|
||||
accepted: boolean
|
||||
orderId?: OrderId
|
||||
acceptedAt?: string
|
||||
message?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type ThemeInvestment = {
|
||||
id: ThemeId
|
||||
name: string
|
||||
issues: IssueRef[]
|
||||
minimumAmount?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type ThemeInvestmentList = {
|
||||
themes: ThemeInvestment[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type SbiMethodError = {
|
||||
status?: string
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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')
|
||||
})()
|
||||
@@ -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<string, string>
|
||||
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<string, string>()
|
||||
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<T>(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(/<application\b[^>]+>/)?.[0] ?? ''
|
||||
const appClass = appTag.match(/android:name="([^"]+)"/)?.[1] ?? null
|
||||
const networkSecurityConfig =
|
||||
text.match(/android:networkSecurityConfig="@xml\/([^"]+)"/)?.[1] ?? null
|
||||
const permissions = unique(
|
||||
matchAll(/<uses-permission[^>]+android:name="([^"]+)"/g, text).map((m) => m[1]),
|
||||
)
|
||||
const deepLinks = unique(
|
||||
matchAll(/<data\b[^>]*\/>/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<string, string> {
|
||||
const strings: Record<string, string> = {}
|
||||
for (const m of matchAll(/<string name="([^"]+)">([\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}`)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user