careermate 0.1.2 → 0.1.3
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.
- package/LICENSE +21 -0
- package/README.md +3 -1
- package/apps/web/public/pages/documents.js +7 -2
- package/apps/web/public/pages/home.js +4 -7
- package/apps/web/public/pages/jobs.js +1 -5
- package/apps/web/public/pages/profile.js +3 -3
- package/apps/web/public/styles.css +7 -2
- package/dist/doctor.mjs +112 -90
- package/dist/install-page/analytics.js +97 -0
- package/dist/install-page/index.html +77 -56
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/robots.txt +4 -0
- package/dist/install-page/sitemap.xml +21 -0
- package/dist/install-page/start.html +13 -10
- package/dist/install-page/style.css +75 -40
- package/dist/mcp.mjs +112 -90
- package/dist/migrate.mjs +95 -82
- package/dist/public/pages/documents.js +7 -2
- package/dist/public/pages/home.js +4 -7
- package/dist/public/pages/jobs.js +1 -5
- package/dist/public/pages/profile.js +3 -3
- package/dist/public/styles.css +7 -2
- package/dist/web.mjs +113 -86
- package/package.json +1 -2
- package/packages/db/src/repositories.ts +18 -1
- package/packages/mcp-tools/src/tools.ts +5 -5
- package/packages/parsers/src/documents.ts +13 -1
- package/packages/shared/src/schemas.ts +115 -81
- package/scripts/build-dist.mjs +7 -1
- package/scripts/gen-og.mjs +1 -1
- package/scripts/test.ts +25 -0
- package/scripts/ui-smoke.ts +32 -4
- package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +0 -12
- package/dist/install-page/.omc/state/idle-notif-cooldown.json +0 -3
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +0 -147
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +0 -22
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +0 -44
- package/docs/ARCHITECTURE.md +0 -208
- package/docs/CHANGES_V1.md +0 -103
- package/docs/DATA_MODEL.md +0 -460
- package/docs/DECISIONS.md +0 -277
- package/docs/DEMO.md +0 -242
- package/docs/MCP_TOOLS.md +0 -292
- package/docs/ROADMAP.md +0 -135
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +0 -135
- package/docs/START_WORKFLOW.md +0 -137
- package/docs/SUPPORTED_AI_APPS.md +0 -112
- package/docs/TODO.md +0 -57
- package/docs/UX_NOTES.md +0 -247
- package/docs/WORKFLOWS.md +0 -202
- package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +0 -64
|
@@ -35,12 +35,46 @@ function isSafeUrl(u: string): boolean {
|
|
|
35
35
|
return !/^[a-z][a-z0-9+.-]*:/i.test(s) || /^(https?:|mailto:)/i.test(s);
|
|
36
36
|
}
|
|
37
37
|
const UNSAFE_URL_MSG = 'javascript: 같은 위험한 링크는 사용할 수 없습니다. http(s) 링크를 사용하세요.';
|
|
38
|
-
const
|
|
38
|
+
const MAX_URL = 2_000;
|
|
39
|
+
const SafeUrl = z.string().min(1).max(MAX_URL).refine(isSafeUrl, { message: UNSAFE_URL_MSG });
|
|
39
40
|
const SafeUrlOptional = z
|
|
40
41
|
.string()
|
|
42
|
+
.max(MAX_URL)
|
|
41
43
|
.optional()
|
|
42
44
|
.refine((u) => u == null || u === '' || isSafeUrl(u), { message: UNSAFE_URL_MSG });
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Length-bounded string helpers. MCP/HTTP inputs are otherwise unbounded, so a
|
|
48
|
+
* malicious or buggy client could send multi-MB strings to exhaust memory/disk.
|
|
49
|
+
* These caps are generous for real résumés/cover letters but reject abuse.
|
|
50
|
+
* - LINE : single-line fields (names, titles, locations)
|
|
51
|
+
* - NOTE : short multi-line fields (summaries, notes)
|
|
52
|
+
* - BODY : full document bodies (resume / cover letter / job description)
|
|
53
|
+
* - ITEM : one array element; ITEMS : array length
|
|
54
|
+
*/
|
|
55
|
+
const MAX_LINE = 500;
|
|
56
|
+
const MAX_NOTE = 10_000;
|
|
57
|
+
const MAX_BODY = 200_000;
|
|
58
|
+
const MAX_ITEM = 2_000;
|
|
59
|
+
const MAX_ITEMS = 500;
|
|
60
|
+
const EMPTY_MSG = '내용이 비어 있습니다(공백만으로는 저장할 수 없습니다).';
|
|
61
|
+
|
|
62
|
+
/** Required single-line value: rejects whitespace-only, trims, bounds length. */
|
|
63
|
+
const reqLine = z.string().trim().min(1).max(MAX_LINE);
|
|
64
|
+
/** Optional single-line value (bounded). */
|
|
65
|
+
const optLine = z.string().max(MAX_LINE).optional();
|
|
66
|
+
/** Optional short multi-line note (bounded). */
|
|
67
|
+
const optNote = z.string().max(MAX_NOTE).optional();
|
|
68
|
+
/** Optional full document body (bounded). */
|
|
69
|
+
const optBody = z.string().max(MAX_BODY).optional();
|
|
70
|
+
/** Required full document body: bounded, rejects whitespace-only (no trim — preserve formatting). */
|
|
71
|
+
const reqBody = z
|
|
72
|
+
.string()
|
|
73
|
+
.max(MAX_BODY)
|
|
74
|
+
.refine((s) => s.trim().length > 0, { message: EMPTY_MSG });
|
|
75
|
+
/** Bounded array of bounded strings. */
|
|
76
|
+
const strList = z.array(z.string().max(MAX_ITEM)).max(MAX_ITEMS);
|
|
77
|
+
|
|
44
78
|
const baseRecord = {
|
|
45
79
|
id,
|
|
46
80
|
created_at: isoDateTime,
|
|
@@ -50,23 +84,23 @@ const baseRecord = {
|
|
|
50
84
|
/* ------------------------------------------------------------------ Profile */
|
|
51
85
|
|
|
52
86
|
export const LinkSchema = z.object({
|
|
53
|
-
label:
|
|
87
|
+
label: reqLine,
|
|
54
88
|
url: SafeUrl,
|
|
55
89
|
});
|
|
56
90
|
export type Link = z.infer<typeof LinkSchema>;
|
|
57
91
|
|
|
58
92
|
export const ProfileInputSchema = z.object({
|
|
59
|
-
name:
|
|
60
|
-
email:
|
|
61
|
-
phone:
|
|
62
|
-
location:
|
|
63
|
-
headline: z.string().optional().describe('한 줄 소개 / 직무 타이틀'),
|
|
64
|
-
summary: z.string().optional().describe('자기소개 요약'),
|
|
65
|
-
desired_roles:
|
|
66
|
-
desired_conditions: z.string().optional().describe('희망 근무 조건 (연봉, 지역, 근무형태 등)'),
|
|
67
|
-
preferred_tone: z.string().optional().describe('자기소개서 선호 문체 (예: 담백하고 구체적)'),
|
|
68
|
-
emphasis_points:
|
|
69
|
-
links: z.array(LinkSchema).optional().describe('포트폴리오/깃허브/링크드인 등'),
|
|
93
|
+
name: reqLine.optional(),
|
|
94
|
+
email: optLine,
|
|
95
|
+
phone: optLine,
|
|
96
|
+
location: optLine,
|
|
97
|
+
headline: z.string().max(MAX_LINE).optional().describe('한 줄 소개 / 직무 타이틀'),
|
|
98
|
+
summary: z.string().max(MAX_NOTE).optional().describe('자기소개 요약'),
|
|
99
|
+
desired_roles: strList.optional().describe('희망 직무'),
|
|
100
|
+
desired_conditions: z.string().max(MAX_NOTE).optional().describe('희망 근무 조건 (연봉, 지역, 근무형태 등)'),
|
|
101
|
+
preferred_tone: z.string().max(MAX_LINE).optional().describe('자기소개서 선호 문체 (예: 담백하고 구체적)'),
|
|
102
|
+
emphasis_points: strList.optional().describe('강조하고 싶은 핵심 포인트'),
|
|
103
|
+
links: z.array(LinkSchema).max(MAX_ITEMS).optional().describe('포트폴리오/깃허브/링크드인 등'),
|
|
70
104
|
});
|
|
71
105
|
export type ProfileInput = z.infer<typeof ProfileInputSchema>;
|
|
72
106
|
|
|
@@ -89,15 +123,15 @@ export type ProfileRecord = z.infer<typeof ProfileRecordSchema>;
|
|
|
89
123
|
/* --------------------------------------------------------------- Experience */
|
|
90
124
|
|
|
91
125
|
export const ExperienceInputSchema = z.object({
|
|
92
|
-
company:
|
|
93
|
-
role:
|
|
94
|
-
employment_type: z.string().optional().describe('정규직/계약직/인턴/프리랜서 등'),
|
|
95
|
-
start_date: z.string().optional().describe('YYYY-MM 또는 YYYY-MM-DD'),
|
|
96
|
-
end_date: z.string().optional().describe('재직 중이면 비워두기'),
|
|
126
|
+
company: reqLine,
|
|
127
|
+
role: optLine,
|
|
128
|
+
employment_type: z.string().max(MAX_LINE).optional().describe('정규직/계약직/인턴/프리랜서 등'),
|
|
129
|
+
start_date: z.string().max(MAX_LINE).optional().describe('YYYY-MM 또는 YYYY-MM-DD'),
|
|
130
|
+
end_date: z.string().max(MAX_LINE).optional().describe('재직 중이면 비워두기'),
|
|
97
131
|
is_current: z.boolean().optional(),
|
|
98
|
-
description:
|
|
99
|
-
achievements:
|
|
100
|
-
tech:
|
|
132
|
+
description: optBody,
|
|
133
|
+
achievements: strList.optional().describe('성과/업적 (정량 지표 권장)'),
|
|
134
|
+
tech: strList.optional().describe('사용 기술/도구'),
|
|
101
135
|
order_index: z.number().optional(),
|
|
102
136
|
});
|
|
103
137
|
export type ExperienceInput = z.infer<typeof ExperienceInputSchema>;
|
|
@@ -120,14 +154,14 @@ export type ExperienceRecord = z.infer<typeof ExperienceRecordSchema>;
|
|
|
120
154
|
/* ------------------------------------------------------------------ Project */
|
|
121
155
|
|
|
122
156
|
export const ProjectInputSchema = z.object({
|
|
123
|
-
name:
|
|
124
|
-
role:
|
|
125
|
-
description:
|
|
126
|
-
highlights:
|
|
127
|
-
tech:
|
|
157
|
+
name: reqLine,
|
|
158
|
+
role: optLine,
|
|
159
|
+
description: optBody,
|
|
160
|
+
highlights: strList.optional(),
|
|
161
|
+
tech: strList.optional(),
|
|
128
162
|
url: SafeUrlOptional,
|
|
129
|
-
start_date:
|
|
130
|
-
end_date:
|
|
163
|
+
start_date: optLine,
|
|
164
|
+
end_date: optLine,
|
|
131
165
|
order_index: z.number().optional(),
|
|
132
166
|
});
|
|
133
167
|
export type ProjectInput = z.infer<typeof ProjectInputSchema>;
|
|
@@ -149,9 +183,9 @@ export type ProjectRecord = z.infer<typeof ProjectRecordSchema>;
|
|
|
149
183
|
/* -------------------------------------------------------------------- Skill */
|
|
150
184
|
|
|
151
185
|
export const SkillInputSchema = z.object({
|
|
152
|
-
name:
|
|
153
|
-
category: z.string().optional().describe('언어/프레임워크/툴/소프트스킬 등'),
|
|
154
|
-
level: z.string().optional().describe('상/중/하 또는 자유 서술'),
|
|
186
|
+
name: reqLine,
|
|
187
|
+
category: z.string().max(MAX_LINE).optional().describe('언어/프레임워크/툴/소프트스킬 등'),
|
|
188
|
+
level: z.string().max(MAX_LINE).optional().describe('상/중/하 또는 자유 서술'),
|
|
155
189
|
years: z.number().optional(),
|
|
156
190
|
order_index: z.number().optional(),
|
|
157
191
|
});
|
|
@@ -171,11 +205,11 @@ export type SkillRecord = z.infer<typeof SkillRecordSchema>;
|
|
|
171
205
|
|
|
172
206
|
export const DocumentInputSchema = z.object({
|
|
173
207
|
kind: z.enum(DOCUMENT_KINDS),
|
|
174
|
-
title:
|
|
175
|
-
content:
|
|
208
|
+
title: reqLine,
|
|
209
|
+
content: reqBody.describe('Markdown 또는 일반 텍스트 본문'),
|
|
176
210
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
177
211
|
is_primary: z.boolean().optional().describe('대표 문서 여부'),
|
|
178
|
-
tags:
|
|
212
|
+
tags: strList.optional(),
|
|
179
213
|
});
|
|
180
214
|
export type DocumentInput = z.infer<typeof DocumentInputSchema>;
|
|
181
215
|
|
|
@@ -193,21 +227,21 @@ export type DocumentRecord = z.infer<typeof DocumentRecordSchema>;
|
|
|
193
227
|
/* -------------------------------------------------------------- CoverLetter */
|
|
194
228
|
|
|
195
229
|
export const CoverLetterInputSchema = z.object({
|
|
196
|
-
title:
|
|
197
|
-
job_id:
|
|
230
|
+
title: reqLine,
|
|
231
|
+
job_id: optLine.describe('특정 공고에 연결할 경우'),
|
|
198
232
|
is_primary: z.boolean().optional(),
|
|
199
|
-
content:
|
|
233
|
+
content: optBody.describe('초기 버전 본문 (있으면 v1로 저장)'),
|
|
200
234
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
201
|
-
note:
|
|
235
|
+
note: optNote.describe('초기 버전 메모'),
|
|
202
236
|
});
|
|
203
237
|
export type CoverLetterInput = z.infer<typeof CoverLetterInputSchema>;
|
|
204
238
|
|
|
205
239
|
export const CoverLetterVersionInputSchema = z.object({
|
|
206
|
-
cover_letter_id:
|
|
207
|
-
title: z.string().optional().describe('새 자기소개서를 만들 때 제목'),
|
|
208
|
-
job_id:
|
|
209
|
-
content:
|
|
210
|
-
note:
|
|
240
|
+
cover_letter_id: optLine.describe('기존 자기소개서에 새 버전 추가 시'),
|
|
241
|
+
title: z.string().max(MAX_LINE).optional().describe('새 자기소개서를 만들 때 제목'),
|
|
242
|
+
job_id: optLine,
|
|
243
|
+
content: reqBody,
|
|
244
|
+
note: optNote.describe('이 버전의 변경 요약 (예: 지원동기 보강)'),
|
|
211
245
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
212
246
|
set_current: z.boolean().optional().describe('이 버전을 현재 버전으로 지정 (기본 true)'),
|
|
213
247
|
});
|
|
@@ -239,16 +273,16 @@ export type CoverLetterRecord = z.infer<typeof CoverLetterRecordSchema>;
|
|
|
239
273
|
/* --------------------------------------------------------------------- Job */
|
|
240
274
|
|
|
241
275
|
export const JobInputSchema = z.object({
|
|
242
|
-
company:
|
|
243
|
-
position:
|
|
276
|
+
company: reqLine,
|
|
277
|
+
position: reqLine.describe('직무/포지션명'),
|
|
244
278
|
url: SafeUrlOptional,
|
|
245
|
-
location:
|
|
246
|
-
employment_type:
|
|
247
|
-
description:
|
|
248
|
-
requirements:
|
|
249
|
-
keywords:
|
|
250
|
-
deadline: z.string().optional().describe('마감일 YYYY-MM-DD'),
|
|
251
|
-
source: z.string().optional().describe('출처 (사람인/원티드/직접 입력 등)'),
|
|
279
|
+
location: optLine,
|
|
280
|
+
employment_type: optLine,
|
|
281
|
+
description: optBody.describe('공고 원문 또는 정리된 텍스트'),
|
|
282
|
+
requirements: strList.optional().describe('자격요건/우대사항 핵심'),
|
|
283
|
+
keywords: strList.optional().describe('핵심 키워드'),
|
|
284
|
+
deadline: z.string().max(MAX_LINE).optional().describe('마감일 YYYY-MM-DD'),
|
|
285
|
+
source: z.string().max(MAX_LINE).optional().describe('출처 (사람인/원티드/직접 입력 등)'),
|
|
252
286
|
});
|
|
253
287
|
export type JobInput = z.infer<typeof JobInputSchema>;
|
|
254
288
|
|
|
@@ -270,14 +304,14 @@ export type JobRecord = z.infer<typeof JobRecordSchema>;
|
|
|
270
304
|
/* ------------------------------------------------------------- FitAnalysis */
|
|
271
305
|
|
|
272
306
|
export const FitAnalysisInputSchema = z.object({
|
|
273
|
-
job_id:
|
|
307
|
+
job_id: reqLine.describe('분석 대상 공고 ID'),
|
|
274
308
|
score: z.number().min(0).max(100).optional().describe('종합 적합도 0~100'),
|
|
275
|
-
summary: z.string().optional().describe('한두 문단 요약'),
|
|
276
|
-
strengths:
|
|
277
|
-
gaps:
|
|
278
|
-
matched_keywords:
|
|
279
|
-
missing_keywords:
|
|
280
|
-
recommendations:
|
|
309
|
+
summary: z.string().max(MAX_NOTE).optional().describe('한두 문단 요약'),
|
|
310
|
+
strengths: strList.optional().describe('강점 / 잘 맞는 부분'),
|
|
311
|
+
gaps: strList.optional().describe('부족한 부분 / 보완 필요'),
|
|
312
|
+
matched_keywords: strList.optional(),
|
|
313
|
+
missing_keywords: strList.optional(),
|
|
314
|
+
recommendations: strList.optional().describe('자기소개서/지원 전략 제안'),
|
|
281
315
|
});
|
|
282
316
|
export type FitAnalysisInput = z.infer<typeof FitAnalysisInputSchema>;
|
|
283
317
|
|
|
@@ -297,20 +331,20 @@ export type FitAnalysisRecord = z.infer<typeof FitAnalysisRecordSchema>;
|
|
|
297
331
|
/* ------------------------------------------------------------- Application */
|
|
298
332
|
|
|
299
333
|
export const ApplicationInputSchema = z.object({
|
|
300
|
-
job_id:
|
|
334
|
+
job_id: reqLine,
|
|
301
335
|
status: z.enum(APPLICATION_STATUSES).optional(),
|
|
302
|
-
resume_id:
|
|
303
|
-
cover_letter_id:
|
|
304
|
-
applied_at:
|
|
305
|
-
notes:
|
|
336
|
+
resume_id: optLine,
|
|
337
|
+
cover_letter_id: optLine,
|
|
338
|
+
applied_at: optLine,
|
|
339
|
+
notes: optNote,
|
|
306
340
|
});
|
|
307
341
|
export type ApplicationInput = z.infer<typeof ApplicationInputSchema>;
|
|
308
342
|
|
|
309
343
|
export const ApplicationStatusUpdateSchema = z.object({
|
|
310
|
-
job_id:
|
|
311
|
-
application_id:
|
|
344
|
+
job_id: optLine,
|
|
345
|
+
application_id: optLine,
|
|
312
346
|
status: z.enum(APPLICATION_STATUSES),
|
|
313
|
-
note:
|
|
347
|
+
note: optNote.describe('상태 변경 사유/메모'),
|
|
314
348
|
});
|
|
315
349
|
export type ApplicationStatusUpdate = z.infer<typeof ApplicationStatusUpdateSchema>;
|
|
316
350
|
|
|
@@ -328,28 +362,28 @@ export type ApplicationRecord = z.infer<typeof ApplicationRecordSchema>;
|
|
|
328
362
|
/* ----------------------------------------------------------- InterviewPrep */
|
|
329
363
|
|
|
330
364
|
export const StarGuideSchema = z.object({
|
|
331
|
-
question: z.string(),
|
|
332
|
-
situation:
|
|
333
|
-
task:
|
|
334
|
-
action:
|
|
335
|
-
result:
|
|
365
|
+
question: z.string().max(MAX_NOTE),
|
|
366
|
+
situation: optNote,
|
|
367
|
+
task: optNote,
|
|
368
|
+
action: optNote,
|
|
369
|
+
result: optNote,
|
|
336
370
|
});
|
|
337
371
|
export type StarGuide = z.infer<typeof StarGuideSchema>;
|
|
338
372
|
|
|
339
373
|
export const InterviewQuestionSchema = z.object({
|
|
340
|
-
question: z.string(),
|
|
341
|
-
intent: z.string().optional().describe('질문 의도'),
|
|
342
|
-
followups:
|
|
343
|
-
answer_outline:
|
|
374
|
+
question: z.string().max(MAX_NOTE),
|
|
375
|
+
intent: z.string().max(MAX_NOTE).optional().describe('질문 의도'),
|
|
376
|
+
followups: strList.optional().describe('예상 꼬리 질문'),
|
|
377
|
+
answer_outline: optBody.describe('답변 가이드/핵심 포인트'),
|
|
344
378
|
});
|
|
345
379
|
export type InterviewQuestion = z.infer<typeof InterviewQuestionSchema>;
|
|
346
380
|
|
|
347
381
|
export const InterviewPrepInputSchema = z.object({
|
|
348
|
-
job_id:
|
|
349
|
-
questions: z.array(InterviewQuestionSchema).optional(),
|
|
350
|
-
star_guides: z.array(StarGuideSchema).optional(),
|
|
351
|
-
self_introduction:
|
|
352
|
-
notes:
|
|
382
|
+
job_id: reqLine,
|
|
383
|
+
questions: z.array(InterviewQuestionSchema).max(MAX_ITEMS).optional(),
|
|
384
|
+
star_guides: z.array(StarGuideSchema).max(MAX_ITEMS).optional(),
|
|
385
|
+
self_introduction: optBody.describe('1분 자기소개 초안'),
|
|
386
|
+
notes: optNote.describe('면접 후기/메모'),
|
|
353
387
|
});
|
|
354
388
|
export type InterviewPrepInput = z.infer<typeof InterviewPrepInputSchema>;
|
|
355
389
|
|
package/scripts/build-dist.mjs
CHANGED
|
@@ -73,7 +73,13 @@ async function main() {
|
|
|
73
73
|
const demoDir = path.join(ROOT, 'install-page', 'demo');
|
|
74
74
|
fs.cpSync(path.join(ROOT, 'install-page'), path.join(DIST, 'install-page'), {
|
|
75
75
|
recursive: true,
|
|
76
|
-
filter: (src) =>
|
|
76
|
+
filter: (src) => {
|
|
77
|
+
const base = path.basename(src);
|
|
78
|
+
// OMC/OMX 오케스트레이션 상태(세션ID·리플레이 로그)는 사용자에게 불필요하고
|
|
79
|
+
// 게시 tarball로 새면 안 된다. install-page에 남아 있어도 dist→npm에는 제외한다.
|
|
80
|
+
if (base === '.omc' || base === '.omx') return false;
|
|
81
|
+
return base !== 'careermate.mcpb' && src !== demoDir;
|
|
82
|
+
},
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
// pdfjs 워커 사이드카: pdf.mjs는 번들에 인라인되지만, 워커는 런타임에 별도 파일로
|
package/scripts/gen-og.mjs
CHANGED
|
@@ -46,7 +46,7 @@ const ogHtml = `<!doctype html><meta charset="utf-8"><style>
|
|
|
46
46
|
<div class="h">내 <span class="accent">AI</span>를<br>나만의 <span class="accent">커리어 비서</span>로</div>
|
|
47
47
|
<div class="sub">이력서·자기소개서·지원 현황을 이 컴퓨터 안에서 관리하세요.<br>분석·글쓰기는 평소 쓰던 AI가, 데이터 보관은 CareerMate가.</div>
|
|
48
48
|
</div>
|
|
49
|
-
<div class="pill"><span class="dot"></span>로컬 전용 · MCP ·
|
|
49
|
+
<div class="pill"><span class="dot"></span>로컬 전용 · MCP · 무료</div>
|
|
50
50
|
</div>`;
|
|
51
51
|
|
|
52
52
|
const iconHtml = (size) => `<!doctype html><meta charset="utf-8"><style>
|
package/scripts/test.ts
CHANGED
|
@@ -107,6 +107,31 @@ ok('MCP가 API의 수정(한 줄 소개)을 즉시 봄 (←)', pr.profileHeadlin
|
|
|
107
107
|
ok('MCP save_job_posting', !!pr.jobId);
|
|
108
108
|
ok('대시보드(API)가 MCP가 저장한 공고를 봄 (→)', (await json('GET', '/api/jobs')).jobs.some((j: any) => j.id === pr.jobId));
|
|
109
109
|
|
|
110
|
+
/* --------------------------------------------- 5. 내보내기 HTML 인젝션 차단 (회귀) */
|
|
111
|
+
// Cover-letter/resume bodies are user/agent text rendered into exported .html
|
|
112
|
+
// (opened in a browser → live XSS surface). markdownToHtml MUST neutralize raw
|
|
113
|
+
// HTML and dangerous link schemes. These assertions lock that property so a
|
|
114
|
+
// future refactor of the markdown renderer can't silently reintroduce XSS.
|
|
115
|
+
section('5) 내보내기 HTML 인젝션 차단 (회귀)');
|
|
116
|
+
const { markdownToHtml } = await import('../packages/exporters/src/markdown.ts');
|
|
117
|
+
const { toPrintableHtml } = await import('../packages/exporters/src/html.ts');
|
|
118
|
+
|
|
119
|
+
const imgPayload = markdownToHtml('<img src=x onerror="alert(1)">');
|
|
120
|
+
ok('raw <img onerror> 무력화', !imgPayload.includes('<img') && imgPayload.includes('<img') && !imgPayload.includes('onerror="alert'));
|
|
121
|
+
|
|
122
|
+
const scriptPayload = markdownToHtml('<script>alert(1)</script>');
|
|
123
|
+
ok('raw <script> 무력화', !scriptPayload.includes('<script>') && scriptPayload.includes('<script>'));
|
|
124
|
+
|
|
125
|
+
const jsLink = markdownToHtml('[클릭](javascript:alert(1))');
|
|
126
|
+
ok('javascript: 링크 차단(→ #)', jsLink.includes('href="#"') && !jsLink.toLowerCase().includes('javascript:'));
|
|
127
|
+
|
|
128
|
+
// Attribute-breakout attempt: a quote inside an otherwise-valid https URL.
|
|
129
|
+
const breakout = markdownToHtml('[x](https://a.com" onmouseover="alert(1))');
|
|
130
|
+
ok('링크 속성 탈출 차단', !breakout.includes('onmouseover="alert') && breakout.includes('"'));
|
|
131
|
+
|
|
132
|
+
const titleInj = toPrintableHtml('</title><script>alert(1)</script>', '본문');
|
|
133
|
+
ok('내보내기 제목 인젝션 차단', !titleInj.includes('<script>') && !titleInj.includes('</title><script>'));
|
|
134
|
+
|
|
110
135
|
console.log(`\n${'='.repeat(40)}`);
|
|
111
136
|
console.log(`결과: ${pass} 통과 · ${fail} 실패`);
|
|
112
137
|
console.log(fail === 0 ? '✅ 전체 통과' : '❌ 실패 있음');
|
package/scripts/ui-smoke.ts
CHANGED
|
@@ -25,18 +25,20 @@ saveCoverLetterVersion({ job_id: job.id, title: '자소서', content: '내용 v1
|
|
|
25
25
|
updateApplicationStatus(job.id, 'document_passed');
|
|
26
26
|
saveInterviewPrep({ job_id: job.id, questions: [{ question: '질문?' }], self_introduction: '소개' });
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Skip gracefully if either the package OR the browser binary is missing — both
|
|
29
|
+
// are optional dev deps, and a confusing uncaught launch error helps no one.
|
|
30
|
+
let browser: any;
|
|
29
31
|
try {
|
|
30
|
-
|
|
32
|
+
const { chromium } = await import('playwright');
|
|
33
|
+
browser = await chromium.launch();
|
|
31
34
|
} catch {
|
|
32
|
-
console.log('⚠️ Playwright가
|
|
35
|
+
console.log('⚠️ Playwright(또는 Chromium 브라우저)가 없어 UI 스모크를 건너뜁니다.');
|
|
33
36
|
console.log(' 설치: npm i -D playwright && npx playwright install chromium');
|
|
34
37
|
process.exit(0);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const { port, server } = await startServer(46050);
|
|
38
41
|
const base = `http://127.0.0.1:${port}`;
|
|
39
|
-
const browser = await chromium.launch();
|
|
40
42
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
41
43
|
|
|
42
44
|
const errors: string[] = [];
|
|
@@ -62,6 +64,32 @@ for (const [name, hash] of routes) {
|
|
|
62
64
|
|
|
63
65
|
if (errors.length) { console.log('\n콘솔/페이지 오류:'); errors.forEach((e) => console.log(' ' + e)); }
|
|
64
66
|
failures += errors.length;
|
|
67
|
+
|
|
68
|
+
// Responsive: every page must fit its viewport with no horizontal overflow at
|
|
69
|
+
// a laptop (1366×768) and a small phone (390×844). A page wider than the
|
|
70
|
+
// viewport means a broken layout (off-screen content, horizontal scrollbar).
|
|
71
|
+
// 1px tolerance for sub-pixel rounding.
|
|
72
|
+
const viewports: [string, number, number][] = [
|
|
73
|
+
['laptop-1366', 1366, 768],
|
|
74
|
+
['phone-390', 390, 844],
|
|
75
|
+
];
|
|
76
|
+
for (const [vpName, width, height] of viewports) {
|
|
77
|
+
await page.setViewportSize({ width, height });
|
|
78
|
+
for (const [name, hash] of routes) {
|
|
79
|
+
route = `${vpName}/${name}`;
|
|
80
|
+
await page.goto(base + '/' + hash, { waitUntil: 'networkidle' });
|
|
81
|
+
await page.waitForTimeout(250);
|
|
82
|
+
// Runs in the browser; reach DOM globals via globalThis so this node-libbed
|
|
83
|
+
// file still typechecks (tsc has no 'dom' lib here).
|
|
84
|
+
const overflow: number = await page.evaluate(() => {
|
|
85
|
+
const w = globalThis as any;
|
|
86
|
+
return Math.max(w.document.documentElement.scrollWidth, w.document.body.scrollWidth) - w.innerWidth;
|
|
87
|
+
});
|
|
88
|
+
const ok = overflow <= 1;
|
|
89
|
+
if (!ok) failures++;
|
|
90
|
+
console.log(` ${ok ? '✅' : '❌'} ${vpName}/${name.padEnd(11)} overflow=${overflow}px`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
65
93
|
console.log(`\n${failures === 0 ? '✅ UI 스모크 테스트 통과' : `❌ 실패 ${failures}건`}`);
|
|
66
94
|
console.log(failures === 0 ? 'UI_VERDICT PASS' : `UI_VERDICT FAIL ${failures}`);
|
|
67
95
|
|
package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{"t":0,"agent":"a10f819","agent_type":"workflow-subagent","event":"agent_start","parent_mode":"none"}
|
|
2
|
-
{"t":0,"agent":"a10f819","agent_type":"workflow-subagent","event":"agent_stop","success":true,"duration_ms":143102}
|
|
3
|
-
{"t":0,"agent":"aca9cea","agent_type":"workflow-subagent","event":"agent_start","parent_mode":"none"}
|
|
4
|
-
{"t":0,"agent":"a4768e2","agent_type":"workflow-subagent","event":"agent_start","parent_mode":"none"}
|
|
5
|
-
{"t":0,"agent":"a349ced","agent_type":"workflow-subagent","event":"agent_start","parent_mode":"none"}
|
|
6
|
-
{"t":0,"agent":"a349ced","agent_type":"workflow-subagent","event":"agent_stop","success":true,"duration_ms":60038}
|
|
7
|
-
{"t":0,"agent":"a4768e2","agent_type":"workflow-subagent","event":"agent_stop","success":true,"duration_ms":114215}
|
|
8
|
-
{"t":0,"agent":"aca9cea","agent_type":"workflow-subagent","event":"agent_stop","success":true,"duration_ms":121159}
|
|
9
|
-
{"t":0,"agent":"acc5653","agent_type":"unknown","event":"agent_stop","success":true}
|
|
10
|
-
{"t":0,"agent":"a0edd5d","agent_type":"unknown","event":"agent_stop","success":true}
|
|
11
|
-
{"t":0,"agent":"a245f43","agent_type":"unknown","event":"agent_stop","success":true}
|
|
12
|
-
{"t":0,"agent":"aefa1c0","agent_type":"unknown","event":"agent_stop","success":true}
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"updatedAt": "2026-06-16T12:38:24.867Z",
|
|
3
|
-
"missions": [
|
|
4
|
-
{
|
|
5
|
-
"id": "session:55a58c0d-bb6e-43ea-8a05-4b6c93b80b34:none",
|
|
6
|
-
"source": "session",
|
|
7
|
-
"name": "none",
|
|
8
|
-
"objective": "Session mission",
|
|
9
|
-
"createdAt": "2026-06-16T12:14:33.928Z",
|
|
10
|
-
"updatedAt": "2026-06-16T12:38:24.867Z",
|
|
11
|
-
"status": "done",
|
|
12
|
-
"workerCount": 4,
|
|
13
|
-
"taskCounts": {
|
|
14
|
-
"total": 4,
|
|
15
|
-
"pending": 0,
|
|
16
|
-
"blocked": 0,
|
|
17
|
-
"inProgress": 0,
|
|
18
|
-
"completed": 4,
|
|
19
|
-
"failed": 0
|
|
20
|
-
},
|
|
21
|
-
"agents": [
|
|
22
|
-
{
|
|
23
|
-
"name": "workflow-subagent:a10f819",
|
|
24
|
-
"role": "workflow-subagent",
|
|
25
|
-
"ownership": "a10f8198a3ec53eed",
|
|
26
|
-
"status": "done",
|
|
27
|
-
"currentStep": null,
|
|
28
|
-
"latestUpdate": "completed",
|
|
29
|
-
"completedSummary": null,
|
|
30
|
-
"updatedAt": "2026-06-16T12:38:24.867Z"
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
"name": "workflow-subagent:aca9cea",
|
|
34
|
-
"role": "workflow-subagent",
|
|
35
|
-
"ownership": "aca9cea71d20c99c3",
|
|
36
|
-
"status": "done",
|
|
37
|
-
"currentStep": null,
|
|
38
|
-
"latestUpdate": "completed",
|
|
39
|
-
"completedSummary": null,
|
|
40
|
-
"updatedAt": "2026-06-16T12:18:58.856Z"
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"name": "workflow-subagent:a4768e2",
|
|
44
|
-
"role": "workflow-subagent",
|
|
45
|
-
"ownership": "a4768e2be198162e9",
|
|
46
|
-
"status": "done",
|
|
47
|
-
"currentStep": null,
|
|
48
|
-
"latestUpdate": "completed",
|
|
49
|
-
"completedSummary": null,
|
|
50
|
-
"updatedAt": "2026-06-16T12:18:51.989Z"
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"name": "workflow-subagent:a349ced",
|
|
54
|
-
"role": "workflow-subagent",
|
|
55
|
-
"ownership": "a349cedbc216266f2",
|
|
56
|
-
"status": "done",
|
|
57
|
-
"currentStep": null,
|
|
58
|
-
"latestUpdate": "completed",
|
|
59
|
-
"completedSummary": null,
|
|
60
|
-
"updatedAt": "2026-06-16T12:17:57.876Z"
|
|
61
|
-
}
|
|
62
|
-
],
|
|
63
|
-
"timeline": [
|
|
64
|
-
{
|
|
65
|
-
"id": "session-start:aca9cea71d20c99c3:2026-06-16T12:16:57.697Z",
|
|
66
|
-
"at": "2026-06-16T12:16:57.697Z",
|
|
67
|
-
"kind": "update",
|
|
68
|
-
"agent": "workflow-subagent:aca9cea",
|
|
69
|
-
"detail": "started workflow-subagent:aca9cea",
|
|
70
|
-
"sourceKey": "session-start:aca9cea71d20c99c3"
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
"id": "session-start:a4768e2be198162e9:2026-06-16T12:16:57.774Z",
|
|
74
|
-
"at": "2026-06-16T12:16:57.774Z",
|
|
75
|
-
"kind": "update",
|
|
76
|
-
"agent": "workflow-subagent:a4768e2",
|
|
77
|
-
"detail": "started workflow-subagent:a4768e2",
|
|
78
|
-
"sourceKey": "session-start:a4768e2be198162e9"
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
"id": "session-start:a349cedbc216266f2:2026-06-16T12:16:57.838Z",
|
|
82
|
-
"at": "2026-06-16T12:16:57.838Z",
|
|
83
|
-
"kind": "update",
|
|
84
|
-
"agent": "workflow-subagent:a349ced",
|
|
85
|
-
"detail": "started workflow-subagent:a349ced",
|
|
86
|
-
"sourceKey": "session-start:a349cedbc216266f2"
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
"id": "session-stop:a349cedbc216266f2:2026-06-16T12:17:57.876Z",
|
|
90
|
-
"at": "2026-06-16T12:17:57.876Z",
|
|
91
|
-
"kind": "completion",
|
|
92
|
-
"agent": "workflow-subagent:a349ced",
|
|
93
|
-
"detail": "completed",
|
|
94
|
-
"sourceKey": "session-stop:a349cedbc216266f2"
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
"id": "session-stop:a4768e2be198162e9:2026-06-16T12:18:51.989Z",
|
|
98
|
-
"at": "2026-06-16T12:18:51.989Z",
|
|
99
|
-
"kind": "completion",
|
|
100
|
-
"agent": "workflow-subagent:a4768e2",
|
|
101
|
-
"detail": "completed",
|
|
102
|
-
"sourceKey": "session-stop:a4768e2be198162e9"
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
"id": "session-stop:aca9cea71d20c99c3:2026-06-16T12:18:58.856Z",
|
|
106
|
-
"at": "2026-06-16T12:18:58.856Z",
|
|
107
|
-
"kind": "completion",
|
|
108
|
-
"agent": "workflow-subagent:aca9cea",
|
|
109
|
-
"detail": "completed",
|
|
110
|
-
"sourceKey": "session-stop:aca9cea71d20c99c3"
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
"id": "session-stop:acc5653c169518c6f:2026-06-16T12:27:45.167Z",
|
|
114
|
-
"at": "2026-06-16T12:27:45.167Z",
|
|
115
|
-
"kind": "completion",
|
|
116
|
-
"agent": "workflow-subagent:a10f819",
|
|
117
|
-
"detail": "completed",
|
|
118
|
-
"sourceKey": "session-stop:acc5653c169518c6f"
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
"id": "session-stop:a0edd5de5c096a00d:2026-06-16T12:30:47.256Z",
|
|
122
|
-
"at": "2026-06-16T12:30:47.256Z",
|
|
123
|
-
"kind": "completion",
|
|
124
|
-
"agent": "workflow-subagent:a10f819",
|
|
125
|
-
"detail": "completed",
|
|
126
|
-
"sourceKey": "session-stop:a0edd5de5c096a00d"
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
"id": "session-stop:a245f437d88f5966f:2026-06-16T12:35:27.111Z",
|
|
130
|
-
"at": "2026-06-16T12:35:27.111Z",
|
|
131
|
-
"kind": "completion",
|
|
132
|
-
"agent": "workflow-subagent:a10f819",
|
|
133
|
-
"detail": "completed",
|
|
134
|
-
"sourceKey": "session-stop:a245f437d88f5966f"
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
"id": "session-stop:aefa1c00c62034d9b:2026-06-16T12:38:24.867Z",
|
|
138
|
-
"at": "2026-06-16T12:38:24.867Z",
|
|
139
|
-
"kind": "completion",
|
|
140
|
-
"agent": "workflow-subagent:a10f819",
|
|
141
|
-
"detail": "completed",
|
|
142
|
-
"sourceKey": "session-stop:aefa1c00c62034d9b"
|
|
143
|
-
}
|
|
144
|
-
]
|
|
145
|
-
}
|
|
146
|
-
]
|
|
147
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"entries": {
|
|
4
|
-
"79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
|
|
5
|
-
"last_emitted_at_ms": 1781613182539,
|
|
6
|
-
"message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
|
|
7
|
-
},
|
|
8
|
-
"445ed27a3872b681d98190bae61ccb84954e1bc4e140df5370be958dee776b3a": {
|
|
9
|
-
"last_emitted_at_ms": 1781612517441,
|
|
10
|
-
"message": "Verify changes work after editing. Test functionality before marking complete."
|
|
11
|
-
},
|
|
12
|
-
"466399dafa2d20f60587180bad0a07358eb7f2bce724df0ff682f038ad33f5ad": {
|
|
13
|
-
"last_emitted_at_ms": 1781612380768,
|
|
14
|
-
"message": "Read multiple files in parallel when possible for faster analysis."
|
|
15
|
-
},
|
|
16
|
-
"459c54e5f35d6a58dcd90d2aecc21fa6d1f35d241bc5b134c506f87dc7b6d67b": {
|
|
17
|
-
"last_emitted_at_ms": 1781612184981,
|
|
18
|
-
"message": "Combine searches in parallel when investigating multiple patterns."
|
|
19
|
-
}
|
|
20
|
-
},
|
|
21
|
-
"updated_at": "2026-06-16T12:33:02.539Z"
|
|
22
|
-
}
|