lakebed 0.0.14 → 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,
@@ -19,6 +21,11 @@ function now() {
19
21
  return new Date().toISOString();
20
22
  }
21
23
 
24
+ function anonymousDeployExpiresAt() {
25
+ const ttlSeconds = parseTtlSeconds();
26
+ return new Date(Date.now() + ttlSeconds * 1000).toISOString();
27
+ }
28
+
22
29
  function dayWindowStart() {
23
30
  return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
24
31
  }
@@ -160,6 +167,106 @@ function normalizeAppBaseDomain(value) {
160
167
  .toLowerCase();
161
168
  }
162
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
+
163
270
  function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
164
271
  if (appBaseDomain) {
165
272
  return `https://${slug}.${appBaseDomain}`;
@@ -195,6 +302,19 @@ function responseForDeploy({ deploy, token }) {
195
302
  };
196
303
  }
197
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
+
198
318
  function isExpired(deploy) {
199
319
  if (deploy?.ownerId) {
200
320
  return false;
@@ -235,14 +355,14 @@ function parseHostDeploy({ appBaseDomain, host, url }) {
235
355
  return null;
236
356
  }
237
357
 
238
- const hostname = host.split(":")[0].toLowerCase();
358
+ const hostname = hostnameFromHost(host);
239
359
  const suffix = `.${appBaseDomain.toLowerCase()}`;
240
360
  if (!hostname.endsWith(suffix)) {
241
361
  return null;
242
362
  }
243
363
 
244
364
  const slug = hostname.slice(0, -suffix.length);
245
- if (!slug || slug === "www") {
365
+ if (!slug || slug.includes(".") || reservedLakebedSubdomainLabels.has(slug)) {
246
366
  return null;
247
367
  }
248
368
 
@@ -261,6 +381,138 @@ function quotaLimitForBucket(bucket, deploy) {
261
381
  return deploy.limits.requestsPerDay;
262
382
  }
263
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
+
264
516
  const USER_LIMIT_OVERRIDE_KEYS = ["requestsPerDay", "mutationsPerDay"];
265
517
  const USER_BOOST_MULTIPLIER = 20;
266
518
 
@@ -660,6 +912,193 @@ function bytesOfJson(value) {
660
912
  return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
661
913
  }
662
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
+
663
1102
  function usageCounts(usage) {
664
1103
  const windowStart = dayWindowStart();
665
1104
  const counts = {
@@ -891,7 +1330,7 @@ function adminHtml() {
891
1330
  .metrics {
892
1331
  display: grid;
893
1332
  gap: 8px;
894
- grid-template-columns: repeat(6, minmax(120px, 1fr));
1333
+ grid-template-columns: repeat(8, minmax(120px, 1fr));
895
1334
  margin-bottom: 14px;
896
1335
  }
897
1336
 
@@ -1273,8 +1712,10 @@ function adminHtml() {
1273
1712
  <div class="metric"><span>artifact bytes</span><strong id="metric-artifacts">0 B</strong></div>
1274
1713
  <div class="metric"><span>state bytes</span><strong id="metric-state">0 B</strong></div>
1275
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>
1276
1716
  <div class="metric"><span>requests today</span><strong id="metric-requests">0</strong></div>
1277
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>
1278
1719
  </section>
1279
1720
 
1280
1721
  <section class="panel" id="deployments-view">
@@ -1875,8 +2316,10 @@ function adminHtml() {
1875
2316
  setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
1876
2317
  setMetric("metric-state", formatBytes(summary.totals.stateBytes));
1877
2318
  setMetric("metric-rows", formatNumber(summary.totals.stateRows));
2319
+ setMetric("metric-logs", formatBytes(summary.totals.logBytes));
1878
2320
  setMetric("metric-requests", formatNumber(summary.totals.requestsToday));
1879
2321
  setMetric("metric-mutations", formatNumber(summary.totals.mutationsToday));
2322
+ setMetric("metric-cleanup", formatNumber(summary.cleanup?.totals?.deletedDeploys || 0));
1880
2323
  statusLine.textContent = "Updated " + formatTime(summary.generatedAt);
1881
2324
  }
1882
2325
 
@@ -2220,12 +2663,109 @@ function escapeHtml(value) {
2220
2663
  .replace(/"/g, "&quot;");
2221
2664
  }
2222
2665
 
2223
- function formatHtmlTime(value) {
2224
- 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));
2225
2758
  }
2226
2759
 
2227
- function formatHtmlExpiry(value) {
2228
- 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>`;
2229
2769
  }
2230
2770
 
2231
2771
  function developerDeploySummary({ artifact, deploy, usage }) {
@@ -2250,18 +2790,34 @@ function developerDeploySummary({ artifact, deploy, usage }) {
2250
2790
 
2251
2791
  function developerHtml({ authConfigured, deploys = [], user }) {
2252
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);
2253
2796
  const rows = deploys
2254
- .map(
2255
- (deploy) => `<tr>
2256
- <td><a href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a><small>${escapeHtml(deploy.deployId)} / ${escapeHtml(deploy.slug)}</small></td>
2257
- <td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
2258
- <td>${escapeHtml(formatHtmlTime(deploy.createdAt))}</td>
2259
- <td>${escapeHtml(formatHtmlTime(deploy.updatedAt))}</td>
2260
- <td>${escapeHtml(formatHtmlExpiry(deploy.expiresAt))}</td>
2261
- <td>${escapeHtml(deploy.usage.requestsToday)} / ${escapeHtml(deploy.limits.requestsPerDay)}</td>
2262
- <td>${escapeHtml(deploy.usage.mutationsToday)} / ${escapeHtml(deploy.limits.mutationsPerDay)}</td>
2263
- </tr>`
2264
- )
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
+ })
2265
2821
  .join("");
2266
2822
 
2267
2823
  return `<!doctype html>
@@ -2273,14 +2829,18 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2273
2829
  <style>
2274
2830
  :root {
2275
2831
  color-scheme: dark;
2276
- --bg: #11130f;
2277
- --panel: #191c16;
2278
- --line: #343b2e;
2279
- --text: #f2f0e8;
2280
- --muted: #a8ab9e;
2281
- --accent: #91d46f;
2282
- --bad: #ee7d71;
2283
- --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;
2284
2844
  }
2285
2845
 
2286
2846
  * {
@@ -2290,13 +2850,12 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2290
2850
  body {
2291
2851
  background: var(--bg);
2292
2852
  color: var(--text);
2293
- font-family: "Avenir Next", "Helvetica Neue", sans-serif;
2294
- letter-spacing: 0;
2853
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2295
2854
  margin: 0;
2296
2855
  }
2297
2856
 
2298
2857
  a {
2299
- color: var(--accent);
2858
+ color: inherit;
2300
2859
  text-decoration: none;
2301
2860
  }
2302
2861
 
@@ -2306,50 +2865,104 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2306
2865
 
2307
2866
  .shell {
2308
2867
  margin: 0 auto;
2309
- max-width: 1120px;
2868
+ max-width: 1560px;
2310
2869
  min-height: 100vh;
2311
- padding: 28px;
2870
+ padding: 34px 32px 56px;
2871
+ width: 100%;
2312
2872
  }
2313
2873
 
2314
2874
  header {
2315
2875
  align-items: center;
2876
+ border-bottom: 1px solid var(--line);
2316
2877
  display: flex;
2317
2878
  gap: 16px;
2318
2879
  justify-content: space-between;
2319
- margin-bottom: 24px;
2880
+ margin-bottom: 22px;
2881
+ padding-bottom: 22px;
2320
2882
  }
2321
2883
 
2322
2884
  h1 {
2323
- font-size: 32px;
2324
- line-height: 1;
2885
+ font-size: 40px;
2886
+ line-height: 1.05;
2325
2887
  margin: 0;
2326
2888
  }
2327
2889
 
2328
2890
  .eyebrow,
2329
- small,
2330
- th,
2331
- .meta {
2891
+ .meta,
2892
+ .deploy-ids,
2893
+ .activity-item span,
2894
+ .expiry,
2895
+ .list-head,
2896
+ .quota-label,
2897
+ .quota-count,
2898
+ .status {
2332
2899
  color: var(--muted);
2333
2900
  font-family: "SFMono-Regular", Consolas, monospace;
2334
2901
  font-size: 12px;
2335
2902
  }
2336
2903
 
2904
+ .eyebrow {
2905
+ color: var(--accent);
2906
+ margin-bottom: 6px;
2907
+ }
2908
+
2909
+ .meta {
2910
+ margin-top: 4px;
2911
+ }
2912
+
2337
2913
  .button {
2338
- background: var(--accent);
2339
- border: 1px solid var(--accent);
2914
+ align-items: center;
2915
+ background: transparent;
2916
+ border: 1px solid var(--line-strong);
2340
2917
  border-radius: 6px;
2341
- color: var(--ink);
2918
+ color: var(--text);
2342
2919
  display: inline-flex;
2343
2920
  font-weight: 700;
2344
2921
  min-height: 40px;
2345
2922
  padding: 10px 14px;
2346
2923
  }
2347
2924
 
2925
+ .button:hover {
2926
+ border-color: var(--accent);
2927
+ text-decoration: none;
2928
+ }
2929
+
2348
2930
  .button.secondary {
2349
2931
  background: transparent;
2350
2932
  color: var(--text);
2351
2933
  }
2352
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
+
2353
2966
  .panel {
2354
2967
  background: var(--panel);
2355
2968
  border: 1px solid var(--line);
@@ -2362,51 +2975,160 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2362
2975
  padding: 22px;
2363
2976
  }
2364
2977
 
2365
- table {
2366
- border-collapse: collapse;
2367
- width: 100%;
2978
+ .deploy-list {
2979
+ display: grid;
2368
2980
  }
2369
2981
 
2370
- th,
2371
- td {
2372
- border-bottom: 1px solid var(--line);
2373
- padding: 13px 14px;
2374
- text-align: left;
2375
- vertical-align: top;
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);
2376
2988
  }
2377
2989
 
2378
- 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 {
2379
3004
  border-bottom: 0;
2380
3005
  }
2381
3006
 
2382
- td:first-child {
2383
- display: grid;
2384
- gap: 4px;
3007
+ .deploy-row:hover {
3008
+ background: var(--panel-raised);
2385
3009
  }
2386
3010
 
2387
- .pill {
2388
- border: 1px solid var(--line);
2389
- border-radius: 999px;
2390
- display: inline-flex;
2391
- font-family: "SFMono-Regular", Consolas, monospace;
2392
- font-size: 12px;
2393
- padding: 3px 8px;
3011
+ .deploy-main,
3012
+ .activity,
3013
+ .usage-grid,
3014
+ .quota {
3015
+ min-width: 0;
2394
3016
  }
2395
3017
 
2396
- .pill.active {
2397
- 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;
2398
3047
  color: var(--accent);
3048
+ display: inline-flex;
3049
+ gap: 6px;
3050
+ white-space: nowrap;
2399
3051
  }
2400
3052
 
2401
- .pill.expired,
2402
- .pill.terminated {
2403
- border-color: rgba(238, 125, 113, 0.75);
2404
- 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;
2405
3120
  }
2406
3121
 
2407
- @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) {
2408
3130
  .shell {
2409
- padding: 18px;
3131
+ padding: 24px 18px 40px;
2410
3132
  }
2411
3133
 
2412
3134
  header {
@@ -2414,12 +3136,46 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2414
3136
  flex-direction: column;
2415
3137
  }
2416
3138
 
2417
- table {
2418
- 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;
2419
3155
  }
2420
3156
 
2421
- .panel {
2422
- 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;
2423
3179
  }
2424
3180
  }
2425
3181
  </style>
@@ -2442,6 +3198,15 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2442
3198
  }
2443
3199
  </div>
2444
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
+ }
2445
3210
 
2446
3211
  ${
2447
3212
  !authConfigured
@@ -2451,20 +3216,14 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2451
3216
  : deploys.length === 0
2452
3217
  ? `<section class="panel"><div class="empty">No claimed deployments.</div></section>`
2453
3218
  : `<section class="panel">
2454
- <table>
2455
- <thead>
2456
- <tr>
2457
- <th>Deploy</th>
2458
- <th>Status</th>
2459
- <th>Created</th>
2460
- <th>Updated</th>
2461
- <th>Expires</th>
2462
- <th>Requests</th>
2463
- <th>Mutations</th>
2464
- </tr>
2465
- </thead>
2466
- <tbody>${rows}</tbody>
2467
- </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>
2468
3227
  </section>`
2469
3228
  }
2470
3229
  </main>
@@ -2475,14 +3234,19 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2475
3234
  export class MemoryAnonymousStore {
2476
3235
  constructor() {
2477
3236
  this.artifacts = new Map();
3237
+ this.deployDomainsByHostname = new Map();
2478
3238
  this.deploys = new Map();
2479
3239
  this.deploysBySlug = new Map();
3240
+ this.deployCreateQuotaEvents = new Map();
3241
+ this.clientQuotaEvents = new Map();
2480
3242
  this.logs = new Map();
2481
3243
  this.quotaEvents = new Map();
2482
3244
  this.queues = new Map();
2483
3245
  this.rows = new Map();
2484
3246
  this.serverEnv = new Map();
2485
3247
  this.users = new Map();
3248
+ this.cleanupRuns = [];
3249
+ this.cleanupTotals = {};
2486
3250
  }
2487
3251
 
2488
3252
  async initialize() {}
@@ -2609,18 +3373,36 @@ export class MemoryAnonymousStore {
2609
3373
  });
2610
3374
  }
2611
3375
 
2612
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds, serverEnv }) {
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
+
3392
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
2613
3393
  const deployId = createDeployId();
2614
3394
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
2615
3395
  let slug = createSlug();
2616
- while (this.deploysBySlug.has(slug)) {
3396
+ while (
3397
+ this.deploysBySlug.has(slug) ||
3398
+ (normalizedAppBaseDomain && this.deployDomainsByHostname.has(`${slug}.${normalizedAppBaseDomain}`))
3399
+ ) {
2617
3400
  slug = createSlug();
2618
3401
  }
2619
3402
 
2620
3403
  const token = createClaimToken();
2621
3404
  const createdAt = now();
2622
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
2623
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
3405
+ const expiresAt = anonymousDeployExpiresAt();
2624
3406
  const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
2625
3407
 
2626
3408
  this.storeArtifact({
@@ -2665,7 +3447,6 @@ export class MemoryAnonymousStore {
2665
3447
  clientBundleHash,
2666
3448
  deployId,
2667
3449
  publicRootUrl,
2668
- requestedTtlSeconds,
2669
3450
  serverEnv
2670
3451
  }) {
2671
3452
  const currentDeploy = await this.getStoredDeployById(deployId);
@@ -2674,7 +3455,6 @@ export class MemoryAnonymousStore {
2674
3455
  }
2675
3456
 
2676
3457
  const updatedAt = now();
2677
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
2678
3458
  const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
2679
3459
  const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
2680
3460
  const deploy = {
@@ -2682,7 +3462,7 @@ export class MemoryAnonymousStore {
2682
3462
  appBaseDomain: nextAppBaseDomain,
2683
3463
  artifactHash,
2684
3464
  clientBundleHash,
2685
- expiresAt: currentDeploy.ownerId ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString(),
3465
+ expiresAt: currentDeploy.ownerId ? null : anonymousDeployExpiresAt(),
2686
3466
  publicRootUrl: nextPublicRootUrl,
2687
3467
  url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
2688
3468
  status: "active",
@@ -2697,6 +3477,7 @@ export class MemoryAnonymousStore {
2697
3477
  createdAt: updatedAt
2698
3478
  });
2699
3479
  this.deploys.set(deployId, deploy);
3480
+ this.decrementArtifactRef(currentDeploy.artifactHash);
2700
3481
  if (serverEnv !== undefined) {
2701
3482
  this.serverEnv.set(deployId, { ...serverEnv });
2702
3483
  }
@@ -2755,6 +3536,62 @@ export class MemoryAnonymousStore {
2755
3536
  return id ? this.getDeployById(id) : null;
2756
3537
  }
2757
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
+
2758
3595
  async getArtifact(hash) {
2759
3596
  return this.artifacts.get(hash) ?? null;
2760
3597
  }
@@ -2804,8 +3641,45 @@ export class MemoryAnonymousStore {
2804
3641
  return { ...(this.serverEnv.get(deployId) ?? {}) };
2805
3642
  }
2806
3643
 
2807
- async transaction(deployId, handler) {
2808
- 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
+ };
2809
3683
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
2810
3684
  this.queues.set(
2811
3685
  deployId,
@@ -2818,9 +3692,13 @@ export class MemoryAnonymousStore {
2818
3692
  }
2819
3693
 
2820
3694
  async appendLog(deployId, level, message, data) {
2821
- const entries = this.logs.get(deployId) ?? [];
2822
- entries.push({ at: now(), data, level, message });
2823
- 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);
2824
3702
  }
2825
3703
 
2826
3704
  async readLogs(deployId, limit = 100) {
@@ -2856,12 +3734,67 @@ export class MemoryAnonymousStore {
2856
3734
  }
2857
3735
 
2858
3736
  async incrementQuota(deployId, bucket, limit) {
3737
+ if (!Number.isFinite(limit)) {
3738
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
3739
+ }
3740
+
2859
3741
  const windowStart = dayWindowStart();
2860
3742
  const key = `${deployId}:${bucket}:${windowStart}`;
2861
3743
  const count = (this.quotaEvents.get(key) ?? 0) + 1;
2862
3744
  this.quotaEvents.set(key, count);
2863
3745
  if (count > limit) {
2864
- 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
+ });
2865
3798
  }
2866
3799
  return { bucket, count, limit, windowStart };
2867
3800
  }
@@ -2947,6 +3880,160 @@ export class MemoryAnonymousStore {
2947
3880
 
2948
3881
  return summaries;
2949
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
+ }
2950
4037
  }
2951
4038
 
2952
4039
  export class PostgresAnonymousStore {
@@ -2989,6 +4076,20 @@ export class PostgresAnonymousStore {
2989
4076
  await this.query("alter table deploys alter column updated_at set not null");
2990
4077
  await this.query("alter table deploys alter column expires_at drop not null");
2991
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)");
2992
4093
  await this.query(`
2993
4094
  create table if not exists artifacts(
2994
4095
  hash text primary key,
@@ -3030,6 +4131,24 @@ export class PostgresAnonymousStore {
3030
4131
  primary key (deploy_id, bucket, window_start)
3031
4132
  )
3032
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
+ `);
3033
4152
  await this.query(`
3034
4153
  create table if not exists deploy_server_env(
3035
4154
  deploy_id text not null references deploys(id) on delete cascade,
@@ -3054,6 +4173,14 @@ export class PostgresAnonymousStore {
3054
4173
  updated_at timestamptz not null
3055
4174
  )
3056
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
+ `);
3057
4184
  await this.query("alter table users add column if not exists boost boolean not null default false");
3058
4185
  await this.query(`
3059
4186
  insert into users(
@@ -3094,6 +4221,33 @@ export class PostgresAnonymousStore {
3094
4221
  return this.pool.query(sql, params);
3095
4222
  }
3096
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
+
3097
4251
  rowToUser(row) {
3098
4252
  if (!row) {
3099
4253
  return null;
@@ -3318,11 +4472,29 @@ export class PostgresAnonymousStore {
3318
4472
  );
3319
4473
  }
3320
4474
 
3321
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds, serverEnv }) {
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
+
4494
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
3322
4495
  const createdAt = now();
3323
4496
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
3324
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
3325
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
4497
+ const expiresAt = anonymousDeployExpiresAt();
3326
4498
  const token = createClaimToken();
3327
4499
  const deployId = createDeployId();
3328
4500
 
@@ -3330,6 +4502,8 @@ export class PostgresAnonymousStore {
3330
4502
 
3331
4503
  for (let attempt = 0; attempt < 8; attempt += 1) {
3332
4504
  const slug = createSlug();
4505
+ const generatedHostname = normalizedAppBaseDomain ? `${slug}.${normalizedAppBaseDomain}` : null;
4506
+
3333
4507
  const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
3334
4508
  const deploy = {
3335
4509
  appBaseDomain: normalizedAppBaseDomain,
@@ -3351,30 +4525,44 @@ export class PostgresAnonymousStore {
3351
4525
  };
3352
4526
 
3353
4527
  try {
3354
- await this.query(
3355
- `
3356
- insert into deploys(
3357
- id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
3358
- claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
3359
- )
3360
- values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
3361
- `,
3362
- [
3363
- deploy.id,
3364
- deploy.slug,
3365
- deploy.status,
3366
- deploy.artifactHash,
3367
- deploy.clientBundleHash,
3368
- deploy.createdAt,
3369
- deploy.updatedAt,
3370
- deploy.expiresAt,
3371
- deploy.claimTokenHash,
3372
- JSON.stringify(deploy.limits),
3373
- deploy.publicRootUrl,
3374
- deploy.appBaseDomain,
3375
- deploy.url
3376
- ]
3377
- );
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
+ }
3378
4566
  if (serverEnv !== undefined) {
3379
4567
  await this.replaceServerEnv(deploy.id, serverEnv, createdAt);
3380
4568
  }
@@ -3397,7 +4585,6 @@ export class PostgresAnonymousStore {
3397
4585
  clientBundleHash,
3398
4586
  deployId,
3399
4587
  publicRootUrl,
3400
- requestedTtlSeconds,
3401
4588
  serverEnv
3402
4589
  }) {
3403
4590
  const currentDeploy = await this.getBaseDeployById(deployId);
@@ -3406,8 +4593,7 @@ export class PostgresAnonymousStore {
3406
4593
  }
3407
4594
 
3408
4595
  const updatedAt = now();
3409
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
3410
- const expiresAt = currentDeploy.ownerId ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
4596
+ const expiresAt = currentDeploy.ownerId ? null : anonymousDeployExpiresAt();
3411
4597
  const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
3412
4598
  const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
3413
4599
  const url = appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug });
@@ -3432,6 +4618,7 @@ export class PostgresAnonymousStore {
3432
4618
  if (serverEnv !== undefined) {
3433
4619
  await this.replaceServerEnv(deployId, serverEnv, updatedAt);
3434
4620
  }
4621
+ await this.decrementArtifactRef(currentDeploy.artifactHash);
3435
4622
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3436
4623
  }
3437
4624
 
@@ -3500,7 +4687,7 @@ export class PostgresAnonymousStore {
3500
4687
  createdAt: new Date(row.created_at).toISOString(),
3501
4688
  expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
3502
4689
  id: row.id,
3503
- limits: row.limits_json,
4690
+ limits: { ...DEFAULT_ANONYMOUS_LIMITS, ...(row.limits_json ?? {}) },
3504
4691
  owner: row.owner_json ?? null,
3505
4692
  ownerId: row.owner_id ?? null,
3506
4693
  publicRootUrl: row.public_root_url,
@@ -3511,6 +4698,24 @@ export class PostgresAnonymousStore {
3511
4698
  };
3512
4699
  }
3513
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
+
3514
4719
  async getDeployById(id) {
3515
4720
  return this.deployWithUserLimitOverrides(await this.getBaseDeployById(id));
3516
4721
  }
@@ -3520,6 +4725,80 @@ export class PostgresAnonymousStore {
3520
4725
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3521
4726
  }
3522
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
+
3523
4802
  async getArtifact(hash) {
3524
4803
  const result = await this.query("select * from artifacts where hash = $1", [hash]);
3525
4804
  const row = result.rows[0];
@@ -3600,8 +4879,46 @@ export class PostgresAnonymousStore {
3600
4879
  return Object.fromEntries(result.rows.map((row) => [row.env_key, decryptServerEnvValue(row.env_value, this.serverEnvSecret)]));
3601
4880
  }
3602
4881
 
3603
- async transaction(deployId, handler) {
3604
- 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
+ };
3605
4922
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
3606
4923
  this.queues.set(
3607
4924
  deployId,
@@ -3614,9 +4931,29 @@ export class PostgresAnonymousStore {
3614
4931
  }
3615
4932
 
3616
4933
  async appendLog(deployId, level, message, data) {
4934
+ const entry = normalizeLogEntry(level, message, data);
3617
4935
  await this.query(
3618
4936
  "insert into logs(deploy_id, level, message, data_json, created_at) values($1, $2, $3, $4::jsonb, $5)",
3619
- [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]
3620
4957
  );
3621
4958
  }
3622
4959
 
@@ -3669,6 +5006,10 @@ export class PostgresAnonymousStore {
3669
5006
  }
3670
5007
 
3671
5008
  async incrementQuota(deployId, bucket, limit) {
5009
+ if (!Number.isFinite(limit)) {
5010
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
5011
+ }
5012
+
3672
5013
  const windowStart = dayWindowStart();
3673
5014
  const result = await this.query(
3674
5015
  `
@@ -3682,7 +5023,74 @@ export class PostgresAnonymousStore {
3682
5023
  );
3683
5024
  const count = result.rows[0].count;
3684
5025
  if (count > limit) {
3685
- 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
+ });
3686
5094
  }
3687
5095
  return { bucket, count, limit, windowStart };
3688
5096
  }
@@ -3796,6 +5204,109 @@ export class PostgresAnonymousStore {
3796
5204
  })
3797
5205
  );
3798
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
+ }
3799
5310
  }
3800
5311
 
3801
5312
  export async function createAnonymousStoreFromEnv(env = process.env) {
@@ -3814,12 +5325,30 @@ export async function createAnonymousStoreFromEnv(env = process.env) {
3814
5325
  }
3815
5326
 
3816
5327
  async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
3817
- 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);
3818
5339
  if (!route) {
3819
5340
  return null;
3820
5341
  }
3821
5342
 
3822
- 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);
3823
5352
  if (!deploy) {
3824
5353
  return { error: "Unknown anonymous deploy.", route, status: 404 };
3825
5354
  }
@@ -3846,6 +5375,10 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
3846
5375
  artifactHash: deploy.artifactHash,
3847
5376
  clientBundleHash: deploy.clientBundleHash,
3848
5377
  deployId: deploy.id,
5378
+ domains:
5379
+ typeof store.listDeployDomainsForDeploy === "function"
5380
+ ? (await store.listDeployDomainsForDeploy(deploy.id)).map(responseForDeployDomain)
5381
+ : [],
3849
5382
  expiresAt: deploy.expiresAt,
3850
5383
  limits: deploy.limits,
3851
5384
  mutations: Object.keys(artifact.server.mutations ?? {}),
@@ -3911,6 +5444,9 @@ export async function startAnonymousServer({
3911
5444
  } = {}) {
3912
5445
  const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
3913
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();
3914
5450
  const resolvedGithubOAuth = normalizeGithubOAuth(githubOAuth);
3915
5451
  const resolvedDeveloperSessionSecret =
3916
5452
  developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
@@ -3918,6 +5454,7 @@ export async function startAnonymousServer({
3918
5454
  const resolvedSourceRuntime = sourceRuntime === undefined ? createSourceRuntimeFromEnv() : sourceRuntime;
3919
5455
  await resolvedStore.initialize();
3920
5456
  const subscriptions = new Map();
5457
+ let cleanupInterval = null;
3921
5458
 
3922
5459
  function activeConnectionCounts() {
3923
5460
  const counts = new Map();
@@ -3938,6 +5475,10 @@ export async function startAnonymousServer({
3938
5475
  async function adminSummary() {
3939
5476
  const deploys = await adminDeploysWithConnections();
3940
5477
  const users = await resolvedStore.listAdminUsers();
5478
+ const cleanup =
5479
+ typeof resolvedStore.readCleanupActivity === "function"
5480
+ ? await resolvedStore.readCleanupActivity()
5481
+ : { lastRun: null, recentRuns: [], totals: {} };
3941
5482
  const totals = deploys.reduce(
3942
5483
  (acc, deploy) => ({
3943
5484
  artifactBytes: acc.artifactBytes + deploy.artifactBytes,
@@ -3964,6 +5505,7 @@ export async function startAnonymousServer({
3964
5505
  return {
3965
5506
  deployCount: deploys.length,
3966
5507
  deploys,
5508
+ cleanup,
3967
5509
  generatedAt: now(),
3968
5510
  totals,
3969
5511
  userCount: users.length,
@@ -3990,10 +5532,50 @@ export async function startAnonymousServer({
3990
5532
  return developerFromRequest(req, resolvedDeveloperSessionSecret);
3991
5533
  }
3992
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
+
3993
5544
  async function developerDeploys(user) {
3994
5545
  return resolvedStore.listDeploysForOwner(user.id);
3995
5546
  }
3996
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
+
3997
5579
  async function refreshDeploySubscriptions(deploy) {
3998
5580
  const storedArtifact = await resolvedStore.getArtifact(deploy.artifactHash);
3999
5581
  if (!storedArtifact) {
@@ -4033,6 +5615,30 @@ export async function startAnonymousServer({
4033
5615
  }
4034
5616
  }
4035
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
+
4036
5642
  async function publishDeploy(deployId) {
4037
5643
  for (const [ws, subscription] of subscriptions) {
4038
5644
  if (subscription.deploy.id !== deployId) {
@@ -4379,7 +5985,92 @@ export async function startAnonymousServer({
4379
5985
  return;
4380
5986
  }
4381
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
+
4382
6072
  if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
6073
+ await enforceAnonymousDeployCreation(req);
4383
6074
  const body = await readJsonBody(req);
4384
6075
  const payload = validateAnonymousDeployPayload(body);
4385
6076
  if (payload.serverEnv !== undefined && Object.keys(payload.serverEnv).length > 0) {
@@ -4393,7 +6084,6 @@ export async function startAnonymousServer({
4393
6084
  clientBundleBase64: payload.clientBundleBase64,
4394
6085
  clientBundleHash: payload.clientBundleHash,
4395
6086
  publicRootUrl: resolvedPublicRootUrl,
4396
- requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
4397
6087
  serverEnv: payload.serverEnv
4398
6088
  });
4399
6089
  await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
@@ -4428,7 +6118,6 @@ export async function startAnonymousServer({
4428
6118
  clientBundleHash: payload.clientBundleHash,
4429
6119
  deployId,
4430
6120
  publicRootUrl: resolvedPublicRootUrl,
4431
- requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
4432
6121
  serverEnv: payload.serverEnv
4433
6122
  });
4434
6123
  if (!deploy) {
@@ -4466,6 +6155,8 @@ export async function startAnonymousServer({
4466
6155
  return;
4467
6156
  }
4468
6157
 
6158
+ const clientKey = forwardedClientKey(req);
6159
+ await enforceClientTrafficQuota(clientKey, "requests");
4469
6160
  await resolvedStore.incrementQuota(
4470
6161
  loaded.deploy.id,
4471
6162
  "requests",
@@ -4495,16 +6186,28 @@ export async function startAnonymousServer({
4495
6186
 
4496
6187
  sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
4497
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
+ }
4498
6200
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
4499
6201
  }
4500
6202
  });
4501
6203
 
4502
6204
  const wss = new WebSocketServer({ noServer: true });
4503
6205
 
4504
- wss.on("connection", (ws, _req, loaded, auth) => {
6206
+ wss.on("connection", (ws, req, loaded, auth) => {
4505
6207
  subscriptions.set(ws, {
4506
6208
  artifact: loaded.artifact,
4507
6209
  auth,
6210
+ clientKey: forwardedClientKey(req),
4508
6211
  deploy: loaded.deploy,
4509
6212
  queries: new Set()
4510
6213
  });
@@ -4525,6 +6228,7 @@ export async function startAnonymousServer({
4525
6228
  }
4526
6229
 
4527
6230
  try {
6231
+ await enforceClientTrafficQuota(subscription.clientKey, "requests");
4528
6232
  await resolvedStore.incrementQuota(
4529
6233
  subscription.deploy.id,
4530
6234
  "requests",
@@ -4551,6 +6255,7 @@ export async function startAnonymousServer({
4551
6255
  }
4552
6256
 
4553
6257
  if (message.op === "mutation.run") {
6258
+ await enforceClientTrafficQuota(subscription.clientKey, "mutations");
4554
6259
  await resolvedStore.incrementQuota(
4555
6260
  subscription.deploy.id,
4556
6261
  "mutations",
@@ -4577,7 +6282,14 @@ export async function startAnonymousServer({
4577
6282
  error: error instanceof Error ? error.message : String(error),
4578
6283
  op: message?.op
4579
6284
  });
6285
+ const quota = isQuotaError(error) ? quotaErrorBody(error) : null;
4580
6286
  websocketSend(ws, {
6287
+ ...(quota
6288
+ ? {
6289
+ code: quota.code,
6290
+ quota
6291
+ }
6292
+ : {}),
4581
6293
  error: error instanceof Error ? error.message : String(error),
4582
6294
  id: message?.id,
4583
6295
  ok: false,
@@ -4624,6 +6336,20 @@ export async function startAnonymousServer({
4624
6336
  });
4625
6337
  });
4626
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
+
4627
6353
  if (!quiet) {
4628
6354
  console.log(`Lakebed anonymous runner listening at ${resolvedPublicRootUrl}`);
4629
6355
  }
@@ -4635,6 +6361,9 @@ export async function startAnonymousServer({
4635
6361
  store: resolvedStore,
4636
6362
  url: resolvedPublicRootUrl,
4637
6363
  async close() {
6364
+ if (cleanupInterval) {
6365
+ clearInterval(cleanupInterval);
6366
+ }
4638
6367
  for (const client of wss.clients) {
4639
6368
  client.close();
4640
6369
  }