lakebed 0.0.2 → 0.0.3
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/README.md +61 -5
- package/package.json +6 -5
- package/src/anonymous-server.js +2013 -70
- package/src/anonymous.js +14 -5
- package/src/auth.js +155 -0
- package/src/cli.js +135 -53
- package/src/client.d.ts +55 -0
- package/src/client.js +445 -9
- package/src/server.d.ts +7 -0
package/src/anonymous-server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
3
|
import {
|
|
3
4
|
createClaimToken,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
parseTtlSeconds,
|
|
11
12
|
validateAnonymousDeployPayload
|
|
12
13
|
} from "./anonymous.js";
|
|
14
|
+
import { authFromUrl as resolveAuthFromUrl, createGuestAuth, requestOrigin, shooBaseUrlFromEnv } from "./auth.js";
|
|
13
15
|
import { WebSocketServer } from "ws";
|
|
14
16
|
|
|
15
17
|
function now() {
|
|
@@ -20,38 +22,7 @@ function dayWindowStart() {
|
|
|
20
22
|
return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
function
|
|
24
|
-
return (
|
|
25
|
-
String(name ?? "local")
|
|
26
|
-
.replace(/^guest:/, "")
|
|
27
|
-
.trim()
|
|
28
|
-
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
29
|
-
.replace(/^-+|-+$/g, "")
|
|
30
|
-
.toLowerCase() || "local"
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function toDisplayName(name) {
|
|
35
|
-
return toGuestName(name)
|
|
36
|
-
.split(/[-_\s.]+/)
|
|
37
|
-
.filter(Boolean)
|
|
38
|
-
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
|
39
|
-
.join(" ");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function createGuestAuth(name) {
|
|
43
|
-
const guestName = toGuestName(name);
|
|
44
|
-
return {
|
|
45
|
-
displayName: toDisplayName(guestName),
|
|
46
|
-
userId: `guest:${guestName}`
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function authFromUrl(url) {
|
|
51
|
-
return createGuestAuth(url.searchParams.get("lakebed_guest") ?? url.searchParams.get("guest") ?? "local");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function html(title, basePath) {
|
|
25
|
+
function html(title, basePath, { shooBaseUrl } = {}) {
|
|
55
26
|
return `<!doctype html>
|
|
56
27
|
<html lang="en">
|
|
57
28
|
<head>
|
|
@@ -62,6 +33,7 @@ function html(title, basePath) {
|
|
|
62
33
|
<body>
|
|
63
34
|
<div id="app"></div>
|
|
64
35
|
<script>window.__LAKEBED_BASE_PATH__ = ${JSON.stringify(basePath)};</script>
|
|
36
|
+
<script>window.__LAKEBED_AUTH__ = ${JSON.stringify({ shooBaseUrl })};</script>
|
|
65
37
|
<script type="module" src="${basePath}/client.js"></script>
|
|
66
38
|
<script>
|
|
67
39
|
const tailwind = document.createElement("script");
|
|
@@ -86,6 +58,14 @@ function sendText(res, status, value, headers = {}) {
|
|
|
86
58
|
res.end(value);
|
|
87
59
|
}
|
|
88
60
|
|
|
61
|
+
function redirect(res, location, headers = {}) {
|
|
62
|
+
res.writeHead(302, {
|
|
63
|
+
Location: location,
|
|
64
|
+
...headers
|
|
65
|
+
});
|
|
66
|
+
res.end();
|
|
67
|
+
}
|
|
68
|
+
|
|
89
69
|
function websocketSend(ws, message) {
|
|
90
70
|
ws.send(JSON.stringify(message));
|
|
91
71
|
}
|
|
@@ -108,11 +88,36 @@ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
|
108
88
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
109
89
|
}
|
|
110
90
|
|
|
91
|
+
function bearerToken(req) {
|
|
92
|
+
const header = req.headers.authorization;
|
|
93
|
+
if (!header) {
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const match = String(header).match(/^Bearer\s+(.+)$/i);
|
|
98
|
+
return match?.[1]?.trim() ?? "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isDeployTokenValid(deploy, token) {
|
|
102
|
+
return Boolean(token && deploy?.claimTokenHash && hashClaimToken(token) === deploy.claimTokenHash);
|
|
103
|
+
}
|
|
104
|
+
|
|
111
105
|
function normalizePublicRootUrl(value, port) {
|
|
112
106
|
const fallback = `http://localhost:${port}`;
|
|
113
107
|
return String(value || fallback).replace(/\/+$/g, "");
|
|
114
108
|
}
|
|
115
109
|
|
|
110
|
+
function normalizeAppBaseDomain(value) {
|
|
111
|
+
return String(value ?? "")
|
|
112
|
+
.trim()
|
|
113
|
+
.replace(/^https?:\/\//i, "")
|
|
114
|
+
.replace(/\/.*$/g, "")
|
|
115
|
+
.replace(/^\*\./, "")
|
|
116
|
+
.replace(/:\d+$/g, "")
|
|
117
|
+
.replace(/\.$/, "")
|
|
118
|
+
.toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
|
|
116
121
|
function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
|
|
117
122
|
if (appBaseDomain) {
|
|
118
123
|
return `https://${slug}.${appBaseDomain}`;
|
|
@@ -136,6 +141,8 @@ function inspectUrls(url) {
|
|
|
136
141
|
|
|
137
142
|
function responseForDeploy({ deploy, token }) {
|
|
138
143
|
return {
|
|
144
|
+
claimed: Boolean(deploy.ownerId),
|
|
145
|
+
claimedAt: deploy.claimedAt ?? undefined,
|
|
139
146
|
claimUrl: token ? claimUrlForDeploy({ deployId: deploy.id, publicRootUrl: deploy.publicRootUrl, token }) : undefined,
|
|
140
147
|
deployId: deploy.id,
|
|
141
148
|
expiresAt: deploy.expiresAt,
|
|
@@ -207,6 +214,1217 @@ function quotaLimitForBucket(bucket, deploy) {
|
|
|
207
214
|
return deploy.limits.requestsPerDay;
|
|
208
215
|
}
|
|
209
216
|
|
|
217
|
+
const adminCookieName = "lakebed_admin";
|
|
218
|
+
const adminCookieMaxAgeSeconds = 60 * 60 * 24 * 7;
|
|
219
|
+
const developerCookieName = "lakebed_developer";
|
|
220
|
+
const developerCookieMaxAgeSeconds = 60 * 60 * 24 * 30;
|
|
221
|
+
const oauthStateCookieName = "lakebed_oauth_state";
|
|
222
|
+
|
|
223
|
+
function adminPasswordFromEnv(env = process.env) {
|
|
224
|
+
return env.LAKEBED_ADMIN_PASSWORD ?? env.SPAN_ADMIN_PASSWORD ?? "";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isAdminConfigured(adminPassword) {
|
|
228
|
+
return String(adminPassword ?? "").length > 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function safeEqual(left, right) {
|
|
232
|
+
const leftBuffer = Buffer.from(String(left));
|
|
233
|
+
const rightBuffer = Buffer.from(String(right));
|
|
234
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function adminSessionToken(adminPassword) {
|
|
242
|
+
return createHmac("sha256", String(adminPassword)).update("lakebed.admin.session.v1").digest("base64url");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isAdminPasswordValid(candidate, adminPassword) {
|
|
246
|
+
if (!isAdminConfigured(adminPassword)) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return safeEqual(adminSessionToken(candidate ?? ""), adminSessionToken(adminPassword));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseCookies(req) {
|
|
254
|
+
const cookies = {};
|
|
255
|
+
for (const part of String(req.headers.cookie ?? "").split(";")) {
|
|
256
|
+
const [rawName, ...rawValue] = part.trim().split("=");
|
|
257
|
+
if (!rawName) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
cookies[rawName] = rawValue.join("=");
|
|
262
|
+
}
|
|
263
|
+
return cookies;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isAdminAuthenticated(req, adminPassword) {
|
|
267
|
+
if (!isAdminConfigured(adminPassword)) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return safeEqual(parseCookies(req)[adminCookieName] ?? "", adminSessionToken(adminPassword));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isSecureRequest(req) {
|
|
275
|
+
return String(req.headers["x-forwarded-proto"] ?? "")
|
|
276
|
+
.split(",")[0]
|
|
277
|
+
.trim()
|
|
278
|
+
.toLowerCase() === "https";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function adminCookie(value, maxAge = adminCookieMaxAgeSeconds, secure = false) {
|
|
282
|
+
return `${adminCookieName}=${value}; HttpOnly; SameSite=Lax; Path=/admin; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function cookie(name, value, { httpOnly = true, maxAge, path = "/", sameSite = "Lax", secure = false } = {}) {
|
|
286
|
+
const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${path}`, `SameSite=${sameSite}`];
|
|
287
|
+
if (httpOnly) {
|
|
288
|
+
parts.push("HttpOnly");
|
|
289
|
+
}
|
|
290
|
+
if (typeof maxAge === "number") {
|
|
291
|
+
parts.push(`Max-Age=${maxAge}`);
|
|
292
|
+
}
|
|
293
|
+
if (secure) {
|
|
294
|
+
parts.push("Secure");
|
|
295
|
+
}
|
|
296
|
+
return parts.join("; ");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hmac(value, secret) {
|
|
300
|
+
return createHmac("sha256", String(secret)).update(String(value)).digest("base64url");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function signedValue(value, secret) {
|
|
304
|
+
return `${value}.${hmac(value, secret)}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function verifySignedValue(value, secret) {
|
|
308
|
+
if (!value || !secret) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const signed = decodeURIComponent(String(value));
|
|
313
|
+
const splitAt = signed.lastIndexOf(".");
|
|
314
|
+
if (splitAt <= 0) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const payload = signed.slice(0, splitAt);
|
|
319
|
+
const signature = signed.slice(splitAt + 1);
|
|
320
|
+
return safeEqual(signature, hmac(payload, secret)) ? payload : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function signedJson(value, secret) {
|
|
324
|
+
return signedValue(Buffer.from(JSON.stringify(value), "utf8").toString("base64url"), secret);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function verifySignedJson(value, secret) {
|
|
328
|
+
const payload = verifySignedValue(value, secret);
|
|
329
|
+
if (!payload) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function normalizeReturnTo(value, fallback = "/deploys") {
|
|
341
|
+
const candidate = String(value || fallback);
|
|
342
|
+
if (!candidate.startsWith("/") || candidate.startsWith("//") || candidate.includes("\\") || candidate.startsWith("/auth/github")) {
|
|
343
|
+
return fallback;
|
|
344
|
+
}
|
|
345
|
+
return candidate;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function githubOAuthFromEnv(env = process.env) {
|
|
349
|
+
const clientId = env.LAKEBED_GITHUB_CLIENT_ID ?? env.GITHUB_CLIENT_ID ?? "";
|
|
350
|
+
const clientSecret = env.LAKEBED_GITHUB_CLIENT_SECRET ?? env.GITHUB_CLIENT_SECRET ?? "";
|
|
351
|
+
if (!clientId || !clientSecret) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
authorizeUrl: env.LAKEBED_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize",
|
|
357
|
+
clientId,
|
|
358
|
+
clientSecret,
|
|
359
|
+
redirectUri: env.LAKEBED_GITHUB_REDIRECT_URI,
|
|
360
|
+
tokenUrl: env.LAKEBED_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
|
|
361
|
+
userUrl: env.LAKEBED_GITHUB_USER_URL ?? "https://api.github.com/user"
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function normalizeGithubOAuth(value) {
|
|
366
|
+
if (!value?.clientId || !value?.clientSecret) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
authorizeUrl: value.authorizeUrl ?? "https://github.com/login/oauth/authorize",
|
|
372
|
+
clientId: String(value.clientId),
|
|
373
|
+
clientSecret: String(value.clientSecret),
|
|
374
|
+
redirectUri: value.redirectUri,
|
|
375
|
+
sessionSecret: value.sessionSecret,
|
|
376
|
+
tokenUrl: value.tokenUrl ?? "https://github.com/login/oauth/access_token",
|
|
377
|
+
userUrl: value.userUrl ?? "https://api.github.com/user"
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function developerAuthConfigured(githubOAuth, sessionSecret) {
|
|
382
|
+
return Boolean(githubOAuth?.clientId && githubOAuth?.clientSecret && sessionSecret);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function githubRedirectUri(githubOAuth, publicRootUrl) {
|
|
386
|
+
return githubOAuth.redirectUri ?? `${publicRootUrl}/auth/github/callback`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function normalizeGithubUser(user) {
|
|
390
|
+
if (!user?.id || !user?.login) {
|
|
391
|
+
throw new Error("GitHub did not return a usable user profile.");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const providerId = String(user.id).replace(/^github:/, "");
|
|
395
|
+
return {
|
|
396
|
+
avatarUrl: typeof user.avatar_url === "string" ? user.avatar_url : null,
|
|
397
|
+
displayName: typeof user.name === "string" && user.name.trim() ? user.name : String(user.login),
|
|
398
|
+
id: `github:${providerId}`,
|
|
399
|
+
login: String(user.login),
|
|
400
|
+
provider: "github",
|
|
401
|
+
providerId,
|
|
402
|
+
url: typeof user.html_url === "string" ? user.html_url : `https://github.com/${user.login}`
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function developerCookie(user, sessionSecret, secure) {
|
|
407
|
+
return cookie(
|
|
408
|
+
developerCookieName,
|
|
409
|
+
signedJson(
|
|
410
|
+
{
|
|
411
|
+
createdAt: now(),
|
|
412
|
+
user
|
|
413
|
+
},
|
|
414
|
+
sessionSecret
|
|
415
|
+
),
|
|
416
|
+
{
|
|
417
|
+
maxAge: developerCookieMaxAgeSeconds,
|
|
418
|
+
path: "/",
|
|
419
|
+
secure
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function clearDeveloperCookie(secure) {
|
|
425
|
+
return cookie(developerCookieName, "", { maxAge: 0, path: "/", secure });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function developerFromRequest(req, sessionSecret) {
|
|
429
|
+
const session = verifySignedJson(parseCookies(req)[developerCookieName], sessionSecret);
|
|
430
|
+
if (!session?.user?.id || !session.createdAt) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (Date.parse(session.createdAt) + developerCookieMaxAgeSeconds * 1000 <= Date.now()) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return session.user;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function oauthStateCookie(value, secure) {
|
|
442
|
+
return cookie(oauthStateCookieName, value, {
|
|
443
|
+
maxAge: 10 * 60,
|
|
444
|
+
path: "/auth/github",
|
|
445
|
+
secure
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function clearOauthStateCookie(secure) {
|
|
450
|
+
return cookie(oauthStateCookieName, "", {
|
|
451
|
+
maxAge: 0,
|
|
452
|
+
path: "/auth/github",
|
|
453
|
+
secure
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function githubUserFromCode({ code, githubOAuth, redirectUri }) {
|
|
458
|
+
const tokenResponse = await fetch(githubOAuth.tokenUrl, {
|
|
459
|
+
body: new URLSearchParams({
|
|
460
|
+
client_id: githubOAuth.clientId,
|
|
461
|
+
client_secret: githubOAuth.clientSecret,
|
|
462
|
+
code,
|
|
463
|
+
redirect_uri: redirectUri
|
|
464
|
+
}),
|
|
465
|
+
headers: {
|
|
466
|
+
Accept: "application/json",
|
|
467
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
468
|
+
"User-Agent": "Lakebed"
|
|
469
|
+
},
|
|
470
|
+
method: "POST"
|
|
471
|
+
});
|
|
472
|
+
const tokenBody = await tokenResponse.json().catch(() => ({}));
|
|
473
|
+
if (!tokenResponse.ok || !tokenBody.access_token) {
|
|
474
|
+
throw new Error(tokenBody.error_description ?? tokenBody.error ?? "GitHub OAuth token exchange failed.");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const userResponse = await fetch(githubOAuth.userUrl, {
|
|
478
|
+
headers: {
|
|
479
|
+
Accept: "application/vnd.github+json",
|
|
480
|
+
Authorization: `Bearer ${tokenBody.access_token}`,
|
|
481
|
+
"User-Agent": "Lakebed"
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
const userBody = await userResponse.json().catch(() => ({}));
|
|
485
|
+
if (!userResponse.ok) {
|
|
486
|
+
throw new Error(userBody.message ?? "GitHub user lookup failed.");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return normalizeGithubUser(userBody);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function bytesOfJson(value) {
|
|
493
|
+
return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function usageCounts(usage) {
|
|
497
|
+
const windowStart = dayWindowStart();
|
|
498
|
+
const counts = {
|
|
499
|
+
mutationsToday: 0,
|
|
500
|
+
requestsToday: 0
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
for (const event of usage) {
|
|
504
|
+
if (event.windowStart !== windowStart) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (event.bucket === "mutations") {
|
|
509
|
+
counts.mutationsToday += event.count;
|
|
510
|
+
} else if (event.bucket === "requests") {
|
|
511
|
+
counts.requestsToday += event.count;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return counts;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function adminDeploySummary({ artifact, artifactBytes = 0, deploy, logBytes = 0, logEntries = 0, stateBytes = 0, stateRows = 0, usage }) {
|
|
519
|
+
return {
|
|
520
|
+
artifactBytes,
|
|
521
|
+
artifactHash: deploy.artifactHash,
|
|
522
|
+
claimedAt: deploy.claimedAt,
|
|
523
|
+
clientBundleHash: deploy.clientBundleHash,
|
|
524
|
+
createdAt: deploy.createdAt,
|
|
525
|
+
expiresAt: deploy.expiresAt,
|
|
526
|
+
id: deploy.id,
|
|
527
|
+
limits: deploy.limits,
|
|
528
|
+
logBytes,
|
|
529
|
+
logEntries,
|
|
530
|
+
name: artifact?.name ?? "Lakebed Capsule",
|
|
531
|
+
ownerId: deploy.ownerId,
|
|
532
|
+
slug: deploy.slug,
|
|
533
|
+
stateBytes,
|
|
534
|
+
stateRows,
|
|
535
|
+
status: isExpired(deploy) ? "expired" : deploy.status,
|
|
536
|
+
tableCount: Object.keys(artifact?.server?.schema ?? {}).length,
|
|
537
|
+
url: deploy.url,
|
|
538
|
+
...usageCounts(usage)
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function adminHtml() {
|
|
543
|
+
return `<!doctype html>
|
|
544
|
+
<html lang="en">
|
|
545
|
+
<head>
|
|
546
|
+
<meta charset="utf-8" />
|
|
547
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
548
|
+
<title>Lakebed Admin</title>
|
|
549
|
+
<style>
|
|
550
|
+
:root {
|
|
551
|
+
color-scheme: dark;
|
|
552
|
+
--bg: #10100f;
|
|
553
|
+
--panel: #181816;
|
|
554
|
+
--line: #34342f;
|
|
555
|
+
--line-strong: #575247;
|
|
556
|
+
--text: #f3efe2;
|
|
557
|
+
--muted: #aaa28f;
|
|
558
|
+
--accent: #9ad66b;
|
|
559
|
+
--warn: #e9bc5d;
|
|
560
|
+
--bad: #ee7d71;
|
|
561
|
+
--ink: #0f120d;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
* {
|
|
565
|
+
box-sizing: border-box;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
body {
|
|
569
|
+
margin: 0;
|
|
570
|
+
background:
|
|
571
|
+
linear-gradient(90deg, rgba(154, 214, 107, 0.06) 1px, transparent 1px),
|
|
572
|
+
linear-gradient(180deg, rgba(154, 214, 107, 0.04) 1px, transparent 1px),
|
|
573
|
+
var(--bg);
|
|
574
|
+
background-size: 34px 34px;
|
|
575
|
+
color: var(--text);
|
|
576
|
+
font-family: "DIN Alternate", "Avenir Next", "Helvetica Neue", sans-serif;
|
|
577
|
+
letter-spacing: 0;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
button,
|
|
581
|
+
input {
|
|
582
|
+
font: inherit;
|
|
583
|
+
letter-spacing: 0;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
a {
|
|
587
|
+
color: var(--accent);
|
|
588
|
+
text-decoration: none;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
a:hover {
|
|
592
|
+
text-decoration: underline;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.shell {
|
|
596
|
+
margin: 0 auto;
|
|
597
|
+
max-width: 1280px;
|
|
598
|
+
min-height: 100vh;
|
|
599
|
+
padding: 24px 28px;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.topbar {
|
|
603
|
+
align-items: center;
|
|
604
|
+
display: flex;
|
|
605
|
+
gap: 18px;
|
|
606
|
+
justify-content: space-between;
|
|
607
|
+
margin-bottom: 24px;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
.brand {
|
|
611
|
+
display: grid;
|
|
612
|
+
gap: 4px;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.eyebrow {
|
|
616
|
+
color: var(--accent);
|
|
617
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
618
|
+
font-size: 12px;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
h1 {
|
|
622
|
+
font-size: clamp(24px, 3vw, 36px);
|
|
623
|
+
font-weight: 700;
|
|
624
|
+
line-height: 1;
|
|
625
|
+
margin: 0;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.actions {
|
|
629
|
+
display: flex;
|
|
630
|
+
flex-wrap: wrap;
|
|
631
|
+
gap: 10px;
|
|
632
|
+
justify-content: flex-end;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.button {
|
|
636
|
+
background: var(--accent);
|
|
637
|
+
border: 1px solid var(--accent);
|
|
638
|
+
border-radius: 6px;
|
|
639
|
+
color: var(--ink);
|
|
640
|
+
cursor: pointer;
|
|
641
|
+
font-weight: 700;
|
|
642
|
+
min-height: 40px;
|
|
643
|
+
padding: 0 14px;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.button.secondary {
|
|
647
|
+
background: transparent;
|
|
648
|
+
color: var(--text);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.metrics {
|
|
652
|
+
display: grid;
|
|
653
|
+
gap: 1px;
|
|
654
|
+
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
|
655
|
+
margin-bottom: 26px;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.metric {
|
|
659
|
+
background: rgba(24, 24, 22, 0.94);
|
|
660
|
+
border: 1px solid var(--line);
|
|
661
|
+
min-height: 96px;
|
|
662
|
+
padding: 16px;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.metric:first-child {
|
|
666
|
+
border-radius: 8px 0 0 8px;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
.metric:last-child {
|
|
670
|
+
border-radius: 0 8px 8px 0;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.metric span {
|
|
674
|
+
color: var(--muted);
|
|
675
|
+
display: block;
|
|
676
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
677
|
+
font-size: 12px;
|
|
678
|
+
margin-bottom: 12px;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.metric strong {
|
|
682
|
+
display: block;
|
|
683
|
+
font-size: 26px;
|
|
684
|
+
line-height: 1.1;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.panel {
|
|
688
|
+
background: rgba(24, 24, 22, 0.96);
|
|
689
|
+
border: 1px solid var(--line);
|
|
690
|
+
border-radius: 8px;
|
|
691
|
+
overflow: hidden;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
.panel-head {
|
|
695
|
+
align-items: center;
|
|
696
|
+
border-bottom: 1px solid var(--line);
|
|
697
|
+
display: flex;
|
|
698
|
+
gap: 12px;
|
|
699
|
+
justify-content: space-between;
|
|
700
|
+
padding: 14px 16px;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.panel-head h2 {
|
|
704
|
+
font-size: 16px;
|
|
705
|
+
margin: 0;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.status-line {
|
|
709
|
+
color: var(--muted);
|
|
710
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
711
|
+
font-size: 12px;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.table-wrap {
|
|
715
|
+
overflow-x: auto;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
table {
|
|
719
|
+
border-collapse: collapse;
|
|
720
|
+
min-width: 1220px;
|
|
721
|
+
width: 100%;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
th,
|
|
725
|
+
td {
|
|
726
|
+
border-bottom: 1px solid var(--line);
|
|
727
|
+
padding: 12px 14px;
|
|
728
|
+
text-align: left;
|
|
729
|
+
vertical-align: top;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
th {
|
|
733
|
+
color: var(--muted);
|
|
734
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
735
|
+
font-size: 12px;
|
|
736
|
+
font-weight: 600;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
td {
|
|
740
|
+
font-size: 14px;
|
|
741
|
+
white-space: nowrap;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
tr:last-child td {
|
|
745
|
+
border-bottom: 0;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.deploy-name {
|
|
749
|
+
display: grid;
|
|
750
|
+
gap: 4px;
|
|
751
|
+
min-width: 240px;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.deploy-name strong {
|
|
755
|
+
font-size: 15px;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
.mono,
|
|
759
|
+
.deploy-name small {
|
|
760
|
+
color: var(--muted);
|
|
761
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
762
|
+
font-size: 12px;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.deploy-name small {
|
|
766
|
+
white-space: normal;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.pill {
|
|
770
|
+
border: 1px solid var(--line-strong);
|
|
771
|
+
border-radius: 999px;
|
|
772
|
+
display: inline-flex;
|
|
773
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
774
|
+
font-size: 12px;
|
|
775
|
+
padding: 3px 8px;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.pill.active {
|
|
779
|
+
border-color: rgba(154, 214, 107, 0.75);
|
|
780
|
+
color: var(--accent);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
.pill.expired {
|
|
784
|
+
border-color: rgba(238, 125, 113, 0.75);
|
|
785
|
+
color: var(--bad);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.pill.terminated {
|
|
789
|
+
border-color: rgba(233, 188, 93, 0.75);
|
|
790
|
+
color: var(--warn);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
.status-cell {
|
|
794
|
+
align-items: center;
|
|
795
|
+
display: flex;
|
|
796
|
+
gap: 8px;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.row-action {
|
|
800
|
+
background: transparent;
|
|
801
|
+
border: 1px solid rgba(238, 125, 113, 0.8);
|
|
802
|
+
border-radius: 6px;
|
|
803
|
+
color: var(--bad);
|
|
804
|
+
cursor: pointer;
|
|
805
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
806
|
+
font-size: 12px;
|
|
807
|
+
min-height: 32px;
|
|
808
|
+
padding: 0 10px;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
.row-action:disabled {
|
|
812
|
+
border-color: var(--line);
|
|
813
|
+
color: var(--muted);
|
|
814
|
+
cursor: default;
|
|
815
|
+
opacity: 0.62;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
th:nth-child(8),
|
|
819
|
+
td:nth-child(8),
|
|
820
|
+
th:nth-child(9),
|
|
821
|
+
td:nth-child(9) {
|
|
822
|
+
min-width: 124px;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
.login {
|
|
826
|
+
align-items: center;
|
|
827
|
+
display: flex;
|
|
828
|
+
min-height: calc(100vh - 56px);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
.login-panel {
|
|
832
|
+
background: rgba(24, 24, 22, 0.98);
|
|
833
|
+
border: 1px solid var(--line);
|
|
834
|
+
border-radius: 8px;
|
|
835
|
+
max-width: 460px;
|
|
836
|
+
padding: 24px;
|
|
837
|
+
width: 100%;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
.login-panel h1 {
|
|
841
|
+
font-size: 34px;
|
|
842
|
+
margin-bottom: 20px;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
.field {
|
|
846
|
+
display: grid;
|
|
847
|
+
gap: 8px;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
label {
|
|
851
|
+
color: var(--muted);
|
|
852
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
853
|
+
font-size: 12px;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
input {
|
|
857
|
+
background: #0c0d0b;
|
|
858
|
+
border: 1px solid var(--line-strong);
|
|
859
|
+
border-radius: 6px;
|
|
860
|
+
color: var(--text);
|
|
861
|
+
min-height: 44px;
|
|
862
|
+
outline: none;
|
|
863
|
+
padding: 0 12px;
|
|
864
|
+
width: 100%;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
input:focus {
|
|
868
|
+
border-color: var(--accent);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
.form-row {
|
|
872
|
+
display: flex;
|
|
873
|
+
gap: 10px;
|
|
874
|
+
margin-top: 16px;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
.error {
|
|
878
|
+
color: var(--bad);
|
|
879
|
+
min-height: 20px;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
.hidden {
|
|
883
|
+
display: none;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
@media (max-width: 860px) {
|
|
887
|
+
.shell {
|
|
888
|
+
padding: 18px;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
.topbar,
|
|
892
|
+
.panel-head {
|
|
893
|
+
align-items: flex-start;
|
|
894
|
+
flex-direction: column;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
.actions {
|
|
898
|
+
justify-content: flex-start;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
.metrics {
|
|
902
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.metric,
|
|
906
|
+
.metric:first-child,
|
|
907
|
+
.metric:last-child {
|
|
908
|
+
border-radius: 8px;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
</style>
|
|
912
|
+
</head>
|
|
913
|
+
<body>
|
|
914
|
+
<main class="shell">
|
|
915
|
+
<section class="login hidden" id="login-view">
|
|
916
|
+
<form class="login-panel" id="login-form">
|
|
917
|
+
<div class="eyebrow">lakebed admin</div>
|
|
918
|
+
<h1>Deploy monitor</h1>
|
|
919
|
+
<div class="field">
|
|
920
|
+
<label for="password">Password</label>
|
|
921
|
+
<input id="password" name="password" type="password" autocomplete="current-password" autofocus />
|
|
922
|
+
</div>
|
|
923
|
+
<div class="form-row">
|
|
924
|
+
<button class="button" type="submit">Unlock</button>
|
|
925
|
+
</div>
|
|
926
|
+
<p class="error" id="login-error"></p>
|
|
927
|
+
</form>
|
|
928
|
+
</section>
|
|
929
|
+
|
|
930
|
+
<section class="hidden" id="dashboard-view">
|
|
931
|
+
<header class="topbar">
|
|
932
|
+
<div class="brand">
|
|
933
|
+
<div class="eyebrow">lakebed admin</div>
|
|
934
|
+
<h1>Deploy monitor</h1>
|
|
935
|
+
</div>
|
|
936
|
+
<div class="actions">
|
|
937
|
+
<button class="button secondary" id="refresh-button" type="button">Refresh</button>
|
|
938
|
+
<button class="button secondary" id="logout-button" type="button">Lock</button>
|
|
939
|
+
</div>
|
|
940
|
+
</header>
|
|
941
|
+
|
|
942
|
+
<section class="metrics" aria-label="Deploy resource totals">
|
|
943
|
+
<div class="metric"><span>deploys</span><strong id="metric-deploys">0</strong></div>
|
|
944
|
+
<div class="metric"><span>artifact bytes</span><strong id="metric-artifacts">0 B</strong></div>
|
|
945
|
+
<div class="metric"><span>state bytes</span><strong id="metric-state">0 B</strong></div>
|
|
946
|
+
<div class="metric"><span>state rows</span><strong id="metric-rows">0</strong></div>
|
|
947
|
+
<div class="metric"><span>requests today</span><strong id="metric-requests">0</strong></div>
|
|
948
|
+
<div class="metric"><span>mutations today</span><strong id="metric-mutations">0</strong></div>
|
|
949
|
+
</section>
|
|
950
|
+
|
|
951
|
+
<section class="panel">
|
|
952
|
+
<div class="panel-head">
|
|
953
|
+
<h2>Deploy resource table</h2>
|
|
954
|
+
<div class="status-line" id="status-line">Loading</div>
|
|
955
|
+
</div>
|
|
956
|
+
<div class="table-wrap">
|
|
957
|
+
<table>
|
|
958
|
+
<thead>
|
|
959
|
+
<tr>
|
|
960
|
+
<th>Deploy</th>
|
|
961
|
+
<th>Status</th>
|
|
962
|
+
<th>Created</th>
|
|
963
|
+
<th>Expires</th>
|
|
964
|
+
<th>Artifact</th>
|
|
965
|
+
<th>State</th>
|
|
966
|
+
<th>Logs</th>
|
|
967
|
+
<th>Requests</th>
|
|
968
|
+
<th>Mutations</th>
|
|
969
|
+
<th>Connections</th>
|
|
970
|
+
</tr>
|
|
971
|
+
</thead>
|
|
972
|
+
<tbody id="deploy-rows"></tbody>
|
|
973
|
+
</table>
|
|
974
|
+
</div>
|
|
975
|
+
</section>
|
|
976
|
+
</section>
|
|
977
|
+
</main>
|
|
978
|
+
|
|
979
|
+
<script>
|
|
980
|
+
const loginView = document.getElementById("login-view");
|
|
981
|
+
const dashboardView = document.getElementById("dashboard-view");
|
|
982
|
+
const loginForm = document.getElementById("login-form");
|
|
983
|
+
const loginError = document.getElementById("login-error");
|
|
984
|
+
const rows = document.getElementById("deploy-rows");
|
|
985
|
+
const statusLine = document.getElementById("status-line");
|
|
986
|
+
let terminatingDeployId = null;
|
|
987
|
+
|
|
988
|
+
function show(view) {
|
|
989
|
+
loginView.classList.toggle("hidden", view !== "login");
|
|
990
|
+
dashboardView.classList.toggle("hidden", view !== "dashboard");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function formatBytes(bytes) {
|
|
994
|
+
const value = Number(bytes || 0);
|
|
995
|
+
if (value < 1024) {
|
|
996
|
+
return value + " B";
|
|
997
|
+
}
|
|
998
|
+
if (value < 1024 * 1024) {
|
|
999
|
+
return (value / 1024).toFixed(1) + " KB";
|
|
1000
|
+
}
|
|
1001
|
+
return (value / 1024 / 1024).toFixed(2) + " MB";
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function formatNumber(value) {
|
|
1005
|
+
return new Intl.NumberFormat().format(Number(value || 0));
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function formatTime(value) {
|
|
1009
|
+
if (!value) {
|
|
1010
|
+
return "unknown";
|
|
1011
|
+
}
|
|
1012
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
1013
|
+
dateStyle: "medium",
|
|
1014
|
+
timeStyle: "short"
|
|
1015
|
+
}).format(new Date(value));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function setMetric(id, value) {
|
|
1019
|
+
document.getElementById(id).textContent = value;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function textCell(value, className) {
|
|
1023
|
+
const td = document.createElement("td");
|
|
1024
|
+
td.textContent = value;
|
|
1025
|
+
if (className) {
|
|
1026
|
+
td.className = className;
|
|
1027
|
+
}
|
|
1028
|
+
return td;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function deployCell(deploy) {
|
|
1032
|
+
const td = document.createElement("td");
|
|
1033
|
+
const wrap = document.createElement("div");
|
|
1034
|
+
const name = document.createElement("strong");
|
|
1035
|
+
const link = document.createElement("a");
|
|
1036
|
+
const id = document.createElement("small");
|
|
1037
|
+
wrap.className = "deploy-name";
|
|
1038
|
+
link.href = deploy.url;
|
|
1039
|
+
link.textContent = deploy.name || deploy.slug;
|
|
1040
|
+
name.appendChild(link);
|
|
1041
|
+
id.textContent = deploy.id + " / " + deploy.slug;
|
|
1042
|
+
wrap.appendChild(name);
|
|
1043
|
+
wrap.appendChild(id);
|
|
1044
|
+
td.appendChild(wrap);
|
|
1045
|
+
return td;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function statusCell(deploy) {
|
|
1049
|
+
const td = document.createElement("td");
|
|
1050
|
+
const wrap = document.createElement("div");
|
|
1051
|
+
const pill = document.createElement("span");
|
|
1052
|
+
wrap.className = "status-cell";
|
|
1053
|
+
pill.className = "pill " + deploy.status;
|
|
1054
|
+
pill.textContent = deploy.status;
|
|
1055
|
+
wrap.appendChild(pill);
|
|
1056
|
+
|
|
1057
|
+
if (deploy.status === "active") {
|
|
1058
|
+
const button = document.createElement("button");
|
|
1059
|
+
button.className = "row-action";
|
|
1060
|
+
button.type = "button";
|
|
1061
|
+
button.textContent = terminatingDeployId === deploy.id ? "Terminating" : "Terminate";
|
|
1062
|
+
button.disabled = terminatingDeployId === deploy.id;
|
|
1063
|
+
button.addEventListener("click", () => {
|
|
1064
|
+
void terminateDeploy(deploy).catch((error) => {
|
|
1065
|
+
statusLine.textContent = error instanceof Error ? error.message : String(error);
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
wrap.appendChild(button);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
td.appendChild(wrap);
|
|
1072
|
+
return td;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function render(summary) {
|
|
1076
|
+
setMetric("metric-deploys", formatNumber(summary.deployCount));
|
|
1077
|
+
setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
|
|
1078
|
+
setMetric("metric-state", formatBytes(summary.totals.stateBytes));
|
|
1079
|
+
setMetric("metric-rows", formatNumber(summary.totals.stateRows));
|
|
1080
|
+
setMetric("metric-requests", formatNumber(summary.totals.requestsToday));
|
|
1081
|
+
setMetric("metric-mutations", formatNumber(summary.totals.mutationsToday));
|
|
1082
|
+
statusLine.textContent = "Updated " + formatTime(summary.generatedAt);
|
|
1083
|
+
rows.replaceChildren();
|
|
1084
|
+
|
|
1085
|
+
for (const deploy of summary.deploys) {
|
|
1086
|
+
const tr = document.createElement("tr");
|
|
1087
|
+
tr.appendChild(deployCell(deploy));
|
|
1088
|
+
tr.appendChild(statusCell(deploy));
|
|
1089
|
+
tr.appendChild(textCell(formatTime(deploy.createdAt)));
|
|
1090
|
+
tr.appendChild(textCell(formatTime(deploy.expiresAt)));
|
|
1091
|
+
tr.appendChild(textCell(formatBytes(deploy.artifactBytes), "mono"));
|
|
1092
|
+
tr.appendChild(textCell(formatBytes(deploy.stateBytes) + " / " + formatNumber(deploy.stateRows) + " rows", "mono"));
|
|
1093
|
+
tr.appendChild(textCell(formatBytes(deploy.logBytes) + " / " + formatNumber(deploy.logEntries) + " entries", "mono"));
|
|
1094
|
+
tr.appendChild(textCell(formatNumber(deploy.requestsToday) + " / " + formatNumber(deploy.limits.requestsPerDay), "mono"));
|
|
1095
|
+
tr.appendChild(textCell(formatNumber(deploy.mutationsToday) + " / " + formatNumber(deploy.limits.mutationsPerDay), "mono"));
|
|
1096
|
+
tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
|
|
1097
|
+
rows.appendChild(tr);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function loadSummary() {
|
|
1102
|
+
const response = await fetch("/admin/api/summary");
|
|
1103
|
+
if (response.status === 401) {
|
|
1104
|
+
show("login");
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (response.status === 503) {
|
|
1108
|
+
show("login");
|
|
1109
|
+
loginError.textContent = "Set LAKEBED_ADMIN_PASSWORD on the deploy runner.";
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (!response.ok) {
|
|
1113
|
+
throw new Error(await response.text());
|
|
1114
|
+
}
|
|
1115
|
+
render(await response.json());
|
|
1116
|
+
show("dashboard");
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async function terminateDeploy(deploy) {
|
|
1120
|
+
if (!confirm("Terminate " + (deploy.name || deploy.slug) + "?")) {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
terminatingDeployId = deploy.id;
|
|
1125
|
+
statusLine.textContent = "Terminating " + deploy.id;
|
|
1126
|
+
const response = await fetch("/admin/api/deploys/" + encodeURIComponent(deploy.id) + "/terminate", {
|
|
1127
|
+
method: "POST"
|
|
1128
|
+
});
|
|
1129
|
+
terminatingDeployId = null;
|
|
1130
|
+
|
|
1131
|
+
if (response.status === 401) {
|
|
1132
|
+
show("login");
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (!response.ok) {
|
|
1136
|
+
statusLine.textContent = "Terminate failed";
|
|
1137
|
+
throw new Error(await response.text());
|
|
1138
|
+
}
|
|
1139
|
+
await loadSummary();
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
loginForm.addEventListener("submit", async (event) => {
|
|
1143
|
+
event.preventDefault();
|
|
1144
|
+
loginError.textContent = "";
|
|
1145
|
+
const form = new FormData(loginForm);
|
|
1146
|
+
const response = await fetch("/admin/api/login", {
|
|
1147
|
+
body: JSON.stringify({ password: String(form.get("password") || "") }),
|
|
1148
|
+
headers: { "Content-Type": "application/json" },
|
|
1149
|
+
method: "POST"
|
|
1150
|
+
});
|
|
1151
|
+
if (!response.ok) {
|
|
1152
|
+
loginError.textContent = response.status === 503
|
|
1153
|
+
? "Set LAKEBED_ADMIN_PASSWORD on the deploy runner."
|
|
1154
|
+
: "Invalid password.";
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
loginForm.reset();
|
|
1158
|
+
await loadSummary();
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
document.getElementById("refresh-button").addEventListener("click", () => {
|
|
1162
|
+
void loadSummary();
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
document.getElementById("logout-button").addEventListener("click", async () => {
|
|
1166
|
+
await fetch("/admin/api/logout", { method: "POST" });
|
|
1167
|
+
show("login");
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
void loadSummary().catch((error) => {
|
|
1171
|
+
statusLine.textContent = error.message;
|
|
1172
|
+
show("dashboard");
|
|
1173
|
+
});
|
|
1174
|
+
</script>
|
|
1175
|
+
</body>
|
|
1176
|
+
</html>`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function escapeHtml(value) {
|
|
1180
|
+
return String(value ?? "")
|
|
1181
|
+
.replace(/&/g, "&")
|
|
1182
|
+
.replace(/</g, "<")
|
|
1183
|
+
.replace(/>/g, ">")
|
|
1184
|
+
.replace(/"/g, """);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function developerDeploySummary({ artifact, deploy, usage }) {
|
|
1188
|
+
return {
|
|
1189
|
+
artifactHash: deploy.artifactHash,
|
|
1190
|
+
claimedAt: deploy.claimedAt,
|
|
1191
|
+
clientBundleHash: deploy.clientBundleHash,
|
|
1192
|
+
createdAt: deploy.createdAt,
|
|
1193
|
+
deployId: deploy.id,
|
|
1194
|
+
expiresAt: deploy.expiresAt,
|
|
1195
|
+
inspect: inspectUrls(deploy.url),
|
|
1196
|
+
limits: deploy.limits,
|
|
1197
|
+
name: artifact?.name ?? "Lakebed Capsule",
|
|
1198
|
+
ownerId: deploy.ownerId,
|
|
1199
|
+
slug: deploy.slug,
|
|
1200
|
+
status: isExpired(deploy) ? "expired" : deploy.status,
|
|
1201
|
+
url: deploy.url,
|
|
1202
|
+
usage: usageCounts(usage)
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function developerHtml({ authConfigured, deploys = [], user }) {
|
|
1207
|
+
const signedIn = Boolean(user);
|
|
1208
|
+
const rows = deploys
|
|
1209
|
+
.map(
|
|
1210
|
+
(deploy) => `<tr>
|
|
1211
|
+
<td><a href="${escapeHtml(deploy.url)}">${escapeHtml(deploy.name)}</a><small>${escapeHtml(deploy.deployId)} / ${escapeHtml(deploy.slug)}</small></td>
|
|
1212
|
+
<td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
|
|
1213
|
+
<td>${escapeHtml(new Date(deploy.createdAt).toLocaleString())}</td>
|
|
1214
|
+
<td>${escapeHtml(new Date(deploy.expiresAt).toLocaleString())}</td>
|
|
1215
|
+
<td>${escapeHtml(deploy.usage.requestsToday)} / ${escapeHtml(deploy.limits.requestsPerDay)}</td>
|
|
1216
|
+
<td>${escapeHtml(deploy.usage.mutationsToday)} / ${escapeHtml(deploy.limits.mutationsPerDay)}</td>
|
|
1217
|
+
</tr>`
|
|
1218
|
+
)
|
|
1219
|
+
.join("");
|
|
1220
|
+
|
|
1221
|
+
return `<!doctype html>
|
|
1222
|
+
<html lang="en">
|
|
1223
|
+
<head>
|
|
1224
|
+
<meta charset="utf-8" />
|
|
1225
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1226
|
+
<title>Lakebed Deployments</title>
|
|
1227
|
+
<style>
|
|
1228
|
+
:root {
|
|
1229
|
+
color-scheme: dark;
|
|
1230
|
+
--bg: #11130f;
|
|
1231
|
+
--panel: #191c16;
|
|
1232
|
+
--line: #343b2e;
|
|
1233
|
+
--text: #f2f0e8;
|
|
1234
|
+
--muted: #a8ab9e;
|
|
1235
|
+
--accent: #91d46f;
|
|
1236
|
+
--bad: #ee7d71;
|
|
1237
|
+
--ink: #0c110a;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
* {
|
|
1241
|
+
box-sizing: border-box;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
body {
|
|
1245
|
+
background: var(--bg);
|
|
1246
|
+
color: var(--text);
|
|
1247
|
+
font-family: "Avenir Next", "Helvetica Neue", sans-serif;
|
|
1248
|
+
letter-spacing: 0;
|
|
1249
|
+
margin: 0;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
a {
|
|
1253
|
+
color: var(--accent);
|
|
1254
|
+
text-decoration: none;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
a:hover {
|
|
1258
|
+
text-decoration: underline;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
.shell {
|
|
1262
|
+
margin: 0 auto;
|
|
1263
|
+
max-width: 1120px;
|
|
1264
|
+
min-height: 100vh;
|
|
1265
|
+
padding: 28px;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
header {
|
|
1269
|
+
align-items: center;
|
|
1270
|
+
display: flex;
|
|
1271
|
+
gap: 16px;
|
|
1272
|
+
justify-content: space-between;
|
|
1273
|
+
margin-bottom: 24px;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
h1 {
|
|
1277
|
+
font-size: 32px;
|
|
1278
|
+
line-height: 1;
|
|
1279
|
+
margin: 0;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
.eyebrow,
|
|
1283
|
+
small,
|
|
1284
|
+
th,
|
|
1285
|
+
.meta {
|
|
1286
|
+
color: var(--muted);
|
|
1287
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
1288
|
+
font-size: 12px;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
.button {
|
|
1292
|
+
background: var(--accent);
|
|
1293
|
+
border: 1px solid var(--accent);
|
|
1294
|
+
border-radius: 6px;
|
|
1295
|
+
color: var(--ink);
|
|
1296
|
+
display: inline-flex;
|
|
1297
|
+
font-weight: 700;
|
|
1298
|
+
min-height: 40px;
|
|
1299
|
+
padding: 10px 14px;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
.button.secondary {
|
|
1303
|
+
background: transparent;
|
|
1304
|
+
color: var(--text);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
.panel {
|
|
1308
|
+
background: var(--panel);
|
|
1309
|
+
border: 1px solid var(--line);
|
|
1310
|
+
border-radius: 8px;
|
|
1311
|
+
overflow: hidden;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.empty {
|
|
1315
|
+
color: var(--muted);
|
|
1316
|
+
padding: 22px;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
table {
|
|
1320
|
+
border-collapse: collapse;
|
|
1321
|
+
width: 100%;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
th,
|
|
1325
|
+
td {
|
|
1326
|
+
border-bottom: 1px solid var(--line);
|
|
1327
|
+
padding: 13px 14px;
|
|
1328
|
+
text-align: left;
|
|
1329
|
+
vertical-align: top;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
tr:last-child td {
|
|
1333
|
+
border-bottom: 0;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
td:first-child {
|
|
1337
|
+
display: grid;
|
|
1338
|
+
gap: 4px;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.pill {
|
|
1342
|
+
border: 1px solid var(--line);
|
|
1343
|
+
border-radius: 999px;
|
|
1344
|
+
display: inline-flex;
|
|
1345
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
1346
|
+
font-size: 12px;
|
|
1347
|
+
padding: 3px 8px;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
.pill.active {
|
|
1351
|
+
border-color: rgba(145, 212, 111, 0.7);
|
|
1352
|
+
color: var(--accent);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
.pill.expired,
|
|
1356
|
+
.pill.terminated {
|
|
1357
|
+
border-color: rgba(238, 125, 113, 0.75);
|
|
1358
|
+
color: var(--bad);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
@media (max-width: 720px) {
|
|
1362
|
+
.shell {
|
|
1363
|
+
padding: 18px;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
header {
|
|
1367
|
+
align-items: flex-start;
|
|
1368
|
+
flex-direction: column;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
table {
|
|
1372
|
+
min-width: 760px;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
.panel {
|
|
1376
|
+
overflow-x: auto;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
</style>
|
|
1380
|
+
</head>
|
|
1381
|
+
<body>
|
|
1382
|
+
<main class="shell">
|
|
1383
|
+
<header>
|
|
1384
|
+
<div>
|
|
1385
|
+
<div class="eyebrow">lakebed</div>
|
|
1386
|
+
<h1>Deployments</h1>
|
|
1387
|
+
${signedIn ? `<div class="meta">Signed in as ${escapeHtml(user.login)}</div>` : ""}
|
|
1388
|
+
</div>
|
|
1389
|
+
<div>
|
|
1390
|
+
${
|
|
1391
|
+
signedIn
|
|
1392
|
+
? `<a class="button secondary" href="/auth/logout">Sign out</a>`
|
|
1393
|
+
: authConfigured
|
|
1394
|
+
? `<a class="button" href="/auth/github">Sign in with GitHub</a>`
|
|
1395
|
+
: ""
|
|
1396
|
+
}
|
|
1397
|
+
</div>
|
|
1398
|
+
</header>
|
|
1399
|
+
|
|
1400
|
+
${
|
|
1401
|
+
!authConfigured
|
|
1402
|
+
? `<section class="panel"><div class="empty">GitHub sign-in is not configured.</div></section>`
|
|
1403
|
+
: !signedIn
|
|
1404
|
+
? `<section class="panel"><div class="empty">Sign in to view claimed deployments.</div></section>`
|
|
1405
|
+
: deploys.length === 0
|
|
1406
|
+
? `<section class="panel"><div class="empty">No claimed deployments.</div></section>`
|
|
1407
|
+
: `<section class="panel">
|
|
1408
|
+
<table>
|
|
1409
|
+
<thead>
|
|
1410
|
+
<tr>
|
|
1411
|
+
<th>Deploy</th>
|
|
1412
|
+
<th>Status</th>
|
|
1413
|
+
<th>Created</th>
|
|
1414
|
+
<th>Expires</th>
|
|
1415
|
+
<th>Requests</th>
|
|
1416
|
+
<th>Mutations</th>
|
|
1417
|
+
</tr>
|
|
1418
|
+
</thead>
|
|
1419
|
+
<tbody>${rows}</tbody>
|
|
1420
|
+
</table>
|
|
1421
|
+
</section>`
|
|
1422
|
+
}
|
|
1423
|
+
</main>
|
|
1424
|
+
</body>
|
|
1425
|
+
</html>`;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
210
1428
|
export class MemoryAnonymousStore {
|
|
211
1429
|
constructor() {
|
|
212
1430
|
this.artifacts = new Map();
|
|
@@ -220,8 +1438,22 @@ export class MemoryAnonymousStore {
|
|
|
220
1438
|
|
|
221
1439
|
async initialize() {}
|
|
222
1440
|
|
|
1441
|
+
storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
|
|
1442
|
+
const currentArtifact = this.artifacts.get(artifactHash);
|
|
1443
|
+
this.artifacts.set(artifactHash, {
|
|
1444
|
+
artifact,
|
|
1445
|
+
bytes: Buffer.byteLength(clientBundleBase64, "base64"),
|
|
1446
|
+
clientBundleBase64,
|
|
1447
|
+
clientBundleHash,
|
|
1448
|
+
createdAt,
|
|
1449
|
+
hash: artifactHash,
|
|
1450
|
+
refCount: (currentArtifact?.refCount ?? 0) + 1
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
223
1454
|
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
|
|
224
1455
|
const deployId = createDeployId();
|
|
1456
|
+
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
225
1457
|
let slug = createSlug();
|
|
226
1458
|
while (this.deploysBySlug.has(slug)) {
|
|
227
1459
|
slug = createSlug();
|
|
@@ -231,28 +1463,28 @@ export class MemoryAnonymousStore {
|
|
|
231
1463
|
const createdAt = now();
|
|
232
1464
|
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
233
1465
|
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
234
|
-
const url = appUrlForSlug({ appBaseDomain, publicRootUrl, slug });
|
|
1466
|
+
const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
|
|
235
1467
|
|
|
236
|
-
|
|
237
|
-
this.artifacts.set(artifactHash, {
|
|
1468
|
+
this.storeArtifact({
|
|
238
1469
|
artifact,
|
|
239
|
-
bytes: Buffer.byteLength(clientBundleBase64, "base64"),
|
|
240
1470
|
clientBundleBase64,
|
|
241
1471
|
clientBundleHash,
|
|
242
1472
|
createdAt,
|
|
243
|
-
|
|
244
|
-
refCount: (currentArtifact?.refCount ?? 0) + 1
|
|
1473
|
+
artifactHash
|
|
245
1474
|
});
|
|
246
1475
|
|
|
247
1476
|
const deploy = {
|
|
248
|
-
appBaseDomain,
|
|
1477
|
+
appBaseDomain: normalizedAppBaseDomain,
|
|
249
1478
|
artifactHash,
|
|
1479
|
+
claimedAt: null,
|
|
250
1480
|
claimTokenHash: hashClaimToken(token),
|
|
251
1481
|
clientBundleHash,
|
|
252
1482
|
createdAt,
|
|
253
1483
|
expiresAt,
|
|
254
1484
|
id: deployId,
|
|
255
1485
|
limits: { ...DEFAULT_ANONYMOUS_LIMITS },
|
|
1486
|
+
owner: null,
|
|
1487
|
+
ownerId: null,
|
|
256
1488
|
publicRootUrl,
|
|
257
1489
|
slug,
|
|
258
1490
|
status: "active",
|
|
@@ -263,6 +1495,85 @@ export class MemoryAnonymousStore {
|
|
|
263
1495
|
return { deploy, token };
|
|
264
1496
|
}
|
|
265
1497
|
|
|
1498
|
+
async updateDeploy({
|
|
1499
|
+
appBaseDomain,
|
|
1500
|
+
artifact,
|
|
1501
|
+
artifactHash,
|
|
1502
|
+
clientBundleBase64,
|
|
1503
|
+
clientBundleHash,
|
|
1504
|
+
deployId,
|
|
1505
|
+
publicRootUrl,
|
|
1506
|
+
requestedTtlSeconds
|
|
1507
|
+
}) {
|
|
1508
|
+
const currentDeploy = await this.getDeployById(deployId);
|
|
1509
|
+
if (!currentDeploy) {
|
|
1510
|
+
return null;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const createdAt = now();
|
|
1514
|
+
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
1515
|
+
const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
|
|
1516
|
+
const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
|
|
1517
|
+
const deploy = {
|
|
1518
|
+
...currentDeploy,
|
|
1519
|
+
appBaseDomain: nextAppBaseDomain,
|
|
1520
|
+
artifactHash,
|
|
1521
|
+
clientBundleHash,
|
|
1522
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
|
1523
|
+
publicRootUrl: nextPublicRootUrl,
|
|
1524
|
+
url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
|
|
1525
|
+
status: "active"
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
this.storeArtifact({
|
|
1529
|
+
artifact,
|
|
1530
|
+
artifactHash,
|
|
1531
|
+
clientBundleBase64,
|
|
1532
|
+
clientBundleHash,
|
|
1533
|
+
createdAt
|
|
1534
|
+
});
|
|
1535
|
+
this.deploys.set(deployId, deploy);
|
|
1536
|
+
return deploy;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
async claimDeploy(deployId, token, owner) {
|
|
1540
|
+
const currentDeploy = await this.getDeployById(deployId);
|
|
1541
|
+
if (!currentDeploy) {
|
|
1542
|
+
return { status: "missing" };
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (!isDeployTokenValid(currentDeploy, token)) {
|
|
1546
|
+
return { deploy: currentDeploy, status: "invalid" };
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (currentDeploy.ownerId && currentDeploy.ownerId !== owner.id) {
|
|
1550
|
+
return { deploy: currentDeploy, status: "conflict" };
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const deploy = {
|
|
1554
|
+
...currentDeploy,
|
|
1555
|
+
claimedAt: currentDeploy.claimedAt ?? now(),
|
|
1556
|
+
owner,
|
|
1557
|
+
ownerId: owner.id
|
|
1558
|
+
};
|
|
1559
|
+
this.deploys.set(deployId, deploy);
|
|
1560
|
+
return { deploy, status: currentDeploy.ownerId ? "already_claimed" : "claimed" };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
async terminateDeploy(deployId) {
|
|
1564
|
+
const currentDeploy = await this.getDeployById(deployId);
|
|
1565
|
+
if (!currentDeploy) {
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
const deploy = {
|
|
1570
|
+
...currentDeploy,
|
|
1571
|
+
status: "terminated"
|
|
1572
|
+
};
|
|
1573
|
+
this.deploys.set(deployId, deploy);
|
|
1574
|
+
return deploy;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
266
1577
|
async getDeployById(id) {
|
|
267
1578
|
return this.deploys.get(id) ?? null;
|
|
268
1579
|
}
|
|
@@ -364,26 +1675,95 @@ export class MemoryAnonymousStore {
|
|
|
364
1675
|
return { tables, truncated };
|
|
365
1676
|
}
|
|
366
1677
|
|
|
367
|
-
async incrementQuota(deployId, bucket, limit) {
|
|
368
|
-
const windowStart = dayWindowStart();
|
|
369
|
-
const key = `${deployId}:${bucket}:${windowStart}`;
|
|
370
|
-
const count = (this.quotaEvents.get(key) ?? 0) + 1;
|
|
371
|
-
this.quotaEvents.set(key, count);
|
|
372
|
-
if (count > limit) {
|
|
373
|
-
throw new Error(`Anonymous ${bucket} quota exceeded. Limit: ${limit} per day.`);
|
|
1678
|
+
async incrementQuota(deployId, bucket, limit) {
|
|
1679
|
+
const windowStart = dayWindowStart();
|
|
1680
|
+
const key = `${deployId}:${bucket}:${windowStart}`;
|
|
1681
|
+
const count = (this.quotaEvents.get(key) ?? 0) + 1;
|
|
1682
|
+
this.quotaEvents.set(key, count);
|
|
1683
|
+
if (count > limit) {
|
|
1684
|
+
throw new Error(`Anonymous ${bucket} quota exceeded. Limit: ${limit} per day.`);
|
|
1685
|
+
}
|
|
1686
|
+
return { bucket, count, limit, windowStart };
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async readUsage(deployId) {
|
|
1690
|
+
const usage = [];
|
|
1691
|
+
for (const [key, count] of this.quotaEvents) {
|
|
1692
|
+
const [eventDeployId, bucket, ...windowParts] = key.split(":");
|
|
1693
|
+
const windowStart = windowParts.join(":");
|
|
1694
|
+
if (eventDeployId === deployId) {
|
|
1695
|
+
usage.push({ bucket, count, windowStart });
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return usage;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
stateResourceForDeploy(deployId) {
|
|
1702
|
+
let stateBytes = 0;
|
|
1703
|
+
let stateRows = 0;
|
|
1704
|
+
|
|
1705
|
+
for (const [key, rows] of this.rows) {
|
|
1706
|
+
const [eventDeployId] = key.split(":");
|
|
1707
|
+
if (eventDeployId !== deployId) {
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
for (const row of rows.values()) {
|
|
1712
|
+
stateRows += 1;
|
|
1713
|
+
stateBytes += bytesOfJson(row);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
return { stateBytes, stateRows };
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
logResourceForDeploy(deployId) {
|
|
1721
|
+
const entries = this.logs.get(deployId) ?? [];
|
|
1722
|
+
return {
|
|
1723
|
+
logBytes: bytesOfJson(entries),
|
|
1724
|
+
logEntries: entries.length
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async listDeployResourceUsage() {
|
|
1729
|
+
const deploys = Array.from(this.deploys.values()).sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)));
|
|
1730
|
+
const summaries = [];
|
|
1731
|
+
|
|
1732
|
+
for (const deploy of deploys) {
|
|
1733
|
+
const storedArtifact = await this.getArtifact(deploy.artifactHash);
|
|
1734
|
+
summaries.push(
|
|
1735
|
+
adminDeploySummary({
|
|
1736
|
+
artifact: storedArtifact?.artifact,
|
|
1737
|
+
artifactBytes: storedArtifact?.bytes ?? 0,
|
|
1738
|
+
deploy,
|
|
1739
|
+
...this.logResourceForDeploy(deploy.id),
|
|
1740
|
+
...this.stateResourceForDeploy(deploy.id),
|
|
1741
|
+
usage: await this.readUsage(deploy.id)
|
|
1742
|
+
})
|
|
1743
|
+
);
|
|
374
1744
|
}
|
|
375
|
-
|
|
1745
|
+
|
|
1746
|
+
return summaries;
|
|
376
1747
|
}
|
|
377
1748
|
|
|
378
|
-
async
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1749
|
+
async listDeploysForOwner(ownerId) {
|
|
1750
|
+
const deploys = Array.from(this.deploys.values())
|
|
1751
|
+
.filter((deploy) => deploy.ownerId === ownerId)
|
|
1752
|
+
.sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)));
|
|
1753
|
+
const summaries = [];
|
|
1754
|
+
|
|
1755
|
+
for (const deploy of deploys) {
|
|
1756
|
+
const storedArtifact = await this.getArtifact(deploy.artifactHash);
|
|
1757
|
+
summaries.push(
|
|
1758
|
+
developerDeploySummary({
|
|
1759
|
+
artifact: storedArtifact?.artifact,
|
|
1760
|
+
deploy,
|
|
1761
|
+
usage: await this.readUsage(deploy.id)
|
|
1762
|
+
})
|
|
1763
|
+
);
|
|
385
1764
|
}
|
|
386
|
-
|
|
1765
|
+
|
|
1766
|
+
return summaries;
|
|
387
1767
|
}
|
|
388
1768
|
}
|
|
389
1769
|
|
|
@@ -408,6 +1788,7 @@ export class PostgresAnonymousStore {
|
|
|
408
1788
|
expires_at timestamptz not null,
|
|
409
1789
|
claimed_at timestamptz,
|
|
410
1790
|
owner_id text,
|
|
1791
|
+
owner_json jsonb,
|
|
411
1792
|
claim_token_hash text not null,
|
|
412
1793
|
limits_json jsonb not null,
|
|
413
1794
|
counters_json jsonb not null default '{}',
|
|
@@ -416,6 +1797,9 @@ export class PostgresAnonymousStore {
|
|
|
416
1797
|
url text not null
|
|
417
1798
|
)
|
|
418
1799
|
`);
|
|
1800
|
+
await this.query("alter table deploys add column if not exists claimed_at timestamptz");
|
|
1801
|
+
await this.query("alter table deploys add column if not exists owner_id text");
|
|
1802
|
+
await this.query("alter table deploys add column if not exists owner_json jsonb");
|
|
419
1803
|
await this.query(`
|
|
420
1804
|
create table if not exists artifacts(
|
|
421
1805
|
hash text primary key,
|
|
@@ -467,13 +1851,7 @@ export class PostgresAnonymousStore {
|
|
|
467
1851
|
return this.pool.query(sql, params);
|
|
468
1852
|
}
|
|
469
1853
|
|
|
470
|
-
async
|
|
471
|
-
const createdAt = now();
|
|
472
|
-
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
473
|
-
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
474
|
-
const token = createClaimToken();
|
|
475
|
-
const deployId = createDeployId();
|
|
476
|
-
|
|
1854
|
+
async storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
|
|
477
1855
|
await this.query(
|
|
478
1856
|
`
|
|
479
1857
|
insert into artifacts(hash, artifact_json, client_bundle_base64, client_bundle_hash, bytes, created_at, ref_count)
|
|
@@ -482,19 +1860,33 @@ export class PostgresAnonymousStore {
|
|
|
482
1860
|
`,
|
|
483
1861
|
[artifactHash, JSON.stringify(artifact), clientBundleBase64, clientBundleHash, Buffer.byteLength(clientBundleBase64, "base64"), createdAt]
|
|
484
1862
|
);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
|
|
1866
|
+
const createdAt = now();
|
|
1867
|
+
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
1868
|
+
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
1869
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
1870
|
+
const token = createClaimToken();
|
|
1871
|
+
const deployId = createDeployId();
|
|
1872
|
+
|
|
1873
|
+
await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt });
|
|
485
1874
|
|
|
486
1875
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
487
1876
|
const slug = createSlug();
|
|
488
|
-
const url = appUrlForSlug({ appBaseDomain, publicRootUrl, slug });
|
|
1877
|
+
const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
|
|
489
1878
|
const deploy = {
|
|
490
|
-
appBaseDomain,
|
|
1879
|
+
appBaseDomain: normalizedAppBaseDomain,
|
|
491
1880
|
artifactHash,
|
|
1881
|
+
claimedAt: null,
|
|
492
1882
|
claimTokenHash: hashClaimToken(token),
|
|
493
1883
|
clientBundleHash,
|
|
494
1884
|
createdAt,
|
|
495
1885
|
expiresAt,
|
|
496
1886
|
id: deployId,
|
|
497
1887
|
limits: { ...DEFAULT_ANONYMOUS_LIMITS },
|
|
1888
|
+
owner: null,
|
|
1889
|
+
ownerId: null,
|
|
498
1890
|
publicRootUrl,
|
|
499
1891
|
slug,
|
|
500
1892
|
status: "active",
|
|
@@ -536,6 +1928,89 @@ export class PostgresAnonymousStore {
|
|
|
536
1928
|
throw new Error("Unable to allocate anonymous deploy slug.");
|
|
537
1929
|
}
|
|
538
1930
|
|
|
1931
|
+
async updateDeploy({
|
|
1932
|
+
appBaseDomain,
|
|
1933
|
+
artifact,
|
|
1934
|
+
artifactHash,
|
|
1935
|
+
clientBundleBase64,
|
|
1936
|
+
clientBundleHash,
|
|
1937
|
+
deployId,
|
|
1938
|
+
publicRootUrl,
|
|
1939
|
+
requestedTtlSeconds
|
|
1940
|
+
}) {
|
|
1941
|
+
const currentDeploy = await this.getDeployById(deployId);
|
|
1942
|
+
if (!currentDeploy) {
|
|
1943
|
+
return null;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const createdAt = now();
|
|
1947
|
+
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
1948
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
1949
|
+
const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
|
|
1950
|
+
const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
|
|
1951
|
+
const url = appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug });
|
|
1952
|
+
await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt });
|
|
1953
|
+
|
|
1954
|
+
const result = await this.query(
|
|
1955
|
+
`
|
|
1956
|
+
update deploys
|
|
1957
|
+
set status = 'active',
|
|
1958
|
+
artifact_hash = $2,
|
|
1959
|
+
client_bundle_hash = $3,
|
|
1960
|
+
expires_at = $4,
|
|
1961
|
+
public_root_url = $5,
|
|
1962
|
+
app_base_domain = $6,
|
|
1963
|
+
url = $7
|
|
1964
|
+
where id = $1
|
|
1965
|
+
returning *
|
|
1966
|
+
`,
|
|
1967
|
+
[deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url]
|
|
1968
|
+
);
|
|
1969
|
+
return this.rowToDeploy(result.rows[0]);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
async claimDeploy(deployId, token, owner) {
|
|
1973
|
+
const currentDeploy = await this.getDeployById(deployId);
|
|
1974
|
+
if (!currentDeploy) {
|
|
1975
|
+
return { status: "missing" };
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
if (!isDeployTokenValid(currentDeploy, token)) {
|
|
1979
|
+
return { deploy: currentDeploy, status: "invalid" };
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
if (currentDeploy.ownerId && currentDeploy.ownerId !== owner.id) {
|
|
1983
|
+
return { deploy: currentDeploy, status: "conflict" };
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const claimedAt = currentDeploy.claimedAt ?? now();
|
|
1987
|
+
const result = await this.query(
|
|
1988
|
+
`
|
|
1989
|
+
update deploys
|
|
1990
|
+
set claimed_at = coalesce(claimed_at, $3),
|
|
1991
|
+
owner_id = $2,
|
|
1992
|
+
owner_json = $4::jsonb
|
|
1993
|
+
where id = $1
|
|
1994
|
+
returning *
|
|
1995
|
+
`,
|
|
1996
|
+
[deployId, owner.id, claimedAt, JSON.stringify(owner)]
|
|
1997
|
+
);
|
|
1998
|
+
return { deploy: this.rowToDeploy(result.rows[0]), status: currentDeploy.ownerId ? "already_claimed" : "claimed" };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
async terminateDeploy(deployId) {
|
|
2002
|
+
const result = await this.query(
|
|
2003
|
+
`
|
|
2004
|
+
update deploys
|
|
2005
|
+
set status = 'terminated'
|
|
2006
|
+
where id = $1
|
|
2007
|
+
returning *
|
|
2008
|
+
`,
|
|
2009
|
+
[deployId]
|
|
2010
|
+
);
|
|
2011
|
+
return this.rowToDeploy(result.rows[0]);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
539
2014
|
rowToDeploy(row) {
|
|
540
2015
|
if (!row) {
|
|
541
2016
|
return null;
|
|
@@ -544,12 +2019,15 @@ export class PostgresAnonymousStore {
|
|
|
544
2019
|
return {
|
|
545
2020
|
appBaseDomain: row.app_base_domain,
|
|
546
2021
|
artifactHash: row.artifact_hash,
|
|
2022
|
+
claimedAt: row.claimed_at ? new Date(row.claimed_at).toISOString() : null,
|
|
547
2023
|
claimTokenHash: row.claim_token_hash,
|
|
548
2024
|
clientBundleHash: row.client_bundle_hash,
|
|
549
2025
|
createdAt: new Date(row.created_at).toISOString(),
|
|
550
2026
|
expiresAt: new Date(row.expires_at).toISOString(),
|
|
551
2027
|
id: row.id,
|
|
552
2028
|
limits: row.limits_json,
|
|
2029
|
+
owner: row.owner_json ?? null,
|
|
2030
|
+
ownerId: row.owner_id ?? null,
|
|
553
2031
|
publicRootUrl: row.public_root_url,
|
|
554
2032
|
slug: row.slug,
|
|
555
2033
|
status: row.status,
|
|
@@ -726,6 +2204,99 @@ export class PostgresAnonymousStore {
|
|
|
726
2204
|
windowStart: new Date(row.window_start).toISOString()
|
|
727
2205
|
}));
|
|
728
2206
|
}
|
|
2207
|
+
|
|
2208
|
+
async listDeployResourceUsage() {
|
|
2209
|
+
const windowStart = dayWindowStart();
|
|
2210
|
+
const result = await this.query(
|
|
2211
|
+
`
|
|
2212
|
+
select
|
|
2213
|
+
d.*,
|
|
2214
|
+
a.artifact_json,
|
|
2215
|
+
coalesce(a.bytes, 0)::int as artifact_bytes,
|
|
2216
|
+
coalesce(sr.state_rows, 0)::int as state_rows,
|
|
2217
|
+
coalesce(sr.state_bytes, 0)::int as state_bytes,
|
|
2218
|
+
coalesce(l.log_entries, 0)::int as log_entries,
|
|
2219
|
+
coalesce(l.log_bytes, 0)::int as log_bytes,
|
|
2220
|
+
coalesce(q.requests_today, 0)::int as requests_today,
|
|
2221
|
+
coalesce(q.mutations_today, 0)::int as mutations_today
|
|
2222
|
+
from deploys d
|
|
2223
|
+
left join artifacts a on a.hash = d.artifact_hash
|
|
2224
|
+
left join (
|
|
2225
|
+
select deploy_id, count(*)::int as state_rows, coalesce(sum(octet_length(data_json::text)), 0)::int as state_bytes
|
|
2226
|
+
from state_rows
|
|
2227
|
+
group by deploy_id
|
|
2228
|
+
) sr on sr.deploy_id = d.id
|
|
2229
|
+
left join (
|
|
2230
|
+
select deploy_id, count(*)::int as log_entries, coalesce(sum(octet_length(message) + octet_length(coalesce(data_json::text, ''))), 0)::int as log_bytes
|
|
2231
|
+
from logs
|
|
2232
|
+
group by deploy_id
|
|
2233
|
+
) l on l.deploy_id = d.id
|
|
2234
|
+
left join (
|
|
2235
|
+
select
|
|
2236
|
+
deploy_id,
|
|
2237
|
+
coalesce(sum(count) filter (where bucket = 'requests' and window_start = $1), 0)::int as requests_today,
|
|
2238
|
+
coalesce(sum(count) filter (where bucket = 'mutations' and window_start = $1), 0)::int as mutations_today
|
|
2239
|
+
from quota_events
|
|
2240
|
+
group by deploy_id
|
|
2241
|
+
) q on q.deploy_id = d.id
|
|
2242
|
+
order by d.created_at desc
|
|
2243
|
+
`,
|
|
2244
|
+
[windowStart]
|
|
2245
|
+
);
|
|
2246
|
+
|
|
2247
|
+
return result.rows.map((row) =>
|
|
2248
|
+
adminDeploySummary({
|
|
2249
|
+
artifact: row.artifact_json,
|
|
2250
|
+
artifactBytes: row.artifact_bytes,
|
|
2251
|
+
deploy: this.rowToDeploy(row),
|
|
2252
|
+
logBytes: row.log_bytes,
|
|
2253
|
+
logEntries: row.log_entries,
|
|
2254
|
+
stateBytes: row.state_bytes,
|
|
2255
|
+
stateRows: row.state_rows,
|
|
2256
|
+
usage: [
|
|
2257
|
+
{ bucket: "requests", count: row.requests_today, windowStart },
|
|
2258
|
+
{ bucket: "mutations", count: row.mutations_today, windowStart }
|
|
2259
|
+
]
|
|
2260
|
+
})
|
|
2261
|
+
);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
async listDeploysForOwner(ownerId) {
|
|
2265
|
+
const windowStart = dayWindowStart();
|
|
2266
|
+
const result = await this.query(
|
|
2267
|
+
`
|
|
2268
|
+
select
|
|
2269
|
+
d.*,
|
|
2270
|
+
a.artifact_json,
|
|
2271
|
+
coalesce(q.requests_today, 0)::int as requests_today,
|
|
2272
|
+
coalesce(q.mutations_today, 0)::int as mutations_today
|
|
2273
|
+
from deploys d
|
|
2274
|
+
left join artifacts a on a.hash = d.artifact_hash
|
|
2275
|
+
left join (
|
|
2276
|
+
select
|
|
2277
|
+
deploy_id,
|
|
2278
|
+
coalesce(sum(count) filter (where bucket = 'requests' and window_start = $2), 0)::int as requests_today,
|
|
2279
|
+
coalesce(sum(count) filter (where bucket = 'mutations' and window_start = $2), 0)::int as mutations_today
|
|
2280
|
+
from quota_events
|
|
2281
|
+
group by deploy_id
|
|
2282
|
+
) q on q.deploy_id = d.id
|
|
2283
|
+
where d.owner_id = $1
|
|
2284
|
+
order by d.created_at desc
|
|
2285
|
+
`,
|
|
2286
|
+
[ownerId, windowStart]
|
|
2287
|
+
);
|
|
2288
|
+
|
|
2289
|
+
return result.rows.map((row) =>
|
|
2290
|
+
developerDeploySummary({
|
|
2291
|
+
artifact: row.artifact_json,
|
|
2292
|
+
deploy: this.rowToDeploy(row),
|
|
2293
|
+
usage: [
|
|
2294
|
+
{ bucket: "requests", count: row.requests_today, windowStart },
|
|
2295
|
+
{ bucket: "mutations", count: row.mutations_today, windowStart }
|
|
2296
|
+
]
|
|
2297
|
+
})
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
729
2300
|
}
|
|
730
2301
|
|
|
731
2302
|
export async function createAnonymousStoreFromEnv(env = process.env) {
|
|
@@ -823,17 +2394,104 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
|
|
|
823
2394
|
}
|
|
824
2395
|
|
|
825
2396
|
export async function startAnonymousServer({
|
|
2397
|
+
adminPassword = adminPasswordFromEnv(),
|
|
826
2398
|
appBaseDomain = process.env.LAKEBED_APP_BASE_DOMAIN ?? "",
|
|
2399
|
+
developerSessionSecret = process.env.LAKEBED_SESSION_SECRET ?? "",
|
|
2400
|
+
githubOAuth = githubOAuthFromEnv(),
|
|
827
2401
|
port = Number(process.env.PORT ?? 8787),
|
|
828
2402
|
publicRootUrl,
|
|
829
2403
|
quiet = false,
|
|
2404
|
+
shooBaseUrl = shooBaseUrlFromEnv(),
|
|
830
2405
|
store
|
|
831
2406
|
} = {}) {
|
|
2407
|
+
const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
832
2408
|
const resolvedPublicRootUrl = normalizePublicRootUrl(publicRootUrl ?? process.env.PUBLIC_ROOT_URL, port);
|
|
2409
|
+
const resolvedGithubOAuth = normalizeGithubOAuth(githubOAuth);
|
|
2410
|
+
const resolvedDeveloperSessionSecret =
|
|
2411
|
+
developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
|
|
833
2412
|
const resolvedStore = store ?? (await createAnonymousStoreFromEnv());
|
|
834
2413
|
await resolvedStore.initialize();
|
|
835
2414
|
const subscriptions = new Map();
|
|
836
2415
|
|
|
2416
|
+
function activeConnectionCounts() {
|
|
2417
|
+
const counts = new Map();
|
|
2418
|
+
for (const subscription of subscriptions.values()) {
|
|
2419
|
+
counts.set(subscription.deploy.id, (counts.get(subscription.deploy.id) ?? 0) + 1);
|
|
2420
|
+
}
|
|
2421
|
+
return counts;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
async function adminSummary() {
|
|
2425
|
+
const connections = activeConnectionCounts();
|
|
2426
|
+
const deploys = (await resolvedStore.listDeployResourceUsage()).map((deploy) => ({
|
|
2427
|
+
...deploy,
|
|
2428
|
+
connections: connections.get(deploy.id) ?? 0
|
|
2429
|
+
}));
|
|
2430
|
+
const totals = deploys.reduce(
|
|
2431
|
+
(acc, deploy) => ({
|
|
2432
|
+
artifactBytes: acc.artifactBytes + deploy.artifactBytes,
|
|
2433
|
+
connections: acc.connections + deploy.connections,
|
|
2434
|
+
logBytes: acc.logBytes + deploy.logBytes,
|
|
2435
|
+
logEntries: acc.logEntries + deploy.logEntries,
|
|
2436
|
+
mutationsToday: acc.mutationsToday + deploy.mutationsToday,
|
|
2437
|
+
requestsToday: acc.requestsToday + deploy.requestsToday,
|
|
2438
|
+
stateBytes: acc.stateBytes + deploy.stateBytes,
|
|
2439
|
+
stateRows: acc.stateRows + deploy.stateRows
|
|
2440
|
+
}),
|
|
2441
|
+
{
|
|
2442
|
+
artifactBytes: 0,
|
|
2443
|
+
connections: 0,
|
|
2444
|
+
logBytes: 0,
|
|
2445
|
+
logEntries: 0,
|
|
2446
|
+
mutationsToday: 0,
|
|
2447
|
+
requestsToday: 0,
|
|
2448
|
+
stateBytes: 0,
|
|
2449
|
+
stateRows: 0
|
|
2450
|
+
}
|
|
2451
|
+
);
|
|
2452
|
+
|
|
2453
|
+
return {
|
|
2454
|
+
deployCount: deploys.length,
|
|
2455
|
+
deploys,
|
|
2456
|
+
generatedAt: now(),
|
|
2457
|
+
totals
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function currentDeveloper(req) {
|
|
2462
|
+
return developerFromRequest(req, resolvedDeveloperSessionSecret);
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
async function developerDeploys(user) {
|
|
2466
|
+
return resolvedStore.listDeploysForOwner(user.id);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
async function refreshDeploySubscriptions(deploy) {
|
|
2470
|
+
const storedArtifact = await resolvedStore.getArtifact(deploy.artifactHash);
|
|
2471
|
+
if (!storedArtifact) {
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
for (const subscription of subscriptions.values()) {
|
|
2476
|
+
if (subscription.deploy.id === deploy.id) {
|
|
2477
|
+
subscription.artifact = storedArtifact.artifact;
|
|
2478
|
+
subscription.deploy = deploy;
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function closeDeployConnections(deployId) {
|
|
2484
|
+
for (const [ws, subscription] of subscriptions) {
|
|
2485
|
+
if (subscription.deploy.id !== deployId) {
|
|
2486
|
+
continue;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
websocketSend(ws, { error: "Anonymous deploy terminated.", ok: false, op: "error" });
|
|
2490
|
+
subscriptions.delete(ws);
|
|
2491
|
+
ws.close(1008, "Deploy terminated");
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
837
2495
|
async function publishDeploy(deployId) {
|
|
838
2496
|
for (const [ws, subscription] of subscriptions) {
|
|
839
2497
|
if (subscription.deploy.id !== deployId) {
|
|
@@ -867,11 +2525,249 @@ export async function startAnonymousServer({
|
|
|
867
2525
|
return;
|
|
868
2526
|
}
|
|
869
2527
|
|
|
2528
|
+
if (req.method === "GET" && (requestUrl.pathname === "/deploys" || requestUrl.pathname === "/dashboard")) {
|
|
2529
|
+
const authConfigured = developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret);
|
|
2530
|
+
const user = authConfigured ? currentDeveloper(req) : null;
|
|
2531
|
+
sendText(
|
|
2532
|
+
res,
|
|
2533
|
+
200,
|
|
2534
|
+
developerHtml({
|
|
2535
|
+
authConfigured,
|
|
2536
|
+
deploys: user ? await developerDeploys(user) : [],
|
|
2537
|
+
user
|
|
2538
|
+
}),
|
|
2539
|
+
{ "Content-Type": "text/html; charset=utf-8" }
|
|
2540
|
+
);
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
if (req.method === "GET" && requestUrl.pathname === "/v1/me") {
|
|
2545
|
+
const user = currentDeveloper(req);
|
|
2546
|
+
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
|
|
2547
|
+
sendJson(res, 401, { error: "Developer authentication required." });
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
sendJson(res, 200, { user });
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
if (req.method === "GET" && requestUrl.pathname === "/v1/me/deploys") {
|
|
2556
|
+
const user = currentDeveloper(req);
|
|
2557
|
+
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
|
|
2558
|
+
sendJson(res, 401, { error: "Developer authentication required." });
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
sendJson(res, 200, { deploys: await developerDeploys(user), user });
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
if (req.method === "GET" && requestUrl.pathname === "/auth/github") {
|
|
2567
|
+
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
|
|
2568
|
+
sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
const returnTo = normalizeReturnTo(requestUrl.searchParams.get("return_to") ?? requestUrl.searchParams.get("returnTo"));
|
|
2573
|
+
const state = randomBytes(16).toString("base64url");
|
|
2574
|
+
const stateCookie = signedJson(
|
|
2575
|
+
{
|
|
2576
|
+
createdAt: now(),
|
|
2577
|
+
nonce: state,
|
|
2578
|
+
returnTo
|
|
2579
|
+
},
|
|
2580
|
+
resolvedDeveloperSessionSecret
|
|
2581
|
+
);
|
|
2582
|
+
const authorizeUrl = new URL(resolvedGithubOAuth.authorizeUrl);
|
|
2583
|
+
authorizeUrl.searchParams.set("client_id", resolvedGithubOAuth.clientId);
|
|
2584
|
+
authorizeUrl.searchParams.set("redirect_uri", githubRedirectUri(resolvedGithubOAuth, resolvedPublicRootUrl));
|
|
2585
|
+
authorizeUrl.searchParams.set("scope", "read:user");
|
|
2586
|
+
authorizeUrl.searchParams.set("state", state);
|
|
2587
|
+
redirect(res, authorizeUrl.href, {
|
|
2588
|
+
"Set-Cookie": oauthStateCookie(stateCookie, isSecureRequest(req))
|
|
2589
|
+
});
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
if (req.method === "GET" && requestUrl.pathname === "/auth/github/callback") {
|
|
2594
|
+
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
|
|
2595
|
+
sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
const secure = isSecureRequest(req);
|
|
2600
|
+
const state = requestUrl.searchParams.get("state") ?? "";
|
|
2601
|
+
const stateCookie = parseCookies(req)[oauthStateCookieName] ?? "";
|
|
2602
|
+
const statePayload = verifySignedJson(stateCookie, resolvedDeveloperSessionSecret);
|
|
2603
|
+
if (!state || !statePayload?.nonce || !safeEqual(String(statePayload.nonce), state) || !statePayload.createdAt) {
|
|
2604
|
+
sendText(res, 400, "Invalid GitHub sign-in state.\n", {
|
|
2605
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
2606
|
+
"Set-Cookie": clearOauthStateCookie(secure)
|
|
2607
|
+
});
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
if (Date.parse(statePayload.createdAt) + 10 * 60 * 1000 <= Date.now()) {
|
|
2612
|
+
sendText(res, 400, "Expired GitHub sign-in state.\n", {
|
|
2613
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
2614
|
+
"Set-Cookie": clearOauthStateCookie(secure)
|
|
2615
|
+
});
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
if (requestUrl.searchParams.get("error")) {
|
|
2620
|
+
sendText(res, 401, `${requestUrl.searchParams.get("error_description") ?? "GitHub sign-in failed."}\n`, {
|
|
2621
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
2622
|
+
"Set-Cookie": clearOauthStateCookie(secure)
|
|
2623
|
+
});
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
const code = requestUrl.searchParams.get("code");
|
|
2628
|
+
if (!code) {
|
|
2629
|
+
sendText(res, 400, "Missing GitHub OAuth code.\n", {
|
|
2630
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
2631
|
+
"Set-Cookie": clearOauthStateCookie(secure)
|
|
2632
|
+
});
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
const user = await githubUserFromCode({
|
|
2637
|
+
code,
|
|
2638
|
+
githubOAuth: resolvedGithubOAuth,
|
|
2639
|
+
redirectUri: githubRedirectUri(resolvedGithubOAuth, resolvedPublicRootUrl)
|
|
2640
|
+
});
|
|
2641
|
+
redirect(res, normalizeReturnTo(statePayload.returnTo), {
|
|
2642
|
+
"Set-Cookie": [developerCookie(user, resolvedDeveloperSessionSecret, secure), clearOauthStateCookie(secure)]
|
|
2643
|
+
});
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
if (req.method === "GET" && requestUrl.pathname === "/auth/logout") {
|
|
2648
|
+
redirect(res, "/deploys", {
|
|
2649
|
+
"Set-Cookie": clearDeveloperCookie(isSecureRequest(req))
|
|
2650
|
+
});
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
const claimMatch = req.method === "GET" ? requestUrl.pathname.match(/^\/claim\/([^/]+)\/([^/]+)$/) : null;
|
|
2655
|
+
if (claimMatch) {
|
|
2656
|
+
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
|
|
2657
|
+
sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
const user = currentDeveloper(req);
|
|
2662
|
+
if (!user) {
|
|
2663
|
+
redirect(res, `/auth/github?return_to=${encodeURIComponent(requestUrl.pathname)}`);
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
const deployId = decodeURIComponent(claimMatch[1]);
|
|
2668
|
+
const claimToken = decodeURIComponent(claimMatch[2]);
|
|
2669
|
+
const claim = await resolvedStore.claimDeploy(deployId, claimToken, user);
|
|
2670
|
+
if (claim.status === "missing") {
|
|
2671
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
if (claim.status === "invalid") {
|
|
2675
|
+
sendJson(res, 401, { error: "Invalid claim token." });
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
if (claim.status === "conflict") {
|
|
2679
|
+
sendJson(res, 409, { error: "Deploy is already claimed by another developer." });
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
await resolvedStore.appendLog(claim.deploy.id, "info", "anonymous deploy claimed", {
|
|
2684
|
+
ownerId: user.id,
|
|
2685
|
+
ownerLogin: user.login
|
|
2686
|
+
});
|
|
2687
|
+
redirect(res, `/deploys?claimed=${encodeURIComponent(claim.deploy.id)}`);
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
if (req.method === "GET" && (requestUrl.pathname === "/admin" || requestUrl.pathname === "/admin/")) {
|
|
2692
|
+
sendText(res, 200, adminHtml(), { "Content-Type": "text/html; charset=utf-8" });
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
if (requestUrl.pathname === "/admin/api/login" && req.method === "POST") {
|
|
2697
|
+
if (!isAdminConfigured(adminPassword)) {
|
|
2698
|
+
sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
const body = await readJsonBody(req, 4096);
|
|
2703
|
+
if (!isAdminPasswordValid(body.password, adminPassword)) {
|
|
2704
|
+
sendJson(res, 401, { error: "Invalid admin password." });
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
sendJson(res, 200, { ok: true }, { "Set-Cookie": adminCookie(adminSessionToken(adminPassword), adminCookieMaxAgeSeconds, isSecureRequest(req)) });
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
if (requestUrl.pathname === "/admin/api/logout" && req.method === "POST") {
|
|
2713
|
+
sendJson(res, 200, { ok: true }, { "Set-Cookie": adminCookie("", 0, isSecureRequest(req)) });
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
if (requestUrl.pathname === "/admin/api/summary" && req.method === "GET") {
|
|
2718
|
+
if (!isAdminConfigured(adminPassword)) {
|
|
2719
|
+
sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
if (!isAdminAuthenticated(req, adminPassword)) {
|
|
2724
|
+
sendJson(res, 401, { error: "Admin authentication required." });
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
sendJson(res, 200, await adminSummary());
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const terminateMatch =
|
|
2733
|
+
req.method === "POST"
|
|
2734
|
+
? requestUrl.pathname.match(/^\/admin\/api\/deploys\/([^/]+)\/terminate$/)
|
|
2735
|
+
: null;
|
|
2736
|
+
if (terminateMatch) {
|
|
2737
|
+
if (!isAdminConfigured(adminPassword)) {
|
|
2738
|
+
sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
if (!isAdminAuthenticated(req, adminPassword)) {
|
|
2743
|
+
sendJson(res, 401, { error: "Admin authentication required." });
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
const deployId = decodeURIComponent(terminateMatch[1]);
|
|
2748
|
+
const deploy = await resolvedStore.terminateDeploy(deployId);
|
|
2749
|
+
if (!deploy) {
|
|
2750
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
await resolvedStore.appendLog(deploy.id, "warn", "anonymous deploy terminated", { source: "admin" });
|
|
2755
|
+
await refreshDeploySubscriptions(deploy);
|
|
2756
|
+
closeDeployConnections(deploy.id);
|
|
2757
|
+
sendJson(res, 200, {
|
|
2758
|
+
deploy: adminDeploySummary({
|
|
2759
|
+
deploy,
|
|
2760
|
+
usage: await resolvedStore.readUsage(deploy.id)
|
|
2761
|
+
})
|
|
2762
|
+
});
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
870
2766
|
if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
|
|
871
2767
|
const body = await readJsonBody(req);
|
|
872
2768
|
const payload = validateAnonymousDeployPayload(body);
|
|
873
2769
|
const { deploy, token } = await resolvedStore.createDeploy({
|
|
874
|
-
appBaseDomain,
|
|
2770
|
+
appBaseDomain: resolvedAppBaseDomain,
|
|
875
2771
|
artifact: payload.artifact,
|
|
876
2772
|
artifactHash: payload.artifactHash,
|
|
877
2773
|
clientBundleBase64: payload.clientBundleBase64,
|
|
@@ -884,6 +2780,43 @@ export async function startAnonymousServer({
|
|
|
884
2780
|
return;
|
|
885
2781
|
}
|
|
886
2782
|
|
|
2783
|
+
if ((req.method === "PUT" || req.method === "PATCH") && requestUrl.pathname.startsWith("/v1/deploys/")) {
|
|
2784
|
+
const deployId = requestUrl.pathname.slice("/v1/deploys/".length);
|
|
2785
|
+
const currentDeploy = await resolvedStore.getDeployById(deployId);
|
|
2786
|
+
if (!currentDeploy) {
|
|
2787
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
if (!isDeployTokenValid(currentDeploy, bearerToken(req))) {
|
|
2792
|
+
sendJson(res, 401, { error: "Invalid deploy token." });
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
const body = await readJsonBody(req);
|
|
2797
|
+
const payload = validateAnonymousDeployPayload(body);
|
|
2798
|
+
const deploy = await resolvedStore.updateDeploy({
|
|
2799
|
+
appBaseDomain: resolvedAppBaseDomain,
|
|
2800
|
+
artifact: payload.artifact,
|
|
2801
|
+
artifactHash: payload.artifactHash,
|
|
2802
|
+
clientBundleBase64: payload.clientBundleBase64,
|
|
2803
|
+
clientBundleHash: payload.clientBundleHash,
|
|
2804
|
+
deployId,
|
|
2805
|
+
publicRootUrl: resolvedPublicRootUrl,
|
|
2806
|
+
requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined
|
|
2807
|
+
});
|
|
2808
|
+
if (!deploy) {
|
|
2809
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy updated", { artifactHash: deploy.artifactHash });
|
|
2814
|
+
await refreshDeploySubscriptions(deploy);
|
|
2815
|
+
await publishDeploy(deploy.id);
|
|
2816
|
+
sendJson(res, 200, responseForDeploy({ deploy }));
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
887
2820
|
if (req.method === "GET" && requestUrl.pathname.startsWith("/v1/deploys/")) {
|
|
888
2821
|
const id = requestUrl.pathname.slice("/v1/deploys/".length);
|
|
889
2822
|
const deploy = (await resolvedStore.getDeployById(id)) ?? (await resolvedStore.getDeployBySlug(id));
|
|
@@ -895,7 +2828,7 @@ export async function startAnonymousServer({
|
|
|
895
2828
|
return;
|
|
896
2829
|
}
|
|
897
2830
|
|
|
898
|
-
const loaded = await loadDeployByRoute({ appBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
2831
|
+
const loaded = await loadDeployByRoute({ appBaseDomain: resolvedAppBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
899
2832
|
if (!loaded) {
|
|
900
2833
|
sendText(res, 200, "Lakebed anonymous deploy runner\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
901
2834
|
return;
|
|
@@ -913,8 +2846,8 @@ export async function startAnonymousServer({
|
|
|
913
2846
|
);
|
|
914
2847
|
|
|
915
2848
|
const appPath = routeSystemPath(loaded.route.appPath);
|
|
916
|
-
if (req.method === "GET" && (appPath === "/" || appPath === "/index.html")) {
|
|
917
|
-
sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath), {
|
|
2849
|
+
if (req.method === "GET" && (appPath === "/" || appPath === "/index.html" || appPath === "/auth/callback")) {
|
|
2850
|
+
sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath, { shooBaseUrl }), {
|
|
918
2851
|
"Content-Type": "text/html; charset=utf-8"
|
|
919
2852
|
});
|
|
920
2853
|
return;
|
|
@@ -1030,14 +2963,23 @@ export async function startAnonymousServer({
|
|
|
1030
2963
|
server.on("upgrade", async (req, socket, head) => {
|
|
1031
2964
|
const host = req.headers.host ?? "localhost";
|
|
1032
2965
|
const requestUrl = new URL(req.url ?? "/", `http://${host}`);
|
|
1033
|
-
const loaded = await loadDeployByRoute({ appBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
2966
|
+
const loaded = await loadDeployByRoute({ appBaseDomain: resolvedAppBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
1034
2967
|
|
|
1035
2968
|
if (!loaded || loaded.error || routeSystemPath(loaded.route.appPath) !== "/__lakebed/ws") {
|
|
1036
2969
|
socket.destroy();
|
|
1037
2970
|
return;
|
|
1038
2971
|
}
|
|
1039
2972
|
|
|
1040
|
-
const auth =
|
|
2973
|
+
const auth = await resolveAuthFromUrl({
|
|
2974
|
+
defaultAuth: createGuestAuth("local"),
|
|
2975
|
+
onError: (error) =>
|
|
2976
|
+
resolvedStore.appendLog(loaded.deploy.id, "warn", "google auth verification failed", {
|
|
2977
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2978
|
+
}),
|
|
2979
|
+
origin: requestOrigin(req, loaded.deploy.url),
|
|
2980
|
+
shooBaseUrl,
|
|
2981
|
+
url: requestUrl
|
|
2982
|
+
});
|
|
1041
2983
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1042
2984
|
wss.emit("connection", ws, req, loaded, auth);
|
|
1043
2985
|
});
|
|
@@ -1056,6 +2998,7 @@ export async function startAnonymousServer({
|
|
|
1056
2998
|
}
|
|
1057
2999
|
|
|
1058
3000
|
return {
|
|
3001
|
+
appBaseDomain: resolvedAppBaseDomain,
|
|
1059
3002
|
port,
|
|
1060
3003
|
publicRootUrl: resolvedPublicRootUrl,
|
|
1061
3004
|
store: resolvedStore,
|