lakebed 0.0.18 → 0.0.20

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
@@ -6,22 +6,16 @@ This package is an early public prototype. It includes the `lakebed` CLI, a loca
6
6
 
7
7
  ## Install
8
8
 
9
- ```sh
10
- npm install -g lakebed
11
- lakebed new
12
- lakebed dev my-app
13
- ```
14
-
15
- Enter `my-app` when `lakebed new` asks for a capsule name.
16
-
17
- Or run it without a global install:
9
+ Run Lakebed through `npx` for now:
18
10
 
19
11
  ```sh
20
12
  npx lakebed new
21
- npm exec --package lakebed -- lakebed dev my-app
13
+ npx lakebed dev my-app
22
14
  ```
23
15
 
24
- `lakebed create` is an alias for `lakebed new`. New capsules get a git repository and initial commit unless they are created inside an existing git repository or `--no-git` is passed.
16
+ Enter `my-app` when `npx lakebed new` asks for a capsule name.
17
+
18
+ `npx lakebed create` is an alias for `npx lakebed new`. New capsules get a git repository and initial commit unless they are created inside an existing git repository or `--no-git` is passed.
25
19
 
26
20
  ## Capsule Shape
27
21
 
@@ -114,23 +108,45 @@ queries: {
114
108
  }
115
109
  ```
116
110
 
117
- `lakebed dev` loads `.env.lakebed.server` locally. For hosted apps, claim the deploy, then run `lakebed deploy` to replace the deploy's server env with the file contents. Env values are not included in anonymous artifacts or source manifests.
111
+ `npx lakebed dev` loads `.env.lakebed.server` locally. For hosted apps, claim the deploy, then run `npx lakebed deploy` to replace the deploy's server env with the file contents. Env values are not included in anonymous artifacts or source manifests.
112
+
113
+ ## External Endpoints
114
+
115
+ Define app-relative HTTP endpoints for webhooks and external services:
116
+
117
+ ```ts
118
+ import { endpoint, json, text } from "lakebed/server";
119
+
120
+ endpoints: {
121
+ incoming: endpoint({ method: "POST", path: "/webhooks/incoming" }, async (ctx, req) => {
122
+ if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
123
+ return text("unauthorized", { status: 401 });
124
+ }
125
+
126
+ const payload = await req.json<{ body: string }>();
127
+ ctx.db.messages.insert({ body: payload.body, authorId: "webhook" });
128
+ return json({ ok: true });
129
+ })
130
+ }
131
+ ```
132
+
133
+ Endpoint handlers receive the same server context as queries and mutations. The request exposes headers, query params, and repeatable `text()`, `json()`, and `bytes()` body readers.
118
134
 
119
135
  ## Commands
120
136
 
121
137
  ```sh
122
- lakebed new [name] [--template todo] [--no-git]
123
- lakebed create [name] [--template todo] [--no-git]
124
- lakebed dev [capsule-dir] [--port 3000]
125
- lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
126
- lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
127
- lakebed claim [capsule-dir] [--api <url>] [--json]
128
- lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
129
- lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
130
- lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
131
- lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
132
- lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
133
- lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
138
+ npx lakebed new [name] [--template todo] [--no-git]
139
+ npx lakebed create [name] [--template todo] [--no-git]
140
+ npx lakebed dev [capsule-dir] [--port 3000]
141
+ npx lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
142
+ npx lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
143
+ npx lakebed claim [capsule-dir] [--api <url>] [--json]
144
+ npx lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
145
+ npx lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
146
+ npx lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
147
+ npx lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
148
+ npx lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
149
+ npx lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
134
150
  ```
135
151
 
136
152
  ## Current Constraints
@@ -149,15 +165,15 @@ Once a Lakebed deploy runner is available, deploy a capsule with:
149
165
 
150
166
  ```sh
151
167
  cd my-app
152
- lakebed deploy
168
+ npx lakebed deploy
153
169
  ```
154
170
 
155
171
  For local deploy-runner testing:
156
172
 
157
173
  ```sh
158
- lakebed anonymous-server --port 8787
174
+ npx lakebed anonymous-server --port 8787
159
175
  cd my-app
160
- lakebed deploy --api http://localhost:8787
176
+ npx lakebed deploy --api http://localhost:8787
161
177
  ```
162
178
 
163
179
  In production, set `PUBLIC_ROOT_URL` to the deploy API origin and `LAKEBED_APP_BASE_DOMAIN` to the app domain without the wildcard prefix:
@@ -189,7 +205,7 @@ LAKEBED_ANONYMOUS_CLEANUP_RETENTION=7d
189
205
  LAKEBED_ANONYMOUS_CLEANUP_INTERVAL=1h
190
206
  ```
191
207
 
192
- Deploy responses include claim metadata. Configure GitHub OAuth on the runner, then run `lakebed claim` to open the claim page and attach the anonymous deploy to a developer account:
208
+ Deploy responses include claim metadata. Configure GitHub OAuth on the runner, then run `npx lakebed claim` to open the claim page and attach the anonymous deploy to a developer account:
193
209
 
194
210
  ```sh
195
211
  LAKEBED_GITHUB_CLIENT_ID=...
@@ -198,9 +214,9 @@ LAKEBED_SESSION_SECRET=...
198
214
  LAKEBED_SERVER_ENV_SECRET=...
199
215
  ```
200
216
 
201
- Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys and do not expire. Anonymous deploys cannot use outbound `fetch` or hosted server env; after a deploy is claimed, `lakebed deploy` can update it with a source-backed server artifact that supports async handlers, server-side fetch, and `.env.lakebed.server` sync. If the first deploy already needs server-side `fetch` or server env, `lakebed deploy` creates a claim-required preview, saves its claim metadata, and prints the `lakebed claim` command. Run that command, then run `lakebed deploy` again to publish the real source-backed app. Set `LAKEBED_SERVER_ENV_SECRET` on Postgres-backed runners to encrypt stored server env values.
217
+ Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys and do not expire. Anonymous deploys cannot use outbound `fetch` or hosted server env; after a deploy is claimed, `npx lakebed deploy` can update it with a source-backed server artifact that supports async handlers, server-side fetch, and `.env.lakebed.server` sync. If the first deploy already needs server-side `fetch` or server env, `npx lakebed deploy` creates a claim-required preview, saves its claim metadata, and prints the `npx lakebed claim` command. Run that command, then run `npx lakebed deploy` again to publish the real source-backed app. Set `LAKEBED_SERVER_ENV_SECRET` on Postgres-backed runners to encrypt stored server env values.
202
218
 
203
- Hosted inspection is private by default. `lakebed inspect`, `lakebed db list`, `lakebed db dump`, and `lakebed logs` send the saved claim token automatically when run from the capsule directory.
219
+ Hosted inspection is private by default. `npx lakebed inspect`, `npx lakebed db list`, `npx lakebed db dump`, and `npx lakebed logs` send the saved claim token automatically when run from the capsule directory.
204
220
 
205
221
  After a deploy is claimed, reserve a Lakebed-owned app subdomain from the capsule directory:
206
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -52,6 +52,9 @@
52
52
  "publishConfig": {
53
53
  "access": "public"
54
54
  },
55
+ "scripts": {
56
+ "check": "node --check src/cli.js && node --check src/runtime.js && node --check src/server.js && node --check src/client.js && node --check src/source-store.js && node --check src/source-runtime.js && node --check src/source-runtime-worker.js && node --check src/source-runtime-loader.mjs && node --check src/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js && node --check src/version.js"
57
+ },
55
58
  "dependencies": {
56
59
  "esbuild": "^0.27.1",
57
60
  "pg": "^8.16.3",
@@ -60,8 +63,5 @@
60
63
  },
61
64
  "devDependencies": {
62
65
  "@types/ws": "^8.18.1"
63
- },
64
- "scripts": {
65
- "check": "node --check src/cli.js && node --check src/runtime.js && node --check src/server.js && node --check src/client.js && node --check src/source-store.js && node --check src/source-runtime.js && node --check src/source-runtime-worker.js && node --check src/source-runtime-loader.mjs && node --check src/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js && node --check src/version.js"
66
66
  }
67
- }
67
+ }
@@ -8,6 +8,7 @@ import {
8
8
  createSlug,
9
9
  DEFAULT_ANONYMOUS_LIMITS,
10
10
  LAKEBED_VERSION,
11
+ executeAnonymousEndpoint,
11
12
  executeAnonymousMutation,
12
13
  executeAnonymousQuery,
13
14
  hashClaimToken,
@@ -27,6 +28,8 @@ function anonymousDeployExpiresAt() {
27
28
  return new Date(Date.now() + ttlSeconds * 1000).toISOString();
28
29
  }
29
30
 
31
+ const endpointBodyMaxBytes = 2 * 1024 * 1024;
32
+
30
33
  function dayWindowStart() {
31
34
  return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
32
35
  }
@@ -120,7 +123,7 @@ function websocketSend(ws, message) {
120
123
  ws.send(JSON.stringify(message));
121
124
  }
122
125
 
123
- async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
126
+ async function readRequestBody(req, maxBytes = endpointBodyMaxBytes) {
124
127
  const chunks = [];
125
128
  let total = 0;
126
129
  for await (const chunk of req) {
@@ -131,11 +134,72 @@ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
131
134
  chunks.push(chunk);
132
135
  }
133
136
 
134
- if (chunks.length === 0) {
137
+ return Buffer.concat(chunks);
138
+ }
139
+
140
+ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
141
+ const body = await readRequestBody(req, maxBytes);
142
+ if (body.byteLength === 0) {
135
143
  return {};
136
144
  }
137
145
 
138
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
146
+ return JSON.parse(body.toString("utf8"));
147
+ }
148
+
149
+ function headersFromNodeRequest(headers) {
150
+ const clean = {};
151
+ for (const [name, value] of Object.entries(headers ?? {})) {
152
+ if (Array.isArray(value)) {
153
+ clean[name.toLowerCase()] = value.join(", ");
154
+ } else if (value !== undefined) {
155
+ clean[name.toLowerCase()] = String(value);
156
+ }
157
+ }
158
+ return clean;
159
+ }
160
+
161
+ function endpointRequestPayload({ appPath, body, req, requestUrl }) {
162
+ return {
163
+ bodyBase64: body.toString("base64"),
164
+ headers: headersFromNodeRequest(req.headers),
165
+ method: String(req.method ?? "GET").toUpperCase(),
166
+ path: appPath,
167
+ url: requestUrl.href
168
+ };
169
+ }
170
+
171
+ function findArtifactEndpoint(artifact, method, path) {
172
+ const requestMethod = String(method ?? "GET").toUpperCase();
173
+ for (const [name, endpoint] of Object.entries(artifact.server?.endpoints ?? {})) {
174
+ if (endpoint?.method === requestMethod && endpoint?.path === path) {
175
+ return { ...endpoint, name };
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function sanitizeEndpointResponseHeaders(headers = {}) {
182
+ const blocked = new Set(["connection", "content-length", "date", "keep-alive", "transfer-encoding", "upgrade"]);
183
+ const clean = {};
184
+ for (const [rawName, rawValue] of Object.entries(headers ?? {})) {
185
+ const name = String(rawName);
186
+ const lower = name.toLowerCase();
187
+ if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/i.test(name) || blocked.has(lower)) {
188
+ continue;
189
+ }
190
+ clean[name] = String(rawValue);
191
+ }
192
+ return clean;
193
+ }
194
+
195
+ function sendEndpointResponse(res, response) {
196
+ const body = Buffer.from(response?.bodyBase64 ?? "", "base64");
197
+ const status = Number.isInteger(response?.status) && response.status >= 100 && response.status <= 599 ? response.status : 200;
198
+ res.writeHead(status, {
199
+ ...sanitizeEndpointResponseHeaders(response?.headers),
200
+ "Content-Length": String(body.byteLength)
201
+ });
202
+ res.end(body);
139
203
  }
140
204
 
141
205
  function bearerToken(req) {
@@ -410,6 +474,26 @@ function routeSystemPath(pathname) {
410
474
  return pathname;
411
475
  }
412
476
 
477
+ function wantsHtml(req) {
478
+ const accept = String(req.headers.accept ?? "");
479
+ return !accept || accept.includes("text/html");
480
+ }
481
+
482
+ function isReservedClientShellPath(pathname) {
483
+ return (
484
+ pathname === "/client.js" ||
485
+ pathname === "/__lakebed" ||
486
+ pathname.startsWith("/__lakebed/") ||
487
+ pathname === "/__span" ||
488
+ pathname.startsWith("/__span/") ||
489
+ (pathname.startsWith("/auth/") && pathname !== "/auth/callback")
490
+ );
491
+ }
492
+
493
+ function isClientShellRequest(req, pathname) {
494
+ return req.method === "GET" && wantsHtml(req) && !isReservedClientShellPath(pathname);
495
+ }
496
+
413
497
  function parsePathDeploy(url) {
414
498
  const parts = url.pathname.split("/").filter(Boolean);
415
499
  if (parts[0] !== "d" || !parts[1]) {
@@ -2893,6 +2977,22 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2893
2977
  ${quotaHtml("Requests", deploy.usage.requestsToday, deploy.limits.requestsPerDay)}
2894
2978
  ${quotaHtml("Mutations", deploy.usage.mutationsToday, deploy.limits.mutationsPerDay)}
2895
2979
  </div>
2980
+ <div class="deploy-actions">
2981
+ ${
2982
+ deploy.status === "active"
2983
+ ? `<button class="button danger terminate-button" type="button" data-terminate-deploy-id="${escapeHtml(deploy.deployId)}" data-terminate-deploy-name="${escapeHtml(deploy.name)}" aria-label="Terminate ${escapeHtml(deploy.name)}">
2984
+ <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
2985
+ <path d="M3 6h18"></path>
2986
+ <path d="M8 6V4h8v2"></path>
2987
+ <path d="M19 6l-1 14H6L5 6"></path>
2988
+ <path d="M10 11v6"></path>
2989
+ <path d="M14 11v6"></path>
2990
+ </svg>
2991
+ <span>Terminate</span>
2992
+ </button>`
2993
+ : ""
2994
+ }
2995
+ </div>
2896
2996
  </article>`;
2897
2997
  })
2898
2998
  .join("");
@@ -3004,11 +3104,30 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3004
3104
  text-decoration: none;
3005
3105
  }
3006
3106
 
3107
+ .button:disabled {
3108
+ cursor: wait;
3109
+ opacity: 0.55;
3110
+ }
3111
+
3007
3112
  .button.secondary {
3008
3113
  background: transparent;
3009
3114
  color: var(--text);
3010
3115
  }
3011
3116
 
3117
+ .button.danger {
3118
+ color: var(--danger);
3119
+ }
3120
+
3121
+ .button.danger:hover {
3122
+ border-color: var(--danger);
3123
+ }
3124
+
3125
+ .button svg {
3126
+ height: 16px;
3127
+ margin-right: 8px;
3128
+ width: 16px;
3129
+ }
3130
+
3012
3131
  .summary {
3013
3132
  background: var(--line);
3014
3133
  border: 1px solid var(--line);
@@ -3061,7 +3180,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3061
3180
  align-items: center;
3062
3181
  display: grid;
3063
3182
  gap: 24px;
3064
- grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px);
3183
+ grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px) minmax(120px, 150px);
3065
3184
  }
3066
3185
 
3067
3186
  .list-head {
@@ -3088,6 +3207,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3088
3207
  .deploy-main,
3089
3208
  .activity,
3090
3209
  .usage-grid,
3210
+ .deploy-actions,
3091
3211
  .quota {
3092
3212
  min-width: 0;
3093
3213
  }
@@ -3203,6 +3323,64 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3203
3323
  width: var(--usage);
3204
3324
  }
3205
3325
 
3326
+ .deploy-actions {
3327
+ display: flex;
3328
+ justify-content: flex-end;
3329
+ }
3330
+
3331
+ .terminate-button {
3332
+ min-width: 126px;
3333
+ }
3334
+
3335
+ .confirm-dialog {
3336
+ background: var(--panel-raised);
3337
+ border: 1px solid var(--line-strong);
3338
+ border-radius: 8px;
3339
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
3340
+ color: var(--text);
3341
+ margin: auto;
3342
+ max-width: 430px;
3343
+ padding: 0;
3344
+ width: calc(100% - 32px);
3345
+ }
3346
+
3347
+ .confirm-dialog::backdrop {
3348
+ background: rgba(0, 0, 0, 0.72);
3349
+ }
3350
+
3351
+ .dialog-body {
3352
+ display: grid;
3353
+ gap: 14px;
3354
+ padding: 20px;
3355
+ }
3356
+
3357
+ .confirm-dialog h2 {
3358
+ font-size: 20px;
3359
+ line-height: 1.2;
3360
+ margin: 0;
3361
+ }
3362
+
3363
+ .confirm-dialog p {
3364
+ color: var(--soft);
3365
+ line-height: 1.5;
3366
+ margin: 0;
3367
+ }
3368
+
3369
+ .dialog-actions {
3370
+ display: flex;
3371
+ flex-wrap: wrap;
3372
+ gap: 10px;
3373
+ justify-content: flex-end;
3374
+ margin-top: 4px;
3375
+ }
3376
+
3377
+ .dialog-error {
3378
+ color: var(--danger);
3379
+ font-family: "SFMono-Regular", Consolas, monospace;
3380
+ font-size: 12px;
3381
+ min-height: 16px;
3382
+ }
3383
+
3206
3384
  @media (max-width: 980px) {
3207
3385
  .shell {
3208
3386
  padding: 24px 18px 40px;
@@ -3234,6 +3412,10 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3234
3412
  .activity-item {
3235
3413
  justify-content: flex-start;
3236
3414
  }
3415
+
3416
+ .deploy-actions {
3417
+ justify-content: flex-start;
3418
+ }
3237
3419
  }
3238
3420
 
3239
3421
  @media (max-width: 640px) {
@@ -3298,12 +3480,122 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3298
3480
  <span>Deploy</span>
3299
3481
  <span>Activity</span>
3300
3482
  <span>Usage today</span>
3483
+ <span>Actions</span>
3301
3484
  </div>
3302
3485
  ${rows}
3303
3486
  </div>
3304
3487
  </section>`
3305
3488
  }
3306
3489
  </main>
3490
+ ${
3491
+ signedIn && activeDeploys > 0
3492
+ ? `<dialog class="confirm-dialog" id="terminate-dialog" aria-describedby="terminate-description" aria-labelledby="terminate-title">
3493
+ <div class="dialog-body">
3494
+ <h2 id="terminate-title">Terminate app?</h2>
3495
+ <p id="terminate-description">This stops serving <strong id="terminate-deploy-name">this app</strong>. Data and logs stay available for inspection.</p>
3496
+ <div class="dialog-error" id="terminate-error" role="alert"></div>
3497
+ <div class="dialog-actions">
3498
+ <button class="button secondary" id="terminate-cancel" type="button">Cancel</button>
3499
+ <button class="button danger" id="terminate-confirm" type="button">
3500
+ <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
3501
+ <path d="M3 6h18"></path>
3502
+ <path d="M8 6V4h8v2"></path>
3503
+ <path d="M19 6l-1 14H6L5 6"></path>
3504
+ <path d="M10 11v6"></path>
3505
+ <path d="M14 11v6"></path>
3506
+ </svg>
3507
+ <span>Terminate</span>
3508
+ </button>
3509
+ </div>
3510
+ </div>
3511
+ </dialog>
3512
+ <script>
3513
+ (() => {
3514
+ const dialog = document.getElementById("terminate-dialog");
3515
+ const name = document.getElementById("terminate-deploy-name");
3516
+ const cancel = document.getElementById("terminate-cancel");
3517
+ const confirm = document.getElementById("terminate-confirm");
3518
+ const error = document.getElementById("terminate-error");
3519
+ let pendingDeployId = "";
3520
+
3521
+ function setBusy(value) {
3522
+ confirm.disabled = value;
3523
+ cancel.disabled = value;
3524
+ }
3525
+
3526
+ function closeDialog() {
3527
+ if (typeof dialog.close === "function") {
3528
+ dialog.close();
3529
+ } else {
3530
+ dialog.removeAttribute("open");
3531
+ }
3532
+ }
3533
+
3534
+ function openDialog(button) {
3535
+ pendingDeployId = button.dataset.terminateDeployId || "";
3536
+ name.textContent = button.dataset.terminateDeployName || "this app";
3537
+ error.textContent = "";
3538
+ setBusy(false);
3539
+ if (typeof dialog.showModal === "function") {
3540
+ dialog.showModal();
3541
+ } else {
3542
+ dialog.setAttribute("open", "");
3543
+ }
3544
+ }
3545
+
3546
+ async function errorMessage(response) {
3547
+ const text = await response.text();
3548
+ if (!text) {
3549
+ return response.statusText || "Terminate failed.";
3550
+ }
3551
+ try {
3552
+ const body = JSON.parse(text);
3553
+ return body.error || response.statusText || "Terminate failed.";
3554
+ } catch {
3555
+ return text;
3556
+ }
3557
+ }
3558
+
3559
+ document.addEventListener("click", (event) => {
3560
+ const button = event.target.closest("[data-terminate-deploy-id]");
3561
+ if (!button) {
3562
+ return;
3563
+ }
3564
+ openDialog(button);
3565
+ });
3566
+
3567
+ dialog.addEventListener("click", (event) => {
3568
+ if (event.target === dialog && !confirm.disabled) {
3569
+ closeDialog();
3570
+ }
3571
+ });
3572
+
3573
+ cancel.addEventListener("click", closeDialog);
3574
+ confirm.addEventListener("click", async () => {
3575
+ if (!pendingDeployId) {
3576
+ return;
3577
+ }
3578
+ setBusy(true);
3579
+ error.textContent = "";
3580
+ const response = await fetch("/v1/me/deploys/" + encodeURIComponent(pendingDeployId) + "/terminate", {
3581
+ headers: { "Accept": "application/json" },
3582
+ method: "POST"
3583
+ });
3584
+ if (response.status === 401) {
3585
+ window.location.assign("/auth/github?return_to=" + encodeURIComponent("/deploys"));
3586
+ return;
3587
+ }
3588
+ if (!response.ok) {
3589
+ error.textContent = await errorMessage(response);
3590
+ setBusy(false);
3591
+ return;
3592
+ }
3593
+ window.location.assign("/deploys");
3594
+ });
3595
+ })();
3596
+ </script>`
3597
+ : ""
3598
+ }
3307
3599
  </body>
3308
3600
  </html>`;
3309
3601
  }
@@ -5499,6 +5791,11 @@ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
5499
5791
  clientBundleHash: deploy.clientBundleHash,
5500
5792
  deployId: deploy.id,
5501
5793
  domains,
5794
+ endpoints: Object.entries(artifact.server.endpoints ?? {}).map(([name, endpoint]) => ({
5795
+ method: endpoint.method,
5796
+ name,
5797
+ path: endpoint.path
5798
+ })),
5502
5799
  expiresAt: deploy.expiresAt,
5503
5800
  inspectPolicy: inspectPolicyForDeploy(deploy),
5504
5801
  limits: deploy.limits,
@@ -5516,15 +5813,15 @@ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
5516
5813
 
5517
5814
  function inspectCommandForPath(deploy, systemPath) {
5518
5815
  if (systemPath === "/__lakebed/db") {
5519
- return `lakebed db dump ${deploy.id}`;
5816
+ return `npx lakebed db dump ${deploy.id}`;
5520
5817
  }
5521
5818
  if (systemPath === "/__lakebed/db/tables") {
5522
- return `lakebed db list ${deploy.id}`;
5819
+ return `npx lakebed db list ${deploy.id}`;
5523
5820
  }
5524
5821
  if (systemPath === "/__lakebed/logs") {
5525
- return `lakebed logs ${deploy.id}`;
5822
+ return `npx lakebed logs ${deploy.id}`;
5526
5823
  }
5527
- return `lakebed inspect ${deploy.id}`;
5824
+ return `npx lakebed inspect ${deploy.id}`;
5528
5825
  }
5529
5826
 
5530
5827
  function inspectAuthFailure(deploy, systemPath) {
@@ -5749,6 +6046,15 @@ export async function startAnonymousServer({
5749
6046
  return resolvedStore.listDeploysForOwner(user.id);
5750
6047
  }
5751
6048
 
6049
+ async function developerSummaryForDeploy(deploy) {
6050
+ const artifact = await resolvedStore.getArtifact(deploy.artifactHash);
6051
+ return developerDeploySummary({
6052
+ artifact: artifact?.artifact,
6053
+ deploy,
6054
+ usage: await resolvedStore.readUsage(deploy.id)
6055
+ });
6056
+ }
6057
+
5752
6058
  async function enforceAnonymousDeployCreation(req) {
5753
6059
  if (deployCreationPolicy.disabled) {
5754
6060
  const error = new LakebedQuotaError({
@@ -5923,6 +6229,43 @@ export async function startAnonymousServer({
5923
6229
  return;
5924
6230
  }
5925
6231
 
6232
+ const developerTerminateMatch =
6233
+ req.method === "POST" ? requestUrl.pathname.match(/^\/v1\/me\/deploys\/([^/]+)\/terminate$/) : null;
6234
+ if (developerTerminateMatch) {
6235
+ const user = currentDeveloper(req);
6236
+ if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
6237
+ sendJson(res, 401, { error: "Developer authentication required." });
6238
+ return;
6239
+ }
6240
+
6241
+ const deployId = decodeURIComponent(developerTerminateMatch[1]);
6242
+ const currentDeploy = await resolvedStore.getDeployById(deployId);
6243
+ if (!currentDeploy || currentDeploy.ownerId !== user.id) {
6244
+ sendJson(res, 404, { error: "Unknown deploy." });
6245
+ return;
6246
+ }
6247
+ if (currentDeploy.status !== "active" || isExpired(currentDeploy)) {
6248
+ sendJson(res, 409, { error: "Deploy is not active." });
6249
+ return;
6250
+ }
6251
+
6252
+ const deploy = await resolvedStore.terminateDeploy(deployId);
6253
+ if (!deploy || deploy.ownerId !== user.id) {
6254
+ sendJson(res, 404, { error: "Unknown deploy." });
6255
+ return;
6256
+ }
6257
+
6258
+ await resolvedStore.appendLog(deploy.id, "warn", "anonymous deploy terminated", {
6259
+ ownerId: user.id,
6260
+ ownerLogin: user.login,
6261
+ source: "developer"
6262
+ });
6263
+ await refreshDeploySubscriptions(deploy);
6264
+ closeDeployConnections(deploy.id);
6265
+ sendJson(res, 200, { deploy: await developerSummaryForDeploy(deploy) });
6266
+ return;
6267
+ }
6268
+
5926
6269
  if (req.method === "GET" && requestUrl.pathname === "/auth/github") {
5927
6270
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
5928
6271
  sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
@@ -6414,6 +6757,48 @@ export async function startAnonymousServer({
6414
6757
  return;
6415
6758
  }
6416
6759
 
6760
+ const endpoint = findArtifactEndpoint(loaded.artifact, req.method, appPath);
6761
+ if (endpoint) {
6762
+ await enforceClientTrafficQuota(clientKey, "mutations");
6763
+ await resolvedStore.incrementQuota(
6764
+ loaded.deploy.id,
6765
+ "mutations",
6766
+ quotaLimitForBucket("mutations", loaded.deploy)
6767
+ );
6768
+ const auth = await resolveAuthFromUrl({
6769
+ defaultAuth: createGuestAuth("local"),
6770
+ onError: (error) =>
6771
+ resolvedStore.appendLog(loaded.deploy.id, "warn", "endpoint auth verification failed", {
6772
+ error: error instanceof Error ? error.message : String(error)
6773
+ }),
6774
+ origin: requestOrigin(req, loaded.deploy.url),
6775
+ shooBaseUrl,
6776
+ url: requestUrl
6777
+ });
6778
+ const body = await readRequestBody(req);
6779
+ const response = await executeAnonymousEndpoint({
6780
+ artifact: loaded.artifact,
6781
+ auth,
6782
+ deployId: loaded.deploy.id,
6783
+ limits: loaded.deploy.limits,
6784
+ name: endpoint.name,
6785
+ request: endpointRequestPayload({ appPath, body, req, requestUrl }),
6786
+ sourceRuntime: resolvedSourceRuntime,
6787
+ state: resolvedStore
6788
+ });
6789
+ sendEndpointResponse(res, response);
6790
+ await publishDeploy(loaded.deploy.id);
6791
+ return;
6792
+ }
6793
+
6794
+ if (isClientShellRequest(req, appPath)) {
6795
+ sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath, { clientBundleHash: loaded.deploy.clientBundleHash, shooBaseUrl }), {
6796
+ "Cache-Control": "no-store",
6797
+ "Content-Type": "text/html; charset=utf-8"
6798
+ });
6799
+ return;
6800
+ }
6801
+
6417
6802
  sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
6418
6803
  } catch (error) {
6419
6804
  if (isQuotaError(error)) {