@pasko70/pibo 2.5.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/context/pibo-native-tooling.md +1 -0
- package/dist/agent-runtime/contract.js +2 -1
- package/dist/agent-runtime/history.js +53 -1
- package/dist/agent-runtime/routed-session.js +100 -26
- package/dist/agent-runtimes/codex-native/adapter.js +304 -30
- package/dist/agent-runtimes/codex-native/client.js +22 -3
- package/dist/agent-runtimes/codex-native/first-use.js +294 -0
- package/dist/agent-runtimes/codex-native/history.js +26 -7
- package/dist/agent-runtimes/codex-native/process.js +14 -0
- package/dist/agent-runtimes/codex-native/thread.js +59 -25
- package/dist/agent-runtimes/codex-native/turn.js +27 -0
- package/dist/agent-runtimes/history-proof.js +8 -0
- package/dist/agent-runtimes/omp/adapter.js +61 -14
- package/dist/agent-runtimes/omp/history.js +189 -34
- package/dist/agent-runtimes/omp/thread.js +82 -36
- package/dist/agent-runtimes/pi/adapter.js +30 -3
- package/dist/agent-runtimes/pi/history.js +66 -5
- package/dist/agent-runtimes/pi/openai-response-observer.js +102 -0
- package/dist/agent-runtimes/pi/routed-session.js +344 -37
- package/dist/apps/chat/chat-api-routes.js +1 -1
- package/dist/apps/chat/chat-files.js +130 -9
- package/dist/apps/chat/chat-settings-routes.js +27 -2
- package/dist/apps/chat/chat-speech.js +100 -0
- package/dist/apps/chat/data/chat-data-mappers.js +25 -5
- package/dist/apps/chat/data/timeline-query-service.js +89 -1
- package/dist/apps/chat/trace-v2.js +210 -8
- package/dist/apps/chat/trace.js +3 -0
- package/dist/apps/chat/web-app.js +108 -3
- package/dist/apps/chat-ui/assets/{dist-DTWJf37A.js → dist-BBDMeU5-.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-CkYWKnvy.js → dist-BDIATCQt.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DZ8Wpj-H.js → dist-JnW4v8UE.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-Dmblz15N.js → dist-lRiLP9rr.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DjcwoyNY.js → dist-mqCviI-c.js} +1 -1
- package/dist/apps/chat-ui/assets/index-0XOOVxLP.js +228 -0
- package/dist/apps/chat-ui/assets/index-DV7_CgsC.css +1 -0
- package/dist/apps/chat-ui/index.html +2 -2
- package/dist/apps/chat-vscode-web/assets/index-Dtw2Z7jz.js +43 -0
- package/dist/apps/chat-vscode-web/assets/{index-CF1-9PKU.css → index-SluZr_-r.css} +1 -1
- package/dist/apps/chat-vscode-web/index.html +2 -2
- package/dist/apps/vscode-artifacts/latest.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-3.0.0.vsix +0 -0
- package/dist/cli.js +49 -5
- package/dist/config/config.js +59 -8
- package/dist/core/preview-server-settings.js +20 -0
- package/dist/core/session-router.js +457 -37
- package/dist/core/user-settings.js +10 -0
- package/dist/data/payload-store.js +57 -1
- package/dist/data/schema.js +18 -1
- package/dist/debug/trace.js +5 -0
- package/dist/gateway/client.js +368 -65
- package/dist/gateway/protocol.js +2 -0
- package/dist/gateway/server.js +8 -0
- package/dist/gateway/web.js +2 -0
- package/dist/index.js +1 -1
- package/dist/loops/store.js +6 -4
- package/dist/mcp/config.js +89 -17
- package/dist/mcp/daemon-client.js +473 -153
- package/dist/mcp/daemon.js +401 -37
- package/dist/plugins/builtin.js +2 -2
- package/dist/plugins/codex-native.js +2 -0
- package/dist/plugins/registry.js +259 -1
- package/dist/previews/cli.js +345 -0
- package/dist/previews/config.js +61 -0
- package/dist/previews/manager.js +644 -0
- package/dist/previews/network.js +198 -0
- package/dist/previews/plugin.js +11 -0
- package/dist/previews/process-supervisor.js +38 -0
- package/dist/previews/proxy.js +387 -0
- package/dist/previews/store.js +750 -0
- package/dist/previews/types.js +1 -0
- package/dist/previews/web-app.js +446 -0
- package/dist/runs/registry.js +22 -0
- package/dist/runs/tools.js +46 -7
- package/dist/session-ui/terminalRows.js +187 -13
- package/dist/sessions/pibo-data-store.js +37 -0
- package/dist/sessions/runtime-binding-persistence.js +34 -0
- package/dist/sessions/runtime-binding.js +6 -0
- package/dist/sessions/sqlite-store.js +37 -0
- package/dist/shared/trace-engine.js +12 -64
- package/dist/shared/trace-event-projection.js +16 -0
- package/dist/shared/trace-history.js +684 -184
- package/dist/shared/trace-limits.js +7 -0
- package/dist/shared/trace-live-patch.js +61 -0
- package/dist/shared/trace-tool-identity.js +43 -0
- package/dist/speech/openai-codex-realtime-call-proxy.js +248 -0
- package/dist/speech/openai-codex.js +407 -0
- package/dist/speech/types.js +8 -0
- package/dist/subagents/context.js +5 -3
- package/dist/subagents/observations.js +94 -6
- package/dist/subagents/tool.js +103 -20
- package/dist/tools/codex-image-artifacts.js +24 -0
- package/dist/tools/codex-image-generation.js +4 -22
- package/dist/web/channel.js +88 -4
- package/dist/web/http.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-0WZI2phJ.css +0 -1
- package/dist/apps/chat-ui/assets/index-DsgTm1Aw.js +0 -228
- package/dist/apps/chat-vscode-web/assets/index-DvTSSvzN.js +0 -43
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-2.5.2.vsix +0 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { ensurePrivatePiboHomeForPath, piboHomePath } from "../core/pibo-home.js";
|
|
6
|
+
import { protectPrivateFileSync } from "../core/private-path.js";
|
|
7
|
+
import { loadPreviewConfig } from "./config.js";
|
|
8
|
+
export const PREVIEW_SCHEMA_VERSION = 4;
|
|
9
|
+
export const MAX_ACTIVE_PREVIEW_EXPOSURES = 256;
|
|
10
|
+
export const MAX_ACTIVE_PREVIEW_EXPOSURES_PER_SESSION = 16;
|
|
11
|
+
export const MAX_OUTSTANDING_PREVIEW_TICKETS = 32;
|
|
12
|
+
export const MAX_PREVIEW_BROWSER_SESSIONS = 64;
|
|
13
|
+
export const CLOSED_PREVIEW_RETENTION_DAYS = 30;
|
|
14
|
+
export class PreviewCapacityError extends Error {
|
|
15
|
+
maxRunningServers;
|
|
16
|
+
constructor(maxRunningServers) {
|
|
17
|
+
super(`Managed Preview server limit reached (${maxRunningServers})`);
|
|
18
|
+
this.maxRunningServers = maxRunningServers;
|
|
19
|
+
this.name = "PreviewCapacityError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class PreviewExposureCapacityError extends Error {
|
|
23
|
+
limit;
|
|
24
|
+
scope;
|
|
25
|
+
constructor(limit, scope) {
|
|
26
|
+
super(`Active Preview exposure limit reached (${limit} per ${scope})`);
|
|
27
|
+
this.limit = limit;
|
|
28
|
+
this.scope = scope;
|
|
29
|
+
this.name = "PreviewExposureCapacityError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function tokenHash(token) {
|
|
33
|
+
return createHash("sha256").update(token).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
function opaqueToken() {
|
|
36
|
+
return randomBytes(32).toString("base64url");
|
|
37
|
+
}
|
|
38
|
+
function isOpaqueToken(value) {
|
|
39
|
+
return value.length === 43 && /^[A-Za-z0-9_-]+$/.test(value);
|
|
40
|
+
}
|
|
41
|
+
function requireIsoDate(value, field) {
|
|
42
|
+
const timestamp = Date.parse(value);
|
|
43
|
+
if (!Number.isFinite(timestamp))
|
|
44
|
+
throw new Error(`${field} must be a valid ISO timestamp`);
|
|
45
|
+
return timestamp;
|
|
46
|
+
}
|
|
47
|
+
function validateExposureInput(input) {
|
|
48
|
+
if (input.id.length > 63 || !/^pv-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(input.id)) {
|
|
49
|
+
throw new Error("Preview id is invalid");
|
|
50
|
+
}
|
|
51
|
+
if (!input.piboSessionId.trim() || input.piboSessionId.length > 256)
|
|
52
|
+
throw new Error("Pibo Session id is invalid");
|
|
53
|
+
if (!input.label.trim() || input.label.length > 120)
|
|
54
|
+
throw new Error("Preview label must contain 1 to 120 characters");
|
|
55
|
+
if (input.projectId && input.projectId.length > 256)
|
|
56
|
+
throw new Error("Preview Project id is too long");
|
|
57
|
+
if (!input.workspace || input.workspace.length > 4_096)
|
|
58
|
+
throw new Error("Preview workspace is invalid");
|
|
59
|
+
if (!Number.isInteger(input.targetPort) || input.targetPort < 1024 || input.targetPort > 65535) {
|
|
60
|
+
throw new Error("Preview target port is invalid");
|
|
61
|
+
}
|
|
62
|
+
const createdAt = requireIsoDate(input.createdAt, "createdAt");
|
|
63
|
+
const expiresAt = requireIsoDate(input.expiresAt, "expiresAt");
|
|
64
|
+
if (expiresAt <= createdAt)
|
|
65
|
+
throw new Error("Preview expiration must be after creation");
|
|
66
|
+
if ((input.managementMode ?? "external") === "managed" && !input.startCommand) {
|
|
67
|
+
throw new Error("Managed Preview definitions require a start command");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function exposureFromRow(row) {
|
|
71
|
+
const managementMode = row.management_mode === "managed" ? "managed" : "external";
|
|
72
|
+
return {
|
|
73
|
+
id: row.id,
|
|
74
|
+
piboSessionId: row.pibo_session_id,
|
|
75
|
+
projectId: row.project_id ?? undefined,
|
|
76
|
+
label: row.label,
|
|
77
|
+
targetHost: row.target_host,
|
|
78
|
+
targetPort: row.target_port,
|
|
79
|
+
targetProcessId: row.target_process_id ?? undefined,
|
|
80
|
+
targetProcessStartTicks: row.target_process_start_ticks ?? undefined,
|
|
81
|
+
workspace: row.workspace,
|
|
82
|
+
managementMode,
|
|
83
|
+
startCommand: row.start_command ?? undefined,
|
|
84
|
+
serverState: managementMode === "managed" && row.server_state !== "external" ? row.server_state : undefined,
|
|
85
|
+
serverGeneration: row.server_generation ?? undefined,
|
|
86
|
+
serverStartedAt: row.server_started_at ?? undefined,
|
|
87
|
+
serverStopAt: row.server_stop_at ?? undefined,
|
|
88
|
+
serverStoppedAt: row.server_stopped_at ?? undefined,
|
|
89
|
+
serverError: row.server_error ?? undefined,
|
|
90
|
+
managerKind: row.manager_kind ?? undefined,
|
|
91
|
+
managerId: row.manager_id ?? undefined,
|
|
92
|
+
managerPid: row.manager_pid ?? undefined,
|
|
93
|
+
managerProcessStartTicks: row.manager_process_start_ticks ?? undefined,
|
|
94
|
+
createdAt: row.created_at,
|
|
95
|
+
expiresAt: row.expires_at,
|
|
96
|
+
closedAt: row.closed_at ?? undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function authorizationGeneration(exposure) {
|
|
100
|
+
if (exposure.managementMode === "external")
|
|
101
|
+
return null;
|
|
102
|
+
if (exposure.serverState !== "running" || !exposure.serverGeneration)
|
|
103
|
+
return undefined;
|
|
104
|
+
return exposure.serverGeneration;
|
|
105
|
+
}
|
|
106
|
+
function sameAuthorizationExposure(first, second) {
|
|
107
|
+
return first.id === second.id &&
|
|
108
|
+
first.createdAt === second.createdAt &&
|
|
109
|
+
first.managementMode === second.managementMode &&
|
|
110
|
+
first.serverGeneration === second.serverGeneration &&
|
|
111
|
+
first.targetHost === second.targetHost &&
|
|
112
|
+
first.targetPort === second.targetPort &&
|
|
113
|
+
first.targetProcessId === second.targetProcessId &&
|
|
114
|
+
first.targetProcessStartTicks === second.targetProcessStartTicks &&
|
|
115
|
+
first.managerKind === second.managerKind &&
|
|
116
|
+
first.managerId === second.managerId &&
|
|
117
|
+
first.managerPid === second.managerPid &&
|
|
118
|
+
first.managerProcessStartTicks === second.managerProcessStartTicks;
|
|
119
|
+
}
|
|
120
|
+
export function previewExposureState(exposure, now = new Date()) {
|
|
121
|
+
if (exposure.closedAt)
|
|
122
|
+
return "closed";
|
|
123
|
+
if (Date.parse(exposure.expiresAt) <= now.getTime())
|
|
124
|
+
return "expired";
|
|
125
|
+
return "active";
|
|
126
|
+
}
|
|
127
|
+
export class PreviewStore {
|
|
128
|
+
path;
|
|
129
|
+
db;
|
|
130
|
+
constructor(path = loadPreviewConfig().databasePath ?? piboHomePath("previews.sqlite")) {
|
|
131
|
+
this.path = path === ":memory:" ? path : resolve(path);
|
|
132
|
+
if (this.path !== ":memory:") {
|
|
133
|
+
ensurePrivatePiboHomeForPath(this.path);
|
|
134
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
135
|
+
}
|
|
136
|
+
this.db = new DatabaseSync(this.path);
|
|
137
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
138
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
139
|
+
if (this.path !== ":memory:") {
|
|
140
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
141
|
+
protectPrivateFileSync(this.path, { force: true });
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
this.applySchema();
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
this.db.close();
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
applySchema() {
|
|
152
|
+
const currentVersion = Number(this.db.prepare("PRAGMA user_version").get().user_version ?? 0);
|
|
153
|
+
if (currentVersion > PREVIEW_SCHEMA_VERSION) {
|
|
154
|
+
throw new Error(`Preview database schema version ${currentVersion} is newer than supported version ${PREVIEW_SCHEMA_VERSION}`);
|
|
155
|
+
}
|
|
156
|
+
const existingExposureSql = this.db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'preview_exposures'").get()?.sql;
|
|
157
|
+
const needsStoppingStateMigration = Boolean(existingExposureSql && !existingExposureSql.includes("'stopping'"));
|
|
158
|
+
if (needsStoppingStateMigration) {
|
|
159
|
+
this.db.exec("PRAGMA foreign_keys = OFF");
|
|
160
|
+
this.db.exec("PRAGMA legacy_alter_table = ON");
|
|
161
|
+
}
|
|
162
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
163
|
+
try {
|
|
164
|
+
this.db.exec(`
|
|
165
|
+
CREATE TABLE IF NOT EXISTS preview_exposures (
|
|
166
|
+
id TEXT PRIMARY KEY,
|
|
167
|
+
pibo_session_id TEXT NOT NULL,
|
|
168
|
+
project_id TEXT,
|
|
169
|
+
label TEXT NOT NULL,
|
|
170
|
+
target_host TEXT NOT NULL CHECK (target_host IN ('127.0.0.1', '::1')),
|
|
171
|
+
target_port INTEGER NOT NULL CHECK (target_port BETWEEN 1024 AND 65535),
|
|
172
|
+
target_process_id INTEGER,
|
|
173
|
+
target_process_start_ticks TEXT,
|
|
174
|
+
workspace TEXT NOT NULL,
|
|
175
|
+
management_mode TEXT NOT NULL DEFAULT 'external' CHECK (management_mode IN ('external', 'managed')),
|
|
176
|
+
start_command TEXT,
|
|
177
|
+
server_state TEXT NOT NULL DEFAULT 'external' CHECK (server_state IN ('external', 'stopped', 'starting', 'running', 'stopping', 'error')),
|
|
178
|
+
server_generation TEXT,
|
|
179
|
+
server_started_at TEXT,
|
|
180
|
+
server_stop_at TEXT,
|
|
181
|
+
server_stopped_at TEXT,
|
|
182
|
+
server_error TEXT,
|
|
183
|
+
manager_kind TEXT,
|
|
184
|
+
manager_id TEXT,
|
|
185
|
+
manager_pid INTEGER,
|
|
186
|
+
manager_process_start_ticks TEXT,
|
|
187
|
+
created_at TEXT NOT NULL,
|
|
188
|
+
expires_at TEXT NOT NULL,
|
|
189
|
+
closed_at TEXT
|
|
190
|
+
);
|
|
191
|
+
CREATE INDEX IF NOT EXISTS preview_exposures_session_idx
|
|
192
|
+
ON preview_exposures (pibo_session_id, created_at DESC);
|
|
193
|
+
CREATE TABLE IF NOT EXISTS preview_tickets (
|
|
194
|
+
token_hash TEXT PRIMARY KEY,
|
|
195
|
+
preview_id TEXT NOT NULL,
|
|
196
|
+
server_generation TEXT,
|
|
197
|
+
created_at TEXT NOT NULL,
|
|
198
|
+
expires_at TEXT NOT NULL,
|
|
199
|
+
used_at TEXT,
|
|
200
|
+
FOREIGN KEY (preview_id) REFERENCES preview_exposures(id) ON DELETE CASCADE
|
|
201
|
+
);
|
|
202
|
+
CREATE INDEX IF NOT EXISTS preview_tickets_preview_idx
|
|
203
|
+
ON preview_tickets (preview_id, expires_at);
|
|
204
|
+
CREATE TABLE IF NOT EXISTS preview_browser_sessions (
|
|
205
|
+
token_hash TEXT PRIMARY KEY,
|
|
206
|
+
preview_id TEXT NOT NULL,
|
|
207
|
+
server_generation TEXT,
|
|
208
|
+
created_at TEXT NOT NULL,
|
|
209
|
+
expires_at TEXT NOT NULL,
|
|
210
|
+
FOREIGN KEY (preview_id) REFERENCES preview_exposures(id) ON DELETE CASCADE
|
|
211
|
+
);
|
|
212
|
+
CREATE INDEX IF NOT EXISTS preview_browser_sessions_preview_idx
|
|
213
|
+
ON preview_browser_sessions (preview_id, expires_at);
|
|
214
|
+
`);
|
|
215
|
+
const columns = new Set(this.db.prepare("PRAGMA table_info(preview_exposures)").all().map((column) => column.name));
|
|
216
|
+
const additions = [
|
|
217
|
+
["target_process_id", "INTEGER"],
|
|
218
|
+
["target_process_start_ticks", "TEXT"],
|
|
219
|
+
["management_mode", "TEXT NOT NULL DEFAULT 'external'"],
|
|
220
|
+
["start_command", "TEXT"],
|
|
221
|
+
["server_state", "TEXT NOT NULL DEFAULT 'external'"],
|
|
222
|
+
["server_generation", "TEXT"],
|
|
223
|
+
["server_started_at", "TEXT"],
|
|
224
|
+
["server_stop_at", "TEXT"],
|
|
225
|
+
["server_stopped_at", "TEXT"],
|
|
226
|
+
["server_error", "TEXT"],
|
|
227
|
+
["manager_kind", "TEXT"],
|
|
228
|
+
["manager_id", "TEXT"],
|
|
229
|
+
["manager_pid", "INTEGER"],
|
|
230
|
+
["manager_process_start_ticks", "TEXT"],
|
|
231
|
+
];
|
|
232
|
+
for (const [name, declaration] of additions) {
|
|
233
|
+
if (!columns.has(name))
|
|
234
|
+
this.db.exec(`ALTER TABLE preview_exposures ADD COLUMN ${name} ${declaration}`);
|
|
235
|
+
}
|
|
236
|
+
for (const table of ["preview_tickets", "preview_browser_sessions"]) {
|
|
237
|
+
const authorityColumns = new Set(this.db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
|
|
238
|
+
if (!authorityColumns.has("server_generation"))
|
|
239
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN server_generation TEXT`);
|
|
240
|
+
}
|
|
241
|
+
if (needsStoppingStateMigration) {
|
|
242
|
+
this.db.exec(`
|
|
243
|
+
ALTER TABLE preview_exposures RENAME TO preview_exposures_before_stopping;
|
|
244
|
+
CREATE TABLE preview_exposures (
|
|
245
|
+
id TEXT PRIMARY KEY,
|
|
246
|
+
pibo_session_id TEXT NOT NULL,
|
|
247
|
+
project_id TEXT,
|
|
248
|
+
label TEXT NOT NULL,
|
|
249
|
+
target_host TEXT NOT NULL CHECK (target_host IN ('127.0.0.1', '::1')),
|
|
250
|
+
target_port INTEGER NOT NULL CHECK (target_port BETWEEN 1024 AND 65535),
|
|
251
|
+
target_process_id INTEGER,
|
|
252
|
+
target_process_start_ticks TEXT,
|
|
253
|
+
workspace TEXT NOT NULL,
|
|
254
|
+
management_mode TEXT NOT NULL DEFAULT 'external' CHECK (management_mode IN ('external', 'managed')),
|
|
255
|
+
start_command TEXT,
|
|
256
|
+
server_state TEXT NOT NULL DEFAULT 'external' CHECK (server_state IN ('external', 'stopped', 'starting', 'running', 'stopping', 'error')),
|
|
257
|
+
server_generation TEXT,
|
|
258
|
+
server_started_at TEXT,
|
|
259
|
+
server_stop_at TEXT,
|
|
260
|
+
server_stopped_at TEXT,
|
|
261
|
+
server_error TEXT,
|
|
262
|
+
manager_kind TEXT,
|
|
263
|
+
manager_id TEXT,
|
|
264
|
+
manager_pid INTEGER,
|
|
265
|
+
manager_process_start_ticks TEXT,
|
|
266
|
+
created_at TEXT NOT NULL,
|
|
267
|
+
expires_at TEXT NOT NULL,
|
|
268
|
+
closed_at TEXT
|
|
269
|
+
);
|
|
270
|
+
INSERT INTO preview_exposures (
|
|
271
|
+
id, pibo_session_id, project_id, label, target_host, target_port,
|
|
272
|
+
target_process_id, target_process_start_ticks, workspace,
|
|
273
|
+
management_mode, start_command, server_state, server_generation,
|
|
274
|
+
server_started_at, server_stop_at, server_stopped_at, server_error,
|
|
275
|
+
manager_kind, manager_id, manager_pid, manager_process_start_ticks,
|
|
276
|
+
created_at, expires_at, closed_at
|
|
277
|
+
)
|
|
278
|
+
SELECT
|
|
279
|
+
id, pibo_session_id, project_id, label, target_host, target_port,
|
|
280
|
+
target_process_id, target_process_start_ticks, workspace,
|
|
281
|
+
management_mode, start_command, server_state, server_generation,
|
|
282
|
+
server_started_at, server_stop_at, server_stopped_at, server_error,
|
|
283
|
+
manager_kind, manager_id, manager_pid, manager_process_start_ticks,
|
|
284
|
+
created_at, expires_at, closed_at
|
|
285
|
+
FROM preview_exposures_before_stopping;
|
|
286
|
+
DROP TABLE preview_exposures_before_stopping;
|
|
287
|
+
`);
|
|
288
|
+
}
|
|
289
|
+
this.db.exec(`
|
|
290
|
+
CREATE INDEX IF NOT EXISTS preview_exposures_session_idx
|
|
291
|
+
ON preview_exposures (pibo_session_id, created_at DESC);
|
|
292
|
+
CREATE INDEX IF NOT EXISTS preview_exposures_managed_state_idx
|
|
293
|
+
ON preview_exposures (management_mode, server_state, expires_at)
|
|
294
|
+
`);
|
|
295
|
+
this.db.exec(`PRAGMA user_version = ${PREVIEW_SCHEMA_VERSION}`);
|
|
296
|
+
this.db.exec("COMMIT");
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
this.db.exec("ROLLBACK");
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
if (needsStoppingStateMigration) {
|
|
304
|
+
this.db.exec("PRAGMA legacy_alter_table = OFF");
|
|
305
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (needsStoppingStateMigration) {
|
|
309
|
+
const violations = this.db.prepare("PRAGMA foreign_key_check").all();
|
|
310
|
+
if (violations.length > 0)
|
|
311
|
+
throw new Error("Preview database migration violated foreign-key integrity");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
createExposure(input) {
|
|
315
|
+
validateExposureInput(input);
|
|
316
|
+
const managementMode = input.managementMode ?? "external";
|
|
317
|
+
const serverState = managementMode === "managed" ? input.serverState ?? "stopped" : "external";
|
|
318
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
319
|
+
try {
|
|
320
|
+
const globalCount = Number(this.db.prepare("SELECT COUNT(*) AS count FROM preview_exposures WHERE closed_at IS NULL AND expires_at > ?").get(input.createdAt).count);
|
|
321
|
+
if (globalCount >= MAX_ACTIVE_PREVIEW_EXPOSURES) {
|
|
322
|
+
throw new PreviewExposureCapacityError(MAX_ACTIVE_PREVIEW_EXPOSURES, "global");
|
|
323
|
+
}
|
|
324
|
+
const sessionCount = Number(this.db.prepare("SELECT COUNT(*) AS count FROM preview_exposures WHERE pibo_session_id = ? AND closed_at IS NULL AND expires_at > ?").get(input.piboSessionId, input.createdAt).count);
|
|
325
|
+
if (sessionCount >= MAX_ACTIVE_PREVIEW_EXPOSURES_PER_SESSION) {
|
|
326
|
+
throw new PreviewExposureCapacityError(MAX_ACTIVE_PREVIEW_EXPOSURES_PER_SESSION, "session");
|
|
327
|
+
}
|
|
328
|
+
this.db.prepare(`
|
|
329
|
+
INSERT INTO preview_exposures (
|
|
330
|
+
id, pibo_session_id, project_id, label, target_host, target_port,
|
|
331
|
+
target_process_id, target_process_start_ticks, workspace,
|
|
332
|
+
management_mode, start_command, server_state,
|
|
333
|
+
created_at, expires_at, closed_at
|
|
334
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
335
|
+
`).run(input.id, input.piboSessionId, input.projectId ?? null, input.label, input.targetHost, input.targetPort, input.targetProcessId ?? null, input.targetProcessStartTicks ?? null, input.workspace, managementMode, input.startCommand ?? null, serverState, input.createdAt, input.expiresAt);
|
|
336
|
+
const exposure = this.requireExposure(input.id);
|
|
337
|
+
this.db.exec("COMMIT");
|
|
338
|
+
return exposure;
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
this.db.exec("ROLLBACK");
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
getExposure(id) {
|
|
346
|
+
const row = this.db.prepare("SELECT * FROM preview_exposures WHERE id = ?").get(id);
|
|
347
|
+
return row ? exposureFromRow(row) : undefined;
|
|
348
|
+
}
|
|
349
|
+
requireExposure(id) {
|
|
350
|
+
const exposure = this.getExposure(id);
|
|
351
|
+
if (!exposure)
|
|
352
|
+
throw new Error(`Preview "${id}" was not found`);
|
|
353
|
+
return exposure;
|
|
354
|
+
}
|
|
355
|
+
listExposures(input = {}) {
|
|
356
|
+
const clauses = [];
|
|
357
|
+
const values = [];
|
|
358
|
+
if (input.piboSessionId) {
|
|
359
|
+
clauses.push("pibo_session_id = ?");
|
|
360
|
+
values.push(input.piboSessionId);
|
|
361
|
+
}
|
|
362
|
+
if (!input.includeInactive) {
|
|
363
|
+
clauses.push("closed_at IS NULL");
|
|
364
|
+
clauses.push("expires_at > ?");
|
|
365
|
+
values.push(new Date().toISOString());
|
|
366
|
+
}
|
|
367
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
368
|
+
return this.db.prepare(`SELECT * FROM preview_exposures ${where} ORDER BY created_at DESC`).all(...values)
|
|
369
|
+
.map(exposureFromRow);
|
|
370
|
+
}
|
|
371
|
+
listManagedServerCandidates() {
|
|
372
|
+
return this.db.prepare(`
|
|
373
|
+
SELECT * FROM preview_exposures
|
|
374
|
+
WHERE management_mode = 'managed'
|
|
375
|
+
AND (server_state IN ('starting', 'running', 'stopping') OR manager_id IS NOT NULL)
|
|
376
|
+
ORDER BY created_at ASC
|
|
377
|
+
`).all().map(exposureFromRow);
|
|
378
|
+
}
|
|
379
|
+
reserveManagedServerStart(id, maxRunningServers, startedAt, stopAt, manager) {
|
|
380
|
+
if (!manager.id ||
|
|
381
|
+
manager.id.length > 256 ||
|
|
382
|
+
(manager.kind !== "systemd" && manager.kind !== "process"))
|
|
383
|
+
throw new Error("Managed Preview owner identity is invalid");
|
|
384
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
385
|
+
try {
|
|
386
|
+
const exposure = this.requireExposure(id);
|
|
387
|
+
if (previewExposureState(exposure, new Date(startedAt)) !== "active")
|
|
388
|
+
throw new Error(`Preview "${id}" is not active`);
|
|
389
|
+
if (exposure.managementMode !== "managed" || !exposure.startCommand)
|
|
390
|
+
throw new Error(`Preview "${id}" has no managed start command`);
|
|
391
|
+
if (exposure.serverState === "starting" ||
|
|
392
|
+
exposure.serverState === "running" ||
|
|
393
|
+
exposure.serverState === "stopping" ||
|
|
394
|
+
exposure.serverGeneration ||
|
|
395
|
+
exposure.managerId) {
|
|
396
|
+
this.db.exec("COMMIT");
|
|
397
|
+
return { exposure, reserved: false };
|
|
398
|
+
}
|
|
399
|
+
const row = this.db.prepare(`
|
|
400
|
+
SELECT COUNT(*) AS count FROM preview_exposures
|
|
401
|
+
WHERE management_mode = 'managed'
|
|
402
|
+
AND (server_state IN ('starting', 'running', 'stopping') OR manager_id IS NOT NULL)
|
|
403
|
+
AND closed_at IS NULL
|
|
404
|
+
AND expires_at > ?
|
|
405
|
+
`).get(startedAt);
|
|
406
|
+
if (Number(row.count) >= maxRunningServers)
|
|
407
|
+
throw new PreviewCapacityError(maxRunningServers);
|
|
408
|
+
const generation = opaqueToken();
|
|
409
|
+
this.db.prepare(`
|
|
410
|
+
UPDATE preview_exposures SET
|
|
411
|
+
server_state = 'starting', server_generation = ?, server_started_at = ?, server_stop_at = ?,
|
|
412
|
+
server_stopped_at = NULL, server_error = NULL,
|
|
413
|
+
manager_kind = ?, manager_id = ?, manager_pid = ?, manager_process_start_ticks = ?,
|
|
414
|
+
target_process_id = NULL, target_process_start_ticks = NULL
|
|
415
|
+
WHERE id = ?
|
|
416
|
+
`).run(generation, startedAt, stopAt, manager.kind, manager.id, manager.pid ?? null, manager.processStartTicks ?? null, id);
|
|
417
|
+
this.db.prepare("DELETE FROM preview_tickets WHERE preview_id = ?").run(id);
|
|
418
|
+
this.db.prepare("DELETE FROM preview_browser_sessions WHERE preview_id = ?").run(id);
|
|
419
|
+
this.db.exec("COMMIT");
|
|
420
|
+
return { exposure: this.requireExposure(id), reserved: true };
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
this.db.exec("ROLLBACK");
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
markManagedServerManager(id, generation, manager) {
|
|
428
|
+
this.db.prepare(`
|
|
429
|
+
UPDATE preview_exposures SET
|
|
430
|
+
manager_pid = ?, manager_process_start_ticks = ?
|
|
431
|
+
WHERE id = ? AND management_mode = 'managed'
|
|
432
|
+
AND server_state = 'starting' AND server_generation = ? AND closed_at IS NULL
|
|
433
|
+
AND manager_kind = ? AND manager_id = ?
|
|
434
|
+
`).run(manager.pid ?? null, manager.processStartTicks ?? null, id, generation, manager.kind, manager.id);
|
|
435
|
+
return this.requireExposure(id);
|
|
436
|
+
}
|
|
437
|
+
markManagedServerRunning(id, generation, input) {
|
|
438
|
+
this.db.prepare(`
|
|
439
|
+
UPDATE preview_exposures SET
|
|
440
|
+
server_state = 'running', server_error = NULL,
|
|
441
|
+
target_host = ?, target_process_id = ?, target_process_start_ticks = ?,
|
|
442
|
+
manager_pid = ?, manager_process_start_ticks = ?
|
|
443
|
+
WHERE id = ? AND management_mode = 'managed'
|
|
444
|
+
AND server_state = 'starting' AND server_generation = ? AND closed_at IS NULL
|
|
445
|
+
AND manager_kind = ? AND manager_id = ?
|
|
446
|
+
`).run(input.targetHost, input.targetProcessId ?? null, input.targetProcessStartTicks ?? null, input.manager.pid ?? null, input.manager.processStartTicks ?? null, id, generation, input.manager.kind, input.manager.id);
|
|
447
|
+
return this.requireExposure(id);
|
|
448
|
+
}
|
|
449
|
+
markManagedServerStopping(id, generation) {
|
|
450
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
451
|
+
try {
|
|
452
|
+
const result = this.db.prepare(`
|
|
453
|
+
UPDATE preview_exposures SET server_state = 'stopping'
|
|
454
|
+
WHERE id = ? AND management_mode = 'managed'
|
|
455
|
+
AND server_state IN ('starting', 'running', 'error')
|
|
456
|
+
AND server_generation = ? AND manager_id IS NOT NULL
|
|
457
|
+
`).run(id, generation);
|
|
458
|
+
if (Number(result.changes ?? 0) > 0) {
|
|
459
|
+
this.db.prepare("DELETE FROM preview_tickets WHERE preview_id = ?").run(id);
|
|
460
|
+
this.db.prepare("DELETE FROM preview_browser_sessions WHERE preview_id = ?").run(id);
|
|
461
|
+
}
|
|
462
|
+
const exposure = this.requireExposure(id);
|
|
463
|
+
this.db.exec("COMMIT");
|
|
464
|
+
return exposure;
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
this.db.exec("ROLLBACK");
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
markManagedServerStopped(id, input = {}) {
|
|
472
|
+
const stoppedAt = input.stoppedAt ?? new Date().toISOString();
|
|
473
|
+
const generationClause = input.expectedGeneration ? " AND server_generation = ?" : "";
|
|
474
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
475
|
+
try {
|
|
476
|
+
const result = this.db.prepare(`
|
|
477
|
+
UPDATE preview_exposures SET
|
|
478
|
+
server_state = ?, server_generation = NULL, server_stopped_at = ?, server_error = ?,
|
|
479
|
+
manager_kind = NULL, manager_id = NULL, manager_pid = NULL, manager_process_start_ticks = NULL,
|
|
480
|
+
target_process_id = NULL, target_process_start_ticks = NULL
|
|
481
|
+
WHERE id = ? AND management_mode = 'managed'${generationClause}
|
|
482
|
+
`).run(input.error ? "error" : "stopped", stoppedAt, input.error ?? null, id, ...(input.expectedGeneration ? [input.expectedGeneration] : []));
|
|
483
|
+
if (Number(result.changes ?? 0) > 0) {
|
|
484
|
+
this.db.prepare("DELETE FROM preview_tickets WHERE preview_id = ?").run(id);
|
|
485
|
+
this.db.prepare("DELETE FROM preview_browser_sessions WHERE preview_id = ?").run(id);
|
|
486
|
+
}
|
|
487
|
+
const exposure = this.requireExposure(id);
|
|
488
|
+
this.db.exec("COMMIT");
|
|
489
|
+
return exposure;
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
this.db.exec("ROLLBACK");
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
closeExposure(id, closedAt = new Date().toISOString()) {
|
|
497
|
+
requireIsoDate(closedAt, "closedAt");
|
|
498
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
499
|
+
try {
|
|
500
|
+
const current = this.getExposure(id);
|
|
501
|
+
if (current?.managementMode === "managed" &&
|
|
502
|
+
(current.serverGeneration || current.managerId || ["starting", "running", "stopping"].includes(current.serverState ?? ""))) {
|
|
503
|
+
throw new Error(`Managed Preview "${id}" must be fully stopped before removal`);
|
|
504
|
+
}
|
|
505
|
+
const result = this.db.prepare(`
|
|
506
|
+
UPDATE preview_exposures SET
|
|
507
|
+
closed_at = COALESCE(closed_at, ?), start_command = NULL,
|
|
508
|
+
server_state = CASE WHEN management_mode = 'managed' THEN 'stopped' ELSE server_state END,
|
|
509
|
+
server_generation = NULL,
|
|
510
|
+
server_stopped_at = CASE WHEN management_mode = 'managed' THEN ? ELSE server_stopped_at END,
|
|
511
|
+
manager_kind = NULL, manager_id = NULL, manager_pid = NULL, manager_process_start_ticks = NULL,
|
|
512
|
+
target_process_id = NULL, target_process_start_ticks = NULL
|
|
513
|
+
WHERE id = ?
|
|
514
|
+
`).run(closedAt, closedAt, id);
|
|
515
|
+
if (Number(result.changes ?? 0) === 0) {
|
|
516
|
+
this.db.exec("COMMIT");
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
this.db.prepare("DELETE FROM preview_tickets WHERE preview_id = ?").run(id);
|
|
520
|
+
this.db.prepare("DELETE FROM preview_browser_sessions WHERE preview_id = ?").run(id);
|
|
521
|
+
const exposure = this.getExposure(id);
|
|
522
|
+
this.db.exec("COMMIT");
|
|
523
|
+
return exposure;
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
this.db.exec("ROLLBACK");
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
createTicket(previewId, ttlSeconds, now = new Date()) {
|
|
531
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 300) {
|
|
532
|
+
throw new Error("Preview ticket lifetime must be between 1 and 300 seconds");
|
|
533
|
+
}
|
|
534
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
535
|
+
try {
|
|
536
|
+
const exposure = this.requireExposure(previewId);
|
|
537
|
+
if (previewExposureState(exposure, now) !== "active")
|
|
538
|
+
throw new Error(`Preview "${previewId}" is not active`);
|
|
539
|
+
const serverGeneration = authorizationGeneration(exposure);
|
|
540
|
+
if (serverGeneration === undefined)
|
|
541
|
+
throw new Error(`Preview "${previewId}" server is not running`);
|
|
542
|
+
const nowIso = now.toISOString();
|
|
543
|
+
this.db.prepare("DELETE FROM preview_tickets WHERE preview_id = ? AND (expires_at <= ? OR used_at IS NOT NULL)")
|
|
544
|
+
.run(previewId, nowIso);
|
|
545
|
+
this.db.prepare("DELETE FROM preview_tickets WHERE token_hash IN (" +
|
|
546
|
+
"SELECT token_hash FROM preview_tickets WHERE preview_id = ? " +
|
|
547
|
+
"ORDER BY created_at DESC, token_hash DESC LIMIT -1 OFFSET ?)").run(previewId, MAX_OUTSTANDING_PREVIEW_TICKETS - 1);
|
|
548
|
+
const token = opaqueToken();
|
|
549
|
+
const expiresAt = new Date(Math.min(now.getTime() + ttlSeconds * 1000, Date.parse(exposure.expiresAt))).toISOString();
|
|
550
|
+
this.db.prepare("INSERT INTO preview_tickets (token_hash, preview_id, server_generation, created_at, expires_at, used_at) VALUES (?, ?, ?, ?, ?, NULL)")
|
|
551
|
+
.run(tokenHash(token), previewId, serverGeneration, nowIso, expiresAt);
|
|
552
|
+
this.db.exec("COMMIT");
|
|
553
|
+
return { token, previewId, expiresAt };
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
this.db.exec("ROLLBACK");
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
consumeTicket(token, previewId, now = new Date()) {
|
|
561
|
+
if (!isOpaqueToken(token))
|
|
562
|
+
return false;
|
|
563
|
+
const hash = tokenHash(token);
|
|
564
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
565
|
+
try {
|
|
566
|
+
const row = this.db.prepare("SELECT preview_id, server_generation, expires_at, used_at FROM preview_tickets WHERE token_hash = ?").get(hash);
|
|
567
|
+
const exposure = row?.preview_id === previewId ? this.getExposure(previewId) : undefined;
|
|
568
|
+
const serverGeneration = exposure && previewExposureState(exposure, now) === "active"
|
|
569
|
+
? authorizationGeneration(exposure)
|
|
570
|
+
: undefined;
|
|
571
|
+
const valid = Boolean(row &&
|
|
572
|
+
row.preview_id === previewId &&
|
|
573
|
+
serverGeneration !== undefined &&
|
|
574
|
+
row.server_generation === serverGeneration &&
|
|
575
|
+
!row.used_at &&
|
|
576
|
+
Date.parse(row.expires_at) > now.getTime());
|
|
577
|
+
if (valid)
|
|
578
|
+
this.db.prepare("UPDATE preview_tickets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL").run(now.toISOString(), hash);
|
|
579
|
+
this.db.exec("COMMIT");
|
|
580
|
+
return valid;
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
this.db.exec("ROLLBACK");
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
createBrowserSession(previewId, ttlMinutes, now = new Date()) {
|
|
588
|
+
if (!Number.isInteger(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > 24 * 60) {
|
|
589
|
+
throw new Error("Preview browser session lifetime must be between 1 minute and 24 hours");
|
|
590
|
+
}
|
|
591
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
592
|
+
try {
|
|
593
|
+
const exposure = this.requireExposure(previewId);
|
|
594
|
+
if (previewExposureState(exposure, now) !== "active")
|
|
595
|
+
throw new Error(`Preview "${previewId}" is not active`);
|
|
596
|
+
const serverGeneration = authorizationGeneration(exposure);
|
|
597
|
+
if (serverGeneration === undefined)
|
|
598
|
+
throw new Error(`Preview "${previewId}" server is not running`);
|
|
599
|
+
const nowIso = now.toISOString();
|
|
600
|
+
this.db.prepare("DELETE FROM preview_browser_sessions WHERE preview_id = ? AND expires_at <= ?").run(previewId, nowIso);
|
|
601
|
+
this.db.prepare("DELETE FROM preview_browser_sessions WHERE token_hash IN (" +
|
|
602
|
+
"SELECT token_hash FROM preview_browser_sessions WHERE preview_id = ? " +
|
|
603
|
+
"ORDER BY created_at DESC, token_hash DESC LIMIT -1 OFFSET ?)").run(previewId, MAX_PREVIEW_BROWSER_SESSIONS - 1);
|
|
604
|
+
const token = opaqueToken();
|
|
605
|
+
const expiresAt = new Date(Math.min(now.getTime() + ttlMinutes * 60_000, Date.parse(exposure.expiresAt))).toISOString();
|
|
606
|
+
this.db.prepare("INSERT INTO preview_browser_sessions (token_hash, preview_id, server_generation, created_at, expires_at) VALUES (?, ?, ?, ?, ?)")
|
|
607
|
+
.run(tokenHash(token), previewId, serverGeneration, nowIso, expiresAt);
|
|
608
|
+
this.db.exec("COMMIT");
|
|
609
|
+
return { token, previewId, expiresAt };
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
this.db.exec("ROLLBACK");
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
exchangeTicketForBrowserSession(token, previewId, ttlMinutes, now = new Date()) {
|
|
617
|
+
if (!isOpaqueToken(token))
|
|
618
|
+
return undefined;
|
|
619
|
+
if (!Number.isInteger(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > 24 * 60) {
|
|
620
|
+
throw new Error("Preview browser session lifetime must be between 1 minute and 24 hours");
|
|
621
|
+
}
|
|
622
|
+
const hash = tokenHash(token);
|
|
623
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
624
|
+
try {
|
|
625
|
+
const row = this.db.prepare("SELECT preview_id, server_generation, expires_at, used_at FROM preview_tickets WHERE token_hash = ?")
|
|
626
|
+
.get(hash);
|
|
627
|
+
const exposure = row?.preview_id === previewId ? this.getExposure(previewId) : undefined;
|
|
628
|
+
const serverGeneration = exposure && previewExposureState(exposure, now) === "active"
|
|
629
|
+
? authorizationGeneration(exposure)
|
|
630
|
+
: undefined;
|
|
631
|
+
if (!row ||
|
|
632
|
+
row.preview_id !== previewId ||
|
|
633
|
+
row.used_at ||
|
|
634
|
+
Date.parse(row.expires_at) <= now.getTime() ||
|
|
635
|
+
serverGeneration === undefined ||
|
|
636
|
+
row.server_generation !== serverGeneration) {
|
|
637
|
+
this.db.exec("COMMIT");
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
const consumed = this.db.prepare("UPDATE preview_tickets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL")
|
|
641
|
+
.run(now.toISOString(), hash);
|
|
642
|
+
if (Number(consumed.changes ?? 0) !== 1) {
|
|
643
|
+
this.db.exec("COMMIT");
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
const nowIso = now.toISOString();
|
|
647
|
+
this.db.prepare("DELETE FROM preview_browser_sessions WHERE preview_id = ? AND expires_at <= ?").run(previewId, nowIso);
|
|
648
|
+
this.db.prepare("DELETE FROM preview_browser_sessions WHERE token_hash IN (" +
|
|
649
|
+
"SELECT token_hash FROM preview_browser_sessions WHERE preview_id = ? " +
|
|
650
|
+
"ORDER BY created_at DESC, token_hash DESC LIMIT -1 OFFSET ?)").run(previewId, MAX_PREVIEW_BROWSER_SESSIONS - 1);
|
|
651
|
+
const browserToken = opaqueToken();
|
|
652
|
+
const expiresAt = new Date(Math.min(now.getTime() + ttlMinutes * 60_000, Date.parse(exposure.expiresAt))).toISOString();
|
|
653
|
+
this.db.prepare("INSERT INTO preview_browser_sessions (token_hash, preview_id, server_generation, created_at, expires_at) VALUES (?, ?, ?, ?, ?)")
|
|
654
|
+
.run(tokenHash(browserToken), previewId, serverGeneration, nowIso, expiresAt);
|
|
655
|
+
this.db.exec("COMMIT");
|
|
656
|
+
return { token: browserToken, previewId, expiresAt };
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
this.db.exec("ROLLBACK");
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
authorizedBrowserSessionExposure(token, previewId, expectedExposure, now = new Date()) {
|
|
664
|
+
if (!token || !isOpaqueToken(token))
|
|
665
|
+
return undefined;
|
|
666
|
+
const row = this.db.prepare(`
|
|
667
|
+
SELECT exposure.*,
|
|
668
|
+
browser.preview_id AS browser_preview_id,
|
|
669
|
+
browser.server_generation AS browser_server_generation,
|
|
670
|
+
browser.expires_at AS browser_expires_at
|
|
671
|
+
FROM preview_browser_sessions browser
|
|
672
|
+
JOIN preview_exposures exposure ON exposure.id = browser.preview_id
|
|
673
|
+
WHERE browser.token_hash = ?
|
|
674
|
+
`).get(tokenHash(token));
|
|
675
|
+
if (!row || row.browser_preview_id !== previewId || Date.parse(row.browser_expires_at) <= now.getTime()) {
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
const exposure = exposureFromRow(row);
|
|
679
|
+
if (previewExposureState(exposure, now) !== "active")
|
|
680
|
+
return undefined;
|
|
681
|
+
const serverGeneration = authorizationGeneration(exposure);
|
|
682
|
+
if (serverGeneration === undefined || row.browser_server_generation !== serverGeneration)
|
|
683
|
+
return undefined;
|
|
684
|
+
return !expectedExposure || sameAuthorizationExposure(exposure, expectedExposure) ? exposure : undefined;
|
|
685
|
+
}
|
|
686
|
+
authenticateBrowserSession(token, previewId, now = new Date()) {
|
|
687
|
+
return Boolean(this.authorizedBrowserSessionExposure(token, previewId, undefined, now));
|
|
688
|
+
}
|
|
689
|
+
diagnostics(now = new Date()) {
|
|
690
|
+
const iso = now.toISOString();
|
|
691
|
+
const count = (sql, ...values) => Number(this.db.prepare(sql).get(...values).count);
|
|
692
|
+
return {
|
|
693
|
+
schemaVersion: Number(this.db.prepare("PRAGMA user_version").get().user_version),
|
|
694
|
+
activeExposures: count("SELECT COUNT(*) AS count FROM preview_exposures WHERE closed_at IS NULL AND expires_at > ?", iso),
|
|
695
|
+
expiredExposures: count("SELECT COUNT(*) AS count FROM preview_exposures WHERE closed_at IS NULL AND expires_at <= ?", iso),
|
|
696
|
+
closedExposures: count("SELECT COUNT(*) AS count FROM preview_exposures WHERE closed_at IS NOT NULL"),
|
|
697
|
+
managedStartingOrRunning: count("SELECT COUNT(*) AS count FROM preview_exposures WHERE management_mode = 'managed' AND server_state IN ('starting', 'running', 'stopping')"),
|
|
698
|
+
outstandingTickets: count("SELECT COUNT(*) AS count FROM preview_tickets WHERE used_at IS NULL AND expires_at > ?", iso),
|
|
699
|
+
activeBrowserSessions: count("SELECT COUNT(*) AS count FROM preview_browser_sessions WHERE expires_at > ?", iso),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
prune(now = new Date()) {
|
|
703
|
+
const iso = now.toISOString();
|
|
704
|
+
const retentionCutoff = new Date(now.getTime() - CLOSED_PREVIEW_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
|
|
705
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
706
|
+
try {
|
|
707
|
+
const inactiveTickets = this.db.prepare(`
|
|
708
|
+
DELETE FROM preview_tickets WHERE preview_id IN (
|
|
709
|
+
SELECT id FROM preview_exposures WHERE closed_at IS NOT NULL OR expires_at <= ?
|
|
710
|
+
)
|
|
711
|
+
`).run(iso);
|
|
712
|
+
const expiredTickets = this.db.prepare("DELETE FROM preview_tickets WHERE expires_at <= ? OR used_at IS NOT NULL").run(iso);
|
|
713
|
+
const inactiveBrowserSessions = this.db.prepare(`
|
|
714
|
+
DELETE FROM preview_browser_sessions WHERE preview_id IN (
|
|
715
|
+
SELECT id FROM preview_exposures WHERE closed_at IS NOT NULL OR expires_at <= ?
|
|
716
|
+
)
|
|
717
|
+
`).run(iso);
|
|
718
|
+
const expiredBrowserSessions = this.db.prepare("DELETE FROM preview_browser_sessions WHERE expires_at <= ?").run(iso);
|
|
719
|
+
const expiredCommands = this.db.prepare(`
|
|
720
|
+
UPDATE preview_exposures SET start_command = NULL
|
|
721
|
+
WHERE expires_at <= ? AND start_command IS NOT NULL
|
|
722
|
+
AND server_state NOT IN ('starting', 'running', 'stopping')
|
|
723
|
+
AND server_generation IS NULL AND manager_id IS NULL
|
|
724
|
+
`).run(iso);
|
|
725
|
+
const exposures = this.db.prepare(`
|
|
726
|
+
DELETE FROM preview_exposures
|
|
727
|
+
WHERE server_generation IS NULL AND manager_id IS NULL
|
|
728
|
+
AND ((closed_at IS NOT NULL AND closed_at <= ?)
|
|
729
|
+
OR (expires_at <= ? AND server_state NOT IN ('starting', 'running', 'stopping')))
|
|
730
|
+
`).run(retentionCutoff, retentionCutoff);
|
|
731
|
+
this.db.exec("COMMIT");
|
|
732
|
+
return {
|
|
733
|
+
tickets: Number(inactiveTickets.changes ?? 0) + Number(expiredTickets.changes ?? 0),
|
|
734
|
+
browserSessions: Number(inactiveBrowserSessions.changes ?? 0) + Number(expiredBrowserSessions.changes ?? 0),
|
|
735
|
+
expiredCommands: Number(expiredCommands.changes ?? 0),
|
|
736
|
+
exposures: Number(exposures.changes ?? 0),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
this.db.exec("ROLLBACK");
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
close() {
|
|
745
|
+
this.db.close();
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
export function createDefaultPreviewStore() {
|
|
749
|
+
return new PreviewStore();
|
|
750
|
+
}
|