@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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'crypto';
|
|
2
|
+
import db, { transaction } from './connection.js';
|
|
3
|
+
|
|
4
|
+
export const SANDBOX_SIZES = Object.freeze({
|
|
5
|
+
small: Object.freeze({ id: 'small', cpuMillis: 500, memoryMiB: 1024, diskGiB: 10 }),
|
|
6
|
+
normal: Object.freeze({ id: 'normal', cpuMillis: 1000, memoryMiB: 2048, diskGiB: 20 })
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const ACTIONS = Object.freeze({
|
|
11
|
+
start: { desiredState: 'running', allowed: ['stopped', 'failed'], stage: 'starting' },
|
|
12
|
+
stop: { desiredState: 'stopped', allowed: ['running', 'failed'], stage: 'stopping' },
|
|
13
|
+
retry: { desiredState: null, allowed: ['failed', 'remove_failed'], stage: 'retrying' },
|
|
14
|
+
remove: { desiredState: 'removed', allowed: ['reserving', 'provisioning', 'starting', 'waiting_for_agent', 'running', 'stopping', 'stopped', 'failed', 'remove_failed', 'recovery_required'], stage: 'removing' }
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export class SandboxConflictError extends Error {
|
|
18
|
+
constructor(code) {
|
|
19
|
+
super(code);
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const getEntitlement = db.prepare('SELECT enabled FROM sandbox_entitlements WHERE user_id = ?');
|
|
25
|
+
const getSandbox = db.prepare('SELECT * FROM sandboxes WHERE user_id = ? AND reservation_held = 1');
|
|
26
|
+
const getSandboxById = db.prepare('SELECT * FROM sandboxes WHERE id = ?');
|
|
27
|
+
const getOperation = db.prepare('SELECT * FROM sandbox_operations WHERE user_id = ? AND idempotency_key = ?');
|
|
28
|
+
const getLatestOperation = db.prepare('SELECT * FROM sandbox_operations WHERE sandbox_id = ? ORDER BY created_at DESC LIMIT 1');
|
|
29
|
+
const getActiveOperation = db.prepare(`
|
|
30
|
+
SELECT * FROM sandbox_operations
|
|
31
|
+
WHERE sandbox_id = ? AND status IN ('pending', 'running')
|
|
32
|
+
LIMIT 1
|
|
33
|
+
`);
|
|
34
|
+
const reservedCount = db.prepare('SELECT COUNT(*) AS slots FROM sandboxes WHERE reservation_held = 1');
|
|
35
|
+
const reservedTotals = db.prepare(`
|
|
36
|
+
SELECT COALESCE(SUM(cpu_millis), 0) AS cpu,
|
|
37
|
+
COALESCE(SUM(memory_mib), 0) AS memory, COALESCE(SUM(disk_gib), 0) AS disk
|
|
38
|
+
FROM sandboxes WHERE host_id = ? AND reservation_held = 1
|
|
39
|
+
`);
|
|
40
|
+
const activeStartupMemory = db.prepare(`
|
|
41
|
+
SELECT COALESCE(SUM(s.memory_mib), 0) AS memory
|
|
42
|
+
FROM sandbox_operations o JOIN sandboxes s ON s.id = o.sandbox_id
|
|
43
|
+
WHERE s.host_id = ? AND s.reservation_held = 1
|
|
44
|
+
AND o.kind IN ('create', 'start', 'retry')
|
|
45
|
+
AND o.status = 'running'
|
|
46
|
+
`);
|
|
47
|
+
const insertSandbox = db.prepare(`
|
|
48
|
+
INSERT INTO sandboxes (
|
|
49
|
+
id, user_id, host_id, host_epoch, agent_name, size_id, cpu_millis, memory_mib, disk_gib,
|
|
50
|
+
desired_state, observed_state, generation, instance_id, image_digest,
|
|
51
|
+
reservation_held, created_at, updated_at
|
|
52
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', 'reserving', 1, ?, ?, 1, ?, ?)
|
|
53
|
+
`);
|
|
54
|
+
const insertOperation = db.prepare(`
|
|
55
|
+
INSERT INTO sandbox_operations (
|
|
56
|
+
id, sandbox_id, user_id, idempotency_key, request_digest, kind, status,
|
|
57
|
+
stage, generation, host_epoch, deadline_at, created_at, updated_at
|
|
58
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
function digestRequest(value) {
|
|
62
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hashSecret(secret) {
|
|
66
|
+
return createHash('sha256').update(secret).digest();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function deriveBootstrapSecret(operationId, seed) {
|
|
70
|
+
return createHmac('sha256', seed).update(operationId).digest('base64url');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function secretMatches(secret, expectedHex) {
|
|
74
|
+
const actual = hashSecret(secret);
|
|
75
|
+
const expected = Buffer.from(expectedHex, 'hex');
|
|
76
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertBoundClaims(record, claims) {
|
|
80
|
+
if (!record || claims.sandboxId !== record.sandbox_id || claims.instanceId !== record.instance_id
|
|
81
|
+
|| claims.generation !== record.generation || claims.imageDigest !== record.image_digest) {
|
|
82
|
+
throw new SandboxConflictError('SANDBOX_CREDENTIAL_SCOPE_MISMATCH');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function credentialMatchesSandbox(credential, sandbox) {
|
|
87
|
+
return sandbox && credential.sandbox_id === sandbox.id
|
|
88
|
+
&& credential.instance_id === sandbox.instance_id
|
|
89
|
+
&& credential.generation === sandbox.generation
|
|
90
|
+
&& credential.image_digest === sandbox.image_digest;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function publicSnapshot(sandbox) {
|
|
94
|
+
if (!sandbox) return null;
|
|
95
|
+
const operation = getLatestOperation.get(sandbox.id) || null;
|
|
96
|
+
return {
|
|
97
|
+
id: sandbox.id,
|
|
98
|
+
agentName: sandbox.agent_name,
|
|
99
|
+
sizeId: sandbox.size_id,
|
|
100
|
+
desiredState: sandbox.desired_state,
|
|
101
|
+
observedState: sandbox.observed_state,
|
|
102
|
+
generation: sandbox.generation,
|
|
103
|
+
reservationHeld: !!sandbox.reservation_held,
|
|
104
|
+
lastErrorCode: sandbox.last_error_code,
|
|
105
|
+
operation: operation && {
|
|
106
|
+
id: operation.id,
|
|
107
|
+
kind: operation.kind,
|
|
108
|
+
status: operation.status,
|
|
109
|
+
stage: operation.stage,
|
|
110
|
+
errorCode: operation.error_code,
|
|
111
|
+
updatedAt: operation.updated_at
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function candidateHosts(config, now = Date.now()) {
|
|
117
|
+
const freshnessMs = config.hostFreshnessMs || 30_000;
|
|
118
|
+
if (!config.imageDigest) return [];
|
|
119
|
+
return db.prepare(`
|
|
120
|
+
SELECT * FROM sandbox_hosts
|
|
121
|
+
WHERE qualified = 1 AND controller_healthy = 1 AND helper_healthy = 1
|
|
122
|
+
AND runtime_healthy = 1 AND quota_healthy = 1 AND network_healthy = 1
|
|
123
|
+
AND image_digest = ? AND updated_at >= ?
|
|
124
|
+
ORDER BY id
|
|
125
|
+
`).all(config.imageDigest, now - freshnessMs);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function memoryReserveMiB(config) {
|
|
129
|
+
const reserve = Number(config.hostMemoryReserveMiB);
|
|
130
|
+
return Number.isSafeInteger(reserve) && reserve > 0 ? reserve : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hostCanFit(host, size, config) {
|
|
134
|
+
const reserve = memoryReserveMiB(config);
|
|
135
|
+
if (reserve === null || !Number.isSafeInteger(host.memory_mib_available)) return false;
|
|
136
|
+
const used = reservedTotals.get(host.id);
|
|
137
|
+
const startingMemory = activeStartupMemory.get(host.id).memory;
|
|
138
|
+
return reservedCount.get().slots < config.maxReservedSandboxes
|
|
139
|
+
&& used.cpu + size.cpuMillis <= host.cpu_millis_total
|
|
140
|
+
&& used.memory + size.memoryMiB <= host.memory_mib_total
|
|
141
|
+
&& host.memory_mib_available - reserve - startingMemory >= size.memoryMiB
|
|
142
|
+
&& used.disk + size.diskGiB <= host.disk_gib_total;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function assertIdempotency(userId, key, requestDigest) {
|
|
146
|
+
if (!key) throw new SandboxConflictError('SANDBOX_IDEMPOTENCY_KEY_REQUIRED');
|
|
147
|
+
const prior = getOperation.get(userId, key);
|
|
148
|
+
if (!prior) return null;
|
|
149
|
+
if (prior.request_digest !== requestDigest) throw new SandboxConflictError('SANDBOX_IDEMPOTENCY_CONFLICT');
|
|
150
|
+
return { snapshot: publicSnapshot(getSandboxById.get(prior.sandbox_id)), replayed: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function operationDeadline(config, now) {
|
|
154
|
+
return now + (config.operationTimeoutMs || 10 * 60_000);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function appendAuditEvent({ sandbox, operationId = null, eventType, actorKind, outcome, errorCode = null, now = Date.now() }) {
|
|
158
|
+
db.prepare(`
|
|
159
|
+
INSERT INTO sandbox_audit_events (
|
|
160
|
+
sandbox_id, user_id, operation_id, event_type, actor_kind,
|
|
161
|
+
generation, host_epoch, outcome, error_code, created_at
|
|
162
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
163
|
+
`).run(
|
|
164
|
+
sandbox.id, sandbox.user_id, operationId, eventType, actorKind,
|
|
165
|
+
sandbox.generation, sandbox.host_epoch, outcome, errorCode, now
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const sandboxDb = {
|
|
170
|
+
isEpochActivated(hostId, epoch, activationDigest) {
|
|
171
|
+
if (!Number.isSafeInteger(epoch) || epoch < 1 || !activationDigest) return false;
|
|
172
|
+
return Boolean(db.prepare(`
|
|
173
|
+
SELECT 1 FROM sandbox_host_epochs
|
|
174
|
+
WHERE host_id = ? AND epoch = ? AND activation_digest = ?
|
|
175
|
+
`).get(hostId, epoch, activationDigest));
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
recordEpochActivation(hostId, epoch, activationDigest, now = Date.now()) {
|
|
179
|
+
return transaction(() => {
|
|
180
|
+
if (!Number.isSafeInteger(epoch) || epoch < 1 || !activationDigest) {
|
|
181
|
+
throw new SandboxConflictError('SANDBOX_STALE_RESULT');
|
|
182
|
+
}
|
|
183
|
+
const updated = db.prepare(`
|
|
184
|
+
UPDATE sandbox_host_epochs
|
|
185
|
+
SET activation_digest = ?, activated_at = ?, updated_at = ?
|
|
186
|
+
WHERE host_id = ? AND epoch = ?
|
|
187
|
+
AND (activation_digest IS NULL OR activation_digest = ?)
|
|
188
|
+
`).run(activationDigest, now, now, hostId, epoch, activationDigest);
|
|
189
|
+
if (updated.changes !== 1) throw new SandboxConflictError('SANDBOX_STALE_RESULT');
|
|
190
|
+
db.prepare(`
|
|
191
|
+
INSERT INTO sandbox_host_audit_events
|
|
192
|
+
(host_id, epoch, event_type, outcome, error_code, created_at)
|
|
193
|
+
VALUES (?, ?, 'epoch_activation', 'succeeded', NULL, ?)
|
|
194
|
+
`).run(hostId, epoch, now);
|
|
195
|
+
return true;
|
|
196
|
+
})();
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
capability(userId, config) {
|
|
200
|
+
if (!config.enabled) return { available: false, reasonCode: 'SANDBOX_DISABLED', catalog: [] };
|
|
201
|
+
if (!getEntitlement.get(userId)?.enabled) {
|
|
202
|
+
return { available: false, reasonCode: 'SANDBOX_NOT_ENTITLED', catalog: [] };
|
|
203
|
+
}
|
|
204
|
+
const hosts = candidateHosts(config);
|
|
205
|
+
const availableSizes = Object.values(SANDBOX_SIZES).filter(size =>
|
|
206
|
+
hosts.some(host => hostCanFit(host, size, config))
|
|
207
|
+
);
|
|
208
|
+
if (availableSizes.length === 0) {
|
|
209
|
+
return { available: false, reasonCode: 'SANDBOX_CAPACITY_UNAVAILABLE', catalog: [] };
|
|
210
|
+
}
|
|
211
|
+
return { available: true, reasonCode: null, catalog: availableSizes };
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
snapshot(userId) {
|
|
215
|
+
return publicSnapshot(getSandbox.get(userId));
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
entitlement(userId) {
|
|
219
|
+
return { enabled: Boolean(getEntitlement.get(userId)?.enabled) };
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
setEntitlement(userId, enabled, actorUsername, now = Date.now()) {
|
|
223
|
+
return transaction(() => {
|
|
224
|
+
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
|
|
225
|
+
if (!user) throw new SandboxConflictError('SANDBOX_USER_NOT_FOUND');
|
|
226
|
+
const normalizedEnabled = enabled === true ? 1 : enabled === false ? 0 : null;
|
|
227
|
+
if (normalizedEnabled === null) {
|
|
228
|
+
throw new SandboxConflictError('SANDBOX_ENTITLEMENT_INVALID');
|
|
229
|
+
}
|
|
230
|
+
db.prepare(`
|
|
231
|
+
INSERT INTO sandbox_entitlements (user_id, enabled, updated_at)
|
|
232
|
+
VALUES (?, ?, ?)
|
|
233
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
234
|
+
enabled = excluded.enabled,
|
|
235
|
+
updated_at = excluded.updated_at
|
|
236
|
+
`).run(userId, normalizedEnabled, now);
|
|
237
|
+
db.prepare(`
|
|
238
|
+
INSERT INTO sandbox_entitlement_audit_events (
|
|
239
|
+
user_id, actor_username, enabled, created_at
|
|
240
|
+
) VALUES (?, ?, ?, ?)
|
|
241
|
+
`).run(userId, String(actorUsername || 'unknown'), normalizedEnabled, now);
|
|
242
|
+
return { enabled: Boolean(normalizedEnabled), updatedAt: now };
|
|
243
|
+
})();
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
create(userId, request, config) {
|
|
247
|
+
const agentName = String(request.agentName || '').trim();
|
|
248
|
+
const size = SANDBOX_SIZES[request.sizeId];
|
|
249
|
+
if (!agentName || agentName.length > 64 || !/^[a-zA-Z0-9 ._-]+$/.test(agentName)) {
|
|
250
|
+
throw new SandboxConflictError('SANDBOX_INVALID_AGENT_NAME');
|
|
251
|
+
}
|
|
252
|
+
if (!size) throw new SandboxConflictError('SANDBOX_INVALID_SIZE');
|
|
253
|
+
const requestDigest = digestRequest({ kind: 'create', agentName, sizeId: size.id });
|
|
254
|
+
|
|
255
|
+
return transaction(() => {
|
|
256
|
+
const replay = assertIdempotency(userId, request.idempotencyKey, requestDigest);
|
|
257
|
+
if (replay) return replay;
|
|
258
|
+
if (getSandbox.get(userId)) throw new SandboxConflictError('SANDBOX_ALREADY_EXISTS');
|
|
259
|
+
const capability = this.capability(userId, config);
|
|
260
|
+
if (!capability.available) throw new SandboxConflictError(capability.reasonCode);
|
|
261
|
+
const host = candidateHosts(config).find(candidate => hostCanFit(candidate, size, config));
|
|
262
|
+
if (!host) throw new SandboxConflictError('SANDBOX_CAPACITY_UNAVAILABLE');
|
|
263
|
+
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
const sandboxId = `sandbox_${randomUUID()}`;
|
|
266
|
+
insertSandbox.run(
|
|
267
|
+
sandboxId, userId, host.id, host.epoch, agentName, size.id, size.cpuMillis,
|
|
268
|
+
size.memoryMiB, size.diskGiB, `sandbox_instance_${randomUUID()}`,
|
|
269
|
+
host.image_digest, now, now
|
|
270
|
+
);
|
|
271
|
+
const operationId = `sandbox_op_${randomUUID()}`;
|
|
272
|
+
insertOperation.run(
|
|
273
|
+
operationId, sandboxId, userId, request.idempotencyKey,
|
|
274
|
+
requestDigest, 'create', 'reserving_capacity', 1, host.epoch,
|
|
275
|
+
operationDeadline(config, now), now, now
|
|
276
|
+
);
|
|
277
|
+
appendAuditEvent({
|
|
278
|
+
sandbox: getSandboxById.get(sandboxId), operationId,
|
|
279
|
+
eventType: 'operation_requested', actorKind: 'user', outcome: 'accepted', now
|
|
280
|
+
});
|
|
281
|
+
return { snapshot: publicSnapshot(getSandbox.get(userId)), replayed: false };
|
|
282
|
+
})();
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
requestAction(userId, kind, idempotencyKey, config) {
|
|
286
|
+
const action = ACTIONS[kind];
|
|
287
|
+
if (!action) throw new SandboxConflictError('SANDBOX_INVALID_ACTION');
|
|
288
|
+
const requestDigest = digestRequest({ kind });
|
|
289
|
+
return transaction(() => {
|
|
290
|
+
const replay = assertIdempotency(userId, idempotencyKey, requestDigest);
|
|
291
|
+
if (replay) return replay;
|
|
292
|
+
const sandbox = getSandbox.get(userId);
|
|
293
|
+
if (!sandbox) throw new SandboxConflictError('SANDBOX_NOT_FOUND');
|
|
294
|
+
if (!action.allowed.includes(sandbox.observed_state)) {
|
|
295
|
+
throw new SandboxConflictError('SANDBOX_ACTION_NOT_ALLOWED');
|
|
296
|
+
}
|
|
297
|
+
if (getActiveOperation.get(sandbox.id)) {
|
|
298
|
+
throw new SandboxConflictError('SANDBOX_OPERATION_IN_PROGRESS');
|
|
299
|
+
}
|
|
300
|
+
const retryingRemove = kind === 'retry' && sandbox.observed_state === 'remove_failed';
|
|
301
|
+
const operationKind = retryingRemove ? 'remove' : kind;
|
|
302
|
+
const generation = ['retry', 'remove'].includes(kind)
|
|
303
|
+
? sandbox.generation + 1
|
|
304
|
+
: sandbox.generation;
|
|
305
|
+
const desiredState = retryingRemove
|
|
306
|
+
? 'removed'
|
|
307
|
+
: kind === 'retry' ? 'running' : action.desiredState;
|
|
308
|
+
const operationStage = retryingRemove ? ACTIONS.remove.stage : action.stage;
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
let hostEpoch = sandbox.host_epoch;
|
|
311
|
+
if (kind === 'remove' && sandbox.observed_state === 'recovery_required') {
|
|
312
|
+
const host = db.prepare('SELECT epoch FROM sandbox_hosts WHERE id = ?').get(sandbox.host_id);
|
|
313
|
+
// Removal must remain reachable after a Host record is lost. Preserve the
|
|
314
|
+
// last fenced epoch unless the same Host has reported a newer one.
|
|
315
|
+
if (host) hostEpoch = host.epoch;
|
|
316
|
+
}
|
|
317
|
+
if (operationKind === 'remove') {
|
|
318
|
+
const revoked = db.prepare(`
|
|
319
|
+
UPDATE sandbox_credentials SET revoked_at = ?
|
|
320
|
+
WHERE sandbox_id = ? AND revoked_at IS NULL
|
|
321
|
+
`).run(now, sandbox.id);
|
|
322
|
+
if (revoked.changes > 0) {
|
|
323
|
+
appendAuditEvent({
|
|
324
|
+
sandbox, eventType: 'credential_revoked', actorKind: 'server',
|
|
325
|
+
outcome: 'succeeded', now
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
db.prepare(`
|
|
330
|
+
UPDATE sandboxes SET desired_state = ?, generation = ?, host_epoch = ?,
|
|
331
|
+
last_error_code = NULL, updated_at = ?
|
|
332
|
+
WHERE id = ? AND generation = ? AND reservation_held = 1
|
|
333
|
+
`).run(desiredState, generation, hostEpoch, now, sandbox.id, sandbox.generation);
|
|
334
|
+
const operationId = `sandbox_op_${randomUUID()}`;
|
|
335
|
+
insertOperation.run(
|
|
336
|
+
operationId, sandbox.id, userId, idempotencyKey,
|
|
337
|
+
requestDigest, operationKind, operationStage, generation, hostEpoch,
|
|
338
|
+
operationDeadline(config, now), now, now
|
|
339
|
+
);
|
|
340
|
+
appendAuditEvent({
|
|
341
|
+
sandbox: getSandboxById.get(sandbox.id), operationId,
|
|
342
|
+
eventType: 'operation_requested', actorKind: 'user', outcome: 'accepted', now
|
|
343
|
+
});
|
|
344
|
+
return { snapshot: publicSnapshot(getSandbox.get(userId)), replayed: false };
|
|
345
|
+
})();
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
admitPendingOperation(operationId, config, now = Date.now()) {
|
|
349
|
+
return transaction(() => {
|
|
350
|
+
const operation = db.prepare(`
|
|
351
|
+
SELECT o.*, s.host_id, s.instance_id, s.image_digest, s.desired_state,
|
|
352
|
+
s.cpu_millis, s.memory_mib, s.disk_gib
|
|
353
|
+
FROM sandbox_operations o JOIN sandboxes s ON s.id = o.sandbox_id
|
|
354
|
+
WHERE o.id = ? AND o.status = 'pending' AND s.reservation_held = 1
|
|
355
|
+
`).get(operationId);
|
|
356
|
+
if (!operation || !['create', 'start', 'retry'].includes(operation.kind)) return operation || null;
|
|
357
|
+
const host = candidateHosts(config, now).find(candidate => candidate.id === operation.host_id);
|
|
358
|
+
const reserve = memoryReserveMiB(config);
|
|
359
|
+
const startingMemory = host ? activeStartupMemory.get(host.id).memory : 0;
|
|
360
|
+
if (!host || reserve === null
|
|
361
|
+
|| host.memory_mib_available - reserve - startingMemory < operation.memory_mib) {
|
|
362
|
+
db.prepare(`
|
|
363
|
+
UPDATE sandbox_operations SET status = 'failed', stage = 'capacity_rejected',
|
|
364
|
+
error_code = 'SANDBOX_CAPACITY_UNAVAILABLE', updated_at = ?
|
|
365
|
+
WHERE id = ? AND status = 'pending'
|
|
366
|
+
`).run(now, operation.id);
|
|
367
|
+
db.prepare(`
|
|
368
|
+
UPDATE sandboxes SET observed_state = 'failed',
|
|
369
|
+
last_error_code = 'SANDBOX_CAPACITY_UNAVAILABLE', updated_at = ?
|
|
370
|
+
WHERE id = ? AND generation = ? AND reservation_held = 1
|
|
371
|
+
`).run(now, operation.sandbox_id, operation.generation);
|
|
372
|
+
appendAuditEvent({
|
|
373
|
+
sandbox: getSandboxById.get(operation.sandbox_id), operationId: operation.id,
|
|
374
|
+
eventType: 'runtime_admission', actorKind: 'server', outcome: 'rejected',
|
|
375
|
+
errorCode: 'SANDBOX_CAPACITY_UNAVAILABLE', now
|
|
376
|
+
});
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
const admitted = db.prepare(`
|
|
380
|
+
UPDATE sandbox_operations SET status = 'running', stage = 'dispatching', updated_at = ?
|
|
381
|
+
WHERE id = ? AND status = 'pending'
|
|
382
|
+
`).run(now, operation.id);
|
|
383
|
+
return admitted.changes === 1 ? { ...operation, status: 'running', stage: 'dispatching' } : null;
|
|
384
|
+
})();
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
listPendingOperations(now = Date.now()) {
|
|
388
|
+
db.prepare(`
|
|
389
|
+
UPDATE sandbox_operations SET status = 'failed', error_code = 'SANDBOX_OPERATION_TIMEOUT', updated_at = ?
|
|
390
|
+
WHERE status IN ('pending', 'running') AND deadline_at < ?
|
|
391
|
+
`).run(now, now);
|
|
392
|
+
db.prepare(`
|
|
393
|
+
UPDATE sandboxes SET observed_state = CASE WHEN desired_state = 'removed' THEN 'remove_failed' ELSE 'failed' END,
|
|
394
|
+
last_error_code = 'SANDBOX_OPERATION_TIMEOUT', updated_at = ?
|
|
395
|
+
WHERE id IN (SELECT sandbox_id FROM sandbox_operations WHERE error_code = 'SANDBOX_OPERATION_TIMEOUT' AND updated_at = ?)
|
|
396
|
+
`).run(now, now);
|
|
397
|
+
return db.prepare(`
|
|
398
|
+
SELECT o.*, s.host_id, s.instance_id, s.image_digest, s.desired_state,
|
|
399
|
+
s.cpu_millis, s.memory_mib, s.disk_gib
|
|
400
|
+
FROM sandbox_operations o JOIN sandboxes s ON s.id = o.sandbox_id
|
|
401
|
+
WHERE o.status = 'pending' AND s.reservation_held = 1
|
|
402
|
+
ORDER BY o.created_at
|
|
403
|
+
`).all();
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
reconcileRuntimeState(now = Date.now(), config = {}, runtime = {}) {
|
|
407
|
+
return transaction(() => {
|
|
408
|
+
const freshnessMs = config.hostFreshnessMs || 30_000;
|
|
409
|
+
const agentGraceMs = config.agentRecoveryGraceMs || 60_000;
|
|
410
|
+
const sandboxes = db.prepare(`
|
|
411
|
+
SELECT s.*, h.epoch AS current_host_epoch, h.qualified, h.controller_healthy,
|
|
412
|
+
h.helper_healthy, h.runtime_healthy, h.quota_healthy, h.network_healthy,
|
|
413
|
+
h.updated_at AS host_updated_at
|
|
414
|
+
FROM sandboxes s LEFT JOIN sandbox_hosts h ON h.id = s.host_id
|
|
415
|
+
WHERE s.reservation_held = 1 AND s.observed_state != 'recovery_required'
|
|
416
|
+
`).all();
|
|
417
|
+
let recovered = 0;
|
|
418
|
+
|
|
419
|
+
for (const sandbox of sandboxes) {
|
|
420
|
+
let errorCode = null;
|
|
421
|
+
if (!sandbox.current_host_epoch || sandbox.current_host_epoch !== sandbox.host_epoch) {
|
|
422
|
+
errorCode = 'SANDBOX_HOST_EPOCH_CHANGED';
|
|
423
|
+
} else if (!sandbox.qualified || !sandbox.controller_healthy || !sandbox.helper_healthy
|
|
424
|
+
|| !sandbox.runtime_healthy || !sandbox.quota_healthy || !sandbox.network_healthy
|
|
425
|
+
|| sandbox.host_updated_at < now - freshnessMs) {
|
|
426
|
+
errorCode = 'SANDBOX_HOST_UNAVAILABLE';
|
|
427
|
+
} else if (sandbox.observed_state === 'running' && sandbox.updated_at < now - agentGraceMs
|
|
428
|
+
&& !runtime.isAgentReady?.({
|
|
429
|
+
sandboxId: sandbox.id,
|
|
430
|
+
instanceId: sandbox.instance_id,
|
|
431
|
+
generation: sandbox.generation,
|
|
432
|
+
imageDigest: sandbox.image_digest
|
|
433
|
+
})) {
|
|
434
|
+
errorCode = 'SANDBOX_AGENT_NOT_READY';
|
|
435
|
+
}
|
|
436
|
+
if (!errorCode) continue;
|
|
437
|
+
|
|
438
|
+
db.prepare(`
|
|
439
|
+
UPDATE sandboxes SET observed_state = 'recovery_required', last_error_code = ?, updated_at = ?
|
|
440
|
+
WHERE id = ? AND generation = ? AND reservation_held = 1
|
|
441
|
+
`).run(errorCode, now, sandbox.id, sandbox.generation);
|
|
442
|
+
db.prepare(`
|
|
443
|
+
UPDATE sandbox_operations SET status = 'failed', error_code = ?, updated_at = ?
|
|
444
|
+
WHERE sandbox_id = ? AND status IN ('pending', 'running')
|
|
445
|
+
`).run(errorCode, now, sandbox.id);
|
|
446
|
+
const revoked = db.prepare(`
|
|
447
|
+
UPDATE sandbox_credentials SET revoked_at = ?
|
|
448
|
+
WHERE sandbox_id = ? AND revoked_at IS NULL
|
|
449
|
+
`).run(now, sandbox.id);
|
|
450
|
+
appendAuditEvent({
|
|
451
|
+
sandbox: getSandboxById.get(sandbox.id), eventType: 'runtime_reconcile',
|
|
452
|
+
actorKind: 'server', outcome: 'recovery_required', errorCode, now
|
|
453
|
+
});
|
|
454
|
+
if (revoked.changes > 0) {
|
|
455
|
+
appendAuditEvent({
|
|
456
|
+
sandbox: getSandboxById.get(sandbox.id), eventType: 'credential_revoked',
|
|
457
|
+
actorKind: 'server', outcome: 'succeeded', errorCode, now
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
recovered++;
|
|
461
|
+
}
|
|
462
|
+
return recovered;
|
|
463
|
+
})();
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
applyControllerResult(result, config = {}, runtime = {}) {
|
|
467
|
+
return transaction(() => {
|
|
468
|
+
const operation = db.prepare('SELECT * FROM sandbox_operations WHERE id = ?').get(result.operationId);
|
|
469
|
+
if (!operation) throw new SandboxConflictError('SANDBOX_OPERATION_NOT_FOUND');
|
|
470
|
+
const sandbox = getSandboxById.get(operation.sandbox_id);
|
|
471
|
+
if (!sandbox || result.action !== operation.kind || result.hostId !== sandbox.host_id
|
|
472
|
+
|| result.sandboxId !== operation.sandbox_id || result.requestDigest !== operation.request_digest
|
|
473
|
+
|| operation.generation !== result.generation || sandbox.generation !== result.generation
|
|
474
|
+
|| operation.host_epoch !== result.hostEpoch || sandbox.host_epoch !== result.hostEpoch) {
|
|
475
|
+
throw new SandboxConflictError('SANDBOX_STALE_RESULT');
|
|
476
|
+
}
|
|
477
|
+
const host = db.prepare('SELECT * FROM sandbox_hosts WHERE id = ?').get(sandbox.host_id);
|
|
478
|
+
const freshnessMs = config.hostFreshnessMs || 30_000;
|
|
479
|
+
const isRemove = operation.kind === 'remove';
|
|
480
|
+
if (!isRemove && (!host || host.epoch !== sandbox.host_epoch)) {
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
db.prepare(`
|
|
483
|
+
UPDATE sandboxes SET observed_state = 'recovery_required',
|
|
484
|
+
last_error_code = 'SANDBOX_HOST_EPOCH_CHANGED', updated_at = ?
|
|
485
|
+
WHERE id = ? AND generation = ? AND reservation_held = 1
|
|
486
|
+
`).run(now, sandbox.id, sandbox.generation);
|
|
487
|
+
db.prepare(`
|
|
488
|
+
UPDATE sandbox_operations SET status = 'failed',
|
|
489
|
+
error_code = 'SANDBOX_HOST_EPOCH_CHANGED', updated_at = ?
|
|
490
|
+
WHERE id = ? AND status IN ('pending', 'running')
|
|
491
|
+
`).run(now, operation.id);
|
|
492
|
+
db.prepare(`
|
|
493
|
+
UPDATE sandbox_credentials SET revoked_at = ?
|
|
494
|
+
WHERE sandbox_id = ? AND revoked_at IS NULL
|
|
495
|
+
`).run(now, sandbox.id);
|
|
496
|
+
appendAuditEvent({
|
|
497
|
+
sandbox, operationId: operation.id, eventType: 'controller_result',
|
|
498
|
+
actorKind: 'controller', outcome: 'recovery_required',
|
|
499
|
+
errorCode: 'SANDBOX_HOST_EPOCH_CHANGED', now
|
|
500
|
+
});
|
|
501
|
+
return { snapshot: publicSnapshot(getSandboxById.get(sandbox.id)), replayed: false };
|
|
502
|
+
}
|
|
503
|
+
if (!isRemove && (!host.qualified || !host.controller_healthy || !host.helper_healthy
|
|
504
|
+
|| !host.runtime_healthy || !host.quota_healthy || !host.network_healthy
|
|
505
|
+
|| host.updated_at < Date.now() - freshnessMs)) {
|
|
506
|
+
throw new SandboxConflictError('SANDBOX_HOST_UNAVAILABLE');
|
|
507
|
+
}
|
|
508
|
+
if (operation.status !== 'pending' && operation.status !== 'running') {
|
|
509
|
+
return { snapshot: publicSnapshot(sandbox), replayed: true };
|
|
510
|
+
}
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
if (!result.success) {
|
|
513
|
+
const observed = operation.kind === 'remove' ? 'remove_failed' : 'failed';
|
|
514
|
+
const errorCode = result.errorCode || 'SANDBOX_RUNTIME_FAILED';
|
|
515
|
+
db.prepare('UPDATE sandboxes SET observed_state = ?, last_error_code = ?, updated_at = ? WHERE id = ?')
|
|
516
|
+
.run(observed, errorCode, now, sandbox.id);
|
|
517
|
+
db.prepare("UPDATE sandbox_operations SET status = 'failed', error_code = ?, updated_at = ? WHERE id = ?")
|
|
518
|
+
.run(errorCode, now, operation.id);
|
|
519
|
+
appendAuditEvent({
|
|
520
|
+
sandbox, operationId: operation.id, eventType: 'controller_result',
|
|
521
|
+
actorKind: 'controller', outcome: 'failed', errorCode, now
|
|
522
|
+
});
|
|
523
|
+
return { snapshot: publicSnapshot(getSandboxById.get(sandbox.id)), replayed: false };
|
|
524
|
+
}
|
|
525
|
+
if (operation.kind === 'remove') {
|
|
526
|
+
const proof = result.absenceProof || {};
|
|
527
|
+
const required = ['container', 'storage', 'quota', 'network', 'credential'];
|
|
528
|
+
if (!required.every(key => proof[key] === true)) {
|
|
529
|
+
throw new SandboxConflictError('SANDBOX_REMOVE_PROOF_REQUIRED');
|
|
530
|
+
}
|
|
531
|
+
db.prepare(`
|
|
532
|
+
UPDATE sandboxes SET observed_state = 'removed', reservation_held = 0,
|
|
533
|
+
removed_at = ?, updated_at = ? WHERE id = ? AND reservation_held = 1
|
|
534
|
+
`).run(now, now, sandbox.id);
|
|
535
|
+
} else {
|
|
536
|
+
if (operation.kind !== 'stop') {
|
|
537
|
+
const proof = result.readinessProof || {};
|
|
538
|
+
const required = ['image', 'cpu', 'memory', 'pid', 'io', 'quota', 'network', 'credential'];
|
|
539
|
+
if (!required.every(key => proof[key] === true)) {
|
|
540
|
+
throw new SandboxConflictError('SANDBOX_READINESS_PROOF_REQUIRED');
|
|
541
|
+
}
|
|
542
|
+
if (result.imageDigest !== sandbox.image_digest) {
|
|
543
|
+
throw new SandboxConflictError('SANDBOX_IMAGE_MISMATCH');
|
|
544
|
+
}
|
|
545
|
+
if (!runtime.isAgentReady?.({
|
|
546
|
+
sandboxId: sandbox.id,
|
|
547
|
+
instanceId: sandbox.instance_id,
|
|
548
|
+
generation: sandbox.generation,
|
|
549
|
+
imageDigest: sandbox.image_digest
|
|
550
|
+
})) {
|
|
551
|
+
throw new SandboxConflictError('SANDBOX_AGENT_NOT_READY');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const observed = operation.kind === 'stop' ? 'stopped' : 'running';
|
|
555
|
+
db.prepare('UPDATE sandboxes SET observed_state = ?, last_error_code = NULL, updated_at = ? WHERE id = ?')
|
|
556
|
+
.run(observed, now, sandbox.id);
|
|
557
|
+
}
|
|
558
|
+
db.prepare("UPDATE sandbox_operations SET status = 'succeeded', stage = 'complete', updated_at = ? WHERE id = ?")
|
|
559
|
+
.run(now, operation.id);
|
|
560
|
+
appendAuditEvent({
|
|
561
|
+
sandbox: getSandboxById.get(sandbox.id), operationId: operation.id,
|
|
562
|
+
eventType: 'controller_result', actorKind: 'controller', outcome: 'succeeded', now
|
|
563
|
+
});
|
|
564
|
+
return { snapshot: publicSnapshot(getSandboxById.get(sandbox.id)), replayed: false };
|
|
565
|
+
})();
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
issueBootstrap(operationId, ttlMs = 5 * 60_000, signingKey) {
|
|
569
|
+
return transaction(() => {
|
|
570
|
+
const operation = db.prepare('SELECT * FROM sandbox_operations WHERE id = ?').get(operationId);
|
|
571
|
+
if (!operation || !['create', 'start', 'retry'].includes(operation.kind)
|
|
572
|
+
|| !['pending', 'running'].includes(operation.status)) {
|
|
573
|
+
throw new SandboxConflictError('SANDBOX_OPERATION_NOT_FOUND');
|
|
574
|
+
}
|
|
575
|
+
const sandbox = getSandboxById.get(operation.sandbox_id);
|
|
576
|
+
if (!sandbox || sandbox.generation !== operation.generation
|
|
577
|
+
|| sandbox.host_epoch !== operation.host_epoch || !sandbox.reservation_held) {
|
|
578
|
+
throw new SandboxConflictError('SANDBOX_STALE_RESULT');
|
|
579
|
+
}
|
|
580
|
+
if (!signingKey) throw new SandboxConflictError('SANDBOX_BOOTSTRAP_SIGNING_KEY_REQUIRED');
|
|
581
|
+
const now = Date.now();
|
|
582
|
+
const credentialPrefix = `sandbox_bootstrap_${operation.id}`;
|
|
583
|
+
const existing = db.prepare(`
|
|
584
|
+
SELECT * FROM sandbox_credentials
|
|
585
|
+
WHERE id LIKE ? AND sandbox_id = ? AND generation = ? AND kind = 'bootstrap'
|
|
586
|
+
AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at >= ?
|
|
587
|
+
ORDER BY created_at DESC LIMIT 1
|
|
588
|
+
`).get(`${credentialPrefix}%`, sandbox.id, sandbox.generation, now);
|
|
589
|
+
if (existing) {
|
|
590
|
+
return {
|
|
591
|
+
token: deriveBootstrapSecret(existing.id, signingKey),
|
|
592
|
+
sandboxId: sandbox.id,
|
|
593
|
+
instanceId: sandbox.instance_id,
|
|
594
|
+
generation: sandbox.generation,
|
|
595
|
+
imageDigest: sandbox.image_digest,
|
|
596
|
+
expiresAt: existing.expires_at
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const credentialId = `${credentialPrefix}_${randomUUID()}`;
|
|
600
|
+
const secret = deriveBootstrapSecret(credentialId, signingKey);
|
|
601
|
+
db.prepare(`
|
|
602
|
+
INSERT INTO sandbox_credentials (
|
|
603
|
+
id, sandbox_id, instance_id, generation, image_digest, kind, secret_hash,
|
|
604
|
+
expires_at, created_at
|
|
605
|
+
) VALUES (?, ?, ?, ?, ?, 'bootstrap', ?, ?, ?)
|
|
606
|
+
`).run(credentialId, sandbox.id, sandbox.instance_id,
|
|
607
|
+
sandbox.generation, sandbox.image_digest, hashSecret(secret).toString('hex'), now + ttlMs, now);
|
|
608
|
+
return {
|
|
609
|
+
token: secret,
|
|
610
|
+
sandboxId: sandbox.id,
|
|
611
|
+
instanceId: sandbox.instance_id,
|
|
612
|
+
generation: sandbox.generation,
|
|
613
|
+
imageDigest: sandbox.image_digest,
|
|
614
|
+
expiresAt: now + ttlMs
|
|
615
|
+
};
|
|
616
|
+
})();
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
exchangeBootstrap(token, claims) {
|
|
620
|
+
return transaction(() => {
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
const candidates = db.prepare(`
|
|
623
|
+
SELECT * FROM sandbox_credentials
|
|
624
|
+
WHERE kind = 'bootstrap' AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at >= ?
|
|
625
|
+
`).all(now);
|
|
626
|
+
const bootstrap = candidates.find(row => secretMatches(token, row.secret_hash));
|
|
627
|
+
if (!bootstrap) throw new SandboxConflictError('SANDBOX_BOOTSTRAP_INVALID');
|
|
628
|
+
assertBoundClaims(bootstrap, claims);
|
|
629
|
+
const sandbox = getSandboxById.get(bootstrap.sandbox_id);
|
|
630
|
+
if (!credentialMatchesSandbox(bootstrap, sandbox) || !sandbox.reservation_held) {
|
|
631
|
+
throw new SandboxConflictError('SANDBOX_BOOTSTRAP_INVALID');
|
|
632
|
+
}
|
|
633
|
+
const consumed = db.prepare(`
|
|
634
|
+
UPDATE sandbox_credentials SET consumed_at = ?
|
|
635
|
+
WHERE id = ? AND consumed_at IS NULL AND revoked_at IS NULL
|
|
636
|
+
`).run(now, bootstrap.id);
|
|
637
|
+
if (consumed.changes !== 1) throw new SandboxConflictError('SANDBOX_BOOTSTRAP_INVALID');
|
|
638
|
+
db.prepare(`
|
|
639
|
+
UPDATE sandbox_credentials SET revoked_at = ?
|
|
640
|
+
WHERE sandbox_id = ? AND kind = 'agent' AND revoked_at IS NULL
|
|
641
|
+
`).run(now, sandbox.id);
|
|
642
|
+
const secret = randomBytes(32).toString('base64url');
|
|
643
|
+
const credentialId = `sandbox_credential_${randomUUID()}`;
|
|
644
|
+
db.prepare(`
|
|
645
|
+
INSERT INTO sandbox_credentials (
|
|
646
|
+
id, sandbox_id, instance_id, generation, image_digest, kind, secret_hash, created_at
|
|
647
|
+
) VALUES (?, ?, ?, ?, ?, 'agent', ?, ?)
|
|
648
|
+
`).run(credentialId, sandbox.id, sandbox.instance_id, sandbox.generation,
|
|
649
|
+
sandbox.image_digest, hashSecret(secret).toString('hex'), now);
|
|
650
|
+
appendAuditEvent({
|
|
651
|
+
sandbox, eventType: 'credential_issued', actorKind: 'managed_agent',
|
|
652
|
+
outcome: 'succeeded', now
|
|
653
|
+
});
|
|
654
|
+
return { credentialId, secret };
|
|
655
|
+
})();
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
authenticateCredential(credentialId, secret, claims) {
|
|
659
|
+
const credential = db.prepare(`
|
|
660
|
+
SELECT * FROM sandbox_credentials
|
|
661
|
+
WHERE id = ? AND kind = 'agent' AND revoked_at IS NULL
|
|
662
|
+
`).get(credentialId);
|
|
663
|
+
if (!credential || !secretMatches(secret, credential.secret_hash)) {
|
|
664
|
+
throw new SandboxConflictError('SANDBOX_CREDENTIAL_INVALID');
|
|
665
|
+
}
|
|
666
|
+
assertBoundClaims(credential, claims);
|
|
667
|
+
const sandbox = getSandboxById.get(credential.sandbox_id);
|
|
668
|
+
if (!credentialMatchesSandbox(credential, sandbox) || !sandbox.reservation_held) {
|
|
669
|
+
throw new SandboxConflictError('SANDBOX_CREDENTIAL_INVALID');
|
|
670
|
+
}
|
|
671
|
+
return { sandboxId: sandbox.id, userId: sandbox.user_id };
|
|
672
|
+
}
|
|
673
|
+
};
|