fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation
This commit is contained in:
parent
1b8fe445f3
commit
9aa7302944
30 changed files with 2614 additions and 386 deletions
101
apps/desktop/src/main/services/sync/session-token-store.ts
Normal file
101
apps/desktop/src/main/services/sync/session-token-store.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// src/main/services/sync/session-token-store.ts
|
||||
// refresh token 영속화(OS 암호화 저장소 + 파일). Supabase·Electron을 직접 알지 않도록 포트로 받는다.
|
||||
//
|
||||
// supabase-js는 메인 프로세스에서 refresh token을 약 1시간마다 회전시킨다. 회전된 토큰을 저장하지 않으면
|
||||
// 재시작 때 이미 쓰인 토큰으로 복원하다 로그아웃된다 — bindAuthEvents 가 회전 이벤트마다 저장한다.
|
||||
|
||||
export interface TokenCipher {
|
||||
isEncryptionAvailable(): boolean
|
||||
encryptString(plain: string): Buffer
|
||||
decryptString(encrypted: Buffer): string
|
||||
}
|
||||
|
||||
export interface TokenFileSystem {
|
||||
existsSync(path: string): boolean
|
||||
readFileSync(path: string): Buffer
|
||||
writeFileSync(path: string, data: Buffer): void
|
||||
unlinkSync(path: string): void
|
||||
}
|
||||
|
||||
export interface SessionTokenStoreLogger {
|
||||
warn(message: string): void
|
||||
}
|
||||
|
||||
export interface SessionTokenStore {
|
||||
load(): string | null
|
||||
save(refreshToken: string): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
export class EncryptedFileTokenStore implements SessionTokenStore {
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly cipher: TokenCipher,
|
||||
private readonly fs: TokenFileSystem,
|
||||
private readonly logger: SessionTokenStoreLogger
|
||||
) {}
|
||||
|
||||
load(): string | null {
|
||||
try {
|
||||
if (!this.fs.existsSync(this.filePath)) return null
|
||||
if (!this.cipher.isEncryptionAvailable()) return null
|
||||
return this.cipher.decryptString(this.fs.readFileSync(this.filePath))
|
||||
} catch (err) {
|
||||
this.logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
save(refreshToken: string): void {
|
||||
try {
|
||||
if (!refreshToken) return
|
||||
if (this.cipher.isEncryptionAvailable()) {
|
||||
this.fs.writeFileSync(this.filePath, this.cipher.encryptString(refreshToken))
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
try {
|
||||
if (this.fs.existsSync(this.filePath)) this.fs.unlinkSync(this.filePath)
|
||||
} catch {
|
||||
// 지울 파일이 없거나 잠겨 있어도 로그아웃은 계속한다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** supabase auth 이벤트 중 세션 회전을 뜻하는 것 */
|
||||
const ROTATION_EVENTS = new Set(['TOKEN_REFRESHED', 'SIGNED_IN', 'USER_UPDATED'])
|
||||
|
||||
export interface AuthSessionLike {
|
||||
refresh_token: string
|
||||
user: { id: string }
|
||||
}
|
||||
|
||||
export type AuthStateSubscribe<S extends AuthSessionLike> = (
|
||||
callback: (event: string, session: S | null) => void
|
||||
) => { unsubscribe(): void }
|
||||
|
||||
/**
|
||||
* auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다.
|
||||
* - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시).
|
||||
* - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다.
|
||||
* onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다.
|
||||
*/
|
||||
export function bindAuthEvents<S extends AuthSessionLike>(
|
||||
subscribe: AuthStateSubscribe<S>,
|
||||
store: SessionTokenStore,
|
||||
activeUserId: () => string | null,
|
||||
onSession: (session: S) => void
|
||||
): () => void {
|
||||
const subscription = subscribe((event, session) => {
|
||||
if (!session || !ROTATION_EVENTS.has(event)) return
|
||||
const userId = activeUserId()
|
||||
if (!userId || session.user.id !== userId) return
|
||||
store.save(session.refresh_token)
|
||||
onSession(session)
|
||||
})
|
||||
return () => subscription.unsubscribe()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue