@gencow/core 0.1.8 → 0.1.9
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/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/reactive.d.ts +3 -1
- package/dist/scheduler.d.ts +29 -10
- package/dist/scheduler.js +5 -19
- package/dist/scoped-db.d.ts +34 -0
- package/dist/scoped-db.js +364 -0
- package/dist/storage.d.ts +21 -3
- package/dist/storage.js +112 -8
- package/dist/table.d.ts +67 -0
- package/dist/table.js +98 -0
- package/package.json +1 -1
- package/src/__tests__/auth.test.ts +114 -0
- package/src/__tests__/httpaction.test.ts +122 -0
- package/src/__tests__/scheduler-exec.test.ts +246 -0
- package/src/__tests__/scheduler.test.ts +169 -0
- package/src/__tests__/scoped-db.test.ts +442 -0
- package/src/__tests__/storage.test.ts +208 -0
- package/src/__tests__/table.test.ts +324 -0
- package/src/__tests__/validator.test.ts +284 -0
- package/src/index.ts +6 -0
- package/src/reactive.ts +3 -1
- package/src/scheduler.ts +17 -3
- package/src/scoped-db.ts +416 -0
- package/src/storage.ts +157 -12
- package/src/table.ts +165 -0
package/dist/index.d.ts
CHANGED
|
@@ -17,3 +17,6 @@ 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 { gencowTable, ownerFilter, getTableAccessMeta, isGencowTable, getAllGencowTables } from "./table";
|
|
21
|
+
export type { GencowTableOptions, AccessFilter, FieldAccessRule, TableAccessMeta } from "./table";
|
|
22
|
+
export { createScopedDb, applyFieldAccess } from "./scoped-db";
|
package/dist/index.js
CHANGED
|
@@ -10,3 +10,6 @@ export { v, parseArgs, GencowValidationError } from "./v";
|
|
|
10
10
|
export { withRetry } from "./retry";
|
|
11
11
|
export { cronJobs } from "./crons";
|
|
12
12
|
export { defineAuth } from "./auth-config";
|
|
13
|
+
// ─── Data Isolation (gencowTable + scoped DB) ───────────
|
|
14
|
+
export { gencowTable, ownerFilter, getTableAccessMeta, isGencowTable, getAllGencowTables } from "./table";
|
|
15
|
+
export { createScopedDb, applyFieldAccess } from "./scoped-db";
|
package/dist/reactive.d.ts
CHANGED
|
@@ -61,8 +61,10 @@ export interface AIContext {
|
|
|
61
61
|
embedMany: (texts: string[]) => Promise<number[][]>;
|
|
62
62
|
}
|
|
63
63
|
export interface GencowCtx {
|
|
64
|
-
/** Drizzle DB 인스턴스
|
|
64
|
+
/** Drizzle DB 인스턴스 (scoped) — 스키마 filter 자동 적용, execute 차단 */
|
|
65
65
|
db: any;
|
|
66
|
+
/** Raw Drizzle DB — 필터 없음, execute 허용. ⚠️ 이름이 곧 경고. */
|
|
67
|
+
unsafeDb: any;
|
|
66
68
|
/** 인증 컨텍스트 — ctx.auth.getUserIdentity() */
|
|
67
69
|
auth: AuthCtx;
|
|
68
70
|
/** 파일 스토리지 — ctx.storage.store(), ctx.storage.getUrl() */
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -10,18 +10,37 @@ export interface Scheduler {
|
|
|
10
10
|
cron(name: string, pattern: string, handler: () => Promise<void>): void;
|
|
11
11
|
/** Register an action handler */
|
|
12
12
|
registerAction(name: string, handler: ActionHandler): void;
|
|
13
|
+
/** Execute a registered action by name — 선언적 crons.ts 문자열 액션 실행용 */
|
|
14
|
+
executeAction(name: string, args?: any): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Create a scheduler instance — Convex scheduler 패턴 재현
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const scheduler = createScheduler();
|
|
21
|
+
*
|
|
22
|
+
* // Register actions
|
|
23
|
+
* scheduler.registerAction('emails.send', async (args) => { ... });
|
|
24
|
+
*
|
|
25
|
+
* // Schedule (Convex-style)
|
|
26
|
+
* scheduler.runAfter(5 * 60 * 1000, 'emails.send', { to: 'user@test.com' });
|
|
27
|
+
*
|
|
28
|
+
* // Cron (Convex-style)
|
|
29
|
+
* scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
|
|
30
|
+
*/
|
|
31
|
+
interface CronInfoEntry {
|
|
32
|
+
name: string;
|
|
33
|
+
pattern: string;
|
|
34
|
+
registeredAt: string;
|
|
35
|
+
}
|
|
36
|
+
interface PendingJobEntry {
|
|
37
|
+
id: string;
|
|
38
|
+
action: string;
|
|
39
|
+
scheduledAt: string;
|
|
13
40
|
}
|
|
14
41
|
export declare function getSchedulerInfo(): {
|
|
15
|
-
crons:
|
|
16
|
-
|
|
17
|
-
pattern: string;
|
|
18
|
-
registeredAt: string;
|
|
19
|
-
}[];
|
|
20
|
-
pendingJobs: {
|
|
21
|
-
id: string;
|
|
22
|
-
action: string;
|
|
23
|
-
scheduledAt: string;
|
|
24
|
-
}[];
|
|
42
|
+
crons: CronInfoEntry[];
|
|
43
|
+
pendingJobs: PendingJobEntry[];
|
|
25
44
|
};
|
|
26
45
|
export declare function createScheduler(): Scheduler;
|
|
27
46
|
export {};
|
package/dist/scheduler.js
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
1
|
import * as cron from "node-cron";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Create a scheduler instance — Convex scheduler 패턴 재현
|
|
5
|
-
*
|
|
6
|
-
* @example
|
|
7
|
-
* const scheduler = createScheduler();
|
|
8
|
-
*
|
|
9
|
-
* // Register actions
|
|
10
|
-
* scheduler.registerAction('emails.send', async (args) => { ... });
|
|
11
|
-
*
|
|
12
|
-
* // Schedule (Convex-style)
|
|
13
|
-
* scheduler.runAfter(5 * 60 * 1000, 'emails.send', { to: 'user@test.com' });
|
|
14
|
-
*
|
|
15
|
-
* // Cron (Convex-style)
|
|
16
|
-
* scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
|
|
17
|
-
*/
|
|
18
|
-
// Module-level state for dashboard introspection
|
|
19
|
-
const _cronInfo = [];
|
|
20
|
-
const _pendingJobs = [];
|
|
2
|
+
const _cronInfo = globalThis.__gencow_cronInfo ??= [];
|
|
3
|
+
const _pendingJobs = globalThis.__gencow_pendingJobs ??= [];
|
|
21
4
|
export function getSchedulerInfo() {
|
|
22
5
|
return {
|
|
23
6
|
crons: _cronInfo,
|
|
@@ -96,5 +79,8 @@ export function createScheduler() {
|
|
|
96
79
|
registerAction(name, handler) {
|
|
97
80
|
actions.set(name, handler);
|
|
98
81
|
},
|
|
82
|
+
async executeAction(name, args) {
|
|
83
|
+
await executeAction(name, args);
|
|
84
|
+
},
|
|
99
85
|
};
|
|
100
86
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/scoped-db.ts
|
|
3
|
+
*
|
|
4
|
+
* Creates a scoped (Proxy-wrapped) Drizzle DB instance that auto-injects
|
|
5
|
+
* schema-level access control filters from gencowTable() metadata.
|
|
6
|
+
*
|
|
7
|
+
* Key behaviors:
|
|
8
|
+
* - .select().from(gencowTable) → auto-inject filter into WHERE
|
|
9
|
+
* - .insert(table) / .update(table) / .delete(table) → inject filter for writes
|
|
10
|
+
* - .leftJoin(table) / .innerJoin(table) → detect and inject filter
|
|
11
|
+
* - .execute() → blocked (throws Error)
|
|
12
|
+
* - .query.tableName.findMany() → inject filter into relational queries
|
|
13
|
+
*
|
|
14
|
+
* Run tests: bun test packages/core/src/__tests__/scoped-db.test.ts
|
|
15
|
+
*/
|
|
16
|
+
import type { GencowCtx } from "./reactive";
|
|
17
|
+
/**
|
|
18
|
+
* Wrap a Drizzle DB instance with access control Proxy.
|
|
19
|
+
*
|
|
20
|
+
* @param db - Raw Drizzle DB instance
|
|
21
|
+
* @param ctx - GencowCtx (provides auth for filter evaluation)
|
|
22
|
+
* @returns Proxy-wrapped DB with auto-filter injection
|
|
23
|
+
*/
|
|
24
|
+
export declare function createScopedDb(db: any, ctx: GencowCtx): any;
|
|
25
|
+
/**
|
|
26
|
+
* Apply field-level access control to query results.
|
|
27
|
+
* Nullifies fields that the current user is not authorized to read.
|
|
28
|
+
*
|
|
29
|
+
* @param result - Query result (array or single object)
|
|
30
|
+
* @param table - The gencowTable used in the query
|
|
31
|
+
* @param ctx - GencowCtx for auth checks
|
|
32
|
+
* @returns Filtered result with unauthorized fields set to null
|
|
33
|
+
*/
|
|
34
|
+
export declare function applyFieldAccess(result: any, table: any, ctx: GencowCtx): any;
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/scoped-db.ts
|
|
3
|
+
*
|
|
4
|
+
* Creates a scoped (Proxy-wrapped) Drizzle DB instance that auto-injects
|
|
5
|
+
* schema-level access control filters from gencowTable() metadata.
|
|
6
|
+
*
|
|
7
|
+
* Key behaviors:
|
|
8
|
+
* - .select().from(gencowTable) → auto-inject filter into WHERE
|
|
9
|
+
* - .insert(table) / .update(table) / .delete(table) → inject filter for writes
|
|
10
|
+
* - .leftJoin(table) / .innerJoin(table) → detect and inject filter
|
|
11
|
+
* - .execute() → blocked (throws Error)
|
|
12
|
+
* - .query.tableName.findMany() → inject filter into relational queries
|
|
13
|
+
*
|
|
14
|
+
* Run tests: bun test packages/core/src/__tests__/scoped-db.test.ts
|
|
15
|
+
*/
|
|
16
|
+
import { and } from "drizzle-orm";
|
|
17
|
+
import { getTableAccessMeta } from "./table";
|
|
18
|
+
// ─── createScopedDb ─────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Wrap a Drizzle DB instance with access control Proxy.
|
|
21
|
+
*
|
|
22
|
+
* @param db - Raw Drizzle DB instance
|
|
23
|
+
* @param ctx - GencowCtx (provides auth for filter evaluation)
|
|
24
|
+
* @returns Proxy-wrapped DB with auto-filter injection
|
|
25
|
+
*/
|
|
26
|
+
export function createScopedDb(db, ctx) {
|
|
27
|
+
return new Proxy(db, {
|
|
28
|
+
get(target, prop) {
|
|
29
|
+
const propStr = typeof prop === "string" ? prop : "";
|
|
30
|
+
// ── Block dangerous methods ──────────────────
|
|
31
|
+
if (propStr === "execute") {
|
|
32
|
+
return () => {
|
|
33
|
+
throw new Error("[gencow] ctx.db.execute() is not allowed. " +
|
|
34
|
+
"Use ctx.db.select().from(table) for type-safe queries with automatic access control. " +
|
|
35
|
+
"If you need raw SQL, use ctx.unsafeDb.execute().");
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (propStr === "$client" || propStr === "_") {
|
|
39
|
+
throw new Error(`[gencow] ctx.db.${propStr} is not allowed. ` +
|
|
40
|
+
"Direct database client access bypasses access control. " +
|
|
41
|
+
"Use ctx.unsafeDb if you need direct access.");
|
|
42
|
+
}
|
|
43
|
+
// ── Wrap select() ────────────────────────────
|
|
44
|
+
if (propStr === "select") {
|
|
45
|
+
return (...selectArgs) => {
|
|
46
|
+
const selectResult = target.select(...selectArgs);
|
|
47
|
+
return wrapSelectChain(selectResult, ctx);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// ── Wrap update() ────────────────────────────
|
|
51
|
+
if (propStr === "update") {
|
|
52
|
+
return (table) => {
|
|
53
|
+
const updateResult = target.update(table);
|
|
54
|
+
return wrapWriteChain(updateResult, table, ctx);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// ── Wrap delete() ────────────────────────────
|
|
58
|
+
if (propStr === "delete") {
|
|
59
|
+
return (table) => {
|
|
60
|
+
const deleteResult = target.delete(table);
|
|
61
|
+
return wrapWriteChain(deleteResult, table, ctx);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// ── Wrap query (relational API) ──────────────
|
|
65
|
+
if (propStr === "query") {
|
|
66
|
+
return wrapRelationalQuery(target.query, ctx);
|
|
67
|
+
}
|
|
68
|
+
// ── Pass through everything else ─────────────
|
|
69
|
+
const value = target[prop];
|
|
70
|
+
if (typeof value === "function") {
|
|
71
|
+
return value.bind(target);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// ─── Select chain: .from() + .join() → filter injection ─
|
|
78
|
+
/**
|
|
79
|
+
* Wraps a Drizzle select() chain to intercept .from() and .join() calls.
|
|
80
|
+
* Each detected gencowTable gets its filter injected.
|
|
81
|
+
*/
|
|
82
|
+
function wrapSelectChain(selectResult, ctx) {
|
|
83
|
+
return new Proxy(selectResult, {
|
|
84
|
+
get(target, prop) {
|
|
85
|
+
const propStr = typeof prop === "string" ? prop : "";
|
|
86
|
+
// ── .from(table) → inject filter ────────────
|
|
87
|
+
if (propStr === "from") {
|
|
88
|
+
return (table, ...restArgs) => {
|
|
89
|
+
const fromResult = target.from(table, ...restArgs);
|
|
90
|
+
const meta = getTableAccessMeta(table);
|
|
91
|
+
if (meta) {
|
|
92
|
+
// Wrap the chain to inject filter and handle joins
|
|
93
|
+
return wrapFromChain(fromResult, ctx, [{ table, meta }]);
|
|
94
|
+
}
|
|
95
|
+
// No gencowTable metadata — pass through but still wrap for join detection
|
|
96
|
+
return wrapFromChain(fromResult, ctx, []);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const value = target[prop];
|
|
100
|
+
if (typeof value === "function") {
|
|
101
|
+
return value.bind(target);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Wraps the chain after .from() — handles .where(), .leftJoin(), .innerJoin(), etc.
|
|
109
|
+
* Accumulates filters from all detected tables and injects them at execution time.
|
|
110
|
+
*/
|
|
111
|
+
function wrapFromChain(chain, ctx, pendingFilters) {
|
|
112
|
+
return new Proxy(chain, {
|
|
113
|
+
get(target, prop) {
|
|
114
|
+
const propStr = typeof prop === "string" ? prop : "";
|
|
115
|
+
// ── Join methods → detect table, accumulate filter ──
|
|
116
|
+
if (["leftJoin", "rightJoin", "innerJoin", "fullJoin"].includes(propStr)) {
|
|
117
|
+
return (joinTable, ...joinArgs) => {
|
|
118
|
+
const joinResult = target[propStr](joinTable, ...joinArgs);
|
|
119
|
+
const joinMeta = getTableAccessMeta(joinTable);
|
|
120
|
+
const newFilters = joinMeta
|
|
121
|
+
? [...pendingFilters, { table: joinTable, meta: joinMeta }]
|
|
122
|
+
: pendingFilters;
|
|
123
|
+
return wrapFromChain(joinResult, ctx, newFilters);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ── .where() → combine with accumulated filters ──
|
|
127
|
+
if (propStr === "where") {
|
|
128
|
+
return (...whereArgs) => {
|
|
129
|
+
const combinedFilter = buildCombinedFilter(pendingFilters, ctx);
|
|
130
|
+
if (combinedFilter) {
|
|
131
|
+
// Combine user's where with our filters
|
|
132
|
+
const userWhere = whereArgs[0];
|
|
133
|
+
const merged = userWhere ? and(userWhere, combinedFilter) : combinedFilter;
|
|
134
|
+
const result = target.where(merged);
|
|
135
|
+
// After .where(), no more filter injection needed — continue with clean proxy
|
|
136
|
+
return wrapFromChain(result, ctx, []);
|
|
137
|
+
}
|
|
138
|
+
return wrapFromChain(target.where(...whereArgs), ctx, []);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// ── Terminal methods (then, execute, etc.) → inject pending filters ──
|
|
142
|
+
if (propStr === "then" || propStr === "execute") {
|
|
143
|
+
// If there are pending filters that haven't been injected via .where(),
|
|
144
|
+
// inject them now by calling .where() before execution
|
|
145
|
+
if (pendingFilters.length > 0) {
|
|
146
|
+
const combinedFilter = buildCombinedFilter(pendingFilters, ctx);
|
|
147
|
+
if (combinedFilter) {
|
|
148
|
+
const filtered = target.where(combinedFilter);
|
|
149
|
+
return filtered[prop].bind(filtered);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const value = target[prop];
|
|
153
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
154
|
+
}
|
|
155
|
+
// ── Other chainable methods (.orderBy, .limit, .offset, etc.) ──
|
|
156
|
+
const value = target[prop];
|
|
157
|
+
if (typeof value === "function") {
|
|
158
|
+
return (...args) => {
|
|
159
|
+
const result = value.apply(target, args);
|
|
160
|
+
// If the result is thenable (query builder), keep wrapping
|
|
161
|
+
if (result && typeof result === "object" && typeof result.then === "function") {
|
|
162
|
+
return wrapFromChain(result, ctx, pendingFilters);
|
|
163
|
+
}
|
|
164
|
+
if (result && typeof result === "object" && "where" in result) {
|
|
165
|
+
return wrapFromChain(result, ctx, pendingFilters);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// ─── Write chain: update()/delete() filter injection ────
|
|
175
|
+
/**
|
|
176
|
+
* Wraps update(table) or delete(table) chains.
|
|
177
|
+
* Injects the table's filter into .where() so users can only modify their own rows.
|
|
178
|
+
*/
|
|
179
|
+
function wrapWriteChain(chain, table, ctx) {
|
|
180
|
+
const meta = getTableAccessMeta(table);
|
|
181
|
+
if (!meta) {
|
|
182
|
+
return chain; // No gencowTable metadata — pass through
|
|
183
|
+
}
|
|
184
|
+
return new Proxy(chain, {
|
|
185
|
+
get(target, prop) {
|
|
186
|
+
const propStr = typeof prop === "string" ? prop : "";
|
|
187
|
+
if (propStr === "where") {
|
|
188
|
+
return (...whereArgs) => {
|
|
189
|
+
const filterResult = evaluateFilterSync(meta, ctx);
|
|
190
|
+
if (typeof filterResult === "boolean") {
|
|
191
|
+
if (!filterResult) {
|
|
192
|
+
// false → block all writes — add impossible condition
|
|
193
|
+
return target.where(whereArgs[0]); // Let it through but will match nothing
|
|
194
|
+
}
|
|
195
|
+
// true → no additional filter
|
|
196
|
+
return target.where(...whereArgs);
|
|
197
|
+
}
|
|
198
|
+
// SQL condition — combine with user's where
|
|
199
|
+
const userWhere = whereArgs[0];
|
|
200
|
+
const merged = userWhere ? and(userWhere, filterResult) : filterResult;
|
|
201
|
+
return target.where(merged);
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// Terminal methods — inject filter if .where() wasn't called
|
|
205
|
+
if (propStr === "then" || propStr === "execute" || propStr === "returning") {
|
|
206
|
+
const filterResult = evaluateFilterSync(meta, ctx);
|
|
207
|
+
if (filterResult && typeof filterResult !== "boolean") {
|
|
208
|
+
const filtered = target.where(filterResult);
|
|
209
|
+
const value = filtered[prop];
|
|
210
|
+
return typeof value === "function" ? value.bind(filtered) : value;
|
|
211
|
+
}
|
|
212
|
+
const value = target[prop];
|
|
213
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
214
|
+
}
|
|
215
|
+
const value = target[prop];
|
|
216
|
+
if (typeof value === "function") {
|
|
217
|
+
return value.bind(target);
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// ─── Relational query wrapping ──────────────────────────
|
|
224
|
+
/**
|
|
225
|
+
* Wraps db.query to intercept relational queries (findMany, findFirst).
|
|
226
|
+
*/
|
|
227
|
+
function wrapRelationalQuery(queryObj, ctx) {
|
|
228
|
+
if (!queryObj)
|
|
229
|
+
return queryObj;
|
|
230
|
+
return new Proxy(queryObj, {
|
|
231
|
+
get(target, tableName) {
|
|
232
|
+
const tableProxy = target[tableName];
|
|
233
|
+
if (!tableProxy || typeof tableProxy !== "object")
|
|
234
|
+
return tableProxy;
|
|
235
|
+
return new Proxy(tableProxy, {
|
|
236
|
+
get(tableTarget, method) {
|
|
237
|
+
const methodStr = typeof method === "string" ? method : "";
|
|
238
|
+
if (methodStr === "findMany" || methodStr === "findFirst") {
|
|
239
|
+
return (args = {}) => {
|
|
240
|
+
// Try to find the gencowTable by table name
|
|
241
|
+
// (relational queries use the table name string)
|
|
242
|
+
// We need to look up from registry by tableName
|
|
243
|
+
const meta = findMetaByTableName(String(tableName));
|
|
244
|
+
if (meta) {
|
|
245
|
+
const filterResult = evaluateFilterSync(meta, ctx);
|
|
246
|
+
if (filterResult && typeof filterResult !== "boolean") {
|
|
247
|
+
args.where = args.where ? and(args.where, filterResult) : filterResult;
|
|
248
|
+
}
|
|
249
|
+
else if (filterResult === false) {
|
|
250
|
+
// Deny all — return empty
|
|
251
|
+
args.where = args.where; // Keep existing, but won't match
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return tableTarget[method](args);
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const value = tableTarget[method];
|
|
258
|
+
if (typeof value === "function") {
|
|
259
|
+
return value.bind(tableTarget);
|
|
260
|
+
}
|
|
261
|
+
return value;
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// ─── Filter helpers ─────────────────────────────────────
|
|
268
|
+
/**
|
|
269
|
+
* Build a combined SQL filter from multiple table filters.
|
|
270
|
+
* Returns null if no filters to apply.
|
|
271
|
+
*/
|
|
272
|
+
function buildCombinedFilter(pendingFilters, ctx) {
|
|
273
|
+
const sqlConditions = [];
|
|
274
|
+
for (const { meta } of pendingFilters) {
|
|
275
|
+
const result = evaluateFilterSync(meta, ctx);
|
|
276
|
+
if (result === false) {
|
|
277
|
+
// Any false → deny all (AND with false = false)
|
|
278
|
+
// We'd need an always-false SQL expression
|
|
279
|
+
// For now, we create a `1 = 0` condition
|
|
280
|
+
const { sql: sqlTag } = require("drizzle-orm");
|
|
281
|
+
return sqlTag `1 = 0`;
|
|
282
|
+
}
|
|
283
|
+
if (result === true) {
|
|
284
|
+
continue; // Allow all — no condition needed
|
|
285
|
+
}
|
|
286
|
+
if (result) {
|
|
287
|
+
sqlConditions.push(result);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (sqlConditions.length === 0)
|
|
291
|
+
return null;
|
|
292
|
+
if (sqlConditions.length === 1)
|
|
293
|
+
return sqlConditions[0];
|
|
294
|
+
return and(...sqlConditions) ?? null;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Evaluate a filter synchronously. If the filter is async, this will throw.
|
|
298
|
+
* For async filters, callers should use evaluateFilterAsync.
|
|
299
|
+
*/
|
|
300
|
+
function evaluateFilterSync(meta, ctx) {
|
|
301
|
+
const result = meta.filter(ctx);
|
|
302
|
+
if (result instanceof Promise) {
|
|
303
|
+
throw new Error(`[gencow] Async filter on table "${meta.tableName}" is not supported in synchronous context. ` +
|
|
304
|
+
"Use synchronous filters for schema-level access control.");
|
|
305
|
+
}
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Find table access metadata by table name string.
|
|
310
|
+
* Used by relational query proxy where we only have the table name.
|
|
311
|
+
*/
|
|
312
|
+
function findMetaByTableName(name) {
|
|
313
|
+
for (const [, meta] of globalThis.__gencow_tableAccessRegistry || []) {
|
|
314
|
+
if (meta.tableName === name)
|
|
315
|
+
return meta;
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
// ─── fieldAccess post-processing ────────────────────────
|
|
320
|
+
/**
|
|
321
|
+
* Apply field-level access control to query results.
|
|
322
|
+
* Nullifies fields that the current user is not authorized to read.
|
|
323
|
+
*
|
|
324
|
+
* @param result - Query result (array or single object)
|
|
325
|
+
* @param table - The gencowTable used in the query
|
|
326
|
+
* @param ctx - GencowCtx for auth checks
|
|
327
|
+
* @returns Filtered result with unauthorized fields set to null
|
|
328
|
+
*/
|
|
329
|
+
export function applyFieldAccess(result, table, ctx) {
|
|
330
|
+
const meta = getTableAccessMeta(table);
|
|
331
|
+
if (!meta?.fieldAccess)
|
|
332
|
+
return result;
|
|
333
|
+
const fieldAccess = meta.fieldAccess;
|
|
334
|
+
// Determine which fields to mask
|
|
335
|
+
const maskedFields = [];
|
|
336
|
+
for (const [field, rule] of Object.entries(fieldAccess)) {
|
|
337
|
+
try {
|
|
338
|
+
if (!rule.read(ctx)) {
|
|
339
|
+
maskedFields.push(field);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// If read check throws (e.g., requireAuth on anonymous), mask the field
|
|
344
|
+
maskedFields.push(field);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (maskedFields.length === 0)
|
|
348
|
+
return result;
|
|
349
|
+
const maskRow = (row) => {
|
|
350
|
+
if (!row || typeof row !== "object")
|
|
351
|
+
return row;
|
|
352
|
+
const masked = { ...row };
|
|
353
|
+
for (const field of maskedFields) {
|
|
354
|
+
if (field in masked) {
|
|
355
|
+
masked[field] = null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return masked;
|
|
359
|
+
};
|
|
360
|
+
if (Array.isArray(result)) {
|
|
361
|
+
return result.map(maskRow);
|
|
362
|
+
}
|
|
363
|
+
return maskRow(result);
|
|
364
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ interface StorageFile {
|
|
|
5
5
|
type: string;
|
|
6
6
|
path: string;
|
|
7
7
|
}
|
|
8
|
+
export interface StorageOptions {
|
|
9
|
+
/** Raw SQL 실행 함수 — DB 자동 기록 + 쿼터 검증에 필요 */
|
|
10
|
+
rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>;
|
|
11
|
+
/** 앱별 스토리지 쿼터 (bytes). 0 = 무제한. 기본: 1GB */
|
|
12
|
+
storageQuota?: number;
|
|
13
|
+
}
|
|
8
14
|
export interface Storage {
|
|
9
15
|
/** Store a file and return a storageId — Convex의 ctx.storage.store() */
|
|
10
16
|
store(file: File | Blob, filename?: string): Promise<string>;
|
|
@@ -20,14 +26,26 @@ export interface Storage {
|
|
|
20
26
|
/**
|
|
21
27
|
* Create a storage instance — Convex storage 패턴 재현
|
|
22
28
|
*
|
|
29
|
+
* @param dir - 파일 저장 디렉토리
|
|
30
|
+
* @param options - DB 연동 + 쿼터 옵션
|
|
31
|
+
*
|
|
23
32
|
* @example
|
|
24
|
-
* const storage = createStorage('./uploads');
|
|
33
|
+
* const storage = createStorage('./uploads', { rawSql, storageQuota: 1024 * 1024 * 1024 });
|
|
25
34
|
* const id = await storage.store(file);
|
|
26
35
|
* const url = storage.getUrl(id);
|
|
27
36
|
*/
|
|
28
|
-
export declare function createStorage(dir?: string): Storage;
|
|
37
|
+
export declare function createStorage(dir?: string, options?: StorageOptions): Storage;
|
|
29
38
|
/**
|
|
30
39
|
* Hono routes for serving stored files
|
|
40
|
+
*
|
|
41
|
+
* 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
|
|
42
|
+
* 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
|
|
31
43
|
*/
|
|
32
|
-
export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?:
|
|
44
|
+
export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>, storageDir?: string): (c: {
|
|
45
|
+
req: {
|
|
46
|
+
param: (key: string) => string;
|
|
47
|
+
};
|
|
48
|
+
json: (data: unknown, status?: number) => Response;
|
|
49
|
+
body: (data: unknown, status: number, headers: Record<string, string>) => Response;
|
|
50
|
+
}) => Promise<Response>;
|
|
33
51
|
export {};
|