@yeaft/webchat-agent 1.0.373 → 1.0.374

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.
Files changed (34) hide show
  1. package/cli.js +8 -0
  2. package/connection/index.js +11 -1
  3. package/index.js +4 -0
  4. package/local-runtime/server/api.js +2 -0
  5. package/local-runtime/server/auth/login.js +1 -0
  6. package/local-runtime/server/auth/oauth-flow.js +2 -2
  7. package/local-runtime/server/auth/token.js +4 -0
  8. package/local-runtime/server/config.js +47 -1
  9. package/local-runtime/server/database.js +1 -0
  10. package/local-runtime/server/db/connection.js +346 -4
  11. package/local-runtime/server/db/sandbox-db.js +673 -0
  12. package/local-runtime/server/db/user-db.js +137 -7
  13. package/local-runtime/server/index.js +60 -2
  14. package/local-runtime/server/routes/sandbox-routes.js +124 -0
  15. package/local-runtime/server/routes/user-routes.js +30 -15
  16. package/local-runtime/server/sandbox-agent-auth.js +66 -0
  17. package/local-runtime/server/sandbox-attestation-listener.js +128 -0
  18. package/local-runtime/server/sandbox-config.js +42 -0
  19. package/local-runtime/server/sandbox-host-attestation.js +175 -0
  20. package/local-runtime/server/sandbox-reconciler.js +355 -0
  21. package/local-runtime/server/ws-agent.js +34 -11
  22. package/local-runtime/server/ws-client.js +18 -5
  23. package/local-runtime/version.json +1 -1
  24. package/local-runtime/web/app.bundle.js +89 -28
  25. package/local-runtime/web/app.bundle.js.gz +0 -0
  26. package/local-runtime/web/index.html +2 -2
  27. package/local-runtime/web/style.bundle.css +1 -1
  28. package/local-runtime/web/style.bundle.css.gz +0 -0
  29. package/managed-sandbox/agent-runtime.js +73 -0
  30. package/managed-sandbox/controller.js +118 -0
  31. package/managed-sandbox/helper.js +437 -0
  32. package/managed-sandbox/identity-store.js +9 -0
  33. package/managed-sandbox/runtime-executor.js +387 -0
  34. package/package.json +1 -1
@@ -1,4 +1,50 @@
1
- import { stmts, generateUserId, generateAgentSecret, transaction } from './connection.js';
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import db, { stmts, generateUserId, generateAgentSecret, transaction } from './connection.js';
3
+
4
+ function deletionRequestDigest() {
5
+ return createHash('sha256').update(JSON.stringify({ kind: 'remove' })).digest('hex');
6
+ }
7
+
8
+ function ensureDeletionRemoval(userId, deletionId, now = Date.now()) {
9
+ const sandbox = db.prepare('SELECT * FROM sandboxes WHERE user_id = ? AND reservation_held = 1').get(userId);
10
+ if (!sandbox) return null;
11
+ const baseKey = `account-delete:${deletionId}`;
12
+ const attempts = db.prepare(`
13
+ SELECT * FROM sandbox_operations
14
+ WHERE user_id = ? AND (idempotency_key = ? OR idempotency_key LIKE ?)
15
+ ORDER BY created_at DESC, rowid DESC
16
+ `).all(userId, baseKey, `${baseKey}:attempt:%`);
17
+ const latest = attempts[0];
18
+ if (latest && latest.status !== 'failed') return latest;
19
+ const key = attempts.length === 0 ? baseKey : `${baseKey}:attempt:${attempts.length + 1}`;
20
+ const existing = db.prepare('SELECT * FROM sandbox_operations WHERE user_id = ? AND idempotency_key = ?')
21
+ .get(userId, key);
22
+ if (existing) return existing;
23
+ db.prepare(`
24
+ UPDATE sandbox_operations SET status = 'failed', error_code = 'ACCOUNT_DELETION_REQUESTED', updated_at = ?
25
+ WHERE sandbox_id = ? AND status IN ('pending', 'running')
26
+ `).run(now, sandbox.id);
27
+ db.prepare('UPDATE sandbox_credentials SET revoked_at = ? WHERE sandbox_id = ? AND revoked_at IS NULL')
28
+ .run(now, sandbox.id);
29
+ const host = db.prepare('SELECT epoch FROM sandbox_hosts WHERE id = ?').get(sandbox.host_id);
30
+ const hostEpoch = host?.epoch || sandbox.host_epoch;
31
+ const generation = sandbox.generation + 1;
32
+ db.prepare(`
33
+ UPDATE sandboxes SET desired_state = 'removed', observed_state = CASE
34
+ WHEN observed_state = 'removed' THEN observed_state ELSE 'recovery_required' END,
35
+ generation = ?, host_epoch = ?, last_error_code = NULL, updated_at = ?
36
+ WHERE id = ? AND reservation_held = 1
37
+ `).run(generation, hostEpoch, now, sandbox.id);
38
+ const operationId = `sandbox_op_${randomUUID()}`;
39
+ db.prepare(`
40
+ INSERT INTO sandbox_operations (
41
+ id, sandbox_id, user_id, idempotency_key, request_digest, kind, status,
42
+ stage, generation, host_epoch, deadline_at, created_at, updated_at
43
+ ) VALUES (?, ?, ?, ?, ?, 'remove', 'pending', 'removing', ?, ?, ?, ?, ?)
44
+ `).run(operationId, sandbox.id, userId, key, deletionRequestDigest(), generation,
45
+ hostEpoch, now + 10 * 60_000, now, now);
46
+ return db.prepare('SELECT * FROM sandbox_operations WHERE id = ?').get(operationId);
47
+ }
2
48
 
3
49
  export const userDb = {
4
50
  getOrCreate(username, displayName = null) {
@@ -21,9 +67,10 @@ export const userDb = {
21
67
  },
22
68
 
23
69
  migrateUser(username, passwordHash, email, role = 'admin') {
70
+ if (this.isDeletionTombstoned(username)) return null;
24
71
  const existing = stmts.getUserByUsername.get(username);
25
72
  if (existing) {
26
- if (existing.password_hash) {
73
+ if ((existing.deletion_state && existing.deletion_state !== 'active') || existing.password_hash) {
27
74
  return existing;
28
75
  }
29
76
  const newSecret = generateAgentSecret();
@@ -41,6 +88,10 @@ export const userDb = {
41
88
  return stmts.getUserByUsername.get(username);
42
89
  },
43
90
 
91
+ isDeletionTombstoned(username) {
92
+ return stmts.getUserDeletionTombstone.get(username) !== undefined;
93
+ },
94
+
44
95
  getUserByAgentSecret(secret) {
45
96
  if (!secret) return null;
46
97
  return stmts.getUserByAgentSecret.get(secret) || null;
@@ -50,6 +101,63 @@ export const userDb = {
50
101
  return stmts.getAllUsers.all();
51
102
  },
52
103
 
104
+ isActive(userId) {
105
+ return db.prepare("SELECT 1 FROM users WHERE id = ? AND deletion_state = 'active'").get(userId) !== undefined;
106
+ },
107
+
108
+ beginDeletion(userId, now = Date.now()) {
109
+ return transaction(() => {
110
+ const user = stmts.getUserById.get(userId);
111
+ if (!user) return null;
112
+ const deletionId = user.deletion_id || `deletion_${randomUUID()}`;
113
+ if (user.deletion_state !== 'pending') {
114
+ const changed = db.prepare(`
115
+ UPDATE users SET deletion_state = 'pending', deletion_requested_at = ?, deletion_id = ?,
116
+ password_hash = NULL, agent_secret = NULL, totp_secret = NULL, totp_enabled = 0
117
+ WHERE id = ? AND deletion_state = 'active'
118
+ `).run(now, deletionId, userId);
119
+ if (changed.changes !== 1) {
120
+ throw new Error('ACCOUNT_DELETION_STATE_CONFLICT');
121
+ }
122
+ }
123
+ db.prepare('UPDATE sandbox_entitlements SET enabled = 0, updated_at = ? WHERE user_id = ?')
124
+ .run(now, userId);
125
+ const operation = ensureDeletionRemoval(userId, deletionId, now);
126
+ return { deletionId, status: 'pending', operationId: operation?.id || null };
127
+ })();
128
+ },
129
+
130
+ reconcilePendingDeletions(now = Date.now()) {
131
+ const pending = db.prepare("SELECT id, deletion_id FROM users WHERE deletion_state = 'pending'").all();
132
+ let finalized = 0;
133
+ for (const user of pending) {
134
+ if (stmts.getReservedSandboxForUser.get(user.id)) {
135
+ transaction(() => ensureDeletionRemoval(user.id, user.deletion_id, now))();
136
+ continue;
137
+ }
138
+ const unsettled = db.prepare(`
139
+ SELECT 1 FROM sandboxes s
140
+ WHERE s.user_id = ? AND (
141
+ s.observed_state != 'removed' OR s.removed_at IS NULL
142
+ OR EXISTS (
143
+ SELECT 1 FROM sandbox_operations o
144
+ WHERE o.sandbox_id = s.id AND o.status IN ('pending', 'running')
145
+ )
146
+ OR NOT EXISTS (
147
+ SELECT 1 FROM sandbox_operations o
148
+ WHERE o.sandbox_id = s.id AND o.kind = 'remove' AND o.status = 'succeeded'
149
+ )
150
+ OR EXISTS (
151
+ SELECT 1 FROM sandbox_credentials c
152
+ WHERE c.sandbox_id = s.id AND c.revoked_at IS NULL
153
+ )
154
+ ) LIMIT 1
155
+ `).get(user.id);
156
+ if (!unsettled && this.deleteUser(user.id, { requirePending: true })) finalized++;
157
+ }
158
+ return finalized;
159
+ },
160
+
53
161
  updateLogin(id) {
54
162
  stmts.updateUserLogin.run(Date.now(), id);
55
163
  },
@@ -106,7 +214,8 @@ export const userDb = {
106
214
 
107
215
  getByAadOid(aadOid) {
108
216
  if (!aadOid) return null;
109
- return stmts.getUserByAadOid.get(aadOid) || null;
217
+ const user = stmts.getUserByAadOid.get(aadOid) || null;
218
+ return user?.deletion_state === 'active' ? user : null;
110
219
  },
111
220
 
112
221
  updateAadOid(userId, aadOid) {
@@ -145,18 +254,39 @@ export const userDb = {
145
254
  * Caller is responsible for revoking JWT sessions (we don't import the
146
255
  * session store from here to keep this layer pure).
147
256
  */
148
- deleteUser(userId) {
149
- // No inner try/catch any failure must bubble out so the transaction
150
- // rolls back. A partial delete (e.g. users row gone but daily_stats
151
- // orphaned) is worse than the operation failing outright.
257
+ deleteUser(userId, { requirePending = false } = {}) {
258
+ // Final erasure is allowed only after every Sandbox reservation has settled.
152
259
  const run = transaction((id) => {
260
+ const user = stmts.getUserById.get(id);
261
+ if (!user || (requirePending && user.deletion_state !== 'pending')) return false;
262
+ if (stmts.getReservedSandboxForUser.get(id)) {
263
+ const error = new Error('SANDBOX_REMOVE_REQUIRED');
264
+ error.code = 'SANDBOX_REMOVE_REQUIRED';
265
+ throw error;
266
+ }
267
+ if (requirePending) {
268
+ const unsettled = db.prepare(`
269
+ SELECT 1 FROM sandboxes s WHERE s.user_id = ? AND (
270
+ s.observed_state != 'removed' OR s.removed_at IS NULL
271
+ OR EXISTS (SELECT 1 FROM sandbox_operations o WHERE o.sandbox_id = s.id AND o.status IN ('pending', 'running'))
272
+ OR NOT EXISTS (SELECT 1 FROM sandbox_operations o WHERE o.sandbox_id = s.id AND o.kind = 'remove' AND o.status = 'succeeded')
273
+ OR EXISTS (SELECT 1 FROM sandbox_credentials c WHERE c.sandbox_id = s.id AND c.revoked_at IS NULL)
274
+ ) LIMIT 1
275
+ `).get(id);
276
+ if (unsettled) return false;
277
+ }
278
+ stmts.deleteReleasedSandboxesForUser.run(id);
153
279
  stmts.deleteIdentitiesForUser.run(id);
154
280
  stmts.deleteUserSessionsByUser.run(id);
281
+ stmts.deleteYeaftSessionsByUserCascade.run(id);
155
282
  stmts.deleteUserStats.run(id);
156
283
  stmts.deleteDailyStatsForUser.run(id);
157
284
  stmts.deleteCustomExpertRolesForUser.run(id);
158
285
  stmts.deleteInvitationsCreatedBy.run(id);
159
286
  stmts.clearInvitationUsedBy.run(id);
287
+ if (requirePending) {
288
+ stmts.insertUserDeletionTombstone.run(user.username, user.deletion_id, Date.now());
289
+ }
160
290
  const result = stmts.deleteUserById.run(id);
161
291
  return result.changes > 0;
162
292
  });
@@ -9,13 +9,18 @@ import { fileURLToPath } from 'url';
9
9
  import { dirname, join } from 'path';
10
10
  import { CONFIG, isEmailConfigured, validateProductionConfig } from './config.js';
11
11
  import { agents, webClients, userFileTabs, userStatsDeltas } from './context.js';
12
- import { invitationDb, userStatsDb, closeDb } from './database.js';
12
+ import { invitationDb, userDb, userStatsDb, closeDb } from './database.js';
13
13
  import { registerApiRoutes } from './api.js';
14
14
  import { registerProxyRoutes, handleProxyWebSocketUpgrade } from './proxy.js';
15
15
  import { handleAgentConnection } from './ws-agent.js';
16
16
  import { handleWebConnection } from './ws-client.js';
17
17
  import { sendToWebClient } from './ws-utils.js';
18
18
  import { markAgentHeartbeatPing, markAgentHeartbeatStall, shouldTerminateAgentHeartbeat } from './heartbeat-policy.js';
19
+ import { createSandboxReconciler } from './sandbox-reconciler.js';
20
+ import {
21
+ closeSandboxAttestationListener,
22
+ startSandboxAttestationListener
23
+ } from './sandbox-attestation-listener.js';
19
24
 
20
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
26
 
@@ -62,6 +67,7 @@ const wss = new WebSocketServer({
62
67
  // =====================
63
68
  const AGENT_HEARTBEAT_INTERVAL = 30000;
64
69
  const CLIENT_HEARTBEAT_INTERVAL = 90000;
70
+ const ACCOUNT_DELETION_RECONCILE_INTERVAL = 5000;
65
71
 
66
72
  let lastAgentHeartbeatTickAt = Date.now();
67
73
  setInterval(() => {
@@ -221,13 +227,57 @@ if (configValidation.warnings) {
221
227
  console.warn('');
222
228
  }
223
229
 
224
- server.listen(CONFIG.port, CONFIG.host, () => {
230
+ let sandboxReconcileTimer = null;
231
+ let sandboxAttestationListener = null;
232
+ const accountDeletionTimer = setInterval(() => {
233
+ try {
234
+ userDb.reconcilePendingDeletions();
235
+ } catch (error) {
236
+ console.error('[AccountDeletion] Reconcile failed:', error.message);
237
+ }
238
+ }, ACCOUNT_DELETION_RECONCILE_INTERVAL);
239
+ accountDeletionTimer.unref?.();
240
+ userDb.reconcilePendingDeletions();
241
+
242
+ async function startServers() {
243
+ sandboxAttestationListener = await startSandboxAttestationListener({ config: CONFIG.sandbox });
244
+ if (sandboxAttestationListener) {
245
+ console.log(`Sandbox Host attestation listener running on https://${CONFIG.sandbox.hostAttestationListenerHost}:${CONFIG.sandbox.hostAttestationListenerPort}`);
246
+ }
247
+
248
+ try {
249
+ await new Promise((resolve, reject) => {
250
+ const onError = (err) => {
251
+ server.off('listening', onListening);
252
+ reject(err);
253
+ };
254
+ const onListening = () => {
255
+ server.off('error', onError);
256
+ resolve();
257
+ };
258
+ server.once('error', onError);
259
+ server.once('listening', onListening);
260
+ server.listen(CONFIG.port, CONFIG.host);
261
+ });
262
+ } catch (err) {
263
+ await closeSandboxAttestationListener(sandboxAttestationListener);
264
+ sandboxAttestationListener = null;
265
+ throw err;
266
+ }
267
+
268
+ sandboxReconcileTimer = createSandboxReconciler({ config: CONFIG.sandbox }).start();
225
269
  console.log(`Server running on http://${CONFIG.host || '0.0.0.0'}:${CONFIG.port}`);
226
270
  console.log(`Auth mode: ${CONFIG.skipAuth ? 'SKIP (development)' : 'ENABLED'}`);
227
271
  if (!CONFIG.skipAuth) {
228
272
  console.log(`Users configured: ${CONFIG.users.length}`);
229
273
  console.log(`Email verification: ${isEmailConfigured() ? 'ENABLED' : 'DISABLED'}`);
230
274
  }
275
+ }
276
+
277
+ startServers().catch(err => {
278
+ console.error('[Startup] Failed to start server:', err);
279
+ if (sandboxReconcileTimer) clearInterval(sandboxReconcileTimer);
280
+ process.exit(1);
231
281
  });
232
282
 
233
283
  // =====================
@@ -235,6 +285,14 @@ server.listen(CONFIG.port, CONFIG.host, () => {
235
285
  // =====================
236
286
  async function gracefulShutdown(signal) {
237
287
  console.log(`\n[Shutdown] Received ${signal}, starting graceful shutdown...`);
288
+ if (sandboxReconcileTimer) clearInterval(sandboxReconcileTimer);
289
+ clearInterval(accountDeletionTimer);
290
+ try {
291
+ await closeSandboxAttestationListener(sandboxAttestationListener);
292
+ if (sandboxAttestationListener) console.log('[Shutdown] Sandbox attestation listener closed');
293
+ } catch (err) {
294
+ console.error('[Shutdown] Failed to close Sandbox attestation listener:', err.message);
295
+ }
238
296
 
239
297
  // 1. 通知所有 web client 服务即将更新
240
298
  const updateMsg = { type: 'server_updating' };
@@ -0,0 +1,124 @@
1
+ import { timingSafeEqual } from 'crypto';
2
+ import { CONFIG } from '../config.js';
3
+ import { sandboxDb, userDb } from '../database.js';
4
+ import { SandboxConflictError } from '../db/sandbox-db.js';
5
+ import {
6
+ registerSandboxHostAttestation,
7
+ SandboxHostAttestationError
8
+ } from '../sandbox-host-attestation.js';
9
+
10
+ function loadUser(req) {
11
+ let user = userDb.getByUsername(req.user.username);
12
+ if (!user && CONFIG.skipAuth) user = userDb.getOrCreate(req.user.username, req.user.username);
13
+ return user;
14
+ }
15
+
16
+ function sendError(res, err) {
17
+ if (err instanceof SandboxConflictError) {
18
+ return res.status(409).json({ code: err.code });
19
+ }
20
+ console.error('Sandbox API error:', err);
21
+ return res.status(500).json({ code: 'SANDBOX_INTERNAL_ERROR' });
22
+ }
23
+
24
+ function normalizedFingerprint(value) {
25
+ return String(value || '').replaceAll(':', '').trim().toLowerCase();
26
+ }
27
+
28
+ export function authenticateSandboxController(request, expectedFingerprint) {
29
+ if (!request?.socket?.authorized) return false;
30
+ const expected = Buffer.from(normalizedFingerprint(expectedFingerprint));
31
+ const certificate = request.socket.getPeerCertificate?.();
32
+ const actual = Buffer.from(normalizedFingerprint(certificate?.fingerprint256));
33
+ return expected.length > 0 && expected.length === actual.length
34
+ && timingSafeEqual(expected, actual);
35
+ }
36
+
37
+ export function handleSandboxHostAttestation(req, res, config = CONFIG.sandbox) {
38
+ if (!authenticateSandboxController(req, config.controllerAttestationFingerprint)) {
39
+ return res.status(401).json({ code: 'SANDBOX_CONTROLLER_IDENTITY_REJECTED' });
40
+ }
41
+ try {
42
+ registerSandboxHostAttestation(req.body, config);
43
+ return res.status(202).json({ accepted: true });
44
+ } catch (err) {
45
+ if (err instanceof SandboxHostAttestationError) {
46
+ return res.status(401).json({ code: 'SANDBOX_HOST_ATTESTATION_REJECTED' });
47
+ }
48
+ return sendError(res, err);
49
+ }
50
+ }
51
+
52
+ export function registerSandboxRoutes(app, { requireAuth, requireAdmin }) {
53
+ // The managed runtime has no user Agent secret. Its one-time bootstrap token
54
+ // is itself the scoped authorization for obtaining a revocable credential.
55
+ app.post('/api/sandbox/bootstrap/exchange', (req, res) => {
56
+ try {
57
+ const result = sandboxDb.exchangeBootstrap(req.body?.token, req.body?.claims);
58
+ return res.json(result);
59
+ } catch (err) {
60
+ if (err instanceof SandboxConflictError) {
61
+ return res.status(401).json({ code: 'SANDBOX_BOOTSTRAP_INVALID' });
62
+ }
63
+ return sendError(res, err);
64
+ }
65
+ });
66
+
67
+ app.put('/api/admin/sandbox/entitlements/:userId', requireAuth, requireAdmin, (req, res) => {
68
+ try {
69
+ const entitlement = sandboxDb.setEntitlement(
70
+ req.params.userId,
71
+ req.body?.enabled,
72
+ req.user?.username
73
+ );
74
+ return res.json({ entitlement });
75
+ } catch (err) {
76
+ if (err instanceof SandboxConflictError && err.code === 'SANDBOX_USER_NOT_FOUND') {
77
+ return res.status(404).json({ code: err.code });
78
+ }
79
+ return sendError(res, err);
80
+ }
81
+ });
82
+
83
+ app.get('/api/sandbox/capability', requireAuth, (req, res) => {
84
+ const user = loadUser(req);
85
+ if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
86
+ res.json(sandboxDb.capability(user.id, CONFIG.sandbox));
87
+ });
88
+
89
+ app.get('/api/sandbox', requireAuth, (req, res) => {
90
+ const user = loadUser(req);
91
+ if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
92
+ res.json({ sandbox: sandboxDb.snapshot(user.id) });
93
+ });
94
+
95
+ app.post('/api/sandbox', requireAuth, (req, res) => {
96
+ const user = loadUser(req);
97
+ if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
98
+ try {
99
+ const result = sandboxDb.create(user.id, {
100
+ agentName: req.body?.agentName,
101
+ sizeId: req.body?.sizeId,
102
+ idempotencyKey: req.get('Idempotency-Key')
103
+ }, CONFIG.sandbox);
104
+ return res.status(result.replayed ? 200 : 202).json(result);
105
+ } catch (err) {
106
+ return sendError(res, err);
107
+ }
108
+ });
109
+
110
+ for (const action of ['start', 'stop', 'retry', 'remove']) {
111
+ app.post(`/api/sandbox/${action}`, requireAuth, (req, res) => {
112
+ const user = loadUser(req);
113
+ if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
114
+ try {
115
+ const result = sandboxDb.requestAction(
116
+ user.id, action, req.get('Idempotency-Key'), CONFIG.sandbox
117
+ );
118
+ return res.status(result.replayed ? 200 : 202).json(result);
119
+ } catch (err) {
120
+ return sendError(res, err);
121
+ }
122
+ });
123
+ }
124
+ }
@@ -1,7 +1,12 @@
1
1
  import { CONFIG } from '../config.js';
2
2
  import { hashPassword } from '../auth.js';
3
3
  import { userDb, sessionDb } from '../database.js';
4
- import { activeSessions, revokedTokens } from '../auth/session-store.js';
4
+ import {
5
+ activeSessions, pendingVerifications, pendingTotpVerifications, pendingTotpSetup,
6
+ revokedTokens
7
+ } from '../auth/session-store.js';
8
+ import { clearSessionCookie } from '../auth/request-auth.js';
9
+ import { agents, webClients, userStatsDeltas } from '../context.js';
5
10
 
6
11
  // 过滤用户敏感字段
7
12
  function sanitizeUser(user) {
@@ -149,21 +154,31 @@ export function registerUserRoutes(app, { requireAuth, requireAdmin }) {
149
154
  }
150
155
  }
151
156
 
152
- const removed = userDb.deleteUser(user.id);
153
- if (!removed) return res.status(404).json({ error: 'User not found or already deleted' });
154
-
155
- // Best-effort: revoke every active JWT belonging to this user so any
156
- // open tabs can't keep talking to the API.
157
- try {
158
- for (const [token, info] of activeSessions.entries()) {
159
- if (info && info.username === user.username) {
160
- activeSessions.delete(token);
161
- revokedTokens.add(token);
162
- }
163
- }
164
- } catch {}
157
+ const deletion = userDb.beginDeletion(user.id);
158
+ if (!deletion) return res.status(404).json({ error: 'User not found or already deleted' });
165
159
 
166
- res.json({ success: true });
160
+ // Durable eligibility is enforced from the user row. These sweeps close
161
+ // already-open channels immediately rather than waiting for their next use.
162
+ for (const [token, info] of activeSessions.entries()) {
163
+ if (info?.username === user.username) {
164
+ activeSessions.delete(token);
165
+ revokedTokens.add(token);
166
+ }
167
+ }
168
+ for (const pending of [pendingVerifications, pendingTotpVerifications, pendingTotpSetup]) {
169
+ for (const [token, info] of pending) {
170
+ if (info?.username === user.username) pending.delete(token);
171
+ }
172
+ }
173
+ for (const [, client] of webClients) {
174
+ if (client.userId === user.id) client.ws.close(1008, 'Account disabled');
175
+ }
176
+ for (const [, agent] of agents) {
177
+ if (agent.ownerId === user.id) agent.ws.close(1008, 'Account disabled');
178
+ }
179
+ userStatsDeltas.delete(user.id);
180
+ clearSessionCookie(req, res);
181
+ res.status(202).json(deletion);
167
182
  } catch (err) {
168
183
  console.error('Delete user error:', err);
169
184
  res.status(500).json({ error: 'Failed to delete account' });
@@ -0,0 +1,66 @@
1
+ import { sandboxDb } from './database.js';
2
+ import { agents } from './context.js';
3
+
4
+ function readClaims(message) {
5
+ const claims = message?.sandboxClaims;
6
+ if (!claims || typeof claims !== 'object') return null;
7
+ if (typeof claims.sandboxId !== 'string' || typeof claims.instanceId !== 'string'
8
+ || !Number.isInteger(claims.generation) || typeof claims.imageDigest !== 'string') {
9
+ return null;
10
+ }
11
+ return claims;
12
+ }
13
+
14
+ /**
15
+ * Authenticate a managed Sandbox Agent without accepting the user's general
16
+ * Agent secret. URL identity and credential scope must describe the same
17
+ * immutable Sandbox instance.
18
+ */
19
+ export function authenticateSandboxAgent(message, pending, store = sandboxDb) {
20
+ if (message?.authKind !== 'sandbox' || typeof message.credentialId !== 'string'
21
+ || typeof message.secret !== 'string') {
22
+ return null;
23
+ }
24
+ const claims = readClaims(message);
25
+ if (!claims || pending.instanceId !== claims.instanceId || pending.agentId !== claims.sandboxId) {
26
+ return null;
27
+ }
28
+ try {
29
+ const authenticated = store.authenticateCredential(message.credentialId, message.secret, claims);
30
+ if (authenticated.sandboxId !== claims.sandboxId) return null;
31
+ return {
32
+ ...authenticated,
33
+ instanceId: claims.instanceId,
34
+ generation: claims.generation,
35
+ imageDigest: claims.imageDigest,
36
+ sessionKey: message.secret
37
+ };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Resolve readiness from the authenticated managed Agent connection. Controller
45
+ * booleans are inspection evidence, but cannot prove that the scoped Agent has
46
+ * completed its end-to-end Server sync.
47
+ */
48
+ export function canForceReadyAfterSyncTimeout(agent) {
49
+ return !agent?.sandboxIdentity;
50
+ }
51
+
52
+ export function isSandboxAgentReady(identity, connectedAgents = agents) {
53
+ if (!identity) return false;
54
+ for (const agent of connectedAgents.values()) {
55
+ const current = agent.sandboxIdentity;
56
+ if (agent.status === 'ready' && agent.isAlive && current
57
+ && current.sandboxId === identity.sandboxId
58
+ && agent.instanceId === identity.instanceId
59
+ && current.generation === identity.generation
60
+ && current.imageDigest === identity.imageDigest
61
+ && agent.capabilities?.includes('managed-sandbox')) {
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ }
@@ -0,0 +1,128 @@
1
+ import express from 'express';
2
+ import { createServer } from 'https';
3
+ import { CONFIG } from './config.js';
4
+ import {
5
+ authenticateSandboxController,
6
+ handleSandboxHostAttestation
7
+ } from './routes/sandbox-routes.js';
8
+
9
+ export const SANDBOX_HOST_ATTESTATION_PATH = '/api/sandbox/hosts/attest';
10
+
11
+ function authenticatePeer(req, res, next, config) {
12
+ if (!authenticateSandboxController(req, config.controllerAttestationFingerprint)) {
13
+ return res.status(401).json({ code: 'SANDBOX_CONTROLLER_IDENTITY_REJECTED' });
14
+ }
15
+ return next();
16
+ }
17
+
18
+ function parseStrictJson(req, res, next) {
19
+ try {
20
+ const body = JSON.parse(req.body.toString('utf8'));
21
+ if (!body || typeof body !== 'object') {
22
+ return res.status(400).json({ code: 'SANDBOX_ATTESTATION_BODY_INVALID' });
23
+ }
24
+ req.body = body;
25
+ return next();
26
+ } catch {
27
+ return res.status(400).json({ code: 'SANDBOX_ATTESTATION_BODY_INVALID' });
28
+ }
29
+ }
30
+
31
+ function bodyErrorHandler(err, req, res, next) {
32
+ if (!err) return next();
33
+ if (err.type === 'entity.too.large') {
34
+ return res.status(413).json({ code: 'SANDBOX_ATTESTATION_BODY_TOO_LARGE' });
35
+ }
36
+ return next(err);
37
+ }
38
+
39
+ /**
40
+ * Create the dedicated HTTPS server used only for Controller Host attestation.
41
+ * TLS chain authorization occurs before Express, while the route also pins the
42
+ * leaf certificate fingerprint before consuming the signed payload.
43
+ *
44
+ * @param {{ config?: object, handler?: Function }} options
45
+ * @returns {import('https').Server}
46
+ */
47
+ export function createSandboxAttestationListener({
48
+ config = CONFIG.sandbox,
49
+ handler = handleSandboxHostAttestation
50
+ } = {}) {
51
+ const app = express();
52
+ app.disable('x-powered-by');
53
+
54
+ app.post(
55
+ SANDBOX_HOST_ATTESTATION_PATH,
56
+ (req, res, next) => authenticatePeer(req, res, next, config),
57
+ express.raw({ limit: config.hostAttestationBodyLimitBytes, type: () => true }),
58
+ parseStrictJson,
59
+ (req, res) => handler(req, res, config)
60
+ );
61
+ app.use(bodyErrorHandler);
62
+
63
+ const listener = createServer({
64
+ cert: config.hostAttestationServerCert,
65
+ key: config.hostAttestationServerKey,
66
+ ca: config.hostAttestationClientCa,
67
+ requestCert: true,
68
+ rejectUnauthorized: true,
69
+ minVersion: 'TLSv1.2'
70
+ }, app);
71
+ const sockets = new Set();
72
+ listener.on('connection', socket => {
73
+ sockets.add(socket);
74
+ socket.once('close', () => sockets.delete(socket));
75
+ });
76
+ listener.sandboxAttestationSockets = sockets;
77
+ listener.sandboxAttestationShutdownTimeoutMs = config.hostAttestationShutdownTimeoutMs;
78
+ return listener;
79
+ }
80
+
81
+ /**
82
+ * Start the attestation listener when Managed Sandbox is enabled.
83
+ * @param {{ config?: object, handler?: Function }} options
84
+ * @returns {Promise<import('https').Server|null>}
85
+ */
86
+ export async function startSandboxAttestationListener({
87
+ config = CONFIG.sandbox,
88
+ handler = handleSandboxHostAttestation
89
+ } = {}) {
90
+ if (!config.enabled) return null;
91
+ const listener = createSandboxAttestationListener({ config, handler });
92
+ await new Promise((resolve, reject) => {
93
+ const onError = (err) => {
94
+ listener.off('listening', onListening);
95
+ reject(err);
96
+ };
97
+ const onListening = () => {
98
+ listener.off('error', onError);
99
+ resolve();
100
+ };
101
+ listener.once('error', onError);
102
+ listener.once('listening', onListening);
103
+ listener.listen(config.hostAttestationListenerPort, config.hostAttestationListenerHost);
104
+ });
105
+ return listener;
106
+ }
107
+
108
+ export async function closeSandboxAttestationListener(listener) {
109
+ if (!listener) return;
110
+ const sockets = listener.sandboxAttestationSockets || new Set();
111
+ const timeoutMs = listener.sandboxAttestationShutdownTimeoutMs || 1_000;
112
+ await new Promise((resolve, reject) => {
113
+ let settled = false;
114
+ const finish = err => {
115
+ if (settled) return;
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ if (err && err.code !== 'ERR_SERVER_NOT_RUNNING') reject(err);
119
+ else resolve();
120
+ };
121
+ const timer = setTimeout(() => {
122
+ for (const socket of sockets) socket.destroy();
123
+ }, timeoutMs);
124
+ timer.unref?.();
125
+ listener.close(finish);
126
+ listener.closeIdleConnections?.();
127
+ });
128
+ }