@gencow/core 0.1.19 → 0.1.22
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/crud.d.ts +30 -12
- package/dist/crud.js +233 -52
- package/dist/index.d.ts +18 -17
- package/dist/index.js +10 -10
- package/dist/reactive.d.ts +4 -4
- package/dist/rls-db.d.ts +3 -5
- package/dist/rls-db.js +3 -5
- package/dist/rls.d.ts +44 -1
- package/dist/rls.js +62 -2
- package/dist/server.d.ts +5 -4
- package/dist/server.js +4 -4
- package/dist/storage.d.ts +29 -2
- package/dist/storage.js +396 -8
- package/package.json +6 -2
- package/src/__tests__/crud-owner-rls.test.ts +380 -0
- package/src/__tests__/fixtures/basic/auth.ts +32 -0
- package/src/__tests__/fixtures/basic/drizzle.config.ts +15 -0
- package/src/__tests__/fixtures/basic/index.ts +6 -0
- package/src/__tests__/fixtures/basic/migrations/0000_faithful_silver_sable.sql +66 -0
- package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +438 -0
- package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +13 -0
- package/src/__tests__/fixtures/basic/schema.ts +35 -0
- package/src/__tests__/fixtures/basic/tasks.ts +15 -0
- package/src/__tests__/fixtures/common/auth-schema.ts +63 -0
- package/src/__tests__/helpers/pglite-migrations.ts +35 -0
- package/src/__tests__/helpers/pglite-rls-session.ts +54 -0
- package/src/__tests__/helpers/seed-like-fill.ts +196 -0
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +53 -0
- package/src/__tests__/image-optimization.test.ts +652 -0
- package/src/__tests__/rls-crud-basic.test.ts +431 -0
- package/src/__tests__/tsconfig.json +8 -0
- package/src/crud.ts +272 -49
- package/src/index.ts +18 -17
- package/src/reactive.ts +4 -4
- package/src/rls-db.ts +3 -5
- package/src/rls.ts +87 -3
- package/src/server.ts +5 -4
- package/src/storage.ts +473 -8
- package/dist/scoped-db.d.ts +0 -34
- package/dist/scoped-db.js +0 -364
- package/dist/table.d.ts +0 -67
- package/dist/table.js +0 -98
package/dist/crud.d.ts
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
* export const { list, get, create, update, remove } = crud(tasks);
|
|
13
13
|
* ```
|
|
14
14
|
*
|
|
15
|
+
* ownerRls 연동 (2-Layer 방어):
|
|
16
|
+
* ```ts
|
|
17
|
+
* export const tasks = pgTable("tasks", {
|
|
18
|
+
* id: serial("id").primaryKey(),
|
|
19
|
+
* userId: text("user_id").notNull(),
|
|
20
|
+
* }, (t) => ownerRls(t.userId));
|
|
21
|
+
*
|
|
22
|
+
* // crud()가 ownerRls를 자동 감지 → 모든 쿼리에 WHERE userId = auth.userId 주입
|
|
23
|
+
* export const { list, get, create, update, remove } = crud(tasks);
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
15
26
|
* list 반환값: `{ data: T[], total: number }`
|
|
16
27
|
* → 프론트: `const result = useQuery(api.tasks.list); result?.data.map(...)`
|
|
17
28
|
* → total은 동일 필터 조건의 전체 레코드 수 (페이지네이션 UI 지원)
|
|
@@ -20,10 +31,17 @@
|
|
|
20
31
|
* 프론트엔드에서 useQuery(api.tasks.list) / useMutation(api.tasks.create) 로 바로 사용 가능.
|
|
21
32
|
*
|
|
22
33
|
* 📄 Spec: docs/specs/spec-crud-advanced-filters.md (v3), docs/specs/spec-crud-v2-enhancements.md (v2)
|
|
34
|
+
* 📄 ownerRls: docs/specs/spec-ownerrls-complete.md
|
|
23
35
|
* 📦 참조: saas-js/drizzle-crud 팩토리 패턴
|
|
24
36
|
*/
|
|
25
37
|
import { type SQL } from "drizzle-orm";
|
|
26
38
|
import type { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
39
|
+
/** 부트 로그에서 ownerRls 감지 테이블 목록을 반환 */
|
|
40
|
+
export declare function getOwnerRlsTables(): readonly {
|
|
41
|
+
tableName: string;
|
|
42
|
+
columnName: string;
|
|
43
|
+
readPublic: boolean;
|
|
44
|
+
}[];
|
|
27
45
|
type CrudOptions<T extends PgTable> = {
|
|
28
46
|
/** 검색 대상 필드 — list의 search 파라미터에 사용 */
|
|
29
47
|
searchFields?: (keyof T["_"]["columns"])[];
|
|
@@ -100,23 +118,23 @@ export declare function parseFilterNode(node: Record<string, unknown>, table: an
|
|
|
100
118
|
* ```
|
|
101
119
|
*/
|
|
102
120
|
export declare function crud<T extends PgTable>(table: T, options?: CrudOptions<T>): {
|
|
103
|
-
list: import("./reactive").QueryDef<{
|
|
104
|
-
page: import("./v").Validator<number | undefined>;
|
|
105
|
-
limit: import("./v").Validator<number | undefined>;
|
|
106
|
-
search: import("./v").Validator<string | undefined>;
|
|
107
|
-
orderBy: import("./v").Validator<string | undefined>;
|
|
108
|
-
orderDir: import("./v").Validator<string | undefined>;
|
|
109
|
-
filters: import("./v").Validator<any>;
|
|
121
|
+
list: import("./reactive.js").QueryDef<{
|
|
122
|
+
page: import("./v.js").Validator<number | undefined>;
|
|
123
|
+
limit: import("./v.js").Validator<number | undefined>;
|
|
124
|
+
search: import("./v.js").Validator<string | undefined>;
|
|
125
|
+
orderBy: import("./v.js").Validator<string | undefined>;
|
|
126
|
+
orderDir: import("./v.js").Validator<string | undefined>;
|
|
127
|
+
filters: import("./v.js").Validator<any>;
|
|
110
128
|
}, {
|
|
111
129
|
data: any;
|
|
112
130
|
total: number;
|
|
113
131
|
}> | undefined;
|
|
114
|
-
get: import("./reactive").QueryDef<{
|
|
115
|
-
id: import("./v").Validator<string> | import("./v").Validator<number>;
|
|
132
|
+
get: import("./reactive.js").QueryDef<{
|
|
133
|
+
id: import("./v.js").Validator<string> | import("./v.js").Validator<number>;
|
|
116
134
|
}, any> | undefined;
|
|
117
|
-
create: import("./reactive").MutationDef<any, any> | undefined;
|
|
118
|
-
update: import("./reactive").MutationDef<any, any> | undefined;
|
|
119
|
-
remove: import("./reactive").MutationDef<any, {
|
|
135
|
+
create: import("./reactive.js").MutationDef<any, any> | undefined;
|
|
136
|
+
update: import("./reactive.js").MutationDef<any, any> | undefined;
|
|
137
|
+
remove: import("./reactive.js").MutationDef<any, {
|
|
120
138
|
success: boolean;
|
|
121
139
|
}> | undefined;
|
|
122
140
|
};
|
package/dist/crud.js
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
* export const { list, get, create, update, remove } = crud(tasks);
|
|
13
13
|
* ```
|
|
14
14
|
*
|
|
15
|
+
* ownerRls 연동 (2-Layer 방어):
|
|
16
|
+
* ```ts
|
|
17
|
+
* export const tasks = pgTable("tasks", {
|
|
18
|
+
* id: serial("id").primaryKey(),
|
|
19
|
+
* userId: text("user_id").notNull(),
|
|
20
|
+
* }, (t) => ownerRls(t.userId));
|
|
21
|
+
*
|
|
22
|
+
* // crud()가 ownerRls를 자동 감지 → 모든 쿼리에 WHERE userId = auth.userId 주입
|
|
23
|
+
* export const { list, get, create, update, remove } = crud(tasks);
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
15
26
|
* list 반환값: `{ data: T[], total: number }`
|
|
16
27
|
* → 프론트: `const result = useQuery(api.tasks.list); result?.data.map(...)`
|
|
17
28
|
* → total은 동일 필터 조건의 전체 레코드 수 (페이지네이션 UI 지원)
|
|
@@ -20,11 +31,21 @@
|
|
|
20
31
|
* 프론트엔드에서 useQuery(api.tasks.list) / useMutation(api.tasks.create) 로 바로 사용 가능.
|
|
21
32
|
*
|
|
22
33
|
* 📄 Spec: docs/specs/spec-crud-advanced-filters.md (v3), docs/specs/spec-crud-v2-enhancements.md (v2)
|
|
34
|
+
* 📄 ownerRls: docs/specs/spec-ownerrls-complete.md
|
|
23
35
|
* 📦 참조: saas-js/drizzle-crud 팩토리 패턴
|
|
24
36
|
*/
|
|
25
|
-
import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName } from "drizzle-orm";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
37
|
+
import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName, getTableColumns } from "drizzle-orm";
|
|
38
|
+
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
39
|
+
import { query, mutation } from "./reactive.js";
|
|
40
|
+
import { v } from "./v.js";
|
|
41
|
+
import { getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
|
|
42
|
+
// ─── ownerRls 감지 추적 (부트 로그용) ──────────────────
|
|
43
|
+
/** crud()가 ownerRls를 자동 감지한 테이블 목록 — 서버 부트 로그에서 출력 */
|
|
44
|
+
const _ownerRlsTables = [];
|
|
45
|
+
/** 부트 로그에서 ownerRls 감지 테이블 목록을 반환 */
|
|
46
|
+
export function getOwnerRlsTables() {
|
|
47
|
+
return _ownerRlsTables;
|
|
48
|
+
}
|
|
28
49
|
// ─── Helpers ────────────────────────────────────────────
|
|
29
50
|
/**
|
|
30
51
|
* id 컬럼의 Drizzle dataType을 검사하여 적절한 validator를 반환.
|
|
@@ -37,6 +58,73 @@ function detectIdType(column) {
|
|
|
37
58
|
return v.string();
|
|
38
59
|
return v.number();
|
|
39
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* DB 컬럼명에서 JS 프로퍼티명을 역매핑한다.
|
|
63
|
+
* getTableColumns()의 키(JS명) → 값(Column.name = DB명) 매핑을 탐색.
|
|
64
|
+
* 매칭 실패 시 columnName을 그대로 반환 (best-effort).
|
|
65
|
+
*/
|
|
66
|
+
function resolvePropertyName(table, columnName) {
|
|
67
|
+
try {
|
|
68
|
+
const columns = getTableColumns(table);
|
|
69
|
+
for (const [propName, col] of Object.entries(columns)) {
|
|
70
|
+
if (col.name === columnName)
|
|
71
|
+
return propName;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// 테스트 환경에서 getTableColumns 실패 가능 — fallback
|
|
76
|
+
}
|
|
77
|
+
return columnName;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 테이블에서 ownerRls 메타데이터를 감지한다.
|
|
81
|
+
*
|
|
82
|
+
* 감지 순서:
|
|
83
|
+
* 1. WeakMap 레지스트리 (registerOwnerRls로 명시 등록된 경우)
|
|
84
|
+
* 2. getTableConfig().policies에서 pgPolicy 존재 여부 + 컬럼 convention
|
|
85
|
+
*
|
|
86
|
+
* Fallback convention: 테이블에 userId/user_id 컬럼이 있고 pgPolicy가 존재하면
|
|
87
|
+
* ownerRls가 적용된 것으로 간주.
|
|
88
|
+
*/
|
|
89
|
+
function detectOwnerMeta(table) {
|
|
90
|
+
const anyTable = table;
|
|
91
|
+
// 1. WeakMap 레지스트리에서 조회 (가장 정확)
|
|
92
|
+
const meta = getOwnerRlsMeta(table);
|
|
93
|
+
if (meta) {
|
|
94
|
+
// propertyName 역매핑: DB명 "user_id" → JS명 "userId"
|
|
95
|
+
const propName = resolvePropertyName(table, meta.columnName);
|
|
96
|
+
const col = anyTable[propName];
|
|
97
|
+
if (col) {
|
|
98
|
+
return { column: col, columnName: meta.columnName, propertyName: propName, readPublic: meta.readPublic };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// 2. getTableConfig().policies로 pgPolicy 존재 감지 + convention 탐색
|
|
102
|
+
try {
|
|
103
|
+
const config = getTableConfig(table);
|
|
104
|
+
if (config.policies && config.policies.length > 0) {
|
|
105
|
+
// convention: userId 또는 user_id 컬럼 탐색 (JS 프로퍼티명)
|
|
106
|
+
const propName = anyTable["userId"] ? "userId" : anyTable["user_id"] ? "user_id" : null;
|
|
107
|
+
if (propName) {
|
|
108
|
+
const userIdCol = anyTable[propName];
|
|
109
|
+
const colName = userIdCol.name || "user_id";
|
|
110
|
+
// 테이블에 메타 등록 (이후 호출에서 WeakMap으로 빠르게 조회)
|
|
111
|
+
registerOwnerRls(table, { columnName: colName, readPublic: false });
|
|
112
|
+
return { column: userIdCol, columnName: colName, propertyName: propName, readPublic: false };
|
|
113
|
+
}
|
|
114
|
+
// S2 방어: pgPolicy가 존재하지만 convention 컬럼(userId/user_id)이 없는 경우
|
|
115
|
+
// ownerRls()를 호출했으나 비표준 컬럼명(e.g. ownerId, authorId)을 사용한 것으로 의심
|
|
116
|
+
// → silent 무시 방지: 보안 경고 출력
|
|
117
|
+
const tblName = getTableName(table);
|
|
118
|
+
console.warn(`[crud] ⚠️ Table "${tblName}" has ${config.policies.length} pgPolicy but no userId/user_id column found. ` +
|
|
119
|
+
`ownerRls auto-isolation will NOT be applied. ` +
|
|
120
|
+
`If you used ownerRls(), ensure the column is named 'userId' (JS) / 'user_id' (DB).`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// getTableConfig 실패 시 무시 (테스트 환경 등)
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
40
128
|
// ─── v3 Filter Engine — 재귀적 동적 필터링 ──────────────
|
|
41
129
|
/** 재귀 필터 최대 깊이 — DoS 방어 (깊이 초과 시 조건 묵살) */
|
|
42
130
|
const MAX_FILTER_DEPTH = 5;
|
|
@@ -180,8 +268,27 @@ export function crud(table, options) {
|
|
|
180
268
|
// createdAt 컬럼 (정렬용, 없으면 id 사용)
|
|
181
269
|
const createdAtCol = anyTable["createdAt"];
|
|
182
270
|
const defaultOrderCol = createdAtCol || pk;
|
|
183
|
-
// userId 컬럼 (자동 주입용)
|
|
271
|
+
// userId 컬럼 (자동 주입용 — ownerRls 미적용 시 기존 동작)
|
|
184
272
|
const userIdCol = anyTable["userId"];
|
|
273
|
+
// ── ownerRls 감지: 2-Layer 방어의 Layer 1 ──
|
|
274
|
+
// ownerRls() 메타데이터가 감지되면 모든 CRUD에 userId 필터 자동 주입
|
|
275
|
+
const ownerMeta = detectOwnerMeta(table);
|
|
276
|
+
// 부트 로그용 레지스트리 등록 (S1: 중복 방지 — hot-reload/methods 분할 대응)
|
|
277
|
+
if (ownerMeta && !_ownerRlsTables.some(t => t.tableName === tableName)) {
|
|
278
|
+
_ownerRlsTables.push({
|
|
279
|
+
tableName,
|
|
280
|
+
columnName: ownerMeta.columnName,
|
|
281
|
+
readPublic: ownerMeta.readPublic,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// B1: public + ownerRls 조합 방어
|
|
285
|
+
// ownerRls가 감지된 테이블에서 public: true + readPublic: false 조합은
|
|
286
|
+
// 보안 의도와 모순 → 경고 출력 + CUD는 ownerRls 보호 유지
|
|
287
|
+
if (ownerMeta && isPublic && !ownerMeta.readPublic) {
|
|
288
|
+
console.warn(`[crud] ⚠️ Table "${tableName}": ownerRls detected but public=true. ` +
|
|
289
|
+
`CUD operations will still enforce ownerRls (auth required). ` +
|
|
290
|
+
`Consider removing { public: true } or using ownerRls(col, { read: "public" }).`);
|
|
291
|
+
}
|
|
185
292
|
// ── 내부 헬퍼: WHERE 조건 빌드 (list + count + realtime 공유) ──
|
|
186
293
|
function buildWhereConditions(args) {
|
|
187
294
|
const conditions = [];
|
|
@@ -206,14 +313,30 @@ export function crud(table, options) {
|
|
|
206
313
|
}
|
|
207
314
|
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
208
315
|
}
|
|
316
|
+
/** Uses `db.transaction` when present (e.g. createRlsDb); else runs `fn(db)` for test mocks without `.transaction`. */
|
|
317
|
+
async function inRlsOrPlainTx(db, fn) {
|
|
318
|
+
if (typeof db?.transaction === "function") {
|
|
319
|
+
return await db.transaction(fn);
|
|
320
|
+
}
|
|
321
|
+
return await fn(db);
|
|
322
|
+
}
|
|
209
323
|
// ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
|
|
210
|
-
//
|
|
324
|
+
// Runs inside db.transaction so createRlsDb() can SET LOCAL app.current_user_id for RLS.
|
|
211
325
|
// ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
|
|
212
326
|
// TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
|
|
213
|
-
async function fetchListWithTotal(db, whereClause) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
327
|
+
async function fetchListWithTotal(db, whereClause, userId) {
|
|
328
|
+
// ownerRls 적용 시 realtime emit에도 userId 필터 추가
|
|
329
|
+
// → 다른 사용자에게 타인 데이터가 push되지 않도록
|
|
330
|
+
let effectiveWhere = whereClause;
|
|
331
|
+
if (ownerMeta && userId && !ownerMeta.readPublic) {
|
|
332
|
+
const ownerFilter = eq(ownerMeta.column, userId);
|
|
333
|
+
effectiveWhere = effectiveWhere ? and(effectiveWhere, ownerFilter) : ownerFilter;
|
|
334
|
+
}
|
|
335
|
+
return await inRlsOrPlainTx(db, async (tx) => {
|
|
336
|
+
const data = await tx.select().from(anyTable).where(effectiveWhere).orderBy(desc(defaultOrderCol));
|
|
337
|
+
const countResult = await tx.select({ count: drizzleCount() }).from(anyTable).where(effectiveWhere);
|
|
338
|
+
return { data, total: Number(countResult[0]?.count ?? 0) };
|
|
339
|
+
});
|
|
217
340
|
}
|
|
218
341
|
// ── methods 필터링: 지정된 메서드만 레지스트리 등록 ──
|
|
219
342
|
// methods 옵션 미지정 시 전체 5개 등록 (하위호환)
|
|
@@ -230,12 +353,18 @@ export function crud(table, options) {
|
|
|
230
353
|
filters: v.optional(v.any()),
|
|
231
354
|
},
|
|
232
355
|
handler: async (ctx, args) => {
|
|
233
|
-
|
|
234
|
-
|
|
356
|
+
// S2: requireAuth 통합 — ownerRls(readPublic=false)이면 인증 필수
|
|
357
|
+
const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
|
|
358
|
+
const user = needsAuth ? ctx.auth.requireAuth() : null;
|
|
235
359
|
const page = Math.max(1, args?.page || 1);
|
|
236
360
|
const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
|
|
237
361
|
const offset = (page - 1) * limit;
|
|
238
|
-
|
|
362
|
+
let whereClause = buildWhereConditions(args);
|
|
363
|
+
// ── ownerRls Layer 1: list에 userId 필터 주입 ──
|
|
364
|
+
if (ownerMeta && !ownerMeta.readPublic && user) {
|
|
365
|
+
const ownerFilter = eq(ownerMeta.column, user.id);
|
|
366
|
+
whereClause = whereClause ? and(whereClause, ownerFilter) : ownerFilter;
|
|
367
|
+
}
|
|
239
368
|
// Order
|
|
240
369
|
let orderByClause;
|
|
241
370
|
if (args?.orderBy && anyTable[args.orderBy]) {
|
|
@@ -245,20 +374,22 @@ export function crud(table, options) {
|
|
|
245
374
|
else {
|
|
246
375
|
orderByClause = desc(defaultOrderCol);
|
|
247
376
|
}
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
377
|
+
// One transaction: SET LOCAL user id (createRlsDb) + list + count for RLS.
|
|
378
|
+
return await inRlsOrPlainTx(ctx.db, async (tx) => {
|
|
379
|
+
const results = await tx.select()
|
|
380
|
+
.from(anyTable)
|
|
381
|
+
.where(whereClause)
|
|
382
|
+
.orderBy(orderByClause)
|
|
383
|
+
.limit(limit)
|
|
384
|
+
.offset(offset);
|
|
385
|
+
const countResult = await tx.select({ count: drizzleCount() })
|
|
386
|
+
.from(anyTable)
|
|
387
|
+
.where(whereClause);
|
|
388
|
+
return {
|
|
389
|
+
data: results,
|
|
390
|
+
total: Number(countResult[0]?.count ?? 0),
|
|
391
|
+
};
|
|
392
|
+
});
|
|
262
393
|
}
|
|
263
394
|
});
|
|
264
395
|
// ── get ───────────────────────────────────────
|
|
@@ -266,35 +397,61 @@ export function crud(table, options) {
|
|
|
266
397
|
public: isPublic,
|
|
267
398
|
args: { id: idValidator },
|
|
268
399
|
handler: async (ctx, args) => {
|
|
269
|
-
|
|
270
|
-
|
|
400
|
+
// S2: requireAuth 통합
|
|
401
|
+
const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
|
|
402
|
+
const user = needsAuth ? ctx.auth.requireAuth() : null;
|
|
271
403
|
let whereCond = eq(pk, args.id);
|
|
404
|
+
// ── ownerRls Layer 1: get에 userId 필터 주입 ──
|
|
405
|
+
if (ownerMeta && !ownerMeta.readPublic && user) {
|
|
406
|
+
whereCond = and(whereCond, eq(ownerMeta.column, user.id));
|
|
407
|
+
}
|
|
272
408
|
if (options?.softDelete) {
|
|
273
409
|
const sdField = anyTable[options.softDelete.field];
|
|
274
410
|
whereCond = and(whereCond, eq(sdField, null));
|
|
275
411
|
}
|
|
276
|
-
|
|
277
|
-
|
|
412
|
+
return await inRlsOrPlainTx(ctx.db, async (tx) => {
|
|
413
|
+
const [result] = await tx.select().from(anyTable).where(whereCond).limit(1);
|
|
414
|
+
return result ?? null;
|
|
415
|
+
});
|
|
278
416
|
}
|
|
279
417
|
});
|
|
280
418
|
// ── create ────────────────────────────────────
|
|
281
419
|
const createDef = !enabledMethods.has('create') ? undefined : mutation(`${prefix}.create`, {
|
|
282
420
|
public: isPublic,
|
|
283
421
|
handler: async (ctx, args) => {
|
|
284
|
-
|
|
422
|
+
// ownerRls 테이블은 항상 인증 필수 (보안 우선)
|
|
423
|
+
const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
|
|
285
424
|
let insertData = { ...args };
|
|
286
|
-
//
|
|
287
|
-
|
|
425
|
+
// ── ownerRls Layer 1: create 시 userId 강제 주입 ──
|
|
426
|
+
// 명시적으로 타인 userId를 보낸 경우는 즉시 차단 (fail-closed).
|
|
427
|
+
if (ownerMeta && user) {
|
|
428
|
+
const requestedOwner = insertData[ownerMeta.propertyName] ??
|
|
429
|
+
insertData[ownerMeta.columnName] ??
|
|
430
|
+
insertData.userId;
|
|
431
|
+
if (requestedOwner != null && requestedOwner !== user.id) {
|
|
432
|
+
throw new Error("Forbidden: cannot create resource for another user");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// S1 수정: propertyName(JS명)을 키로 사용 — Drizzle insert 호환
|
|
436
|
+
// 사용자 입력을 덮어씀 (보안: 타인 ID로 데이터 생성 방지)
|
|
437
|
+
if (ownerMeta && user) {
|
|
438
|
+
insertData[ownerMeta.propertyName] = user.id;
|
|
439
|
+
}
|
|
440
|
+
else if (userIdCol && user && !insertData.userId) {
|
|
441
|
+
// ownerRls 미적용 테이블의 기존 자동 주입 동작 유지
|
|
288
442
|
insertData.userId = user.id;
|
|
289
443
|
}
|
|
290
444
|
// beforeCreate 훅
|
|
291
445
|
if (options?.hooks?.beforeCreate) {
|
|
292
446
|
insertData = await options.hooks.beforeCreate(insertData);
|
|
293
447
|
}
|
|
294
|
-
const [result] = await ctx.db.insert(anyTable).values(insertData).returning();
|
|
448
|
+
const [result] = await inRlsOrPlainTx(ctx.db, async (tx) => tx.insert(anyTable).values(insertData).returning());
|
|
295
449
|
// Realtime push — { data, total } 형태로 emit
|
|
450
|
+
// ownerRls 시 해당 사용자 데이터만 push (타 사용자에게 누출 방지)
|
|
451
|
+
// S5: requireAuth로 확실한 userId 획득 (getUserIdentity null 방지)
|
|
296
452
|
if (useRealtime && enabledMethods.has('list')) {
|
|
297
|
-
const
|
|
453
|
+
const currentUserId = user?.id;
|
|
454
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
298
455
|
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
299
456
|
}
|
|
300
457
|
return result;
|
|
@@ -304,10 +461,22 @@ export function crud(table, options) {
|
|
|
304
461
|
const updateDef = !enabledMethods.has('update') ? undefined : mutation(`${prefix}.update`, {
|
|
305
462
|
public: isPublic,
|
|
306
463
|
handler: async (ctx, args) => {
|
|
307
|
-
|
|
308
|
-
|
|
464
|
+
// ownerRls 테이블은 항상 인증 필수
|
|
465
|
+
const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
|
|
309
466
|
const { id, ...updates } = args;
|
|
310
467
|
let updateData = { ...updates };
|
|
468
|
+
// ── ownerRls Layer 1: update 시 userId 변경 차단 + 소유자 검증 ──
|
|
469
|
+
let updateWhere = eq(pk, id);
|
|
470
|
+
if (ownerMeta && user) {
|
|
471
|
+
// WHERE id = ? AND user_id = ? (타인 데이터 수정 불가)
|
|
472
|
+
updateWhere = and(eq(pk, id), eq(ownerMeta.column, user.id));
|
|
473
|
+
// S1 수정: propertyName(JS명)으로 userId 변경 시도 차단
|
|
474
|
+
delete updateData[ownerMeta.propertyName];
|
|
475
|
+
// DB명도 방어적으로 삭제 (만약 클라이언트가 DB명으로 보낸 경우)
|
|
476
|
+
if (ownerMeta.propertyName !== ownerMeta.columnName) {
|
|
477
|
+
delete updateData[ownerMeta.columnName];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
311
480
|
// updatedAt 자동 갱신
|
|
312
481
|
if (anyTable["updatedAt"]) {
|
|
313
482
|
updateData.updatedAt = new Date();
|
|
@@ -316,14 +485,16 @@ export function crud(table, options) {
|
|
|
316
485
|
if (options?.hooks?.beforeUpdate) {
|
|
317
486
|
updateData = await options.hooks.beforeUpdate(updateData);
|
|
318
487
|
}
|
|
319
|
-
const [result] = await ctx.db.update(anyTable)
|
|
488
|
+
const [result] = await inRlsOrPlainTx(ctx.db, async (tx) => tx.update(anyTable)
|
|
320
489
|
.set(updateData)
|
|
321
|
-
.where(
|
|
322
|
-
.returning();
|
|
490
|
+
.where(updateWhere)
|
|
491
|
+
.returning());
|
|
323
492
|
// Realtime push (list + get 양쪽) — { data, total } 형태
|
|
493
|
+
// S5: ownerRls 시 user.id 사용 (getUserIdentity null 방지)
|
|
324
494
|
if (useRealtime) {
|
|
495
|
+
const currentUserId = ownerMeta ? user?.id : undefined;
|
|
325
496
|
if (enabledMethods.has('list')) {
|
|
326
|
-
const listResult = await fetchListWithTotal(ctx.db);
|
|
497
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
327
498
|
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
328
499
|
}
|
|
329
500
|
if (enabledMethods.has('get')) {
|
|
@@ -337,20 +508,30 @@ export function crud(table, options) {
|
|
|
337
508
|
const removeDef = !enabledMethods.has('remove') ? undefined : mutation(`${prefix}.remove`, {
|
|
338
509
|
public: isPublic,
|
|
339
510
|
handler: async (ctx, args) => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
else {
|
|
349
|
-
await ctx.db.delete(anyTable).where(eq(pk, args.id));
|
|
511
|
+
// ownerRls 테이블은 항상 인증 필수
|
|
512
|
+
const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
|
|
513
|
+
// ── ownerRls Layer 1: remove 시 소유자 검증 ──
|
|
514
|
+
let deleteWhere = eq(pk, args.id);
|
|
515
|
+
if (ownerMeta && user) {
|
|
516
|
+
// WHERE id = ? AND user_id = ? (타인 데이터 삭제 불가)
|
|
517
|
+
deleteWhere = and(eq(pk, args.id), eq(ownerMeta.column, user.id));
|
|
350
518
|
}
|
|
519
|
+
await inRlsOrPlainTx(ctx.db, async (tx) => {
|
|
520
|
+
if (options?.softDelete) {
|
|
521
|
+
const sdField = options.softDelete.field;
|
|
522
|
+
await tx.update(anyTable)
|
|
523
|
+
.set({ [sdField]: new Date() })
|
|
524
|
+
.where(deleteWhere);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
await tx.delete(anyTable).where(deleteWhere);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
351
530
|
// Realtime push — { data, total } 형태
|
|
531
|
+
// S5: ownerRls 시 user.id 사용
|
|
352
532
|
if (useRealtime && enabledMethods.has('list')) {
|
|
353
|
-
const
|
|
533
|
+
const currentUserId = ownerMeta ? user?.id : undefined;
|
|
534
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
354
535
|
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
355
536
|
}
|
|
356
537
|
return { success: true };
|
package/dist/index.d.ts
CHANGED
|
@@ -4,20 +4,21 @@
|
|
|
4
4
|
* Provides: query, mutation, storage, scheduler, auth
|
|
5
5
|
* All with Convex-compatible DX patterns.
|
|
6
6
|
*/
|
|
7
|
-
export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive";
|
|
8
|
-
export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
9
|
-
export type { Storage } from "./storage";
|
|
10
|
-
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
11
|
-
export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler";
|
|
12
|
-
export { v, parseArgs, GencowValidationError } from "./v";
|
|
13
|
-
export type { Validator, Infer, InferArgs } from "./v";
|
|
14
|
-
export { withRetry } from "./retry";
|
|
15
|
-
export type { RetryOptions } from "./retry";
|
|
16
|
-
export { cronJobs } from "./crons";
|
|
17
|
-
export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons";
|
|
18
|
-
export { defineAuth } from "./auth-config";
|
|
19
|
-
export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
|
|
20
|
-
export { ownerRls } from "./rls";
|
|
21
|
-
export {
|
|
22
|
-
export {
|
|
23
|
-
export { crud
|
|
7
|
+
export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive.js";
|
|
8
|
+
export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
|
|
9
|
+
export type { Storage } from "./storage.js";
|
|
10
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
11
|
+
export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler.js";
|
|
12
|
+
export { v, parseArgs, GencowValidationError } from "./v.js";
|
|
13
|
+
export type { Validator, Infer, InferArgs } from "./v.js";
|
|
14
|
+
export { withRetry } from "./retry.js";
|
|
15
|
+
export type { RetryOptions } from "./retry.js";
|
|
16
|
+
export { cronJobs } from "./crons.js";
|
|
17
|
+
export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons.js";
|
|
18
|
+
export { defineAuth } from "./auth-config.js";
|
|
19
|
+
export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config.js";
|
|
20
|
+
export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
|
|
21
|
+
export type { OwnerRlsMeta } from "./rls.js";
|
|
22
|
+
export { createRlsDb } from "./rls-db.js";
|
|
23
|
+
export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud.js";
|
|
24
|
+
export { crud as gencowCrud } from "./crud.js";
|
package/dist/index.js
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
* Provides: query, mutation, storage, scheduler, auth
|
|
5
5
|
* All with Convex-compatible DX patterns.
|
|
6
6
|
*/
|
|
7
|
-
export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
8
|
-
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
9
|
-
export { v, parseArgs, GencowValidationError } from "./v";
|
|
10
|
-
export { withRetry } from "./retry";
|
|
11
|
-
export { cronJobs } from "./crons";
|
|
12
|
-
export { defineAuth } from "./auth-config";
|
|
7
|
+
export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
|
|
8
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
9
|
+
export { v, parseArgs, GencowValidationError } from "./v.js";
|
|
10
|
+
export { withRetry } from "./retry.js";
|
|
11
|
+
export { cronJobs } from "./crons.js";
|
|
12
|
+
export { defineAuth } from "./auth-config.js";
|
|
13
13
|
// ─── RLS + CRUD Factory ───────────
|
|
14
|
-
export { ownerRls } from "./rls";
|
|
15
|
-
export { createRlsDb } from "./rls-db";
|
|
16
|
-
export { crud, parseFilterNode, applyFilterOp } from "./crud";
|
|
14
|
+
export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
|
|
15
|
+
export { createRlsDb } from "./rls-db.js";
|
|
16
|
+
export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud.js";
|
|
17
17
|
// Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
|
|
18
|
-
export { crud as gencowCrud } from "./crud";
|
|
18
|
+
export { crud as gencowCrud } from "./crud.js";
|
package/dist/reactive.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { WSContext } from "hono/ws";
|
|
2
|
-
import type { Storage } from "./storage";
|
|
3
|
-
import type { Scheduler } from "./scheduler";
|
|
4
|
-
import { type InferArgs } from "./v";
|
|
2
|
+
import type { Storage } from "./storage.js";
|
|
3
|
+
import type { Scheduler } from "./scheduler.js";
|
|
4
|
+
import { type InferArgs } from "./v.js";
|
|
5
5
|
export interface UserIdentity {
|
|
6
6
|
id: string;
|
|
7
7
|
email: string;
|
|
@@ -100,7 +100,7 @@ export interface GencowCtx {
|
|
|
100
100
|
/** 실시간 push — ctx.realtime.emit(queryKey, data) */
|
|
101
101
|
realtime: RealtimeCtx;
|
|
102
102
|
/** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
|
|
103
|
-
retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
|
|
103
|
+
retry: <T>(fn: () => Promise<T>, options?: import("./retry.js").RetryOptions) => Promise<T>;
|
|
104
104
|
/** AI 헬퍼 */
|
|
105
105
|
ai?: AIContext;
|
|
106
106
|
}
|
package/dist/rls-db.d.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* Supabase
|
|
3
|
+
* Wraps Drizzle so `transaction()` runs `set_config('app.current_user_id', ...)` first (RLS policies).
|
|
4
|
+
* Supabase-style session GUC injection.
|
|
5
5
|
*
|
|
6
|
-
* set_config(
|
|
7
|
-
* → is_local=true: 현재 트랜잭션에서만 유효
|
|
8
|
-
* → PgBouncer transaction 모드에서 안전
|
|
6
|
+
* `set_config(..., true)` is transaction-local (safe with PgBouncer transaction pooling).
|
|
9
7
|
*/
|
|
10
8
|
export declare function createRlsDb(db: PgDatabase<any, any, any>, userId: string): PgDatabase<any, any, any>;
|
package/dist/rls-db.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* Supabase
|
|
3
|
+
* Wraps Drizzle so `transaction()` runs `set_config('app.current_user_id', ...)` first (RLS policies).
|
|
4
|
+
* Supabase-style session GUC injection.
|
|
5
5
|
*
|
|
6
|
-
* set_config(
|
|
7
|
-
* → is_local=true: 현재 트랜잭션에서만 유효
|
|
8
|
-
* → PgBouncer transaction 모드에서 안전
|
|
6
|
+
* `set_config(..., true)` is transaction-local (safe with PgBouncer transaction pooling).
|
|
9
7
|
*/
|
|
10
8
|
export function createRlsDb(db, userId) {
|
|
11
9
|
return new Proxy(db, {
|
package/dist/rls.d.ts
CHANGED
|
@@ -1,4 +1,47 @@
|
|
|
1
|
-
import { type AnyPgColumn } from "drizzle-orm/pg-core";
|
|
1
|
+
import { type AnyPgColumn, type PgTable } from "drizzle-orm/pg-core";
|
|
2
|
+
export interface OwnerRlsMeta {
|
|
3
|
+
/** userId 컬럼의 DB 이름 (e.g. "user_id") */
|
|
4
|
+
columnName: string;
|
|
5
|
+
/** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
|
|
6
|
+
readPublic: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 테이블에 등록된 ownerRls 메타데이터를 반환한다.
|
|
10
|
+
* ownerRls()가 호출되지 않은 테이블은 undefined를 반환.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getOwnerRlsMeta(table: PgTable): OwnerRlsMeta | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* 내부용: ownerRls()가 호출될 때 메타데이터를 레지스트리에 등록.
|
|
15
|
+
* pgTable의 extraConfig 콜백에서 호출되므로, 테이블 참조가 아닌
|
|
16
|
+
* 임시 프록시 객체가 전달될 수 있음 → registerOwnerRls()로 사후 등록.
|
|
17
|
+
*/
|
|
18
|
+
export declare function registerOwnerRls(table: PgTable, meta: OwnerRlsMeta): void;
|
|
19
|
+
/**
|
|
20
|
+
* 사용자 소유권 기반 RLS 정책을 선언한다.
|
|
21
|
+
*
|
|
22
|
+
* 2-Layer 방어:
|
|
23
|
+
* - Layer 1 (앱 레벨): crud()가 이 메타데이터를 감지하여
|
|
24
|
+
* 모든 CRUD 쿼리에 WHERE userId = auth.userId 자동 주입.
|
|
25
|
+
* PGlite + PostgreSQL 양쪽에서 동작.
|
|
26
|
+
* - Layer 2 (DB 레벨): PostgreSQL RLS 정책으로 심층 방어.
|
|
27
|
+
* createRlsDb()의 transaction() 내에서 set_config 주입.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* export const tasks = pgTable("tasks", {
|
|
32
|
+
* id: serial("id").primaryKey(),
|
|
33
|
+
* title: text("title").notNull(),
|
|
34
|
+
* userId: text("user_id").notNull(),
|
|
35
|
+
* }, (t) => ownerRls(t.userId));
|
|
36
|
+
*
|
|
37
|
+
* // read: "public" — 누구나 읽기 가능, CUD는 소유자만
|
|
38
|
+
* export const posts = pgTable("posts", {
|
|
39
|
+
* id: serial("id").primaryKey(),
|
|
40
|
+
* content: text("content").notNull(),
|
|
41
|
+
* userId: text("user_id").notNull(),
|
|
42
|
+
* }, (t) => ownerRls(t.userId, { read: "public" }));
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
2
45
|
export declare function ownerRls(userIdColumn: AnyPgColumn, options?: {
|
|
3
46
|
read?: "public";
|
|
4
47
|
}): import("drizzle-orm/pg-core").PgPolicy[];
|