@bbigbang/core 0.1.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/dist/config.js +380 -0
- package/dist/execution/executionDispatcher.js +3810 -0
- package/dist/main.js +90 -0
- package/dist/nodeEventHistory.js +206 -0
- package/dist/scheduler/dreamLogic.js +50 -0
- package/dist/scheduler/dreamScheduler.js +65 -0
- package/dist/services/agentFileAccessService.js +1913 -0
- package/dist/services/agentRuntimeCleanupBroker.js +62 -0
- package/dist/services/agentSkillsBroker.js +118 -0
- package/dist/services/agentSkillsService.js +83 -0
- package/dist/services/agentWorkspaceBroker.js +937 -0
- package/dist/services/agentWorkspaceService.js +70 -0
- package/dist/services/appVersion.js +14 -0
- package/dist/services/auth.js +586 -0
- package/dist/services/claudeControlBroker.js +154 -0
- package/dist/services/claudeTranscriptBroker.js +100 -0
- package/dist/services/claudeTranscriptService.js +359 -0
- package/dist/services/codexAppServerBroker.js +155 -0
- package/dist/services/codexTranscriptBroker.js +98 -0
- package/dist/services/codexTranscriptService.js +961 -0
- package/dist/services/droidMissionBroker.js +124 -0
- package/dist/services/droidMissionImporter.js +630 -0
- package/dist/services/droidModelOptions.js +165 -0
- package/dist/services/hubServerRegistrationService.js +268 -0
- package/dist/services/libraryManifest.js +43 -0
- package/dist/services/libraryScaffold.js +26 -0
- package/dist/services/libraryService.js +2263 -0
- package/dist/services/memoryService.js +386 -0
- package/dist/services/missionEvidence.js +377 -0
- package/dist/services/missionService.js +2361 -0
- package/dist/services/missionTrace.js +158 -0
- package/dist/services/nativeMissionBriefParser.js +120 -0
- package/dist/services/nativeMissionOrchestrator.js +2045 -0
- package/dist/services/nativeMissionReportGenerator.js +227 -0
- package/dist/services/nativeMissionValidationRunner.js +452 -0
- package/dist/services/nativeMissionWorkerBroker.js +190 -0
- package/dist/services/nodeRegistry.js +34 -0
- package/dist/services/nodeStateReconciler.js +97 -0
- package/dist/services/panelMediaScanner.js +119 -0
- package/dist/services/persistentRuntimeJsonlClient.js +153 -0
- package/dist/services/platformAgentPolicy.js +180 -0
- package/dist/services/platformAgentService.js +2041 -0
- package/dist/services/projectAccessResolver.js +93 -0
- package/dist/services/projectService.js +392 -0
- package/dist/services/resourceSpaceService.js +140 -0
- package/dist/services/scenarioRuntimeService.js +1130 -0
- package/dist/services/suggestedPlannerService.js +868 -0
- package/dist/services/workbenchGitBroker.js +161 -0
- package/dist/services/workbenchGitService.js +69 -0
- package/dist/services/workbenchInspectBroker.js +65 -0
- package/dist/services/workbenchNodePathService.js +79 -0
- package/dist/services/workbenchRegistryService.js +240 -0
- package/dist/services/workbenchRootService.js +181 -0
- package/dist/services/workbenchTerminalBroker.js +378 -0
- package/dist/services/workspaceRunOwnership.js +60 -0
- package/dist/services/workspaceScaffold.js +105 -0
- package/dist/services/workspaceSessionRuntimeService.js +576 -0
- package/dist/services/workspaceSessionService.js +245 -0
- package/dist/services/workspaceToolActionRunner.js +1582 -0
- package/dist/services/workspaceToolErrors.js +10 -0
- package/dist/services/workspaceToolExecutionUtils.js +895 -0
- package/dist/services/workspaceToolLatestStateProjector.js +91 -0
- package/dist/services/workspaceToolManifest.js +572 -0
- package/dist/services/workspaceToolMutationQueue.js +43 -0
- package/dist/services/workspaceToolPanelProjection.js +460 -0
- package/dist/services/workspaceToolPromotion.js +255 -0
- package/dist/services/workspaceToolPromotionState.js +224 -0
- package/dist/services/workspaceToolPublishDiagnostics.js +189 -0
- package/dist/services/workspaceToolPublishIdentityResolver.js +146 -0
- package/dist/services/workspaceToolReadModel.js +378 -0
- package/dist/services/workspaceToolRunLedger.js +239 -0
- package/dist/services/workspaceToolService.js +3067 -0
- package/dist/services/workspaceToolSnapshotPanelSync.js +293 -0
- package/dist/services/workspaceToolTerminalLifecycle.js +283 -0
- package/dist/services/workspaceToolTypes.js +1 -0
- package/dist/services/workspaceToolUploadMaterializer.js +228 -0
- package/dist/web/actionCardRoutes.js +129 -0
- package/dist/web/actionCards.js +469 -0
- package/dist/web/activationContext.js +684 -0
- package/dist/web/agentChannelGuards.js +48 -0
- package/dist/web/agentMentionCooldowns.js +32 -0
- package/dist/web/agentReminders.js +1668 -0
- package/dist/web/agentRuntimePresence.js +197 -0
- package/dist/web/agentSelfState.js +494 -0
- package/dist/web/agentTaskLinks.js +26 -0
- package/dist/web/agentVisibility.js +79 -0
- package/dist/web/assets.js +95 -0
- package/dist/web/channelActivationPrompt.js +395 -0
- package/dist/web/channelMemoryNotes.js +127 -0
- package/dist/web/channelMentions.js +10 -0
- package/dist/web/channelMessageSequences.js +19 -0
- package/dist/web/channelSubscriptions.js +26 -0
- package/dist/web/clearedTaskRoots.js +10 -0
- package/dist/web/collaborationPromptGuidance.js +36 -0
- package/dist/web/collaborationSurfaceState.js +140 -0
- package/dist/web/contextBundleRanking.js +154 -0
- package/dist/web/contextBundleResolver.js +488 -0
- package/dist/web/conversationBuiltinSkillRoots.js +50 -0
- package/dist/web/conversationControls.js +232 -0
- package/dist/web/conversationHandoffs.js +612 -0
- package/dist/web/conversationManager.js +2511 -0
- package/dist/web/conversationSummaries.js +876 -0
- package/dist/web/conversationSurfaceKinds.js +17 -0
- package/dist/web/conversationTargets.js +173 -0
- package/dist/web/directActivationPrompt.js +122 -0
- package/dist/web/directReplyTargets.js +69 -0
- package/dist/web/directThreadResolver.js +129 -0
- package/dist/web/dmTaskHandoffPrompt.js +120 -0
- package/dist/web/dmTaskThreadStatusProjection.js +229 -0
- package/dist/web/ftsQuery.js +33 -0
- package/dist/web/internalAgentRouter.js +11341 -0
- package/dist/web/libraryCuratorScheduler.js +58 -0
- package/dist/web/libraryDocumentPromptGuidance.js +8 -0
- package/dist/web/messageCheckpoints.js +19 -0
- package/dist/web/nodeWsHandler.js +2495 -0
- package/dist/web/notificationRounds.js +1061 -0
- package/dist/web/panelActionMessages.js +108 -0
- package/dist/web/panelActivationPrompt.js +18 -0
- package/dist/web/panelAudit.js +273 -0
- package/dist/web/panelLifecycle.js +222 -0
- package/dist/web/panelMediaPolicy.js +43 -0
- package/dist/web/panelPathPolicy.js +63 -0
- package/dist/web/panelPreviews.js +175 -0
- package/dist/web/panelQueryHandles.js +2749 -0
- package/dist/web/panelRoutes.js +2147 -0
- package/dist/web/panels.js +904 -0
- package/dist/web/peerInboxAggregates.js +1247 -0
- package/dist/web/planApprovalState.js +92 -0
- package/dist/web/platformAgentScheduler.js +66 -0
- package/dist/web/proactiveOpportunities.js +452 -0
- package/dist/web/promptContextSections.js +242 -0
- package/dist/web/promptHistorySanitizer.js +26 -0
- package/dist/web/promptSlashCommands.js +158 -0
- package/dist/web/rollingConversationSummary.js +453 -0
- package/dist/web/routeHelpers.js +11 -0
- package/dist/web/routes/handoff.js +288 -0
- package/dist/web/routes/history.js +345 -0
- package/dist/web/routes/memory.js +258 -0
- package/dist/web/routes/selfState.js +171 -0
- package/dist/web/routes/workspace.js +154 -0
- package/dist/web/runSurfaceWatermarks.js +431 -0
- package/dist/web/runtimeCapabilities.js +48 -0
- package/dist/web/sameAgentHandoffs.js +494 -0
- package/dist/web/server.js +15567 -0
- package/dist/web/sharedCollaborationCapsules.js +163 -0
- package/dist/web/soloSessionRelay.js +42 -0
- package/dist/web/soloWsHandler.js +138 -0
- package/dist/web/suggestedPlannerScheduler.js +56 -0
- package/dist/web/surfaceActivationPolicy.js +108 -0
- package/dist/web/surfaceCollaborators.js +61 -0
- package/dist/web/surfaceSystemStatus.js +263 -0
- package/dist/web/targetParticipants.js +77 -0
- package/dist/web/taskEvents.js +49 -0
- package/dist/web/taskLifecycleMessages.js +165 -0
- package/dist/web/taskLoops.js +732 -0
- package/dist/web/taskMemoryNotes.js +224 -0
- package/dist/web/taskNumbers.js +16 -0
- package/dist/web/taskOwnerGuards.js +49 -0
- package/dist/web/taskParticipantResolver.js +42 -0
- package/dist/web/taskParticipants.js +97 -0
- package/dist/web/taskSourceDetails.js +20 -0
- package/dist/web/taskStateViews.js +210 -0
- package/dist/web/taskStatusTransitions.js +9 -0
- package/dist/web/taskThreadFollowups.js +599 -0
- package/dist/web/taskThreadRuntimeClosure.js +685 -0
- package/dist/web/taskUpdateDelivery.js +104 -0
- package/dist/web/threadReplyContentHeuristics.js +30 -0
- package/dist/web/threadRoots.js +61 -0
- package/dist/web/threadTaskBindings.js +365 -0
- package/dist/web/uiPanelPromptGuidance.js +27 -0
- package/dist/web/workspaceMemoryHints.js +143 -0
- package/dist/web/workspaceToolPromptGuidance.js +30 -0
- package/dist/web/wsHandler.js +397 -0
- package/dist/web/wsSink.js +116 -0
- package/package.json +54 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export class AgentWorkspaceServiceError extends Error {
|
|
2
|
+
statusCode;
|
|
3
|
+
constructor(statusCode, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.statusCode = statusCode;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export class AgentWorkspaceService {
|
|
9
|
+
getAgentById;
|
|
10
|
+
broker;
|
|
11
|
+
constructor(params) {
|
|
12
|
+
this.getAgentById = params.getAgentById;
|
|
13
|
+
this.broker = params.broker;
|
|
14
|
+
}
|
|
15
|
+
async listWorkspace(agentId, relativePath, options = {}) {
|
|
16
|
+
const agent = this.getAgentById(agentId);
|
|
17
|
+
if (!agent)
|
|
18
|
+
throw new AgentWorkspaceServiceError(404, 'Agent not found.');
|
|
19
|
+
if (!agent.nodeId)
|
|
20
|
+
throw new AgentWorkspaceServiceError(409, 'Agent is not assigned to a remote node.');
|
|
21
|
+
if (!agent.workspacePath)
|
|
22
|
+
throw new AgentWorkspaceServiceError(409, 'Agent has no workspace configured.');
|
|
23
|
+
try {
|
|
24
|
+
return await this.broker.listDirectory(agent.nodeId, agent.workspacePath, relativePath, options);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw mapAgentWorkspaceError(error);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async readWorkspaceFile(agentId, relativePath) {
|
|
31
|
+
const agent = this.getAgentById(agentId);
|
|
32
|
+
if (!agent)
|
|
33
|
+
throw new AgentWorkspaceServiceError(404, 'Agent not found.');
|
|
34
|
+
if (!agent.nodeId)
|
|
35
|
+
throw new AgentWorkspaceServiceError(409, 'Agent is not assigned to a remote node.');
|
|
36
|
+
if (!agent.workspacePath)
|
|
37
|
+
throw new AgentWorkspaceServiceError(409, 'Agent has no workspace configured.');
|
|
38
|
+
try {
|
|
39
|
+
return await this.broker.readFile(agent.nodeId, agent.workspacePath, relativePath);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw mapAgentWorkspaceError(error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function mapAgentWorkspaceError(error) {
|
|
47
|
+
const message = String(error?.message ?? error);
|
|
48
|
+
if (message === 'Agent node is offline.' || message.startsWith('Agent node disconnected:')) {
|
|
49
|
+
return new AgentWorkspaceServiceError(409, message);
|
|
50
|
+
}
|
|
51
|
+
if (message === 'Workspace request timed out.' || message === 'Workspace stream request timed out.') {
|
|
52
|
+
return new AgentWorkspaceServiceError(504, message);
|
|
53
|
+
}
|
|
54
|
+
if (message.startsWith('not_found:')) {
|
|
55
|
+
return new AgentWorkspaceServiceError(404, message.slice('not_found:'.length));
|
|
56
|
+
}
|
|
57
|
+
if (message.startsWith('path_outside_workspace:')) {
|
|
58
|
+
return new AgentWorkspaceServiceError(400, message.slice('path_outside_workspace:'.length));
|
|
59
|
+
}
|
|
60
|
+
if (message.startsWith('binary_file:')) {
|
|
61
|
+
return new AgentWorkspaceServiceError(415, message.slice('binary_file:'.length));
|
|
62
|
+
}
|
|
63
|
+
if (message.startsWith('file_too_large:')) {
|
|
64
|
+
return new AgentWorkspaceServiceError(413, message.slice('file_too_large:'.length));
|
|
65
|
+
}
|
|
66
|
+
if (message.startsWith('not_directory:') || message.startsWith('not_file:')) {
|
|
67
|
+
return new AgentWorkspaceServiceError(400, message.slice(message.indexOf(':') + 1));
|
|
68
|
+
}
|
|
69
|
+
return new AgentWorkspaceServiceError(500, message);
|
|
70
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export function readPackageVersion(packageRootFromDist) {
|
|
5
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const packageJsonPath = path.resolve(currentDir, packageRootFromDist, 'package.json');
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
9
|
+
return typeof parsed.version === 'string' && parsed.version.trim() ? parsed.version.trim() : '0.0.0';
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return '0.0.0';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import { createHash, randomUUID, randomBytes } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
// Use bcryptjs for password hashing (pure JS, no native dependencies)
|
|
4
|
+
import bcrypt from 'bcryptjs';
|
|
5
|
+
const SALT_ROUNDS = 10;
|
|
6
|
+
const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
7
|
+
const INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
8
|
+
export const AUTH_RATE_LIMIT_MAX_FAILURES = 5;
|
|
9
|
+
export const AUTH_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
10
|
+
export const AUTH_RATE_LIMIT_LOCK_MS = 15 * 60 * 1000;
|
|
11
|
+
export const AUTH_RATE_LIMIT_ERROR = 'Too many authentication attempts. Please try again later.';
|
|
12
|
+
const AUTH_RATE_LIMIT_MAX_SUBJECT_LENGTH = 256;
|
|
13
|
+
function mapPublicUserRow(row) {
|
|
14
|
+
return {
|
|
15
|
+
id: row.id,
|
|
16
|
+
username: row.username,
|
|
17
|
+
isAdmin: row.is_admin === 1,
|
|
18
|
+
avatarUrl: row.avatar_url?.trim() || null,
|
|
19
|
+
createdAt: row.created_at,
|
|
20
|
+
updatedAt: row.updated_at,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function mapUserRow(row) {
|
|
24
|
+
return {
|
|
25
|
+
...mapPublicUserRow(row),
|
|
26
|
+
securityCodeEnabled: Boolean(row.security_code_hash?.trim()),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function normalizeNullableText(value) {
|
|
30
|
+
const trimmed = value?.trim();
|
|
31
|
+
return trimmed ? trimmed.slice(0, 255) : null;
|
|
32
|
+
}
|
|
33
|
+
function maskSessionIp(value) {
|
|
34
|
+
const normalized = normalizeNullableText(value);
|
|
35
|
+
if (!normalized)
|
|
36
|
+
return null;
|
|
37
|
+
const ip = normalized.split('%', 1)[0];
|
|
38
|
+
const embeddedIpv4 = ip.match(/\b(\d{1,3}(?:\.\d{1,3}){3})\b/)?.[1];
|
|
39
|
+
const ipv4 = isIP(ip) === 4 ? ip : embeddedIpv4 && isIP(embeddedIpv4) === 4 ? embeddedIpv4 : null;
|
|
40
|
+
if (ipv4) {
|
|
41
|
+
const parts = ipv4.split('.');
|
|
42
|
+
return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
|
|
43
|
+
}
|
|
44
|
+
if (isIP(ip) === 6) {
|
|
45
|
+
const [left, right = ''] = ip.toLowerCase().split('::');
|
|
46
|
+
const leftParts = left ? left.split(':').filter(Boolean) : [];
|
|
47
|
+
const rightParts = right ? right.split(':').filter(Boolean) : [];
|
|
48
|
+
const missing = Math.max(0, 8 - leftParts.length - rightParts.length);
|
|
49
|
+
const parts = [...leftParts, ...Array(missing).fill('0'), ...rightParts].slice(0, 8);
|
|
50
|
+
const prefix = parts.slice(0, 3).map((part) => (parseInt(part || '0', 16) || 0).toString(16));
|
|
51
|
+
return `${prefix.join(':')}::/48`;
|
|
52
|
+
}
|
|
53
|
+
return 'Unknown';
|
|
54
|
+
}
|
|
55
|
+
function parseSessionClientInfo(info = {}) {
|
|
56
|
+
const userAgent = info.userAgent ?? '';
|
|
57
|
+
const ua = userAgent.toLowerCase();
|
|
58
|
+
let browser = 'Unknown';
|
|
59
|
+
if (ua.includes('edg/'))
|
|
60
|
+
browser = 'Edge';
|
|
61
|
+
else if (ua.includes('firefox/'))
|
|
62
|
+
browser = 'Firefox';
|
|
63
|
+
else if (ua.includes('crios/') || ua.includes('chrome/'))
|
|
64
|
+
browser = 'Chrome';
|
|
65
|
+
else if ((ua.includes('safari/') && ua.includes('mobile/')) || ua.includes('version/') && ua.includes('mobile/'))
|
|
66
|
+
browser = 'Mobile Safari';
|
|
67
|
+
else if (ua.includes('safari/'))
|
|
68
|
+
browser = 'Safari';
|
|
69
|
+
let os = 'Unknown';
|
|
70
|
+
if (ua.includes('windows nt'))
|
|
71
|
+
os = 'Windows';
|
|
72
|
+
else if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ipod'))
|
|
73
|
+
os = 'iOS';
|
|
74
|
+
else if (ua.includes('android'))
|
|
75
|
+
os = 'Android';
|
|
76
|
+
else if (ua.includes('mac os x') || ua.includes('macintosh'))
|
|
77
|
+
os = 'macOS';
|
|
78
|
+
else if (ua.includes('linux'))
|
|
79
|
+
os = 'Linux';
|
|
80
|
+
let deviceType = 'unknown';
|
|
81
|
+
if (ua.includes('bot') || ua.includes('spider') || ua.includes('crawler'))
|
|
82
|
+
deviceType = 'bot';
|
|
83
|
+
else if (ua.includes('ipad') || ua.includes('tablet'))
|
|
84
|
+
deviceType = 'tablet';
|
|
85
|
+
else if (ua.includes('mobile') || ua.includes('iphone') || (ua.includes('android') && !ua.includes('tablet')))
|
|
86
|
+
deviceType = 'mobile';
|
|
87
|
+
else if (ua.trim())
|
|
88
|
+
deviceType = 'desktop';
|
|
89
|
+
return {
|
|
90
|
+
ip: maskSessionIp(info.ip),
|
|
91
|
+
userAgentFamily: browser,
|
|
92
|
+
osFamily: os,
|
|
93
|
+
deviceType,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function userSessionMetadataColumnsExist(db) {
|
|
97
|
+
const rows = db.prepare("PRAGMA table_info('user_sessions')").all();
|
|
98
|
+
const names = new Set(rows.map((row) => row.name));
|
|
99
|
+
return (names.has('ip')
|
|
100
|
+
&& names.has('user_agent_family')
|
|
101
|
+
&& names.has('os_family')
|
|
102
|
+
&& names.has('device_type')
|
|
103
|
+
&& names.has('last_seen_ip')
|
|
104
|
+
&& names.has('last_seen_user_agent_family')
|
|
105
|
+
&& names.has('last_seen_os_family')
|
|
106
|
+
&& names.has('last_seen_device_type'));
|
|
107
|
+
}
|
|
108
|
+
// Generate a secure random token
|
|
109
|
+
function generateSecureToken() {
|
|
110
|
+
return randomBytes(32).toString('hex');
|
|
111
|
+
}
|
|
112
|
+
// Hash password using bcrypt
|
|
113
|
+
export async function hashPassword(password) {
|
|
114
|
+
return bcrypt.hash(password, SALT_ROUNDS);
|
|
115
|
+
}
|
|
116
|
+
// Verify password
|
|
117
|
+
export async function verifyPassword(password, hash) {
|
|
118
|
+
return bcrypt.compare(password, hash);
|
|
119
|
+
}
|
|
120
|
+
export function isValidSecurityCode(securityCode) {
|
|
121
|
+
return typeof securityCode === 'string' && /^\d{6}$/.test(securityCode);
|
|
122
|
+
}
|
|
123
|
+
export function hasSurroundingWhitespace(value) {
|
|
124
|
+
return value.trim() !== value;
|
|
125
|
+
}
|
|
126
|
+
export function getPasswordWhitespaceValidationError(password, label = 'Password') {
|
|
127
|
+
if (typeof password !== 'string' || !hasSurroundingWhitespace(password))
|
|
128
|
+
return null;
|
|
129
|
+
return `${label} cannot start or end with spaces`;
|
|
130
|
+
}
|
|
131
|
+
function getStoredPasswordValidationError(password) {
|
|
132
|
+
return getPasswordWhitespaceValidationError(password);
|
|
133
|
+
}
|
|
134
|
+
function normalizeRateLimitSubject(subject) {
|
|
135
|
+
const trimmed = subject.trim();
|
|
136
|
+
if (trimmed.length <= AUTH_RATE_LIMIT_MAX_SUBJECT_LENGTH)
|
|
137
|
+
return trimmed;
|
|
138
|
+
return `sha256:${createHash('sha256').update(trimmed).digest('hex')}`;
|
|
139
|
+
}
|
|
140
|
+
function normalizeRateLimitIp(ip) {
|
|
141
|
+
return ip.trim() || 'unknown';
|
|
142
|
+
}
|
|
143
|
+
export function pruneExpiredAuthRateLimits(db, now = Date.now()) {
|
|
144
|
+
const expiredWindowStartedBefore = now - AUTH_RATE_LIMIT_WINDOW_MS;
|
|
145
|
+
db.prepare(`DELETE FROM auth_rate_limits
|
|
146
|
+
WHERE locked_until IS NULL
|
|
147
|
+
AND window_started_at <= ?`).run(expiredWindowStartedBefore);
|
|
148
|
+
db.prepare(`DELETE FROM auth_rate_limits
|
|
149
|
+
WHERE locked_until IS NOT NULL
|
|
150
|
+
AND locked_until <= ?`).run(now);
|
|
151
|
+
}
|
|
152
|
+
export function checkAuthRateLimit(db, params) {
|
|
153
|
+
const now = params.now ?? Date.now();
|
|
154
|
+
const subject = normalizeRateLimitSubject(params.subject);
|
|
155
|
+
const ip = normalizeRateLimitIp(params.ip);
|
|
156
|
+
const row = db.prepare(`SELECT locked_until as lockedUntil
|
|
157
|
+
FROM auth_rate_limits
|
|
158
|
+
WHERE bucket = ? AND subject = ? AND ip = ?`).get(params.bucket, subject, ip);
|
|
159
|
+
if (!row?.lockedUntil || row.lockedUntil <= now) {
|
|
160
|
+
return { limited: false, retryAfterMs: 0, lockedUntil: null };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
limited: true,
|
|
164
|
+
retryAfterMs: row.lockedUntil - now,
|
|
165
|
+
lockedUntil: row.lockedUntil,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function recordAuthFailure(db, params) {
|
|
169
|
+
const now = params.now ?? Date.now();
|
|
170
|
+
pruneExpiredAuthRateLimits(db, now);
|
|
171
|
+
const subject = normalizeRateLimitSubject(params.subject);
|
|
172
|
+
const ip = normalizeRateLimitIp(params.ip);
|
|
173
|
+
const row = db.prepare(`SELECT failure_count as failureCount,
|
|
174
|
+
window_started_at as windowStartedAt,
|
|
175
|
+
locked_until as lockedUntil
|
|
176
|
+
FROM auth_rate_limits
|
|
177
|
+
WHERE bucket = ? AND subject = ? AND ip = ?`).get(params.bucket, subject, ip);
|
|
178
|
+
if (row?.lockedUntil && row.lockedUntil > now) {
|
|
179
|
+
return {
|
|
180
|
+
limited: true,
|
|
181
|
+
retryAfterMs: row.lockedUntil - now,
|
|
182
|
+
lockedUntil: row.lockedUntil,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const withinWindow = row && now - row.windowStartedAt < AUTH_RATE_LIMIT_WINDOW_MS;
|
|
186
|
+
const failureCount = withinWindow ? row.failureCount + 1 : 1;
|
|
187
|
+
const windowStartedAt = withinWindow ? row.windowStartedAt : now;
|
|
188
|
+
const lockedUntil = failureCount >= AUTH_RATE_LIMIT_MAX_FAILURES
|
|
189
|
+
? now + AUTH_RATE_LIMIT_LOCK_MS
|
|
190
|
+
: null;
|
|
191
|
+
db.prepare(`INSERT INTO auth_rate_limits (
|
|
192
|
+
bucket, subject, ip, failure_count, window_started_at, locked_until, last_failed_at
|
|
193
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
194
|
+
ON CONFLICT(bucket, subject, ip) DO UPDATE SET
|
|
195
|
+
failure_count = excluded.failure_count,
|
|
196
|
+
window_started_at = excluded.window_started_at,
|
|
197
|
+
locked_until = excluded.locked_until,
|
|
198
|
+
last_failed_at = excluded.last_failed_at`).run(params.bucket, subject, ip, failureCount, windowStartedAt, lockedUntil, now);
|
|
199
|
+
return { limited: false, retryAfterMs: 0, lockedUntil };
|
|
200
|
+
}
|
|
201
|
+
export function clearAuthFailures(db, params) {
|
|
202
|
+
const subject = normalizeRateLimitSubject(params.subject);
|
|
203
|
+
const ip = normalizeRateLimitIp(params.ip);
|
|
204
|
+
if (params.bucket) {
|
|
205
|
+
db.prepare('DELETE FROM auth_rate_limits WHERE bucket = ? AND subject = ? AND ip = ?').run(params.bucket, subject, ip);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
db.prepare('DELETE FROM auth_rate_limits WHERE subject = ? AND ip = ?').run(subject, ip);
|
|
209
|
+
}
|
|
210
|
+
export function getLoginAuthRateLimitIdentity(db, username) {
|
|
211
|
+
const row = db.prepare(`SELECT username, security_code_hash
|
|
212
|
+
FROM users
|
|
213
|
+
WHERE username = ?`).get(username);
|
|
214
|
+
if (!row)
|
|
215
|
+
return null;
|
|
216
|
+
return {
|
|
217
|
+
subject: row.username,
|
|
218
|
+
securityCodeEnabled: Boolean(row.security_code_hash?.trim()),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// Check if setup is complete (has at least one admin user)
|
|
222
|
+
export function hasAdminUser(db) {
|
|
223
|
+
const row = db.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get();
|
|
224
|
+
return row.count > 0;
|
|
225
|
+
}
|
|
226
|
+
// Check if any users exist
|
|
227
|
+
export function hasAnyUser(db) {
|
|
228
|
+
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
|
229
|
+
return row.count > 0;
|
|
230
|
+
}
|
|
231
|
+
// Create initial admin user during setup
|
|
232
|
+
export async function createAdminUser(db, username, password, clientInfo) {
|
|
233
|
+
const passwordValidationError = getStoredPasswordValidationError(password);
|
|
234
|
+
if (passwordValidationError) {
|
|
235
|
+
return { success: false, error: passwordValidationError };
|
|
236
|
+
}
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const userId = randomUUID();
|
|
239
|
+
const passwordHash = await hashPassword(password);
|
|
240
|
+
try {
|
|
241
|
+
db.prepare(`INSERT INTO users (id, username, password_hash, is_admin, created_at, updated_at)
|
|
242
|
+
VALUES (?, ?, ?, 1, ?, ?)`).run(userId, username, passwordHash, now, now);
|
|
243
|
+
const user = {
|
|
244
|
+
id: userId,
|
|
245
|
+
username,
|
|
246
|
+
isAdmin: true,
|
|
247
|
+
avatarUrl: null,
|
|
248
|
+
securityCodeEnabled: false,
|
|
249
|
+
createdAt: now,
|
|
250
|
+
updatedAt: now,
|
|
251
|
+
};
|
|
252
|
+
const session = createSession(db, userId, clientInfo);
|
|
253
|
+
return { success: true, user, session };
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
257
|
+
if (message.includes('UNIQUE constraint failed')) {
|
|
258
|
+
return { success: false, error: 'Username already exists' };
|
|
259
|
+
}
|
|
260
|
+
return { success: false, error: message };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Create a new user (admin only)
|
|
264
|
+
export async function createUser(db, username, password, isAdmin = false) {
|
|
265
|
+
const passwordValidationError = getStoredPasswordValidationError(password);
|
|
266
|
+
if (passwordValidationError) {
|
|
267
|
+
return { success: false, error: passwordValidationError };
|
|
268
|
+
}
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
const userId = randomUUID();
|
|
271
|
+
const passwordHash = await hashPassword(password);
|
|
272
|
+
try {
|
|
273
|
+
db.prepare(`INSERT INTO users (id, username, password_hash, is_admin, created_at, updated_at)
|
|
274
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(userId, username, passwordHash, isAdmin ? 1 : 0, now, now);
|
|
275
|
+
const user = {
|
|
276
|
+
id: userId,
|
|
277
|
+
username,
|
|
278
|
+
isAdmin,
|
|
279
|
+
avatarUrl: null,
|
|
280
|
+
securityCodeEnabled: false,
|
|
281
|
+
createdAt: now,
|
|
282
|
+
updatedAt: now,
|
|
283
|
+
};
|
|
284
|
+
return { success: true, user };
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
288
|
+
if (message.includes('UNIQUE constraint failed')) {
|
|
289
|
+
return { success: false, error: 'Username already exists' };
|
|
290
|
+
}
|
|
291
|
+
return { success: false, error: message };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Create a session for a user
|
|
295
|
+
export function createSession(db, userId, clientInfo) {
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
const sessionId = randomUUID();
|
|
298
|
+
const token = generateSecureToken();
|
|
299
|
+
const expiresAt = now + SESSION_EXPIRY_MS;
|
|
300
|
+
const metadata = parseSessionClientInfo(clientInfo);
|
|
301
|
+
if (userSessionMetadataColumnsExist(db)) {
|
|
302
|
+
db.prepare(`INSERT INTO user_sessions (
|
|
303
|
+
id, user_id, token, created_at, expires_at, last_used_at,
|
|
304
|
+
ip, user_agent_family, os_family, device_type,
|
|
305
|
+
last_seen_ip, last_seen_user_agent_family, last_seen_os_family, last_seen_device_type
|
|
306
|
+
)
|
|
307
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sessionId, userId, token, now, expiresAt, now, metadata.ip, metadata.userAgentFamily, metadata.osFamily, metadata.deviceType, metadata.ip, metadata.userAgentFamily, metadata.osFamily, metadata.deviceType);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
db.prepare(`INSERT INTO user_sessions (id, user_id, token, created_at, expires_at, last_used_at)
|
|
311
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, userId, token, now, expiresAt, now);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
id: sessionId,
|
|
315
|
+
userId,
|
|
316
|
+
token,
|
|
317
|
+
createdAt: now,
|
|
318
|
+
expiresAt,
|
|
319
|
+
lastUsedAt: now,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// Validate session token
|
|
323
|
+
export function validateSession(db, token, clientInfo) {
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
const sessionRow = db.prepare(`SELECT s.id as sessionId, s.user_id as userId, s.expires_at as expiresAt
|
|
326
|
+
FROM user_sessions s
|
|
327
|
+
WHERE s.token = ?`).get(token);
|
|
328
|
+
if (!sessionRow)
|
|
329
|
+
return null;
|
|
330
|
+
// Check if session is expired
|
|
331
|
+
if (sessionRow.expiresAt && now > sessionRow.expiresAt) {
|
|
332
|
+
// Delete expired session
|
|
333
|
+
db.prepare('DELETE FROM user_sessions WHERE id = ?').run(sessionRow.sessionId);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const metadata = parseSessionClientInfo(clientInfo);
|
|
337
|
+
const hasUserAgent = Boolean(clientInfo?.userAgent?.trim());
|
|
338
|
+
if (userSessionMetadataColumnsExist(db)) {
|
|
339
|
+
db.prepare(`UPDATE user_sessions
|
|
340
|
+
SET last_used_at = ?,
|
|
341
|
+
last_seen_ip = COALESCE(?, last_seen_ip),
|
|
342
|
+
last_seen_user_agent_family = COALESCE(?, last_seen_user_agent_family),
|
|
343
|
+
last_seen_os_family = COALESCE(?, last_seen_os_family),
|
|
344
|
+
last_seen_device_type = COALESCE(?, last_seen_device_type)
|
|
345
|
+
WHERE id = ?`).run(now, metadata.ip, hasUserAgent ? metadata.userAgentFamily : null, hasUserAgent ? metadata.osFamily : null, hasUserAgent ? metadata.deviceType : null, sessionRow.sessionId);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
db.prepare('UPDATE user_sessions SET last_used_at = ? WHERE id = ?').run(now, sessionRow.sessionId);
|
|
349
|
+
}
|
|
350
|
+
// Get user info
|
|
351
|
+
const userRow = db.prepare(`SELECT id, username, is_admin, avatar_url, security_code_hash, created_at, updated_at
|
|
352
|
+
FROM users WHERE id = ?`).get(sessionRow.userId);
|
|
353
|
+
if (!userRow)
|
|
354
|
+
return null;
|
|
355
|
+
return mapUserRow(userRow);
|
|
356
|
+
}
|
|
357
|
+
// Login user
|
|
358
|
+
export async function loginUser(db, username, password, securityCode, clientInfo) {
|
|
359
|
+
const userRow = db.prepare(`SELECT id, username, password_hash, is_admin, avatar_url, security_code_hash, created_at, updated_at
|
|
360
|
+
FROM users WHERE username = ?`).get(username);
|
|
361
|
+
if (!userRow) {
|
|
362
|
+
return { success: false, error: 'Invalid credentials', failureBucket: 'password' };
|
|
363
|
+
}
|
|
364
|
+
const passwordValid = await verifyPassword(password, userRow.password_hash);
|
|
365
|
+
if (!passwordValid) {
|
|
366
|
+
return { success: false, error: 'Invalid credentials', failureBucket: 'password' };
|
|
367
|
+
}
|
|
368
|
+
if (userRow.security_code_hash) {
|
|
369
|
+
if (!isValidSecurityCode(securityCode)) {
|
|
370
|
+
return { success: false, error: 'Invalid credentials', failureBucket: 'security_code' };
|
|
371
|
+
}
|
|
372
|
+
const securityCodeValid = await verifyPassword(securityCode, userRow.security_code_hash);
|
|
373
|
+
if (!securityCodeValid) {
|
|
374
|
+
return { success: false, error: 'Invalid credentials', failureBucket: 'security_code' };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const user = mapUserRow(userRow);
|
|
378
|
+
const session = createSession(db, userRow.id, clientInfo);
|
|
379
|
+
return { success: true, user, session };
|
|
380
|
+
}
|
|
381
|
+
// Logout user (delete session)
|
|
382
|
+
export function logoutUser(db, token) {
|
|
383
|
+
const result = db.prepare('DELETE FROM user_sessions WHERE token = ?').run(token);
|
|
384
|
+
return result.changes > 0;
|
|
385
|
+
}
|
|
386
|
+
export function listUserSessions(db, params) {
|
|
387
|
+
const now = params.now ?? Date.now();
|
|
388
|
+
const hasMetadataColumns = userSessionMetadataColumnsExist(db);
|
|
389
|
+
const rows = hasMetadataColumns
|
|
390
|
+
? db.prepare(`SELECT id, token, created_at, expires_at, last_used_at,
|
|
391
|
+
ip, user_agent_family, os_family, device_type,
|
|
392
|
+
last_seen_ip, last_seen_user_agent_family, last_seen_os_family, last_seen_device_type
|
|
393
|
+
FROM user_sessions
|
|
394
|
+
WHERE user_id = ?
|
|
395
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
396
|
+
ORDER BY COALESCE(last_used_at, created_at) DESC`).all(params.userId, now)
|
|
397
|
+
: db.prepare(`SELECT id, token, created_at, expires_at, last_used_at
|
|
398
|
+
FROM user_sessions
|
|
399
|
+
WHERE user_id = ?
|
|
400
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
401
|
+
ORDER BY COALESCE(last_used_at, created_at) DESC`).all(params.userId, now).map((row) => ({
|
|
402
|
+
...row,
|
|
403
|
+
ip: null,
|
|
404
|
+
user_agent_family: null,
|
|
405
|
+
os_family: null,
|
|
406
|
+
device_type: null,
|
|
407
|
+
last_seen_ip: null,
|
|
408
|
+
last_seen_user_agent_family: null,
|
|
409
|
+
last_seen_os_family: null,
|
|
410
|
+
last_seen_device_type: null,
|
|
411
|
+
}));
|
|
412
|
+
const sessions = rows.map((row) => ({
|
|
413
|
+
id: row.id,
|
|
414
|
+
current: row.token === params.currentToken,
|
|
415
|
+
createdAt: row.created_at,
|
|
416
|
+
lastUsedAt: row.last_used_at,
|
|
417
|
+
expiresAt: row.expires_at,
|
|
418
|
+
ip: maskSessionIp(row.last_seen_ip ?? row.ip),
|
|
419
|
+
deviceType: row.last_seen_device_type ?? row.device_type ?? 'unknown',
|
|
420
|
+
browser: row.last_seen_user_agent_family ?? row.user_agent_family ?? 'Unknown',
|
|
421
|
+
os: row.last_seen_os_family ?? row.os_family ?? 'Unknown',
|
|
422
|
+
}));
|
|
423
|
+
return { activeCount: sessions.length, sessions };
|
|
424
|
+
}
|
|
425
|
+
export function revokeUserSession(db, params) {
|
|
426
|
+
const row = db.prepare('SELECT token FROM user_sessions WHERE id = ? AND user_id = ?').get(params.sessionId, params.userId);
|
|
427
|
+
if (!row)
|
|
428
|
+
return { ok: false, reason: 'not_found' };
|
|
429
|
+
if (row.token === params.currentToken)
|
|
430
|
+
return { ok: false, reason: 'current' };
|
|
431
|
+
db.prepare('DELETE FROM user_sessions WHERE id = ? AND user_id = ?').run(params.sessionId, params.userId);
|
|
432
|
+
return { ok: true };
|
|
433
|
+
}
|
|
434
|
+
export function revokeOtherUserSessions(db, userId, currentToken) {
|
|
435
|
+
const result = db.prepare('DELETE FROM user_sessions WHERE user_id = ? AND token <> ?').run(userId, currentToken);
|
|
436
|
+
return result.changes;
|
|
437
|
+
}
|
|
438
|
+
// Create an invite token
|
|
439
|
+
export function createInviteToken(db) {
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
const id = randomUUID();
|
|
442
|
+
const token = generateSecureToken();
|
|
443
|
+
const expiresAt = now + INVITE_EXPIRY_MS;
|
|
444
|
+
db.prepare(`INSERT INTO invite_tokens (id, token, used, expires_at, created_at, used_at, used_by)
|
|
445
|
+
VALUES (?, ?, 0, ?, ?, NULL, NULL)`).run(id, token, expiresAt, now);
|
|
446
|
+
return {
|
|
447
|
+
id,
|
|
448
|
+
token,
|
|
449
|
+
used: false,
|
|
450
|
+
expiresAt,
|
|
451
|
+
createdAt: now,
|
|
452
|
+
usedAt: null,
|
|
453
|
+
usedBy: null,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// Validate an invite token
|
|
457
|
+
export function validateInviteToken(db, token) {
|
|
458
|
+
const now = Date.now();
|
|
459
|
+
const row = db.prepare(`SELECT id, used, expires_at FROM invite_tokens WHERE token = ?`).get(token);
|
|
460
|
+
if (!row) {
|
|
461
|
+
return { valid: false, error: 'Invalid invite token' };
|
|
462
|
+
}
|
|
463
|
+
if (row.used === 1) {
|
|
464
|
+
return { valid: false, error: 'Invite token has already been used' };
|
|
465
|
+
}
|
|
466
|
+
if (now > row.expires_at) {
|
|
467
|
+
return { valid: false, error: 'Invite token has expired' };
|
|
468
|
+
}
|
|
469
|
+
return { valid: true };
|
|
470
|
+
}
|
|
471
|
+
// Mark invite token as used
|
|
472
|
+
export function useInviteToken(db, token, userId) {
|
|
473
|
+
const now = Date.now();
|
|
474
|
+
db.prepare(`UPDATE invite_tokens SET used = 1, used_at = ?, used_by = ? WHERE token = ?`).run(now, userId, token);
|
|
475
|
+
}
|
|
476
|
+
// Setup with invite token — first invite creates admin, subsequent ones create regular users
|
|
477
|
+
export async function setupWithInvite(db, token, username, password, clientInfo) {
|
|
478
|
+
// Validate invite token
|
|
479
|
+
const validation = validateInviteToken(db, token);
|
|
480
|
+
if (!validation.valid) {
|
|
481
|
+
return { success: false, error: validation.error };
|
|
482
|
+
}
|
|
483
|
+
// First user becomes admin, everyone else is a regular user
|
|
484
|
+
const isFirstAdmin = !hasAdminUser(db);
|
|
485
|
+
const result = isFirstAdmin
|
|
486
|
+
? await createAdminUser(db, username, password, clientInfo)
|
|
487
|
+
: await createUser(db, username, password, false);
|
|
488
|
+
if (!result.success) {
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
// Mark token as used
|
|
492
|
+
if (result.user) {
|
|
493
|
+
useInviteToken(db, token, result.user.id);
|
|
494
|
+
}
|
|
495
|
+
if (result.success && result.user && !result.session) {
|
|
496
|
+
result.session = createSession(db, result.user.id, clientInfo);
|
|
497
|
+
}
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
// Get user by ID
|
|
501
|
+
export function getUserById(db, userId) {
|
|
502
|
+
const row = db.prepare(`SELECT id, username, is_admin, avatar_url, security_code_hash, created_at, updated_at
|
|
503
|
+
FROM users WHERE id = ?`).get(userId);
|
|
504
|
+
if (!row)
|
|
505
|
+
return null;
|
|
506
|
+
return mapUserRow(row);
|
|
507
|
+
}
|
|
508
|
+
export function updateUserProfile(db, userId, update) {
|
|
509
|
+
const now = Date.now();
|
|
510
|
+
if (Object.prototype.hasOwnProperty.call(update, 'avatarUrl')) {
|
|
511
|
+
const avatarUrl = update.avatarUrl?.trim() || null;
|
|
512
|
+
db.prepare('UPDATE users SET avatar_url = ?, updated_at = ? WHERE id = ?').run(avatarUrl, now, userId);
|
|
513
|
+
}
|
|
514
|
+
return getUserById(db, userId);
|
|
515
|
+
}
|
|
516
|
+
// List all users
|
|
517
|
+
export function listUsers(db) {
|
|
518
|
+
const rows = db.prepare(`SELECT id, username, is_admin, avatar_url, security_code_hash, created_at, updated_at
|
|
519
|
+
FROM users ORDER BY created_at DESC`).all();
|
|
520
|
+
return rows.map(mapPublicUserRow);
|
|
521
|
+
}
|
|
522
|
+
export async function setUserSecurityCode(db, userId, securityCode) {
|
|
523
|
+
const now = Date.now();
|
|
524
|
+
const securityCodeHash = await hashPassword(securityCode);
|
|
525
|
+
db.prepare('UPDATE users SET security_code_hash = ?, updated_at = ? WHERE id = ?').run(securityCodeHash, now, userId);
|
|
526
|
+
return getUserById(db, userId);
|
|
527
|
+
}
|
|
528
|
+
export function disableUserSecurityCode(db, userId) {
|
|
529
|
+
const now = Date.now();
|
|
530
|
+
db.prepare('UPDATE users SET security_code_hash = NULL, updated_at = ? WHERE id = ?').run(now, userId);
|
|
531
|
+
return getUserById(db, userId);
|
|
532
|
+
}
|
|
533
|
+
export function revokeOtherSessions(db, userId, currentToken) {
|
|
534
|
+
db.prepare('DELETE FROM user_sessions WHERE user_id = ? AND token <> ?').run(userId, currentToken);
|
|
535
|
+
}
|
|
536
|
+
// Delete user
|
|
537
|
+
export function deleteUser(db, userId) {
|
|
538
|
+
const result = db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
|
539
|
+
if (result.changes > 0) {
|
|
540
|
+
// Also delete all sessions for this user
|
|
541
|
+
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(userId);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
// Cleanup expired sessions and invite tokens
|
|
547
|
+
export function cleanupExpiredTokens(db) {
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
db.prepare('DELETE FROM user_sessions WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
|
|
550
|
+
db.prepare('DELETE FROM invite_tokens WHERE expires_at < ?').run(now);
|
|
551
|
+
}
|
|
552
|
+
// Get agent IDs the user has been granted access to
|
|
553
|
+
export function getUserAgentAccess(db, userId) {
|
|
554
|
+
const rows = db
|
|
555
|
+
.prepare(`SELECT uaa.agent_id
|
|
556
|
+
FROM user_agent_access uaa
|
|
557
|
+
JOIN agents a ON a.agent_id = uaa.agent_id
|
|
558
|
+
WHERE uaa.user_id = ?
|
|
559
|
+
AND a.deleted_at IS NULL`)
|
|
560
|
+
.all(userId);
|
|
561
|
+
return rows.map((r) => r.agent_id);
|
|
562
|
+
}
|
|
563
|
+
// Get channel IDs the user has been granted access to
|
|
564
|
+
export function getUserChannelAccess(db, userId) {
|
|
565
|
+
const rows = db
|
|
566
|
+
.prepare('SELECT channel_id FROM user_channel_access WHERE user_id = ?')
|
|
567
|
+
.all(userId);
|
|
568
|
+
return rows.map((r) => r.channel_id);
|
|
569
|
+
}
|
|
570
|
+
// Replace all access grants for a user atomically
|
|
571
|
+
export function setUserAccess(db, userId, agentIds, channelIds) {
|
|
572
|
+
const now = Date.now();
|
|
573
|
+
const tx = db.transaction(() => {
|
|
574
|
+
db.prepare('DELETE FROM user_agent_access WHERE user_id = ?').run(userId);
|
|
575
|
+
db.prepare('DELETE FROM user_channel_access WHERE user_id = ?').run(userId);
|
|
576
|
+
const insertAgent = db.prepare('INSERT INTO user_agent_access (user_id, agent_id, granted_at) VALUES (?, ?, ?)');
|
|
577
|
+
for (const agentId of agentIds) {
|
|
578
|
+
insertAgent.run(userId, agentId, now);
|
|
579
|
+
}
|
|
580
|
+
const insertChannel = db.prepare('INSERT INTO user_channel_access (user_id, channel_id, granted_at) VALUES (?, ?, ?)');
|
|
581
|
+
for (const channelId of channelIds) {
|
|
582
|
+
insertChannel.run(userId, channelId, now);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
tx();
|
|
586
|
+
}
|