@tamayrp14/fire-api 0.0.0-stage → 1.0.1
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 +276 -2
- package/bin/fire.js +139 -0
- package/bin/fire.mjs +670 -0
- package/bun.lock +20 -0
- package/docs/GETTING_STARTED.md +150 -0
- package/docs/LAUNCH_KIT.md +236 -0
- package/docs/superpowers/plans/2026-11-14-fire-api-framework.md +144 -0
- package/docs/superpowers/specs/fire-api-design-spec.md +111 -0
- package/examples/basic-api/index.ts +177 -0
- package/package.json +68 -4
- package/src/ai/mcp.ts +140 -0
- package/src/auth/auth.ts +110 -0
- package/src/auth/crypto.ts +70 -0
- package/src/auth/jwt.ts +105 -0
- package/src/cache/cache.ts +72 -0
- package/src/cli/bench.ts +118 -0
- package/src/cli/compile.ts +29 -0
- package/src/cli/create-app.ts +284 -0
- package/src/cli/docker.ts +51 -0
- package/src/cli/generator.ts +75 -0
- package/src/cli/routes.ts +76 -0
- package/src/client.ts +120 -0
- package/src/compat/express.ts +139 -0
- package/src/core/app.ts +723 -0
- package/src/core/context.ts +218 -0
- package/src/core/error.ts +42 -0
- package/src/core/router.ts +262 -0
- package/src/db/db-interface.ts +9 -0
- package/src/db/hub.ts +256 -0
- package/src/db/memory-adapter.ts +202 -0
- package/src/db/migrator.ts +100 -0
- package/src/db/sqlite-adapter.ts +58 -0
- package/src/docs/collection.ts +57 -0
- package/src/docs/scalar-ui.ts +25 -0
- package/src/events/event-bus.ts +61 -0
- package/src/index.ts +37 -0
- package/src/jobs/cron.ts +77 -0
- package/src/jobs/queue.ts +108 -0
- package/src/logging/logger.ts +66 -0
- package/src/metrics/metrics.ts +66 -0
- package/src/modules/product/product.route.ts +36 -0
- package/src/modules/product/product.schema.ts +10 -0
- package/src/schema/mock.ts +60 -0
- package/src/schema/openapi.ts +101 -0
- package/src/schema/schema.ts +519 -0
- package/src/schema/validator.ts +5 -0
- package/src/shield/compress.ts +61 -0
- package/src/shield/cors.ts +94 -0
- package/src/shield/headers.ts +55 -0
- package/src/shield/idempotency.ts +76 -0
- package/src/shield/rate-limiter.ts +81 -0
- package/src/shield/sanitizer.ts +23 -0
- package/src/shield/webhook.ts +101 -0
- package/src/static/serve.ts +51 -0
- package/src/storage/storage.ts +176 -0
- package/src/stream/sse.ts +89 -0
- package/src/studio/studio.ts +221 -0
- package/src/types.ts +241 -0
- package/src/ws/ws.ts +118 -0
- package/test/ai-mcp.test.ts +61 -0
- package/test/auth.test.ts +108 -0
- package/test/bench.test.ts +31 -0
- package/test/cache.test.ts +36 -0
- package/test/cli-create.test.ts +64 -0
- package/test/client.test.ts +54 -0
- package/test/collection-export.test.ts +52 -0
- package/test/compat.test.ts +48 -0
- package/test/compress.test.ts +48 -0
- package/test/context.test.ts +48 -0
- package/test/db.test.ts +62 -0
- package/test/docker.test.ts +28 -0
- package/test/events.test.ts +62 -0
- package/test/file-upload.test.ts +116 -0
- package/test/hooks.test.ts +77 -0
- package/test/idempotency.test.ts +65 -0
- package/test/integration.test.ts +111 -0
- package/test/jobs.test.ts +60 -0
- package/test/metrics.test.ts +40 -0
- package/test/mock.test.ts +33 -0
- package/test/multi-tenant.test.ts +80 -0
- package/test/observability.test.ts +95 -0
- package/test/openapi.test.ts +41 -0
- package/test/orm-relations.test.ts +85 -0
- package/test/orm-shortcuts.test.ts +52 -0
- package/test/rbac.test.ts +85 -0
- package/test/router-fastpath.test.ts +42 -0
- package/test/router.test.ts +84 -0
- package/test/schema.test.ts +49 -0
- package/test/seeder-migrator.test.ts +78 -0
- package/test/shield.test.ts +55 -0
- package/test/shorthand-dx.test.ts +31 -0
- package/test/sse.test.ts +43 -0
- package/test/static-serve.test.ts +53 -0
- package/test/storage.test.ts +31 -0
- package/test/studio.test.ts +43 -0
- package/test/tx.test.ts +92 -0
- package/test/webhook.test.ts +113 -0
- package/test/ws.test.ts +54 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createApp, createRouter, s, createAuth, enableStudio } from '../../src/index';
|
|
2
|
+
|
|
3
|
+
// 1. Initialize FireAPI Application
|
|
4
|
+
const app = createApp({
|
|
5
|
+
port: 3000,
|
|
6
|
+
database: {
|
|
7
|
+
driver: 'memory',
|
|
8
|
+
autoMock: true,
|
|
9
|
+
},
|
|
10
|
+
storage: {
|
|
11
|
+
driver: 'memory',
|
|
12
|
+
baseUrl: 'http://localhost:3000/uploads',
|
|
13
|
+
},
|
|
14
|
+
security: {
|
|
15
|
+
rateLimit: { max: 100, window: '1m' },
|
|
16
|
+
cors: true,
|
|
17
|
+
headers: true,
|
|
18
|
+
},
|
|
19
|
+
docs: '/docs',
|
|
20
|
+
openapiPath: '/openapi.json',
|
|
21
|
+
metricsPath: '/metrics', // Standard Prometheus scraping endpoint
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// 2. Turnkey Authentication & Embedded Dev Studio
|
|
25
|
+
app.use('/auth', createAuth({ jwtSecret: 'jwt-super-secret-key' }));
|
|
26
|
+
enableStudio(app, { path: '/studio' });
|
|
27
|
+
|
|
28
|
+
// 3. Background Job Worker & In-Process Typed Event Bus
|
|
29
|
+
app.queue.handle('send-email', async (data) => {
|
|
30
|
+
console.log(`[Queue Worker] 📧 Sending email to ${data.to}: "${data.subject}"`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
app.on('order.*', async (order) => {
|
|
34
|
+
console.log(`[Event Bus] 📡 Order event captured: total=$${order.total}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// 4. Lifecycle Interceptor Hook
|
|
38
|
+
app.hook('afterHandle', async (ctx, response) => {
|
|
39
|
+
response.headers.set('X-Powered-By', 'FireAPI Engine');
|
|
40
|
+
return response;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 5. Scheduled Cron Task
|
|
44
|
+
app.cron('@hourly', async (appInstance) => {
|
|
45
|
+
console.log('[FireCron] ⏰ Hourly background maintenance running...');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// 5. Define Book Validation Schema
|
|
49
|
+
const BookSchema = s.object({
|
|
50
|
+
title: s.string().min(2).description('Title of the book'),
|
|
51
|
+
author: s.string().min(2).description('Author name'),
|
|
52
|
+
price: s.number().min(0).description('Price in USD'),
|
|
53
|
+
category: s.enum(['tech', 'fiction', 'science']).default('tech'),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const bookRouter = createRouter('/books');
|
|
57
|
+
|
|
58
|
+
// GET /books - With route-level caching and eager-loaded relations
|
|
59
|
+
bookRouter.get('/', {
|
|
60
|
+
cache: { ttl: 60, tags: ['books'] },
|
|
61
|
+
ai: { description: 'Retrieve all books from the store', tags: ['Books'] },
|
|
62
|
+
handler: async (ctx) => {
|
|
63
|
+
return ctx.db.books.include('reviews').find();
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// POST /books/checkout - Idempotency Shield (Safe Replays with X-Idempotency-Key)
|
|
68
|
+
bookRouter.post('/checkout', {
|
|
69
|
+
idempotent: true,
|
|
70
|
+
handler: async (ctx) => {
|
|
71
|
+
const order = await ctx.db.orders.insert({
|
|
72
|
+
items: ctx.body.items,
|
|
73
|
+
total: ctx.body.total,
|
|
74
|
+
orderedAt: new Date().toISOString(),
|
|
75
|
+
});
|
|
76
|
+
return ctx.created(order);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// POST /books/transfer - Atomic Transaction Scope with auto-rollback
|
|
81
|
+
bookRouter.post('/transfer', async (ctx) => {
|
|
82
|
+
const result = await ctx.db.tx(async (trx) => {
|
|
83
|
+
await trx.inventories.update(ctx.body.warehouseA, { stock: 10 });
|
|
84
|
+
await trx.inventories.update(ctx.body.warehouseB, { stock: 90 });
|
|
85
|
+
await trx.transfers.insert({ from: ctx.body.warehouseA, to: ctx.body.warehouseB, qty: 40 });
|
|
86
|
+
return { success: true };
|
|
87
|
+
});
|
|
88
|
+
return ctx.ok(result);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// POST /webhooks/payment - Turnkey HMAC Webhook Verifier
|
|
92
|
+
bookRouter.post('/webhooks/payment', async (ctx) => {
|
|
93
|
+
const valid = await ctx.webhook.verifyHMAC({
|
|
94
|
+
secret: 'whsec_sample_secret_key',
|
|
95
|
+
});
|
|
96
|
+
if (!valid) return ctx.unauthorized('Invalid webhook signature');
|
|
97
|
+
return ctx.ok({ received: true, event: ctx.body.event });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// POST /books - With validation, background email dispatch & cache invalidation
|
|
101
|
+
bookRouter.post('/', {
|
|
102
|
+
schema: { body: BookSchema },
|
|
103
|
+
ai: { description: 'Add a new book to the library', tags: ['Books'] },
|
|
104
|
+
handler: async (ctx) => {
|
|
105
|
+
const newBook = await ctx.db.books.insert(ctx.body);
|
|
106
|
+
await ctx.cache.invalidate('books');
|
|
107
|
+
|
|
108
|
+
// Dispatch background email without blocking the HTTP response
|
|
109
|
+
await ctx.queue.dispatch('send-email', {
|
|
110
|
+
to: 'admin@fire.dev',
|
|
111
|
+
subject: `New book added: ${newBook.title}`,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return ctx.created(newBook);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// DELETE /books/:id - Ultra-simple 1-line delete
|
|
119
|
+
bookRouter.delete('/:id', async (ctx) => {
|
|
120
|
+
await ctx.db.books.delete(ctx.params.id);
|
|
121
|
+
return { deleted: true };
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// POST /books/cover - Universal Object Storage Upload
|
|
125
|
+
bookRouter.post('/cover', {
|
|
126
|
+
ai: { description: 'Upload book cover image', tags: ['Storage'] },
|
|
127
|
+
handler: async (ctx) => {
|
|
128
|
+
const uploaded = await ctx.storage.put('covers/sample.png', 'fake_png_binary_data', {
|
|
129
|
+
contentType: 'image/png',
|
|
130
|
+
});
|
|
131
|
+
return ctx.created(uploaded);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// GET /books/stream/ai - Real-Time AI Token Streaming via Server-Sent Events (SSE)
|
|
136
|
+
bookRouter.get('/stream/ai', {
|
|
137
|
+
ai: { description: 'Simulate streaming AI thoughts token-by-token', tags: ['AI'] },
|
|
138
|
+
handler: (ctx) => {
|
|
139
|
+
return ctx.stream.sse(async (stream) => {
|
|
140
|
+
const tokens = ['FireAPI', 'is', 'the', 'fastest', 'modern', 'TypeScript', 'framework!'];
|
|
141
|
+
for (const token of tokens) {
|
|
142
|
+
await stream.send(token, { event: 'token' });
|
|
143
|
+
await new Promise((r) => setTimeout(r, 100)); // Simulate LLM latency
|
|
144
|
+
}
|
|
145
|
+
await stream.send('[DONE]', { event: 'done' });
|
|
146
|
+
stream.close();
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// 6. Native Real-Time WebSockets with Rooms
|
|
152
|
+
app.ws('/chat/:room', {
|
|
153
|
+
open(ws, ctx) {
|
|
154
|
+
ws.join(ctx.params.room);
|
|
155
|
+
ws.publish(ctx.params.room, { notice: `User connected to room ${ctx.params.room}` });
|
|
156
|
+
},
|
|
157
|
+
message(ws, msg, ctx) {
|
|
158
|
+
ws.publish(ctx.params.room, { message: msg });
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// 7. Mount Router & Enable Model Context Protocol (MCP) for AI Agents
|
|
163
|
+
app.use(bookRouter);
|
|
164
|
+
app.enableMCP({ name: 'BookStoreMCP', version: '1.0.0' });
|
|
165
|
+
|
|
166
|
+
// 8. Start Server
|
|
167
|
+
console.log('🔥 FireAPI server launching at http://localhost:3000');
|
|
168
|
+
console.log('🖥️ FireStudio Dashboard: http://localhost:3000/studio');
|
|
169
|
+
console.log('📖 Interactive Scalar API Docs: http://localhost:3000/docs');
|
|
170
|
+
console.log('📊 Prometheus Metrics: http://localhost:3000/metrics');
|
|
171
|
+
console.log('⚡ OpenAPI 3.1 Spec: http://localhost:3000/openapi.json');
|
|
172
|
+
console.log('🤖 MCP AI Endpoint: http://localhost:3000/mcp');
|
|
173
|
+
|
|
174
|
+
app.listen(3000);
|
|
175
|
+
|
|
176
|
+
export type AppRouter = typeof app;
|
|
177
|
+
export default app;
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tamayrp14/fire-api",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Ultra-lightweight, blazing-fast, type-safe API framework with universal DB, security shield, turnkey auth, express compat, dev studio, and native AI/MCP integration",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"fire-api": "./bin/fire.mjs",
|
|
10
|
+
"fire": "./bin/fire.mjs"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/yogarizyapratama/FIRE-API.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/yogarizyapratama/FIRE-API#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/yogarizyapratama/FIRE-API/issues"
|
|
19
|
+
},
|
|
20
|
+
"author": "Yoga Rizya Pratama",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts"
|
|
28
|
+
},
|
|
29
|
+
"./client": {
|
|
30
|
+
"import": "./dist/client.js",
|
|
31
|
+
"types": "./dist/client.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"./auth": {
|
|
34
|
+
"import": "./dist/auth/auth.js",
|
|
35
|
+
"types": "./dist/auth/auth.d.ts"
|
|
36
|
+
},
|
|
37
|
+
"./compat": {
|
|
38
|
+
"import": "./dist/compat/express.js",
|
|
39
|
+
"types": "./dist/compat/express.d.ts"
|
|
40
|
+
},
|
|
41
|
+
"./studio": {
|
|
42
|
+
"import": "./dist/studio/studio.js",
|
|
43
|
+
"types": "./dist/studio/studio.d.ts"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "bun test",
|
|
48
|
+
"build": "bun build ./bin/fire.js --outfile ./bin/fire.mjs --target node --format esm && chmod +x ./bin/fire.mjs && bun build ./src/index.ts ./src/client.ts ./src/auth/auth.ts ./src/compat/express.ts ./src/studio/studio.ts --outdir ./dist --target node && tsc --module ESNext --moduleResolution bundler --declaration --emitDeclarationOnly --outDir dist",
|
|
49
|
+
"prepublishOnly": "bun run build"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"framework",
|
|
53
|
+
"api",
|
|
54
|
+
"typescript",
|
|
55
|
+
"fast",
|
|
56
|
+
"bun",
|
|
57
|
+
"node",
|
|
58
|
+
"mcp",
|
|
59
|
+
"sqlite",
|
|
60
|
+
"postgres",
|
|
61
|
+
"auth",
|
|
62
|
+
"studio",
|
|
63
|
+
"rest"
|
|
64
|
+
],
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/node": "^22.13.0",
|
|
68
|
+
"typescript": "^5.7.3"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/ai/mcp.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Router } from '../core/router';
|
|
2
|
+
import { createContext } from '../core/context';
|
|
3
|
+
import { BaseSchema } from '../schema/schema';
|
|
4
|
+
|
|
5
|
+
export interface MCPToolManifest {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
inputSchema: Record<string, any>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MCPToolResult {
|
|
12
|
+
content: { type: 'text'; text: string }[];
|
|
13
|
+
isError?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MCPServerOptions {
|
|
17
|
+
name?: string;
|
|
18
|
+
version?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function routeToToolName(method: string, path: string): string {
|
|
22
|
+
const clean = path
|
|
23
|
+
.replace(/^\//, '')
|
|
24
|
+
.replace(/:([a-zA-Z0-9_]+)/g, '$1')
|
|
25
|
+
.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
26
|
+
.replace(/_+/g, '_')
|
|
27
|
+
.replace(/_$/, '');
|
|
28
|
+
return `${method.toLowerCase()}_${clean || 'root'}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class MCPServer {
|
|
32
|
+
constructor(private router: Router, private options: MCPServerOptions = {}) {}
|
|
33
|
+
|
|
34
|
+
public listTools(): MCPToolManifest[] {
|
|
35
|
+
const tools: MCPToolManifest[] = [];
|
|
36
|
+
|
|
37
|
+
for (const route of this.router.routesList) {
|
|
38
|
+
if (!route.options.ai) continue;
|
|
39
|
+
|
|
40
|
+
const toolName = route.options.ai.operationId || routeToToolName(route.method, route.path);
|
|
41
|
+
const schema = route.options.schema;
|
|
42
|
+
|
|
43
|
+
let inputSchema: Record<string, any> = { type: 'object', properties: {} };
|
|
44
|
+
|
|
45
|
+
if (schema?.body && schema.body instanceof BaseSchema) {
|
|
46
|
+
inputSchema = schema.body.toOpenAPI();
|
|
47
|
+
} else if (schema?.params && schema.params instanceof BaseSchema) {
|
|
48
|
+
inputSchema = schema.params.toOpenAPI();
|
|
49
|
+
} else if (schema?.query && schema.query instanceof BaseSchema) {
|
|
50
|
+
inputSchema = schema.query.toOpenAPI();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
tools.push({
|
|
54
|
+
name: toolName,
|
|
55
|
+
description: route.options.ai.description || `${route.method} ${route.path}`,
|
|
56
|
+
inputSchema,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return tools;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public async callTool(name: string, args: Record<string, any> = {}): Promise<MCPToolResult> {
|
|
64
|
+
for (const route of this.router.routesList) {
|
|
65
|
+
if (!route.options.ai) continue;
|
|
66
|
+
const toolName = route.options.ai.operationId || routeToToolName(route.method, route.path);
|
|
67
|
+
|
|
68
|
+
if (toolName === name) {
|
|
69
|
+
try {
|
|
70
|
+
// Construct simulated request and context
|
|
71
|
+
const req = new Request(`http://localhost${route.path}`, {
|
|
72
|
+
method: route.method,
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: route.method !== 'GET' && route.method !== 'HEAD' ? JSON.stringify(args) : undefined,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const ctx = createContext({
|
|
78
|
+
req,
|
|
79
|
+
params: args,
|
|
80
|
+
body: args,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const res = await route.options.handler(ctx);
|
|
84
|
+
let outputText = '';
|
|
85
|
+
|
|
86
|
+
if (res instanceof Response) {
|
|
87
|
+
outputText = await res.text();
|
|
88
|
+
} else {
|
|
89
|
+
outputText = typeof res === 'string' ? res : JSON.stringify(res);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: 'text', text: outputText }],
|
|
94
|
+
isError: false,
|
|
95
|
+
};
|
|
96
|
+
} catch (err: any) {
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: 'text', text: err?.message || 'Error executing tool' }],
|
|
99
|
+
isError: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: 'text', text: `Tool not found: ${name}` }],
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public handleMCPProtocol(message: { method: string; params?: any; id?: any }): Promise<any> {
|
|
112
|
+
const { method, params, id } = message;
|
|
113
|
+
|
|
114
|
+
if (method === 'tools/list') {
|
|
115
|
+
return Promise.resolve({
|
|
116
|
+
jsonrpc: '2.0',
|
|
117
|
+
id,
|
|
118
|
+
result: { tools: this.listTools() },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (method === 'tools/call') {
|
|
123
|
+
return this.callTool(params?.name, params?.arguments).then((res) => ({
|
|
124
|
+
jsonrpc: '2.0',
|
|
125
|
+
id,
|
|
126
|
+
result: res,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Promise.resolve({
|
|
131
|
+
jsonrpc: '2.0',
|
|
132
|
+
id,
|
|
133
|
+
error: { code: -32601, message: 'Method not found' },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createMCPServer(router: Router, options: MCPServerOptions = {}): MCPServer {
|
|
139
|
+
return new MCPServer(router, options);
|
|
140
|
+
}
|
package/src/auth/auth.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createRouter, Router } from '../core/router';
|
|
2
|
+
import { Middleware } from '../types';
|
|
3
|
+
import { s } from '../schema/schema';
|
|
4
|
+
import { hashPassword, verifyPassword } from './crypto';
|
|
5
|
+
import { signJWT, verifyJWT } from './jwt';
|
|
6
|
+
|
|
7
|
+
export interface CreateAuthOptions {
|
|
8
|
+
jwtSecret: string;
|
|
9
|
+
tableName?: string;
|
|
10
|
+
tokenExpirySeconds?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createAuth(options: CreateAuthOptions): Router {
|
|
14
|
+
const router = createRouter();
|
|
15
|
+
const tableName = options.tableName || 'users';
|
|
16
|
+
const secret = options.jwtSecret;
|
|
17
|
+
const expiry = options.tokenExpirySeconds || 86400; // 24 hours
|
|
18
|
+
|
|
19
|
+
// POST /register
|
|
20
|
+
router.post('/register', {
|
|
21
|
+
schema: {
|
|
22
|
+
body: s.object({
|
|
23
|
+
email: s.string().email(),
|
|
24
|
+
password: s.string().min(6),
|
|
25
|
+
name: s.string().optional(),
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
ai: { description: 'Register a new user account with credentials', tags: ['Auth'] },
|
|
29
|
+
handler: async (ctx) => {
|
|
30
|
+
const { email, password, name } = ctx.body;
|
|
31
|
+
|
|
32
|
+
// Check if user already exists
|
|
33
|
+
const existing = await ctx.db.table(tableName).where('email', email).findOne();
|
|
34
|
+
if (existing) {
|
|
35
|
+
return ctx.badRequest('A user with this email already exists');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hashedPassword = await hashPassword(password);
|
|
39
|
+
const newUser = await ctx.db.table(tableName).insert({
|
|
40
|
+
email,
|
|
41
|
+
password: hashedPassword,
|
|
42
|
+
name: name || email.split('@')[0],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Strip password from returned user object
|
|
46
|
+
const safeUser = { id: newUser.id, email: newUser.email, name: newUser.name };
|
|
47
|
+
const token = await signJWT(safeUser, secret, expiry);
|
|
48
|
+
|
|
49
|
+
return ctx.created({ user: safeUser, token });
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// POST /login
|
|
54
|
+
router.post('/login', {
|
|
55
|
+
schema: {
|
|
56
|
+
body: s.object({
|
|
57
|
+
email: s.string().email(),
|
|
58
|
+
password: s.string(),
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
ai: { description: 'Authenticate user with email and password', tags: ['Auth'] },
|
|
62
|
+
handler: async (ctx) => {
|
|
63
|
+
const { email, password } = ctx.body;
|
|
64
|
+
|
|
65
|
+
const user = await ctx.db.table(tableName).where('email', email).findOne();
|
|
66
|
+
if (!user) {
|
|
67
|
+
return ctx.unauthorized('Invalid email or password');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isValid = await verifyPassword(password, user.password);
|
|
71
|
+
if (!isValid) {
|
|
72
|
+
return ctx.unauthorized('Invalid email or password');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const safeUser = { id: user.id, email: user.email, name: user.name };
|
|
76
|
+
const token = await signJWT(safeUser, secret, expiry);
|
|
77
|
+
|
|
78
|
+
return ctx.ok({ user: safeUser, token });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// GET /me
|
|
83
|
+
router.get('/me', {
|
|
84
|
+
middlewares: [requireAuth(secret)],
|
|
85
|
+
ai: { description: 'Get authenticated user profile', tags: ['Auth'] },
|
|
86
|
+
handler: (ctx) => {
|
|
87
|
+
return ctx.ok({ user: ctx.user });
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return router;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function requireAuth(secret: string): Middleware {
|
|
95
|
+
return async (ctx, next) => {
|
|
96
|
+
const authHeader = ctx.req.headers.get('authorization') || ctx.req.headers.get('Authorization');
|
|
97
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
98
|
+
return ctx.unauthorized('Missing or invalid Authorization header');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const token = authHeader.slice(7).trim();
|
|
102
|
+
try {
|
|
103
|
+
const payload = await verifyJWT(token, secret);
|
|
104
|
+
ctx.user = payload;
|
|
105
|
+
return next();
|
|
106
|
+
} catch {
|
|
107
|
+
return ctx.unauthorized('Invalid or expired token');
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
function bufferToHex(buffer: ArrayBuffer | Uint8Array): string {
|
|
2
|
+
return Array.from(new Uint8Array(buffer as any))
|
|
3
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
4
|
+
.join('');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function hexToBuffer(hex: string): Uint8Array {
|
|
8
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
9
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
10
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
11
|
+
}
|
|
12
|
+
return bytes;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
16
|
+
const salt = new Uint8Array(16);
|
|
17
|
+
crypto.getRandomValues(salt);
|
|
18
|
+
|
|
19
|
+
const enc = new TextEncoder();
|
|
20
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
21
|
+
'raw',
|
|
22
|
+
enc.encode(password),
|
|
23
|
+
'PBKDF2',
|
|
24
|
+
false,
|
|
25
|
+
['deriveBits', 'deriveKey']
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const derived = await crypto.subtle.deriveBits(
|
|
29
|
+
{
|
|
30
|
+
name: 'PBKDF2',
|
|
31
|
+
salt: salt as any,
|
|
32
|
+
iterations: 100000,
|
|
33
|
+
hash: 'SHA-256',
|
|
34
|
+
},
|
|
35
|
+
keyMaterial,
|
|
36
|
+
256
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return `${bufferToHex(salt)}:${bufferToHex(derived)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
|
43
|
+
const parts = storedHash.split(':');
|
|
44
|
+
if (parts.length !== 2) return false;
|
|
45
|
+
|
|
46
|
+
const [saltHex, originalHashHex] = parts;
|
|
47
|
+
const salt = hexToBuffer(saltHex);
|
|
48
|
+
|
|
49
|
+
const enc = new TextEncoder();
|
|
50
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
51
|
+
'raw',
|
|
52
|
+
enc.encode(password),
|
|
53
|
+
'PBKDF2',
|
|
54
|
+
false,
|
|
55
|
+
['deriveBits', 'deriveKey']
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const derived = await crypto.subtle.deriveBits(
|
|
59
|
+
{
|
|
60
|
+
name: 'PBKDF2',
|
|
61
|
+
salt: salt as any,
|
|
62
|
+
iterations: 100000,
|
|
63
|
+
hash: 'SHA-256',
|
|
64
|
+
},
|
|
65
|
+
keyMaterial,
|
|
66
|
+
256
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return bufferToHex(derived) === originalHashHex;
|
|
70
|
+
}
|
package/src/auth/jwt.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
function base64UrlEncode(str: string): string {
|
|
2
|
+
const bytes = new TextEncoder().encode(str);
|
|
3
|
+
let binary = '';
|
|
4
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
5
|
+
binary += String.fromCharCode(bytes[i]);
|
|
6
|
+
}
|
|
7
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function base64UrlDecode(str: string): string {
|
|
11
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
12
|
+
while (base64.length % 4) {
|
|
13
|
+
base64 += '=';
|
|
14
|
+
}
|
|
15
|
+
const binary = atob(base64);
|
|
16
|
+
const bytes = new Uint8Array(binary.length);
|
|
17
|
+
for (let i = 0; i < binary.length; i++) {
|
|
18
|
+
bytes[i] = binary.charCodeAt(i);
|
|
19
|
+
}
|
|
20
|
+
return new TextDecoder().decode(bytes);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
|
|
24
|
+
const bytes = new Uint8Array(buffer);
|
|
25
|
+
let binary = '';
|
|
26
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
27
|
+
binary += String.fromCharCode(bytes[i]);
|
|
28
|
+
}
|
|
29
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function signJWT(
|
|
33
|
+
payload: Record<string, any>,
|
|
34
|
+
secret: string,
|
|
35
|
+
expiresInSeconds = 86400
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
38
|
+
const now = Math.floor(Date.now() / 1000);
|
|
39
|
+
const fullPayload = {
|
|
40
|
+
...payload,
|
|
41
|
+
iat: now,
|
|
42
|
+
exp: now + expiresInSeconds,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
46
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
|
|
47
|
+
const dataToSign = `${encodedHeader}.${encodedPayload}`;
|
|
48
|
+
|
|
49
|
+
const enc = new TextEncoder();
|
|
50
|
+
const key = await crypto.subtle.importKey(
|
|
51
|
+
'raw',
|
|
52
|
+
enc.encode(secret),
|
|
53
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
54
|
+
false,
|
|
55
|
+
['sign']
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const signature = await crypto.subtle.sign('HMAC', key, enc.encode(dataToSign));
|
|
59
|
+
const encodedSignature = arrayBufferToBase64Url(signature);
|
|
60
|
+
|
|
61
|
+
return `${dataToSign}.${encodedSignature}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function verifyJWT<T = any>(token: string, secret: string): Promise<T> {
|
|
65
|
+
const parts = token.split('.');
|
|
66
|
+
if (parts.length !== 3) {
|
|
67
|
+
throw new Error('Invalid JWT format');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
71
|
+
const dataToVerify = `${encodedHeader}.${encodedPayload}`;
|
|
72
|
+
|
|
73
|
+
const enc = new TextEncoder();
|
|
74
|
+
const key = await crypto.subtle.importKey(
|
|
75
|
+
'raw',
|
|
76
|
+
enc.encode(secret),
|
|
77
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
78
|
+
false,
|
|
79
|
+
['verify']
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Decode signature to ArrayBuffer
|
|
83
|
+
let base64Sig = encodedSignature.replace(/-/g, '+').replace(/_/g, '/');
|
|
84
|
+
while (base64Sig.length % 4) {
|
|
85
|
+
base64Sig += '=';
|
|
86
|
+
}
|
|
87
|
+
const binarySig = atob(base64Sig);
|
|
88
|
+
const sigBytes = new Uint8Array(binarySig.length);
|
|
89
|
+
for (let i = 0; i < binarySig.length; i++) {
|
|
90
|
+
sigBytes[i] = binarySig.charCodeAt(i);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const isValid = await crypto.subtle.verify('HMAC', key, sigBytes, enc.encode(dataToVerify));
|
|
94
|
+
if (!isValid) {
|
|
95
|
+
throw new Error('Invalid JWT signature');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const payload: any = JSON.parse(base64UrlDecode(encodedPayload));
|
|
99
|
+
const now = Math.floor(Date.now() / 1000);
|
|
100
|
+
if (payload.exp && payload.exp < now) {
|
|
101
|
+
throw new Error('JWT token has expired');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return payload as T;
|
|
105
|
+
}
|