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
-26
View File
@@ -1,26 +0,0 @@
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock tsconfig.json vite.config.ts ./
COPY packages/sbi-client/package.json packages/sbi-client/package.json
COPY apps/csbie/package.json apps/csbie/package.json
COPY apps/csbie-server/package.json apps/csbie-server/package.json
COPY apps/csbie-ui/package.json apps/csbie-ui/package.json
RUN bun install --frozen-lockfile
FROM deps AS build
COPY . .
RUN bun --filter @repo/csbie build
FROM oven/bun:1-slim AS runtime
ENV NODE_ENV=production
ENV CSBIE_DATABASE_PATH=/app/data/csbie.sqlite
ENV CSBIE_KEYRING_BACKEND=sqlite
ENV CSBIE_KEYRING_SQLITE_PATH=/app/data/csbie.keyring.sqlite
WORKDIR /app
COPY --from=build /app/apps/csbie/dist ./apps/csbie/dist
COPY --from=build /app/apps/csbie-ui/dist ./apps/csbie-ui/dist
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring@*/node_modules/@napi-rs/keyring ./node_modules/@napi-rs/keyring
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring-linux-x64-gnu@*/node_modules/@napi-rs/keyring-linux-x64-gnu ./node_modules/@napi-rs/keyring-linux-x64-gnu
WORKDIR /app/apps/csbie/dist
EXPOSE 8787
CMD ["bun", "index.js"]
+29
View File
@@ -0,0 +1,29 @@
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock tsconfig.json vite.config.ts ./
COPY packages/client-sbi/package.json packages/client-sbi/package.json
COPY packages/mnie-types/package.json packages/mnie-types/package.json
COPY packages/mnie-sdk/package.json packages/mnie-sdk/package.json
COPY packages/mnie-cli/package.json packages/mnie-cli/package.json
COPY apps/mnie-app/package.json apps/mnie-app/package.json
COPY apps/mnie-server/package.json apps/mnie-server/package.json
COPY apps/mnie-ui/package.json apps/mnie-ui/package.json
RUN bun install --frozen-lockfile
FROM deps AS build
COPY . .
RUN bun --filter @repo/mnie-app build
FROM oven/bun:1-slim AS runtime
ENV NODE_ENV=production
ENV MNIE_DATABASE_PATH=/app/data/mnie-app.sqlite
ENV MNIE_KEYRING_BACKEND=sqlite
ENV MNIE_KEYRING_SQLITE_PATH=/app/data/mnie-app.keyring.sqlite
WORKDIR /app
COPY --from=build /app/apps/mnie-app/dist ./apps/mnie-app/dist
COPY --from=build /app/apps/mnie-ui/dist ./apps/mnie-ui/dist
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring@*/node_modules/@napi-rs/keyring ./node_modules/@napi-rs/keyring
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring-linux-x64-gnu@*/node_modules/@napi-rs/keyring-linux-x64-gnu ./node_modules/@napi-rs/keyring-linux-x64-gnu
WORKDIR /app/apps/mnie-app/dist
EXPOSE 8787
CMD ["bun", "index.js"]
@@ -1,16 +1,16 @@
{
"name": "@repo/csbie",
"name": "@repo/mnie-app",
"private": true,
"type": "module",
"scripts": {
"clean": "rm -rf dist ../csbie-ui/dist",
"clean": "rm -rf dist ../mnie-ui/dist",
"dev": "bun --env-file=../../.env src/dev.ts",
"build": "bun --filter @repo/csbie-ui build && bun build src/index.ts --target=bun --outdir=dist --external @napi-rs/keyring",
"build": "bun --filter @repo/mnie-ui build && bun build src/index.ts --target=bun --outdir=dist --external @napi-rs/keyring",
"start": "bun --env-file=../../.env dist/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@repo/csbie-server": "workspace:*",
"@repo/mnie-server": "workspace:*",
"hono": "^4.8.3"
},
"devDependencies": {
@@ -1,10 +1,10 @@
const uiPort = Number(process.env.CSBIE_UI_DEV_PORT ?? 5173)
const serverPort = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const uiPort = Number(process.env.MNIE_UI_DEV_PORT ?? 5173)
const serverPort = Number(process.env.PORT ?? process.env.MNIE_SERVER_PORT ?? 8787)
const uiOrigin = `http://127.0.0.1:${uiPort}`
const serverEntry = new URL('../../csbie-server/src/index.ts', import.meta.url).pathname
const serverEntry = new URL('../../mnie-server/src/index.ts', import.meta.url).pathname
const ui = Bun.spawn({
cmd: ['bun', '--filter', '@repo/csbie-ui', 'dev'],
cmd: ['bun', '--filter', '@repo/mnie-ui', 'dev'],
stdout: 'inherit',
stderr: 'inherit',
stdin: 'inherit',
@@ -19,8 +19,8 @@ const server = Bun.spawn({
env: {
...process.env,
PORT: String(serverPort),
CSBIE_ORIGIN: uiOrigin,
CSBIE_CORS_ORIGIN: uiOrigin,
MNIE_ORIGIN: uiOrigin,
MNIE_CORS_ORIGIN: uiOrigin,
},
})
@@ -1,15 +1,15 @@
import { existsSync } from 'node:fs'
import { extname, isAbsolute, join, relative, resolve } from 'node:path'
import { Hono } from 'hono'
import { createServerApp } from '@repo/csbie-server/app'
import { loadConfig } from '@repo/csbie-server/config'
import { createDb } from '@repo/csbie-server/db'
import { createServerApp } from '@repo/mnie-server/app'
import { loadConfig } from '@repo/mnie-server/config'
import { createDb } from '@repo/mnie-server/db'
const config = loadConfig()
const db = createDb(config.databasePath)
const api = createServerApp(db, config)
const app = new Hono()
const uiDist = resolve(import.meta.dir, '../../csbie-ui/dist')
const uiDist = resolve(import.meta.dir, '../../mnie-ui/dist')
const contentTypes: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
@@ -63,4 +63,4 @@ const server = Bun.serve({
websocket: api.websocket,
})
console.log(`csbie listening on http://localhost:${server.port}`)
console.log(`mnie listening on http://localhost:${server.port}`)
@@ -5,6 +5,6 @@ export default defineConfig({
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.CSBIE_DATABASE_PATH ?? './data/csbie.sqlite',
url: process.env.MNIE_DATABASE_PATH ?? './data/mnie-app.sqlite',
},
})
@@ -1,5 +1,5 @@
{
"name": "@repo/csbie-server",
"name": "@repo/mnie-server",
"private": true,
"type": "module",
"exports": {
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/keyring": "^1.2.0",
"@repo/sbi-client": "workspace:*",
"@repo/client-sbi": "workspace:*",
"@simplewebauthn/server": "^13.1.2",
"drizzle-orm": "^0.44.2",
"hono": "^4.8.3",
@@ -48,8 +48,8 @@ export const createServerApp = (db: Db, config: ServerConfig) => {
issuerUrl: new URL(config.origin),
baseUrl: new URL(config.origin),
resourceServerUrl: new URL('/api/mcp', config.origin),
resourceName: 'CSBIE MCP',
scopesSupported: ['mcp'],
resourceName: 'Mnie finance management',
scopesSupported: ['read', 'write', 'trade', 'mcp'],
provider: oauthProvider,
authorizationOptions: { rateLimit: false },
tokenOptions: { rateLimit: false },
@@ -32,19 +32,19 @@ const optionalUrl = (value: string | undefined) => {
}
export const loadConfig = (): ServerConfig => {
const port = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const databasePath = resolve(process.env.CSBIE_DATABASE_PATH ?? './data/csbie.sqlite')
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.CSBIE_ORIGIN ?? `http://localhost:${port}`
const rpId = process.env.CSBIE_RP_ID ?? new URL(origin).hostname
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.CSBIE_CORS_ORIGIN ?? origin,
sessionCookieName: process.env.CSBIE_SESSION_COOKIE ?? 'csbie_session',
rpName: process.env.CSBIE_RP_NAME ?? 'CSBIE',
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),
@@ -24,4 +24,5 @@ export type AuthContext =
type: 'apiKey'
authenticated: true
apiKeyId: string
scopes: string[]
}
@@ -50,6 +50,7 @@ export const apiKeys = sqliteTable(
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' }),
@@ -14,4 +14,4 @@ const server = Bun.serve({
websocket,
})
console.log(`csbie-server listening on http://localhost:${server.port}`)
console.log(`mnie-server listening on http://localhost:${server.port}`)
@@ -1,7 +1,7 @@
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import type { MiddlewareHandler } from 'hono'
import type { PlaintextStoredWebAuthnCredential } from '@repo/sbi-client'
import type { PlaintextStoredWebAuthnCredential } from '@repo/client-sbi'
import type { AppBindings } from '../context'
import { sbiPasskeys } from '../db/schema'
import {
@@ -74,6 +74,21 @@ const passkeyForCredential = async (db: AppBindings['Variables']['db'], credenti
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>()
@@ -125,8 +140,8 @@ export const createAuthRoutes = () => {
const verification = await verifyRegistrationResponse({
response: response as never,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpId,
expectedOrigin: expectedWebAuthnOrigins(config.origin),
expectedRPID: expectedWebAuthnRpIds(config.rpId),
requireUserVerification: true,
})
@@ -186,8 +201,8 @@ export const createAuthRoutes = () => {
const verification = await verifyAuthenticationResponse({
response: response as never,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpId,
expectedOrigin: expectedWebAuthnOrigins(config.origin),
expectedRPID: expectedWebAuthnRpIds(config.rpId),
credential: {
id: passkey.credentialId,
publicKey: Buffer.from(passkey.publicKey, 'base64url'),
@@ -25,6 +25,9 @@ const textResult = (value: unknown) => ({
const requireAuthenticated = (auth: AuthContext) => {
if (!auth.authenticated) throw new Error('unauthorized')
if (auth.type === 'apiKey' && !auth.scopes.includes('mcp')) {
throw new Error('missing OAuth scope: mcp')
}
}
const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000
@@ -835,7 +838,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
const auth = c.get('auth')
const server = new McpServer({
name: 'csbie',
name: 'mnie',
version: '0.1.0',
})
@@ -869,6 +872,10 @@ const createMcpServer = (c: Context<AppBindings>) => {
requireAuthenticated(auth)
if (auth.type === 'apiKey') {
const requiredScope = isTradingMethod(method) ? 'trade' : 'read'
if (!auth.scopes.includes(requiredScope)) {
throw new Error(`missing OAuth scope: ${requiredScope}`)
}
await assertApiKeyMethodAllowed(db, auth.apiKeyId, method)
}
@@ -943,7 +950,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
}
server.registerTool(
'csbie-get',
'mnie-get',
{
title: 'Get SBI Data',
description: `Read SBI data through a small abstract action API. This tool never places, corrects, cancels, or otherwise changes real orders. ${getActionDescription}`,
@@ -958,7 +965,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
return textResult({
getActions,
changeActions,
changeTool: 'csbie-request-change',
changeTool: 'mnie-request-change',
confirmTool: 'confirm-request',
})
}
@@ -976,7 +983,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
)
server.registerTool(
'csbie-request-change',
'mnie-request-change',
{
title: 'Create SBI Change Request',
description: `Prepare a real SBI change by running the corresponding estimate/preview and returning a UUID. This tool never submits the change; pass the UUID to confirm-request. ${changeActionDescription}`,
@@ -999,7 +1006,7 @@ const createMcpServer = (c: Context<AppBindings>) => {
description:
'Submit a previously prepared SBI change request by UUID. The UUID expires shortly, is single-use, and is bound to the same authenticated caller.',
inputSchema: {
uuid: z.string().uuid().describe('UUID returned by csbie-request-change'),
uuid: z.string().uuid().describe('UUID returned by mnie-request-change'),
},
},
async ({ uuid }) => {
@@ -7,6 +7,7 @@ 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
@@ -62,6 +63,9 @@ export const createOAuthRoutes = () => {
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')
@@ -78,7 +82,7 @@ export const createOAuthRoutes = () => {
clientId: body.clientId,
redirectUri: body.redirectUri,
codeChallenge: body.codeChallenge,
scopes: body.scope?.split(' ').filter(Boolean) ?? [],
scopes: requestedScopes.length ? requestedScopes : ['read'],
resource: body.resource,
apiKeySettings: body.settings,
})
@@ -1,4 +1,4 @@
import type { SbiClientMethods } from '@repo/sbi-client'
import type { SbiClientMethods } from '@repo/client-sbi'
export const RPC_METHODS = [
'session.profile',
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'
import { loginWithPasskey } from '@repo/sbi-client'
import type { SbiClientMethods, SbiClientOptions } from '@repo/sbi-client'
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'
@@ -1,4 +1,4 @@
import type { MarketCode, SbiClientMethods } from '@repo/sbi-client'
import type { MarketCode, SbiClientMethods } from '@repo/client-sbi'
import { createBunWebSocket } from 'hono/bun'
import type { WSContext } from 'hono/ws'
import { randomUUID } from 'node:crypto'
@@ -22,6 +22,7 @@ type RpcSocketState = {
client?: SbiClientMethods
sbiPasskeyId?: string
apiKeyId?: string
scopes?: string[]
boardPollingSubscriptions: Map<string, AbortController>
}
@@ -103,6 +104,12 @@ const stopBoardPollingSubscriptions = (state: RpcSocketState) => {
}
}
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,
@@ -110,6 +117,7 @@ const subscribeBoardPolling = async (
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')
@@ -159,6 +167,7 @@ const handleRpc = async (
}
if (request.method === 'sbi.connect') {
assertScope(state, 'read')
const passkeyId =
request.params && typeof request.params === 'object'
? (request.params as { passkeyId?: string }).passkeyId
@@ -190,6 +199,7 @@ const handleRpc = async (
}
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)
@@ -228,6 +238,7 @@ export const createRpcWebSocket = (db: Db, config: ServerConfig) => {
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(),
}
@@ -10,6 +10,7 @@ export type ApiKeySettings = {
maxOrderPriceJpy?: number | null
maxOrderAmountJpy?: number | null
allowedMethods?: string[] | null
scopes?: string[] | null
}
const normalizeLimit = (value: unknown) => {
@@ -31,6 +32,12 @@ export const normalizeApiKeySettings = (settings: ApiKeySettings = {}) => ({
: 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) =>
@@ -44,6 +51,7 @@ export const listApiKeys = async (db: Db) =>
maxOrderPriceJpy: apiKeys.maxOrderPriceJpy,
maxOrderAmountJpy: apiKeys.maxOrderAmountJpy,
allowedMethods: apiKeys.allowedMethods,
scopes: apiKeys.scopes,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
revokedAt: apiKeys.revokedAt,
@@ -53,7 +61,7 @@ export const listApiKeys = async (db: Db) =>
.orderBy(apiKeys.createdAt)
export const createApiKey = async (db: Db, label: string, settings: ApiKeySettings = {}) => {
const token = `csbie_${randomToken()}`
const token = `mnie_${randomToken()}`
const now = new Date()
const row = {
id: randomId('key'),
@@ -22,7 +22,12 @@ const readQueryApiKey = (request: Request) => {
const apiKeyAuth = async (db: Db, token: string) => {
const apiKey = await verifyApiKey(db, token)
return apiKey
? ({ type: 'apiKey', authenticated: true, apiKeyId: apiKey.id } satisfies AuthContext)
? ({
type: 'apiKey',
authenticated: true,
apiKeyId: apiKey.id,
scopes: apiKey.scopes?.length ? apiKey.scopes : ['read', 'write', 'trade', 'mcp'],
} satisfies AuthContext)
: ({ type: 'none', authenticated: false } satisfies AuthContext)
}
@@ -3,7 +3,7 @@ import { mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { Database } from 'bun:sqlite'
const SERVICE = 'csbie'
const SERVICE = 'mnie'
const SQLITE_BACKEND = 'sqlite'
const PLATFORM_BACKEND = 'platform'
@@ -15,9 +15,9 @@ let keytar: Keytar | undefined
let sqlite: Database | undefined
const keyringBackend = (): KeyringBackend => {
const backend = process.env.CSBIE_KEYRING_BACKEND ?? PLATFORM_BACKEND
const backend = process.env.MNIE_KEYRING_BACKEND ?? PLATFORM_BACKEND
if (backend === PLATFORM_BACKEND || backend === SQLITE_BACKEND) return backend
throw new Error(`unsupported CSBIE_KEYRING_BACKEND: ${backend}`)
throw new Error(`unsupported MNIE_KEYRING_BACKEND: ${backend}`)
}
const loadKeytar = async () => {
@@ -27,15 +27,15 @@ const loadKeytar = async () => {
const sqlitePath = () =>
resolve(
process.env.CSBIE_KEYRING_SQLITE_PATH ??
process.env.CSBIE_DATABASE_PATH?.replace(/\.sqlite$/u, '.keyring.sqlite') ??
'./data/csbie.keyring.sqlite',
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.CSBIE_KEYRING_SECRET
const secret = process.env.MNIE_KEYRING_SECRET
if (!secret) {
throw new Error('CSBIE_KEYRING_SECRET is required when CSBIE_KEYRING_BACKEND=sqlite')
throw new Error('MNIE_KEYRING_SECRET is required when MNIE_KEYRING_BACKEND=sqlite')
}
return createHash('sha256').update(secret).digest()
}
@@ -16,6 +16,7 @@ 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,
@@ -54,7 +55,11 @@ export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthSe
registerClient: async (
client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
) => {
const clientInfo = client as OAuthClientInformationFull
const clientInfo = {
...client,
client_id: randomToken(),
client_id_issued_at: Math.floor(Date.now() / 1000),
} satisfies OAuthClientInformationFull
await db
.insert(oauthClients)
.values({
@@ -117,14 +122,13 @@ export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthSe
.delete(oauthAuthorizationCodes)
.where(eq(oauthAuthorizationCodes.code, authorizationCode))
const key = await createApiKey(
db,
`OAuth: ${client.client_name ?? client.client_id}`,
(code.apiKeySettings ?? {}) as ApiKeySettings,
)
const refreshToken = `csbie_refresh_${randomToken()}`
const now = new Date()
const scopes = code.scopes
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,
@@ -157,12 +161,15 @@ export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthSe
)
if (!token || token.expiresAt <= new Date()) throw new Error('refresh token expired')
const newAccess = await createApiKey(db, `OAuth: ${client.client_name ?? client.client_id}`)
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: (scopes ?? token.scopes).join(' '),
scope: nextScopes.join(' '),
refresh_token: refreshToken,
} satisfies OAuthTokens
},
@@ -173,7 +180,7 @@ export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthSe
return {
token,
clientId: apiKey.id,
scopes: ['mcp'],
scopes: apiKey.scopes?.length ? apiKey.scopes : [...DEFAULT_SCOPES],
expiresAt: Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS,
} satisfies AuthInfo
},
@@ -1,10 +1,10 @@
import { sha256, safeEqual } from './crypto'
export const verifySetupPassword = (password: string) => {
const hash = process.env.CSBIE_SETUP_PASSWORD_HASH
const hash = process.env.MNIE_SETUP_PASSWORD_HASH
if (hash) return safeEqual(sha256(password), hash)
const plaintext = process.env.CSBIE_SETUP_PASSWORD
const plaintext = process.env.MNIE_SETUP_PASSWORD
if (plaintext) return safeEqual(password, plaintext)
return false
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cSBIe</title>
<title>Mnie</title>
</head>
<body>
<div id="app"></div>
@@ -1,5 +1,5 @@
{
"name": "@repo/csbie-ui",
"name": "@repo/mnie-ui",
"private": true,
"type": "module",
"scripts": {
@@ -25,6 +25,7 @@ const router = useRouter()
const activeTab = computed<RouteName>(() => {
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
})
const isOAuthRoute = computed(() => route.name === 'oauthAuthorize')
const showAuthGate = computed(() => true)
const decodedRouteParam = (value: string) => {
try {
@@ -208,10 +209,10 @@ onMounted(async () => {
<template>
<main :class="ui.appShell">
<AppSidebar :active-tab="activeTab" @navigate="navigate" />
<AppSidebar v-if="!isOAuthRoute" :active-tab="activeTab" @navigate="navigate" />
<section :class="ui.workspace">
<AppHeader v-if="activeTab !== 'settings'" :active-tab="activeTab" />
<AppHeader v-if="!isOAuthRoute && activeTab !== 'settings'" :active-tab="activeTab" />
<OAuthApprovalPanel
v-if="oauthApproval.active && status.authenticated"
@@ -12,6 +12,7 @@ export type ApiKey = {
maxOrderPriceJpy?: number | null
maxOrderAmountJpy?: number | null
allowedMethods?: string[] | null
scopes?: string[] | null
createdAt: string
lastUsedAt?: string | null
revokedAt?: string | null
@@ -26,6 +27,7 @@ export type ApiKeySettings = Pick<
| 'maxOrderPriceJpy'
| 'maxOrderAmountJpy'
| 'allowedMethods'
| 'scopes'
>
export type SbiPasskey = {

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

@@ -92,6 +92,14 @@ const tradingMethods = [
const tradingMethodSet = new Set<string>(tradingMethods)
const readMethods = rpcMethods.filter((method) => !tradingMethodSet.has(method))
const scopes = ['read', 'write', 'trade', 'mcp'] as const
const toggleScope = (scope: string) => {
const current = settings.value.scopes ?? []
settings.value.scopes = current.includes(scope)
? current.filter((candidate) => candidate !== scope)
: [...current, scope].sort()
}
const setMethods = (methods: readonly string[] | null) => {
settings.value.allowedMethods = methods ? [...methods] : null
@@ -116,6 +124,7 @@ const ui = {
input:
'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition focus:border-[#a8c7fa]',
permissions: 'border-t border-[#33383f] pt-3',
scopeGrid: 'grid grid-cols-2 gap-2 md:grid-cols-4',
summary: 'cursor-pointer font-black text-[#e3e3e9]',
actions: 'mt-3 flex flex-wrap gap-2',
button:
@@ -129,6 +138,18 @@ const ui = {
<template>
<div :class="ui.root">
<div :class="ui.scopeGrid">
<label v-for="scope in scopes" :key="scope" :class="ui.methodToggle">
<input
:class="ui.checkbox"
type="checkbox"
:checked="settings.scopes?.includes(scope)"
@change="toggleScope(scope)"
/>
<span>{{ scope }}</span>
</label>
</div>
<div :class="[ui.limitGrid, compact && ui.limitGridCompact]">
<label :class="ui.label">
1時間 取引上限
@@ -17,7 +17,7 @@ const items = sidebarItems
<template>
<aside :class="ui.sidebar">
<div :class="ui.brandMark" aria-label="CSBIE">
<div :class="ui.brandMark" aria-label="MNIE">
<Activity class="h-8 w-8" :stroke-width="2.75" aria-hidden="true" />
</div>
<nav :class="ui.navStack" aria-label="Main">
@@ -7,4 +7,5 @@ export const defaultApiKeyPolicy = (): ApiKeySettings => ({
maxOrderPriceJpy: null,
maxOrderAmountJpy: null,
allowedMethods: null,
scopes: ['read'],
})
@@ -39,6 +39,8 @@ export const useOAuthApproval = () => {
state: url.searchParams.get('state') ?? '',
resource: url.searchParams.get('resource') ?? '',
}
const requestedScopes = oauthApproval.value.scope.split(' ').filter(Boolean)
oauthSettings.value.scopes = requestedScopes.length ? requestedScopes : ['read']
if (!oauthApproval.value.clientId) return
const response = await fetch(`/api/oauth/client/${oauthApproval.value.clientId}`, {
@@ -261,10 +261,10 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const reportDataError = (message: string, cause?: unknown) => {
if (cause) {
console.error(`[csbie-ui] データ取得エラー: ${message}`, cause)
console.error(`[mnie-ui] データ取得エラー: ${message}`, cause)
return
}
console.error(`[csbie-ui] データ取得エラー: ${message}`)
console.error(`[mnie-ui] データ取得エラー: ${message}`)
}
const selectedStock = computed(() => {
@@ -2,8 +2,8 @@ import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
const serverPort = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const serverOrigin = process.env.CSBIE_SERVER_ORIGIN ?? `http://127.0.0.1:${serverPort}`
const serverPort = Number(process.env.PORT ?? process.env.MNIE_SERVER_PORT ?? 8787)
const serverOrigin = process.env.MNIE_SERVER_ORIGIN ?? `http://127.0.0.1:${serverPort}`
const proxyToServer = {
target: serverOrigin,
changeOrigin: true,
@@ -13,11 +13,11 @@ const proxyToServer = {
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173),
port: Number(process.env.MNIE_UI_DEV_PORT ?? 5173),
strictPort: true,
hmr: {
host: '127.0.0.1',
clientPort: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173),
clientPort: Number(process.env.MNIE_UI_DEV_PORT ?? 5173),
},
proxy: {
'/api': {