lakebed 0.0.6 → 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.
package/README.md CHANGED
@@ -170,4 +170,4 @@ Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the
170
170
 
171
171
  ## Admin Dashboard
172
172
 
173
- Set `LAKEBED_ADMIN_PASSWORD` on the anonymous deploy runner, then open `/admin` on the runner origin. The password is exchanged for an HttpOnly cookie so the dashboard stays unlocked until the cookie expires or the password changes. The resource table can terminate active deploys while preserving their resource history.
173
+ Set `LAKEBED_ADMIN_PASSWORD` on the anonymous deploy runner, then open `/admin` on the runner origin. The password is exchanged for an HttpOnly cookie so the dashboard stays unlocked until the cookie expires or the password changes. The resource table can terminate active deploys while preserving their resource history, and the users table can set per-user request and mutation limit overrides for claimed deploys.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -62,7 +62,8 @@ function decryptServerEnvValue(value, secret) {
62
62
  return Buffer.concat([decipher.update(Buffer.from(ciphertextBase64, "base64url")), decipher.final()]).toString("utf8");
63
63
  }
64
64
 
65
- function html(title, basePath, { shooBaseUrl } = {}) {
65
+ function html(title, basePath, { clientBundleHash, shooBaseUrl } = {}) {
66
+ const clientScriptUrl = `${basePath}/client.js${clientBundleHash ? `?v=${encodeURIComponent(clientBundleHash)}` : ""}`;
66
67
  return `<!doctype html>
67
68
  <html lang="en">
68
69
  <head>
@@ -74,7 +75,7 @@ function html(title, basePath, { shooBaseUrl } = {}) {
74
75
  <div id="app"></div>
75
76
  <script>window.__LAKEBED_BASE_PATH__ = ${JSON.stringify(basePath)};</script>
76
77
  <script>window.__LAKEBED_AUTH__ = ${JSON.stringify({ shooBaseUrl })};</script>
77
- <script type="module" src="${basePath}/client.js"></script>
78
+ <script type="module" src="${clientScriptUrl}"></script>
78
79
  <script>
79
80
  const tailwind = document.createElement("script");
80
81
  tailwind.src = "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4";
@@ -254,6 +255,93 @@ function quotaLimitForBucket(bucket, deploy) {
254
255
  return deploy.limits.requestsPerDay;
255
256
  }
256
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
+
257
345
  const adminCookieName = "lakebed_admin";
258
346
  const adminCookieMaxAgeSeconds = 60 * 60 * 24 * 7;
259
347
  const developerCookieName = "lakebed_developer";
@@ -731,6 +819,10 @@ function adminHtml() {
731
819
  overflow: hidden;
732
820
  }
733
821
 
822
+ .panel + .panel {
823
+ margin-top: 18px;
824
+ }
825
+
734
826
  .panel-head {
735
827
  align-items: center;
736
828
  border-bottom: 1px solid var(--line);
@@ -855,6 +947,58 @@ function adminHtml() {
855
947
  opacity: 0.62;
856
948
  }
857
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
+
858
1002
  th:nth-child(8),
859
1003
  td:nth-child(8),
860
1004
  th:nth-child(9),
@@ -938,6 +1082,11 @@ function adminHtml() {
938
1082
  justify-content: flex-start;
939
1083
  }
940
1084
 
1085
+ .limit-form {
1086
+ grid-template-columns: 1fr;
1087
+ width: 100%;
1088
+ }
1089
+
941
1090
  .metrics {
942
1091
  grid-template-columns: repeat(2, minmax(0, 1fr));
943
1092
  }
@@ -1013,6 +1162,43 @@ function adminHtml() {
1013
1162
  </table>
1014
1163
  </div>
1015
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>
1016
1202
  </section>
1017
1203
  </main>
1018
1204
 
@@ -1022,8 +1208,11 @@ function adminHtml() {
1022
1208
  const loginForm = document.getElementById("login-form");
1023
1209
  const loginError = document.getElementById("login-error");
1024
1210
  const rows = document.getElementById("deploy-rows");
1211
+ const userRows = document.getElementById("user-rows");
1212
+ const newUserLimitForm = document.getElementById("new-user-limit-form");
1025
1213
  const statusLine = document.getElementById("status-line");
1026
1214
  let terminatingDeployId = null;
1215
+ let savingUserId = null;
1027
1216
 
1028
1217
  function show(view) {
1029
1218
  loginView.classList.toggle("hidden", view !== "login");
@@ -1059,6 +1248,19 @@ function adminHtml() {
1059
1248
  document.getElementById(id).textContent = value;
1060
1249
  }
1061
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
+
1062
1264
  function textCell(value, className) {
1063
1265
  const td = document.createElement("td");
1064
1266
  td.textContent = value;
@@ -1112,6 +1314,80 @@ function adminHtml() {
1112
1314
  return td;
1113
1315
  }
1114
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
+
1115
1391
  function render(summary) {
1116
1392
  setMetric("metric-deploys", formatNumber(summary.deployCount));
1117
1393
  setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
@@ -1136,6 +1412,27 @@ function adminHtml() {
1136
1412
  tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
1137
1413
  rows.appendChild(tr);
1138
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
+ }
1139
1436
  }
1140
1437
 
1141
1438
  async function loadSummary() {
@@ -1156,6 +1453,35 @@ function adminHtml() {
1156
1453
  show("dashboard");
1157
1454
  }
1158
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
+
1159
1485
  async function terminateDeploy(deploy) {
1160
1486
  if (!confirm("Terminate " + (deploy.name || deploy.slug) + "?")) {
1161
1487
  return;
@@ -1198,6 +1524,33 @@ function adminHtml() {
1198
1524
  await loadSummary();
1199
1525
  });
1200
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
+
1201
1554
  document.getElementById("refresh-button").addEventListener("click", () => {
1202
1555
  void loadSummary();
1203
1556
  });
@@ -1475,10 +1828,99 @@ export class MemoryAnonymousStore {
1475
1828
  this.queues = new Map();
1476
1829
  this.rows = new Map();
1477
1830
  this.serverEnv = new Map();
1831
+ this.users = new Map();
1478
1832
  }
1479
1833
 
1480
1834
  async initialize() {}
1481
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
+
1482
1924
  storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
1483
1925
  const currentArtifact = this.artifacts.get(artifactHash);
1484
1926
  this.artifacts.set(artifactHash, {
@@ -1550,7 +1992,7 @@ export class MemoryAnonymousStore {
1550
1992
  requestedTtlSeconds,
1551
1993
  serverEnv
1552
1994
  }) {
1553
- const currentDeploy = await this.getDeployById(deployId);
1995
+ const currentDeploy = await this.getStoredDeployById(deployId);
1554
1996
  if (!currentDeploy) {
1555
1997
  return null;
1556
1998
  }
@@ -1581,11 +2023,11 @@ export class MemoryAnonymousStore {
1581
2023
  if (serverEnv !== undefined) {
1582
2024
  this.serverEnv.set(deployId, { ...serverEnv });
1583
2025
  }
1584
- return deploy;
2026
+ return this.deployWithUserLimitOverrides(deploy);
1585
2027
  }
1586
2028
 
1587
2029
  async claimDeploy(deployId, token, owner) {
1588
- const currentDeploy = await this.getDeployById(deployId);
2030
+ const currentDeploy = await this.getStoredDeployById(deployId);
1589
2031
  if (!currentDeploy) {
1590
2032
  return { status: "missing" };
1591
2033
  }
@@ -1604,12 +2046,13 @@ export class MemoryAnonymousStore {
1604
2046
  owner,
1605
2047
  ownerId: owner.id
1606
2048
  };
2049
+ await this.rememberUser(owner);
1607
2050
  this.deploys.set(deployId, deploy);
1608
- return { deploy, status: currentDeploy.ownerId ? "already_claimed" : "claimed" };
2051
+ return { deploy: await this.deployWithUserLimitOverrides(deploy), status: currentDeploy.ownerId ? "already_claimed" : "claimed" };
1609
2052
  }
1610
2053
 
1611
2054
  async terminateDeploy(deployId) {
1612
- const currentDeploy = await this.getDeployById(deployId);
2055
+ const currentDeploy = await this.getStoredDeployById(deployId);
1613
2056
  if (!currentDeploy) {
1614
2057
  return null;
1615
2058
  }
@@ -1619,11 +2062,11 @@ export class MemoryAnonymousStore {
1619
2062
  status: "terminated"
1620
2063
  };
1621
2064
  this.deploys.set(deployId, deploy);
1622
- return deploy;
2065
+ return this.deployWithUserLimitOverrides(deploy);
1623
2066
  }
1624
2067
 
1625
2068
  async getDeployById(id) {
1626
- return this.deploys.get(id) ?? null;
2069
+ return this.deployWithUserLimitOverrides(await this.getStoredDeployById(id));
1627
2070
  }
1628
2071
 
1629
2072
  async getDeployBySlug(slug) {
@@ -1786,12 +2229,13 @@ export class MemoryAnonymousStore {
1786
2229
  const summaries = [];
1787
2230
 
1788
2231
  for (const deploy of deploys) {
2232
+ const effectiveDeploy = await this.deployWithUserLimitOverrides(deploy);
1789
2233
  const storedArtifact = await this.getArtifact(deploy.artifactHash);
1790
2234
  summaries.push(
1791
2235
  adminDeploySummary({
1792
2236
  artifact: storedArtifact?.artifact,
1793
2237
  artifactBytes: storedArtifact?.bytes ?? 0,
1794
- deploy,
2238
+ deploy: effectiveDeploy,
1795
2239
  ...this.logResourceForDeploy(deploy.id),
1796
2240
  ...this.stateResourceForDeploy(deploy.id),
1797
2241
  usage: await this.readUsage(deploy.id)
@@ -1809,11 +2253,12 @@ export class MemoryAnonymousStore {
1809
2253
  const summaries = [];
1810
2254
 
1811
2255
  for (const deploy of deploys) {
2256
+ const effectiveDeploy = await this.deployWithUserLimitOverrides(deploy);
1812
2257
  const storedArtifact = await this.getArtifact(deploy.artifactHash);
1813
2258
  summaries.push(
1814
2259
  developerDeploySummary({
1815
2260
  artifact: storedArtifact?.artifact,
1816
- deploy,
2261
+ deploy: effectiveDeploy,
1817
2262
  usage: await this.readUsage(deploy.id)
1818
2263
  })
1819
2264
  );
@@ -1907,6 +2352,48 @@ export class PostgresAnonymousStore {
1907
2352
  primary key (deploy_id, env_key)
1908
2353
  )
1909
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
+ `);
1910
2397
  }
1911
2398
 
1912
2399
  async query(sql, params = []) {
@@ -1917,6 +2404,199 @@ export class PostgresAnonymousStore {
1917
2404
  return this.pool.query(sql, params);
1918
2405
  }
1919
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
+
1920
2600
  async storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
1921
2601
  await this.query(
1922
2602
  `
@@ -2008,7 +2688,7 @@ export class PostgresAnonymousStore {
2008
2688
  requestedTtlSeconds,
2009
2689
  serverEnv
2010
2690
  }) {
2011
- const currentDeploy = await this.getDeployById(deployId);
2691
+ const currentDeploy = await this.getBaseDeployById(deployId);
2012
2692
  if (!currentDeploy) {
2013
2693
  return null;
2014
2694
  }
@@ -2039,11 +2719,11 @@ export class PostgresAnonymousStore {
2039
2719
  if (serverEnv !== undefined) {
2040
2720
  await this.replaceServerEnv(deployId, serverEnv, createdAt);
2041
2721
  }
2042
- return this.rowToDeploy(result.rows[0]);
2722
+ return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
2043
2723
  }
2044
2724
 
2045
2725
  async claimDeploy(deployId, token, owner) {
2046
- const currentDeploy = await this.getDeployById(deployId);
2726
+ const currentDeploy = await this.getBaseDeployById(deployId);
2047
2727
  if (!currentDeploy) {
2048
2728
  return { status: "missing" };
2049
2729
  }
@@ -2068,7 +2748,11 @@ export class PostgresAnonymousStore {
2068
2748
  `,
2069
2749
  [deployId, owner.id, claimedAt, JSON.stringify(owner)]
2070
2750
  );
2071
- 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
+ };
2072
2756
  }
2073
2757
 
2074
2758
  async terminateDeploy(deployId) {
@@ -2081,7 +2765,7 @@ export class PostgresAnonymousStore {
2081
2765
  `,
2082
2766
  [deployId]
2083
2767
  );
2084
- return this.rowToDeploy(result.rows[0]);
2768
+ return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
2085
2769
  }
2086
2770
 
2087
2771
  rowToDeploy(row) {
@@ -2109,13 +2793,12 @@ export class PostgresAnonymousStore {
2109
2793
  }
2110
2794
 
2111
2795
  async getDeployById(id) {
2112
- const result = await this.query("select * from deploys where id = $1", [id]);
2113
- return this.rowToDeploy(result.rows[0]);
2796
+ return this.deployWithUserLimitOverrides(await this.getBaseDeployById(id));
2114
2797
  }
2115
2798
 
2116
2799
  async getDeployBySlug(slug) {
2117
2800
  const result = await this.query("select * from deploys where slug = $1", [slug]);
2118
- return this.rowToDeploy(result.rows[0]);
2801
+ return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
2119
2802
  }
2120
2803
 
2121
2804
  async getArtifact(hash) {
@@ -2335,11 +3018,13 @@ export class PostgresAnonymousStore {
2335
3018
  [windowStart]
2336
3019
  );
2337
3020
 
2338
- return result.rows.map((row) =>
2339
- adminDeploySummary({
3021
+ return Promise.all(
3022
+ result.rows.map(async (row) => {
3023
+ const deploy = await this.deployWithUserLimitOverrides(this.rowToDeploy(row));
3024
+ return adminDeploySummary({
2340
3025
  artifact: row.artifact_json,
2341
3026
  artifactBytes: row.artifact_bytes,
2342
- deploy: this.rowToDeploy(row),
3027
+ deploy,
2343
3028
  logBytes: row.log_bytes,
2344
3029
  logEntries: row.log_entries,
2345
3030
  stateBytes: row.state_bytes,
@@ -2348,6 +3033,7 @@ export class PostgresAnonymousStore {
2348
3033
  { bucket: "requests", count: row.requests_today, windowStart },
2349
3034
  { bucket: "mutations", count: row.mutations_today, windowStart }
2350
3035
  ]
3036
+ });
2351
3037
  })
2352
3038
  );
2353
3039
  }
@@ -2377,14 +3063,17 @@ export class PostgresAnonymousStore {
2377
3063
  [ownerId, windowStart]
2378
3064
  );
2379
3065
 
2380
- return result.rows.map((row) =>
2381
- developerDeploySummary({
3066
+ return Promise.all(
3067
+ result.rows.map(async (row) => {
3068
+ const deploy = await this.deployWithUserLimitOverrides(this.rowToDeploy(row));
3069
+ return developerDeploySummary({
2382
3070
  artifact: row.artifact_json,
2383
- deploy: this.rowToDeploy(row),
3071
+ deploy,
2384
3072
  usage: [
2385
3073
  { bucket: "requests", count: row.requests_today, windowStart },
2386
3074
  { bucket: "mutations", count: row.mutations_today, windowStart }
2387
3075
  ]
3076
+ });
2388
3077
  })
2389
3078
  );
2390
3079
  }
@@ -2521,6 +3210,7 @@ export async function startAnonymousServer({
2521
3210
  ...deploy,
2522
3211
  connections: connections.get(deploy.id) ?? 0
2523
3212
  }));
3213
+ const users = await resolvedStore.listAdminUsers();
2524
3214
  const totals = deploys.reduce(
2525
3215
  (acc, deploy) => ({
2526
3216
  artifactBytes: acc.artifactBytes + deploy.artifactBytes,
@@ -2548,7 +3238,9 @@ export async function startAnonymousServer({
2548
3238
  deployCount: deploys.length,
2549
3239
  deploys,
2550
3240
  generatedAt: now(),
2551
- totals
3241
+ totals,
3242
+ userCount: users.length,
3243
+ users
2552
3244
  };
2553
3245
  }
2554
3246
 
@@ -2574,6 +3266,19 @@ export async function startAnonymousServer({
2574
3266
  }
2575
3267
  }
2576
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
+
2577
3282
  function closeDeployConnections(deployId) {
2578
3283
  for (const [ws, subscription] of subscriptions) {
2579
3284
  if (subscription.deploy.id !== deployId) {
@@ -2609,6 +3314,14 @@ export async function startAnonymousServer({
2609
3314
  }
2610
3315
  }
2611
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
+
2612
3325
  const server = createServer(async (req, res) => {
2613
3326
  const host = req.headers.host ?? "localhost";
2614
3327
  const requestUrl = new URL(req.url ?? "/", `http://${host}`);
@@ -2823,6 +3536,34 @@ export async function startAnonymousServer({
2823
3536
  return;
2824
3537
  }
2825
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
+
2826
3567
  const terminateMatch =
2827
3568
  req.method === "POST"
2828
3569
  ? requestUrl.pathname.match(/^\/admin\/api\/deploys\/([^/]+)\/terminate$/)
@@ -2916,6 +3657,7 @@ export async function startAnonymousServer({
2916
3657
 
2917
3658
  await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy updated", { artifactHash: deploy.artifactHash });
2918
3659
  await refreshDeploySubscriptions(deploy);
3660
+ refreshDeployClients(deploy);
2919
3661
  await publishDeploy(deploy.id);
2920
3662
  sendJson(res, 200, responseForDeploy({ deploy }));
2921
3663
  return;
@@ -2951,7 +3693,8 @@ export async function startAnonymousServer({
2951
3693
 
2952
3694
  const appPath = routeSystemPath(loaded.route.appPath);
2953
3695
  if (req.method === "GET" && (appPath === "/" || appPath === "/index.html" || appPath === "/auth/callback")) {
2954
- 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",
2955
3698
  "Content-Type": "text/html; charset=utf-8"
2956
3699
  });
2957
3700
  return;
package/src/client.js CHANGED
@@ -19,6 +19,7 @@ const pending = new Map();
19
19
  const activeSubscriptions = new Set();
20
20
  let authInitPromise = null;
21
21
  let authInitialized = false;
22
+ let refreshRequested = false;
22
23
 
23
24
  function toGuestName(name) {
24
25
  return (
@@ -68,6 +69,15 @@ function emitQuery(name, value) {
68
69
  }
69
70
  }
70
71
 
72
+ function refreshPage() {
73
+ if (refreshRequested) {
74
+ return;
75
+ }
76
+
77
+ refreshRequested = true;
78
+ window.location.reload();
79
+ }
80
+
71
81
  function send(message) {
72
82
  const ws = connect();
73
83
  const payload = JSON.stringify(message);
@@ -438,6 +448,11 @@ function connect() {
438
448
  return;
439
449
  }
440
450
 
451
+ if (message.op === "refresh") {
452
+ refreshPage();
453
+ return;
454
+ }
455
+
441
456
  if (message.id && pending.has(message.id)) {
442
457
  const handlers = pending.get(message.id);
443
458
  pending.delete(message.id);
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.6";
1
+ export const LAKEBED_VERSION = "0.0.7";