@gaonjs/web 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type FastifyInstance, type FastifyServerOptions } from 'fastify';
2
+ import { type FastifyCorsOptions } from '@fastify/cors';
3
+ import type { RouteDef } from './routes.js';
4
+ import { type DispatchOptions } from './dispatch.js';
5
+ import { type AppSessionOptions } from './session.js';
6
+ import { type AuthOptions, type JwtAuthOptions } from './auth.js';
7
+ /** 한 앱 정의 — 프리픽스(생략 시 관례)·라우트·컨트롤러 맵. */
8
+ export interface AppSpec {
9
+ readonly name: string;
10
+ /** 생략 시 관례: web→'/', 그 외→'/<name>'(§3.3). */
11
+ readonly prefix?: string;
12
+ readonly routes: RouteDef;
13
+ readonly controllers: Record<string, object>;
14
+ /** 세션 배터리 설정(§7). 생략 시 세션 없음(JWT/API 앱 등). */
15
+ readonly session?: AppSessionOptions;
16
+ /**
17
+ * 인증(§7). 세션 앱은 AuthOptions(기본), API 앱은 JwtAuthOptions(strategy:'jwt').
18
+ * 세션 앱은 requireAuth 실패 시 로그인 페이지로, JWT 앱은 401 로 응답한다.
19
+ */
20
+ readonly auth?: AuthOptions | JwtAuthOptions;
21
+ /** 요청별 컨텍스트 확장(추가 훅). */
22
+ readonly dispatch?: DispatchOptions;
23
+ }
24
+ export interface SecurityOptions {
25
+ /** CORS 설정. 기본은 same-origin 만 허용(origin:false). false 로 완전 비활성. */
26
+ readonly cors?: FastifyCorsOptions | false;
27
+ /** rate limit. 기본 100 req/분/IP. false 로 비활성. */
28
+ readonly rateLimit?: {
29
+ max?: number;
30
+ timeWindow?: string | number;
31
+ } | false;
32
+ }
33
+ export interface CreateAppOptions {
34
+ readonly apps: readonly AppSpec[];
35
+ /** Inertia 에셋 버전(전 앱 공통). 클라이언트 불일치 시 풀 리로드. */
36
+ readonly version?: string;
37
+ /** 서명 쿠키·CSRF 용 비밀. 생략 시 env GAON_COOKIE_SECRET. */
38
+ readonly cookieSecret?: string;
39
+ readonly security?: SecurityOptions;
40
+ readonly fastify?: FastifyServerOptions;
41
+ }
42
+ /**
43
+ * 앱 스펙들로 Fastify 인스턴스를 만든다. 쿠키·CORS·rate limit(보안 기본값)을
44
+ * 켜고, 각 앱의 라우트를 프리픽스 아래 등록한다. listen 은 호출자가 한다.
45
+ */
46
+ export declare function createApp(opts: CreateAppOptions): Promise<FastifyInstance>;
47
+ export type { RouteDef };
package/dist/app.js ADDED
@@ -0,0 +1,63 @@
1
+ // @gaonjs/web · Fastify 앱 팩토리 + 보안 기본값 (§2.5.1)
2
+ //
3
+ // 보안(CORS·rate limit·CSRF)은 코어에 내장하고 **기본으로 켠다** — 별도
4
+ // 패키지를 설치해야 방어가 생기는 방식은 명시적으로 기각했다(§2.5.1). 끄는
5
+ // 것은 명시적 설정으로만 한다.
6
+ //
7
+ // CSRF·세션은 세션 배터리 스텝에서 이 팩토리에 결합된다. 여기서는 HTTP
8
+ // 실행 레이어의 뼈대(쿠키·CORS·rate limit·앱별 라우트 등록)를 놓는다.
9
+ import Fastify from 'fastify';
10
+ import cookie from '@fastify/cookie';
11
+ import cors from '@fastify/cors';
12
+ import rateLimit from '@fastify/rate-limit';
13
+ import { prefixFor, registerApp } from './dispatch.js';
14
+ import { registerSession } from './session.js';
15
+ import { jwtAuthAugment, sessionAuthAugment } from './auth.js';
16
+ /**
17
+ * 앱 스펙들로 Fastify 인스턴스를 만든다. 쿠키·CORS·rate limit(보안 기본값)을
18
+ * 켜고, 각 앱의 라우트를 프리픽스 아래 등록한다. listen 은 호출자가 한다.
19
+ */
20
+ export async function createApp(opts) {
21
+ const app = Fastify(opts.fastify);
22
+ const security = opts.security ?? {};
23
+ // 쿠키 — 세션·CSRF·서명 쿠키의 토대. secret 은 서명 쿠키에만 필요.
24
+ await app.register(cookie, { secret: opts.cookieSecret ?? process.env.GAON_COOKIE_SECRET });
25
+ // CORS — 기본 same-origin(origin:false). 크로스 오리진은 명시 설정으로만.
26
+ if (security.cors !== false) {
27
+ await app.register(cors, security.cors ?? { origin: false });
28
+ }
29
+ // rate limit — 기본 100 req/분/IP.
30
+ if (security.rateLimit !== false) {
31
+ const rl = security.rateLimit ?? {};
32
+ await app.register(rateLimit, { max: rl.max ?? 100, timeWindow: rl.timeWindow ?? '1 minute' });
33
+ }
34
+ // 앱마다 캡슐화 스코프에 등록한다 — 세션·CSRF·라우트가 그 앱 프리픽스에
35
+ // 갇혀 앱별 완전 분리가 성립한다(질문 13). 프리픽스는 스코프가 건다.
36
+ for (const spec of opts.apps) {
37
+ const prefix = spec.prefix ?? prefixFor(spec.name);
38
+ await app.register(async (scope) => {
39
+ if (spec.session)
40
+ await registerSession(scope, spec.name, prefix, spec.session);
41
+ registerApp(scope, { name: spec.name, routes: spec.routes, controllers: spec.controllers }, dispatchOptionsFor(spec, opts.version));
42
+ }, { prefix });
43
+ }
44
+ return app;
45
+ }
46
+ /** 앱의 인증·추가 훅을 디스패치 옵션으로 합성한다. */
47
+ function dispatchOptionsFor(spec, version) {
48
+ const auth = spec.auth;
49
+ // JWT 앱은 401(리다이렉트 없음), 세션 앱은 로그인 페이지로.
50
+ const isJwt = auth != null && 'strategy' in auth && auth.strategy === 'jwt';
51
+ const authAugment = auth ? (isJwt ? jwtAuthAugment(auth) : sessionAuthAugment(auth)) : undefined;
52
+ const loginRedirect = auth && !isJwt ? auth.loginRedirect ?? '/login' : undefined;
53
+ const extra = spec.dispatch?.augment;
54
+ const augment = authAugment || extra
55
+ ? async (ctx, request, reply) => {
56
+ if (authAugment)
57
+ await authAugment(ctx, request, reply);
58
+ if (extra)
59
+ await extra(ctx, request, reply);
60
+ }
61
+ : undefined;
62
+ return { version, loginRedirect, ...spec.dispatch, augment };
63
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ import type { ControllerContext, GaonCurrentUser } from './controller.js';
3
+ /** requireAuth 가 던지는 비로그인 신호. 클래스 대신 마커 프로퍼티를 쓴다. */
4
+ export declare function unauthorizedError(): Error;
5
+ /** 던져진 값이 비로그인 신호인지 판정한다(디스패처가 응답 분기에 사용). */
6
+ export declare function isUnauthorized(e: unknown): boolean;
7
+ export interface AuthOptions {
8
+ /**
9
+ * session.userId → 사용자 로드. 앱이 도메인 모델로 구현한다(예:
10
+ * (id) => User.query().where('id','=',BigInt(id)).first()). falsy 반환은
11
+ * 비로그인 취급. web 이 data 를 직접 import 하지 않게 하는 이음새다.
12
+ */
13
+ loadUser(id: unknown): Promise<GaonCurrentUser | null | undefined> | GaonCurrentUser | null | undefined;
14
+ /** requireAuth 실패 시 리다이렉트 경로(폼/Inertia). 기본 '/login'. */
15
+ loginRedirect?: string;
16
+ }
17
+ /**
18
+ * 컨텍스트에 currentUser 를 채우는 augment 를 만든다. 세션의 userId 로
19
+ * 사용자를 로드해 ctx.currentUser·ctx.auth.user 에 심는다.
20
+ */
21
+ export declare function sessionAuthAugment(opts: AuthOptions): (ctx: ControllerContext, request: FastifyRequest, _reply: FastifyReply) => Promise<void>;
22
+ export interface JwtAuthOptions {
23
+ /** JWT 앱임을 표시하는 판별자. */
24
+ strategy: 'jwt';
25
+ /** HS256 서명 secret. 앱마다 달라야 토큰이 앱을 넘지 못한다(질문 13). */
26
+ secret: string;
27
+ /** 토큰 sub(사용자 id) → 사용자 로드. */
28
+ loadUser(id: unknown): Promise<GaonCurrentUser | null | undefined> | GaonCurrentUser | null | undefined;
29
+ /** 액세스 토큰 수명. 기본 '15m'. */
30
+ accessTtl?: string;
31
+ /** 리프레시 토큰 수명. 기본 '7d'. */
32
+ refreshTtl?: string;
33
+ }
34
+ /**
35
+ * JWT 앱용 augment. Bearer 액세스 토큰으로 currentUser 를 채우고, this.jwt 로
36
+ * 토큰 발급/재발급 API 를 얹는다(로그인·리프레시 컨트롤러가 사용).
37
+ */
38
+ export declare function jwtAuthAugment(opts: JwtAuthOptions): (ctx: ControllerContext, request: FastifyRequest, _reply: FastifyReply) => Promise<void>;
package/dist/auth.js ADDED
@@ -0,0 +1,78 @@
1
+ // @gaonjs/web · 인증 코어 (currentUser · requireAuth · 세션 로그인)
2
+ //
3
+ // 세션 쿠키가 기본 인증이다(§7). 로그인은 세션에 사용자 id 를 심고
4
+ // (this.auth.login), 요청마다 그 id 로 사용자를 로드해 this.currentUser 에
5
+ // 채운다. 보호 라우트는 this.requireAuth() 가드로 지킨다 — 비로그인이면
6
+ // 예외를 던져 디스패처가 로그인 페이지로 보내거나(폼/Inertia) 401(API)로 답한다.
7
+ //
8
+ // web 은 도메인(모델·DB)을 모른다 — 사용자 로드는 앱이 loadUser 로 주입한다
9
+ // (의존 방향 §3.2 보호: web→data 직접 의존 없음).
10
+ import { signToken, verifyToken } from './jwt.js';
11
+ const UNAUTHORIZED = Symbol.for('gaon.web.unauthorized');
12
+ /** requireAuth 가 던지는 비로그인 신호. 클래스 대신 마커 프로퍼티를 쓴다. */
13
+ export function unauthorizedError() {
14
+ const e = new Error('Unauthorized');
15
+ e[UNAUTHORIZED] = true;
16
+ return e;
17
+ }
18
+ /** 던져진 값이 비로그인 신호인지 판정한다(디스패처가 응답 분기에 사용). */
19
+ export function isUnauthorized(e) {
20
+ return typeof e === 'object' && e !== null && e[UNAUTHORIZED] === true;
21
+ }
22
+ /**
23
+ * 컨텍스트에 currentUser 를 채우는 augment 를 만든다. 세션의 userId 로
24
+ * 사용자를 로드해 ctx.currentUser·ctx.auth.user 에 심는다.
25
+ */
26
+ export function sessionAuthAugment(opts) {
27
+ return async (ctx, request, _reply) => {
28
+ const sess = request.session;
29
+ const uid = sess?.userId;
30
+ if (uid == null)
31
+ return;
32
+ const user = (await opts.loadUser(uid)) ?? null;
33
+ ctx.currentUser = user;
34
+ ctx.auth.user = user;
35
+ };
36
+ }
37
+ /** Authorization: Bearer <token> 에서 토큰만 뽑는다. */
38
+ function bearer(request) {
39
+ const h = request.headers.authorization;
40
+ if (typeof h === 'string' && h.startsWith('Bearer '))
41
+ return h.slice(7).trim();
42
+ }
43
+ /**
44
+ * JWT 앱용 augment. Bearer 액세스 토큰으로 currentUser 를 채우고, this.jwt 로
45
+ * 토큰 발급/재발급 API 를 얹는다(로그인·리프레시 컨트롤러가 사용).
46
+ */
47
+ export function jwtAuthAugment(opts) {
48
+ const accessTtl = opts.accessTtl ?? '15m';
49
+ const refreshTtl = opts.refreshTtl ?? '7d';
50
+ const api = {
51
+ async issue(user) {
52
+ const sub = String(user.id);
53
+ return {
54
+ accessToken: await signToken(opts.secret, sub, 'access', accessTtl),
55
+ refreshToken: await signToken(opts.secret, sub, 'refresh', refreshTtl),
56
+ };
57
+ },
58
+ async refresh(refreshToken) {
59
+ const payload = await verifyToken(opts.secret, refreshToken);
60
+ if (!payload || payload.typ !== 'refresh' || typeof payload.sub !== 'string')
61
+ return null;
62
+ return { accessToken: await signToken(opts.secret, payload.sub, 'access', accessTtl) };
63
+ },
64
+ };
65
+ return async (ctx, request, _reply) => {
66
+ ;
67
+ ctx.jwt = api;
68
+ const token = bearer(request);
69
+ if (!token)
70
+ return;
71
+ const payload = await verifyToken(opts.secret, token);
72
+ if (!payload || payload.typ !== 'access' || typeof payload.sub !== 'string')
73
+ return;
74
+ const user = (await opts.loadUser(payload.sub)) ?? null;
75
+ ctx.currentUser = user;
76
+ ctx.auth.user = user;
77
+ };
78
+ }
@@ -0,0 +1,103 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ /** 렌더 결과 — 페이지 이름 + props. 타입 브리지 입력(§6.2). */
3
+ export interface Rendered<P> {
4
+ readonly page: string;
5
+ readonly props: P;
6
+ }
7
+ /**
8
+ * 리다이렉트 결과. Rendered<P>(page·props)와 구조가 겹치지 않아야 한다 —
9
+ * 겹치면 타입 브리지 PropsOf 가 리다이렉트를 렌더로 오인한다. 디스패처는
10
+ * `'redirect' in result` 로 분기한다.
11
+ */
12
+ export interface Redirect {
13
+ readonly redirect: string;
14
+ }
15
+ /** JSON 응답 결과 — API/JWT 앱이 Inertia 대신 순수 데이터를 반환할 때. */
16
+ export interface JsonResult {
17
+ readonly json: unknown;
18
+ readonly status?: number;
19
+ }
20
+ export type ActionResult<P = unknown> = Rendered<P> | Redirect | JsonResult | void;
21
+ /**
22
+ * 현재 로그인 사용자의 모양. 앱이 declaration merging 으로 자기 User 필드를
23
+ * 얹는다(GaonRouteMap·GaonTables 와 동일 관례) — `gaon g auth` 스캐폴드가
24
+ * 이 병합을 생성한다. 빈 인터페이스라 실제 타입 있는 레코드(RowOf 등)를
25
+ * loadUser 반환으로 그대로 받을 수 있고, 병합 후 this.currentUser 필드에
26
+ * 타입 접근이 열린다.
27
+ */
28
+ export interface GaonCurrentUser {
29
+ }
30
+ /** this.auth — 현재 사용자와 로그인/로그아웃(세션 조작). */
31
+ export interface AuthApi {
32
+ readonly user: GaonCurrentUser | null;
33
+ /** 세션에 사용자 id 를 심어 로그인 상태로 만든다. */
34
+ login(user: {
35
+ id: unknown;
36
+ }): void;
37
+ /** 세션에서 사용자를 지운다. */
38
+ logout(): void;
39
+ }
40
+ export interface JwtTokens {
41
+ readonly accessToken: string;
42
+ readonly refreshToken: string;
43
+ }
44
+ /** this.jwt — JWT 앱(API)에서 토큰을 발급·재발급한다. 세션 앱에는 없다. */
45
+ export interface JwtApi {
46
+ /** 로그인 성공 시 액세스+리프레시 토큰을 발급한다(응답 본문으로 반환). */
47
+ issue(user: {
48
+ id: unknown;
49
+ }): Promise<JwtTokens>;
50
+ /** 리프레시 토큰으로 새 액세스 토큰을 발급한다. 무효면 null. */
51
+ refresh(refreshToken: string): Promise<{
52
+ accessToken: string;
53
+ } | null>;
54
+ }
55
+ /**
56
+ * 액션 본문의 `this`. render 는 항상 있고, 실행 레이어에서 request/reply·
57
+ * 세션·인증이 채워진다(세션·인증 스텝에서 확장). 단위 테스트의 기본
58
+ * 컨텍스트는 render/redirect/params 만 순수 구현하고 request/reply 는 없다.
59
+ */
60
+ export interface ControllerContext {
61
+ render<P>(page: string, props: P): Rendered<P>;
62
+ redirect(to: string): Redirect;
63
+ /** JSON 응답(API/JWT 앱). 값은 응답 경계에서 직렬화된다(bigint/Date/hidden §4.2). */
64
+ json(data: unknown, status?: number): JsonResult;
65
+ /**
66
+ * 폼 입력을 모델 form 의 Row 모양으로 읽는다. 현재는 요청 본문의 타입
67
+ * 있는 통로다 — 스키마 파생 검증(실패 시 자동 422, §5.2)은 model 의 form
68
+ * 이 런타임 컬럼 메타를 실어야 가능하며(현재 form._row 는 팬텀), 그 확장은
69
+ * data 레이어와 함께 후속으로 둔다.
70
+ */
71
+ params<T>(form: {
72
+ readonly _row: T;
73
+ }): T;
74
+ /**
75
+ * 세션 저장소(§7 세션 배터리). 세션 앱에서만 존재하고, JWT/API 앱은
76
+ * undefined 다. 로그인은 `this.session.userId = ...` 로 사용자를 심는다.
77
+ */
78
+ readonly session: Record<string, unknown> | undefined;
79
+ /** CSRF 토큰을 발급한다(세션에 시크릿 저장). 페이지가 폼/헤더로 되돌려 보낸다. */
80
+ csrfToken(): string;
81
+ /** 현재 로그인 사용자. 비로그인 시 null. 인증 설정이 있는 앱에서 채워진다(§7). */
82
+ currentUser: GaonCurrentUser | null;
83
+ readonly auth: AuthApi;
84
+ /** JWT 앱(API)에서만 존재하는 토큰 발급 API. 세션 앱은 undefined. */
85
+ readonly jwt?: JwtApi;
86
+ /**
87
+ * 보호 라우트 가드. 비로그인이면 예외를 던져 디스패처가 로그인 페이지로
88
+ * 리다이렉트(폼/Inertia)하거나 401(API)로 응답한다. 로그인 상태면 사용자를 돌려준다.
89
+ */
90
+ requireAuth(): GaonCurrentUser;
91
+ readonly request: FastifyRequest;
92
+ readonly reply: FastifyReply;
93
+ }
94
+ /** 디스패처가 raw(미바인딩) 액션 맵을 꺼내는 키. */
95
+ export declare const RAW_ACTIONS: unique symbol;
96
+ /** 컨트롤러 객체에서 raw 액션 맵을 읽는다(디스패처 전용). */
97
+ export declare function rawActions(ctrl: object): Record<string, (...a: unknown[]) => unknown>;
98
+ /**
99
+ * 액션 객체를 반환한다. 반환 객체의 각 액션은 기본 컨텍스트에 바인딩돼
100
+ * 있어(단위 테스트용) c.index() 로 직접 호출된다. 동시에 raw 액션 맵을
101
+ * 심볼로 실어, 디스패처가 요청별 컨텍스트로 action.call(ctx) 하게 한다.
102
+ */
103
+ export declare function controller<A extends object>(actions: A & ThisType<ControllerContext>): A;
@@ -0,0 +1,67 @@
1
+ // @gaonjs/web · controller() 런타임 (M3 타입 브리지 + M5 실행 레이어)
2
+ //
3
+ // 설계 §5.2: `controller({ async index() { return this.render(...) } })`.
4
+ // 액션은 this.render(page, props) 로 페이지·props 를, this.redirect(to) 로
5
+ // 리다이렉트를 반환한다. 렌더 반환 타입(Rendered<P>)이 타입 브리지
6
+ // (.gaon/routes.d.ts)를 거쳐 Vue 페이지의 pageProps<K>() 로 흐른다(§6.2·§6.3).
7
+ //
8
+ // ThisType 주의(§13.4-1 의 model 교훈과 동형): 액션 제네릭에 제약을 걸면
9
+ // ThisType 추론이 조용히 붕괴한다 → **무제약 A + ThisType 교차**로 둔다.
10
+ //
11
+ // 실행 레이어(M5): dispatch.ts 가 요청마다 새 컨텍스트를 만들어 액션을
12
+ // 그 컨텍스트로 호출한다. 그래서 controller() 는 raw(미바인딩) 액션을
13
+ // 심볼로 노출한다. 반환 객체 자체는 기본 컨텍스트에 바인딩돼 있어 단위
14
+ // 테스트에서 c.index() 를 그대로 호출할 수 있다(this.render 동작).
15
+ /** 요청 없이 호출되는 기본 컨텍스트(단위 테스트·타입 브리지). */
16
+ const defaultContext = {
17
+ render(page, props) {
18
+ return { page, props };
19
+ },
20
+ redirect(to) {
21
+ return { redirect: to };
22
+ },
23
+ json(data, status) {
24
+ return { json: data, status };
25
+ },
26
+ params(_form) {
27
+ return undefined;
28
+ },
29
+ session: undefined,
30
+ csrfToken() {
31
+ return '';
32
+ },
33
+ currentUser: null,
34
+ auth: { user: null, login() { }, logout() { } },
35
+ requireAuth() {
36
+ throw new Error('requireAuth: 인증 컨텍스트가 없습니다(단위 테스트 기본 컨텍스트).');
37
+ },
38
+ // request/reply 는 실제 요청에서만 존재한다 — 기본 컨텍스트(단위 테스트·타입
39
+ // 브리지)는 의도적으로 생략하므로 unknown 경유로 캐스트한다.
40
+ };
41
+ /** 디스패처가 raw(미바인딩) 액션 맵을 꺼내는 키. */
42
+ export const RAW_ACTIONS = Symbol.for('gaon.web.rawActions');
43
+ /** 컨트롤러 객체에서 raw 액션 맵을 읽는다(디스패처 전용). */
44
+ export function rawActions(ctrl) {
45
+ return ctrl[RAW_ACTIONS];
46
+ }
47
+ /**
48
+ * 액션 객체를 반환한다. 반환 객체의 각 액션은 기본 컨텍스트에 바인딩돼
49
+ * 있어(단위 테스트용) c.index() 로 직접 호출된다. 동시에 raw 액션 맵을
50
+ * 심볼로 실어, 디스패처가 요청별 컨텍스트로 action.call(ctx) 하게 한다.
51
+ */
52
+ export function controller(actions) {
53
+ const raw = {};
54
+ const bound = {};
55
+ for (const [name, fn] of Object.entries(actions)) {
56
+ if (typeof fn === 'function') {
57
+ const f = fn;
58
+ raw[name] = f;
59
+ bound[name] = f.bind(defaultContext);
60
+ }
61
+ else {
62
+ bound[name] = fn;
63
+ }
64
+ }
65
+ Object.defineProperty(bound, RAW_ACTIONS, { value: raw, enumerable: false });
66
+ return bound;
67
+ }
@@ -0,0 +1,30 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
+ import type { RouteDef } from './routes.js';
3
+ import { type ControllerContext } from './controller.js';
4
+ /** 한 앱의 디스패치 재료 — 라우트·컨트롤러 맵. */
5
+ export interface AppDispatch {
6
+ readonly name: string;
7
+ readonly routes: RouteDef;
8
+ /** 컨트롤러 이름 → controller() 객체. */
9
+ readonly controllers: Record<string, object>;
10
+ }
11
+ /** 컨텍스트에 요청별로 얹히는 확장(인증 스텝이 채운다). */
12
+ export interface ContextExtras {
13
+ augment?(ctx: ControllerContext, request: FastifyRequest, reply: FastifyReply): void | Promise<void>;
14
+ }
15
+ export interface DispatchOptions extends ContextExtras {
16
+ /** Inertia 에셋 버전 — 클라이언트와 불일치 시 풀 리로드. */
17
+ readonly version?: string;
18
+ /**
19
+ * requireAuth 실패 시 리다이렉트 경로. 세션 앱은 로그인 페이지로 보내고,
20
+ * 미설정(API/JWT 앱)이면 401 로 응답한다.
21
+ */
22
+ readonly loginRedirect?: string;
23
+ }
24
+ /**
25
+ * 한 앱의 라우트를 (프리픽스가 이미 걸린) Fastify 스코프에 등록한다.
26
+ * 컨트롤러에 없는 액션은 라우팅하지 않는다 — 라우트 표는 후보이고 구현이 진실이다.
27
+ */
28
+ export declare function registerApp(app: FastifyInstance, dispatch: AppDispatch, opts?: DispatchOptions): void;
29
+ /** web 앱은 '/', 그 외는 '/<name>' (§3.3 프리픽스 관례). */
30
+ export declare function prefixFor(name: string): string;
@@ -0,0 +1,121 @@
1
+ // @gaonjs/web · 디스패치 (라우트 표 → Fastify 등록 → 컨트롤러 실행)
2
+ //
3
+ // routes.ts 가 만든 RouteDef.entries 를 돌며, 각 엔트리의 컨트롤러에 해당
4
+ // 액션이 실제로 구현돼 있으면 Fastify 라우트로 등록한다(없는 액션은 건너뜀).
5
+ // 요청마다 새 컨텍스트를 만들어 액션을 그 컨텍스트로 호출하고(§5.2), 결과를
6
+ // Inertia 응답으로 내보낸다.
7
+ //
8
+ // 앱별 프리픽스는 createApp 의 캡슐화 스코프(register({ prefix }))가 담당하므로,
9
+ // 여기서는 라우트를 프리픽스 없는 상대 경로로 등록한다 — 세션·CSRF 도 같은
10
+ // 스코프에 갇혀 앱별 완전 분리가 성립한다(질문 13).
11
+ import { rawActions, } from './controller.js';
12
+ import { inertiaRedirect, renderInertia } from './inertia.js';
13
+ import { serializeProps } from './serialize.js';
14
+ import { isUnauthorized, unauthorizedError } from './auth.js';
15
+ function buildContext(request, reply) {
16
+ // 세션·CSRF 는 @fastify/session · csrf-protection 이 전역 증강으로 붙이지만,
17
+ // 세션 배터리를 등록한 앱에서만 런타임에 존재한다(JWT/API 앱은 undefined).
18
+ const req = request;
19
+ const rep = reply;
20
+ const sess = req.session;
21
+ const auth = {
22
+ user: null,
23
+ login(user) {
24
+ // 세션은 Redis 에 JSON 으로 직렬화된다 — bigint PK 는 JSON 에서 던지므로
25
+ // id 를 문자열로 저장한다. loadUser 가 다시 BigInt 로 되돌린다.
26
+ if (sess)
27
+ sess.userId = String(user.id);
28
+ },
29
+ logout() {
30
+ if (sess)
31
+ delete sess.userId;
32
+ },
33
+ };
34
+ return {
35
+ render(page, props) {
36
+ return { page, props };
37
+ },
38
+ redirect(to) {
39
+ return { redirect: to };
40
+ },
41
+ json(data, status) {
42
+ return { json: data, status };
43
+ },
44
+ params(_form) {
45
+ return (request.body ?? {});
46
+ },
47
+ session: sess,
48
+ csrfToken: () => (rep.generateCsrf ? rep.generateCsrf() : ''),
49
+ currentUser: null,
50
+ auth,
51
+ requireAuth() {
52
+ if (this.currentUser == null)
53
+ throw unauthorizedError();
54
+ return this.currentUser;
55
+ },
56
+ request,
57
+ reply,
58
+ };
59
+ }
60
+ function finish(request, reply, result, version) {
61
+ if (result == null) {
62
+ reply.code(204).send();
63
+ return;
64
+ }
65
+ if ('redirect' in result) {
66
+ inertiaRedirect(request, reply, result.redirect);
67
+ return;
68
+ }
69
+ if ('json' in result) {
70
+ reply
71
+ .code(result.status ?? 200)
72
+ .header('Content-Type', 'application/json; charset=utf-8')
73
+ .send(JSON.stringify(serializeProps(result.json)));
74
+ return;
75
+ }
76
+ renderInertia(request, reply, result.page, result.props, { version });
77
+ }
78
+ /**
79
+ * 한 앱의 라우트를 (프리픽스가 이미 걸린) Fastify 스코프에 등록한다.
80
+ * 컨트롤러에 없는 액션은 라우팅하지 않는다 — 라우트 표는 후보이고 구현이 진실이다.
81
+ */
82
+ export function registerApp(app, dispatch, opts = {}) {
83
+ for (const entry of dispatch.routes.entries) {
84
+ const ctrl = dispatch.controllers[entry.controller];
85
+ if (!ctrl)
86
+ continue;
87
+ const actions = rawActions(ctrl);
88
+ const fn = actions?.[entry.action];
89
+ if (typeof fn !== 'function')
90
+ continue;
91
+ const handler = async (request, reply) => {
92
+ const ctx = buildContext(request, reply);
93
+ if (opts.augment)
94
+ await opts.augment(ctx, request, reply);
95
+ let result;
96
+ try {
97
+ result = (await fn.call(ctx));
98
+ }
99
+ catch (err) {
100
+ if (isUnauthorized(err)) {
101
+ // 세션 앱: 로그인 페이지로. API/JWT 앱: 401.
102
+ if (opts.loginRedirect)
103
+ inertiaRedirect(request, reply, opts.loginRedirect);
104
+ else
105
+ reply.code(401).send({ error: 'Unauthorized' });
106
+ return reply;
107
+ }
108
+ throw err;
109
+ }
110
+ if (reply.sent)
111
+ return reply; // 가드 등이 이미 응답했으면 그대로 둔다
112
+ finish(request, reply, result, opts.version);
113
+ return reply;
114
+ };
115
+ app.route({ method: entry.method.toUpperCase(), url: entry.path, handler });
116
+ }
117
+ }
118
+ /** web 앱은 '/', 그 외는 '/<name>' (§3.3 프리픽스 관례). */
119
+ export function prefixFor(name) {
120
+ return name === 'web' ? '/' : `/${name}`;
121
+ }
@@ -0,0 +1,23 @@
1
+ export interface DiscoveredRoute {
2
+ /** 라우트 맵 키 — `<controller>#<action>`. */
3
+ readonly key: string;
4
+ /** 이 컨트롤러의 import 바인딩 이름(파일 내 유일·유효 식별자). */
5
+ readonly binding: string;
6
+ /** `.gaon/routes.d.ts` 기준 컨트롤러 import 지정자(.js, posix). */
7
+ readonly importPath: string;
8
+ /** 컨트롤러 액션 이름. */
9
+ readonly action: string;
10
+ }
11
+ /** outDir(.gaon) 기준 상대 import 지정자 (.ts→.js, posix, ./·../ 보정). */
12
+ export declare function toImportSpecifier(outDir: string, file: string): string;
13
+ /** 컨트롤러 이름 → 파일 내 유효·유일 import 바인딩. */
14
+ export declare function bindingName(controller: string): string;
15
+ /**
16
+ * 앱 디렉터리를 스캔해 라우트 목록을 수집한다.
17
+ * routes.ts 가 없으면 빈 목록(앱에 라우트가 아직 없을 수 있다).
18
+ */
19
+ export declare function collectRoutes(appDir: string, outDir: string): Promise<DiscoveredRoute[]>;
20
+ /** 수집된 라우트 목록 → routes.d.ts 소스 텍스트. */
21
+ export declare function renderRoutesDts(routes: DiscoveredRoute[]): string;
22
+ /** 전 과정: 앱 스캔 → 렌더 → apps/<app>/.gaon/routes.d.ts 기록. 텍스트 반환. */
23
+ export declare function generateRoutesDts(appDir: string, outFile: string): Promise<string>;
@@ -0,0 +1,120 @@
1
+ // @gaonjs/web · routes.d.ts 생성기 (M3 — 타입 브리지, 앱별)
2
+ //
3
+ // 앱 하나(apps/<app>)의 `routes.ts` 를 실행해 참조 컨트롤러를 얻고,
4
+ // 각 컨트롤러 파일(controllers/<name>.ts)의 default export 에서 **실제
5
+ // 구현된 액션 키**를 읽어 `.gaon/routes.d.ts` 를 낳는다.
6
+ //
7
+ // 생성 파일은 **타입 참조만** 담는다(CLAUDE.md 규칙 3):
8
+ // '<ctrl>#<action>': (typeof <ctrl>)['<action>']
9
+ // TanStack Router·Nuxt 가 검증한 방식이며, 참조만 담아 tsserver 부하가
10
+ // 거의 없다(설계 §6.3). 실제 액션 타입 계산은 전부 추론에 맡긴다.
11
+ import { readdir, stat } from 'node:fs/promises';
12
+ import { mkdir, writeFile } from 'node:fs/promises';
13
+ import { dirname, join, relative, resolve } from 'node:path';
14
+ import { pathToFileURL } from 'node:url';
15
+ /** outDir(.gaon) 기준 상대 import 지정자 (.ts→.js, posix, ./·../ 보정). */
16
+ export function toImportSpecifier(outDir, file) {
17
+ let rel = relative(resolve(outDir), resolve(file)).replace(/\\/g, '/');
18
+ rel = rel.replace(/\.ts$/, '.js');
19
+ if (!rel.startsWith('.'))
20
+ rel = './' + rel;
21
+ return rel;
22
+ }
23
+ /** 컨트롤러 이름 → 파일 내 유효·유일 import 바인딩. */
24
+ export function bindingName(controller) {
25
+ return 'c_' + controller.replace(/[^A-Za-z0-9_]/g, '_');
26
+ }
27
+ /** ESM 캐시 우회 — 워치 재생성 시 편집 반영(§generator.ts 와 동일 기법). */
28
+ async function importFresh(file) {
29
+ const st = await stat(file);
30
+ return (await import(`${pathToFileURL(file).href}?t=${st.mtimeMs}-${st.size}`));
31
+ }
32
+ function isRouteDef(v) {
33
+ return typeof v === 'object' && v !== null && Array.isArray(v.controllers);
34
+ }
35
+ /** default export(actions 객체)에서 함수 값을 가진 키만 액션으로 취한다. */
36
+ function actionKeys(mod) {
37
+ const def = mod.default;
38
+ if (typeof def !== 'object' || def === null)
39
+ return [];
40
+ return Object.entries(def)
41
+ .filter(([, v]) => typeof v === 'function')
42
+ .map(([k]) => k)
43
+ .sort();
44
+ }
45
+ /**
46
+ * 앱 디렉터리를 스캔해 라우트 목록을 수집한다.
47
+ * routes.ts 가 없으면 빈 목록(앱에 라우트가 아직 없을 수 있다).
48
+ */
49
+ export async function collectRoutes(appDir, outDir) {
50
+ const routesFile = join(resolve(appDir), 'routes.ts');
51
+ const controllersDir = join(resolve(appDir), 'controllers');
52
+ const routesMod = await importFresh(routesFile);
53
+ const def = routesMod.default;
54
+ if (!isRouteDef(def)) {
55
+ throw new Error(`[@gaonjs/web] ${routesFile} 의 default export 가 routes(...) 결과가 아닙니다.\n` +
56
+ `→ routes.ts 를 \`export default routes((r) => { ... })\` 형태로 작성하세요.`);
57
+ }
58
+ const existing = new Set(await listControllerFiles(controllersDir));
59
+ const found = [];
60
+ for (const ctrl of def.controllers) {
61
+ const file = join(controllersDir, `${ctrl}.ts`);
62
+ if (!existing.has(file)) {
63
+ throw new Error(`[@gaonjs/web] 라우트가 참조하는 컨트롤러가 없습니다: ${ctrl}\n` +
64
+ `→ ${file} 에 \`export default controller({ ... })\` 를 만드세요.`);
65
+ }
66
+ const mod = await importFresh(file);
67
+ const importPath = toImportSpecifier(outDir, file);
68
+ const binding = bindingName(ctrl);
69
+ for (const action of actionKeys(mod)) {
70
+ found.push({ key: `${ctrl}#${action}`, binding, importPath, action });
71
+ }
72
+ }
73
+ return found.sort((a, b) => a.key.localeCompare(b.key));
74
+ }
75
+ async function listControllerFiles(dir) {
76
+ let entries;
77
+ try {
78
+ entries = await readdir(dir, { withFileTypes: true });
79
+ }
80
+ catch {
81
+ return []; // controllers 디렉터리가 아직 없을 수 있다
82
+ }
83
+ return entries
84
+ .filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.d.ts'))
85
+ .map((e) => join(dir, e.name));
86
+ }
87
+ /** 수집된 라우트 목록 → routes.d.ts 소스 텍스트. */
88
+ export function renderRoutesDts(routes) {
89
+ const lines = [];
90
+ lines.push('// AUTO-GENERATED by @gaonjs/web — 편집 금지.');
91
+ lines.push('// `gaon check` / `gaon dev` 가 검사·개발 중 이 파일을 재생성한다(§6.3).');
92
+ // 컨트롤러별 import (바인딩 유일) — 등장 순서 안정화.
93
+ const seen = new Map(); // binding → importPath
94
+ for (const r of routes)
95
+ if (!seen.has(r.binding))
96
+ seen.set(r.binding, r.importPath);
97
+ for (const [binding, importPath] of seen) {
98
+ lines.push(`import type ${binding} from '${importPath}'`);
99
+ }
100
+ lines.push('');
101
+ lines.push('declare global {');
102
+ lines.push(' interface GaonRouteMap {');
103
+ for (const r of routes) {
104
+ lines.push(` '${r.key}': (typeof ${r.binding})['${r.action}']`);
105
+ }
106
+ lines.push(' }');
107
+ lines.push('}');
108
+ lines.push('');
109
+ lines.push('export {}');
110
+ lines.push('');
111
+ return lines.join('\n');
112
+ }
113
+ /** 전 과정: 앱 스캔 → 렌더 → apps/<app>/.gaon/routes.d.ts 기록. 텍스트 반환. */
114
+ export async function generateRoutesDts(appDir, outFile) {
115
+ const outDir = dirname(resolve(outFile));
116
+ const text = renderRoutesDts(await collectRoutes(appDir, outDir));
117
+ await mkdir(outDir, { recursive: true });
118
+ await writeFile(outFile, text, 'utf8');
119
+ return text;
120
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,13 @@
1
1
  export declare const version: string;
2
- export declare const status: "planned";
2
+ export { routes, type RouteBuilder, type RouteDef, type RouteEntry, type HttpMethod, } from "./routes.js";
3
+ export { controller, rawActions, RAW_ACTIONS, type Rendered, type Redirect, type JsonResult, type ActionResult, type ControllerContext, type AuthApi, type JwtApi, type JwtTokens, type GaonCurrentUser, } from "./controller.js";
4
+ export { collectRoutes, renderRoutesDts, generateRoutesDts, toImportSpecifier, bindingName, type DiscoveredRoute, } from "./generator.js";
5
+ export { createApp, type AppSpec, type SecurityOptions, type CreateAppOptions, } from "./app.js";
6
+ export { registerApp, prefixFor, type AppDispatch, type DispatchOptions, type ContextExtras, } from "./dispatch.js";
7
+ export { registerSession, type AppSessionOptions, } from "./session.js";
8
+ export { redisSessionStore, type RedisStoreOptions, } from "./redisStore.js";
9
+ export { sessionAuthAugment, jwtAuthAugment, unauthorizedError, isUnauthorized, type AuthOptions, type JwtAuthOptions, } from "./auth.js";
10
+ export { signToken, verifyToken, type TokenType } from "./jwt.js";
11
+ export { hashPassword, verifyPassword } from "./password.js";
12
+ export { renderInertia, inertiaRedirect, type InertiaPage, type InertiaOptions, } from "./inertia.js";
13
+ export { serializeProps } from "./serialize.js";
package/dist/index.js CHANGED
@@ -1,8 +1,32 @@
1
1
  /**
2
2
  * @gaonjs/web — Gaon 웹 레이어 (Fastify 통합·라우팅·보안 기본값).
3
3
  *
4
- * 구현은 로드맵 M2 이후. 현재는 개발 상태만 노출하는 스텁이다.
4
+ * 타입 브리지 이음새(M3: routes() DSL·controller() 헬퍼·routes.d.ts 생성기)와
5
+ * HTTP 실행 레이어(M5: Fastify 앱 팩토리·라우트 디스패치·Inertia 응답·보안
6
+ * 기본값)를 함께 제공한다.
5
7
  */
6
8
  import { VERSION } from "@gaonjs/core";
7
9
  export const version = VERSION;
8
- export const status = "planned";
10
+ // 라우팅 DSL (§5.1) — 타입 브리지 + 실행 레이어 라우트 표
11
+ export { routes, } from "./routes.js";
12
+ // controller() — this.render/redirect/params/currentUser (§5.2·§7)
13
+ export { controller, rawActions, RAW_ACTIONS, } from "./controller.js";
14
+ // routes.d.ts 생성기 (앱별 타입 브리지)
15
+ export { collectRoutes, renderRoutesDts, generateRoutesDts, toImportSpecifier, bindingName, } from "./generator.js";
16
+ // HTTP 실행 레이어 (Fastify 앱 팩토리·보안 기본값)
17
+ export { createApp, } from "./app.js";
18
+ // 라우트 디스패치 (라우트 표 → Fastify 등록)
19
+ export { registerApp, prefixFor, } from "./dispatch.js";
20
+ // 세션 배터리 (앱별 분리 + CSRF · Redis 스토어)
21
+ export { registerSession, } from "./session.js";
22
+ export { redisSessionStore, } from "./redisStore.js";
23
+ // 인증 코어 (currentUser · requireAuth · 세션 로그인 · JWT)
24
+ export { sessionAuthAugment, jwtAuthAugment, unauthorizedError, isUnauthorized, } from "./auth.js";
25
+ // JWT 토큰 크립토 (API 앱 · HS256)
26
+ export { signToken, verifyToken } from "./jwt.js";
27
+ // 비밀번호 해싱 (bcrypt · hidden passwordDigest §4.2)
28
+ export { hashPassword, verifyPassword } from "./password.js";
29
+ // Inertia 프로토콜 (서버측 렌더링 계약)
30
+ export { renderInertia, inertiaRedirect, } from "./inertia.js";
31
+ // 응답 경계 직렬화 (hidden 제외 §4.2)
32
+ export { serializeProps } from "./serialize.js";
@@ -0,0 +1,24 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ /** Inertia 페이지 객체 — 응답 본문(JSON) 또는 data-page(HTML)에 실린다. */
3
+ export interface InertiaPage {
4
+ readonly component: string;
5
+ readonly props: unknown;
6
+ readonly url: string;
7
+ readonly version: string;
8
+ }
9
+ export interface InertiaOptions {
10
+ /** 에셋 버전 — 클라이언트와 불일치하면 풀 리로드를 강제한다. 기본 ''. */
11
+ readonly version?: string;
12
+ /** 최초(비-Inertia) 요청에 쓰는 HTML 문서 셸. 기본 셸은 #app + data-page. */
13
+ readonly rootTemplate?: (page: InertiaPage) => string;
14
+ }
15
+ /**
16
+ * render 결과(page + props)를 Inertia 응답으로 내보낸다.
17
+ * props 는 응답 경계에서 직렬화된다(Date/bigint→string, hidden 제외 §4.2).
18
+ */
19
+ export declare function renderInertia(request: FastifyRequest, reply: FastifyReply, page: string, props: unknown, opts?: InertiaOptions): void;
20
+ /**
21
+ * Inertia 규약에 맞는 리다이렉트. 변경 메서드(PUT/PATCH/DELETE) 뒤에는 303
22
+ * 이어야 브라우저·클라이언트가 GET 으로 팔로우한다(그 외 302).
23
+ */
24
+ export declare function inertiaRedirect(request: FastifyRequest, reply: FastifyReply, to: string): void;
@@ -0,0 +1,74 @@
1
+ // @gaonjs/web · Inertia 프로토콜 (서버측 렌더링 계약)
2
+ //
3
+ // 컨트롤러의 render('posts/Index', props) 는 프론트엔드를 모른다 — 서버 계약은
4
+ // "페이지 이름 + 직렬화된 props" 뿐이다(§6.1·§6.4). 그 계약을 HTTP 로 실어
5
+ // 나르는 것이 Inertia 프로토콜이다:
6
+ //
7
+ // · X-Inertia 요청(SPA 네비게이션): JSON { component, props, url, version } 응답.
8
+ // · 최초 요청(브라우저 문서 로드): HTML 셸에 페이지 객체를 data-page 로 임베드.
9
+ // 실제 마운트는 프론트 어댑터(@gaonjs/vue)와 앱 엔트리가 수행한다.
10
+ // · 리다이렉트: PUT/PATCH/DELETE 뒤에는 303(그래야 브라우저가 GET 으로 팔로우),
11
+ // 그 외 302. 버전 불일치 GET 은 409 + X-Inertia-Location 으로 풀 리로드 유도.
12
+ //
13
+ // 참조: Inertia.js 프로토콜(https://inertiajs.com/the-protocol) — Laravel 어댑터가
14
+ // 검증한 규약을 그대로 따른다.
15
+ import { serializeProps } from './serialize.js';
16
+ function isInertia(request) {
17
+ return request.headers['x-inertia'] === 'true';
18
+ }
19
+ /** data-page 속성에 안전하게 넣기 위한 HTML 속성 이스케이프. */
20
+ function escapeAttr(json) {
21
+ return json
22
+ .replace(/&/g, '&amp;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;');
27
+ }
28
+ function defaultRoot(page) {
29
+ const data = escapeAttr(JSON.stringify(page));
30
+ // 앱 엔트리(스캐폴드가 생성)가 #app 을 마운트하고 data-page 를 읽는다.
31
+ return ('<!doctype html>\n' +
32
+ '<html>\n<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>\n' +
33
+ `<body><div id="app" data-page="${data}"></div></body>\n</html>\n`);
34
+ }
35
+ /**
36
+ * render 결과(page + props)를 Inertia 응답으로 내보낸다.
37
+ * props 는 응답 경계에서 직렬화된다(Date/bigint→string, hidden 제외 §4.2).
38
+ */
39
+ export function renderInertia(request, reply, page, props, opts = {}) {
40
+ const version = opts.version ?? '';
41
+ const pageObject = {
42
+ component: page,
43
+ props: serializeProps(props),
44
+ url: request.url,
45
+ version,
46
+ };
47
+ // 버전 불일치(X-Inertia GET): 409 + Location 으로 클라이언트가 풀 리로드.
48
+ if (isInertia(request) && request.method === 'GET') {
49
+ const clientVersion = request.headers['x-inertia-version'];
50
+ if (clientVersion !== undefined && clientVersion !== version) {
51
+ reply.header('X-Inertia-Location', request.url).code(409).send();
52
+ return;
53
+ }
54
+ }
55
+ if (isInertia(request)) {
56
+ reply
57
+ .header('X-Inertia', 'true')
58
+ .header('Vary', 'X-Inertia')
59
+ .header('Content-Type', 'application/json; charset=utf-8')
60
+ .send(JSON.stringify(pageObject));
61
+ return;
62
+ }
63
+ const root = opts.rootTemplate ?? defaultRoot;
64
+ reply.header('Content-Type', 'text/html; charset=utf-8').send(root(pageObject));
65
+ }
66
+ /**
67
+ * Inertia 규약에 맞는 리다이렉트. 변경 메서드(PUT/PATCH/DELETE) 뒤에는 303
68
+ * 이어야 브라우저·클라이언트가 GET 으로 팔로우한다(그 외 302).
69
+ */
70
+ export function inertiaRedirect(request, reply, to) {
71
+ const m = request.method;
72
+ const code = m === 'PUT' || m === 'PATCH' || m === 'DELETE' ? 303 : 302;
73
+ reply.header('Location', to).code(code).send();
74
+ }
package/dist/jwt.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { type JWTPayload } from 'jose';
2
+ /** typ 클레임으로 액세스/리프레시를 구분한다. */
3
+ export type TokenType = 'access' | 'refresh';
4
+ /** sub(사용자 id) + typ 을 담은 HS256 토큰을 만든다. ttl 예: '15m', '7d'. */
5
+ export declare function signToken(secret: string, sub: string, typ: TokenType, ttl: string): Promise<string>;
6
+ /** 토큰을 검증한다. 유효하면 payload, 만료·위조·불일치면 null(예외를 삼킨다). */
7
+ export declare function verifyToken(secret: string, token: string): Promise<JWTPayload | null>;
package/dist/jwt.js ADDED
@@ -0,0 +1,29 @@
1
+ // @gaonjs/web · JWT 토큰 (API 앱 옵션 · §7 질문 4)
2
+ //
3
+ // 세션 쿠키가 기본이고, JWT 는 API 전용 앱을 위한 옵션이다(app.config 에서 선택).
4
+ // 액세스 토큰(짧은 수명)으로 요청을 인증하고, 리프레시 토큰(긴 수명)으로 액세스
5
+ // 토큰을 재발급한다. 서명은 HS256(jose) — 앱마다 secret 이 달라 토큰이 앱을
6
+ // 넘지 못한다(질문 13 — API 앱의 앱별 분리).
7
+ import { SignJWT, jwtVerify } from 'jose';
8
+ function keyOf(secret) {
9
+ return new TextEncoder().encode(secret);
10
+ }
11
+ /** sub(사용자 id) + typ 을 담은 HS256 토큰을 만든다. ttl 예: '15m', '7d'. */
12
+ export function signToken(secret, sub, typ, ttl) {
13
+ return new SignJWT({ typ })
14
+ .setProtectedHeader({ alg: 'HS256' })
15
+ .setSubject(sub)
16
+ .setIssuedAt()
17
+ .setExpirationTime(ttl)
18
+ .sign(keyOf(secret));
19
+ }
20
+ /** 토큰을 검증한다. 유효하면 payload, 만료·위조·불일치면 null(예외를 삼킨다). */
21
+ export async function verifyToken(secret, token) {
22
+ try {
23
+ const { payload } = await jwtVerify(token, keyOf(secret));
24
+ return payload;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
@@ -0,0 +1,4 @@
1
+ /** 평문 비밀번호를 bcrypt 다이제스트로 해싱한다(passwordDigest 컬럼에 저장). */
2
+ export declare function hashPassword(plain: string): Promise<string>;
3
+ /** 평문과 저장된 다이제스트를 상수시간 비교한다. */
4
+ export declare function verifyPassword(plain: string, digest: string): Promise<boolean>;
@@ -0,0 +1,17 @@
1
+ // @gaonjs/web · 비밀번호 해싱 (§7 인증)
2
+ //
3
+ // 해시는 bcrypt 로 한다(설계 §7 "bcrypt or argon2"). 순수 JS 구현인 bcryptjs
4
+ // 를 쓰는 이유: 네이티브 빌드(node-gyp)가 없어 어느 환경에서나 설치가 확실하다
5
+ // (AI 첫 시도 성공률 §1.2). 해시 결과는 스키마의 hidden 컬럼(passwordDigest)에
6
+ // 담기며, 직렬화 경계에서 타입·런타임 양쪽으로 페이지 노출이 막힌다(§4.2).
7
+ import bcrypt from 'bcryptjs';
8
+ /** bcrypt cost. 10 은 보안/지연의 통상 균형점. */
9
+ const COST = 10;
10
+ /** 평문 비밀번호를 bcrypt 다이제스트로 해싱한다(passwordDigest 컬럼에 저장). */
11
+ export function hashPassword(plain) {
12
+ return bcrypt.hash(plain, COST);
13
+ }
14
+ /** 평문과 저장된 다이제스트를 상수시간 비교한다. */
15
+ export function verifyPassword(plain, digest) {
16
+ return bcrypt.compare(plain, digest);
17
+ }
@@ -0,0 +1,10 @@
1
+ import type { Redis } from 'ioredis';
2
+ import type { SessionStore } from '@fastify/session';
3
+ export interface RedisStoreOptions {
4
+ /** 앱별 세션 키 prefix — 완전 분리의 축. 예: 'gaon:sess:web:'. */
5
+ readonly prefix: string;
6
+ /** 세션 만료(초). session.cookie.maxAge 가 있으면 그 값을 우선한다. */
7
+ readonly ttlSeconds: number;
8
+ }
9
+ /** ioredis 인스턴스를 @fastify/session Store 로 감싼다. */
10
+ export declare function redisSessionStore(redis: Redis, opts: RedisStoreOptions): SessionStore;
@@ -0,0 +1,37 @@
1
+ // @gaonjs/web · Redis 세션 스토어 (@fastify/session Store 구현)
2
+ //
3
+ // 세션은 Redis 에 둔다(§7.4 — 캐시/세션 기본 Redis, 개발·테스트도 실 Redis).
4
+ // 앱별 키 prefix 로 세션 저장 공간을 완전히 분리한다(§ 질문 13 — 앱 간 로그인
5
+ // 공유 불허). 인메모리 기본 스토어는 프로세스가 여럿이면 세션이 새므로
6
+ // 운영 경로가 아니다(@fastify/session 문서도 경고).
7
+ /** ioredis 인스턴스를 @fastify/session Store 로 감싼다. */
8
+ export function redisSessionStore(redis, opts) {
9
+ const keyOf = (sid) => `${opts.prefix}${sid}`;
10
+ const ttlOf = (session) => {
11
+ const maxAge = session.cookie?.maxAge;
12
+ if (typeof maxAge === 'number' && maxAge > 0)
13
+ return Math.ceil(maxAge / 1000);
14
+ return opts.ttlSeconds;
15
+ };
16
+ return {
17
+ set(sessionId, session, callback) {
18
+ const payload = JSON.stringify(session);
19
+ redis
20
+ .set(keyOf(sessionId), payload, 'EX', ttlOf(session))
21
+ .then(() => callback(null))
22
+ .catch((err) => callback(err));
23
+ },
24
+ get(sessionId, callback) {
25
+ redis
26
+ .get(keyOf(sessionId))
27
+ .then((raw) => callback(null, raw ? JSON.parse(raw) : null))
28
+ .catch((err) => callback(err));
29
+ },
30
+ destroy(sessionId, callback) {
31
+ redis
32
+ .del(keyOf(sessionId))
33
+ .then(() => callback(null))
34
+ .catch((err) => callback(err));
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,27 @@
1
+ export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
2
+ /** 실행 레이어가 소비하는 라우트 엔트리. path 는 앱 프리픽스 이전의 상대 경로. */
3
+ export interface RouteEntry {
4
+ readonly method: HttpMethod;
5
+ readonly path: string;
6
+ readonly controller: string;
7
+ readonly action: string;
8
+ }
9
+ export interface RouteBuilder {
10
+ /** Rails 식 복수 리소스 — index/new/create/show/edit/update/destroy. */
11
+ resources(name: string): void;
12
+ /** 단수 리소스(세션 등) — new/create/show/update/destroy (index·:id 없음). */
13
+ resource(name: string): void;
14
+ get(path: string, target: string): void;
15
+ post(path: string, target: string): void;
16
+ put(path: string, target: string): void;
17
+ patch(path: string, target: string): void;
18
+ delete(path: string, target: string): void;
19
+ }
20
+ export interface RouteDef {
21
+ /** 참조 컨트롤러 이름들(정렬·중복제거). 타입 브리지 입력. */
22
+ readonly controllers: readonly string[];
23
+ /** 실행 레이어용 라우트 엔트리(선언 순서). */
24
+ readonly entries: readonly RouteEntry[];
25
+ }
26
+ /** 라우트 정의 빌더를 실행해 컨트롤러 목록과 라우트 엔트리를 수집한다. */
27
+ export declare function routes(build: (r: RouteBuilder) => void): RouteDef;
package/dist/routes.js ADDED
@@ -0,0 +1,63 @@
1
+ // @gaonjs/web · 라우팅 DSL (§5.1)
2
+ //
3
+ // `routes((r) => { r.resources('posts') })` 형태다. 두 소비자가 있다:
4
+ // · 타입 브리지(routes.d.ts 생성기): 어느 컨트롤러가 참조되는지(controllers).
5
+ // · 실행 레이어(dispatch): method·path·controller·action 엔트리(entries).
6
+ //
7
+ // resources/resource 는 Rails 관례로 표준 액션을 전개한다. 실제로 어떤
8
+ // 액션이 등록되는지는 디스패처가 컨트롤러에 그 액션이 구현돼 있는지로
9
+ // 거른다 — 라우트 표는 후보이고, 없는 액션은 라우팅되지 않는다.
10
+ function resourcesEntries(name) {
11
+ return [
12
+ { method: 'get', path: `/${name}`, controller: name, action: 'index' },
13
+ { method: 'get', path: `/${name}/new`, controller: name, action: 'new' },
14
+ { method: 'post', path: `/${name}`, controller: name, action: 'create' },
15
+ { method: 'get', path: `/${name}/:id`, controller: name, action: 'show' },
16
+ { method: 'get', path: `/${name}/:id/edit`, controller: name, action: 'edit' },
17
+ { method: 'patch', path: `/${name}/:id`, controller: name, action: 'update' },
18
+ { method: 'delete', path: `/${name}/:id`, controller: name, action: 'destroy' },
19
+ ];
20
+ }
21
+ function resourceEntries(name) {
22
+ return [
23
+ { method: 'get', path: `/${name}/new`, controller: name, action: 'new' },
24
+ { method: 'post', path: `/${name}`, controller: name, action: 'create' },
25
+ { method: 'get', path: `/${name}`, controller: name, action: 'show' },
26
+ { method: 'patch', path: `/${name}`, controller: name, action: 'update' },
27
+ { method: 'delete', path: `/${name}`, controller: name, action: 'destroy' },
28
+ ];
29
+ }
30
+ /** `controller#action` 대상을 파싱한다. action 생략 시 관례로 채우지 않고 그대로 둔다. */
31
+ function parseTarget(target) {
32
+ const [controller, action] = target.split('#');
33
+ return { controller: (controller ?? '').trim(), action: (action ?? '').trim() };
34
+ }
35
+ /** 라우트 정의 빌더를 실행해 컨트롤러 목록과 라우트 엔트리를 수집한다. */
36
+ export function routes(build) {
37
+ const controllers = new Set();
38
+ const entries = [];
39
+ const manual = (method, path, target) => {
40
+ const { controller, action } = parseTarget(target);
41
+ if (controller) {
42
+ controllers.add(controller);
43
+ entries.push({ method, path, controller, action });
44
+ }
45
+ };
46
+ const r = {
47
+ resources: (name) => {
48
+ controllers.add(name);
49
+ entries.push(...resourcesEntries(name));
50
+ },
51
+ resource: (name) => {
52
+ controllers.add(name);
53
+ entries.push(...resourceEntries(name));
54
+ },
55
+ get: (p, t) => manual('get', p, t),
56
+ post: (p, t) => manual('post', p, t),
57
+ put: (p, t) => manual('put', p, t),
58
+ patch: (p, t) => manual('patch', p, t),
59
+ delete: (p, t) => manual('delete', p, t),
60
+ };
61
+ build(r);
62
+ return { controllers: [...controllers].sort(), entries };
63
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 컨트롤러 props 를 JSON-safe 페이지 props 로 변환한다.
3
+ * Date→ISO 문자열, bigint→문자열, 함수 제거, hidden 컬럼 제외, 중첩 재귀.
4
+ */
5
+ export declare function serializeProps<T>(value: T): unknown;
@@ -0,0 +1,46 @@
1
+ // @gaonjs/web · 응답 경계 직렬화 (render → Inertia props 변환)
2
+ //
3
+ // 컨트롤러가 render(page, props) 로 넘긴 props 는 그대로 JSON 이 아니다(§4.4):
4
+ // 모델 레코드에는 메서드·관계 함수가 붙어 있고, Date·bigint 는 JSON 위에서
5
+ // 문자열이 되며(bigint 는 JSON.stringify 가 던진다), hidden 컬럼(예:
6
+ // passwordDigest)은 노출되면 안 된다(§4.2).
7
+ //
8
+ // 이 변환은 프론트엔드 중립이다(§6.4 — 서버 계약은 "페이지 이름 + 직렬화된
9
+ // props" 뿐). 따라서 render→응답을 소유한 web 이 런타임을 갖고, @gaonjs/vue
10
+ // 는 이 JSON 모양에 대응하는 타입 매핑(Serialized<T>)과 클라이언트 이음새를
11
+ // 담당한다. hidden 판정 지식(HIDDEN_COLUMNS 마커)의 출처는 @gaonjs/core 다.
12
+ import { HIDDEN_COLUMNS } from '@gaonjs/core';
13
+ /**
14
+ * 컨트롤러 props 를 JSON-safe 페이지 props 로 변환한다.
15
+ * Date→ISO 문자열, bigint→문자열, 함수 제거, hidden 컬럼 제외, 중첩 재귀.
16
+ */
17
+ export function serializeProps(value) {
18
+ return serialize(value);
19
+ }
20
+ function serialize(value) {
21
+ if (value === null || value === undefined)
22
+ return value;
23
+ if (typeof value === 'bigint')
24
+ return value.toString();
25
+ if (value instanceof Date)
26
+ return value.toISOString();
27
+ if (Array.isArray(value))
28
+ return value.map(serialize);
29
+ if (typeof value === 'object') {
30
+ // 모델 레코드는 HIDDEN_COLUMNS 마커(비열거)로 제외할 컬럼명을 싣는다.
31
+ const hidden = value[HIDDEN_COLUMNS];
32
+ const out = {};
33
+ for (const key of Object.keys(value)) {
34
+ if (hidden && hidden.includes(key))
35
+ continue;
36
+ const v = value[key];
37
+ if (typeof v === 'function')
38
+ continue; // 메서드·관계 함수는 직렬화하지 않는다
39
+ out[key] = serialize(v);
40
+ }
41
+ return out;
42
+ }
43
+ if (typeof value === 'function')
44
+ return undefined;
45
+ return value;
46
+ }
@@ -0,0 +1,27 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { Redis } from 'ioredis';
3
+ export interface AppSessionOptions {
4
+ /** 세션 스토어 백엔드(Redis 인스턴스). redisUrl 과 택일. 앱별 prefix 로 분리된다. */
5
+ readonly redis?: Redis;
6
+ /** Redis 접속 URL. redis 인스턴스 대신 이걸 주면 프레임웍이 클라이언트를 만든다(스캐폴드 DX). */
7
+ readonly redisUrl?: string;
8
+ /** 서명 secret — 32자 이상(@fastify/session 요구). 앱마다 달라야 분리가 완성된다. */
9
+ readonly secret: string;
10
+ /** 세션 쿠키 이름. 생략 시 `<app>_sid`. */
11
+ readonly cookieName?: string;
12
+ /** Redis 키 prefix. 생략 시 `gaon:sess:<app>:`. */
13
+ readonly redisPrefix?: string;
14
+ /** 세션 만료(초). 기본 7일. */
15
+ readonly ttlSeconds?: number;
16
+ /** 쿠키 Secure. 기본 NODE_ENV==='production'. */
17
+ readonly secure?: boolean;
18
+ /** SameSite. 기본 'lax'. */
19
+ readonly sameSite?: 'lax' | 'strict' | 'none';
20
+ /** CSRF 보호. 기본 켬(§2.5.1). 명시적으로만 끈다. */
21
+ readonly csrf?: boolean;
22
+ }
23
+ /**
24
+ * 한 앱 스코프에 세션(+CSRF)을 등록한다. createApp 이 앱별 캡슐화 스코프
25
+ * 안에서 호출하므로, 세션·쿠키·CSRF 가 그 앱의 라우트에만 적용된다.
26
+ */
27
+ export declare function registerSession(scope: FastifyInstance, appName: string, prefix: string, opts: AppSessionOptions): Promise<void>;
@@ -0,0 +1,76 @@
1
+ // @gaonjs/web · 세션 배터리 (앱별 완전 분리 + CSRF)
2
+ //
3
+ // 세션 쿠키가 기본 인증 방식이다(§7 · 질문 4). 앱별로 세션을 **완전히
4
+ // 분리**한다(질문 13 — 앱 간 로그인 공유 불허): 각 앱은 자기만의
5
+ // · 세션 쿠키 이름(`<app>_sid`)
6
+ // · 서명 secret
7
+ // · Redis 키 prefix
8
+ // · 쿠키 path(앱 프리픽스)
9
+ // 를 갖는다. 이는 Fastify 캡슐화 스코프(createApp 이 앱마다 register)에서
10
+ // 세션 플러그인을 앱 단위로 등록해 달성한다.
11
+ //
12
+ // CSRF 는 보안 기본값으로 켠다(§2.5.1). 세션에 시크릿을 두고(@fastify/session
13
+ // 연동), 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 토큰 검증을 강제한다.
14
+ // 토큰은 X-CSRF-TOKEN 헤더(Inertia/axios 관례) 또는 폼 _csrf 로 받는다.
15
+ import { Redis } from 'ioredis';
16
+ import session from '@fastify/session';
17
+ import csrf from '@fastify/csrf-protection';
18
+ import { redisSessionStore } from './redisStore.js';
19
+ const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60; // 7일
20
+ const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
21
+ function csrfToken(req) {
22
+ const header = req.headers['x-csrf-token'];
23
+ if (typeof header === 'string')
24
+ return header;
25
+ const body = req.body;
26
+ if (body && typeof body._csrf === 'string')
27
+ return body._csrf;
28
+ }
29
+ /**
30
+ * 한 앱 스코프에 세션(+CSRF)을 등록한다. createApp 이 앱별 캡슐화 스코프
31
+ * 안에서 호출하므로, 세션·쿠키·CSRF 가 그 앱의 라우트에만 적용된다.
32
+ */
33
+ export async function registerSession(scope, appName, prefix, opts) {
34
+ const ttlSeconds = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;
35
+ const secure = opts.secure ?? process.env.NODE_ENV === 'production';
36
+ // redis 인스턴스 우선, 없으면 redisUrl 로 만든다(프레임웍 소유 → onClose 에서 정리).
37
+ let redis = opts.redis;
38
+ if (!redis) {
39
+ if (!opts.redisUrl) {
40
+ throw new Error(`[@gaonjs/web] 세션 설정에 redis 인스턴스나 redisUrl 이 필요합니다.\n` +
41
+ `→ app.config 의 session 에 { redisUrl: 'redis://127.0.0.1:6379' } 를 추가하세요.`);
42
+ }
43
+ redis = new Redis(opts.redisUrl);
44
+ scope.addHook('onClose', async () => {
45
+ await redis.quit();
46
+ });
47
+ }
48
+ await scope.register(session, {
49
+ secret: opts.secret,
50
+ cookieName: opts.cookieName ?? `${appName}_sid`,
51
+ saveUninitialized: false,
52
+ store: redisSessionStore(redis, {
53
+ prefix: opts.redisPrefix ?? `gaon:sess:${appName}:`,
54
+ ttlSeconds,
55
+ }),
56
+ cookie: {
57
+ httpOnly: true,
58
+ secure,
59
+ sameSite: opts.sameSite ?? 'lax',
60
+ path: prefix, // 쿠키를 앱 프리픽스로 스코프 — 분리 방어 한 겹 더
61
+ maxAge: ttlSeconds * 1000,
62
+ },
63
+ });
64
+ if (opts.csrf !== false) {
65
+ await scope.register(csrf, {
66
+ sessionPlugin: '@fastify/session',
67
+ getToken: csrfToken,
68
+ });
69
+ // 상태 변경 메서드에 토큰 검증을 강제한다(안전 메서드는 통과).
70
+ scope.addHook('preHandler', (req, reply, done) => {
71
+ if (!UNSAFE.has(req.method))
72
+ return done();
73
+ scope.csrfProtection(req, reply, done);
74
+ });
75
+ }
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/web",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Gaon 웹 레이어: Fastify 통합·파일 기반 라우팅·보안 기본값 (구현 예정)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,16 @@
24
24
  "README.md"
25
25
  ],
26
26
  "dependencies": {
27
- "@gaonjs/core": "0.1.2"
27
+ "@fastify/cookie": "^11.1.2",
28
+ "@fastify/cors": "^11.3.0",
29
+ "@fastify/csrf-protection": "^8.0.0",
30
+ "@fastify/rate-limit": "^11.1.0",
31
+ "@fastify/session": "^11.1.2",
32
+ "bcryptjs": "^3.0.3",
33
+ "fastify": "^5.10.0",
34
+ "ioredis": "^5.11.1",
35
+ "jose": "^6.2.4",
36
+ "@gaonjs/core": "0.1.3"
28
37
  },
29
38
  "scripts": {
30
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"