lakebed 0.0.3-alpha.0 → 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 +56 -2
- package/package.json +6 -5
- package/src/anonymous-server.js +1874 -58
- package/src/anonymous.js +14 -5
- package/src/auth.js +155 -0
- package/src/cli.js +40 -42
- 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
|
}
|
|
@@ -127,6 +107,17 @@ function normalizePublicRootUrl(value, port) {
|
|
|
127
107
|
return String(value || fallback).replace(/\/+$/g, "");
|
|
128
108
|
}
|
|
129
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
|
+
|
|
130
121
|
function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
|
|
131
122
|
if (appBaseDomain) {
|
|
132
123
|
return `https://${slug}.${appBaseDomain}`;
|
|
@@ -150,6 +141,8 @@ function inspectUrls(url) {
|
|
|
150
141
|
|
|
151
142
|
function responseForDeploy({ deploy, token }) {
|
|
152
143
|
return {
|
|
144
|
+
claimed: Boolean(deploy.ownerId),
|
|
145
|
+
claimedAt: deploy.claimedAt ?? undefined,
|
|
153
146
|
claimUrl: token ? claimUrlForDeploy({ deployId: deploy.id, publicRootUrl: deploy.publicRootUrl, token }) : undefined,
|
|
154
147
|
deployId: deploy.id,
|
|
155
148
|
expiresAt: deploy.expiresAt,
|
|
@@ -206,19 +199,1230 @@ function parseHostDeploy({ appBaseDomain, host, url }) {
|
|
|
206
199
|
return null;
|
|
207
200
|
}
|
|
208
201
|
|
|
209
|
-
return {
|
|
210
|
-
appPath: url.pathname || "/",
|
|
211
|
-
basePath: "",
|
|
212
|
-
slug
|
|
213
|
-
};
|
|
214
|
-
}
|
|
202
|
+
return {
|
|
203
|
+
appPath: url.pathname || "/",
|
|
204
|
+
basePath: "",
|
|
205
|
+
slug
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function quotaLimitForBucket(bucket, deploy) {
|
|
210
|
+
if (bucket === "mutations") {
|
|
211
|
+
return deploy.limits.mutationsPerDay;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return deploy.limits.requestsPerDay;
|
|
215
|
+
}
|
|
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
|
+
}
|
|
215
1365
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
1366
|
+
header {
|
|
1367
|
+
align-items: flex-start;
|
|
1368
|
+
flex-direction: column;
|
|
1369
|
+
}
|
|
220
1370
|
|
|
221
|
-
|
|
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>`;
|
|
222
1426
|
}
|
|
223
1427
|
|
|
224
1428
|
export class MemoryAnonymousStore {
|
|
@@ -249,6 +1453,7 @@ export class MemoryAnonymousStore {
|
|
|
249
1453
|
|
|
250
1454
|
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
|
|
251
1455
|
const deployId = createDeployId();
|
|
1456
|
+
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
252
1457
|
let slug = createSlug();
|
|
253
1458
|
while (this.deploysBySlug.has(slug)) {
|
|
254
1459
|
slug = createSlug();
|
|
@@ -258,7 +1463,7 @@ export class MemoryAnonymousStore {
|
|
|
258
1463
|
const createdAt = now();
|
|
259
1464
|
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
260
1465
|
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
261
|
-
const url = appUrlForSlug({ appBaseDomain, publicRootUrl, slug });
|
|
1466
|
+
const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
|
|
262
1467
|
|
|
263
1468
|
this.storeArtifact({
|
|
264
1469
|
artifact,
|
|
@@ -269,14 +1474,17 @@ export class MemoryAnonymousStore {
|
|
|
269
1474
|
});
|
|
270
1475
|
|
|
271
1476
|
const deploy = {
|
|
272
|
-
appBaseDomain,
|
|
1477
|
+
appBaseDomain: normalizedAppBaseDomain,
|
|
273
1478
|
artifactHash,
|
|
1479
|
+
claimedAt: null,
|
|
274
1480
|
claimTokenHash: hashClaimToken(token),
|
|
275
1481
|
clientBundleHash,
|
|
276
1482
|
createdAt,
|
|
277
1483
|
expiresAt,
|
|
278
1484
|
id: deployId,
|
|
279
1485
|
limits: { ...DEFAULT_ANONYMOUS_LIMITS },
|
|
1486
|
+
owner: null,
|
|
1487
|
+
ownerId: null,
|
|
280
1488
|
publicRootUrl,
|
|
281
1489
|
slug,
|
|
282
1490
|
status: "active",
|
|
@@ -287,7 +1495,16 @@ export class MemoryAnonymousStore {
|
|
|
287
1495
|
return { deploy, token };
|
|
288
1496
|
}
|
|
289
1497
|
|
|
290
|
-
async updateDeploy({
|
|
1498
|
+
async updateDeploy({
|
|
1499
|
+
appBaseDomain,
|
|
1500
|
+
artifact,
|
|
1501
|
+
artifactHash,
|
|
1502
|
+
clientBundleBase64,
|
|
1503
|
+
clientBundleHash,
|
|
1504
|
+
deployId,
|
|
1505
|
+
publicRootUrl,
|
|
1506
|
+
requestedTtlSeconds
|
|
1507
|
+
}) {
|
|
291
1508
|
const currentDeploy = await this.getDeployById(deployId);
|
|
292
1509
|
if (!currentDeploy) {
|
|
293
1510
|
return null;
|
|
@@ -295,11 +1512,16 @@ export class MemoryAnonymousStore {
|
|
|
295
1512
|
|
|
296
1513
|
const createdAt = now();
|
|
297
1514
|
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
1515
|
+
const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
|
|
1516
|
+
const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
|
|
298
1517
|
const deploy = {
|
|
299
1518
|
...currentDeploy,
|
|
1519
|
+
appBaseDomain: nextAppBaseDomain,
|
|
300
1520
|
artifactHash,
|
|
301
1521
|
clientBundleHash,
|
|
302
1522
|
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
|
1523
|
+
publicRootUrl: nextPublicRootUrl,
|
|
1524
|
+
url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
|
|
303
1525
|
status: "active"
|
|
304
1526
|
};
|
|
305
1527
|
|
|
@@ -314,6 +1536,44 @@ export class MemoryAnonymousStore {
|
|
|
314
1536
|
return deploy;
|
|
315
1537
|
}
|
|
316
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
|
+
|
|
317
1577
|
async getDeployById(id) {
|
|
318
1578
|
return this.deploys.get(id) ?? null;
|
|
319
1579
|
}
|
|
@@ -429,13 +1689,82 @@ export class MemoryAnonymousStore {
|
|
|
429
1689
|
async readUsage(deployId) {
|
|
430
1690
|
const usage = [];
|
|
431
1691
|
for (const [key, count] of this.quotaEvents) {
|
|
432
|
-
const [eventDeployId, bucket,
|
|
1692
|
+
const [eventDeployId, bucket, ...windowParts] = key.split(":");
|
|
1693
|
+
const windowStart = windowParts.join(":");
|
|
433
1694
|
if (eventDeployId === deployId) {
|
|
434
1695
|
usage.push({ bucket, count, windowStart });
|
|
435
1696
|
}
|
|
436
1697
|
}
|
|
437
1698
|
return usage;
|
|
438
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
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
return summaries;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
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
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
return summaries;
|
|
1767
|
+
}
|
|
439
1768
|
}
|
|
440
1769
|
|
|
441
1770
|
export class PostgresAnonymousStore {
|
|
@@ -459,6 +1788,7 @@ export class PostgresAnonymousStore {
|
|
|
459
1788
|
expires_at timestamptz not null,
|
|
460
1789
|
claimed_at timestamptz,
|
|
461
1790
|
owner_id text,
|
|
1791
|
+
owner_json jsonb,
|
|
462
1792
|
claim_token_hash text not null,
|
|
463
1793
|
limits_json jsonb not null,
|
|
464
1794
|
counters_json jsonb not null default '{}',
|
|
@@ -467,6 +1797,9 @@ export class PostgresAnonymousStore {
|
|
|
467
1797
|
url text not null
|
|
468
1798
|
)
|
|
469
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");
|
|
470
1803
|
await this.query(`
|
|
471
1804
|
create table if not exists artifacts(
|
|
472
1805
|
hash text primary key,
|
|
@@ -531,6 +1864,7 @@ export class PostgresAnonymousStore {
|
|
|
531
1864
|
|
|
532
1865
|
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
|
|
533
1866
|
const createdAt = now();
|
|
1867
|
+
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
534
1868
|
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
535
1869
|
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
536
1870
|
const token = createClaimToken();
|
|
@@ -540,16 +1874,19 @@ export class PostgresAnonymousStore {
|
|
|
540
1874
|
|
|
541
1875
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
542
1876
|
const slug = createSlug();
|
|
543
|
-
const url = appUrlForSlug({ appBaseDomain, publicRootUrl, slug });
|
|
1877
|
+
const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
|
|
544
1878
|
const deploy = {
|
|
545
|
-
appBaseDomain,
|
|
1879
|
+
appBaseDomain: normalizedAppBaseDomain,
|
|
546
1880
|
artifactHash,
|
|
1881
|
+
claimedAt: null,
|
|
547
1882
|
claimTokenHash: hashClaimToken(token),
|
|
548
1883
|
clientBundleHash,
|
|
549
1884
|
createdAt,
|
|
550
1885
|
expiresAt,
|
|
551
1886
|
id: deployId,
|
|
552
1887
|
limits: { ...DEFAULT_ANONYMOUS_LIMITS },
|
|
1888
|
+
owner: null,
|
|
1889
|
+
ownerId: null,
|
|
553
1890
|
publicRootUrl,
|
|
554
1891
|
slug,
|
|
555
1892
|
status: "active",
|
|
@@ -591,7 +1928,16 @@ export class PostgresAnonymousStore {
|
|
|
591
1928
|
throw new Error("Unable to allocate anonymous deploy slug.");
|
|
592
1929
|
}
|
|
593
1930
|
|
|
594
|
-
async updateDeploy({
|
|
1931
|
+
async updateDeploy({
|
|
1932
|
+
appBaseDomain,
|
|
1933
|
+
artifact,
|
|
1934
|
+
artifactHash,
|
|
1935
|
+
clientBundleBase64,
|
|
1936
|
+
clientBundleHash,
|
|
1937
|
+
deployId,
|
|
1938
|
+
publicRootUrl,
|
|
1939
|
+
requestedTtlSeconds
|
|
1940
|
+
}) {
|
|
595
1941
|
const currentDeploy = await this.getDeployById(deployId);
|
|
596
1942
|
if (!currentDeploy) {
|
|
597
1943
|
return null;
|
|
@@ -600,16 +1946,67 @@ export class PostgresAnonymousStore {
|
|
|
600
1946
|
const createdAt = now();
|
|
601
1947
|
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
602
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 });
|
|
603
1952
|
await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt });
|
|
604
1953
|
|
|
605
1954
|
const result = await this.query(
|
|
606
1955
|
`
|
|
607
1956
|
update deploys
|
|
608
|
-
set status = 'active',
|
|
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'
|
|
609
2006
|
where id = $1
|
|
610
2007
|
returning *
|
|
611
2008
|
`,
|
|
612
|
-
[deployId
|
|
2009
|
+
[deployId]
|
|
613
2010
|
);
|
|
614
2011
|
return this.rowToDeploy(result.rows[0]);
|
|
615
2012
|
}
|
|
@@ -622,12 +2019,15 @@ export class PostgresAnonymousStore {
|
|
|
622
2019
|
return {
|
|
623
2020
|
appBaseDomain: row.app_base_domain,
|
|
624
2021
|
artifactHash: row.artifact_hash,
|
|
2022
|
+
claimedAt: row.claimed_at ? new Date(row.claimed_at).toISOString() : null,
|
|
625
2023
|
claimTokenHash: row.claim_token_hash,
|
|
626
2024
|
clientBundleHash: row.client_bundle_hash,
|
|
627
2025
|
createdAt: new Date(row.created_at).toISOString(),
|
|
628
2026
|
expiresAt: new Date(row.expires_at).toISOString(),
|
|
629
2027
|
id: row.id,
|
|
630
2028
|
limits: row.limits_json,
|
|
2029
|
+
owner: row.owner_json ?? null,
|
|
2030
|
+
ownerId: row.owner_id ?? null,
|
|
631
2031
|
publicRootUrl: row.public_root_url,
|
|
632
2032
|
slug: row.slug,
|
|
633
2033
|
status: row.status,
|
|
@@ -804,6 +2204,99 @@ export class PostgresAnonymousStore {
|
|
|
804
2204
|
windowStart: new Date(row.window_start).toISOString()
|
|
805
2205
|
}));
|
|
806
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
|
+
}
|
|
807
2300
|
}
|
|
808
2301
|
|
|
809
2302
|
export async function createAnonymousStoreFromEnv(env = process.env) {
|
|
@@ -901,17 +2394,78 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
|
|
|
901
2394
|
}
|
|
902
2395
|
|
|
903
2396
|
export async function startAnonymousServer({
|
|
2397
|
+
adminPassword = adminPasswordFromEnv(),
|
|
904
2398
|
appBaseDomain = process.env.LAKEBED_APP_BASE_DOMAIN ?? "",
|
|
2399
|
+
developerSessionSecret = process.env.LAKEBED_SESSION_SECRET ?? "",
|
|
2400
|
+
githubOAuth = githubOAuthFromEnv(),
|
|
905
2401
|
port = Number(process.env.PORT ?? 8787),
|
|
906
2402
|
publicRootUrl,
|
|
907
2403
|
quiet = false,
|
|
2404
|
+
shooBaseUrl = shooBaseUrlFromEnv(),
|
|
908
2405
|
store
|
|
909
2406
|
} = {}) {
|
|
2407
|
+
const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
910
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 || "";
|
|
911
2412
|
const resolvedStore = store ?? (await createAnonymousStoreFromEnv());
|
|
912
2413
|
await resolvedStore.initialize();
|
|
913
2414
|
const subscriptions = new Map();
|
|
914
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
|
+
|
|
915
2469
|
async function refreshDeploySubscriptions(deploy) {
|
|
916
2470
|
const storedArtifact = await resolvedStore.getArtifact(deploy.artifactHash);
|
|
917
2471
|
if (!storedArtifact) {
|
|
@@ -926,6 +2480,18 @@ export async function startAnonymousServer({
|
|
|
926
2480
|
}
|
|
927
2481
|
}
|
|
928
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
|
+
|
|
929
2495
|
async function publishDeploy(deployId) {
|
|
930
2496
|
for (const [ws, subscription] of subscriptions) {
|
|
931
2497
|
if (subscription.deploy.id !== deployId) {
|
|
@@ -959,11 +2525,249 @@ export async function startAnonymousServer({
|
|
|
959
2525
|
return;
|
|
960
2526
|
}
|
|
961
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
|
+
|
|
962
2766
|
if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
|
|
963
2767
|
const body = await readJsonBody(req);
|
|
964
2768
|
const payload = validateAnonymousDeployPayload(body);
|
|
965
2769
|
const { deploy, token } = await resolvedStore.createDeploy({
|
|
966
|
-
appBaseDomain,
|
|
2770
|
+
appBaseDomain: resolvedAppBaseDomain,
|
|
967
2771
|
artifact: payload.artifact,
|
|
968
2772
|
artifactHash: payload.artifactHash,
|
|
969
2773
|
clientBundleBase64: payload.clientBundleBase64,
|
|
@@ -992,11 +2796,13 @@ export async function startAnonymousServer({
|
|
|
992
2796
|
const body = await readJsonBody(req);
|
|
993
2797
|
const payload = validateAnonymousDeployPayload(body);
|
|
994
2798
|
const deploy = await resolvedStore.updateDeploy({
|
|
2799
|
+
appBaseDomain: resolvedAppBaseDomain,
|
|
995
2800
|
artifact: payload.artifact,
|
|
996
2801
|
artifactHash: payload.artifactHash,
|
|
997
2802
|
clientBundleBase64: payload.clientBundleBase64,
|
|
998
2803
|
clientBundleHash: payload.clientBundleHash,
|
|
999
2804
|
deployId,
|
|
2805
|
+
publicRootUrl: resolvedPublicRootUrl,
|
|
1000
2806
|
requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined
|
|
1001
2807
|
});
|
|
1002
2808
|
if (!deploy) {
|
|
@@ -1022,7 +2828,7 @@ export async function startAnonymousServer({
|
|
|
1022
2828
|
return;
|
|
1023
2829
|
}
|
|
1024
2830
|
|
|
1025
|
-
const loaded = await loadDeployByRoute({ appBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
2831
|
+
const loaded = await loadDeployByRoute({ appBaseDomain: resolvedAppBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
1026
2832
|
if (!loaded) {
|
|
1027
2833
|
sendText(res, 200, "Lakebed anonymous deploy runner\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
1028
2834
|
return;
|
|
@@ -1040,8 +2846,8 @@ export async function startAnonymousServer({
|
|
|
1040
2846
|
);
|
|
1041
2847
|
|
|
1042
2848
|
const appPath = routeSystemPath(loaded.route.appPath);
|
|
1043
|
-
if (req.method === "GET" && (appPath === "/" || appPath === "/index.html")) {
|
|
1044
|
-
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 }), {
|
|
1045
2851
|
"Content-Type": "text/html; charset=utf-8"
|
|
1046
2852
|
});
|
|
1047
2853
|
return;
|
|
@@ -1157,14 +2963,23 @@ export async function startAnonymousServer({
|
|
|
1157
2963
|
server.on("upgrade", async (req, socket, head) => {
|
|
1158
2964
|
const host = req.headers.host ?? "localhost";
|
|
1159
2965
|
const requestUrl = new URL(req.url ?? "/", `http://${host}`);
|
|
1160
|
-
const loaded = await loadDeployByRoute({ appBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
2966
|
+
const loaded = await loadDeployByRoute({ appBaseDomain: resolvedAppBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
1161
2967
|
|
|
1162
2968
|
if (!loaded || loaded.error || routeSystemPath(loaded.route.appPath) !== "/__lakebed/ws") {
|
|
1163
2969
|
socket.destroy();
|
|
1164
2970
|
return;
|
|
1165
2971
|
}
|
|
1166
2972
|
|
|
1167
|
-
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
|
+
});
|
|
1168
2983
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1169
2984
|
wss.emit("connection", ws, req, loaded, auth);
|
|
1170
2985
|
});
|
|
@@ -1183,6 +2998,7 @@ export async function startAnonymousServer({
|
|
|
1183
2998
|
}
|
|
1184
2999
|
|
|
1185
3000
|
return {
|
|
3001
|
+
appBaseDomain: resolvedAppBaseDomain,
|
|
1186
3002
|
port,
|
|
1187
3003
|
publicRootUrl: resolvedPublicRootUrl,
|
|
1188
3004
|
store: resolvedStore,
|