lakebed 0.0.16 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,14 +122,14 @@ lakebed new [name] [--template todo] [--no-git]
122
122
  lakebed create [name] [--template todo] [--no-git]
123
123
  lakebed dev [capsule-dir] [--port 3000]
124
124
  lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
125
- lakebed deploy [capsule-dir] [--api <url>] [--json]
125
+ lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
126
126
  lakebed claim [capsule-dir] [--api <url>] [--json]
127
127
  lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
128
128
  lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
129
- lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
130
- lakebed db list [deploy-id-or-url] [--port 3000]
131
- lakebed db dump [deploy-id-or-url] [--port 3000]
132
- lakebed logs [deploy-id-or-url] [--port 3000]
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>]
133
133
  ```
134
134
 
135
135
  ## Current Constraints
@@ -199,6 +199,8 @@ LAKEBED_SERVER_ENV_SECRET=...
199
199
 
200
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.
201
201
 
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.
203
+
202
204
  After a deploy is claimed, reserve a Lakebed-owned app subdomain from the capsule directory:
203
205
 
204
206
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -7,6 +7,7 @@ import {
7
7
  createDeployId,
8
8
  createSlug,
9
9
  DEFAULT_ANONYMOUS_LIMITS,
10
+ LAKEBED_VERSION,
10
11
  executeAnonymousMutation,
11
12
  executeAnonymousQuery,
12
13
  hashClaimToken,
@@ -151,6 +152,79 @@ function isDeployTokenValid(deploy, token) {
151
152
  return Boolean(token && deploy?.claimTokenHash && hashClaimToken(token) === deploy.claimTokenHash);
152
153
  }
153
154
 
155
+ const inspectPolicies = new Set(["private", "redacted", "public"]);
156
+
157
+ function normalizeInspectPolicy(value, fallback = "private") {
158
+ if (value === undefined || value === null || value === "") {
159
+ return fallback;
160
+ }
161
+
162
+ const policy = String(value).trim().toLowerCase();
163
+ if (!inspectPolicies.has(policy)) {
164
+ throw new Error("inspectPolicy must be private, redacted, or public.");
165
+ }
166
+ return policy;
167
+ }
168
+
169
+ function inspectPolicyForDeploy(deploy) {
170
+ return normalizeInspectPolicy(deploy?.inspectPolicy, "private");
171
+ }
172
+
173
+ const sensitiveKeyPattern =
174
+ /(^|[_-])(authorization|bearer|cookie|jwt|password|secret|session|token|api[_-]?key|access[_-]?key|private[_-]?key|refresh[_-]?token)([_-]|$)/i;
175
+ const secretValuePatterns = [
176
+ /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi,
177
+ /\b(sk|pk|rk|ghp|gho|github_pat)_[A-Za-z0-9_]{12,}\b/g,
178
+ /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g
179
+ ];
180
+
181
+ function redactSensitiveString(value) {
182
+ return secretValuePatterns.reduce((text, pattern) => text.replace(pattern, "[redacted]"), String(value));
183
+ }
184
+
185
+ function isSensitiveKey(key) {
186
+ const normalized = String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2");
187
+ return sensitiveKeyPattern.test(normalized);
188
+ }
189
+
190
+ function redactInspectValue(value, seen = new WeakSet()) {
191
+ if (typeof value === "string") {
192
+ return redactSensitiveString(value);
193
+ }
194
+
195
+ if (!value || typeof value !== "object") {
196
+ return value;
197
+ }
198
+
199
+ if (seen.has(value)) {
200
+ return "[redacted circular]";
201
+ }
202
+ seen.add(value);
203
+
204
+ if (Array.isArray(value)) {
205
+ return value.map((entry) => redactInspectValue(entry, seen));
206
+ }
207
+
208
+ return Object.fromEntries(
209
+ Object.entries(value).map(([key, entryValue]) => [
210
+ key,
211
+ isSensitiveKey(key) ? "[redacted]" : redactInspectValue(entryValue, seen)
212
+ ])
213
+ );
214
+ }
215
+
216
+ function redactLogEntry(entry) {
217
+ return {
218
+ ...entry,
219
+ data: redactInspectValue(entry.data),
220
+ message: redactSensitiveString(entry.message)
221
+ };
222
+ }
223
+
224
+ function redactLogData(data) {
225
+ return redactInspectValue(data);
226
+ }
227
+
154
228
  function normalizePublicRootUrl(value, port) {
155
229
  const fallback = `http://localhost:${port}`;
156
230
  return String(value || fallback).replace(/\/+$/g, "");
@@ -296,6 +370,7 @@ function responseForDeploy({ deploy, token }) {
296
370
  deployId: deploy.id,
297
371
  expiresAt: deploy.expiresAt,
298
372
  inspect: inspectUrls(deploy.url),
373
+ inspectPolicy: inspectPolicyForDeploy(deploy),
299
374
  limits: deploy.limits,
300
375
  updatedAt: deploy.updatedAt,
301
376
  url: deploy.url
@@ -698,7 +773,7 @@ function isSecureRequest(req) {
698
773
  }
699
774
 
700
775
  function adminCookie(value, maxAge = adminCookieMaxAgeSeconds, secure = false) {
701
- return `${adminCookieName}=${value}; HttpOnly; SameSite=Lax; Path=/admin; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
776
+ return `${adminCookieName}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
702
777
  }
703
778
 
704
779
  function cookie(name, value, { httpOnly = true, maxAge, path = "/", sameSite = "Lax", secure = false } = {}) {
@@ -1130,6 +1205,7 @@ function adminDeploySummary({ artifact, artifactBytes = 0, deploy, logBytes = 0,
1130
1205
  createdAt: deploy.createdAt,
1131
1206
  expiresAt: deploy.expiresAt,
1132
1207
  id: deploy.id,
1208
+ inspectPolicy: inspectPolicyForDeploy(deploy),
1133
1209
  limits: deploy.limits,
1134
1210
  logBytes,
1135
1211
  logEntries,
@@ -2777,6 +2853,7 @@ function developerDeploySummary({ artifact, deploy, usage }) {
2777
2853
  deployId: deploy.id,
2778
2854
  expiresAt: deploy.expiresAt,
2779
2855
  inspect: inspectUrls(deploy.url),
2856
+ inspectPolicy: inspectPolicyForDeploy(deploy),
2780
2857
  limits: deploy.limits,
2781
2858
  name: artifact?.name ?? "Lakebed Capsule",
2782
2859
  ownerId: deploy.ownerId,
@@ -3389,7 +3466,7 @@ export class MemoryAnonymousStore {
3389
3466
  return false;
3390
3467
  }
3391
3468
 
3392
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
3469
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, inspectPolicy, publicRootUrl, serverEnv }) {
3393
3470
  const deployId = createDeployId();
3394
3471
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
3395
3472
  let slug = createSlug();
@@ -3422,6 +3499,7 @@ export class MemoryAnonymousStore {
3422
3499
  createdAt,
3423
3500
  expiresAt,
3424
3501
  id: deployId,
3502
+ inspectPolicy: normalizeInspectPolicy(inspectPolicy),
3425
3503
  limits: { ...DEFAULT_ANONYMOUS_LIMITS },
3426
3504
  owner: null,
3427
3505
  ownerId: null,
@@ -3446,6 +3524,7 @@ export class MemoryAnonymousStore {
3446
3524
  clientBundleBase64,
3447
3525
  clientBundleHash,
3448
3526
  deployId,
3527
+ inspectPolicy,
3449
3528
  publicRootUrl,
3450
3529
  serverEnv
3451
3530
  }) {
@@ -3463,6 +3542,7 @@ export class MemoryAnonymousStore {
3463
3542
  artifactHash,
3464
3543
  clientBundleHash,
3465
3544
  expiresAt: currentDeploy.ownerId ? null : anonymousDeployExpiresAt(),
3545
+ inspectPolicy: inspectPolicy === undefined ? inspectPolicyForDeploy(currentDeploy) : normalizeInspectPolicy(inspectPolicy),
3466
3546
  publicRootUrl: nextPublicRootUrl,
3467
3547
  url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
3468
3548
  status: "active",
@@ -3692,7 +3772,7 @@ export class MemoryAnonymousStore {
3692
3772
  }
3693
3773
 
3694
3774
  async appendLog(deployId, level, message, data) {
3695
- const entries = [...(this.logs.get(deployId) ?? []), normalizeLogEntry(level, message, data)];
3775
+ const entries = [...(this.logs.get(deployId) ?? []), redactLogEntry(normalizeLogEntry(level, message, data))];
3696
3776
  const maxEntries = DEFAULT_ANONYMOUS_LIMITS.logEntries;
3697
3777
  const maxBytes = DEFAULT_ANONYMOUS_LIMITS.logBytes;
3698
3778
  while (entries.length > maxEntries || bytesOfJson(entries) > maxBytes) {
@@ -3702,7 +3782,7 @@ export class MemoryAnonymousStore {
3702
3782
  }
3703
3783
 
3704
3784
  async readLogs(deployId, limit = 100) {
3705
- return (this.logs.get(deployId) ?? []).slice(-limit);
3785
+ return (this.logs.get(deployId) ?? []).slice(-limit).map(redactLogEntry);
3706
3786
  }
3707
3787
 
3708
3788
  async tableCounts(deployId, schema) {
@@ -4061,6 +4141,7 @@ export class PostgresAnonymousStore {
4061
4141
  owner_id text,
4062
4142
  owner_json jsonb,
4063
4143
  claim_token_hash text not null,
4144
+ inspect_policy text not null default 'private',
4064
4145
  limits_json jsonb not null,
4065
4146
  counters_json jsonb not null default '{}',
4066
4147
  public_root_url text not null,
@@ -4071,6 +4152,7 @@ export class PostgresAnonymousStore {
4071
4152
  await this.query("alter table deploys add column if not exists claimed_at timestamptz");
4072
4153
  await this.query("alter table deploys add column if not exists owner_id text");
4073
4154
  await this.query("alter table deploys add column if not exists owner_json jsonb");
4155
+ await this.query("alter table deploys add column if not exists inspect_policy text not null default 'private'");
4074
4156
  await this.query("alter table deploys add column if not exists updated_at timestamptz");
4075
4157
  await this.query("update deploys set updated_at = created_at where updated_at is null");
4076
4158
  await this.query("alter table deploys alter column updated_at set not null");
@@ -4491,10 +4573,11 @@ export class PostgresAnonymousStore {
4491
4573
  return true;
4492
4574
  }
4493
4575
 
4494
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
4576
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, inspectPolicy, publicRootUrl, serverEnv }) {
4495
4577
  const createdAt = now();
4496
4578
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
4497
4579
  const expiresAt = anonymousDeployExpiresAt();
4580
+ const normalizedInspectPolicy = normalizeInspectPolicy(inspectPolicy);
4498
4581
  const token = createClaimToken();
4499
4582
  const deployId = createDeployId();
4500
4583
 
@@ -4514,6 +4597,7 @@ export class PostgresAnonymousStore {
4514
4597
  createdAt,
4515
4598
  expiresAt,
4516
4599
  id: deployId,
4600
+ inspectPolicy: normalizedInspectPolicy,
4517
4601
  limits: { ...DEFAULT_ANONYMOUS_LIMITS },
4518
4602
  owner: null,
4519
4603
  ownerId: null,
@@ -4538,9 +4622,9 @@ export class PostgresAnonymousStore {
4538
4622
  `
4539
4623
  insert into deploys(
4540
4624
  id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
4541
- claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
4625
+ claim_token_hash, inspect_policy, limits_json, counters_json, public_root_url, app_base_domain, url
4542
4626
  )
4543
- values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
4627
+ values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, '{}'::jsonb, $12, $13, $14)
4544
4628
  `,
4545
4629
  [
4546
4630
  deploy.id,
@@ -4552,6 +4636,7 @@ export class PostgresAnonymousStore {
4552
4636
  deploy.updatedAt,
4553
4637
  deploy.expiresAt,
4554
4638
  deploy.claimTokenHash,
4639
+ deploy.inspectPolicy,
4555
4640
  JSON.stringify(deploy.limits),
4556
4641
  deploy.publicRootUrl,
4557
4642
  deploy.appBaseDomain,
@@ -4584,6 +4669,7 @@ export class PostgresAnonymousStore {
4584
4669
  clientBundleBase64,
4585
4670
  clientBundleHash,
4586
4671
  deployId,
4672
+ inspectPolicy,
4587
4673
  publicRootUrl,
4588
4674
  serverEnv
4589
4675
  }) {
@@ -4596,6 +4682,7 @@ export class PostgresAnonymousStore {
4596
4682
  const expiresAt = currentDeploy.ownerId ? null : anonymousDeployExpiresAt();
4597
4683
  const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
4598
4684
  const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
4685
+ const nextInspectPolicy = inspectPolicy === undefined ? inspectPolicyForDeploy(currentDeploy) : normalizeInspectPolicy(inspectPolicy);
4599
4686
  const url = appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug });
4600
4687
  await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt: updatedAt });
4601
4688
 
@@ -4609,11 +4696,12 @@ export class PostgresAnonymousStore {
4609
4696
  public_root_url = $5,
4610
4697
  app_base_domain = $6,
4611
4698
  url = $7,
4612
- updated_at = $8
4699
+ inspect_policy = $8,
4700
+ updated_at = $9
4613
4701
  where id = $1
4614
4702
  returning *
4615
4703
  `,
4616
- [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url, updatedAt]
4704
+ [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url, nextInspectPolicy, updatedAt]
4617
4705
  );
4618
4706
  if (serverEnv !== undefined) {
4619
4707
  await this.replaceServerEnv(deployId, serverEnv, updatedAt);
@@ -4687,6 +4775,7 @@ export class PostgresAnonymousStore {
4687
4775
  createdAt: new Date(row.created_at).toISOString(),
4688
4776
  expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
4689
4777
  id: row.id,
4778
+ inspectPolicy: normalizeInspectPolicy(row.inspect_policy),
4690
4779
  limits: { ...DEFAULT_ANONYMOUS_LIMITS, ...(row.limits_json ?? {}) },
4691
4780
  owner: row.owner_json ?? null,
4692
4781
  ownerId: row.owner_id ?? null,
@@ -4931,7 +5020,7 @@ export class PostgresAnonymousStore {
4931
5020
  }
4932
5021
 
4933
5022
  async appendLog(deployId, level, message, data) {
4934
- const entry = normalizeLogEntry(level, message, data);
5023
+ const entry = redactLogEntry(normalizeLogEntry(level, message, data));
4935
5024
  await this.query(
4936
5025
  "insert into logs(deploy_id, level, message, data_json, created_at) values($1, $2, $3, $4::jsonb, $5)",
4937
5026
  [deployId, entry.level, entry.message, JSON.stringify(entry.data ?? null), entry.at]
@@ -4962,7 +5051,7 @@ export class PostgresAnonymousStore {
4962
5051
  "select level, message, data_json, created_at from logs where deploy_id = $1 order by sequence desc limit $2",
4963
5052
  [deployId, limit]
4964
5053
  );
4965
- return result.rows.reverse().map((row) => ({
5054
+ return result.rows.reverse().map((row) => redactLogEntry({
4966
5055
  at: new Date(row.created_at).toISOString(),
4967
5056
  data: row.data_json,
4968
5057
  level: row.level,
@@ -5369,49 +5458,164 @@ async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
5369
5458
  return { artifact: storedArtifact.artifact, basePath: route.basePath, deploy, route, storedArtifact };
5370
5459
  }
5371
5460
 
5372
- async function serveInspect({ artifact, deploy, route, store, systemPath }, res) {
5461
+ function mutationInspectDetails(artifact) {
5462
+ return Object.entries(artifact.server?.mutations ?? {}).map(([name, mutation]) => {
5463
+ if (mutation?.op === "source") {
5464
+ return { guards: [], mode: "source-backed", name };
5465
+ }
5466
+
5467
+ const guards = [];
5468
+ for (const operation of mutation?.body ?? []) {
5469
+ for (const guard of operation.guards ?? []) {
5470
+ guards.push({
5471
+ equalsAuth: guard.equalsAuth,
5472
+ field: guard.field,
5473
+ operation: operation.op,
5474
+ table: operation.table
5475
+ });
5476
+ }
5477
+ }
5478
+
5479
+ return {
5480
+ guards,
5481
+ mode: guards.length > 0 ? "guarded-ir" : "interpreted-ir",
5482
+ name
5483
+ };
5484
+ });
5485
+ }
5486
+
5487
+ function publicManifestForDeploy({ artifact, deploy }) {
5488
+ return {
5489
+ clientBundleHash: deploy.clientBundleHash,
5490
+ deployId: deploy.id,
5491
+ name: artifact.name ?? "Lakebed Capsule",
5492
+ runtimeVersion: LAKEBED_VERSION
5493
+ };
5494
+ }
5495
+
5496
+ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
5497
+ return {
5498
+ artifactHash: deploy.artifactHash,
5499
+ clientBundleHash: deploy.clientBundleHash,
5500
+ deployId: deploy.id,
5501
+ domains,
5502
+ expiresAt: deploy.expiresAt,
5503
+ inspectPolicy: inspectPolicyForDeploy(deploy),
5504
+ limits: deploy.limits,
5505
+ mutationDetails: mutationInspectDetails(artifact),
5506
+ mutations: Object.keys(artifact.server.mutations ?? {}),
5507
+ name: artifact.name ?? "Lakebed Capsule",
5508
+ queries: Object.keys(artifact.server.queries ?? {}),
5509
+ runtimeVersion: LAKEBED_VERSION,
5510
+ schema: artifact.server.schema,
5511
+ slug: deploy.slug,
5512
+ updatedAt: deploy.updatedAt,
5513
+ url: deploy.url
5514
+ };
5515
+ }
5516
+
5517
+ function inspectCommandForPath(deploy, systemPath) {
5518
+ if (systemPath === "/__lakebed/db") {
5519
+ return `lakebed db dump ${deploy.id}`;
5520
+ }
5521
+ if (systemPath === "/__lakebed/db/tables") {
5522
+ return `lakebed db list ${deploy.id}`;
5523
+ }
5524
+ if (systemPath === "/__lakebed/logs") {
5525
+ return `lakebed logs ${deploy.id}`;
5526
+ }
5527
+ return `lakebed inspect ${deploy.id}`;
5528
+ }
5529
+
5530
+ function inspectAuthFailure(deploy, systemPath) {
5531
+ return {
5532
+ command: inspectCommandForPath(deploy, systemPath),
5533
+ error: "Lakebed hosted inspection requires authorization.",
5534
+ hint: "Run this command from the capsule directory so Lakebed can read .lakebed/deploy.json, or send Authorization: Bearer <claim-token>.",
5535
+ inspectPolicy: inspectPolicyForDeploy(deploy),
5536
+ path: systemPath
5537
+ };
5538
+ }
5539
+
5540
+ function inspectAuthorized({ adminPassword, currentDeveloper, deploy, req }) {
5541
+ if (inspectPolicyForDeploy(deploy) === "public") {
5542
+ return true;
5543
+ }
5544
+
5545
+ if (isDeployTokenValid(deploy, bearerToken(req))) {
5546
+ return true;
5547
+ }
5548
+
5549
+ if (isAdminAuthenticated(req, adminPassword)) {
5550
+ return true;
5551
+ }
5552
+
5553
+ const user = currentDeveloper(req);
5554
+ return Boolean(user?.id && deploy.ownerId && user.id === deploy.ownerId);
5555
+ }
5556
+
5557
+ async function serveInspect({ adminPassword, artifact, currentDeveloper, deploy, req, route, store, systemPath }, res) {
5558
+ const policy = inspectPolicyForDeploy(deploy);
5559
+ const authorized = inspectAuthorized({ adminPassword, currentDeveloper, deploy, req });
5560
+
5373
5561
  if (systemPath === "/__lakebed/manifest") {
5374
- sendJson(res, 200, {
5375
- artifactHash: deploy.artifactHash,
5376
- clientBundleHash: deploy.clientBundleHash,
5377
- deployId: deploy.id,
5378
- domains:
5379
- typeof store.listDeployDomainsForDeploy === "function"
5380
- ? (await store.listDeployDomainsForDeploy(deploy.id)).map(responseForDeployDomain)
5381
- : [],
5382
- expiresAt: deploy.expiresAt,
5383
- limits: deploy.limits,
5384
- mutations: Object.keys(artifact.server.mutations ?? {}),
5385
- name: artifact.name ?? "Lakebed Capsule",
5386
- queries: Object.keys(artifact.server.queries ?? {}),
5387
- schema: artifact.server.schema,
5388
- slug: deploy.slug,
5389
- updatedAt: deploy.updatedAt,
5390
- url: deploy.url
5391
- });
5562
+ if (!authorized && policy === "private") {
5563
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5564
+ return true;
5565
+ }
5566
+
5567
+ const domains =
5568
+ typeof store.listDeployDomainsForDeploy === "function"
5569
+ ? (await store.listDeployDomainsForDeploy(deploy.id)).map(responseForDeployDomain)
5570
+ : [];
5571
+ sendJson(res, 200, authorized ? fullManifestForDeploy({ artifact, deploy, domains }) : publicManifestForDeploy({ artifact, deploy }));
5392
5572
  return true;
5393
5573
  }
5394
5574
 
5395
5575
  if (systemPath === "/__lakebed/db/tables") {
5396
5576
  const counts = await store.tableCounts(deploy.id, artifact.server.schema);
5397
- sendJson(res, 200, {
5398
- tables: Object.keys(artifact.server.schema ?? {}),
5399
- counts
5400
- });
5577
+ if (!authorized && policy !== "redacted") {
5578
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5579
+ return true;
5580
+ }
5581
+
5582
+ sendJson(res, 200, authorized ? { tables: Object.keys(artifact.server.schema ?? {}), counts } : { counts, redacted: true });
5401
5583
  return true;
5402
5584
  }
5403
5585
 
5404
5586
  if (systemPath === "/__lakebed/db") {
5587
+ if (!authorized && policy !== "redacted") {
5588
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5589
+ return true;
5590
+ }
5591
+
5592
+ if (policy === "redacted" && !authorized) {
5593
+ const counts = await store.tableCounts(deploy.id, artifact.server.schema);
5594
+ sendJson(res, 200, { counts, redacted: true });
5595
+ return true;
5596
+ }
5597
+
5405
5598
  sendJson(res, 200, await store.dumpState(deploy.id, artifact.server.schema, deploy.limits.rowsReturned));
5406
5599
  return true;
5407
5600
  }
5408
5601
 
5409
5602
  if (systemPath === "/__lakebed/logs") {
5410
- sendJson(res, 200, await store.readLogs(deploy.id, 100));
5603
+ if (!authorized && policy !== "redacted") {
5604
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5605
+ return true;
5606
+ }
5607
+
5608
+ const logs = await store.readLogs(deploy.id, 100);
5609
+ sendJson(res, 200, policy === "redacted" && !authorized ? logs.map(redactLogEntry) : logs);
5411
5610
  return true;
5412
5611
  }
5413
5612
 
5414
5613
  if (systemPath === "/__lakebed/usage") {
5614
+ if (!authorized && policy !== "redacted") {
5615
+ sendJson(res, 401, inspectAuthFailure(deploy, systemPath));
5616
+ return true;
5617
+ }
5618
+
5415
5619
  sendJson(res, 200, {
5416
5620
  limits: deploy.limits,
5417
5621
  usage: await store.readUsage(deploy.id)
@@ -6073,6 +6277,13 @@ export async function startAnonymousServer({
6073
6277
  await enforceAnonymousDeployCreation(req);
6074
6278
  const body = await readJsonBody(req);
6075
6279
  const payload = validateAnonymousDeployPayload(body);
6280
+ let inspectPolicy;
6281
+ try {
6282
+ inspectPolicy = normalizeInspectPolicy(body.inspectPolicy);
6283
+ } catch (error) {
6284
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
6285
+ return;
6286
+ }
6076
6287
  if (payload.serverEnv !== undefined && Object.keys(payload.serverEnv).length > 0) {
6077
6288
  sendJson(res, 400, { error: "Server env requires a claimed deploy." });
6078
6289
  return;
@@ -6083,6 +6294,7 @@ export async function startAnonymousServer({
6083
6294
  artifactHash: payload.artifactHash,
6084
6295
  clientBundleBase64: payload.clientBundleBase64,
6085
6296
  clientBundleHash: payload.clientBundleHash,
6297
+ inspectPolicy,
6086
6298
  publicRootUrl: resolvedPublicRootUrl,
6087
6299
  serverEnv: payload.serverEnv
6088
6300
  });
@@ -6106,6 +6318,13 @@ export async function startAnonymousServer({
6106
6318
 
6107
6319
  const body = await readJsonBody(req);
6108
6320
  const payload = validateAnonymousDeployPayload(body, { allowClaimedSource: Boolean(currentDeploy.ownerId) });
6321
+ let inspectPolicy;
6322
+ try {
6323
+ inspectPolicy = Object.hasOwn(body, "inspectPolicy") ? normalizeInspectPolicy(body.inspectPolicy) : undefined;
6324
+ } catch (error) {
6325
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
6326
+ return;
6327
+ }
6109
6328
  if (payload.serverEnv !== undefined && !currentDeploy.ownerId) {
6110
6329
  sendJson(res, 400, { error: "Server env requires a claimed deploy." });
6111
6330
  return;
@@ -6117,6 +6336,7 @@ export async function startAnonymousServer({
6117
6336
  clientBundleBase64: payload.clientBundleBase64,
6118
6337
  clientBundleHash: payload.clientBundleHash,
6119
6338
  deployId,
6339
+ inspectPolicy,
6120
6340
  publicRootUrl: resolvedPublicRootUrl,
6121
6341
  serverEnv: payload.serverEnv
6122
6342
  });
@@ -6180,7 +6400,17 @@ export async function startAnonymousServer({
6180
6400
  return;
6181
6401
  }
6182
6402
 
6183
- if (req.method === "GET" && (await serveInspect({ ...loaded, store: resolvedStore, systemPath: appPath }, res))) {
6403
+ if (
6404
+ req.method === "GET" &&
6405
+ (await serveInspect({
6406
+ ...loaded,
6407
+ adminPassword,
6408
+ currentDeveloper,
6409
+ req,
6410
+ store: resolvedStore,
6411
+ systemPath: appPath
6412
+ }, res))
6413
+ ) {
6184
6414
  return;
6185
6415
  }
6186
6416
 
package/src/anonymous.js CHANGED
@@ -420,13 +420,13 @@ async function readSourceFiles(sourceStore) {
420
420
  return files.sort((left, right) => left.path.localeCompare(right.path));
421
421
  }
422
422
 
423
- function forbiddenSourceDiagnostics(files) {
423
+ function forbiddenSourceDiagnostics(files, { allowAsync = false } = {}) {
424
424
  const checks = [
425
425
  [/\beval\s*\(/, "eval is not available in anonymous server code."],
426
426
  [/\bFunction\s*\(/, "Function constructors are not available in anonymous server code."],
427
427
  [/\bimport\s*\(/, "Dynamic import is not available in anonymous server code."],
428
- [/\bfetch\s*\(/, "Outbound fetch is disabled for anonymous deploys."],
429
- [/\basync\b/, "Async server handlers are not part of the anonymous IR yet. Use synchronous Lakebed database operations."],
428
+ [/\bfetch\b/, "Outbound fetch is disabled for anonymous deploys."],
429
+ ...(allowAsync ? [] : [[/\basync\b/, "Async server handlers are not part of the anonymous IR yet. Use synchronous Lakebed database operations."]]),
430
430
  [/\bwhile\s*\(/, "while loops are not available in anonymous server code."],
431
431
  [/\bfor\s*\(\s*;/, "Unbounded for loops are not available in anonymous server code."],
432
432
  [/\bprocess\b/, "process is not available in anonymous server code."],
@@ -486,18 +486,6 @@ function serializeSchema(schema) {
486
486
  return { diagnostics, schema: cleanSchema };
487
487
  }
488
488
 
489
- function inferMutationGuards(handler) {
490
- const source = Function.prototype.toString.call(handler);
491
- const guards = [];
492
- if (source.includes("ownerId") && source.includes("auth.userId")) {
493
- guards.push({ field: "ownerId", equalsAuth: "userId", op: "rowFieldEqualsAuth" });
494
- }
495
- if (source.includes("authorId") && source.includes("auth.userId")) {
496
- guards.push({ field: "authorId", equalsAuth: "userId", op: "rowFieldEqualsAuth" });
497
- }
498
- return guards;
499
- }
500
-
501
489
  function compileQueryHandler({ handler, name, schema }) {
502
490
  const { ctx, recorder } = createTraceContext({ mode: "query", schema });
503
491
  try {
@@ -513,6 +501,40 @@ function compileQueryHandler({ handler, name, schema }) {
513
501
  return recorder.query;
514
502
  }
515
503
 
504
+ function isArgExpression(expr) {
505
+ return Array.isArray(expr) && expr[0] === "arg" && Number.isInteger(expr[1]);
506
+ }
507
+
508
+ function expressionContainsOp(expr, targetOp) {
509
+ if (!isExpression(expr)) {
510
+ if (Array.isArray(expr)) {
511
+ return expr.some((item) => expressionContainsOp(item, targetOp));
512
+ }
513
+ if (isPlainObject(expr)) {
514
+ return Object.values(expr).some((value) => expressionContainsOp(value, targetOp));
515
+ }
516
+ return false;
517
+ }
518
+
519
+ if (expr[0] === targetOp) {
520
+ return true;
521
+ }
522
+
523
+ return expr.slice(1).some((item) => expressionContainsOp(item, targetOp));
524
+ }
525
+
526
+ function expressionContainsArg(expr) {
527
+ return isArgExpression(expr) || expressionContainsOp(expr, "arg");
528
+ }
529
+
530
+ function expressionContainsRow(expr) {
531
+ return expressionContainsOp(expr, "row");
532
+ }
533
+
534
+ function directWriteGuardDiagnostic(name, operation) {
535
+ return `Unable to compile mutation "${name}" to anonymous IR: Direct ${operation.op}(id) from an argument-derived value cannot be proven safe by the IR compiler. Use the anonymous source runtime, claim the deploy, or rewrite this mutation to an owner-filtered query shape.`;
536
+ }
537
+
516
538
  function compileMutationHandler({ handler, name, schema }) {
517
539
  const { ctx, recorder } = createTraceContext({ mode: "mutation", schema });
518
540
  const args = Array.from({ length: Math.max(0, handler.length - 1) }, (_, index) => createSymbolicArg(index));
@@ -523,7 +545,6 @@ function compileMutationHandler({ handler, name, schema }) {
523
545
  throw new Error(`Unable to compile mutation "${name}" to anonymous IR: ${error instanceof Error ? error.message : String(error)}`);
524
546
  }
525
547
 
526
- const guards = inferMutationGuards(handler);
527
548
  const operations = [];
528
549
 
529
550
  for (const operation of recorder.operations) {
@@ -540,8 +561,14 @@ function compileMutationHandler({ handler, name, schema }) {
540
561
  continue;
541
562
  }
542
563
 
543
- if (operation.op === "update") {
544
- operations.push({ ...operation, guards });
564
+ if (operation.op === "update" || operation.op === "delete") {
565
+ if (expressionContainsArg(operation.id)) {
566
+ throw new Error(directWriteGuardDiagnostic(name, operation));
567
+ }
568
+ if (expressionContainsRow(operation.id)) {
569
+ throw new Error(`Unable to compile mutation "${name}" to anonymous IR: ${operation.op} uses an unsupported symbolic row id.`);
570
+ }
571
+ operations.push(operation);
545
572
  continue;
546
573
  }
547
574
 
@@ -583,9 +610,9 @@ function compileServerToIr(app, schema) {
583
610
  return { diagnostics, mutations, queries };
584
611
  }
585
612
 
586
- export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = LAKEBED_VERSION }) {
613
+ export async function createAnonymousArtifact({ app, clientOut, serverOut, sourceStore, version = LAKEBED_VERSION }) {
587
614
  const sourceFiles = await readSourceFiles(sourceStore);
588
- const diagnostics = forbiddenSourceDiagnostics(sourceFiles);
615
+ const diagnostics = forbiddenSourceDiagnostics(sourceFiles, { allowAsync: Boolean(serverOut) });
589
616
  const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
590
617
  diagnostics.push(...schemaDiagnostics);
591
618
 
@@ -593,18 +620,40 @@ export async function createAnonymousArtifact({ app, clientOut, sourceStore, ver
593
620
  throw new AnonymousCompilerError(diagnostics);
594
621
  }
595
622
 
596
- const compiled = compileServerToIr(app, schema);
597
- diagnostics.push(...compiled.diagnostics);
598
-
599
- if (diagnostics.length > 0) {
600
- throw new AnonymousCompilerError(diagnostics);
601
- }
602
-
603
623
  const clientBundle = await readFile(clientOut);
604
624
  const clientBundleBase64 = clientBundle.toString("base64");
605
625
  const clientBundleHash = sha256(clientBundle);
626
+ const serverBundle = serverOut ? await readFile(serverOut) : null;
627
+ const serverBundleBase64 = serverBundle?.toString("base64");
628
+ const serverBundleHash = serverBundle ? sha256(serverBundle) : null;
606
629
  const sourceManifest = sourceFiles.map(({ bytes, hash, path }) => ({ bytes, hash, path }));
607
630
  const sourceSnapshotHash = sha256(stableStringify(sourceManifest));
631
+ const server = serverBundle
632
+ ? {
633
+ helpers: {},
634
+ imports: ["lakebed/server"],
635
+ mutations: Object.fromEntries(Object.keys(app.mutations ?? {}).map((name) => [name, { op: "source" }])),
636
+ queries: Object.fromEntries(Object.keys(app.queries ?? {}).map((name) => [name, { op: "source" }])),
637
+ schema,
638
+ source: {
639
+ bytes: serverBundle.byteLength,
640
+ bundle: serverBundleBase64,
641
+ bundleHash: serverBundleHash,
642
+ entry: "/server.mjs"
643
+ }
644
+ }
645
+ : null;
646
+ let compiled = null;
647
+
648
+ if (!server) {
649
+ compiled = compileServerToIr(app, schema);
650
+ diagnostics.push(...compiled.diagnostics);
651
+
652
+ if (diagnostics.length > 0) {
653
+ throw new AnonymousCompilerError(diagnostics);
654
+ }
655
+ }
656
+
608
657
  const artifact = {
609
658
  name: app.name ?? "Lakebed Capsule",
610
659
  client: {
@@ -616,14 +665,14 @@ export async function createAnonymousArtifact({ app, clientOut, sourceStore, ver
616
665
  compiler: "0.1.0",
617
666
  lakebed: version
618
667
  },
619
- deployTarget: "anonymous-interpreter",
668
+ deployTarget: server ? "anonymous-source" : "anonymous-interpreter",
620
669
  format: ANONYMOUS_ARTIFACT_FORMAT,
621
670
  limits: {
622
671
  instructionBudget: DEFAULT_ANONYMOUS_LIMITS.instructionBudget,
623
672
  maxRowsReturned: DEFAULT_ANONYMOUS_LIMITS.rowsReturned,
624
673
  maxValueBytes: DEFAULT_ANONYMOUS_LIMITS.maxValueBytes
625
674
  },
626
- server: {
675
+ server: server ?? {
627
676
  helpers: {},
628
677
  imports: ["lakebed/server"],
629
678
  mutations: compiled.mutations,
@@ -801,6 +850,31 @@ function validateQuery(query, path, schema, diagnostics) {
801
850
  }
802
851
  }
803
852
 
853
+ function validateGuards(guards, path, schema, tableName, diagnostics) {
854
+ if (guards === undefined) {
855
+ return;
856
+ }
857
+
858
+ if (!Array.isArray(guards)) {
859
+ diagnostics.push(diagnostic(path, "Mutation guards must be an array."));
860
+ return;
861
+ }
862
+
863
+ for (const [index, guard] of guards.entries()) {
864
+ const guardPath = `${path}.guards.${index}`;
865
+ if (!isPlainObject(guard) || guard.op !== "rowFieldEqualsAuth") {
866
+ diagnostics.push(diagnostic(guardPath, "Unsupported mutation guard."));
867
+ continue;
868
+ }
869
+ if (typeof guard.field !== "string" || !schema?.[tableName]?.fields?.[guard.field]) {
870
+ diagnostics.push(diagnostic(guardPath, "Mutation guard field must exist in the table schema."));
871
+ }
872
+ if (guard.equalsAuth !== "userId") {
873
+ diagnostics.push(diagnostic(guardPath, "Mutation guard auth field must be userId."));
874
+ }
875
+ }
876
+ }
877
+
804
878
  export function validateAnonymousArtifact(artifact, { allowClaimedSource = false } = {}) {
805
879
  const diagnostics = [];
806
880
 
@@ -812,8 +886,13 @@ export function validateAnonymousArtifact(artifact, { allowClaimedSource = false
812
886
  diagnostics.push(diagnostic("artifact.format", `Expected ${ANONYMOUS_ARTIFACT_FORMAT}.`));
813
887
  }
814
888
 
815
- if (artifact.deployTarget !== "anonymous-interpreter" && !(allowClaimedSource && artifact.deployTarget === "claimed-source")) {
816
- diagnostics.push(diagnostic("artifact.deployTarget", "Anonymous deploys require deployTarget anonymous-interpreter."));
889
+ const sourceDeployTargets = new Set(["anonymous-source", "claimed-source"]);
890
+ if (
891
+ artifact.deployTarget !== "anonymous-interpreter" &&
892
+ artifact.deployTarget !== "anonymous-source" &&
893
+ !(allowClaimedSource && artifact.deployTarget === "claimed-source")
894
+ ) {
895
+ diagnostics.push(diagnostic("artifact.deployTarget", "Anonymous deploys require deployTarget anonymous-interpreter or anonymous-source."));
817
896
  }
818
897
 
819
898
  const schema = artifact.server?.schema;
@@ -837,14 +916,14 @@ export function validateAnonymousArtifact(artifact, { allowClaimedSource = false
837
916
  if (query?.op === "source" && artifact.server?.source === undefined) {
838
917
  diagnostics.push(diagnostic(`artifact.server.queries.${name}`, "Source query requires artifact.server.source."));
839
918
  }
840
- if (query?.op === "source" && artifact.deployTarget !== "claimed-source") {
841
- diagnostics.push(diagnostic(`artifact.server.queries.${name}`, "Source query requires deployTarget claimed-source."));
919
+ if (query?.op === "source" && !sourceDeployTargets.has(artifact.deployTarget)) {
920
+ diagnostics.push(diagnostic(`artifact.server.queries.${name}`, "Source query requires deployTarget anonymous-source or claimed-source."));
842
921
  }
843
922
  }
844
923
 
845
924
  if (artifact.server?.source !== undefined) {
846
- if (artifact.deployTarget !== "claimed-source") {
847
- diagnostics.push(diagnostic("artifact.server.source", "Server source bundles require deployTarget claimed-source."));
925
+ if (!sourceDeployTargets.has(artifact.deployTarget)) {
926
+ diagnostics.push(diagnostic("artifact.server.source", "Server source bundles require deployTarget anonymous-source or claimed-source."));
848
927
  }
849
928
  const source = artifact.server.source;
850
929
  if (
@@ -871,8 +950,8 @@ export function validateAnonymousArtifact(artifact, { allowClaimedSource = false
871
950
  if (artifact.server?.source === undefined) {
872
951
  diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Source mutation requires artifact.server.source."));
873
952
  }
874
- if (artifact.deployTarget !== "claimed-source") {
875
- diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Source mutation requires deployTarget claimed-source."));
953
+ if (!sourceDeployTargets.has(artifact.deployTarget)) {
954
+ diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Source mutation requires deployTarget anonymous-source or claimed-source."));
876
955
  }
877
956
  continue;
878
957
  }
@@ -894,8 +973,10 @@ export function validateAnonymousArtifact(artifact, { allowClaimedSource = false
894
973
  } else if (operation.op === "update") {
895
974
  validateValue(operation.id, path, diagnostics);
896
975
  validateValue(operation.patch, path, diagnostics);
976
+ validateGuards(operation.guards, path, schema, operation.table, diagnostics);
897
977
  } else if (operation.op === "delete") {
898
978
  validateValue(operation.id, path, diagnostics);
979
+ validateGuards(operation.guards, path, schema, operation.table, diagnostics);
899
980
  } else if (operation.op === "deleteWhere") {
900
981
  validateQuery(operation.query, path, schema, diagnostics);
901
982
  }
@@ -1290,7 +1371,7 @@ export async function executeAnonymousQuery({ args = [], artifact, auth, deployI
1290
1371
  return executeQuerySpec({ args, artifact, auth, deployId, query, state });
1291
1372
  }
1292
1373
 
1293
- async function checkUpdateGuards({ auth, guards = [], row }) {
1374
+ async function checkRowGuards({ auth, guards = [], row }) {
1294
1375
  for (const guard of guards) {
1295
1376
  if (guard.op === "rowFieldEqualsAuth" && row?.[guard.field] !== auth[guard.equalsAuth]) {
1296
1377
  return false;
@@ -1335,7 +1416,7 @@ export async function executeAnonymousMutation({ args = [], artifact, auth, depl
1335
1416
  if (operation.op === "update") {
1336
1417
  const id = evaluateValue(operation.id, { args, auth });
1337
1418
  const row = await tx.getRow(deployId, operation.table, id);
1338
- if (!row || !(await checkUpdateGuards({ auth, guards: operation.guards, row }))) {
1419
+ if (!row || !(await checkRowGuards({ auth, guards: operation.guards, row }))) {
1339
1420
  continue;
1340
1421
  }
1341
1422
  const patch = preparePatch(artifact.server.schema, operation.table, evaluateValue(operation.patch, { args, auth, row }));
@@ -1345,6 +1426,10 @@ export async function executeAnonymousMutation({ args = [], artifact, auth, depl
1345
1426
 
1346
1427
  if (operation.op === "delete") {
1347
1428
  const id = evaluateValue(operation.id, { args, auth });
1429
+ const row = await tx.getRow(deployId, operation.table, id);
1430
+ if (!row || !(await checkRowGuards({ auth, guards: operation.guards, row }))) {
1431
+ continue;
1432
+ }
1348
1433
  await tx.deleteRow(deployId, operation.table, id);
1349
1434
  continue;
1350
1435
  }
package/src/cli.js CHANGED
@@ -40,17 +40,17 @@ Usage:
40
40
  lakebed create [name] [--template todo] [--no-git]
41
41
  lakebed dev [capsule-dir] [--port 3000]
42
42
  lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
43
- lakebed deploy [capsule-dir] [--api <url>] [--json]
43
+ lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
44
44
  lakebed claim [capsule-dir] [--api <url>] [--json]
45
45
  lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
46
46
  lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
47
- lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
47
+ lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
48
48
  lakebed run-many [capsule-dir] [--count 20] [--base-port 4000]
49
49
  lakebed auth as <name>
50
50
  lakebed auth reset
51
- lakebed db list [deploy-id-or-url] [--port 3000]
52
- lakebed db dump [deploy-id-or-url] [--port 3000]
53
- lakebed logs [deploy-id-or-url] [--port 3000]
51
+ lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
52
+ lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
53
+ lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
54
54
  `);
55
55
  }
56
56
 
@@ -68,6 +68,7 @@ const optionsWithValues = new Set([
68
68
  "--app-base-domain",
69
69
  "--base-port",
70
70
  "--count",
71
+ "--inspect-token",
71
72
  "--out",
72
73
  "--port",
73
74
  "--public-root-url",
@@ -794,6 +795,7 @@ async function buildAnonymousEnvelope(capsuleArg, sourceStore) {
794
795
  const artifact = await createAnonymousArtifact({
795
796
  app: built.app,
796
797
  clientOut: built.clientOut,
798
+ serverOut: built.serverOut,
797
799
  sourceStore
798
800
  });
799
801
 
@@ -1003,12 +1005,15 @@ async function openUrlInBrowser(url) {
1003
1005
  await execFileAsync(invocation.command, invocation.args, { windowsHide: true });
1004
1006
  }
1005
1007
 
1006
- function deployRequestBody(envelope, { serverEnv } = {}) {
1008
+ function deployRequestBody(envelope, { inspectPolicy, serverEnv } = {}) {
1007
1009
  const body = {
1008
1010
  artifact: envelope.artifact,
1009
1011
  clientBundle: envelope.clientBundle,
1010
1012
  clientVersion: LAKEBED_VERSION
1011
1013
  };
1014
+ if (inspectPolicy !== undefined) {
1015
+ body.inspectPolicy = inspectPolicy;
1016
+ }
1012
1017
  if (serverEnv !== undefined) {
1013
1018
  body.serverEnv = {
1014
1019
  mode: "replace",
@@ -1057,6 +1062,22 @@ function deployApiUrl(args) {
1057
1062
  return String(readArg(args, "--api", process.env.LAKEBED_DEPLOY_API ?? process.env.SPAN_DEPLOY_API ?? defaultDeployApiUrl)).replace(/\/+$/g, "");
1058
1063
  }
1059
1064
 
1065
+ function hasExplicitOption(args, name) {
1066
+ return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
1067
+ }
1068
+
1069
+ function normalizeHostedUrl(value) {
1070
+ if (!value) {
1071
+ return "";
1072
+ }
1073
+
1074
+ try {
1075
+ return new URL(value).href.replace(/\/+$/g, "");
1076
+ } catch {
1077
+ return String(value).replace(/\/+$/g, "");
1078
+ }
1079
+ }
1080
+
1060
1081
  async function readResponseJson(response) {
1061
1082
  const body = await response.text();
1062
1083
  if (!response.ok) {
@@ -1078,6 +1099,7 @@ async function deployCommand(args) {
1078
1099
  const serverEnvKeys = Object.keys(serverEnv).sort();
1079
1100
  const hasServerEnvValues = serverEnvKeys.length > 0;
1080
1101
  const api = deployApiUrl(args);
1102
+ const inspectPolicy = hasFlag(args, "--public-inspect") ? "public" : undefined;
1081
1103
  const metadata = await readDeployMetadata(capsuleDir);
1082
1104
  const canUpdate =
1083
1105
  metadata?.api === api && typeof metadata?.deployId === "string" && typeof metadata?.claimToken === "string";
@@ -1128,7 +1150,7 @@ async function deployCommand(args) {
1128
1150
 
1129
1151
  if (canUpdate) {
1130
1152
  response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`, {
1131
- body: deployRequestBody(envelope, { serverEnv: serverEnvForUpdate }),
1153
+ body: deployRequestBody(envelope, { inspectPolicy, serverEnv: serverEnvForUpdate }),
1132
1154
  headers: {
1133
1155
  "Authorization": `Bearer ${metadata.claimToken}`,
1134
1156
  "Content-Type": "application/json"
@@ -1149,7 +1171,7 @@ async function deployCommand(args) {
1149
1171
  }
1150
1172
 
1151
1173
  response ??= await fetch(`${api}/v1/anonymous-deploys`, {
1152
- body: deployRequestBody(envelope),
1174
+ body: deployRequestBody(envelope, { inspectPolicy }),
1153
1175
  headers: {
1154
1176
  "Content-Type": "application/json"
1155
1177
  },
@@ -1193,6 +1215,9 @@ async function deployCommand(args) {
1193
1215
  console.log(`Claim: ${claimCommandText({ api, capsuleArg })}`);
1194
1216
  }
1195
1217
  console.log(`Inspect: lakebed inspect ${deployed.deployId}`);
1218
+ if (deployed.inspectPolicy === "public") {
1219
+ console.log("Inspect policy: public - data and logs are readable by anyone with the app URL.");
1220
+ }
1196
1221
  console.log("\nLimits:");
1197
1222
  console.log(` source/artifact: ${deployed.limits.artifactBytes} bytes`);
1198
1223
  console.log(` state: ${deployed.limits.stateBytes} bytes`);
@@ -1322,7 +1347,15 @@ async function anonymousServerCommand(args) {
1322
1347
  await new Promise(() => {});
1323
1348
  }
1324
1349
 
1325
- async function resolveDeployUrl(target, args) {
1350
+ function deployLookupApiUrl(target, args, metadata) {
1351
+ if (!hasExplicitOption(args, "--api") && metadata?.api && metadata.deployId === target) {
1352
+ return String(metadata.api).replace(/\/+$/g, "");
1353
+ }
1354
+
1355
+ return deployApiUrl(args);
1356
+ }
1357
+
1358
+ async function resolveDeployUrl(target, args, metadata) {
1326
1359
  if (!target) {
1327
1360
  throw new Error("Expected a deploy ID or URL.");
1328
1361
  }
@@ -1331,16 +1364,40 @@ async function resolveDeployUrl(target, args) {
1331
1364
  const url = new URL(target);
1332
1365
  return url.href.replace(/\/+$/g, "");
1333
1366
  } catch {
1334
- const api = deployApiUrl(args);
1367
+ const api = deployLookupApiUrl(target, args, metadata);
1335
1368
  const response = await fetch(`${api}/v1/deploys/${encodeURIComponent(target)}`);
1336
1369
  const deploy = await readResponseJson(response);
1337
1370
  return deploy.url.replace(/\/+$/g, "");
1338
1371
  }
1339
1372
  }
1340
1373
 
1374
+ function inspectTokenForHostedTarget({ args, metadata, target, url }) {
1375
+ const explicitToken = readArg(args, "--inspect-token", process.env.LAKEBED_INSPECT_TOKEN ?? "");
1376
+ if (explicitToken) {
1377
+ return explicitToken;
1378
+ }
1379
+
1380
+ if (!metadata?.claimToken) {
1381
+ return "";
1382
+ }
1383
+
1384
+ const normalizedTarget = normalizeHostedUrl(target);
1385
+ const normalizedUrl = normalizeHostedUrl(url);
1386
+ const metadataUrl = normalizeHostedUrl(metadata.url);
1387
+ if (metadata.deployId === target || (metadataUrl && (metadataUrl === normalizedTarget || metadataUrl === normalizedUrl))) {
1388
+ return metadata.claimToken;
1389
+ }
1390
+
1391
+ return "";
1392
+ }
1393
+
1341
1394
  async function hostedJson(target, path, args) {
1342
- const url = await resolveDeployUrl(target, args);
1343
- const response = await fetch(`${url}${path}`);
1395
+ const metadata = await readDeployMetadata(root);
1396
+ const url = await resolveDeployUrl(target, args, metadata);
1397
+ const inspectToken = inspectTokenForHostedTarget({ args, metadata, target, url });
1398
+ const response = await fetch(`${url}${path}`, {
1399
+ headers: inspectToken ? { Authorization: `Bearer ${inspectToken}` } : {}
1400
+ });
1344
1401
  return readResponseJson(response);
1345
1402
  }
1346
1403
 
@@ -1354,15 +1411,36 @@ async function inspectCommand(args) {
1354
1411
  }
1355
1412
 
1356
1413
  console.log(`Deploy: ${manifest.deployId}`);
1357
- console.log(`URL: ${manifest.url}`);
1414
+ if (manifest.url) {
1415
+ console.log(`URL: ${manifest.url}`);
1416
+ }
1358
1417
  console.log(`Updated: ${formatOptionalTimestamp(manifest.updatedAt, "unknown")}`);
1359
1418
  console.log(`Expires: ${formatOptionalTimestamp(manifest.expiresAt)}`);
1419
+ console.log(`Runtime: ${manifest.runtimeVersion ?? "unknown"}`);
1420
+ if (manifest.inspectPolicy) {
1421
+ console.log(`Policy: ${manifest.inspectPolicy}`);
1422
+ }
1360
1423
  if (Array.isArray(manifest.domains) && manifest.domains.length > 0) {
1361
1424
  console.log(`Domains: ${manifest.domains.map((domain) => domain.hostname).join(", ")}`);
1362
1425
  }
1363
- console.log(`Artifact: ${manifest.artifactHash}`);
1364
- console.log(`Queries: ${manifest.queries.join(", ") || "(none)"}`);
1365
- console.log(`Mutations: ${manifest.mutations.join(", ") || "(none)"}`);
1426
+ if (manifest.artifactHash) {
1427
+ console.log(`Artifact: ${manifest.artifactHash}`);
1428
+ }
1429
+ if (Array.isArray(manifest.queries)) {
1430
+ console.log(`Queries: ${manifest.queries.join(", ") || "(none)"}`);
1431
+ }
1432
+ if (Array.isArray(manifest.mutations)) {
1433
+ console.log(`Mutations: ${manifest.mutations.join(", ") || "(none)"}`);
1434
+ }
1435
+ if (Array.isArray(manifest.mutationDetails) && manifest.mutationDetails.length > 0) {
1436
+ console.log("Mutation runtime:");
1437
+ for (const detail of manifest.mutationDetails) {
1438
+ const guardSummary = (detail.guards ?? [])
1439
+ .map((guard) => `${guard.table}.${guard.operation}:${guard.field}=auth.${guard.equalsAuth}`)
1440
+ .join(", ");
1441
+ console.log(` ${detail.name}: ${detail.mode}${guardSummary ? ` (${guardSummary})` : ""}`);
1442
+ }
1443
+ }
1366
1444
  }
1367
1445
 
1368
1446
  async function authCommand(args) {
@@ -13,6 +13,7 @@ const DEFAULT_LIMITS = {
13
13
 
14
14
  let nextFetchId = 1;
15
15
  const pendingFetches = new Map();
16
+ let allowBrokeredFetch = true;
16
17
 
17
18
  function stableStringify(value) {
18
19
  if (value === undefined) {
@@ -312,6 +313,9 @@ function createBrokeredResponse(response) {
312
313
  }
313
314
 
314
315
  function brokeredFetch(input, init = {}) {
316
+ if (!allowBrokeredFetch) {
317
+ return Promise.reject(new Error("Outbound fetch is disabled for anonymous deploys."));
318
+ }
315
319
  if (!sendToParent) {
316
320
  return Promise.reject(new Error("Source fetch broker is not available."));
317
321
  }
@@ -393,6 +397,7 @@ function sendError(error) {
393
397
  }
394
398
 
395
399
  async function runSource(request) {
400
+ allowBrokeredFetch = request.allowFetch !== false;
396
401
  const app = await loadSourceApp(request.artifact);
397
402
  const source = createSourceContext({
398
403
  artifact: request.artifact,
@@ -522,6 +522,7 @@ export class ChildProcessSourceRuntime {
522
522
  async executeQuery({ args = [], artifact, auth, deployId, name, state }) {
523
523
  const snapshot = await snapshotSourceState({ artifact, deployId, state });
524
524
  const response = await this.runWorker({
525
+ allowFetch: artifact.deployTarget === "claimed-source",
525
526
  args,
526
527
  artifact,
527
528
  auth,
@@ -537,6 +538,7 @@ export class ChildProcessSourceRuntime {
537
538
  return state.transaction(deployId, async (tx) => {
538
539
  const snapshot = await snapshotSourceState({ artifact, deployId, state: tx });
539
540
  const response = await this.runWorker({
541
+ allowFetch: artifact.deployTarget === "claimed-source",
540
542
  args,
541
543
  artifact,
542
544
  auth,
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.16";
1
+ export const LAKEBED_VERSION = "0.0.17";