lakebed 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import {
4
4
  createClaimToken,
@@ -22,7 +22,48 @@ function dayWindowStart() {
22
22
  return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
23
23
  }
24
24
 
25
- function html(title, basePath, { shooBaseUrl } = {}) {
25
+ function serverEnvSecretFromEnv(env = process.env) {
26
+ return env.LAKEBED_SERVER_ENV_SECRET ?? env.LAKEBED_SESSION_SECRET ?? env.SPAN_SESSION_SECRET ?? "";
27
+ }
28
+
29
+ function serverEnvEncryptionKey(secret) {
30
+ return createHash("sha256").update(String(secret)).digest();
31
+ }
32
+
33
+ function encryptServerEnvValue(value, secret) {
34
+ if (!secret) {
35
+ return `plain:${Buffer.from(value, "utf8").toString("base64")}`;
36
+ }
37
+
38
+ const iv = randomBytes(12);
39
+ const cipher = createCipheriv("aes-256-gcm", serverEnvEncryptionKey(secret), iv);
40
+ const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
41
+ const tag = cipher.getAuthTag();
42
+ return `v1:${iv.toString("base64url")}:${tag.toString("base64url")}:${ciphertext.toString("base64url")}`;
43
+ }
44
+
45
+ function decryptServerEnvValue(value, secret) {
46
+ const stored = String(value ?? "");
47
+ if (stored.startsWith("plain:")) {
48
+ return Buffer.from(stored.slice("plain:".length), "base64").toString("utf8");
49
+ }
50
+
51
+ if (!stored.startsWith("v1:")) {
52
+ return stored;
53
+ }
54
+
55
+ if (!secret) {
56
+ throw new Error("LAKEBED_SERVER_ENV_SECRET is required to decrypt hosted server env.");
57
+ }
58
+
59
+ const [, ivBase64, tagBase64, ciphertextBase64] = stored.split(":");
60
+ const decipher = createDecipheriv("aes-256-gcm", serverEnvEncryptionKey(secret), Buffer.from(ivBase64, "base64url"));
61
+ decipher.setAuthTag(Buffer.from(tagBase64, "base64url"));
62
+ return Buffer.concat([decipher.update(Buffer.from(ciphertextBase64, "base64url")), decipher.final()]).toString("utf8");
63
+ }
64
+
65
+ function html(title, basePath, { clientBundleHash, shooBaseUrl } = {}) {
66
+ const clientScriptUrl = `${basePath}/client.js${clientBundleHash ? `?v=${encodeURIComponent(clientBundleHash)}` : ""}`;
26
67
  return `<!doctype html>
27
68
  <html lang="en">
28
69
  <head>
@@ -34,7 +75,7 @@ function html(title, basePath, { shooBaseUrl } = {}) {
34
75
  <div id="app"></div>
35
76
  <script>window.__LAKEBED_BASE_PATH__ = ${JSON.stringify(basePath)};</script>
36
77
  <script>window.__LAKEBED_AUTH__ = ${JSON.stringify({ shooBaseUrl })};</script>
37
- <script type="module" src="${basePath}/client.js"></script>
78
+ <script type="module" src="${clientScriptUrl}"></script>
38
79
  <script>
39
80
  const tailwind = document.createElement("script");
40
81
  tailwind.src = "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4";
@@ -214,6 +255,93 @@ function quotaLimitForBucket(bucket, deploy) {
214
255
  return deploy.limits.requestsPerDay;
215
256
  }
216
257
 
258
+ const USER_LIMIT_OVERRIDE_KEYS = ["requestsPerDay", "mutationsPerDay"];
259
+
260
+ function normalizeUserId(value) {
261
+ const userId = String(value ?? "").trim();
262
+ if (!userId) {
263
+ throw new Error("User id is required.");
264
+ }
265
+ if (userId.length > 256) {
266
+ throw new Error("User id must be 256 characters or fewer.");
267
+ }
268
+ return userId;
269
+ }
270
+
271
+ function normalizeLimitOverrideValue(value, key) {
272
+ const parsed = typeof value === "string" && value.trim() ? Number(value) : value;
273
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
274
+ throw new Error(`${key} must be a positive integer.`);
275
+ }
276
+ return parsed;
277
+ }
278
+
279
+ function normalizeUserLimitOverrides(value = {}) {
280
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
281
+ throw new Error("limits must be a JSON object.");
282
+ }
283
+
284
+ const overrides = {};
285
+ for (const key of Object.keys(value)) {
286
+ if (!USER_LIMIT_OVERRIDE_KEYS.includes(key)) {
287
+ throw new Error(`Unsupported user limit override: ${key}.`);
288
+ }
289
+ }
290
+
291
+ for (const key of USER_LIMIT_OVERRIDE_KEYS) {
292
+ const raw = value[key];
293
+ if (raw === undefined || raw === null || raw === "") {
294
+ continue;
295
+ }
296
+ overrides[key] = normalizeLimitOverrideValue(raw, key);
297
+ }
298
+ return overrides;
299
+ }
300
+
301
+ function limitsWithOverrides(baseLimits, limitOverrides) {
302
+ return {
303
+ ...baseLimits,
304
+ ...normalizeUserLimitOverrides(limitOverrides ?? {})
305
+ };
306
+ }
307
+
308
+ function userFromOwner(owner, current = {}) {
309
+ const id = normalizeUserId(owner?.id ?? current.id);
310
+ return {
311
+ avatarUrl: owner?.avatarUrl ?? current.avatarUrl ?? null,
312
+ createdAt: current.createdAt ?? now(),
313
+ displayName: owner?.displayName ?? owner?.login ?? current.displayName ?? id,
314
+ id,
315
+ limitOverrides: normalizeUserLimitOverrides(current.limitOverrides ?? {}),
316
+ login: owner?.login ?? current.login ?? null,
317
+ provider: owner?.provider ?? current.provider ?? null,
318
+ providerId: owner?.providerId ?? current.providerId ?? null,
319
+ updatedAt: now(),
320
+ url: owner?.url ?? current.url ?? null
321
+ };
322
+ }
323
+
324
+ function adminUserSummary({ activeDeployCount = 0, deployCount = 0, usage = [], user }) {
325
+ const limitOverrides = normalizeUserLimitOverrides(user.limitOverrides ?? {});
326
+ return {
327
+ activeDeployCount,
328
+ avatarUrl: user.avatarUrl,
329
+ createdAt: user.createdAt,
330
+ defaultLimits: DEFAULT_ANONYMOUS_LIMITS,
331
+ deployCount,
332
+ displayName: user.displayName ?? user.login ?? user.id,
333
+ id: user.id,
334
+ limitOverrides,
335
+ limits: limitsWithOverrides(DEFAULT_ANONYMOUS_LIMITS, limitOverrides),
336
+ login: user.login,
337
+ provider: user.provider,
338
+ providerId: user.providerId,
339
+ updatedAt: user.updatedAt,
340
+ url: user.url,
341
+ ...usageCounts(usage)
342
+ };
343
+ }
344
+
217
345
  const adminCookieName = "lakebed_admin";
218
346
  const adminCookieMaxAgeSeconds = 60 * 60 * 24 * 7;
219
347
  const developerCookieName = "lakebed_developer";
@@ -691,6 +819,10 @@ function adminHtml() {
691
819
  overflow: hidden;
692
820
  }
693
821
 
822
+ .panel + .panel {
823
+ margin-top: 18px;
824
+ }
825
+
694
826
  .panel-head {
695
827
  align-items: center;
696
828
  border-bottom: 1px solid var(--line);
@@ -815,6 +947,58 @@ function adminHtml() {
815
947
  opacity: 0.62;
816
948
  }
817
949
 
950
+ .row-action.neutral {
951
+ border-color: var(--line-strong);
952
+ color: var(--text);
953
+ }
954
+
955
+ .row-action.primary {
956
+ border-color: rgba(154, 214, 107, 0.8);
957
+ color: var(--accent);
958
+ }
959
+
960
+ .user-name {
961
+ display: grid;
962
+ gap: 4px;
963
+ min-width: 220px;
964
+ }
965
+
966
+ .user-name strong {
967
+ font-size: 15px;
968
+ }
969
+
970
+ .limit-cell {
971
+ display: grid;
972
+ gap: 6px;
973
+ min-width: 150px;
974
+ }
975
+
976
+ .limit-cell input {
977
+ min-height: 34px;
978
+ padding: 0 10px;
979
+ }
980
+
981
+ .limit-form {
982
+ align-items: end;
983
+ display: grid;
984
+ gap: 8px;
985
+ grid-template-columns: minmax(180px, 1fr) 132px 132px auto;
986
+ width: min(100%, 720px);
987
+ }
988
+
989
+ .limit-form .field {
990
+ gap: 5px;
991
+ }
992
+
993
+ .limit-form input {
994
+ min-height: 36px;
995
+ }
996
+
997
+ .limit-actions {
998
+ display: flex;
999
+ gap: 8px;
1000
+ }
1001
+
818
1002
  th:nth-child(8),
819
1003
  td:nth-child(8),
820
1004
  th:nth-child(9),
@@ -898,6 +1082,11 @@ function adminHtml() {
898
1082
  justify-content: flex-start;
899
1083
  }
900
1084
 
1085
+ .limit-form {
1086
+ grid-template-columns: 1fr;
1087
+ width: 100%;
1088
+ }
1089
+
901
1090
  .metrics {
902
1091
  grid-template-columns: repeat(2, minmax(0, 1fr));
903
1092
  }
@@ -973,6 +1162,43 @@ function adminHtml() {
973
1162
  </table>
974
1163
  </div>
975
1164
  </section>
1165
+
1166
+ <section class="panel">
1167
+ <div class="panel-head">
1168
+ <h2>User limit overrides</h2>
1169
+ <form class="limit-form" id="new-user-limit-form">
1170
+ <div class="field">
1171
+ <label for="new-user-id">User id</label>
1172
+ <input id="new-user-id" name="userId" autocomplete="off" placeholder="github:42" />
1173
+ </div>
1174
+ <div class="field">
1175
+ <label for="new-user-requests">Requests</label>
1176
+ <input id="new-user-requests" name="requestsPerDay" inputmode="numeric" min="1" step="1" type="number" />
1177
+ </div>
1178
+ <div class="field">
1179
+ <label for="new-user-mutations">Mutations</label>
1180
+ <input id="new-user-mutations" name="mutationsPerDay" inputmode="numeric" min="1" step="1" type="number" />
1181
+ </div>
1182
+ <button class="button secondary" type="submit">Save</button>
1183
+ </form>
1184
+ </div>
1185
+ <div class="table-wrap">
1186
+ <table>
1187
+ <thead>
1188
+ <tr>
1189
+ <th>User</th>
1190
+ <th>Deploys</th>
1191
+ <th>Requests today</th>
1192
+ <th>Mutations today</th>
1193
+ <th>Request limit</th>
1194
+ <th>Mutation limit</th>
1195
+ <th>Actions</th>
1196
+ </tr>
1197
+ </thead>
1198
+ <tbody id="user-rows"></tbody>
1199
+ </table>
1200
+ </div>
1201
+ </section>
976
1202
  </section>
977
1203
  </main>
978
1204
 
@@ -982,8 +1208,11 @@ function adminHtml() {
982
1208
  const loginForm = document.getElementById("login-form");
983
1209
  const loginError = document.getElementById("login-error");
984
1210
  const rows = document.getElementById("deploy-rows");
1211
+ const userRows = document.getElementById("user-rows");
1212
+ const newUserLimitForm = document.getElementById("new-user-limit-form");
985
1213
  const statusLine = document.getElementById("status-line");
986
1214
  let terminatingDeployId = null;
1215
+ let savingUserId = null;
987
1216
 
988
1217
  function show(view) {
989
1218
  loginView.classList.toggle("hidden", view !== "login");
@@ -1019,6 +1248,19 @@ function adminHtml() {
1019
1248
  document.getElementById(id).textContent = value;
1020
1249
  }
1021
1250
 
1251
+ function parseLimitValue(value, name) {
1252
+ const trimmed = String(value || "").trim();
1253
+ if (!trimmed) {
1254
+ return null;
1255
+ }
1256
+
1257
+ const parsed = Number(trimmed);
1258
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
1259
+ throw new Error(name + " must be a positive integer.");
1260
+ }
1261
+ return parsed;
1262
+ }
1263
+
1022
1264
  function textCell(value, className) {
1023
1265
  const td = document.createElement("td");
1024
1266
  td.textContent = value;
@@ -1072,6 +1314,80 @@ function adminHtml() {
1072
1314
  return td;
1073
1315
  }
1074
1316
 
1317
+ function userCell(user) {
1318
+ const td = document.createElement("td");
1319
+ const wrap = document.createElement("div");
1320
+ const name = document.createElement("strong");
1321
+ const id = document.createElement("small");
1322
+ wrap.className = "user-name";
1323
+ if (user.url) {
1324
+ const link = document.createElement("a");
1325
+ link.href = user.url;
1326
+ link.textContent = user.login || user.displayName || user.id;
1327
+ name.appendChild(link);
1328
+ } else {
1329
+ name.textContent = user.login || user.displayName || user.id;
1330
+ }
1331
+ id.className = "mono";
1332
+ id.textContent = user.id;
1333
+ wrap.appendChild(name);
1334
+ wrap.appendChild(id);
1335
+ td.appendChild(wrap);
1336
+ return td;
1337
+ }
1338
+
1339
+ function limitInputCell(user, key, label) {
1340
+ const td = document.createElement("td");
1341
+ const wrap = document.createElement("div");
1342
+ const input = document.createElement("input");
1343
+ const effective = document.createElement("small");
1344
+ wrap.className = "limit-cell";
1345
+ input.inputMode = "numeric";
1346
+ input.min = "1";
1347
+ input.step = "1";
1348
+ input.type = "number";
1349
+ input.dataset.key = key;
1350
+ input.value = user.limitOverrides[key] ? String(user.limitOverrides[key]) : "";
1351
+ input.placeholder = String(user.defaultLimits[key]);
1352
+ input.setAttribute("aria-label", label + " override for " + user.id);
1353
+ effective.className = "mono";
1354
+ effective.textContent = "effective " + formatNumber(user.limits[key]);
1355
+ wrap.appendChild(input);
1356
+ wrap.appendChild(effective);
1357
+ td.appendChild(wrap);
1358
+ return td;
1359
+ }
1360
+
1361
+ function userActionCell(user) {
1362
+ const td = document.createElement("td");
1363
+ const wrap = document.createElement("div");
1364
+ const save = document.createElement("button");
1365
+ const clear = document.createElement("button");
1366
+ wrap.className = "limit-actions";
1367
+ save.className = "row-action primary";
1368
+ save.type = "button";
1369
+ save.textContent = savingUserId === user.id ? "Saving" : "Save";
1370
+ save.disabled = savingUserId === user.id;
1371
+ save.addEventListener("click", () => {
1372
+ void saveUserLimits(user.id, save.closest("tr")).catch((error) => {
1373
+ statusLine.textContent = error instanceof Error ? error.message : String(error);
1374
+ });
1375
+ });
1376
+ clear.className = "row-action neutral";
1377
+ clear.type = "button";
1378
+ clear.textContent = "Clear";
1379
+ clear.disabled = savingUserId === user.id;
1380
+ clear.addEventListener("click", () => {
1381
+ void saveUserLimits(user.id, null, { requestsPerDay: null, mutationsPerDay: null }).catch((error) => {
1382
+ statusLine.textContent = error instanceof Error ? error.message : String(error);
1383
+ });
1384
+ });
1385
+ wrap.appendChild(save);
1386
+ wrap.appendChild(clear);
1387
+ td.appendChild(wrap);
1388
+ return td;
1389
+ }
1390
+
1075
1391
  function render(summary) {
1076
1392
  setMetric("metric-deploys", formatNumber(summary.deployCount));
1077
1393
  setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
@@ -1096,6 +1412,27 @@ function adminHtml() {
1096
1412
  tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
1097
1413
  rows.appendChild(tr);
1098
1414
  }
1415
+
1416
+ userRows.replaceChildren();
1417
+ for (const user of summary.users || []) {
1418
+ const tr = document.createElement("tr");
1419
+ tr.appendChild(userCell(user));
1420
+ tr.appendChild(textCell(formatNumber(user.activeDeployCount) + " / " + formatNumber(user.deployCount), "mono"));
1421
+ tr.appendChild(textCell(formatNumber(user.requestsToday) + " / " + formatNumber(user.limits.requestsPerDay), "mono"));
1422
+ tr.appendChild(textCell(formatNumber(user.mutationsToday) + " / " + formatNumber(user.limits.mutationsPerDay), "mono"));
1423
+ tr.appendChild(limitInputCell(user, "requestsPerDay", "Request limit"));
1424
+ tr.appendChild(limitInputCell(user, "mutationsPerDay", "Mutation limit"));
1425
+ tr.appendChild(userActionCell(user));
1426
+ userRows.appendChild(tr);
1427
+ }
1428
+
1429
+ if (!summary.users?.length) {
1430
+ const tr = document.createElement("tr");
1431
+ const td = textCell("No claimed users or manual overrides yet.", "mono");
1432
+ td.colSpan = 7;
1433
+ tr.appendChild(td);
1434
+ userRows.appendChild(tr);
1435
+ }
1099
1436
  }
1100
1437
 
1101
1438
  async function loadSummary() {
@@ -1116,6 +1453,35 @@ function adminHtml() {
1116
1453
  show("dashboard");
1117
1454
  }
1118
1455
 
1456
+ async function saveUserLimits(userId, row, explicitLimits) {
1457
+ const limits = explicitLimits || {
1458
+ requestsPerDay: parseLimitValue(row.querySelector('input[data-key="requestsPerDay"]').value, "Request limit"),
1459
+ mutationsPerDay: parseLimitValue(row.querySelector('input[data-key="mutationsPerDay"]').value, "Mutation limit")
1460
+ };
1461
+
1462
+ savingUserId = userId;
1463
+ statusLine.textContent = "Saving limits for " + userId;
1464
+ try {
1465
+ const response = await fetch("/admin/api/users/" + encodeURIComponent(userId) + "/limits", {
1466
+ body: JSON.stringify({ limits }),
1467
+ headers: { "Content-Type": "application/json" },
1468
+ method: "PUT"
1469
+ });
1470
+
1471
+ if (response.status === 401) {
1472
+ show("login");
1473
+ return;
1474
+ }
1475
+ if (!response.ok) {
1476
+ statusLine.textContent = "Save failed";
1477
+ throw new Error(await response.text());
1478
+ }
1479
+ } finally {
1480
+ savingUserId = null;
1481
+ }
1482
+ await loadSummary();
1483
+ }
1484
+
1119
1485
  async function terminateDeploy(deploy) {
1120
1486
  if (!confirm("Terminate " + (deploy.name || deploy.slug) + "?")) {
1121
1487
  return;
@@ -1158,6 +1524,33 @@ function adminHtml() {
1158
1524
  await loadSummary();
1159
1525
  });
1160
1526
 
1527
+ newUserLimitForm.addEventListener("submit", async (event) => {
1528
+ event.preventDefault();
1529
+ const form = new FormData(newUserLimitForm);
1530
+ const userId = String(form.get("userId") || "").trim();
1531
+ if (!userId) {
1532
+ statusLine.textContent = "User id is required.";
1533
+ return;
1534
+ }
1535
+
1536
+ try {
1537
+ const requestsPerDay = parseLimitValue(form.get("requestsPerDay"), "Request limit");
1538
+ const mutationsPerDay = parseLimitValue(form.get("mutationsPerDay"), "Mutation limit");
1539
+ if (!requestsPerDay && !mutationsPerDay) {
1540
+ statusLine.textContent = "Enter at least one custom limit.";
1541
+ return;
1542
+ }
1543
+
1544
+ await saveUserLimits(userId, null, {
1545
+ requestsPerDay,
1546
+ mutationsPerDay
1547
+ });
1548
+ newUserLimitForm.reset();
1549
+ } catch (error) {
1550
+ statusLine.textContent = error instanceof Error ? error.message : String(error);
1551
+ }
1552
+ });
1553
+
1161
1554
  document.getElementById("refresh-button").addEventListener("click", () => {
1162
1555
  void loadSummary();
1163
1556
  });
@@ -1434,10 +1827,100 @@ export class MemoryAnonymousStore {
1434
1827
  this.quotaEvents = new Map();
1435
1828
  this.queues = new Map();
1436
1829
  this.rows = new Map();
1830
+ this.serverEnv = new Map();
1831
+ this.users = new Map();
1437
1832
  }
1438
1833
 
1439
1834
  async initialize() {}
1440
1835
 
1836
+ async rememberUser(owner) {
1837
+ const id = normalizeUserId(owner?.id);
1838
+ const user = userFromOwner(owner, this.users.get(id));
1839
+ this.users.set(id, user);
1840
+ return user;
1841
+ }
1842
+
1843
+ async getUserLimitOverrides(userId) {
1844
+ return normalizeUserLimitOverrides(this.users.get(normalizeUserId(userId))?.limitOverrides ?? {});
1845
+ }
1846
+
1847
+ async setUserLimitOverrides(userId, limitOverrides) {
1848
+ const id = normalizeUserId(userId);
1849
+ const current = this.users.get(id) ?? {
1850
+ createdAt: now(),
1851
+ displayName: id,
1852
+ id,
1853
+ limitOverrides: {}
1854
+ };
1855
+ const user = {
1856
+ ...current,
1857
+ displayName: current.displayName ?? current.login ?? id,
1858
+ id,
1859
+ limitOverrides: normalizeUserLimitOverrides(limitOverrides),
1860
+ updatedAt: now()
1861
+ };
1862
+ this.users.set(id, user);
1863
+ return this.getAdminUser(id);
1864
+ }
1865
+
1866
+ async deployWithUserLimitOverrides(deploy) {
1867
+ if (!deploy?.ownerId) {
1868
+ return deploy;
1869
+ }
1870
+
1871
+ const limitOverrides = await this.getUserLimitOverrides(deploy.ownerId);
1872
+ if (Object.keys(limitOverrides).length === 0) {
1873
+ return deploy;
1874
+ }
1875
+
1876
+ return {
1877
+ ...deploy,
1878
+ limits: limitsWithOverrides(deploy.limits, limitOverrides)
1879
+ };
1880
+ }
1881
+
1882
+ async getStoredDeployById(id) {
1883
+ return this.deploys.get(id) ?? null;
1884
+ }
1885
+
1886
+ async getAdminUser(userId) {
1887
+ const id = normalizeUserId(userId);
1888
+ const user = this.users.get(id);
1889
+ if (!user) {
1890
+ return null;
1891
+ }
1892
+
1893
+ const deploys = Array.from(this.deploys.values()).filter((deploy) => deploy.ownerId === id);
1894
+ const usage = [];
1895
+ for (const deploy of deploys) {
1896
+ usage.push(...(await this.readUsage(deploy.id)));
1897
+ }
1898
+
1899
+ return adminUserSummary({
1900
+ activeDeployCount: deploys.filter((deploy) => deploy.status === "active" && !isExpired(deploy)).length,
1901
+ deployCount: deploys.length,
1902
+ usage,
1903
+ user
1904
+ });
1905
+ }
1906
+
1907
+ async listAdminUsers() {
1908
+ for (const deploy of this.deploys.values()) {
1909
+ if (deploy.owner && !this.users.has(deploy.ownerId)) {
1910
+ await this.rememberUser(deploy.owner);
1911
+ }
1912
+ }
1913
+
1914
+ const users = [];
1915
+ for (const user of this.users.values()) {
1916
+ users.push(await this.getAdminUser(user.id));
1917
+ }
1918
+
1919
+ return users
1920
+ .filter(Boolean)
1921
+ .sort((left, right) => String(left.login ?? left.id).localeCompare(String(right.login ?? right.id)));
1922
+ }
1923
+
1441
1924
  storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
1442
1925
  const currentArtifact = this.artifacts.get(artifactHash);
1443
1926
  this.artifacts.set(artifactHash, {
@@ -1451,7 +1934,7 @@ export class MemoryAnonymousStore {
1451
1934
  });
1452
1935
  }
1453
1936
 
1454
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
1937
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds, serverEnv }) {
1455
1938
  const deployId = createDeployId();
1456
1939
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
1457
1940
  let slug = createSlug();
@@ -1492,6 +1975,9 @@ export class MemoryAnonymousStore {
1492
1975
  };
1493
1976
  this.deploys.set(deployId, deploy);
1494
1977
  this.deploysBySlug.set(slug, deployId);
1978
+ if (serverEnv !== undefined) {
1979
+ this.serverEnv.set(deployId, { ...serverEnv });
1980
+ }
1495
1981
  return { deploy, token };
1496
1982
  }
1497
1983
 
@@ -1503,9 +1989,10 @@ export class MemoryAnonymousStore {
1503
1989
  clientBundleHash,
1504
1990
  deployId,
1505
1991
  publicRootUrl,
1506
- requestedTtlSeconds
1992
+ requestedTtlSeconds,
1993
+ serverEnv
1507
1994
  }) {
1508
- const currentDeploy = await this.getDeployById(deployId);
1995
+ const currentDeploy = await this.getStoredDeployById(deployId);
1509
1996
  if (!currentDeploy) {
1510
1997
  return null;
1511
1998
  }
@@ -1533,11 +2020,14 @@ export class MemoryAnonymousStore {
1533
2020
  createdAt
1534
2021
  });
1535
2022
  this.deploys.set(deployId, deploy);
1536
- return deploy;
2023
+ if (serverEnv !== undefined) {
2024
+ this.serverEnv.set(deployId, { ...serverEnv });
2025
+ }
2026
+ return this.deployWithUserLimitOverrides(deploy);
1537
2027
  }
1538
2028
 
1539
2029
  async claimDeploy(deployId, token, owner) {
1540
- const currentDeploy = await this.getDeployById(deployId);
2030
+ const currentDeploy = await this.getStoredDeployById(deployId);
1541
2031
  if (!currentDeploy) {
1542
2032
  return { status: "missing" };
1543
2033
  }
@@ -1556,12 +2046,13 @@ export class MemoryAnonymousStore {
1556
2046
  owner,
1557
2047
  ownerId: owner.id
1558
2048
  };
2049
+ await this.rememberUser(owner);
1559
2050
  this.deploys.set(deployId, deploy);
1560
- return { deploy, status: currentDeploy.ownerId ? "already_claimed" : "claimed" };
2051
+ return { deploy: await this.deployWithUserLimitOverrides(deploy), status: currentDeploy.ownerId ? "already_claimed" : "claimed" };
1561
2052
  }
1562
2053
 
1563
2054
  async terminateDeploy(deployId) {
1564
- const currentDeploy = await this.getDeployById(deployId);
2055
+ const currentDeploy = await this.getStoredDeployById(deployId);
1565
2056
  if (!currentDeploy) {
1566
2057
  return null;
1567
2058
  }
@@ -1571,11 +2062,11 @@ export class MemoryAnonymousStore {
1571
2062
  status: "terminated"
1572
2063
  };
1573
2064
  this.deploys.set(deployId, deploy);
1574
- return deploy;
2065
+ return this.deployWithUserLimitOverrides(deploy);
1575
2066
  }
1576
2067
 
1577
2068
  async getDeployById(id) {
1578
- return this.deploys.get(id) ?? null;
2069
+ return this.deployWithUserLimitOverrides(await this.getStoredDeployById(id));
1579
2070
  }
1580
2071
 
1581
2072
  async getDeployBySlug(slug) {
@@ -1624,6 +2115,14 @@ export class MemoryAnonymousStore {
1624
2115
  this.tableRows(deployId, tableName).delete(rowId);
1625
2116
  }
1626
2117
 
2118
+ async replaceServerEnv(deployId, serverEnv) {
2119
+ this.serverEnv.set(deployId, { ...serverEnv });
2120
+ }
2121
+
2122
+ async getServerEnv(deployId) {
2123
+ return { ...(this.serverEnv.get(deployId) ?? {}) };
2124
+ }
2125
+
1627
2126
  async transaction(deployId, handler) {
1628
2127
  const run = () => handler(this);
1629
2128
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
@@ -1730,12 +2229,13 @@ export class MemoryAnonymousStore {
1730
2229
  const summaries = [];
1731
2230
 
1732
2231
  for (const deploy of deploys) {
2232
+ const effectiveDeploy = await this.deployWithUserLimitOverrides(deploy);
1733
2233
  const storedArtifact = await this.getArtifact(deploy.artifactHash);
1734
2234
  summaries.push(
1735
2235
  adminDeploySummary({
1736
2236
  artifact: storedArtifact?.artifact,
1737
2237
  artifactBytes: storedArtifact?.bytes ?? 0,
1738
- deploy,
2238
+ deploy: effectiveDeploy,
1739
2239
  ...this.logResourceForDeploy(deploy.id),
1740
2240
  ...this.stateResourceForDeploy(deploy.id),
1741
2241
  usage: await this.readUsage(deploy.id)
@@ -1753,11 +2253,12 @@ export class MemoryAnonymousStore {
1753
2253
  const summaries = [];
1754
2254
 
1755
2255
  for (const deploy of deploys) {
2256
+ const effectiveDeploy = await this.deployWithUserLimitOverrides(deploy);
1756
2257
  const storedArtifact = await this.getArtifact(deploy.artifactHash);
1757
2258
  summaries.push(
1758
2259
  developerDeploySummary({
1759
2260
  artifact: storedArtifact?.artifact,
1760
- deploy,
2261
+ deploy: effectiveDeploy,
1761
2262
  usage: await this.readUsage(deploy.id)
1762
2263
  })
1763
2264
  );
@@ -1768,10 +2269,11 @@ export class MemoryAnonymousStore {
1768
2269
  }
1769
2270
 
1770
2271
  export class PostgresAnonymousStore {
1771
- constructor({ connectionString }) {
2272
+ constructor({ connectionString, serverEnvSecret = "" }) {
1772
2273
  this.connectionString = connectionString;
1773
2274
  this.pool = null;
1774
2275
  this.queues = new Map();
2276
+ this.serverEnvSecret = serverEnvSecret;
1775
2277
  }
1776
2278
 
1777
2279
  async initialize() {
@@ -1841,6 +2343,57 @@ export class PostgresAnonymousStore {
1841
2343
  primary key (deploy_id, bucket, window_start)
1842
2344
  )
1843
2345
  `);
2346
+ await this.query(`
2347
+ create table if not exists deploy_server_env(
2348
+ deploy_id text not null references deploys(id) on delete cascade,
2349
+ env_key text not null,
2350
+ env_value text not null,
2351
+ updated_at timestamptz not null,
2352
+ primary key (deploy_id, env_key)
2353
+ )
2354
+ `);
2355
+ await this.query(`
2356
+ create table if not exists users(
2357
+ id text primary key,
2358
+ provider text,
2359
+ provider_id text,
2360
+ login text,
2361
+ display_name text,
2362
+ avatar_url text,
2363
+ url text,
2364
+ limit_overrides_json jsonb not null default '{}',
2365
+ created_at timestamptz not null,
2366
+ updated_at timestamptz not null
2367
+ )
2368
+ `);
2369
+ await this.query(`
2370
+ insert into users(
2371
+ id, provider, provider_id, login, display_name, avatar_url, url,
2372
+ limit_overrides_json, created_at, updated_at
2373
+ )
2374
+ select distinct on (owner_id)
2375
+ owner_id,
2376
+ owner_json->>'provider',
2377
+ owner_json->>'providerId',
2378
+ owner_json->>'login',
2379
+ coalesce(owner_json->>'displayName', owner_json->>'login', owner_id),
2380
+ owner_json->>'avatarUrl',
2381
+ owner_json->>'url',
2382
+ '{}'::jsonb,
2383
+ coalesce(claimed_at, created_at, now()),
2384
+ coalesce(claimed_at, created_at, now())
2385
+ from deploys
2386
+ where owner_id is not null
2387
+ order by owner_id, claimed_at desc nulls last, created_at desc
2388
+ on conflict(id) do update set
2389
+ provider = coalesce(excluded.provider, users.provider),
2390
+ provider_id = coalesce(excluded.provider_id, users.provider_id),
2391
+ login = coalesce(excluded.login, users.login),
2392
+ display_name = coalesce(excluded.display_name, users.display_name),
2393
+ avatar_url = coalesce(excluded.avatar_url, users.avatar_url),
2394
+ url = coalesce(excluded.url, users.url),
2395
+ updated_at = greatest(users.updated_at, excluded.updated_at)
2396
+ `);
1844
2397
  }
1845
2398
 
1846
2399
  async query(sql, params = []) {
@@ -1851,6 +2404,199 @@ export class PostgresAnonymousStore {
1851
2404
  return this.pool.query(sql, params);
1852
2405
  }
1853
2406
 
2407
+ rowToUser(row) {
2408
+ if (!row) {
2409
+ return null;
2410
+ }
2411
+
2412
+ return {
2413
+ avatarUrl: row.avatar_url ?? null,
2414
+ createdAt: new Date(row.created_at).toISOString(),
2415
+ displayName: row.display_name ?? row.login ?? row.id,
2416
+ id: row.id,
2417
+ limitOverrides: normalizeUserLimitOverrides(row.limit_overrides_json ?? {}),
2418
+ login: row.login ?? null,
2419
+ provider: row.provider ?? null,
2420
+ providerId: row.provider_id ?? null,
2421
+ updatedAt: new Date(row.updated_at).toISOString(),
2422
+ url: row.url ?? null
2423
+ };
2424
+ }
2425
+
2426
+ async rememberUser(owner) {
2427
+ const user = userFromOwner(owner);
2428
+ const result = await this.query(
2429
+ `
2430
+ insert into users(
2431
+ id, provider, provider_id, login, display_name, avatar_url, url,
2432
+ limit_overrides_json, created_at, updated_at
2433
+ )
2434
+ values($1, $2, $3, $4, $5, $6, $7, '{}'::jsonb, $8, $9)
2435
+ on conflict(id) do update set
2436
+ provider = excluded.provider,
2437
+ provider_id = excluded.provider_id,
2438
+ login = excluded.login,
2439
+ display_name = excluded.display_name,
2440
+ avatar_url = excluded.avatar_url,
2441
+ url = excluded.url,
2442
+ updated_at = excluded.updated_at
2443
+ returning *
2444
+ `,
2445
+ [
2446
+ user.id,
2447
+ user.provider,
2448
+ user.providerId,
2449
+ user.login,
2450
+ user.displayName,
2451
+ user.avatarUrl,
2452
+ user.url,
2453
+ user.createdAt,
2454
+ user.updatedAt
2455
+ ]
2456
+ );
2457
+ return this.rowToUser(result.rows[0]);
2458
+ }
2459
+
2460
+ async getUserLimitOverrides(userId) {
2461
+ const result = await this.query("select limit_overrides_json from users where id = $1", [normalizeUserId(userId)]);
2462
+ return normalizeUserLimitOverrides(result.rows[0]?.limit_overrides_json ?? {});
2463
+ }
2464
+
2465
+ async setUserLimitOverrides(userId, limitOverrides) {
2466
+ const id = normalizeUserId(userId);
2467
+ const updatedAt = now();
2468
+ await this.query(
2469
+ `
2470
+ insert into users(id, display_name, limit_overrides_json, created_at, updated_at)
2471
+ values($1, $2, $3::jsonb, $4, $4)
2472
+ on conflict(id) do update set
2473
+ limit_overrides_json = excluded.limit_overrides_json,
2474
+ updated_at = excluded.updated_at
2475
+ `,
2476
+ [id, id, JSON.stringify(normalizeUserLimitOverrides(limitOverrides)), updatedAt]
2477
+ );
2478
+ return this.getAdminUser(id);
2479
+ }
2480
+
2481
+ async deployWithUserLimitOverrides(deploy) {
2482
+ if (!deploy?.ownerId) {
2483
+ return deploy;
2484
+ }
2485
+
2486
+ const limitOverrides = await this.getUserLimitOverrides(deploy.ownerId);
2487
+ if (Object.keys(limitOverrides).length === 0) {
2488
+ return deploy;
2489
+ }
2490
+
2491
+ return {
2492
+ ...deploy,
2493
+ limits: limitsWithOverrides(deploy.limits, limitOverrides)
2494
+ };
2495
+ }
2496
+
2497
+ async getBaseDeployById(id) {
2498
+ const result = await this.query("select * from deploys where id = $1", [id]);
2499
+ return this.rowToDeploy(result.rows[0]);
2500
+ }
2501
+
2502
+ async getAdminUser(userId) {
2503
+ const windowStart = dayWindowStart();
2504
+ const result = await this.query(
2505
+ `
2506
+ select
2507
+ u.*,
2508
+ coalesce(d.deploy_count, 0)::int as deploy_count,
2509
+ coalesce(d.active_deploy_count, 0)::int as active_deploy_count,
2510
+ coalesce(q.requests_today, 0)::int as requests_today,
2511
+ coalesce(q.mutations_today, 0)::int as mutations_today
2512
+ from users u
2513
+ left join (
2514
+ select
2515
+ owner_id,
2516
+ count(*)::int as deploy_count,
2517
+ count(*) filter (where status = 'active' and expires_at > now())::int as active_deploy_count
2518
+ from deploys
2519
+ where owner_id is not null
2520
+ group by owner_id
2521
+ ) d on d.owner_id = u.id
2522
+ left join (
2523
+ select
2524
+ d.owner_id,
2525
+ coalesce(sum(q.count) filter (where q.bucket = 'requests' and q.window_start = $2), 0)::int as requests_today,
2526
+ coalesce(sum(q.count) filter (where q.bucket = 'mutations' and q.window_start = $2), 0)::int as mutations_today
2527
+ from deploys d
2528
+ join quota_events q on q.deploy_id = d.id
2529
+ where d.owner_id is not null
2530
+ group by d.owner_id
2531
+ ) q on q.owner_id = u.id
2532
+ where u.id = $1
2533
+ `,
2534
+ [normalizeUserId(userId), windowStart]
2535
+ );
2536
+ const row = result.rows[0];
2537
+ if (!row) {
2538
+ return null;
2539
+ }
2540
+
2541
+ return adminUserSummary({
2542
+ activeDeployCount: row.active_deploy_count,
2543
+ deployCount: row.deploy_count,
2544
+ usage: [
2545
+ { bucket: "requests", count: row.requests_today, windowStart },
2546
+ { bucket: "mutations", count: row.mutations_today, windowStart }
2547
+ ],
2548
+ user: this.rowToUser(row)
2549
+ });
2550
+ }
2551
+
2552
+ async listAdminUsers() {
2553
+ const windowStart = dayWindowStart();
2554
+ const result = await this.query(
2555
+ `
2556
+ select
2557
+ u.*,
2558
+ coalesce(d.deploy_count, 0)::int as deploy_count,
2559
+ coalesce(d.active_deploy_count, 0)::int as active_deploy_count,
2560
+ coalesce(q.requests_today, 0)::int as requests_today,
2561
+ coalesce(q.mutations_today, 0)::int as mutations_today
2562
+ from users u
2563
+ left join (
2564
+ select
2565
+ owner_id,
2566
+ count(*)::int as deploy_count,
2567
+ count(*) filter (where status = 'active' and expires_at > now())::int as active_deploy_count
2568
+ from deploys
2569
+ where owner_id is not null
2570
+ group by owner_id
2571
+ ) d on d.owner_id = u.id
2572
+ left join (
2573
+ select
2574
+ d.owner_id,
2575
+ coalesce(sum(q.count) filter (where q.bucket = 'requests' and q.window_start = $1), 0)::int as requests_today,
2576
+ coalesce(sum(q.count) filter (where q.bucket = 'mutations' and q.window_start = $1), 0)::int as mutations_today
2577
+ from deploys d
2578
+ join quota_events q on q.deploy_id = d.id
2579
+ where d.owner_id is not null
2580
+ group by d.owner_id
2581
+ ) q on q.owner_id = u.id
2582
+ order by coalesce(u.login, u.id) asc
2583
+ `,
2584
+ [windowStart]
2585
+ );
2586
+
2587
+ return result.rows.map((row) =>
2588
+ adminUserSummary({
2589
+ activeDeployCount: row.active_deploy_count,
2590
+ deployCount: row.deploy_count,
2591
+ usage: [
2592
+ { bucket: "requests", count: row.requests_today, windowStart },
2593
+ { bucket: "mutations", count: row.mutations_today, windowStart }
2594
+ ],
2595
+ user: this.rowToUser(row)
2596
+ })
2597
+ );
2598
+ }
2599
+
1854
2600
  async storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
1855
2601
  await this.query(
1856
2602
  `
@@ -1862,7 +2608,7 @@ export class PostgresAnonymousStore {
1862
2608
  );
1863
2609
  }
1864
2610
 
1865
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
2611
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds, serverEnv }) {
1866
2612
  const createdAt = now();
1867
2613
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
1868
2614
  const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
@@ -1917,6 +2663,9 @@ export class PostgresAnonymousStore {
1917
2663
  deploy.url
1918
2664
  ]
1919
2665
  );
2666
+ if (serverEnv !== undefined) {
2667
+ await this.replaceServerEnv(deploy.id, serverEnv, createdAt);
2668
+ }
1920
2669
  return { deploy, token };
1921
2670
  } catch (error) {
1922
2671
  if (error?.code !== "23505" || attempt === 7) {
@@ -1936,9 +2685,10 @@ export class PostgresAnonymousStore {
1936
2685
  clientBundleHash,
1937
2686
  deployId,
1938
2687
  publicRootUrl,
1939
- requestedTtlSeconds
2688
+ requestedTtlSeconds,
2689
+ serverEnv
1940
2690
  }) {
1941
- const currentDeploy = await this.getDeployById(deployId);
2691
+ const currentDeploy = await this.getBaseDeployById(deployId);
1942
2692
  if (!currentDeploy) {
1943
2693
  return null;
1944
2694
  }
@@ -1966,11 +2716,14 @@ export class PostgresAnonymousStore {
1966
2716
  `,
1967
2717
  [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url]
1968
2718
  );
1969
- return this.rowToDeploy(result.rows[0]);
2719
+ if (serverEnv !== undefined) {
2720
+ await this.replaceServerEnv(deployId, serverEnv, createdAt);
2721
+ }
2722
+ return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
1970
2723
  }
1971
2724
 
1972
2725
  async claimDeploy(deployId, token, owner) {
1973
- const currentDeploy = await this.getDeployById(deployId);
2726
+ const currentDeploy = await this.getBaseDeployById(deployId);
1974
2727
  if (!currentDeploy) {
1975
2728
  return { status: "missing" };
1976
2729
  }
@@ -1995,7 +2748,11 @@ export class PostgresAnonymousStore {
1995
2748
  `,
1996
2749
  [deployId, owner.id, claimedAt, JSON.stringify(owner)]
1997
2750
  );
1998
- return { deploy: this.rowToDeploy(result.rows[0]), status: currentDeploy.ownerId ? "already_claimed" : "claimed" };
2751
+ await this.rememberUser(owner);
2752
+ return {
2753
+ deploy: await this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0])),
2754
+ status: currentDeploy.ownerId ? "already_claimed" : "claimed"
2755
+ };
1999
2756
  }
2000
2757
 
2001
2758
  async terminateDeploy(deployId) {
@@ -2008,7 +2765,7 @@ export class PostgresAnonymousStore {
2008
2765
  `,
2009
2766
  [deployId]
2010
2767
  );
2011
- return this.rowToDeploy(result.rows[0]);
2768
+ return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
2012
2769
  }
2013
2770
 
2014
2771
  rowToDeploy(row) {
@@ -2036,13 +2793,12 @@ export class PostgresAnonymousStore {
2036
2793
  }
2037
2794
 
2038
2795
  async getDeployById(id) {
2039
- const result = await this.query("select * from deploys where id = $1", [id]);
2040
- return this.rowToDeploy(result.rows[0]);
2796
+ return this.deployWithUserLimitOverrides(await this.getBaseDeployById(id));
2041
2797
  }
2042
2798
 
2043
2799
  async getDeployBySlug(slug) {
2044
2800
  const result = await this.query("select * from deploys where slug = $1", [slug]);
2045
- return this.rowToDeploy(result.rows[0]);
2801
+ return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
2046
2802
  }
2047
2803
 
2048
2804
  async getArtifact(hash) {
@@ -2107,6 +2863,24 @@ export class PostgresAnonymousStore {
2107
2863
  await this.query("delete from state_rows where deploy_id = $1 and table_name = $2 and row_id = $3", [deployId, tableName, rowId]);
2108
2864
  }
2109
2865
 
2866
+ async replaceServerEnv(deployId, serverEnv, updatedAt = now()) {
2867
+ await this.query("delete from deploy_server_env where deploy_id = $1", [deployId]);
2868
+ for (const [key, value] of Object.entries(serverEnv)) {
2869
+ await this.query(
2870
+ `
2871
+ insert into deploy_server_env(deploy_id, env_key, env_value, updated_at)
2872
+ values($1, $2, $3, $4)
2873
+ `,
2874
+ [deployId, key, encryptServerEnvValue(value, this.serverEnvSecret), updatedAt]
2875
+ );
2876
+ }
2877
+ }
2878
+
2879
+ async getServerEnv(deployId) {
2880
+ const result = await this.query("select env_key, env_value from deploy_server_env where deploy_id = $1", [deployId]);
2881
+ return Object.fromEntries(result.rows.map((row) => [row.env_key, decryptServerEnvValue(row.env_value, this.serverEnvSecret)]));
2882
+ }
2883
+
2110
2884
  async transaction(deployId, handler) {
2111
2885
  const run = () => handler(this);
2112
2886
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
@@ -2244,11 +3018,13 @@ export class PostgresAnonymousStore {
2244
3018
  [windowStart]
2245
3019
  );
2246
3020
 
2247
- return result.rows.map((row) =>
2248
- adminDeploySummary({
3021
+ return Promise.all(
3022
+ result.rows.map(async (row) => {
3023
+ const deploy = await this.deployWithUserLimitOverrides(this.rowToDeploy(row));
3024
+ return adminDeploySummary({
2249
3025
  artifact: row.artifact_json,
2250
3026
  artifactBytes: row.artifact_bytes,
2251
- deploy: this.rowToDeploy(row),
3027
+ deploy,
2252
3028
  logBytes: row.log_bytes,
2253
3029
  logEntries: row.log_entries,
2254
3030
  stateBytes: row.state_bytes,
@@ -2257,6 +3033,7 @@ export class PostgresAnonymousStore {
2257
3033
  { bucket: "requests", count: row.requests_today, windowStart },
2258
3034
  { bucket: "mutations", count: row.mutations_today, windowStart }
2259
3035
  ]
3036
+ });
2260
3037
  })
2261
3038
  );
2262
3039
  }
@@ -2286,14 +3063,17 @@ export class PostgresAnonymousStore {
2286
3063
  [ownerId, windowStart]
2287
3064
  );
2288
3065
 
2289
- return result.rows.map((row) =>
2290
- developerDeploySummary({
3066
+ return Promise.all(
3067
+ result.rows.map(async (row) => {
3068
+ const deploy = await this.deployWithUserLimitOverrides(this.rowToDeploy(row));
3069
+ return developerDeploySummary({
2291
3070
  artifact: row.artifact_json,
2292
- deploy: this.rowToDeploy(row),
3071
+ deploy,
2293
3072
  usage: [
2294
3073
  { bucket: "requests", count: row.requests_today, windowStart },
2295
3074
  { bucket: "mutations", count: row.mutations_today, windowStart }
2296
3075
  ]
3076
+ });
2297
3077
  })
2298
3078
  );
2299
3079
  }
@@ -2301,7 +3081,10 @@ export class PostgresAnonymousStore {
2301
3081
 
2302
3082
  export async function createAnonymousStoreFromEnv(env = process.env) {
2303
3083
  if (env.DATABASE_URL) {
2304
- const store = new PostgresAnonymousStore({ connectionString: env.DATABASE_URL });
3084
+ const store = new PostgresAnonymousStore({
3085
+ connectionString: env.DATABASE_URL,
3086
+ serverEnvSecret: serverEnvSecretFromEnv(env)
3087
+ });
2305
3088
  await store.initialize();
2306
3089
  return store;
2307
3090
  }
@@ -2427,6 +3210,7 @@ export async function startAnonymousServer({
2427
3210
  ...deploy,
2428
3211
  connections: connections.get(deploy.id) ?? 0
2429
3212
  }));
3213
+ const users = await resolvedStore.listAdminUsers();
2430
3214
  const totals = deploys.reduce(
2431
3215
  (acc, deploy) => ({
2432
3216
  artifactBytes: acc.artifactBytes + deploy.artifactBytes,
@@ -2454,7 +3238,9 @@ export async function startAnonymousServer({
2454
3238
  deployCount: deploys.length,
2455
3239
  deploys,
2456
3240
  generatedAt: now(),
2457
- totals
3241
+ totals,
3242
+ userCount: users.length,
3243
+ users
2458
3244
  };
2459
3245
  }
2460
3246
 
@@ -2480,6 +3266,19 @@ export async function startAnonymousServer({
2480
3266
  }
2481
3267
  }
2482
3268
 
3269
+ async function refreshOwnerSubscriptions(ownerId) {
3270
+ for (const subscription of subscriptions.values()) {
3271
+ if (subscription.deploy.ownerId !== ownerId) {
3272
+ continue;
3273
+ }
3274
+
3275
+ const deploy = await resolvedStore.getDeployById(subscription.deploy.id);
3276
+ if (deploy) {
3277
+ subscription.deploy = deploy;
3278
+ }
3279
+ }
3280
+ }
3281
+
2483
3282
  function closeDeployConnections(deployId) {
2484
3283
  for (const [ws, subscription] of subscriptions) {
2485
3284
  if (subscription.deploy.id !== deployId) {
@@ -2515,6 +3314,14 @@ export async function startAnonymousServer({
2515
3314
  }
2516
3315
  }
2517
3316
 
3317
+ function refreshDeployClients(deploy) {
3318
+ for (const [ws, subscription] of subscriptions) {
3319
+ if (subscription.deploy.id === deploy.id) {
3320
+ websocketSend(ws, { clientBundleHash: deploy.clientBundleHash, op: "refresh" });
3321
+ }
3322
+ }
3323
+ }
3324
+
2518
3325
  const server = createServer(async (req, res) => {
2519
3326
  const host = req.headers.host ?? "localhost";
2520
3327
  const requestUrl = new URL(req.url ?? "/", `http://${host}`);
@@ -2729,6 +3536,34 @@ export async function startAnonymousServer({
2729
3536
  return;
2730
3537
  }
2731
3538
 
3539
+ const userLimitsMatch =
3540
+ req.method === "PUT" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)\/limits$/) : null;
3541
+ if (userLimitsMatch) {
3542
+ if (!isAdminConfigured(adminPassword)) {
3543
+ sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
3544
+ return;
3545
+ }
3546
+
3547
+ if (!isAdminAuthenticated(req, adminPassword)) {
3548
+ sendJson(res, 401, { error: "Admin authentication required." });
3549
+ return;
3550
+ }
3551
+
3552
+ const body = await readJsonBody(req, 4096);
3553
+ let limitOverrides;
3554
+ try {
3555
+ limitOverrides = normalizeUserLimitOverrides(body.limits ?? body.limitOverrides ?? {});
3556
+ } catch (error) {
3557
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
3558
+ return;
3559
+ }
3560
+
3561
+ const user = await resolvedStore.setUserLimitOverrides(decodeURIComponent(userLimitsMatch[1]), limitOverrides);
3562
+ await refreshOwnerSubscriptions(user.id);
3563
+ sendJson(res, 200, { user });
3564
+ return;
3565
+ }
3566
+
2732
3567
  const terminateMatch =
2733
3568
  req.method === "POST"
2734
3569
  ? requestUrl.pathname.match(/^\/admin\/api\/deploys\/([^/]+)\/terminate$/)
@@ -2766,6 +3601,10 @@ export async function startAnonymousServer({
2766
3601
  if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
2767
3602
  const body = await readJsonBody(req);
2768
3603
  const payload = validateAnonymousDeployPayload(body);
3604
+ if (payload.serverEnv !== undefined && Object.keys(payload.serverEnv).length > 0) {
3605
+ sendJson(res, 400, { error: "Server env requires a claimed deploy." });
3606
+ return;
3607
+ }
2769
3608
  const { deploy, token } = await resolvedStore.createDeploy({
2770
3609
  appBaseDomain: resolvedAppBaseDomain,
2771
3610
  artifact: payload.artifact,
@@ -2773,7 +3612,8 @@ export async function startAnonymousServer({
2773
3612
  clientBundleBase64: payload.clientBundleBase64,
2774
3613
  clientBundleHash: payload.clientBundleHash,
2775
3614
  publicRootUrl: resolvedPublicRootUrl,
2776
- requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined
3615
+ requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
3616
+ serverEnv: payload.serverEnv
2777
3617
  });
2778
3618
  await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
2779
3619
  sendJson(res, 201, responseForDeploy({ deploy, token }));
@@ -2795,6 +3635,10 @@ export async function startAnonymousServer({
2795
3635
 
2796
3636
  const body = await readJsonBody(req);
2797
3637
  const payload = validateAnonymousDeployPayload(body, { allowClaimedSource: Boolean(currentDeploy.ownerId) });
3638
+ if (payload.serverEnv !== undefined && !currentDeploy.ownerId) {
3639
+ sendJson(res, 400, { error: "Server env requires a claimed deploy." });
3640
+ return;
3641
+ }
2798
3642
  const deploy = await resolvedStore.updateDeploy({
2799
3643
  appBaseDomain: resolvedAppBaseDomain,
2800
3644
  artifact: payload.artifact,
@@ -2803,7 +3647,8 @@ export async function startAnonymousServer({
2803
3647
  clientBundleHash: payload.clientBundleHash,
2804
3648
  deployId,
2805
3649
  publicRootUrl: resolvedPublicRootUrl,
2806
- requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined
3650
+ requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
3651
+ serverEnv: payload.serverEnv
2807
3652
  });
2808
3653
  if (!deploy) {
2809
3654
  sendJson(res, 404, { error: "Unknown deploy." });
@@ -2812,6 +3657,7 @@ export async function startAnonymousServer({
2812
3657
 
2813
3658
  await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy updated", { artifactHash: deploy.artifactHash });
2814
3659
  await refreshDeploySubscriptions(deploy);
3660
+ refreshDeployClients(deploy);
2815
3661
  await publishDeploy(deploy.id);
2816
3662
  sendJson(res, 200, responseForDeploy({ deploy }));
2817
3663
  return;
@@ -2847,7 +3693,8 @@ export async function startAnonymousServer({
2847
3693
 
2848
3694
  const appPath = routeSystemPath(loaded.route.appPath);
2849
3695
  if (req.method === "GET" && (appPath === "/" || appPath === "/index.html" || appPath === "/auth/callback")) {
2850
- sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath, { shooBaseUrl }), {
3696
+ sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath, { clientBundleHash: loaded.deploy.clientBundleHash, shooBaseUrl }), {
3697
+ "Cache-Control": "no-store",
2851
3698
  "Content-Type": "text/html; charset=utf-8"
2852
3699
  });
2853
3700
  return;