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.
- package/README.md +36 -5
- package/package.json +1 -1
- package/src/anonymous-server.js +2119 -157
- package/src/anonymous.js +149 -44
- package/src/cli.js +173 -17
- package/src/source-runtime-worker.js +5 -0
- package/src/source-runtime.js +4 -1
- package/src/version.js +1 -1
package/src/anonymous-server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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(
|
|
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, """);
|
|
2226
2740
|
}
|
|
2227
2741
|
|
|
2228
|
-
function
|
|
2229
|
-
return
|
|
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
|
|
2233
|
-
|
|
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)
|
|
2261
|
-
|
|
2262
|
-
<
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
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
|
-
--
|
|
2282
|
-
--
|
|
2283
|
-
--
|
|
2284
|
-
--
|
|
2285
|
-
--
|
|
2286
|
-
--
|
|
2287
|
-
--
|
|
2288
|
-
--
|
|
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:
|
|
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:
|
|
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:
|
|
2945
|
+
max-width: 1560px;
|
|
2315
2946
|
min-height: 100vh;
|
|
2316
|
-
padding:
|
|
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:
|
|
2957
|
+
margin-bottom: 22px;
|
|
2958
|
+
padding-bottom: 22px;
|
|
2325
2959
|
}
|
|
2326
2960
|
|
|
2327
2961
|
h1 {
|
|
2328
|
-
font-size:
|
|
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
|
-
|
|
2335
|
-
|
|
2336
|
-
.
|
|
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
|
-
|
|
2344
|
-
|
|
2991
|
+
align-items: center;
|
|
2992
|
+
background: transparent;
|
|
2993
|
+
border: 1px solid var(--line-strong);
|
|
2345
2994
|
border-radius: 6px;
|
|
2346
|
-
color: var(--
|
|
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
|
|
2354
|
-
|
|
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
|
-
|
|
2371
|
-
|
|
2372
|
-
width: 100%;
|
|
3055
|
+
.deploy-list {
|
|
3056
|
+
display: grid;
|
|
2373
3057
|
}
|
|
2374
3058
|
|
|
2375
|
-
|
|
2376
|
-
|
|
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:
|
|
2379
|
-
text-align: left;
|
|
2380
|
-
vertical-align: top;
|
|
3070
|
+
padding: 12px 18px;
|
|
2381
3071
|
}
|
|
2382
3072
|
|
|
2383
|
-
|
|
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
|
-
|
|
2388
|
-
|
|
2389
|
-
gap: 4px;
|
|
3084
|
+
.deploy-row:hover {
|
|
3085
|
+
background: var(--panel-raised);
|
|
2390
3086
|
}
|
|
2391
3087
|
|
|
2392
|
-
.
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
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
|
-
.
|
|
2402
|
-
|
|
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
|
-
.
|
|
2407
|
-
|
|
2408
|
-
border-
|
|
2409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2423
|
-
|
|
3216
|
+
h1 {
|
|
3217
|
+
font-size: 32px;
|
|
2424
3218
|
}
|
|
2425
3219
|
|
|
2426
|
-
.
|
|
2427
|
-
|
|
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
|
-
<
|
|
2460
|
-
<
|
|
2461
|
-
<
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
2810
|
-
const
|
|
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
|
-
|
|
2825
|
-
|
|
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
|
|
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
|
|
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.
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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),
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
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
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
}
|