lakebed 0.0.15 → 0.0.16

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,5 +1,7 @@
1
1
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
+ import { isIP } from "node:net";
4
+ import { domainToASCII } from "node:url";
3
5
  import {
4
6
  createClaimToken,
5
7
  createDeployId,
@@ -165,6 +167,106 @@ function normalizeAppBaseDomain(value) {
165
167
  .toLowerCase();
166
168
  }
167
169
 
170
+ const reservedLakebedSubdomainLabels = new Set([
171
+ "admin",
172
+ "api",
173
+ "app",
174
+ "assets",
175
+ "auth",
176
+ "billing",
177
+ "cdn",
178
+ "cname",
179
+ "console",
180
+ "dashboard",
181
+ "docs",
182
+ "ftp",
183
+ "imap",
184
+ "login",
185
+ "mail",
186
+ "ns1",
187
+ "ns2",
188
+ "origin",
189
+ "pop",
190
+ "proxy-fallback",
191
+ "smtp",
192
+ "static",
193
+ "status",
194
+ "support",
195
+ "www"
196
+ ]);
197
+
198
+ function normalizeHostname(value) {
199
+ const trimmed = String(value ?? "")
200
+ .trim()
201
+ .replace(/\.$/, "")
202
+ .toLowerCase();
203
+ if (!trimmed) {
204
+ return "";
205
+ }
206
+
207
+ return domainToASCII(trimmed).toLowerCase();
208
+ }
209
+
210
+ function hostnameFromHost(host) {
211
+ const value = String(host ?? "").trim();
212
+ if (value.startsWith("[")) {
213
+ const end = value.indexOf("]");
214
+ return normalizeHostname(end === -1 ? value : value.slice(1, end));
215
+ }
216
+
217
+ return normalizeHostname(value.split(":")[0]);
218
+ }
219
+
220
+ function validateLakebedSubdomainLabel(label) {
221
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) {
222
+ throw new Error("Lakebed subdomains must use one DNS label with letters, numbers, or hyphens.");
223
+ }
224
+
225
+ if (reservedLakebedSubdomainLabels.has(label)) {
226
+ throw new Error(`The subdomain ${label} is reserved for Lakebed.`);
227
+ }
228
+ }
229
+
230
+ function normalizeLakebedSubdomainInput(value, appBaseDomain) {
231
+ const baseDomain = normalizeHostname(appBaseDomain);
232
+ if (!baseDomain) {
233
+ throw new Error("Lakebed app subdomains are not configured on this runner.");
234
+ }
235
+
236
+ const raw = String(value ?? "").trim();
237
+ if (!raw) {
238
+ throw new Error("Subdomain is required.");
239
+ }
240
+
241
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.includes("/") || raw.includes("\\") || raw.includes(":")) {
242
+ throw new Error("Enter a subdomain like my-app.lakebed.app, without a scheme, port, or path.");
243
+ }
244
+
245
+ const hostname = normalizeHostname(raw);
246
+ if (!hostname) {
247
+ throw new Error("Subdomain is not a valid hostname.");
248
+ }
249
+
250
+ const suffix = `.${baseDomain}`;
251
+ let label;
252
+ if (hostname.endsWith(suffix)) {
253
+ label = hostname.slice(0, -suffix.length);
254
+ if (!label || label.includes(".")) {
255
+ throw new Error(`Lakebed subdomains must be exactly one label under ${baseDomain}.`);
256
+ }
257
+ } else if (!hostname.includes(".")) {
258
+ label = hostname;
259
+ } else {
260
+ throw new Error(`Lakebed subdomains must end with ${baseDomain}.`);
261
+ }
262
+
263
+ validateLakebedSubdomainLabel(label);
264
+ return {
265
+ hostname: `${label}.${baseDomain}`,
266
+ label
267
+ };
268
+ }
269
+
168
270
  function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
169
271
  if (appBaseDomain) {
170
272
  return `https://${slug}.${appBaseDomain}`;
@@ -200,6 +302,19 @@ function responseForDeploy({ deploy, token }) {
200
302
  };
201
303
  }
202
304
 
305
+ function responseForDeployDomain(domain) {
306
+ return {
307
+ createdAt: domain.createdAt,
308
+ deployId: domain.deployId,
309
+ hostname: domain.hostname,
310
+ kind: domain.kind,
311
+ primary: Boolean(domain.isPrimary),
312
+ status: domain.status,
313
+ updatedAt: domain.updatedAt,
314
+ url: domain.url
315
+ };
316
+ }
317
+
203
318
  function isExpired(deploy) {
204
319
  if (deploy?.ownerId) {
205
320
  return false;
@@ -240,14 +355,14 @@ function parseHostDeploy({ appBaseDomain, host, url }) {
240
355
  return null;
241
356
  }
242
357
 
243
- const hostname = host.split(":")[0].toLowerCase();
358
+ const hostname = hostnameFromHost(host);
244
359
  const suffix = `.${appBaseDomain.toLowerCase()}`;
245
360
  if (!hostname.endsWith(suffix)) {
246
361
  return null;
247
362
  }
248
363
 
249
364
  const slug = hostname.slice(0, -suffix.length);
250
- if (!slug || slug === "www") {
365
+ if (!slug || slug.includes(".") || reservedLakebedSubdomainLabels.has(slug)) {
251
366
  return null;
252
367
  }
253
368
 
@@ -266,6 +381,138 @@ function quotaLimitForBucket(bucket, deploy) {
266
381
  return deploy.limits.requestsPerDay;
267
382
  }
268
383
 
384
+ function clientTrafficQuotaBucket(bucket) {
385
+ return bucket === "mutations" ? "anonymous_client_mutations" : "anonymous_client_requests";
386
+ }
387
+
388
+ function clientTrafficQuotaSuggestion(bucket) {
389
+ return bucket === "mutations"
390
+ ? "Retry after reset or reduce mutation frequency from this client."
391
+ : "Retry after reset or reduce request frequency from this client.";
392
+ }
393
+
394
+ function anonymousDeployCreationPolicy({ env = process.env, publicRootUrl }) {
395
+ const production = !isLocalPublicRootUrl(publicRootUrl);
396
+ const disabled =
397
+ booleanFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_DISABLED) ||
398
+ booleanFromEnv(env.LAKEBED_ANONYMOUS_DEPLOYS_DISABLED);
399
+ const defaultPerClient = production ? 50 : Number.POSITIVE_INFINITY;
400
+ const defaultGlobal = production ? 5000 : Number.POSITIVE_INFINITY;
401
+ return {
402
+ disabled,
403
+ globalLimit: positiveIntegerLimitFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_GLOBAL_PER_DAY, defaultGlobal),
404
+ perClientLimit: positiveIntegerLimitFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_PER_CLIENT_PER_DAY, defaultPerClient),
405
+ production
406
+ };
407
+ }
408
+
409
+ function anonymousClientTrafficPolicy({ env = process.env, publicRootUrl }) {
410
+ const production = !isLocalPublicRootUrl(publicRootUrl);
411
+ return {
412
+ mutationsPerClientLimit: positiveIntegerLimitFromEnv(
413
+ env.LAKEBED_ANONYMOUS_MUTATIONS_PER_CLIENT_PER_DAY,
414
+ production ? 10000 : Number.POSITIVE_INFINITY
415
+ ),
416
+ requestsPerClientLimit: positiveIntegerLimitFromEnv(
417
+ env.LAKEBED_ANONYMOUS_REQUESTS_PER_CLIENT_PER_DAY,
418
+ production ? 100000 : Number.POSITIVE_INFINITY
419
+ )
420
+ };
421
+ }
422
+
423
+ function cleanupPolicyFromEnv(env = process.env) {
424
+ return {
425
+ graceSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_GRACE, 60 * 60, { min: 0 }),
426
+ intervalSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_INTERVAL, 60 * 60, { min: 60 }),
427
+ retentionSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_RETENTION, 7 * 24 * 60 * 60, { min: 0 })
428
+ };
429
+ }
430
+
431
+ function normalizePeerAddress(value) {
432
+ let address = String(value ?? "").trim();
433
+ if (!address) {
434
+ return "unknown";
435
+ }
436
+ if (address.startsWith("::ffff:")) {
437
+ address = address.slice("::ffff:".length);
438
+ }
439
+ if (address.startsWith("[") && address.includes("]")) {
440
+ address = address.slice(1, address.indexOf("]"));
441
+ }
442
+ if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(address)) {
443
+ address = address.slice(0, address.lastIndexOf(":"));
444
+ }
445
+ return address;
446
+ }
447
+
448
+ function ipv4ToNumber(address) {
449
+ const parts = address.split(".").map((part) => Number(part));
450
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
451
+ return null;
452
+ }
453
+ return parts.reduce((acc, part) => (acc << 8) + part, 0) >>> 0;
454
+ }
455
+
456
+ function ipv4InCidr(address, cidr) {
457
+ const [base, rawBits] = cidr.split("/");
458
+ const bits = Number(rawBits);
459
+ const value = ipv4ToNumber(address);
460
+ const baseValue = ipv4ToNumber(base);
461
+ if (value === null || baseValue === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
462
+ return false;
463
+ }
464
+
465
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
466
+ return (value & mask) === (baseValue & mask);
467
+ }
468
+
469
+ function hasRailwayRuntimeEnv(env = process.env) {
470
+ return Boolean(env.RAILWAY_SERVICE_ID || env.RAILWAY_PROJECT_ID || env.RAILWAY_DEPLOYMENT_ID || env.RAILWAY_REPLICA_ID);
471
+ }
472
+
473
+ function isRailwayProxyPeer(req, env = process.env) {
474
+ const edge = String(req.headers["x-railway-edge"] ?? "");
475
+ if (!hasRailwayRuntimeEnv(env) || !/^railway\/[a-z0-9][a-z0-9-]*$/i.test(edge)) {
476
+ return false;
477
+ }
478
+
479
+ // Railway's proxy guidance treats 100.0.0.0/8 as the trusted internal proxy range.
480
+ const address = normalizePeerAddress(req.socket.remoteAddress);
481
+ return isIP(address) === 4 && ipv4InCidr(address, "100.0.0.0/8");
482
+ }
483
+
484
+ function shouldTrustProxyHeaders(req, env = process.env) {
485
+ if (booleanFromEnv(env.LAKEBED_TRUST_PROXY_HEADERS)) {
486
+ return true;
487
+ }
488
+
489
+ return isRailwayProxyPeer(req, env);
490
+ }
491
+
492
+ function firstHeaderValue(req, names) {
493
+ for (const name of names) {
494
+ const raw = req.headers[name];
495
+ const candidate = String(Array.isArray(raw) ? raw[0] : (raw ?? ""))
496
+ .split(",")[0]
497
+ .trim();
498
+ if (candidate) {
499
+ return normalizePeerAddress(candidate).slice(0, 256);
500
+ }
501
+ }
502
+ return "";
503
+ }
504
+
505
+ function forwardedClientKey(req, env = process.env) {
506
+ if (shouldTrustProxyHeaders(req, env)) {
507
+ const forwarded = firstHeaderValue(req, ["x-real-ip", "cf-connecting-ip", "fly-client-ip", "x-forwarded-for"]);
508
+ if (forwarded) {
509
+ return forwarded;
510
+ }
511
+ }
512
+
513
+ return normalizePeerAddress(req.socket.remoteAddress).slice(0, 256);
514
+ }
515
+
269
516
  const USER_LIMIT_OVERRIDE_KEYS = ["requestsPerDay", "mutationsPerDay"];
270
517
  const USER_BOOST_MULTIPLIER = 20;
271
518
 
@@ -665,6 +912,193 @@ function bytesOfJson(value) {
665
912
  return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
666
913
  }
667
914
 
915
+ function durationSecondsFromEnv(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
916
+ if (value === undefined || value === null || value === "") {
917
+ return fallback;
918
+ }
919
+
920
+ const match = String(value).trim().match(/^(\d+)([smhd])?$/);
921
+ if (!match) {
922
+ throw new Error(`Invalid duration: ${value}. Use a value like 15m, 1h, 7d, or 604800.`);
923
+ }
924
+
925
+ const multipliers = { d: 86400, h: 3600, m: 60, s: 1 };
926
+ const seconds = Number(match[1]) * multipliers[match[2] ?? "s"];
927
+ return Math.max(min, Math.min(max, seconds));
928
+ }
929
+
930
+ function positiveIntegerLimitFromEnv(value, fallback) {
931
+ if (value === undefined || value === null || value === "") {
932
+ return fallback;
933
+ }
934
+
935
+ const parsed = Number(value);
936
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
937
+ throw new Error(`Invalid limit: ${value}. Use a non-negative integer.`);
938
+ }
939
+ return parsed;
940
+ }
941
+
942
+ function isLocalPublicRootUrl(publicRootUrl) {
943
+ try {
944
+ const hostname = new URL(publicRootUrl).hostname.toLowerCase();
945
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
946
+ } catch {
947
+ return true;
948
+ }
949
+ }
950
+
951
+ function booleanFromEnv(value) {
952
+ return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
953
+ }
954
+
955
+ function quotaResetAt(windowStart) {
956
+ return new Date(Date.parse(windowStart) + 24 * 60 * 60 * 1000).toISOString();
957
+ }
958
+
959
+ function quotaRetryAfterSeconds(windowStart) {
960
+ return Math.max(1, Math.ceil((Date.parse(quotaResetAt(windowStart)) - Date.now()) / 1000));
961
+ }
962
+
963
+ export class LakebedQuotaError extends Error {
964
+ constructor({ bucket, count, limit, resetAt, retryAfterSeconds, status = 429, suggestion }) {
965
+ super(`${bucket} quota exceeded. Limit: ${limit}.`);
966
+ this.name = "LakebedQuotaError";
967
+ this.bucket = bucket;
968
+ this.code = "lakebed_quota_exceeded";
969
+ this.count = count;
970
+ this.limit = limit;
971
+ this.resetAt = resetAt ?? null;
972
+ this.retryAfterSeconds = retryAfterSeconds ?? null;
973
+ this.status = status;
974
+ this.suggestion = suggestion;
975
+ }
976
+ }
977
+
978
+ function isQuotaError(error) {
979
+ return error instanceof LakebedQuotaError || error?.code === "lakebed_quota_exceeded";
980
+ }
981
+
982
+ function quotaErrorBody(error, extra = {}) {
983
+ return {
984
+ bucket: error.bucket,
985
+ code: error.code ?? "lakebed_quota_exceeded",
986
+ current: error.count,
987
+ error: error.message,
988
+ limit: error.limit,
989
+ resetAt: error.resetAt ?? null,
990
+ retryAfterSeconds: error.retryAfterSeconds ?? null,
991
+ suggestion: error.suggestion,
992
+ ...extra
993
+ };
994
+ }
995
+
996
+ function quotaErrorHeaders(error) {
997
+ const headers = {};
998
+ if (Number.isFinite(error.retryAfterSeconds)) {
999
+ headers["Retry-After"] = String(error.retryAfterSeconds);
1000
+ }
1001
+ if (Number.isFinite(error.limit)) {
1002
+ headers["X-RateLimit-Limit"] = String(error.limit);
1003
+ }
1004
+ if (Number.isFinite(error.count) && Number.isFinite(error.limit)) {
1005
+ headers["X-RateLimit-Remaining"] = String(Math.max(0, error.limit - error.count));
1006
+ }
1007
+ if (error.resetAt) {
1008
+ headers["X-RateLimit-Reset"] = error.resetAt;
1009
+ }
1010
+ return headers;
1011
+ }
1012
+
1013
+ function stateRowsLimitFromTransactionOptions(options = {}) {
1014
+ const explicit = options.stateRowsLimit;
1015
+ if (Number.isSafeInteger(explicit) && explicit > 0) {
1016
+ return explicit;
1017
+ }
1018
+ const stateBytes = options.stateBytesLimit ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes;
1019
+ return Math.max(1, Math.floor(stateBytes / 64));
1020
+ }
1021
+
1022
+ function assertStateResourceLimits({ stateBytes, stateRows }, options = {}) {
1023
+ const stateBytesLimit = options.stateBytesLimit ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes;
1024
+ const stateRowsLimit = stateRowsLimitFromTransactionOptions(options);
1025
+ if (Number.isFinite(stateRowsLimit) && stateRows > stateRowsLimit) {
1026
+ throw new LakebedQuotaError({
1027
+ bucket: "state_rows",
1028
+ count: stateRows,
1029
+ limit: stateRowsLimit,
1030
+ suggestion: "Delete rows or claim the deploy before retrying this mutation."
1031
+ });
1032
+ }
1033
+ if (Number.isFinite(stateBytesLimit) && stateBytes > stateBytesLimit) {
1034
+ throw new LakebedQuotaError({
1035
+ bucket: "state_bytes",
1036
+ count: stateBytes,
1037
+ limit: stateBytesLimit,
1038
+ suggestion: "Reduce stored values, delete rows, or claim the deploy before retrying this mutation."
1039
+ });
1040
+ }
1041
+ }
1042
+
1043
+ function safeJsonValue(value) {
1044
+ if (value === undefined) {
1045
+ return null;
1046
+ }
1047
+
1048
+ try {
1049
+ JSON.stringify(value);
1050
+ return value;
1051
+ } catch {
1052
+ return String(value);
1053
+ }
1054
+ }
1055
+
1056
+ function truncateUtf8(value, maxBytes) {
1057
+ const text = String(value ?? "");
1058
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) {
1059
+ return text;
1060
+ }
1061
+
1062
+ let end = text.length;
1063
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) {
1064
+ end -= 1;
1065
+ }
1066
+ return text.slice(0, end);
1067
+ }
1068
+
1069
+ function normalizeLogEntry(level, message, data, limits = DEFAULT_ANONYMOUS_LIMITS) {
1070
+ const maxEntryBytes = limits.logEntryBytes ?? DEFAULT_ANONYMOUS_LIMITS.logEntryBytes;
1071
+ const maxMessageBytes = Math.max(128, Math.min(maxEntryBytes, Math.floor(maxEntryBytes / 2)));
1072
+ const originalMessageBytes = Buffer.byteLength(String(message ?? ""), "utf8");
1073
+ const cleanMessage =
1074
+ originalMessageBytes > maxMessageBytes
1075
+ ? `${truncateUtf8(message, Math.max(0, maxMessageBytes - 18))}...[truncated]`
1076
+ : String(message ?? "");
1077
+ let cleanData = safeJsonValue(data);
1078
+ let entry = { at: now(), data: cleanData, level, message: cleanMessage };
1079
+
1080
+ if (bytesOfJson(entry) > maxEntryBytes) {
1081
+ const dataText = JSON.stringify(cleanData ?? null);
1082
+ cleanData = {
1083
+ preview: truncateUtf8(dataText, Math.max(0, maxEntryBytes - bytesOfJson({ at: entry.at, data: null, level, message: cleanMessage }) - 128)),
1084
+ truncated: true,
1085
+ originalBytes: Buffer.byteLength(dataText, "utf8")
1086
+ };
1087
+ entry = { ...entry, data: cleanData };
1088
+ }
1089
+
1090
+ if (bytesOfJson(entry) > maxEntryBytes) {
1091
+ entry = {
1092
+ at: entry.at,
1093
+ data: { truncated: true },
1094
+ level,
1095
+ message: truncateUtf8(cleanMessage, Math.max(64, maxEntryBytes - 96))
1096
+ };
1097
+ }
1098
+
1099
+ return entry;
1100
+ }
1101
+
668
1102
  function usageCounts(usage) {
669
1103
  const windowStart = dayWindowStart();
670
1104
  const counts = {
@@ -896,7 +1330,7 @@ function adminHtml() {
896
1330
  .metrics {
897
1331
  display: grid;
898
1332
  gap: 8px;
899
- grid-template-columns: repeat(6, minmax(120px, 1fr));
1333
+ grid-template-columns: repeat(8, minmax(120px, 1fr));
900
1334
  margin-bottom: 14px;
901
1335
  }
902
1336
 
@@ -1278,8 +1712,10 @@ function adminHtml() {
1278
1712
  <div class="metric"><span>artifact bytes</span><strong id="metric-artifacts">0 B</strong></div>
1279
1713
  <div class="metric"><span>state bytes</span><strong id="metric-state">0 B</strong></div>
1280
1714
  <div class="metric"><span>state rows</span><strong id="metric-rows">0</strong></div>
1715
+ <div class="metric"><span>log bytes</span><strong id="metric-logs">0 B</strong></div>
1281
1716
  <div class="metric"><span>requests today</span><strong id="metric-requests">0</strong></div>
1282
1717
  <div class="metric"><span>mutations today</span><strong id="metric-mutations">0</strong></div>
1718
+ <div class="metric"><span>cleaned deploys</span><strong id="metric-cleanup">0</strong></div>
1283
1719
  </section>
1284
1720
 
1285
1721
  <section class="panel" id="deployments-view">
@@ -1880,8 +2316,10 @@ function adminHtml() {
1880
2316
  setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
1881
2317
  setMetric("metric-state", formatBytes(summary.totals.stateBytes));
1882
2318
  setMetric("metric-rows", formatNumber(summary.totals.stateRows));
2319
+ setMetric("metric-logs", formatBytes(summary.totals.logBytes));
1883
2320
  setMetric("metric-requests", formatNumber(summary.totals.requestsToday));
1884
2321
  setMetric("metric-mutations", formatNumber(summary.totals.mutationsToday));
2322
+ setMetric("metric-cleanup", formatNumber(summary.cleanup?.totals?.deletedDeploys || 0));
1885
2323
  statusLine.textContent = "Updated " + formatTime(summary.generatedAt);
1886
2324
  }
1887
2325
 
@@ -2225,12 +2663,109 @@ function escapeHtml(value) {
2225
2663
  .replace(/"/g, "&quot;");
2226
2664
  }
2227
2665
 
2228
- function formatHtmlTime(value) {
2229
- return value ? new Date(value).toLocaleString() : "unknown";
2666
+ function formatHtmlNumber(value) {
2667
+ return new Intl.NumberFormat().format(Number(value || 0));
2668
+ }
2669
+
2670
+ function parseHtmlDate(value) {
2671
+ if (!value) {
2672
+ return null;
2673
+ }
2674
+ const date = value instanceof Date ? value : new Date(value);
2675
+ return Number.isFinite(date.getTime()) ? date : null;
2676
+ }
2677
+
2678
+ function formatHtmlAbsoluteTime(value) {
2679
+ const date = parseHtmlDate(value);
2680
+ if (!date) {
2681
+ return "unknown";
2682
+ }
2683
+ return new Intl.DateTimeFormat(undefined, {
2684
+ day: "numeric",
2685
+ hour: "numeric",
2686
+ minute: "2-digit",
2687
+ month: "short",
2688
+ timeZoneName: "short",
2689
+ year: "numeric"
2690
+ }).format(date);
2691
+ }
2692
+
2693
+ function relativeHtmlUnit(diffMs, unitMs) {
2694
+ const value = Math.round(diffMs / unitMs);
2695
+ if (value === 0) {
2696
+ return diffMs < 0 ? -1 : 1;
2697
+ }
2698
+ return value;
2699
+ }
2700
+
2701
+ function formatHtmlRelativeTime(value, baseDate = new Date()) {
2702
+ const date = parseHtmlDate(value);
2703
+ if (!date) {
2704
+ return "unknown";
2705
+ }
2706
+
2707
+ const diffMs = date.getTime() - baseDate.getTime();
2708
+ const absMs = Math.abs(diffMs);
2709
+ const second = 1000;
2710
+ const minute = 60 * second;
2711
+ const hour = 60 * minute;
2712
+ const day = 24 * hour;
2713
+ const week = 7 * day;
2714
+ const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
2715
+
2716
+ if (absMs < 45 * second) {
2717
+ return diffMs < 0 ? "just now" : "soon";
2718
+ }
2719
+ if (absMs < 45 * minute) {
2720
+ return formatter.format(relativeHtmlUnit(diffMs, minute), "minute");
2721
+ }
2722
+ if (absMs < 22 * hour) {
2723
+ return formatter.format(relativeHtmlUnit(diffMs, hour), "hour");
2724
+ }
2725
+ if (absMs < 26 * day) {
2726
+ return formatter.format(relativeHtmlUnit(diffMs, day), "day");
2727
+ }
2728
+ if (absMs < 8 * week) {
2729
+ return formatter.format(relativeHtmlUnit(diffMs, week), "week");
2730
+ }
2731
+ return formatHtmlAbsoluteTime(date);
2732
+ }
2733
+
2734
+ function htmlTime(value) {
2735
+ const date = parseHtmlDate(value);
2736
+ if (!date) {
2737
+ return `<span>unknown</span>`;
2738
+ }
2739
+
2740
+ return `<time datetime="${escapeHtml(date.toISOString())}" title="${escapeHtml(formatHtmlAbsoluteTime(date))}">${escapeHtml(formatHtmlRelativeTime(date))}</time>`;
2741
+ }
2742
+
2743
+ function cssClassToken(value) {
2744
+ return (
2745
+ String(value ?? "")
2746
+ .toLowerCase()
2747
+ .replace(/[^a-z0-9_-]+/g, "-")
2748
+ .replace(/^-+|-+$/g, "") || "unknown"
2749
+ );
2750
+ }
2751
+
2752
+ function quotaPercent(used, limit) {
2753
+ const numericLimit = Number(limit || 0);
2754
+ if (numericLimit <= 0) {
2755
+ return 0;
2756
+ }
2757
+ return Math.max(0, Math.min(100, (Number(used || 0) / numericLimit) * 100));
2230
2758
  }
2231
2759
 
2232
- function formatHtmlExpiry(value) {
2233
- return value ? formatHtmlTime(value) : "never";
2760
+ function quotaHtml(label, used, limit) {
2761
+ const percent = quotaPercent(used, limit).toFixed(2);
2762
+ return `<div class="quota">
2763
+ <div class="quota-row">
2764
+ <span class="quota-label">${escapeHtml(label)}</span>
2765
+ <span class="quota-count">${escapeHtml(formatHtmlNumber(used))} / ${escapeHtml(formatHtmlNumber(limit))}</span>
2766
+ </div>
2767
+ <div class="meter" aria-hidden="true"><span style="--usage: ${percent}%"></span></div>
2768
+ </div>`;
2234
2769
  }
2235
2770
 
2236
2771
  function developerDeploySummary({ artifact, deploy, usage }) {
@@ -2255,18 +2790,34 @@ function developerDeploySummary({ artifact, deploy, usage }) {
2255
2790
 
2256
2791
  function developerHtml({ authConfigured, deploys = [], user }) {
2257
2792
  const signedIn = Boolean(user);
2793
+ const activeDeploys = deploys.filter((deploy) => deploy.status === "active").length;
2794
+ const requestsToday = deploys.reduce((total, deploy) => total + Number(deploy.usage.requestsToday || 0), 0);
2795
+ const mutationsToday = deploys.reduce((total, deploy) => total + Number(deploy.usage.mutationsToday || 0), 0);
2258
2796
  const rows = deploys
2259
- .map(
2260
- (deploy) => `<tr>
2261
- <td><a href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a><small>${escapeHtml(deploy.deployId)} / ${escapeHtml(deploy.slug)}</small></td>
2262
- <td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
2263
- <td>${escapeHtml(formatHtmlTime(deploy.createdAt))}</td>
2264
- <td>${escapeHtml(formatHtmlTime(deploy.updatedAt))}</td>
2265
- <td>${escapeHtml(formatHtmlExpiry(deploy.expiresAt))}</td>
2266
- <td>${escapeHtml(deploy.usage.requestsToday)} / ${escapeHtml(deploy.limits.requestsPerDay)}</td>
2267
- <td>${escapeHtml(deploy.usage.mutationsToday)} / ${escapeHtml(deploy.limits.mutationsPerDay)}</td>
2268
- </tr>`
2269
- )
2797
+ .map((deploy) => {
2798
+ const statusClass = cssClassToken(deploy.status);
2799
+ return `<article class="deploy-row">
2800
+ <div class="deploy-main">
2801
+ <div class="deploy-title-line">
2802
+ <a class="deploy-name" href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a>
2803
+ <span class="status status-${escapeHtml(statusClass)}">${escapeHtml(deploy.status)}</span>
2804
+ ${deploy.expiresAt ? `<span class="expiry">expires ${htmlTime(deploy.expiresAt)}</span>` : ""}
2805
+ </div>
2806
+ <div class="deploy-ids">
2807
+ <span>${escapeHtml(deploy.deployId)}</span>
2808
+ <span>${escapeHtml(deploy.slug)}</span>
2809
+ </div>
2810
+ </div>
2811
+ <div class="activity">
2812
+ <div class="activity-item"><span>Updated</span>${htmlTime(deploy.updatedAt)}</div>
2813
+ <div class="activity-item"><span>Created</span>${htmlTime(deploy.createdAt)}</div>
2814
+ </div>
2815
+ <div class="usage-grid">
2816
+ ${quotaHtml("Requests", deploy.usage.requestsToday, deploy.limits.requestsPerDay)}
2817
+ ${quotaHtml("Mutations", deploy.usage.mutationsToday, deploy.limits.mutationsPerDay)}
2818
+ </div>
2819
+ </article>`;
2820
+ })
2270
2821
  .join("");
2271
2822
 
2272
2823
  return `<!doctype html>
@@ -2278,14 +2829,18 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2278
2829
  <style>
2279
2830
  :root {
2280
2831
  color-scheme: dark;
2281
- --bg: #11130f;
2282
- --panel: #191c16;
2283
- --line: #343b2e;
2284
- --text: #f2f0e8;
2285
- --muted: #a8ab9e;
2286
- --accent: #91d46f;
2287
- --bad: #ee7d71;
2288
- --ink: #0c110a;
2832
+ --accent: #b7f26d;
2833
+ --bg: #070a09;
2834
+ --cyan: #71d6ff;
2835
+ --danger: #ff7b72;
2836
+ --line: #26302b;
2837
+ --line-strong: #3b4740;
2838
+ --muted: #8f9c94;
2839
+ --panel: #0f1513;
2840
+ --panel-raised: #141b18;
2841
+ --soft: #bfcbc3;
2842
+ --text: #eef6f0;
2843
+ --warning: #ffd166;
2289
2844
  }
2290
2845
 
2291
2846
  * {
@@ -2295,13 +2850,12 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2295
2850
  body {
2296
2851
  background: var(--bg);
2297
2852
  color: var(--text);
2298
- font-family: "Avenir Next", "Helvetica Neue", sans-serif;
2299
- letter-spacing: 0;
2853
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2300
2854
  margin: 0;
2301
2855
  }
2302
2856
 
2303
2857
  a {
2304
- color: var(--accent);
2858
+ color: inherit;
2305
2859
  text-decoration: none;
2306
2860
  }
2307
2861
 
@@ -2311,50 +2865,104 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2311
2865
 
2312
2866
  .shell {
2313
2867
  margin: 0 auto;
2314
- max-width: 1120px;
2868
+ max-width: 1560px;
2315
2869
  min-height: 100vh;
2316
- padding: 28px;
2870
+ padding: 34px 32px 56px;
2871
+ width: 100%;
2317
2872
  }
2318
2873
 
2319
2874
  header {
2320
2875
  align-items: center;
2876
+ border-bottom: 1px solid var(--line);
2321
2877
  display: flex;
2322
2878
  gap: 16px;
2323
2879
  justify-content: space-between;
2324
- margin-bottom: 24px;
2880
+ margin-bottom: 22px;
2881
+ padding-bottom: 22px;
2325
2882
  }
2326
2883
 
2327
2884
  h1 {
2328
- font-size: 32px;
2329
- line-height: 1;
2885
+ font-size: 40px;
2886
+ line-height: 1.05;
2330
2887
  margin: 0;
2331
2888
  }
2332
2889
 
2333
2890
  .eyebrow,
2334
- small,
2335
- th,
2336
- .meta {
2891
+ .meta,
2892
+ .deploy-ids,
2893
+ .activity-item span,
2894
+ .expiry,
2895
+ .list-head,
2896
+ .quota-label,
2897
+ .quota-count,
2898
+ .status {
2337
2899
  color: var(--muted);
2338
2900
  font-family: "SFMono-Regular", Consolas, monospace;
2339
2901
  font-size: 12px;
2340
2902
  }
2341
2903
 
2904
+ .eyebrow {
2905
+ color: var(--accent);
2906
+ margin-bottom: 6px;
2907
+ }
2908
+
2909
+ .meta {
2910
+ margin-top: 4px;
2911
+ }
2912
+
2342
2913
  .button {
2343
- background: var(--accent);
2344
- border: 1px solid var(--accent);
2914
+ align-items: center;
2915
+ background: transparent;
2916
+ border: 1px solid var(--line-strong);
2345
2917
  border-radius: 6px;
2346
- color: var(--ink);
2918
+ color: var(--text);
2347
2919
  display: inline-flex;
2348
2920
  font-weight: 700;
2349
2921
  min-height: 40px;
2350
2922
  padding: 10px 14px;
2351
2923
  }
2352
2924
 
2925
+ .button:hover {
2926
+ border-color: var(--accent);
2927
+ text-decoration: none;
2928
+ }
2929
+
2353
2930
  .button.secondary {
2354
2931
  background: transparent;
2355
2932
  color: var(--text);
2356
2933
  }
2357
2934
 
2935
+ .summary {
2936
+ background: var(--line);
2937
+ border: 1px solid var(--line);
2938
+ border-radius: 8px;
2939
+ display: grid;
2940
+ gap: 1px;
2941
+ grid-template-columns: repeat(3, minmax(0, 1fr));
2942
+ margin-bottom: 18px;
2943
+ overflow: hidden;
2944
+ }
2945
+
2946
+ .metric {
2947
+ background: var(--panel);
2948
+ padding: 15px 16px;
2949
+ }
2950
+
2951
+ .metric span {
2952
+ color: var(--muted);
2953
+ display: block;
2954
+ font-family: "SFMono-Regular", Consolas, monospace;
2955
+ font-size: 12px;
2956
+ margin-bottom: 6px;
2957
+ }
2958
+
2959
+ .metric strong {
2960
+ color: var(--text);
2961
+ display: block;
2962
+ font-size: 22px;
2963
+ line-height: 1;
2964
+ }
2965
+
2358
2966
  .panel {
2359
2967
  background: var(--panel);
2360
2968
  border: 1px solid var(--line);
@@ -2367,51 +2975,160 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2367
2975
  padding: 22px;
2368
2976
  }
2369
2977
 
2370
- table {
2371
- border-collapse: collapse;
2372
- width: 100%;
2978
+ .deploy-list {
2979
+ display: grid;
2373
2980
  }
2374
2981
 
2375
- th,
2376
- td {
2377
- border-bottom: 1px solid var(--line);
2378
- padding: 13px 14px;
2379
- text-align: left;
2380
- vertical-align: top;
2381
- }
2982
+ .list-head,
2983
+ .deploy-row {
2984
+ align-items: center;
2985
+ display: grid;
2986
+ gap: 24px;
2987
+ grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px);
2988
+ }
2382
2989
 
2383
- tr:last-child td {
2990
+ .list-head {
2991
+ background: #0b100e;
2992
+ border-bottom: 1px solid var(--line);
2993
+ padding: 12px 18px;
2994
+ }
2995
+
2996
+ .deploy-row {
2997
+ background: var(--panel);
2998
+ border-bottom: 1px solid var(--line);
2999
+ min-width: 0;
3000
+ padding: 18px;
3001
+ }
3002
+
3003
+ .deploy-row:last-child {
2384
3004
  border-bottom: 0;
2385
3005
  }
2386
3006
 
2387
- td:first-child {
2388
- display: grid;
2389
- gap: 4px;
3007
+ .deploy-row:hover {
3008
+ background: var(--panel-raised);
2390
3009
  }
2391
3010
 
2392
- .pill {
2393
- border: 1px solid var(--line);
2394
- border-radius: 999px;
2395
- display: inline-flex;
2396
- font-family: "SFMono-Regular", Consolas, monospace;
2397
- font-size: 12px;
2398
- padding: 3px 8px;
3011
+ .deploy-main,
3012
+ .activity,
3013
+ .usage-grid,
3014
+ .quota {
3015
+ min-width: 0;
2399
3016
  }
2400
3017
 
2401
- .pill.active {
2402
- border-color: rgba(145, 212, 111, 0.7);
3018
+ .deploy-title-line {
3019
+ align-items: center;
3020
+ display: flex;
3021
+ flex-wrap: wrap;
3022
+ gap: 8px 10px;
3023
+ margin-bottom: 8px;
3024
+ min-width: 0;
3025
+ }
3026
+
3027
+ .deploy-name {
3028
+ color: var(--text);
3029
+ font-size: 16px;
3030
+ font-weight: 700;
3031
+ overflow-wrap: anywhere;
3032
+ }
3033
+
3034
+ .deploy-ids {
3035
+ align-items: center;
3036
+ display: flex;
3037
+ flex-wrap: wrap;
3038
+ gap: 6px 12px;
3039
+ }
3040
+
3041
+ .deploy-ids span {
3042
+ overflow-wrap: anywhere;
3043
+ }
3044
+
3045
+ .status {
3046
+ align-items: center;
2403
3047
  color: var(--accent);
3048
+ display: inline-flex;
3049
+ gap: 6px;
3050
+ white-space: nowrap;
2404
3051
  }
2405
3052
 
2406
- .pill.expired,
2407
- .pill.terminated {
2408
- border-color: rgba(238, 125, 113, 0.75);
2409
- color: var(--bad);
3053
+ .status::before {
3054
+ background: var(--accent);
3055
+ border-radius: 999px;
3056
+ box-shadow: 0 0 0 3px rgba(183, 242, 109, 0.14);
3057
+ content: "";
3058
+ height: 7px;
3059
+ width: 7px;
3060
+ }
3061
+
3062
+ .status-expired,
3063
+ .status-terminated {
3064
+ color: var(--danger);
3065
+ }
3066
+
3067
+ .status-expired::before,
3068
+ .status-terminated::before {
3069
+ background: var(--danger);
3070
+ box-shadow: 0 0 0 3px rgba(255, 123, 114, 0.15);
3071
+ }
3072
+
3073
+ .expiry {
3074
+ color: var(--warning);
3075
+ white-space: nowrap;
3076
+ }
3077
+
3078
+ .activity {
3079
+ display: grid;
3080
+ gap: 6px;
3081
+ }
3082
+
3083
+ .activity-item {
3084
+ align-items: baseline;
3085
+ color: var(--soft);
3086
+ display: flex;
3087
+ gap: 12px;
3088
+ justify-content: space-between;
3089
+ }
3090
+
3091
+ time {
3092
+ color: var(--text);
3093
+ white-space: nowrap;
3094
+ }
3095
+
3096
+ .usage-grid {
3097
+ display: grid;
3098
+ gap: 12px;
3099
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3100
+ }
3101
+
3102
+ .quota-row {
3103
+ align-items: baseline;
3104
+ display: flex;
3105
+ gap: 12px;
3106
+ justify-content: space-between;
3107
+ margin-bottom: 8px;
3108
+ }
3109
+
3110
+ .quota-count {
3111
+ color: var(--text);
3112
+ white-space: nowrap;
3113
+ }
3114
+
3115
+ .meter {
3116
+ background: #222b26;
3117
+ border-radius: 999px;
3118
+ height: 6px;
3119
+ overflow: hidden;
2410
3120
  }
2411
3121
 
2412
- @media (max-width: 720px) {
3122
+ .meter span {
3123
+ background: linear-gradient(90deg, var(--accent), var(--cyan));
3124
+ display: block;
3125
+ height: 100%;
3126
+ width: var(--usage);
3127
+ }
3128
+
3129
+ @media (max-width: 980px) {
2413
3130
  .shell {
2414
- padding: 18px;
3131
+ padding: 24px 18px 40px;
2415
3132
  }
2416
3133
 
2417
3134
  header {
@@ -2419,12 +3136,46 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2419
3136
  flex-direction: column;
2420
3137
  }
2421
3138
 
2422
- table {
2423
- min-width: 760px;
3139
+ h1 {
3140
+ font-size: 32px;
3141
+ }
3142
+
3143
+ .summary {
3144
+ grid-template-columns: repeat(3, minmax(0, 1fr));
3145
+ }
3146
+
3147
+ .list-head {
3148
+ display: none;
3149
+ }
3150
+
3151
+ .deploy-row {
3152
+ align-items: stretch;
3153
+ gap: 16px;
3154
+ grid-template-columns: 1fr;
2424
3155
  }
2425
3156
 
2426
- .panel {
2427
- overflow-x: auto;
3157
+ .activity-item {
3158
+ justify-content: flex-start;
3159
+ }
3160
+ }
3161
+
3162
+ @media (max-width: 640px) {
3163
+ .shell {
3164
+ padding: 20px 12px 36px;
3165
+ }
3166
+
3167
+ .summary,
3168
+ .usage-grid {
3169
+ grid-template-columns: 1fr;
3170
+ }
3171
+
3172
+ .deploy-ids {
3173
+ display: grid;
3174
+ }
3175
+
3176
+ .activity-item {
3177
+ display: grid;
3178
+ gap: 2px;
2428
3179
  }
2429
3180
  }
2430
3181
  </style>
@@ -2447,6 +3198,15 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2447
3198
  }
2448
3199
  </div>
2449
3200
  </header>
3201
+ ${
3202
+ signedIn && deploys.length
3203
+ ? `<section class="summary" aria-label="Deployment summary">
3204
+ <div class="metric"><span>active deploys</span><strong>${escapeHtml(formatHtmlNumber(activeDeploys))}</strong></div>
3205
+ <div class="metric"><span>requests today</span><strong>${escapeHtml(formatHtmlNumber(requestsToday))}</strong></div>
3206
+ <div class="metric"><span>mutations today</span><strong>${escapeHtml(formatHtmlNumber(mutationsToday))}</strong></div>
3207
+ </section>`
3208
+ : ""
3209
+ }
2450
3210
 
2451
3211
  ${
2452
3212
  !authConfigured
@@ -2456,20 +3216,14 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2456
3216
  : deploys.length === 0
2457
3217
  ? `<section class="panel"><div class="empty">No claimed deployments.</div></section>`
2458
3218
  : `<section class="panel">
2459
- <table>
2460
- <thead>
2461
- <tr>
2462
- <th>Deploy</th>
2463
- <th>Status</th>
2464
- <th>Created</th>
2465
- <th>Updated</th>
2466
- <th>Expires</th>
2467
- <th>Requests</th>
2468
- <th>Mutations</th>
2469
- </tr>
2470
- </thead>
2471
- <tbody>${rows}</tbody>
2472
- </table>
3219
+ <div class="deploy-list">
3220
+ <div class="list-head" aria-hidden="true">
3221
+ <span>Deploy</span>
3222
+ <span>Activity</span>
3223
+ <span>Usage today</span>
3224
+ </div>
3225
+ ${rows}
3226
+ </div>
2473
3227
  </section>`
2474
3228
  }
2475
3229
  </main>
@@ -2480,14 +3234,19 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2480
3234
  export class MemoryAnonymousStore {
2481
3235
  constructor() {
2482
3236
  this.artifacts = new Map();
3237
+ this.deployDomainsByHostname = new Map();
2483
3238
  this.deploys = new Map();
2484
3239
  this.deploysBySlug = new Map();
3240
+ this.deployCreateQuotaEvents = new Map();
3241
+ this.clientQuotaEvents = new Map();
2485
3242
  this.logs = new Map();
2486
3243
  this.quotaEvents = new Map();
2487
3244
  this.queues = new Map();
2488
3245
  this.rows = new Map();
2489
3246
  this.serverEnv = new Map();
2490
3247
  this.users = new Map();
3248
+ this.cleanupRuns = [];
3249
+ this.cleanupTotals = {};
2491
3250
  }
2492
3251
 
2493
3252
  async initialize() {}
@@ -2614,11 +3373,30 @@ export class MemoryAnonymousStore {
2614
3373
  });
2615
3374
  }
2616
3375
 
3376
+ decrementArtifactRef(artifactHash) {
3377
+ const artifact = this.artifacts.get(artifactHash);
3378
+ if (!artifact) {
3379
+ return false;
3380
+ }
3381
+
3382
+ const refCount = Math.max(0, Number(artifact.refCount ?? 0) - 1);
3383
+ if (refCount <= 0) {
3384
+ this.artifacts.delete(artifactHash);
3385
+ return true;
3386
+ }
3387
+
3388
+ this.artifacts.set(artifactHash, { ...artifact, refCount });
3389
+ return false;
3390
+ }
3391
+
2617
3392
  async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
2618
3393
  const deployId = createDeployId();
2619
3394
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
2620
3395
  let slug = createSlug();
2621
- while (this.deploysBySlug.has(slug)) {
3396
+ while (
3397
+ this.deploysBySlug.has(slug) ||
3398
+ (normalizedAppBaseDomain && this.deployDomainsByHostname.has(`${slug}.${normalizedAppBaseDomain}`))
3399
+ ) {
2622
3400
  slug = createSlug();
2623
3401
  }
2624
3402
 
@@ -2699,6 +3477,7 @@ export class MemoryAnonymousStore {
2699
3477
  createdAt: updatedAt
2700
3478
  });
2701
3479
  this.deploys.set(deployId, deploy);
3480
+ this.decrementArtifactRef(currentDeploy.artifactHash);
2702
3481
  if (serverEnv !== undefined) {
2703
3482
  this.serverEnv.set(deployId, { ...serverEnv });
2704
3483
  }
@@ -2757,6 +3536,62 @@ export class MemoryAnonymousStore {
2757
3536
  return id ? this.getDeployById(id) : null;
2758
3537
  }
2759
3538
 
3539
+ async getDeployDomainByHostname(hostname) {
3540
+ const domain = this.deployDomainsByHostname.get(hostname);
3541
+ return domain ? { ...domain } : null;
3542
+ }
3543
+
3544
+ async listDeployDomainsForDeploy(deployId) {
3545
+ return Array.from(this.deployDomainsByHostname.values())
3546
+ .filter((domain) => domain.deployId === deployId)
3547
+ .sort((left, right) => String(left.hostname).localeCompare(String(right.hostname)))
3548
+ .map((domain) => ({ ...domain }));
3549
+ }
3550
+
3551
+ async createLakebedSubdomain({ deployId, hostname, label, ownerId }) {
3552
+ const deploy = await this.getStoredDeployById(deployId);
3553
+ if (!deploy) {
3554
+ return { status: "missing" };
3555
+ }
3556
+ if (!deploy.ownerId) {
3557
+ return { deploy, status: "unclaimed" };
3558
+ }
3559
+ if (deploy.ownerId !== ownerId) {
3560
+ return { deploy, status: "forbidden" };
3561
+ }
3562
+ if (deploy.status !== "active" || isExpired(deploy)) {
3563
+ return { deploy, status: "inactive" };
3564
+ }
3565
+
3566
+ const existingDomain = this.deployDomainsByHostname.get(hostname);
3567
+ if (existingDomain) {
3568
+ if (existingDomain.deployId === deployId) {
3569
+ return { deploy, domain: { ...existingDomain }, status: "exists" };
3570
+ }
3571
+
3572
+ return { deploy, domain: { ...existingDomain }, status: "conflict" };
3573
+ }
3574
+
3575
+ if (this.deploysBySlug.has(label)) {
3576
+ return { deploy, status: "slug_conflict" };
3577
+ }
3578
+
3579
+ const timestamp = now();
3580
+ const domain = {
3581
+ createdAt: timestamp,
3582
+ deployId,
3583
+ hostname,
3584
+ isPrimary: false,
3585
+ kind: "lakebed_subdomain",
3586
+ ownerId,
3587
+ status: "active",
3588
+ updatedAt: timestamp,
3589
+ url: `https://${hostname}`
3590
+ };
3591
+ this.deployDomainsByHostname.set(hostname, domain);
3592
+ return { deploy, domain: { ...domain }, status: "created" };
3593
+ }
3594
+
2760
3595
  async getArtifact(hash) {
2761
3596
  return this.artifacts.get(hash) ?? null;
2762
3597
  }
@@ -2806,8 +3641,45 @@ export class MemoryAnonymousStore {
2806
3641
  return { ...(this.serverEnv.get(deployId) ?? {}) };
2807
3642
  }
2808
3643
 
2809
- async transaction(deployId, handler) {
2810
- const run = () => handler(this);
3644
+ snapshotRowsForDeploy(deployId) {
3645
+ const snapshot = new Map();
3646
+ for (const [key, rows] of this.rows) {
3647
+ const [eventDeployId] = key.split(":");
3648
+ if (eventDeployId === deployId) {
3649
+ snapshot.set(key, new Map(Array.from(rows.entries()).map(([rowId, row]) => [rowId, { ...row }])));
3650
+ }
3651
+ }
3652
+ return snapshot;
3653
+ }
3654
+
3655
+ restoreRowsForDeploy(deployId, snapshot) {
3656
+ for (const key of Array.from(this.rows.keys())) {
3657
+ const [eventDeployId] = key.split(":");
3658
+ if (eventDeployId === deployId) {
3659
+ this.rows.delete(key);
3660
+ }
3661
+ }
3662
+ for (const [key, rows] of snapshot) {
3663
+ this.rows.set(key, new Map(Array.from(rows.entries()).map(([rowId, row]) => [rowId, { ...row }])));
3664
+ }
3665
+ }
3666
+
3667
+ assertStateWithinLimits(deployId, options) {
3668
+ assertStateResourceLimits(this.stateResourceForDeploy(deployId), options);
3669
+ }
3670
+
3671
+ async transaction(deployId, handler, options = {}) {
3672
+ const run = async () => {
3673
+ const snapshot = this.snapshotRowsForDeploy(deployId);
3674
+ try {
3675
+ const result = await handler(this);
3676
+ this.assertStateWithinLimits(deployId, options);
3677
+ return result;
3678
+ } catch (error) {
3679
+ this.restoreRowsForDeploy(deployId, snapshot);
3680
+ throw error;
3681
+ }
3682
+ };
2811
3683
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
2812
3684
  this.queues.set(
2813
3685
  deployId,
@@ -2820,9 +3692,13 @@ export class MemoryAnonymousStore {
2820
3692
  }
2821
3693
 
2822
3694
  async appendLog(deployId, level, message, data) {
2823
- const entries = this.logs.get(deployId) ?? [];
2824
- entries.push({ at: now(), data, level, message });
2825
- this.logs.set(deployId, entries.slice(-DEFAULT_ANONYMOUS_LIMITS.logEntries));
3695
+ const entries = [...(this.logs.get(deployId) ?? []), normalizeLogEntry(level, message, data)];
3696
+ const maxEntries = DEFAULT_ANONYMOUS_LIMITS.logEntries;
3697
+ const maxBytes = DEFAULT_ANONYMOUS_LIMITS.logBytes;
3698
+ while (entries.length > maxEntries || bytesOfJson(entries) > maxBytes) {
3699
+ entries.shift();
3700
+ }
3701
+ this.logs.set(deployId, entries);
2826
3702
  }
2827
3703
 
2828
3704
  async readLogs(deployId, limit = 100) {
@@ -2858,12 +3734,67 @@ export class MemoryAnonymousStore {
2858
3734
  }
2859
3735
 
2860
3736
  async incrementQuota(deployId, bucket, limit) {
3737
+ if (!Number.isFinite(limit)) {
3738
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
3739
+ }
3740
+
2861
3741
  const windowStart = dayWindowStart();
2862
3742
  const key = `${deployId}:${bucket}:${windowStart}`;
2863
3743
  const count = (this.quotaEvents.get(key) ?? 0) + 1;
2864
3744
  this.quotaEvents.set(key, count);
2865
3745
  if (count > limit) {
2866
- throw new Error(`Anonymous ${bucket} quota exceeded. Limit: ${limit} per day.`);
3746
+ throw new LakebedQuotaError({
3747
+ bucket,
3748
+ count,
3749
+ limit,
3750
+ resetAt: quotaResetAt(windowStart),
3751
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
3752
+ suggestion: bucket === "mutations" ? "Retry after reset, reduce mutation frequency, or claim the deploy." : "Retry after reset or reduce request frequency."
3753
+ });
3754
+ }
3755
+ return { bucket, count, limit, windowStart };
3756
+ }
3757
+
3758
+ async incrementDeployCreateQuota(clientKey, bucket, limit) {
3759
+ if (!Number.isFinite(limit)) {
3760
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
3761
+ }
3762
+
3763
+ const windowStart = dayWindowStart();
3764
+ const key = JSON.stringify([clientKey, bucket, windowStart]);
3765
+ const count = (this.deployCreateQuotaEvents.get(key) ?? 0) + 1;
3766
+ this.deployCreateQuotaEvents.set(key, count);
3767
+ if (count > limit) {
3768
+ throw new LakebedQuotaError({
3769
+ bucket,
3770
+ count,
3771
+ limit,
3772
+ resetAt: quotaResetAt(windowStart),
3773
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
3774
+ suggestion: "Retry after reset, reuse an existing deploy, or sign in and claim deploys you want to keep."
3775
+ });
3776
+ }
3777
+ return { bucket, count, limit, windowStart };
3778
+ }
3779
+
3780
+ async incrementClientQuota(clientKey, bucket, limit) {
3781
+ if (!Number.isFinite(limit)) {
3782
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
3783
+ }
3784
+
3785
+ const windowStart = dayWindowStart();
3786
+ const key = JSON.stringify([clientKey, bucket, windowStart]);
3787
+ const count = (this.clientQuotaEvents.get(key) ?? 0) + 1;
3788
+ this.clientQuotaEvents.set(key, count);
3789
+ if (count > limit) {
3790
+ throw new LakebedQuotaError({
3791
+ bucket,
3792
+ count,
3793
+ limit,
3794
+ resetAt: quotaResetAt(windowStart),
3795
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
3796
+ suggestion: clientTrafficQuotaSuggestion(bucket)
3797
+ });
2867
3798
  }
2868
3799
  return { bucket, count, limit, windowStart };
2869
3800
  }
@@ -2949,6 +3880,160 @@ export class MemoryAnonymousStore {
2949
3880
 
2950
3881
  return summaries;
2951
3882
  }
3883
+
3884
+ deleteDeployResources(deployId) {
3885
+ const deploy = this.deploys.get(deployId);
3886
+ if (!deploy) {
3887
+ return {
3888
+ deletedArtifacts: 0,
3889
+ deletedDomains: 0,
3890
+ deletedLogEntries: 0,
3891
+ deletedQuotaEvents: 0,
3892
+ deletedServerEnv: 0,
3893
+ deletedStateRows: 0
3894
+ };
3895
+ }
3896
+
3897
+ let deletedStateRows = 0;
3898
+ for (const [key, rows] of Array.from(this.rows.entries())) {
3899
+ const [eventDeployId] = key.split(":");
3900
+ if (eventDeployId === deployId) {
3901
+ deletedStateRows += rows.size;
3902
+ this.rows.delete(key);
3903
+ }
3904
+ }
3905
+
3906
+ const deletedLogEntries = this.logs.get(deployId)?.length ?? 0;
3907
+ this.logs.delete(deployId);
3908
+
3909
+ let deletedQuotaEvents = 0;
3910
+ for (const key of Array.from(this.quotaEvents.keys())) {
3911
+ const [eventDeployId] = key.split(":");
3912
+ if (eventDeployId === deployId) {
3913
+ deletedQuotaEvents += 1;
3914
+ this.quotaEvents.delete(key);
3915
+ }
3916
+ }
3917
+
3918
+ const deletedServerEnv = this.serverEnv.has(deployId) ? Object.keys(this.serverEnv.get(deployId) ?? {}).length : 0;
3919
+ this.serverEnv.delete(deployId);
3920
+
3921
+ let deletedDomains = 0;
3922
+ for (const [hostname, domain] of Array.from(this.deployDomainsByHostname.entries())) {
3923
+ if (domain.deployId === deployId) {
3924
+ this.deployDomainsByHostname.delete(hostname);
3925
+ deletedDomains += 1;
3926
+ }
3927
+ }
3928
+
3929
+ this.deploys.delete(deployId);
3930
+ this.deploysBySlug.delete(deploy.slug);
3931
+ const deletedArtifacts = this.decrementArtifactRef(deploy.artifactHash) ? 1 : 0;
3932
+
3933
+ return {
3934
+ deletedArtifacts,
3935
+ deletedDomains,
3936
+ deletedLogEntries,
3937
+ deletedQuotaEvents,
3938
+ deletedServerEnv,
3939
+ deletedStateRows
3940
+ };
3941
+ }
3942
+
3943
+ recordCleanupRun(stats) {
3944
+ this.cleanupRuns.push(stats);
3945
+ this.cleanupRuns = this.cleanupRuns.slice(-20);
3946
+ for (const [key, value] of Object.entries(stats)) {
3947
+ if (typeof value === "number") {
3948
+ this.cleanupTotals[key] = (this.cleanupTotals[key] ?? 0) + value;
3949
+ }
3950
+ }
3951
+ }
3952
+
3953
+ async cleanupExpiredDeploys({ graceSeconds = 60 * 60, retentionSeconds = 7 * 24 * 60 * 60, nowMs = Date.now() } = {}) {
3954
+ const startedAt = new Date(nowMs).toISOString();
3955
+ const markCutoffMs = nowMs - graceSeconds * 1000;
3956
+ const deleteCutoffMs = nowMs - retentionSeconds * 1000;
3957
+ const stats = {
3958
+ deletedArtifacts: 0,
3959
+ deletedDomains: 0,
3960
+ deletedDeploys: 0,
3961
+ deletedLogEntries: 0,
3962
+ deletedQuotaEvents: 0,
3963
+ deletedServerEnv: 0,
3964
+ deletedStateRows: 0,
3965
+ examinedDeploys: this.deploys.size,
3966
+ finishedAt: null,
3967
+ markedTerminated: 0,
3968
+ startedAt
3969
+ };
3970
+
3971
+ for (const deploy of Array.from(this.deploys.values())) {
3972
+ if (deploy.ownerId || !deploy.expiresAt) {
3973
+ continue;
3974
+ }
3975
+
3976
+ const expiresAtMs = Date.parse(deploy.expiresAt);
3977
+ if (Number.isFinite(expiresAtMs) && expiresAtMs <= markCutoffMs && deploy.status === "active") {
3978
+ this.deploys.set(deploy.id, { ...deploy, status: "terminated", updatedAt: new Date(nowMs).toISOString() });
3979
+ stats.markedTerminated += 1;
3980
+ }
3981
+ }
3982
+
3983
+ for (const deploy of Array.from(this.deploys.values())) {
3984
+ if (deploy.ownerId || !deploy.expiresAt) {
3985
+ continue;
3986
+ }
3987
+
3988
+ const expiresAtMs = Date.parse(deploy.expiresAt);
3989
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs > deleteCutoffMs) {
3990
+ continue;
3991
+ }
3992
+
3993
+ const deleted = this.deleteDeployResources(deploy.id);
3994
+ stats.deletedDeploys += 1;
3995
+ for (const [key, value] of Object.entries(deleted)) {
3996
+ stats[key] += value;
3997
+ }
3998
+ }
3999
+
4000
+ for (const [key] of Array.from(this.deployCreateQuotaEvents.entries())) {
4001
+ try {
4002
+ const [, , windowStart] = JSON.parse(key);
4003
+ if (Date.parse(windowStart) <= deleteCutoffMs) {
4004
+ this.deployCreateQuotaEvents.delete(key);
4005
+ stats.deletedQuotaEvents += 1;
4006
+ }
4007
+ } catch {
4008
+ this.deployCreateQuotaEvents.delete(key);
4009
+ stats.deletedQuotaEvents += 1;
4010
+ }
4011
+ }
4012
+ for (const [key] of Array.from(this.clientQuotaEvents.entries())) {
4013
+ try {
4014
+ const [, , windowStart] = JSON.parse(key);
4015
+ if (Date.parse(windowStart) <= deleteCutoffMs) {
4016
+ this.clientQuotaEvents.delete(key);
4017
+ stats.deletedQuotaEvents += 1;
4018
+ }
4019
+ } catch {
4020
+ this.clientQuotaEvents.delete(key);
4021
+ stats.deletedQuotaEvents += 1;
4022
+ }
4023
+ }
4024
+
4025
+ stats.finishedAt = now();
4026
+ this.recordCleanupRun(stats);
4027
+ return stats;
4028
+ }
4029
+
4030
+ async readCleanupActivity() {
4031
+ return {
4032
+ lastRun: this.cleanupRuns[this.cleanupRuns.length - 1] ?? null,
4033
+ recentRuns: [...this.cleanupRuns].reverse(),
4034
+ totals: { ...this.cleanupTotals }
4035
+ };
4036
+ }
2952
4037
  }
2953
4038
 
2954
4039
  export class PostgresAnonymousStore {
@@ -2991,6 +4076,20 @@ export class PostgresAnonymousStore {
2991
4076
  await this.query("alter table deploys alter column updated_at set not null");
2992
4077
  await this.query("alter table deploys alter column expires_at drop not null");
2993
4078
  await this.query("update deploys set expires_at = null where owner_id is not null and expires_at is not null");
4079
+ await this.query(`
4080
+ create table if not exists deploy_domains(
4081
+ hostname text primary key,
4082
+ deploy_id text not null references deploys(id) on delete cascade,
4083
+ owner_id text not null,
4084
+ kind text not null,
4085
+ status text not null,
4086
+ is_primary boolean not null default false,
4087
+ created_at timestamptz not null,
4088
+ updated_at timestamptz not null
4089
+ )
4090
+ `);
4091
+ await this.query("create index if not exists deploy_domains_deploy_id_idx on deploy_domains(deploy_id)");
4092
+ await this.query("create index if not exists deploy_domains_owner_id_idx on deploy_domains(owner_id)");
2994
4093
  await this.query(`
2995
4094
  create table if not exists artifacts(
2996
4095
  hash text primary key,
@@ -3032,6 +4131,24 @@ export class PostgresAnonymousStore {
3032
4131
  primary key (deploy_id, bucket, window_start)
3033
4132
  )
3034
4133
  `);
4134
+ await this.query(`
4135
+ create table if not exists deploy_create_quota_events(
4136
+ client_key text not null,
4137
+ bucket text not null,
4138
+ window_start timestamptz not null,
4139
+ count integer not null,
4140
+ primary key (client_key, bucket, window_start)
4141
+ )
4142
+ `);
4143
+ await this.query(`
4144
+ create table if not exists client_quota_events(
4145
+ client_key text not null,
4146
+ bucket text not null,
4147
+ window_start timestamptz not null,
4148
+ count integer not null,
4149
+ primary key (client_key, bucket, window_start)
4150
+ )
4151
+ `);
3035
4152
  await this.query(`
3036
4153
  create table if not exists deploy_server_env(
3037
4154
  deploy_id text not null references deploys(id) on delete cascade,
@@ -3056,6 +4173,14 @@ export class PostgresAnonymousStore {
3056
4173
  updated_at timestamptz not null
3057
4174
  )
3058
4175
  `);
4176
+ await this.query(`
4177
+ create table if not exists cleanup_runs(
4178
+ id bigserial primary key,
4179
+ started_at timestamptz not null,
4180
+ finished_at timestamptz not null,
4181
+ stats_json jsonb not null
4182
+ )
4183
+ `);
3059
4184
  await this.query("alter table users add column if not exists boost boolean not null default false");
3060
4185
  await this.query(`
3061
4186
  insert into users(
@@ -3096,6 +4221,33 @@ export class PostgresAnonymousStore {
3096
4221
  return this.pool.query(sql, params);
3097
4222
  }
3098
4223
 
4224
+ async withPostgresTransaction(handler) {
4225
+ if (!this.pool) {
4226
+ throw new Error("Postgres store is not initialized.");
4227
+ }
4228
+
4229
+ const client = await this.pool.connect();
4230
+ try {
4231
+ await client.query("begin");
4232
+ const result = await handler(client);
4233
+ await client.query("commit");
4234
+ return result;
4235
+ } catch (error) {
4236
+ try {
4237
+ await client.query("rollback");
4238
+ } catch {
4239
+ // Preserve the original transaction error.
4240
+ }
4241
+ throw error;
4242
+ } finally {
4243
+ client.release();
4244
+ }
4245
+ }
4246
+
4247
+ async lockHostnameAllocation(client, hostname) {
4248
+ await client.query("select pg_advisory_xact_lock(hashtext('lakebed.deploy_hostname'), hashtext($1))", [hostname]);
4249
+ }
4250
+
3099
4251
  rowToUser(row) {
3100
4252
  if (!row) {
3101
4253
  return null;
@@ -3320,6 +4472,25 @@ export class PostgresAnonymousStore {
3320
4472
  );
3321
4473
  }
3322
4474
 
4475
+ async decrementArtifactRef(artifactHash) {
4476
+ const result = await this.query(
4477
+ `
4478
+ update artifacts
4479
+ set ref_count = greatest(ref_count - 1, 0)
4480
+ where hash = $1
4481
+ returning ref_count
4482
+ `,
4483
+ [artifactHash]
4484
+ );
4485
+ const refCount = result.rows[0]?.ref_count;
4486
+ if (refCount === undefined || Number(refCount) > 0) {
4487
+ return false;
4488
+ }
4489
+
4490
+ await this.query("delete from artifacts where hash = $1 and ref_count <= 0", [artifactHash]);
4491
+ return true;
4492
+ }
4493
+
3323
4494
  async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
3324
4495
  const createdAt = now();
3325
4496
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
@@ -3331,6 +4502,8 @@ export class PostgresAnonymousStore {
3331
4502
 
3332
4503
  for (let attempt = 0; attempt < 8; attempt += 1) {
3333
4504
  const slug = createSlug();
4505
+ const generatedHostname = normalizedAppBaseDomain ? `${slug}.${normalizedAppBaseDomain}` : null;
4506
+
3334
4507
  const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
3335
4508
  const deploy = {
3336
4509
  appBaseDomain: normalizedAppBaseDomain,
@@ -3352,30 +4525,44 @@ export class PostgresAnonymousStore {
3352
4525
  };
3353
4526
 
3354
4527
  try {
3355
- await this.query(
3356
- `
3357
- insert into deploys(
3358
- id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
3359
- claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
3360
- )
3361
- values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
3362
- `,
3363
- [
3364
- deploy.id,
3365
- deploy.slug,
3366
- deploy.status,
3367
- deploy.artifactHash,
3368
- deploy.clientBundleHash,
3369
- deploy.createdAt,
3370
- deploy.updatedAt,
3371
- deploy.expiresAt,
3372
- deploy.claimTokenHash,
3373
- JSON.stringify(deploy.limits),
3374
- deploy.publicRootUrl,
3375
- deploy.appBaseDomain,
3376
- deploy.url
3377
- ]
3378
- );
4528
+ const inserted = await this.withPostgresTransaction(async (client) => {
4529
+ if (generatedHostname) {
4530
+ await this.lockHostnameAllocation(client, generatedHostname);
4531
+ const domainConflict = await client.query("select 1 from deploy_domains where hostname = $1", [generatedHostname]);
4532
+ if (domainConflict.rows.length > 0) {
4533
+ return false;
4534
+ }
4535
+ }
4536
+
4537
+ await client.query(
4538
+ `
4539
+ insert into deploys(
4540
+ id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
4541
+ claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
4542
+ )
4543
+ values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
4544
+ `,
4545
+ [
4546
+ deploy.id,
4547
+ deploy.slug,
4548
+ deploy.status,
4549
+ deploy.artifactHash,
4550
+ deploy.clientBundleHash,
4551
+ deploy.createdAt,
4552
+ deploy.updatedAt,
4553
+ deploy.expiresAt,
4554
+ deploy.claimTokenHash,
4555
+ JSON.stringify(deploy.limits),
4556
+ deploy.publicRootUrl,
4557
+ deploy.appBaseDomain,
4558
+ deploy.url
4559
+ ]
4560
+ );
4561
+ return true;
4562
+ });
4563
+ if (!inserted) {
4564
+ continue;
4565
+ }
3379
4566
  if (serverEnv !== undefined) {
3380
4567
  await this.replaceServerEnv(deploy.id, serverEnv, createdAt);
3381
4568
  }
@@ -3431,6 +4618,7 @@ export class PostgresAnonymousStore {
3431
4618
  if (serverEnv !== undefined) {
3432
4619
  await this.replaceServerEnv(deployId, serverEnv, updatedAt);
3433
4620
  }
4621
+ await this.decrementArtifactRef(currentDeploy.artifactHash);
3434
4622
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3435
4623
  }
3436
4624
 
@@ -3499,7 +4687,7 @@ export class PostgresAnonymousStore {
3499
4687
  createdAt: new Date(row.created_at).toISOString(),
3500
4688
  expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
3501
4689
  id: row.id,
3502
- limits: row.limits_json,
4690
+ limits: { ...DEFAULT_ANONYMOUS_LIMITS, ...(row.limits_json ?? {}) },
3503
4691
  owner: row.owner_json ?? null,
3504
4692
  ownerId: row.owner_id ?? null,
3505
4693
  publicRootUrl: row.public_root_url,
@@ -3510,6 +4698,24 @@ export class PostgresAnonymousStore {
3510
4698
  };
3511
4699
  }
3512
4700
 
4701
+ rowToDeployDomain(row) {
4702
+ if (!row) {
4703
+ return null;
4704
+ }
4705
+
4706
+ return {
4707
+ createdAt: new Date(row.created_at).toISOString(),
4708
+ deployId: row.deploy_id,
4709
+ hostname: row.hostname,
4710
+ isPrimary: Boolean(row.is_primary),
4711
+ kind: row.kind,
4712
+ ownerId: row.owner_id,
4713
+ status: row.status,
4714
+ updatedAt: new Date(row.updated_at).toISOString(),
4715
+ url: `https://${row.hostname}`
4716
+ };
4717
+ }
4718
+
3513
4719
  async getDeployById(id) {
3514
4720
  return this.deployWithUserLimitOverrides(await this.getBaseDeployById(id));
3515
4721
  }
@@ -3519,6 +4725,80 @@ export class PostgresAnonymousStore {
3519
4725
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3520
4726
  }
3521
4727
 
4728
+ async getDeployDomainByHostname(hostname) {
4729
+ const result = await this.query("select * from deploy_domains where hostname = $1", [hostname]);
4730
+ return this.rowToDeployDomain(result.rows[0]);
4731
+ }
4732
+
4733
+ async listDeployDomainsForDeploy(deployId) {
4734
+ const result = await this.query("select * from deploy_domains where deploy_id = $1 order by hostname asc", [deployId]);
4735
+ return result.rows.map((row) => this.rowToDeployDomain(row));
4736
+ }
4737
+
4738
+ async createLakebedSubdomain({ deployId, hostname, label, ownerId }) {
4739
+ try {
4740
+ return await this.withPostgresTransaction(async (client) => {
4741
+ const deployResult = await client.query("select * from deploys where id = $1 for update", [deployId]);
4742
+ const deploy = this.rowToDeploy(deployResult.rows[0]);
4743
+ if (!deploy) {
4744
+ return { status: "missing" };
4745
+ }
4746
+ if (!deploy.ownerId) {
4747
+ return { deploy, status: "unclaimed" };
4748
+ }
4749
+ if (deploy.ownerId !== ownerId) {
4750
+ return { deploy, status: "forbidden" };
4751
+ }
4752
+ if (deploy.status !== "active" || isExpired(deploy)) {
4753
+ return { deploy, status: "inactive" };
4754
+ }
4755
+
4756
+ await this.lockHostnameAllocation(client, hostname);
4757
+
4758
+ const existingResult = await client.query("select * from deploy_domains where hostname = $1", [hostname]);
4759
+ const existing = this.rowToDeployDomain(existingResult.rows[0]);
4760
+ if (existing) {
4761
+ if (existing.deployId === deployId) {
4762
+ return { deploy, domain: existing, status: "exists" };
4763
+ }
4764
+
4765
+ return { deploy, domain: existing, status: "conflict" };
4766
+ }
4767
+
4768
+ const slugConflict = await client.query("select id from deploys where slug = $1 limit 1", [label]);
4769
+ if (slugConflict.rows.length > 0) {
4770
+ return { deploy, status: "slug_conflict" };
4771
+ }
4772
+
4773
+ const timestamp = now();
4774
+ const insertResult = await client.query(
4775
+ `
4776
+ insert into deploy_domains(hostname, deploy_id, owner_id, kind, status, is_primary, created_at, updated_at)
4777
+ values($1, $2, $3, 'lakebed_subdomain', 'active', false, $4, $4)
4778
+ returning *
4779
+ `,
4780
+ [hostname, deployId, ownerId, timestamp]
4781
+ );
4782
+ return { deploy, domain: this.rowToDeployDomain(insertResult.rows[0]), status: "created" };
4783
+ });
4784
+ } catch (error) {
4785
+ if (error?.code !== "23505") {
4786
+ throw error;
4787
+ }
4788
+
4789
+ const deploy = await this.getBaseDeployById(deployId);
4790
+ if (!deploy) {
4791
+ return { status: "missing" };
4792
+ }
4793
+ const conflict = await this.getDeployDomainByHostname(hostname);
4794
+ if (conflict?.deployId === deployId) {
4795
+ return { deploy, domain: conflict, status: "exists" };
4796
+ }
4797
+
4798
+ return { deploy, domain: conflict, status: "conflict" };
4799
+ }
4800
+ }
4801
+
3522
4802
  async getArtifact(hash) {
3523
4803
  const result = await this.query("select * from artifacts where hash = $1", [hash]);
3524
4804
  const row = result.rows[0];
@@ -3599,8 +4879,46 @@ export class PostgresAnonymousStore {
3599
4879
  return Object.fromEntries(result.rows.map((row) => [row.env_key, decryptServerEnvValue(row.env_value, this.serverEnvSecret)]));
3600
4880
  }
3601
4881
 
3602
- async transaction(deployId, handler) {
3603
- const run = () => handler(this);
4882
+ async stateResourceForDeploy(deployId) {
4883
+ const result = await this.query(
4884
+ `
4885
+ select
4886
+ count(*)::int as state_rows,
4887
+ coalesce(sum(octet_length(data_json::text)), 0)::int as state_bytes
4888
+ from state_rows
4889
+ where deploy_id = $1
4890
+ `,
4891
+ [deployId]
4892
+ );
4893
+ return {
4894
+ stateBytes: result.rows[0]?.state_bytes ?? 0,
4895
+ stateRows: result.rows[0]?.state_rows ?? 0
4896
+ };
4897
+ }
4898
+
4899
+ async assertStateWithinLimits(deployId, options) {
4900
+ assertStateResourceLimits(await this.stateResourceForDeploy(deployId), options);
4901
+ }
4902
+
4903
+ async transaction(deployId, handler, options = {}) {
4904
+ const run = async () => {
4905
+ const client = await this.pool.connect();
4906
+ const tx = Object.create(this);
4907
+ tx.query = (sql, params = []) => client.query(sql, params);
4908
+ tx.pool = this.pool;
4909
+ try {
4910
+ await client.query("begin");
4911
+ const result = await handler(tx);
4912
+ await tx.assertStateWithinLimits(deployId, options);
4913
+ await client.query("commit");
4914
+ return result;
4915
+ } catch (error) {
4916
+ await client.query("rollback").catch(() => undefined);
4917
+ throw error;
4918
+ } finally {
4919
+ client.release();
4920
+ }
4921
+ };
3604
4922
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
3605
4923
  this.queues.set(
3606
4924
  deployId,
@@ -3613,9 +4931,29 @@ export class PostgresAnonymousStore {
3613
4931
  }
3614
4932
 
3615
4933
  async appendLog(deployId, level, message, data) {
4934
+ const entry = normalizeLogEntry(level, message, data);
3616
4935
  await this.query(
3617
4936
  "insert into logs(deploy_id, level, message, data_json, created_at) values($1, $2, $3, $4::jsonb, $5)",
3618
- [deployId, level, message, JSON.stringify(data ?? null), now()]
4937
+ [deployId, entry.level, entry.message, JSON.stringify(entry.data ?? null), entry.at]
4938
+ );
4939
+ await this.query(
4940
+ `
4941
+ delete from logs
4942
+ where deploy_id = $1
4943
+ and sequence in (
4944
+ select sequence
4945
+ from (
4946
+ select
4947
+ sequence,
4948
+ row_number() over (order by sequence desc) as row_number,
4949
+ sum(octet_length(message) + octet_length(coalesce(data_json::text, ''))) over (order by sequence desc) as cumulative_bytes
4950
+ from logs
4951
+ where deploy_id = $1
4952
+ ) ranked
4953
+ where row_number > $2 or cumulative_bytes > $3
4954
+ )
4955
+ `,
4956
+ [deployId, DEFAULT_ANONYMOUS_LIMITS.logEntries, DEFAULT_ANONYMOUS_LIMITS.logBytes]
3619
4957
  );
3620
4958
  }
3621
4959
 
@@ -3668,6 +5006,10 @@ export class PostgresAnonymousStore {
3668
5006
  }
3669
5007
 
3670
5008
  async incrementQuota(deployId, bucket, limit) {
5009
+ if (!Number.isFinite(limit)) {
5010
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
5011
+ }
5012
+
3671
5013
  const windowStart = dayWindowStart();
3672
5014
  const result = await this.query(
3673
5015
  `
@@ -3681,7 +5023,74 @@ export class PostgresAnonymousStore {
3681
5023
  );
3682
5024
  const count = result.rows[0].count;
3683
5025
  if (count > limit) {
3684
- throw new Error(`Anonymous ${bucket} quota exceeded. Limit: ${limit} per day.`);
5026
+ throw new LakebedQuotaError({
5027
+ bucket,
5028
+ count,
5029
+ limit,
5030
+ resetAt: quotaResetAt(windowStart),
5031
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
5032
+ suggestion: bucket === "mutations" ? "Retry after reset, reduce mutation frequency, or claim the deploy." : "Retry after reset or reduce request frequency."
5033
+ });
5034
+ }
5035
+ return { bucket, count, limit, windowStart };
5036
+ }
5037
+
5038
+ async incrementDeployCreateQuota(clientKey, bucket, limit) {
5039
+ if (!Number.isFinite(limit)) {
5040
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
5041
+ }
5042
+
5043
+ const windowStart = dayWindowStart();
5044
+ const result = await this.query(
5045
+ `
5046
+ insert into deploy_create_quota_events(client_key, bucket, window_start, count)
5047
+ values($1, $2, $3, 1)
5048
+ on conflict(client_key, bucket, window_start)
5049
+ do update set count = deploy_create_quota_events.count + 1
5050
+ returning count
5051
+ `,
5052
+ [clientKey, bucket, windowStart]
5053
+ );
5054
+ const count = result.rows[0].count;
5055
+ if (count > limit) {
5056
+ throw new LakebedQuotaError({
5057
+ bucket,
5058
+ count,
5059
+ limit,
5060
+ resetAt: quotaResetAt(windowStart),
5061
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
5062
+ suggestion: "Retry after reset, reuse an existing deploy, or sign in and claim deploys you want to keep."
5063
+ });
5064
+ }
5065
+ return { bucket, count, limit, windowStart };
5066
+ }
5067
+
5068
+ async incrementClientQuota(clientKey, bucket, limit) {
5069
+ if (!Number.isFinite(limit)) {
5070
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
5071
+ }
5072
+
5073
+ const windowStart = dayWindowStart();
5074
+ const result = await this.query(
5075
+ `
5076
+ insert into client_quota_events(client_key, bucket, window_start, count)
5077
+ values($1, $2, $3, 1)
5078
+ on conflict(client_key, bucket, window_start)
5079
+ do update set count = client_quota_events.count + 1
5080
+ returning count
5081
+ `,
5082
+ [clientKey, bucket, windowStart]
5083
+ );
5084
+ const count = result.rows[0].count;
5085
+ if (count > limit) {
5086
+ throw new LakebedQuotaError({
5087
+ bucket,
5088
+ count,
5089
+ limit,
5090
+ resetAt: quotaResetAt(windowStart),
5091
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
5092
+ suggestion: clientTrafficQuotaSuggestion(bucket)
5093
+ });
3685
5094
  }
3686
5095
  return { bucket, count, limit, windowStart };
3687
5096
  }
@@ -3795,6 +5204,109 @@ export class PostgresAnonymousStore {
3795
5204
  })
3796
5205
  );
3797
5206
  }
5207
+
5208
+ async recordCleanupRun(stats) {
5209
+ await this.query(
5210
+ "insert into cleanup_runs(started_at, finished_at, stats_json) values($1, $2, $3::jsonb)",
5211
+ [stats.startedAt, stats.finishedAt, JSON.stringify(stats)]
5212
+ );
5213
+ }
5214
+
5215
+ async cleanupExpiredDeploys({ graceSeconds = 60 * 60, retentionSeconds = 7 * 24 * 60 * 60, nowMs = Date.now() } = {}) {
5216
+ const startedAt = new Date(nowMs).toISOString();
5217
+ const finishedAt = now();
5218
+ const markCutoff = new Date(nowMs - graceSeconds * 1000).toISOString();
5219
+ const deleteCutoff = new Date(nowMs - retentionSeconds * 1000).toISOString();
5220
+ const examined = await this.query("select count(*)::int as count from deploys");
5221
+ const marked = await this.query(
5222
+ `
5223
+ update deploys
5224
+ set status = 'terminated',
5225
+ updated_at = $2
5226
+ where owner_id is null
5227
+ and expires_at is not null
5228
+ and expires_at <= $1
5229
+ and status = 'active'
5230
+ returning id
5231
+ `,
5232
+ [markCutoff, finishedAt]
5233
+ );
5234
+ const candidates = await this.query(
5235
+ `
5236
+ select id, artifact_hash
5237
+ from deploys
5238
+ where owner_id is null
5239
+ and expires_at is not null
5240
+ and expires_at <= $1
5241
+ `,
5242
+ [deleteCutoff]
5243
+ );
5244
+ const stats = {
5245
+ deletedArtifacts: 0,
5246
+ deletedDeploys: 0,
5247
+ deletedLogEntries: 0,
5248
+ deletedQuotaEvents: 0,
5249
+ deletedServerEnv: 0,
5250
+ deletedStateRows: 0,
5251
+ examinedDeploys: examined.rows[0]?.count ?? 0,
5252
+ finishedAt,
5253
+ markedTerminated: marked.rowCount,
5254
+ startedAt
5255
+ };
5256
+
5257
+ for (const deploy of candidates.rows) {
5258
+ const stateRows = await this.query("delete from state_rows where deploy_id = $1", [deploy.id]);
5259
+ const logs = await this.query("delete from logs where deploy_id = $1", [deploy.id]);
5260
+ const quota = await this.query("delete from quota_events where deploy_id = $1", [deploy.id]);
5261
+ const serverEnv = await this.query("delete from deploy_server_env where deploy_id = $1", [deploy.id]);
5262
+ await this.query("delete from deploys where id = $1", [deploy.id]);
5263
+ stats.deletedDeploys += 1;
5264
+ stats.deletedStateRows += stateRows.rowCount;
5265
+ stats.deletedLogEntries += logs.rowCount;
5266
+ stats.deletedQuotaEvents += quota.rowCount;
5267
+ stats.deletedServerEnv += serverEnv.rowCount;
5268
+ stats.deletedArtifacts += (await this.decrementArtifactRef(deploy.artifact_hash)) ? 1 : 0;
5269
+ }
5270
+ const deployCreateQuota = await this.query("delete from deploy_create_quota_events where window_start <= $1", [deleteCutoff]);
5271
+ stats.deletedQuotaEvents += deployCreateQuota.rowCount;
5272
+ const clientQuota = await this.query("delete from client_quota_events where window_start <= $1", [deleteCutoff]);
5273
+ stats.deletedQuotaEvents += clientQuota.rowCount;
5274
+
5275
+ await this.recordCleanupRun(stats);
5276
+ return stats;
5277
+ }
5278
+
5279
+ async readCleanupActivity() {
5280
+ const result = await this.query("select stats_json from cleanup_runs order by id desc limit 20");
5281
+ const recentRuns = result.rows.map((row) => row.stats_json);
5282
+ const totalsResult = await this.query(`
5283
+ select
5284
+ coalesce(sum((stats_json->>'deletedArtifacts')::int), 0)::int as deleted_artifacts,
5285
+ coalesce(sum((stats_json->>'deletedDeploys')::int), 0)::int as deleted_deploys,
5286
+ coalesce(sum((stats_json->>'deletedLogEntries')::int), 0)::int as deleted_log_entries,
5287
+ coalesce(sum((stats_json->>'deletedQuotaEvents')::int), 0)::int as deleted_quota_events,
5288
+ coalesce(sum((stats_json->>'deletedServerEnv')::int), 0)::int as deleted_server_env,
5289
+ coalesce(sum((stats_json->>'deletedStateRows')::int), 0)::int as deleted_state_rows,
5290
+ coalesce(sum((stats_json->>'examinedDeploys')::int), 0)::int as examined_deploys,
5291
+ coalesce(sum((stats_json->>'markedTerminated')::int), 0)::int as marked_terminated
5292
+ from cleanup_runs
5293
+ `);
5294
+ const totalsRow = totalsResult.rows[0] ?? {};
5295
+ return {
5296
+ lastRun: recentRuns[0] ?? null,
5297
+ recentRuns,
5298
+ totals: {
5299
+ deletedArtifacts: totalsRow.deleted_artifacts ?? 0,
5300
+ deletedDeploys: totalsRow.deleted_deploys ?? 0,
5301
+ deletedLogEntries: totalsRow.deleted_log_entries ?? 0,
5302
+ deletedQuotaEvents: totalsRow.deleted_quota_events ?? 0,
5303
+ deletedServerEnv: totalsRow.deleted_server_env ?? 0,
5304
+ deletedStateRows: totalsRow.deleted_state_rows ?? 0,
5305
+ examinedDeploys: totalsRow.examined_deploys ?? 0,
5306
+ markedTerminated: totalsRow.marked_terminated ?? 0
5307
+ }
5308
+ };
5309
+ }
3798
5310
  }
3799
5311
 
3800
5312
  export async function createAnonymousStoreFromEnv(env = process.env) {
@@ -3813,12 +5325,30 @@ export async function createAnonymousStoreFromEnv(env = process.env) {
3813
5325
  }
3814
5326
 
3815
5327
  async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
3816
- const route = parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
5328
+ const hostname = hostnameFromHost(host);
5329
+ const domain = hostname && typeof store.getDeployDomainByHostname === "function" ? await store.getDeployDomainByHostname(hostname) : null;
5330
+ const route = domain
5331
+ ? {
5332
+ appPath: url.pathname || "/",
5333
+ basePath: "",
5334
+ domain,
5335
+ hostname: domain.hostname,
5336
+ deployId: domain.deployId
5337
+ }
5338
+ : parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
3817
5339
  if (!route) {
3818
5340
  return null;
3819
5341
  }
3820
5342
 
3821
- const deploy = await store.getDeployBySlug(route.slug);
5343
+ if (domain?.status && domain.status !== "active") {
5344
+ return {
5345
+ error: domain.status === "disabled" ? "Lakebed subdomain is disabled." : "Lakebed subdomain is not active.",
5346
+ route,
5347
+ status: domain.status === "disabled" ? 410 : 404
5348
+ };
5349
+ }
5350
+
5351
+ const deploy = route.deployId ? await store.getDeployById(route.deployId) : await store.getDeployBySlug(route.slug);
3822
5352
  if (!deploy) {
3823
5353
  return { error: "Unknown anonymous deploy.", route, status: 404 };
3824
5354
  }
@@ -3845,6 +5375,10 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
3845
5375
  artifactHash: deploy.artifactHash,
3846
5376
  clientBundleHash: deploy.clientBundleHash,
3847
5377
  deployId: deploy.id,
5378
+ domains:
5379
+ typeof store.listDeployDomainsForDeploy === "function"
5380
+ ? (await store.listDeployDomainsForDeploy(deploy.id)).map(responseForDeployDomain)
5381
+ : [],
3848
5382
  expiresAt: deploy.expiresAt,
3849
5383
  limits: deploy.limits,
3850
5384
  mutations: Object.keys(artifact.server.mutations ?? {}),
@@ -3910,6 +5444,9 @@ export async function startAnonymousServer({
3910
5444
  } = {}) {
3911
5445
  const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
3912
5446
  const resolvedPublicRootUrl = normalizePublicRootUrl(publicRootUrl ?? process.env.PUBLIC_ROOT_URL, port);
5447
+ const deployCreationPolicy = anonymousDeployCreationPolicy({ publicRootUrl: resolvedPublicRootUrl });
5448
+ const clientTrafficPolicy = anonymousClientTrafficPolicy({ publicRootUrl: resolvedPublicRootUrl });
5449
+ const cleanupPolicy = cleanupPolicyFromEnv();
3913
5450
  const resolvedGithubOAuth = normalizeGithubOAuth(githubOAuth);
3914
5451
  const resolvedDeveloperSessionSecret =
3915
5452
  developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
@@ -3917,6 +5454,7 @@ export async function startAnonymousServer({
3917
5454
  const resolvedSourceRuntime = sourceRuntime === undefined ? createSourceRuntimeFromEnv() : sourceRuntime;
3918
5455
  await resolvedStore.initialize();
3919
5456
  const subscriptions = new Map();
5457
+ let cleanupInterval = null;
3920
5458
 
3921
5459
  function activeConnectionCounts() {
3922
5460
  const counts = new Map();
@@ -3937,6 +5475,10 @@ export async function startAnonymousServer({
3937
5475
  async function adminSummary() {
3938
5476
  const deploys = await adminDeploysWithConnections();
3939
5477
  const users = await resolvedStore.listAdminUsers();
5478
+ const cleanup =
5479
+ typeof resolvedStore.readCleanupActivity === "function"
5480
+ ? await resolvedStore.readCleanupActivity()
5481
+ : { lastRun: null, recentRuns: [], totals: {} };
3940
5482
  const totals = deploys.reduce(
3941
5483
  (acc, deploy) => ({
3942
5484
  artifactBytes: acc.artifactBytes + deploy.artifactBytes,
@@ -3963,6 +5505,7 @@ export async function startAnonymousServer({
3963
5505
  return {
3964
5506
  deployCount: deploys.length,
3965
5507
  deploys,
5508
+ cleanup,
3966
5509
  generatedAt: now(),
3967
5510
  totals,
3968
5511
  userCount: users.length,
@@ -3989,10 +5532,50 @@ export async function startAnonymousServer({
3989
5532
  return developerFromRequest(req, resolvedDeveloperSessionSecret);
3990
5533
  }
3991
5534
 
5535
+ function canManageDeploy(req, deploy) {
5536
+ if (isDeployTokenValid(deploy, bearerToken(req))) {
5537
+ return true;
5538
+ }
5539
+
5540
+ const user = currentDeveloper(req);
5541
+ return Boolean(user?.id && deploy.ownerId && user.id === deploy.ownerId);
5542
+ }
5543
+
3992
5544
  async function developerDeploys(user) {
3993
5545
  return resolvedStore.listDeploysForOwner(user.id);
3994
5546
  }
3995
5547
 
5548
+ async function enforceAnonymousDeployCreation(req) {
5549
+ if (deployCreationPolicy.disabled) {
5550
+ const error = new LakebedQuotaError({
5551
+ bucket: "anonymous_deploy_create_global",
5552
+ count: deployCreationPolicy.globalLimit,
5553
+ limit: deployCreationPolicy.globalLimit,
5554
+ status: 503,
5555
+ suggestion: "Anonymous deploy creation is temporarily disabled. Retry later or claim an existing deploy."
5556
+ });
5557
+ error.code = "lakebed_deploy_creation_disabled";
5558
+ error.message = "Anonymous deploy creation is temporarily disabled.";
5559
+ throw error;
5560
+ }
5561
+
5562
+ const clientKey = forwardedClientKey(req);
5563
+ await resolvedStore.incrementDeployCreateQuota(clientKey, "anonymous_deploy_create_client", deployCreationPolicy.perClientLimit);
5564
+ await resolvedStore.incrementDeployCreateQuota("global", "anonymous_deploy_create_global", deployCreationPolicy.globalLimit);
5565
+ }
5566
+
5567
+ async function enforceClientTrafficQuota(clientKey, bucket) {
5568
+ if (typeof resolvedStore.incrementClientQuota !== "function") {
5569
+ return;
5570
+ }
5571
+
5572
+ const limit =
5573
+ bucket === "mutations"
5574
+ ? clientTrafficPolicy.mutationsPerClientLimit
5575
+ : clientTrafficPolicy.requestsPerClientLimit;
5576
+ await resolvedStore.incrementClientQuota(clientKey, clientTrafficQuotaBucket(bucket), limit);
5577
+ }
5578
+
3996
5579
  async function refreshDeploySubscriptions(deploy) {
3997
5580
  const storedArtifact = await resolvedStore.getArtifact(deploy.artifactHash);
3998
5581
  if (!storedArtifact) {
@@ -4032,6 +5615,30 @@ export async function startAnonymousServer({
4032
5615
  }
4033
5616
  }
4034
5617
 
5618
+ function cleanupTouchedResources(stats) {
5619
+ return (
5620
+ Number(stats?.markedTerminated ?? 0) +
5621
+ Number(stats?.deletedDeploys ?? 0) +
5622
+ Number(stats?.deletedStateRows ?? 0) +
5623
+ Number(stats?.deletedLogEntries ?? 0) +
5624
+ Number(stats?.deletedArtifacts ?? 0)
5625
+ );
5626
+ }
5627
+
5628
+ async function runCleanup(reason = "periodic") {
5629
+ if (typeof resolvedStore.cleanupExpiredDeploys !== "function") {
5630
+ return null;
5631
+ }
5632
+
5633
+ const stats = await resolvedStore.cleanupExpiredDeploys(cleanupPolicy);
5634
+ if (!quiet && cleanupTouchedResources(stats) > 0) {
5635
+ console.log(
5636
+ `[lakebed:cleanup] ${reason} marked=${stats.markedTerminated} deleted=${stats.deletedDeploys} stateRows=${stats.deletedStateRows} logs=${stats.deletedLogEntries} artifacts=${stats.deletedArtifacts}`
5637
+ );
5638
+ }
5639
+ return stats;
5640
+ }
5641
+
4035
5642
  async function publishDeploy(deployId) {
4036
5643
  for (const [ws, subscription] of subscriptions) {
4037
5644
  if (subscription.deploy.id !== deployId) {
@@ -4378,7 +5985,92 @@ export async function startAnonymousServer({
4378
5985
  return;
4379
5986
  }
4380
5987
 
5988
+ const deployDomainsMatch = requestUrl.pathname.match(/^\/v1\/deploys\/([^/]+)\/domains$/);
5989
+ if (deployDomainsMatch && (req.method === "GET" || req.method === "POST")) {
5990
+ const deployId = decodeURIComponent(deployDomainsMatch[1]);
5991
+ const currentDeploy = await resolvedStore.getDeployById(deployId);
5992
+ if (!currentDeploy) {
5993
+ sendJson(res, 404, { error: "Unknown deploy." });
5994
+ return;
5995
+ }
5996
+
5997
+ if (!canManageDeploy(req, currentDeploy)) {
5998
+ sendJson(res, 401, { error: "Invalid deploy token." });
5999
+ return;
6000
+ }
6001
+
6002
+ if (req.method === "GET") {
6003
+ sendJson(res, 200, {
6004
+ deployId: currentDeploy.id,
6005
+ domains:
6006
+ typeof resolvedStore.listDeployDomainsForDeploy === "function"
6007
+ ? (await resolvedStore.listDeployDomainsForDeploy(currentDeploy.id)).map(responseForDeployDomain)
6008
+ : []
6009
+ });
6010
+ return;
6011
+ }
6012
+
6013
+ if (!currentDeploy.ownerId) {
6014
+ sendJson(res, 409, { error: "Deploy must be claimed before registering Lakebed subdomains." });
6015
+ return;
6016
+ }
6017
+ if (currentDeploy.status !== "active" || isExpired(currentDeploy)) {
6018
+ sendJson(res, 409, { error: "Deploy must be active to register Lakebed subdomains." });
6019
+ return;
6020
+ }
6021
+
6022
+ let normalizedDomain;
6023
+ try {
6024
+ const body = await readJsonBody(req, 8192);
6025
+ normalizedDomain = normalizeLakebedSubdomainInput(body.hostname ?? body.subdomain, resolvedAppBaseDomain);
6026
+ } catch (error) {
6027
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
6028
+ return;
6029
+ }
6030
+
6031
+ const created = await resolvedStore.createLakebedSubdomain({
6032
+ deployId: currentDeploy.id,
6033
+ hostname: normalizedDomain.hostname,
6034
+ label: normalizedDomain.label,
6035
+ ownerId: currentDeploy.ownerId
6036
+ });
6037
+ if (created.status === "missing") {
6038
+ sendJson(res, 404, { error: "Unknown deploy." });
6039
+ return;
6040
+ }
6041
+ if (created.status === "unclaimed") {
6042
+ sendJson(res, 409, { error: "Deploy must be claimed before registering Lakebed subdomains." });
6043
+ return;
6044
+ }
6045
+ if (created.status === "forbidden") {
6046
+ sendJson(res, 403, { error: "Deploy is claimed by another developer." });
6047
+ return;
6048
+ }
6049
+ if (created.status === "inactive") {
6050
+ sendJson(res, 409, { error: "Deploy must be active to register Lakebed subdomains." });
6051
+ return;
6052
+ }
6053
+ if (created.status === "conflict") {
6054
+ sendJson(res, 409, { error: "Subdomain is already registered." });
6055
+ return;
6056
+ }
6057
+ if (created.status === "slug_conflict") {
6058
+ sendJson(res, 409, { error: "Subdomain is already used by a generated Lakebed deploy URL." });
6059
+ return;
6060
+ }
6061
+
6062
+ if (created.status === "created") {
6063
+ await resolvedStore.appendLog(currentDeploy.id, "info", "lakebed subdomain registered", {
6064
+ hostname: created.domain.hostname
6065
+ });
6066
+ }
6067
+
6068
+ sendJson(res, created.status === "created" ? 201 : 200, responseForDeployDomain(created.domain));
6069
+ return;
6070
+ }
6071
+
4381
6072
  if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
6073
+ await enforceAnonymousDeployCreation(req);
4382
6074
  const body = await readJsonBody(req);
4383
6075
  const payload = validateAnonymousDeployPayload(body);
4384
6076
  if (payload.serverEnv !== undefined && Object.keys(payload.serverEnv).length > 0) {
@@ -4463,6 +6155,8 @@ export async function startAnonymousServer({
4463
6155
  return;
4464
6156
  }
4465
6157
 
6158
+ const clientKey = forwardedClientKey(req);
6159
+ await enforceClientTrafficQuota(clientKey, "requests");
4466
6160
  await resolvedStore.incrementQuota(
4467
6161
  loaded.deploy.id,
4468
6162
  "requests",
@@ -4492,16 +6186,28 @@ export async function startAnonymousServer({
4492
6186
 
4493
6187
  sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
4494
6188
  } catch (error) {
6189
+ if (isQuotaError(error)) {
6190
+ sendJson(
6191
+ res,
6192
+ error.status ?? 429,
6193
+ quotaErrorBody(error, {
6194
+ signInUrl: `${resolvedPublicRootUrl}/auth/github?return_to=${encodeURIComponent("/deploys")}`
6195
+ }),
6196
+ quotaErrorHeaders(error)
6197
+ );
6198
+ return;
6199
+ }
4495
6200
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
4496
6201
  }
4497
6202
  });
4498
6203
 
4499
6204
  const wss = new WebSocketServer({ noServer: true });
4500
6205
 
4501
- wss.on("connection", (ws, _req, loaded, auth) => {
6206
+ wss.on("connection", (ws, req, loaded, auth) => {
4502
6207
  subscriptions.set(ws, {
4503
6208
  artifact: loaded.artifact,
4504
6209
  auth,
6210
+ clientKey: forwardedClientKey(req),
4505
6211
  deploy: loaded.deploy,
4506
6212
  queries: new Set()
4507
6213
  });
@@ -4522,6 +6228,7 @@ export async function startAnonymousServer({
4522
6228
  }
4523
6229
 
4524
6230
  try {
6231
+ await enforceClientTrafficQuota(subscription.clientKey, "requests");
4525
6232
  await resolvedStore.incrementQuota(
4526
6233
  subscription.deploy.id,
4527
6234
  "requests",
@@ -4548,6 +6255,7 @@ export async function startAnonymousServer({
4548
6255
  }
4549
6256
 
4550
6257
  if (message.op === "mutation.run") {
6258
+ await enforceClientTrafficQuota(subscription.clientKey, "mutations");
4551
6259
  await resolvedStore.incrementQuota(
4552
6260
  subscription.deploy.id,
4553
6261
  "mutations",
@@ -4574,7 +6282,14 @@ export async function startAnonymousServer({
4574
6282
  error: error instanceof Error ? error.message : String(error),
4575
6283
  op: message?.op
4576
6284
  });
6285
+ const quota = isQuotaError(error) ? quotaErrorBody(error) : null;
4577
6286
  websocketSend(ws, {
6287
+ ...(quota
6288
+ ? {
6289
+ code: quota.code,
6290
+ quota
6291
+ }
6292
+ : {}),
4578
6293
  error: error instanceof Error ? error.message : String(error),
4579
6294
  id: message?.id,
4580
6295
  ok: false,
@@ -4621,6 +6336,20 @@ export async function startAnonymousServer({
4621
6336
  });
4622
6337
  });
4623
6338
 
6339
+ void runCleanup("startup").catch((error) => {
6340
+ if (!quiet) {
6341
+ console.error(`[lakebed:cleanup] startup failed: ${error instanceof Error ? error.message : String(error)}`);
6342
+ }
6343
+ });
6344
+ cleanupInterval = setInterval(() => {
6345
+ void runCleanup("periodic").catch((error) => {
6346
+ if (!quiet) {
6347
+ console.error(`[lakebed:cleanup] periodic failed: ${error instanceof Error ? error.message : String(error)}`);
6348
+ }
6349
+ });
6350
+ }, cleanupPolicy.intervalSeconds * 1000);
6351
+ cleanupInterval.unref?.();
6352
+
4624
6353
  if (!quiet) {
4625
6354
  console.log(`Lakebed anonymous runner listening at ${resolvedPublicRootUrl}`);
4626
6355
  }
@@ -4632,6 +6361,9 @@ export async function startAnonymousServer({
4632
6361
  store: resolvedStore,
4633
6362
  url: resolvedPublicRootUrl,
4634
6363
  async close() {
6364
+ if (cleanupInterval) {
6365
+ clearInterval(cleanupInterval);
6366
+ }
4635
6367
  for (const client of wss.clients) {
4636
6368
  client.close();
4637
6369
  }