@hanmariyang/drafting 1.6.2

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/api/dist/db/index.js +107 -0
  4. package/api/dist/db/repos.js +670 -0
  5. package/api/dist/index.js +89 -0
  6. package/api/dist/lib/ai.js +314 -0
  7. package/api/dist/lib/config.js +57 -0
  8. package/api/dist/lib/crypto.js +71 -0
  9. package/api/dist/lib/design-system-gen.js +332 -0
  10. package/api/dist/lib/fixtures.js +150 -0
  11. package/api/dist/lib/gateway.js +55 -0
  12. package/api/dist/lib/handoff.js +283 -0
  13. package/api/dist/lib/items-gen.js +211 -0
  14. package/api/dist/lib/lint-service.js +118 -0
  15. package/api/dist/lib/lint.js +141 -0
  16. package/api/dist/lib/mockup-gen.js +136 -0
  17. package/api/dist/lib/numbering.js +75 -0
  18. package/api/dist/lib/provider-errors.js +31 -0
  19. package/api/dist/lib/render.js +154 -0
  20. package/api/dist/lib/style-guide.js +47 -0
  21. package/api/dist/lib/templates.js +85 -0
  22. package/api/dist/lib/types.js +1 -0
  23. package/api/dist/lib/wireframes.js +137 -0
  24. package/api/dist/providers/byok/anthropic.js +75 -0
  25. package/api/dist/providers/byok/openai-compat.js +95 -0
  26. package/api/dist/providers/cli.js +391 -0
  27. package/api/dist/providers/index.js +68 -0
  28. package/api/dist/providers/managed.js +22 -0
  29. package/api/dist/providers/sse.js +37 -0
  30. package/api/dist/providers/stub.js +55 -0
  31. package/api/dist/providers/types.js +1 -0
  32. package/api/dist/routes/backup.js +24 -0
  33. package/api/dist/routes/deliverables.js +588 -0
  34. package/api/dist/routes/documents.js +205 -0
  35. package/api/dist/routes/helpers.js +43 -0
  36. package/api/dist/routes/interview.js +141 -0
  37. package/api/dist/routes/keys.js +70 -0
  38. package/api/dist/routes/projects.js +103 -0
  39. package/api/dist/routes/settings.js +134 -0
  40. package/api/dist/routes/share.js +39 -0
  41. package/api/dist/routes/suggestions.js +144 -0
  42. package/api/templates/design-system.json +17 -0
  43. package/api/templates/feature-spec.json +73 -0
  44. package/api/templates/ia.json +52 -0
  45. package/api/templates/prd.json +60 -0
  46. package/api/templates/user-flow.json +61 -0
  47. package/bin/drafting.mjs +79 -0
  48. package/db/schema.sql +154 -0
  49. package/package.json +62 -0
  50. package/web/dist/assets/index-CS06cWP3.js +125 -0
  51. package/web/dist/assets/index-DWoYeaZU.css +1 -0
  52. package/web/dist/index.html +14 -0
@@ -0,0 +1,89 @@
1
+ import Fastify from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import fastifyStatic from '@fastify/static';
4
+ import fs from 'node:fs';
5
+ import { pathToFileURL } from 'node:url';
6
+ import path from 'node:path';
7
+ import { config } from "./lib/config.js";
8
+ import { getDb } from "./db/index.js";
9
+ import { getMasterKey } from "./lib/crypto.js";
10
+ import { loadTemplates } from "./lib/templates.js";
11
+ import { HttpError } from "./routes/helpers.js";
12
+ import { projectRoutes } from "./routes/projects.js";
13
+ import { documentRoutes } from "./routes/documents.js";
14
+ import { interviewRoutes } from "./routes/interview.js";
15
+ import { suggestionRoutes } from "./routes/suggestions.js";
16
+ import { deliverableRoutes } from "./routes/deliverables.js";
17
+ import { keyRoutes } from "./routes/keys.js";
18
+ import { settingsRoutes } from "./routes/settings.js";
19
+ import { shareRoutes } from "./routes/share.js";
20
+ import { backupRoutes } from "./routes/backup.js";
21
+ export async function buildServer() {
22
+ // fail fast on infra: db schema applied, master key resolvable, templates loaded
23
+ getDb();
24
+ getMasterKey();
25
+ loadTemplates();
26
+ const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
27
+ // 보안: 기본은 same-origin(교차 오리진 차단) — 무인증 로컬 API 를 악성 페이지의
28
+ // CSRF·데이터 유출(/api/backup 등)로부터 보호. 리버스 프록시 구성은 env 로 허용 오리진 지정.
29
+ await app.register(cors, {
30
+ origin: config.allowOrigins.length ? config.allowOrigins : false,
31
+ });
32
+ // Tolerate empty bodies on POST/PUT/DELETE that carry an application/json
33
+ // content-type but no payload (browsers set the header even with no body).
34
+ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
35
+ const text = body.trim();
36
+ if (!text)
37
+ return done(null, undefined);
38
+ try {
39
+ done(null, JSON.parse(text));
40
+ }
41
+ catch (err) {
42
+ err.statusCode = 400;
43
+ done(err, undefined);
44
+ }
45
+ });
46
+ app.setErrorHandler((err, _req, reply) => {
47
+ if (err instanceof HttpError) {
48
+ reply.code(err.status).send({ error: err.message });
49
+ return;
50
+ }
51
+ app.log.error(err);
52
+ reply.code(500).send({ error: err?.message ?? 'internal error' });
53
+ });
54
+ await app.register(projectRoutes);
55
+ await app.register(documentRoutes);
56
+ await app.register(interviewRoutes);
57
+ await app.register(suggestionRoutes);
58
+ await app.register(deliverableRoutes);
59
+ await app.register(keyRoutes);
60
+ await app.register(settingsRoutes);
61
+ await app.register(shareRoutes);
62
+ await app.register(backupRoutes);
63
+ app.get('/api/health', async () => ({ ok: true, version: config.version }));
64
+ // serve the built SPA in production (dev uses the vite server)
65
+ if (fs.existsSync(config.webDist)) {
66
+ await app.register(fastifyStatic, { root: config.webDist, wildcard: false });
67
+ app.setNotFoundHandler((req, reply) => {
68
+ if (req.url.startsWith('/api') || req.url.startsWith('/s/')) {
69
+ reply.code(404).send({ error: 'not found' });
70
+ return;
71
+ }
72
+ reply.type('text/html').send(fs.readFileSync(path.join(config.webDist, 'index.html')));
73
+ });
74
+ }
75
+ return app;
76
+ }
77
+ // only start when run directly (tests import buildServer without listening)
78
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
79
+ if (isMain) {
80
+ const app = await buildServer();
81
+ try {
82
+ await app.listen({ port: config.port, host: config.host });
83
+ app.log.info(`Drafting listening on http://${config.host}:${config.port}`);
84
+ }
85
+ catch (err) {
86
+ app.log.error(err);
87
+ process.exit(1);
88
+ }
89
+ }
@@ -0,0 +1,314 @@
1
+ import { resolveProvider } from "../providers/index.js";
2
+ import { getTemplateForType } from "./templates.js";
3
+ import { getSetting } from "../db/repos.js";
4
+ import * as repo from "../db/repos.js";
5
+ const DEFAULT_MODELS = {
6
+ anthropic: 'claude-sonnet-4-6',
7
+ openai: 'gpt-4o-mini',
8
+ // 3.5-sonnet 은 OpenRouter 에서 deprecated(404) — 활성 4.x 최신으로.
9
+ openrouter: 'anthropic/claude-sonnet-4.6',
10
+ };
11
+ const PROVIDER_ORDER = ['openrouter', 'anthropic', 'openai'];
12
+ function pickDefaultProvider() {
13
+ for (const p of PROVIDER_ORDER) {
14
+ if (repo.getKeyMeta(p))
15
+ return p;
16
+ }
17
+ return 'openrouter';
18
+ }
19
+ /** Per-document-type model + token budget (SPEC-18/19). Settings override defaults. */
20
+ export function getModelConfig(docType) {
21
+ const stored = getSetting('provider_models') ?? {};
22
+ const base = {
23
+ provider: pickDefaultProvider(),
24
+ model: '',
25
+ // 추론형(reasoning) 모델은 추론 토큰이 예산을 먹어 본문이 비기 쉽다 — 헤드룸 확보.
26
+ maxTokens: 8192,
27
+ };
28
+ const merged = { ...base, ...(stored.default ?? {}), ...(stored[docType] ?? {}) };
29
+ if (!merged.provider)
30
+ merged.provider = base.provider;
31
+ // 모델 미지정 시: 게이트웨이가 설정돼 있으면 그 게이트웨이가 제공하는 첫 모델을 기본값으로
32
+ // (하드코딩 claude 기본값은 게이트웨이 키가 접근 못 해 거부되는 전형을 방지).
33
+ if (!merged.model)
34
+ merged.model = gatewayDefaultModel(merged.provider) ?? DEFAULT_MODELS[merged.provider];
35
+ if (!merged.maxTokens)
36
+ merged.maxTokens = 8192;
37
+ return merged;
38
+ }
39
+ /** 게이트웨이(openai_base_url) 가 설정된 openai/openrouter 면 그 게이트웨이의 첫 모델을 반환. */
40
+ function gatewayDefaultModel(provider) {
41
+ if (provider !== 'openai' && provider !== 'openrouter')
42
+ return null;
43
+ const base = getSetting('openai_base_url') || '';
44
+ if (!base)
45
+ return null; // 표준 provider — 하드코딩 기본 모델 유지
46
+ const models = getSetting('openai_models') ?? [];
47
+ return models.length ? models[0] : null;
48
+ }
49
+ // ── prompt construction ──────────────────────────────────────────────────────
50
+ function parentContextBlock(documentId) {
51
+ const ctx = repo.getParentContext(documentId);
52
+ if (!ctx)
53
+ return '';
54
+ const body = ctx.sections
55
+ .map((s) => `### ${s.heading}\n${s.body}`)
56
+ .join('\n\n');
57
+ return (`상위 문서 컨텍스트 (${ctx.parentType}, "${ctx.parentTitle}", v${ctx.parentVersion}) — ` +
58
+ `이 내용과 정합하도록 작성하라:\n\n${body}\n\n---\n`);
59
+ }
60
+ function answersBlock(answers) {
61
+ if (!answers.length)
62
+ return '(인터뷰 답변 없음 — 합리적 기본값으로 작성)';
63
+ return answers.map((a) => `Q: ${a.question}\nA: ${a.answer}`).join('\n\n');
64
+ }
65
+ /** Short human basis label for a suggestion (SYSTEM.md §0.3). */
66
+ export function draftSourceLabel(answers) {
67
+ if (!answers.length)
68
+ return '인터뷰 (기본값)';
69
+ const ids = answers
70
+ .map((a) => a.questionId)
71
+ .filter(Boolean)
72
+ .slice(0, 4);
73
+ return ids.length ? `인터뷰 ${ids.join('·')}` : '인터뷰';
74
+ }
75
+ /** Messages to draft one section. Section-by-section keeps SSE boundaries clean. */
76
+ export function buildSectionMessages(params) {
77
+ const parent = parentContextBlock(params.documentId);
78
+ // 문서 전체 섹션 구성을 알려줘야 섹션 간 내용 중복(개요에 문제 정의 통째 포함 등)이 없다
79
+ const allSections = getTemplateForType(params.docType)?.sections ?? [];
80
+ const outline = allSections.length
81
+ ? `이 문서의 전체 섹션 구성: ${allSections.map((s) => `"${s}"`).join(' · ')}. ` +
82
+ `너는 그중 "${params.heading}" 하나만 쓴다. 다른 섹션에서 다룰 내용은 이 섹션에서 반복하거나 미리 쓰지 말라. `
83
+ : '';
84
+ const system = `${params.guidance}\n\n` +
85
+ `너는 지금 "${params.docType}" 문서의 한 섹션만 작성한다. ` +
86
+ outline +
87
+ `섹션 제목("${params.heading}")은 앱이 별도로 표시하므로 본문에 다시 쓰지 말라. ` +
88
+ `제목이나 다른 섹션은 쓰지 말고, 요청된 섹션 본문만 마크다운으로 출력하라. ` +
89
+ `간결하고 구체적으로, 불릿과 짧은 문단을 섞어 작성하라.`;
90
+ const user = `${parent}` +
91
+ `아래는 기획 인터뷰 답변이다:\n\n${answersBlock(params.answers)}\n\n` +
92
+ `이제 다음 섹션을 작성하라.\n섹션 제목: ${params.heading}\n`;
93
+ return [
94
+ { role: 'system', content: system },
95
+ { role: 'user', content: user },
96
+ ];
97
+ }
98
+ /**
99
+ * 모델이 프롬프트 지시를 어기고 본문 첫 줄에 섹션 제목을 반복하는 경우가 있다
100
+ * ("## 문제 정의" / "**문제 정의**" / "문제 정의:" 등). 앱이 heading 을 별도
101
+ * 렌더하므로 저장 전에 걷어낸다. 제목과 일치할 때만 — 일반 본문은 건드리지 않는다.
102
+ */
103
+ export function stripLeadingHeading(body, heading) {
104
+ const trimmed = body.trim();
105
+ const nl = trimmed.indexOf('\n');
106
+ const first = (nl === -1 ? trimmed : trimmed.slice(0, nl)).trim();
107
+ const normalize = (s) => s
108
+ .replace(/^#{1,6}\s*/, '') // 마크다운 heading 마커
109
+ .replace(/^\*\*(.*)\*\*$/, '$1') // 볼드 감싸기
110
+ .replace(/[\s::.]+$/, '') // 꼬리 콜론·마침표
111
+ .trim();
112
+ if (normalize(first) !== normalize(heading))
113
+ return trimmed;
114
+ return nl === -1 ? '' : trimmed.slice(nl + 1).trim();
115
+ }
116
+ /**
117
+ * Generate a full document draft, section by section. Creates empty sections
118
+ * up-front (so each has a stable id the client can target), then streams each
119
+ * one. Emits SSE-shaped events per docs/spec/ux-mode-transition.md §3.
120
+ */
121
+ export async function* streamDocumentDraft(documentId, signal) {
122
+ const doc = repo.getDocument(documentId);
123
+ if (!doc) {
124
+ yield { type: 'error', message: 'document not found' };
125
+ return;
126
+ }
127
+ const template = getTemplateForType(doc.type);
128
+ const headings = template?.sections ?? ['개요'];
129
+ const session = repo.getSessionByDocument(documentId);
130
+ const answers = session?.answers ?? [];
131
+ const guidance = template?.draftGuidance ?? '명확한 기획 문서를 작성한다.';
132
+ const cfg = getModelConfig(doc.type);
133
+ const provider = resolveProvider(cfg.provider);
134
+ repo.setDocumentStatus(documentId, 'streaming');
135
+ // AI output is always a PROPOSAL (SYSTEM.md §0.1). Create empty sections
136
+ // up-front (stable ids for the client) in 'proposed' state.
137
+ const created = repo.replaceSections(documentId, headings.map((h) => ({ heading: h, body: '' })), 'proposed');
138
+ // Basis for the whole draft = the interview answers behind it (§0.3).
139
+ const draftSource = draftSourceLabel(answers);
140
+ try {
141
+ for (let i = 0; i < created.length; i++) {
142
+ const section = created[i];
143
+ yield {
144
+ type: 'section_start',
145
+ sectionId: section.id,
146
+ heading: section.heading,
147
+ index: i,
148
+ total: created.length,
149
+ };
150
+ const messages = buildSectionMessages({
151
+ documentId,
152
+ docType: doc.type,
153
+ heading: section.heading,
154
+ answers,
155
+ guidance,
156
+ });
157
+ let body = '';
158
+ for await (const delta of provider.streamChat({
159
+ model: cfg.model,
160
+ maxTokens: cfg.maxTokens,
161
+ messages,
162
+ signal,
163
+ })) {
164
+ body += delta;
165
+ yield { type: 'token', sectionId: section.id, delta };
166
+ }
167
+ const finalBody = stripLeadingHeading(body, section.heading);
168
+ repo.updateSection(section.id, { body: finalBody });
169
+ // Each generated section arrives as an 'add' proposal with its basis.
170
+ repo.createSuggestion({
171
+ documentId,
172
+ sectionId: section.id,
173
+ kind: 'add',
174
+ title: `"${section.heading}" 섹션 초안`,
175
+ body: '인터뷰 답변을 바탕으로 생성된 초안입니다. 수락하면 문서에 반영됩니다.',
176
+ quoteAfter: finalBody,
177
+ source: draftSource,
178
+ });
179
+ yield { type: 'section_end', sectionId: section.id };
180
+ }
181
+ repo.setDocumentStatus(documentId, 'ready');
182
+ repo.snapshotDocument(documentId, 'save', { reason: 'initial_draft' });
183
+ yield { type: 'done', documentId };
184
+ }
185
+ catch (e) {
186
+ repo.setDocumentStatus(documentId, 'draft');
187
+ yield { type: 'error', message: e.message };
188
+ }
189
+ }
190
+ /**
191
+ * Rewrite one section from an explicit user instruction (SYSTEM.md §0.4, the
192
+ * 3rd handling option). Non-streaming: runs the provider to completion, sets the
193
+ * section back to 'proposed', and returns a NEW 'revise' suggestion whose source
194
+ * is the user's own instruction. Works with the stub provider (no network).
195
+ */
196
+ export async function rewriteSection(sectionId, instruction) {
197
+ const section = repo.getSection(sectionId);
198
+ if (!section)
199
+ return null;
200
+ const doc = repo.getDocument(section.document_id);
201
+ if (!doc)
202
+ return null;
203
+ const template = getTemplateForType(doc.type);
204
+ const session = repo.getSessionByDocument(doc.id);
205
+ const answers = session?.answers ?? [];
206
+ const guidance = template?.draftGuidance ?? '명확한 기획 문서를 작성한다.';
207
+ const cfg = getModelConfig(doc.type);
208
+ const provider = resolveProvider(cfg.provider);
209
+ const messages = buildSectionMessages({
210
+ documentId: doc.id,
211
+ docType: doc.type,
212
+ heading: section.heading,
213
+ answers,
214
+ guidance,
215
+ });
216
+ // append the user's rewrite instruction + the current text as context
217
+ messages.push({
218
+ role: 'user',
219
+ content: `현재 "${section.heading}" 섹션 본문:\n\n${section.body}\n\n` +
220
+ `아래 지시에 따라 이 섹션을 다시 써라. 섹션 본문만 마크다운으로 출력하라.\n` +
221
+ `지시: ${instruction}`,
222
+ });
223
+ const before = section.body;
224
+ let body = '';
225
+ for await (const delta of provider.streamChat({
226
+ model: cfg.model,
227
+ maxTokens: cfg.maxTokens,
228
+ messages,
229
+ })) {
230
+ body += delta;
231
+ }
232
+ const next = stripLeadingHeading(body, section.heading);
233
+ repo.updateSection(section.id, { body: next });
234
+ repo.setSectionStatus(section.id, 'proposed');
235
+ repo.snapshotDocument(doc.id, 'save', { reason: 'rewrite_section', sectionId });
236
+ const trimmed = instruction.trim();
237
+ const suggestion = repo.createSuggestion({
238
+ documentId: doc.id,
239
+ sectionId: section.id,
240
+ kind: 'revise',
241
+ title: `"${section.heading}" 고쳐쓰기`,
242
+ body: '사용자 지시로 재작성했습니다. 수락하면 문서에 반영됩니다.',
243
+ quoteBefore: before,
244
+ quoteAfter: next,
245
+ source: `사용자 지시: ${trimmed.length > 60 ? trimmed.slice(0, 60) + '…' : trimmed}`,
246
+ });
247
+ return { section: repo.getSection(section.id), suggestion };
248
+ }
249
+ /** Regenerate a single section (SPEC-07) — only this section is replaced. */
250
+ export async function* streamSectionRegeneration(sectionId, signal) {
251
+ const section = repo.getSection(sectionId);
252
+ if (!section) {
253
+ yield { type: 'error', message: 'section not found' };
254
+ return;
255
+ }
256
+ const doc = repo.getDocument(section.document_id);
257
+ if (!doc) {
258
+ yield { type: 'error', message: 'document not found' };
259
+ return;
260
+ }
261
+ const template = getTemplateForType(doc.type);
262
+ const session = repo.getSessionByDocument(doc.id);
263
+ const answers = session?.answers ?? [];
264
+ const guidance = template?.draftGuidance ?? '명확한 기획 문서를 작성한다.';
265
+ const cfg = getModelConfig(doc.type);
266
+ const provider = resolveProvider(cfg.provider);
267
+ const before = section.body;
268
+ yield {
269
+ type: 'section_start',
270
+ sectionId: section.id,
271
+ heading: section.heading,
272
+ index: 0,
273
+ total: 1,
274
+ };
275
+ try {
276
+ const messages = buildSectionMessages({
277
+ documentId: doc.id,
278
+ docType: doc.type,
279
+ heading: section.heading,
280
+ answers,
281
+ guidance,
282
+ });
283
+ let body = '';
284
+ for await (const delta of provider.streamChat({
285
+ model: cfg.model,
286
+ maxTokens: cfg.maxTokens,
287
+ messages,
288
+ signal,
289
+ })) {
290
+ body += delta;
291
+ yield { type: 'token', sectionId: section.id, delta };
292
+ }
293
+ // Regenerated content is a fresh PROPOSAL — back to 'proposed' until re-accepted.
294
+ const finalBody = stripLeadingHeading(body, section.heading);
295
+ repo.updateSection(section.id, { body: finalBody });
296
+ repo.setSectionStatus(section.id, 'proposed');
297
+ repo.createSuggestion({
298
+ documentId: doc.id,
299
+ sectionId: section.id,
300
+ kind: 'revise',
301
+ title: `"${section.heading}" 섹션 재생성`,
302
+ body: '섹션을 재생성했습니다. 수락하면 새 본문이 문서에 반영됩니다.',
303
+ quoteBefore: before,
304
+ quoteAfter: finalBody,
305
+ source: draftSourceLabel(answers),
306
+ });
307
+ yield { type: 'section_end', sectionId: section.id };
308
+ repo.snapshotDocument(doc.id, 'save', { reason: 'regenerate_section', sectionId });
309
+ yield { type: 'done', documentId: doc.id };
310
+ }
311
+ catch (e) {
312
+ yield { type: 'error', message: e.message };
313
+ }
314
+ }
@@ -0,0 +1,57 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
+ /**
6
+ * Find the repo root by walking up until we see db/schema.sql. Works whether the
7
+ * code runs from api/src/lib (dev/tests, .ts) or api/dist/lib (prod build, .js).
8
+ */
9
+ function findRepoRoot(start) {
10
+ let dir = start;
11
+ for (let i = 0; i < 8; i++) {
12
+ if (fs.existsSync(path.join(dir, 'db', 'schema.sql')))
13
+ return dir;
14
+ const parent = path.dirname(dir);
15
+ if (parent === dir)
16
+ break;
17
+ dir = parent;
18
+ }
19
+ // fallback: assume three levels up from api/src|dist/lib
20
+ return path.resolve(start, '..', '..', '..');
21
+ }
22
+ export const REPO_ROOT = process.env.DRAFTING_ROOT ?? findRepoRoot(__dirname);
23
+ function bool(v) {
24
+ return v === '1' || v === 'true' || v === 'yes';
25
+ }
26
+ /** 앱 버전은 루트 package.json 의 단일 소스에서 읽는다 (하드코딩 금지 — 릴리스마다 어긋남). */
27
+ function readVersion() {
28
+ try {
29
+ const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
30
+ return pkg.version ?? '0.0.0';
31
+ }
32
+ catch {
33
+ return '0.0.0';
34
+ }
35
+ }
36
+ export const config = {
37
+ port: Number(process.env.PORT ?? 8080),
38
+ host: process.env.HOST ?? '0.0.0.0',
39
+ databasePath: process.env.DATABASE_PATH ?? path.join(REPO_ROOT, 'data', 'drafting.sqlite'),
40
+ encryptionKey: process.env.APP_ENCRYPTION_KEY ?? '',
41
+ managedTier: bool(process.env.MANAGED_TIER),
42
+ aiStub: bool(process.env.AI_STUB),
43
+ schemaPath: path.join(REPO_ROOT, 'db', 'schema.sql'),
44
+ templatesDir: path.join(REPO_ROOT, 'api', 'templates'),
45
+ webDist: path.join(REPO_ROOT, 'web', 'dist'),
46
+ version: readVersion(),
47
+ // OpenAI 호환 게이트웨이(LiteLLM·Azure·사내 프록시 등) base URL 오버라이드.
48
+ // 비워두면 표준 OpenAI. 회사 게이트웨이 주소는 여기(env/설정)에만 두고 코드엔 넣지 않는다.
49
+ openaiBaseUrl: (process.env.OPENAI_BASE_URL ?? process.env.LITELLM_BASE_URL ?? '').trim(),
50
+ // CORS 는 기본 same-origin 만(웹 UI 를 api 가 같은 오리진으로 서빙, dev 는 vite 프록시).
51
+ // 다른 오리진에서 프론트를 띄우는 리버스 프록시 구성만 여기에 허용 오리진을 명시한다.
52
+ // 이 값을 비워두면 브라우저의 교차 오리진 요청이 차단돼 로컬 API 가 CSRF·유출로부터 보호된다.
53
+ allowOrigins: (process.env.DRAFTING_ALLOW_ORIGINS ?? '')
54
+ .split(',')
55
+ .map((s) => s.trim())
56
+ .filter(Boolean),
57
+ };
@@ -0,0 +1,71 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { config } from "./config.js";
5
+ // AES-256-GCM at-rest encryption for BYOK provider keys (G-01: never plaintext).
6
+ const ALGO = 'aes-256-gcm';
7
+ let cachedKey = null;
8
+ /**
9
+ * Resolve the 32-byte master key. Priority:
10
+ * 1. APP_ENCRYPTION_KEY env (base64 or hex decoding to exactly 32 bytes)
11
+ * 2. Persisted data/master.key (generated on first boot, chmod 600)
12
+ * Generating-and-persisting keeps a fresh install zero-config while still
13
+ * surviving restarts. Set APP_ENCRYPTION_KEY in prod so keys survive a wipe.
14
+ */
15
+ export function getMasterKey() {
16
+ if (cachedKey)
17
+ return cachedKey;
18
+ const fromEnv = config.encryptionKey.trim();
19
+ if (fromEnv) {
20
+ const buf = decodeKey(fromEnv);
21
+ if (buf.length !== 32) {
22
+ throw new Error(`APP_ENCRYPTION_KEY must decode to 32 bytes (got ${buf.length}). ` +
23
+ `Generate: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`);
24
+ }
25
+ cachedKey = buf;
26
+ return buf;
27
+ }
28
+ const keyFile = path.join(path.dirname(config.databasePath), 'master.key');
29
+ if (fs.existsSync(keyFile)) {
30
+ cachedKey = decodeKey(fs.readFileSync(keyFile, 'utf8').trim());
31
+ return cachedKey;
32
+ }
33
+ const generated = crypto.randomBytes(32);
34
+ fs.mkdirSync(path.dirname(keyFile), { recursive: true });
35
+ fs.writeFileSync(keyFile, generated.toString('base64'), { mode: 0o600 });
36
+ fs.chmodSync(keyFile, 0o600);
37
+ cachedKey = generated;
38
+ return generated;
39
+ }
40
+ function decodeKey(s) {
41
+ // try base64 then hex
42
+ if (/^[0-9a-fA-F]{64}$/.test(s))
43
+ return Buffer.from(s, 'hex');
44
+ return Buffer.from(s, 'base64');
45
+ }
46
+ export function seal(plaintext) {
47
+ const key = getMasterKey();
48
+ const iv = crypto.randomBytes(12);
49
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
50
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
51
+ const authTag = cipher.getAuthTag();
52
+ return {
53
+ ciphertext: ct.toString('base64'),
54
+ iv: iv.toString('base64'),
55
+ authTag: authTag.toString('base64'),
56
+ };
57
+ }
58
+ export function open(sealed) {
59
+ const key = getMasterKey();
60
+ const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(sealed.iv, 'base64'));
61
+ decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'));
62
+ const pt = Buffer.concat([
63
+ decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
64
+ decipher.final(),
65
+ ]);
66
+ return pt.toString('utf8');
67
+ }
68
+ /** Reset cached key — test-only. */
69
+ export function _resetKeyCache() {
70
+ cachedKey = null;
71
+ }