@yeaft/webchat-agent 1.0.372 → 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.
- package/cli.js +8 -0
- package/connection/index.js +11 -1
- package/index.js +4 -0
- package/local-runtime/server/api.js +2 -0
- package/local-runtime/server/auth/login.js +1 -0
- package/local-runtime/server/auth/oauth-flow.js +2 -2
- package/local-runtime/server/auth/token.js +4 -0
- package/local-runtime/server/config.js +47 -1
- package/local-runtime/server/database.js +1 -0
- package/local-runtime/server/db/connection.js +346 -4
- package/local-runtime/server/db/sandbox-db.js +673 -0
- package/local-runtime/server/db/user-db.js +137 -7
- package/local-runtime/server/index.js +60 -2
- package/local-runtime/server/routes/sandbox-routes.js +124 -0
- package/local-runtime/server/routes/user-routes.js +30 -15
- package/local-runtime/server/sandbox-agent-auth.js +66 -0
- package/local-runtime/server/sandbox-attestation-listener.js +128 -0
- package/local-runtime/server/sandbox-config.js +42 -0
- package/local-runtime/server/sandbox-host-attestation.js +175 -0
- package/local-runtime/server/sandbox-reconciler.js +355 -0
- package/local-runtime/server/ws-agent.js +34 -11
- package/local-runtime/server/ws-client.js +18 -5
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +90 -29
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/managed-sandbox/agent-runtime.js +73 -0
- package/managed-sandbox/controller.js +118 -0
- package/managed-sandbox/helper.js +437 -0
- package/managed-sandbox/identity-store.js +9 -0
- package/managed-sandbox/runtime-executor.js +387 -0
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -65,6 +65,14 @@ if (command === 'doctor') {
|
|
|
65
65
|
await handleLlmCommand(subArgs);
|
|
66
66
|
} else if (command === 'local') {
|
|
67
67
|
await handleLocalCommand(subArgs);
|
|
68
|
+
} else if (command === 'managed-sandbox') {
|
|
69
|
+
try {
|
|
70
|
+
const { runManagedSandboxAgent } = await import('./managed-sandbox/agent-runtime.js');
|
|
71
|
+
await runManagedSandboxAgent(subArgs);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`Managed Sandbox Agent failed: ${error.message}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
68
76
|
} else if (command === 'upgrade') {
|
|
69
77
|
await upgrade(subArgs);
|
|
70
78
|
} else if (command === '--version' || command === '-v') {
|
package/connection/index.js
CHANGED
|
@@ -67,7 +67,17 @@ export function connect(WebSocketImpl = WebSocket) {
|
|
|
67
67
|
console.log('Received auth challenge, sending credentials...');
|
|
68
68
|
ctx.pendingAuthTempId = msg.tempId;
|
|
69
69
|
// Send authentication via WebSocket (not URL)
|
|
70
|
-
|
|
70
|
+
const managed = ctx.CONFIG.managedSandboxIdentity;
|
|
71
|
+
socket.send(JSON.stringify(managed ? {
|
|
72
|
+
type: 'auth',
|
|
73
|
+
tempId: msg.tempId,
|
|
74
|
+
authKind: 'sandbox',
|
|
75
|
+
credentialId: managed.credentialId,
|
|
76
|
+
secret: managed.secret,
|
|
77
|
+
sandboxClaims: managed.claims,
|
|
78
|
+
capabilities: ctx.agentCapabilities,
|
|
79
|
+
version: ctx.agentVersion
|
|
80
|
+
} : {
|
|
71
81
|
type: 'auth',
|
|
72
82
|
tempId: msg.tempId,
|
|
73
83
|
secret: ctx.CONFIG.agentSecret,
|
package/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { loadNodePty } from './terminal.js';
|
|
22
22
|
import { connect } from './connection.js';
|
|
23
23
|
import { loadMcpServers } from './mcp.js';
|
|
24
|
+
import { getManagedSandboxIdentity } from './managed-sandbox/identity-store.js';
|
|
24
25
|
import { loadConfig as loadYeaftConfig } from './yeaft/config.js';
|
|
25
26
|
import {
|
|
26
27
|
ensureManagedCliTools,
|
|
@@ -48,6 +49,7 @@ ctx.pkgName = pkg.name;
|
|
|
48
49
|
// service instances must stay scoped to their standard per-instance config.
|
|
49
50
|
const LOCAL_CONFIG_FILE = join(process.cwd(), '.claude-agent.json');
|
|
50
51
|
const IS_LOCAL_RUN = process.env.YEAFT_LOCAL_RUN === 'true';
|
|
52
|
+
const MANAGED_SANDBOX_IDENTITY = getManagedSandboxIdentity();
|
|
51
53
|
const DEFAULT_AGENT_NAME = getDefaultAgentName();
|
|
52
54
|
|
|
53
55
|
// 加载或创建配置
|
|
@@ -119,6 +121,7 @@ const CONFIG = {
|
|
|
119
121
|
telemetry: loadYeaftConfig({ dir: YEAFT_DIR }).telemetry,
|
|
120
122
|
reconnectInterval: fileConfig.reconnectInterval,
|
|
121
123
|
agentSecret: process.env.AGENT_SECRET || fileConfig.agentSecret,
|
|
124
|
+
managedSandboxIdentity: MANAGED_SANDBOX_IDENTITY,
|
|
122
125
|
// 显式禁用的工具(非 MCP 相关)
|
|
123
126
|
explicitDisallowedTools: (() => {
|
|
124
127
|
const raw = process.env.DISALLOWED_TOOLS || fileConfig.disallowedTools || '';
|
|
@@ -155,6 +158,7 @@ async function detectCapabilities() {
|
|
|
155
158
|
// flip `agent.encryptOutbound = false`, stopping outbound encryption
|
|
156
159
|
// to this peer. Old servers ignore the unknown capability token.
|
|
157
160
|
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'work_center', 'work_center_message_v2', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch'];
|
|
161
|
+
if (MANAGED_SANDBOX_IDENTITY) capabilities.push('managed-sandbox');
|
|
158
162
|
if (process.platform === 'linux') capabilities.push('work_item_attachments');
|
|
159
163
|
const pty = await loadNodePty();
|
|
160
164
|
if (pty) capabilities.push('terminal');
|
|
@@ -11,6 +11,7 @@ import { registerSessionRoutes } from './routes/session-routes.js';
|
|
|
11
11
|
import { registerUploadRoutes } from './routes/upload-routes.js';
|
|
12
12
|
import { registerAdminRoutes } from './routes/admin-routes.js';
|
|
13
13
|
import { registerExpertRoutes } from './routes/expert-routes.js';
|
|
14
|
+
import { registerSandboxRoutes } from './routes/sandbox-routes.js';
|
|
14
15
|
|
|
15
16
|
// 登录速率限制: IP -> { attempts, resetAt }
|
|
16
17
|
const loginAttempts = new Map();
|
|
@@ -111,4 +112,5 @@ export function registerApiRoutes(app) {
|
|
|
111
112
|
registerUploadRoutes(app, shared);
|
|
112
113
|
registerAdminRoutes(app, shared);
|
|
113
114
|
registerExpertRoutes(app, shared);
|
|
115
|
+
registerSandboxRoutes(app, shared);
|
|
114
116
|
}
|
|
@@ -13,6 +13,7 @@ import { generateVerificationCode, maskEmail } from './utils.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export function completeLogin(username, sessionKey, role) {
|
|
15
15
|
const user = getUserByUsername(username);
|
|
16
|
+
if (!user) return { success: false, error: 'Invalid or disabled account' };
|
|
16
17
|
const token = issueSessionToken(username);
|
|
17
18
|
activeSessions.set(token, { username, sessionKey });
|
|
18
19
|
return {
|
|
@@ -214,8 +214,8 @@ export async function handleCallback({ provider, code, state }) {
|
|
|
214
214
|
// intent='login' (or anything else — default to login).
|
|
215
215
|
if (existing) {
|
|
216
216
|
const user = userDb.get(existing.user_id);
|
|
217
|
-
if (!user) {
|
|
218
|
-
return { kind: 'error', status:
|
|
217
|
+
if (!user || user.deletion_state !== 'active') {
|
|
218
|
+
return { kind: 'error', status: 403, error: 'Account is disabled' };
|
|
219
219
|
}
|
|
220
220
|
// Backfill display_name on subsequent logins if it was never set away
|
|
221
221
|
// from the auto-generated username (e.g. early logins before this code
|
|
@@ -3,6 +3,7 @@ import { readFileSync, existsSync, writeFileSync } from 'fs';
|
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
5
|
import { userDb } from './database.js';
|
|
6
|
+
import { validateSandboxDeploymentConfig } from './sandbox-config.js';
|
|
6
7
|
|
|
7
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
9
|
const __dirname = dirname(__filename);
|
|
@@ -114,6 +115,44 @@ export const CONFIG = {
|
|
|
114
115
|
// Agent authentication (global fallback — per-user agent_secret is preferred)
|
|
115
116
|
agentSecret: process.env.AGENT_SECRET || DEFAULT_AGENT_SECRET,
|
|
116
117
|
|
|
118
|
+
// Managed Sandbox is fail-closed. Enabling the product flag is not enough:
|
|
119
|
+
// entitlement and a qualified dedicated Host with capacity are also required.
|
|
120
|
+
sandbox: {
|
|
121
|
+
enabled: process.env.SANDBOX_ENABLED === 'true',
|
|
122
|
+
maxReservedSandboxes: parseInt(process.env.SANDBOX_MAX_RESERVED, 10) || 2,
|
|
123
|
+
hostMemoryReserveMiB: parseInt(process.env.SANDBOX_HOST_MEMORY_RESERVE_MIB, 10) || 2048,
|
|
124
|
+
hostFreshnessMs: parseInt(process.env.SANDBOX_HOST_FRESHNESS_MS, 10) || 30_000,
|
|
125
|
+
agentRecoveryGraceMs: parseInt(process.env.SANDBOX_AGENT_RECOVERY_GRACE_MS, 10) || 60_000,
|
|
126
|
+
operationTimeoutMs: parseInt(process.env.SANDBOX_OPERATION_TIMEOUT_MS, 10) || 10 * 60_000,
|
|
127
|
+
reconcileIntervalMs: parseInt(process.env.SANDBOX_RECONCILE_INTERVAL_MS, 10) || 5_000,
|
|
128
|
+
controllerRequestTimeoutMs: parseInt(process.env.SANDBOX_CONTROLLER_TIMEOUT_MS, 10) || 10_000,
|
|
129
|
+
bootstrapTtlMs: parseInt(process.env.SANDBOX_BOOTSTRAP_TTL_MS, 10) || 5 * 60_000,
|
|
130
|
+
bootstrapSigningKey: process.env.SANDBOX_BOOTSTRAP_SIGNING_KEY || '',
|
|
131
|
+
hostAttestationKey: process.env.SANDBOX_HOST_ATTESTATION_KEY || '',
|
|
132
|
+
controllerAttestationFingerprint: process.env.SANDBOX_CONTROLLER_ATTESTATION_FINGERPRINT || '',
|
|
133
|
+
hostAttestationListenerHost: process.env.SANDBOX_HOST_ATTESTATION_LISTENER_HOST || '',
|
|
134
|
+
hostAttestationListenerPort: parseInt(process.env.SANDBOX_HOST_ATTESTATION_LISTENER_PORT, 10) || 0,
|
|
135
|
+
hostAttestationServerCert: process.env.SANDBOX_HOST_ATTESTATION_SERVER_CERT || '',
|
|
136
|
+
hostAttestationServerKey: process.env.SANDBOX_HOST_ATTESTATION_SERVER_KEY || '',
|
|
137
|
+
hostAttestationClientCa: process.env.SANDBOX_HOST_ATTESTATION_CLIENT_CA || '',
|
|
138
|
+
hostAttestationBodyLimitBytes:
|
|
139
|
+
parseInt(process.env.SANDBOX_HOST_ATTESTATION_BODY_LIMIT_BYTES, 10) || 64 * 1024,
|
|
140
|
+
hostAttestationShutdownTimeoutMs:
|
|
141
|
+
parseInt(process.env.SANDBOX_HOST_ATTESTATION_SHUTDOWN_TIMEOUT_MS, 10) || 1_000,
|
|
142
|
+
helperAttestationPublicKey: process.env.SANDBOX_HELPER_ATTESTATION_PUBLIC_KEY || '',
|
|
143
|
+
hostAttestationMaxSkewMs: parseInt(process.env.SANDBOX_HOST_ATTESTATION_MAX_SKEW_MS, 10) || 30_000,
|
|
144
|
+
imageDigest: process.env.SANDBOX_IMAGE_DIGEST || '',
|
|
145
|
+
controllerUrl: process.env.SANDBOX_CONTROLLER_URL || '',
|
|
146
|
+
controllerToken: process.env.SANDBOX_CONTROLLER_TOKEN || '',
|
|
147
|
+
controllerClientCert: process.env.SANDBOX_CONTROLLER_CLIENT_CERT || '',
|
|
148
|
+
controllerClientKey: process.env.SANDBOX_CONTROLLER_CLIENT_KEY || '',
|
|
149
|
+
controllerCaCert: process.env.SANDBOX_CONTROLLER_CA_CERT || '',
|
|
150
|
+
operationSigningPrivateKey: process.env.SANDBOX_OPERATION_SIGNING_PRIVATE_KEY || '',
|
|
151
|
+
controllerResultPublicKey: process.env.SANDBOX_CONTROLLER_RESULT_PUBLIC_KEY || '',
|
|
152
|
+
controllerProtocolMaxSkewMs: parseInt(process.env.SANDBOX_CONTROLLER_PROTOCOL_MAX_SKEW_MS, 10) || 30_000,
|
|
153
|
+
controllerHostId: process.env.SANDBOX_CONTROLLER_HOST_ID || ''
|
|
154
|
+
},
|
|
155
|
+
|
|
117
156
|
// File upload settings
|
|
118
157
|
maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 50 * 1024 * 1024, // 50MB
|
|
119
158
|
fileCleanupInterval: parseInt(process.env.FILE_CLEANUP_INTERVAL, 10) || 600000, // 10 minutes
|
|
@@ -192,6 +231,7 @@ export function getUserByUsername(username) {
|
|
|
192
231
|
// Query database first (includes migrated, registered, and SSO-only users).
|
|
193
232
|
const dbUser = userDb.getByUsername(username);
|
|
194
233
|
if (dbUser) {
|
|
234
|
+
if (dbUser.deletion_state && dbUser.deletion_state !== 'active') return null;
|
|
195
235
|
return {
|
|
196
236
|
username: dbUser.username,
|
|
197
237
|
passwordHash: dbUser.password_hash || null,
|
|
@@ -202,7 +242,9 @@ export function getUserByUsername(username) {
|
|
|
202
242
|
id: dbUser.id
|
|
203
243
|
};
|
|
204
244
|
}
|
|
205
|
-
//
|
|
245
|
+
// A finalized deletion remains authoritative after the users row is erased.
|
|
246
|
+
if (userDb.isDeletionTombstoned(username)) return null;
|
|
247
|
+
// Fallback to CONFIG.users only when no authoritative database state exists.
|
|
206
248
|
return CONFIG.users.find(u => u.username === username) || null;
|
|
207
249
|
}
|
|
208
250
|
|
|
@@ -257,6 +299,10 @@ export function validateProductionConfig() {
|
|
|
257
299
|
errors.push('JWT_SECRET must be set to a secure value in production mode');
|
|
258
300
|
}
|
|
259
301
|
|
|
302
|
+
if (CONFIG.sandbox.enabled && !validateSandboxDeploymentConfig(CONFIG.sandbox)) {
|
|
303
|
+
errors.push('Sandbox requires an HTTPS dedicated Controller, Host binding, fixed image digest, a dedicated mTLS Host attestation listener with a pinned Controller certificate, Controller token, asymmetric operation/result keys, bootstrap signing key, Host attestation key, and Helper attestation public key');
|
|
304
|
+
}
|
|
305
|
+
|
|
260
306
|
// Check that at least one user with a password exists (in DB or config)
|
|
261
307
|
// Only warn (don't block startup) — allows first-time setup via create-user.js
|
|
262
308
|
const dbUsers = userDb.getAll();
|
|
@@ -10,5 +10,6 @@ export { messageDb } from './db/message-db.js';
|
|
|
10
10
|
export { userStatsDb } from './db/user-stats-db.js';
|
|
11
11
|
export { expertDb } from './db/expert-db.js';
|
|
12
12
|
export { identityDb } from './db/identity-db.js';
|
|
13
|
+
export { sandboxDb } from './db/sandbox-db.js';
|
|
13
14
|
export { closeDb } from './db/connection.js';
|
|
14
15
|
export { default } from './db/connection.js';
|
|
@@ -32,7 +32,18 @@ db.exec(`
|
|
|
32
32
|
username TEXT UNIQUE NOT NULL,
|
|
33
33
|
display_name TEXT,
|
|
34
34
|
created_at INTEGER NOT NULL,
|
|
35
|
-
last_login_at INTEGER
|
|
35
|
+
last_login_at INTEGER,
|
|
36
|
+
deletion_state TEXT NOT NULL DEFAULT 'active',
|
|
37
|
+
deletion_requested_at INTEGER,
|
|
38
|
+
deletion_id TEXT UNIQUE
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- Finalized deletion survives removal of the users row. This prevents a
|
|
42
|
+
-- configured credential from becoming authoritative again on restart.
|
|
43
|
+
CREATE TABLE IF NOT EXISTS user_deletion_tombstones (
|
|
44
|
+
username TEXT PRIMARY KEY,
|
|
45
|
+
deletion_id TEXT,
|
|
46
|
+
deleted_at INTEGER NOT NULL
|
|
36
47
|
);
|
|
37
48
|
|
|
38
49
|
-- 会话表
|
|
@@ -141,6 +152,158 @@ db.exec(`
|
|
|
141
152
|
-- concurrent reads continue during the build; writers will block briefly.
|
|
142
153
|
CREATE INDEX IF NOT EXISTS idx_messages_session_role_id ON messages(session_id, role, id DESC);
|
|
143
154
|
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_stats(date);
|
|
155
|
+
|
|
156
|
+
-- Managed Sandbox control-plane state. Runtime resources are deliberately
|
|
157
|
+
-- not created on this mixed-use Server Host; qualified Controllers report
|
|
158
|
+
-- Host capacity separately.
|
|
159
|
+
CREATE TABLE IF NOT EXISTS sandbox_hosts (
|
|
160
|
+
id TEXT PRIMARY KEY,
|
|
161
|
+
epoch INTEGER NOT NULL,
|
|
162
|
+
qualified INTEGER NOT NULL DEFAULT 0,
|
|
163
|
+
controller_healthy INTEGER NOT NULL DEFAULT 0,
|
|
164
|
+
helper_healthy INTEGER NOT NULL DEFAULT 0,
|
|
165
|
+
runtime_healthy INTEGER NOT NULL DEFAULT 0,
|
|
166
|
+
quota_healthy INTEGER NOT NULL DEFAULT 0,
|
|
167
|
+
network_healthy INTEGER NOT NULL DEFAULT 0,
|
|
168
|
+
image_digest TEXT NOT NULL,
|
|
169
|
+
cpu_millis_total INTEGER NOT NULL,
|
|
170
|
+
memory_mib_total INTEGER NOT NULL,
|
|
171
|
+
memory_mib_available INTEGER NOT NULL,
|
|
172
|
+
disk_gib_total INTEGER NOT NULL,
|
|
173
|
+
updated_at INTEGER NOT NULL
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
CREATE TABLE IF NOT EXISTS sandbox_host_attestations (
|
|
177
|
+
nonce TEXT PRIMARY KEY,
|
|
178
|
+
host_id TEXT NOT NULL REFERENCES sandbox_hosts(id) ON DELETE CASCADE,
|
|
179
|
+
epoch INTEGER NOT NULL,
|
|
180
|
+
observed_at INTEGER NOT NULL,
|
|
181
|
+
created_at INTEGER NOT NULL
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
-- Controller boot identity is only a change detector. The Server owns this
|
|
185
|
+
-- durable monotonic fence and its exact activation identity.
|
|
186
|
+
CREATE TABLE IF NOT EXISTS sandbox_host_epochs (
|
|
187
|
+
host_id TEXT PRIMARY KEY REFERENCES sandbox_hosts(id) ON DELETE CASCADE,
|
|
188
|
+
source_epoch TEXT NOT NULL,
|
|
189
|
+
epoch INTEGER NOT NULL CHECK(epoch >= 1),
|
|
190
|
+
activation_digest TEXT,
|
|
191
|
+
activated_at INTEGER,
|
|
192
|
+
updated_at INTEGER NOT NULL
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
CREATE INDEX IF NOT EXISTS idx_sandbox_host_attestations_host
|
|
196
|
+
ON sandbox_host_attestations(host_id, created_at DESC);
|
|
197
|
+
|
|
198
|
+
CREATE TABLE IF NOT EXISTS sandbox_host_audit_events (
|
|
199
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
200
|
+
host_id TEXT NOT NULL,
|
|
201
|
+
epoch INTEGER NOT NULL,
|
|
202
|
+
event_type TEXT NOT NULL,
|
|
203
|
+
outcome TEXT NOT NULL,
|
|
204
|
+
error_code TEXT,
|
|
205
|
+
created_at INTEGER NOT NULL
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_sandbox_host_audit_created
|
|
209
|
+
ON sandbox_host_audit_events(host_id, created_at DESC, id DESC);
|
|
210
|
+
|
|
211
|
+
CREATE TABLE IF NOT EXISTS sandbox_entitlements (
|
|
212
|
+
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
213
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
214
|
+
updated_at INTEGER NOT NULL
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
CREATE TABLE IF NOT EXISTS sandbox_entitlement_audit_events (
|
|
218
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
219
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
220
|
+
actor_username TEXT NOT NULL,
|
|
221
|
+
enabled INTEGER NOT NULL,
|
|
222
|
+
created_at INTEGER NOT NULL
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
CREATE INDEX IF NOT EXISTS idx_sandbox_entitlement_audit_user_created
|
|
226
|
+
ON sandbox_entitlement_audit_events(user_id, created_at DESC, id DESC);
|
|
227
|
+
|
|
228
|
+
CREATE TABLE IF NOT EXISTS sandboxes (
|
|
229
|
+
id TEXT PRIMARY KEY,
|
|
230
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
|
231
|
+
host_id TEXT NOT NULL REFERENCES sandbox_hosts(id) ON DELETE RESTRICT,
|
|
232
|
+
host_epoch INTEGER NOT NULL,
|
|
233
|
+
agent_name TEXT NOT NULL,
|
|
234
|
+
size_id TEXT NOT NULL,
|
|
235
|
+
cpu_millis INTEGER NOT NULL,
|
|
236
|
+
memory_mib INTEGER NOT NULL,
|
|
237
|
+
disk_gib INTEGER NOT NULL,
|
|
238
|
+
desired_state TEXT NOT NULL,
|
|
239
|
+
observed_state TEXT NOT NULL,
|
|
240
|
+
generation INTEGER NOT NULL DEFAULT 1,
|
|
241
|
+
instance_id TEXT NOT NULL,
|
|
242
|
+
image_digest TEXT NOT NULL,
|
|
243
|
+
reservation_held INTEGER NOT NULL DEFAULT 1,
|
|
244
|
+
last_error_code TEXT,
|
|
245
|
+
created_at INTEGER NOT NULL,
|
|
246
|
+
updated_at INTEGER NOT NULL,
|
|
247
|
+
removed_at INTEGER
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_sandboxes_active_user
|
|
251
|
+
ON sandboxes(user_id) WHERE reservation_held = 1;
|
|
252
|
+
CREATE INDEX IF NOT EXISTS idx_sandboxes_host_reservation
|
|
253
|
+
ON sandboxes(host_id, reservation_held);
|
|
254
|
+
|
|
255
|
+
CREATE TABLE IF NOT EXISTS sandbox_operations (
|
|
256
|
+
id TEXT PRIMARY KEY,
|
|
257
|
+
sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
|
|
258
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
259
|
+
idempotency_key TEXT NOT NULL,
|
|
260
|
+
request_digest TEXT NOT NULL,
|
|
261
|
+
kind TEXT NOT NULL,
|
|
262
|
+
status TEXT NOT NULL,
|
|
263
|
+
stage TEXT NOT NULL,
|
|
264
|
+
generation INTEGER NOT NULL,
|
|
265
|
+
host_epoch INTEGER NOT NULL,
|
|
266
|
+
deadline_at INTEGER NOT NULL,
|
|
267
|
+
error_code TEXT,
|
|
268
|
+
created_at INTEGER NOT NULL,
|
|
269
|
+
updated_at INTEGER NOT NULL,
|
|
270
|
+
UNIQUE(user_id, idempotency_key)
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_sandbox_operations_active
|
|
274
|
+
ON sandbox_operations(sandbox_id)
|
|
275
|
+
WHERE status IN ('pending', 'running');
|
|
276
|
+
|
|
277
|
+
CREATE TABLE IF NOT EXISTS sandbox_credentials (
|
|
278
|
+
id TEXT PRIMARY KEY,
|
|
279
|
+
sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
|
|
280
|
+
instance_id TEXT NOT NULL,
|
|
281
|
+
generation INTEGER NOT NULL,
|
|
282
|
+
image_digest TEXT NOT NULL,
|
|
283
|
+
kind TEXT NOT NULL,
|
|
284
|
+
secret_hash TEXT NOT NULL,
|
|
285
|
+
expires_at INTEGER,
|
|
286
|
+
consumed_at INTEGER,
|
|
287
|
+
revoked_at INTEGER,
|
|
288
|
+
created_at INTEGER NOT NULL
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
CREATE TABLE IF NOT EXISTS sandbox_audit_events (
|
|
292
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
293
|
+
sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
|
|
294
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
295
|
+
operation_id TEXT,
|
|
296
|
+
event_type TEXT NOT NULL,
|
|
297
|
+
actor_kind TEXT NOT NULL,
|
|
298
|
+
generation INTEGER NOT NULL,
|
|
299
|
+
host_epoch INTEGER NOT NULL,
|
|
300
|
+
outcome TEXT NOT NULL,
|
|
301
|
+
error_code TEXT,
|
|
302
|
+
created_at INTEGER NOT NULL
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
CREATE INDEX IF NOT EXISTS idx_sandbox_audit_sandbox_created
|
|
306
|
+
ON sandbox_audit_events(sandbox_id, created_at, id);
|
|
144
307
|
`);
|
|
145
308
|
|
|
146
309
|
// 数据库迁移 - 添加缺失的列
|
|
@@ -194,7 +357,11 @@ const migrations = [
|
|
|
194
357
|
`ALTER TABLE daily_stats ADD COLUMN output_tokens INTEGER DEFAULT 0`,
|
|
195
358
|
`ALTER TABLE daily_stats ADD COLUMN cache_read_tokens INTEGER DEFAULT 0`,
|
|
196
359
|
`ALTER TABLE daily_stats ADD COLUMN cache_write_tokens INTEGER DEFAULT 0`,
|
|
197
|
-
`ALTER TABLE daily_stats ADD COLUMN total_tokens INTEGER DEFAULT 0
|
|
360
|
+
`ALTER TABLE daily_stats ADD COLUMN total_tokens INTEGER DEFAULT 0`,
|
|
361
|
+
`ALTER TABLE sandbox_hosts ADD COLUMN memory_mib_available INTEGER NOT NULL DEFAULT 0`,
|
|
362
|
+
`ALTER TABLE users ADD COLUMN deletion_state TEXT NOT NULL DEFAULT 'active'`,
|
|
363
|
+
`ALTER TABLE users ADD COLUMN deletion_requested_at INTEGER`,
|
|
364
|
+
`ALTER TABLE users ADD COLUMN deletion_id TEXT`
|
|
198
365
|
];
|
|
199
366
|
|
|
200
367
|
// Yeaft sessions table — server-side persistence so the unified sidebar
|
|
@@ -356,6 +523,165 @@ for (const migration of migrations) {
|
|
|
356
523
|
}
|
|
357
524
|
}
|
|
358
525
|
|
|
526
|
+
// One-time fail-closed migration from the PR's opaque TEXT Host epochs. SQLite
|
|
527
|
+
// does not change existing column affinity for CREATE TABLE IF NOT EXISTS, so
|
|
528
|
+
// normalize every live fence explicitly and seed the Server-owned allocator.
|
|
529
|
+
const unmigratedSandboxHosts = db.prepare(`
|
|
530
|
+
SELECT h.id, h.epoch FROM sandbox_hosts h
|
|
531
|
+
LEFT JOIN sandbox_host_epochs e ON e.host_id = h.id
|
|
532
|
+
WHERE e.host_id IS NULL
|
|
533
|
+
`).all();
|
|
534
|
+
for (const host of unmigratedSandboxHosts) {
|
|
535
|
+
const values = [host.epoch];
|
|
536
|
+
for (const row of db.prepare('SELECT host_epoch AS epoch FROM sandboxes WHERE host_id = ?').all(host.id)) {
|
|
537
|
+
values.push(row.epoch);
|
|
538
|
+
}
|
|
539
|
+
for (const row of db.prepare(`
|
|
540
|
+
SELECT o.host_epoch AS epoch FROM sandbox_operations o
|
|
541
|
+
JOIN sandboxes s ON s.id = o.sandbox_id WHERE s.host_id = ?
|
|
542
|
+
`).all(host.id)) values.push(row.epoch);
|
|
543
|
+
const observed = values.map(value => {
|
|
544
|
+
const numeric = Number(value);
|
|
545
|
+
if (Number.isSafeInteger(numeric) && numeric >= 1) return numeric;
|
|
546
|
+
const match = String(value || '').match(/(\d+)$/);
|
|
547
|
+
return match ? Number(match[1]) : 0;
|
|
548
|
+
}).filter(value => Number.isSafeInteger(value) && value >= 1);
|
|
549
|
+
const epoch = Math.max(0, ...observed) + 1;
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
db.exec('BEGIN IMMEDIATE');
|
|
552
|
+
try {
|
|
553
|
+
db.prepare('UPDATE sandbox_hosts SET epoch = ? WHERE id = ?').run(epoch, host.id);
|
|
554
|
+
db.prepare('UPDATE sandboxes SET host_epoch = ? WHERE host_id = ?').run(epoch, host.id);
|
|
555
|
+
db.prepare(`
|
|
556
|
+
UPDATE sandbox_operations SET host_epoch = ?
|
|
557
|
+
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE host_id = ?)
|
|
558
|
+
`).run(epoch, host.id);
|
|
559
|
+
db.prepare(`
|
|
560
|
+
UPDATE sandbox_audit_events SET host_epoch = ?
|
|
561
|
+
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE host_id = ?)
|
|
562
|
+
`).run(epoch, host.id);
|
|
563
|
+
db.prepare('UPDATE sandbox_host_attestations SET epoch = ? WHERE host_id = ?').run(epoch, host.id);
|
|
564
|
+
db.prepare('UPDATE sandbox_host_audit_events SET epoch = ? WHERE host_id = ?').run(epoch, host.id);
|
|
565
|
+
db.prepare(`
|
|
566
|
+
INSERT INTO sandbox_host_epochs
|
|
567
|
+
(host_id, source_epoch, epoch, activation_digest, activated_at, updated_at)
|
|
568
|
+
VALUES (?, ?, ?, NULL, NULL, ?)
|
|
569
|
+
`).run(host.id, `migrated:${String(host.epoch)}`, epoch, now);
|
|
570
|
+
db.exec('COMMIT');
|
|
571
|
+
} catch (error) {
|
|
572
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// SQLite keeps the affinity from the original CREATE TABLE. Rebuild legacy
|
|
578
|
+
// sandbox tables so epoch values are stored and compared as integers, rather
|
|
579
|
+
// than merely writing numeric-looking values into TEXT-affinity columns.
|
|
580
|
+
const sandboxEpochTableRebuilds = [
|
|
581
|
+
{
|
|
582
|
+
table: 'sandbox_hosts', column: 'epoch',
|
|
583
|
+
create: `CREATE TABLE sandbox_hosts (
|
|
584
|
+
id TEXT PRIMARY KEY, epoch INTEGER NOT NULL, qualified INTEGER NOT NULL DEFAULT 0,
|
|
585
|
+
controller_healthy INTEGER NOT NULL DEFAULT 0, helper_healthy INTEGER NOT NULL DEFAULT 0,
|
|
586
|
+
runtime_healthy INTEGER NOT NULL DEFAULT 0, quota_healthy INTEGER NOT NULL DEFAULT 0,
|
|
587
|
+
network_healthy INTEGER NOT NULL DEFAULT 0, image_digest TEXT NOT NULL,
|
|
588
|
+
cpu_millis_total INTEGER NOT NULL, memory_mib_total INTEGER NOT NULL,
|
|
589
|
+
memory_mib_available INTEGER NOT NULL, disk_gib_total INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
590
|
+
)`,
|
|
591
|
+
columns: 'id, epoch, qualified, controller_healthy, helper_healthy, runtime_healthy, quota_healthy, network_healthy, image_digest, cpu_millis_total, memory_mib_total, memory_mib_available, disk_gib_total, updated_at',
|
|
592
|
+
select: 'id, CAST(epoch AS INTEGER), qualified, controller_healthy, helper_healthy, runtime_healthy, quota_healthy, network_healthy, image_digest, cpu_millis_total, memory_mib_total, memory_mib_available, disk_gib_total, updated_at',
|
|
593
|
+
indexes: ['CREATE INDEX IF NOT EXISTS idx_sandboxes_host_reservation ON sandboxes(host_id, reservation_held)']
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
table: 'sandbox_host_attestations', column: 'epoch',
|
|
597
|
+
create: `CREATE TABLE sandbox_host_attestations (
|
|
598
|
+
nonce TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES sandbox_hosts(id) ON DELETE CASCADE,
|
|
599
|
+
epoch INTEGER NOT NULL, observed_at INTEGER NOT NULL, created_at INTEGER NOT NULL
|
|
600
|
+
)`,
|
|
601
|
+
columns: 'nonce, host_id, epoch, observed_at, created_at',
|
|
602
|
+
select: 'nonce, host_id, CAST(epoch AS INTEGER), observed_at, created_at',
|
|
603
|
+
indexes: ['CREATE INDEX IF NOT EXISTS idx_sandbox_host_attestations_host ON sandbox_host_attestations(host_id, created_at DESC)']
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
table: 'sandbox_host_audit_events', column: 'epoch',
|
|
607
|
+
create: `CREATE TABLE sandbox_host_audit_events (
|
|
608
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, host_id TEXT NOT NULL, epoch INTEGER NOT NULL,
|
|
609
|
+
event_type TEXT NOT NULL, outcome TEXT NOT NULL, error_code TEXT, created_at INTEGER NOT NULL
|
|
610
|
+
)`,
|
|
611
|
+
columns: 'id, host_id, epoch, event_type, outcome, error_code, created_at',
|
|
612
|
+
select: 'id, host_id, CAST(epoch AS INTEGER), event_type, outcome, error_code, created_at',
|
|
613
|
+
indexes: ['CREATE INDEX IF NOT EXISTS idx_sandbox_host_audit_created ON sandbox_host_audit_events(host_id, created_at DESC, id DESC)']
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
table: 'sandboxes', column: 'host_epoch',
|
|
617
|
+
create: `CREATE TABLE sandboxes (
|
|
618
|
+
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
|
619
|
+
host_id TEXT NOT NULL REFERENCES sandbox_hosts(id) ON DELETE RESTRICT, host_epoch INTEGER NOT NULL,
|
|
620
|
+
agent_name TEXT NOT NULL, size_id TEXT NOT NULL, cpu_millis INTEGER NOT NULL,
|
|
621
|
+
memory_mib INTEGER NOT NULL, disk_gib INTEGER NOT NULL, desired_state TEXT NOT NULL,
|
|
622
|
+
observed_state TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 1, instance_id TEXT NOT NULL,
|
|
623
|
+
image_digest TEXT NOT NULL, reservation_held INTEGER NOT NULL DEFAULT 1, last_error_code TEXT,
|
|
624
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, removed_at INTEGER
|
|
625
|
+
)`,
|
|
626
|
+
columns: 'id, user_id, host_id, host_epoch, agent_name, size_id, cpu_millis, memory_mib, disk_gib, desired_state, observed_state, generation, instance_id, image_digest, reservation_held, last_error_code, created_at, updated_at, removed_at',
|
|
627
|
+
select: 'id, user_id, host_id, CAST(host_epoch AS INTEGER), agent_name, size_id, cpu_millis, memory_mib, disk_gib, desired_state, observed_state, generation, instance_id, image_digest, reservation_held, last_error_code, created_at, updated_at, removed_at',
|
|
628
|
+
indexes: [
|
|
629
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS idx_sandboxes_active_user ON sandboxes(user_id) WHERE reservation_held = 1',
|
|
630
|
+
'CREATE INDEX IF NOT EXISTS idx_sandboxes_host_reservation ON sandboxes(host_id, reservation_held)'
|
|
631
|
+
]
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
table: 'sandbox_operations', column: 'host_epoch',
|
|
635
|
+
create: `CREATE TABLE sandbox_operations (
|
|
636
|
+
id TEXT PRIMARY KEY, sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
|
|
637
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, idempotency_key TEXT NOT NULL,
|
|
638
|
+
request_digest TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, stage TEXT NOT NULL,
|
|
639
|
+
generation INTEGER NOT NULL, host_epoch INTEGER NOT NULL, deadline_at INTEGER NOT NULL,
|
|
640
|
+
error_code TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
|
641
|
+
UNIQUE(user_id, idempotency_key)
|
|
642
|
+
)`,
|
|
643
|
+
columns: 'id, sandbox_id, user_id, idempotency_key, request_digest, kind, status, stage, generation, host_epoch, deadline_at, error_code, created_at, updated_at',
|
|
644
|
+
select: 'id, sandbox_id, user_id, idempotency_key, request_digest, kind, status, stage, generation, CAST(host_epoch AS INTEGER), deadline_at, error_code, created_at, updated_at',
|
|
645
|
+
indexes: ["CREATE UNIQUE INDEX IF NOT EXISTS idx_sandbox_operations_active ON sandbox_operations(sandbox_id) WHERE status IN ('pending', 'running')"]
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
table: 'sandbox_audit_events', column: 'host_epoch',
|
|
649
|
+
create: `CREATE TABLE sandbox_audit_events (
|
|
650
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
|
|
651
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, operation_id TEXT,
|
|
652
|
+
event_type TEXT NOT NULL, actor_kind TEXT NOT NULL, generation INTEGER NOT NULL,
|
|
653
|
+
host_epoch INTEGER NOT NULL, outcome TEXT NOT NULL, error_code TEXT, created_at INTEGER NOT NULL
|
|
654
|
+
)`,
|
|
655
|
+
columns: 'id, sandbox_id, user_id, operation_id, event_type, actor_kind, generation, host_epoch, outcome, error_code, created_at',
|
|
656
|
+
select: 'id, sandbox_id, user_id, operation_id, event_type, actor_kind, generation, CAST(host_epoch AS INTEGER), outcome, error_code, created_at',
|
|
657
|
+
indexes: ['CREATE INDEX IF NOT EXISTS idx_sandbox_audit_sandbox_created ON sandbox_audit_events(sandbox_id, created_at, id)']
|
|
658
|
+
}
|
|
659
|
+
];
|
|
660
|
+
|
|
661
|
+
for (const rebuild of sandboxEpochTableRebuilds) {
|
|
662
|
+
const affinity = db.prepare(`PRAGMA table_info(${rebuild.table})`).all()
|
|
663
|
+
.find(column => column.name === rebuild.column)?.type?.toUpperCase();
|
|
664
|
+
if (affinity === 'INTEGER') continue;
|
|
665
|
+
const legacyTable = `${rebuild.table}_legacy_epoch`;
|
|
666
|
+
db.exec('PRAGMA foreign_keys = OFF');
|
|
667
|
+
db.exec('PRAGMA legacy_alter_table = ON');
|
|
668
|
+
db.exec('BEGIN IMMEDIATE');
|
|
669
|
+
try {
|
|
670
|
+
db.exec(`ALTER TABLE ${rebuild.table} RENAME TO ${legacyTable}`);
|
|
671
|
+
db.exec(rebuild.create);
|
|
672
|
+
db.exec(`INSERT INTO ${rebuild.table} (${rebuild.columns}) SELECT ${rebuild.select} FROM ${legacyTable}`);
|
|
673
|
+
db.exec(`DROP TABLE ${legacyTable}`);
|
|
674
|
+
for (const index of rebuild.indexes) db.exec(index);
|
|
675
|
+
db.exec('COMMIT');
|
|
676
|
+
} catch (error) {
|
|
677
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
678
|
+
throw error;
|
|
679
|
+
} finally {
|
|
680
|
+
db.exec('PRAGMA legacy_alter_table = OFF');
|
|
681
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
359
685
|
// User identities table (multi-provider SSO + account binding)
|
|
360
686
|
// One user can have multiple identities (microsoft / github / google / wechat / alipay).
|
|
361
687
|
// UNIQUE(provider, subject) enforces "this provider account is bound to one user only".
|
|
@@ -442,7 +768,8 @@ try { db.exec(customExpertTables); } catch (e) { /* tables already exist */ }
|
|
|
442
768
|
const postMigrationIndexes = [
|
|
443
769
|
`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
|
|
444
770
|
`CREATE INDEX IF NOT EXISTS idx_users_agent_secret ON users(agent_secret)`,
|
|
445
|
-
`CREATE INDEX IF NOT EXISTS idx_users_aad_oid ON users(aad_oid)
|
|
771
|
+
`CREATE INDEX IF NOT EXISTS idx_users_aad_oid ON users(aad_oid)`,
|
|
772
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_deletion_id ON users(deletion_id)`
|
|
446
773
|
];
|
|
447
774
|
// Time the post-migration index pass so the operational signal lands in the
|
|
448
775
|
// deploy log on first startup after composite-index addition — a multi-second
|
|
@@ -514,8 +841,17 @@ export const stmts = {
|
|
|
514
841
|
SELECT * FROM users WHERE username = ?
|
|
515
842
|
`),
|
|
516
843
|
|
|
844
|
+
getUserDeletionTombstone: db.prepare(`
|
|
845
|
+
SELECT * FROM user_deletion_tombstones WHERE username = ?
|
|
846
|
+
`),
|
|
847
|
+
|
|
848
|
+
insertUserDeletionTombstone: db.prepare(`
|
|
849
|
+
INSERT OR IGNORE INTO user_deletion_tombstones (username, deletion_id, deleted_at)
|
|
850
|
+
VALUES (?, ?, ?)
|
|
851
|
+
`),
|
|
852
|
+
|
|
517
853
|
getUserByAgentSecret: db.prepare(`
|
|
518
|
-
SELECT * FROM users WHERE agent_secret = ?
|
|
854
|
+
SELECT * FROM users WHERE agent_secret = ? AND deletion_state = 'active'
|
|
519
855
|
`),
|
|
520
856
|
|
|
521
857
|
getAllUsers: db.prepare(`
|
|
@@ -988,6 +1324,12 @@ export const stmts = {
|
|
|
988
1324
|
clearInvitationUsedBy: db.prepare(`
|
|
989
1325
|
UPDATE invitations SET used_by = NULL WHERE used_by = ?
|
|
990
1326
|
`),
|
|
1327
|
+
getReservedSandboxForUser: db.prepare(`
|
|
1328
|
+
SELECT id FROM sandboxes WHERE user_id = ? AND reservation_held = 1 LIMIT 1
|
|
1329
|
+
`),
|
|
1330
|
+
deleteReleasedSandboxesForUser: db.prepare(`
|
|
1331
|
+
DELETE FROM sandboxes WHERE user_id = ? AND reservation_held = 0
|
|
1332
|
+
`),
|
|
991
1333
|
deleteUserById: db.prepare(`
|
|
992
1334
|
DELETE FROM users WHERE id = ?
|
|
993
1335
|
`),
|