lakebed 0.0.2 → 0.0.3

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