@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.
Files changed (42) hide show
  1. package/dist/crud.d.ts +30 -12
  2. package/dist/crud.js +233 -52
  3. package/dist/index.d.ts +18 -17
  4. package/dist/index.js +10 -10
  5. package/dist/reactive.d.ts +4 -4
  6. package/dist/rls-db.d.ts +3 -5
  7. package/dist/rls-db.js +3 -5
  8. package/dist/rls.d.ts +44 -1
  9. package/dist/rls.js +62 -2
  10. package/dist/server.d.ts +5 -4
  11. package/dist/server.js +4 -4
  12. package/dist/storage.d.ts +29 -2
  13. package/dist/storage.js +396 -8
  14. package/package.json +6 -2
  15. package/src/__tests__/crud-owner-rls.test.ts +380 -0
  16. package/src/__tests__/fixtures/basic/auth.ts +32 -0
  17. package/src/__tests__/fixtures/basic/drizzle.config.ts +15 -0
  18. package/src/__tests__/fixtures/basic/index.ts +6 -0
  19. package/src/__tests__/fixtures/basic/migrations/0000_faithful_silver_sable.sql +66 -0
  20. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +438 -0
  21. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +13 -0
  22. package/src/__tests__/fixtures/basic/schema.ts +35 -0
  23. package/src/__tests__/fixtures/basic/tasks.ts +15 -0
  24. package/src/__tests__/fixtures/common/auth-schema.ts +63 -0
  25. package/src/__tests__/helpers/pglite-migrations.ts +35 -0
  26. package/src/__tests__/helpers/pglite-rls-session.ts +54 -0
  27. package/src/__tests__/helpers/seed-like-fill.ts +196 -0
  28. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +53 -0
  29. package/src/__tests__/image-optimization.test.ts +652 -0
  30. package/src/__tests__/rls-crud-basic.test.ts +431 -0
  31. package/src/__tests__/tsconfig.json +8 -0
  32. package/src/crud.ts +272 -49
  33. package/src/index.ts +18 -17
  34. package/src/reactive.ts +4 -4
  35. package/src/rls-db.ts +3 -5
  36. package/src/rls.ts +87 -3
  37. package/src/server.ts +5 -4
  38. package/src/storage.ts +473 -8
  39. package/dist/scoped-db.d.ts +0 -34
  40. package/dist/scoped-db.js +0 -364
  41. package/dist/table.d.ts +0 -67
  42. package/dist/table.js +0 -98
package/src/crud.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,13 +31,26 @@
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
 
26
38
  import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName, getTableColumns, type SQL } from "drizzle-orm";
27
39
  import type { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
28
- import { query, mutation } from "./reactive";
29
- import { v } from "./v";
40
+ import { getTableConfig } from "drizzle-orm/pg-core";
41
+ import { query, mutation } from "./reactive.js";
42
+ import { v } from "./v.js";
43
+ import { getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
44
+
45
+ // ─── ownerRls 감지 추적 (부트 로그용) ──────────────────
46
+
47
+ /** crud()가 ownerRls를 자동 감지한 테이블 목록 — 서버 부트 로그에서 출력 */
48
+ const _ownerRlsTables: { tableName: string; columnName: string; readPublic: boolean }[] = [];
49
+
50
+ /** 부트 로그에서 ownerRls 감지 테이블 목록을 반환 */
51
+ export function getOwnerRlsTables(): readonly { tableName: string; columnName: string; readPublic: boolean }[] {
52
+ return _ownerRlsTables;
53
+ }
30
54
 
31
55
  // ─── Types ──────────────────────────────────────────────
32
56
 
@@ -74,6 +98,93 @@ function detectIdType(column: AnyPgColumn) {
74
98
  return v.number();
75
99
  }
76
100
 
101
+ // ─── ownerRls 감지 ──────────────────────────────────────
102
+
103
+ interface OwnerMetaResolved {
104
+ /** userId 역할을 하는 Drizzle 컬럼 참조 (WHERE 조건에 사용) */
105
+ column: AnyPgColumn;
106
+ /** userId 컬럼의 DB 이름 (e.g. "user_id") */
107
+ columnName: string;
108
+ /** userId 컬럼의 JS 프로퍼티 이름 (e.g. "userId" — INSERT/UPDATE 데이터 키에 사용) */
109
+ propertyName: string;
110
+ /** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
111
+ readPublic: boolean;
112
+ }
113
+
114
+ /**
115
+ * DB 컬럼명에서 JS 프로퍼티명을 역매핑한다.
116
+ * getTableColumns()의 키(JS명) → 값(Column.name = DB명) 매핑을 탐색.
117
+ * 매칭 실패 시 columnName을 그대로 반환 (best-effort).
118
+ */
119
+ function resolvePropertyName(table: PgTable, columnName: string): string {
120
+ try {
121
+ const columns = getTableColumns(table);
122
+ for (const [propName, col] of Object.entries(columns)) {
123
+ if ((col as any).name === columnName) return propName;
124
+ }
125
+ } catch {
126
+ // 테스트 환경에서 getTableColumns 실패 가능 — fallback
127
+ }
128
+ return columnName;
129
+ }
130
+
131
+ /**
132
+ * 테이블에서 ownerRls 메타데이터를 감지한다.
133
+ *
134
+ * 감지 순서:
135
+ * 1. WeakMap 레지스트리 (registerOwnerRls로 명시 등록된 경우)
136
+ * 2. getTableConfig().policies에서 pgPolicy 존재 여부 + 컬럼 convention
137
+ *
138
+ * Fallback convention: 테이블에 userId/user_id 컬럼이 있고 pgPolicy가 존재하면
139
+ * ownerRls가 적용된 것으로 간주.
140
+ */
141
+ function detectOwnerMeta(table: PgTable): OwnerMetaResolved | null {
142
+ const anyTable = table as any;
143
+
144
+ // 1. WeakMap 레지스트리에서 조회 (가장 정확)
145
+ const meta = getOwnerRlsMeta(table);
146
+ if (meta) {
147
+ // propertyName 역매핑: DB명 "user_id" → JS명 "userId"
148
+ const propName = resolvePropertyName(table, meta.columnName);
149
+ const col = anyTable[propName] as AnyPgColumn | undefined;
150
+ if (col) {
151
+ return { column: col, columnName: meta.columnName, propertyName: propName, readPublic: meta.readPublic };
152
+ }
153
+ }
154
+
155
+ // 2. getTableConfig().policies로 pgPolicy 존재 감지 + convention 탐색
156
+ try {
157
+ const config = getTableConfig(table);
158
+ if (config.policies && config.policies.length > 0) {
159
+ // convention: userId 또는 user_id 컬럼 탐색 (JS 프로퍼티명)
160
+ const propName = anyTable["userId"] ? "userId" : anyTable["user_id"] ? "user_id" : null;
161
+ if (propName) {
162
+ const userIdCol = anyTable[propName] as AnyPgColumn;
163
+ const colName = (userIdCol as any).name || "user_id";
164
+
165
+ // 테이블에 메타 등록 (이후 호출에서 WeakMap으로 빠르게 조회)
166
+ registerOwnerRls(table, { columnName: colName, readPublic: false });
167
+
168
+ return { column: userIdCol, columnName: colName, propertyName: propName, readPublic: false };
169
+ }
170
+
171
+ // S2 방어: pgPolicy가 존재하지만 convention 컬럼(userId/user_id)이 없는 경우
172
+ // ownerRls()를 호출했으나 비표준 컬럼명(e.g. ownerId, authorId)을 사용한 것으로 의심
173
+ // → silent 무시 방지: 보안 경고 출력
174
+ const tblName = getTableName(table);
175
+ console.warn(
176
+ `[crud] ⚠️ Table "${tblName}" has ${config.policies.length} pgPolicy but no userId/user_id column found. ` +
177
+ `ownerRls auto-isolation will NOT be applied. ` +
178
+ `If you used ownerRls(), ensure the column is named 'userId' (JS) / 'user_id' (DB).`
179
+ );
180
+ }
181
+ } catch {
182
+ // getTableConfig 실패 시 무시 (테스트 환경 등)
183
+ }
184
+
185
+ return null;
186
+ }
187
+
77
188
  // ─── v3 Filter Engine — 재귀적 동적 필터링 ──────────────
78
189
 
79
190
  /** 재귀 필터 최대 깊이 — DoS 방어 (깊이 초과 시 조건 묵살) */
@@ -231,9 +342,33 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
231
342
  const createdAtCol = anyTable["createdAt"] as AnyPgColumn | undefined;
232
343
  const defaultOrderCol = createdAtCol || pk;
233
344
 
234
- // userId 컬럼 (자동 주입용)
345
+ // userId 컬럼 (자동 주입용 — ownerRls 미적용 시 기존 동작)
235
346
  const userIdCol = anyTable["userId"] as AnyPgColumn | undefined;
236
347
 
348
+ // ── ownerRls 감지: 2-Layer 방어의 Layer 1 ──
349
+ // ownerRls() 메타데이터가 감지되면 모든 CRUD에 userId 필터 자동 주입
350
+ const ownerMeta = detectOwnerMeta(table);
351
+
352
+ // 부트 로그용 레지스트리 등록 (S1: 중복 방지 — hot-reload/methods 분할 대응)
353
+ if (ownerMeta && !_ownerRlsTables.some(t => t.tableName === tableName)) {
354
+ _ownerRlsTables.push({
355
+ tableName,
356
+ columnName: ownerMeta.columnName,
357
+ readPublic: ownerMeta.readPublic,
358
+ });
359
+ }
360
+
361
+ // B1: public + ownerRls 조합 방어
362
+ // ownerRls가 감지된 테이블에서 public: true + readPublic: false 조합은
363
+ // 보안 의도와 모순 → 경고 출력 + CUD는 ownerRls 보호 유지
364
+ if (ownerMeta && isPublic && !ownerMeta.readPublic) {
365
+ console.warn(
366
+ `[crud] ⚠️ Table "${tableName}": ownerRls detected but public=true. ` +
367
+ `CUD operations will still enforce ownerRls (auth required). ` +
368
+ `Consider removing { public: true } or using ownerRls(col, { read: "public" }).`
369
+ );
370
+ }
371
+
237
372
  // ── 내부 헬퍼: WHERE 조건 빌드 (list + count + realtime 공유) ──
238
373
  function buildWhereConditions(args: any): SQL | undefined {
239
374
  const conditions: SQL[] = [];
@@ -268,14 +403,31 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
268
403
  return conditions.length > 0 ? and(...conditions) : undefined;
269
404
  }
270
405
 
406
+ /** Uses `db.transaction` when present (e.g. createRlsDb); else runs `fn(db)` for test mocks without `.transaction`. */
407
+ async function inRlsOrPlainTx<T>(db: any, fn: (tx: any) => Promise<T>): Promise<T> {
408
+ if (typeof db?.transaction === "function") {
409
+ return await db.transaction(fn);
410
+ }
411
+ return await fn(db);
412
+ }
413
+
271
414
  // ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
272
- // 순차 실행: 소규모 커넥션 환경에서 동시 점유 방지
415
+ // Runs inside db.transaction so createRlsDb() can SET LOCAL app.current_user_id for RLS.
273
416
  // ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
274
417
  // TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
275
- async function fetchListWithTotal(db: any, whereClause?: SQL) {
276
- const data = await db.select().from(anyTable).where(whereClause).orderBy(desc(defaultOrderCol));
277
- const countResult = await db.select({ count: drizzleCount() }).from(anyTable).where(whereClause);
278
- return { data, total: Number(countResult[0]?.count ?? 0) };
418
+ async function fetchListWithTotal(db: any, whereClause?: SQL, userId?: string) {
419
+ // ownerRls 적용 realtime emit에도 userId 필터 추가
420
+ // 다른 사용자에게 타인 데이터가 push되지 않도록
421
+ let effectiveWhere = whereClause;
422
+ if (ownerMeta && userId && !ownerMeta.readPublic) {
423
+ const ownerFilter = eq(ownerMeta.column, userId);
424
+ effectiveWhere = effectiveWhere ? and(effectiveWhere, ownerFilter) : ownerFilter;
425
+ }
426
+ return await inRlsOrPlainTx(db, async (tx: any) => {
427
+ const data = await tx.select().from(anyTable).where(effectiveWhere).orderBy(desc(defaultOrderCol));
428
+ const countResult = await tx.select({ count: drizzleCount() }).from(anyTable).where(effectiveWhere);
429
+ return { data, total: Number(countResult[0]?.count ?? 0) };
430
+ });
279
431
  }
280
432
 
281
433
  // ── methods 필터링: 지정된 메서드만 레지스트리 등록 ──
@@ -295,13 +447,21 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
295
447
  filters: v.optional(v.any()),
296
448
  },
297
449
  handler: async (ctx: any, args: any) => {
298
- if (!isPublic) ctx.auth.requireAuth();
450
+ // S2: requireAuth 통합 — ownerRls(readPublic=false)이면 인증 필수
451
+ const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
452
+ const user = needsAuth ? ctx.auth.requireAuth() : null;
299
453
 
300
454
  const page = Math.max(1, args?.page || 1);
301
455
  const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
302
456
  const offset = (page - 1) * limit;
303
457
 
304
- const whereClause = buildWhereConditions(args);
458
+ let whereClause = buildWhereConditions(args);
459
+
460
+ // ── ownerRls Layer 1: list에 userId 필터 주입 ──
461
+ if (ownerMeta && !ownerMeta.readPublic && user) {
462
+ const ownerFilter = eq(ownerMeta.column, user.id);
463
+ whereClause = whereClause ? and(whereClause, ownerFilter) : ownerFilter;
464
+ }
305
465
 
306
466
  // Order
307
467
  let orderByClause;
@@ -312,21 +472,23 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
312
472
  orderByClause = desc(defaultOrderCol);
313
473
  }
314
474
 
315
- // SELECT + COUNT 순차 실행 소규모 커넥션 환경에서 동시 점유 방지
316
- const results = await ctx.db.select()
317
- .from(anyTable)
318
- .where(whereClause)
319
- .orderBy(orderByClause)
320
- .limit(limit)
321
- .offset(offset);
322
- const countResult = await ctx.db.select({ count: drizzleCount() })
323
- .from(anyTable)
324
- .where(whereClause);
325
-
326
- return {
327
- data: results,
328
- total: Number(countResult[0]?.count ?? 0),
329
- };
475
+ // One transaction: SET LOCAL user id (createRlsDb) + list + count for RLS.
476
+ return await inRlsOrPlainTx(ctx.db, async (tx: any) => {
477
+ const results = await tx.select()
478
+ .from(anyTable)
479
+ .where(whereClause)
480
+ .orderBy(orderByClause)
481
+ .limit(limit)
482
+ .offset(offset);
483
+ const countResult = await tx.select({ count: drizzleCount() })
484
+ .from(anyTable)
485
+ .where(whereClause);
486
+
487
+ return {
488
+ data: results,
489
+ total: Number(countResult[0]?.count ?? 0),
490
+ };
491
+ });
330
492
  }
331
493
  });
332
494
 
@@ -336,17 +498,26 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
336
498
  public: isPublic,
337
499
  args: { id: idValidator },
338
500
  handler: async (ctx: any, args: any) => {
339
- if (!isPublic) ctx.auth.requireAuth();
501
+ // S2: requireAuth 통합
502
+ const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
503
+ const user = needsAuth ? ctx.auth.requireAuth() : null;
340
504
 
341
505
  let whereCond: SQL = eq(pk, args.id);
342
506
 
507
+ // ── ownerRls Layer 1: get에 userId 필터 주입 ──
508
+ if (ownerMeta && !ownerMeta.readPublic && user) {
509
+ whereCond = and(whereCond, eq(ownerMeta.column, user.id))!;
510
+ }
511
+
343
512
  if (options?.softDelete) {
344
513
  const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
345
514
  whereCond = and(whereCond, eq(sdField, null as any))!;
346
515
  }
347
516
 
348
- const [result] = await ctx.db.select().from(anyTable).where(whereCond).limit(1);
349
- return result ?? null;
517
+ return await inRlsOrPlainTx(ctx.db, async (tx: any) => {
518
+ const [result] = await tx.select().from(anyTable).where(whereCond).limit(1);
519
+ return result ?? null;
520
+ });
350
521
  }
351
522
  });
352
523
 
@@ -355,12 +526,29 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
355
526
  const createDef = !enabledMethods.has('create') ? undefined : mutation(`${prefix}.create`, {
356
527
  public: isPublic,
357
528
  handler: async (ctx: any, args: any) => {
358
- const user = isPublic ? null : ctx.auth.requireAuth();
529
+ // ownerRls 테이블은 항상 인증 필수 (보안 우선)
530
+ const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
359
531
 
360
532
  let insertData = { ...args };
361
533
 
362
- // userId 자동 주입 (테이블에 userId 컬럼이 있고 인증된 경우)
363
- if (userIdCol && user && !insertData.userId) {
534
+ // ── ownerRls Layer 1: create 시 userId 강제 주입 ──
535
+ // 명시적으로 타인 userId를 보낸 경우는 즉시 차단 (fail-closed).
536
+ if (ownerMeta && user) {
537
+ const requestedOwner =
538
+ insertData[ownerMeta.propertyName] ??
539
+ insertData[ownerMeta.columnName] ??
540
+ insertData.userId;
541
+ if (requestedOwner != null && requestedOwner !== user.id) {
542
+ throw new Error("Forbidden: cannot create resource for another user");
543
+ }
544
+ }
545
+
546
+ // S1 수정: propertyName(JS명)을 키로 사용 — Drizzle insert 호환
547
+ // 사용자 입력을 덮어씀 (보안: 타인 ID로 데이터 생성 방지)
548
+ if (ownerMeta && user) {
549
+ insertData[ownerMeta.propertyName] = user.id;
550
+ } else if (userIdCol && user && !insertData.userId) {
551
+ // ownerRls 미적용 테이블의 기존 자동 주입 동작 유지
364
552
  insertData.userId = user.id;
365
553
  }
366
554
 
@@ -369,11 +557,16 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
369
557
  insertData = await options.hooks.beforeCreate(insertData);
370
558
  }
371
559
 
372
- const [result] = await ctx.db.insert(anyTable).values(insertData).returning();
560
+ const [result] = await inRlsOrPlainTx(ctx.db, async (tx: any) =>
561
+ tx.insert(anyTable).values(insertData).returning()
562
+ );
373
563
 
374
564
  // Realtime push — { data, total } 형태로 emit
565
+ // ownerRls 시 해당 사용자 데이터만 push (타 사용자에게 누출 방지)
566
+ // S5: requireAuth로 확실한 userId 획득 (getUserIdentity null 방지)
375
567
  if (useRealtime && enabledMethods.has('list')) {
376
- const listResult = await fetchListWithTotal(ctx.db);
568
+ const currentUserId = user?.id;
569
+ const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
377
570
  ctx.realtime.emit(`${prefix}.list`, listResult);
378
571
  }
379
572
 
@@ -386,12 +579,26 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
386
579
  const updateDef = !enabledMethods.has('update') ? undefined : mutation(`${prefix}.update`, {
387
580
  public: isPublic,
388
581
  handler: async (ctx: any, args: any) => {
389
- if (!isPublic) ctx.auth.requireAuth();
582
+ // ownerRls 테이블은 항상 인증 필수
583
+ const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
390
584
 
391
585
  const { id, ...updates } = args;
392
586
 
393
587
  let updateData = { ...updates };
394
588
 
589
+ // ── ownerRls Layer 1: update 시 userId 변경 차단 + 소유자 검증 ──
590
+ let updateWhere: SQL = eq(pk, id);
591
+ if (ownerMeta && user) {
592
+ // WHERE id = ? AND user_id = ? (타인 데이터 수정 불가)
593
+ updateWhere = and(eq(pk, id), eq(ownerMeta.column, user.id))!;
594
+ // S1 수정: propertyName(JS명)으로 userId 변경 시도 차단
595
+ delete updateData[ownerMeta.propertyName];
596
+ // DB명도 방어적으로 삭제 (만약 클라이언트가 DB명으로 보낸 경우)
597
+ if (ownerMeta.propertyName !== ownerMeta.columnName) {
598
+ delete updateData[ownerMeta.columnName];
599
+ }
600
+ }
601
+
395
602
  // updatedAt 자동 갱신
396
603
  if (anyTable["updatedAt"]) {
397
604
  updateData.updatedAt = new Date();
@@ -402,15 +609,19 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
402
609
  updateData = await options.hooks.beforeUpdate(updateData);
403
610
  }
404
611
 
405
- const [result] = await ctx.db.update(anyTable)
406
- .set(updateData)
407
- .where(eq(pk, id))
408
- .returning();
612
+ const [result] = await inRlsOrPlainTx(ctx.db, async (tx: any) =>
613
+ tx.update(anyTable)
614
+ .set(updateData)
615
+ .where(updateWhere)
616
+ .returning()
617
+ );
409
618
 
410
619
  // Realtime push (list + get 양쪽) — { data, total } 형태
620
+ // S5: ownerRls 시 user.id 사용 (getUserIdentity null 방지)
411
621
  if (useRealtime) {
622
+ const currentUserId = ownerMeta ? user?.id : undefined;
412
623
  if (enabledMethods.has('list')) {
413
- const listResult = await fetchListWithTotal(ctx.db);
624
+ const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
414
625
  ctx.realtime.emit(`${prefix}.list`, listResult);
415
626
  }
416
627
  if (enabledMethods.has('get')) {
@@ -427,20 +638,32 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
427
638
  const removeDef = !enabledMethods.has('remove') ? undefined : mutation(`${prefix}.remove`, {
428
639
  public: isPublic,
429
640
  handler: async (ctx: any, args: any) => {
430
- if (!isPublic) ctx.auth.requireAuth();
431
-
432
- if (options?.softDelete) {
433
- const sdField = options.softDelete.field as string;
434
- await ctx.db.update(anyTable)
435
- .set({ [sdField]: new Date() } as any)
436
- .where(eq(pk, args.id));
437
- } else {
438
- await ctx.db.delete(anyTable).where(eq(pk, args.id));
641
+ // ownerRls 테이블은 항상 인증 필수
642
+ const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
643
+
644
+ // ── ownerRls Layer 1: remove 시 소유자 검증 ──
645
+ let deleteWhere: SQL = eq(pk, args.id);
646
+ if (ownerMeta && user) {
647
+ // WHERE id = ? AND user_id = ? (타인 데이터 삭제 불가)
648
+ deleteWhere = and(eq(pk, args.id), eq(ownerMeta.column, user.id))!;
439
649
  }
440
650
 
651
+ await inRlsOrPlainTx(ctx.db, async (tx: any) => {
652
+ if (options?.softDelete) {
653
+ const sdField = options.softDelete.field as string;
654
+ await tx.update(anyTable)
655
+ .set({ [sdField]: new Date() } as any)
656
+ .where(deleteWhere);
657
+ } else {
658
+ await tx.delete(anyTable).where(deleteWhere);
659
+ }
660
+ });
661
+
441
662
  // Realtime push — { data, total } 형태
663
+ // S5: ownerRls 시 user.id 사용
442
664
  if (useRealtime && enabledMethods.has('list')) {
443
- const listResult = await fetchListWithTotal(ctx.db);
665
+ const currentUserId = ownerMeta ? user?.id : undefined;
666
+ const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
444
667
  ctx.realtime.emit(`${prefix}.list`, listResult);
445
668
  }
446
669
 
package/src/index.ts CHANGED
@@ -5,27 +5,28 @@
5
5
  * All with Convex-compatible DX patterns.
6
6
  */
7
7
 
8
- export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive";
9
- export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
10
- export type { Storage } from "./storage";
11
- export { createScheduler, getSchedulerInfo } from "./scheduler";
12
- export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler";
13
- export { v, parseArgs, GencowValidationError } from "./v";
14
- export type { Validator, Infer, InferArgs } from "./v";
15
- export { withRetry } from "./retry";
16
- export type { RetryOptions } from "./retry";
17
- export { cronJobs } from "./crons";
18
- export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons";
19
- export { defineAuth } from "./auth-config";
20
- export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
8
+ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive.js";
9
+ export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
10
+ export type { Storage } from "./storage.js";
11
+ export { createScheduler, getSchedulerInfo } from "./scheduler.js";
12
+ export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler.js";
13
+ export { v, parseArgs, GencowValidationError } from "./v.js";
14
+ export type { Validator, Infer, InferArgs } from "./v.js";
15
+ export { withRetry } from "./retry.js";
16
+ export type { RetryOptions } from "./retry.js";
17
+ export { cronJobs } from "./crons.js";
18
+ export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons.js";
19
+ export { defineAuth } from "./auth-config.js";
20
+ export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config.js";
21
21
 
22
22
  // ─── RLS + CRUD Factory ───────────
23
- export { ownerRls } from "./rls";
24
- export { createRlsDb } from "./rls-db";
25
- export { crud, parseFilterNode, applyFilterOp } from "./crud";
23
+ export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
24
+ export type { OwnerRlsMeta } from "./rls.js";
25
+ export { createRlsDb } from "./rls-db.js";
26
+ export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud.js";
26
27
 
27
28
  // Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
28
- export { crud as gencowCrud } from "./crud";
29
+ export { crud as gencowCrud } from "./crud.js";
29
30
 
30
31
 
31
32
 
package/src/reactive.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 Validator, type InferArgs } from "./v";
2
+ import type { Storage } from "./storage.js";
3
+ import type { Scheduler } from "./scheduler.js";
4
+ import { type Validator, type InferArgs } from "./v.js";
5
5
 
6
6
  // ─── GencowCtx — 사용자 함수에 주입되는 컨텍스트 ──────────
7
7
 
@@ -106,7 +106,7 @@ export interface GencowCtx {
106
106
  /** 실시간 push — ctx.realtime.emit(queryKey, data) */
107
107
  realtime: RealtimeCtx;
108
108
  /** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
109
- retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
109
+ retry: <T>(fn: () => Promise<T>, options?: import("./retry.js").RetryOptions) => Promise<T>;
110
110
  /** AI 헬퍼 */
111
111
  ai?: AIContext;
112
112
  }
package/src/rls-db.ts CHANGED
@@ -2,12 +2,10 @@ import { sql } from "drizzle-orm";
2
2
  import type { PgDatabase } from "drizzle-orm/pg-core";
3
3
 
4
4
  /**
5
- * JWT payload.sub(userId) RLS 세션 변수를 주입하는 DB 래퍼.
6
- * Supabase createDrizzle 패턴 참고.
5
+ * Wraps Drizzle so `transaction()` runs `set_config('app.current_user_id', ...)` first (RLS policies).
6
+ * Supabase-style session GUC injection.
7
7
  *
8
- * set_config(name, value, is_local=true)
9
- * → is_local=true: 현재 트랜잭션에서만 유효
10
- * → PgBouncer transaction 모드에서 안전
8
+ * `set_config(..., true)` is transaction-local (safe with PgBouncer transaction pooling).
11
9
  */
12
10
  export function createRlsDb(db: PgDatabase<any, any, any>, userId: string) {
13
11
  return new Proxy(db, {
package/src/rls.ts CHANGED
@@ -1,15 +1,99 @@
1
1
  import { sql, type SQL } from "drizzle-orm";
2
- import { pgPolicy, type AnyPgColumn } from "drizzle-orm/pg-core";
2
+ import { pgPolicy, type AnyPgColumn, type PgTable } from "drizzle-orm/pg-core";
3
3
 
4
+ // ─── ownerRls 메타데이터 레지스트리 ─────────────────────────
5
+ //
6
+ // getTableConfig()는 pgPolicy 배열을 복사하므로 부착 프로퍼티가 유실됨.
7
+ // 테이블 → 메타데이터 매핑을 WeakMap으로 관리하여 crud()가 접근.
8
+ // WeakMap이므로 테이블이 GC되면 메타데이터도 자동 해제.
9
+
10
+ export interface OwnerRlsMeta {
11
+ /** userId 컬럼의 DB 이름 (e.g. "user_id") */
12
+ columnName: string;
13
+ /** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
14
+ readPublic: boolean;
15
+ }
16
+
17
+ const _ownerRlsRegistry = new WeakMap<PgTable, OwnerRlsMeta>();
18
+
19
+ /**
20
+ * 테이블에 등록된 ownerRls 메타데이터를 반환한다.
21
+ * ownerRls()가 호출되지 않은 테이블은 undefined를 반환.
22
+ */
23
+ export function getOwnerRlsMeta(table: PgTable): OwnerRlsMeta | undefined {
24
+ return _ownerRlsRegistry.get(table);
25
+ }
26
+
27
+ /**
28
+ * 내부용: ownerRls()가 호출될 때 메타데이터를 레지스트리에 등록.
29
+ * pgTable의 extraConfig 콜백에서 호출되므로, 테이블 참조가 아닌
30
+ * 임시 프록시 객체가 전달될 수 있음 → registerOwnerRls()로 사후 등록.
31
+ */
32
+ export function registerOwnerRls(table: PgTable, meta: OwnerRlsMeta): void {
33
+ _ownerRlsRegistry.set(table, meta);
34
+ }
35
+
36
+ // ─── ownerRls — DB-level RLS 정책 선언 + 앱 레벨 메타데이터 등록 ──
37
+
38
+ /**
39
+ * 사용자 소유권 기반 RLS 정책을 선언한다.
40
+ *
41
+ * 2-Layer 방어:
42
+ * - Layer 1 (앱 레벨): crud()가 이 메타데이터를 감지하여
43
+ * 모든 CRUD 쿼리에 WHERE userId = auth.userId 자동 주입.
44
+ * PGlite + PostgreSQL 양쪽에서 동작.
45
+ * - Layer 2 (DB 레벨): PostgreSQL RLS 정책으로 심층 방어.
46
+ * createRlsDb()의 transaction() 내에서 set_config 주입.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * export const tasks = pgTable("tasks", {
51
+ * id: serial("id").primaryKey(),
52
+ * title: text("title").notNull(),
53
+ * userId: text("user_id").notNull(),
54
+ * }, (t) => ownerRls(t.userId));
55
+ *
56
+ * // read: "public" — 누구나 읽기 가능, CUD는 소유자만
57
+ * export const posts = pgTable("posts", {
58
+ * id: serial("id").primaryKey(),
59
+ * content: text("content").notNull(),
60
+ * userId: text("user_id").notNull(),
61
+ * }, (t) => ownerRls(t.userId, { read: "public" }));
62
+ * ```
63
+ */
4
64
  export function ownerRls(
5
65
  userIdColumn: AnyPgColumn,
6
66
  options?: { read?: "public" },
7
67
  ) {
8
- const isOwner = sql`${userIdColumn} = current_setting('app.current_user_id')`;
9
- return [
68
+ // S3 방어: userIdColumn.name 미존재 시 명확한 에러
69
+ const colName = (userIdColumn as any).name;
70
+ if (!colName) {
71
+ throw new Error(
72
+ "[ownerRls] userIdColumn must have a .name property. " +
73
+ "Ensure you pass a valid Drizzle column reference (e.g. t.userId)."
74
+ );
75
+ }
76
+
77
+ /** `missing_ok` avoids errors before first `set_config` and matches PG custom GUC behavior in PGlite. */
78
+ const isOwner = sql`${userIdColumn} = current_setting('app.current_user_id', true)`;
79
+
80
+ // ── 앱 레벨 메타데이터: crud()가 런타임에 읽음 ──
81
+ const meta: OwnerRlsMeta = {
82
+ columnName: colName,
83
+ readPublic: options?.read === "public",
84
+ };
85
+
86
+ const policies = [
10
87
  pgPolicy("rls-select", { for: "select", using: options?.read === "public" ? sql`true` : isOwner }),
11
88
  pgPolicy("rls-insert", { for: "insert", withCheck: isOwner }),
12
89
  pgPolicy("rls-update", { for: "update", using: isOwner, withCheck: isOwner }),
13
90
  pgPolicy("rls-delete", { for: "delete", using: isOwner }),
14
91
  ];
92
+
93
+ // N2 정리: non-enumerable _ownerRlsMeta 마커 제거 (dead code).
94
+ // ownerRls()는 pgTable extraConfig 콜백에서 호출되며, 이 시점에서는
95
+ // 테이블 참조가 프록시이므로 WeakMap 등록 불가능.
96
+ // 실제 메타데이터 등록은 crud()의 detectOwnerMeta() fallback에서 수행.
97
+
98
+ return policies;
15
99
  }
package/src/server.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * executing server. Excluded from client-side core (`index.ts`) so they aren't
6
6
  * bundled into user functions which run in Firecracker.
7
7
  */
8
- export { createDb } from "./db";
9
- export { createStorage, storageRoutes } from "./storage";
10
- export { createScheduler, getSchedulerInfo } from "./scheduler";
11
- export { authMiddleware, authRoutes, getUsers } from "./auth";
8
+ export { createDb } from "./db.js";
9
+ export { createStorage, storageRoutes } from "./storage.js";
10
+ export type { StorageImageTierConfig } from "./storage.js";
11
+ export { createScheduler, getSchedulerInfo } from "./scheduler.js";
12
+ export { authMiddleware, authRoutes, getUsers } from "./auth.js";