first commit

This commit is contained in:
Shotaro Nakamura
2026-06-18 22:59:53 +09:00
commit 20434dcd11
88 changed files with 13950 additions and 0 deletions
+23
View File
@@ -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
View File
@@ -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/
+10
View File
@@ -0,0 +1,10 @@
## Rules
- Run `bun fmt` and `bun typecheck` before finishing.
- Do not hardcode real endpoint
## Hints
- 技術的に不可能なことはできないと応答する。代わりに fallback を使ったりはしない。相談する。
- fallback は控える。エラーを積極的に出す。
- 実際の passkey.json を利用して、SDK を実際に実行して直ったか確認する
+5
View File
@@ -0,0 +1,5 @@
# cSBI
[![技術者倫理 遵守済み](https://img.shields.io/badge/%E6%8A%80%E8%A1%93%E8%80%85%E5%80%AB%E7%90%86-%E9%81%B5%E5%AE%88%E6%B8%88%E3%81%BF-0a0a0a?style=for-the-badge&labelColor=ffffff)](https://技術者倫理.com)
A client for SBI-compatible servers. This project is not intended for use with a real SBI server.
+10
View File
@@ -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',
},
})
+31
View File
@@ -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"
}
}
+67
View File
@@ -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 }
}
+42
View File
@@ -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),
}
}
+27
View File
@@ -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
}
+152
View File
@@ -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 })
}
+102
View File
@@ -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(),
})
+17
View File
@@ -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}`)
+108
View File
@@ -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
}
+218
View File
@@ -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
}
+604
View File
@@ -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
}
+90
View File
@@ -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
}
+112
View File
@@ -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)
}
+33
View File
@@ -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,
)
}
+253
View File
@@ -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
}
+13
View File
@@ -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
}
+17
View File
@@ -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()))
}
+11
View File
@@ -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}`)
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"rootDir": "src",
"outDir": "dist",
"noEmit": true
},
"include": ["src/**/*.ts"]
}
+12
View File
@@ -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>
+26
View File
@@ -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"
}
}
+310
View File
@@ -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>
+112
View File
@@ -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>
+21
View File
@@ -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 },
]
+85
View File
@@ -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,
}
}
+6
View File
@@ -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')
+20
View File
@@ -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' },
],
})
+14
View File
@@ -0,0 +1,14 @@
@import 'tailwindcss';
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
min-width: 320px;
overflow: hidden;
background: #101418;
}
+162
View File
@@ -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
+102
View File
@@ -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
}
+14
View File
@@ -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)}%`
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"jsx": "preserve"
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+35
View File
@@ -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,
},
},
})
+21
View File
@@ -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"]
+20
View File
@@ -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"
}
}
+37
View File
@@ -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)
+55
View File
@@ -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}`)
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"rootDir": "src",
"outDir": "dist",
"noEmit": true
},
"include": ["src/**/*.ts"]
}
+996
View File
@@ -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=="],
}
}
+32
View File
@@ -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]"
}
+15
View File
@@ -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.
+26
View File
@@ -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"
}
}
+27
View File
@@ -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
+517
View File
@@ -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
}
+865
View File
@@ -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(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/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(),
}
}
+602
View File
@@ -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
}
+29
View File
@@ -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
}
}
+51
View File
@@ -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}`)
}
+298
View File
@@ -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')
})()
+307
View File
@@ -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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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}`)
+29
View File
@@ -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
}
}
+45
View File
@@ -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'],
},
},
},
})