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.
@@ -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
+ }