feat: introduce mnie
This commit is contained in:
@@ -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"]
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
+9
-1
@@ -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'),
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
|
||||
+8
-8
@@ -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()
|
||||
}
|
||||
+18
-11
@@ -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 |
+21
@@ -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時間 取引上限
|
||||
+1
-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">
|
||||
+1
@@ -7,4 +7,5 @@ export const defaultApiKeyPolicy = (): ApiKeySettings => ({
|
||||
maxOrderPriceJpy: null,
|
||||
maxOrderAmountJpy: null,
|
||||
allowedMethods: null,
|
||||
scopes: ['read'],
|
||||
})
|
||||
+2
@@ -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}`, {
|
||||
+2
-2
@@ -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': {
|
||||
Reference in New Issue
Block a user