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,322 @@
|
|
|
1
|
+
// Session management tools
|
|
2
|
+
|
|
3
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
4
|
+
import type { McpTool, McpToolResult } from '../protocol';
|
|
5
|
+
import { createToolResult } from '../protocol';
|
|
6
|
+
import {
|
|
7
|
+
createSession,
|
|
8
|
+
getSession,
|
|
9
|
+
listSessions,
|
|
10
|
+
updateSessionHeartbeat,
|
|
11
|
+
updateSessionStatus,
|
|
12
|
+
endSession,
|
|
13
|
+
cleanupStaleSessions,
|
|
14
|
+
listClaims,
|
|
15
|
+
} from '../../db/queries';
|
|
16
|
+
import type { TodoItem } from '../../db/types';
|
|
17
|
+
|
|
18
|
+
export const sessionTools: McpTool[] = [
|
|
19
|
+
{
|
|
20
|
+
name: 'collab_session_start',
|
|
21
|
+
description:
|
|
22
|
+
'Register a new collaboration session. Call this when starting work to enable coordination with other sessions.',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
name: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: "Optional session name, e.g., 'frontend-refactor'",
|
|
29
|
+
},
|
|
30
|
+
project_root: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'Project root directory path',
|
|
33
|
+
},
|
|
34
|
+
machine_id: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Optional machine identifier for multi-machine setups',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ['project_root'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'collab_session_end',
|
|
44
|
+
description: 'End a session and release all its claims.',
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
session_id: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'Session ID to end',
|
|
51
|
+
},
|
|
52
|
+
release_claims: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
enum: ['complete', 'abandon'],
|
|
55
|
+
description: 'How to handle unreleased claims: complete (mark as done) or abandon',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
required: ['session_id'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'collab_session_list',
|
|
63
|
+
description: 'List all active sessions. Use to see who else is working.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
include_inactive: {
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
description: 'Include inactive/terminated sessions',
|
|
70
|
+
},
|
|
71
|
+
project_root: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description: 'Filter by project root',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'collab_session_heartbeat',
|
|
80
|
+
description: 'Update session heartbeat to indicate the session is still active.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
session_id: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: 'Session ID to update',
|
|
87
|
+
},
|
|
88
|
+
current_task: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'Optional: Current task being worked on',
|
|
91
|
+
},
|
|
92
|
+
todos: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
description: 'Optional: Current todo list to sync',
|
|
95
|
+
items: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
content: { type: 'string' },
|
|
99
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
required: ['session_id'],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'collab_status_update',
|
|
109
|
+
description: 'Update session work status. Use this to share what you are currently working on with other sessions.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
session_id: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description: 'Your session ID',
|
|
116
|
+
},
|
|
117
|
+
current_task: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description: 'Description of current task (e.g., "Refactoring auth module")',
|
|
120
|
+
},
|
|
121
|
+
todos: {
|
|
122
|
+
type: 'array',
|
|
123
|
+
description: 'Your current todo list',
|
|
124
|
+
items: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
content: { type: 'string', description: 'Task description' },
|
|
128
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
|
|
129
|
+
},
|
|
130
|
+
required: ['content', 'status'],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
required: ['session_id'],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
export async function handleSessionTool(
|
|
140
|
+
db: D1Database,
|
|
141
|
+
name: string,
|
|
142
|
+
args: Record<string, unknown>,
|
|
143
|
+
userId?: string
|
|
144
|
+
): Promise<McpToolResult> {
|
|
145
|
+
switch (name) {
|
|
146
|
+
case 'collab_session_start': {
|
|
147
|
+
// Cleanup stale sessions first
|
|
148
|
+
await cleanupStaleSessions(db, 30);
|
|
149
|
+
|
|
150
|
+
const session = await createSession(db, {
|
|
151
|
+
name: args.name as string | undefined,
|
|
152
|
+
project_root: args.project_root as string,
|
|
153
|
+
machine_id: args.machine_id as string | undefined,
|
|
154
|
+
user_id: userId,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const activeSessions = await listSessions(db, { project_root: args.project_root as string, user_id: userId });
|
|
158
|
+
|
|
159
|
+
return createToolResult(
|
|
160
|
+
JSON.stringify(
|
|
161
|
+
{
|
|
162
|
+
session_id: session.id,
|
|
163
|
+
name: session.name,
|
|
164
|
+
message: `Session registered. ${activeSessions.length} active session(s) in this project.`,
|
|
165
|
+
active_sessions: activeSessions.map((s) => ({
|
|
166
|
+
id: s.id,
|
|
167
|
+
name: s.name,
|
|
168
|
+
last_heartbeat: s.last_heartbeat,
|
|
169
|
+
})),
|
|
170
|
+
},
|
|
171
|
+
null,
|
|
172
|
+
2
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'collab_session_end': {
|
|
178
|
+
const sessionId = args.session_id as string;
|
|
179
|
+
const releaseClaims = (args.release_claims as 'complete' | 'abandon') ?? 'abandon';
|
|
180
|
+
|
|
181
|
+
const session = await getSession(db, sessionId);
|
|
182
|
+
if (!session) {
|
|
183
|
+
return createToolResult(
|
|
184
|
+
JSON.stringify({ error: 'SESSION_NOT_FOUND', message: 'Session not found' }),
|
|
185
|
+
true
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await endSession(db, sessionId, releaseClaims);
|
|
190
|
+
|
|
191
|
+
return createToolResult(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
success: true,
|
|
194
|
+
message: `Session ended. All claims marked as ${releaseClaims === 'complete' ? 'completed' : 'abandoned'}.`,
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
case 'collab_session_list': {
|
|
200
|
+
// Do not filter by user_id - collaboration tool should show all sessions
|
|
201
|
+
const sessions = await listSessions(db, {
|
|
202
|
+
include_inactive: args.include_inactive as boolean,
|
|
203
|
+
project_root: args.project_root as string | undefined,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Get active claims count for each session and include status info
|
|
207
|
+
const sessionsWithDetails = await Promise.all(
|
|
208
|
+
sessions.map(async (session) => {
|
|
209
|
+
const claims = await listClaims(db, { session_id: session.id, status: 'active' });
|
|
210
|
+
|
|
211
|
+
// Parse progress and todos if present
|
|
212
|
+
let progress = null;
|
|
213
|
+
let todos = null;
|
|
214
|
+
try {
|
|
215
|
+
if (session.progress) progress = JSON.parse(session.progress);
|
|
216
|
+
if (session.todos) todos = JSON.parse(session.todos);
|
|
217
|
+
} catch {
|
|
218
|
+
// Ignore parse errors
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
id: session.id,
|
|
223
|
+
name: session.name,
|
|
224
|
+
project_root: session.project_root,
|
|
225
|
+
status: session.status,
|
|
226
|
+
active_claims: claims.length,
|
|
227
|
+
last_heartbeat: session.last_heartbeat,
|
|
228
|
+
current_task: session.current_task,
|
|
229
|
+
progress,
|
|
230
|
+
todos,
|
|
231
|
+
};
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return createToolResult(
|
|
236
|
+
JSON.stringify(
|
|
237
|
+
{
|
|
238
|
+
sessions: sessionsWithDetails,
|
|
239
|
+
total: sessionsWithDetails.length,
|
|
240
|
+
},
|
|
241
|
+
null,
|
|
242
|
+
2
|
|
243
|
+
)
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'collab_session_heartbeat': {
|
|
248
|
+
const sessionId = args.session_id as string;
|
|
249
|
+
const currentTask = args.current_task as string | undefined;
|
|
250
|
+
const todos = args.todos as TodoItem[] | undefined;
|
|
251
|
+
|
|
252
|
+
const updated = await updateSessionHeartbeat(db, sessionId, {
|
|
253
|
+
current_task: currentTask,
|
|
254
|
+
todos,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (!updated) {
|
|
258
|
+
return createToolResult(
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
error: 'SESSION_NOT_FOUND',
|
|
261
|
+
message: 'Session not found or inactive. Please start a new session.',
|
|
262
|
+
}),
|
|
263
|
+
true
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return createToolResult(
|
|
268
|
+
JSON.stringify({
|
|
269
|
+
success: true,
|
|
270
|
+
message: 'Heartbeat updated',
|
|
271
|
+
status_synced: !!(currentTask || todos),
|
|
272
|
+
})
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'collab_status_update': {
|
|
277
|
+
const sessionId = args.session_id as string;
|
|
278
|
+
const currentTask = args.current_task as string | undefined;
|
|
279
|
+
const todos = args.todos as TodoItem[] | undefined;
|
|
280
|
+
|
|
281
|
+
// Validate session exists
|
|
282
|
+
const session = await getSession(db, sessionId);
|
|
283
|
+
if (!session || session.status !== 'active') {
|
|
284
|
+
return createToolResult(
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
error: 'SESSION_INVALID',
|
|
287
|
+
message: 'Session not found or inactive.',
|
|
288
|
+
}),
|
|
289
|
+
true
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await updateSessionStatus(db, sessionId, {
|
|
294
|
+
current_task: currentTask,
|
|
295
|
+
todos,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Calculate progress for response
|
|
299
|
+
let progress = null;
|
|
300
|
+
if (todos && todos.length > 0) {
|
|
301
|
+
const completed = todos.filter((t) => t.status === 'completed').length;
|
|
302
|
+
progress = {
|
|
303
|
+
completed,
|
|
304
|
+
total: todos.length,
|
|
305
|
+
percentage: Math.round((completed / todos.length) * 100),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return createToolResult(
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
success: true,
|
|
312
|
+
message: 'Status updated successfully.',
|
|
313
|
+
current_task: currentTask ?? null,
|
|
314
|
+
progress,
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
default:
|
|
320
|
+
return createToolResult(`Unknown session tool: ${name}`, true);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// API Token generator
|
|
2
|
+
|
|
3
|
+
import { generateRandomBytes, bytesToHex } from '../utils/crypto';
|
|
4
|
+
|
|
5
|
+
const TOKEN_PREFIX = 'mcp_';
|
|
6
|
+
const TOKEN_RANDOM_BYTES = 24; // 192 bits of randomness
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a new API token
|
|
10
|
+
* Format: mcp_<48 hex characters>
|
|
11
|
+
* Total length: 52 characters
|
|
12
|
+
*/
|
|
13
|
+
export function generateApiToken(): string {
|
|
14
|
+
const randomBytes = generateRandomBytes(TOKEN_RANDOM_BYTES);
|
|
15
|
+
const randomHex = bytesToHex(randomBytes);
|
|
16
|
+
return `${TOKEN_PREFIX}${randomHex}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get token prefix for display
|
|
21
|
+
* Shows first 12 characters (mcp_ + 8 hex chars)
|
|
22
|
+
*/
|
|
23
|
+
export function getTokenPrefix(token: string): string {
|
|
24
|
+
return token.substring(0, 12);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate token format
|
|
29
|
+
*/
|
|
30
|
+
export function isValidTokenFormat(token: string): boolean {
|
|
31
|
+
// Must start with mcp_ and have 48 hex characters after
|
|
32
|
+
return /^mcp_[a-f0-9]{48}$/.test(token);
|
|
33
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Token management API handlers
|
|
2
|
+
|
|
3
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { generateApiToken } from './generator';
|
|
6
|
+
import { createApiToken, listApiTokens, revokeApiToken } from '../db/auth-queries';
|
|
7
|
+
import type { AuthContext } from '../auth/types';
|
|
8
|
+
|
|
9
|
+
// Request schemas
|
|
10
|
+
const CreateTokenRequestSchema = z.object({
|
|
11
|
+
name: z.string().min(1, 'Name is required').max(100, 'Name too long'),
|
|
12
|
+
scopes: z.array(z.string()).default(['mcp']),
|
|
13
|
+
expires_in_days: z.number().int().min(1).max(365).optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
interface HandlerContext {
|
|
17
|
+
db: D1Database;
|
|
18
|
+
request: Request;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// CORS headers
|
|
22
|
+
const corsHeaders = {
|
|
23
|
+
'Access-Control-Allow-Origin': '*',
|
|
24
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
25
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
29
|
+
return new Response(JSON.stringify(body), {
|
|
30
|
+
status,
|
|
31
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function errorResponse(error: string, code: string, status: number, details?: { field: string; message: string }[]): Response {
|
|
36
|
+
return jsonResponse({ error, code, details }, status);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* POST /tokens
|
|
41
|
+
* Create a new API token
|
|
42
|
+
*/
|
|
43
|
+
export async function handleCreateToken(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
|
|
44
|
+
const body = await ctx.request.json();
|
|
45
|
+
const parsed = CreateTokenRequestSchema.safeParse(body);
|
|
46
|
+
|
|
47
|
+
if (!parsed.success) {
|
|
48
|
+
const details = parsed.error.issues.map((i) => ({
|
|
49
|
+
field: i.path.join('.'),
|
|
50
|
+
message: i.message,
|
|
51
|
+
}));
|
|
52
|
+
return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { name, scopes, expires_in_days } = parsed.data;
|
|
56
|
+
|
|
57
|
+
// Generate token
|
|
58
|
+
const rawToken = generateApiToken();
|
|
59
|
+
|
|
60
|
+
// Store token
|
|
61
|
+
const { token } = await createApiToken(ctx.db, {
|
|
62
|
+
user_id: authContext.userId,
|
|
63
|
+
name,
|
|
64
|
+
token: rawToken,
|
|
65
|
+
scopes,
|
|
66
|
+
expires_in_days,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Response includes the raw token (only shown once)
|
|
70
|
+
const response = {
|
|
71
|
+
token: {
|
|
72
|
+
id: token.id,
|
|
73
|
+
name: token.name,
|
|
74
|
+
token: rawToken, // Only time raw token is returned
|
|
75
|
+
token_prefix: token.token_prefix,
|
|
76
|
+
scopes: JSON.parse(token.scopes),
|
|
77
|
+
expires_at: token.expires_at,
|
|
78
|
+
created_at: token.created_at,
|
|
79
|
+
},
|
|
80
|
+
warning: 'This token will only be shown once. Store it securely.',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return jsonResponse(response, 201);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* GET /tokens
|
|
88
|
+
* List all tokens for the authenticated user
|
|
89
|
+
*/
|
|
90
|
+
export async function handleListTokens(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
|
|
91
|
+
const tokens = await listApiTokens(ctx.db, authContext.userId);
|
|
92
|
+
|
|
93
|
+
const response = {
|
|
94
|
+
tokens,
|
|
95
|
+
total: tokens.length,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return jsonResponse(response);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* DELETE /tokens/:id
|
|
103
|
+
* Revoke a token
|
|
104
|
+
*/
|
|
105
|
+
export async function handleRevokeToken(ctx: HandlerContext, authContext: AuthContext, tokenId: string): Promise<Response> {
|
|
106
|
+
const success = await revokeApiToken(ctx.db, tokenId, authContext.userId);
|
|
107
|
+
|
|
108
|
+
if (!success) {
|
|
109
|
+
return errorResponse('Token not found', 'ERR_NOT_FOUND', 404);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return jsonResponse({ message: 'Token revoked successfully' });
|
|
113
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Cryptographic utilities using Web Crypto API
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a cryptographically secure random string
|
|
5
|
+
*/
|
|
6
|
+
export function generateRandomBytes(length: number): Uint8Array {
|
|
7
|
+
return crypto.getRandomValues(new Uint8Array(length));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert bytes to hex string
|
|
12
|
+
*/
|
|
13
|
+
export function bytesToHex(bytes: Uint8Array): string {
|
|
14
|
+
return Array.from(bytes)
|
|
15
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
16
|
+
.join('');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert hex string to bytes
|
|
21
|
+
*/
|
|
22
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
23
|
+
const matches = hex.match(/.{2}/g);
|
|
24
|
+
if (!matches) {
|
|
25
|
+
throw new Error('Invalid hex string');
|
|
26
|
+
}
|
|
27
|
+
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Base64 URL-safe encoding (for JWT)
|
|
32
|
+
*/
|
|
33
|
+
export function base64UrlEncode(data: string | ArrayBuffer): string {
|
|
34
|
+
let base64: string;
|
|
35
|
+
if (typeof data === 'string') {
|
|
36
|
+
base64 = btoa(data);
|
|
37
|
+
} else {
|
|
38
|
+
base64 = btoa(String.fromCharCode(...new Uint8Array(data)));
|
|
39
|
+
}
|
|
40
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Base64 URL-safe decoding (for JWT)
|
|
45
|
+
*/
|
|
46
|
+
export function base64UrlDecode(str: string): string {
|
|
47
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
48
|
+
const padding = (4 - (base64.length % 4)) % 4;
|
|
49
|
+
base64 += '='.repeat(padding);
|
|
50
|
+
return atob(base64);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* SHA-256 hash
|
|
55
|
+
*/
|
|
56
|
+
export async function sha256(data: string): Promise<string> {
|
|
57
|
+
const encoder = new TextEncoder();
|
|
58
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data));
|
|
59
|
+
return bytesToHex(new Uint8Array(hashBuffer));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Constant-time string comparison to prevent timing attacks
|
|
64
|
+
*/
|
|
65
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
66
|
+
if (a.length !== b.length) {
|
|
67
|
+
// Compare against itself to maintain constant time even when lengths differ
|
|
68
|
+
b = a;
|
|
69
|
+
}
|
|
70
|
+
let result = a.length === b.length ? 0 : 1;
|
|
71
|
+
for (let i = 0; i < a.length; i++) {
|
|
72
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
73
|
+
}
|
|
74
|
+
return result === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generate UUID v4
|
|
79
|
+
*/
|
|
80
|
+
export function generateId(): string {
|
|
81
|
+
return crypto.randomUUID();
|
|
82
|
+
}
|