lakebed 0.0.17 → 0.0.19

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
 
@@ -61,7 +55,7 @@ export default capsule({
61
55
 
62
56
  ## Auth
63
57
 
64
- Every app starts with local guest auth. To let users sign in with Google, render the built-in button. Lakebed always asks Shoo for profile fields so the app can show a useful identifier like `auth.email` or `auth.displayName`.
58
+ Every app starts with local guest auth. To let users sign in with Google, render the built-in button. Lakebed asks Shoo for profile fields and exposes the user's name and avatar through `auth.displayName` and `auth.picture`.
65
59
  Check `auth.isLoading` before showing signed-out UI, because Lakebed may still be confirming a stored session.
66
60
 
67
61
  ```tsx
@@ -69,7 +63,7 @@ import { SignInWithGoogle, signOut, useAuth } from "lakebed/client";
69
63
 
70
64
  export function App() {
71
65
  const auth = useAuth();
72
- const authLabel = auth.email ?? auth.displayName;
66
+ const authLabel = auth.displayName;
73
67
 
74
68
  if (auth.isLoading) {
75
69
  return <p>Checking session</p>;
@@ -78,7 +72,8 @@ export function App() {
78
72
  return auth.isGuest ? (
79
73
  <SignInWithGoogle />
80
74
  ) : (
81
- <button type="button" onClick={() => signOut()}>
75
+ <button className="inline-flex items-center gap-2" type="button" onClick={() => signOut()}>
76
+ {auth.picture ? <img alt="" className="h-6 w-6 rounded-full" referrerPolicy="no-referrer" src={auth.picture} /> : null}
82
77
  Sign out {authLabel}
83
78
  </button>
84
79
  );
@@ -90,7 +85,7 @@ After sign-in, server handlers receive the verified Google identity through `ctx
90
85
  ```ts
91
86
  mutations: {
92
87
  save: mutation((ctx) => {
93
- ctx.log.info("signed in user", { userId: ctx.auth.userId, email: ctx.auth.email });
88
+ ctx.log.info("signed in user", { userId: ctx.auth.userId, displayName: ctx.auth.displayName });
94
89
  });
95
90
  }
96
91
  ```
@@ -113,23 +108,45 @@ queries: {
113
108
  }
114
109
  ```
115
110
 
116
- `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.
117
134
 
118
135
  ## Commands
119
136
 
120
137
  ```sh
121
- lakebed new [name] [--template todo] [--no-git]
122
- lakebed create [name] [--template todo] [--no-git]
123
- lakebed dev [capsule-dir] [--port 3000]
124
- lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
125
- lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
126
- lakebed claim [capsule-dir] [--api <url>] [--json]
127
- lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
128
- lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
129
- lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
130
- lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
131
- lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
132
- 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>]
133
150
  ```
134
151
 
135
152
  ## Current Constraints
@@ -148,15 +165,15 @@ Once a Lakebed deploy runner is available, deploy a capsule with:
148
165
 
149
166
  ```sh
150
167
  cd my-app
151
- lakebed deploy
168
+ npx lakebed deploy
152
169
  ```
153
170
 
154
171
  For local deploy-runner testing:
155
172
 
156
173
  ```sh
157
- lakebed anonymous-server --port 8787
174
+ npx lakebed anonymous-server --port 8787
158
175
  cd my-app
159
- lakebed deploy --api http://localhost:8787
176
+ npx lakebed deploy --api http://localhost:8787
160
177
  ```
161
178
 
162
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:
@@ -188,7 +205,7 @@ LAKEBED_ANONYMOUS_CLEANUP_RETENTION=7d
188
205
  LAKEBED_ANONYMOUS_CLEANUP_INTERVAL=1h
189
206
  ```
190
207
 
191
- 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:
192
209
 
193
210
  ```sh
194
211
  LAKEBED_GITHUB_CLIENT_ID=...
@@ -197,9 +214,9 @@ LAKEBED_SESSION_SECRET=...
197
214
  LAKEBED_SERVER_ENV_SECRET=...
198
215
  ```
199
216
 
200
- 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.
201
218
 
202
- 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.
203
220
 
204
221
  After a deploy is claimed, reserve a Lakebed-owned app subdomain from the capsule directory:
205
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -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) {
@@ -2893,6 +2957,22 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2893
2957
  ${quotaHtml("Requests", deploy.usage.requestsToday, deploy.limits.requestsPerDay)}
2894
2958
  ${quotaHtml("Mutations", deploy.usage.mutationsToday, deploy.limits.mutationsPerDay)}
2895
2959
  </div>
2960
+ <div class="deploy-actions">
2961
+ ${
2962
+ deploy.status === "active"
2963
+ ? `<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)}">
2964
+ <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
2965
+ <path d="M3 6h18"></path>
2966
+ <path d="M8 6V4h8v2"></path>
2967
+ <path d="M19 6l-1 14H6L5 6"></path>
2968
+ <path d="M10 11v6"></path>
2969
+ <path d="M14 11v6"></path>
2970
+ </svg>
2971
+ <span>Terminate</span>
2972
+ </button>`
2973
+ : ""
2974
+ }
2975
+ </div>
2896
2976
  </article>`;
2897
2977
  })
2898
2978
  .join("");
@@ -3004,11 +3084,30 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3004
3084
  text-decoration: none;
3005
3085
  }
3006
3086
 
3087
+ .button:disabled {
3088
+ cursor: wait;
3089
+ opacity: 0.55;
3090
+ }
3091
+
3007
3092
  .button.secondary {
3008
3093
  background: transparent;
3009
3094
  color: var(--text);
3010
3095
  }
3011
3096
 
3097
+ .button.danger {
3098
+ color: var(--danger);
3099
+ }
3100
+
3101
+ .button.danger:hover {
3102
+ border-color: var(--danger);
3103
+ }
3104
+
3105
+ .button svg {
3106
+ height: 16px;
3107
+ margin-right: 8px;
3108
+ width: 16px;
3109
+ }
3110
+
3012
3111
  .summary {
3013
3112
  background: var(--line);
3014
3113
  border: 1px solid var(--line);
@@ -3061,7 +3160,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3061
3160
  align-items: center;
3062
3161
  display: grid;
3063
3162
  gap: 24px;
3064
- grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px);
3163
+ grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px) minmax(120px, 150px);
3065
3164
  }
3066
3165
 
3067
3166
  .list-head {
@@ -3088,6 +3187,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3088
3187
  .deploy-main,
3089
3188
  .activity,
3090
3189
  .usage-grid,
3190
+ .deploy-actions,
3091
3191
  .quota {
3092
3192
  min-width: 0;
3093
3193
  }
@@ -3203,6 +3303,64 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3203
3303
  width: var(--usage);
3204
3304
  }
3205
3305
 
3306
+ .deploy-actions {
3307
+ display: flex;
3308
+ justify-content: flex-end;
3309
+ }
3310
+
3311
+ .terminate-button {
3312
+ min-width: 126px;
3313
+ }
3314
+
3315
+ .confirm-dialog {
3316
+ background: var(--panel-raised);
3317
+ border: 1px solid var(--line-strong);
3318
+ border-radius: 8px;
3319
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
3320
+ color: var(--text);
3321
+ margin: auto;
3322
+ max-width: 430px;
3323
+ padding: 0;
3324
+ width: calc(100% - 32px);
3325
+ }
3326
+
3327
+ .confirm-dialog::backdrop {
3328
+ background: rgba(0, 0, 0, 0.72);
3329
+ }
3330
+
3331
+ .dialog-body {
3332
+ display: grid;
3333
+ gap: 14px;
3334
+ padding: 20px;
3335
+ }
3336
+
3337
+ .confirm-dialog h2 {
3338
+ font-size: 20px;
3339
+ line-height: 1.2;
3340
+ margin: 0;
3341
+ }
3342
+
3343
+ .confirm-dialog p {
3344
+ color: var(--soft);
3345
+ line-height: 1.5;
3346
+ margin: 0;
3347
+ }
3348
+
3349
+ .dialog-actions {
3350
+ display: flex;
3351
+ flex-wrap: wrap;
3352
+ gap: 10px;
3353
+ justify-content: flex-end;
3354
+ margin-top: 4px;
3355
+ }
3356
+
3357
+ .dialog-error {
3358
+ color: var(--danger);
3359
+ font-family: "SFMono-Regular", Consolas, monospace;
3360
+ font-size: 12px;
3361
+ min-height: 16px;
3362
+ }
3363
+
3206
3364
  @media (max-width: 980px) {
3207
3365
  .shell {
3208
3366
  padding: 24px 18px 40px;
@@ -3234,6 +3392,10 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3234
3392
  .activity-item {
3235
3393
  justify-content: flex-start;
3236
3394
  }
3395
+
3396
+ .deploy-actions {
3397
+ justify-content: flex-start;
3398
+ }
3237
3399
  }
3238
3400
 
3239
3401
  @media (max-width: 640px) {
@@ -3298,12 +3460,122 @@ function developerHtml({ authConfigured, deploys = [], user }) {
3298
3460
  <span>Deploy</span>
3299
3461
  <span>Activity</span>
3300
3462
  <span>Usage today</span>
3463
+ <span>Actions</span>
3301
3464
  </div>
3302
3465
  ${rows}
3303
3466
  </div>
3304
3467
  </section>`
3305
3468
  }
3306
3469
  </main>
3470
+ ${
3471
+ signedIn && activeDeploys > 0
3472
+ ? `<dialog class="confirm-dialog" id="terminate-dialog" aria-describedby="terminate-description" aria-labelledby="terminate-title">
3473
+ <div class="dialog-body">
3474
+ <h2 id="terminate-title">Terminate app?</h2>
3475
+ <p id="terminate-description">This stops serving <strong id="terminate-deploy-name">this app</strong>. Data and logs stay available for inspection.</p>
3476
+ <div class="dialog-error" id="terminate-error" role="alert"></div>
3477
+ <div class="dialog-actions">
3478
+ <button class="button secondary" id="terminate-cancel" type="button">Cancel</button>
3479
+ <button class="button danger" id="terminate-confirm" type="button">
3480
+ <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
3481
+ <path d="M3 6h18"></path>
3482
+ <path d="M8 6V4h8v2"></path>
3483
+ <path d="M19 6l-1 14H6L5 6"></path>
3484
+ <path d="M10 11v6"></path>
3485
+ <path d="M14 11v6"></path>
3486
+ </svg>
3487
+ <span>Terminate</span>
3488
+ </button>
3489
+ </div>
3490
+ </div>
3491
+ </dialog>
3492
+ <script>
3493
+ (() => {
3494
+ const dialog = document.getElementById("terminate-dialog");
3495
+ const name = document.getElementById("terminate-deploy-name");
3496
+ const cancel = document.getElementById("terminate-cancel");
3497
+ const confirm = document.getElementById("terminate-confirm");
3498
+ const error = document.getElementById("terminate-error");
3499
+ let pendingDeployId = "";
3500
+
3501
+ function setBusy(value) {
3502
+ confirm.disabled = value;
3503
+ cancel.disabled = value;
3504
+ }
3505
+
3506
+ function closeDialog() {
3507
+ if (typeof dialog.close === "function") {
3508
+ dialog.close();
3509
+ } else {
3510
+ dialog.removeAttribute("open");
3511
+ }
3512
+ }
3513
+
3514
+ function openDialog(button) {
3515
+ pendingDeployId = button.dataset.terminateDeployId || "";
3516
+ name.textContent = button.dataset.terminateDeployName || "this app";
3517
+ error.textContent = "";
3518
+ setBusy(false);
3519
+ if (typeof dialog.showModal === "function") {
3520
+ dialog.showModal();
3521
+ } else {
3522
+ dialog.setAttribute("open", "");
3523
+ }
3524
+ }
3525
+
3526
+ async function errorMessage(response) {
3527
+ const text = await response.text();
3528
+ if (!text) {
3529
+ return response.statusText || "Terminate failed.";
3530
+ }
3531
+ try {
3532
+ const body = JSON.parse(text);
3533
+ return body.error || response.statusText || "Terminate failed.";
3534
+ } catch {
3535
+ return text;
3536
+ }
3537
+ }
3538
+
3539
+ document.addEventListener("click", (event) => {
3540
+ const button = event.target.closest("[data-terminate-deploy-id]");
3541
+ if (!button) {
3542
+ return;
3543
+ }
3544
+ openDialog(button);
3545
+ });
3546
+
3547
+ dialog.addEventListener("click", (event) => {
3548
+ if (event.target === dialog && !confirm.disabled) {
3549
+ closeDialog();
3550
+ }
3551
+ });
3552
+
3553
+ cancel.addEventListener("click", closeDialog);
3554
+ confirm.addEventListener("click", async () => {
3555
+ if (!pendingDeployId) {
3556
+ return;
3557
+ }
3558
+ setBusy(true);
3559
+ error.textContent = "";
3560
+ const response = await fetch("/v1/me/deploys/" + encodeURIComponent(pendingDeployId) + "/terminate", {
3561
+ headers: { "Accept": "application/json" },
3562
+ method: "POST"
3563
+ });
3564
+ if (response.status === 401) {
3565
+ window.location.assign("/auth/github?return_to=" + encodeURIComponent("/deploys"));
3566
+ return;
3567
+ }
3568
+ if (!response.ok) {
3569
+ error.textContent = await errorMessage(response);
3570
+ setBusy(false);
3571
+ return;
3572
+ }
3573
+ window.location.assign("/deploys");
3574
+ });
3575
+ })();
3576
+ </script>`
3577
+ : ""
3578
+ }
3307
3579
  </body>
3308
3580
  </html>`;
3309
3581
  }
@@ -5499,6 +5771,11 @@ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
5499
5771
  clientBundleHash: deploy.clientBundleHash,
5500
5772
  deployId: deploy.id,
5501
5773
  domains,
5774
+ endpoints: Object.entries(artifact.server.endpoints ?? {}).map(([name, endpoint]) => ({
5775
+ method: endpoint.method,
5776
+ name,
5777
+ path: endpoint.path
5778
+ })),
5502
5779
  expiresAt: deploy.expiresAt,
5503
5780
  inspectPolicy: inspectPolicyForDeploy(deploy),
5504
5781
  limits: deploy.limits,
@@ -5516,15 +5793,15 @@ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
5516
5793
 
5517
5794
  function inspectCommandForPath(deploy, systemPath) {
5518
5795
  if (systemPath === "/__lakebed/db") {
5519
- return `lakebed db dump ${deploy.id}`;
5796
+ return `npx lakebed db dump ${deploy.id}`;
5520
5797
  }
5521
5798
  if (systemPath === "/__lakebed/db/tables") {
5522
- return `lakebed db list ${deploy.id}`;
5799
+ return `npx lakebed db list ${deploy.id}`;
5523
5800
  }
5524
5801
  if (systemPath === "/__lakebed/logs") {
5525
- return `lakebed logs ${deploy.id}`;
5802
+ return `npx lakebed logs ${deploy.id}`;
5526
5803
  }
5527
- return `lakebed inspect ${deploy.id}`;
5804
+ return `npx lakebed inspect ${deploy.id}`;
5528
5805
  }
5529
5806
 
5530
5807
  function inspectAuthFailure(deploy, systemPath) {
@@ -5749,6 +6026,15 @@ export async function startAnonymousServer({
5749
6026
  return resolvedStore.listDeploysForOwner(user.id);
5750
6027
  }
5751
6028
 
6029
+ async function developerSummaryForDeploy(deploy) {
6030
+ const artifact = await resolvedStore.getArtifact(deploy.artifactHash);
6031
+ return developerDeploySummary({
6032
+ artifact: artifact?.artifact,
6033
+ deploy,
6034
+ usage: await resolvedStore.readUsage(deploy.id)
6035
+ });
6036
+ }
6037
+
5752
6038
  async function enforceAnonymousDeployCreation(req) {
5753
6039
  if (deployCreationPolicy.disabled) {
5754
6040
  const error = new LakebedQuotaError({
@@ -5923,6 +6209,43 @@ export async function startAnonymousServer({
5923
6209
  return;
5924
6210
  }
5925
6211
 
6212
+ const developerTerminateMatch =
6213
+ req.method === "POST" ? requestUrl.pathname.match(/^\/v1\/me\/deploys\/([^/]+)\/terminate$/) : null;
6214
+ if (developerTerminateMatch) {
6215
+ const user = currentDeveloper(req);
6216
+ if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
6217
+ sendJson(res, 401, { error: "Developer authentication required." });
6218
+ return;
6219
+ }
6220
+
6221
+ const deployId = decodeURIComponent(developerTerminateMatch[1]);
6222
+ const currentDeploy = await resolvedStore.getDeployById(deployId);
6223
+ if (!currentDeploy || currentDeploy.ownerId !== user.id) {
6224
+ sendJson(res, 404, { error: "Unknown deploy." });
6225
+ return;
6226
+ }
6227
+ if (currentDeploy.status !== "active" || isExpired(currentDeploy)) {
6228
+ sendJson(res, 409, { error: "Deploy is not active." });
6229
+ return;
6230
+ }
6231
+
6232
+ const deploy = await resolvedStore.terminateDeploy(deployId);
6233
+ if (!deploy || deploy.ownerId !== user.id) {
6234
+ sendJson(res, 404, { error: "Unknown deploy." });
6235
+ return;
6236
+ }
6237
+
6238
+ await resolvedStore.appendLog(deploy.id, "warn", "anonymous deploy terminated", {
6239
+ ownerId: user.id,
6240
+ ownerLogin: user.login,
6241
+ source: "developer"
6242
+ });
6243
+ await refreshDeploySubscriptions(deploy);
6244
+ closeDeployConnections(deploy.id);
6245
+ sendJson(res, 200, { deploy: await developerSummaryForDeploy(deploy) });
6246
+ return;
6247
+ }
6248
+
5926
6249
  if (req.method === "GET" && requestUrl.pathname === "/auth/github") {
5927
6250
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
5928
6251
  sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
@@ -6414,6 +6737,40 @@ export async function startAnonymousServer({
6414
6737
  return;
6415
6738
  }
6416
6739
 
6740
+ const endpoint = findArtifactEndpoint(loaded.artifact, req.method, appPath);
6741
+ if (endpoint) {
6742
+ await enforceClientTrafficQuota(clientKey, "mutations");
6743
+ await resolvedStore.incrementQuota(
6744
+ loaded.deploy.id,
6745
+ "mutations",
6746
+ quotaLimitForBucket("mutations", loaded.deploy)
6747
+ );
6748
+ const auth = await resolveAuthFromUrl({
6749
+ defaultAuth: createGuestAuth("local"),
6750
+ onError: (error) =>
6751
+ resolvedStore.appendLog(loaded.deploy.id, "warn", "endpoint auth verification failed", {
6752
+ error: error instanceof Error ? error.message : String(error)
6753
+ }),
6754
+ origin: requestOrigin(req, loaded.deploy.url),
6755
+ shooBaseUrl,
6756
+ url: requestUrl
6757
+ });
6758
+ const body = await readRequestBody(req);
6759
+ const response = await executeAnonymousEndpoint({
6760
+ artifact: loaded.artifact,
6761
+ auth,
6762
+ deployId: loaded.deploy.id,
6763
+ limits: loaded.deploy.limits,
6764
+ name: endpoint.name,
6765
+ request: endpointRequestPayload({ appPath, body, req, requestUrl }),
6766
+ sourceRuntime: resolvedSourceRuntime,
6767
+ state: resolvedStore
6768
+ });
6769
+ sendEndpointResponse(res, response);
6770
+ await publishDeploy(loaded.deploy.id);
6771
+ return;
6772
+ }
6773
+
6417
6774
  sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
6418
6775
  } catch (error) {
6419
6776
  if (isQuotaError(error)) {