@gencow/core 0.1.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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * packages/core/src/__tests__/ws-integration.test.ts
3
+ *
4
+ * 실제 WebSocket 통합 테스트 — Bun WebSocket 서버 + 클라이언트
5
+ *
6
+ * mock이 아닌 실제 TCP 소켓을 통한 전달을 검증합니다.
7
+ * 서버와 클라이언트가 동일 머신이므로 루프백(lo0) 네트워크를 거칩니다.
8
+ *
9
+ * 시나리오:
10
+ * 1. 단일 구독자 emit 실시간 수신
11
+ * 2. 다수 구독자 동시 수신
12
+ * 3. debounce 동작 (실제 타이머 + TCP)
13
+ * 4. client disconnect 후 자동 정리
14
+ * 5. 부하: 50 concurrent 구독자 × 20 mutations
15
+ *
16
+ * Run: bun test packages/core/src/__tests__/ws-integration.test.ts
17
+ */
18
+
19
+ import { describe, it, expect, afterAll } from "bun:test";
20
+ import {
21
+ buildRealtimeCtx,
22
+ handleWsMessage,
23
+ registerClient,
24
+ deregisterClient,
25
+ subscribe,
26
+ } from "../reactive";
27
+
28
+ // ─── 최소 WebSocket 서버 (Bun native) ────────────────────────────────────────
29
+ //
30
+ // Hono 없이 Bun.serve()로 직접 WS 서버 구성.
31
+ // 실제 subscribe / handleWsMessage / buildRealtimeCtx를 그대로 사용한다.
32
+
33
+ function startServer(port: number) {
34
+ const server = Bun.serve({
35
+ port,
36
+ fetch(req: Request, server: any) {
37
+ if (req.headers.get("upgrade") === "websocket") {
38
+ const upgraded = server.upgrade(req);
39
+ if (!upgraded) return new Response("WS upgrade failed", { status: 500 });
40
+ return undefined;
41
+ }
42
+ return new Response("OK");
43
+ },
44
+ websocket: {
45
+ open(ws: any) {
46
+ registerClient(ws);
47
+ },
48
+ message(ws: any, data: string | Buffer) {
49
+ handleWsMessage(ws, typeof data === "string" ? data : data.toString());
50
+ },
51
+ close(ws: any) {
52
+ deregisterClient(ws);
53
+ },
54
+ },
55
+ });
56
+ return server;
57
+ }
58
+
59
+ // ─── Helper: 클라이언트 연결 + 메시지 수집 ───────────────────────────────────
60
+
61
+ function connectClient(port: number): Promise<{
62
+ ws: WebSocket;
63
+ messages: any[];
64
+ waitForMessage: (filter?: (msg: any) => boolean, timeoutMs?: number) => Promise<any>;
65
+ close: () => void;
66
+ }> {
67
+ return new Promise((resolve, reject) => {
68
+ const ws = new WebSocket(`ws://localhost:${port}`);
69
+ const messages: any[] = [];
70
+ const listeners: Array<{ filter: (msg: any) => boolean; resolve: (msg: any) => void; reject: (e: any) => void; timer: any }> = [];
71
+
72
+ ws.onmessage = (e: MessageEvent) => {
73
+ const msg = JSON.parse(e.data);
74
+ messages.push(msg);
75
+ for (const l of listeners) {
76
+ if (l.filter(msg)) {
77
+ clearTimeout(l.timer);
78
+ l.resolve(msg);
79
+ }
80
+ }
81
+ };
82
+
83
+ ws.onopen = () => resolve({
84
+ ws,
85
+ messages,
86
+ waitForMessage: (filter = () => true, timeoutMs = 2000) =>
87
+ new Promise((res, rej) => {
88
+ // 이미 수신된 메시지 중 일치하는 것이 있으면 즉시 반환
89
+ const found = messages.find(filter);
90
+ if (found) return res(found);
91
+ const timer = setTimeout(() => rej(new Error(`waitForMessage timeout (${timeoutMs}ms)`)), timeoutMs);
92
+ listeners.push({ filter, resolve: res, reject: rej, timer });
93
+ }),
94
+ close: () => ws.close(),
95
+ });
96
+
97
+ ws.onerror = reject;
98
+ });
99
+ }
100
+
101
+ // ─── 서버 인스턴스 (테스트 파일 당 하나) ─────────────────────────────────────
102
+
103
+ const PORT = 57890; // 충돌 방지용 높은 포트
104
+ const server = startServer(PORT);
105
+
106
+ afterAll(() => {
107
+ // force=true: 열려 있는 WebSocket 연결을 즉시 닫고 서버를 종료한다.
108
+ server.stop(true);
109
+ });
110
+
111
+ // ─── 1. 단일 구독자 실시간 수신 ──────────────────────────────────────────────
112
+
113
+ describe("[WS Integration] 단일 구독자 emit 수신", () => {
114
+ it("subscribe 후 emit하면 query:updated 메시지를 실시간으로 수신한다", async () => {
115
+ const client = await connectClient(PORT);
116
+
117
+ // subscribe 메시지 전송
118
+ client.ws.send(JSON.stringify({ type: "subscribe", query: "ws.tasks.list" }));
119
+ await client.waitForMessage(m => m.type === "subscribed");
120
+
121
+ // 서버 사이드에서 emit
122
+ const freshData = [{ id: 1, title: "Real WebSocket Task" }];
123
+ buildRealtimeCtx().emit("ws.tasks.list", freshData);
124
+
125
+ // query:updated 수신 대기
126
+ const msg = await client.waitForMessage(m => m.type === "query:updated", 500);
127
+
128
+ expect(msg.query).toBe("ws.tasks.list");
129
+ expect(msg.data).toEqual(freshData);
130
+
131
+ client.close();
132
+ });
133
+
134
+ it("subscribe 전에는 query:updated를 수신하지 않는다", async () => {
135
+ const client = await connectClient(PORT);
136
+
137
+ // subscribe 없이 emit
138
+ buildRealtimeCtx().emit("ws.unsubscribed", [{ id: 99 }]);
139
+ await new Promise(r => setTimeout(r, 200));
140
+
141
+ const unrelated = client.messages.filter(m => m.query === "ws.unsubscribed");
142
+ expect(unrelated).toHaveLength(0);
143
+
144
+ client.close();
145
+ });
146
+ });
147
+
148
+ // ─── 2. 다수 구독자 동시 수신 ────────────────────────────────────────────────
149
+
150
+ describe("[WS Integration] 다수 구독자 동시 수신", () => {
151
+ it("10명 구독자 모두 동일 query:updated를 수신한다", async () => {
152
+ const N = 10;
153
+ const queryKey = "ws.multi.tasks";
154
+ const clients = await Promise.all(
155
+ Array.from({ length: N }, () => connectClient(PORT))
156
+ );
157
+
158
+ // 모두 같은 query 구독
159
+ for (const c of clients) {
160
+ c.ws.send(JSON.stringify({ type: "subscribe", query: queryKey }));
161
+ }
162
+ await Promise.all(clients.map(c => c.waitForMessage(m => m.type === "subscribed")));
163
+
164
+ // emit
165
+ const payload = [{ id: 1 }, { id: 2 }, { id: 3 }];
166
+ buildRealtimeCtx().emit(queryKey, payload);
167
+
168
+ // 모두 수신 대기
169
+ const results = await Promise.all(
170
+ clients.map(c => c.waitForMessage(m => m.type === "query:updated", 500))
171
+ );
172
+
173
+ expect(results).toHaveLength(N);
174
+ for (const msg of results) {
175
+ expect(msg.query).toBe(queryKey);
176
+ expect(msg.data).toEqual(payload);
177
+ }
178
+
179
+ clients.forEach(c => c.close());
180
+ });
181
+
182
+ it("50명 구독자 부하 테스트 — 모두 수신, P99 latency 측정", async () => {
183
+ const N = 50;
184
+ const queryKey = "ws.load.tasks";
185
+
186
+ const clients = await Promise.all(
187
+ Array.from({ length: N }, () => connectClient(PORT))
188
+ );
189
+ for (const c of clients) {
190
+ c.ws.send(JSON.stringify({ type: "subscribe", query: queryKey }));
191
+ }
192
+ await Promise.all(clients.map(c => c.waitForMessage(m => m.type === "subscribed")));
193
+
194
+ const emitTime = performance.now();
195
+ buildRealtimeCtx().emit(queryKey, [{ id: 1, title: "load test" }]);
196
+
197
+ const receivePromises = clients.map(async c => {
198
+ const msg = await c.waitForMessage(m => m.type === "query:updated", 1000);
199
+ return performance.now() - emitTime;
200
+ });
201
+
202
+ const latencies = await Promise.all(receivePromises);
203
+ latencies.sort((a, b) => a - b);
204
+
205
+ const p50 = latencies[Math.floor(N * 0.5)];
206
+ const p99 = latencies[Math.floor(N * 0.99)];
207
+
208
+ console.log(`[WS/50 clients] P50=${p50.toFixed(1)}ms P99=${p99.toFixed(1)}ms (debounce=50ms incl.)`);
209
+
210
+ expect(latencies).toHaveLength(N); // 50/50 수신
211
+ expect(p99).toBeLessThan(500); // 디바운스 포함 P99 < 500ms
212
+
213
+ clients.forEach(c => c.close());
214
+ });
215
+ });
216
+
217
+ // ─── 3. 실제 debounce 동작 검증 ──────────────────────────────────────────────
218
+
219
+ describe("[WS Integration] debounce 동작 (실제 타이머)", () => {
220
+ it("40ms 내 5번 rapid emit → 단 1회 query:updated 수신", async () => {
221
+ const client = await connectClient(PORT);
222
+ client.ws.send(JSON.stringify({ type: "subscribe", query: "ws.debounce" }));
223
+ await client.waitForMessage(m => m.type === "subscribed");
224
+
225
+ const rt = buildRealtimeCtx();
226
+ for (let i = 0; i < 5; i++) {
227
+ rt.emit("ws.debounce", [{ id: i }]);
228
+ await new Promise(r => setTimeout(r, 8)); // 8ms 간격 → 40ms < 50ms
229
+ }
230
+
231
+ // debounce(50ms) + 마진
232
+ await new Promise(r => setTimeout(r, 150));
233
+
234
+ const updates = client.messages.filter(m => m.type === "query:updated" && m.query === "ws.debounce");
235
+ console.log(`[WS/debounce] 5 emits → ${updates.length} messages received`);
236
+
237
+ expect(updates).toHaveLength(1); // 1번만 전달
238
+ expect(updates[0].data).toEqual([{ id: 4 }]); // 마지막 데이터
239
+
240
+ client.close();
241
+ });
242
+ });
243
+
244
+ // ─── 4. 클라이언트 연결 해제 후 자동 정리 ────────────────────────────────────
245
+
246
+ describe("[WS Integration] disconnect 후 정리", () => {
247
+ it("disconnected 클라이언트는 다음 emit에서 에러 없이 제거된다", async () => {
248
+ const stableClient = await connectClient(PORT);
249
+ const disconnectClient = await connectClient(PORT);
250
+
251
+ const queryKey = "ws.cleanup.test";
252
+ stableClient.ws.send(JSON.stringify({ type: "subscribe", query: queryKey }));
253
+ disconnectClient.ws.send(JSON.stringify({ type: "subscribe", query: queryKey }));
254
+
255
+ await Promise.all([
256
+ stableClient.waitForMessage(m => m.type === "subscribed"),
257
+ disconnectClient.waitForMessage(m => m.type === "subscribed"),
258
+ ]);
259
+
260
+ // 한 클라이언트 강제 종료
261
+ disconnectClient.close();
262
+ await new Promise(r => setTimeout(r, 100)); // 서버가 close 처리할 시간
263
+
264
+ // emit 후 stable client는 정상 수신
265
+ buildRealtimeCtx().emit(queryKey, [{ id: 1 }]);
266
+ const msg = await stableClient.waitForMessage(m => m.type === "query:updated", 500);
267
+
268
+ expect(msg.query).toBe(queryKey);
269
+ stableClient.close();
270
+ });
271
+ });
272
+
273
+ // ─── 5. 부하: 다수 mutation 연속 실행 ────────────────────────────────────────
274
+
275
+ describe("[WS Integration] 연속 mutations 부하", () => {
276
+ it("20 mutations × 각각 query:updated → 구독자가 모두 수신한다", async () => {
277
+ const N_MUTATIONS = 20;
278
+ const queryKey = "ws.mutations.load";
279
+ const client = await connectClient(PORT);
280
+
281
+ client.ws.send(JSON.stringify({ type: "subscribe", query: queryKey }));
282
+ await client.waitForMessage(m => m.type === "subscribed");
283
+
284
+ // 20번 mutation 실행 (각자 별도 buildRealtimeCtx — 서로 독립적 debounce)
285
+ const promises: Promise<any>[] = [];
286
+ for (let i = 0; i < N_MUTATIONS; i++) {
287
+ const rt = buildRealtimeCtx();
288
+ rt.emit(queryKey, [{ id: i, title: `Mutation ${i}` }]);
289
+ // 각 emit을 60ms 간격으로 — 각자 debounce가 완료된 후 다음 emit
290
+ if (i < N_MUTATIONS - 1) await new Promise(r => setTimeout(r, 60));
291
+ }
292
+
293
+ // 마지막 debounce 완료 대기
294
+ await new Promise(r => setTimeout(r, 200));
295
+
296
+ const updates = client.messages.filter(m => m.type === "query:updated" && m.query === queryKey);
297
+ console.log(`[WS/mutations] ${N_MUTATIONS} mutations → ${updates.length} updates received`);
298
+
299
+ // 각 mutation이 별도 buildRealtimeCtx → 각자 debounce → N번 전달
300
+ expect(updates.length).toBe(N_MUTATIONS);
301
+
302
+ client.close();
303
+ }, 30000); // 20 × 60ms = 1200ms + margin
304
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * packages/core/src/__tests__/ws-scale.test.ts
3
+ *
4
+ * 실제 TCP WebSocket 대규모 동시 연결 부하 테스트.
5
+ *
6
+ * Bun.serve()로 서버를 띄우고, 실제 WebSocket 클라이언트를 N개 연결하여
7
+ * emit 후 모든 클라이언트가 query:updated를 수신하는 시간을 측정.
8
+ *
9
+ * Mock이 아닌 실제 TCP 소켓 — 커널 레벨 I/O 포함.
10
+ *
11
+ * ⚠️ 5,000+ 연결 시 OS listen backlog 제한에 걸릴 수 있어
12
+ * 500개 단위로 batch connect + retry 적용.
13
+ *
14
+ * Run: bun test packages/core/src/__tests__/ws-scale.test.ts --timeout 120000
15
+ */
16
+
17
+ import { describe, it, expect, afterAll } from "bun:test";
18
+ import {
19
+ buildRealtimeCtx,
20
+ handleWsMessage,
21
+ registerClient,
22
+ deregisterClient,
23
+ } from "../reactive";
24
+
25
+ // ─── WebSocket 서버 ──────────────────────────────────────────────────────────
26
+
27
+ const PORT = 57891;
28
+
29
+ const server = Bun.serve({
30
+ port: PORT,
31
+ fetch(req: Request, server: any) {
32
+ if (req.headers.get("upgrade") === "websocket") {
33
+ server.upgrade(req);
34
+ return undefined;
35
+ }
36
+ return new Response("OK");
37
+ },
38
+ websocket: {
39
+ open(ws: any) { registerClient(ws); },
40
+ message(ws: any, data: string | Buffer) {
41
+ handleWsMessage(ws, typeof data === "string" ? data : data.toString());
42
+ },
43
+ close(ws: any) { deregisterClient(ws); },
44
+ },
45
+ });
46
+
47
+ afterAll(() => server.stop(true));
48
+
49
+ // ─── 헬퍼 ────────────────────────────────────────────────────────────────────
50
+
51
+ interface ScaleClient {
52
+ ws: WebSocket;
53
+ receivedAt: number | null;
54
+ }
55
+
56
+ const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
57
+
58
+ /**
59
+ * 단일 WebSocket 연결 + subscribe + ready 대기.
60
+ * 연결 실패 시 retry (최대 3회, 100ms 간격).
61
+ */
62
+ function connectOne(queryKey: string, maxRetries = 3): Promise<ScaleClient> {
63
+ return new Promise(async (resolveOuter) => {
64
+ const entry: ScaleClient = { ws: null as any, receivedAt: null };
65
+
66
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
67
+ try {
68
+ await new Promise<void>((resolve, reject) => {
69
+ const ws = new WebSocket(`ws://localhost:${PORT}`);
70
+ let settled = false;
71
+
72
+ const timeout = setTimeout(() => {
73
+ if (!settled) { settled = true; ws.close(); reject(new Error("timeout")); }
74
+ }, 5000);
75
+
76
+ ws.onopen = () => {
77
+ ws.send(JSON.stringify({ type: "subscribe", query: queryKey }));
78
+ };
79
+
80
+ ws.onmessage = (e: MessageEvent) => {
81
+ const msg = JSON.parse(e.data);
82
+ if (msg.type === "subscribed" && !settled) {
83
+ settled = true;
84
+ clearTimeout(timeout);
85
+ entry.ws = ws;
86
+ // 이후 query:updated 수신 시 기록
87
+ ws.onmessage = (e2: MessageEvent) => {
88
+ const m = JSON.parse(e2.data);
89
+ if (m.type === "query:updated" && m.query === queryKey) {
90
+ entry.receivedAt = performance.now();
91
+ }
92
+ };
93
+ resolve();
94
+ }
95
+ };
96
+
97
+ ws.onerror = (e) => {
98
+ if (!settled) { settled = true; clearTimeout(timeout); reject(e); }
99
+ };
100
+ });
101
+ resolveOuter(entry);
102
+ return;
103
+ } catch {
104
+ if (attempt < maxRetries) await wait(100 * (attempt + 1));
105
+ }
106
+ }
107
+ // 모든 retry 실패 시에도 반환 (receivedAt=null으로 실패 추적)
108
+ resolveOuter(entry);
109
+ });
110
+ }
111
+
112
+ /**
113
+ * N개 연결을 BATCH_SIZE 단위로 나눠 순차적으로 연결.
114
+ * OS listen backlog 포화를 방지.
115
+ */
116
+ async function connectBatch(n: number, queryKey: string, batchSize = 500): Promise<ScaleClient[]> {
117
+ const all: ScaleClient[] = [];
118
+ for (let i = 0; i < n; i += batchSize) {
119
+ const chunk = Math.min(batchSize, n - i);
120
+ const batch = await Promise.all(
121
+ Array.from({ length: chunk }, () => connectOne(queryKey))
122
+ );
123
+ all.push(...batch);
124
+ // 배치 사이 짧은 대기 — 서버가 accept queue를 비울 시간
125
+ if (i + batchSize < n) await wait(50);
126
+ }
127
+ return all;
128
+ }
129
+
130
+ function disconnectAll(clients: ScaleClient[]) {
131
+ for (const c of clients) {
132
+ try { c.ws?.close(); } catch { }
133
+ }
134
+ }
135
+
136
+ function percentile(sorted: number[], p: number): number {
137
+ if (sorted.length === 0) return 0;
138
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
139
+ return sorted[Math.max(0, idx)];
140
+ }
141
+
142
+ // ─── 공통 테스트 로직 ────────────────────────────────────────────────────────
143
+
144
+ async function runScaleTest(n: number, label: string, waitMs: number) {
145
+ const key = `scale.${n}.${Date.now()}`;
146
+
147
+ const t0 = performance.now();
148
+ const clients = await connectBatch(n, key);
149
+ const connectTime = performance.now() - t0;
150
+ const connected = clients.filter(c => c.ws !== null).length;
151
+
152
+ const emitTime = performance.now();
153
+ buildRealtimeCtx().emit(key, [{ id: 1, title: "Scale Test" }]);
154
+ await wait(waitMs);
155
+
156
+ const latencies = clients
157
+ .filter(c => c.receivedAt !== null)
158
+ .map(c => c.receivedAt! - emitTime)
159
+ .sort((a, b) => a - b);
160
+
161
+ const rate = (latencies.length / n) * 100;
162
+ const p50 = percentile(latencies, 50);
163
+ const p99 = percentile(latencies, 99);
164
+ const max = latencies[latencies.length - 1] ?? 0;
165
+
166
+ console.log(`[${label}] connect=${connectTime.toFixed(0)}ms (${connected}/${n}) deliver: ${latencies.length}/${n} (${rate.toFixed(1)}%) P50=${p50.toFixed(1)}ms P99=${p99.toFixed(1)}ms Max=${max.toFixed(1)}ms`);
167
+
168
+ disconnectAll(clients);
169
+ await wait(200);
170
+
171
+ return { n, connectMs: connectTime, connected, delivered: latencies.length, rate, p50, p99, max };
172
+ }
173
+
174
+ // ─── 테스트 ──────────────────────────────────────────────────────────────────
175
+
176
+ describe("[WS Scale] 실제 TCP WebSocket 대규모 동시 연결", () => {
177
+ const results: Awaited<ReturnType<typeof runScaleTest>>[] = [];
178
+
179
+ it("100 동시 연결 — baseline", async () => {
180
+ const r = await runScaleTest(100, "WS/100 ", 200);
181
+ results.push(r);
182
+ expect(r.rate).toBe(100);
183
+ });
184
+
185
+ it("500 동시 연결", async () => {
186
+ const r = await runScaleTest(500, "WS/500 ", 300);
187
+ results.push(r);
188
+ expect(r.rate).toBe(100);
189
+ });
190
+
191
+ it("1,000 동시 연결", async () => {
192
+ const r = await runScaleTest(1_000, "WS/1k ", 500);
193
+ results.push(r);
194
+ expect(r.rate).toBe(100);
195
+ });
196
+
197
+ it("2,000 동시 연결", async () => {
198
+ const r = await runScaleTest(2_000, "WS/2k ", 1000);
199
+ results.push(r);
200
+ expect(r.rate).toBe(100);
201
+ });
202
+
203
+ it("5,000 동시 연결", async () => {
204
+ const r = await runScaleTest(5_000, "WS/5k ", 2000);
205
+ results.push(r);
206
+ expect(r.rate).toBe(100);
207
+ });
208
+
209
+ it("10,000 동시 연결", async () => {
210
+ const r = await runScaleTest(10_000, "WS/10k ", 3000);
211
+ results.push(r);
212
+ expect(r.rate).toBe(100);
213
+ });
214
+
215
+ it("결과 요약 테이블 출력", () => {
216
+ console.log("\n┌──────────┬────────────┬──────────┬──────────┬──────────┬──────────┐");
217
+ console.log("│ 구독자 │ 연결 시간 │ 수신률 │ P50 │ P99 │ Max │");
218
+ console.log("├──────────┼────────────┼──────────┼──────────┼──────────┼──────────┤");
219
+ for (const r of results) {
220
+ const n = String(r.n).padStart(7);
221
+ const conn = `${r.connectMs.toFixed(0)}ms`.padStart(8);
222
+ const rate = `${r.rate.toFixed(1)}%`.padStart(6);
223
+ const p50 = `${r.p50.toFixed(1)}ms`.padStart(7);
224
+ const p99 = `${r.p99.toFixed(1)}ms`.padStart(7);
225
+ const max = `${r.max.toFixed(1)}ms`.padStart(7);
226
+ console.log(`│ ${n} │ ${conn} │ ${rate} │ ${p50} │ ${p99} │ ${max} │`);
227
+ }
228
+ console.log("└──────────┴────────────┴──────────┴──────────┴──────────┴──────────┘\n");
229
+
230
+ expect(results.length).toBeGreaterThanOrEqual(6);
231
+ });
232
+ });
package/src/auth.ts ADDED
@@ -0,0 +1,155 @@
1
+ import type { Context, Next } from "hono";
2
+ import { HTTPException } from "hono/http-exception";
3
+ import { sign, verify } from "hono/utils/jwt/jwt";
4
+
5
+ // ─── Types ──────────────────────────────────────────────
6
+
7
+ interface User {
8
+ id: string;
9
+ email: string;
10
+ name?: string;
11
+ }
12
+
13
+ interface AuthContext {
14
+ /** Get current user or null — Convex의 ctx.auth.getUserIdentity() */
15
+ getUserIdentity(): User | null;
16
+ /** Get current user or throw 401 — 편의 메서드 */
17
+ requireAuth(): User;
18
+ }
19
+
20
+ interface AuthConfig {
21
+ jwtSecret: string;
22
+ }
23
+
24
+ // ─── In-memory user store (POC용, 프로덕션에서는 Drizzle 테이블 사용) ──
25
+
26
+ const users = new Map<string, User & { passwordHash: string; createdAt: string }>();
27
+
28
+ // ─── Simple password hashing (POC용) ────────────────────
29
+
30
+ async function hashPassword(password: string): Promise<string> {
31
+ const encoder = new TextEncoder();
32
+ const data = encoder.encode(password);
33
+ const hash = await crypto.subtle.digest("SHA-256", data);
34
+ return Array.from(new Uint8Array(hash))
35
+ .map((b) => b.toString(16).padStart(2, "0"))
36
+ .join("");
37
+ }
38
+
39
+ // ─── Auth middleware — Convex ctx.auth 패턴 재현 ─────────
40
+
41
+ /**
42
+ * Auth middleware that injects `c.get('auth')` into context
43
+ *
44
+ * @example
45
+ * app.use('*', authMiddleware({ jwtSecret: 'secret' }));
46
+ *
47
+ * // In query/mutation:
48
+ * const user = c.get('auth').requireAuth();
49
+ */
50
+ export function authMiddleware(config: AuthConfig) {
51
+ return async (c: Context, next: Next) => {
52
+ let currentUser: User | null = null;
53
+
54
+ // Extract JWT from Authorization header
55
+ const authHeader = c.req.header("Authorization");
56
+ if (authHeader?.startsWith("Bearer ")) {
57
+ const token = authHeader.slice(7);
58
+ try {
59
+ const payload = (await verify(token, config.jwtSecret, "HS256")) as any;
60
+ currentUser = {
61
+ id: payload.sub as string,
62
+ email: payload.email as string,
63
+ name: payload.name as string | undefined,
64
+ };
65
+ } catch {
66
+ // Invalid token — continue as unauthenticated
67
+ }
68
+ }
69
+
70
+ const authContext: AuthContext = {
71
+ getUserIdentity: () => currentUser,
72
+ requireAuth: () => {
73
+ if (!currentUser) {
74
+ throw new HTTPException(401, { message: "Authentication required" });
75
+ }
76
+ return currentUser;
77
+ },
78
+ };
79
+
80
+ c.set("auth", authContext);
81
+ await next();
82
+ };
83
+ }
84
+
85
+ // ─── Auth routes — 회원가입/로그인/프로필 ────────────────
86
+
87
+ export function authRoutes(config: AuthConfig) {
88
+ return {
89
+ /** POST /auth/signup — 회원가입 */
90
+ async signup(c: Context) {
91
+ const { email, password, name } = await c.req.json();
92
+
93
+ if (!email || !password) {
94
+ return c.json({ error: "Email and password required" }, 400);
95
+ }
96
+
97
+ if (users.has(email)) {
98
+ return c.json({ error: "User already exists" }, 409);
99
+ }
100
+
101
+ const id = crypto.randomUUID();
102
+ const passwordHash = await hashPassword(password);
103
+ users.set(email, { id, email, name, passwordHash, createdAt: new Date().toISOString() });
104
+
105
+ const token = await sign(
106
+ { sub: id, email, name, exp: Math.floor(Date.now() / 1000) + 86400 },
107
+ config.jwtSecret
108
+ );
109
+
110
+ return c.json({ token, user: { id, email, name } });
111
+ },
112
+
113
+ /** POST /auth/login — 로그인 */
114
+ async login(c: Context) {
115
+ const { email, password } = await c.req.json();
116
+
117
+ const user = users.get(email);
118
+ if (!user) {
119
+ return c.json({ error: "Invalid credentials" }, 401);
120
+ }
121
+
122
+ const hash = await hashPassword(password);
123
+ if (hash !== user.passwordHash) {
124
+ return c.json({ error: "Invalid credentials" }, 401);
125
+ }
126
+
127
+ const token = await sign(
128
+ {
129
+ sub: user.id,
130
+ email: user.email,
131
+ name: user.name,
132
+ exp: Math.floor(Date.now() / 1000) + 86400,
133
+ },
134
+ config.jwtSecret
135
+ );
136
+
137
+ return c.json({
138
+ token,
139
+ user: { id: user.id, email: user.email, name: user.name },
140
+ });
141
+ },
142
+
143
+ /** GET /auth/me — 현재 유저 정보 */
144
+ async me(c: Context) {
145
+ const auth: AuthContext = c.get("auth");
146
+ const user = auth.requireAuth();
147
+ return c.json(user);
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Get all registered users (for admin dashboard) */
153
+ export function getUsers(): (User & { createdAt: string })[] {
154
+ return Array.from(users.values()).map(({ passwordHash, ...user }) => user);
155
+ }