clawfire 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -0
- package/dist/admin.cjs +309 -0
- package/dist/admin.cjs.map +1 -0
- package/dist/admin.d.cts +93 -0
- package/dist/admin.d.ts +93 -0
- package/dist/admin.js +274 -0
- package/dist/admin.js.map +1 -0
- package/dist/auth-DQ3cifhb.d.cts +55 -0
- package/dist/auth-DtnUPbXT.d.ts +55 -0
- package/dist/chunk-37Y2XI7X.js +75 -0
- package/dist/chunk-YGIPORYL.js +339 -0
- package/dist/cli.js +241 -0
- package/dist/client.cjs +97 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +4 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +68 -0
- package/dist/client.js.map +1 -0
- package/dist/codegen.cjs +648 -0
- package/dist/codegen.cjs.map +1 -0
- package/dist/codegen.d.cts +25 -0
- package/dist/codegen.d.ts +25 -0
- package/dist/codegen.js +617 -0
- package/dist/codegen.js.map +1 -0
- package/dist/config-QMBJRn9G.d.cts +46 -0
- package/dist/config-QMBJRn9G.d.ts +46 -0
- package/dist/dev-server-QAVWINAT.js +973 -0
- package/dist/dev.cjs +1388 -0
- package/dist/dev.cjs.map +1 -0
- package/dist/dev.d.cts +111 -0
- package/dist/dev.d.ts +111 -0
- package/dist/dev.js +1349 -0
- package/dist/dev.js.map +1 -0
- package/dist/discover-BPMAZFBD.js +9 -0
- package/dist/discover-DYNqz_ym.d.cts +28 -0
- package/dist/discover-DYNqz_ym.d.ts +28 -0
- package/dist/errors-s_mP7rs9.d.cts +33 -0
- package/dist/errors-s_mP7rs9.d.ts +33 -0
- package/dist/functions.cjs +1156 -0
- package/dist/functions.cjs.map +1 -0
- package/dist/functions.d.cts +115 -0
- package/dist/functions.d.ts +115 -0
- package/dist/functions.js +1108 -0
- package/dist/functions.js.map +1 -0
- package/dist/hosting-7WVFHAYJ.js +85 -0
- package/dist/html-PCUCJGBH.js +7 -0
- package/dist/index.cjs +349 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +312 -0
- package/dist/index.js.map +1 -0
- package/dist/playground.cjs +364 -0
- package/dist/playground.cjs.map +1 -0
- package/dist/playground.d.cts +12 -0
- package/dist/playground.d.ts +12 -0
- package/dist/playground.js +337 -0
- package/dist/playground.js.map +1 -0
- package/dist/router-BVB_I-tu.d.ts +65 -0
- package/dist/router-Cikk8Heq.d.cts +65 -0
- package/dist/schema-BJsictSV.d.cts +172 -0
- package/dist/schema-BJsictSV.d.ts +172 -0
- package/package.json +150 -0
- package/templates/CLAUDE.md +71 -0
- package/templates/app/routes/auth/login.ts +35 -0
- package/templates/app/routes/health.ts +20 -0
- package/templates/app/schemas/user.ts +26 -0
- package/templates/clawfire.config.ts +25 -0
- package/templates/functions/index.ts +43 -0
- package/templates/starter/.claude/skills/clawfire-api/SKILL.md +131 -0
- package/templates/starter/.claude/skills/clawfire-auth/SKILL.md +111 -0
- package/templates/starter/.claude/skills/clawfire-deploy/SKILL.md +95 -0
- package/templates/starter/.claude/skills/clawfire-diagnose/SKILL.md +99 -0
- package/templates/starter/.claude/skills/clawfire-model/SKILL.md +128 -0
- package/templates/starter/CLAUDE.md +227 -0
- package/templates/starter/app/routes/health.ts +20 -0
- package/templates/starter/app/routes/todos/create.ts +25 -0
- package/templates/starter/app/routes/todos/delete.ts +20 -0
- package/templates/starter/app/routes/todos/list.ts +26 -0
- package/templates/starter/app/routes/todos/update.ts +32 -0
- package/templates/starter/app/schemas/todo.ts +16 -0
- package/templates/starter/app/store.ts +56 -0
- package/templates/starter/clawfire.config.ts +25 -0
- package/templates/starter/dev.ts +12 -0
- package/templates/starter/package.json +19 -0
- package/templates/starter/public/index.html +365 -0
- package/templates/starter/tsconfig.json +17 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __esm = (fn, res) => function __init() {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
};
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
22
|
+
|
|
23
|
+
// src/core/errors.ts
|
|
24
|
+
var errors_exports = {};
|
|
25
|
+
__export(errors_exports, {
|
|
26
|
+
ClawfireError: () => ClawfireError,
|
|
27
|
+
Errors: () => Errors
|
|
28
|
+
});
|
|
29
|
+
var HTTP_STATUS_MAP, ClawfireError, Errors;
|
|
30
|
+
var init_errors = __esm({
|
|
31
|
+
"src/core/errors.ts"() {
|
|
32
|
+
"use strict";
|
|
33
|
+
HTTP_STATUS_MAP = {
|
|
34
|
+
VALIDATION_ERROR: 400,
|
|
35
|
+
UNAUTHORIZED: 401,
|
|
36
|
+
FORBIDDEN: 403,
|
|
37
|
+
NOT_FOUND: 404,
|
|
38
|
+
CONFLICT: 409,
|
|
39
|
+
RATE_LIMITED: 429,
|
|
40
|
+
REAUTH_REQUIRED: 401,
|
|
41
|
+
INTERNAL_ERROR: 500,
|
|
42
|
+
SERVICE_UNAVAILABLE: 503
|
|
43
|
+
};
|
|
44
|
+
ClawfireError = class extends Error {
|
|
45
|
+
code;
|
|
46
|
+
statusCode;
|
|
47
|
+
details;
|
|
48
|
+
constructor(code, message, details) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "ClawfireError";
|
|
51
|
+
this.code = code;
|
|
52
|
+
this.statusCode = HTTP_STATUS_MAP[code];
|
|
53
|
+
this.details = details;
|
|
54
|
+
}
|
|
55
|
+
toJSON() {
|
|
56
|
+
return {
|
|
57
|
+
error: {
|
|
58
|
+
code: this.code,
|
|
59
|
+
message: this.message,
|
|
60
|
+
...this.details ? { details: this.details } : {}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
Errors = {
|
|
66
|
+
validation: (message, details) => new ClawfireError("VALIDATION_ERROR", message, details),
|
|
67
|
+
unauthorized: (message = "Authentication required") => new ClawfireError("UNAUTHORIZED", message),
|
|
68
|
+
forbidden: (message = "Insufficient permissions") => new ClawfireError("FORBIDDEN", message),
|
|
69
|
+
notFound: (message = "Resource not found") => new ClawfireError("NOT_FOUND", message),
|
|
70
|
+
conflict: (message) => new ClawfireError("CONFLICT", message),
|
|
71
|
+
rateLimited: (message = "Too many requests") => new ClawfireError("RATE_LIMITED", message),
|
|
72
|
+
reauthRequired: (message = "Re-authentication required for this action") => new ClawfireError("REAUTH_REQUIRED", message),
|
|
73
|
+
internal: (message = "Internal server error") => new ClawfireError("INTERNAL_ERROR", message),
|
|
74
|
+
unavailable: (message = "Service temporarily unavailable") => new ClawfireError("SERVICE_UNAVAILABLE", message)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// src/functions.ts
|
|
80
|
+
var functions_exports = {};
|
|
81
|
+
__export(functions_exports, {
|
|
82
|
+
ClawfireError: () => ClawfireError,
|
|
83
|
+
ClawfireRouter: () => ClawfireRouter,
|
|
84
|
+
Errors: () => Errors,
|
|
85
|
+
checkAuthLevel: () => checkAuthLevel,
|
|
86
|
+
corsMiddleware: () => corsMiddleware,
|
|
87
|
+
createAdminDB: () => createAdminDB,
|
|
88
|
+
createRouter: () => createRouter,
|
|
89
|
+
createSecurityMiddleware: () => createSecurityMiddleware,
|
|
90
|
+
defineAPI: () => defineAPI,
|
|
91
|
+
defineModel: () => defineModel,
|
|
92
|
+
discoverRoutes: () => discoverRoutes,
|
|
93
|
+
extractBearerToken: () => extractBearerToken,
|
|
94
|
+
generateDefaultRules: () => generateDefaultRules,
|
|
95
|
+
generateFirebaseJson: () => generateFirebaseJson,
|
|
96
|
+
generateFirestoreIndexes: () => generateFirestoreIndexes,
|
|
97
|
+
generateFirestoreRules: () => generateFirestoreRules,
|
|
98
|
+
generateRouteImports: () => generateRouteImports,
|
|
99
|
+
getUserRole: () => getUserRole,
|
|
100
|
+
inputSizeLimit: () => inputSizeLimit,
|
|
101
|
+
logger: () => logger,
|
|
102
|
+
modelToZodSchema: () => modelToZodSchema,
|
|
103
|
+
requestLogger: () => requestLogger,
|
|
104
|
+
sanitizeInput: () => sanitizeInput,
|
|
105
|
+
setCustomClaims: () => setCustomClaims,
|
|
106
|
+
setUserRole: () => setUserRole,
|
|
107
|
+
verifyReauth: () => verifyReauth,
|
|
108
|
+
verifyToken: () => verifyToken,
|
|
109
|
+
z: () => import_zod2.z,
|
|
110
|
+
zodToJsonSchema: () => zodToJsonSchema
|
|
111
|
+
});
|
|
112
|
+
module.exports = __toCommonJS(functions_exports);
|
|
113
|
+
|
|
114
|
+
// src/core/schema.ts
|
|
115
|
+
var import_zod = require("zod");
|
|
116
|
+
function defineAPI(contract) {
|
|
117
|
+
return contract;
|
|
118
|
+
}
|
|
119
|
+
function defineModel(definition) {
|
|
120
|
+
return {
|
|
121
|
+
timestamps: true,
|
|
122
|
+
...definition
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function zodToJsonSchema(schema) {
|
|
126
|
+
return extractZodShape(schema);
|
|
127
|
+
}
|
|
128
|
+
function extractZodShape(schema) {
|
|
129
|
+
const def = schema._def;
|
|
130
|
+
if (!def) return { type: "unknown" };
|
|
131
|
+
switch (def.typeName) {
|
|
132
|
+
case "ZodObject": {
|
|
133
|
+
const shape = schema.shape;
|
|
134
|
+
const properties = {};
|
|
135
|
+
const required = [];
|
|
136
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
137
|
+
properties[key] = extractZodShape(value);
|
|
138
|
+
if (!value.isOptional?.()) {
|
|
139
|
+
const innerDef = value._def;
|
|
140
|
+
if (innerDef?.typeName !== "ZodOptional" && innerDef?.typeName !== "ZodDefault") {
|
|
141
|
+
required.push(key);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { type: "object", properties, ...required.length > 0 ? { required } : {} };
|
|
146
|
+
}
|
|
147
|
+
case "ZodString":
|
|
148
|
+
return { type: "string", ...def.checks?.length ? extractStringChecks(def.checks) : {} };
|
|
149
|
+
case "ZodNumber":
|
|
150
|
+
return { type: "number" };
|
|
151
|
+
case "ZodBoolean":
|
|
152
|
+
return { type: "boolean" };
|
|
153
|
+
case "ZodArray":
|
|
154
|
+
return { type: "array", items: extractZodShape(def.type) };
|
|
155
|
+
case "ZodEnum":
|
|
156
|
+
return { type: "string", enum: def.values };
|
|
157
|
+
case "ZodOptional":
|
|
158
|
+
return { ...extractZodShape(def.innerType), optional: true };
|
|
159
|
+
case "ZodDefault":
|
|
160
|
+
return { ...extractZodShape(def.innerType), default: def.defaultValue() };
|
|
161
|
+
case "ZodNullable":
|
|
162
|
+
return { ...extractZodShape(def.innerType), nullable: true };
|
|
163
|
+
case "ZodLiteral":
|
|
164
|
+
return { type: typeof def.value, const: def.value };
|
|
165
|
+
case "ZodUnion":
|
|
166
|
+
return { oneOf: def.options.map((o) => extractZodShape(o)) };
|
|
167
|
+
case "ZodRecord":
|
|
168
|
+
return { type: "object", additionalProperties: extractZodShape(def.valueType) };
|
|
169
|
+
case "ZodDate":
|
|
170
|
+
return { type: "string", format: "date-time" };
|
|
171
|
+
default:
|
|
172
|
+
return { type: "unknown" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function extractStringChecks(checks) {
|
|
176
|
+
const result = {};
|
|
177
|
+
for (const check of checks) {
|
|
178
|
+
switch (check.kind) {
|
|
179
|
+
case "min":
|
|
180
|
+
result.minLength = check.value;
|
|
181
|
+
break;
|
|
182
|
+
case "max":
|
|
183
|
+
result.maxLength = check.value;
|
|
184
|
+
break;
|
|
185
|
+
case "email":
|
|
186
|
+
result.format = "email";
|
|
187
|
+
break;
|
|
188
|
+
case "url":
|
|
189
|
+
result.format = "uri";
|
|
190
|
+
break;
|
|
191
|
+
case "uuid":
|
|
192
|
+
result.format = "uuid";
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
function modelToZodSchema(model) {
|
|
199
|
+
const shape = {};
|
|
200
|
+
for (const [key, field] of Object.entries(model.fields)) {
|
|
201
|
+
let fieldSchema = fieldToZod(field);
|
|
202
|
+
if (!field.required) {
|
|
203
|
+
fieldSchema = fieldSchema.optional();
|
|
204
|
+
}
|
|
205
|
+
shape[key] = fieldSchema;
|
|
206
|
+
}
|
|
207
|
+
if (model.timestamps) {
|
|
208
|
+
shape.createdAt = import_zod.z.string().datetime().optional();
|
|
209
|
+
shape.updatedAt = import_zod.z.string().datetime().optional();
|
|
210
|
+
}
|
|
211
|
+
if (model.softDelete) {
|
|
212
|
+
shape.deletedAt = import_zod.z.string().datetime().nullable().optional();
|
|
213
|
+
}
|
|
214
|
+
return import_zod.z.object(shape);
|
|
215
|
+
}
|
|
216
|
+
function fieldToZod(field) {
|
|
217
|
+
switch (field.type) {
|
|
218
|
+
case "string":
|
|
219
|
+
if (field.enum) return import_zod.z.enum(field.enum);
|
|
220
|
+
return import_zod.z.string();
|
|
221
|
+
case "number":
|
|
222
|
+
return import_zod.z.number();
|
|
223
|
+
case "boolean":
|
|
224
|
+
return import_zod.z.boolean();
|
|
225
|
+
case "timestamp":
|
|
226
|
+
return import_zod.z.string().datetime();
|
|
227
|
+
case "array":
|
|
228
|
+
if (field.items) return import_zod.z.array(fieldToZod(field.items));
|
|
229
|
+
return import_zod.z.array(import_zod.z.unknown());
|
|
230
|
+
case "map":
|
|
231
|
+
if (field.values) return import_zod.z.record(import_zod.z.string(), fieldToZod(field.values));
|
|
232
|
+
return import_zod.z.record(import_zod.z.string(), import_zod.z.unknown());
|
|
233
|
+
case "reference":
|
|
234
|
+
return import_zod.z.string();
|
|
235
|
+
// 참조는 문서 경로 문자열
|
|
236
|
+
case "geopoint":
|
|
237
|
+
return import_zod.z.object({ latitude: import_zod.z.number(), longitude: import_zod.z.number() });
|
|
238
|
+
default:
|
|
239
|
+
return import_zod.z.unknown();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function contractToManifest(path, contract) {
|
|
243
|
+
return {
|
|
244
|
+
path,
|
|
245
|
+
method: "POST",
|
|
246
|
+
meta: contract.meta,
|
|
247
|
+
inputSchema: zodToJsonSchema(contract.input),
|
|
248
|
+
outputSchema: zodToJsonSchema(contract.output)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/routing/router.ts
|
|
253
|
+
init_errors();
|
|
254
|
+
|
|
255
|
+
// src/core/logger.ts
|
|
256
|
+
var SENSITIVE_FIELDS = [
|
|
257
|
+
"password",
|
|
258
|
+
"token",
|
|
259
|
+
"secret",
|
|
260
|
+
"apiKey",
|
|
261
|
+
"api_key",
|
|
262
|
+
"authorization",
|
|
263
|
+
"credit_card",
|
|
264
|
+
"creditCard",
|
|
265
|
+
"ssn",
|
|
266
|
+
"cardNumber",
|
|
267
|
+
"card_number",
|
|
268
|
+
"cvv",
|
|
269
|
+
"pin"
|
|
270
|
+
];
|
|
271
|
+
var LOG_LEVELS = {
|
|
272
|
+
debug: 0,
|
|
273
|
+
info: 1,
|
|
274
|
+
warn: 2,
|
|
275
|
+
error: 3
|
|
276
|
+
};
|
|
277
|
+
var currentLevel = "info";
|
|
278
|
+
function shouldLog(level) {
|
|
279
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
280
|
+
}
|
|
281
|
+
function maskSensitive(obj) {
|
|
282
|
+
if (obj === null || obj === void 0) return obj;
|
|
283
|
+
if (typeof obj === "string") return obj;
|
|
284
|
+
if (typeof obj !== "object") return obj;
|
|
285
|
+
if (Array.isArray(obj)) {
|
|
286
|
+
return obj.map(maskSensitive);
|
|
287
|
+
}
|
|
288
|
+
const masked = {};
|
|
289
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
290
|
+
if (SENSITIVE_FIELDS.some((f) => key.toLowerCase().includes(f.toLowerCase()))) {
|
|
291
|
+
masked[key] = "***MASKED***";
|
|
292
|
+
} else if (typeof value === "object" && value !== null) {
|
|
293
|
+
masked[key] = maskSensitive(value);
|
|
294
|
+
} else {
|
|
295
|
+
masked[key] = value;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return masked;
|
|
299
|
+
}
|
|
300
|
+
function formatLog(level, message, data) {
|
|
301
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
302
|
+
const prefix = `[${timestamp}] [CLAWFIRE] [${level.toUpperCase()}]`;
|
|
303
|
+
if (data !== void 0) {
|
|
304
|
+
return `${prefix} ${message} ${JSON.stringify(maskSensitive(data))}`;
|
|
305
|
+
}
|
|
306
|
+
return `${prefix} ${message}`;
|
|
307
|
+
}
|
|
308
|
+
var logger = {
|
|
309
|
+
debug(message, data) {
|
|
310
|
+
if (shouldLog("debug")) console.debug(formatLog("debug", message, data));
|
|
311
|
+
},
|
|
312
|
+
info(message, data) {
|
|
313
|
+
if (shouldLog("info")) console.info(formatLog("info", message, data));
|
|
314
|
+
},
|
|
315
|
+
warn(message, data) {
|
|
316
|
+
if (shouldLog("warn")) console.warn(formatLog("warn", message, data));
|
|
317
|
+
},
|
|
318
|
+
error(message, data) {
|
|
319
|
+
if (shouldLog("error")) console.error(formatLog("error", message, data));
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// src/firebase/auth.ts
|
|
324
|
+
init_errors();
|
|
325
|
+
async function verifyToken(auth, idToken) {
|
|
326
|
+
const decoded = await auth.verifyIdToken(idToken);
|
|
327
|
+
return {
|
|
328
|
+
uid: decoded.uid,
|
|
329
|
+
email: decoded.email,
|
|
330
|
+
emailVerified: decoded.email_verified,
|
|
331
|
+
role: decoded.role || decoded.customClaims?.role,
|
|
332
|
+
customClaims: decoded,
|
|
333
|
+
token: idToken
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
async function verifyReauth(auth, idToken, maxAgeSeconds = 300) {
|
|
337
|
+
const decoded = await auth.verifyIdToken(idToken, true);
|
|
338
|
+
const authTime = decoded.auth_time * 1e3;
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
if (now - authTime > maxAgeSeconds * 1e3) {
|
|
341
|
+
throw Errors.reauthRequired(
|
|
342
|
+
`Re-authentication required. Last auth was ${Math.floor((now - authTime) / 1e3)}s ago.`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
uid: decoded.uid,
|
|
347
|
+
email: decoded.email,
|
|
348
|
+
emailVerified: decoded.email_verified,
|
|
349
|
+
role: decoded.role || decoded.customClaims?.role,
|
|
350
|
+
customClaims: decoded,
|
|
351
|
+
token: idToken
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function extractBearerToken(authHeader) {
|
|
355
|
+
if (!authHeader) return null;
|
|
356
|
+
const parts = authHeader.split(" ");
|
|
357
|
+
if (parts.length !== 2 || parts[0] !== "Bearer") return null;
|
|
358
|
+
return parts[1];
|
|
359
|
+
}
|
|
360
|
+
function checkAuthLevel(authCtx, level, roles, reauthenticated) {
|
|
361
|
+
switch (level) {
|
|
362
|
+
case "public":
|
|
363
|
+
return;
|
|
364
|
+
// 누구나 접근 가능
|
|
365
|
+
case "authenticated":
|
|
366
|
+
if (!authCtx) throw Errors.unauthorized();
|
|
367
|
+
return;
|
|
368
|
+
case "role":
|
|
369
|
+
if (!authCtx) throw Errors.unauthorized();
|
|
370
|
+
if (!roles || roles.length === 0) return;
|
|
371
|
+
if (!authCtx.role || !roles.includes(authCtx.role)) {
|
|
372
|
+
throw Errors.forbidden(`Required role: ${roles.join(" or ")}`);
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
case "reauth":
|
|
376
|
+
if (!authCtx) throw Errors.unauthorized();
|
|
377
|
+
if (!reauthenticated) {
|
|
378
|
+
throw Errors.reauthRequired();
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async function setUserRole(auth, uid, role) {
|
|
384
|
+
const user = await auth.getUser(uid);
|
|
385
|
+
const currentClaims = user.customClaims || {};
|
|
386
|
+
await auth.setCustomUserClaims(uid, { ...currentClaims, role });
|
|
387
|
+
}
|
|
388
|
+
async function getUserRole(auth, uid) {
|
|
389
|
+
const user = await auth.getUser(uid);
|
|
390
|
+
return user.customClaims?.role;
|
|
391
|
+
}
|
|
392
|
+
async function setCustomClaims(auth, uid, claims) {
|
|
393
|
+
const user = await auth.getUser(uid);
|
|
394
|
+
const currentClaims = user.customClaims || {};
|
|
395
|
+
await auth.setCustomUserClaims(uid, { ...currentClaims, ...claims });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/routing/router.ts
|
|
399
|
+
var RateLimiter = class {
|
|
400
|
+
store = /* @__PURE__ */ new Map();
|
|
401
|
+
defaultLimit;
|
|
402
|
+
constructor(defaultLimit) {
|
|
403
|
+
this.defaultLimit = defaultLimit;
|
|
404
|
+
}
|
|
405
|
+
check(key, limit) {
|
|
406
|
+
const max = limit || this.defaultLimit;
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
const entry = this.store.get(key);
|
|
409
|
+
if (!entry || now > entry.resetAt) {
|
|
410
|
+
this.store.set(key, { count: 1, resetAt: now + 6e4 });
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
if (entry.count >= max) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
entry.count++;
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
// 오래된 엔트리 정리
|
|
420
|
+
cleanup() {
|
|
421
|
+
const now = Date.now();
|
|
422
|
+
for (const [key, entry] of this.store) {
|
|
423
|
+
if (now > entry.resetAt) this.store.delete(key);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
var ClawfireRouter = class {
|
|
428
|
+
routes = /* @__PURE__ */ new Map();
|
|
429
|
+
options;
|
|
430
|
+
rateLimiter;
|
|
431
|
+
cleanupInterval;
|
|
432
|
+
constructor(options = {}) {
|
|
433
|
+
this.options = options;
|
|
434
|
+
this.rateLimiter = new RateLimiter(options.rateLimit || 100);
|
|
435
|
+
this.cleanupInterval = setInterval(() => this.rateLimiter.cleanup(), 6e4);
|
|
436
|
+
}
|
|
437
|
+
/** 라우트 등록 */
|
|
438
|
+
register(path, contract) {
|
|
439
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
440
|
+
this.routes.set(normalizedPath, { path: normalizedPath, contract });
|
|
441
|
+
logger.debug(`Route registered: ${normalizedPath}`);
|
|
442
|
+
return this;
|
|
443
|
+
}
|
|
444
|
+
/** 여러 라우트 한 번에 등록 */
|
|
445
|
+
registerAll(routes) {
|
|
446
|
+
for (const [path, contract] of Object.entries(routes)) {
|
|
447
|
+
this.register(path, contract);
|
|
448
|
+
}
|
|
449
|
+
return this;
|
|
450
|
+
}
|
|
451
|
+
/** 매니페스트 생성 */
|
|
452
|
+
getManifest() {
|
|
453
|
+
const apis = [];
|
|
454
|
+
for (const [path, route] of this.routes) {
|
|
455
|
+
apis.push(contractToManifest(path, route.contract));
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
version: "1.0.0",
|
|
459
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
460
|
+
apis,
|
|
461
|
+
models: {}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/** HTTP 요청 핸들러 (Firebase Functions에서 사용) */
|
|
465
|
+
async handleRequest(req, res) {
|
|
466
|
+
const corsOrigins = this.options.cors || [];
|
|
467
|
+
const origin = req.headers?.origin || req.headers?.Origin || "";
|
|
468
|
+
if (corsOrigins.length > 0 && corsOrigins.includes(origin)) {
|
|
469
|
+
res.set({
|
|
470
|
+
"Access-Control-Allow-Origin": origin,
|
|
471
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
472
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
473
|
+
"Access-Control-Max-Age": "86400"
|
|
474
|
+
});
|
|
475
|
+
} else if (corsOrigins.length === 0) {
|
|
476
|
+
res.set({
|
|
477
|
+
"Access-Control-Allow-Origin": "",
|
|
478
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
479
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (req.method === "OPTIONS") {
|
|
483
|
+
res.status(204).end();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
res.set({
|
|
487
|
+
"X-Content-Type-Options": "nosniff",
|
|
488
|
+
"X-Frame-Options": "DENY",
|
|
489
|
+
"X-XSS-Protection": "1; mode=block",
|
|
490
|
+
"Content-Type": "application/json"
|
|
491
|
+
});
|
|
492
|
+
let routePath = req.path || req.url || "";
|
|
493
|
+
if (routePath.startsWith("/api")) {
|
|
494
|
+
routePath = routePath.slice(4);
|
|
495
|
+
}
|
|
496
|
+
if (routePath === "/__manifest") {
|
|
497
|
+
res.status(200).json(this.getManifest());
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (req.method && req.method !== "POST") {
|
|
501
|
+
res.status(405).json({ error: { code: "METHOD_NOT_ALLOWED", message: "Only POST is allowed" } });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const route = this.matchRoute(routePath);
|
|
505
|
+
if (!route) {
|
|
506
|
+
res.status(404).json({ error: { code: "NOT_FOUND", message: `API not found: ${routePath}` } });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
const clientKey = req.ip || "unknown";
|
|
511
|
+
const rateLimit = route.contract.meta.rateLimit || this.options.rateLimit;
|
|
512
|
+
if (rateLimit && !this.rateLimiter.check(clientKey, rateLimit)) {
|
|
513
|
+
throw Errors.rateLimited();
|
|
514
|
+
}
|
|
515
|
+
let authCtx = null;
|
|
516
|
+
let reauthenticated = false;
|
|
517
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
518
|
+
if (authHeader && this.options.auth) {
|
|
519
|
+
const token = extractBearerToken(authHeader);
|
|
520
|
+
if (token) {
|
|
521
|
+
if (route.contract.meta.reauth || route.contract.meta.auth === "reauth") {
|
|
522
|
+
authCtx = await verifyReauth(this.options.auth, token);
|
|
523
|
+
reauthenticated = true;
|
|
524
|
+
} else {
|
|
525
|
+
authCtx = await verifyToken(this.options.auth, token);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (route.contract.meta.auth) {
|
|
530
|
+
checkAuthLevel(authCtx, route.contract.meta.auth, route.contract.meta.roles, reauthenticated);
|
|
531
|
+
}
|
|
532
|
+
const rawInput = req.body || {};
|
|
533
|
+
const parsed = route.contract.input.safeParse(rawInput);
|
|
534
|
+
if (!parsed.success) {
|
|
535
|
+
throw Errors.validation("Invalid input", parsed.error.flatten());
|
|
536
|
+
}
|
|
537
|
+
const ctx = {
|
|
538
|
+
auth: authCtx,
|
|
539
|
+
reauthenticated,
|
|
540
|
+
headers: req.headers,
|
|
541
|
+
ip: req.ip
|
|
542
|
+
};
|
|
543
|
+
let result;
|
|
544
|
+
if (this.options.middleware && this.options.middleware.length > 0) {
|
|
545
|
+
result = await this.runMiddleware(
|
|
546
|
+
this.options.middleware,
|
|
547
|
+
parsed.data,
|
|
548
|
+
ctx,
|
|
549
|
+
() => route.contract.handler(parsed.data, ctx)
|
|
550
|
+
);
|
|
551
|
+
} else {
|
|
552
|
+
result = await route.contract.handler(parsed.data, ctx);
|
|
553
|
+
}
|
|
554
|
+
res.status(200).json({ data: result });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (err instanceof ClawfireError) {
|
|
557
|
+
logger.warn(`API error [${err.code}]: ${err.message}`);
|
|
558
|
+
res.status(err.statusCode).json(err.toJSON());
|
|
559
|
+
} else {
|
|
560
|
+
logger.error("Unhandled error", err);
|
|
561
|
+
res.status(500).json({
|
|
562
|
+
error: {
|
|
563
|
+
code: "INTERNAL_ERROR",
|
|
564
|
+
message: "An unexpected error occurred"
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** 라우트 매칭 (동적 파라미터 지원) */
|
|
571
|
+
matchRoute(path) {
|
|
572
|
+
const exact = this.routes.get(path);
|
|
573
|
+
if (exact) return exact;
|
|
574
|
+
for (const [routePath, route] of this.routes) {
|
|
575
|
+
if (this.matchDynamicRoute(routePath, path)) {
|
|
576
|
+
return route;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return void 0;
|
|
580
|
+
}
|
|
581
|
+
matchDynamicRoute(pattern, actual) {
|
|
582
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
583
|
+
const actualParts = actual.split("/").filter(Boolean);
|
|
584
|
+
if (patternParts.length !== actualParts.length) return false;
|
|
585
|
+
return patternParts.every(
|
|
586
|
+
(part, i) => part.startsWith(":") || part.startsWith("[") || part === actualParts[i]
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
/** 미들웨어 체인 실행 */
|
|
590
|
+
async runMiddleware(middlewares, input, ctx, handler) {
|
|
591
|
+
let index = 0;
|
|
592
|
+
const next = async () => {
|
|
593
|
+
if (index >= middlewares.length) {
|
|
594
|
+
return handler();
|
|
595
|
+
}
|
|
596
|
+
const mw = middlewares[index++];
|
|
597
|
+
return mw(input, ctx, next);
|
|
598
|
+
};
|
|
599
|
+
return next();
|
|
600
|
+
}
|
|
601
|
+
/** 등록된 라우트 목록 */
|
|
602
|
+
getRoutes() {
|
|
603
|
+
return Array.from(this.routes.values());
|
|
604
|
+
}
|
|
605
|
+
/** 리소스 정리 */
|
|
606
|
+
destroy() {
|
|
607
|
+
if (this.cleanupInterval) {
|
|
608
|
+
clearInterval(this.cleanupInterval);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
function createRouter(options) {
|
|
613
|
+
return new ClawfireRouter(options);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/routing/discover.ts
|
|
617
|
+
var import_path = require("path");
|
|
618
|
+
var import_fs = require("fs");
|
|
619
|
+
function discoverRoutes(routesDir) {
|
|
620
|
+
if (!(0, import_fs.existsSync)(routesDir)) {
|
|
621
|
+
return [];
|
|
622
|
+
}
|
|
623
|
+
const routes = [];
|
|
624
|
+
scanDirectory(routesDir, routesDir, routes);
|
|
625
|
+
return routes.sort((a, b) => a.apiPath.localeCompare(b.apiPath));
|
|
626
|
+
}
|
|
627
|
+
function scanDirectory(baseDir, currentDir, routes) {
|
|
628
|
+
const entries = (0, import_fs.readdirSync)(currentDir);
|
|
629
|
+
for (const entry of entries) {
|
|
630
|
+
const fullPath = (0, import_path.join)(currentDir, entry);
|
|
631
|
+
const stat = (0, import_fs.statSync)(fullPath);
|
|
632
|
+
if (stat.isDirectory()) {
|
|
633
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
634
|
+
scanDirectory(baseDir, fullPath, routes);
|
|
635
|
+
} else if (stat.isFile()) {
|
|
636
|
+
if (!entry.endsWith(".ts") && !entry.endsWith(".js")) continue;
|
|
637
|
+
if (entry.startsWith("_")) continue;
|
|
638
|
+
if (entry.endsWith(".d.ts")) continue;
|
|
639
|
+
const relativePath = (0, import_path.relative)(baseDir, fullPath);
|
|
640
|
+
const route = filePathToRoute(relativePath);
|
|
641
|
+
routes.push(route);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function filePathToRoute(filePath) {
|
|
646
|
+
const params = [];
|
|
647
|
+
let routePath = filePath.replace(/\.(ts|js)$/, "");
|
|
648
|
+
routePath = routePath.replace(/\\/g, "/");
|
|
649
|
+
if (routePath.endsWith("/index") || routePath === "index") {
|
|
650
|
+
routePath = routePath.replace(/\/?index$/, "");
|
|
651
|
+
}
|
|
652
|
+
routePath = routePath.replace(/\[([^\]]+)\]/g, (_, param) => {
|
|
653
|
+
params.push(param);
|
|
654
|
+
return `:${param}`;
|
|
655
|
+
});
|
|
656
|
+
const apiPath = `/${routePath}`;
|
|
657
|
+
return {
|
|
658
|
+
filePath,
|
|
659
|
+
apiPath,
|
|
660
|
+
params
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function generateRouteImports(routes, routesDir) {
|
|
664
|
+
const lines = [
|
|
665
|
+
"// AUTO-GENERATED by Clawfire \u2014 DO NOT EDIT",
|
|
666
|
+
"// This file is regenerated whenever routes change.",
|
|
667
|
+
"",
|
|
668
|
+
'import { createRouter } from "clawfire/functions";',
|
|
669
|
+
""
|
|
670
|
+
];
|
|
671
|
+
routes.forEach((route, i) => {
|
|
672
|
+
const importPath = `./${route.filePath.replace(/\.(ts|js)$/, ".js")}`;
|
|
673
|
+
lines.push(`import route_${i} from "${importPath}";`);
|
|
674
|
+
});
|
|
675
|
+
lines.push("");
|
|
676
|
+
lines.push("export function registerAllRoutes(router: ReturnType<typeof createRouter>) {");
|
|
677
|
+
routes.forEach((route, i) => {
|
|
678
|
+
lines.push(` router.register("${route.apiPath}", route_${i});`);
|
|
679
|
+
});
|
|
680
|
+
lines.push(" return router;");
|
|
681
|
+
lines.push("}");
|
|
682
|
+
return lines.join("\n");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/firebase/firestore.ts
|
|
686
|
+
function createAdminDB(firestore) {
|
|
687
|
+
return {
|
|
688
|
+
/** 문서 조회 */
|
|
689
|
+
async get(collection, id) {
|
|
690
|
+
const doc = await firestore.collection(collection).doc(id).get();
|
|
691
|
+
if (!doc.exists) return null;
|
|
692
|
+
return { id: doc.id, ...doc.data() };
|
|
693
|
+
},
|
|
694
|
+
/** 문서 생성 (ID 자동 생성) */
|
|
695
|
+
async create(collection, data) {
|
|
696
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
697
|
+
const ref = await firestore.collection(collection).add({
|
|
698
|
+
...data,
|
|
699
|
+
createdAt: now,
|
|
700
|
+
updatedAt: now
|
|
701
|
+
});
|
|
702
|
+
return ref.id;
|
|
703
|
+
},
|
|
704
|
+
/** 문서 생성 (ID 지정) */
|
|
705
|
+
async set(collection, id, data, merge = false) {
|
|
706
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
707
|
+
await firestore.collection(collection).doc(id).set(
|
|
708
|
+
{
|
|
709
|
+
...data,
|
|
710
|
+
...merge ? { updatedAt: now } : { createdAt: now, updatedAt: now }
|
|
711
|
+
},
|
|
712
|
+
{ merge }
|
|
713
|
+
);
|
|
714
|
+
},
|
|
715
|
+
/** 문서 부분 업데이트 */
|
|
716
|
+
async update(collection, id, data) {
|
|
717
|
+
await firestore.collection(collection).doc(id).update({
|
|
718
|
+
...data,
|
|
719
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
720
|
+
});
|
|
721
|
+
},
|
|
722
|
+
/** 문서 삭제 */
|
|
723
|
+
async delete(collection, id) {
|
|
724
|
+
await firestore.collection(collection).doc(id).delete();
|
|
725
|
+
},
|
|
726
|
+
/** 소프트 삭제 */
|
|
727
|
+
async softDelete(collection, id) {
|
|
728
|
+
await firestore.collection(collection).doc(id).update({
|
|
729
|
+
deletedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
730
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
731
|
+
});
|
|
732
|
+
},
|
|
733
|
+
/** 쿼리 */
|
|
734
|
+
async query(collection, options = {}) {
|
|
735
|
+
let ref = firestore.collection(collection);
|
|
736
|
+
if (options.filters) {
|
|
737
|
+
for (const filter of options.filters) {
|
|
738
|
+
ref = ref.where(filter.field, filter.op, filter.value);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (options.orderBy) {
|
|
742
|
+
for (const order of options.orderBy) {
|
|
743
|
+
ref = ref.orderBy(order.field, order.direction || "asc");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (options.startAfter) {
|
|
747
|
+
ref = ref.startAfter(options.startAfter);
|
|
748
|
+
}
|
|
749
|
+
const limit = options.limit || 20;
|
|
750
|
+
ref = ref.limit(limit + 1);
|
|
751
|
+
const snapshot = await ref.get();
|
|
752
|
+
const docs = snapshot.docs.map((doc) => ({
|
|
753
|
+
id: doc.id,
|
|
754
|
+
...doc.data()
|
|
755
|
+
}));
|
|
756
|
+
const hasMore = docs.length > limit;
|
|
757
|
+
if (hasMore) docs.pop();
|
|
758
|
+
return {
|
|
759
|
+
data: docs,
|
|
760
|
+
hasMore,
|
|
761
|
+
lastDoc: docs.length > 0 ? snapshot.docs[docs.length - 1] : void 0
|
|
762
|
+
};
|
|
763
|
+
},
|
|
764
|
+
/** 문서 수 */
|
|
765
|
+
async count(collection, filters) {
|
|
766
|
+
let ref = firestore.collection(collection);
|
|
767
|
+
if (filters) {
|
|
768
|
+
for (const filter of filters) {
|
|
769
|
+
ref = ref.where(filter.field, filter.op, filter.value);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const snapshot = await ref.count().get();
|
|
773
|
+
return snapshot.data().count;
|
|
774
|
+
},
|
|
775
|
+
/** 배치 작업 */
|
|
776
|
+
async batch(operations) {
|
|
777
|
+
const batch = firestore.batch();
|
|
778
|
+
const ids = [];
|
|
779
|
+
for (const op of operations) {
|
|
780
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
781
|
+
switch (op.type) {
|
|
782
|
+
case "create": {
|
|
783
|
+
const ref = firestore.collection(op.collection).doc();
|
|
784
|
+
batch.set(ref, { ...op.data, createdAt: now, updatedAt: now });
|
|
785
|
+
ids.push(ref.id);
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
case "set": {
|
|
789
|
+
const ref = firestore.collection(op.collection).doc(op.id);
|
|
790
|
+
batch.set(ref, { ...op.data, createdAt: now, updatedAt: now });
|
|
791
|
+
ids.push(op.id);
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
case "update": {
|
|
795
|
+
const ref = firestore.collection(op.collection).doc(op.id);
|
|
796
|
+
batch.update(ref, { ...op.data, updatedAt: now });
|
|
797
|
+
ids.push(op.id);
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
case "delete": {
|
|
801
|
+
const ref = firestore.collection(op.collection).doc(op.id);
|
|
802
|
+
batch.delete(ref);
|
|
803
|
+
ids.push(op.id);
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
await batch.commit();
|
|
809
|
+
return ids;
|
|
810
|
+
},
|
|
811
|
+
/** 트랜잭션 */
|
|
812
|
+
async transaction(fn) {
|
|
813
|
+
return firestore.runTransaction(async (transaction) => {
|
|
814
|
+
return fn({
|
|
815
|
+
async get(collection, id) {
|
|
816
|
+
const doc = await transaction.get(firestore.collection(collection).doc(id));
|
|
817
|
+
if (!doc.exists) return null;
|
|
818
|
+
return { id: doc.id, ...doc.data() };
|
|
819
|
+
},
|
|
820
|
+
set(collection, id, data) {
|
|
821
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
822
|
+
transaction.set(firestore.collection(collection).doc(id), {
|
|
823
|
+
...data,
|
|
824
|
+
createdAt: now,
|
|
825
|
+
updatedAt: now
|
|
826
|
+
});
|
|
827
|
+
},
|
|
828
|
+
update(collection, id, data) {
|
|
829
|
+
transaction.update(firestore.collection(collection).doc(id), {
|
|
830
|
+
...data,
|
|
831
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
832
|
+
});
|
|
833
|
+
},
|
|
834
|
+
delete(collection, id) {
|
|
835
|
+
transaction.delete(firestore.collection(collection).doc(id));
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
},
|
|
840
|
+
/** 원시 Firestore 접근 */
|
|
841
|
+
raw: firestore
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/firebase/rules.ts
|
|
846
|
+
function generateFirestoreRules(models) {
|
|
847
|
+
const rules = [];
|
|
848
|
+
rules.push("rules_version = '2';");
|
|
849
|
+
rules.push("service cloud.firestore {");
|
|
850
|
+
rules.push(" match /databases/{database}/documents {");
|
|
851
|
+
rules.push("");
|
|
852
|
+
rules.push(" // \u2500\u2500 Helper Functions \u2500\u2500");
|
|
853
|
+
rules.push(" function isAuthenticated() {");
|
|
854
|
+
rules.push(" return request.auth != null;");
|
|
855
|
+
rules.push(" }");
|
|
856
|
+
rules.push("");
|
|
857
|
+
rules.push(" function isOwner(userId) {");
|
|
858
|
+
rules.push(" return isAuthenticated() && request.auth.uid == userId;");
|
|
859
|
+
rules.push(" }");
|
|
860
|
+
rules.push("");
|
|
861
|
+
rules.push(" function hasRole(role) {");
|
|
862
|
+
rules.push(" return isAuthenticated() && request.auth.token.role == role;");
|
|
863
|
+
rules.push(" }");
|
|
864
|
+
rules.push("");
|
|
865
|
+
rules.push(" function hasAnyRole(roles) {");
|
|
866
|
+
rules.push(" return isAuthenticated() && request.auth.token.role in roles;");
|
|
867
|
+
rules.push(" }");
|
|
868
|
+
rules.push("");
|
|
869
|
+
rules.push(" function isRecentAuth() {");
|
|
870
|
+
rules.push(" return request.auth.token.auth_time > (request.time - duration.value(5, 'm'));");
|
|
871
|
+
rules.push(" }");
|
|
872
|
+
rules.push("");
|
|
873
|
+
for (const [name, model] of Object.entries(models)) {
|
|
874
|
+
rules.push(` // \u2500\u2500 ${name} \u2500\u2500`);
|
|
875
|
+
rules.push(` match /${model.collection}/{docId} {`);
|
|
876
|
+
const modelRules = model.rules || { read: "authenticated", create: "authenticated", update: "authenticated", delete: "authenticated" };
|
|
877
|
+
rules.push(` allow read: if ${buildRuleCondition(modelRules, "read", model)};`);
|
|
878
|
+
rules.push(` allow create: if ${buildRuleCondition(modelRules, "create", model)};`);
|
|
879
|
+
rules.push(` allow update: if ${buildRuleCondition(modelRules, "update", model)};`);
|
|
880
|
+
rules.push(` allow delete: if ${buildRuleCondition(modelRules, "delete", model)};`);
|
|
881
|
+
if (model.subcollections) {
|
|
882
|
+
for (const [subName, subModel] of Object.entries(model.subcollections)) {
|
|
883
|
+
rules.push("");
|
|
884
|
+
rules.push(` // ${subName}`);
|
|
885
|
+
rules.push(` match /${subModel.collection}/{subDocId} {`);
|
|
886
|
+
const subRules = subModel.rules || { read: "authenticated", create: "authenticated", update: "authenticated", delete: "authenticated" };
|
|
887
|
+
rules.push(` allow read: if ${buildRuleCondition(subRules, "read", subModel)};`);
|
|
888
|
+
rules.push(` allow create: if ${buildRuleCondition(subRules, "create", subModel)};`);
|
|
889
|
+
rules.push(` allow update: if ${buildRuleCondition(subRules, "update", subModel)};`);
|
|
890
|
+
rules.push(` allow delete: if ${buildRuleCondition(subRules, "delete", subModel)};`);
|
|
891
|
+
rules.push(" }");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
rules.push(" }");
|
|
895
|
+
rules.push("");
|
|
896
|
+
}
|
|
897
|
+
rules.push(" }");
|
|
898
|
+
rules.push("}");
|
|
899
|
+
return rules.join("\n");
|
|
900
|
+
}
|
|
901
|
+
function buildRuleCondition(rules, operation, model) {
|
|
902
|
+
const level = rules[operation] || "authenticated";
|
|
903
|
+
const roles = rules[`${operation}Roles`];
|
|
904
|
+
const ownerField = rules.ownerField;
|
|
905
|
+
const conditions = [];
|
|
906
|
+
switch (level) {
|
|
907
|
+
case "public":
|
|
908
|
+
return "true";
|
|
909
|
+
case "authenticated":
|
|
910
|
+
conditions.push("isAuthenticated()");
|
|
911
|
+
break;
|
|
912
|
+
case "role":
|
|
913
|
+
if (roles && roles.length > 0) {
|
|
914
|
+
if (roles.length === 1) {
|
|
915
|
+
conditions.push(`hasRole('${roles[0]}')`);
|
|
916
|
+
} else {
|
|
917
|
+
conditions.push(`hasAnyRole(${JSON.stringify(roles)})`);
|
|
918
|
+
}
|
|
919
|
+
} else {
|
|
920
|
+
conditions.push("isAuthenticated()");
|
|
921
|
+
}
|
|
922
|
+
break;
|
|
923
|
+
case "reauth":
|
|
924
|
+
conditions.push("isAuthenticated()");
|
|
925
|
+
conditions.push("isRecentAuth()");
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
if (ownerField && (operation === "read" || operation === "update" || operation === "delete")) {
|
|
929
|
+
const ownerCondition = operation === "read" ? `resource.data.${ownerField} == request.auth.uid` : `resource.data.${ownerField} == request.auth.uid`;
|
|
930
|
+
if (conditions.length > 0) {
|
|
931
|
+
return `(${conditions.join(" && ")}) || (isAuthenticated() && ${ownerCondition})`;
|
|
932
|
+
}
|
|
933
|
+
return `isAuthenticated() && ${ownerCondition}`;
|
|
934
|
+
}
|
|
935
|
+
if (ownerField && operation === "create") {
|
|
936
|
+
conditions.push(`request.resource.data.${ownerField} == request.auth.uid`);
|
|
937
|
+
}
|
|
938
|
+
return conditions.join(" && ") || "false";
|
|
939
|
+
}
|
|
940
|
+
function generateFirestoreIndexes(models) {
|
|
941
|
+
const indexes = [];
|
|
942
|
+
for (const model of Object.values(models)) {
|
|
943
|
+
if (model.indexes) {
|
|
944
|
+
for (const index of model.indexes) {
|
|
945
|
+
indexes.push({
|
|
946
|
+
collectionGroup: model.collection,
|
|
947
|
+
queryScope: "COLLECTION",
|
|
948
|
+
fields: index.fields.map((f) => ({
|
|
949
|
+
fieldPath: f.field,
|
|
950
|
+
order: (f.order || "asc").toUpperCase()
|
|
951
|
+
}))
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return { indexes };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/firebase/hosting.ts
|
|
960
|
+
function generateFirebaseJson(config) {
|
|
961
|
+
return {
|
|
962
|
+
hosting: {
|
|
963
|
+
public: "public",
|
|
964
|
+
ignore: ["firebase.json", "**/.*", "**/node_modules/**"],
|
|
965
|
+
rewrites: [
|
|
966
|
+
{
|
|
967
|
+
source: "/api/**",
|
|
968
|
+
function: "api"
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
source: "**",
|
|
972
|
+
destination: "/index.html"
|
|
973
|
+
}
|
|
974
|
+
],
|
|
975
|
+
headers: [
|
|
976
|
+
{
|
|
977
|
+
source: "/api/**",
|
|
978
|
+
headers: [
|
|
979
|
+
{ key: "Cache-Control", value: "no-store" },
|
|
980
|
+
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
981
|
+
{ key: "X-Frame-Options", value: "DENY" },
|
|
982
|
+
{ key: "X-XSS-Protection", value: "1; mode=block" },
|
|
983
|
+
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }
|
|
984
|
+
]
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
source: "**/*.@(js|css|svg|png|jpg|jpeg|gif|ico|webp|woff|woff2)",
|
|
988
|
+
headers: [
|
|
989
|
+
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }
|
|
990
|
+
]
|
|
991
|
+
}
|
|
992
|
+
]
|
|
993
|
+
},
|
|
994
|
+
functions: {
|
|
995
|
+
source: "functions",
|
|
996
|
+
runtime: "nodejs20",
|
|
997
|
+
codebase: "clawfire"
|
|
998
|
+
},
|
|
999
|
+
firestore: {
|
|
1000
|
+
rules: "firestore.rules",
|
|
1001
|
+
indexes: "firestore.indexes.json"
|
|
1002
|
+
},
|
|
1003
|
+
emulators: {
|
|
1004
|
+
functions: { port: 5001 },
|
|
1005
|
+
firestore: { port: 8080 },
|
|
1006
|
+
auth: { port: 9099 },
|
|
1007
|
+
hosting: { port: 5e3 },
|
|
1008
|
+
ui: { enabled: true, port: 4e3 }
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
function generateDefaultRules() {
|
|
1013
|
+
return `rules_version = '2';
|
|
1014
|
+
service cloud.firestore {
|
|
1015
|
+
match /databases/{database}/documents {
|
|
1016
|
+
|
|
1017
|
+
// Helper functions
|
|
1018
|
+
function isAuthenticated() {
|
|
1019
|
+
return request.auth != null;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function isOwner(userId) {
|
|
1023
|
+
return isAuthenticated() && request.auth.uid == userId;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function hasRole(role) {
|
|
1027
|
+
return isAuthenticated() && request.auth.token.role == role;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Default: deny all
|
|
1031
|
+
match /{document=**} {
|
|
1032
|
+
allow read, write: if false;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
`;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/security/middleware.ts
|
|
1040
|
+
function requestLogger() {
|
|
1041
|
+
return async (input, ctx, next) => {
|
|
1042
|
+
const start = Date.now();
|
|
1043
|
+
const uid = ctx.auth?.uid || "anonymous";
|
|
1044
|
+
logger.info(`\u2192 Request from ${uid}`, {
|
|
1045
|
+
hasAuth: !!ctx.auth,
|
|
1046
|
+
ip: ctx.ip
|
|
1047
|
+
});
|
|
1048
|
+
try {
|
|
1049
|
+
const result = await next();
|
|
1050
|
+
const duration = Date.now() - start;
|
|
1051
|
+
logger.info(`\u2190 Response [${duration}ms]`);
|
|
1052
|
+
return result;
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
const duration = Date.now() - start;
|
|
1055
|
+
logger.error(`\u2190 Error [${duration}ms]`, {
|
|
1056
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
1057
|
+
});
|
|
1058
|
+
throw err;
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function inputSizeLimit(maxBytes = 1024 * 1024) {
|
|
1063
|
+
return async (input, ctx, next) => {
|
|
1064
|
+
const size = JSON.stringify(input).length;
|
|
1065
|
+
if (size > maxBytes) {
|
|
1066
|
+
const { Errors: Errors2 } = await Promise.resolve().then(() => (init_errors(), errors_exports));
|
|
1067
|
+
throw Errors2.validation(`Input too large: ${size} bytes (max: ${maxBytes})`);
|
|
1068
|
+
}
|
|
1069
|
+
return next();
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
function sanitizeInput() {
|
|
1073
|
+
return async (input, ctx, next) => {
|
|
1074
|
+
if (typeof input === "object" && input !== null) {
|
|
1075
|
+
sanitizeObject(input);
|
|
1076
|
+
}
|
|
1077
|
+
return next();
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function sanitizeObject(obj) {
|
|
1081
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1082
|
+
if (typeof value === "string") {
|
|
1083
|
+
obj[key] = value.replace(/</g, "<").replace(/>/g, ">");
|
|
1084
|
+
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1085
|
+
sanitizeObject(value);
|
|
1086
|
+
} else if (Array.isArray(value)) {
|
|
1087
|
+
for (let i = 0; i < value.length; i++) {
|
|
1088
|
+
if (typeof value[i] === "string") {
|
|
1089
|
+
value[i] = value[i].replace(/</g, "<").replace(/>/g, ">");
|
|
1090
|
+
} else if (typeof value[i] === "object" && value[i] !== null) {
|
|
1091
|
+
sanitizeObject(value[i]);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
function corsMiddleware(allowedOrigins = []) {
|
|
1098
|
+
return async (input, ctx, next) => {
|
|
1099
|
+
return next();
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function createSecurityMiddleware(options) {
|
|
1103
|
+
const middlewares = [];
|
|
1104
|
+
if (options?.logRequests !== false) {
|
|
1105
|
+
middlewares.push(requestLogger());
|
|
1106
|
+
}
|
|
1107
|
+
if (options?.maxInputSize) {
|
|
1108
|
+
middlewares.push(inputSizeLimit(options.maxInputSize));
|
|
1109
|
+
} else {
|
|
1110
|
+
middlewares.push(inputSizeLimit());
|
|
1111
|
+
}
|
|
1112
|
+
if (options?.sanitize !== false) {
|
|
1113
|
+
middlewares.push(sanitizeInput());
|
|
1114
|
+
}
|
|
1115
|
+
return middlewares;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/core/index.ts
|
|
1119
|
+
init_errors();
|
|
1120
|
+
|
|
1121
|
+
// src/functions.ts
|
|
1122
|
+
init_errors();
|
|
1123
|
+
var import_zod2 = require("zod");
|
|
1124
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1125
|
+
0 && (module.exports = {
|
|
1126
|
+
ClawfireError,
|
|
1127
|
+
ClawfireRouter,
|
|
1128
|
+
Errors,
|
|
1129
|
+
checkAuthLevel,
|
|
1130
|
+
corsMiddleware,
|
|
1131
|
+
createAdminDB,
|
|
1132
|
+
createRouter,
|
|
1133
|
+
createSecurityMiddleware,
|
|
1134
|
+
defineAPI,
|
|
1135
|
+
defineModel,
|
|
1136
|
+
discoverRoutes,
|
|
1137
|
+
extractBearerToken,
|
|
1138
|
+
generateDefaultRules,
|
|
1139
|
+
generateFirebaseJson,
|
|
1140
|
+
generateFirestoreIndexes,
|
|
1141
|
+
generateFirestoreRules,
|
|
1142
|
+
generateRouteImports,
|
|
1143
|
+
getUserRole,
|
|
1144
|
+
inputSizeLimit,
|
|
1145
|
+
logger,
|
|
1146
|
+
modelToZodSchema,
|
|
1147
|
+
requestLogger,
|
|
1148
|
+
sanitizeInput,
|
|
1149
|
+
setCustomClaims,
|
|
1150
|
+
setUserRole,
|
|
1151
|
+
verifyReauth,
|
|
1152
|
+
verifyToken,
|
|
1153
|
+
z,
|
|
1154
|
+
zodToJsonSchema
|
|
1155
|
+
});
|
|
1156
|
+
//# sourceMappingURL=functions.cjs.map
|