handmux 0.27.2 → 0.29.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/handmux.js +2 -2
- package/dist/bin/handmux.js +51 -21
- package/dist/connectors/bridgeClient.js +30 -14
- package/dist/connectors/claude/index.js +78 -23
- package/dist/hooks/handmux-write.cjs +11 -0
- package/dist/package.json +13 -13
- package/dist/public/assets/index-BjnnFTLC.css +32 -0
- package/dist/public/assets/index-D0tDAqO-.js +356 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/bridgeTransport.js +31 -4
- package/dist/src/agent-runtime/builtinRuntime.js +10 -2
- package/dist/src/agent-runtime/conversation.js +3 -3
- package/dist/src/agent-runtime/conversationStore.js +2 -2
- package/dist/src/agent-runtime/inbox.js +20 -2
- package/dist/src/agent-runtime/run.js +5 -1
- package/dist/src/agents/claudeConversation.js +69 -6
- package/dist/src/agents/claudeLocalCommand.js +54 -0
- package/dist/src/agents/claudeNativeTail.js +271 -0
- package/dist/src/agents/claudePaneInput.js +47 -0
- package/dist/src/agents/codexConversation.js +46 -0
- package/dist/src/agents/piInboxBridge.js +64 -5
- package/dist/src/agents/processIdentity.js +13 -6
- package/dist/src/apiAccountProviders.js +5 -4
- package/dist/src/browser/bootstrap.js +5 -0
- package/dist/src/browser/credentials.js +68 -0
- package/dist/src/browser/manager.js +40 -5
- package/dist/src/browser/publicProxy.js +15 -5
- package/dist/src/browser/routes.js +7 -0
- package/dist/src/browser/workerClient.js +174 -9
- package/dist/src/browser/workerServer.js +13 -1
- package/dist/src/claudeEvents.js +94 -38
- package/dist/src/cli/authCmd.js +202 -0
- package/dist/src/cli/authDefaults.js +18 -0
- package/dist/src/cli/claudeHooks.js +3 -3
- package/dist/src/cli/hookScaffold.js +5 -1
- package/dist/src/cli/i18n/en.js +66 -11
- package/dist/src/cli/i18n/zh.js +66 -11
- package/dist/src/cli/options.js +7 -13
- package/dist/src/cli/pushCmd.js +24 -11
- package/dist/src/cli/setupModel.js +28 -6
- package/dist/src/cli/setupWizard.js +47 -25
- package/dist/src/cli/shortcutEditor.js +20 -2
- package/dist/src/cli/supervisor.js +5 -7
- package/dist/src/codexAppServer.js +25 -15
- package/dist/src/codexTranscriptParse.js +40 -1
- package/dist/src/codexUsageSnapshot.js +1 -1
- package/dist/src/deviceAccess.js +134 -0
- package/dist/src/deviceAuth/control.js +154 -0
- package/dist/src/deviceAuth/http.js +321 -0
- package/dist/src/deviceAuth/service.js +626 -0
- package/dist/src/docs.js +3 -0
- package/dist/src/httpApi.js +8 -3
- package/dist/src/paneInput.js +16 -2
- package/dist/src/previewServer.js +26 -2
- package/dist/src/previews.js +37 -13
- package/dist/src/projectTask/backup.js +28 -0
- package/dist/src/projectTask/lock.js +1 -1
- package/dist/src/projectTask/migrations.js +30 -1
- package/dist/src/projectTask/runtime.js +7 -1
- package/dist/src/projectTask/schema.js +2 -2
- package/dist/src/push.js +44 -13
- package/dist/src/requestAuthority.js +8 -0
- package/dist/src/requestOrigin.js +62 -0
- package/dist/src/routes/agents.js +3 -1
- package/dist/src/routes/files.js +2 -0
- package/dist/src/routes/previews.js +9 -3
- package/dist/src/routes/push.js +55 -58
- package/dist/src/routes/system.js +4 -4
- package/dist/src/routes/terminal.js +9 -1
- package/dist/src/server.js +88 -12
- package/dist/src/terminalStream.js +90 -5
- package/dist/src/tmux/commands.js +2 -0
- package/dist/src/transcriptParse.js +4 -1
- package/dist/src/workspace/environment.js +14 -5
- package/dist/src/workspace/runtime.js +16 -1
- package/package.json +4 -4
- package/dist/public/assets/index-B4pKf3IW.js +0 -356
- package/dist/public/assets/index-Cdrh2CUp.css +0 -32
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { bearerFrom } from '../auth.js';
|
|
3
|
+
import { DeviceAuthError, readSessionSecret, readPairingCookies, sessionCookieName, pairingCookieName } from './service.js';
|
|
4
|
+
import { requestOrigin } from '../requestOrigin.js';
|
|
5
|
+
export function setSessionCookie(res, origin, secret, expiresAt, now = Date.now()) {
|
|
6
|
+
const maxAge = Math.max(0, Math.min(expiresAt === null ? 34_560_000_000 : expiresAt - now, 34_560_000_000));
|
|
7
|
+
res.cookie(sessionCookieName(origin), secret, { httpOnly: true, secure: origin.startsWith('https:'), sameSite: 'strict', path: '/', maxAge });
|
|
8
|
+
}
|
|
9
|
+
function setPairingCookie(res, origin, name, secret) {
|
|
10
|
+
res.cookie(name, secret, { httpOnly: true, secure: origin.startsWith('https:'), sameSite: 'strict', path: '/', maxAge: secret ? 900_000 : 0 });
|
|
11
|
+
}
|
|
12
|
+
function browserSummary(ua) {
|
|
13
|
+
const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /(?:Chrome|CriOS)\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
|
|
14
|
+
const os = /(?:iPhone|iPad|iPod)/.test(ua) ? 'iOS' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : '';
|
|
15
|
+
return os ? `${browser} · ${os}` : browser;
|
|
16
|
+
}
|
|
17
|
+
export function createDeviceAuthRouter({ service, resolveOrigin, resolvePublicUrl, previewDomain }) {
|
|
18
|
+
const router = Router();
|
|
19
|
+
const advertisedOrigin = () => {
|
|
20
|
+
const value = resolvePublicUrl?.();
|
|
21
|
+
if (!value)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return new URL(value).origin;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const previewOrigin = () => {
|
|
31
|
+
if (!previewDomain)
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(/^https?:\/\//i.test(previewDomain) ? previewDomain : `https://${previewDomain}`);
|
|
35
|
+
return `https://*.${url.hostname}`;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const anonymousBuckets = new Map();
|
|
42
|
+
const authenticatedBuckets = new Map();
|
|
43
|
+
const tokenBuckets = new Map();
|
|
44
|
+
router.use((req, res, next) => {
|
|
45
|
+
res.set('Cache-Control', 'no-store');
|
|
46
|
+
res.set('Pragma', 'no-cache');
|
|
47
|
+
const resolvedOrigin = resolveOrigin(req);
|
|
48
|
+
// An unlisted host may only reach the status/pairing bootstrap. It still
|
|
49
|
+
// needs the Token to create a pairing and can never touch business
|
|
50
|
+
// or device-management routes before an approved device is enrolled.
|
|
51
|
+
const origin = resolvedOrigin ?? requestOrigin(req);
|
|
52
|
+
const pairingBootstrap = !resolvedOrigin && origin && (req.path === '/status' || req.path === '/pairing');
|
|
53
|
+
if (!origin || (!pairingBootstrap && !resolvedOrigin)
|
|
54
|
+
|| (req.get('Origin') && req.get('Origin') !== origin)
|
|
55
|
+
|| (!['GET', 'HEAD'].includes(req.method) && req.get('Origin') !== origin)) {
|
|
56
|
+
res.status(403).json({ error: 'AUTH_ORIGIN_REJECTED', message: 'Open handmux from its trusted access address and retry' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
res.locals.authOrigin = origin;
|
|
60
|
+
// Status and pairing are the only bootstrap endpoints on an unlisted origin. A
|
|
61
|
+
// session cookie from a previously trusted address must not make that origin
|
|
62
|
+
// look authenticated; otherwise the app mounts business UI, receives origin
|
|
63
|
+
// rejections, and can loop back into the pairing screen.
|
|
64
|
+
res.locals.authOriginTrusted = Boolean(resolvedOrigin);
|
|
65
|
+
let principal;
|
|
66
|
+
try {
|
|
67
|
+
principal = service.authenticateRequest(req, origin);
|
|
68
|
+
if (!principal) {
|
|
69
|
+
for (const candidate of readPairingCookies(req, origin)) {
|
|
70
|
+
principal = service.authenticateSecret(candidate.secret, origin);
|
|
71
|
+
if (principal)
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!principal)
|
|
76
|
+
principal = tokenPrincipal(req, origin);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
res.status(503).json({ error: 'AUTH_UNAVAILABLE', message: 'Authentication storage is unavailable; restart handmux and retry' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
// Separate bounded maps reserve capacity for authenticated sessions even under anonymous floods.
|
|
84
|
+
const token = principal && service.isTokenPrincipal(principal);
|
|
85
|
+
const buckets = !principal ? anonymousBuckets : token ? tokenBuckets : authenticatedBuckets;
|
|
86
|
+
for (const [key, value] of buckets)
|
|
87
|
+
if (now - value.at >= 60_000)
|
|
88
|
+
buckets.delete(key);
|
|
89
|
+
const key = !principal ? req.socket.remoteAddress ?? 'unknown'
|
|
90
|
+
: token ? `${principal.sessionId}:${req.socket.remoteAddress ?? 'unknown'}:${(req.get('user-agent') ?? '').slice(0, 200)}`
|
|
91
|
+
: principal.sessionId;
|
|
92
|
+
let bucket = buckets.get(key);
|
|
93
|
+
if (!bucket) {
|
|
94
|
+
if (buckets.size >= 1024) {
|
|
95
|
+
res.status(429).json({ error: 'AUTH_RATE_LIMIT', message: 'Wait one minute and try again' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
bucket = { at: now, requests: 0, creates: 0 };
|
|
99
|
+
buckets.set(key, bucket);
|
|
100
|
+
}
|
|
101
|
+
bucket.requests++;
|
|
102
|
+
if (req.method === 'POST' && req.path === '/pairing')
|
|
103
|
+
bucket.creates++;
|
|
104
|
+
if (bucket.requests > 600 || bucket.creates > 20) {
|
|
105
|
+
res.set('Retry-After', '60').status(429).json({ error: 'AUTH_RATE_LIMIT', message: 'Wait one minute and try again' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
next();
|
|
109
|
+
});
|
|
110
|
+
const candidates = (req, origin) => readPairingCookies(req, origin).map(c => ({ ...c, pairing: service.pairing(c.secret, origin), principal: service.authenticateSecret(c.secret, origin) }));
|
|
111
|
+
const selectCandidate = (req, origin) => {
|
|
112
|
+
const rank = (state) => state === 'authorized' ? 0 : state === 'configuring' ? 1 : state === 'waiting' ? 2 : 3;
|
|
113
|
+
return candidates(req, origin).sort((a, b) => (a.principal ? 0 : rank(a.pairing?.state)) - (b.principal ? 0 : rank(b.pairing?.state)) || a.name.localeCompare(b.name))[0];
|
|
114
|
+
};
|
|
115
|
+
const tokenPrincipal = (req, origin) => service.authenticateToken(bearerFrom(req.get('authorization')) ?? req.get('X-Handmux-Token'), origin);
|
|
116
|
+
const accessPrincipal = (req, origin) => {
|
|
117
|
+
const device = service.authenticateRequest(req, origin);
|
|
118
|
+
const token = tokenPrincipal(req, origin);
|
|
119
|
+
if (!token)
|
|
120
|
+
return null;
|
|
121
|
+
return service.trustedDeviceEnabled ? device : token;
|
|
122
|
+
};
|
|
123
|
+
const status = (req, res) => {
|
|
124
|
+
const origin = String(res.locals.authOrigin);
|
|
125
|
+
let secret = readSessionSecret(req, origin);
|
|
126
|
+
const token = tokenPrincipal(req, origin);
|
|
127
|
+
let principal = res.locals.authOriginTrusted === false ? null : accessPrincipal(req, origin);
|
|
128
|
+
const formalPrincipal = principal;
|
|
129
|
+
const candidate = selectCandidate(req, origin);
|
|
130
|
+
// A late anonymous POST may overwrite only the candidate cookie, never a live session.
|
|
131
|
+
// Existing primary sessions always win over another tab's pending/authorized candidate.
|
|
132
|
+
if (res.locals.authOriginTrusted !== false && !principal && candidate && token) {
|
|
133
|
+
principal = service.authenticateSecret(candidate.secret, origin);
|
|
134
|
+
if (principal)
|
|
135
|
+
secret = candidate.secret;
|
|
136
|
+
}
|
|
137
|
+
if (principal && secret)
|
|
138
|
+
setSessionCookie(res, origin, secret, principal.expiresAt);
|
|
139
|
+
// An authorized pairing cookie is a leftover candidate, not a usable session. On an
|
|
140
|
+
// untrusted origin clear it so the recovery screen can start a fresh pairing request
|
|
141
|
+
// instead of rendering an empty "already authorized" state.
|
|
142
|
+
const staleAuthorizedPairing = res.locals.authOriginTrusted === false && candidate?.pairing?.state === 'authorized';
|
|
143
|
+
if (staleAuthorizedPairing)
|
|
144
|
+
setPairingCookie(res, origin, candidate.name, '');
|
|
145
|
+
const pairing = staleAuthorizedPairing ? undefined : candidate?.pairing;
|
|
146
|
+
res.json({ mode: service.mode, tokenEnabled: true, tokenAuthenticated: !!token,
|
|
147
|
+
trustedDeviceEnabled: service.trustedDeviceEnabled, trustedOriginEnabled: service.trustedOriginEnabled,
|
|
148
|
+
originTrusted: res.locals.authOriginTrusted !== false,
|
|
149
|
+
// A recovered candidate gets Set-Cookie, but cannot authorize business requests until
|
|
150
|
+
// the browser sends that formal cookie back. This also works after pairing memory is lost.
|
|
151
|
+
sessionPending: !!principal && !formalPrincipal,
|
|
152
|
+
requiresTrustedDevice: service.requiresTrustedDevice && !formalPrincipal, publicUrl: advertisedOrigin(), previewDomain: previewOrigin(), trustedOrigins: service.trustedOrigins, authenticated: !!formalPrincipal,
|
|
153
|
+
currentDeviceId: formalPrincipal && !service.isTokenPrincipal(formalPrincipal) ? formalPrincipal.deviceId : null, ...(pairing ? { pairing } : {}), serverTime: Date.now() });
|
|
154
|
+
};
|
|
155
|
+
const safe = (handler) => (req, res) => {
|
|
156
|
+
try {
|
|
157
|
+
handler(req, res);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error instanceof DeviceAuthError)
|
|
161
|
+
res.status(error.status).json({ error: error.code, message: error.message });
|
|
162
|
+
else
|
|
163
|
+
res.status(503).json({ error: 'AUTH_UNAVAILABLE', message: 'Authentication storage is unavailable; restart handmux and retry' });
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
router.get('/status', safe(status));
|
|
167
|
+
router.get('/pairing', safe(status));
|
|
168
|
+
router.post('/pairing', safe((req, res) => {
|
|
169
|
+
const origin = String(res.locals.authOrigin);
|
|
170
|
+
const token = tokenPrincipal(req, origin);
|
|
171
|
+
const access = res.locals.authOriginTrusted === false ? null : accessPrincipal(req, origin);
|
|
172
|
+
if (access && service.trustedDeviceEnabled) {
|
|
173
|
+
status(req, res);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!token)
|
|
177
|
+
throw new DeviceAuthError('TOKEN_REQUIRED', 'Enter the Token before requesting device authorization', 401);
|
|
178
|
+
const all = candidates(req, origin);
|
|
179
|
+
for (const c of all)
|
|
180
|
+
if (!c.pairing || c.pairing.state === 'expired' || c.pairing.state === 'canceled')
|
|
181
|
+
setPairingCookie(res, origin, c.name, '');
|
|
182
|
+
const candidate = selectCandidate(req, origin);
|
|
183
|
+
if (candidate && service.authenticateSecret(candidate.secret, origin)) {
|
|
184
|
+
status(req, res);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const result = service.createPairing(candidate?.secret ?? null, origin, browserSummary(req.get('user-agent') ?? ''));
|
|
188
|
+
if (result.secret)
|
|
189
|
+
setPairingCookie(res, origin, `${pairingCookieName(origin)}_${result.pairing.id.slice(5)}`, result.secret);
|
|
190
|
+
res.json({ mode: service.mode, tokenEnabled: true, tokenAuthenticated: true, trustedDeviceEnabled: service.trustedDeviceEnabled, trustedOriginEnabled: service.trustedOriginEnabled, originTrusted: res.locals.authOriginTrusted !== false, requiresTrustedDevice: true, currentDeviceId: null, authenticated: false, pairing: result.pairing, serverTime: Date.now() });
|
|
191
|
+
}));
|
|
192
|
+
// Enabling protection is safe to offer in the Web UI only after the current browser
|
|
193
|
+
// has been registered as a device. Disabling remains a CLI-only operation.
|
|
194
|
+
router.post('/device-protection/enable', safe((req, res) => {
|
|
195
|
+
const origin = String(res.locals.authOrigin);
|
|
196
|
+
if (!tokenPrincipal(req, origin))
|
|
197
|
+
throw new DeviceAuthError('TOKEN_REQUIRED', 'Enter the Token before enabling device protection', 401);
|
|
198
|
+
const device = service.authenticateRequest(req, origin);
|
|
199
|
+
if (!device || service.isTokenPrincipal(device))
|
|
200
|
+
throw new DeviceAuthError('DEVICE_REQUIRED', 'Register this browser before enabling device protection', 409);
|
|
201
|
+
service.setTrustedDeviceEnabled(true);
|
|
202
|
+
res.json({ mode: service.mode, tokenEnabled: true, trustedDeviceEnabled: service.trustedDeviceEnabled, trustedOriginEnabled: service.trustedOriginEnabled, currentDeviceId: device.deviceId, authenticated: true, serverTime: Date.now() });
|
|
203
|
+
}));
|
|
204
|
+
router.post('/origin-protection/enable', safe((req, res) => {
|
|
205
|
+
const origin = String(res.locals.authOrigin);
|
|
206
|
+
if (!tokenPrincipal(req, origin))
|
|
207
|
+
throw new DeviceAuthError('TOKEN_REQUIRED', 'Enter the Token before enabling access-address restrictions', 401);
|
|
208
|
+
// Keep the browser that enables protection reachable. The enable request is
|
|
209
|
+
// made from the current origin, so register it atomically before enforcing
|
|
210
|
+
// the new restriction.
|
|
211
|
+
service.setTrustedOrigin(origin);
|
|
212
|
+
service.setTrustedOriginEnabled(true);
|
|
213
|
+
res.json({ mode: service.mode, tokenEnabled: true, trustedDeviceEnabled: service.trustedDeviceEnabled, trustedOriginEnabled: service.trustedOriginEnabled, currentDeviceId: null, authenticated: true, serverTime: Date.now() });
|
|
214
|
+
}));
|
|
215
|
+
router.delete('/pairing', safe((req, res) => {
|
|
216
|
+
const origin = String(res.locals.authOrigin);
|
|
217
|
+
const id = typeof req.body?.id === 'string' ? req.body.id : '';
|
|
218
|
+
const candidate = candidates(req, origin).find(c => c.pairing?.id === id);
|
|
219
|
+
const pairing = service.cancelPairing(candidate?.secret ?? null, origin, id);
|
|
220
|
+
const principal = res.locals.authOriginTrusted === false ? null : accessPrincipal(req, origin);
|
|
221
|
+
res.json({ mode: service.mode, tokenEnabled: true, tokenAuthenticated: !!tokenPrincipal(req, origin),
|
|
222
|
+
trustedDeviceEnabled: service.trustedDeviceEnabled, trustedOriginEnabled: service.trustedOriginEnabled,
|
|
223
|
+
originTrusted: res.locals.authOriginTrusted !== false,
|
|
224
|
+
requiresTrustedDevice: service.requiresTrustedDevice && !principal, currentDeviceId: principal && !service.isTokenPrincipal(principal) ? principal.deviceId : null, authenticated: !!principal, pairing, serverTime: Date.now() });
|
|
225
|
+
}));
|
|
226
|
+
const logout = (req, res) => {
|
|
227
|
+
const origin = String(res.locals.authOrigin);
|
|
228
|
+
const principal = service.authenticateRequest(req, origin);
|
|
229
|
+
if (principal)
|
|
230
|
+
service.revoke(principal.deviceId);
|
|
231
|
+
for (const c of candidates(req, origin)) {
|
|
232
|
+
const authorized = service.authenticateSecret(c.secret, origin);
|
|
233
|
+
if (authorized)
|
|
234
|
+
service.revoke(authorized.deviceId);
|
|
235
|
+
else if (c.pairing)
|
|
236
|
+
service.cancelPairing(c.secret, origin, c.pairing.id);
|
|
237
|
+
setPairingCookie(res, origin, c.name, '');
|
|
238
|
+
}
|
|
239
|
+
setSessionCookie(res, origin, '', 0);
|
|
240
|
+
res.json({ ok: true, mode: service.mode, tokenEnabled: service.tokenEnabled, currentDeviceId: null, authenticated: false, serverTime: Date.now() });
|
|
241
|
+
};
|
|
242
|
+
router.post('/logout', safe(logout));
|
|
243
|
+
// Management never promotes a candidate credential. A formal primary session is required at the
|
|
244
|
+
// point of use, separately from the broader quota classification used by login recovery above.
|
|
245
|
+
const manage = (handler) => safe((req, res) => {
|
|
246
|
+
const actor = accessPrincipal(req, String(res.locals.authOrigin));
|
|
247
|
+
if (!actor)
|
|
248
|
+
throw new DeviceAuthError('SESSION_INVALID', 'Sign in with an authorized device to manage devices', 401);
|
|
249
|
+
service.assertActive(actor);
|
|
250
|
+
const editingSelf = req.params.id === actor.deviceId && (req.method === 'PATCH' || req.method === 'DELETE');
|
|
251
|
+
const secret = readSessionSecret(req, actor.origin);
|
|
252
|
+
if (!editingSelf && secret)
|
|
253
|
+
setSessionCookie(res, actor.origin, secret, actor.expiresAt);
|
|
254
|
+
handler(req, res, actor);
|
|
255
|
+
});
|
|
256
|
+
router.get('/devices', safe((req, res) => {
|
|
257
|
+
const origin = String(res.locals.authOrigin);
|
|
258
|
+
const actor = accessPrincipal(req, origin);
|
|
259
|
+
if (!actor)
|
|
260
|
+
throw new DeviceAuthError('SESSION_INVALID', 'Sign in with the Token to view devices', 401);
|
|
261
|
+
const secret = readSessionSecret(req, origin);
|
|
262
|
+
if (actor && secret)
|
|
263
|
+
setSessionCookie(res, origin, secret, actor.expiresAt);
|
|
264
|
+
const devices = service.list().sort((a, b) => Number(b.id === actor?.deviceId) - Number(a.id === actor?.deviceId) || b.last_used_at - a.last_used_at || a.id.localeCompare(b.id));
|
|
265
|
+
res.json({ devices, tokenEnabled: true, trustedDeviceEnabled: service.trustedDeviceEnabled, trustedOriginEnabled: service.trustedOriginEnabled, publicUrl: advertisedOrigin(), previewDomain: previewOrigin(), trustedOrigins: service.trustedOrigins, currentDeviceId: service.isTokenPrincipal(actor) ? null : actor.deviceId, serverTime: Date.now() });
|
|
266
|
+
}));
|
|
267
|
+
router.post('/devices/self', safe((req, res) => {
|
|
268
|
+
const origin = String(res.locals.authOrigin);
|
|
269
|
+
const actor = service.authenticateRequest(req, origin);
|
|
270
|
+
if (actor && tokenPrincipal(req, origin)) {
|
|
271
|
+
res.json({ device: service.list().find(d => d.id === actor.deviceId), serverTime: Date.now() });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!tokenPrincipal(req, origin))
|
|
275
|
+
throw new DeviceAuthError('SESSION_INVALID', 'Sign in with the Token before adding this browser', 401);
|
|
276
|
+
if (service.trustedDeviceEnabled)
|
|
277
|
+
throw new DeviceAuthError('DEVICE_REQUIRED', 'Authorize this browser from a trusted device or the handmux CLI', 409);
|
|
278
|
+
const candidate = selectCandidate(req, origin);
|
|
279
|
+
if (!candidate)
|
|
280
|
+
throw new DeviceAuthError('PAIRING_NOT_FOUND', 'Prepare this browser for registration and retry', 409);
|
|
281
|
+
const device = service.registerSelf(candidate.secret, origin, { name: req.body?.name, expire: req.body?.expire });
|
|
282
|
+
setSessionCookie(res, origin, candidate.secret, device.expires_at);
|
|
283
|
+
res.json({ device, serverTime: Date.now() });
|
|
284
|
+
}));
|
|
285
|
+
router.post('/trusted-origins', manage((req, res) => {
|
|
286
|
+
const trustedOrigins = service.addTrustedOrigin(req.body?.origin);
|
|
287
|
+
res.json({ trustedOrigins, serverTime: Date.now() });
|
|
288
|
+
}));
|
|
289
|
+
router.delete('/trusted-origins', manage((req, res) => {
|
|
290
|
+
const trustedOrigins = service.removeTrustedOrigin(req.body?.origin);
|
|
291
|
+
res.json({ trustedOrigins, serverTime: Date.now() });
|
|
292
|
+
}));
|
|
293
|
+
router.post('/trusted-origins/inspect', manage((req, res) => {
|
|
294
|
+
res.json({ affectedDevices: service.inspectTrustedOriginRemoval(req.body?.origin), serverTime: Date.now() });
|
|
295
|
+
}));
|
|
296
|
+
router.patch('/devices/:id', manage((req, res, actor) => {
|
|
297
|
+
if (req.body?.expire !== undefined)
|
|
298
|
+
throw new DeviceAuthError('DEVICE_EXPIRY_CLI_ONLY', 'Change device expiry with the handmux CLI on the server', 403);
|
|
299
|
+
const device = service.edit(String(req.params.id), { name: req.body?.name, version: req.body?.version }, actor);
|
|
300
|
+
if (device.id === actor.deviceId) {
|
|
301
|
+
const secret = readSessionSecret(req, actor.origin);
|
|
302
|
+
if (secret)
|
|
303
|
+
setSessionCookie(res, actor.origin, secret, device.expires_at);
|
|
304
|
+
}
|
|
305
|
+
res.json({ device, serverTime: Date.now() });
|
|
306
|
+
}));
|
|
307
|
+
router.delete('/devices/:id', manage((req, res, actor) => {
|
|
308
|
+
if (req.params.id === actor.deviceId) {
|
|
309
|
+
logout(req, res);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const device = service.revoke(String(req.params.id), actor);
|
|
313
|
+
res.json({ device, serverTime: Date.now() });
|
|
314
|
+
}));
|
|
315
|
+
router.get('/approvals', manage((_req, res, actor) => res.json({ approvals: service.approvals(actor), serverTime: Date.now() })));
|
|
316
|
+
router.post('/approvals', manage((req, res, actor) => res.json({ approval: service.claimWeb(req.body?.code, actor), serverTime: Date.now() })));
|
|
317
|
+
router.get('/approvals/:id', manage((req, res, actor) => res.json({ approval: service.approval(actor, String(req.params.id)), serverTime: Date.now() })));
|
|
318
|
+
router.delete('/approvals/:id', manage((req, res, actor) => res.json({ approval: service.cancelApproval(actor, String(req.params.id)), serverTime: Date.now() })));
|
|
319
|
+
router.post('/approvals/:id/authorize', manage((req, res, actor) => res.json({ device: service.authorizeWeb(actor, String(req.params.id), { name: req.body?.name, expire: req.body?.expire }), serverTime: Date.now() })));
|
|
320
|
+
return router;
|
|
321
|
+
}
|