lakebed 0.0.15 → 0.0.17

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,10 +1,13 @@
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,
6
8
  createSlug,
7
9
  DEFAULT_ANONYMOUS_LIMITS,
10
+ LAKEBED_VERSION,
8
11
  executeAnonymousMutation,
9
12
  executeAnonymousQuery,
10
13
  hashClaimToken,
@@ -149,6 +152,79 @@ function isDeployTokenValid(deploy, token) {
149
152
  return Boolean(token && deploy?.claimTokenHash && hashClaimToken(token) === deploy.claimTokenHash);
150
153
  }
151
154
 
155
+ const inspectPolicies = new Set(["private", "redacted", "public"]);
156
+
157
+ function normalizeInspectPolicy(value, fallback = "private") {
158
+ if (value === undefined || value === null || value === "") {
159
+ return fallback;
160
+ }
161
+
162
+ const policy = String(value).trim().toLowerCase();
163
+ if (!inspectPolicies.has(policy)) {
164
+ throw new Error("inspectPolicy must be private, redacted, or public.");
165
+ }
166
+ return policy;
167
+ }
168
+
169
+ function inspectPolicyForDeploy(deploy) {
170
+ return normalizeInspectPolicy(deploy?.inspectPolicy, "private");
171
+ }
172
+
173
+ const sensitiveKeyPattern =
174
+ /(^|[_-])(authorization|bearer|cookie|jwt|password|secret|session|token|api[_-]?key|access[_-]?key|private[_-]?key|refresh[_-]?token)([_-]|$)/i;
175
+ const secretValuePatterns = [
176
+ /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi,
177
+ /\b(sk|pk|rk|ghp|gho|github_pat)_[A-Za-z0-9_]{12,}\b/g,
178
+ /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g
179
+ ];
180
+
181
+ function redactSensitiveString(value) {
182
+ return secretValuePatterns.reduce((text, pattern) => text.replace(pattern, "[redacted]"), String(value));
183
+ }
184
+
185
+ function isSensitiveKey(key) {
186
+ const normalized = String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2");
187
+ return sensitiveKeyPattern.test(normalized);
188
+ }
189
+
190
+ function redactInspectValue(value, seen = new WeakSet()) {
191
+ if (typeof value === "string") {
192
+ return redactSensitiveString(value);
193
+ }
194
+
195
+ if (!value || typeof value !== "object") {
196
+ return value;
197
+ }
198
+
199
+ if (seen.has(value)) {
200
+ return "[redacted circular]";
201
+ }
202
+ seen.add(value);
203
+
204
+ if (Array.isArray(value)) {
205
+ return value.map((entry) => redactInspectValue(entry, seen));
206
+ }
207
+
208
+ return Object.fromEntries(
209
+ Object.entries(value).map(([key, entryValue]) => [
210
+ key,
211
+ isSensitiveKey(key) ? "[redacted]" : redactInspectValue(entryValue, seen)
212
+ ])
213
+ );
214
+ }
215
+
216
+ function redactLogEntry(entry) {
217
+ return {
218
+ ...entry,
219
+ data: redactInspectValue(entry.data),
220
+ message: redactSensitiveString(entry.message)
221
+ };
222
+ }
223
+
224
+ function redactLogData(data) {
225
+ return redactInspectValue(data);
226
+ }
227
+
152
228
  function normalizePublicRootUrl(value, port) {
153
229
  const fallback = `http://localhost:${port}`;
154
230
  return String(value || fallback).replace(/\/+$/g, "");
@@ -165,6 +241,106 @@ function normalizeAppBaseDomain(value) {
165
241
  .toLowerCase();
166
242
  }
167
243
 
244
+ const reservedLakebedSubdomainLabels = new Set([
245
+ "admin",
246
+ "api",
247
+ "app",
248
+ "assets",
249
+ "auth",
250
+ "billing",
251
+ "cdn",
252
+ "cname",
253
+ "console",
254
+ "dashboard",
255
+ "docs",
256
+ "ftp",
257
+ "imap",
258
+ "login",
259
+ "mail",
260
+ "ns1",
261
+ "ns2",
262
+ "origin",
263
+ "pop",
264
+ "proxy-fallback",
265
+ "smtp",
266
+ "static",
267
+ "status",
268
+ "support",
269
+ "www"
270
+ ]);
271
+
272
+ function normalizeHostname(value) {
273
+ const trimmed = String(value ?? "")
274
+ .trim()
275
+ .replace(/\.$/, "")
276
+ .toLowerCase();
277
+ if (!trimmed) {
278
+ return "";
279
+ }
280
+
281
+ return domainToASCII(trimmed).toLowerCase();
282
+ }
283
+
284
+ function hostnameFromHost(host) {
285
+ const value = String(host ?? "").trim();
286
+ if (value.startsWith("[")) {
287
+ const end = value.indexOf("]");
288
+ return normalizeHostname(end === -1 ? value : value.slice(1, end));
289
+ }
290
+
291
+ return normalizeHostname(value.split(":")[0]);
292
+ }
293
+
294
+ function validateLakebedSubdomainLabel(label) {
295
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) {
296
+ throw new Error("Lakebed subdomains must use one DNS label with letters, numbers, or hyphens.");
297
+ }
298
+
299
+ if (reservedLakebedSubdomainLabels.has(label)) {
300
+ throw new Error(`The subdomain ${label} is reserved for Lakebed.`);
301
+ }
302
+ }
303
+
304
+ function normalizeLakebedSubdomainInput(value, appBaseDomain) {
305
+ const baseDomain = normalizeHostname(appBaseDomain);
306
+ if (!baseDomain) {
307
+ throw new Error("Lakebed app subdomains are not configured on this runner.");
308
+ }
309
+
310
+ const raw = String(value ?? "").trim();
311
+ if (!raw) {
312
+ throw new Error("Subdomain is required.");
313
+ }
314
+
315
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.includes("/") || raw.includes("\\") || raw.includes(":")) {
316
+ throw new Error("Enter a subdomain like my-app.lakebed.app, without a scheme, port, or path.");
317
+ }
318
+
319
+ const hostname = normalizeHostname(raw);
320
+ if (!hostname) {
321
+ throw new Error("Subdomain is not a valid hostname.");
322
+ }
323
+
324
+ const suffix = `.${baseDomain}`;
325
+ let label;
326
+ if (hostname.endsWith(suffix)) {
327
+ label = hostname.slice(0, -suffix.length);
328
+ if (!label || label.includes(".")) {
329
+ throw new Error(`Lakebed subdomains must be exactly one label under ${baseDomain}.`);
330
+ }
331
+ } else if (!hostname.includes(".")) {
332
+ label = hostname;
333
+ } else {
334
+ throw new Error(`Lakebed subdomains must end with ${baseDomain}.`);
335
+ }
336
+
337
+ validateLakebedSubdomainLabel(label);
338
+ return {
339
+ hostname: `${label}.${baseDomain}`,
340
+ label
341
+ };
342
+ }
343
+
168
344
  function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
169
345
  if (appBaseDomain) {
170
346
  return `https://${slug}.${appBaseDomain}`;
@@ -194,12 +370,26 @@ function responseForDeploy({ deploy, token }) {
194
370
  deployId: deploy.id,
195
371
  expiresAt: deploy.expiresAt,
196
372
  inspect: inspectUrls(deploy.url),
373
+ inspectPolicy: inspectPolicyForDeploy(deploy),
197
374
  limits: deploy.limits,
198
375
  updatedAt: deploy.updatedAt,
199
376
  url: deploy.url
200
377
  };
201
378
  }
202
379
 
380
+ function responseForDeployDomain(domain) {
381
+ return {
382
+ createdAt: domain.createdAt,
383
+ deployId: domain.deployId,
384
+ hostname: domain.hostname,
385
+ kind: domain.kind,
386
+ primary: Boolean(domain.isPrimary),
387
+ status: domain.status,
388
+ updatedAt: domain.updatedAt,
389
+ url: domain.url
390
+ };
391
+ }
392
+
203
393
  function isExpired(deploy) {
204
394
  if (deploy?.ownerId) {
205
395
  return false;
@@ -240,14 +430,14 @@ function parseHostDeploy({ appBaseDomain, host, url }) {
240
430
  return null;
241
431
  }
242
432
 
243
- const hostname = host.split(":")[0].toLowerCase();
433
+ const hostname = hostnameFromHost(host);
244
434
  const suffix = `.${appBaseDomain.toLowerCase()}`;
245
435
  if (!hostname.endsWith(suffix)) {
246
436
  return null;
247
437
  }
248
438
 
249
439
  const slug = hostname.slice(0, -suffix.length);
250
- if (!slug || slug === "www") {
440
+ if (!slug || slug.includes(".") || reservedLakebedSubdomainLabels.has(slug)) {
251
441
  return null;
252
442
  }
253
443
 
@@ -266,6 +456,138 @@ function quotaLimitForBucket(bucket, deploy) {
266
456
  return deploy.limits.requestsPerDay;
267
457
  }
268
458
 
459
+ function clientTrafficQuotaBucket(bucket) {
460
+ return bucket === "mutations" ? "anonymous_client_mutations" : "anonymous_client_requests";
461
+ }
462
+
463
+ function clientTrafficQuotaSuggestion(bucket) {
464
+ return bucket === "mutations"
465
+ ? "Retry after reset or reduce mutation frequency from this client."
466
+ : "Retry after reset or reduce request frequency from this client.";
467
+ }
468
+
469
+ function anonymousDeployCreationPolicy({ env = process.env, publicRootUrl }) {
470
+ const production = !isLocalPublicRootUrl(publicRootUrl);
471
+ const disabled =
472
+ booleanFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_DISABLED) ||
473
+ booleanFromEnv(env.LAKEBED_ANONYMOUS_DEPLOYS_DISABLED);
474
+ const defaultPerClient = production ? 50 : Number.POSITIVE_INFINITY;
475
+ const defaultGlobal = production ? 5000 : Number.POSITIVE_INFINITY;
476
+ return {
477
+ disabled,
478
+ globalLimit: positiveIntegerLimitFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_GLOBAL_PER_DAY, defaultGlobal),
479
+ perClientLimit: positiveIntegerLimitFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_PER_CLIENT_PER_DAY, defaultPerClient),
480
+ production
481
+ };
482
+ }
483
+
484
+ function anonymousClientTrafficPolicy({ env = process.env, publicRootUrl }) {
485
+ const production = !isLocalPublicRootUrl(publicRootUrl);
486
+ return {
487
+ mutationsPerClientLimit: positiveIntegerLimitFromEnv(
488
+ env.LAKEBED_ANONYMOUS_MUTATIONS_PER_CLIENT_PER_DAY,
489
+ production ? 10000 : Number.POSITIVE_INFINITY
490
+ ),
491
+ requestsPerClientLimit: positiveIntegerLimitFromEnv(
492
+ env.LAKEBED_ANONYMOUS_REQUESTS_PER_CLIENT_PER_DAY,
493
+ production ? 100000 : Number.POSITIVE_INFINITY
494
+ )
495
+ };
496
+ }
497
+
498
+ function cleanupPolicyFromEnv(env = process.env) {
499
+ return {
500
+ graceSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_GRACE, 60 * 60, { min: 0 }),
501
+ intervalSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_INTERVAL, 60 * 60, { min: 60 }),
502
+ retentionSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_RETENTION, 7 * 24 * 60 * 60, { min: 0 })
503
+ };
504
+ }
505
+
506
+ function normalizePeerAddress(value) {
507
+ let address = String(value ?? "").trim();
508
+ if (!address) {
509
+ return "unknown";
510
+ }
511
+ if (address.startsWith("::ffff:")) {
512
+ address = address.slice("::ffff:".length);
513
+ }
514
+ if (address.startsWith("[") && address.includes("]")) {
515
+ address = address.slice(1, address.indexOf("]"));
516
+ }
517
+ if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(address)) {
518
+ address = address.slice(0, address.lastIndexOf(":"));
519
+ }
520
+ return address;
521
+ }
522
+
523
+ function ipv4ToNumber(address) {
524
+ const parts = address.split(".").map((part) => Number(part));
525
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
526
+ return null;
527
+ }
528
+ return parts.reduce((acc, part) => (acc << 8) + part, 0) >>> 0;
529
+ }
530
+
531
+ function ipv4InCidr(address, cidr) {
532
+ const [base, rawBits] = cidr.split("/");
533
+ const bits = Number(rawBits);
534
+ const value = ipv4ToNumber(address);
535
+ const baseValue = ipv4ToNumber(base);
536
+ if (value === null || baseValue === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
537
+ return false;
538
+ }
539
+
540
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
541
+ return (value & mask) === (baseValue & mask);
542
+ }
543
+
544
+ function hasRailwayRuntimeEnv(env = process.env) {
545
+ return Boolean(env.RAILWAY_SERVICE_ID || env.RAILWAY_PROJECT_ID || env.RAILWAY_DEPLOYMENT_ID || env.RAILWAY_REPLICA_ID);
546
+ }
547
+
548
+ function isRailwayProxyPeer(req, env = process.env) {
549
+ const edge = String(req.headers["x-railway-edge"] ?? "");
550
+ if (!hasRailwayRuntimeEnv(env) || !/^railway\/[a-z0-9][a-z0-9-]*$/i.test(edge)) {
551
+ return false;
552
+ }
553
+
554
+ // Railway's proxy guidance treats 100.0.0.0/8 as the trusted internal proxy range.
555
+ const address = normalizePeerAddress(req.socket.remoteAddress);
556
+ return isIP(address) === 4 && ipv4InCidr(address, "100.0.0.0/8");
557
+ }
558
+
559
+ function shouldTrustProxyHeaders(req, env = process.env) {
560
+ if (booleanFromEnv(env.LAKEBED_TRUST_PROXY_HEADERS)) {
561
+ return true;
562
+ }
563
+
564
+ return isRailwayProxyPeer(req, env);
565
+ }
566
+
567
+ function firstHeaderValue(req, names) {
568
+ for (const name of names) {
569
+ const raw = req.headers[name];
570
+ const candidate = String(Array.isArray(raw) ? raw[0] : (raw ?? ""))
571
+ .split(",")[0]
572
+ .trim();
573
+ if (candidate) {
574
+ return normalizePeerAddress(candidate).slice(0, 256);
575
+ }
576
+ }
577
+ return "";
578
+ }
579
+
580
+ function forwardedClientKey(req, env = process.env) {
581
+ if (shouldTrustProxyHeaders(req, env)) {
582
+ const forwarded = firstHeaderValue(req, ["x-real-ip", "cf-connecting-ip", "fly-client-ip", "x-forwarded-for"]);
583
+ if (forwarded) {
584
+ return forwarded;
585
+ }
586
+ }
587
+
588
+ return normalizePeerAddress(req.socket.remoteAddress).slice(0, 256);
589
+ }
590
+
269
591
  const USER_LIMIT_OVERRIDE_KEYS = ["requestsPerDay", "mutationsPerDay"];
270
592
  const USER_BOOST_MULTIPLIER = 20;
271
593
 
@@ -451,7 +773,7 @@ function isSecureRequest(req) {
451
773
  }
452
774
 
453
775
  function adminCookie(value, maxAge = adminCookieMaxAgeSeconds, secure = false) {
454
- return `${adminCookieName}=${value}; HttpOnly; SameSite=Lax; Path=/admin; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
776
+ return `${adminCookieName}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
455
777
  }
456
778
 
457
779
  function cookie(name, value, { httpOnly = true, maxAge, path = "/", sameSite = "Lax", secure = false } = {}) {
@@ -665,6 +987,193 @@ function bytesOfJson(value) {
665
987
  return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
666
988
  }
667
989
 
990
+ function durationSecondsFromEnv(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
991
+ if (value === undefined || value === null || value === "") {
992
+ return fallback;
993
+ }
994
+
995
+ const match = String(value).trim().match(/^(\d+)([smhd])?$/);
996
+ if (!match) {
997
+ throw new Error(`Invalid duration: ${value}. Use a value like 15m, 1h, 7d, or 604800.`);
998
+ }
999
+
1000
+ const multipliers = { d: 86400, h: 3600, m: 60, s: 1 };
1001
+ const seconds = Number(match[1]) * multipliers[match[2] ?? "s"];
1002
+ return Math.max(min, Math.min(max, seconds));
1003
+ }
1004
+
1005
+ function positiveIntegerLimitFromEnv(value, fallback) {
1006
+ if (value === undefined || value === null || value === "") {
1007
+ return fallback;
1008
+ }
1009
+
1010
+ const parsed = Number(value);
1011
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
1012
+ throw new Error(`Invalid limit: ${value}. Use a non-negative integer.`);
1013
+ }
1014
+ return parsed;
1015
+ }
1016
+
1017
+ function isLocalPublicRootUrl(publicRootUrl) {
1018
+ try {
1019
+ const hostname = new URL(publicRootUrl).hostname.toLowerCase();
1020
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
1021
+ } catch {
1022
+ return true;
1023
+ }
1024
+ }
1025
+
1026
+ function booleanFromEnv(value) {
1027
+ return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
1028
+ }
1029
+
1030
+ function quotaResetAt(windowStart) {
1031
+ return new Date(Date.parse(windowStart) + 24 * 60 * 60 * 1000).toISOString();
1032
+ }
1033
+
1034
+ function quotaRetryAfterSeconds(windowStart) {
1035
+ return Math.max(1, Math.ceil((Date.parse(quotaResetAt(windowStart)) - Date.now()) / 1000));
1036
+ }
1037
+
1038
+ export class LakebedQuotaError extends Error {
1039
+ constructor({ bucket, count, limit, resetAt, retryAfterSeconds, status = 429, suggestion }) {
1040
+ super(`${bucket} quota exceeded. Limit: ${limit}.`);
1041
+ this.name = "LakebedQuotaError";
1042
+ this.bucket = bucket;
1043
+ this.code = "lakebed_quota_exceeded";
1044
+ this.count = count;
1045
+ this.limit = limit;
1046
+ this.resetAt = resetAt ?? null;
1047
+ this.retryAfterSeconds = retryAfterSeconds ?? null;
1048
+ this.status = status;
1049
+ this.suggestion = suggestion;
1050
+ }
1051
+ }
1052
+
1053
+ function isQuotaError(error) {
1054
+ return error instanceof LakebedQuotaError || error?.code === "lakebed_quota_exceeded";
1055
+ }
1056
+
1057
+ function quotaErrorBody(error, extra = {}) {
1058
+ return {
1059
+ bucket: error.bucket,
1060
+ code: error.code ?? "lakebed_quota_exceeded",
1061
+ current: error.count,
1062
+ error: error.message,
1063
+ limit: error.limit,
1064
+ resetAt: error.resetAt ?? null,
1065
+ retryAfterSeconds: error.retryAfterSeconds ?? null,
1066
+ suggestion: error.suggestion,
1067
+ ...extra
1068
+ };
1069
+ }
1070
+
1071
+ function quotaErrorHeaders(error) {
1072
+ const headers = {};
1073
+ if (Number.isFinite(error.retryAfterSeconds)) {
1074
+ headers["Retry-After"] = String(error.retryAfterSeconds);
1075
+ }
1076
+ if (Number.isFinite(error.limit)) {
1077
+ headers["X-RateLimit-Limit"] = String(error.limit);
1078
+ }
1079
+ if (Number.isFinite(error.count) && Number.isFinite(error.limit)) {
1080
+ headers["X-RateLimit-Remaining"] = String(Math.max(0, error.limit - error.count));
1081
+ }
1082
+ if (error.resetAt) {
1083
+ headers["X-RateLimit-Reset"] = error.resetAt;
1084
+ }
1085
+ return headers;
1086
+ }
1087
+
1088
+ function stateRowsLimitFromTransactionOptions(options = {}) {
1089
+ const explicit = options.stateRowsLimit;
1090
+ if (Number.isSafeInteger(explicit) && explicit > 0) {
1091
+ return explicit;
1092
+ }
1093
+ const stateBytes = options.stateBytesLimit ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes;
1094
+ return Math.max(1, Math.floor(stateBytes / 64));
1095
+ }
1096
+
1097
+ function assertStateResourceLimits({ stateBytes, stateRows }, options = {}) {
1098
+ const stateBytesLimit = options.stateBytesLimit ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes;
1099
+ const stateRowsLimit = stateRowsLimitFromTransactionOptions(options);
1100
+ if (Number.isFinite(stateRowsLimit) && stateRows > stateRowsLimit) {
1101
+ throw new LakebedQuotaError({
1102
+ bucket: "state_rows",
1103
+ count: stateRows,
1104
+ limit: stateRowsLimit,
1105
+ suggestion: "Delete rows or claim the deploy before retrying this mutation."
1106
+ });
1107
+ }
1108
+ if (Number.isFinite(stateBytesLimit) && stateBytes > stateBytesLimit) {
1109
+ throw new LakebedQuotaError({
1110
+ bucket: "state_bytes",
1111
+ count: stateBytes,
1112
+ limit: stateBytesLimit,
1113
+ suggestion: "Reduce stored values, delete rows, or claim the deploy before retrying this mutation."
1114
+ });
1115
+ }
1116
+ }
1117
+
1118
+ function safeJsonValue(value) {
1119
+ if (value === undefined) {
1120
+ return null;
1121
+ }
1122
+
1123
+ try {
1124
+ JSON.stringify(value);
1125
+ return value;
1126
+ } catch {
1127
+ return String(value);
1128
+ }
1129
+ }
1130
+
1131
+ function truncateUtf8(value, maxBytes) {
1132
+ const text = String(value ?? "");
1133
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) {
1134
+ return text;
1135
+ }
1136
+
1137
+ let end = text.length;
1138
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) {
1139
+ end -= 1;
1140
+ }
1141
+ return text.slice(0, end);
1142
+ }
1143
+
1144
+ function normalizeLogEntry(level, message, data, limits = DEFAULT_ANONYMOUS_LIMITS) {
1145
+ const maxEntryBytes = limits.logEntryBytes ?? DEFAULT_ANONYMOUS_LIMITS.logEntryBytes;
1146
+ const maxMessageBytes = Math.max(128, Math.min(maxEntryBytes, Math.floor(maxEntryBytes / 2)));
1147
+ const originalMessageBytes = Buffer.byteLength(String(message ?? ""), "utf8");
1148
+ const cleanMessage =
1149
+ originalMessageBytes > maxMessageBytes
1150
+ ? `${truncateUtf8(message, Math.max(0, maxMessageBytes - 18))}...[truncated]`
1151
+ : String(message ?? "");
1152
+ let cleanData = safeJsonValue(data);
1153
+ let entry = { at: now(), data: cleanData, level, message: cleanMessage };
1154
+
1155
+ if (bytesOfJson(entry) > maxEntryBytes) {
1156
+ const dataText = JSON.stringify(cleanData ?? null);
1157
+ cleanData = {
1158
+ preview: truncateUtf8(dataText, Math.max(0, maxEntryBytes - bytesOfJson({ at: entry.at, data: null, level, message: cleanMessage }) - 128)),
1159
+ truncated: true,
1160
+ originalBytes: Buffer.byteLength(dataText, "utf8")
1161
+ };
1162
+ entry = { ...entry, data: cleanData };
1163
+ }
1164
+
1165
+ if (bytesOfJson(entry) > maxEntryBytes) {
1166
+ entry = {
1167
+ at: entry.at,
1168
+ data: { truncated: true },
1169
+ level,
1170
+ message: truncateUtf8(cleanMessage, Math.max(64, maxEntryBytes - 96))
1171
+ };
1172
+ }
1173
+
1174
+ return entry;
1175
+ }
1176
+
668
1177
  function usageCounts(usage) {
669
1178
  const windowStart = dayWindowStart();
670
1179
  const counts = {
@@ -696,6 +1205,7 @@ function adminDeploySummary({ artifact, artifactBytes = 0, deploy, logBytes = 0,
696
1205
  createdAt: deploy.createdAt,
697
1206
  expiresAt: deploy.expiresAt,
698
1207
  id: deploy.id,
1208
+ inspectPolicy: inspectPolicyForDeploy(deploy),
699
1209
  limits: deploy.limits,
700
1210
  logBytes,
701
1211
  logEntries,
@@ -896,7 +1406,7 @@ function adminHtml() {
896
1406
  .metrics {
897
1407
  display: grid;
898
1408
  gap: 8px;
899
- grid-template-columns: repeat(6, minmax(120px, 1fr));
1409
+ grid-template-columns: repeat(8, minmax(120px, 1fr));
900
1410
  margin-bottom: 14px;
901
1411
  }
902
1412
 
@@ -1278,8 +1788,10 @@ function adminHtml() {
1278
1788
  <div class="metric"><span>artifact bytes</span><strong id="metric-artifacts">0 B</strong></div>
1279
1789
  <div class="metric"><span>state bytes</span><strong id="metric-state">0 B</strong></div>
1280
1790
  <div class="metric"><span>state rows</span><strong id="metric-rows">0</strong></div>
1791
+ <div class="metric"><span>log bytes</span><strong id="metric-logs">0 B</strong></div>
1281
1792
  <div class="metric"><span>requests today</span><strong id="metric-requests">0</strong></div>
1282
1793
  <div class="metric"><span>mutations today</span><strong id="metric-mutations">0</strong></div>
1794
+ <div class="metric"><span>cleaned deploys</span><strong id="metric-cleanup">0</strong></div>
1283
1795
  </section>
1284
1796
 
1285
1797
  <section class="panel" id="deployments-view">
@@ -1880,8 +2392,10 @@ function adminHtml() {
1880
2392
  setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
1881
2393
  setMetric("metric-state", formatBytes(summary.totals.stateBytes));
1882
2394
  setMetric("metric-rows", formatNumber(summary.totals.stateRows));
2395
+ setMetric("metric-logs", formatBytes(summary.totals.logBytes));
1883
2396
  setMetric("metric-requests", formatNumber(summary.totals.requestsToday));
1884
2397
  setMetric("metric-mutations", formatNumber(summary.totals.mutationsToday));
2398
+ setMetric("metric-cleanup", formatNumber(summary.cleanup?.totals?.deletedDeploys || 0));
1885
2399
  statusLine.textContent = "Updated " + formatTime(summary.generatedAt);
1886
2400
  }
1887
2401
 
@@ -2225,12 +2739,109 @@ function escapeHtml(value) {
2225
2739
  .replace(/"/g, "&quot;");
2226
2740
  }
2227
2741
 
2228
- function formatHtmlTime(value) {
2229
- return value ? new Date(value).toLocaleString() : "unknown";
2742
+ function formatHtmlNumber(value) {
2743
+ return new Intl.NumberFormat().format(Number(value || 0));
2744
+ }
2745
+
2746
+ function parseHtmlDate(value) {
2747
+ if (!value) {
2748
+ return null;
2749
+ }
2750
+ const date = value instanceof Date ? value : new Date(value);
2751
+ return Number.isFinite(date.getTime()) ? date : null;
2752
+ }
2753
+
2754
+ function formatHtmlAbsoluteTime(value) {
2755
+ const date = parseHtmlDate(value);
2756
+ if (!date) {
2757
+ return "unknown";
2758
+ }
2759
+ return new Intl.DateTimeFormat(undefined, {
2760
+ day: "numeric",
2761
+ hour: "numeric",
2762
+ minute: "2-digit",
2763
+ month: "short",
2764
+ timeZoneName: "short",
2765
+ year: "numeric"
2766
+ }).format(date);
2767
+ }
2768
+
2769
+ function relativeHtmlUnit(diffMs, unitMs) {
2770
+ const value = Math.round(diffMs / unitMs);
2771
+ if (value === 0) {
2772
+ return diffMs < 0 ? -1 : 1;
2773
+ }
2774
+ return value;
2775
+ }
2776
+
2777
+ function formatHtmlRelativeTime(value, baseDate = new Date()) {
2778
+ const date = parseHtmlDate(value);
2779
+ if (!date) {
2780
+ return "unknown";
2781
+ }
2782
+
2783
+ const diffMs = date.getTime() - baseDate.getTime();
2784
+ const absMs = Math.abs(diffMs);
2785
+ const second = 1000;
2786
+ const minute = 60 * second;
2787
+ const hour = 60 * minute;
2788
+ const day = 24 * hour;
2789
+ const week = 7 * day;
2790
+ const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
2791
+
2792
+ if (absMs < 45 * second) {
2793
+ return diffMs < 0 ? "just now" : "soon";
2794
+ }
2795
+ if (absMs < 45 * minute) {
2796
+ return formatter.format(relativeHtmlUnit(diffMs, minute), "minute");
2797
+ }
2798
+ if (absMs < 22 * hour) {
2799
+ return formatter.format(relativeHtmlUnit(diffMs, hour), "hour");
2800
+ }
2801
+ if (absMs < 26 * day) {
2802
+ return formatter.format(relativeHtmlUnit(diffMs, day), "day");
2803
+ }
2804
+ if (absMs < 8 * week) {
2805
+ return formatter.format(relativeHtmlUnit(diffMs, week), "week");
2806
+ }
2807
+ return formatHtmlAbsoluteTime(date);
2808
+ }
2809
+
2810
+ function htmlTime(value) {
2811
+ const date = parseHtmlDate(value);
2812
+ if (!date) {
2813
+ return `<span>unknown</span>`;
2814
+ }
2815
+
2816
+ return `<time datetime="${escapeHtml(date.toISOString())}" title="${escapeHtml(formatHtmlAbsoluteTime(date))}">${escapeHtml(formatHtmlRelativeTime(date))}</time>`;
2817
+ }
2818
+
2819
+ function cssClassToken(value) {
2820
+ return (
2821
+ String(value ?? "")
2822
+ .toLowerCase()
2823
+ .replace(/[^a-z0-9_-]+/g, "-")
2824
+ .replace(/^-+|-+$/g, "") || "unknown"
2825
+ );
2826
+ }
2827
+
2828
+ function quotaPercent(used, limit) {
2829
+ const numericLimit = Number(limit || 0);
2830
+ if (numericLimit <= 0) {
2831
+ return 0;
2832
+ }
2833
+ return Math.max(0, Math.min(100, (Number(used || 0) / numericLimit) * 100));
2230
2834
  }
2231
2835
 
2232
- function formatHtmlExpiry(value) {
2233
- return value ? formatHtmlTime(value) : "never";
2836
+ function quotaHtml(label, used, limit) {
2837
+ const percent = quotaPercent(used, limit).toFixed(2);
2838
+ return `<div class="quota">
2839
+ <div class="quota-row">
2840
+ <span class="quota-label">${escapeHtml(label)}</span>
2841
+ <span class="quota-count">${escapeHtml(formatHtmlNumber(used))} / ${escapeHtml(formatHtmlNumber(limit))}</span>
2842
+ </div>
2843
+ <div class="meter" aria-hidden="true"><span style="--usage: ${percent}%"></span></div>
2844
+ </div>`;
2234
2845
  }
2235
2846
 
2236
2847
  function developerDeploySummary({ artifact, deploy, usage }) {
@@ -2242,6 +2853,7 @@ function developerDeploySummary({ artifact, deploy, usage }) {
2242
2853
  deployId: deploy.id,
2243
2854
  expiresAt: deploy.expiresAt,
2244
2855
  inspect: inspectUrls(deploy.url),
2856
+ inspectPolicy: inspectPolicyForDeploy(deploy),
2245
2857
  limits: deploy.limits,
2246
2858
  name: artifact?.name ?? "Lakebed Capsule",
2247
2859
  ownerId: deploy.ownerId,
@@ -2255,18 +2867,34 @@ function developerDeploySummary({ artifact, deploy, usage }) {
2255
2867
 
2256
2868
  function developerHtml({ authConfigured, deploys = [], user }) {
2257
2869
  const signedIn = Boolean(user);
2870
+ const activeDeploys = deploys.filter((deploy) => deploy.status === "active").length;
2871
+ const requestsToday = deploys.reduce((total, deploy) => total + Number(deploy.usage.requestsToday || 0), 0);
2872
+ const mutationsToday = deploys.reduce((total, deploy) => total + Number(deploy.usage.mutationsToday || 0), 0);
2258
2873
  const rows = deploys
2259
- .map(
2260
- (deploy) => `<tr>
2261
- <td><a href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a><small>${escapeHtml(deploy.deployId)} / ${escapeHtml(deploy.slug)}</small></td>
2262
- <td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
2263
- <td>${escapeHtml(formatHtmlTime(deploy.createdAt))}</td>
2264
- <td>${escapeHtml(formatHtmlTime(deploy.updatedAt))}</td>
2265
- <td>${escapeHtml(formatHtmlExpiry(deploy.expiresAt))}</td>
2266
- <td>${escapeHtml(deploy.usage.requestsToday)} / ${escapeHtml(deploy.limits.requestsPerDay)}</td>
2267
- <td>${escapeHtml(deploy.usage.mutationsToday)} / ${escapeHtml(deploy.limits.mutationsPerDay)}</td>
2268
- </tr>`
2269
- )
2874
+ .map((deploy) => {
2875
+ const statusClass = cssClassToken(deploy.status);
2876
+ return `<article class="deploy-row">
2877
+ <div class="deploy-main">
2878
+ <div class="deploy-title-line">
2879
+ <a class="deploy-name" href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a>
2880
+ <span class="status status-${escapeHtml(statusClass)}">${escapeHtml(deploy.status)}</span>
2881
+ ${deploy.expiresAt ? `<span class="expiry">expires ${htmlTime(deploy.expiresAt)}</span>` : ""}
2882
+ </div>
2883
+ <div class="deploy-ids">
2884
+ <span>${escapeHtml(deploy.deployId)}</span>
2885
+ <span>${escapeHtml(deploy.slug)}</span>
2886
+ </div>
2887
+ </div>
2888
+ <div class="activity">
2889
+ <div class="activity-item"><span>Updated</span>${htmlTime(deploy.updatedAt)}</div>
2890
+ <div class="activity-item"><span>Created</span>${htmlTime(deploy.createdAt)}</div>
2891
+ </div>
2892
+ <div class="usage-grid">
2893
+ ${quotaHtml("Requests", deploy.usage.requestsToday, deploy.limits.requestsPerDay)}
2894
+ ${quotaHtml("Mutations", deploy.usage.mutationsToday, deploy.limits.mutationsPerDay)}
2895
+ </div>
2896
+ </article>`;
2897
+ })
2270
2898
  .join("");
2271
2899
 
2272
2900
  return `<!doctype html>
@@ -2278,14 +2906,18 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2278
2906
  <style>
2279
2907
  :root {
2280
2908
  color-scheme: dark;
2281
- --bg: #11130f;
2282
- --panel: #191c16;
2283
- --line: #343b2e;
2284
- --text: #f2f0e8;
2285
- --muted: #a8ab9e;
2286
- --accent: #91d46f;
2287
- --bad: #ee7d71;
2288
- --ink: #0c110a;
2909
+ --accent: #b7f26d;
2910
+ --bg: #070a09;
2911
+ --cyan: #71d6ff;
2912
+ --danger: #ff7b72;
2913
+ --line: #26302b;
2914
+ --line-strong: #3b4740;
2915
+ --muted: #8f9c94;
2916
+ --panel: #0f1513;
2917
+ --panel-raised: #141b18;
2918
+ --soft: #bfcbc3;
2919
+ --text: #eef6f0;
2920
+ --warning: #ffd166;
2289
2921
  }
2290
2922
 
2291
2923
  * {
@@ -2295,13 +2927,12 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2295
2927
  body {
2296
2928
  background: var(--bg);
2297
2929
  color: var(--text);
2298
- font-family: "Avenir Next", "Helvetica Neue", sans-serif;
2299
- letter-spacing: 0;
2930
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2300
2931
  margin: 0;
2301
2932
  }
2302
2933
 
2303
2934
  a {
2304
- color: var(--accent);
2935
+ color: inherit;
2305
2936
  text-decoration: none;
2306
2937
  }
2307
2938
 
@@ -2311,48 +2942,102 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2311
2942
 
2312
2943
  .shell {
2313
2944
  margin: 0 auto;
2314
- max-width: 1120px;
2945
+ max-width: 1560px;
2315
2946
  min-height: 100vh;
2316
- padding: 28px;
2947
+ padding: 34px 32px 56px;
2948
+ width: 100%;
2317
2949
  }
2318
2950
 
2319
2951
  header {
2320
2952
  align-items: center;
2953
+ border-bottom: 1px solid var(--line);
2321
2954
  display: flex;
2322
2955
  gap: 16px;
2323
2956
  justify-content: space-between;
2324
- margin-bottom: 24px;
2957
+ margin-bottom: 22px;
2958
+ padding-bottom: 22px;
2325
2959
  }
2326
2960
 
2327
2961
  h1 {
2328
- font-size: 32px;
2329
- line-height: 1;
2962
+ font-size: 40px;
2963
+ line-height: 1.05;
2330
2964
  margin: 0;
2331
2965
  }
2332
2966
 
2333
2967
  .eyebrow,
2334
- small,
2335
- th,
2336
- .meta {
2968
+ .meta,
2969
+ .deploy-ids,
2970
+ .activity-item span,
2971
+ .expiry,
2972
+ .list-head,
2973
+ .quota-label,
2974
+ .quota-count,
2975
+ .status {
2337
2976
  color: var(--muted);
2338
2977
  font-family: "SFMono-Regular", Consolas, monospace;
2339
2978
  font-size: 12px;
2340
2979
  }
2341
2980
 
2981
+ .eyebrow {
2982
+ color: var(--accent);
2983
+ margin-bottom: 6px;
2984
+ }
2985
+
2986
+ .meta {
2987
+ margin-top: 4px;
2988
+ }
2989
+
2342
2990
  .button {
2343
- background: var(--accent);
2344
- border: 1px solid var(--accent);
2991
+ align-items: center;
2992
+ background: transparent;
2993
+ border: 1px solid var(--line-strong);
2345
2994
  border-radius: 6px;
2346
- color: var(--ink);
2995
+ color: var(--text);
2347
2996
  display: inline-flex;
2348
2997
  font-weight: 700;
2349
2998
  min-height: 40px;
2350
2999
  padding: 10px 14px;
2351
3000
  }
2352
3001
 
2353
- .button.secondary {
2354
- background: transparent;
3002
+ .button:hover {
3003
+ border-color: var(--accent);
3004
+ text-decoration: none;
3005
+ }
3006
+
3007
+ .button.secondary {
3008
+ background: transparent;
3009
+ color: var(--text);
3010
+ }
3011
+
3012
+ .summary {
3013
+ background: var(--line);
3014
+ border: 1px solid var(--line);
3015
+ border-radius: 8px;
3016
+ display: grid;
3017
+ gap: 1px;
3018
+ grid-template-columns: repeat(3, minmax(0, 1fr));
3019
+ margin-bottom: 18px;
3020
+ overflow: hidden;
3021
+ }
3022
+
3023
+ .metric {
3024
+ background: var(--panel);
3025
+ padding: 15px 16px;
3026
+ }
3027
+
3028
+ .metric span {
3029
+ color: var(--muted);
3030
+ display: block;
3031
+ font-family: "SFMono-Regular", Consolas, monospace;
3032
+ font-size: 12px;
3033
+ margin-bottom: 6px;
3034
+ }
3035
+
3036
+ .metric strong {
2355
3037
  color: var(--text);
3038
+ display: block;
3039
+ font-size: 22px;
3040
+ line-height: 1;
2356
3041
  }
2357
3042
 
2358
3043
  .panel {
@@ -2367,51 +3052,160 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2367
3052
  padding: 22px;
2368
3053
  }
2369
3054
 
2370
- table {
2371
- border-collapse: collapse;
2372
- width: 100%;
3055
+ .deploy-list {
3056
+ display: grid;
2373
3057
  }
2374
3058
 
2375
- th,
2376
- td {
3059
+ .list-head,
3060
+ .deploy-row {
3061
+ align-items: center;
3062
+ display: grid;
3063
+ gap: 24px;
3064
+ grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px);
3065
+ }
3066
+
3067
+ .list-head {
3068
+ background: #0b100e;
2377
3069
  border-bottom: 1px solid var(--line);
2378
- padding: 13px 14px;
2379
- text-align: left;
2380
- vertical-align: top;
3070
+ padding: 12px 18px;
2381
3071
  }
2382
3072
 
2383
- tr:last-child td {
3073
+ .deploy-row {
3074
+ background: var(--panel);
3075
+ border-bottom: 1px solid var(--line);
3076
+ min-width: 0;
3077
+ padding: 18px;
3078
+ }
3079
+
3080
+ .deploy-row:last-child {
2384
3081
  border-bottom: 0;
2385
3082
  }
2386
3083
 
2387
- td:first-child {
2388
- display: grid;
2389
- gap: 4px;
3084
+ .deploy-row:hover {
3085
+ background: var(--panel-raised);
2390
3086
  }
2391
3087
 
2392
- .pill {
2393
- border: 1px solid var(--line);
2394
- border-radius: 999px;
2395
- display: inline-flex;
2396
- font-family: "SFMono-Regular", Consolas, monospace;
2397
- font-size: 12px;
2398
- padding: 3px 8px;
3088
+ .deploy-main,
3089
+ .activity,
3090
+ .usage-grid,
3091
+ .quota {
3092
+ min-width: 0;
2399
3093
  }
2400
3094
 
2401
- .pill.active {
2402
- border-color: rgba(145, 212, 111, 0.7);
3095
+ .deploy-title-line {
3096
+ align-items: center;
3097
+ display: flex;
3098
+ flex-wrap: wrap;
3099
+ gap: 8px 10px;
3100
+ margin-bottom: 8px;
3101
+ min-width: 0;
3102
+ }
3103
+
3104
+ .deploy-name {
3105
+ color: var(--text);
3106
+ font-size: 16px;
3107
+ font-weight: 700;
3108
+ overflow-wrap: anywhere;
3109
+ }
3110
+
3111
+ .deploy-ids {
3112
+ align-items: center;
3113
+ display: flex;
3114
+ flex-wrap: wrap;
3115
+ gap: 6px 12px;
3116
+ }
3117
+
3118
+ .deploy-ids span {
3119
+ overflow-wrap: anywhere;
3120
+ }
3121
+
3122
+ .status {
3123
+ align-items: center;
2403
3124
  color: var(--accent);
3125
+ display: inline-flex;
3126
+ gap: 6px;
3127
+ white-space: nowrap;
2404
3128
  }
2405
3129
 
2406
- .pill.expired,
2407
- .pill.terminated {
2408
- border-color: rgba(238, 125, 113, 0.75);
2409
- color: var(--bad);
3130
+ .status::before {
3131
+ background: var(--accent);
3132
+ border-radius: 999px;
3133
+ box-shadow: 0 0 0 3px rgba(183, 242, 109, 0.14);
3134
+ content: "";
3135
+ height: 7px;
3136
+ width: 7px;
3137
+ }
3138
+
3139
+ .status-expired,
3140
+ .status-terminated {
3141
+ color: var(--danger);
2410
3142
  }
2411
3143
 
2412
- @media (max-width: 720px) {
3144
+ .status-expired::before,
3145
+ .status-terminated::before {
3146
+ background: var(--danger);
3147
+ box-shadow: 0 0 0 3px rgba(255, 123, 114, 0.15);
3148
+ }
3149
+
3150
+ .expiry {
3151
+ color: var(--warning);
3152
+ white-space: nowrap;
3153
+ }
3154
+
3155
+ .activity {
3156
+ display: grid;
3157
+ gap: 6px;
3158
+ }
3159
+
3160
+ .activity-item {
3161
+ align-items: baseline;
3162
+ color: var(--soft);
3163
+ display: flex;
3164
+ gap: 12px;
3165
+ justify-content: space-between;
3166
+ }
3167
+
3168
+ time {
3169
+ color: var(--text);
3170
+ white-space: nowrap;
3171
+ }
3172
+
3173
+ .usage-grid {
3174
+ display: grid;
3175
+ gap: 12px;
3176
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3177
+ }
3178
+
3179
+ .quota-row {
3180
+ align-items: baseline;
3181
+ display: flex;
3182
+ gap: 12px;
3183
+ justify-content: space-between;
3184
+ margin-bottom: 8px;
3185
+ }
3186
+
3187
+ .quota-count {
3188
+ color: var(--text);
3189
+ white-space: nowrap;
3190
+ }
3191
+
3192
+ .meter {
3193
+ background: #222b26;
3194
+ border-radius: 999px;
3195
+ height: 6px;
3196
+ overflow: hidden;
3197
+ }
3198
+
3199
+ .meter span {
3200
+ background: linear-gradient(90deg, var(--accent), var(--cyan));
3201
+ display: block;
3202
+ height: 100%;
3203
+ width: var(--usage);
3204
+ }
3205
+
3206
+ @media (max-width: 980px) {
2413
3207
  .shell {
2414
- padding: 18px;
3208
+ padding: 24px 18px 40px;
2415
3209
  }
2416
3210
 
2417
3211
  header {
@@ -2419,12 +3213,46 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2419
3213
  flex-direction: column;
2420
3214
  }
2421
3215
 
2422
- table {
2423
- min-width: 760px;
3216
+ h1 {
3217
+ font-size: 32px;
2424
3218
  }
2425
3219
 
2426
- .panel {
2427
- overflow-x: auto;
3220
+ .summary {
3221
+ grid-template-columns: repeat(3, minmax(0, 1fr));
3222
+ }
3223
+
3224
+ .list-head {
3225
+ display: none;
3226
+ }
3227
+
3228
+ .deploy-row {
3229
+ align-items: stretch;
3230
+ gap: 16px;
3231
+ grid-template-columns: 1fr;
3232
+ }
3233
+
3234
+ .activity-item {
3235
+ justify-content: flex-start;
3236
+ }
3237
+ }
3238
+
3239
+ @media (max-width: 640px) {
3240
+ .shell {
3241
+ padding: 20px 12px 36px;
3242
+ }
3243
+
3244
+ .summary,
3245
+ .usage-grid {
3246
+ grid-template-columns: 1fr;
3247
+ }
3248
+
3249
+ .deploy-ids {
3250
+ display: grid;
3251
+ }
3252
+
3253
+ .activity-item {
3254
+ display: grid;
3255
+ gap: 2px;
2428
3256
  }
2429
3257
  }
2430
3258
  </style>
@@ -2447,6 +3275,15 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2447
3275
  }
2448
3276
  </div>
2449
3277
  </header>
3278
+ ${
3279
+ signedIn && deploys.length
3280
+ ? `<section class="summary" aria-label="Deployment summary">
3281
+ <div class="metric"><span>active deploys</span><strong>${escapeHtml(formatHtmlNumber(activeDeploys))}</strong></div>
3282
+ <div class="metric"><span>requests today</span><strong>${escapeHtml(formatHtmlNumber(requestsToday))}</strong></div>
3283
+ <div class="metric"><span>mutations today</span><strong>${escapeHtml(formatHtmlNumber(mutationsToday))}</strong></div>
3284
+ </section>`
3285
+ : ""
3286
+ }
2450
3287
 
2451
3288
  ${
2452
3289
  !authConfigured
@@ -2456,20 +3293,14 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2456
3293
  : deploys.length === 0
2457
3294
  ? `<section class="panel"><div class="empty">No claimed deployments.</div></section>`
2458
3295
  : `<section class="panel">
2459
- <table>
2460
- <thead>
2461
- <tr>
2462
- <th>Deploy</th>
2463
- <th>Status</th>
2464
- <th>Created</th>
2465
- <th>Updated</th>
2466
- <th>Expires</th>
2467
- <th>Requests</th>
2468
- <th>Mutations</th>
2469
- </tr>
2470
- </thead>
2471
- <tbody>${rows}</tbody>
2472
- </table>
3296
+ <div class="deploy-list">
3297
+ <div class="list-head" aria-hidden="true">
3298
+ <span>Deploy</span>
3299
+ <span>Activity</span>
3300
+ <span>Usage today</span>
3301
+ </div>
3302
+ ${rows}
3303
+ </div>
2473
3304
  </section>`
2474
3305
  }
2475
3306
  </main>
@@ -2480,14 +3311,19 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2480
3311
  export class MemoryAnonymousStore {
2481
3312
  constructor() {
2482
3313
  this.artifacts = new Map();
3314
+ this.deployDomainsByHostname = new Map();
2483
3315
  this.deploys = new Map();
2484
3316
  this.deploysBySlug = new Map();
3317
+ this.deployCreateQuotaEvents = new Map();
3318
+ this.clientQuotaEvents = new Map();
2485
3319
  this.logs = new Map();
2486
3320
  this.quotaEvents = new Map();
2487
3321
  this.queues = new Map();
2488
3322
  this.rows = new Map();
2489
3323
  this.serverEnv = new Map();
2490
3324
  this.users = new Map();
3325
+ this.cleanupRuns = [];
3326
+ this.cleanupTotals = {};
2491
3327
  }
2492
3328
 
2493
3329
  async initialize() {}
@@ -2614,11 +3450,30 @@ export class MemoryAnonymousStore {
2614
3450
  });
2615
3451
  }
2616
3452
 
2617
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
3453
+ decrementArtifactRef(artifactHash) {
3454
+ const artifact = this.artifacts.get(artifactHash);
3455
+ if (!artifact) {
3456
+ return false;
3457
+ }
3458
+
3459
+ const refCount = Math.max(0, Number(artifact.refCount ?? 0) - 1);
3460
+ if (refCount <= 0) {
3461
+ this.artifacts.delete(artifactHash);
3462
+ return true;
3463
+ }
3464
+
3465
+ this.artifacts.set(artifactHash, { ...artifact, refCount });
3466
+ return false;
3467
+ }
3468
+
3469
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, inspectPolicy, publicRootUrl, serverEnv }) {
2618
3470
  const deployId = createDeployId();
2619
3471
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
2620
3472
  let slug = createSlug();
2621
- while (this.deploysBySlug.has(slug)) {
3473
+ while (
3474
+ this.deploysBySlug.has(slug) ||
3475
+ (normalizedAppBaseDomain && this.deployDomainsByHostname.has(`${slug}.${normalizedAppBaseDomain}`))
3476
+ ) {
2622
3477
  slug = createSlug();
2623
3478
  }
2624
3479
 
@@ -2644,6 +3499,7 @@ export class MemoryAnonymousStore {
2644
3499
  createdAt,
2645
3500
  expiresAt,
2646
3501
  id: deployId,
3502
+ inspectPolicy: normalizeInspectPolicy(inspectPolicy),
2647
3503
  limits: { ...DEFAULT_ANONYMOUS_LIMITS },
2648
3504
  owner: null,
2649
3505
  ownerId: null,
@@ -2668,6 +3524,7 @@ export class MemoryAnonymousStore {
2668
3524
  clientBundleBase64,
2669
3525
  clientBundleHash,
2670
3526
  deployId,
3527
+ inspectPolicy,
2671
3528
  publicRootUrl,
2672
3529
  serverEnv
2673
3530
  }) {
@@ -2685,6 +3542,7 @@ export class MemoryAnonymousStore {
2685
3542
  artifactHash,
2686
3543
  clientBundleHash,
2687
3544
  expiresAt: currentDeploy.ownerId ? null : anonymousDeployExpiresAt(),
3545
+ inspectPolicy: inspectPolicy === undefined ? inspectPolicyForDeploy(currentDeploy) : normalizeInspectPolicy(inspectPolicy),
2688
3546
  publicRootUrl: nextPublicRootUrl,
2689
3547
  url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
2690
3548
  status: "active",
@@ -2699,6 +3557,7 @@ export class MemoryAnonymousStore {
2699
3557
  createdAt: updatedAt
2700
3558
  });
2701
3559
  this.deploys.set(deployId, deploy);
3560
+ this.decrementArtifactRef(currentDeploy.artifactHash);
2702
3561
  if (serverEnv !== undefined) {
2703
3562
  this.serverEnv.set(deployId, { ...serverEnv });
2704
3563
  }
@@ -2757,6 +3616,62 @@ export class MemoryAnonymousStore {
2757
3616
  return id ? this.getDeployById(id) : null;
2758
3617
  }
2759
3618
 
3619
+ async getDeployDomainByHostname(hostname) {
3620
+ const domain = this.deployDomainsByHostname.get(hostname);
3621
+ return domain ? { ...domain } : null;
3622
+ }
3623
+
3624
+ async listDeployDomainsForDeploy(deployId) {
3625
+ return Array.from(this.deployDomainsByHostname.values())
3626
+ .filter((domain) => domain.deployId === deployId)
3627
+ .sort((left, right) => String(left.hostname).localeCompare(String(right.hostname)))
3628
+ .map((domain) => ({ ...domain }));
3629
+ }
3630
+
3631
+ async createLakebedSubdomain({ deployId, hostname, label, ownerId }) {
3632
+ const deploy = await this.getStoredDeployById(deployId);
3633
+ if (!deploy) {
3634
+ return { status: "missing" };
3635
+ }
3636
+ if (!deploy.ownerId) {
3637
+ return { deploy, status: "unclaimed" };
3638
+ }
3639
+ if (deploy.ownerId !== ownerId) {
3640
+ return { deploy, status: "forbidden" };
3641
+ }
3642
+ if (deploy.status !== "active" || isExpired(deploy)) {
3643
+ return { deploy, status: "inactive" };
3644
+ }
3645
+
3646
+ const existingDomain = this.deployDomainsByHostname.get(hostname);
3647
+ if (existingDomain) {
3648
+ if (existingDomain.deployId === deployId) {
3649
+ return { deploy, domain: { ...existingDomain }, status: "exists" };
3650
+ }
3651
+
3652
+ return { deploy, domain: { ...existingDomain }, status: "conflict" };
3653
+ }
3654
+
3655
+ if (this.deploysBySlug.has(label)) {
3656
+ return { deploy, status: "slug_conflict" };
3657
+ }
3658
+
3659
+ const timestamp = now();
3660
+ const domain = {
3661
+ createdAt: timestamp,
3662
+ deployId,
3663
+ hostname,
3664
+ isPrimary: false,
3665
+ kind: "lakebed_subdomain",
3666
+ ownerId,
3667
+ status: "active",
3668
+ updatedAt: timestamp,
3669
+ url: `https://${hostname}`
3670
+ };
3671
+ this.deployDomainsByHostname.set(hostname, domain);
3672
+ return { deploy, domain: { ...domain }, status: "created" };
3673
+ }
3674
+
2760
3675
  async getArtifact(hash) {
2761
3676
  return this.artifacts.get(hash) ?? null;
2762
3677
  }
@@ -2806,8 +3721,45 @@ export class MemoryAnonymousStore {
2806
3721
  return { ...(this.serverEnv.get(deployId) ?? {}) };
2807
3722
  }
2808
3723
 
2809
- async transaction(deployId, handler) {
2810
- const run = () => handler(this);
3724
+ snapshotRowsForDeploy(deployId) {
3725
+ const snapshot = new Map();
3726
+ for (const [key, rows] of this.rows) {
3727
+ const [eventDeployId] = key.split(":");
3728
+ if (eventDeployId === deployId) {
3729
+ snapshot.set(key, new Map(Array.from(rows.entries()).map(([rowId, row]) => [rowId, { ...row }])));
3730
+ }
3731
+ }
3732
+ return snapshot;
3733
+ }
3734
+
3735
+ restoreRowsForDeploy(deployId, snapshot) {
3736
+ for (const key of Array.from(this.rows.keys())) {
3737
+ const [eventDeployId] = key.split(":");
3738
+ if (eventDeployId === deployId) {
3739
+ this.rows.delete(key);
3740
+ }
3741
+ }
3742
+ for (const [key, rows] of snapshot) {
3743
+ this.rows.set(key, new Map(Array.from(rows.entries()).map(([rowId, row]) => [rowId, { ...row }])));
3744
+ }
3745
+ }
3746
+
3747
+ assertStateWithinLimits(deployId, options) {
3748
+ assertStateResourceLimits(this.stateResourceForDeploy(deployId), options);
3749
+ }
3750
+
3751
+ async transaction(deployId, handler, options = {}) {
3752
+ const run = async () => {
3753
+ const snapshot = this.snapshotRowsForDeploy(deployId);
3754
+ try {
3755
+ const result = await handler(this);
3756
+ this.assertStateWithinLimits(deployId, options);
3757
+ return result;
3758
+ } catch (error) {
3759
+ this.restoreRowsForDeploy(deployId, snapshot);
3760
+ throw error;
3761
+ }
3762
+ };
2811
3763
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
2812
3764
  this.queues.set(
2813
3765
  deployId,
@@ -2820,13 +3772,17 @@ export class MemoryAnonymousStore {
2820
3772
  }
2821
3773
 
2822
3774
  async appendLog(deployId, level, message, data) {
2823
- const entries = this.logs.get(deployId) ?? [];
2824
- entries.push({ at: now(), data, level, message });
2825
- this.logs.set(deployId, entries.slice(-DEFAULT_ANONYMOUS_LIMITS.logEntries));
3775
+ const entries = [...(this.logs.get(deployId) ?? []), redactLogEntry(normalizeLogEntry(level, message, data))];
3776
+ const maxEntries = DEFAULT_ANONYMOUS_LIMITS.logEntries;
3777
+ const maxBytes = DEFAULT_ANONYMOUS_LIMITS.logBytes;
3778
+ while (entries.length > maxEntries || bytesOfJson(entries) > maxBytes) {
3779
+ entries.shift();
3780
+ }
3781
+ this.logs.set(deployId, entries);
2826
3782
  }
2827
3783
 
2828
3784
  async readLogs(deployId, limit = 100) {
2829
- return (this.logs.get(deployId) ?? []).slice(-limit);
3785
+ return (this.logs.get(deployId) ?? []).slice(-limit).map(redactLogEntry);
2830
3786
  }
2831
3787
 
2832
3788
  async tableCounts(deployId, schema) {
@@ -2858,12 +3814,67 @@ export class MemoryAnonymousStore {
2858
3814
  }
2859
3815
 
2860
3816
  async incrementQuota(deployId, bucket, limit) {
3817
+ if (!Number.isFinite(limit)) {
3818
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
3819
+ }
3820
+
2861
3821
  const windowStart = dayWindowStart();
2862
3822
  const key = `${deployId}:${bucket}:${windowStart}`;
2863
3823
  const count = (this.quotaEvents.get(key) ?? 0) + 1;
2864
3824
  this.quotaEvents.set(key, count);
2865
3825
  if (count > limit) {
2866
- throw new Error(`Anonymous ${bucket} quota exceeded. Limit: ${limit} per day.`);
3826
+ throw new LakebedQuotaError({
3827
+ bucket,
3828
+ count,
3829
+ limit,
3830
+ resetAt: quotaResetAt(windowStart),
3831
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
3832
+ suggestion: bucket === "mutations" ? "Retry after reset, reduce mutation frequency, or claim the deploy." : "Retry after reset or reduce request frequency."
3833
+ });
3834
+ }
3835
+ return { bucket, count, limit, windowStart };
3836
+ }
3837
+
3838
+ async incrementDeployCreateQuota(clientKey, bucket, limit) {
3839
+ if (!Number.isFinite(limit)) {
3840
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
3841
+ }
3842
+
3843
+ const windowStart = dayWindowStart();
3844
+ const key = JSON.stringify([clientKey, bucket, windowStart]);
3845
+ const count = (this.deployCreateQuotaEvents.get(key) ?? 0) + 1;
3846
+ this.deployCreateQuotaEvents.set(key, count);
3847
+ if (count > limit) {
3848
+ throw new LakebedQuotaError({
3849
+ bucket,
3850
+ count,
3851
+ limit,
3852
+ resetAt: quotaResetAt(windowStart),
3853
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
3854
+ suggestion: "Retry after reset, reuse an existing deploy, or sign in and claim deploys you want to keep."
3855
+ });
3856
+ }
3857
+ return { bucket, count, limit, windowStart };
3858
+ }
3859
+
3860
+ async incrementClientQuota(clientKey, bucket, limit) {
3861
+ if (!Number.isFinite(limit)) {
3862
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
3863
+ }
3864
+
3865
+ const windowStart = dayWindowStart();
3866
+ const key = JSON.stringify([clientKey, bucket, windowStart]);
3867
+ const count = (this.clientQuotaEvents.get(key) ?? 0) + 1;
3868
+ this.clientQuotaEvents.set(key, count);
3869
+ if (count > limit) {
3870
+ throw new LakebedQuotaError({
3871
+ bucket,
3872
+ count,
3873
+ limit,
3874
+ resetAt: quotaResetAt(windowStart),
3875
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
3876
+ suggestion: clientTrafficQuotaSuggestion(bucket)
3877
+ });
2867
3878
  }
2868
3879
  return { bucket, count, limit, windowStart };
2869
3880
  }
@@ -2949,6 +3960,160 @@ export class MemoryAnonymousStore {
2949
3960
 
2950
3961
  return summaries;
2951
3962
  }
3963
+
3964
+ deleteDeployResources(deployId) {
3965
+ const deploy = this.deploys.get(deployId);
3966
+ if (!deploy) {
3967
+ return {
3968
+ deletedArtifacts: 0,
3969
+ deletedDomains: 0,
3970
+ deletedLogEntries: 0,
3971
+ deletedQuotaEvents: 0,
3972
+ deletedServerEnv: 0,
3973
+ deletedStateRows: 0
3974
+ };
3975
+ }
3976
+
3977
+ let deletedStateRows = 0;
3978
+ for (const [key, rows] of Array.from(this.rows.entries())) {
3979
+ const [eventDeployId] = key.split(":");
3980
+ if (eventDeployId === deployId) {
3981
+ deletedStateRows += rows.size;
3982
+ this.rows.delete(key);
3983
+ }
3984
+ }
3985
+
3986
+ const deletedLogEntries = this.logs.get(deployId)?.length ?? 0;
3987
+ this.logs.delete(deployId);
3988
+
3989
+ let deletedQuotaEvents = 0;
3990
+ for (const key of Array.from(this.quotaEvents.keys())) {
3991
+ const [eventDeployId] = key.split(":");
3992
+ if (eventDeployId === deployId) {
3993
+ deletedQuotaEvents += 1;
3994
+ this.quotaEvents.delete(key);
3995
+ }
3996
+ }
3997
+
3998
+ const deletedServerEnv = this.serverEnv.has(deployId) ? Object.keys(this.serverEnv.get(deployId) ?? {}).length : 0;
3999
+ this.serverEnv.delete(deployId);
4000
+
4001
+ let deletedDomains = 0;
4002
+ for (const [hostname, domain] of Array.from(this.deployDomainsByHostname.entries())) {
4003
+ if (domain.deployId === deployId) {
4004
+ this.deployDomainsByHostname.delete(hostname);
4005
+ deletedDomains += 1;
4006
+ }
4007
+ }
4008
+
4009
+ this.deploys.delete(deployId);
4010
+ this.deploysBySlug.delete(deploy.slug);
4011
+ const deletedArtifacts = this.decrementArtifactRef(deploy.artifactHash) ? 1 : 0;
4012
+
4013
+ return {
4014
+ deletedArtifacts,
4015
+ deletedDomains,
4016
+ deletedLogEntries,
4017
+ deletedQuotaEvents,
4018
+ deletedServerEnv,
4019
+ deletedStateRows
4020
+ };
4021
+ }
4022
+
4023
+ recordCleanupRun(stats) {
4024
+ this.cleanupRuns.push(stats);
4025
+ this.cleanupRuns = this.cleanupRuns.slice(-20);
4026
+ for (const [key, value] of Object.entries(stats)) {
4027
+ if (typeof value === "number") {
4028
+ this.cleanupTotals[key] = (this.cleanupTotals[key] ?? 0) + value;
4029
+ }
4030
+ }
4031
+ }
4032
+
4033
+ async cleanupExpiredDeploys({ graceSeconds = 60 * 60, retentionSeconds = 7 * 24 * 60 * 60, nowMs = Date.now() } = {}) {
4034
+ const startedAt = new Date(nowMs).toISOString();
4035
+ const markCutoffMs = nowMs - graceSeconds * 1000;
4036
+ const deleteCutoffMs = nowMs - retentionSeconds * 1000;
4037
+ const stats = {
4038
+ deletedArtifacts: 0,
4039
+ deletedDomains: 0,
4040
+ deletedDeploys: 0,
4041
+ deletedLogEntries: 0,
4042
+ deletedQuotaEvents: 0,
4043
+ deletedServerEnv: 0,
4044
+ deletedStateRows: 0,
4045
+ examinedDeploys: this.deploys.size,
4046
+ finishedAt: null,
4047
+ markedTerminated: 0,
4048
+ startedAt
4049
+ };
4050
+
4051
+ for (const deploy of Array.from(this.deploys.values())) {
4052
+ if (deploy.ownerId || !deploy.expiresAt) {
4053
+ continue;
4054
+ }
4055
+
4056
+ const expiresAtMs = Date.parse(deploy.expiresAt);
4057
+ if (Number.isFinite(expiresAtMs) && expiresAtMs <= markCutoffMs && deploy.status === "active") {
4058
+ this.deploys.set(deploy.id, { ...deploy, status: "terminated", updatedAt: new Date(nowMs).toISOString() });
4059
+ stats.markedTerminated += 1;
4060
+ }
4061
+ }
4062
+
4063
+ for (const deploy of Array.from(this.deploys.values())) {
4064
+ if (deploy.ownerId || !deploy.expiresAt) {
4065
+ continue;
4066
+ }
4067
+
4068
+ const expiresAtMs = Date.parse(deploy.expiresAt);
4069
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs > deleteCutoffMs) {
4070
+ continue;
4071
+ }
4072
+
4073
+ const deleted = this.deleteDeployResources(deploy.id);
4074
+ stats.deletedDeploys += 1;
4075
+ for (const [key, value] of Object.entries(deleted)) {
4076
+ stats[key] += value;
4077
+ }
4078
+ }
4079
+
4080
+ for (const [key] of Array.from(this.deployCreateQuotaEvents.entries())) {
4081
+ try {
4082
+ const [, , windowStart] = JSON.parse(key);
4083
+ if (Date.parse(windowStart) <= deleteCutoffMs) {
4084
+ this.deployCreateQuotaEvents.delete(key);
4085
+ stats.deletedQuotaEvents += 1;
4086
+ }
4087
+ } catch {
4088
+ this.deployCreateQuotaEvents.delete(key);
4089
+ stats.deletedQuotaEvents += 1;
4090
+ }
4091
+ }
4092
+ for (const [key] of Array.from(this.clientQuotaEvents.entries())) {
4093
+ try {
4094
+ const [, , windowStart] = JSON.parse(key);
4095
+ if (Date.parse(windowStart) <= deleteCutoffMs) {
4096
+ this.clientQuotaEvents.delete(key);
4097
+ stats.deletedQuotaEvents += 1;
4098
+ }
4099
+ } catch {
4100
+ this.clientQuotaEvents.delete(key);
4101
+ stats.deletedQuotaEvents += 1;
4102
+ }
4103
+ }
4104
+
4105
+ stats.finishedAt = now();
4106
+ this.recordCleanupRun(stats);
4107
+ return stats;
4108
+ }
4109
+
4110
+ async readCleanupActivity() {
4111
+ return {
4112
+ lastRun: this.cleanupRuns[this.cleanupRuns.length - 1] ?? null,
4113
+ recentRuns: [...this.cleanupRuns].reverse(),
4114
+ totals: { ...this.cleanupTotals }
4115
+ };
4116
+ }
2952
4117
  }
2953
4118
 
2954
4119
  export class PostgresAnonymousStore {
@@ -2976,6 +4141,7 @@ export class PostgresAnonymousStore {
2976
4141
  owner_id text,
2977
4142
  owner_json jsonb,
2978
4143
  claim_token_hash text not null,
4144
+ inspect_policy text not null default 'private',
2979
4145
  limits_json jsonb not null,
2980
4146
  counters_json jsonb not null default '{}',
2981
4147
  public_root_url text not null,
@@ -2986,11 +4152,26 @@ export class PostgresAnonymousStore {
2986
4152
  await this.query("alter table deploys add column if not exists claimed_at timestamptz");
2987
4153
  await this.query("alter table deploys add column if not exists owner_id text");
2988
4154
  await this.query("alter table deploys add column if not exists owner_json jsonb");
4155
+ await this.query("alter table deploys add column if not exists inspect_policy text not null default 'private'");
2989
4156
  await this.query("alter table deploys add column if not exists updated_at timestamptz");
2990
4157
  await this.query("update deploys set updated_at = created_at where updated_at is null");
2991
4158
  await this.query("alter table deploys alter column updated_at set not null");
2992
4159
  await this.query("alter table deploys alter column expires_at drop not null");
2993
4160
  await this.query("update deploys set expires_at = null where owner_id is not null and expires_at is not null");
4161
+ await this.query(`
4162
+ create table if not exists deploy_domains(
4163
+ hostname text primary key,
4164
+ deploy_id text not null references deploys(id) on delete cascade,
4165
+ owner_id text not null,
4166
+ kind text not null,
4167
+ status text not null,
4168
+ is_primary boolean not null default false,
4169
+ created_at timestamptz not null,
4170
+ updated_at timestamptz not null
4171
+ )
4172
+ `);
4173
+ await this.query("create index if not exists deploy_domains_deploy_id_idx on deploy_domains(deploy_id)");
4174
+ await this.query("create index if not exists deploy_domains_owner_id_idx on deploy_domains(owner_id)");
2994
4175
  await this.query(`
2995
4176
  create table if not exists artifacts(
2996
4177
  hash text primary key,
@@ -3032,6 +4213,24 @@ export class PostgresAnonymousStore {
3032
4213
  primary key (deploy_id, bucket, window_start)
3033
4214
  )
3034
4215
  `);
4216
+ await this.query(`
4217
+ create table if not exists deploy_create_quota_events(
4218
+ client_key text not null,
4219
+ bucket text not null,
4220
+ window_start timestamptz not null,
4221
+ count integer not null,
4222
+ primary key (client_key, bucket, window_start)
4223
+ )
4224
+ `);
4225
+ await this.query(`
4226
+ create table if not exists client_quota_events(
4227
+ client_key text not null,
4228
+ bucket text not null,
4229
+ window_start timestamptz not null,
4230
+ count integer not null,
4231
+ primary key (client_key, bucket, window_start)
4232
+ )
4233
+ `);
3035
4234
  await this.query(`
3036
4235
  create table if not exists deploy_server_env(
3037
4236
  deploy_id text not null references deploys(id) on delete cascade,
@@ -3056,6 +4255,14 @@ export class PostgresAnonymousStore {
3056
4255
  updated_at timestamptz not null
3057
4256
  )
3058
4257
  `);
4258
+ await this.query(`
4259
+ create table if not exists cleanup_runs(
4260
+ id bigserial primary key,
4261
+ started_at timestamptz not null,
4262
+ finished_at timestamptz not null,
4263
+ stats_json jsonb not null
4264
+ )
4265
+ `);
3059
4266
  await this.query("alter table users add column if not exists boost boolean not null default false");
3060
4267
  await this.query(`
3061
4268
  insert into users(
@@ -3096,6 +4303,33 @@ export class PostgresAnonymousStore {
3096
4303
  return this.pool.query(sql, params);
3097
4304
  }
3098
4305
 
4306
+ async withPostgresTransaction(handler) {
4307
+ if (!this.pool) {
4308
+ throw new Error("Postgres store is not initialized.");
4309
+ }
4310
+
4311
+ const client = await this.pool.connect();
4312
+ try {
4313
+ await client.query("begin");
4314
+ const result = await handler(client);
4315
+ await client.query("commit");
4316
+ return result;
4317
+ } catch (error) {
4318
+ try {
4319
+ await client.query("rollback");
4320
+ } catch {
4321
+ // Preserve the original transaction error.
4322
+ }
4323
+ throw error;
4324
+ } finally {
4325
+ client.release();
4326
+ }
4327
+ }
4328
+
4329
+ async lockHostnameAllocation(client, hostname) {
4330
+ await client.query("select pg_advisory_xact_lock(hashtext('lakebed.deploy_hostname'), hashtext($1))", [hostname]);
4331
+ }
4332
+
3099
4333
  rowToUser(row) {
3100
4334
  if (!row) {
3101
4335
  return null;
@@ -3320,10 +4554,30 @@ export class PostgresAnonymousStore {
3320
4554
  );
3321
4555
  }
3322
4556
 
3323
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
4557
+ async decrementArtifactRef(artifactHash) {
4558
+ const result = await this.query(
4559
+ `
4560
+ update artifacts
4561
+ set ref_count = greatest(ref_count - 1, 0)
4562
+ where hash = $1
4563
+ returning ref_count
4564
+ `,
4565
+ [artifactHash]
4566
+ );
4567
+ const refCount = result.rows[0]?.ref_count;
4568
+ if (refCount === undefined || Number(refCount) > 0) {
4569
+ return false;
4570
+ }
4571
+
4572
+ await this.query("delete from artifacts where hash = $1 and ref_count <= 0", [artifactHash]);
4573
+ return true;
4574
+ }
4575
+
4576
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, inspectPolicy, publicRootUrl, serverEnv }) {
3324
4577
  const createdAt = now();
3325
4578
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
3326
4579
  const expiresAt = anonymousDeployExpiresAt();
4580
+ const normalizedInspectPolicy = normalizeInspectPolicy(inspectPolicy);
3327
4581
  const token = createClaimToken();
3328
4582
  const deployId = createDeployId();
3329
4583
 
@@ -3331,6 +4585,8 @@ export class PostgresAnonymousStore {
3331
4585
 
3332
4586
  for (let attempt = 0; attempt < 8; attempt += 1) {
3333
4587
  const slug = createSlug();
4588
+ const generatedHostname = normalizedAppBaseDomain ? `${slug}.${normalizedAppBaseDomain}` : null;
4589
+
3334
4590
  const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
3335
4591
  const deploy = {
3336
4592
  appBaseDomain: normalizedAppBaseDomain,
@@ -3341,6 +4597,7 @@ export class PostgresAnonymousStore {
3341
4597
  createdAt,
3342
4598
  expiresAt,
3343
4599
  id: deployId,
4600
+ inspectPolicy: normalizedInspectPolicy,
3344
4601
  limits: { ...DEFAULT_ANONYMOUS_LIMITS },
3345
4602
  owner: null,
3346
4603
  ownerId: null,
@@ -3352,30 +4609,45 @@ export class PostgresAnonymousStore {
3352
4609
  };
3353
4610
 
3354
4611
  try {
3355
- await this.query(
3356
- `
3357
- insert into deploys(
3358
- id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
3359
- claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
3360
- )
3361
- values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
3362
- `,
3363
- [
3364
- deploy.id,
3365
- deploy.slug,
3366
- deploy.status,
3367
- deploy.artifactHash,
3368
- deploy.clientBundleHash,
3369
- deploy.createdAt,
3370
- deploy.updatedAt,
3371
- deploy.expiresAt,
3372
- deploy.claimTokenHash,
3373
- JSON.stringify(deploy.limits),
3374
- deploy.publicRootUrl,
3375
- deploy.appBaseDomain,
3376
- deploy.url
3377
- ]
3378
- );
4612
+ const inserted = await this.withPostgresTransaction(async (client) => {
4613
+ if (generatedHostname) {
4614
+ await this.lockHostnameAllocation(client, generatedHostname);
4615
+ const domainConflict = await client.query("select 1 from deploy_domains where hostname = $1", [generatedHostname]);
4616
+ if (domainConflict.rows.length > 0) {
4617
+ return false;
4618
+ }
4619
+ }
4620
+
4621
+ await client.query(
4622
+ `
4623
+ insert into deploys(
4624
+ id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
4625
+ claim_token_hash, inspect_policy, limits_json, counters_json, public_root_url, app_base_domain, url
4626
+ )
4627
+ values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, '{}'::jsonb, $12, $13, $14)
4628
+ `,
4629
+ [
4630
+ deploy.id,
4631
+ deploy.slug,
4632
+ deploy.status,
4633
+ deploy.artifactHash,
4634
+ deploy.clientBundleHash,
4635
+ deploy.createdAt,
4636
+ deploy.updatedAt,
4637
+ deploy.expiresAt,
4638
+ deploy.claimTokenHash,
4639
+ deploy.inspectPolicy,
4640
+ JSON.stringify(deploy.limits),
4641
+ deploy.publicRootUrl,
4642
+ deploy.appBaseDomain,
4643
+ deploy.url
4644
+ ]
4645
+ );
4646
+ return true;
4647
+ });
4648
+ if (!inserted) {
4649
+ continue;
4650
+ }
3379
4651
  if (serverEnv !== undefined) {
3380
4652
  await this.replaceServerEnv(deploy.id, serverEnv, createdAt);
3381
4653
  }
@@ -3397,6 +4669,7 @@ export class PostgresAnonymousStore {
3397
4669
  clientBundleBase64,
3398
4670
  clientBundleHash,
3399
4671
  deployId,
4672
+ inspectPolicy,
3400
4673
  publicRootUrl,
3401
4674
  serverEnv
3402
4675
  }) {
@@ -3409,6 +4682,7 @@ export class PostgresAnonymousStore {
3409
4682
  const expiresAt = currentDeploy.ownerId ? null : anonymousDeployExpiresAt();
3410
4683
  const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
3411
4684
  const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
4685
+ const nextInspectPolicy = inspectPolicy === undefined ? inspectPolicyForDeploy(currentDeploy) : normalizeInspectPolicy(inspectPolicy);
3412
4686
  const url = appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug });
3413
4687
  await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt: updatedAt });
3414
4688
 
@@ -3422,15 +4696,17 @@ export class PostgresAnonymousStore {
3422
4696
  public_root_url = $5,
3423
4697
  app_base_domain = $6,
3424
4698
  url = $7,
3425
- updated_at = $8
4699
+ inspect_policy = $8,
4700
+ updated_at = $9
3426
4701
  where id = $1
3427
4702
  returning *
3428
4703
  `,
3429
- [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url, updatedAt]
4704
+ [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url, nextInspectPolicy, updatedAt]
3430
4705
  );
3431
4706
  if (serverEnv !== undefined) {
3432
4707
  await this.replaceServerEnv(deployId, serverEnv, updatedAt);
3433
4708
  }
4709
+ await this.decrementArtifactRef(currentDeploy.artifactHash);
3434
4710
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3435
4711
  }
3436
4712
 
@@ -3499,7 +4775,8 @@ export class PostgresAnonymousStore {
3499
4775
  createdAt: new Date(row.created_at).toISOString(),
3500
4776
  expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
3501
4777
  id: row.id,
3502
- limits: row.limits_json,
4778
+ inspectPolicy: normalizeInspectPolicy(row.inspect_policy),
4779
+ limits: { ...DEFAULT_ANONYMOUS_LIMITS, ...(row.limits_json ?? {}) },
3503
4780
  owner: row.owner_json ?? null,
3504
4781
  ownerId: row.owner_id ?? null,
3505
4782
  publicRootUrl: row.public_root_url,
@@ -3510,6 +4787,24 @@ export class PostgresAnonymousStore {
3510
4787
  };
3511
4788
  }
3512
4789
 
4790
+ rowToDeployDomain(row) {
4791
+ if (!row) {
4792
+ return null;
4793
+ }
4794
+
4795
+ return {
4796
+ createdAt: new Date(row.created_at).toISOString(),
4797
+ deployId: row.deploy_id,
4798
+ hostname: row.hostname,
4799
+ isPrimary: Boolean(row.is_primary),
4800
+ kind: row.kind,
4801
+ ownerId: row.owner_id,
4802
+ status: row.status,
4803
+ updatedAt: new Date(row.updated_at).toISOString(),
4804
+ url: `https://${row.hostname}`
4805
+ };
4806
+ }
4807
+
3513
4808
  async getDeployById(id) {
3514
4809
  return this.deployWithUserLimitOverrides(await this.getBaseDeployById(id));
3515
4810
  }
@@ -3519,6 +4814,80 @@ export class PostgresAnonymousStore {
3519
4814
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3520
4815
  }
3521
4816
 
4817
+ async getDeployDomainByHostname(hostname) {
4818
+ const result = await this.query("select * from deploy_domains where hostname = $1", [hostname]);
4819
+ return this.rowToDeployDomain(result.rows[0]);
4820
+ }
4821
+
4822
+ async listDeployDomainsForDeploy(deployId) {
4823
+ const result = await this.query("select * from deploy_domains where deploy_id = $1 order by hostname asc", [deployId]);
4824
+ return result.rows.map((row) => this.rowToDeployDomain(row));
4825
+ }
4826
+
4827
+ async createLakebedSubdomain({ deployId, hostname, label, ownerId }) {
4828
+ try {
4829
+ return await this.withPostgresTransaction(async (client) => {
4830
+ const deployResult = await client.query("select * from deploys where id = $1 for update", [deployId]);
4831
+ const deploy = this.rowToDeploy(deployResult.rows[0]);
4832
+ if (!deploy) {
4833
+ return { status: "missing" };
4834
+ }
4835
+ if (!deploy.ownerId) {
4836
+ return { deploy, status: "unclaimed" };
4837
+ }
4838
+ if (deploy.ownerId !== ownerId) {
4839
+ return { deploy, status: "forbidden" };
4840
+ }
4841
+ if (deploy.status !== "active" || isExpired(deploy)) {
4842
+ return { deploy, status: "inactive" };
4843
+ }
4844
+
4845
+ await this.lockHostnameAllocation(client, hostname);
4846
+
4847
+ const existingResult = await client.query("select * from deploy_domains where hostname = $1", [hostname]);
4848
+ const existing = this.rowToDeployDomain(existingResult.rows[0]);
4849
+ if (existing) {
4850
+ if (existing.deployId === deployId) {
4851
+ return { deploy, domain: existing, status: "exists" };
4852
+ }
4853
+
4854
+ return { deploy, domain: existing, status: "conflict" };
4855
+ }
4856
+
4857
+ const slugConflict = await client.query("select id from deploys where slug = $1 limit 1", [label]);
4858
+ if (slugConflict.rows.length > 0) {
4859
+ return { deploy, status: "slug_conflict" };
4860
+ }
4861
+
4862
+ const timestamp = now();
4863
+ const insertResult = await client.query(
4864
+ `
4865
+ insert into deploy_domains(hostname, deploy_id, owner_id, kind, status, is_primary, created_at, updated_at)
4866
+ values($1, $2, $3, 'lakebed_subdomain', 'active', false, $4, $4)
4867
+ returning *
4868
+ `,
4869
+ [hostname, deployId, ownerId, timestamp]
4870
+ );
4871
+ return { deploy, domain: this.rowToDeployDomain(insertResult.rows[0]), status: "created" };
4872
+ });
4873
+ } catch (error) {
4874
+ if (error?.code !== "23505") {
4875
+ throw error;
4876
+ }
4877
+
4878
+ const deploy = await this.getBaseDeployById(deployId);
4879
+ if (!deploy) {
4880
+ return { status: "missing" };
4881
+ }
4882
+ const conflict = await this.getDeployDomainByHostname(hostname);
4883
+ if (conflict?.deployId === deployId) {
4884
+ return { deploy, domain: conflict, status: "exists" };
4885
+ }
4886
+
4887
+ return { deploy, domain: conflict, status: "conflict" };
4888
+ }
4889
+ }
4890
+
3522
4891
  async getArtifact(hash) {
3523
4892
  const result = await this.query("select * from artifacts where hash = $1", [hash]);
3524
4893
  const row = result.rows[0];
@@ -3594,13 +4963,51 @@ export class PostgresAnonymousStore {
3594
4963
  }
3595
4964
  }
3596
4965
 
3597
- async getServerEnv(deployId) {
3598
- const result = await this.query("select env_key, env_value from deploy_server_env where deploy_id = $1", [deployId]);
3599
- return Object.fromEntries(result.rows.map((row) => [row.env_key, decryptServerEnvValue(row.env_value, this.serverEnvSecret)]));
4966
+ async getServerEnv(deployId) {
4967
+ const result = await this.query("select env_key, env_value from deploy_server_env where deploy_id = $1", [deployId]);
4968
+ return Object.fromEntries(result.rows.map((row) => [row.env_key, decryptServerEnvValue(row.env_value, this.serverEnvSecret)]));
4969
+ }
4970
+
4971
+ async stateResourceForDeploy(deployId) {
4972
+ const result = await this.query(
4973
+ `
4974
+ select
4975
+ count(*)::int as state_rows,
4976
+ coalesce(sum(octet_length(data_json::text)), 0)::int as state_bytes
4977
+ from state_rows
4978
+ where deploy_id = $1
4979
+ `,
4980
+ [deployId]
4981
+ );
4982
+ return {
4983
+ stateBytes: result.rows[0]?.state_bytes ?? 0,
4984
+ stateRows: result.rows[0]?.state_rows ?? 0
4985
+ };
4986
+ }
4987
+
4988
+ async assertStateWithinLimits(deployId, options) {
4989
+ assertStateResourceLimits(await this.stateResourceForDeploy(deployId), options);
3600
4990
  }
3601
4991
 
3602
- async transaction(deployId, handler) {
3603
- const run = () => handler(this);
4992
+ async transaction(deployId, handler, options = {}) {
4993
+ const run = async () => {
4994
+ const client = await this.pool.connect();
4995
+ const tx = Object.create(this);
4996
+ tx.query = (sql, params = []) => client.query(sql, params);
4997
+ tx.pool = this.pool;
4998
+ try {
4999
+ await client.query("begin");
5000
+ const result = await handler(tx);
5001
+ await tx.assertStateWithinLimits(deployId, options);
5002
+ await client.query("commit");
5003
+ return result;
5004
+ } catch (error) {
5005
+ await client.query("rollback").catch(() => undefined);
5006
+ throw error;
5007
+ } finally {
5008
+ client.release();
5009
+ }
5010
+ };
3604
5011
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
3605
5012
  this.queues.set(
3606
5013
  deployId,
@@ -3613,9 +5020,29 @@ export class PostgresAnonymousStore {
3613
5020
  }
3614
5021
 
3615
5022
  async appendLog(deployId, level, message, data) {
5023
+ const entry = redactLogEntry(normalizeLogEntry(level, message, data));
3616
5024
  await this.query(
3617
5025
  "insert into logs(deploy_id, level, message, data_json, created_at) values($1, $2, $3, $4::jsonb, $5)",
3618
- [deployId, level, message, JSON.stringify(data ?? null), now()]
5026
+ [deployId, entry.level, entry.message, JSON.stringify(entry.data ?? null), entry.at]
5027
+ );
5028
+ await this.query(
5029
+ `
5030
+ delete from logs
5031
+ where deploy_id = $1
5032
+ and sequence in (
5033
+ select sequence
5034
+ from (
5035
+ select
5036
+ sequence,
5037
+ row_number() over (order by sequence desc) as row_number,
5038
+ sum(octet_length(message) + octet_length(coalesce(data_json::text, ''))) over (order by sequence desc) as cumulative_bytes
5039
+ from logs
5040
+ where deploy_id = $1
5041
+ ) ranked
5042
+ where row_number > $2 or cumulative_bytes > $3
5043
+ )
5044
+ `,
5045
+ [deployId, DEFAULT_ANONYMOUS_LIMITS.logEntries, DEFAULT_ANONYMOUS_LIMITS.logBytes]
3619
5046
  );
3620
5047
  }
3621
5048
 
@@ -3624,7 +5051,7 @@ export class PostgresAnonymousStore {
3624
5051
  "select level, message, data_json, created_at from logs where deploy_id = $1 order by sequence desc limit $2",
3625
5052
  [deployId, limit]
3626
5053
  );
3627
- return result.rows.reverse().map((row) => ({
5054
+ return result.rows.reverse().map((row) => redactLogEntry({
3628
5055
  at: new Date(row.created_at).toISOString(),
3629
5056
  data: row.data_json,
3630
5057
  level: row.level,
@@ -3668,6 +5095,10 @@ export class PostgresAnonymousStore {
3668
5095
  }
3669
5096
 
3670
5097
  async incrementQuota(deployId, bucket, limit) {
5098
+ if (!Number.isFinite(limit)) {
5099
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
5100
+ }
5101
+
3671
5102
  const windowStart = dayWindowStart();
3672
5103
  const result = await this.query(
3673
5104
  `
@@ -3681,7 +5112,74 @@ export class PostgresAnonymousStore {
3681
5112
  );
3682
5113
  const count = result.rows[0].count;
3683
5114
  if (count > limit) {
3684
- throw new Error(`Anonymous ${bucket} quota exceeded. Limit: ${limit} per day.`);
5115
+ throw new LakebedQuotaError({
5116
+ bucket,
5117
+ count,
5118
+ limit,
5119
+ resetAt: quotaResetAt(windowStart),
5120
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
5121
+ suggestion: bucket === "mutations" ? "Retry after reset, reduce mutation frequency, or claim the deploy." : "Retry after reset or reduce request frequency."
5122
+ });
5123
+ }
5124
+ return { bucket, count, limit, windowStart };
5125
+ }
5126
+
5127
+ async incrementDeployCreateQuota(clientKey, bucket, limit) {
5128
+ if (!Number.isFinite(limit)) {
5129
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
5130
+ }
5131
+
5132
+ const windowStart = dayWindowStart();
5133
+ const result = await this.query(
5134
+ `
5135
+ insert into deploy_create_quota_events(client_key, bucket, window_start, count)
5136
+ values($1, $2, $3, 1)
5137
+ on conflict(client_key, bucket, window_start)
5138
+ do update set count = deploy_create_quota_events.count + 1
5139
+ returning count
5140
+ `,
5141
+ [clientKey, bucket, windowStart]
5142
+ );
5143
+ const count = result.rows[0].count;
5144
+ if (count > limit) {
5145
+ throw new LakebedQuotaError({
5146
+ bucket,
5147
+ count,
5148
+ limit,
5149
+ resetAt: quotaResetAt(windowStart),
5150
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
5151
+ suggestion: "Retry after reset, reuse an existing deploy, or sign in and claim deploys you want to keep."
5152
+ });
5153
+ }
5154
+ return { bucket, count, limit, windowStart };
5155
+ }
5156
+
5157
+ async incrementClientQuota(clientKey, bucket, limit) {
5158
+ if (!Number.isFinite(limit)) {
5159
+ return { bucket, count: 0, limit, windowStart: dayWindowStart() };
5160
+ }
5161
+
5162
+ const windowStart = dayWindowStart();
5163
+ const result = await this.query(
5164
+ `
5165
+ insert into client_quota_events(client_key, bucket, window_start, count)
5166
+ values($1, $2, $3, 1)
5167
+ on conflict(client_key, bucket, window_start)
5168
+ do update set count = client_quota_events.count + 1
5169
+ returning count
5170
+ `,
5171
+ [clientKey, bucket, windowStart]
5172
+ );
5173
+ const count = result.rows[0].count;
5174
+ if (count > limit) {
5175
+ throw new LakebedQuotaError({
5176
+ bucket,
5177
+ count,
5178
+ limit,
5179
+ resetAt: quotaResetAt(windowStart),
5180
+ retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
5181
+ suggestion: clientTrafficQuotaSuggestion(bucket)
5182
+ });
3685
5183
  }
3686
5184
  return { bucket, count, limit, windowStart };
3687
5185
  }
@@ -3795,6 +5293,109 @@ export class PostgresAnonymousStore {
3795
5293
  })
3796
5294
  );
3797
5295
  }
5296
+
5297
+ async recordCleanupRun(stats) {
5298
+ await this.query(
5299
+ "insert into cleanup_runs(started_at, finished_at, stats_json) values($1, $2, $3::jsonb)",
5300
+ [stats.startedAt, stats.finishedAt, JSON.stringify(stats)]
5301
+ );
5302
+ }
5303
+
5304
+ async cleanupExpiredDeploys({ graceSeconds = 60 * 60, retentionSeconds = 7 * 24 * 60 * 60, nowMs = Date.now() } = {}) {
5305
+ const startedAt = new Date(nowMs).toISOString();
5306
+ const finishedAt = now();
5307
+ const markCutoff = new Date(nowMs - graceSeconds * 1000).toISOString();
5308
+ const deleteCutoff = new Date(nowMs - retentionSeconds * 1000).toISOString();
5309
+ const examined = await this.query("select count(*)::int as count from deploys");
5310
+ const marked = await this.query(
5311
+ `
5312
+ update deploys
5313
+ set status = 'terminated',
5314
+ updated_at = $2
5315
+ where owner_id is null
5316
+ and expires_at is not null
5317
+ and expires_at <= $1
5318
+ and status = 'active'
5319
+ returning id
5320
+ `,
5321
+ [markCutoff, finishedAt]
5322
+ );
5323
+ const candidates = await this.query(
5324
+ `
5325
+ select id, artifact_hash
5326
+ from deploys
5327
+ where owner_id is null
5328
+ and expires_at is not null
5329
+ and expires_at <= $1
5330
+ `,
5331
+ [deleteCutoff]
5332
+ );
5333
+ const stats = {
5334
+ deletedArtifacts: 0,
5335
+ deletedDeploys: 0,
5336
+ deletedLogEntries: 0,
5337
+ deletedQuotaEvents: 0,
5338
+ deletedServerEnv: 0,
5339
+ deletedStateRows: 0,
5340
+ examinedDeploys: examined.rows[0]?.count ?? 0,
5341
+ finishedAt,
5342
+ markedTerminated: marked.rowCount,
5343
+ startedAt
5344
+ };
5345
+
5346
+ for (const deploy of candidates.rows) {
5347
+ const stateRows = await this.query("delete from state_rows where deploy_id = $1", [deploy.id]);
5348
+ const logs = await this.query("delete from logs where deploy_id = $1", [deploy.id]);
5349
+ const quota = await this.query("delete from quota_events where deploy_id = $1", [deploy.id]);
5350
+ const serverEnv = await this.query("delete from deploy_server_env where deploy_id = $1", [deploy.id]);
5351
+ await this.query("delete from deploys where id = $1", [deploy.id]);
5352
+ stats.deletedDeploys += 1;
5353
+ stats.deletedStateRows += stateRows.rowCount;
5354
+ stats.deletedLogEntries += logs.rowCount;
5355
+ stats.deletedQuotaEvents += quota.rowCount;
5356
+ stats.deletedServerEnv += serverEnv.rowCount;
5357
+ stats.deletedArtifacts += (await this.decrementArtifactRef(deploy.artifact_hash)) ? 1 : 0;
5358
+ }
5359
+ const deployCreateQuota = await this.query("delete from deploy_create_quota_events where window_start <= $1", [deleteCutoff]);
5360
+ stats.deletedQuotaEvents += deployCreateQuota.rowCount;
5361
+ const clientQuota = await this.query("delete from client_quota_events where window_start <= $1", [deleteCutoff]);
5362
+ stats.deletedQuotaEvents += clientQuota.rowCount;
5363
+
5364
+ await this.recordCleanupRun(stats);
5365
+ return stats;
5366
+ }
5367
+
5368
+ async readCleanupActivity() {
5369
+ const result = await this.query("select stats_json from cleanup_runs order by id desc limit 20");
5370
+ const recentRuns = result.rows.map((row) => row.stats_json);
5371
+ const totalsResult = await this.query(`
5372
+ select
5373
+ coalesce(sum((stats_json->>'deletedArtifacts')::int), 0)::int as deleted_artifacts,
5374
+ coalesce(sum((stats_json->>'deletedDeploys')::int), 0)::int as deleted_deploys,
5375
+ coalesce(sum((stats_json->>'deletedLogEntries')::int), 0)::int as deleted_log_entries,
5376
+ coalesce(sum((stats_json->>'deletedQuotaEvents')::int), 0)::int as deleted_quota_events,
5377
+ coalesce(sum((stats_json->>'deletedServerEnv')::int), 0)::int as deleted_server_env,
5378
+ coalesce(sum((stats_json->>'deletedStateRows')::int), 0)::int as deleted_state_rows,
5379
+ coalesce(sum((stats_json->>'examinedDeploys')::int), 0)::int as examined_deploys,
5380
+ coalesce(sum((stats_json->>'markedTerminated')::int), 0)::int as marked_terminated
5381
+ from cleanup_runs
5382
+ `);
5383
+ const totalsRow = totalsResult.rows[0] ?? {};
5384
+ return {
5385
+ lastRun: recentRuns[0] ?? null,
5386
+ recentRuns,
5387
+ totals: {
5388
+ deletedArtifacts: totalsRow.deleted_artifacts ?? 0,
5389
+ deletedDeploys: totalsRow.deleted_deploys ?? 0,
5390
+ deletedLogEntries: totalsRow.deleted_log_entries ?? 0,
5391
+ deletedQuotaEvents: totalsRow.deleted_quota_events ?? 0,
5392
+ deletedServerEnv: totalsRow.deleted_server_env ?? 0,
5393
+ deletedStateRows: totalsRow.deleted_state_rows ?? 0,
5394
+ examinedDeploys: totalsRow.examined_deploys ?? 0,
5395
+ markedTerminated: totalsRow.marked_terminated ?? 0
5396
+ }
5397
+ };
5398
+ }
3798
5399
  }
3799
5400
 
3800
5401
  export async function createAnonymousStoreFromEnv(env = process.env) {
@@ -3813,12 +5414,30 @@ export async function createAnonymousStoreFromEnv(env = process.env) {
3813
5414
  }
3814
5415
 
3815
5416
  async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
3816
- const route = parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
5417
+ const hostname = hostnameFromHost(host);
5418
+ const domain = hostname && typeof store.getDeployDomainByHostname === "function" ? await store.getDeployDomainByHostname(hostname) : null;
5419
+ const route = domain
5420
+ ? {
5421
+ appPath: url.pathname || "/",
5422
+ basePath: "",
5423
+ domain,
5424
+ hostname: domain.hostname,
5425
+ deployId: domain.deployId
5426
+ }
5427
+ : parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
3817
5428
  if (!route) {
3818
5429
  return null;
3819
5430
  }
3820
5431
 
3821
- const deploy = await store.getDeployBySlug(route.slug);
5432
+ if (domain?.status && domain.status !== "active") {
5433
+ return {
5434
+ error: domain.status === "disabled" ? "Lakebed subdomain is disabled." : "Lakebed subdomain is not active.",
5435
+ route,
5436
+ status: domain.status === "disabled" ? 410 : 404
5437
+ };
5438
+ }
5439
+
5440
+ const deploy = route.deployId ? await store.getDeployById(route.deployId) : await store.getDeployBySlug(route.slug);
3822
5441
  if (!deploy) {
3823
5442
  return { error: "Unknown anonymous deploy.", route, status: 404 };
3824
5443
  }
@@ -3839,45 +5458,164 @@ async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
3839
5458
  return { artifact: storedArtifact.artifact, basePath: route.basePath, deploy, route, storedArtifact };
3840
5459
  }
3841
5460
 
3842
- async function serveInspect({ artifact, deploy, route, store, systemPath }, res) {
5461
+ function mutationInspectDetails(artifact) {
5462
+ return Object.entries(artifact.server?.mutations ?? {}).map(([name, mutation]) => {
5463
+ if (mutation?.op === "source") {
5464
+ return { guards: [], mode: "source-backed", name };
5465
+ }
5466
+
5467
+ const guards = [];
5468
+ for (const operation of mutation?.body ?? []) {
5469
+ for (const guard of operation.guards ?? []) {
5470
+ guards.push({
5471
+ equalsAuth: guard.equalsAuth,
5472
+ field: guard.field,
5473
+ operation: operation.op,
5474
+ table: operation.table
5475
+ });
5476
+ }
5477
+ }
5478
+
5479
+ return {
5480
+ guards,
5481
+ mode: guards.length > 0 ? "guarded-ir" : "interpreted-ir",
5482
+ name
5483
+ };
5484
+ });
5485
+ }
5486
+
5487
+ function publicManifestForDeploy({ artifact, deploy }) {
5488
+ return {
5489
+ clientBundleHash: deploy.clientBundleHash,
5490
+ deployId: deploy.id,
5491
+ name: artifact.name ?? "Lakebed Capsule",
5492
+ runtimeVersion: LAKEBED_VERSION
5493
+ };
5494
+ }
5495
+
5496
+ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
5497
+ return {
5498
+ artifactHash: deploy.artifactHash,
5499
+ clientBundleHash: deploy.clientBundleHash,
5500
+ deployId: deploy.id,
5501
+ domains,
5502
+ expiresAt: deploy.expiresAt,
5503
+ inspectPolicy: inspectPolicyForDeploy(deploy),
5504
+ limits: deploy.limits,
5505
+ mutationDetails: mutationInspectDetails(artifact),
5506
+ mutations: Object.keys(artifact.server.mutations ?? {}),
5507
+ name: artifact.name ?? "Lakebed Capsule",
5508
+ queries: Object.keys(artifact.server.queries ?? {}),
5509
+ runtimeVersion: LAKEBED_VERSION,
5510
+ schema: artifact.server.schema,
5511
+ slug: deploy.slug,
5512
+ updatedAt: deploy.updatedAt,
5513
+ url: deploy.url
5514
+ };
5515
+ }
5516
+
5517
+ function inspectCommandForPath(deploy, systemPath) {
5518
+ if (systemPath === "/__lakebed/db") {
5519
+ return `lakebed db dump ${deploy.id}`;
5520
+ }
5521
+ if (systemPath === "/__lakebed/db/tables") {
5522
+ return `lakebed db list ${deploy.id}`;
5523
+ }
5524
+ if (systemPath === "/__lakebed/logs") {
5525
+ return `lakebed logs ${deploy.id}`;
5526
+ }
5527
+ return `lakebed inspect ${deploy.id}`;
5528
+ }
5529
+
5530
+ function inspectAuthFailure(deploy, systemPath) {
5531
+ return {
5532
+ command: inspectCommandForPath(deploy, systemPath),
5533
+ error: "Lakebed hosted inspection requires authorization.",
5534
+ hint: "Run this command from the capsule directory so Lakebed can read .lakebed/deploy.json, or send Authorization: Bearer <claim-token>.",
5535
+ inspectPolicy: inspectPolicyForDeploy(deploy),
5536
+ path: systemPath
5537
+ };
5538
+ }
5539
+
5540
+ function inspectAuthorized({ adminPassword, currentDeveloper, deploy, req }) {
5541
+ if (inspectPolicyForDeploy(deploy) === "public") {
5542
+ return true;
5543
+ }
5544
+
5545
+ if (isDeployTokenValid(deploy, bearerToken(req))) {
5546
+ return true;
5547
+ }
5548
+
5549
+ if (isAdminAuthenticated(req, adminPassword)) {
5550
+ return true;
5551
+ }
5552
+
5553
+ const user = currentDeveloper(req);
5554
+ return Boolean(user?.id && deploy.ownerId && user.id === deploy.ownerId);
5555
+ }
5556
+
5557
+ async function serveInspect({ adminPassword, artifact, currentDeveloper, deploy, req, route, store, systemPath }, res) {
5558
+ const policy = inspectPolicyForDeploy(deploy);
5559
+ const authorized = inspectAuthorized({ adminPassword, currentDeveloper, deploy, req });
5560
+
3843
5561
  if (systemPath === "/__lakebed/manifest") {
3844
- sendJson(res, 200, {
3845
- artifactHash: deploy.artifactHash,
3846
- clientBundleHash: deploy.clientBundleHash,
3847
- deployId: deploy.id,
3848
- expiresAt: deploy.expiresAt,
3849
- limits: deploy.limits,
3850
- mutations: Object.keys(artifact.server.mutations ?? {}),
3851
- name: artifact.name ?? "Lakebed Capsule",
3852
- queries: Object.keys(artifact.server.queries ?? {}),
3853
- schema: artifact.server.schema,
3854
- slug: deploy.slug,
3855
- updatedAt: deploy.updatedAt,
3856
- url: deploy.url
3857
- });
5562
+ if (!authorized && policy === "private") {
5563
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5564
+ return true;
5565
+ }
5566
+
5567
+ const domains =
5568
+ typeof store.listDeployDomainsForDeploy === "function"
5569
+ ? (await store.listDeployDomainsForDeploy(deploy.id)).map(responseForDeployDomain)
5570
+ : [];
5571
+ sendJson(res, 200, authorized ? fullManifestForDeploy({ artifact, deploy, domains }) : publicManifestForDeploy({ artifact, deploy }));
3858
5572
  return true;
3859
5573
  }
3860
5574
 
3861
5575
  if (systemPath === "/__lakebed/db/tables") {
3862
5576
  const counts = await store.tableCounts(deploy.id, artifact.server.schema);
3863
- sendJson(res, 200, {
3864
- tables: Object.keys(artifact.server.schema ?? {}),
3865
- counts
3866
- });
5577
+ if (!authorized && policy !== "redacted") {
5578
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5579
+ return true;
5580
+ }
5581
+
5582
+ sendJson(res, 200, authorized ? { tables: Object.keys(artifact.server.schema ?? {}), counts } : { counts, redacted: true });
3867
5583
  return true;
3868
5584
  }
3869
5585
 
3870
5586
  if (systemPath === "/__lakebed/db") {
5587
+ if (!authorized && policy !== "redacted") {
5588
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5589
+ return true;
5590
+ }
5591
+
5592
+ if (policy === "redacted" && !authorized) {
5593
+ const counts = await store.tableCounts(deploy.id, artifact.server.schema);
5594
+ sendJson(res, 200, { counts, redacted: true });
5595
+ return true;
5596
+ }
5597
+
3871
5598
  sendJson(res, 200, await store.dumpState(deploy.id, artifact.server.schema, deploy.limits.rowsReturned));
3872
5599
  return true;
3873
5600
  }
3874
5601
 
3875
5602
  if (systemPath === "/__lakebed/logs") {
3876
- sendJson(res, 200, await store.readLogs(deploy.id, 100));
5603
+ if (!authorized && policy !== "redacted") {
5604
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5605
+ return true;
5606
+ }
5607
+
5608
+ const logs = await store.readLogs(deploy.id, 100);
5609
+ sendJson(res, 200, policy === "redacted" && !authorized ? logs.map(redactLogEntry) : logs);
3877
5610
  return true;
3878
5611
  }
3879
5612
 
3880
5613
  if (systemPath === "/__lakebed/usage") {
5614
+ if (!authorized && policy !== "redacted") {
5615
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5616
+ return true;
5617
+ }
5618
+
3881
5619
  sendJson(res, 200, {
3882
5620
  limits: deploy.limits,
3883
5621
  usage: await store.readUsage(deploy.id)
@@ -3910,6 +5648,9 @@ export async function startAnonymousServer({
3910
5648
  } = {}) {
3911
5649
  const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
3912
5650
  const resolvedPublicRootUrl = normalizePublicRootUrl(publicRootUrl ?? process.env.PUBLIC_ROOT_URL, port);
5651
+ const deployCreationPolicy = anonymousDeployCreationPolicy({ publicRootUrl: resolvedPublicRootUrl });
5652
+ const clientTrafficPolicy = anonymousClientTrafficPolicy({ publicRootUrl: resolvedPublicRootUrl });
5653
+ const cleanupPolicy = cleanupPolicyFromEnv();
3913
5654
  const resolvedGithubOAuth = normalizeGithubOAuth(githubOAuth);
3914
5655
  const resolvedDeveloperSessionSecret =
3915
5656
  developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
@@ -3917,6 +5658,7 @@ export async function startAnonymousServer({
3917
5658
  const resolvedSourceRuntime = sourceRuntime === undefined ? createSourceRuntimeFromEnv() : sourceRuntime;
3918
5659
  await resolvedStore.initialize();
3919
5660
  const subscriptions = new Map();
5661
+ let cleanupInterval = null;
3920
5662
 
3921
5663
  function activeConnectionCounts() {
3922
5664
  const counts = new Map();
@@ -3937,6 +5679,10 @@ export async function startAnonymousServer({
3937
5679
  async function adminSummary() {
3938
5680
  const deploys = await adminDeploysWithConnections();
3939
5681
  const users = await resolvedStore.listAdminUsers();
5682
+ const cleanup =
5683
+ typeof resolvedStore.readCleanupActivity === "function"
5684
+ ? await resolvedStore.readCleanupActivity()
5685
+ : { lastRun: null, recentRuns: [], totals: {} };
3940
5686
  const totals = deploys.reduce(
3941
5687
  (acc, deploy) => ({
3942
5688
  artifactBytes: acc.artifactBytes + deploy.artifactBytes,
@@ -3963,6 +5709,7 @@ export async function startAnonymousServer({
3963
5709
  return {
3964
5710
  deployCount: deploys.length,
3965
5711
  deploys,
5712
+ cleanup,
3966
5713
  generatedAt: now(),
3967
5714
  totals,
3968
5715
  userCount: users.length,
@@ -3989,10 +5736,50 @@ export async function startAnonymousServer({
3989
5736
  return developerFromRequest(req, resolvedDeveloperSessionSecret);
3990
5737
  }
3991
5738
 
5739
+ function canManageDeploy(req, deploy) {
5740
+ if (isDeployTokenValid(deploy, bearerToken(req))) {
5741
+ return true;
5742
+ }
5743
+
5744
+ const user = currentDeveloper(req);
5745
+ return Boolean(user?.id && deploy.ownerId && user.id === deploy.ownerId);
5746
+ }
5747
+
3992
5748
  async function developerDeploys(user) {
3993
5749
  return resolvedStore.listDeploysForOwner(user.id);
3994
5750
  }
3995
5751
 
5752
+ async function enforceAnonymousDeployCreation(req) {
5753
+ if (deployCreationPolicy.disabled) {
5754
+ const error = new LakebedQuotaError({
5755
+ bucket: "anonymous_deploy_create_global",
5756
+ count: deployCreationPolicy.globalLimit,
5757
+ limit: deployCreationPolicy.globalLimit,
5758
+ status: 503,
5759
+ suggestion: "Anonymous deploy creation is temporarily disabled. Retry later or claim an existing deploy."
5760
+ });
5761
+ error.code = "lakebed_deploy_creation_disabled";
5762
+ error.message = "Anonymous deploy creation is temporarily disabled.";
5763
+ throw error;
5764
+ }
5765
+
5766
+ const clientKey = forwardedClientKey(req);
5767
+ await resolvedStore.incrementDeployCreateQuota(clientKey, "anonymous_deploy_create_client", deployCreationPolicy.perClientLimit);
5768
+ await resolvedStore.incrementDeployCreateQuota("global", "anonymous_deploy_create_global", deployCreationPolicy.globalLimit);
5769
+ }
5770
+
5771
+ async function enforceClientTrafficQuota(clientKey, bucket) {
5772
+ if (typeof resolvedStore.incrementClientQuota !== "function") {
5773
+ return;
5774
+ }
5775
+
5776
+ const limit =
5777
+ bucket === "mutations"
5778
+ ? clientTrafficPolicy.mutationsPerClientLimit
5779
+ : clientTrafficPolicy.requestsPerClientLimit;
5780
+ await resolvedStore.incrementClientQuota(clientKey, clientTrafficQuotaBucket(bucket), limit);
5781
+ }
5782
+
3996
5783
  async function refreshDeploySubscriptions(deploy) {
3997
5784
  const storedArtifact = await resolvedStore.getArtifact(deploy.artifactHash);
3998
5785
  if (!storedArtifact) {
@@ -4032,6 +5819,30 @@ export async function startAnonymousServer({
4032
5819
  }
4033
5820
  }
4034
5821
 
5822
+ function cleanupTouchedResources(stats) {
5823
+ return (
5824
+ Number(stats?.markedTerminated ?? 0) +
5825
+ Number(stats?.deletedDeploys ?? 0) +
5826
+ Number(stats?.deletedStateRows ?? 0) +
5827
+ Number(stats?.deletedLogEntries ?? 0) +
5828
+ Number(stats?.deletedArtifacts ?? 0)
5829
+ );
5830
+ }
5831
+
5832
+ async function runCleanup(reason = "periodic") {
5833
+ if (typeof resolvedStore.cleanupExpiredDeploys !== "function") {
5834
+ return null;
5835
+ }
5836
+
5837
+ const stats = await resolvedStore.cleanupExpiredDeploys(cleanupPolicy);
5838
+ if (!quiet && cleanupTouchedResources(stats) > 0) {
5839
+ console.log(
5840
+ `[lakebed:cleanup] ${reason} marked=${stats.markedTerminated} deleted=${stats.deletedDeploys} stateRows=${stats.deletedStateRows} logs=${stats.deletedLogEntries} artifacts=${stats.deletedArtifacts}`
5841
+ );
5842
+ }
5843
+ return stats;
5844
+ }
5845
+
4035
5846
  async function publishDeploy(deployId) {
4036
5847
  for (const [ws, subscription] of subscriptions) {
4037
5848
  if (subscription.deploy.id !== deployId) {
@@ -4378,9 +6189,101 @@ export async function startAnonymousServer({
4378
6189
  return;
4379
6190
  }
4380
6191
 
6192
+ const deployDomainsMatch = requestUrl.pathname.match(/^\/v1\/deploys\/([^/]+)\/domains$/);
6193
+ if (deployDomainsMatch && (req.method === "GET" || req.method === "POST")) {
6194
+ const deployId = decodeURIComponent(deployDomainsMatch[1]);
6195
+ const currentDeploy = await resolvedStore.getDeployById(deployId);
6196
+ if (!currentDeploy) {
6197
+ sendJson(res, 404, { error: "Unknown deploy." });
6198
+ return;
6199
+ }
6200
+
6201
+ if (!canManageDeploy(req, currentDeploy)) {
6202
+ sendJson(res, 401, { error: "Invalid deploy token." });
6203
+ return;
6204
+ }
6205
+
6206
+ if (req.method === "GET") {
6207
+ sendJson(res, 200, {
6208
+ deployId: currentDeploy.id,
6209
+ domains:
6210
+ typeof resolvedStore.listDeployDomainsForDeploy === "function"
6211
+ ? (await resolvedStore.listDeployDomainsForDeploy(currentDeploy.id)).map(responseForDeployDomain)
6212
+ : []
6213
+ });
6214
+ return;
6215
+ }
6216
+
6217
+ if (!currentDeploy.ownerId) {
6218
+ sendJson(res, 409, { error: "Deploy must be claimed before registering Lakebed subdomains." });
6219
+ return;
6220
+ }
6221
+ if (currentDeploy.status !== "active" || isExpired(currentDeploy)) {
6222
+ sendJson(res, 409, { error: "Deploy must be active to register Lakebed subdomains." });
6223
+ return;
6224
+ }
6225
+
6226
+ let normalizedDomain;
6227
+ try {
6228
+ const body = await readJsonBody(req, 8192);
6229
+ normalizedDomain = normalizeLakebedSubdomainInput(body.hostname ?? body.subdomain, resolvedAppBaseDomain);
6230
+ } catch (error) {
6231
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
6232
+ return;
6233
+ }
6234
+
6235
+ const created = await resolvedStore.createLakebedSubdomain({
6236
+ deployId: currentDeploy.id,
6237
+ hostname: normalizedDomain.hostname,
6238
+ label: normalizedDomain.label,
6239
+ ownerId: currentDeploy.ownerId
6240
+ });
6241
+ if (created.status === "missing") {
6242
+ sendJson(res, 404, { error: "Unknown deploy." });
6243
+ return;
6244
+ }
6245
+ if (created.status === "unclaimed") {
6246
+ sendJson(res, 409, { error: "Deploy must be claimed before registering Lakebed subdomains." });
6247
+ return;
6248
+ }
6249
+ if (created.status === "forbidden") {
6250
+ sendJson(res, 403, { error: "Deploy is claimed by another developer." });
6251
+ return;
6252
+ }
6253
+ if (created.status === "inactive") {
6254
+ sendJson(res, 409, { error: "Deploy must be active to register Lakebed subdomains." });
6255
+ return;
6256
+ }
6257
+ if (created.status === "conflict") {
6258
+ sendJson(res, 409, { error: "Subdomain is already registered." });
6259
+ return;
6260
+ }
6261
+ if (created.status === "slug_conflict") {
6262
+ sendJson(res, 409, { error: "Subdomain is already used by a generated Lakebed deploy URL." });
6263
+ return;
6264
+ }
6265
+
6266
+ if (created.status === "created") {
6267
+ await resolvedStore.appendLog(currentDeploy.id, "info", "lakebed subdomain registered", {
6268
+ hostname: created.domain.hostname
6269
+ });
6270
+ }
6271
+
6272
+ sendJson(res, created.status === "created" ? 201 : 200, responseForDeployDomain(created.domain));
6273
+ return;
6274
+ }
6275
+
4381
6276
  if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
6277
+ await enforceAnonymousDeployCreation(req);
4382
6278
  const body = await readJsonBody(req);
4383
6279
  const payload = validateAnonymousDeployPayload(body);
6280
+ let inspectPolicy;
6281
+ try {
6282
+ inspectPolicy = normalizeInspectPolicy(body.inspectPolicy);
6283
+ } catch (error) {
6284
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
6285
+ return;
6286
+ }
4384
6287
  if (payload.serverEnv !== undefined && Object.keys(payload.serverEnv).length > 0) {
4385
6288
  sendJson(res, 400, { error: "Server env requires a claimed deploy." });
4386
6289
  return;
@@ -4391,6 +6294,7 @@ export async function startAnonymousServer({
4391
6294
  artifactHash: payload.artifactHash,
4392
6295
  clientBundleBase64: payload.clientBundleBase64,
4393
6296
  clientBundleHash: payload.clientBundleHash,
6297
+ inspectPolicy,
4394
6298
  publicRootUrl: resolvedPublicRootUrl,
4395
6299
  serverEnv: payload.serverEnv
4396
6300
  });
@@ -4414,6 +6318,13 @@ export async function startAnonymousServer({
4414
6318
 
4415
6319
  const body = await readJsonBody(req);
4416
6320
  const payload = validateAnonymousDeployPayload(body, { allowClaimedSource: Boolean(currentDeploy.ownerId) });
6321
+ let inspectPolicy;
6322
+ try {
6323
+ inspectPolicy = Object.hasOwn(body, "inspectPolicy") ? normalizeInspectPolicy(body.inspectPolicy) : undefined;
6324
+ } catch (error) {
6325
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
6326
+ return;
6327
+ }
4417
6328
  if (payload.serverEnv !== undefined && !currentDeploy.ownerId) {
4418
6329
  sendJson(res, 400, { error: "Server env requires a claimed deploy." });
4419
6330
  return;
@@ -4425,6 +6336,7 @@ export async function startAnonymousServer({
4425
6336
  clientBundleBase64: payload.clientBundleBase64,
4426
6337
  clientBundleHash: payload.clientBundleHash,
4427
6338
  deployId,
6339
+ inspectPolicy,
4428
6340
  publicRootUrl: resolvedPublicRootUrl,
4429
6341
  serverEnv: payload.serverEnv
4430
6342
  });
@@ -4463,6 +6375,8 @@ export async function startAnonymousServer({
4463
6375
  return;
4464
6376
  }
4465
6377
 
6378
+ const clientKey = forwardedClientKey(req);
6379
+ await enforceClientTrafficQuota(clientKey, "requests");
4466
6380
  await resolvedStore.incrementQuota(
4467
6381
  loaded.deploy.id,
4468
6382
  "requests",
@@ -4486,22 +6400,44 @@ export async function startAnonymousServer({
4486
6400
  return;
4487
6401
  }
4488
6402
 
4489
- if (req.method === "GET" && (await serveInspect({ ...loaded, store: resolvedStore, systemPath: appPath }, res))) {
6403
+ if (
6404
+ req.method === "GET" &&
6405
+ (await serveInspect({
6406
+ ...loaded,
6407
+ adminPassword,
6408
+ currentDeveloper,
6409
+ req,
6410
+ store: resolvedStore,
6411
+ systemPath: appPath
6412
+ }, res))
6413
+ ) {
4490
6414
  return;
4491
6415
  }
4492
6416
 
4493
6417
  sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
4494
6418
  } catch (error) {
6419
+ if (isQuotaError(error)) {
6420
+ sendJson(
6421
+ res,
6422
+ error.status ?? 429,
6423
+ quotaErrorBody(error, {
6424
+ signInUrl: `${resolvedPublicRootUrl}/auth/github?return_to=${encodeURIComponent("/deploys")}`
6425
+ }),
6426
+ quotaErrorHeaders(error)
6427
+ );
6428
+ return;
6429
+ }
4495
6430
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
4496
6431
  }
4497
6432
  });
4498
6433
 
4499
6434
  const wss = new WebSocketServer({ noServer: true });
4500
6435
 
4501
- wss.on("connection", (ws, _req, loaded, auth) => {
6436
+ wss.on("connection", (ws, req, loaded, auth) => {
4502
6437
  subscriptions.set(ws, {
4503
6438
  artifact: loaded.artifact,
4504
6439
  auth,
6440
+ clientKey: forwardedClientKey(req),
4505
6441
  deploy: loaded.deploy,
4506
6442
  queries: new Set()
4507
6443
  });
@@ -4522,6 +6458,7 @@ export async function startAnonymousServer({
4522
6458
  }
4523
6459
 
4524
6460
  try {
6461
+ await enforceClientTrafficQuota(subscription.clientKey, "requests");
4525
6462
  await resolvedStore.incrementQuota(
4526
6463
  subscription.deploy.id,
4527
6464
  "requests",
@@ -4548,6 +6485,7 @@ export async function startAnonymousServer({
4548
6485
  }
4549
6486
 
4550
6487
  if (message.op === "mutation.run") {
6488
+ await enforceClientTrafficQuota(subscription.clientKey, "mutations");
4551
6489
  await resolvedStore.incrementQuota(
4552
6490
  subscription.deploy.id,
4553
6491
  "mutations",
@@ -4574,7 +6512,14 @@ export async function startAnonymousServer({
4574
6512
  error: error instanceof Error ? error.message : String(error),
4575
6513
  op: message?.op
4576
6514
  });
6515
+ const quota = isQuotaError(error) ? quotaErrorBody(error) : null;
4577
6516
  websocketSend(ws, {
6517
+ ...(quota
6518
+ ? {
6519
+ code: quota.code,
6520
+ quota
6521
+ }
6522
+ : {}),
4578
6523
  error: error instanceof Error ? error.message : String(error),
4579
6524
  id: message?.id,
4580
6525
  ok: false,
@@ -4621,6 +6566,20 @@ export async function startAnonymousServer({
4621
6566
  });
4622
6567
  });
4623
6568
 
6569
+ void runCleanup("startup").catch((error) => {
6570
+ if (!quiet) {
6571
+ console.error(`[lakebed:cleanup] startup failed: ${error instanceof Error ? error.message : String(error)}`);
6572
+ }
6573
+ });
6574
+ cleanupInterval = setInterval(() => {
6575
+ void runCleanup("periodic").catch((error) => {
6576
+ if (!quiet) {
6577
+ console.error(`[lakebed:cleanup] periodic failed: ${error instanceof Error ? error.message : String(error)}`);
6578
+ }
6579
+ });
6580
+ }, cleanupPolicy.intervalSeconds * 1000);
6581
+ cleanupInterval.unref?.();
6582
+
4624
6583
  if (!quiet) {
4625
6584
  console.log(`Lakebed anonymous runner listening at ${resolvedPublicRootUrl}`);
4626
6585
  }
@@ -4632,6 +6591,9 @@ export async function startAnonymousServer({
4632
6591
  store: resolvedStore,
4633
6592
  url: resolvedPublicRootUrl,
4634
6593
  async close() {
6594
+ if (cleanupInterval) {
6595
+ clearInterval(cleanupInterval);
6596
+ }
4635
6597
  for (const client of wss.clients) {
4636
6598
  client.close();
4637
6599
  }