opensoma 0.1.3 → 0.2.1

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 (108) hide show
  1. package/dist/package.json +18 -2
  2. package/dist/src/client.d.ts +10 -0
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +139 -22
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +16 -0
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +105 -44
  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 +8 -0
  16. package/dist/src/commands/helpers.d.ts.map +1 -1
  17. package/dist/src/commands/helpers.js +35 -4
  18. package/dist/src/commands/helpers.js.map +1 -1
  19. package/dist/src/commands/member.d.ts.map +1 -1
  20. package/dist/src/commands/member.js.map +1 -1
  21. package/dist/src/commands/mentoring.d.ts.map +1 -1
  22. package/dist/src/commands/mentoring.js +14 -5
  23. package/dist/src/commands/mentoring.js.map +1 -1
  24. package/dist/src/commands/notice.d.ts.map +1 -1
  25. package/dist/src/commands/notice.js.map +1 -1
  26. package/dist/src/commands/room.d.ts.map +1 -1
  27. package/dist/src/commands/room.js.map +1 -1
  28. package/dist/src/commands/team.d.ts.map +1 -1
  29. package/dist/src/commands/team.js.map +1 -1
  30. package/dist/src/credential-manager.d.ts +7 -0
  31. package/dist/src/credential-manager.d.ts.map +1 -1
  32. package/dist/src/credential-manager.js +76 -2
  33. package/dist/src/credential-manager.js.map +1 -1
  34. package/dist/src/errors.d.ts +8 -0
  35. package/dist/src/errors.d.ts.map +1 -0
  36. package/dist/src/errors.js +11 -0
  37. package/dist/src/errors.js.map +1 -0
  38. package/dist/src/formatters.d.ts.map +1 -1
  39. package/dist/src/formatters.js +54 -7
  40. package/dist/src/formatters.js.map +1 -1
  41. package/dist/src/http.d.ts +6 -0
  42. package/dist/src/http.d.ts.map +1 -1
  43. package/dist/src/http.js +183 -8
  44. package/dist/src/http.js.map +1 -1
  45. package/dist/src/index.d.ts +1 -0
  46. package/dist/src/index.d.ts.map +1 -1
  47. package/dist/src/index.js +1 -0
  48. package/dist/src/index.js.map +1 -1
  49. package/dist/src/session-recovery.d.ts +12 -0
  50. package/dist/src/session-recovery.d.ts.map +1 -0
  51. package/dist/src/session-recovery.js +34 -0
  52. package/dist/src/session-recovery.js.map +1 -0
  53. package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -1
  54. package/dist/src/shared/utils/mentoring-params.js +4 -1
  55. package/dist/src/shared/utils/mentoring-params.js.map +1 -1
  56. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  57. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  58. package/dist/src/token-extractor.d.ts +12 -0
  59. package/dist/src/token-extractor.d.ts.map +1 -1
  60. package/dist/src/token-extractor.js +81 -18
  61. package/dist/src/token-extractor.js.map +1 -1
  62. package/dist/src/types.d.ts +18 -0
  63. package/dist/src/types.d.ts.map +1 -1
  64. package/dist/src/types.js +7 -0
  65. package/dist/src/types.js.map +1 -1
  66. package/package.json +17 -1
  67. package/src/client.test.ts +176 -12
  68. package/src/client.ts +154 -37
  69. package/src/commands/auth.test.ts +167 -0
  70. package/src/commands/auth.ts +140 -58
  71. package/src/commands/dashboard.ts +5 -6
  72. package/src/commands/event.ts +5 -6
  73. package/src/commands/helpers.test.ts +112 -0
  74. package/src/commands/helpers.ts +61 -6
  75. package/src/commands/member.ts +4 -5
  76. package/src/commands/mentoring.ts +36 -19
  77. package/src/commands/notice.ts +4 -5
  78. package/src/commands/room.ts +4 -5
  79. package/src/commands/team.ts +4 -5
  80. package/src/credential-manager.test.ts +42 -2
  81. package/src/credential-manager.ts +104 -3
  82. package/src/errors.ts +10 -0
  83. package/src/formatters.test.ts +1 -1
  84. package/src/formatters.ts +91 -18
  85. package/src/http.test.ts +75 -10
  86. package/src/http.ts +228 -10
  87. package/src/index.ts +1 -0
  88. package/src/session-recovery.ts +56 -0
  89. package/src/shared/utils/mentoring-params.test.ts +9 -4
  90. package/src/shared/utils/mentoring-params.ts +6 -3
  91. package/src/shared/utils/swmaestro.ts +2 -2
  92. package/src/token-extractor.test.ts +46 -8
  93. package/src/token-extractor.ts +115 -22
  94. package/src/types.test.ts +4 -2
  95. package/src/types.ts +7 -0
  96. package/.claude-plugin/README.md +0 -145
  97. package/.claude-plugin/plugin.json +0 -23
  98. package/.github/workflows/release.yml +0 -86
  99. package/.oxfmtrc.json +0 -9
  100. package/.oxlintrc.json +0 -4
  101. package/AGENTS.md +0 -78
  102. package/README.md +0 -252
  103. package/bun.lock +0 -297
  104. package/bunfig.toml +0 -2
  105. package/e2e/.gitkeep +0 -0
  106. package/skills/opensoma/SKILL.md +0 -345
  107. package/skills/opensoma/references/common-patterns.md +0 -182
  108. package/skills/opensoma/references/output-format.md +0 -130
package/src/http.test.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, mock, test } from 'bun:test'
2
2
 
3
- import { MENU_NO } from '@/constants'
4
- import { SomaHttp } from '@/http'
3
+ import { MENU_NO } from './constants'
4
+ import { SomaHttp } from './http'
5
5
 
6
6
  const originalFetch = globalThis.fetch
7
7
 
@@ -12,8 +12,13 @@ afterEach(() => {
12
12
 
13
13
  describe('SomaHttp', () => {
14
14
  test('get sends query params and stores cookies', async () => {
15
- const fetchMock = mock(async (input: RequestInfo | URL) => {
15
+ const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
16
16
  expect(String(input)).toBe(`https://www.swmaestro.ai/sw/member/user/forLogin.do?menuNo=${MENU_NO.LOGIN}`)
17
+ expect(init?.headers).toEqual({
18
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
19
+ 'User-Agent':
20
+ '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',
21
+ })
17
22
  return createResponse('<html></html>', ['JSESSIONID=session-1; Path=/', 'XSRF-TOKEN=csrf-1; Path=/'])
18
23
  })
19
24
  globalThis.fetch = fetchMock as typeof fetch
@@ -30,6 +35,9 @@ describe('SomaHttp', () => {
30
35
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
31
36
  expect(init?.method).toBe('POST')
32
37
  expect(init?.headers).toEqual({
38
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
39
+ 'User-Agent':
40
+ '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',
33
41
  cookie: 'JSESSIONID=session-1',
34
42
  'Content-Type': 'application/x-www-form-urlencoded',
35
43
  })
@@ -47,6 +55,9 @@ describe('SomaHttp', () => {
47
55
  test('postJson returns parsed json', async () => {
48
56
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
49
57
  expect(init?.headers).toEqual({
58
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
59
+ 'User-Agent':
60
+ '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',
50
61
  cookie: 'JSESSIONID=session-1',
51
62
  Accept: 'application/json',
52
63
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -56,7 +67,9 @@ describe('SomaHttp', () => {
56
67
  globalThis.fetch = fetchMock as typeof fetch
57
68
 
58
69
  const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
59
- const json = await http.postJson<{ resultCode: string }>('/mypage/officeMng/rentTime.do', { itemId: '17' })
70
+ const json = await http.postJson<{ resultCode: string }>('/mypage/officeMng/rentTime.do', {
71
+ itemId: '17',
72
+ })
60
73
 
61
74
  expect(json).toEqual({ resultCode: 'SUCCESS' })
62
75
  })
@@ -67,6 +80,11 @@ describe('SomaHttp', () => {
67
80
 
68
81
  if (url.includes('/forLogin.do')) {
69
82
  expect(init?.method).toBe('GET')
83
+ expect(init?.headers).toEqual({
84
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
85
+ 'User-Agent':
86
+ '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',
87
+ })
70
88
  return createResponse('<form><input type="hidden" name="csrfToken" value="csrf-login"></form>', [
71
89
  'JSESSIONID=session-2; Path=/',
72
90
  ])
@@ -75,6 +93,9 @@ describe('SomaHttp', () => {
75
93
  expect(url).toBe('https://www.swmaestro.ai/sw/member/user/toLogin.do')
76
94
  expect(init?.method).toBe('POST')
77
95
  expect(init?.headers).toEqual({
96
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
97
+ 'User-Agent':
98
+ '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',
78
99
  cookie: 'JSESSIONID=session-2',
79
100
  'Content-Type': 'application/x-www-form-urlencoded',
80
101
  })
@@ -96,16 +117,32 @@ describe('SomaHttp', () => {
96
117
  })
97
118
 
98
119
  test('checkLogin returns user identity when logged in, null otherwise', async () => {
99
- const loggedInMock = mock(async () =>
120
+ const loggedInMock = mock(async (_input: RequestInfo | URL, _init?: RequestInit) =>
100
121
  createResponse(
101
- JSON.stringify({ resultCode: 'fail', userVO: { userId: 'user@example.com', userNm: 'Test' } }),
122
+ JSON.stringify({
123
+ resultCode: 'fail',
124
+ userVO: { userId: 'user@example.com', userNm: 'Test' },
125
+ }),
102
126
  [],
103
127
  'application/json',
104
128
  ),
105
129
  )
106
130
  globalThis.fetch = loggedInMock as typeof fetch
107
131
 
108
- await expect(new SomaHttp().checkLogin()).resolves.toEqual({ userId: 'user@example.com', userNm: 'Test' })
132
+ await expect(new SomaHttp().checkLogin()).resolves.toEqual({
133
+ userId: 'user@example.com',
134
+ userNm: 'Test',
135
+ })
136
+ expect(loggedInMock).toHaveBeenCalledWith('https://www.swmaestro.ai/sw/member/user/checkLogin.json', {
137
+ method: 'GET',
138
+ redirect: 'manual',
139
+ headers: {
140
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
141
+ 'User-Agent':
142
+ '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',
143
+ Accept: 'application/json',
144
+ },
145
+ })
109
146
 
110
147
  const notLoggedInMock = mock(async () =>
111
148
  createResponse(JSON.stringify({ resultCode: 'fail', userVO: { userId: '', userSn: 0 } }), [], 'application/json'),
@@ -115,6 +152,29 @@ describe('SomaHttp', () => {
115
152
  await expect(new SomaHttp().checkLogin()).resolves.toBeNull()
116
153
  })
117
154
 
155
+ test('checkLogin returns null when the server redirects to login', async () => {
156
+ const fetchMock = mock(async () =>
157
+ createResponse('', [], 'text/html', {
158
+ status: 302,
159
+ headers: { Location: 'http://www.swmaestro.ai/sw/member/user/loginForward.do' },
160
+ }),
161
+ )
162
+ globalThis.fetch = fetchMock as typeof fetch
163
+
164
+ await expect(new SomaHttp({ sessionCookie: 'session-1' }).checkLogin()).resolves.toBeNull()
165
+ })
166
+
167
+ test('checkLogin returns null when the server serves login html instead of json', async () => {
168
+ const fetchMock = mock(async () =>
169
+ createResponse(
170
+ '<html><head><title>AI·SW마에스트로</title></head><body><form><input name="username"><input name="password"></form></body></html>',
171
+ ),
172
+ )
173
+ globalThis.fetch = fetchMock as typeof fetch
174
+
175
+ await expect(new SomaHttp({ sessionCookie: 'session-1' }).checkLogin()).resolves.toBeNull()
176
+ })
177
+
118
178
  test('logout calls logout endpoint', async () => {
119
179
  const fetchMock = mock(async (input: RequestInfo | URL) => {
120
180
  expect(String(input)).toBe('https://www.swmaestro.ai/sw/member/user/logout.do')
@@ -138,9 +198,14 @@ describe('SomaHttp', () => {
138
198
  })
139
199
  })
140
200
 
141
- function createResponse(body: string, cookies: string[] = [], contentType = 'text/html'): Response {
142
- const headers = new Headers({ 'Content-Type': contentType })
143
- const response = new Response(body, { headers })
201
+ function createResponse(
202
+ body: string,
203
+ cookies: string[] = [],
204
+ contentType = 'text/html',
205
+ options: { status?: number; headers?: Record<string, string> } = {},
206
+ ): Response {
207
+ const headers = new Headers({ 'Content-Type': contentType, ...(options.headers ?? {}) })
208
+ const response = new Response(body, { headers, status: options.status })
144
209
  const cookieHeaders = cookies
145
210
 
146
211
  Object.defineProperty(response.headers, 'getSetCookie', {
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,152 @@ 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
+ if (this.isAuthRedirect(location)) {
157
+ return '__AUTH_ERROR__'
158
+ }
159
+
160
+ // Check for login page - server returns login page HTML (with login form) when session is invalid
161
+ // The login page has both the SW마에스트로 title AND a login form with username/password inputs
162
+ // Skip this check during login flow (forLogin = GET for CSRF, toLogin = POST credentials)
163
+ const isLoginPath = path?.includes('/member/user/forLogin') || path?.includes('/member/user/toLogin')
164
+ const hasUsername = body.includes('name="username"')
165
+ const hasPassword = body.includes('name="password"')
166
+ if (
167
+ !isLoginPath &&
168
+ hasUsername &&
169
+ hasPassword &&
170
+ (pageTitle.includes('AI·SW마에스트로') || pageTitle.includes('SW마에스트로'))
171
+ ) {
172
+ return '__AUTH_ERROR__'
173
+ }
174
+
175
+ const errorTitleMatch = body.match(/<title>(에러안내|오류|Error)[^<]*<\/title>/i)
176
+ if (errorTitleMatch) {
177
+ this.log('Found error title match')
178
+ const msgVarMatch = body.match(/var\s+msg\s*=\s*['"](.+?)['"];/)
179
+ if (msgVarMatch) {
180
+ this.log('Found msg var:', msgVarMatch[1].slice(0, 100))
181
+ return msgVarMatch[1]
182
+ }
183
+ const errorPatterns = [
184
+ '등록에 실패',
185
+ '저장에 실패',
186
+ '오류가 발생',
187
+ '실패하였습니다',
188
+ '잘못된 접근',
189
+ '권한이 없습니다',
190
+ 'SQLException',
191
+ 'Error updating database',
192
+ ]
193
+ for (const pattern of errorPatterns) {
194
+ if (body.includes(pattern)) {
195
+ this.log('Found error pattern:', pattern)
196
+ return `멘토링 등록에 실패했습니다: ${pattern}`
197
+ }
198
+ }
199
+ return '에러가 발생했습니다'
200
+ }
201
+
202
+ return null
60
203
  }
61
204
 
62
205
  async postJson<T>(path: string, body: Record<string, string>): Promise<T> {
@@ -78,26 +221,78 @@ export class SomaHttp {
78
221
  async login(username: string, password: string): Promise<void> {
79
222
  const csrfToken = await this.extractCsrfToken()
80
223
 
81
- await this.post('/member/user/toLogin.do', {
224
+ const html = await this.post('/member/user/toLogin.do', {
82
225
  username,
83
226
  password,
84
227
  csrfToken,
85
228
  loginFlag: '',
86
229
  menuNo: MENU_NO.LOGIN,
87
230
  })
231
+
232
+ // SWMaestro returns an intermediate form that auto-submits via JS:
233
+ // <form action="/sw/login.do"><input name="password" value="bcrypt_hash"/>...
234
+ // We need to parse and submit this form to complete authentication.
235
+ const actionMatch = html.match(/action=["']([^"']+)["']/)
236
+ const fields: Record<string, string> = {}
237
+
238
+ // Match name="..." or name='...' followed by value="..." or value='...'
239
+ // Uses backreferences (\1 and \3) to match the same quote type for closing
240
+ for (const match of html.matchAll(/name=(['"])([^'"]+)\1\s+value=(['"])(.*?)\3/g)) {
241
+ fields[match[2]] = match[4]
242
+ }
243
+
244
+ if (actionMatch?.[1] && Object.keys(fields).length > 0) {
245
+ const action = actionMatch[1].replace(/^\/sw/, '')
246
+ await this.post(action, fields)
247
+ }
88
248
  }
89
249
 
90
250
  async checkLogin(): Promise<UserIdentity | null> {
91
- const response = await fetch(this.buildUrl('/member/user/checkLogin.json'), {
251
+ const path = '/member/user/checkLogin.json'
252
+ const response = await fetch(this.buildUrl(path), {
92
253
  method: 'GET',
93
254
  headers: {
94
255
  ...this.buildHeaders(),
95
256
  Accept: 'application/json',
96
257
  },
258
+ redirect: 'manual',
97
259
  })
98
260
 
99
261
  this.updateFromResponse(response)
100
- const json = (await response.json()) as CheckLoginResponse
262
+ const location = response.headers.get('location')
263
+
264
+ if (response.status >= 300 && response.status < 400) {
265
+ if (this.isAuthRedirect(location)) {
266
+ return null
267
+ }
268
+
269
+ throw new Error(`Unexpected redirect while checking login: ${location ?? response.status}`)
270
+ }
271
+
272
+ const body = await response.text()
273
+ const errorInfo = this.extractErrorFromResponse(body, location, path)
274
+ if (errorInfo === '__AUTH_ERROR__') {
275
+ return null
276
+ }
277
+
278
+ if (errorInfo) {
279
+ throw new Error(errorInfo)
280
+ }
281
+
282
+ const contentType = response.headers.get('content-type') ?? ''
283
+ if (contentType.includes('text/html')) {
284
+ return null
285
+ }
286
+
287
+ let json: CheckLoginResponse
288
+ try {
289
+ json = JSON.parse(body) as CheckLoginResponse
290
+ } catch (error) {
291
+ throw new Error(
292
+ `Failed to parse checkLogin response: ${error instanceof Error ? error.message : 'unknown error'}`,
293
+ )
294
+ }
295
+
101
296
  const userId = json.userVO?.userId
102
297
  if (!userId) return null
103
298
  return { userId, userNm: json.userVO?.userNm ?? '' }
@@ -141,7 +336,24 @@ export class SomaHttp {
141
336
 
142
337
  private buildHeaders(): Record<string, string> {
143
338
  const cookieHeader = this.serializeCookies()
144
- return cookieHeader ? { cookie: cookieHeader } : {}
339
+ return {
340
+ 'Accept-Language': DEFAULT_ACCEPT_LANGUAGE,
341
+ 'User-Agent': DEFAULT_USER_AGENT,
342
+ ...(cookieHeader ? { cookie: cookieHeader } : {}),
343
+ }
344
+ }
345
+
346
+ private isAuthRedirect(location: string | null): boolean {
347
+ if (!location) {
348
+ return false
349
+ }
350
+
351
+ return [
352
+ '/member/user/loginForward.do',
353
+ '/member/user/forLogin.do',
354
+ '/member/user/toLogin.do',
355
+ '/member/user/logout.do',
356
+ ].some((path) => location.includes(path))
145
357
  }
146
358
 
147
359
  private buildBody(body: Record<string, string>): Record<string, string> {
@@ -193,4 +405,10 @@ export class SomaHttp {
193
405
  private serializeCookies(): string {
194
406
  return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
195
407
  }
408
+
409
+ private log(...args: unknown[]): void {
410
+ if (this.verbose) {
411
+ console.log('[opensoma]', ...args)
412
+ }
413
+ }
196
414
  }
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'
@@ -0,0 +1,56 @@
1
+ import { CredentialManager } from './credential-manager'
2
+ import { type UserIdentity, SomaHttp } from './http'
3
+ import type { Credentials } from './types'
4
+
5
+ type CredentialStore = Pick<CredentialManager, 'setCredentials'>
6
+ type ReloginHttp = Pick<SomaHttp, 'checkLogin' | 'getCsrfToken' | 'getSessionCookie' | 'login'>
7
+
8
+ export function canRecoverSession(credentials: Credentials): credentials is Credentials & {
9
+ password: string
10
+ username: string
11
+ } {
12
+ return Boolean(credentials.username && credentials.password)
13
+ }
14
+
15
+ export async function recoverSession(
16
+ credentials: Credentials,
17
+ manager: CredentialStore = new CredentialManager(),
18
+ createHttp: () => ReloginHttp = () => new SomaHttp(),
19
+ ): Promise<Credentials | null> {
20
+ if (!canRecoverSession(credentials)) {
21
+ return null
22
+ }
23
+
24
+ const http = createHttp()
25
+ await http.login(credentials.username, credentials.password)
26
+
27
+ const identity = await http.checkLogin()
28
+ if (!identity) {
29
+ return null
30
+ }
31
+
32
+ const refreshedCredentials = buildRefreshedCredentials(credentials, identity, http)
33
+ await manager.setCredentials(refreshedCredentials)
34
+ return refreshedCredentials
35
+ }
36
+
37
+ function buildRefreshedCredentials(
38
+ credentials: Credentials & { password: string; username: string },
39
+ identity: UserIdentity,
40
+ http: Pick<SomaHttp, 'getCsrfToken' | 'getSessionCookie'>,
41
+ ): Credentials {
42
+ const sessionCookie = http.getSessionCookie()
43
+ const csrfToken = http.getCsrfToken()
44
+
45
+ if (!sessionCookie || !csrfToken) {
46
+ throw new Error('Automatic re-login succeeded but session state is incomplete')
47
+ }
48
+
49
+ return {
50
+ sessionCookie,
51
+ csrfToken,
52
+ username: identity.userId || credentials.username,
53
+ password: credentials.password,
54
+ loggedInAt: new Date().toISOString(),
55
+ }
56
+ }
@@ -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