session-collab-mcp 0.3.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/bin/session-collab-mcp +9 -0
- package/migrations/0001_init.sql +72 -0
- package/migrations/0002_auth.sql +56 -0
- package/migrations/0002_session_status.sql +10 -0
- package/package.json +39 -0
- package/src/auth/handlers.ts +326 -0
- package/src/auth/jwt.ts +112 -0
- package/src/auth/middleware.ts +144 -0
- package/src/auth/password.ts +84 -0
- package/src/auth/types.ts +70 -0
- package/src/cli.ts +140 -0
- package/src/db/auth-queries.ts +256 -0
- package/src/db/queries.ts +562 -0
- package/src/db/sqlite-adapter.ts +137 -0
- package/src/db/types.ts +137 -0
- package/src/frontend/app.ts +1181 -0
- package/src/index.ts +438 -0
- package/src/mcp/protocol.ts +99 -0
- package/src/mcp/server.ts +155 -0
- package/src/mcp/tools/claim.ts +358 -0
- package/src/mcp/tools/decision.ts +124 -0
- package/src/mcp/tools/message.ts +150 -0
- package/src/mcp/tools/session.ts +322 -0
- package/src/tokens/generator.ts +33 -0
- package/src/tokens/handlers.ts +113 -0
- package/src/utils/crypto.ts +82 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Authentication middleware
|
|
2
|
+
|
|
3
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
4
|
+
import { verifyJwt } from './jwt';
|
|
5
|
+
import { getApiTokenByHash, updateApiTokenLastUsed } from '../db/auth-queries';
|
|
6
|
+
import { sha256, timingSafeEqual } from '../utils/crypto';
|
|
7
|
+
import type { AuthContext } from './types';
|
|
8
|
+
|
|
9
|
+
export interface Env {
|
|
10
|
+
DB: D1Database;
|
|
11
|
+
JWT_SECRET?: string;
|
|
12
|
+
API_TOKEN?: string; // Legacy single token support
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate authentication from request
|
|
17
|
+
* Supports three modes:
|
|
18
|
+
* 1. JWT (Bearer ey...) - for web UI
|
|
19
|
+
* 2. API Token (Bearer mcp_...) - for MCP requests
|
|
20
|
+
* 3. Legacy (Bearer <API_TOKEN>) - backward compatibility
|
|
21
|
+
*/
|
|
22
|
+
export async function validateAuth(request: Request, env: Env): Promise<AuthContext | null> {
|
|
23
|
+
const authHeader = request.headers.get('Authorization');
|
|
24
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const token = authHeader.slice(7); // Remove 'Bearer '
|
|
29
|
+
|
|
30
|
+
// 1. Try API Token authentication (mcp_xxx)
|
|
31
|
+
if (token.startsWith('mcp_')) {
|
|
32
|
+
return await validateApiToken(token, env.DB);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 2. Try JWT authentication (starts with eyJ for base64 encoded JSON)
|
|
36
|
+
if (token.startsWith('eyJ') && env.JWT_SECRET) {
|
|
37
|
+
return await validateJwtToken(token, env.JWT_SECRET);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3. Try legacy API_TOKEN (backward compatibility)
|
|
41
|
+
if (env.API_TOKEN && timingSafeEqual(token, env.API_TOKEN)) {
|
|
42
|
+
return {
|
|
43
|
+
type: 'legacy',
|
|
44
|
+
userId: 'legacy',
|
|
45
|
+
scopes: ['mcp'],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate API Token
|
|
54
|
+
*/
|
|
55
|
+
async function validateApiToken(token: string, db: D1Database): Promise<AuthContext | null> {
|
|
56
|
+
const tokenHash = await sha256(token);
|
|
57
|
+
const apiToken = await getApiTokenByHash(db, tokenHash);
|
|
58
|
+
|
|
59
|
+
if (!apiToken) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Update last used timestamp (fire and forget)
|
|
64
|
+
updateApiTokenLastUsed(db, apiToken.id).catch(() => {});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
type: 'api_token',
|
|
68
|
+
userId: apiToken.user_id,
|
|
69
|
+
tokenId: apiToken.id,
|
|
70
|
+
scopes: JSON.parse(apiToken.scopes),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate JWT Token
|
|
76
|
+
*/
|
|
77
|
+
async function validateJwtToken(token: string, secret: string): Promise<AuthContext | null> {
|
|
78
|
+
const payload = await verifyJwt(token, secret);
|
|
79
|
+
|
|
80
|
+
if (!payload || payload.type !== 'access') {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
type: 'jwt',
|
|
86
|
+
userId: payload.sub,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if auth context has required scope
|
|
92
|
+
*/
|
|
93
|
+
export function hasScope(authContext: AuthContext, requiredScope: string): boolean {
|
|
94
|
+
// JWT users have all scopes
|
|
95
|
+
if (authContext.type === 'jwt') {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Legacy token has mcp scope
|
|
100
|
+
if (authContext.type === 'legacy') {
|
|
101
|
+
return requiredScope === 'mcp';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// API Token - check scopes
|
|
105
|
+
return authContext.scopes?.includes(requiredScope) ?? false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create unauthorized response
|
|
110
|
+
*/
|
|
111
|
+
export function unauthorizedResponse(): Response {
|
|
112
|
+
return new Response(
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
error: 'Unauthorized',
|
|
115
|
+
code: 'ERR_UNAUTHORIZED',
|
|
116
|
+
}),
|
|
117
|
+
{
|
|
118
|
+
status: 401,
|
|
119
|
+
headers: {
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
'Access-Control-Allow-Origin': '*',
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create forbidden response
|
|
129
|
+
*/
|
|
130
|
+
export function forbiddenResponse(message = 'Insufficient permissions'): Response {
|
|
131
|
+
return new Response(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
error: message,
|
|
134
|
+
code: 'ERR_FORBIDDEN',
|
|
135
|
+
}),
|
|
136
|
+
{
|
|
137
|
+
status: 403,
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
'Access-Control-Allow-Origin': '*',
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Password hashing using PBKDF2-SHA256 (Web Crypto API)
|
|
2
|
+
|
|
3
|
+
import { bytesToHex, hexToBytes, timingSafeEqual } from '../utils/crypto';
|
|
4
|
+
|
|
5
|
+
const ITERATIONS = 100000;
|
|
6
|
+
const SALT_LENGTH = 16;
|
|
7
|
+
const HASH_LENGTH = 256; // bits
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hash a password using PBKDF2-SHA256
|
|
11
|
+
* Format: pbkdf2:sha256:<iterations>$<salt_hex>$<hash_hex>
|
|
12
|
+
*/
|
|
13
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
14
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
15
|
+
|
|
16
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
|
|
17
|
+
|
|
18
|
+
const hash = await crypto.subtle.deriveBits(
|
|
19
|
+
{
|
|
20
|
+
name: 'PBKDF2',
|
|
21
|
+
salt,
|
|
22
|
+
iterations: ITERATIONS,
|
|
23
|
+
hash: 'SHA-256',
|
|
24
|
+
},
|
|
25
|
+
key,
|
|
26
|
+
HASH_LENGTH
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const saltHex = bytesToHex(salt);
|
|
30
|
+
const hashHex = bytesToHex(new Uint8Array(hash));
|
|
31
|
+
|
|
32
|
+
return `pbkdf2:sha256:${ITERATIONS}$${saltHex}$${hashHex}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Verify a password against a stored hash
|
|
37
|
+
*/
|
|
38
|
+
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
39
|
+
const match = stored.match(/^pbkdf2:sha256:(\d+)\$([a-f0-9]+)\$([a-f0-9]+)$/);
|
|
40
|
+
if (!match) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [, iterStr, saltHex, storedHashHex] = match;
|
|
45
|
+
const iterations = parseInt(iterStr, 10);
|
|
46
|
+
const salt = hexToBytes(saltHex);
|
|
47
|
+
|
|
48
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
|
|
49
|
+
|
|
50
|
+
const hash = await crypto.subtle.deriveBits(
|
|
51
|
+
{
|
|
52
|
+
name: 'PBKDF2',
|
|
53
|
+
salt,
|
|
54
|
+
iterations,
|
|
55
|
+
hash: 'SHA-256',
|
|
56
|
+
},
|
|
57
|
+
key,
|
|
58
|
+
HASH_LENGTH
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const hashHex = bytesToHex(new Uint8Array(hash));
|
|
62
|
+
|
|
63
|
+
return timingSafeEqual(hashHex, storedHashHex);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate password strength
|
|
68
|
+
* Returns null if valid, error message if invalid
|
|
69
|
+
*/
|
|
70
|
+
export function validatePasswordStrength(password: string): string | null {
|
|
71
|
+
if (password.length < 8) {
|
|
72
|
+
return 'Password must be at least 8 characters';
|
|
73
|
+
}
|
|
74
|
+
if (!/[a-z]/.test(password)) {
|
|
75
|
+
return 'Password must contain at least one lowercase letter';
|
|
76
|
+
}
|
|
77
|
+
if (!/[A-Z]/.test(password)) {
|
|
78
|
+
return 'Password must contain at least one uppercase letter';
|
|
79
|
+
}
|
|
80
|
+
if (!/[0-9]/.test(password)) {
|
|
81
|
+
return 'Password must contain at least one number';
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Authentication types and request/response schemas
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
// Request schemas
|
|
6
|
+
export const RegisterRequestSchema = z.object({
|
|
7
|
+
email: z.string().email('Invalid email format'),
|
|
8
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
9
|
+
display_name: z.string().min(1).max(100).optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const LoginRequestSchema = z.object({
|
|
13
|
+
email: z.string().email('Invalid email format'),
|
|
14
|
+
password: z.string().min(1, 'Password is required'),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const RefreshRequestSchema = z.object({
|
|
18
|
+
refresh_token: z.string().min(1, 'Refresh token is required'),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const UpdateProfileRequestSchema = z.object({
|
|
22
|
+
display_name: z.string().min(1).max(100).optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const ChangePasswordRequestSchema = z.object({
|
|
26
|
+
current_password: z.string().min(1, 'Current password is required'),
|
|
27
|
+
new_password: z.string().min(8, 'New password must be at least 8 characters'),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Type aliases
|
|
31
|
+
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
|
|
32
|
+
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
|
33
|
+
export type RefreshRequest = z.infer<typeof RefreshRequestSchema>;
|
|
34
|
+
export type UpdateProfileRequest = z.infer<typeof UpdateProfileRequestSchema>;
|
|
35
|
+
export type ChangePasswordRequest = z.infer<typeof ChangePasswordRequestSchema>;
|
|
36
|
+
|
|
37
|
+
// Response types
|
|
38
|
+
export interface AuthResponse {
|
|
39
|
+
user: {
|
|
40
|
+
id: string;
|
|
41
|
+
email: string;
|
|
42
|
+
display_name: string | null;
|
|
43
|
+
created_at: string;
|
|
44
|
+
};
|
|
45
|
+
access_token: string;
|
|
46
|
+
refresh_token: string;
|
|
47
|
+
expires_in: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UserResponse {
|
|
51
|
+
id: string;
|
|
52
|
+
email: string;
|
|
53
|
+
display_name: string | null;
|
|
54
|
+
created_at: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Auth context for authenticated requests
|
|
58
|
+
export interface AuthContext {
|
|
59
|
+
type: 'jwt' | 'api_token' | 'legacy';
|
|
60
|
+
userId: string;
|
|
61
|
+
tokenId?: string; // API Token ID
|
|
62
|
+
scopes?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Error types
|
|
66
|
+
export interface AuthError {
|
|
67
|
+
error: string;
|
|
68
|
+
code: string;
|
|
69
|
+
details?: { field: string; message: string }[];
|
|
70
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local MCP server for session collaboration
|
|
3
|
+
// Runs via stdio, stores data in ~/.claude/session-collab/collab.db
|
|
4
|
+
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
import { createLocalDatabase, getDefaultDbPath } from './db/sqlite-adapter.js';
|
|
7
|
+
import { handleMcpRequest, getMcpTools } from './mcp/server.js';
|
|
8
|
+
import { readFileSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
// Load migrations
|
|
15
|
+
function loadMigrations(): string[] {
|
|
16
|
+
const migrationsDir = join(__dirname, '..', 'migrations');
|
|
17
|
+
return [
|
|
18
|
+
readFileSync(join(migrationsDir, '0001_init.sql'), 'utf-8'),
|
|
19
|
+
readFileSync(join(migrationsDir, '0002_auth.sql'), 'utf-8'),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Parse command line arguments
|
|
24
|
+
function parseArgs(): { dbPath?: string } {
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
let dbPath: string | undefined;
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
if (args[i] === '--db' && args[i + 1]) {
|
|
30
|
+
dbPath = args[i + 1];
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { dbPath };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// JSON-RPC response helpers
|
|
39
|
+
function jsonRpcResponse(id: number | string | null, result: unknown): string {
|
|
40
|
+
return JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function jsonRpcError(id: number | string | null, code: number, message: string): string {
|
|
44
|
+
return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main(): Promise<void> {
|
|
48
|
+
const { dbPath } = parseArgs();
|
|
49
|
+
const db = createLocalDatabase(dbPath);
|
|
50
|
+
|
|
51
|
+
// Initialize schema
|
|
52
|
+
try {
|
|
53
|
+
const migrations = loadMigrations();
|
|
54
|
+
db.initSchema(migrations);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// Schema might already exist, that's fine
|
|
57
|
+
if (!(error instanceof Error && error.message.includes('already exists'))) {
|
|
58
|
+
console.error('Warning: Migration error:', error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Log startup to stderr (stdout is for JSON-RPC)
|
|
63
|
+
console.error(`Session Collab MCP Server (local)`);
|
|
64
|
+
console.error(`Database: ${dbPath ?? getDefaultDbPath()}`);
|
|
65
|
+
|
|
66
|
+
const rl = createInterface({
|
|
67
|
+
input: process.stdin,
|
|
68
|
+
output: process.stdout,
|
|
69
|
+
terminal: false,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
rl.on('line', async (line) => {
|
|
73
|
+
if (!line.trim()) return;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const request = JSON.parse(line);
|
|
77
|
+
const { id, method, params } = request;
|
|
78
|
+
|
|
79
|
+
// Handle MCP protocol methods
|
|
80
|
+
switch (method) {
|
|
81
|
+
case 'initialize': {
|
|
82
|
+
const response = jsonRpcResponse(id, {
|
|
83
|
+
protocolVersion: '2024-11-05',
|
|
84
|
+
capabilities: {
|
|
85
|
+
tools: {},
|
|
86
|
+
},
|
|
87
|
+
serverInfo: {
|
|
88
|
+
name: 'session-collab-mcp',
|
|
89
|
+
version: '0.3.0',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
console.log(response);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case 'notifications/initialized': {
|
|
97
|
+
// No response needed for notifications
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case 'tools/list': {
|
|
102
|
+
const tools = getMcpTools();
|
|
103
|
+
const response = jsonRpcResponse(id, { tools });
|
|
104
|
+
console.log(response);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case 'tools/call': {
|
|
109
|
+
const result = await handleMcpRequest(
|
|
110
|
+
db as unknown as import('@cloudflare/workers-types').D1Database,
|
|
111
|
+
params.name,
|
|
112
|
+
params.arguments ?? {}
|
|
113
|
+
);
|
|
114
|
+
const response = jsonRpcResponse(id, result);
|
|
115
|
+
console.log(response);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
default: {
|
|
120
|
+
const errorResponse = jsonRpcError(id, -32601, `Method not found: ${method}`);
|
|
121
|
+
console.log(errorResponse);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
126
|
+
const errorResponse = jsonRpcError(null, -32700, `Parse error: ${errorMessage}`);
|
|
127
|
+
console.log(errorResponse);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
rl.on('close', () => {
|
|
132
|
+
db.close();
|
|
133
|
+
process.exit(0);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main().catch((error) => {
|
|
138
|
+
console.error('Fatal error:', error);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Database queries for authentication
|
|
2
|
+
|
|
3
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
4
|
+
import type { User, UserPublic, ApiToken, ApiTokenPublic, RefreshToken } from './types';
|
|
5
|
+
import { generateId, sha256 } from '../utils/crypto';
|
|
6
|
+
|
|
7
|
+
// ============ User Queries ============
|
|
8
|
+
|
|
9
|
+
export async function createUser(
|
|
10
|
+
db: D1Database,
|
|
11
|
+
params: {
|
|
12
|
+
email: string;
|
|
13
|
+
password_hash: string;
|
|
14
|
+
display_name?: string;
|
|
15
|
+
}
|
|
16
|
+
): Promise<User> {
|
|
17
|
+
const id = generateId();
|
|
18
|
+
const now = new Date().toISOString();
|
|
19
|
+
|
|
20
|
+
await db
|
|
21
|
+
.prepare(
|
|
22
|
+
`INSERT INTO users (id, email, password_hash, display_name, created_at, updated_at, status)
|
|
23
|
+
VALUES (?, ?, ?, ?, ?, ?, 'active')`
|
|
24
|
+
)
|
|
25
|
+
.bind(id, params.email.toLowerCase(), params.password_hash, params.display_name ?? null, now, now)
|
|
26
|
+
.run();
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
email: params.email.toLowerCase(),
|
|
31
|
+
password_hash: params.password_hash,
|
|
32
|
+
display_name: params.display_name ?? null,
|
|
33
|
+
created_at: now,
|
|
34
|
+
updated_at: now,
|
|
35
|
+
last_login_at: null,
|
|
36
|
+
status: 'active',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getUserByEmail(db: D1Database, email: string): Promise<User | null> {
|
|
41
|
+
const result = await db.prepare('SELECT * FROM users WHERE email = ? AND status = ?').bind(email.toLowerCase(), 'active').first<User>();
|
|
42
|
+
return result ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
|
46
|
+
const result = await db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').bind(id, 'active').first<User>();
|
|
47
|
+
return result ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function updateUserLastLogin(db: D1Database, id: string): Promise<void> {
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
await db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').bind(now, now, id).run();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function updateUserPassword(db: D1Database, id: string, password_hash: string): Promise<boolean> {
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const result = await db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').bind(password_hash, now, id).run();
|
|
58
|
+
return result.meta.changes > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function updateUserProfile(
|
|
62
|
+
db: D1Database,
|
|
63
|
+
id: string,
|
|
64
|
+
params: { display_name?: string }
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
const result = await db.prepare('UPDATE users SET display_name = ?, updated_at = ? WHERE id = ?').bind(params.display_name ?? null, now, id).run();
|
|
68
|
+
return result.meta.changes > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function toUserPublic(user: User): UserPublic {
|
|
72
|
+
return {
|
|
73
|
+
id: user.id,
|
|
74
|
+
email: user.email,
|
|
75
|
+
display_name: user.display_name,
|
|
76
|
+
created_at: user.created_at,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============ API Token Queries ============
|
|
81
|
+
|
|
82
|
+
export async function createApiToken(
|
|
83
|
+
db: D1Database,
|
|
84
|
+
params: {
|
|
85
|
+
user_id: string;
|
|
86
|
+
name: string;
|
|
87
|
+
token: string; // raw token, will be hashed
|
|
88
|
+
scopes?: string[];
|
|
89
|
+
expires_in_days?: number;
|
|
90
|
+
}
|
|
91
|
+
): Promise<{ token: ApiToken; raw_token: string }> {
|
|
92
|
+
const id = generateId();
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
const tokenHash = await sha256(params.token);
|
|
95
|
+
const tokenPrefix = params.token.substring(0, 12); // "mcp_" + first 8 chars
|
|
96
|
+
const scopes = JSON.stringify(params.scopes ?? ['mcp']);
|
|
97
|
+
const expiresAt = params.expires_in_days ? new Date(Date.now() + params.expires_in_days * 24 * 60 * 60 * 1000).toISOString() : null;
|
|
98
|
+
|
|
99
|
+
await db
|
|
100
|
+
.prepare(
|
|
101
|
+
`INSERT INTO api_tokens (id, user_id, name, token_hash, token_prefix, scopes, expires_at, created_at)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
103
|
+
)
|
|
104
|
+
.bind(id, params.user_id, params.name, tokenHash, tokenPrefix, scopes, expiresAt, now)
|
|
105
|
+
.run();
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
token: {
|
|
109
|
+
id,
|
|
110
|
+
user_id: params.user_id,
|
|
111
|
+
name: params.name,
|
|
112
|
+
token_hash: tokenHash,
|
|
113
|
+
token_prefix: tokenPrefix,
|
|
114
|
+
scopes,
|
|
115
|
+
last_used_at: null,
|
|
116
|
+
expires_at: expiresAt,
|
|
117
|
+
created_at: now,
|
|
118
|
+
revoked_at: null,
|
|
119
|
+
},
|
|
120
|
+
raw_token: params.token,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function getApiTokenByHash(db: D1Database, tokenHash: string): Promise<ApiToken | null> {
|
|
125
|
+
const result = await db
|
|
126
|
+
.prepare('SELECT * FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL')
|
|
127
|
+
.bind(tokenHash)
|
|
128
|
+
.first<ApiToken>();
|
|
129
|
+
|
|
130
|
+
if (!result) return null;
|
|
131
|
+
|
|
132
|
+
// Check expiration
|
|
133
|
+
if (result.expires_at && new Date(result.expires_at) < new Date()) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function listApiTokens(db: D1Database, userId: string): Promise<ApiTokenPublic[]> {
|
|
141
|
+
const result = await db
|
|
142
|
+
.prepare('SELECT * FROM api_tokens WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC')
|
|
143
|
+
.bind(userId)
|
|
144
|
+
.all<ApiToken>();
|
|
145
|
+
|
|
146
|
+
return result.results.map(toApiTokenPublic);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function revokeApiToken(db: D1Database, id: string, userId: string): Promise<boolean> {
|
|
150
|
+
const now = new Date().toISOString();
|
|
151
|
+
const result = await db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ?').bind(now, id, userId).run();
|
|
152
|
+
return result.meta.changes > 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function updateApiTokenLastUsed(db: D1Database, id: string): Promise<void> {
|
|
156
|
+
const now = new Date().toISOString();
|
|
157
|
+
await db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').bind(now, id).run();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function toApiTokenPublic(token: ApiToken): ApiTokenPublic {
|
|
161
|
+
return {
|
|
162
|
+
id: token.id,
|
|
163
|
+
name: token.name,
|
|
164
|
+
token_prefix: token.token_prefix,
|
|
165
|
+
scopes: JSON.parse(token.scopes),
|
|
166
|
+
last_used_at: token.last_used_at,
|
|
167
|
+
expires_at: token.expires_at,
|
|
168
|
+
created_at: token.created_at,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============ Refresh Token Queries ============
|
|
173
|
+
|
|
174
|
+
export async function createRefreshToken(
|
|
175
|
+
db: D1Database,
|
|
176
|
+
params: {
|
|
177
|
+
user_id: string;
|
|
178
|
+
token_hash: string;
|
|
179
|
+
expires_at: string;
|
|
180
|
+
user_agent?: string;
|
|
181
|
+
ip_address?: string;
|
|
182
|
+
}
|
|
183
|
+
): Promise<RefreshToken> {
|
|
184
|
+
const id = generateId();
|
|
185
|
+
const now = new Date().toISOString();
|
|
186
|
+
|
|
187
|
+
await db
|
|
188
|
+
.prepare(
|
|
189
|
+
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at, user_agent, ip_address)
|
|
190
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
191
|
+
)
|
|
192
|
+
.bind(id, params.user_id, params.token_hash, params.expires_at, now, params.user_agent ?? null, params.ip_address ?? null)
|
|
193
|
+
.run();
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
id,
|
|
197
|
+
user_id: params.user_id,
|
|
198
|
+
token_hash: params.token_hash,
|
|
199
|
+
expires_at: params.expires_at,
|
|
200
|
+
created_at: now,
|
|
201
|
+
revoked_at: null,
|
|
202
|
+
user_agent: params.user_agent ?? null,
|
|
203
|
+
ip_address: params.ip_address ?? null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function getRefreshTokenByHash(db: D1Database, tokenHash: string): Promise<RefreshToken | null> {
|
|
208
|
+
const result = await db
|
|
209
|
+
.prepare('SELECT * FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL')
|
|
210
|
+
.bind(tokenHash)
|
|
211
|
+
.first<RefreshToken>();
|
|
212
|
+
|
|
213
|
+
if (!result) return null;
|
|
214
|
+
|
|
215
|
+
// Check expiration
|
|
216
|
+
if (new Date(result.expires_at) < new Date()) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function revokeRefreshToken(db: D1Database, id: string): Promise<boolean> {
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
const result = await db.prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?').bind(now, id).run();
|
|
226
|
+
return result.meta.changes > 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function revokeAllUserRefreshTokens(db: D1Database, userId: string): Promise<number> {
|
|
230
|
+
const now = new Date().toISOString();
|
|
231
|
+
const result = await db.prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL').bind(now, userId).run();
|
|
232
|
+
return result.meta.changes;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ============ Cleanup Queries ============
|
|
236
|
+
|
|
237
|
+
export async function cleanupExpiredTokens(db: D1Database): Promise<{ apiTokens: number; refreshTokens: number }> {
|
|
238
|
+
const now = new Date().toISOString();
|
|
239
|
+
|
|
240
|
+
// Mark expired API tokens as revoked
|
|
241
|
+
const apiResult = await db
|
|
242
|
+
.prepare("UPDATE api_tokens SET revoked_at = ? WHERE expires_at < ? AND revoked_at IS NULL")
|
|
243
|
+
.bind(now, now)
|
|
244
|
+
.run();
|
|
245
|
+
|
|
246
|
+
// Mark expired refresh tokens as revoked
|
|
247
|
+
const refreshResult = await db
|
|
248
|
+
.prepare("UPDATE refresh_tokens SET revoked_at = ? WHERE expires_at < ? AND revoked_at IS NULL")
|
|
249
|
+
.bind(now, now)
|
|
250
|
+
.run();
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
apiTokens: apiResult.meta.changes,
|
|
254
|
+
refreshTokens: refreshResult.meta.changes,
|
|
255
|
+
};
|
|
256
|
+
}
|