@straiffi/archon 1.1.3 → 1.2.1
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/README.md +4 -0
- package/dist/client/assets/TestsDialog-4_jJ_cnr.js +5 -0
- package/dist/client/assets/badge-Bpry9xkS.js +41 -0
- package/dist/client/assets/index-BgmAtYCf.js +151 -0
- package/dist/client/assets/index-Bw66dtZG.css +2 -0
- package/dist/client/index.html +3 -3
- package/dist/server/index.js +206 -445
- package/dist/server/index.js.map +1 -1
- package/dist/server/lib/chatOperations.js +126 -0
- package/dist/server/lib/chatOperations.js.map +1 -0
- package/dist/server/lib/desktopServerHost.js +16 -0
- package/dist/server/lib/desktopServerHost.js.map +1 -0
- package/dist/server/lib/mobileAccess.js +1051 -0
- package/dist/server/lib/mobileAccess.js.map +1 -0
- package/dist/server/lib/mobileAccessSecurity.js +42 -0
- package/dist/server/lib/mobileAccessSecurity.js.map +1 -0
- package/dist/server/lib/ngrok.js +144 -0
- package/dist/server/lib/ngrok.js.map +1 -0
- package/dist/server/lib/projects.js +1 -0
- package/dist/server/lib/projects.js.map +1 -1
- package/dist/server/lib/realtime.js +2 -0
- package/dist/server/lib/realtime.js.map +1 -0
- package/dist/server/lib/run.js +3 -0
- package/dist/server/lib/run.js.map +1 -1
- package/dist/server/lib/staticClient.js +14 -3
- package/dist/server/lib/staticClient.js.map +1 -1
- package/dist/server/lib/ticketFollowUpOperations.js +87 -0
- package/dist/server/lib/ticketFollowUpOperations.js.map +1 -0
- package/dist/server/lib/ticketRunOperations.js +333 -0
- package/dist/server/lib/ticketRunOperations.js.map +1 -0
- package/dist/server/lib/ticketSettingsOperations.js +62 -0
- package/dist/server/lib/ticketSettingsOperations.js.map +1 -0
- package/dist/server/lib/ticketWorkflowOperations.js +114 -0
- package/dist/server/lib/ticketWorkflowOperations.js.map +1 -0
- package/package.json +1 -1
- package/dist/client/assets/TestsDialog-Buw5J9nT.js +0 -5
- package/dist/client/assets/badge-BentDQnz.js +0 -41
- package/dist/client/assets/index-Dr_tNX7Y.css +0 -2
- package/dist/client/assets/index-kbsOL4Bp.js +0 -142
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createHash, randomBytes } from 'crypto';
|
|
3
|
+
import { createServer } from 'http';
|
|
4
|
+
import { networkInterfaces as readNetworkInterfaces } from 'os';
|
|
5
|
+
import { Server as SocketIoServer } from 'socket.io';
|
|
6
|
+
import { createProjectChatSession, stopProjectChatSessionResponse, submitProjectChatSessionMessage, updateProjectChatSessionMode } from './chatOperations.js';
|
|
7
|
+
import { DEFAULT_DESKTOP_BIND_HOST } from './desktopServerHost.js';
|
|
8
|
+
import { startManagedNgrokTunnel } from './ngrok.js';
|
|
9
|
+
import { STATIC_CLIENT_DIRECTORY_OPTIONS, STATIC_CLIENT_INDEX_OPTIONS } from './staticClient.js';
|
|
10
|
+
import { transitionTicketState } from './ticketWorkflowOperations.js';
|
|
11
|
+
import { resumeBuildTicketRun, runTicketRun, stopActiveRunByContextKey, stopBuildTicketRun, stopTicketRun } from './ticketRunOperations.js';
|
|
12
|
+
import { submitPlanTicketFollowUp, submitTicketFollowUp } from './ticketFollowUpOperations.js';
|
|
13
|
+
import { updateTicketAgentSettings } from './ticketSettingsOperations.js';
|
|
14
|
+
const DEFAULT_MOBILE_PORT = 43111;
|
|
15
|
+
const MOBILE_PORT_FALLBACK_COUNT = 20;
|
|
16
|
+
const PAIRING_TTL_MS = 2 * 60 * 1000;
|
|
17
|
+
const PAIRING_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
|
18
|
+
const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
|
|
19
|
+
const MOBILE_SESSION_COOKIE = 'archon_mobile_session';
|
|
20
|
+
const MOBILE_CSRF_HEADER = 'x-archon-mobile-csrf';
|
|
21
|
+
const CUSTOM_PUBLIC_URL_VALIDATION_ERROR = 'Custom public URL must be a valid HTTPS origin without a path, query, or fragment.';
|
|
22
|
+
const CUSTOM_PUBLIC_URL_PORT_IN_USE_ERROR = `Custom public URL mode requires local loopback port ${DEFAULT_MOBILE_PORT}, but it is already in use. Free that port and try again.`;
|
|
23
|
+
const MOBILE_REALTIME_EVENTS = new Set([
|
|
24
|
+
'ticket:created',
|
|
25
|
+
'ticket:updated',
|
|
26
|
+
'ticket:deleted',
|
|
27
|
+
'ticket-dependency:created',
|
|
28
|
+
'ticket-dependency:deleted',
|
|
29
|
+
'chat-session:created',
|
|
30
|
+
'chat-session:updated',
|
|
31
|
+
'chat-session:deleted',
|
|
32
|
+
]);
|
|
33
|
+
const hashSecret = (value) => {
|
|
34
|
+
return createHash('sha256').update(value).digest('hex');
|
|
35
|
+
};
|
|
36
|
+
const createId = () => randomBytes(16).toString('base64url');
|
|
37
|
+
const createSecret = () => randomBytes(32).toString('base64url');
|
|
38
|
+
const createDisplayCode = () => {
|
|
39
|
+
const value = randomBytes(4).toString('hex').toUpperCase();
|
|
40
|
+
return `${value.slice(0, 4)}-${value.slice(4)}`;
|
|
41
|
+
};
|
|
42
|
+
const isPrivateIpv4Address = (address) => {
|
|
43
|
+
const parts = address.split('.').map(part => Number(part));
|
|
44
|
+
if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const [first, second] = parts;
|
|
48
|
+
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168);
|
|
49
|
+
};
|
|
50
|
+
export const selectPrivateLanAddress = (networkInterfaces) => {
|
|
51
|
+
const candidates = Object.values(networkInterfaces)
|
|
52
|
+
.flatMap(entries => entries ?? [])
|
|
53
|
+
.filter(entry => entry.family === 'IPv4' && !entry.internal && isPrivateIpv4Address(entry.address));
|
|
54
|
+
const uniqueCandidates = Array.from(new Set(candidates.map(entry => entry.address)));
|
|
55
|
+
if (uniqueCandidates.length === 1) {
|
|
56
|
+
return { address: uniqueCandidates[0], error: null };
|
|
57
|
+
}
|
|
58
|
+
if (uniqueCandidates.length === 0) {
|
|
59
|
+
return { address: null, error: 'No private LAN IPv4 interface is available for mobile access.' };
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
address: null,
|
|
63
|
+
error: 'Multiple private LAN interfaces were found. Choose one explicitly before enabling mobile access.',
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
export const resolveMobileAccessPublicBaseUrl = (value) => {
|
|
67
|
+
const trimmedValue = value?.trim() ?? '';
|
|
68
|
+
if (trimmedValue.length === 0) {
|
|
69
|
+
return { baseUrl: null, error: null };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(trimmedValue);
|
|
73
|
+
if (url.protocol !== 'https:'
|
|
74
|
+
|| !url.host
|
|
75
|
+
|| url.pathname !== '/'
|
|
76
|
+
|| url.search.length > 0
|
|
77
|
+
|| url.hash.length > 0
|
|
78
|
+
|| url.username.length > 0
|
|
79
|
+
|| url.password.length > 0) {
|
|
80
|
+
return { baseUrl: null, error: CUSTOM_PUBLIC_URL_VALIDATION_ERROR };
|
|
81
|
+
}
|
|
82
|
+
return { baseUrl: url.origin, error: null };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return { baseUrl: null, error: CUSTOM_PUBLIC_URL_VALIDATION_ERROR };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const listenOnMobilePort = ({ app, host, preferredPort, allowPortFallback = true, configureServer }) => {
|
|
89
|
+
const tryListen = (port) => {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const server = createServer(app);
|
|
92
|
+
const socketServer = configureServer?.(server) ?? null;
|
|
93
|
+
const handleError = (error) => {
|
|
94
|
+
server.off('listening', handleListening);
|
|
95
|
+
socketServer?.disconnectSockets?.(true);
|
|
96
|
+
reject(error);
|
|
97
|
+
};
|
|
98
|
+
const handleListening = () => {
|
|
99
|
+
server.off('error', handleError);
|
|
100
|
+
const address = server.address();
|
|
101
|
+
resolve({
|
|
102
|
+
server,
|
|
103
|
+
port: typeof address === 'object' && address ? address.port : port,
|
|
104
|
+
socketServer,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
server.once('error', handleError);
|
|
108
|
+
server.once('listening', handleListening);
|
|
109
|
+
server.listen(port, host);
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
const attempt = async (offset) => {
|
|
113
|
+
try {
|
|
114
|
+
return await tryListen(preferredPort + offset);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (allowPortFallback && error.code === 'EADDRINUSE' && offset < MOBILE_PORT_FALLBACK_COUNT) {
|
|
118
|
+
return await attempt(offset + 1);
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
return attempt(0);
|
|
124
|
+
};
|
|
125
|
+
const parseCookie = (cookieHeader, name) => {
|
|
126
|
+
if (!cookieHeader) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
for (const entry of cookieHeader.split(';')) {
|
|
130
|
+
const [rawKey, ...rawValue] = entry.trim().split('=');
|
|
131
|
+
if (rawKey === name) {
|
|
132
|
+
return decodeURIComponent(rawValue.join('='));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
const setNoStore = (res) => {
|
|
138
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
139
|
+
};
|
|
140
|
+
const setNoReferrer = (res) => {
|
|
141
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
142
|
+
};
|
|
143
|
+
const getOriginHost = (value) => {
|
|
144
|
+
try {
|
|
145
|
+
return new URL(value).host.toLowerCase();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const listSerializedProjects = async () => {
|
|
152
|
+
const { listProjects, serializeProject } = await import('./projects.js');
|
|
153
|
+
return listProjects().map(serializeProject).filter(project => project !== null);
|
|
154
|
+
};
|
|
155
|
+
const getSerializedProjectSummary = async (projectId) => {
|
|
156
|
+
const [{ default: db }, { getProjectById, serializeProject }, { getActiveRunStatusForProject }] = await Promise.all([
|
|
157
|
+
import('../db.js'),
|
|
158
|
+
import('./projects.js'),
|
|
159
|
+
import('./run.js'),
|
|
160
|
+
]);
|
|
161
|
+
const project = serializeProject(getProjectById(projectId));
|
|
162
|
+
if (!project) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const stateRows = db.prepare(`
|
|
166
|
+
SELECT state, COUNT(*) AS count
|
|
167
|
+
FROM tickets
|
|
168
|
+
WHERE project_id = ?
|
|
169
|
+
AND parked_at IS NULL
|
|
170
|
+
GROUP BY state
|
|
171
|
+
`).all(projectId);
|
|
172
|
+
const counts = {
|
|
173
|
+
plan: 0,
|
|
174
|
+
build: 0,
|
|
175
|
+
review: 0,
|
|
176
|
+
};
|
|
177
|
+
for (const row of stateRows) {
|
|
178
|
+
counts[row.state] = Number(row.count ?? 0);
|
|
179
|
+
}
|
|
180
|
+
const parkedRow = db.prepare('SELECT COUNT(*) AS count FROM tickets WHERE project_id = ? AND parked_at IS NOT NULL').get(projectId);
|
|
181
|
+
const parked = Number(parkedRow?.count ?? 0);
|
|
182
|
+
return {
|
|
183
|
+
project,
|
|
184
|
+
tickets: {
|
|
185
|
+
total: project.ticket_count,
|
|
186
|
+
plan: counts.plan,
|
|
187
|
+
build: counts.build,
|
|
188
|
+
review: counts.review,
|
|
189
|
+
parked,
|
|
190
|
+
},
|
|
191
|
+
active_run: getActiveRunStatusForProject(project.id),
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
const listMobileChatSessions = async (projectId) => {
|
|
195
|
+
const [{ getProjectById }, { listChatSessions }] = await Promise.all([
|
|
196
|
+
import('./projects.js'),
|
|
197
|
+
import('./chats.js'),
|
|
198
|
+
]);
|
|
199
|
+
if (!getProjectById(projectId)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return listChatSessions(projectId);
|
|
203
|
+
};
|
|
204
|
+
const listMobileModels = async (tool) => {
|
|
205
|
+
if (tool !== 'opencode') {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
const { getDiscoveredModels } = await import('./models.js');
|
|
209
|
+
return await getDiscoveredModels(tool);
|
|
210
|
+
};
|
|
211
|
+
const listMobileSkills = async (tool, projectId) => {
|
|
212
|
+
if (tool !== 'opencode') {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const [{ getProjectById }, { getDiscoveredSkills }] = await Promise.all([
|
|
216
|
+
import('./projects.js'),
|
|
217
|
+
import('./skills.js'),
|
|
218
|
+
]);
|
|
219
|
+
const project = projectId ? getProjectById(projectId) : null;
|
|
220
|
+
return await getDiscoveredSkills(tool, { projectPath: project?.repo_path ?? null });
|
|
221
|
+
};
|
|
222
|
+
const listMobileTickets = async (projectId) => {
|
|
223
|
+
const [{ getProjectById }, { listTickets }] = await Promise.all([
|
|
224
|
+
import('./projects.js'),
|
|
225
|
+
import('./tickets.js'),
|
|
226
|
+
]);
|
|
227
|
+
if (!getProjectById(projectId)) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return listTickets(projectId, { detail: 'board' });
|
|
231
|
+
};
|
|
232
|
+
const getMobileTicket = async (projectId, ticketId) => {
|
|
233
|
+
const [{ getProjectById }, { getTicket }] = await Promise.all([
|
|
234
|
+
import('./projects.js'),
|
|
235
|
+
import('./tickets.js'),
|
|
236
|
+
]);
|
|
237
|
+
if (!getProjectById(projectId)) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const ticket = getTicket(ticketId);
|
|
241
|
+
if (!ticket || ticket.project_id !== projectId) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return ticket;
|
|
245
|
+
};
|
|
246
|
+
const getMobileChatSession = async (projectId, chatSessionId) => {
|
|
247
|
+
const [{ refreshChatSessionUsageSnapshot }, { getProjectById }, { getChatSession }] = await Promise.all([
|
|
248
|
+
import('./agent.js'),
|
|
249
|
+
import('./projects.js'),
|
|
250
|
+
import('./chats.js'),
|
|
251
|
+
]);
|
|
252
|
+
if (!getProjectById(projectId)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const chatSession = getChatSession(chatSessionId, { includeMessages: true });
|
|
256
|
+
if (!chatSession || chatSession.project_id !== projectId) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const refreshedChatSession = refreshChatSessionUsageSnapshot(chatSession.id, { includeMessages: true });
|
|
260
|
+
if (!refreshedChatSession || refreshedChatSession.project_id !== projectId) {
|
|
261
|
+
return chatSession;
|
|
262
|
+
}
|
|
263
|
+
return refreshedChatSession;
|
|
264
|
+
};
|
|
265
|
+
const createDefaultMobileSocketServer = (server, authenticate) => {
|
|
266
|
+
const socketServer = new SocketIoServer(server);
|
|
267
|
+
socketServer.use((socket, next) => {
|
|
268
|
+
const session = authenticate(socket.handshake.headers.cookie);
|
|
269
|
+
if (!session) {
|
|
270
|
+
next(new Error('Authentication required.'));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
socket.data.mobileSessionId = session.sessionId;
|
|
274
|
+
next();
|
|
275
|
+
});
|
|
276
|
+
return socketServer;
|
|
277
|
+
};
|
|
278
|
+
export const createMobileAccessController = (options = {}) => {
|
|
279
|
+
const getNetworkInterfaces = options.networkInterfaces ?? readNetworkInterfaces;
|
|
280
|
+
const listen = options.listen ?? listenOnMobilePort;
|
|
281
|
+
const getNow = options.now ?? (() => new Date());
|
|
282
|
+
const defaultClientBuild = options.clientBuild ?? null;
|
|
283
|
+
const listMobileProjects = options.listProjects ?? listSerializedProjects;
|
|
284
|
+
const getMobileProjectSummary = options.getProjectSummary ?? getSerializedProjectSummary;
|
|
285
|
+
const listProjectChatSessions = options.listChatSessions ?? listMobileChatSessions;
|
|
286
|
+
const getProjectChatSession = options.getChatSession ?? getMobileChatSession;
|
|
287
|
+
const listProjectTickets = options.listTickets ?? listMobileTickets;
|
|
288
|
+
const getProjectTicket = options.getTicket ?? getMobileTicket;
|
|
289
|
+
const listAgentModels = options.listModels ?? listMobileModels;
|
|
290
|
+
const listAgentSkills = options.listSkills ?? listMobileSkills;
|
|
291
|
+
const createMobileChatSession = options.createChatSession ?? ((projectId, payload, broadcaster) => createProjectChatSession({ projectId, payload, broadcaster }));
|
|
292
|
+
const updateMobileChatSession = options.updateChatSession ?? ((projectId, chatSessionId, payload, broadcaster) => updateProjectChatSessionMode({ projectId, chatSessionId, payload, broadcaster }));
|
|
293
|
+
const submitMobileChatMessage = options.submitChatMessage ?? ((projectId, chatSessionId, payload, broadcaster) => submitProjectChatSessionMessage({ projectId, chatSessionId, payload, broadcaster }));
|
|
294
|
+
const stopMobileChatSession = options.stopChatSession ?? ((projectId, chatSessionId) => stopProjectChatSessionResponse({ projectId, chatSessionId }));
|
|
295
|
+
const createSocketServer = options.createSocketServer ?? createDefaultMobileSocketServer;
|
|
296
|
+
const startManagedTunnel = options.startManagedTunnel ?? startManagedNgrokTunnel;
|
|
297
|
+
let operationBroadcaster = options.broadcaster ?? { emit: (event, payload) => emitRealtime(event, payload) };
|
|
298
|
+
let mobileReviewBundleTickets = options.reviewBundleTickets ?? (() => ({
|
|
299
|
+
ok: false,
|
|
300
|
+
status: 409,
|
|
301
|
+
error: 'Bundle review transitions are unavailable from mobile access.',
|
|
302
|
+
}));
|
|
303
|
+
const transitionMobileTicket = options.transitionTicket ?? ((projectId, ticketId, payload, broadcaster) => transitionTicketState({
|
|
304
|
+
ticketId,
|
|
305
|
+
requestedState: payload?.state,
|
|
306
|
+
requestedProjectId: projectId,
|
|
307
|
+
broadcaster,
|
|
308
|
+
reviewBundleTickets: mobileReviewBundleTickets,
|
|
309
|
+
}));
|
|
310
|
+
const runMobileTicket = options.runTicket ?? ((projectId, ticketId, broadcaster) => runTicketRun({ ticketId, requestedProjectId: projectId, broadcaster }));
|
|
311
|
+
const stopMobileTicket = options.stopTicket ?? ((projectId, ticketId, broadcaster) => stopTicketRun({ ticketId, requestedProjectId: projectId, broadcaster }));
|
|
312
|
+
const stopMobileRun = options.stopRun ?? ((runContextKey, broadcaster) => stopActiveRunByContextKey({ runContextKey, broadcaster }));
|
|
313
|
+
const stopMobileBuildTicket = options.stopBuildTicket ?? ((projectId, ticketId) => stopBuildTicketRun({ ticketId, requestedProjectId: projectId }));
|
|
314
|
+
const resumeMobileBuildTicket = options.resumeBuildTicket ?? ((projectId, ticketId, broadcaster) => resumeBuildTicketRun({ ticketId, requestedProjectId: projectId, broadcaster }));
|
|
315
|
+
const updateMobileTicketSettings = options.updateTicketSettings ?? ((projectId, ticketId, payload, broadcaster) => updateTicketAgentSettings({ ticketId, requestedProjectId: projectId, payload, broadcaster }));
|
|
316
|
+
const submitMobilePlanTicketFollowUp = options.submitPlanTicketFollowUp ?? ((projectId, ticketId, payload, broadcaster) => submitPlanTicketFollowUp({ ticketId, requestedProjectId: projectId, payload, broadcaster }));
|
|
317
|
+
const submitMobileTicketFollowUp = options.submitTicketFollowUp ?? ((projectId, ticketId, payload, broadcaster) => submitTicketFollowUp({ ticketId, requestedProjectId: projectId, payload, broadcaster }));
|
|
318
|
+
let mobileAccessId = null;
|
|
319
|
+
let mobileAccessMode = null;
|
|
320
|
+
let localListenerUrl = null;
|
|
321
|
+
let mobileUrl = null;
|
|
322
|
+
let managedTunnelStop = null;
|
|
323
|
+
let pairingChallenge = null;
|
|
324
|
+
let sessions = new Map();
|
|
325
|
+
let attempts = new Map();
|
|
326
|
+
let server = null;
|
|
327
|
+
let mobileSocketServer = null;
|
|
328
|
+
let lastError = null;
|
|
329
|
+
const nowMs = () => getNow().getTime();
|
|
330
|
+
const isPairingExpired = () => {
|
|
331
|
+
return Boolean(pairingChallenge && pairingChallenge.expiresAt.getTime() <= nowMs());
|
|
332
|
+
};
|
|
333
|
+
const generatePairingChallenge = (preferredProjectId = null) => {
|
|
334
|
+
if (!mobileAccessId || !mobileUrl) {
|
|
335
|
+
throw new Error('Mobile access is not enabled.');
|
|
336
|
+
}
|
|
337
|
+
const pairingId = createId();
|
|
338
|
+
const pairingSecret = createSecret();
|
|
339
|
+
const displayCode = createDisplayCode();
|
|
340
|
+
const url = new URL('/mobile/pair', mobileUrl);
|
|
341
|
+
url.searchParams.set('mobileAccessId', mobileAccessId);
|
|
342
|
+
url.searchParams.set('pairingId', pairingId);
|
|
343
|
+
pairingChallenge = {
|
|
344
|
+
mobileAccessId,
|
|
345
|
+
pairingId,
|
|
346
|
+
pairingSecretHash: hashSecret(pairingSecret),
|
|
347
|
+
preferredProjectId,
|
|
348
|
+
displayCode,
|
|
349
|
+
qrUrl: `${url.toString()}#pairingSecret=${encodeURIComponent(pairingSecret)}`,
|
|
350
|
+
expiresAt: new Date(nowMs() + PAIRING_TTL_MS),
|
|
351
|
+
consumedAt: null,
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
const serializeSessions = () => {
|
|
355
|
+
return Array.from(sessions.values()).map(session => ({
|
|
356
|
+
id: session.id,
|
|
357
|
+
paired_at: session.pairedAt.toISOString(),
|
|
358
|
+
last_seen_at: session.lastSeenAt.toISOString(),
|
|
359
|
+
remote_address: session.remoteAddress,
|
|
360
|
+
user_agent: session.userAgent,
|
|
361
|
+
}));
|
|
362
|
+
};
|
|
363
|
+
const getStatus = () => {
|
|
364
|
+
if (!server) {
|
|
365
|
+
return {
|
|
366
|
+
mode: null,
|
|
367
|
+
state: lastError ? 'error' : 'disabled',
|
|
368
|
+
local_listener_url: null,
|
|
369
|
+
url: null,
|
|
370
|
+
pairing: null,
|
|
371
|
+
sessions: [],
|
|
372
|
+
error: lastError,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const hasSessions = sessions.size > 0;
|
|
376
|
+
const pairing = pairingChallenge && !pairingChallenge.consumedAt && !isPairingExpired()
|
|
377
|
+
? {
|
|
378
|
+
display_code: pairingChallenge.displayCode,
|
|
379
|
+
qr_url: pairingChallenge.qrUrl,
|
|
380
|
+
expires_at: pairingChallenge.expiresAt.toISOString(),
|
|
381
|
+
}
|
|
382
|
+
: null;
|
|
383
|
+
return {
|
|
384
|
+
mode: mobileAccessMode,
|
|
385
|
+
state: hasSessions ? 'connected' : pairing ? 'awaiting_pairing' : 'expired',
|
|
386
|
+
local_listener_url: localListenerUrl,
|
|
387
|
+
url: mobileUrl,
|
|
388
|
+
pairing,
|
|
389
|
+
sessions: serializeSessions(),
|
|
390
|
+
error: lastError,
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
const start = async ({ mode, preferredPort = DEFAULT_MOBILE_PORT, clientBuild = defaultClientBuild, broadcaster, reviewBundleTickets, publicBaseUrl, preferredProjectId = null, } = {}) => {
|
|
394
|
+
if (server) {
|
|
395
|
+
if (broadcaster) {
|
|
396
|
+
operationBroadcaster = broadcaster;
|
|
397
|
+
}
|
|
398
|
+
if (reviewBundleTickets) {
|
|
399
|
+
mobileReviewBundleTickets = reviewBundleTickets;
|
|
400
|
+
}
|
|
401
|
+
return getStatus();
|
|
402
|
+
}
|
|
403
|
+
lastError = null;
|
|
404
|
+
operationBroadcaster = broadcaster ?? options.broadcaster ?? { emit: (event, payload) => emitRealtime(event, payload) };
|
|
405
|
+
mobileReviewBundleTickets = reviewBundleTickets ?? options.reviewBundleTickets ?? mobileReviewBundleTickets;
|
|
406
|
+
const resolvedPublicBaseUrl = resolveMobileAccessPublicBaseUrl(publicBaseUrl);
|
|
407
|
+
if (resolvedPublicBaseUrl.error) {
|
|
408
|
+
lastError = resolvedPublicBaseUrl.error;
|
|
409
|
+
return getStatus();
|
|
410
|
+
}
|
|
411
|
+
const nextMode = mode ?? (resolvedPublicBaseUrl.baseUrl ? 'custom_public_url' : 'lan');
|
|
412
|
+
if (nextMode === 'custom_public_url' && !resolvedPublicBaseUrl.baseUrl) {
|
|
413
|
+
lastError = CUSTOM_PUBLIC_URL_VALIDATION_ERROR;
|
|
414
|
+
return getStatus();
|
|
415
|
+
}
|
|
416
|
+
const selection = nextMode === 'lan' ? selectPrivateLanAddress(getNetworkInterfaces()) : { address: DEFAULT_DESKTOP_BIND_HOST, error: null };
|
|
417
|
+
if (!selection.address) {
|
|
418
|
+
lastError = selection.error;
|
|
419
|
+
return getStatus();
|
|
420
|
+
}
|
|
421
|
+
mobileAccessId = createId();
|
|
422
|
+
mobileAccessMode = nextMode;
|
|
423
|
+
sessions = new Map();
|
|
424
|
+
attempts = new Map();
|
|
425
|
+
let startedServer = null;
|
|
426
|
+
let startedSocketServer = null;
|
|
427
|
+
let nextManagedTunnelStop = null;
|
|
428
|
+
try {
|
|
429
|
+
const app = createMobileApp(clientBuild);
|
|
430
|
+
const result = await listen({
|
|
431
|
+
app,
|
|
432
|
+
host: selection.address,
|
|
433
|
+
preferredPort,
|
|
434
|
+
allowPortFallback: nextMode === 'lan',
|
|
435
|
+
configureServer: httpServer => createSocketServer(httpServer, validateMobileSocketHandshake),
|
|
436
|
+
});
|
|
437
|
+
startedServer = result.server;
|
|
438
|
+
startedSocketServer = result.socketServer ?? null;
|
|
439
|
+
const nextLocalListenerUrl = `http://${selection.address}:${result.port}`;
|
|
440
|
+
let nextMobileUrl = `http://${selection.address}:${result.port}/mobile`;
|
|
441
|
+
if (nextMode === 'custom_public_url') {
|
|
442
|
+
nextMobileUrl = `${resolvedPublicBaseUrl.baseUrl}/mobile`;
|
|
443
|
+
}
|
|
444
|
+
if (nextMode === 'ngrok') {
|
|
445
|
+
const tunnel = await startManagedTunnel({ localListenerUrl: nextLocalListenerUrl });
|
|
446
|
+
nextManagedTunnelStop = tunnel.stop;
|
|
447
|
+
nextMobileUrl = `${tunnel.publicBaseUrl}/mobile`;
|
|
448
|
+
}
|
|
449
|
+
server = startedServer;
|
|
450
|
+
mobileSocketServer = startedSocketServer;
|
|
451
|
+
localListenerUrl = nextLocalListenerUrl;
|
|
452
|
+
mobileUrl = nextMobileUrl;
|
|
453
|
+
managedTunnelStop = nextManagedTunnelStop;
|
|
454
|
+
generatePairingChallenge(preferredProjectId);
|
|
455
|
+
return getStatus();
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
if (nextManagedTunnelStop) {
|
|
459
|
+
await nextManagedTunnelStop().catch(() => { });
|
|
460
|
+
}
|
|
461
|
+
startedSocketServer?.disconnectSockets?.(true);
|
|
462
|
+
if (startedServer) {
|
|
463
|
+
await new Promise(resolve => {
|
|
464
|
+
startedServer?.close(() => resolve());
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
mobileAccessId = null;
|
|
468
|
+
mobileAccessMode = null;
|
|
469
|
+
localListenerUrl = null;
|
|
470
|
+
mobileUrl = null;
|
|
471
|
+
managedTunnelStop = null;
|
|
472
|
+
pairingChallenge = null;
|
|
473
|
+
sessions.clear();
|
|
474
|
+
attempts.clear();
|
|
475
|
+
server = null;
|
|
476
|
+
mobileSocketServer = null;
|
|
477
|
+
const errorCode = error?.code;
|
|
478
|
+
lastError = errorCode === 'EADDRINUSE' && nextMode === 'custom_public_url'
|
|
479
|
+
? CUSTOM_PUBLIC_URL_PORT_IN_USE_ERROR
|
|
480
|
+
: error instanceof Error
|
|
481
|
+
? error.message
|
|
482
|
+
: 'Failed to start mobile access.';
|
|
483
|
+
return getStatus();
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
const stop = async () => {
|
|
487
|
+
const activeServer = server;
|
|
488
|
+
const activeManagedTunnelStop = managedTunnelStop;
|
|
489
|
+
disconnectAllMobileSockets();
|
|
490
|
+
mobileAccessId = null;
|
|
491
|
+
mobileAccessMode = null;
|
|
492
|
+
localListenerUrl = null;
|
|
493
|
+
mobileUrl = null;
|
|
494
|
+
managedTunnelStop = null;
|
|
495
|
+
pairingChallenge = null;
|
|
496
|
+
sessions.clear();
|
|
497
|
+
attempts.clear();
|
|
498
|
+
server = null;
|
|
499
|
+
mobileSocketServer = null;
|
|
500
|
+
lastError = null;
|
|
501
|
+
if (!activeServer) {
|
|
502
|
+
return getStatus();
|
|
503
|
+
}
|
|
504
|
+
let tunnelStopError = null;
|
|
505
|
+
if (activeManagedTunnelStop) {
|
|
506
|
+
try {
|
|
507
|
+
await activeManagedTunnelStop();
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
tunnelStopError = error instanceof Error ? error : new Error('Failed to stop managed tunnel.');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
await new Promise((resolve, reject) => {
|
|
514
|
+
activeServer.close(error => {
|
|
515
|
+
if (error) {
|
|
516
|
+
reject(error);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
resolve();
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
if (tunnelStopError) {
|
|
523
|
+
throw tunnelStopError;
|
|
524
|
+
}
|
|
525
|
+
return getStatus();
|
|
526
|
+
};
|
|
527
|
+
const regeneratePairingChallenge = (preferredProjectId = null) => {
|
|
528
|
+
if (!server) {
|
|
529
|
+
lastError = 'Mobile access is not enabled.';
|
|
530
|
+
return getStatus();
|
|
531
|
+
}
|
|
532
|
+
generatePairingChallenge(preferredProjectId);
|
|
533
|
+
lastError = null;
|
|
534
|
+
return getStatus();
|
|
535
|
+
};
|
|
536
|
+
const createAttemptKey = (payload) => {
|
|
537
|
+
return `${payload.remoteAddress ?? 'unknown'}:${payload.pairingId}`;
|
|
538
|
+
};
|
|
539
|
+
const isRateLimited = (payload) => {
|
|
540
|
+
const key = createAttemptKey(payload);
|
|
541
|
+
const current = attempts.get(key);
|
|
542
|
+
const timestamp = nowMs();
|
|
543
|
+
if (!current || current.resetAt <= timestamp) {
|
|
544
|
+
attempts.set(key, { count: 1, resetAt: timestamp + PAIRING_RATE_LIMIT_WINDOW_MS });
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
current.count += 1;
|
|
548
|
+
return current.count > PAIRING_RATE_LIMIT_MAX_ATTEMPTS;
|
|
549
|
+
};
|
|
550
|
+
const getValidPairingChallenge = (payload) => {
|
|
551
|
+
if (isRateLimited(payload)) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
if (!pairingChallenge ||
|
|
555
|
+
pairingChallenge.consumedAt ||
|
|
556
|
+
pairingChallenge.expiresAt.getTime() <= nowMs() ||
|
|
557
|
+
payload.mobileAccessId !== pairingChallenge.mobileAccessId ||
|
|
558
|
+
payload.pairingId !== pairingChallenge.pairingId ||
|
|
559
|
+
hashSecret(payload.pairingSecret) !== pairingChallenge.pairingSecretHash) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
return pairingChallenge;
|
|
563
|
+
};
|
|
564
|
+
const previewPairingChallenge = (payload) => {
|
|
565
|
+
const challenge = getValidPairingChallenge(payload);
|
|
566
|
+
if (!challenge) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
displayCode: challenge.displayCode,
|
|
571
|
+
expiresAt: challenge.expiresAt.toISOString(),
|
|
572
|
+
};
|
|
573
|
+
};
|
|
574
|
+
const consumePairingChallenge = (payload) => {
|
|
575
|
+
const challenge = getValidPairingChallenge(payload);
|
|
576
|
+
if (!challenge) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
challenge.consumedAt = getNow();
|
|
580
|
+
const sessionId = createId();
|
|
581
|
+
const sessionToken = createSecret();
|
|
582
|
+
const csrfToken = createSecret();
|
|
583
|
+
const timestamp = getNow();
|
|
584
|
+
sessions.set(sessionId, {
|
|
585
|
+
id: sessionId,
|
|
586
|
+
tokenHash: hashSecret(sessionToken),
|
|
587
|
+
csrfToken,
|
|
588
|
+
preferredProjectId: challenge.preferredProjectId,
|
|
589
|
+
pairedAt: timestamp,
|
|
590
|
+
lastSeenAt: timestamp,
|
|
591
|
+
remoteAddress: payload.remoteAddress ?? null,
|
|
592
|
+
userAgent: payload.userAgent ?? null,
|
|
593
|
+
});
|
|
594
|
+
return {
|
|
595
|
+
sessionId,
|
|
596
|
+
sessionToken,
|
|
597
|
+
csrfToken,
|
|
598
|
+
preferredProjectId: challenge.preferredProjectId,
|
|
599
|
+
};
|
|
600
|
+
};
|
|
601
|
+
const validateSessionToken = (sessionToken) => {
|
|
602
|
+
if (!sessionToken) {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
const tokenHash = hashSecret(sessionToken);
|
|
606
|
+
const session = Array.from(sessions.values()).find(candidate => candidate.tokenHash === tokenHash);
|
|
607
|
+
if (!session) {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
session.lastSeenAt = getNow();
|
|
611
|
+
return {
|
|
612
|
+
sessionId: session.id,
|
|
613
|
+
csrfToken: session.csrfToken,
|
|
614
|
+
preferredProjectId: session.preferredProjectId,
|
|
615
|
+
};
|
|
616
|
+
};
|
|
617
|
+
const validateMobileRequest = (req) => {
|
|
618
|
+
return validateSessionToken(parseCookie(req.headers.cookie, MOBILE_SESSION_COOKIE));
|
|
619
|
+
};
|
|
620
|
+
const validateMobileSocketHandshake = (cookieHeader) => {
|
|
621
|
+
return validateSessionToken(parseCookie(cookieHeader, MOBILE_SESSION_COOKIE));
|
|
622
|
+
};
|
|
623
|
+
const validateMobileWriteRequest = (req) => {
|
|
624
|
+
const session = validateMobileRequest(req);
|
|
625
|
+
if (!session) {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
const origin = req.get('origin');
|
|
629
|
+
const host = req.get('host');
|
|
630
|
+
if (origin) {
|
|
631
|
+
const originHost = getOriginHost(origin);
|
|
632
|
+
if (!originHost) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
const expectedHost = mobileAccessMode && mobileAccessMode !== 'lan'
|
|
636
|
+
? getOriginHost(mobileUrl ?? '')
|
|
637
|
+
: host?.toLowerCase() ?? null;
|
|
638
|
+
if (!expectedHost || originHost !== expectedHost) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (req.get(MOBILE_CSRF_HEADER) !== session.csrfToken) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
return session;
|
|
646
|
+
};
|
|
647
|
+
const disconnectMobileSessionSockets = (sessionId) => {
|
|
648
|
+
for (const socket of mobileSocketServer?.sockets.sockets.values() ?? []) {
|
|
649
|
+
if (socket.data.mobileSessionId === sessionId) {
|
|
650
|
+
socket.disconnect(true);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
const disconnectAllMobileSockets = () => {
|
|
655
|
+
mobileSocketServer?.disconnectSockets?.(true);
|
|
656
|
+
for (const socket of mobileSocketServer?.sockets.sockets.values() ?? []) {
|
|
657
|
+
socket.disconnect(true);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
const emitRealtime = (event, payload) => {
|
|
661
|
+
if (!MOBILE_REALTIME_EVENTS.has(event)) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
return Boolean(mobileSocketServer?.emit(event, payload));
|
|
665
|
+
};
|
|
666
|
+
const revokeMobileSession = (sessionId) => {
|
|
667
|
+
disconnectMobileSessionSockets(sessionId);
|
|
668
|
+
sessions.delete(sessionId);
|
|
669
|
+
return getStatus();
|
|
670
|
+
};
|
|
671
|
+
const revokeAllMobileSessions = () => {
|
|
672
|
+
disconnectAllMobileSockets();
|
|
673
|
+
sessions.clear();
|
|
674
|
+
return getStatus();
|
|
675
|
+
};
|
|
676
|
+
const sendMobileIndex = (res, clientBuild) => {
|
|
677
|
+
setNoReferrer(res);
|
|
678
|
+
if (!clientBuild) {
|
|
679
|
+
res
|
|
680
|
+
.status(503)
|
|
681
|
+
.type('html')
|
|
682
|
+
.send('<!doctype html><title>Archon mobile unavailable</title><main>Archon mobile client is not available in this server build.</main>');
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
res.sendFile(clientBuild.indexPath, STATIC_CLIENT_INDEX_OPTIONS);
|
|
686
|
+
};
|
|
687
|
+
const createMobileApp = (clientBuild) => {
|
|
688
|
+
const app = express();
|
|
689
|
+
app.disable('x-powered-by');
|
|
690
|
+
app.use(express.json({ limit: '64kb' }));
|
|
691
|
+
app.use((_, res, next) => {
|
|
692
|
+
setNoStore(res);
|
|
693
|
+
next();
|
|
694
|
+
});
|
|
695
|
+
app.get('/mobile-auth/status', (req, res) => {
|
|
696
|
+
const session = validateMobileRequest(req);
|
|
697
|
+
if (!session) {
|
|
698
|
+
res.json({ authenticated: false });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
res.json({
|
|
702
|
+
authenticated: true,
|
|
703
|
+
csrf_token: session.csrfToken,
|
|
704
|
+
preferred_project_id: session.preferredProjectId,
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
const createPairingPayload = (req) => {
|
|
708
|
+
const body = req.body;
|
|
709
|
+
return {
|
|
710
|
+
mobileAccessId: typeof body.mobileAccessId === 'string' ? body.mobileAccessId : '',
|
|
711
|
+
pairingId: typeof body.pairingId === 'string' ? body.pairingId : '',
|
|
712
|
+
pairingSecret: typeof body.pairingSecret === 'string' ? body.pairingSecret : '',
|
|
713
|
+
remoteAddress: req.ip,
|
|
714
|
+
userAgent: req.get('user-agent') ?? null,
|
|
715
|
+
};
|
|
716
|
+
};
|
|
717
|
+
app.post('/mobile-auth/pair', (req, res) => {
|
|
718
|
+
const result = consumePairingChallenge(createPairingPayload(req));
|
|
719
|
+
if (!result) {
|
|
720
|
+
res.status(401).json({ error: 'Pairing failed.' });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
res.cookie(MOBILE_SESSION_COOKIE, result.sessionToken, {
|
|
724
|
+
httpOnly: true,
|
|
725
|
+
sameSite: 'strict',
|
|
726
|
+
secure: mobileAccessMode !== 'lan',
|
|
727
|
+
path: '/',
|
|
728
|
+
});
|
|
729
|
+
res.json({
|
|
730
|
+
authenticated: true,
|
|
731
|
+
csrf_token: result.csrfToken,
|
|
732
|
+
preferred_project_id: result.preferredProjectId,
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
app.post('/mobile-auth/pair/preview', (req, res) => {
|
|
736
|
+
const result = previewPairingChallenge(createPairingPayload(req));
|
|
737
|
+
if (!result) {
|
|
738
|
+
res.status(401).json({ error: 'Pairing failed.' });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
res.json({ display_code: result.displayCode, expires_at: result.expiresAt });
|
|
742
|
+
});
|
|
743
|
+
app.post('/mobile-auth/logout', (req, res) => {
|
|
744
|
+
const session = validateMobileWriteRequest(req);
|
|
745
|
+
if (!session) {
|
|
746
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
sessions.delete(session.sessionId);
|
|
750
|
+
res.clearCookie(MOBILE_SESSION_COOKIE, {
|
|
751
|
+
sameSite: 'strict',
|
|
752
|
+
secure: mobileAccessMode !== 'lan',
|
|
753
|
+
path: '/',
|
|
754
|
+
});
|
|
755
|
+
res.json({ ok: true });
|
|
756
|
+
});
|
|
757
|
+
const sendTicketOperationResult = (res, result) => {
|
|
758
|
+
if (!result.ok) {
|
|
759
|
+
res.status(result.status).json({
|
|
760
|
+
error: result.error,
|
|
761
|
+
...result.details,
|
|
762
|
+
});
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
res.status(result.status).json(result.value);
|
|
766
|
+
result.afterResponse?.();
|
|
767
|
+
};
|
|
768
|
+
const requireMobileWriteSession = (req, res) => {
|
|
769
|
+
const session = validateMobileWriteRequest(req);
|
|
770
|
+
if (!session) {
|
|
771
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
return session;
|
|
775
|
+
};
|
|
776
|
+
app.get('/mobile/projects', async (req, res) => {
|
|
777
|
+
const session = validateMobileRequest(req);
|
|
778
|
+
if (!session) {
|
|
779
|
+
res.status(401).json({ error: 'Authentication required.' });
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
res.json(await listMobileProjects());
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
res.status(500).json({ error: 'Unable to list projects.' });
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
app.get('/mobile/projects/:projectId/summary', async (req, res) => {
|
|
790
|
+
const session = validateMobileRequest(req);
|
|
791
|
+
if (!session) {
|
|
792
|
+
res.status(401).json({ error: 'Authentication required.' });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
const summary = await getMobileProjectSummary(req.params.projectId);
|
|
797
|
+
if (!summary) {
|
|
798
|
+
res.status(404).json({ error: 'Not found' });
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
res.json(summary);
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
res.status(500).json({ error: 'Unable to load project summary.' });
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
app.get('/mobile/projects/:projectId/chat-sessions', async (req, res) => {
|
|
808
|
+
const session = validateMobileRequest(req);
|
|
809
|
+
if (!session) {
|
|
810
|
+
res.status(401).json({ error: 'Authentication required.' });
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const chatSessions = await listProjectChatSessions(req.params.projectId);
|
|
815
|
+
if (!chatSessions) {
|
|
816
|
+
res.status(404).json({ error: 'Not found' });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
res.json(chatSessions);
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
res.status(500).json({ error: 'Unable to list chat sessions.' });
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
app.get('/mobile/projects/:projectId/chat-sessions/:sessionId', async (req, res) => {
|
|
826
|
+
const session = validateMobileRequest(req);
|
|
827
|
+
if (!session) {
|
|
828
|
+
res.status(401).json({ error: 'Authentication required.' });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
const chatSession = await getProjectChatSession(req.params.projectId, req.params.sessionId);
|
|
833
|
+
if (!chatSession) {
|
|
834
|
+
res.status(404).json({ error: 'Not found' });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
res.json(chatSession);
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
res.status(500).json({ error: 'Unable to load chat session.' });
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
app.get('/mobile/projects/:projectId/tickets', async (req, res) => {
|
|
844
|
+
const session = validateMobileRequest(req);
|
|
845
|
+
if (!session) {
|
|
846
|
+
res.status(401).json({ error: 'Authentication required.' });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
const tickets = await listProjectTickets(req.params.projectId);
|
|
851
|
+
if (!tickets) {
|
|
852
|
+
res.status(404).json({ error: 'Not found' });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
res.json(tickets);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
res.status(500).json({ error: 'Unable to list tickets.' });
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
app.get('/mobile/projects/:projectId/tickets/:ticketId', async (req, res) => {
|
|
862
|
+
const session = validateMobileRequest(req);
|
|
863
|
+
if (!session) {
|
|
864
|
+
res.status(401).json({ error: 'Authentication required.' });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
const ticket = await getProjectTicket(req.params.projectId, req.params.ticketId);
|
|
869
|
+
if (!ticket) {
|
|
870
|
+
res.status(404).json({ error: 'Not found' });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
res.json(ticket);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
res.status(500).json({ error: 'Unable to load ticket.' });
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
app.patch('/mobile/projects/:projectId/tickets/:ticketId', (req, res) => {
|
|
880
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
sendTicketOperationResult(res, updateMobileTicketSettings(req.params.projectId, req.params.ticketId, req.body, operationBroadcaster));
|
|
884
|
+
});
|
|
885
|
+
app.patch('/mobile/projects/:projectId/tickets/:ticketId/state', (req, res) => {
|
|
886
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
sendTicketOperationResult(res, transitionMobileTicket(req.params.projectId, req.params.ticketId, req.body, operationBroadcaster));
|
|
890
|
+
});
|
|
891
|
+
app.post('/mobile/projects/:projectId/tickets/:ticketId/run', (req, res) => {
|
|
892
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
sendTicketOperationResult(res, runMobileTicket(req.params.projectId, req.params.ticketId, operationBroadcaster));
|
|
896
|
+
});
|
|
897
|
+
app.post('/mobile/projects/:projectId/tickets/:ticketId/stop', async (req, res) => {
|
|
898
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
sendTicketOperationResult(res, await stopMobileTicket(req.params.projectId, req.params.ticketId, operationBroadcaster));
|
|
902
|
+
});
|
|
903
|
+
app.post('/mobile/projects/:projectId/tickets/:ticketId/stop-build', async (req, res) => {
|
|
904
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
sendTicketOperationResult(res, await stopMobileBuildTicket(req.params.projectId, req.params.ticketId));
|
|
908
|
+
});
|
|
909
|
+
app.post('/mobile/projects/:projectId/tickets/:ticketId/resume-build', (req, res) => {
|
|
910
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
sendTicketOperationResult(res, resumeMobileBuildTicket(req.params.projectId, req.params.ticketId, operationBroadcaster));
|
|
914
|
+
});
|
|
915
|
+
app.post('/mobile/runs/:runContextKey/stop', async (req, res) => {
|
|
916
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
sendTicketOperationResult(res, await stopMobileRun(req.params.runContextKey, operationBroadcaster));
|
|
920
|
+
});
|
|
921
|
+
app.post('/mobile/projects/:projectId/tickets/:ticketId/plan-follow-up', (req, res) => {
|
|
922
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
sendTicketOperationResult(res, submitMobilePlanTicketFollowUp(req.params.projectId, req.params.ticketId, req.body, operationBroadcaster));
|
|
926
|
+
});
|
|
927
|
+
app.post('/mobile/projects/:projectId/tickets/:ticketId/follow-up', (req, res) => {
|
|
928
|
+
if (!requireMobileWriteSession(req, res)) {
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
sendTicketOperationResult(res, submitMobileTicketFollowUp(req.params.projectId, req.params.ticketId, req.body, operationBroadcaster));
|
|
932
|
+
});
|
|
933
|
+
app.post('/mobile/projects/:projectId/chat-sessions', (req, res) => {
|
|
934
|
+
const session = validateMobileWriteRequest(req);
|
|
935
|
+
if (!session) {
|
|
936
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const result = createMobileChatSession(req.params.projectId, req.body, operationBroadcaster);
|
|
940
|
+
if (!result.ok) {
|
|
941
|
+
res.status(result.status).json({ error: result.error });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
res.status(result.status).json(result.value);
|
|
945
|
+
});
|
|
946
|
+
app.patch('/mobile/projects/:projectId/chat-sessions/:sessionId', (req, res) => {
|
|
947
|
+
const session = validateMobileWriteRequest(req);
|
|
948
|
+
if (!session) {
|
|
949
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const result = updateMobileChatSession(req.params.projectId, req.params.sessionId, req.body, operationBroadcaster);
|
|
953
|
+
if (!result.ok) {
|
|
954
|
+
res.status(result.status).json({ error: result.error });
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
res.status(result.status).json(result.value);
|
|
958
|
+
});
|
|
959
|
+
app.post('/mobile/projects/:projectId/chat-sessions/:sessionId/messages', (req, res) => {
|
|
960
|
+
const session = validateMobileWriteRequest(req);
|
|
961
|
+
if (!session) {
|
|
962
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const result = submitMobileChatMessage(req.params.projectId, req.params.sessionId, req.body, operationBroadcaster);
|
|
966
|
+
if (!result.ok) {
|
|
967
|
+
res.status(result.status).json({ error: result.error });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
res.status(result.status).json(result.value);
|
|
971
|
+
});
|
|
972
|
+
app.post('/mobile/projects/:projectId/chat-sessions/:sessionId/stop', async (req, res) => {
|
|
973
|
+
const session = validateMobileWriteRequest(req);
|
|
974
|
+
if (!session) {
|
|
975
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const result = await stopMobileChatSession(req.params.projectId, req.params.sessionId);
|
|
979
|
+
if (!result.ok) {
|
|
980
|
+
res.status(result.status).json({ error: result.error });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
res.status(result.status).json(result.value);
|
|
984
|
+
});
|
|
985
|
+
app.get('/mobile/models', async (req, res) => {
|
|
986
|
+
const session = validateMobileRequest(req);
|
|
987
|
+
if (!session) {
|
|
988
|
+
res.status(401).json({ error: 'Authentication required.' });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const tool = typeof req.query.tool === 'string' ? req.query.tool : '';
|
|
992
|
+
try {
|
|
993
|
+
res.json(await listAgentModels(tool));
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
res.status(500).json({ error: 'Unable to list models.' });
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
app.get('/mobile/skills', async (req, res) => {
|
|
1000
|
+
const session = validateMobileRequest(req);
|
|
1001
|
+
if (!session) {
|
|
1002
|
+
res.status(401).json({ error: 'Authentication required.' });
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const tool = typeof req.query.tool === 'string' ? req.query.tool : '';
|
|
1006
|
+
const projectId = typeof req.query.project_id === 'string' ? req.query.project_id : null;
|
|
1007
|
+
try {
|
|
1008
|
+
res.json(await listAgentSkills(tool, projectId));
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
res.status(500).json({ error: 'Unable to list skills.' });
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
if (clientBuild) {
|
|
1015
|
+
app.use(express.static(clientBuild.directoryPath, STATIC_CLIENT_DIRECTORY_OPTIONS));
|
|
1016
|
+
}
|
|
1017
|
+
app.get('/mobile', (_req, res) => {
|
|
1018
|
+
sendMobileIndex(res, clientBuild);
|
|
1019
|
+
});
|
|
1020
|
+
app.get('/mobile/*path', (_req, res) => {
|
|
1021
|
+
sendMobileIndex(res, clientBuild);
|
|
1022
|
+
});
|
|
1023
|
+
return app;
|
|
1024
|
+
};
|
|
1025
|
+
return {
|
|
1026
|
+
getStatus,
|
|
1027
|
+
start,
|
|
1028
|
+
stop,
|
|
1029
|
+
regeneratePairingChallenge,
|
|
1030
|
+
consumePairingChallenge,
|
|
1031
|
+
previewPairingChallenge,
|
|
1032
|
+
validateMobileRequest,
|
|
1033
|
+
validateMobileSocketHandshake,
|
|
1034
|
+
validateMobileWriteRequest,
|
|
1035
|
+
emitRealtime,
|
|
1036
|
+
revokeMobileSession,
|
|
1037
|
+
revokeAllMobileSessions,
|
|
1038
|
+
};
|
|
1039
|
+
};
|
|
1040
|
+
export const mobileAccess = createMobileAccessController();
|
|
1041
|
+
export const getMobileAccessStatus = () => mobileAccess.getStatus();
|
|
1042
|
+
export const startMobileAccess = (options) => mobileAccess.start(options);
|
|
1043
|
+
export const stopMobileAccess = () => mobileAccess.stop();
|
|
1044
|
+
export const regenerateMobilePairingChallenge = (preferredProjectId) => mobileAccess.regeneratePairingChallenge(preferredProjectId);
|
|
1045
|
+
export const consumeMobilePairingChallenge = (payload) => mobileAccess.consumePairingChallenge(payload);
|
|
1046
|
+
export const validateMobileRequest = (req) => mobileAccess.validateMobileRequest(req);
|
|
1047
|
+
export const validateMobileWriteRequest = (req) => mobileAccess.validateMobileWriteRequest(req);
|
|
1048
|
+
export const emitMobileRealtime = (event, payload) => mobileAccess.emitRealtime(event, payload);
|
|
1049
|
+
export const revokeMobileSession = (sessionId) => mobileAccess.revokeMobileSession(sessionId);
|
|
1050
|
+
export const revokeAllMobileSessions = () => mobileAccess.revokeAllMobileSessions();
|
|
1051
|
+
//# sourceMappingURL=mobileAccess.js.map
|