feat: introduce mnie
This commit is contained in:
@@ -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.MNIE_DATABASE_PATH ?? './data/mnie-app.sqlite',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@repo/mnie-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/client-sbi": "workspace:*",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"hono": "^4.8.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { secureHeaders } from 'hono/secure-headers'
|
||||
import { mcpAuthRouter } from '@hono/mcp'
|
||||
import type { ServerConfig } from './config'
|
||||
import type { AppBindings } from './context'
|
||||
import type { Db } from './db'
|
||||
import { createOAuthServerProvider } from './security/oauth-provider'
|
||||
import { authenticateRequest } from './security/http-auth'
|
||||
import { createAdminRoutes } from './routes/admin'
|
||||
import { createAuthRoutes } from './routes/auth'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
import { createOAuthRoutes } from './routes/oauth'
|
||||
import { createRpcWebSocket } from './rpc/ws'
|
||||
|
||||
export const createServerApp = (db: Db, config: ServerConfig) => {
|
||||
const app = new Hono<AppBindings>()
|
||||
const rpcWebSocket = createRpcWebSocket(db, config)
|
||||
const oauthProvider = createOAuthServerProvider(db, config)
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const auth = await authenticateRequest(db, config, c.req.raw)
|
||||
c.set('db', db)
|
||||
c.set('config', config)
|
||||
c.set('auth', auth)
|
||||
c.set('authenticated', auth.authenticated)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
secureHeaders({
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}),
|
||||
)
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: config.corsOrigin,
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
|
||||
app.get('/health', (c) => c.json({ ok: true }))
|
||||
app.route(
|
||||
'/',
|
||||
mcpAuthRouter({
|
||||
issuerUrl: new URL(config.origin),
|
||||
baseUrl: new URL(config.origin),
|
||||
resourceServerUrl: new URL('/api/mcp', config.origin),
|
||||
resourceName: 'Mnie finance management',
|
||||
scopesSupported: ['read', 'write', 'trade', 'mcp'],
|
||||
provider: oauthProvider,
|
||||
authorizationOptions: { rateLimit: false },
|
||||
tokenOptions: { rateLimit: false },
|
||||
clientRegistrationOptions: { rateLimit: false },
|
||||
revocationOptions: { rateLimit: false },
|
||||
}),
|
||||
)
|
||||
app.get('/ws', rpcWebSocket.upgradeWebSocket)
|
||||
app.route('/auth', createAuthRoutes())
|
||||
app.route('/admin', createAdminRoutes())
|
||||
app.route('/oauth', createOAuthRoutes())
|
||||
app.route('/mcp', createMcpRoutes())
|
||||
|
||||
return { app, websocket: rpcWebSocket.websocket }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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
|
||||
foreignStockBaseUrl?: string
|
||||
usStockBaseUrl?: string
|
||||
foreignStockRestUrl?: string
|
||||
foreignStockGraphqlBffUrl?: string
|
||||
foreignStockGraphqlIntUrl?: string
|
||||
mainSiteBaseUrl?: string
|
||||
mainSiteEtGatePath?: string
|
||||
mainSiteAssetsValuationsPath?: string
|
||||
mainSiteExchangeOrderInputPath?: string
|
||||
mainSiteExchangeOrderPasswordPath?: string
|
||||
mainSiteExchangeOrderConfirmPath?: string
|
||||
mainSiteExchangeOrderCompletePath?: 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.MNIE_SERVER_PORT ?? 8787)
|
||||
const databasePath = resolve(process.env.MNIE_DATABASE_PATH ?? './data/mnie-app.sqlite')
|
||||
mkdirSync(dirname(databasePath), { recursive: true })
|
||||
|
||||
const origin = process.env.MNIE_ORIGIN ?? `http://localhost:${port}`
|
||||
const rpId = process.env.MNIE_RP_ID ?? new URL(origin).hostname
|
||||
|
||||
return {
|
||||
port,
|
||||
databasePath,
|
||||
corsOrigin: process.env.MNIE_CORS_ORIGIN ?? origin,
|
||||
sessionCookieName: process.env.MNIE_SESSION_COOKIE ?? 'mnie_session',
|
||||
rpName: process.env.MNIE_RP_NAME ?? 'MNIE',
|
||||
rpId,
|
||||
origin,
|
||||
authBaseUrl: optionalUrl(process.env.SBI_AUTH_BASE_URL),
|
||||
mtsBaseUrl: optionalUrl(process.env.SBI_MTS_BASE_URL),
|
||||
izanagiBaseUrl: optionalUrl(process.env.SBI_IZANAGI_BASE_URL),
|
||||
foreignStockBaseUrl: optionalUrl(process.env.SBI_FOREIGN_STOCK_BASE_URL),
|
||||
usStockBaseUrl: optionalUrl(process.env.SBI_US_STOCK_BASE_URL),
|
||||
foreignStockRestUrl: optionalUrl(process.env.SBI_FOREIGN_STOCK_REST_URL),
|
||||
foreignStockGraphqlBffUrl: optionalUrl(process.env.SBI_FOREIGN_STOCK_GRAPHQL_BFF_URL),
|
||||
foreignStockGraphqlIntUrl: optionalUrl(process.env.SBI_FOREIGN_STOCK_GRAPHQL_INT_URL),
|
||||
mainSiteBaseUrl: optionalUrl(process.env.SBI_MAIN_SITE_BASE_URL),
|
||||
mainSiteEtGatePath: process.env.SBI_MAIN_SITE_ET_GATE_PATH,
|
||||
mainSiteAssetsValuationsPath: process.env.SBI_MAIN_SITE_ASSETS_VALUATIONS_PATH,
|
||||
mainSiteExchangeOrderInputPath: process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_INPUT_PATH,
|
||||
mainSiteExchangeOrderPasswordPath: process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_PASSWORD_PATH,
|
||||
mainSiteExchangeOrderConfirmPath: process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_CONFIRM_PATH,
|
||||
mainSiteExchangeOrderCompletePath: process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_COMPLETE_PATH,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
scopes: string[]
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
||||
import * as schema from './schema'
|
||||
|
||||
export type Db = ReturnType<typeof createDb>
|
||||
|
||||
export const createDb = (path: string) => {
|
||||
const sqlite = new Database(path, { create: true, strict: true })
|
||||
sqlite.run('PRAGMA journal_mode = WAL')
|
||||
sqlite.run('PRAGMA foreign_keys = ON')
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS user_passkeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
credential_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
challenge TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS sbi_passkeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
keyring_account TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
max_trades_per_hour INTEGER,
|
||||
max_trades_per_6_hours INTEGER,
|
||||
max_trades_per_day INTEGER,
|
||||
max_order_price_jpy INTEGER,
|
||||
max_order_amount_jpy INTEGER,
|
||||
allowed_methods TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
)
|
||||
`)
|
||||
const apiKeyColumns = sqlite
|
||||
.query<{ name: string }, []>('PRAGMA table_info(api_keys)')
|
||||
.all()
|
||||
.map((column) => column.name)
|
||||
for (const [name, type] of [
|
||||
['max_trades_per_hour', 'INTEGER'],
|
||||
['max_trades_per_6_hours', 'INTEGER'],
|
||||
['max_trades_per_day', 'INTEGER'],
|
||||
['max_order_price_jpy', 'INTEGER'],
|
||||
['max_order_amount_jpy', 'INTEGER'],
|
||||
['allowed_methods', 'TEXT'],
|
||||
] as const) {
|
||||
if (!apiKeyColumns.includes(name)) sqlite.run(`ALTER TABLE api_keys ADD COLUMN ${name} ${type}`)
|
||||
}
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS api_key_trade_usage (
|
||||
api_key_id TEXT NOT NULL,
|
||||
window TEXT NOT NULL,
|
||||
hour_bucket TEXT NOT NULL,
|
||||
trade_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (api_key_id, window, hour_bucket)
|
||||
)
|
||||
`)
|
||||
const tradeUsageColumns = sqlite
|
||||
.query<{ name: string }, []>('PRAGMA table_info(api_key_trade_usage)')
|
||||
.all()
|
||||
.map((column) => column.name)
|
||||
if (!tradeUsageColumns.includes('window')) {
|
||||
sqlite.run('DROP TABLE api_key_trade_usage')
|
||||
sqlite.run(`
|
||||
CREATE TABLE api_key_trade_usage (
|
||||
api_key_id TEXT NOT NULL,
|
||||
window TEXT NOT NULL,
|
||||
hour_bucket TEXT NOT NULL,
|
||||
trade_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (api_key_id, window, hour_bucket)
|
||||
)
|
||||
`)
|
||||
}
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
client TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
resource TEXT,
|
||||
api_key_settings TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
const oauthCodeColumns = sqlite
|
||||
.query<{ name: string }, []>('PRAGMA table_info(oauth_authorization_codes)')
|
||||
.all()
|
||||
.map((column) => column.name)
|
||||
if (!oauthCodeColumns.includes('api_key_settings')) {
|
||||
sqlite.run('ALTER TABLE oauth_authorization_codes ADD COLUMN api_key_settings TEXT')
|
||||
}
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
resource TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
return drizzle(sqlite, { schema })
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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>(),
|
||||
scopes: text('scopes', { mode: 'json' }).$type<string[] | null>(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }),
|
||||
revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }),
|
||||
},
|
||||
(table) => [uniqueIndex('api_keys_token_hash_unique').on(table.tokenHash)],
|
||||
)
|
||||
|
||||
export const apiKeyTradeUsage = sqliteTable(
|
||||
'api_key_trade_usage',
|
||||
{
|
||||
apiKeyId: text('api_key_id').notNull(),
|
||||
window: text('window', { enum: ['1h', '3h', '1d'] }).notNull(),
|
||||
hourBucket: text('hour_bucket').notNull(),
|
||||
tradeCount: integer('trade_count').notNull().default(0),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.apiKeyId, table.window, table.hourBucket] })],
|
||||
)
|
||||
|
||||
export const oauthClients = sqliteTable('oauth_clients', {
|
||||
id: text('id').primaryKey(),
|
||||
client: text('client', { mode: 'json' }).$type<Record<string, unknown>>().notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
})
|
||||
|
||||
export const oauthAuthorizationCodes = sqliteTable('oauth_authorization_codes', {
|
||||
code: text('code').primaryKey(),
|
||||
clientId: text('client_id').notNull(),
|
||||
redirectUri: text('redirect_uri').notNull(),
|
||||
codeChallenge: text('code_challenge').notNull(),
|
||||
scopes: text('scopes', { mode: 'json' }).$type<string[]>().notNull(),
|
||||
resource: text('resource'),
|
||||
apiKeySettings: text('api_key_settings', { mode: 'json' }).$type<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
})
|
||||
|
||||
export const oauthRefreshTokens = sqliteTable('oauth_refresh_tokens', {
|
||||
tokenHash: text('token_hash').primaryKey(),
|
||||
clientId: text('client_id').notNull(),
|
||||
apiKeyId: text('api_key_id').notNull(),
|
||||
scopes: text('scopes', { mode: 'json' }).$type<string[]>().notNull(),
|
||||
resource: text('resource'),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { loadConfig } from './config'
|
||||
import { createDb } from './db'
|
||||
import { createServerApp } from './app'
|
||||
|
||||
const config = loadConfig()
|
||||
const db = createDb(config.databasePath)
|
||||
const { app, websocket } = createServerApp(db, config)
|
||||
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
fetch(request, server) {
|
||||
return app.fetch(request, { server })
|
||||
},
|
||||
websocket,
|
||||
})
|
||||
|
||||
console.log(`mnie-server listening on http://localhost:${server.port}`)
|
||||
@@ -0,0 +1,108 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import type { PlaintextStoredWebAuthnCredential } from '@repo/client-sbi'
|
||||
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 requireOwnerSession: MiddlewareHandler<AppBindings> = async (c, next) => {
|
||||
if (c.get('auth').type !== 'session') return c.json({ error: 'unauthorized' }, 401)
|
||||
await next()
|
||||
}
|
||||
|
||||
export const createAdminRoutes = () => {
|
||||
const app = new Hono<AppBindings>()
|
||||
app.use('*', requireOwnerSession)
|
||||
|
||||
app.get('/api-keys', async (c) => c.json({ apiKeys: await listApiKeys(c.get('db')) }))
|
||||
|
||||
app.post('/api-keys', async (c) => {
|
||||
const { label, settings } = await c.req.json<{ label?: string; settings?: ApiKeySettings }>()
|
||||
if (!label?.trim()) return c.json({ error: 'label is required' }, 400)
|
||||
const key = await createApiKey(c.get('db'), label.trim(), settings)
|
||||
return c.json({ apiKey: key }, 201)
|
||||
})
|
||||
|
||||
app.patch('/api-keys/:id/settings', async (c) => {
|
||||
const body = await c.req.json<ApiKeySettings>()
|
||||
await updateApiKeySettings(c.get('db'), c.req.param('id'), body)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
app.delete('/api-keys/:id', async (c) => {
|
||||
await revokeApiKey(c.get('db'), c.req.param('id'))
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/sbi-passkeys', async (c) => {
|
||||
const rows = await c.get('db').select().from(sbiPasskeys).orderBy(sbiPasskeys.createdAt)
|
||||
return c.json({
|
||||
passkeys: rows.map(({ keyringAccount: _keyringAccount, ...row }) => ({
|
||||
...row,
|
||||
keyringAccount: undefined,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/sbi-passkeys', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
label?: string
|
||||
credential?: PlaintextStoredWebAuthnCredential
|
||||
tradePassword?: string
|
||||
deviceId?: string
|
||||
}>()
|
||||
if (!body.label?.trim() || !body.credential) {
|
||||
return c.json({ error: 'label and credential are required' }, 400)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const id = randomId('sbi')
|
||||
const keyringAccount = `sbi-passkey:${id}`
|
||||
await saveSecret(keyringAccount, {
|
||||
credential: body.credential,
|
||||
tradePassword: body.tradePassword,
|
||||
deviceId: body.deviceId,
|
||||
} satisfies StoredSbiPasskeySecret)
|
||||
await c.get('db').insert(sbiPasskeys).values({
|
||||
id,
|
||||
label: body.label.trim(),
|
||||
keyringAccount,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return c.json(
|
||||
{ passkey: { id, label: body.label.trim(), createdAt: now, updatedAt: now } },
|
||||
201,
|
||||
)
|
||||
})
|
||||
|
||||
app.delete('/sbi-passkeys/:id', async (c) => {
|
||||
const db = c.get('db')
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(sbiPasskeys)
|
||||
.where(eq(sbiPasskeys.id, c.req.param('id')))
|
||||
if (!row) return c.json({ error: 'not found' }, 404)
|
||||
await deleteSecret(row.keyringAccount)
|
||||
await db.delete(sbiPasskeys).where(eq(sbiPasskeys.id, row.id))
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
|
||||
const loopbackHosts = new Set(['localhost', '127.0.0.1'])
|
||||
|
||||
const expectedWebAuthnOrigins = (origin: string) => {
|
||||
const url = new URL(origin)
|
||||
if (!loopbackHosts.has(url.hostname)) return origin
|
||||
return [...loopbackHosts].map((hostname) => {
|
||||
const candidate = new URL(origin)
|
||||
candidate.hostname = hostname
|
||||
return candidate.origin
|
||||
})
|
||||
}
|
||||
|
||||
const expectedWebAuthnRpIds = (rpId: string) =>
|
||||
loopbackHosts.has(rpId) ? [...loopbackHosts] : rpId
|
||||
|
||||
export const createAuthRoutes = () => {
|
||||
const app = new Hono<AppBindings>()
|
||||
|
||||
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: expectedWebAuthnOrigins(config.origin),
|
||||
expectedRPID: expectedWebAuthnRpIds(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: expectedWebAuthnOrigins(config.origin),
|
||||
expectedRPID: expectedWebAuthnRpIds(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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } 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 supportedScopes = new Set(['read', 'write', 'trade', 'mcp'])
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isOwnerSession = (c: Context<AppBindings>) => c.get('auth').type === 'session'
|
||||
|
||||
export const createOAuthRoutes = () => {
|
||||
const app = new Hono<AppBindings>()
|
||||
|
||||
app.get('/client/:id', async (c) => {
|
||||
if (!isOwnerSession(c)) 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 (!isOwnerSession(c)) 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 requestedScopes = body.scope?.split(' ').filter(Boolean) ?? []
|
||||
const unsupportedScope = requestedScopes.find((scope) => !supportedScopes.has(scope))
|
||||
if (unsupportedScope) return c.json({ error: `unsupported scope: ${unsupportedScope}` }, 400)
|
||||
|
||||
const [client] = await c
|
||||
.get('db')
|
||||
.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: requestedScopes.length ? requestedScopes : ['read'],
|
||||
resource: body.resource,
|
||||
apiKeySettings: body.settings,
|
||||
})
|
||||
|
||||
const redirectUrl = new URL(body.redirectUri)
|
||||
redirectUrl.searchParams.set('code', code)
|
||||
if (body.state) redirectUrl.searchParams.set('state', body.state)
|
||||
return c.json({ redirectTo: redirectUrl.toString() })
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { SbiClientMethods } from '@repo/client-sbi'
|
||||
|
||||
export const RPC_METHODS = [
|
||||
'session.profile',
|
||||
'account.profile',
|
||||
'account.assets.current',
|
||||
'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.detail',
|
||||
'orders.inquiry.executionsToday',
|
||||
'orders.inquiry.open',
|
||||
'orders.inquiry.tradeRecords',
|
||||
'orders.cash.preOrder',
|
||||
'orders.cash.estimate',
|
||||
'orders.cash.place',
|
||||
'orders.cash.estimateCorrection',
|
||||
'orders.cash.estimateCorrectionConfirm',
|
||||
'orders.cash.placeCorrection',
|
||||
'orders.cash.estimateCancel',
|
||||
'orders.cash.placeCancel',
|
||||
'orders.margin.preOrderOpen',
|
||||
'orders.margin.estimateOpen',
|
||||
'orders.margin.open',
|
||||
'orders.margin.preOrderClose',
|
||||
'orders.margin.estimateClose',
|
||||
'orders.margin.close',
|
||||
'orders.margin.estimateCloseSummary',
|
||||
'orders.margin.closeSummary',
|
||||
'orders.margin.estimateSummary',
|
||||
'orders.margin.placeSummary',
|
||||
'orders.margin.preOrderActualDelivery',
|
||||
'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',
|
||||
'orders.exchange.rate',
|
||||
'orders.exchange.estimate',
|
||||
'orders.exchange.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',
|
||||
'orders.exchange.place',
|
||||
])
|
||||
|
||||
const cashOrderMethods = new Set<string>([
|
||||
'orders.cash.estimate',
|
||||
'orders.cash.place',
|
||||
'orders.cash.estimateCorrection',
|
||||
'orders.cash.estimateCorrectionConfirm',
|
||||
'orders.cash.placeCorrection',
|
||||
'orders.cash.estimateCancel',
|
||||
'orders.cash.placeCancel',
|
||||
])
|
||||
|
||||
export const isRpcMethod = (method: string): method is RpcMethod => methodSet.has(method)
|
||||
|
||||
export const isTradingMethod = (method: string) => tradingMethods.has(method)
|
||||
|
||||
export const isCashOrderMethod = (method: string) => cashOrderMethods.has(method)
|
||||
|
||||
export const invokeSbiMethod = async (
|
||||
client: SbiClientMethods,
|
||||
method: RpcMethod,
|
||||
params: unknown,
|
||||
) => {
|
||||
const target = method.split('.').reduce<unknown>((value, key) => {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
return (value as Record<string, unknown>)[key]
|
||||
}, client)
|
||||
|
||||
if (typeof target !== 'function') throw new Error(`RPC method not callable: ${method}`)
|
||||
|
||||
if (Array.isArray(params)) return target(...params)
|
||||
if (params === undefined || params === null) return target()
|
||||
return target(params)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { loginWithPasskey } from '@repo/client-sbi'
|
||||
import type { SbiClientMethods, SbiClientOptions } from '@repo/client-sbi'
|
||||
import type { ServerConfig } from '../config'
|
||||
import type { Db } from '../db'
|
||||
import { sbiPasskeys } from '../db/schema'
|
||||
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,
|
||||
foreignStockBaseUrl: config.foreignStockBaseUrl,
|
||||
usStockBaseUrl: config.usStockBaseUrl,
|
||||
foreignStockRestUrl: config.foreignStockRestUrl,
|
||||
foreignStockGraphqlBffUrl: config.foreignStockGraphqlBffUrl,
|
||||
foreignStockGraphqlIntUrl: config.foreignStockGraphqlIntUrl,
|
||||
mainSiteBaseUrl: config.mainSiteBaseUrl,
|
||||
mainSiteEtGatePath: config.mainSiteEtGatePath,
|
||||
mainSiteAssetsValuationsPath: config.mainSiteAssetsValuationsPath,
|
||||
mainSiteExchangeOrderInputPath: config.mainSiteExchangeOrderInputPath,
|
||||
mainSiteExchangeOrderPasswordPath: config.mainSiteExchangeOrderPasswordPath,
|
||||
mainSiteExchangeOrderConfirmPath: config.mainSiteExchangeOrderConfirmPath,
|
||||
mainSiteExchangeOrderCompletePath: config.mainSiteExchangeOrderCompletePath,
|
||||
},
|
||||
clientOptions,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { MarketCode, SbiClientMethods } from '@repo/client-sbi'
|
||||
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
|
||||
scopes?: string[]
|
||||
boardPollingSubscriptions: Map<string, AbortController>
|
||||
}
|
||||
|
||||
type BoardPollingParams = {
|
||||
issueCode: string
|
||||
market: MarketCode
|
||||
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 (typeof value.market !== 'string' || !value.market) {
|
||||
throw new Error('market is required')
|
||||
}
|
||||
if (!['XTKS', 'XNAS', 'XNYS', 'ARCX'].includes(value.market)) {
|
||||
throw new Error('market must be XTKS, XNAS, XNYS, or ARCX')
|
||||
}
|
||||
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: value.market as MarketCode,
|
||||
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 assertScope = (state: RpcSocketState, scope: 'read' | 'trade') => {
|
||||
if (!state.apiKeyId) return
|
||||
const scopes = state.scopes ?? ['read', 'write', 'trade', 'mcp']
|
||||
if (!scopes.includes(scope)) throw new Error(`missing OAuth scope: ${scope}`)
|
||||
}
|
||||
|
||||
const subscribeBoardPolling = async (
|
||||
db: Db,
|
||||
state: RpcSocketState,
|
||||
ws: WSContext,
|
||||
request: JsonRpcRequest,
|
||||
) => {
|
||||
if (!state.client) return error(request.id, 4001, 'SBI session is not connected')
|
||||
assertScope(state, 'read')
|
||||
|
||||
if (state.apiKeyId) {
|
||||
await assertApiKeyMethodAllowed(db, state.apiKeyId, 'market.issue.board')
|
||||
}
|
||||
|
||||
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') {
|
||||
assertScope(state, 'read')
|
||||
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')
|
||||
assertScope(state, isTradingMethod(request.method) ? 'trade' : 'read')
|
||||
|
||||
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,
|
||||
scopes: auth.type === 'apiKey' ? auth.scopes : 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,95 @@
|
||||
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
|
||||
scopes?: 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,
|
||||
scopes:
|
||||
settings.scopes === undefined
|
||||
? null
|
||||
: settings.scopes?.length
|
||||
? [...new Set(settings.scopes)].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,
|
||||
scopes: apiKeys.scopes,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(isNull(apiKeys.revokedAt))
|
||||
.orderBy(apiKeys.createdAt)
|
||||
|
||||
export const createApiKey = async (db: Db, label: string, settings: ApiKeySettings = {}) => {
|
||||
const token = `mnie_${randomToken()}`
|
||||
const now = new Date()
|
||||
const row = {
|
||||
id: randomId('key'),
|
||||
label,
|
||||
tokenHash: sha256(token),
|
||||
...normalizeApiKeySettings(settings),
|
||||
createdAt: now,
|
||||
}
|
||||
await db.insert(apiKeys).values(row)
|
||||
return { ...row, token, tokenHash: undefined }
|
||||
}
|
||||
|
||||
export const updateApiKeySettings = async (db: Db, id: string, settings: ApiKeySettings) => {
|
||||
await db.update(apiKeys).set(normalizeApiKeySettings(settings)).where(eq(apiKeys.id, id))
|
||||
}
|
||||
|
||||
export const revokeApiKey = async (db: Db, id: string) => {
|
||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id))
|
||||
}
|
||||
|
||||
export const verifyApiKey = async (db: Db, token: string) => {
|
||||
const tokenHash = sha256(token)
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.tokenHash, tokenHash), isNull(apiKeys.revokedAt)))
|
||||
.limit(1)
|
||||
if (!row) return undefined
|
||||
await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id))
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
export const randomId = (prefix: string) => `${prefix}_${randomBytes(18).toString('base64url')}`
|
||||
|
||||
export const randomToken = () => randomBytes(32).toString('base64url')
|
||||
|
||||
export const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||
|
||||
export const safeEqual = (left: string, right: string) => {
|
||||
const leftBuffer = Buffer.from(left)
|
||||
const rightBuffer = Buffer.from(right)
|
||||
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
scopes: apiKey.scopes?.length ? apiKey.scopes : ['read', 'write', 'trade', 'mcp'],
|
||||
} satisfies AuthContext)
|
||||
: ({ type: 'none', authenticated: false } satisfies AuthContext)
|
||||
}
|
||||
|
||||
export const authenticateRequest = async (db: Db, config: ServerConfig, request: Request) => {
|
||||
const authorization = request.headers.get('authorization')
|
||||
if (authorization?.startsWith('Bearer ')) {
|
||||
return apiKeyAuth(db, authorization.slice('Bearer '.length))
|
||||
}
|
||||
|
||||
const queryApiKey = readQueryApiKey(request)
|
||||
if (queryApiKey) {
|
||||
return apiKeyAuth(db, queryApiKey)
|
||||
}
|
||||
|
||||
const sessionId = readCookie(request.headers.get('cookie'), config.sessionCookieName)
|
||||
if (!sessionId) return { type: 'none', authenticated: false } satisfies AuthContext
|
||||
const [session] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1)
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return { type: 'none', authenticated: false } satisfies AuthContext
|
||||
}
|
||||
return { type: 'session', authenticated: true, sessionId } satisfies AuthContext
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { Database } from 'bun:sqlite'
|
||||
|
||||
const SERVICE = 'mnie'
|
||||
const SQLITE_BACKEND = 'sqlite'
|
||||
const PLATFORM_BACKEND = 'platform'
|
||||
|
||||
type KeyringBackend = typeof PLATFORM_BACKEND | typeof SQLITE_BACKEND
|
||||
|
||||
type Keytar = typeof import('@napi-rs/keyring/keytar')
|
||||
|
||||
let keytar: Keytar | undefined
|
||||
let sqlite: Database | undefined
|
||||
|
||||
const keyringBackend = (): KeyringBackend => {
|
||||
const backend = process.env.MNIE_KEYRING_BACKEND ?? PLATFORM_BACKEND
|
||||
if (backend === PLATFORM_BACKEND || backend === SQLITE_BACKEND) return backend
|
||||
throw new Error(`unsupported MNIE_KEYRING_BACKEND: ${backend}`)
|
||||
}
|
||||
|
||||
const loadKeytar = async () => {
|
||||
keytar ??= await import('@napi-rs/keyring/keytar')
|
||||
return keytar
|
||||
}
|
||||
|
||||
const sqlitePath = () =>
|
||||
resolve(
|
||||
process.env.MNIE_KEYRING_SQLITE_PATH ??
|
||||
process.env.MNIE_DATABASE_PATH?.replace(/\.sqlite$/u, '.keyring.sqlite') ??
|
||||
'./data/mnie-app.keyring.sqlite',
|
||||
)
|
||||
|
||||
const sqliteKey = () => {
|
||||
const secret = process.env.MNIE_KEYRING_SECRET
|
||||
if (!secret) {
|
||||
throw new Error('MNIE_KEYRING_SECRET is required when MNIE_KEYRING_BACKEND=sqlite')
|
||||
}
|
||||
return createHash('sha256').update(secret).digest()
|
||||
}
|
||||
|
||||
const keyringDb = () => {
|
||||
if (sqlite) return sqlite
|
||||
const path = sqlitePath()
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
sqlite = new Database(path, { create: true, strict: true })
|
||||
sqlite.run('PRAGMA journal_mode = WAL')
|
||||
sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS keyring_secrets (
|
||||
service TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
ciphertext TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (service, account)
|
||||
)
|
||||
`)
|
||||
return sqlite
|
||||
}
|
||||
|
||||
const encrypt = (plaintext: string) => {
|
||||
const nonce = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', sqliteKey(), nonce)
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
return {
|
||||
nonce: nonce.toString('base64url'),
|
||||
tag: cipher.getAuthTag().toString('base64url'),
|
||||
ciphertext: ciphertext.toString('base64url'),
|
||||
}
|
||||
}
|
||||
|
||||
const decrypt = (row: { nonce: string; tag: string; ciphertext: string }) => {
|
||||
const decipher = createDecipheriv('aes-256-gcm', sqliteKey(), Buffer.from(row.nonce, 'base64url'))
|
||||
decipher.setAuthTag(Buffer.from(row.tag, 'base64url'))
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(row.ciphertext, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
}
|
||||
|
||||
export const saveSecret = async (account: string, secret: unknown) => {
|
||||
const payload = JSON.stringify(secret)
|
||||
if (keyringBackend() === PLATFORM_BACKEND) {
|
||||
const { setPassword } = await loadKeytar()
|
||||
await setPassword(SERVICE, account, payload)
|
||||
return
|
||||
}
|
||||
|
||||
const encrypted = encrypt(payload)
|
||||
keyringDb()
|
||||
.query(
|
||||
`
|
||||
INSERT INTO keyring_secrets (service, account, nonce, tag, ciphertext, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(service, account) DO UPDATE SET
|
||||
nonce = excluded.nonce,
|
||||
tag = excluded.tag,
|
||||
ciphertext = excluded.ciphertext,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
)
|
||||
.run(SERVICE, account, encrypted.nonce, encrypted.tag, encrypted.ciphertext, Date.now())
|
||||
}
|
||||
|
||||
export const readSecret = async <T>(account: string): Promise<T> => {
|
||||
if (keyringBackend() === PLATFORM_BACKEND) {
|
||||
const { getPassword } = await loadKeytar()
|
||||
const secret = await getPassword(SERVICE, account)
|
||||
if (!secret) throw new Error(`secret not found: ${account}`)
|
||||
return JSON.parse(secret) as T
|
||||
}
|
||||
|
||||
const row = keyringDb()
|
||||
.query<{ nonce: string; tag: string; ciphertext: string }, [string, string]>(
|
||||
`
|
||||
SELECT nonce, tag, ciphertext
|
||||
FROM keyring_secrets
|
||||
WHERE service = ? AND account = ?
|
||||
`,
|
||||
)
|
||||
.get(SERVICE, account)
|
||||
const secret = row ? decrypt(row) : undefined
|
||||
if (!secret) throw new Error(`secret not found: ${account}`)
|
||||
return JSON.parse(secret) as T
|
||||
}
|
||||
|
||||
export const deleteSecret = async (account: string) => {
|
||||
if (keyringBackend() === PLATFORM_BACKEND) {
|
||||
const { deletePassword } = await loadKeytar()
|
||||
await deletePassword(SERVICE, account)
|
||||
return
|
||||
}
|
||||
|
||||
keyringDb()
|
||||
.query('DELETE FROM keyring_secrets WHERE service = ? AND account = ?')
|
||||
.run(SERVICE, account)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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
|
||||
const DEFAULT_SCOPES = ['read', 'write', 'trade', 'mcp'] as const
|
||||
|
||||
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,
|
||||
client_id: randomToken(),
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
} satisfies 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 scopes = code.scopes
|
||||
const key = await createApiKey(db, `OAuth: ${client.client_name ?? client.client_id}`, {
|
||||
...((code.apiKeySettings ?? {}) as ApiKeySettings),
|
||||
scopes,
|
||||
})
|
||||
const refreshToken = `mnie_refresh_${randomToken()}`
|
||||
const now = new Date()
|
||||
await db.insert(oauthRefreshTokens).values({
|
||||
tokenHash: sha256(refreshToken),
|
||||
clientId: client.client_id,
|
||||
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 nextScopes = scopes ?? token.scopes
|
||||
const newAccess = await createApiKey(db, `OAuth: ${client.client_name ?? client.client_id}`, {
|
||||
scopes: nextScopes,
|
||||
})
|
||||
return {
|
||||
access_token: newAccess.token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
||||
scope: nextScopes.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: apiKey.scopes?.length ? apiKey.scopes : [...DEFAULT_SCOPES],
|
||||
expiresAt: Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS,
|
||||
} satisfies AuthInfo
|
||||
},
|
||||
|
||||
revokeToken: async (_client, request) => {
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(apiKeys.tokenHash, sha256(request.token)))
|
||||
await db
|
||||
.update(oauthRefreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(oauthRefreshTokens.tokenHash, sha256(request.token)))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { StoredSbiPasskeySecret } from '../routes/admin'
|
||||
|
||||
const nonEmpty = (value: string | undefined) => value?.trim() || undefined
|
||||
|
||||
export const effectiveSbiDeviceId = (secret: StoredSbiPasskeySecret) =>
|
||||
nonEmpty(secret.deviceId) ?? nonEmpty(process.env.SBI_DEVICE_ID)
|
||||
|
||||
export const effectiveSbiTradePassword = (secret: StoredSbiPasskeySecret) =>
|
||||
nonEmpty(secret.tradePassword) ?? nonEmpty(process.env.SBI_TRADE_PASSWORD)
|
||||
|
||||
export const hasNonEmptySecretValue = (value: string | undefined) => Boolean(nonEmpty(value))
|
||||
@@ -0,0 +1,44 @@
|
||||
import { eq, lt } from 'drizzle-orm'
|
||||
import type { Context } from 'hono'
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||
import type { ServerConfig } from '../config'
|
||||
import type { Db } from '../db'
|
||||
import { sessions } from '../db/schema'
|
||||
import { randomId } from './crypto'
|
||||
|
||||
const SESSION_DAYS = 30
|
||||
|
||||
export const createSession = async (db: Db) => {
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + SESSION_DAYS * 24 * 60 * 60 * 1000)
|
||||
const id = randomId('ses')
|
||||
await db.insert(sessions).values({ id, createdAt: now, expiresAt })
|
||||
return { id, expiresAt }
|
||||
}
|
||||
|
||||
export const setSessionCookie = (c: Context, config: ServerConfig, id: string, expires: Date) => {
|
||||
setCookie(c, config.sessionCookieName, id, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: config.origin.startsWith('https://'),
|
||||
path: '/',
|
||||
expires,
|
||||
})
|
||||
}
|
||||
|
||||
export const clearSessionCookie = async (c: Context, db: Db, config: ServerConfig) => {
|
||||
const id = getCookie(c, config.sessionCookieName)
|
||||
if (id) await db.delete(sessions).where(eq(sessions.id, id))
|
||||
deleteCookie(c, config.sessionCookieName, { path: '/' })
|
||||
}
|
||||
|
||||
export const verifySessionCookie = async (c: Context, db: Db, config: ServerConfig) => {
|
||||
const id = getCookie(c, config.sessionCookieName)
|
||||
if (!id) return false
|
||||
const [session] = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1)
|
||||
return Boolean(session && session.expiresAt > new Date())
|
||||
}
|
||||
|
||||
export const pruneSessions = async (db: Db) => {
|
||||
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { sha256, safeEqual } from './crypto'
|
||||
|
||||
export const verifySetupPassword = (password: string) => {
|
||||
const hash = process.env.MNIE_SETUP_PASSWORD_HASH
|
||||
if (hash) return safeEqual(sha256(password), hash)
|
||||
|
||||
const plaintext = process.env.MNIE_SETUP_PASSWORD
|
||||
if (plaintext) return safeEqual(password, plaintext)
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { Db } from '../db'
|
||||
import { apiKeyTradeUsage, apiKeys } from '../db/schema'
|
||||
import type { RpcMethod } from '../rpc/methods'
|
||||
|
||||
type WindowName = '1h' | '3h' | '1d'
|
||||
|
||||
type TradeLimitInput = {
|
||||
apiKeyId: string
|
||||
params: unknown
|
||||
}
|
||||
|
||||
const windowBucket = (date: Date, window: WindowName) => {
|
||||
const year = date.getUTCFullYear()
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getUTCDate()).padStart(2, '0')
|
||||
if (window === '1d') return `${year}-${month}-${day}`
|
||||
|
||||
const hour = date.getUTCHours()
|
||||
const bucketHour = window === '3h' ? Math.floor(hour / 3) * 3 : hour
|
||||
return `${year}-${month}-${day}T${String(bucketHour).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const numberFromParams = (params: unknown, key: string) => {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) return undefined
|
||||
const value = (params as Record<string, unknown>)[key]
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : undefined
|
||||
}
|
||||
|
||||
const assertPriceLimits = (
|
||||
params: unknown,
|
||||
settings: {
|
||||
maxOrderPriceJpy: number | null
|
||||
maxOrderAmountJpy: number | null
|
||||
},
|
||||
) => {
|
||||
const price = numberFromParams(params, 'price')
|
||||
const quantity = numberFromParams(params, 'quantity')
|
||||
|
||||
if (settings.maxOrderPriceJpy != null && price != null && price > settings.maxOrderPriceJpy) {
|
||||
throw new Error(`order price exceeds API key limit (${settings.maxOrderPriceJpy} JPY)`)
|
||||
}
|
||||
|
||||
if (settings.maxOrderAmountJpy == null) return
|
||||
if (price == null || quantity == null) {
|
||||
throw new Error('order amount limit requires price and quantity in trading params')
|
||||
}
|
||||
|
||||
const amount = price * quantity
|
||||
if (amount > settings.maxOrderAmountJpy) {
|
||||
throw new Error(`order amount exceeds API key limit (${settings.maxOrderAmountJpy} JPY)`)
|
||||
}
|
||||
}
|
||||
|
||||
const checkAndConsumeWindow = async (
|
||||
db: Db,
|
||||
apiKeyId: string,
|
||||
window: WindowName,
|
||||
limit: number | null,
|
||||
now: Date,
|
||||
) => {
|
||||
if (limit == null) return
|
||||
const hourBucket = windowBucket(now, window)
|
||||
const where = and(
|
||||
eq(apiKeyTradeUsage.apiKeyId, apiKeyId),
|
||||
eq(apiKeyTradeUsage.window, window),
|
||||
eq(apiKeyTradeUsage.hourBucket, hourBucket),
|
||||
)
|
||||
const [usage] = await db.select().from(apiKeyTradeUsage).where(where).limit(1)
|
||||
const currentCount = usage?.tradeCount ?? 0
|
||||
if (currentCount >= limit) throw new Error(`${window} trade limit exceeded for API key`)
|
||||
|
||||
if (usage) {
|
||||
await db
|
||||
.update(apiKeyTradeUsage)
|
||||
.set({ tradeCount: currentCount + 1, updatedAt: now })
|
||||
.where(where)
|
||||
return
|
||||
}
|
||||
|
||||
await db.insert(apiKeyTradeUsage).values({
|
||||
apiKeyId,
|
||||
window,
|
||||
hourBucket,
|
||||
tradeCount: 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
export const assertAndConsumeApiKeyTradeLimits = async ({
|
||||
db,
|
||||
apiKeyId,
|
||||
params,
|
||||
}: TradeLimitInput & { db: Db }) => {
|
||||
const [key] = await db.select().from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1)
|
||||
if (!key || key.revokedAt) throw new Error('API key is not active')
|
||||
|
||||
assertPriceLimits(params, {
|
||||
maxOrderPriceJpy: key.maxOrderPriceJpy,
|
||||
maxOrderAmountJpy: key.maxOrderAmountJpy,
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
await checkAndConsumeWindow(db, apiKeyId, '1h', key.maxTradesPerHour, now)
|
||||
await checkAndConsumeWindow(db, apiKeyId, '3h', key.maxTradesPer6Hours, now)
|
||||
await checkAndConsumeWindow(db, apiKeyId, '1d', key.maxTradesPerDay, now)
|
||||
}
|
||||
|
||||
export const assertApiKeyMethodAllowed = async (db: Db, apiKeyId: string, method: RpcMethod) => {
|
||||
const [key] = await db.select().from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1)
|
||||
if (!key || key.revokedAt) throw new Error('API key is not active')
|
||||
if (key.allowedMethods === null || key.allowedMethods === undefined) return
|
||||
if (key.allowedMethods.includes(method)) return
|
||||
throw new Error(`API key is not allowed to call ${method}`)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user