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,9 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'child_process';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const cli = join(__dirname, '..', 'src', 'cli.ts');
8
+
9
+ spawn('npx', ['tsx', cli], { stdio: 'inherit', shell: true }).on('exit', process.exit);
@@ -0,0 +1,72 @@
1
+ -- Session Collaboration MCP - Initial Schema
2
+ -- Database: Cloudflare D1 (SQLite-compatible)
3
+
4
+ -- Sessions table: track active Claude Code sessions
5
+ CREATE TABLE IF NOT EXISTS sessions (
6
+ id TEXT PRIMARY KEY,
7
+ name TEXT,
8
+ project_root TEXT NOT NULL,
9
+ machine_id TEXT,
10
+ created_at TEXT DEFAULT (datetime('now')),
11
+ last_heartbeat TEXT DEFAULT (datetime('now')),
12
+ status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'terminated'))
13
+ );
14
+
15
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
16
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_root);
17
+
18
+ -- Claims table: WIP declarations
19
+ CREATE TABLE IF NOT EXISTS claims (
20
+ id TEXT PRIMARY KEY,
21
+ session_id TEXT NOT NULL,
22
+ intent TEXT NOT NULL,
23
+ scope TEXT DEFAULT 'medium' CHECK (scope IN ('small', 'medium', 'large')),
24
+ status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'abandoned')),
25
+ created_at TEXT DEFAULT (datetime('now')),
26
+ updated_at TEXT DEFAULT (datetime('now')),
27
+ completed_summary TEXT,
28
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_claims_session ON claims(session_id);
32
+ CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status);
33
+
34
+ -- Claim files: normalized file paths for efficient querying
35
+ CREATE TABLE IF NOT EXISTS claim_files (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ claim_id TEXT NOT NULL,
38
+ file_path TEXT NOT NULL,
39
+ is_pattern INTEGER DEFAULT 0,
40
+ FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
41
+ UNIQUE(claim_id, file_path)
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_claim_files_path ON claim_files(file_path);
45
+ CREATE INDEX IF NOT EXISTS idx_claim_files_claim ON claim_files(claim_id);
46
+
47
+ -- Messages table: inter-session communication
48
+ CREATE TABLE IF NOT EXISTS messages (
49
+ id TEXT PRIMARY KEY,
50
+ from_session_id TEXT NOT NULL,
51
+ to_session_id TEXT,
52
+ content TEXT NOT NULL,
53
+ read_at TEXT,
54
+ created_at TEXT DEFAULT (datetime('now')),
55
+ FOREIGN KEY (from_session_id) REFERENCES sessions(id) ON DELETE CASCADE
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session_id);
59
+ CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(to_session_id, read_at);
60
+
61
+ -- Decisions table: architectural decisions log (optional feature)
62
+ CREATE TABLE IF NOT EXISTS decisions (
63
+ id TEXT PRIMARY KEY,
64
+ session_id TEXT NOT NULL,
65
+ category TEXT CHECK (category IN ('architecture', 'naming', 'api', 'database', 'ui', 'other')),
66
+ title TEXT NOT NULL,
67
+ description TEXT NOT NULL,
68
+ created_at TEXT DEFAULT (datetime('now')),
69
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
70
+ );
71
+
72
+ CREATE INDEX IF NOT EXISTS idx_decisions_category ON decisions(category);
@@ -0,0 +1,56 @@
1
+ -- Authentication & API Token Schema
2
+ -- Migration: 0002_auth.sql
3
+
4
+ -- Users table: user accounts
5
+ CREATE TABLE IF NOT EXISTS users (
6
+ id TEXT PRIMARY KEY,
7
+ email TEXT NOT NULL UNIQUE,
8
+ password_hash TEXT NOT NULL,
9
+ display_name TEXT,
10
+ created_at TEXT DEFAULT (datetime('now')),
11
+ updated_at TEXT DEFAULT (datetime('now')),
12
+ last_login_at TEXT,
13
+ status TEXT DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'deleted'))
14
+ );
15
+
16
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
17
+ CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
18
+
19
+ -- API Tokens table: user API keys for MCP authentication
20
+ CREATE TABLE IF NOT EXISTS api_tokens (
21
+ id TEXT PRIMARY KEY,
22
+ user_id TEXT NOT NULL,
23
+ name TEXT NOT NULL,
24
+ token_hash TEXT NOT NULL,
25
+ token_prefix TEXT NOT NULL,
26
+ scopes TEXT DEFAULT '["mcp"]',
27
+ last_used_at TEXT,
28
+ expires_at TEXT,
29
+ created_at TEXT DEFAULT (datetime('now')),
30
+ revoked_at TEXT,
31
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
35
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_prefix ON api_tokens(token_prefix);
36
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
37
+
38
+ -- Refresh Tokens table: JWT refresh tokens (revocable)
39
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
40
+ id TEXT PRIMARY KEY,
41
+ user_id TEXT NOT NULL,
42
+ token_hash TEXT NOT NULL,
43
+ expires_at TEXT NOT NULL,
44
+ created_at TEXT DEFAULT (datetime('now')),
45
+ revoked_at TEXT,
46
+ user_agent TEXT,
47
+ ip_address TEXT,
48
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
52
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
53
+
54
+ -- Add user_id column to sessions table for user association
55
+ ALTER TABLE sessions ADD COLUMN user_id TEXT REFERENCES users(id);
56
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
@@ -0,0 +1,10 @@
1
+ -- Add work status tracking to sessions
2
+ -- Enables real-time visibility into what each session is working on
3
+
4
+ -- Add new columns to sessions table
5
+ ALTER TABLE sessions ADD COLUMN current_task TEXT;
6
+ ALTER TABLE sessions ADD COLUMN progress TEXT; -- JSON: {"completed": 3, "total": 5, "percentage": 60}
7
+ ALTER TABLE sessions ADD COLUMN todos TEXT; -- JSON array of todo items
8
+
9
+ -- Create index for faster queries on active sessions with tasks
10
+ CREATE INDEX IF NOT EXISTS idx_sessions_active_task ON sessions(status, current_task) WHERE status = 'active';
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "session-collab-mcp",
3
+ "version": "0.3.0",
4
+ "description": "MCP server for Claude Code session collaboration - prevents conflicts between parallel sessions",
5
+ "type": "module",
6
+ "bin": {
7
+ "session-collab-mcp": "./bin/session-collab-mcp"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "migrations"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "start": "node dist/cli.js",
17
+ "start:dev": "tsx src/cli.ts",
18
+ "prepublishOnly": "npm run build",
19
+ "dev": "wrangler dev",
20
+ "deploy": "wrangler deploy",
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "eslint src/",
23
+ "test": "vitest"
24
+ },
25
+ "dependencies": {
26
+ "better-sqlite3": "^11.7.0",
27
+ "tsx": "^4.19.2",
28
+ "zod": "^3.24.1"
29
+ },
30
+ "devDependencies": {
31
+ "@cloudflare/workers-types": "^4.20241218.0",
32
+ "@types/better-sqlite3": "^7.6.12",
33
+ "eslint": "^9.17.0",
34
+ "tsup": "^8.5.1",
35
+ "typescript": "^5.7.2",
36
+ "vitest": "^2.1.8",
37
+ "wrangler": "^3.99.0"
38
+ }
39
+ }
@@ -0,0 +1,326 @@
1
+ // Authentication API handlers
2
+
3
+ import type { D1Database } from '@cloudflare/workers-types';
4
+ import { hashPassword, verifyPassword, validatePasswordStrength } from './password';
5
+ import { createAccessToken, createRefreshToken, verifyJwt, getTokenExpiry } from './jwt';
6
+ import {
7
+ createUser,
8
+ getUserByEmail,
9
+ getUserById,
10
+ updateUserLastLogin,
11
+ updateUserPassword,
12
+ updateUserProfile,
13
+ toUserPublic,
14
+ createRefreshToken as createRefreshTokenDb,
15
+ getRefreshTokenByHash,
16
+ revokeRefreshToken,
17
+ revokeAllUserRefreshTokens,
18
+ } from '../db/auth-queries';
19
+ import { sha256 } from '../utils/crypto';
20
+ import {
21
+ RegisterRequestSchema,
22
+ LoginRequestSchema,
23
+ RefreshRequestSchema,
24
+ UpdateProfileRequestSchema,
25
+ ChangePasswordRequestSchema,
26
+ type AuthResponse,
27
+ type UserResponse,
28
+ type AuthContext,
29
+ } from './types';
30
+
31
+ interface HandlerContext {
32
+ db: D1Database;
33
+ jwtSecret: string;
34
+ request: Request;
35
+ }
36
+
37
+ // CORS headers
38
+ const corsHeaders = {
39
+ 'Access-Control-Allow-Origin': '*',
40
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
41
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
42
+ };
43
+
44
+ function jsonResponse(body: unknown, status = 200): Response {
45
+ return new Response(JSON.stringify(body), {
46
+ status,
47
+ headers: { 'Content-Type': 'application/json', ...corsHeaders },
48
+ });
49
+ }
50
+
51
+ function errorResponse(error: string, code: string, status: number, details?: { field: string; message: string }[]): Response {
52
+ return jsonResponse({ error, code, details }, status);
53
+ }
54
+
55
+ /**
56
+ * POST /auth/register
57
+ */
58
+ export async function handleRegister(ctx: HandlerContext): Promise<Response> {
59
+ const body = await ctx.request.json();
60
+ const parsed = RegisterRequestSchema.safeParse(body);
61
+
62
+ if (!parsed.success) {
63
+ const details = parsed.error.issues.map((i) => ({
64
+ field: i.path.join('.'),
65
+ message: i.message,
66
+ }));
67
+ return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
68
+ }
69
+
70
+ const { email, password, display_name } = parsed.data;
71
+
72
+ // Validate password strength
73
+ const passwordError = validatePasswordStrength(password);
74
+ if (passwordError) {
75
+ return errorResponse(passwordError, 'ERR_WEAK_PASSWORD', 422, [{ field: 'password', message: passwordError }]);
76
+ }
77
+
78
+ // Check if email already exists
79
+ const existingUser = await getUserByEmail(ctx.db, email);
80
+ if (existingUser) {
81
+ return errorResponse('Email already registered', 'ERR_EMAIL_EXISTS', 409, [{ field: 'email', message: 'Email already registered' }]);
82
+ }
83
+
84
+ // Create user
85
+ const passwordHash = await hashPassword(password);
86
+ const user = await createUser(ctx.db, {
87
+ email,
88
+ password_hash: passwordHash,
89
+ display_name,
90
+ });
91
+
92
+ // Create tokens
93
+ const accessToken = await createAccessToken(user.id, ctx.jwtSecret);
94
+ const refreshToken = await createRefreshToken(user.id, ctx.jwtSecret);
95
+ const refreshTokenHash = await sha256(refreshToken);
96
+ const { refreshToken: refreshExpiry } = getTokenExpiry();
97
+
98
+ // Store refresh token
99
+ await createRefreshTokenDb(ctx.db, {
100
+ user_id: user.id,
101
+ token_hash: refreshTokenHash,
102
+ expires_at: new Date(Date.now() + refreshExpiry * 1000).toISOString(),
103
+ user_agent: ctx.request.headers.get('User-Agent') ?? undefined,
104
+ ip_address: ctx.request.headers.get('CF-Connecting-IP') ?? undefined,
105
+ });
106
+
107
+ const response: AuthResponse = {
108
+ user: toUserPublic(user),
109
+ access_token: accessToken,
110
+ refresh_token: refreshToken,
111
+ expires_in: getTokenExpiry().accessToken,
112
+ };
113
+
114
+ return jsonResponse(response, 201);
115
+ }
116
+
117
+ /**
118
+ * POST /auth/login
119
+ */
120
+ export async function handleLogin(ctx: HandlerContext): Promise<Response> {
121
+ const body = await ctx.request.json();
122
+ const parsed = LoginRequestSchema.safeParse(body);
123
+
124
+ if (!parsed.success) {
125
+ const details = parsed.error.issues.map((i) => ({
126
+ field: i.path.join('.'),
127
+ message: i.message,
128
+ }));
129
+ return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
130
+ }
131
+
132
+ const { email, password } = parsed.data;
133
+
134
+ // Find user
135
+ const user = await getUserByEmail(ctx.db, email);
136
+ if (!user) {
137
+ return errorResponse('Invalid email or password', 'ERR_INVALID_CREDENTIALS', 401);
138
+ }
139
+
140
+ // Verify password
141
+ const valid = await verifyPassword(password, user.password_hash);
142
+ if (!valid) {
143
+ return errorResponse('Invalid email or password', 'ERR_INVALID_CREDENTIALS', 401);
144
+ }
145
+
146
+ // Update last login
147
+ await updateUserLastLogin(ctx.db, user.id);
148
+
149
+ // Create tokens
150
+ const accessToken = await createAccessToken(user.id, ctx.jwtSecret);
151
+ const refreshToken = await createRefreshToken(user.id, ctx.jwtSecret);
152
+ const refreshTokenHash = await sha256(refreshToken);
153
+ const { refreshToken: refreshExpiry } = getTokenExpiry();
154
+
155
+ // Store refresh token
156
+ await createRefreshTokenDb(ctx.db, {
157
+ user_id: user.id,
158
+ token_hash: refreshTokenHash,
159
+ expires_at: new Date(Date.now() + refreshExpiry * 1000).toISOString(),
160
+ user_agent: ctx.request.headers.get('User-Agent') ?? undefined,
161
+ ip_address: ctx.request.headers.get('CF-Connecting-IP') ?? undefined,
162
+ });
163
+
164
+ const response: AuthResponse = {
165
+ user: toUserPublic(user),
166
+ access_token: accessToken,
167
+ refresh_token: refreshToken,
168
+ expires_in: getTokenExpiry().accessToken,
169
+ };
170
+
171
+ return jsonResponse(response);
172
+ }
173
+
174
+ /**
175
+ * POST /auth/refresh
176
+ */
177
+ export async function handleRefresh(ctx: HandlerContext): Promise<Response> {
178
+ const body = await ctx.request.json();
179
+ const parsed = RefreshRequestSchema.safeParse(body);
180
+
181
+ if (!parsed.success) {
182
+ return errorResponse('Invalid request', 'ERR_VALIDATION', 422);
183
+ }
184
+
185
+ const { refresh_token } = parsed.data;
186
+
187
+ // Verify JWT
188
+ const payload = await verifyJwt(refresh_token, ctx.jwtSecret);
189
+ if (!payload || payload.type !== 'refresh') {
190
+ return errorResponse('Invalid refresh token', 'ERR_INVALID_TOKEN', 401);
191
+ }
192
+
193
+ // Check if refresh token is in database (not revoked)
194
+ const refreshTokenHash = await sha256(refresh_token);
195
+ const storedToken = await getRefreshTokenByHash(ctx.db, refreshTokenHash);
196
+ if (!storedToken) {
197
+ return errorResponse('Refresh token has been revoked', 'ERR_TOKEN_REVOKED', 401);
198
+ }
199
+
200
+ // Get user
201
+ const user = await getUserById(ctx.db, payload.sub);
202
+ if (!user) {
203
+ return errorResponse('User not found', 'ERR_USER_NOT_FOUND', 404);
204
+ }
205
+
206
+ // Revoke old refresh token
207
+ await revokeRefreshToken(ctx.db, storedToken.id);
208
+
209
+ // Create new tokens
210
+ const accessToken = await createAccessToken(user.id, ctx.jwtSecret);
211
+ const newRefreshToken = await createRefreshToken(user.id, ctx.jwtSecret);
212
+ const newRefreshTokenHash = await sha256(newRefreshToken);
213
+ const { refreshToken: refreshExpiry } = getTokenExpiry();
214
+
215
+ // Store new refresh token
216
+ await createRefreshTokenDb(ctx.db, {
217
+ user_id: user.id,
218
+ token_hash: newRefreshTokenHash,
219
+ expires_at: new Date(Date.now() + refreshExpiry * 1000).toISOString(),
220
+ user_agent: ctx.request.headers.get('User-Agent') ?? undefined,
221
+ ip_address: ctx.request.headers.get('CF-Connecting-IP') ?? undefined,
222
+ });
223
+
224
+ const response: AuthResponse = {
225
+ user: toUserPublic(user),
226
+ access_token: accessToken,
227
+ refresh_token: newRefreshToken,
228
+ expires_in: getTokenExpiry().accessToken,
229
+ };
230
+
231
+ return jsonResponse(response);
232
+ }
233
+
234
+ /**
235
+ * POST /auth/logout
236
+ */
237
+ export async function handleLogout(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
238
+ // Revoke all refresh tokens for user
239
+ await revokeAllUserRefreshTokens(ctx.db, authContext.userId);
240
+
241
+ return jsonResponse({ message: 'Logged out successfully' });
242
+ }
243
+
244
+ /**
245
+ * GET /auth/me
246
+ */
247
+ export async function handleGetMe(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
248
+ const user = await getUserById(ctx.db, authContext.userId);
249
+ if (!user) {
250
+ return errorResponse('User not found', 'ERR_USER_NOT_FOUND', 404);
251
+ }
252
+
253
+ const response: UserResponse = toUserPublic(user);
254
+ return jsonResponse(response);
255
+ }
256
+
257
+ /**
258
+ * PUT /auth/me
259
+ */
260
+ export async function handleUpdateMe(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
261
+ const body = await ctx.request.json();
262
+ const parsed = UpdateProfileRequestSchema.safeParse(body);
263
+
264
+ if (!parsed.success) {
265
+ const details = parsed.error.issues.map((i) => ({
266
+ field: i.path.join('.'),
267
+ message: i.message,
268
+ }));
269
+ return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
270
+ }
271
+
272
+ await updateUserProfile(ctx.db, authContext.userId, parsed.data);
273
+
274
+ const user = await getUserById(ctx.db, authContext.userId);
275
+ if (!user) {
276
+ return errorResponse('User not found', 'ERR_USER_NOT_FOUND', 404);
277
+ }
278
+
279
+ const response: UserResponse = toUserPublic(user);
280
+ return jsonResponse(response);
281
+ }
282
+
283
+ /**
284
+ * PUT /auth/password
285
+ */
286
+ export async function handleChangePassword(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
287
+ const body = await ctx.request.json();
288
+ const parsed = ChangePasswordRequestSchema.safeParse(body);
289
+
290
+ if (!parsed.success) {
291
+ const details = parsed.error.issues.map((i) => ({
292
+ field: i.path.join('.'),
293
+ message: i.message,
294
+ }));
295
+ return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
296
+ }
297
+
298
+ const { current_password, new_password } = parsed.data;
299
+
300
+ // Validate new password strength
301
+ const passwordError = validatePasswordStrength(new_password);
302
+ if (passwordError) {
303
+ return errorResponse(passwordError, 'ERR_WEAK_PASSWORD', 422, [{ field: 'new_password', message: passwordError }]);
304
+ }
305
+
306
+ // Get user
307
+ const user = await getUserById(ctx.db, authContext.userId);
308
+ if (!user) {
309
+ return errorResponse('User not found', 'ERR_USER_NOT_FOUND', 404);
310
+ }
311
+
312
+ // Verify current password
313
+ const valid = await verifyPassword(current_password, user.password_hash);
314
+ if (!valid) {
315
+ return errorResponse('Current password is incorrect', 'ERR_INVALID_PASSWORD', 401, [{ field: 'current_password', message: 'Current password is incorrect' }]);
316
+ }
317
+
318
+ // Update password
319
+ const newPasswordHash = await hashPassword(new_password);
320
+ await updateUserPassword(ctx.db, authContext.userId, newPasswordHash);
321
+
322
+ // Revoke all refresh tokens (force re-login)
323
+ await revokeAllUserRefreshTokens(ctx.db, authContext.userId);
324
+
325
+ return jsonResponse({ message: 'Password changed successfully. Please login again.' });
326
+ }
@@ -0,0 +1,112 @@
1
+ // JWT utilities using Web Crypto API (HS256)
2
+
3
+ import { base64UrlEncode, base64UrlDecode } from '../utils/crypto';
4
+
5
+ export interface JwtPayload {
6
+ sub: string; // user_id
7
+ iat: number; // issued at (unix timestamp)
8
+ exp: number; // expires at (unix timestamp)
9
+ type: 'access' | 'refresh';
10
+ }
11
+
12
+ const JWT_ALGORITHM = 'HS256';
13
+ const ACCESS_TOKEN_EXPIRY = 15 * 60; // 15 minutes
14
+ const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days
15
+
16
+ /**
17
+ * Sign a JWT using HS256
18
+ */
19
+ export async function signJwt(payload: JwtPayload, secret: string): Promise<string> {
20
+ const header = { alg: JWT_ALGORITHM, typ: 'JWT' };
21
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
22
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
23
+
24
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
25
+
26
+ const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`));
27
+
28
+ return `${encodedHeader}.${encodedPayload}.${base64UrlEncode(signature)}`;
29
+ }
30
+
31
+ /**
32
+ * Verify and decode a JWT
33
+ * Returns null if invalid or expired
34
+ */
35
+ export async function verifyJwt(token: string, secret: string): Promise<JwtPayload | null> {
36
+ const parts = token.split('.');
37
+ if (parts.length !== 3) {
38
+ return null;
39
+ }
40
+
41
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
42
+
43
+ // Verify signature
44
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
45
+
46
+ // Decode signature from base64url
47
+ const signatureStr = base64UrlDecode(encodedSignature);
48
+ const signature = new Uint8Array(signatureStr.length);
49
+ for (let i = 0; i < signatureStr.length; i++) {
50
+ signature[i] = signatureStr.charCodeAt(i);
51
+ }
52
+
53
+ const valid = await crypto.subtle.verify('HMAC', key, signature, new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`));
54
+
55
+ if (!valid) {
56
+ return null;
57
+ }
58
+
59
+ // Decode and parse payload
60
+ try {
61
+ const payloadStr = base64UrlDecode(encodedPayload);
62
+ const payload = JSON.parse(payloadStr) as JwtPayload;
63
+
64
+ // Check expiration
65
+ const now = Math.floor(Date.now() / 1000);
66
+ if (payload.exp < now) {
67
+ return null;
68
+ }
69
+
70
+ return payload;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create an access token
78
+ */
79
+ export async function createAccessToken(userId: string, secret: string): Promise<string> {
80
+ const now = Math.floor(Date.now() / 1000);
81
+ const payload: JwtPayload = {
82
+ sub: userId,
83
+ iat: now,
84
+ exp: now + ACCESS_TOKEN_EXPIRY,
85
+ type: 'access',
86
+ };
87
+ return signJwt(payload, secret);
88
+ }
89
+
90
+ /**
91
+ * Create a refresh token
92
+ */
93
+ export async function createRefreshToken(userId: string, secret: string): Promise<string> {
94
+ const now = Math.floor(Date.now() / 1000);
95
+ const payload: JwtPayload = {
96
+ sub: userId,
97
+ iat: now,
98
+ exp: now + REFRESH_TOKEN_EXPIRY,
99
+ type: 'refresh',
100
+ };
101
+ return signJwt(payload, secret);
102
+ }
103
+
104
+ /**
105
+ * Get token expiry times
106
+ */
107
+ export function getTokenExpiry(): { accessToken: number; refreshToken: number } {
108
+ return {
109
+ accessToken: ACCESS_TOKEN_EXPIRY,
110
+ refreshToken: REFRESH_TOKEN_EXPIRY,
111
+ };
112
+ }