@gencow/core 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/crud.d.ts +18 -0
  2. package/dist/crud.js +231 -50
  3. package/dist/index.d.ts +3 -2
  4. package/dist/index.js +2 -2
  5. package/dist/rls-db.d.ts +3 -5
  6. package/dist/rls-db.js +3 -5
  7. package/dist/rls.d.ts +44 -1
  8. package/dist/rls.js +62 -2
  9. package/dist/server.d.ts +1 -0
  10. package/dist/storage.d.ts +29 -2
  11. package/dist/storage.js +396 -8
  12. package/package.json +42 -39
  13. package/src/__tests__/crud-owner-rls.test.ts +380 -0
  14. package/src/__tests__/fixtures/basic/auth.ts +32 -0
  15. package/src/__tests__/fixtures/basic/drizzle.config.ts +15 -0
  16. package/src/__tests__/fixtures/basic/index.ts +6 -0
  17. package/src/__tests__/fixtures/basic/migrations/0000_faithful_silver_sable.sql +66 -0
  18. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +438 -0
  19. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +13 -0
  20. package/src/__tests__/fixtures/basic/schema.ts +35 -0
  21. package/src/__tests__/fixtures/basic/tasks.ts +15 -0
  22. package/src/__tests__/fixtures/common/auth-schema.ts +63 -0
  23. package/src/__tests__/helpers/pglite-migrations.ts +35 -0
  24. package/src/__tests__/helpers/pglite-rls-session.ts +54 -0
  25. package/src/__tests__/helpers/seed-like-fill.ts +196 -0
  26. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +53 -0
  27. package/src/__tests__/image-optimization.test.ts +652 -0
  28. package/src/__tests__/rls-crud-basic.test.ts +431 -0
  29. package/src/__tests__/tsconfig.json +8 -0
  30. package/src/crud.ts +270 -47
  31. package/src/index.ts +3 -2
  32. package/src/rls-db.ts +3 -5
  33. package/src/rls.ts +87 -3
  34. package/src/server.ts +1 -0
  35. package/src/storage.ts +473 -8
  36. package/dist/scoped-db.d.ts +0 -34
  37. package/dist/scoped-db.js +0 -364
  38. package/dist/table.d.ts +0 -67
  39. 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"])[];
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";
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";
26
39
  import { query, mutation } from "./reactive";
27
40
  import { v } from "./v";
41
+ import { getOwnerRlsMeta, registerOwnerRls } from "./rls";
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
- const data = await db.select().from(anyTable).where(whereClause).orderBy(desc(defaultOrderCol));
215
- const countResult = await db.select({ count: drizzleCount() }).from(anyTable).where(whereClause);
216
- return { data, total: Number(countResult[0]?.count ?? 0) };
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
- if (!isPublic)
234
- ctx.auth.requireAuth();
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
- const whereClause = buildWhereConditions(args);
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
- // SELECT + COUNT 순차 실행 소규모 커넥션 환경에서 동시 점유 방지
249
- const results = await ctx.db.select()
250
- .from(anyTable)
251
- .where(whereClause)
252
- .orderBy(orderByClause)
253
- .limit(limit)
254
- .offset(offset);
255
- const countResult = await ctx.db.select({ count: drizzleCount() })
256
- .from(anyTable)
257
- .where(whereClause);
258
- return {
259
- data: results,
260
- total: Number(countResult[0]?.count ?? 0),
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
- if (!isPublic)
270
- ctx.auth.requireAuth();
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
- const [result] = await ctx.db.select().from(anyTable).where(whereCond).limit(1);
277
- return result ?? null;
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
- const user = isPublic ? null : ctx.auth.requireAuth();
422
+ // ownerRls 테이블은 항상 인증 필수 (보안 우선)
423
+ const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
285
424
  let insertData = { ...args };
286
- // userId 자동 주입 (테이블에 userId 컬럼이 있고 인증된 경우)
287
- if (userIdCol && user && !insertData.userId) {
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 listResult = await fetchListWithTotal(ctx.db);
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
- if (!isPublic)
308
- ctx.auth.requireAuth();
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(eq(pk, id))
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
- if (!isPublic)
341
- ctx.auth.requireAuth();
342
- if (options?.softDelete) {
343
- const sdField = options.softDelete.field;
344
- await ctx.db.update(anyTable)
345
- .set({ [sdField]: new Date() })
346
- .where(eq(pk, args.id));
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 listResult = await fetchListWithTotal(ctx.db);
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
@@ -17,7 +17,8 @@ export { cronJobs } from "./crons";
17
17
  export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons";
18
18
  export { defineAuth } from "./auth-config";
19
19
  export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
20
- export { ownerRls } from "./rls";
20
+ export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls";
21
+ export type { OwnerRlsMeta } from "./rls";
21
22
  export { createRlsDb } from "./rls-db";
22
- export { crud, parseFilterNode, applyFilterOp } from "./crud";
23
+ export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud";
23
24
  export { crud as gencowCrud } from "./crud";
package/dist/index.js CHANGED
@@ -11,8 +11,8 @@ export { withRetry } from "./retry";
11
11
  export { cronJobs } from "./crons";
12
12
  export { defineAuth } from "./auth-config";
13
13
  // ─── RLS + CRUD Factory ───────────
14
- export { ownerRls } from "./rls";
14
+ export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls";
15
15
  export { createRlsDb } from "./rls-db";
16
- export { crud, parseFilterNode, applyFilterOp } from "./crud";
16
+ export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud";
17
17
  // Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
18
18
  export { crud as gencowCrud } from "./crud";
package/dist/rls-db.d.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import type { PgDatabase } from "drizzle-orm/pg-core";
2
2
  /**
3
- * JWT payload.sub(userId) RLS 세션 변수를 주입하는 DB 래퍼.
4
- * Supabase createDrizzle 패턴 참고.
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(name, value, is_local=true)
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
- * JWT payload.sub(userId) RLS 세션 변수를 주입하는 DB 래퍼.
4
- * Supabase createDrizzle 패턴 참고.
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(name, value, is_local=true)
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[];
package/dist/rls.js CHANGED
@@ -1,11 +1,71 @@
1
1
  import { sql } from "drizzle-orm";
2
2
  import { pgPolicy } from "drizzle-orm/pg-core";
3
+ const _ownerRlsRegistry = new WeakMap();
4
+ /**
5
+ * 테이블에 등록된 ownerRls 메타데이터를 반환한다.
6
+ * ownerRls()가 호출되지 않은 테이블은 undefined를 반환.
7
+ */
8
+ export function getOwnerRlsMeta(table) {
9
+ return _ownerRlsRegistry.get(table);
10
+ }
11
+ /**
12
+ * 내부용: ownerRls()가 호출될 때 메타데이터를 레지스트리에 등록.
13
+ * pgTable의 extraConfig 콜백에서 호출되므로, 테이블 참조가 아닌
14
+ * 임시 프록시 객체가 전달될 수 있음 → registerOwnerRls()로 사후 등록.
15
+ */
16
+ export function registerOwnerRls(table, meta) {
17
+ _ownerRlsRegistry.set(table, meta);
18
+ }
19
+ // ─── ownerRls — DB-level RLS 정책 선언 + 앱 레벨 메타데이터 등록 ──
20
+ /**
21
+ * 사용자 소유권 기반 RLS 정책을 선언한다.
22
+ *
23
+ * 2-Layer 방어:
24
+ * - Layer 1 (앱 레벨): crud()가 이 메타데이터를 감지하여
25
+ * 모든 CRUD 쿼리에 WHERE userId = auth.userId 자동 주입.
26
+ * PGlite + PostgreSQL 양쪽에서 동작.
27
+ * - Layer 2 (DB 레벨): PostgreSQL RLS 정책으로 심층 방어.
28
+ * createRlsDb()의 transaction() 내에서 set_config 주입.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * export const tasks = pgTable("tasks", {
33
+ * id: serial("id").primaryKey(),
34
+ * title: text("title").notNull(),
35
+ * userId: text("user_id").notNull(),
36
+ * }, (t) => ownerRls(t.userId));
37
+ *
38
+ * // read: "public" — 누구나 읽기 가능, CUD는 소유자만
39
+ * export const posts = pgTable("posts", {
40
+ * id: serial("id").primaryKey(),
41
+ * content: text("content").notNull(),
42
+ * userId: text("user_id").notNull(),
43
+ * }, (t) => ownerRls(t.userId, { read: "public" }));
44
+ * ```
45
+ */
3
46
  export function ownerRls(userIdColumn, options) {
4
- const isOwner = sql `${userIdColumn} = current_setting('app.current_user_id')`;
5
- return [
47
+ // S3 방어: userIdColumn.name 미존재 시 명확한 에러
48
+ const colName = userIdColumn.name;
49
+ if (!colName) {
50
+ throw new Error("[ownerRls] userIdColumn must have a .name property. " +
51
+ "Ensure you pass a valid Drizzle column reference (e.g. t.userId).");
52
+ }
53
+ /** `missing_ok` avoids errors before first `set_config` and matches PG custom GUC behavior in PGlite. */
54
+ const isOwner = sql `${userIdColumn} = current_setting('app.current_user_id', true)`;
55
+ // ── 앱 레벨 메타데이터: crud()가 런타임에 읽음 ──
56
+ const meta = {
57
+ columnName: colName,
58
+ readPublic: options?.read === "public",
59
+ };
60
+ const policies = [
6
61
  pgPolicy("rls-select", { for: "select", using: options?.read === "public" ? sql `true` : isOwner }),
7
62
  pgPolicy("rls-insert", { for: "insert", withCheck: isOwner }),
8
63
  pgPolicy("rls-update", { for: "update", using: isOwner, withCheck: isOwner }),
9
64
  pgPolicy("rls-delete", { for: "delete", using: isOwner }),
10
65
  ];
66
+ // N2 정리: non-enumerable _ownerRlsMeta 마커 제거 (dead code).
67
+ // ownerRls()는 pgTable extraConfig 콜백에서 호출되며, 이 시점에서는
68
+ // 테이블 참조가 프록시이므로 WeakMap 등록 불가능.
69
+ // 실제 메타데이터 등록은 crud()의 detectOwnerMeta() fallback에서 수행.
70
+ return policies;
11
71
  }
package/dist/server.d.ts CHANGED
@@ -7,5 +7,6 @@
7
7
  */
8
8
  export { createDb } from "./db";
9
9
  export { createStorage, storageRoutes } from "./storage";
10
+ export type { StorageImageTierConfig } from "./storage";
10
11
  export { createScheduler, getSchedulerInfo } from "./scheduler";
11
12
  export { authMiddleware, authRoutes, getUsers } from "./auth";