feat: introduce mnie
This commit is contained in:
+13
-13
@@ -2,21 +2,21 @@
|
||||
|
||||
# Local development
|
||||
PORT=8787
|
||||
CSBIE_SERVER_PORT=8787
|
||||
CSBIE_UI_DEV_PORT=5173
|
||||
CSBIE_DATABASE_PATH=./data/csbie.sqlite
|
||||
CSBIE_KEYRING_BACKEND=platform
|
||||
CSBIE_KEYRING_SQLITE_PATH=./data/csbie.keyring.sqlite
|
||||
CSBIE_KEYRING_SECRET=
|
||||
CSBIE_ORIGIN=http://127.0.0.1:5173
|
||||
CSBIE_CORS_ORIGIN=http://127.0.0.1:5173
|
||||
CSBIE_SESSION_COOKIE=csbie_session
|
||||
CSBIE_RP_NAME=CSBIE
|
||||
CSBIE_RP_ID=127.0.0.1
|
||||
MNIE_SERVER_PORT=8787
|
||||
MNIE_UI_DEV_PORT=5173
|
||||
MNIE_DATABASE_PATH=./data/mnie-app.sqlite
|
||||
MNIE_KEYRING_BACKEND=platform
|
||||
MNIE_KEYRING_SQLITE_PATH=./data/mnie-app.keyring.sqlite
|
||||
MNIE_KEYRING_SECRET=
|
||||
MNIE_ORIGIN=http://127.0.0.1:5173
|
||||
MNIE_CORS_ORIGIN=http://127.0.0.1:5173
|
||||
MNIE_SESSION_COOKIE=mnie_session
|
||||
MNIE_RP_NAME=MNIE
|
||||
MNIE_RP_ID=127.0.0.1
|
||||
|
||||
# Initial owner passkey setup. Prefer the hash form outside throwaway local dev.
|
||||
CSBIE_SETUP_PASSWORD=
|
||||
CSBIE_SETUP_PASSWORD_HASH=
|
||||
MNIE_SETUP_PASSWORD=
|
||||
MNIE_SETUP_PASSWORD_HASH=
|
||||
|
||||
# SBI endpoints and local SBI session options. Values are loaded at runtime only.
|
||||
SBI_AUTH_BASE_URL=
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
# cSBI
|
||||
# Mnie
|
||||
|
||||
[](https://技術者倫理.com)
|
||||
|
||||
A client for SBI-compatible servers. This project is not intended for use with a real SBI server.
|
||||
Self-host finance management for running your own portfolio, trading, API key, OAuth, MCP, and CLI workflows.
|
||||
|
||||
## cSBI SDK
|
||||
## Mnie SDK
|
||||
|
||||
## cSBIe
|
||||
`@repo/mnie-sdk` connects to a Mnie server with an API key and exposes typed finance methods from `@repo/mnie-types`.
|
||||
|
||||
cSBIe is a web ui and server for managing SBI-compatible API.
|
||||
## Apps
|
||||
|
||||
- `apps/mnie-app`: bundled server plus UI runtime
|
||||
- `apps/mnie-server`: API, OAuth, MCP, RPC, storage, and provider session management
|
||||
- `apps/mnie-ui`: web UI
|
||||
|
||||
## Packages
|
||||
|
||||
- `packages/mnie-types`: shared finance SDK interfaces
|
||||
- `packages/mnie-sdk`: API-key SDK for Mnie
|
||||
- `packages/mnie-cli`: CLI using `mnie-sdk`
|
||||
- `packages/client-sbi`: SBI provider client
|
||||
|
||||
### Install
|
||||
|
||||
@@ -16,15 +27,15 @@ Before running, setup .env, refering `.env.example`.
|
||||
|
||||
#### With docker
|
||||
|
||||
Docker uses `CSBIE_KEYRING_BACKEND=sqlite` because platform keyrings are not available in
|
||||
headless containers. Set `CSBIE_KEYRING_SECRET` in `.env` before saving SBI passkeys.
|
||||
Docker uses `MNIE_KEYRING_BACKEND=sqlite` because platform keyrings are not available in
|
||||
headless containers. Set `MNIE_KEYRING_SECRET` in `.env` before saving SBI passkeys.
|
||||
|
||||
docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
csbie:
|
||||
image: git.yutakobayashi.com/nakasyou/csbi:latest
|
||||
mnie:
|
||||
image: git.yutakobayashi.com/nakasyou/mnie:latest
|
||||
ports:
|
||||
- '18787:8787'
|
||||
env_file:
|
||||
@@ -36,15 +47,15 @@ services:
|
||||
or
|
||||
|
||||
```bash
|
||||
docker run --rm -it --name csbie \
|
||||
docker run --rm -it --name mnie \
|
||||
-p 18787:8787 \
|
||||
--env-file .env \
|
||||
-v "$PWD/data:/app/data" \
|
||||
git.yutakobayashi.com/nakasyou/csbi:latest
|
||||
git.yutakobayashi.com/nakasyou/mnie:latest
|
||||
```
|
||||
|
||||
#### With Nix
|
||||
|
||||
```bash
|
||||
nix run 'git+https://git.yutakobayashi.com/nakasyou/csbi.git#csbie'
|
||||
nix run 'git+https://git.yutakobayashi.com/nakasyou/mnie.git#mnie'
|
||||
```
|
||||
|
||||
@@ -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': {
|
||||
@@ -12,10 +12,10 @@
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"apps/csbie": {
|
||||
"name": "@repo/csbie",
|
||||
"apps/mnie-app": {
|
||||
"name": "@repo/mnie-app",
|
||||
"dependencies": {
|
||||
"@repo/csbie-server": "workspace:*",
|
||||
"@repo/mnie-server": "workspace:*",
|
||||
"hono": "^4.8.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -23,13 +23,13 @@
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"apps/csbie-server": {
|
||||
"name": "@repo/csbie-server",
|
||||
"apps/mnie-server": {
|
||||
"name": "@repo/mnie-server",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
@@ -41,8 +41,8 @@
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"apps/csbie-ui": {
|
||||
"name": "@repo/csbie-ui",
|
||||
"apps/mnie-ui": {
|
||||
"name": "@repo/mnie-ui",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"d3": "^7.9.0",
|
||||
@@ -61,9 +61,10 @@
|
||||
"vue-tsc": "^3",
|
||||
},
|
||||
},
|
||||
"packages/sbi-client": {
|
||||
"name": "@repo/sbi-client",
|
||||
"packages/client-sbi": {
|
||||
"name": "@repo/client-sbi",
|
||||
"dependencies": {
|
||||
"@repo/mnie-types": "workspace:*",
|
||||
"iconv-lite": "^0.7.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -73,6 +74,43 @@
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"packages/mnie-cli": {
|
||||
"name": "@repo/mnie-cli",
|
||||
"bin": {
|
||||
"mnie": "./src/index.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/keyring": "^1.2.0",
|
||||
"@repo/mnie-sdk": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"packages/mnie-sdk": {
|
||||
"name": "@repo/mnie-sdk",
|
||||
"dependencies": {
|
||||
"@repo/mnie-types": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"packages/mnie-types": {
|
||||
"name": "@repo/mnie-types",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="],
|
||||
@@ -319,13 +357,19 @@
|
||||
|
||||
"@polka/url": ["@polka/[email protected]", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@repo/csbie": ["@repo/csbie@workspace:apps/csbie"],
|
||||
"@repo/client-sbi": ["@repo/client-sbi@workspace:packages/client-sbi"],
|
||||
|
||||
"@repo/csbie-server": ["@repo/csbie-server@workspace:apps/csbie-server"],
|
||||
"@repo/mnie-app": ["@repo/mnie-app@workspace:apps/mnie-app"],
|
||||
|
||||
"@repo/csbie-ui": ["@repo/csbie-ui@workspace:apps/csbie-ui"],
|
||||
"@repo/mnie-cli": ["@repo/mnie-cli@workspace:packages/mnie-cli"],
|
||||
|
||||
"@repo/sbi-client": ["@repo/sbi-client@workspace:packages/sbi-client"],
|
||||
"@repo/mnie-sdk": ["@repo/mnie-sdk@workspace:packages/mnie-sdk"],
|
||||
|
||||
"@repo/mnie-server": ["@repo/mnie-server@workspace:apps/mnie-server"],
|
||||
|
||||
"@repo/mnie-types": ["@repo/mnie-types@workspace:packages/mnie-types"],
|
||||
|
||||
"@repo/mnie-ui": ["@repo/mnie-ui@workspace:apps/mnie-ui"],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
|
||||
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
services:
|
||||
csbie:
|
||||
image: git.yutakobayashi.com/nakasyou/csbi:latest
|
||||
mnie:
|
||||
image: git.yutakobayashi.com/nakasyou/mnie:latest
|
||||
ports:
|
||||
- '${CSBIE_HOST_PORT:-18787}:8787'
|
||||
- '${MNIE_HOST_PORT:-18787}:8787'
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "cSBI development and runtime helpers";
|
||||
description = "Mnie development and runtime helpers";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -40,7 +40,7 @@
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
composeUp = pkgs.writeShellApplication {
|
||||
name = "csbi-compose-up";
|
||||
name = "mnie-compose-up";
|
||||
runtimeInputs = [
|
||||
pkgs.docker-compose
|
||||
];
|
||||
@@ -49,17 +49,17 @@
|
||||
'';
|
||||
};
|
||||
nativeRun = pkgs.writeShellApplication {
|
||||
name = "csbi-native-run";
|
||||
name = "mnie-native-run";
|
||||
runtimeInputs = [
|
||||
pkgs.bun
|
||||
pkgs.coreutils
|
||||
];
|
||||
text = ''
|
||||
env_file="''${CSBIE_ENV_FILE:-$PWD/.env}"
|
||||
data_dir="''${CSBIE_DATA_DIR:-$PWD/data}"
|
||||
env_file="''${MNIE_ENV_FILE:-$PWD/.env}"
|
||||
data_dir="''${MNIE_DATA_DIR:-$PWD/data}"
|
||||
|
||||
if [ ! -f "$env_file" ]; then
|
||||
echo "CSBIE_ENV_FILE does not exist: $env_file" >&2
|
||||
echo "MNIE_ENV_FILE does not exist: $env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -74,30 +74,30 @@
|
||||
|
||||
cd "$source_dir"
|
||||
bun install --frozen-lockfile --linker hoisted
|
||||
bun --filter @repo/csbie build
|
||||
bun --filter @repo/mnie-app build
|
||||
|
||||
export CSBIE_DATABASE_PATH="''${CSBIE_DATABASE_PATH:-$data_dir/csbie.sqlite}"
|
||||
exec bun --env-file="$env_file" apps/csbie/dist/index.js "$@"
|
||||
export MNIE_DATABASE_PATH="''${MNIE_DATABASE_PATH:-$data_dir/mnie-app.sqlite}"
|
||||
exec bun --env-file="$env_file" apps/mnie-app/dist/index.js "$@"
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${composeUp}/bin/csbi-compose-up";
|
||||
meta.description = "Run cSBI with docker-compose";
|
||||
program = "${composeUp}/bin/mnie-compose-up";
|
||||
meta.description = "Run Mnie with docker-compose";
|
||||
};
|
||||
|
||||
compose-up = {
|
||||
type = "app";
|
||||
program = "${composeUp}/bin/csbi-compose-up";
|
||||
meta.description = "Run cSBI with docker-compose";
|
||||
program = "${composeUp}/bin/mnie-compose-up";
|
||||
meta.description = "Run Mnie with docker-compose";
|
||||
};
|
||||
|
||||
csbie = {
|
||||
mnie = {
|
||||
type = "app";
|
||||
program = "${nativeRun}/bin/csbi-native-run";
|
||||
meta.description = "Build and run cSBI directly with Bun";
|
||||
program = "${nativeRun}/bin/mnie-native-run";
|
||||
meta.description = "Build and run Mnie directly with Bun";
|
||||
};
|
||||
|
||||
}
|
||||
@@ -111,19 +111,19 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.csbie;
|
||||
cfg = config.services.mnie;
|
||||
nativeRun = pkgs.writeShellApplication {
|
||||
name = "csbi-native-run";
|
||||
name = "mnie-native-run";
|
||||
runtimeInputs = [
|
||||
pkgs.bun
|
||||
pkgs.coreutils
|
||||
];
|
||||
text = ''
|
||||
env_file="''${CSBIE_ENV_FILE:-$PWD/.env}"
|
||||
data_dir="''${CSBIE_DATA_DIR:-$PWD/data}"
|
||||
env_file="''${MNIE_ENV_FILE:-$PWD/.env}"
|
||||
data_dir="''${MNIE_DATA_DIR:-$PWD/data}"
|
||||
|
||||
if [ ! -f "$env_file" ]; then
|
||||
echo "CSBIE_ENV_FILE does not exist: $env_file" >&2
|
||||
echo "MNIE_ENV_FILE does not exist: $env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -138,16 +138,16 @@
|
||||
|
||||
cd "$source_dir"
|
||||
bun install --frozen-lockfile --linker hoisted
|
||||
bun --filter @repo/csbie build
|
||||
bun --filter @repo/mnie-app build
|
||||
|
||||
export CSBIE_DATABASE_PATH="''${CSBIE_DATABASE_PATH:-$data_dir/csbie.sqlite}"
|
||||
exec bun --env-file="$env_file" apps/csbie/dist/index.js "$@"
|
||||
export MNIE_DATABASE_PATH="''${MNIE_DATABASE_PATH:-$data_dir/mnie-app.sqlite}"
|
||||
exec bun --env-file="$env_file" apps/mnie-app/dist/index.js "$@"
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.csbie = {
|
||||
enable = lib.mkEnableOption "cSBI";
|
||||
options.services.mnie = {
|
||||
enable = lib.mkEnableOption "Mnie";
|
||||
|
||||
runtime = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
image = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "git.yutakobayashi.com/nakasyou/csbi:latest";
|
||||
default = "git.yutakobayashi.com/nakasyou/mnie:latest";
|
||||
description = "Container image to run when runtime is docker.";
|
||||
};
|
||||
|
||||
@@ -171,14 +171,14 @@
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/csbie";
|
||||
default = "/var/lib/mnie-app";
|
||||
description = "Host directory mounted at /app/data.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 18787;
|
||||
description = "Host port exposed for cSBI.";
|
||||
description = "Host port exposed for Mnie.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
|
||||
(lib.mkIf (cfg.runtime == "docker") {
|
||||
virtualisation.oci-containers.backend = lib.mkDefault "docker";
|
||||
virtualisation.oci-containers.containers.csbie = {
|
||||
virtualisation.oci-containers.containers.mnie = {
|
||||
image = cfg.image;
|
||||
ports = [
|
||||
"${toString cfg.port}:8787"
|
||||
@@ -206,8 +206,8 @@
|
||||
})
|
||||
|
||||
(lib.mkIf (cfg.runtime == "native") {
|
||||
systemd.services.csbie = {
|
||||
description = "cSBI";
|
||||
systemd.services.mnie = {
|
||||
description = "Mnie";
|
||||
wantedBy = [
|
||||
"multi-user.target"
|
||||
];
|
||||
@@ -218,8 +218,8 @@
|
||||
"network-online.target"
|
||||
];
|
||||
environment = {
|
||||
CSBIE_ENV_FILE = toString cfg.envFile;
|
||||
CSBIE_DATA_DIR = cfg.dataDir;
|
||||
MNIE_ENV_FILE = toString cfg.envFile;
|
||||
MNIE_DATA_DIR = cfg.dataDir;
|
||||
PORT = toString cfg.port;
|
||||
};
|
||||
serviceConfig = {
|
||||
@@ -228,7 +228,7 @@
|
||||
RestartSec = "5s";
|
||||
};
|
||||
script = ''
|
||||
exec ${nativeRun}/bin/csbi-native-run
|
||||
exec ${nativeRun}/bin/mnie-native-run
|
||||
'';
|
||||
};
|
||||
})
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "csbi",
|
||||
"name": "mnie",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# client-sbi
|
||||
|
||||
SBI provider client for Mnie.
|
||||
|
||||
The public finance method contract is exported from `@repo/mnie-types`; this package contains SBI-specific login, parsing, and transport code.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@repo/sbi-client",
|
||||
"name": "@repo/client-sbi",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"module": "index.ts",
|
||||
@@ -15,6 +15,7 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/mnie-types": "workspace:*",
|
||||
"iconv-lite": "^0.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
} from './methods/error-map'
|
||||
export type { SbiServerErrorOptions } from './methods/error-map'
|
||||
export { loginWithPasskey } from './session'
|
||||
export type * from '@repo/mnie-types'
|
||||
export type {
|
||||
LoginWithPasskeyOptions,
|
||||
PasskeyLoginResponse,
|
||||
@@ -0,0 +1 @@
|
||||
export type * from '@repo/mnie-types'
|
||||
@@ -207,7 +207,7 @@ const resolveForeignStockEndpointConfig = (
|
||||
restUrl: optionalUrl(restUrl) ?? requiredDerivedUrl(baseUrl, '/rest/'),
|
||||
graphqlBffUrl: optionalUrl(graphqlBffUrl) ?? requiredDerivedUrl(baseUrl, '/graphql/bff'),
|
||||
graphqlIntUrl: optionalUrl(graphqlIntUrl) ?? requiredDerivedUrl(baseUrl, '/graphql/int'),
|
||||
userAgent: userAgent || 'SBIFStockAndroid/1.6.10(csbi/0)',
|
||||
userAgent: userAgent || 'SBIFStockAndroid/1.6.10(mnie/0)',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@repo/mnie-cli",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"mnie": "./src/index.ts"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/keyring": "^1.2.0",
|
||||
"@repo/mnie-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createMnieClient } from '@repo/mnie-sdk'
|
||||
|
||||
type Profile = {
|
||||
origin: string
|
||||
apiKeyStorage: 'file' | 'keyring'
|
||||
apiKey?: string
|
||||
keyringAccount?: string
|
||||
}
|
||||
|
||||
type ProfilesFile = {
|
||||
defaultProfile?: string
|
||||
profiles: Record<string, Profile>
|
||||
}
|
||||
|
||||
const SERVICE = 'mnie-cli'
|
||||
const configDir = join(homedir(), '.mnie-cli')
|
||||
const configPath = join(configDir, 'profiles.json')
|
||||
|
||||
const help = `mnie cli
|
||||
|
||||
Usage:
|
||||
mnie --help
|
||||
mnie profile add <name> --origin <origin> --api-key <key> [--storage file|keyring]
|
||||
mnie profile list
|
||||
mnie profile use <name>
|
||||
mnie rpc methods [--profile <name>]
|
||||
mnie rpc call <method> [json-params] [--profile <name>] [--passkey-id <id>]
|
||||
mnie login --origin <origin> [--profile <name>] [--scopes <scopes>] [--storage file|keyring]
|
||||
|
||||
Examples:
|
||||
mnie profile add local --origin http://127.0.0.1:8787 --api-key mnie_xxx
|
||||
mnie rpc call account.profile --passkey-id sbi_xxx
|
||||
`
|
||||
|
||||
const parseOptions = (args: string[]) => {
|
||||
const positionals: string[] = []
|
||||
const options: Record<string, string | true> = {}
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index]!
|
||||
if (!arg.startsWith('--')) {
|
||||
positionals.push(arg)
|
||||
continue
|
||||
}
|
||||
const key = arg.slice(2)
|
||||
const next = args[index + 1]
|
||||
if (!next || next.startsWith('--')) {
|
||||
options[key] = true
|
||||
continue
|
||||
}
|
||||
options[key] = next
|
||||
index++
|
||||
}
|
||||
return { positionals, options }
|
||||
}
|
||||
|
||||
const option = (options: Record<string, string | true>, key: string) => {
|
||||
const value = options[key]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
const isString = (value: string | undefined): value is string => typeof value === 'string'
|
||||
|
||||
const loadProfiles = async (): Promise<ProfilesFile> => {
|
||||
try {
|
||||
return JSON.parse(await readFile(configPath, 'utf8')) as ProfilesFile
|
||||
} catch (cause) {
|
||||
if (cause && typeof cause === 'object' && 'code' in cause && cause.code === 'ENOENT') {
|
||||
return { profiles: {} }
|
||||
}
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfiles = async (profiles: ProfilesFile) => {
|
||||
await mkdir(dirname(configPath), { recursive: true })
|
||||
await writeFile(configPath, `${JSON.stringify(profiles, null, 2)}\n`, { mode: 0o600 })
|
||||
await chmod(configPath, 0o600)
|
||||
}
|
||||
|
||||
const keyring = async () => import('@napi-rs/keyring/keytar')
|
||||
|
||||
const saveApiKey = async (
|
||||
name: string,
|
||||
origin: string,
|
||||
apiKey: string,
|
||||
storage: 'file' | 'keyring',
|
||||
) => {
|
||||
if (storage === 'file') return { apiKey, apiKeyStorage: storage } as const
|
||||
const account = `profile:${name}`
|
||||
const { setPassword } = await keyring()
|
||||
await setPassword(SERVICE, account, JSON.stringify({ origin, apiKey }))
|
||||
return { keyringAccount: account, apiKeyStorage: storage } as const
|
||||
}
|
||||
|
||||
const readApiKey = async (name: string, profile: Profile) => {
|
||||
if (profile.apiKeyStorage === 'file') {
|
||||
if (!profile.apiKey) throw new Error(`profile ${name} has no file api key`)
|
||||
return profile.apiKey
|
||||
}
|
||||
if (!profile.keyringAccount) throw new Error(`profile ${name} has no keyring account`)
|
||||
const { getPassword } = await keyring()
|
||||
const payload = await getPassword(SERVICE, profile.keyringAccount)
|
||||
if (!payload) throw new Error(`profile ${name} api key was not found in keyring`)
|
||||
return (JSON.parse(payload) as { apiKey: string }).apiKey
|
||||
}
|
||||
|
||||
const requireProfile = async (name?: string) => {
|
||||
const profiles = await loadProfiles()
|
||||
const selected = name ?? profiles.defaultProfile
|
||||
if (!selected) throw new Error('profile is required')
|
||||
const profile = profiles.profiles[selected]
|
||||
if (!profile) throw new Error(`profile not found: ${selected}`)
|
||||
return { name: selected, profile, apiKey: await readApiKey(selected, profile) }
|
||||
}
|
||||
|
||||
const printJson = (value: unknown) => {
|
||||
console.log(JSON.stringify(value, null, 2))
|
||||
}
|
||||
|
||||
const addProfile = async (args: string[]) => {
|
||||
const { positionals, options } = parseOptions(args)
|
||||
const name = positionals[0]
|
||||
const origin = option(options, 'origin')
|
||||
const apiKey = option(options, 'api-key')
|
||||
const storage = option(options, 'storage') ?? 'file'
|
||||
if (!name || !origin || !apiKey)
|
||||
throw new Error('profile add requires name, --origin and --api-key')
|
||||
if (storage !== 'file' && storage !== 'keyring')
|
||||
throw new Error('--storage must be file or keyring')
|
||||
const profiles = await loadProfiles()
|
||||
profiles.profiles[name] = {
|
||||
origin: new URL(origin).origin,
|
||||
...(await saveApiKey(name, new URL(origin).origin, apiKey, storage)),
|
||||
}
|
||||
profiles.defaultProfile ??= name
|
||||
await saveProfiles(profiles)
|
||||
printJson({ ok: true, profile: name })
|
||||
}
|
||||
|
||||
const login = async (args: string[]) => {
|
||||
const { options } = parseOptions(args)
|
||||
const origin = option(options, 'origin')
|
||||
if (!origin) throw new Error('login requires --origin')
|
||||
const profileName = option(options, 'profile') ?? 'default'
|
||||
const scopes = option(options, 'scopes') ?? 'mcp read write trade'
|
||||
const storage = option(options, 'storage') ?? 'keyring'
|
||||
if (storage !== 'file' && storage !== 'keyring')
|
||||
throw new Error('--storage must be file or keyring')
|
||||
|
||||
const callback = await listenForOAuthCallback()
|
||||
const redirectUri = `http://127.0.0.1:${callback.port}/callback`
|
||||
const verifier = randomBytes(32).toString('base64url')
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
||||
const client = await registerOAuthClient(new URL(origin).origin, redirectUri)
|
||||
const authorize = new URL('/authorize', origin)
|
||||
authorize.searchParams.set('response_type', 'code')
|
||||
authorize.searchParams.set('client_id', client.client_id)
|
||||
authorize.searchParams.set('redirect_uri', redirectUri)
|
||||
authorize.searchParams.set('code_challenge', challenge)
|
||||
authorize.searchParams.set('code_challenge_method', 'S256')
|
||||
authorize.searchParams.set('scope', scopes)
|
||||
|
||||
console.error(`Open this URL to approve Mnie CLI:\n${authorize.toString()}`)
|
||||
openBrowser(authorize.toString())
|
||||
const code = await callback.code
|
||||
const tokens = await exchangeCode(new URL(origin).origin, {
|
||||
clientId: client.client_id,
|
||||
redirectUri,
|
||||
code,
|
||||
verifier,
|
||||
})
|
||||
const profiles = await loadProfiles()
|
||||
profiles.profiles[profileName] = {
|
||||
origin: new URL(origin).origin,
|
||||
...(await saveApiKey(profileName, new URL(origin).origin, tokens.access_token, storage)),
|
||||
}
|
||||
profiles.defaultProfile = profileName
|
||||
await saveProfiles(profiles)
|
||||
printJson({ ok: true, profile: profileName, scope: tokens.scope })
|
||||
}
|
||||
|
||||
const listenForOAuthCallback = async () => {
|
||||
let resolveCode!: (code: string) => void
|
||||
let rejectCode!: (error: Error) => void
|
||||
const code = new Promise<string>((resolve, reject) => {
|
||||
resolveCode = resolve
|
||||
rejectCode = reject
|
||||
})
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
const error = url.searchParams.get('error')
|
||||
const value = url.searchParams.get('code')
|
||||
queueMicrotask(() => server.stop(true))
|
||||
if (error) {
|
||||
rejectCode(new Error(error))
|
||||
return new Response('Mnie login failed. You can close this tab.', { status: 400 })
|
||||
}
|
||||
if (!value) {
|
||||
rejectCode(new Error('oauth callback did not include a code'))
|
||||
return new Response('Mnie login failed. You can close this tab.', { status: 400 })
|
||||
}
|
||||
resolveCode(value)
|
||||
return new Response('Mnie login complete. You can close this tab.')
|
||||
},
|
||||
})
|
||||
return { port: server.port, code }
|
||||
}
|
||||
|
||||
const registerOAuthClient = async (origin: string, redirectUri: string) => {
|
||||
const response = await fetch(new URL('/register', origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_name: 'Mnie CLI',
|
||||
redirect_uris: [redirectUri],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(await response.text())
|
||||
return response.json() as Promise<{ client_id: string }>
|
||||
}
|
||||
|
||||
const exchangeCode = async (
|
||||
origin: string,
|
||||
options: { clientId: string; redirectUri: string; code: string; verifier: string },
|
||||
) => {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: options.clientId,
|
||||
redirect_uri: options.redirectUri,
|
||||
code: options.code,
|
||||
code_verifier: options.verifier,
|
||||
})
|
||||
const response = await fetch(new URL('/token', origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
if (!response.ok) throw new Error(await response.text())
|
||||
return response.json() as Promise<{ access_token: string; scope?: string }>
|
||||
}
|
||||
|
||||
const openBrowser = (url: string) => {
|
||||
const command =
|
||||
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
|
||||
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
||||
Bun.spawn({ cmd: [command, ...args], stdout: 'ignore', stderr: 'ignore' })
|
||||
}
|
||||
|
||||
const rpc = async (args: string[]) => {
|
||||
const { positionals, options } = parseOptions(args)
|
||||
const { name, profile, apiKey } = await requireProfile(option(options, 'profile'))
|
||||
const client = createMnieClient({ origin: profile.origin, apiKey })
|
||||
try {
|
||||
if (positionals[0] === 'methods') {
|
||||
printJson(await client.methods())
|
||||
return
|
||||
}
|
||||
if (positionals[0] !== 'call' || !positionals[1])
|
||||
throw new Error('rpc requires methods or call')
|
||||
const passkeyId = option(options, 'passkey-id')
|
||||
if (passkeyId) await client.connect({ passkeyId })
|
||||
const params = positionals[2] ? JSON.parse(positionals[2]) : undefined
|
||||
const method = positionals[1]
|
||||
const result = await method.split('.').reduce<unknown>((target, key) => {
|
||||
if (!target || (typeof target !== 'object' && typeof target !== 'function')) return undefined
|
||||
return (target as Record<string, unknown>)[key]
|
||||
}, client)
|
||||
if (typeof result !== 'function') throw new Error(`method is not callable: ${method}`)
|
||||
printJson(await result(params))
|
||||
} finally {
|
||||
client.close()
|
||||
console.error(`profile: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const [command, subcommand, ...rest] = Bun.argv.slice(2)
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
console.log(help)
|
||||
return
|
||||
}
|
||||
if (command === 'profile' && subcommand === 'add') return addProfile(rest)
|
||||
if (command === 'profile' && subcommand === 'list') return printJson(await loadProfiles())
|
||||
if (command === 'profile' && subcommand === 'use') {
|
||||
const [name] = rest
|
||||
if (!name) throw new Error('profile use requires a name')
|
||||
const profiles = await loadProfiles()
|
||||
if (!profiles.profiles[name]) throw new Error(`profile not found: ${name}`)
|
||||
profiles.defaultProfile = name
|
||||
await saveProfiles(profiles)
|
||||
return printJson({ ok: true, profile: name })
|
||||
}
|
||||
if (command === 'rpc') return rpc([subcommand, ...rest].filter(isString))
|
||||
if (command === 'login') return login([subcommand, ...rest].filter(isString))
|
||||
throw new Error(`unknown command: ${command}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@repo/mnie-sdk",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/mnie-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { SbiClientMethods } from '@repo/mnie-types'
|
||||
|
||||
export type MnieSdkOptions = {
|
||||
origin: string
|
||||
apiKey: string
|
||||
WebSocket?: typeof WebSocket
|
||||
}
|
||||
|
||||
export type JsonRpcError = {
|
||||
code: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export type MnieClient = SbiClientMethods & {
|
||||
connect(options: { passkeyId: string }): Promise<{ connected: true; passkeyId: string }>
|
||||
methods(): Promise<string[]>
|
||||
close(): void
|
||||
}
|
||||
|
||||
type JsonRpcResponse = {
|
||||
jsonrpc?: '2.0'
|
||||
id?: string | number | null
|
||||
result?: unknown
|
||||
error?: JsonRpcError
|
||||
}
|
||||
|
||||
type PendingCall = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
const websocketUrl = (origin: string, apiKey: string) => {
|
||||
const url = new URL('/api/ws', origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.searchParams.set('key', apiKey)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
class MnieJsonRpcClient {
|
||||
readonly #socket: WebSocket
|
||||
readonly #pending = new Map<string, PendingCall>()
|
||||
#nextId = 1
|
||||
#opened: Promise<void>
|
||||
|
||||
constructor(options: MnieSdkOptions) {
|
||||
const WebSocketCtor = options.WebSocket ?? WebSocket
|
||||
this.#socket = new WebSocketCtor(websocketUrl(options.origin, options.apiKey))
|
||||
this.#opened = new Promise((resolve, reject) => {
|
||||
this.#socket.addEventListener('open', () => resolve(), { once: true })
|
||||
this.#socket.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('failed to connect to Mnie RPC')),
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
this.#socket.addEventListener('message', (event) => this.#handleMessage(event))
|
||||
this.#socket.addEventListener('close', () => this.#rejectPending('Mnie RPC socket closed'))
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#socket.close()
|
||||
}
|
||||
|
||||
async call(method: string, params?: unknown) {
|
||||
await this.#opened
|
||||
const id = String(this.#nextId++)
|
||||
const response = new Promise((resolve, reject) => {
|
||||
this.#pending.set(id, { resolve, reject })
|
||||
})
|
||||
this.#socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
|
||||
return response
|
||||
}
|
||||
|
||||
#handleMessage(event: MessageEvent) {
|
||||
const message = JSON.parse(String(event.data)) as JsonRpcResponse
|
||||
if (message.id == null) return
|
||||
const pending = this.#pending.get(String(message.id))
|
||||
if (!pending) return
|
||||
this.#pending.delete(String(message.id))
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message))
|
||||
return
|
||||
}
|
||||
pending.resolve(message.result)
|
||||
}
|
||||
|
||||
#rejectPending(message: string) {
|
||||
for (const pending of this.#pending.values()) pending.reject(new Error(message))
|
||||
this.#pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const methodProxy = (rpc: MnieJsonRpcClient, path: string[] = []): unknown =>
|
||||
new Proxy(() => undefined, {
|
||||
get(_target, property) {
|
||||
if (property === 'then') return undefined
|
||||
if (property === 'connect') {
|
||||
return (params: { passkeyId: string }) => rpc.call('sbi.connect', params)
|
||||
}
|
||||
if (property === 'methods') return () => rpc.call('rpc.methods')
|
||||
if (property === 'close') return () => rpc.close()
|
||||
if (typeof property !== 'string') return undefined
|
||||
return methodProxy(rpc, [...path, property])
|
||||
},
|
||||
apply(_target, _thisArg, args) {
|
||||
return rpc.call(path.join('.'), args.length > 1 ? args : args[0])
|
||||
},
|
||||
})
|
||||
|
||||
export const createMnieClient = (options: MnieSdkOptions) =>
|
||||
methodProxy(new MnieJsonRpcClient(options)) as MnieClient
|
||||
|
||||
export type * from '@repo/mnie-types'
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@repo/mnie-types",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type * from './types'
|
||||
export type * from './methods'
|
||||
@@ -40,7 +40,7 @@ import type {
|
||||
TradeRecordList,
|
||||
TradeSide,
|
||||
Watchlist,
|
||||
} from '../types'
|
||||
} from './types'
|
||||
|
||||
export type PagingOptions = {
|
||||
/** Start index for the result list. Defaults to the first item when omitted. */
|
||||
@@ -0,0 +1,951 @@
|
||||
export type WebAuthnAlgorithm = -7 | -257
|
||||
|
||||
export type WebAuthnUserVerification = 'required' | 'preferred' | 'discouraged'
|
||||
|
||||
export type WebAuthnTransport = 'ble' | 'hybrid' | 'internal' | 'nfc' | 'usb'
|
||||
|
||||
export type WebAuthnJwk = {
|
||||
kty: string
|
||||
crv?: string
|
||||
x?: string
|
||||
y?: string
|
||||
d?: string
|
||||
n?: string
|
||||
e?: string
|
||||
key_ops?: string[]
|
||||
ext?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type StoredWebAuthnCredentialSecret = {
|
||||
privateKey: {
|
||||
format: 'jwk'
|
||||
jwk: WebAuthnJwk
|
||||
}
|
||||
cosePrivateKey?: string
|
||||
registration?: {
|
||||
attestationObject?: string
|
||||
clientDataJSON?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type StoredWebAuthnCredential = {
|
||||
version: 1
|
||||
kind: 'webauthn-credential'
|
||||
provider: 'sbi-sec'
|
||||
rpId: string
|
||||
origin: string
|
||||
credentialId: string
|
||||
userHandle?: string
|
||||
alg: WebAuthnAlgorithm
|
||||
publicKey: {
|
||||
format: 'jwk'
|
||||
jwk: WebAuthnJwk
|
||||
}
|
||||
authenticator: {
|
||||
aaguid?: string
|
||||
signCount: number
|
||||
discoverable: boolean
|
||||
userVerification: WebAuthnUserVerification
|
||||
transports?: WebAuthnTransport[]
|
||||
backupEligible?: boolean
|
||||
backupState?: boolean
|
||||
}
|
||||
secret: {
|
||||
encrypted: true
|
||||
format: 'jwe-like-v1'
|
||||
kdf: {
|
||||
name: 'argon2id' | 'scrypt'
|
||||
salt: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
cipher: {
|
||||
name: 'AES-256-GCM'
|
||||
nonce: string
|
||||
aad: string
|
||||
ciphertext: string
|
||||
tag: string
|
||||
}
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type PlaintextStoredWebAuthnCredential = Omit<StoredWebAuthnCredential, 'secret'> & {
|
||||
label?: string
|
||||
secretPlaintext: StoredWebAuthnCredentialSecret
|
||||
}
|
||||
|
||||
export type PasskeyLoginResponse = {
|
||||
type: 'passkey-login-response'
|
||||
requestUrl: string
|
||||
status: number
|
||||
body: ArrayBuffer
|
||||
text: string
|
||||
accessToken?: string
|
||||
header: {
|
||||
sessionId: string
|
||||
trCode: string
|
||||
resultCode: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type ForeignStockEndpointConfig = {
|
||||
baseUrl?: string
|
||||
restUrl: string
|
||||
graphqlBffUrl: string
|
||||
graphqlIntUrl: string
|
||||
userAgent?: string
|
||||
}
|
||||
|
||||
export type ForeignStockSession = {
|
||||
endpoints: ForeignStockEndpointConfig
|
||||
ssoToken?: string
|
||||
sessionId?: string
|
||||
accountId?: string
|
||||
marketPriceHash?: string
|
||||
candleHash?: string
|
||||
loginAuthenticated?: boolean
|
||||
}
|
||||
|
||||
export type MainSiteAuthCache = {
|
||||
baseUrl: string
|
||||
assetsUrl: string
|
||||
cookieHeader: string
|
||||
authenticatedAt: string
|
||||
}
|
||||
|
||||
export type MainSiteSession = {
|
||||
baseUrl?: string
|
||||
etGatePath?: string
|
||||
assetsValuationsPath?: string
|
||||
exchangeOrderInputPath?: string
|
||||
exchangeOrderPasswordPath?: string
|
||||
exchangeOrderConfirmPath?: string
|
||||
exchangeOrderCompletePath?: string
|
||||
auth?: MainSiteAuthCache
|
||||
authPromise?: Promise<MainSiteAuthCache>
|
||||
}
|
||||
|
||||
export type SbiSession = {
|
||||
mtsBaseUrl: string
|
||||
izanagiBaseUrl?: string
|
||||
foreignStock?: ForeignStockSession
|
||||
mainSite?: MainSiteSession
|
||||
profile: AccountProfile
|
||||
loginResponse: PasskeyLoginResponse
|
||||
tradePassword?: string
|
||||
deviceIdRegistered?: boolean
|
||||
tradeAuthentication?: SbiTradeAuthenticationOptions
|
||||
}
|
||||
|
||||
export type LoginWithPasskeyOptions = {
|
||||
passkeyCredential: PlaintextStoredWebAuthnCredential
|
||||
authBaseUrl?: string
|
||||
mtsBaseUrl?: string
|
||||
izanagiBaseUrl?: string
|
||||
foreignStockBaseUrl?: string
|
||||
usStockBaseUrl?: string
|
||||
foreignStockRestUrl?: string
|
||||
foreignStockGraphqlBffUrl?: string
|
||||
foreignStockGraphqlIntUrl?: string
|
||||
foreignStockUserAgent?: string
|
||||
mainSiteBaseUrl?: string
|
||||
mainSiteEtGatePath?: string
|
||||
mainSiteAssetsValuationsPath?: string
|
||||
mainSiteExchangeOrderInputPath?: string
|
||||
mainSiteExchangeOrderPasswordPath?: string
|
||||
mainSiteExchangeOrderConfirmPath?: string
|
||||
mainSiteExchangeOrderCompletePath?: string
|
||||
}
|
||||
|
||||
export type SbiClientOptions = {
|
||||
tradePassword?: string
|
||||
deviceId?: string
|
||||
tradeAuthentication?: SbiTradeAuthenticationOptions
|
||||
}
|
||||
|
||||
export type SbiTradeAuthenticationRequest = {
|
||||
type: 'phone'
|
||||
telNo?: string
|
||||
phoneNo?: string
|
||||
sbiCallNo?: string
|
||||
authLimitTime?: string
|
||||
}
|
||||
|
||||
export type SbiTradeAuthenticationOptions = {
|
||||
onRequired?: (request: SbiTradeAuthenticationRequest) => void | Promise<void>
|
||||
confirmAttempts?: number
|
||||
confirmIntervalMs?: number
|
||||
}
|
||||
|
||||
export type IssueCode = string
|
||||
export type DomesticMarketCode = 'XTKS' | 'XNGO' | 'XFKA' | 'XSAP'
|
||||
export type SKabuMarketCode = 'STK'
|
||||
export type UsStockMarketCode = 'XNAS' | 'XNYS' | 'ARCX'
|
||||
export type MarketCode = DomesticMarketCode | SKabuMarketCode | UsStockMarketCode
|
||||
export type OrderId = string
|
||||
export type WatchlistId = string
|
||||
export type PositionId = string
|
||||
export type ThemeId = string
|
||||
|
||||
export type CurrencyAmount = {
|
||||
value: number | null
|
||||
text: string
|
||||
currency: 'JPY' | 'USD'
|
||||
}
|
||||
|
||||
export type PercentValue = {
|
||||
value: number | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SignedTextValue = {
|
||||
value: number | null
|
||||
text: string
|
||||
sign?: 'positive' | 'negative' | 'zero'
|
||||
}
|
||||
|
||||
export type AccountAssetsValuationSummary = {
|
||||
assetsErrorType: unknown
|
||||
valuation: number | null
|
||||
netChange: number | null
|
||||
percentChange: number | null
|
||||
monthOnMonth: number | null
|
||||
monthOnMonthRatio: unknown
|
||||
profitLoss: number | null
|
||||
profitLossRate: number | null
|
||||
acquisitionCost: number | null
|
||||
}
|
||||
|
||||
export type AccountAssetsValuationDetail = AccountAssetsValuationSummary & {
|
||||
category: string
|
||||
compositionRatio: number | null
|
||||
}
|
||||
|
||||
export type AccountAssetsValuations = {
|
||||
fetchedAt: string
|
||||
summary: AccountAssetsValuationSummary
|
||||
summaryWithoutDeposit: AccountAssetsValuationSummary
|
||||
summaryWithoutIdeco?: AccountAssetsValuationSummary
|
||||
summaryWithoutDepositAndIdeco?: AccountAssetsValuationSummary
|
||||
summaryDetails: AccountAssetsValuationDetail[]
|
||||
summaryDetailsWithoutDeposit: AccountAssetsValuationDetail[]
|
||||
summaryDetailsWithoutIdeco: AccountAssetsValuationDetail[]
|
||||
summaryDetailsWithoutDepositAndIdeco: AccountAssetsValuationDetail[]
|
||||
}
|
||||
|
||||
export type AccountType =
|
||||
| 'general'
|
||||
| 'specific'
|
||||
| 'growthInvestment'
|
||||
| 'nisa'
|
||||
| 'juniorNisa'
|
||||
| 'unknown'
|
||||
export type DepositType =
|
||||
| 'general'
|
||||
| 'specific'
|
||||
| 'growthInvestment'
|
||||
| 'nisa'
|
||||
| 'juniorNisa'
|
||||
| 'unknown'
|
||||
export type TradeSide = 'buy' | 'sell'
|
||||
export type MarginTradeSide = 'buy' | 'sell'
|
||||
export type OrderStatus = 'open' | 'executed' | 'cancelled' | 'expired' | 'rejected' | 'unknown'
|
||||
export type OrderKind = 'market' | 'limit' | 'stop' | 'oco' | 'ifd' | 'ifdo' | 's' | 'unknown'
|
||||
export type LoginStatus =
|
||||
| 'success'
|
||||
| 'invalidUser'
|
||||
| 'tradeForbidden'
|
||||
| 'locked'
|
||||
| 'fidoAuthorizationIncorrect'
|
||||
| 'fidoAuthorization'
|
||||
| 'passwordChangeRequired'
|
||||
| 'unknown'
|
||||
export type LoginType = 'passkey' | 'password' | 'unknown'
|
||||
export type SpecificAccountType =
|
||||
| 'withHolding'
|
||||
| 'withoutHolding'
|
||||
| 'nonSpecific'
|
||||
| 'notApply'
|
||||
| 'unknown'
|
||||
export type IsaAccountType =
|
||||
| 'nisaTradeForbidden'
|
||||
| 'oldNisaTradePermitted'
|
||||
| 'newNisaTradePermitted'
|
||||
| 'nisaTradePermitted'
|
||||
| 'unknown'
|
||||
|
||||
export type IssueRef = {
|
||||
code: IssueCode
|
||||
market?: MarketCode
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type IssueSearchItem = IssueRef & {
|
||||
extract?: string
|
||||
extractWord?: string
|
||||
boldFrom?: string
|
||||
boldTo?: string
|
||||
hitString?: string
|
||||
}
|
||||
|
||||
export type IssueSearchStatus = 'success' | 'searchError' | 'tooManyResults' | 'unknown'
|
||||
|
||||
export type IssueSearchResult = {
|
||||
status?: string
|
||||
statusText: IssueSearchStatus
|
||||
issues: IssueSearchItem[]
|
||||
}
|
||||
|
||||
export type ChartPeriod = 'minute' | 'day' | 'week' | 'month'
|
||||
|
||||
export type ChartPrice = {
|
||||
dateTime: string
|
||||
open: CurrencyAmount
|
||||
high: CurrencyAmount
|
||||
low: CurrencyAmount
|
||||
close: CurrencyAmount
|
||||
volume?: number | null
|
||||
}
|
||||
|
||||
export type IssueChart = {
|
||||
issue: IssueRef
|
||||
period: ChartPeriod
|
||||
unit: number
|
||||
prices: ChartPrice[]
|
||||
previousClose?: CurrencyAmount
|
||||
currentPrice?: CurrencyAmount
|
||||
highPrice?: CurrencyAmount
|
||||
lowPrice?: CurrencyAmount
|
||||
latestDateTime?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
sessionId: string
|
||||
loginType: LoginType
|
||||
resultCode: string
|
||||
}
|
||||
|
||||
export type AccountProfile = {
|
||||
session: SessionInfo
|
||||
branchCode?: string
|
||||
butenCode?: string
|
||||
accountNumber?: string
|
||||
userId?: string
|
||||
loginStatus?: LoginStatus
|
||||
loginType?: LoginType
|
||||
accountType?: AccountType
|
||||
specificAccountType?: SpecificAccountType
|
||||
hasMarginAccount?: boolean
|
||||
marginAccount?: string
|
||||
corporateFlag?: string
|
||||
commissionPlan?: string
|
||||
expireDate?: string
|
||||
lastLoginDate?: string
|
||||
lastLoginTime?: string
|
||||
tradingPassword?: string
|
||||
fxShareCol?: string
|
||||
fullTerm?: string
|
||||
fullAccount?: string
|
||||
securityAuthenticationResponseCode?: string
|
||||
fidoResponseCode?: string
|
||||
passkeyStatus?: string
|
||||
trId?: string
|
||||
txId?: string
|
||||
actionToken?: string
|
||||
nisa?: {
|
||||
enabled: boolean
|
||||
tradePermitted?: boolean
|
||||
juniorEnabled?: boolean
|
||||
accountType?: IsaAccountType
|
||||
jrNisaAccount?: string
|
||||
jrNisaSpecific?: SpecificAccountType
|
||||
jrNisaSeigen?: string
|
||||
}
|
||||
sor?: {
|
||||
defaultEnabled?: boolean
|
||||
defaultCode?: string
|
||||
lastMarket?: MarketCode
|
||||
juniorNisaLastMarket?: MarketCode
|
||||
}
|
||||
notices?: {
|
||||
hasImportantNotice?: boolean
|
||||
importantNoticeFlag?: string
|
||||
count?: number
|
||||
}
|
||||
restrictions?: {
|
||||
tradeRestricted?: boolean
|
||||
restrictedTradeFlag?: string
|
||||
message?: string
|
||||
}
|
||||
deficit?: {
|
||||
hasMessage?: boolean
|
||||
messageFlag?: string
|
||||
message?: string
|
||||
}
|
||||
maintenance?: {
|
||||
referenceable?: boolean
|
||||
referenceableMaintenanceFlag?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type BuyingPower = {
|
||||
cashBuyingPower?: CurrencyAmount
|
||||
marginBuyingPower?: CurrencyAmount
|
||||
withdrawableAmount?: CurrencyAmount
|
||||
collateralValue?: CurrencyAmount
|
||||
collateralRatio?: PercentValue
|
||||
sbiHybridDepositBalance?: CurrencyAmount
|
||||
noticeMessage?: string
|
||||
records?: CollateralRatioRecord[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type CollateralRatioRecord = {
|
||||
marginRequirements?: CurrencyAmount
|
||||
referenceMarginRequirements?: CurrencyAmount
|
||||
collateralRatioCash?: CurrencyAmount
|
||||
substituteSecuritiesValuationAmount?: CurrencyAmount
|
||||
unsettledPositionLoss?: SignedTextValue
|
||||
unsettledPositionLossFlag?: string
|
||||
settlementLoss?: SignedTextValue
|
||||
settlementLossFlag?: string
|
||||
paymentExpenses?: SignedTextValue
|
||||
paymentExpensesFlag?: string
|
||||
actualCollateral?: CurrencyAmount
|
||||
positionAmount?: CurrencyAmount
|
||||
sbiHybridDepositBalance?: CurrencyAmount
|
||||
minimumCollateral?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type CashPosition = {
|
||||
issue: IssueRef
|
||||
accountType?: AccountType
|
||||
depositType?: DepositType
|
||||
depositTypeCode?: string
|
||||
depositTypeText?: string
|
||||
quantity: number | null
|
||||
availableQuantity?: number | null
|
||||
unexecutedOrderQuantity?: number | null
|
||||
averagePrice?: CurrencyAmount
|
||||
purchasePrice?: CurrencyAmount
|
||||
/** Current unit price, not multiplied by quantity. */
|
||||
currentPrice?: CurrencyAmount
|
||||
priceText?: string
|
||||
/** Total position valuation amount. For cash positions this is quantity-adjusted. */
|
||||
marketValue?: CurrencyAmount
|
||||
presentValueFlag?: string
|
||||
/** Raw SBI valuation amount. Kept for source compatibility; prefer `marketValue` for totals. */
|
||||
valuationPrice?: CurrencyAmount
|
||||
valuationPriceChange?: SignedTextValue
|
||||
valuationPriceChangeRate?: PercentValue
|
||||
valuationPriceChangeFlag?: string
|
||||
profitLoss?: SignedTextValue
|
||||
profitLossRate?: PercentValue
|
||||
profitLossFlag?: string
|
||||
holdingCategory?: string
|
||||
accountInformation?: string
|
||||
}
|
||||
|
||||
export type CashPositionList = {
|
||||
positions: CashPosition[]
|
||||
index?: number
|
||||
totalCount?: number
|
||||
totalMarketValue?: CurrencyAmount
|
||||
totalProfitLoss?: SignedTextValue
|
||||
totalProfitLossRate?: PercentValue
|
||||
totalProfitLossFlag?: string
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type MarginPosition = {
|
||||
id?: PositionId
|
||||
issue: IssueRef
|
||||
side: MarginTradeSide
|
||||
sideText?: string
|
||||
accountType?: AccountType
|
||||
tradeKind?: string
|
||||
quantity: number | null
|
||||
availableCloseQuantity?: number | null
|
||||
unexecutedOrderQuantity?: number | null
|
||||
openPrice?: CurrencyAmount
|
||||
openAmount?: CurrencyAmount
|
||||
/** Current unit price or rate, not multiplied by quantity. */
|
||||
currentPrice?: CurrencyAmount
|
||||
rate?: CurrencyAmount
|
||||
/** Total position valuation amount when provided by SBI. */
|
||||
marketValue?: CurrencyAmount
|
||||
presentValueFlag?: string
|
||||
/** Raw SBI valuation amount. Prefer `marketValue` when calculating totals. */
|
||||
valuationPrice?: CurrencyAmount
|
||||
valuationPriceChange?: SignedTextValue
|
||||
valuationPriceChangeRate?: PercentValue
|
||||
valuationPriceChangeFlag?: string
|
||||
profitLoss?: SignedTextValue
|
||||
profitLossRate?: PercentValue
|
||||
profitLossFlag?: string
|
||||
openDate?: string
|
||||
dueDate?: string
|
||||
dueDateCode?: string
|
||||
dueDateText?: string
|
||||
depositTypeText?: string
|
||||
cost?: CurrencyAmount
|
||||
commission?: CurrencyAmount
|
||||
managementFee?: CurrencyAmount
|
||||
nameTransferFee?: CurrencyAmount
|
||||
interest?: CurrencyAmount
|
||||
backwardation?: CurrencyAmount
|
||||
collateralRatio?: PercentValue
|
||||
bargainMarketCode?: string
|
||||
bargainMarket?: string
|
||||
}
|
||||
|
||||
export type MarginPositionList = {
|
||||
positions: MarginPosition[]
|
||||
index?: number
|
||||
totalCount?: number
|
||||
totalMarketValue?: CurrencyAmount
|
||||
totalProfitLoss?: SignedTextValue
|
||||
totalProfitLossRate?: PercentValue
|
||||
totalProfitLossFlag?: string
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type ProfitLossSummary = {
|
||||
cash?: SignedTextValue
|
||||
margin?: SignedTextValue
|
||||
total?: SignedTextValue
|
||||
totalRate?: PercentValue
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type Quote = {
|
||||
issue: IssueRef
|
||||
price?: CurrencyAmount
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
changeFlag?: string
|
||||
open?: CurrencyAmount
|
||||
high?: CurrencyAmount
|
||||
low?: CurrencyAmount
|
||||
previousClose?: CurrencyAmount
|
||||
volume?: number | null
|
||||
timestamp?: string
|
||||
nominalPrices?: CurrencyAmount[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type BoardPriceLevel = {
|
||||
price: CurrencyAmount
|
||||
quantity?: number | null
|
||||
}
|
||||
|
||||
export type Board = {
|
||||
issue: IssueRef
|
||||
bids: BoardPriceLevel[]
|
||||
asks: BoardPriceLevel[]
|
||||
quote?: Quote
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type MarketIndex = {
|
||||
code?: string
|
||||
categoryCode?: string
|
||||
name: string
|
||||
value?: number | null
|
||||
valueText?: string
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
colorFlag?: string
|
||||
timestamp?: string
|
||||
open?: CurrencyAmount
|
||||
high?: CurrencyAmount
|
||||
low?: CurrencyAmount
|
||||
previousClose?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type DomesticMarket = {
|
||||
status?: string
|
||||
indexes: MarketIndex[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type RankingItem = {
|
||||
rank: number
|
||||
issue: IssueRef
|
||||
value?: number | string | null
|
||||
values?: Array<number | string | null>
|
||||
change?: SignedTextValue
|
||||
changeRate?: PercentValue
|
||||
exchangeName?: string
|
||||
colorFlag?: string
|
||||
}
|
||||
|
||||
export type Ranking = {
|
||||
items: RankingItem[]
|
||||
category?: string
|
||||
updatedAt?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type NewsItem = {
|
||||
id?: string
|
||||
title: string
|
||||
source?: string
|
||||
publishedAt?: string
|
||||
url?: string
|
||||
summary?: string
|
||||
storyDate?: string
|
||||
storyTime?: string
|
||||
processedDate?: string
|
||||
takeTime?: string
|
||||
pnac?: string
|
||||
}
|
||||
|
||||
export type NewsList = {
|
||||
items: NewsItem[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type WatchlistItem = {
|
||||
issue: IssueRef
|
||||
sortOrder?: number
|
||||
memo?: string
|
||||
quote?: Quote
|
||||
}
|
||||
|
||||
export type Watchlist = {
|
||||
id: WatchlistId
|
||||
name: string
|
||||
items: WatchlistItem[]
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type Order = {
|
||||
id: OrderId
|
||||
issue: IssueRef
|
||||
side: TradeSide
|
||||
sideText?: string
|
||||
status: OrderStatus
|
||||
statusText?: string
|
||||
executionStatus?: string
|
||||
executionStatusText?: string
|
||||
kind?: OrderKind
|
||||
accountType?: AccountType
|
||||
depositType?: DepositType
|
||||
depositTypeCode?: string
|
||||
depositTypeText?: string
|
||||
quantity?: number | null
|
||||
unexecutedQuantity?: number | null
|
||||
executedQuantity?: number | null
|
||||
price?: CurrencyAmount
|
||||
executedPrice?: CurrencyAmount
|
||||
orderedAt?: string
|
||||
expiresAt?: string
|
||||
orderNumber?: string
|
||||
orderSubNo?: string
|
||||
tradeId?: string
|
||||
exchangeCode?: string
|
||||
accountInformation?: string
|
||||
cancelable?: boolean
|
||||
correctable?: boolean
|
||||
}
|
||||
|
||||
export type OrderList = {
|
||||
orders: Order[]
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type TradeRecord = {
|
||||
id: string
|
||||
issue: IssueRef
|
||||
tradeRecordTypeCode?: string
|
||||
tradeCurrencyCode?: string
|
||||
listedSecuritiesStatus?: string
|
||||
orderPriceKindCode?: string
|
||||
accountType?: AccountType
|
||||
settlementCurrencyCode?: string
|
||||
amount?: CurrencyAmount
|
||||
quantity?: number | null
|
||||
price?: CurrencyAmount
|
||||
tradeDate?: string
|
||||
valueDate?: string
|
||||
marginCloseLimitType?: string
|
||||
}
|
||||
|
||||
export type TradeRecordList = {
|
||||
records: TradeRecord[]
|
||||
hasMore?: boolean
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type OrderCorrectionPreOrderDetail = {
|
||||
exchangeName?: string
|
||||
marketLoanKbn?: string
|
||||
marketIppanLoanKbn?: string
|
||||
currentPrice?: CurrencyAmount
|
||||
tradeColorFlag?: string
|
||||
priceTick?: string
|
||||
priceTickText?: string
|
||||
tradeTime?: string
|
||||
changeText?: string
|
||||
volumeText?: string
|
||||
}
|
||||
|
||||
export type OrderCorrectionPreOrder = {
|
||||
issue: IssueRef
|
||||
tradeTitle?: string
|
||||
buyingPowerTotal?: CurrencyAmount
|
||||
controlledStockCode?: string
|
||||
hasTradeWarning?: boolean
|
||||
deficitMessageFlag?: string
|
||||
deficitMessage?: string
|
||||
details: OrderCorrectionPreOrderDetail[]
|
||||
orderNumber?: string
|
||||
orderId?: string
|
||||
primaryOrderMethod?: string
|
||||
primaryTriggerZone?: string
|
||||
primaryTriggerPrice?: number | null
|
||||
status?: string
|
||||
statusText?: string
|
||||
tradeId?: string
|
||||
tradeName?: string
|
||||
quantity?: number | null
|
||||
quantityText?: string
|
||||
orderLimit?: string
|
||||
orderLimitText?: string
|
||||
priceSteps: StockOrderPreOrderPriceStep[]
|
||||
sessionRange?: string
|
||||
inputDateText?: string
|
||||
primaryOrderTerm?: string
|
||||
nonSpecificTradeText?: string
|
||||
marketName?: string
|
||||
rbeOrderStatus?: string
|
||||
priceCondition?: string
|
||||
price?: number | null
|
||||
priceAmount?: CurrencyAmount
|
||||
exchangeName?: string
|
||||
transId?: string
|
||||
ptsDayNightFlag?: string
|
||||
smallTickFlag?: string
|
||||
juniorBuyingPowerTotal?: CurrencyAmount
|
||||
secondaryPriceCondition?: string
|
||||
secondaryPrice?: number | null
|
||||
secondaryPriceAmount?: CurrencyAmount
|
||||
autoOrderKind?: string
|
||||
autoOrderNumber?: string
|
||||
autoOrderInputDate?: string
|
||||
secondaryOrderMethod?: string
|
||||
secondaryTriggerZone?: string
|
||||
secondaryTriggerPrice?: number | null
|
||||
secondaryOrderCondition?: string
|
||||
secondaryLimitPrice?: number | null
|
||||
secondaryLimitPriceAmount?: CurrencyAmount
|
||||
secondaryOrderTerm?: string
|
||||
secondaryOcoPriceCondition?: string
|
||||
secondaryOcoPrice?: number | null
|
||||
secondaryOcoPriceAmount?: CurrencyAmount
|
||||
exchangeList?: string
|
||||
}
|
||||
|
||||
export type OrderPreview = {
|
||||
issue: IssueRef
|
||||
side: TradeSide
|
||||
quantity?: number
|
||||
price?: CurrencyAmount
|
||||
estimatedAmount?: CurrencyAmount
|
||||
commission?: CurrencyAmount
|
||||
tax?: CurrencyAmount
|
||||
warnings: string[]
|
||||
confirmationId?: string
|
||||
message?: string
|
||||
correction?: OrderCorrectionPreOrder
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type StockOrderPreOrderPriceStep = {
|
||||
from?: CurrencyAmount
|
||||
to?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type StockOrderPreOrderPaymentLimit = {
|
||||
text?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export type StockOrderPreOrderMarginTradeType =
|
||||
| 'standard'
|
||||
| 'generalBuy'
|
||||
| 'generalSellShort'
|
||||
| 'generalSellInventoryLimited'
|
||||
| 'generalSellInventoryUnlimited'
|
||||
| 'day'
|
||||
| 'hyper'
|
||||
|
||||
export type StockOrderPreOrder = {
|
||||
issue: IssueRef
|
||||
tradeTitle?: string
|
||||
buyingPowerTotal?: CurrencyAmount
|
||||
controlledStockCode?: string
|
||||
hasTradeWarning?: boolean
|
||||
market?: MarketCode
|
||||
exchangeList?: string
|
||||
exchangeListName?: string
|
||||
exchangeListIndexFlag?: string
|
||||
marketLoanKbn?: string
|
||||
marketIppanLoanKbn?: string
|
||||
currentPrice?: CurrencyAmount
|
||||
tradeColorFlag?: string
|
||||
priceTick?: string
|
||||
priceTickText?: string
|
||||
tradeTime?: string
|
||||
changeText?: string
|
||||
volume?: number | null
|
||||
lotSize?: number | null
|
||||
priceSteps: StockOrderPreOrderPriceStep[]
|
||||
sessionRange?: string
|
||||
basePrice?: CurrencyAmount
|
||||
orderTerms: string[]
|
||||
orderTermDates: string[]
|
||||
paymentLimits: StockOrderPreOrderPaymentLimit[]
|
||||
nonSpecificTradeText?: string
|
||||
paymentLimitText?: string
|
||||
acquisitionPrice?: CurrencyAmount
|
||||
position?: number | null
|
||||
unexecutedQuantity?: number | null
|
||||
lotSize2?: number | null
|
||||
ptsDayNightFlag?: string
|
||||
sorServiceType?: string
|
||||
nisa?: {
|
||||
serviceKbn?: string
|
||||
buyLimit?: CurrencyAmount
|
||||
growthServiceKbn?: string
|
||||
juniorServiceKbn?: string
|
||||
juniorBuyLimit?: CurrencyAmount
|
||||
juniorBuyingPowerTotal?: CurrencyAmount
|
||||
}
|
||||
smallTickFlag?: string
|
||||
margin?: {
|
||||
tradeTypes?: StockOrderPreOrderMarginTradeType[]
|
||||
ippanShort?: string
|
||||
ippanLong?: string
|
||||
dayBuy?: string
|
||||
daySell?: string
|
||||
premiumShortSelling?: string
|
||||
premiumFee?: CurrencyAmount
|
||||
ippanPaymentLimit?: string
|
||||
positionStatus?: string
|
||||
}
|
||||
sKabu?: {
|
||||
code?: string
|
||||
available?: boolean
|
||||
}
|
||||
deficitMessageFlag?: string
|
||||
deficitMessage?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type OrderReceipt = {
|
||||
accepted: boolean
|
||||
orderId?: OrderId
|
||||
acceptedAt?: string
|
||||
message?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type ExchangeOrderSide = 'buy' | 'sell'
|
||||
export type ExchangeSpecificMethod = 'foreign' | 'domestic'
|
||||
export type ExchangeAccountKind = 'GENERAL' | 'JR_NISA'
|
||||
export type ExchangeSellMethod = 'SELL_PART' | 'SELL_ALL'
|
||||
|
||||
export type ExchangeOrderPreview = {
|
||||
currencyCode: string
|
||||
currencyName?: string
|
||||
side: ExchangeOrderSide
|
||||
exchangeType?: string
|
||||
accountKind?: ExchangeAccountKind
|
||||
specificMethod?: ExchangeSpecificMethod
|
||||
sellMethod?: ExchangeSellMethod | null
|
||||
tradeQuantity?: string
|
||||
orderAmount?: string
|
||||
exchangeRate?: string
|
||||
netAmount?: string
|
||||
valueDate?: string
|
||||
rateDateTime?: string
|
||||
warningMessage?: string | null
|
||||
isMaintenance?: boolean
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
export type ExchangeOrderReceipt = {
|
||||
accepted: boolean
|
||||
currencyCode?: string
|
||||
side?: ExchangeOrderSide
|
||||
message?: string
|
||||
warningMessage?: string | null
|
||||
rawTitle?: string
|
||||
}
|
||||
|
||||
export type ExchangeRateInfo = {
|
||||
currencyCode: string
|
||||
side: ExchangeOrderSide
|
||||
referenceExchangeRate?: string
|
||||
computeExchangeRate?: string
|
||||
basePrice?: string
|
||||
exchangeTradeType?: string
|
||||
updateTime?: string
|
||||
buyPossibleAmount?: string
|
||||
sellPossibleAmount?: string
|
||||
buyUnit?: string
|
||||
sellUnit?: string
|
||||
buyLimitMin?: string
|
||||
buyLimitMax?: string
|
||||
sellLimitMin?: string
|
||||
sellLimitMax?: string
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ThemeInvestment = {
|
||||
id: ThemeId
|
||||
name: string
|
||||
issues: ThemeInvestmentIssue[]
|
||||
minimumAmount?: CurrencyAmount
|
||||
}
|
||||
|
||||
export type ThemeInvestmentIssue = IssueRef & {
|
||||
controlledStockCode?: string
|
||||
hasTradeWarning?: boolean
|
||||
nisaServiceKbn?: string
|
||||
juniorNisaServiceKbn?: string
|
||||
growthNisaServiceKbn?: string
|
||||
sKabuCode?: string
|
||||
sKabuAvailable?: boolean
|
||||
lotSize?: number | null
|
||||
currentPrice?: CurrencyAmount
|
||||
tradeColorFlag?: string
|
||||
priceTick?: string
|
||||
priceTickText?: string
|
||||
tradeTime?: string
|
||||
}
|
||||
|
||||
export type ThemeInvestmentList = {
|
||||
themes: ThemeInvestment[]
|
||||
buyingPowerTotal?: CurrencyAmount
|
||||
isaBuyLimit?: CurrencyAmount
|
||||
juniorNisaBuyLimit?: CurrencyAmount
|
||||
buyingPowerTotalJuniorNisa?: CurrencyAmount
|
||||
deficitMessage?: string
|
||||
deficitMessageFlag?: string
|
||||
error?: SbiMethodError
|
||||
}
|
||||
|
||||
export type SbiMethodError = {
|
||||
status?: string
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user