diff --git a/package.json b/package.json index f8d3903..b150345 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts", + "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/oauth-store.test.ts && tsx src/review-checkpoints.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index 0e0147b..fb0272d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -178,7 +178,7 @@ async function serve(): Promise { const { createServer } = await import("./server.js"); const config = loadConfig(); - const { app } = createServer(config); + const { app, close } = createServer(config); const httpServer = app.listen(config.port, config.host, () => { console.log(`devspace listening on http://${config.host}:${config.port}/mcp`); console.log(`public base url: ${config.publicBaseUrl}`); @@ -192,7 +192,10 @@ async function serve(): Promise { }); const shutdown = () => { - httpServer.close(() => process.exit(0)); + httpServer.close(() => { + close(); + process.exit(0); + }); }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4e..42b53ac 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -168,3 +168,24 @@ assert.deepEqual(fileConfig.allowedHosts, [ "::1", "devspace.example.com", ]); + +const bomConfigDir = mkdtempSync(join(tmpdir(), "devspace-bom-config-test-")); +writeFileSync( + join(bomConfigDir, "config.json"), + `\uFEFF${JSON.stringify({ + port: 8989, + allowedRoots: [process.cwd()], + publicBaseUrl: "https://bom.example.com", + })}`, + "utf8", +); +writeFileSync( + join(bomConfigDir, "auth.json"), + `\uFEFF${JSON.stringify({ ownerToken: "bom-owner-password-long-enough" })}`, + "utf8", +); + +const bomConfig = loadConfig({ DEVSPACE_CONFIG_DIR: bomConfigDir }); +assert.equal(bomConfig.port, 8989); +assert.equal(bomConfig.oauth.ownerToken, "bom-owner-password-long-enough"); +assert.equal(bomConfig.publicBaseUrl, "https://bom.example.com"); diff --git a/src/db/schema.ts b/src/db/schema.ts index ed5d292..31e4b8d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { index, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const workspaceSessions = sqliteTable( "workspace_sessions", @@ -40,5 +40,42 @@ export const loadedAgentFiles = sqliteTable( export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; +export const oauthClients = sqliteTable("oauth_clients", { + clientId: text("client_id").primaryKey(), + clientJson: text("client_json").notNull(), + createdAt: integer("created_at").notNull(), +}); + +export const oauthAuthorizationCodes = sqliteTable( + "oauth_authorization_codes", + { + codeHash: text("code_hash").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClients.clientId, { onDelete: "cascade" }), + paramsJson: text("params_json").notNull(), + expiresAtMs: integer("expires_at_ms").notNull(), + }, + (table) => [index("oauth_authorization_codes_expiry_idx").on(table.expiresAtMs)], +); + +export const oauthTokens = sqliteTable( + "oauth_tokens", + { + tokenHash: text("token_hash").notNull(), + tokenKind: text("token_kind").notNull(), + clientId: text("client_id") + .notNull() + .references(() => oauthClients.clientId, { onDelete: "cascade" }), + scopesJson: text("scopes_json").notNull(), + expiresAt: integer("expires_at").notNull(), + resource: text("resource"), + }, + (table) => [ + primaryKey({ columns: [table.tokenHash, table.tokenKind] }), + index("oauth_tokens_expiry_idx").on(table.expiresAt), + ], +); + export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 9bb442f..19a2672 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -10,6 +10,10 @@ import type { OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; +import { + SqliteOAuthStore, + type AuthorizationCodeRecord, +} from "./oauth-store.js"; export interface OAuthConfig { ownerToken: string; @@ -19,28 +23,6 @@ export interface OAuthConfig { allowedRedirectHosts: string[]; } -interface AuthorizationCodeRecord { - clientId: string; - params: AuthorizationParams; - expiresAtMs: number; -} - -interface AccessTokenRecord { - token: string; - clientId: string; - scopes: string[]; - expiresAt: number; - resource?: URL; -} - -interface RefreshTokenRecord { - token: string; - clientId: string; - scopes: string[]; - expiresAt: number; - resource?: URL; -} - const CODE_TTL_MS = 5 * 60 * 1000; function randomToken(): string { @@ -138,13 +120,14 @@ function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boole return allowedHosts.includes(parsed.hostname); } -export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { - private readonly clients = new Map(); - - constructor(private readonly allowedRedirectHosts: string[]) {} +export class SqliteOAuthClientsStore implements OAuthRegisteredClientsStore { + constructor( + private readonly allowedRedirectHosts: string[], + private readonly store: SqliteOAuthStore, + ) {} getClient(clientId: string): OAuthClientInformationFull | undefined { - return this.clients.get(clientId); + return this.store.getClient(clientId); } registerClient( @@ -163,24 +146,24 @@ export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { grant_types: client.grant_types ?? ["authorization_code", "refresh_token"], response_types: client.response_types ?? ["code"], }; - this.clients.set(registered.client_id, registered); + this.store.saveClient(registered); return registered; } } export class SingleUserOAuthProvider implements OAuthServerProvider { readonly clientsStore: OAuthRegisteredClientsStore; - private readonly codes = new Map(); - private readonly accessTokens = new Map(); - private readonly refreshTokens = new Map(); + private readonly store: SqliteOAuthStore; private readonly resourceServerUrl: URL; constructor( private readonly config: OAuthConfig, resourceServerUrl: URL, + stateDir: string, ) { this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); - this.clientsStore = new InMemoryOAuthClientsStore(config.allowedRedirectHosts); + this.store = new SqliteOAuthStore(stateDir); + this.clientsStore = new SqliteOAuthClientsStore(config.allowedRedirectHosts, this.store); } async authorize( @@ -224,7 +207,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { } const code = `code-${randomUUID()}`; - this.codes.set(code, { + this.store.saveAuthorizationCode(hashToken(code), { clientId: client.client_id, params, expiresAtMs: Date.now() + CODE_TTL_MS, @@ -259,7 +242,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { throw new InvalidGrantError("Invalid resource"); } - this.codes.delete(authorizationCode); + this.store.deleteAuthorizationCode(hashToken(authorizationCode)); return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, record.params.resource); } @@ -269,7 +252,8 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { scopes?: string[], resource?: URL, ): Promise { - const record = this.refreshTokens.get(hashToken(refreshToken)); + const refreshTokenHash = hashToken(refreshToken); + const record = this.store.getRefreshToken(refreshTokenHash); if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { throw new InvalidGrantError("Invalid refresh token"); } @@ -282,12 +266,12 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { throw new AccessDeniedError("Refresh token cannot grant requested scopes"); } - this.refreshTokens.delete(hashToken(refreshToken)); + this.store.deleteRefreshToken(refreshTokenHash); return this.issueTokens(client.client_id, requestedScopes, resource ?? record.resource); } async verifyAccessToken(token: string): Promise { - const record = this.accessTokens.get(hashToken(token)); + const record = this.store.getAccessToken(hashToken(token)); if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) { throw new InvalidTokenError("Invalid or expired access token"); } @@ -303,15 +287,18 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { async revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise { const hashed = hashToken(request.token); - this.accessTokens.delete(hashed); - this.refreshTokens.delete(hashed); + this.store.revokeToken(hashed); + } + + close(): void { + this.store.close(); } private validCodeRecord( client: OAuthClientInformationFull, authorizationCode: string, ): AuthorizationCodeRecord { - const record = this.codes.get(authorizationCode); + const record = this.store.getAuthorizationCode(hashToken(authorizationCode)); if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) { throw new InvalidGrantError("Invalid authorization code"); } @@ -325,15 +312,13 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { const accessExpiresAt = now + this.config.accessTokenTtlSeconds; const refreshExpiresAt = now + this.config.refreshTokenTtlSeconds; - this.accessTokens.set(hashToken(accessToken), { - token: accessToken, + this.store.saveAccessToken(hashToken(accessToken), { clientId, scopes, expiresAt: accessExpiresAt, resource, }); - this.refreshTokens.set(hashToken(refreshToken), { - token: refreshToken, + this.store.saveRefreshToken(hashToken(refreshToken), { clientId, scopes, expiresAt: refreshExpiresAt, diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts new file mode 100644 index 0000000..348fc2a --- /dev/null +++ b/src/oauth-store.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openDatabase } from "./db/client.js"; +import { SingleUserOAuthProvider } from "./oauth-provider.js"; +import { SqliteOAuthStore } from "./oauth-store.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-oauth-store-test-")); +const stateDir = join(root, "state"); +const resource = new URL("https://example.test/mcp"); +const redirectUri = "http://localhost/callback"; +const config = { + ownerToken: "test-owner-password-long-enough", + accessTokenTtlSeconds: 60, + refreshTokenTtlSeconds: 120, + scopes: ["devspace"], + allowedRedirectHosts: ["localhost"], +}; + +try { + const firstProvider = new SingleUserOAuthProvider(config, resource, stateDir); + const registerClient = firstProvider.clientsStore.registerClient; + assert.ok(registerClient); + + const client = await registerClient.call(firstProvider.clientsStore, { + client_name: "Persistent OAuth test client", + redirect_uris: [redirectUri], + }); + firstProvider.close(); + + const secondProvider = new SingleUserOAuthProvider(config, resource, stateDir); + assert.deepEqual(await secondProvider.clientsStore.getClient(client.client_id), client); + secondProvider.close(); + + const firstStore = new SqliteOAuthStore(stateDir); + const authorizationCode = "authorization-code-value"; + const accessToken = "access-token-value"; + const refreshToken = "refresh-token-value"; + const authorizationCodeHash = hashValue(authorizationCode); + const accessTokenHash = hashValue(accessToken); + const refreshTokenHash = hashValue(refreshToken); + const now = Math.floor(Date.now() / 1000); + + firstStore.saveAuthorizationCode(authorizationCodeHash, { + clientId: client.client_id, + params: { + redirectUri, + codeChallenge: "challenge-value", + scopes: ["devspace"], + resource, + }, + expiresAtMs: Date.now() + 60_000, + }); + firstStore.saveAccessToken(accessTokenHash, { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: now + 60, + resource, + }); + firstStore.saveRefreshToken(refreshTokenHash, { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: now + 120, + resource, + }); + firstStore.close(); + + const secondStore = new SqliteOAuthStore(stateDir); + assert.equal(secondStore.getAuthorizationCode(authorizationCodeHash)?.clientId, client.client_id); + assert.equal( + secondStore.getAuthorizationCode(authorizationCodeHash)?.params.resource?.href, + resource.href, + ); + assert.equal(secondStore.getAccessToken(accessTokenHash)?.clientId, client.client_id); + assert.equal(secondStore.getAccessToken(accessTokenHash)?.resource?.href, resource.href); + assert.equal(secondStore.getRefreshToken(refreshTokenHash)?.clientId, client.client_id); + assert.equal(secondStore.getRefreshToken(refreshTokenHash)?.resource?.href, resource.href); + + secondStore.saveAuthorizationCode("expired-code-hash", { + clientId: client.client_id, + params: { + redirectUri, + codeChallenge: "expired-challenge", + scopes: ["devspace"], + resource, + }, + expiresAtMs: Date.now() - 1, + }); + secondStore.saveAccessToken("expired-access-hash", { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: now - 1, + }); + secondStore.saveRefreshToken("expired-refresh-hash", { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: now - 1, + }); + assert.equal(secondStore.getAuthorizationCode("expired-code-hash"), undefined); + assert.equal(secondStore.getAccessToken("expired-access-hash"), undefined); + assert.equal(secondStore.getRefreshToken("expired-refresh-hash"), undefined); + + secondStore.saveAccessToken("shared-hash", { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: now + 60, + }); + secondStore.saveRefreshToken("shared-hash", { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: now + 60, + }); + secondStore.revokeToken("shared-hash"); + assert.equal(secondStore.getAccessToken("shared-hash"), undefined); + assert.equal(secondStore.getRefreshToken("shared-hash"), undefined); + secondStore.close(); + + const database = openDatabase(stateDir); + try { + const storedCode = database.sqlite + .prepare("select code_hash from oauth_authorization_codes where code_hash = ?") + .get(authorizationCodeHash) as { code_hash: string } | undefined; + const storedTokens = database.sqlite + .prepare("select token_hash from oauth_tokens where token_hash in (?, ?)") + .all(accessTokenHash, refreshTokenHash) as Array<{ token_hash: string }>; + + assert.equal(storedCode?.code_hash, authorizationCodeHash); + assert.notEqual(storedCode?.code_hash, authorizationCode); + assert.equal(storedTokens.some((row) => row.token_hash === accessToken), false); + assert.equal(storedTokens.some((row) => row.token_hash === refreshToken), false); + } finally { + database.close(); + } +} finally { + await rm(root, { recursive: true, force: true }); +} + +function hashValue(value: string): string { + return createHash("sha256").update(value).digest("base64url"); +} diff --git a/src/oauth-store.ts b/src/oauth-store.ts new file mode 100644 index 0000000..e936783 --- /dev/null +++ b/src/oauth-store.ts @@ -0,0 +1,203 @@ +import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; +import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; + +export interface AuthorizationCodeRecord { + clientId: string; + params: AuthorizationParams; + expiresAtMs: number; +} + +export interface TokenRecord { + clientId: string; + scopes: string[]; + expiresAt: number; + resource?: URL; +} + +type TokenKind = "access" | "refresh"; + +interface SerializedAuthorizationParams extends Omit { + resource?: string; +} + +export class SqliteOAuthStore { + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + this.migrate(); + this.deleteExpired(); + } + + getClient(clientId: string): OAuthClientInformationFull | undefined { + const row = this.database.sqlite + .prepare("select client_json from oauth_clients where client_id = ?") + .get(clientId) as { client_json: string } | undefined; + return row ? (JSON.parse(row.client_json) as OAuthClientInformationFull) : undefined; + } + + saveClient(client: OAuthClientInformationFull): void { + this.database.sqlite + .prepare("insert into oauth_clients (client_id, client_json, created_at) values (?, ?, ?)") + .run(client.client_id, JSON.stringify(client), client.client_id_issued_at); + } + + getAuthorizationCode(codeHash: string): AuthorizationCodeRecord | undefined { + const row = this.database.sqlite + .prepare("select client_id, params_json, expires_at_ms from oauth_authorization_codes where code_hash = ?") + .get(codeHash) as { + client_id: string; + params_json: string; + expires_at_ms: number; + } | undefined; + if (!row) return undefined; + if (row.expires_at_ms < Date.now()) { + this.deleteAuthorizationCode(codeHash); + return undefined; + } + return { + clientId: row.client_id, + params: deserializeAuthorizationParams(row.params_json), + expiresAtMs: row.expires_at_ms, + }; + } + + saveAuthorizationCode(codeHash: string, record: AuthorizationCodeRecord): void { + this.database.sqlite + .prepare("insert or replace into oauth_authorization_codes (code_hash, client_id, params_json, expires_at_ms) values (?, ?, ?, ?)") + .run(codeHash, record.clientId, serializeAuthorizationParams(record.params), record.expiresAtMs); + } + + deleteAuthorizationCode(codeHash: string): void { + this.database.sqlite + .prepare("delete from oauth_authorization_codes where code_hash = ?") + .run(codeHash); + } + + getAccessToken(tokenHash: string): TokenRecord | undefined { + return this.getToken("access", tokenHash); + } + + saveAccessToken(tokenHash: string, record: TokenRecord): void { + this.saveToken("access", tokenHash, record); + } + + deleteAccessToken(tokenHash: string): void { + this.deleteToken("access", tokenHash); + } + + getRefreshToken(tokenHash: string): TokenRecord | undefined { + return this.getToken("refresh", tokenHash); + } + + saveRefreshToken(tokenHash: string, record: TokenRecord): void { + this.saveToken("refresh", tokenHash, record); + } + + deleteRefreshToken(tokenHash: string): void { + this.deleteToken("refresh", tokenHash); + } + + revokeToken(tokenHash: string): void { + this.database.sqlite + .prepare("delete from oauth_tokens where token_hash = ?") + .run(tokenHash); + } + + close(): void { + this.database.close(); + } + + private migrate(): void { + this.database.sqlite.exec(` + create table if not exists oauth_clients ( + client_id text primary key, + client_json text not null, + created_at integer not null + ); + create table if not exists oauth_authorization_codes ( + code_hash text primary key, + client_id text not null, + params_json text not null, + expires_at_ms integer not null, + foreign key (client_id) references oauth_clients(client_id) on delete cascade + ); + create index if not exists oauth_authorization_codes_expiry_idx + on oauth_authorization_codes(expires_at_ms); + create table if not exists oauth_tokens ( + token_hash text not null, + token_kind text not null, + client_id text not null, + scopes_json text not null, + expires_at integer not null, + resource text, + primary key (token_hash, token_kind), + foreign key (client_id) references oauth_clients(client_id) on delete cascade + ); + create index if not exists oauth_tokens_expiry_idx on oauth_tokens(expires_at); + `); + } + + private deleteExpired(): void { + this.database.sqlite + .prepare("delete from oauth_authorization_codes where expires_at_ms < ?") + .run(Date.now()); + this.database.sqlite + .prepare("delete from oauth_tokens where expires_at < ?") + .run(Math.floor(Date.now() / 1000)); + } + + private getToken(kind: TokenKind, tokenHash: string): TokenRecord | undefined { + const row = this.database.sqlite + .prepare("select client_id, scopes_json, expires_at, resource from oauth_tokens where token_hash = ? and token_kind = ?") + .get(tokenHash, kind) as { + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; + } | undefined; + if (!row) return undefined; + if (row.expires_at < Math.floor(Date.now() / 1000)) { + this.deleteToken(kind, tokenHash); + return undefined; + } + return { + clientId: row.client_id, + scopes: JSON.parse(row.scopes_json) as string[], + expiresAt: row.expires_at, + resource: row.resource ? new URL(row.resource) : undefined, + }; + } + + private saveToken(kind: TokenKind, tokenHash: string, record: TokenRecord): void { + this.database.sqlite + .prepare("insert or replace into oauth_tokens (token_hash, token_kind, client_id, scopes_json, expires_at, resource) values (?, ?, ?, ?, ?, ?)") + .run( + tokenHash, + kind, + record.clientId, + JSON.stringify(record.scopes), + record.expiresAt, + record.resource?.href ?? null, + ); + } + + private deleteToken(kind: TokenKind, tokenHash: string): void { + this.database.sqlite + .prepare("delete from oauth_tokens where token_hash = ? and token_kind = ?") + .run(tokenHash, kind); + } +} + +function serializeAuthorizationParams(params: AuthorizationParams): string { + return JSON.stringify({ ...params, resource: params.resource?.href }); +} + +function deserializeAuthorizationParams(value: string): AuthorizationParams { + const parsed = JSON.parse(value) as SerializedAuthorizationParams; + return { + ...parsed, + resource: parsed.resource ? new URL(parsed.resource) : undefined, + }; +} diff --git a/src/server.ts b/src/server.ts index 9c554dc..5d3a0c4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -65,6 +65,7 @@ const SHELL_TOOL_ANNOTATIONS = { interface RunningServer { app: ReturnType; config: ServerConfig; + close(): void; } type ToolContent = @@ -1275,7 +1276,7 @@ export function createServer(config = loadConfig()): RunningServer { const transports = new Map(); const mcpUrl = new URL("/mcp", config.publicBaseUrl); const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl); - const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl); + const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir); const bearerAuth = requireBearerAuth({ verifier: oauthProvider, requiredScopes: [config.oauth.scopes[0] ?? "devspace"], @@ -1426,7 +1427,14 @@ export function createServer(config = loadConfig()): RunningServer { } }); - return { app, config }; + return { + app, + config, + close: () => { + oauthProvider.close(); + workspaceStore.close?.(); + }, + }; } async function isMainModule(): Promise { @@ -1438,8 +1446,8 @@ async function isMainModule(): Promise { } if (await isMainModule()) { - const { app, config } = createServer(); - app.listen(config.port, config.host, () => { + const { app, config, close } = createServer(); + const httpServer = app.listen(config.port, config.host, () => { console.log( `devspace listening on http://${config.host}:${config.port}/mcp`, ); @@ -1450,4 +1458,13 @@ if (await isMainModule()) { console.log(`asset logging: ${config.logging.assets ? "enabled" : "disabled"}`); console.log(`trust proxy: ${config.logging.trustProxy ? "enabled" : "disabled"}`); }); + + const shutdown = () => { + httpServer.close(() => { + close(); + process.exit(0); + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); } diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c51..e682cd6 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -90,7 +90,8 @@ export function generateOwnerToken(): string { function readJsonFile(filePath: string): T { try { - return JSON.parse(readFileSync(filePath, "utf8")) as T; + const content = readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""); + return JSON.parse(content) as T; } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`Unable to read ${filePath}: ${reason}`);