feat: introduce mnie

This commit is contained in:
Shotaro Nakamura
2026-06-21 21:37:07 +09:00
parent 614ea8a992
commit fed3ac1b61
104 changed files with 1815 additions and 218 deletions
+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.MNIE_DATABASE_PATH ?? './data/mnie-app.sqlite',
},
})
+31
View File
@@ -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"
}
}
+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: '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 }
}
+66
View File
@@ -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,
}
}
+28
View File
@@ -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[]
}
+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 })
}
+103
View File
@@ -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(),
})
+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(`mnie-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/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
}
+233
View File
@@ -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
+97
View File
@@ -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
}
+123
View File
@@ -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)
}
+45
View File
@@ -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,
)
}
+267
View File
@@ -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)
},
}
}),
}
}
+95
View File
@@ -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
}
+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,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
}
+139
View File
@@ -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))
+44
View File
@@ -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.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}`)
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"rootDir": "src",
"outDir": "dist",
"noEmit": true
},
"include": ["src/**/*.ts"]
}