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.
@@ -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 toGuestName(name) {
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, "&amp;")
1182
+ .replace(/</g, "&lt;")
1183
+ .replace(/>/g, "&gt;")
1184
+ .replace(/"/g, "&quot;");
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
- function quotaLimitForBucket(bucket, deploy) {
217
- if (bucket === "mutations") {
218
- return deploy.limits.mutationsPerDay;
219
- }
1366
+ header {
1367
+ align-items: flex-start;
1368
+ flex-direction: column;
1369
+ }
220
1370
 
221
- return deploy.limits.requestsPerDay;
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({ artifact, artifactHash, clientBundleBase64, clientBundleHash, deployId, requestedTtlSeconds }) {
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, windowStart] = key.split(":");
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({ artifact, artifactHash, clientBundleBase64, clientBundleHash, deployId, requestedTtlSeconds }) {
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', artifact_hash = $2, client_bundle_hash = $3, expires_at = $4
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, artifactHash, clientBundleHash, expiresAt]
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 = authFromUrl(requestUrl);
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,