opensoma 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/package.json +18 -2
  2. package/dist/src/client.d.ts +8 -0
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +123 -21
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +8 -0
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +35 -23
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/commands/dashboard.d.ts.map +1 -1
  11. package/dist/src/commands/dashboard.js +1 -1
  12. package/dist/src/commands/dashboard.js.map +1 -1
  13. package/dist/src/commands/event.d.ts.map +1 -1
  14. package/dist/src/commands/event.js.map +1 -1
  15. package/dist/src/commands/helpers.d.ts.map +1 -1
  16. package/dist/src/commands/helpers.js +12 -2
  17. package/dist/src/commands/helpers.js.map +1 -1
  18. package/dist/src/commands/member.d.ts.map +1 -1
  19. package/dist/src/commands/member.js.map +1 -1
  20. package/dist/src/commands/mentoring.d.ts.map +1 -1
  21. package/dist/src/commands/mentoring.js +14 -5
  22. package/dist/src/commands/mentoring.js.map +1 -1
  23. package/dist/src/commands/notice.d.ts.map +1 -1
  24. package/dist/src/commands/notice.js.map +1 -1
  25. package/dist/src/commands/room.d.ts.map +1 -1
  26. package/dist/src/commands/room.js.map +1 -1
  27. package/dist/src/commands/team.d.ts.map +1 -1
  28. package/dist/src/commands/team.js.map +1 -1
  29. package/dist/src/errors.d.ts +8 -0
  30. package/dist/src/errors.d.ts.map +1 -0
  31. package/dist/src/errors.js +11 -0
  32. package/dist/src/errors.js.map +1 -0
  33. package/dist/src/formatters.d.ts.map +1 -1
  34. package/dist/src/formatters.js +54 -7
  35. package/dist/src/formatters.js.map +1 -1
  36. package/dist/src/http.d.ts +5 -0
  37. package/dist/src/http.d.ts.map +1 -1
  38. package/dist/src/http.js +140 -6
  39. package/dist/src/http.js.map +1 -1
  40. package/dist/src/index.d.ts +1 -0
  41. package/dist/src/index.d.ts.map +1 -1
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/index.js.map +1 -1
  44. package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -1
  45. package/dist/src/shared/utils/mentoring-params.js +4 -1
  46. package/dist/src/shared/utils/mentoring-params.js.map +1 -1
  47. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  48. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  49. package/dist/src/token-extractor.d.ts +12 -0
  50. package/dist/src/token-extractor.d.ts.map +1 -1
  51. package/dist/src/token-extractor.js +81 -18
  52. package/dist/src/token-extractor.js.map +1 -1
  53. package/dist/src/types.d.ts +17 -0
  54. package/dist/src/types.d.ts.map +1 -1
  55. package/dist/src/types.js +6 -0
  56. package/dist/src/types.js.map +1 -1
  57. package/package.json +17 -1
  58. package/src/client.test.ts +112 -12
  59. package/src/client.ts +136 -36
  60. package/src/commands/auth.test.ts +55 -0
  61. package/src/commands/auth.ts +57 -33
  62. package/src/commands/dashboard.ts +5 -6
  63. package/src/commands/event.ts +5 -6
  64. package/src/commands/helpers.ts +21 -4
  65. package/src/commands/member.ts +4 -5
  66. package/src/commands/mentoring.ts +36 -19
  67. package/src/commands/notice.ts +4 -5
  68. package/src/commands/room.ts +4 -5
  69. package/src/commands/team.ts +4 -5
  70. package/src/credential-manager.test.ts +1 -1
  71. package/src/credential-manager.ts +1 -1
  72. package/src/errors.ts +10 -0
  73. package/src/formatters.test.ts +1 -1
  74. package/src/formatters.ts +91 -18
  75. package/src/http.test.ts +43 -7
  76. package/src/http.ts +174 -8
  77. package/src/index.ts +1 -0
  78. package/src/shared/utils/mentoring-params.test.ts +9 -4
  79. package/src/shared/utils/mentoring-params.ts +6 -3
  80. package/src/shared/utils/swmaestro.ts +2 -2
  81. package/src/token-extractor.test.ts +46 -8
  82. package/src/token-extractor.ts +115 -22
  83. package/src/types.test.ts +4 -2
  84. package/src/types.ts +6 -0
  85. package/.claude-plugin/README.md +0 -145
  86. package/.claude-plugin/plugin.json +0 -23
  87. package/.github/workflows/release.yml +0 -86
  88. package/.oxfmtrc.json +0 -9
  89. package/.oxlintrc.json +0 -4
  90. package/AGENTS.md +0 -78
  91. package/README.md +0 -252
  92. package/bun.lock +0 -297
  93. package/bunfig.toml +0 -2
  94. package/e2e/.gitkeep +0 -0
  95. package/skills/opensoma/SKILL.md +0 -345
  96. package/skills/opensoma/references/common-patterns.md +0 -182
  97. package/skills/opensoma/references/output-format.md +0 -130
package/src/http.ts CHANGED
@@ -1,9 +1,16 @@
1
- import { BASE_URL, MENU_NO } from '@/constants'
2
- import { parseCsrfToken } from '@/formatters'
1
+ import { BASE_URL, MENU_NO } from './constants'
2
+ import { AuthenticationError } from './errors'
3
+ import { parseCsrfToken } from './formatters'
4
+
5
+ const DEFAULT_USER_AGENT =
6
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'
7
+ const DEFAULT_ACCEPT_LANGUAGE = 'ko,en-US;q=0.9,en;q=0.8'
3
8
 
4
9
  interface RequestOptions {
5
10
  sessionCookie?: string
11
+ cookies?: string
6
12
  csrfToken?: string
13
+ verbose?: boolean
7
14
  }
8
15
 
9
16
  interface CheckLoginResponse {
@@ -23,11 +30,17 @@ interface HeadersWithCookieHelpers extends Omit<Headers, 'getSetCookie'> {
23
30
  export class SomaHttp {
24
31
  private cookies = new Map<string, string>()
25
32
  private csrfToken: string | null
33
+ private verbose: boolean
26
34
 
27
35
  constructor(options?: RequestOptions) {
28
36
  this.csrfToken = options?.csrfToken ?? null
37
+ this.verbose = options?.verbose ?? false
29
38
 
30
- if (options?.sessionCookie) {
39
+ if (options?.cookies) {
40
+ for (const cookie of options.cookies.split(';')) {
41
+ this.setCookie(cookie.trim())
42
+ }
43
+ } else if (options?.sessionCookie) {
31
44
  this.setCookie(
32
45
  options.sessionCookie.includes('=') ? options.sessionCookie : `JSESSIONID=${options.sessionCookie}`,
33
46
  )
@@ -41,22 +54,148 @@ export class SomaHttp {
41
54
  })
42
55
 
43
56
  this.updateFromResponse(response)
44
- return response.text()
57
+ const body = await response.text()
58
+
59
+ const errorInfo = this.extractErrorFromResponse(body, null, path)
60
+ if (errorInfo === '__AUTH_ERROR__') {
61
+ throw new AuthenticationError()
62
+ }
63
+
64
+ return body
45
65
  }
46
66
 
47
67
  async post(path: string, body: Record<string, string>): Promise<string> {
68
+ const url = this.buildUrl(path)
48
69
  const formBody = new URLSearchParams(this.buildBody(body))
49
- const response = await fetch(this.buildUrl(path), {
70
+
71
+ let response = await fetch(url, {
50
72
  method: 'POST',
51
73
  headers: {
52
74
  ...this.buildHeaders(),
53
75
  'Content-Type': 'application/x-www-form-urlencoded',
54
76
  },
55
77
  body: formBody.toString(),
78
+ redirect: 'manual',
56
79
  })
57
80
 
58
81
  this.updateFromResponse(response)
59
- return response.text()
82
+
83
+ if (response.status >= 300 && response.status < 400) {
84
+ const location = response.headers.get('location')
85
+ const intermediateBody = await response.clone().text()
86
+ const errorInfo = this.extractErrorFromResponse(intermediateBody, location, path)
87
+ if (errorInfo) {
88
+ if (errorInfo === '__AUTH_ERROR__') {
89
+ throw new AuthenticationError()
90
+ }
91
+ throw new Error(errorInfo)
92
+ }
93
+ }
94
+
95
+ let finalUrl = url
96
+ while (response.status >= 300 && response.status < 400) {
97
+ const location = response.headers.get('location')
98
+ if (!location) break
99
+
100
+ const redirectUrl = location.startsWith('http') ? location : new URL(location, `${BASE_URL}/`).toString()
101
+ finalUrl = redirectUrl
102
+ response = await fetch(redirectUrl, {
103
+ method: 'GET',
104
+ headers: this.buildHeaders(),
105
+ redirect: 'manual',
106
+ })
107
+ this.updateFromResponse(response)
108
+ }
109
+
110
+ if (!response.ok) {
111
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
112
+ }
113
+
114
+ const finalBody = await response.text()
115
+ this.log('POST', path, '-> Final URL:', finalUrl)
116
+ this.log('POST', path, '-> Response body (first 200 chars):', finalBody.slice(0, 200))
117
+
118
+ // Use final URL path for auth check, not original path (login flow redirects to main page)
119
+ const finalPath = new URL(finalUrl).pathname
120
+ const errorInfo = this.extractErrorFromResponse(finalBody, null, finalPath)
121
+ if (errorInfo) {
122
+ this.log('POST', path, '-> Error detected:', errorInfo)
123
+ if (errorInfo === '__AUTH_ERROR__') {
124
+ throw new AuthenticationError()
125
+ }
126
+ throw new Error(errorInfo)
127
+ }
128
+
129
+ if (finalPath.includes('insertForm') || finalPath.includes('error') || finalPath.includes('fail')) {
130
+ this.log('POST', path, '-> Suspicious final URL:', finalUrl)
131
+ throw new Error('멘토링 등록에 실패했습니다.')
132
+ }
133
+
134
+ return finalBody
135
+ }
136
+
137
+ private extractErrorFromResponse(body: string, location: string | null, path?: string): string | null {
138
+ this.log(
139
+ 'extractErrorFromResponse',
140
+ path,
141
+ 'body length:',
142
+ body.length,
143
+ 'title:',
144
+ body.match(/<title>([^<]*)<\/title>/)?.[1],
145
+ )
146
+
147
+ const alertMatch = body.match(/<script>\s*alert\(['"](.+?)['"]\)\s*<\/script>/)
148
+ if (alertMatch) {
149
+ this.log('Found alert match:', alertMatch[1])
150
+ return alertMatch[1]
151
+ }
152
+
153
+ const titleMatch = body.match(/<title>([^<]*)<\/title>/i)
154
+ const pageTitle = titleMatch?.[1] ?? ''
155
+
156
+ // Check for login page - server returns login page HTML (with login form) when session is invalid
157
+ // The login page has both the SW마에스트로 title AND a login form with username/password inputs
158
+ // Skip this check during login flow (forLogin = GET for CSRF, toLogin = POST credentials)
159
+ const isLoginPath = path?.includes('/member/user/forLogin') || path?.includes('/member/user/toLogin')
160
+ const hasUsername = body.includes('name="username"')
161
+ const hasPassword = body.includes('name="password"')
162
+ if (
163
+ !isLoginPath &&
164
+ hasUsername &&
165
+ hasPassword &&
166
+ (pageTitle.includes('AI·SW마에스트로') || pageTitle.includes('SW마에스트로'))
167
+ ) {
168
+ return '__AUTH_ERROR__'
169
+ }
170
+
171
+ const errorTitleMatch = body.match(/<title>(에러안내|오류|Error)[^<]*<\/title>/i)
172
+ if (errorTitleMatch) {
173
+ this.log('Found error title match')
174
+ const msgVarMatch = body.match(/var\s+msg\s*=\s*['"](.+?)['"];/)
175
+ if (msgVarMatch) {
176
+ this.log('Found msg var:', msgVarMatch[1].slice(0, 100))
177
+ return msgVarMatch[1]
178
+ }
179
+ const errorPatterns = [
180
+ '등록에 실패',
181
+ '저장에 실패',
182
+ '오류가 발생',
183
+ '실패하였습니다',
184
+ '잘못된 접근',
185
+ '권한이 없습니다',
186
+ 'SQLException',
187
+ 'Error updating database',
188
+ ]
189
+ for (const pattern of errorPatterns) {
190
+ if (body.includes(pattern)) {
191
+ this.log('Found error pattern:', pattern)
192
+ return '멘토링 등록에 실패했습니다: ' + pattern
193
+ }
194
+ }
195
+ return '에러가 발생했습니다'
196
+ }
197
+
198
+ return null
60
199
  }
61
200
 
62
201
  async postJson<T>(path: string, body: Record<string, string>): Promise<T> {
@@ -78,13 +217,30 @@ export class SomaHttp {
78
217
  async login(username: string, password: string): Promise<void> {
79
218
  const csrfToken = await this.extractCsrfToken()
80
219
 
81
- await this.post('/member/user/toLogin.do', {
220
+ const html = await this.post('/member/user/toLogin.do', {
82
221
  username,
83
222
  password,
84
223
  csrfToken,
85
224
  loginFlag: '',
86
225
  menuNo: MENU_NO.LOGIN,
87
226
  })
227
+
228
+ // SWMaestro returns an intermediate form that auto-submits via JS:
229
+ // <form action="/sw/login.do"><input name="password" value="bcrypt_hash"/>...
230
+ // We need to parse and submit this form to complete authentication.
231
+ const actionMatch = html.match(/action=["']([^"']+)["']/)
232
+ const fields: Record<string, string> = {}
233
+
234
+ // Match name="..." or name='...' followed by value="..." or value='...'
235
+ // Uses backreferences (\1 and \3) to match the same quote type for closing
236
+ for (const match of html.matchAll(/name=(['"])([^'"]+)\1\s+value=(['"])(.*?)\3/g)) {
237
+ fields[match[2]] = match[4]
238
+ }
239
+
240
+ if (actionMatch?.[1] && Object.keys(fields).length > 0) {
241
+ const action = actionMatch[1].replace(/^\/sw/, '')
242
+ await this.post(action, fields)
243
+ }
88
244
  }
89
245
 
90
246
  async checkLogin(): Promise<UserIdentity | null> {
@@ -141,7 +297,11 @@ export class SomaHttp {
141
297
 
142
298
  private buildHeaders(): Record<string, string> {
143
299
  const cookieHeader = this.serializeCookies()
144
- return cookieHeader ? { cookie: cookieHeader } : {}
300
+ return {
301
+ 'Accept-Language': DEFAULT_ACCEPT_LANGUAGE,
302
+ 'User-Agent': DEFAULT_USER_AGENT,
303
+ ...(cookieHeader ? { cookie: cookieHeader } : {}),
304
+ }
145
305
  }
146
306
 
147
307
  private buildBody(body: Record<string, string>): Record<string, string> {
@@ -193,4 +353,10 @@ export class SomaHttp {
193
353
  private serializeCookies(): string {
194
354
  return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
195
355
  }
356
+
357
+ private log(...args: unknown[]): void {
358
+ if (this.verbose) {
359
+ console.log('[opensoma]', ...args)
360
+ }
361
+ }
196
362
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { SomaClient } from './client'
2
2
  export type { SomaClientOptions } from './client'
3
+ export { AuthenticationError } from './errors'
3
4
  export { SomaHttp } from './http'
4
5
  export { CredentialManager } from './credential-manager'
5
6
  export * from './types'
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
 
3
- import { MENU_NO, REPORT_CD } from '@/constants'
4
-
3
+ import { MENU_NO, REPORT_CD } from '../../constants'
5
4
  import { buildMentoringListParams, parseSearchQuery } from './mentoring-params'
6
5
 
7
6
  describe('parseSearchQuery', () => {
@@ -40,7 +39,10 @@ describe('buildMentoringListParams', () => {
40
39
  })
41
40
 
42
41
  test('status maps open to A, closed to C', () => {
43
- expect(buildMentoringListParams({ status: 'open' })).toEqual({ menuNo: MENU_NO.MENTORING, searchStatMentolec: 'A' })
42
+ expect(buildMentoringListParams({ status: 'open' })).toEqual({
43
+ menuNo: MENU_NO.MENTORING,
44
+ searchStatMentolec: 'A',
45
+ })
44
46
  expect(buildMentoringListParams({ status: 'closed' })).toEqual({
45
47
  menuNo: MENU_NO.MENTORING,
46
48
  searchStatMentolec: 'C',
@@ -107,6 +109,9 @@ describe('buildMentoringListParams', () => {
107
109
  })
108
110
 
109
111
  test('page sets pageIndex', () => {
110
- expect(buildMentoringListParams({ page: 3 })).toEqual({ menuNo: MENU_NO.MENTORING, pageIndex: '3' })
112
+ expect(buildMentoringListParams({ page: 3 })).toEqual({
113
+ menuNo: MENU_NO.MENTORING,
114
+ pageIndex: '3',
115
+ })
111
116
  })
112
117
  })
@@ -1,8 +1,11 @@
1
- import { MENU_NO, REPORT_CD } from '@/constants'
2
- import type { UserIdentity } from '@/http'
1
+ import { MENU_NO, REPORT_CD } from '../../constants'
2
+ import type { UserIdentity } from '../../http'
3
3
 
4
4
  const STATUS_MAP: Record<string, string> = { open: 'A', closed: 'C' }
5
- const TYPE_MAP: Record<string, string> = { public: REPORT_CD.PUBLIC_MENTORING, lecture: REPORT_CD.MENTOR_LECTURE }
5
+ const TYPE_MAP: Record<string, string> = {
6
+ public: REPORT_CD.PUBLIC_MENTORING,
7
+ lecture: REPORT_CD.MENTOR_LECTURE,
8
+ }
6
9
  const SEARCH_FIELD_MAP: Record<string, string> = { title: '1', author: '2', content: '3' }
7
10
 
8
11
  export interface MentoringSearchQuery {
@@ -1,7 +1,7 @@
1
1
  import { parse } from 'node-html-parser'
2
2
 
3
- import { MENU_NO, REPORT_CD, ROOM_IDS, TIME_SLOTS } from '@/constants'
4
- import { ApplicationHistoryItemSchema, type ApplicationHistoryItem } from '@/types'
3
+ import { MENU_NO, REPORT_CD, ROOM_IDS, TIME_SLOTS } from '../../constants'
4
+ import { ApplicationHistoryItemSchema, type ApplicationHistoryItem } from '../../types'
5
5
 
6
6
  export function toReportCd(type: 'public' | 'lecture'): string {
7
7
  return type === 'lecture' ? REPORT_CD.MENTOR_LECTURE : REPORT_CD.PUBLIC_MENTORING
@@ -1,3 +1,4 @@
1
+ import { Database } from 'bun:sqlite'
1
2
  import { afterEach, describe, expect, test } from 'bun:test'
2
3
  import { execSync } from 'node:child_process'
3
4
  import { createCipheriv, pbkdf2Sync } from 'node:crypto'
@@ -5,7 +6,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
5
6
  import { mkdtemp } from 'node:fs/promises'
6
7
  import { tmpdir } from 'node:os'
7
8
  import { dirname, join } from 'node:path'
8
- import { Database } from 'bun:sqlite'
9
9
 
10
10
  import { BROWSERS, TokenExtractor } from './token-extractor'
11
11
 
@@ -34,9 +34,7 @@ describe('TokenExtractor', () => {
34
34
 
35
35
  expect(paths).toHaveLength(BROWSERS.length)
36
36
  for (const browser of BROWSERS) {
37
- expect(paths).toContainEqual(
38
- join(home, 'Library', 'Application Support', browser.macPath, 'Default', 'Cookies'),
39
- )
37
+ expect(paths).toContainEqual(join(home, 'Library', 'Application Support', browser.macPath, 'Default', 'Cookies'))
40
38
  }
41
39
  })
42
40
 
@@ -67,6 +65,45 @@ describe('TokenExtractor', () => {
67
65
  expect(await extractor.extract()).toEqual({ sessionCookie: 'my-session-id' })
68
66
  })
69
67
 
68
+ test('finds cookie databases across numbered browser profiles', async () => {
69
+ const home = await makeTempDir()
70
+ createCookieFile(join(home, '.config', 'google-chrome', 'Default', 'Cookies'))
71
+ createCookieFile(join(home, '.config', 'google-chrome', 'Profile 1', 'Cookies'))
72
+ createCookieFile(join(home, '.config', 'google-chrome', 'Profile 2', 'Cookies'))
73
+
74
+ const extractor = new TokenExtractor('linux', home)
75
+
76
+ expect(extractor.findCookieDatabases()).toEqual([
77
+ join(home, '.config', 'google-chrome', 'Default', 'Cookies'),
78
+ join(home, '.config', 'google-chrome', 'Profile 1', 'Cookies'),
79
+ join(home, '.config', 'google-chrome', 'Profile 2', 'Cookies'),
80
+ ])
81
+ })
82
+
83
+ test('extractCandidates keeps the newest unique cookie across profiles', async () => {
84
+ const home = await makeTempDir()
85
+ createCookieDbWithPlaintext(join(home, '.config', 'google-chrome', 'Default', 'Cookies'), 'stale-session', 10)
86
+ createCookieDbWithPlaintext(join(home, '.config', 'google-chrome', 'Profile 1', 'Cookies'), 'valid-session', 20)
87
+ createCookieDbWithPlaintext(join(home, '.config', 'google-chrome', 'Profile 2', 'Cookies'), 'stale-session', 30)
88
+
89
+ const extractor = new TokenExtractor('linux', home)
90
+
91
+ await expect(extractor.extractCandidates()).resolves.toEqual([
92
+ {
93
+ browser: 'Chrome',
94
+ lastAccessUtc: 30,
95
+ profile: 'Profile 2',
96
+ sessionCookie: 'stale-session',
97
+ },
98
+ {
99
+ browser: 'Chrome',
100
+ lastAccessUtc: 20,
101
+ profile: 'Profile 1',
102
+ sessionCookie: 'valid-session',
103
+ },
104
+ ])
105
+ })
106
+
70
107
  test('decrypts encrypted cookie on Linux', async () => {
71
108
  const home = await makeTempDir()
72
109
  const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
@@ -111,15 +148,16 @@ function createCookieFile(filePath: string): void {
111
148
  writeFileSync(filePath, '')
112
149
  }
113
150
 
114
- function createCookieDbWithPlaintext(filePath: string, value: string): void {
151
+ function createCookieDbWithPlaintext(filePath: string, value: string, lastAccessUtc = 0): void {
115
152
  mkdirSync(dirname(filePath), { recursive: true })
116
153
  const db = new Database(filePath)
117
154
  db.run(
118
155
  'CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, creation_utc INTEGER, expires_utc INTEGER, is_httponly INTEGER, has_expires INTEGER, is_persistent INTEGER, priority INTEGER, samesite INTEGER, source_scheme INTEGER, is_secure INTEGER, path TEXT, last_access_utc INTEGER, last_update_utc INTEGER, source_port INTEGER, source_type INTEGER)',
119
156
  )
120
- db.run("INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES ('swmaestro.ai', 'JSESSIONID', ?, '')", [
121
- value,
122
- ])
157
+ db.run(
158
+ "INSERT INTO cookies (host_key, name, value, encrypted_value, last_access_utc) VALUES ('swmaestro.ai', 'JSESSIONID', ?, '', ?)",
159
+ [value, lastAccessUtc],
160
+ )
123
161
  db.close()
124
162
  }
125
163
 
@@ -1,16 +1,17 @@
1
1
  import { execSync } from 'node:child_process'
2
2
  import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
3
- import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'
3
+ import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
4
4
  import { createRequire } from 'node:module'
5
5
  import { homedir, tmpdir } from 'node:os'
6
- import { join } from 'node:path'
6
+ import { basename, dirname, join } from 'node:path'
7
7
 
8
8
  const require = createRequire(import.meta.url)
9
9
 
10
10
  const COOKIE_QUERY =
11
- "SELECT encrypted_value, value FROM cookies WHERE host_key LIKE '%swmaestro.ai' AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
11
+ "SELECT encrypted_value, last_access_utc, value FROM cookies WHERE host_key LIKE '%swmaestro.ai' AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
12
12
  const CHROMIUM_SALT = 'saltysalt'
13
13
  const CHROMIUM_IV = Buffer.alloc(16, 0x20)
14
+ const PROFILE_DIR_PATTERN = /^Profile\s+\d+$/
14
15
 
15
16
  type BrowserConfig = {
16
17
  name: string
@@ -22,9 +23,17 @@ type BrowserConfig = {
22
23
 
23
24
  type CookieRow = {
24
25
  encrypted_value: Uint8Array | Buffer | null
26
+ last_access_utc?: number | bigint | null
25
27
  value: string | null
26
28
  }
27
29
 
30
+ export interface ExtractedSessionCandidate {
31
+ browser: string
32
+ lastAccessUtc: number
33
+ profile: string
34
+ sessionCookie: string
35
+ }
36
+
28
37
  export const BROWSERS: BrowserConfig[] = [
29
38
  {
30
39
  name: 'Chrome',
@@ -82,12 +91,36 @@ function queryCookieDb(dbPath: string): CookieRow | undefined {
82
91
  }
83
92
  }
84
93
 
85
- const Database = require('better-sqlite3')
86
- const db = new Database(dbPath, { readonly: true })
87
94
  try {
88
- return db.prepare(COOKIE_QUERY).get() as CookieRow | undefined
89
- } finally {
90
- db.close()
95
+ const { DatabaseSync } = require('node:sqlite') as {
96
+ DatabaseSync: new (
97
+ path: string,
98
+ options?: { readonly?: boolean },
99
+ ) => {
100
+ close: () => void
101
+ prepare: (query: string) => { get: () => CookieRow | undefined }
102
+ }
103
+ }
104
+ const db = new DatabaseSync(dbPath, { readonly: true })
105
+ try {
106
+ return db.prepare(COOKIE_QUERY).get() ?? undefined
107
+ } finally {
108
+ db.close()
109
+ }
110
+ } catch {
111
+ const Database = require('better-sqlite3') as new (
112
+ path: string,
113
+ options?: { readonly?: boolean },
114
+ ) => {
115
+ close: () => void
116
+ prepare: (query: string) => { get: () => CookieRow | undefined }
117
+ }
118
+ const db = new Database(dbPath, { readonly: true })
119
+ try {
120
+ return db.prepare(COOKIE_QUERY).get() ?? undefined
121
+ } finally {
122
+ db.close()
123
+ }
91
124
  }
92
125
  }
93
126
 
@@ -98,12 +131,27 @@ export class TokenExtractor {
98
131
  ) {}
99
132
 
100
133
  async extract(): Promise<{ sessionCookie: string } | null> {
134
+ const candidates = await this.extractCandidates()
135
+ const firstCandidate = candidates[0]
136
+
137
+ if (!firstCandidate) {
138
+ return null
139
+ }
140
+
141
+ return { sessionCookie: firstCandidate.sessionCookie }
142
+ }
143
+
144
+ async extractCandidates(): Promise<ExtractedSessionCandidate[]> {
145
+ const candidates = new Map<string, ExtractedSessionCandidate>()
146
+
101
147
  for (const databasePath of this.findCookieDatabases()) {
102
148
  const browser = this.getBrowserByPath(databasePath)
103
149
  if (!browser) {
104
150
  continue
105
151
  }
106
152
 
153
+ const profile = basename(dirname(databasePath))
154
+
107
155
  const tempDirectory = mkdtempSync(join(tmpdir(), 'opensoma-cookie-db-'))
108
156
  const tempDatabasePath = join(tempDirectory, 'Cookies')
109
157
 
@@ -117,7 +165,13 @@ export class TokenExtractor {
117
165
 
118
166
  const plaintextValue = typeof row.value === 'string' ? row.value.trim() : ''
119
167
  if (plaintextValue) {
120
- return { sessionCookie: plaintextValue }
168
+ this.addCandidate(candidates, {
169
+ browser: browser.name,
170
+ lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
171
+ profile,
172
+ sessionCookie: plaintextValue,
173
+ })
174
+ continue
121
175
  }
122
176
 
123
177
  if (!row.encrypted_value || row.encrypted_value.length === 0) {
@@ -126,29 +180,24 @@ export class TokenExtractor {
126
180
 
127
181
  const decryptedValue = await this.decryptCookie(Buffer.from(row.encrypted_value), browser.name)
128
182
  if (decryptedValue) {
129
- return { sessionCookie: decryptedValue }
183
+ this.addCandidate(candidates, {
184
+ browser: browser.name,
185
+ lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
186
+ profile,
187
+ sessionCookie: decryptedValue,
188
+ })
130
189
  }
131
190
  } catch {
132
- continue
133
191
  } finally {
134
192
  rmSync(tempDirectory, { recursive: true, force: true })
135
193
  }
136
194
  }
137
195
 
138
- return null
196
+ return [...candidates.values()].sort((left, right) => right.lastAccessUtc - left.lastAccessUtc)
139
197
  }
140
198
 
141
199
  findCookieDatabases(): string[] {
142
- const browserPaths =
143
- this.platform === 'darwin'
144
- ? BROWSERS.map((browser) =>
145
- join(this.homeDirectory, 'Library', 'Application Support', browser.macPath, 'Default', 'Cookies'),
146
- )
147
- : this.platform === 'linux'
148
- ? BROWSERS.map((browser) => join(this.homeDirectory, '.config', browser.linuxPath, 'Default', 'Cookies'))
149
- : []
150
-
151
- return browserPaths.filter((databasePath) => existsSync(databasePath))
200
+ return BROWSERS.flatMap((browser) => this.findBrowserCookieDatabases(browser))
152
201
  }
153
202
 
154
203
  private async decryptCookie(encryptedValue: Buffer, browserName: string): Promise<string> {
@@ -205,4 +254,48 @@ export class TokenExtractor {
205
254
  (browser) => databasePath.includes(`${browser.macPath}/`) || databasePath.includes(`${browser.linuxPath}/`),
206
255
  )
207
256
  }
257
+
258
+ private addCandidate(candidates: Map<string, ExtractedSessionCandidate>, candidate: ExtractedSessionCandidate): void {
259
+ const existing = candidates.get(candidate.sessionCookie)
260
+ if (!existing || existing.lastAccessUtc < candidate.lastAccessUtc) {
261
+ candidates.set(candidate.sessionCookie, candidate)
262
+ }
263
+ }
264
+
265
+ private findBrowserCookieDatabases(browser: BrowserConfig): string[] {
266
+ const browserRoot = this.getBrowserRoot(browser)
267
+ if (!browserRoot || !existsSync(browserRoot)) {
268
+ return []
269
+ }
270
+
271
+ return readdirSync(browserRoot, { withFileTypes: true })
272
+ .filter((entry) => entry.isDirectory() && this.isSupportedProfileDirectory(entry.name))
273
+ .sort((left, right) => left.name.localeCompare(right.name))
274
+ .map((entry) => join(browserRoot, entry.name, 'Cookies'))
275
+ .filter((databasePath) => existsSync(databasePath))
276
+ }
277
+
278
+ private getBrowserRoot(browser: BrowserConfig): string | null {
279
+ if (this.platform === 'darwin') {
280
+ return join(this.homeDirectory, 'Library', 'Application Support', browser.macPath)
281
+ }
282
+
283
+ if (this.platform === 'linux') {
284
+ return join(this.homeDirectory, '.config', browser.linuxPath)
285
+ }
286
+
287
+ return null
288
+ }
289
+
290
+ private isSupportedProfileDirectory(profileName: string): boolean {
291
+ return profileName === 'Default' || PROFILE_DIR_PATTERN.test(profileName)
292
+ }
293
+
294
+ private normalizeLastAccessUtc(lastAccessUtc: number | bigint | null | undefined): number {
295
+ if (typeof lastAccessUtc === 'bigint') {
296
+ return Number(lastAccessUtc)
297
+ }
298
+
299
+ return typeof lastAccessUtc === 'number' ? lastAccessUtc : 0
300
+ }
208
301
  }
package/src/types.test.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  PaginationSchema,
14
14
  RoomCardSchema,
15
15
  TeamInfoSchema,
16
- } from '@/types'
16
+ } from './types'
17
17
 
18
18
  describe('schemas', () => {
19
19
  test('accept valid values', () => {
@@ -161,7 +161,9 @@ describe('schemas', () => {
161
161
  expect(() => RoomCardSchema.parse({ itemId: '17' })).toThrow()
162
162
  expect(() => DashboardSchema.parse({ name: '전수열' })).toThrow()
163
163
  expect(() => NoticeListItemSchema.parse({ id: 1, title: '공지' })).toThrow()
164
- expect(() => NoticeDetailSchema.parse({ id: 1, title: '공지', author: '관리자', createdAt: '2026-04-01' })).toThrow()
164
+ expect(() =>
165
+ NoticeDetailSchema.parse({ id: 1, title: '공지', author: '관리자', createdAt: '2026-04-01' }),
166
+ ).toThrow()
165
167
  expect(() => TeamInfoSchema.parse({ teams: [{ name: '김개발' }], currentTeams: 1, maxTeams: 100 })).toThrow()
166
168
  expect(() => MemberInfoSchema.parse({ email: 1, name: '전수열' })).toThrow()
167
169
  expect(() => EventListItemSchema.parse({ id: 1, title: '행사', status: '접수중' })).toThrow()
package/src/types.ts CHANGED
@@ -7,6 +7,11 @@ const DashboardStatusItemSchema = z.object({
7
7
  title: z.string(),
8
8
  url: z.string(),
9
9
  status: z.string(),
10
+ date: z.string().optional(),
11
+ time: z.string().optional(),
12
+ timeEnd: z.string().optional(),
13
+ venue: z.string().optional(),
14
+ type: z.enum(['자유 멘토링', '멘토 특강']).optional(),
10
15
  })
11
16
  const TeamListItemSchema = z.object({
12
17
  name: z.string(),
@@ -127,6 +132,7 @@ export type Pagination = z.infer<typeof PaginationSchema>
127
132
 
128
133
  export const CredentialsSchema = z.object({
129
134
  sessionCookie: z.string(),
135
+ cookies: z.string().optional(),
130
136
  csrfToken: z.string(),
131
137
  username: z.string().optional(),
132
138
  loggedInAt: z.string().optional(),