lakebed 0.0.13 → 0.0.15

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,7 +122,7 @@ 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] [--ttl 7d] [--api <url>] [--json]
125
+ lakebed deploy [capsule-dir] [--api <url>] [--json]
126
126
  lakebed claim [capsule-dir] [--api <url>] [--json]
127
127
  lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
128
128
  lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
@@ -167,7 +167,7 @@ LAKEBED_APP_BASE_DOMAIN=lakebed.app
167
167
 
168
168
  With a verified `*.lakebed.app` custom domain on the runner, deploy responses use `https://<slug>.lakebed.app`.
169
169
 
170
- Deploy responses include a claim URL. Configure GitHub OAuth on the runner, then open that claim URL to attach the anonymous deploy to a developer account:
170
+ 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:
171
171
 
172
172
  ```sh
173
173
  LAKEBED_GITHUB_CLIENT_ID=...
@@ -176,7 +176,7 @@ LAKEBED_SESSION_SECRET=...
176
176
  LAKEBED_SERVER_ENV_SECRET=...
177
177
  ```
178
178
 
179
- Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys. 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 claim URL. Open that URL, 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.
179
+ 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.
180
180
 
181
181
  ## Admin Dashboard
182
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -19,6 +19,11 @@ function now() {
19
19
  return new Date().toISOString();
20
20
  }
21
21
 
22
+ function anonymousDeployExpiresAt() {
23
+ const ttlSeconds = parseTtlSeconds();
24
+ return new Date(Date.now() + ttlSeconds * 1000).toISOString();
25
+ }
26
+
22
27
  function dayWindowStart() {
23
28
  return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
24
29
  }
@@ -190,11 +195,16 @@ function responseForDeploy({ deploy, token }) {
190
195
  expiresAt: deploy.expiresAt,
191
196
  inspect: inspectUrls(deploy.url),
192
197
  limits: deploy.limits,
198
+ updatedAt: deploy.updatedAt,
193
199
  url: deploy.url
194
200
  };
195
201
  }
196
202
 
197
203
  function isExpired(deploy) {
204
+ if (deploy?.ownerId) {
205
+ return false;
206
+ }
207
+
198
208
  return Boolean(deploy.expiresAt) && Date.parse(deploy.expiresAt) <= Date.now();
199
209
  }
200
210
 
@@ -705,6 +715,7 @@ function adminDeploySummary({ artifact, artifactBytes = 0, deploy, logBytes = 0,
705
715
  stateRows,
706
716
  status: isExpired(deploy) ? "expired" : deploy.status,
707
717
  tableCount: Object.keys(artifact?.server?.schema ?? {}).length,
718
+ updatedAt: deploy.updatedAt,
708
719
  url: deploy.url,
709
720
  ...usageCounts(usage)
710
721
  };
@@ -1286,6 +1297,7 @@ function adminHtml() {
1286
1297
  <th>User</th>
1287
1298
  <th>Status</th>
1288
1299
  <th>Created</th>
1300
+ <th>Updated</th>
1289
1301
  <th>Expires</th>
1290
1302
  <th>Usage</th>
1291
1303
  <th>Storage</th>
@@ -1389,6 +1401,7 @@ function adminHtml() {
1389
1401
  <th>User</th>
1390
1402
  <th>Status</th>
1391
1403
  <th>Created</th>
1404
+ <th>Updated</th>
1392
1405
  <th>Expires</th>
1393
1406
  <th>Usage</th>
1394
1407
  <th>Storage</th>
@@ -1464,6 +1477,10 @@ function adminHtml() {
1464
1477
  }).format(new Date(value));
1465
1478
  }
1466
1479
 
1480
+ function formatExpiry(value) {
1481
+ return value ? formatTime(value) : "never";
1482
+ }
1483
+
1467
1484
  function plural(count, label) {
1468
1485
  return formatNumber(count) + " " + label + (count === 1 ? "" : "s");
1469
1486
  }
@@ -1715,7 +1732,7 @@ function adminHtml() {
1715
1732
  if (!deploys.length) {
1716
1733
  const tr = document.createElement("tr");
1717
1734
  const td = textCell(emptyText, "mono");
1718
- td.colSpan = 9;
1735
+ td.colSpan = 10;
1719
1736
  tr.appendChild(td);
1720
1737
  tbody.appendChild(tr);
1721
1738
  return;
@@ -1727,7 +1744,8 @@ function adminHtml() {
1727
1744
  tr.appendChild(deployUserCell(deploy));
1728
1745
  tr.appendChild(statusCell(deploy));
1729
1746
  tr.appendChild(textCell(formatTime(deploy.createdAt), "mono"));
1730
- tr.appendChild(textCell(formatTime(deploy.expiresAt), "mono"));
1747
+ tr.appendChild(textCell(formatTime(deploy.updatedAt), "mono"));
1748
+ tr.appendChild(textCell(formatExpiry(deploy.expiresAt), "mono"));
1731
1749
  tr.appendChild(usageCell(deploy));
1732
1750
  tr.appendChild(storageCell(deploy));
1733
1751
  tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
@@ -2207,6 +2225,14 @@ function escapeHtml(value) {
2207
2225
  .replace(/"/g, "&quot;");
2208
2226
  }
2209
2227
 
2228
+ function formatHtmlTime(value) {
2229
+ return value ? new Date(value).toLocaleString() : "unknown";
2230
+ }
2231
+
2232
+ function formatHtmlExpiry(value) {
2233
+ return value ? formatHtmlTime(value) : "never";
2234
+ }
2235
+
2210
2236
  function developerDeploySummary({ artifact, deploy, usage }) {
2211
2237
  return {
2212
2238
  artifactHash: deploy.artifactHash,
@@ -2221,6 +2247,7 @@ function developerDeploySummary({ artifact, deploy, usage }) {
2221
2247
  ownerId: deploy.ownerId,
2222
2248
  slug: deploy.slug,
2223
2249
  status: isExpired(deploy) ? "expired" : deploy.status,
2250
+ updatedAt: deploy.updatedAt,
2224
2251
  url: deploy.url,
2225
2252
  usage: usageCounts(usage)
2226
2253
  };
@@ -2233,8 +2260,9 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2233
2260
  (deploy) => `<tr>
2234
2261
  <td><a href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a><small>${escapeHtml(deploy.deployId)} / ${escapeHtml(deploy.slug)}</small></td>
2235
2262
  <td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
2236
- <td>${escapeHtml(new Date(deploy.createdAt).toLocaleString())}</td>
2237
- <td>${escapeHtml(new Date(deploy.expiresAt).toLocaleString())}</td>
2263
+ <td>${escapeHtml(formatHtmlTime(deploy.createdAt))}</td>
2264
+ <td>${escapeHtml(formatHtmlTime(deploy.updatedAt))}</td>
2265
+ <td>${escapeHtml(formatHtmlExpiry(deploy.expiresAt))}</td>
2238
2266
  <td>${escapeHtml(deploy.usage.requestsToday)} / ${escapeHtml(deploy.limits.requestsPerDay)}</td>
2239
2267
  <td>${escapeHtml(deploy.usage.mutationsToday)} / ${escapeHtml(deploy.limits.mutationsPerDay)}</td>
2240
2268
  </tr>`
@@ -2434,6 +2462,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2434
2462
  <th>Deploy</th>
2435
2463
  <th>Status</th>
2436
2464
  <th>Created</th>
2465
+ <th>Updated</th>
2437
2466
  <th>Expires</th>
2438
2467
  <th>Requests</th>
2439
2468
  <th>Mutations</th>
@@ -2585,7 +2614,7 @@ export class MemoryAnonymousStore {
2585
2614
  });
2586
2615
  }
2587
2616
 
2588
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds, serverEnv }) {
2617
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
2589
2618
  const deployId = createDeployId();
2590
2619
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
2591
2620
  let slug = createSlug();
@@ -2595,8 +2624,7 @@ export class MemoryAnonymousStore {
2595
2624
 
2596
2625
  const token = createClaimToken();
2597
2626
  const createdAt = now();
2598
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
2599
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
2627
+ const expiresAt = anonymousDeployExpiresAt();
2600
2628
  const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
2601
2629
 
2602
2630
  this.storeArtifact({
@@ -2622,6 +2650,7 @@ export class MemoryAnonymousStore {
2622
2650
  publicRootUrl,
2623
2651
  slug,
2624
2652
  status: "active",
2653
+ updatedAt: createdAt,
2625
2654
  url
2626
2655
  };
2627
2656
  this.deploys.set(deployId, deploy);
@@ -2640,7 +2669,6 @@ export class MemoryAnonymousStore {
2640
2669
  clientBundleHash,
2641
2670
  deployId,
2642
2671
  publicRootUrl,
2643
- requestedTtlSeconds,
2644
2672
  serverEnv
2645
2673
  }) {
2646
2674
  const currentDeploy = await this.getStoredDeployById(deployId);
@@ -2648,8 +2676,7 @@ export class MemoryAnonymousStore {
2648
2676
  return null;
2649
2677
  }
2650
2678
 
2651
- const createdAt = now();
2652
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
2679
+ const updatedAt = now();
2653
2680
  const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
2654
2681
  const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
2655
2682
  const deploy = {
@@ -2657,10 +2684,11 @@ export class MemoryAnonymousStore {
2657
2684
  appBaseDomain: nextAppBaseDomain,
2658
2685
  artifactHash,
2659
2686
  clientBundleHash,
2660
- expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
2687
+ expiresAt: currentDeploy.ownerId ? null : anonymousDeployExpiresAt(),
2661
2688
  publicRootUrl: nextPublicRootUrl,
2662
2689
  url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
2663
- status: "active"
2690
+ status: "active",
2691
+ updatedAt
2664
2692
  };
2665
2693
 
2666
2694
  this.storeArtifact({
@@ -2668,7 +2696,7 @@ export class MemoryAnonymousStore {
2668
2696
  artifactHash,
2669
2697
  clientBundleBase64,
2670
2698
  clientBundleHash,
2671
- createdAt
2699
+ createdAt: updatedAt
2672
2700
  });
2673
2701
  this.deploys.set(deployId, deploy);
2674
2702
  if (serverEnv !== undefined) {
@@ -2691,11 +2719,14 @@ export class MemoryAnonymousStore {
2691
2719
  return { deploy: currentDeploy, status: "conflict" };
2692
2720
  }
2693
2721
 
2722
+ const updatedAt = now();
2694
2723
  const deploy = {
2695
2724
  ...currentDeploy,
2696
- claimedAt: currentDeploy.claimedAt ?? now(),
2725
+ claimedAt: currentDeploy.claimedAt ?? updatedAt,
2726
+ expiresAt: null,
2697
2727
  owner,
2698
- ownerId: owner.id
2728
+ ownerId: owner.id,
2729
+ updatedAt
2699
2730
  };
2700
2731
  await this.rememberUser(owner);
2701
2732
  this.deploys.set(deployId, deploy);
@@ -2710,7 +2741,8 @@ export class MemoryAnonymousStore {
2710
2741
 
2711
2742
  const deploy = {
2712
2743
  ...currentDeploy,
2713
- status: "terminated"
2744
+ status: "terminated",
2745
+ updatedAt: now()
2714
2746
  };
2715
2747
  this.deploys.set(deployId, deploy);
2716
2748
  return this.deployWithUserLimitOverrides(deploy);
@@ -2938,7 +2970,8 @@ export class PostgresAnonymousStore {
2938
2970
  artifact_hash text not null,
2939
2971
  client_bundle_hash text not null,
2940
2972
  created_at timestamptz not null,
2941
- expires_at timestamptz not null,
2973
+ updated_at timestamptz not null,
2974
+ expires_at timestamptz,
2942
2975
  claimed_at timestamptz,
2943
2976
  owner_id text,
2944
2977
  owner_json jsonb,
@@ -2953,6 +2986,11 @@ export class PostgresAnonymousStore {
2953
2986
  await this.query("alter table deploys add column if not exists claimed_at timestamptz");
2954
2987
  await this.query("alter table deploys add column if not exists owner_id text");
2955
2988
  await this.query("alter table deploys add column if not exists owner_json jsonb");
2989
+ await this.query("alter table deploys add column if not exists updated_at timestamptz");
2990
+ await this.query("update deploys set updated_at = created_at where updated_at is null");
2991
+ await this.query("alter table deploys alter column updated_at set not null");
2992
+ await this.query("alter table deploys alter column expires_at drop not null");
2993
+ await this.query("update deploys set expires_at = null where owner_id is not null and expires_at is not null");
2956
2994
  await this.query(`
2957
2995
  create table if not exists artifacts(
2958
2996
  hash text primary key,
@@ -3188,7 +3226,7 @@ export class PostgresAnonymousStore {
3188
3226
  select
3189
3227
  owner_id,
3190
3228
  count(*)::int as deploy_count,
3191
- count(*) filter (where status = 'active' and expires_at > now())::int as active_deploy_count
3229
+ count(*) filter (where status = 'active')::int as active_deploy_count
3192
3230
  from deploys
3193
3231
  where owner_id is not null
3194
3232
  group by owner_id
@@ -3238,7 +3276,7 @@ export class PostgresAnonymousStore {
3238
3276
  select
3239
3277
  owner_id,
3240
3278
  count(*)::int as deploy_count,
3241
- count(*) filter (where status = 'active' and expires_at > now())::int as active_deploy_count
3279
+ count(*) filter (where status = 'active')::int as active_deploy_count
3242
3280
  from deploys
3243
3281
  where owner_id is not null
3244
3282
  group by owner_id
@@ -3282,11 +3320,10 @@ export class PostgresAnonymousStore {
3282
3320
  );
3283
3321
  }
3284
3322
 
3285
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds, serverEnv }) {
3323
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
3286
3324
  const createdAt = now();
3287
3325
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
3288
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
3289
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
3326
+ const expiresAt = anonymousDeployExpiresAt();
3290
3327
  const token = createClaimToken();
3291
3328
  const deployId = createDeployId();
3292
3329
 
@@ -3310,6 +3347,7 @@ export class PostgresAnonymousStore {
3310
3347
  publicRootUrl,
3311
3348
  slug,
3312
3349
  status: "active",
3350
+ updatedAt: createdAt,
3313
3351
  url
3314
3352
  };
3315
3353
 
@@ -3317,10 +3355,10 @@ export class PostgresAnonymousStore {
3317
3355
  await this.query(
3318
3356
  `
3319
3357
  insert into deploys(
3320
- id, slug, status, artifact_hash, client_bundle_hash, created_at, expires_at,
3358
+ id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
3321
3359
  claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
3322
3360
  )
3323
- values($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, '{}'::jsonb, $10, $11, $12)
3361
+ values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
3324
3362
  `,
3325
3363
  [
3326
3364
  deploy.id,
@@ -3329,6 +3367,7 @@ export class PostgresAnonymousStore {
3329
3367
  deploy.artifactHash,
3330
3368
  deploy.clientBundleHash,
3331
3369
  deploy.createdAt,
3370
+ deploy.updatedAt,
3332
3371
  deploy.expiresAt,
3333
3372
  deploy.claimTokenHash,
3334
3373
  JSON.stringify(deploy.limits),
@@ -3359,7 +3398,6 @@ export class PostgresAnonymousStore {
3359
3398
  clientBundleHash,
3360
3399
  deployId,
3361
3400
  publicRootUrl,
3362
- requestedTtlSeconds,
3363
3401
  serverEnv
3364
3402
  }) {
3365
3403
  const currentDeploy = await this.getBaseDeployById(deployId);
@@ -3367,13 +3405,12 @@ export class PostgresAnonymousStore {
3367
3405
  return null;
3368
3406
  }
3369
3407
 
3370
- const createdAt = now();
3371
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
3372
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
3408
+ const updatedAt = now();
3409
+ const expiresAt = currentDeploy.ownerId ? null : anonymousDeployExpiresAt();
3373
3410
  const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
3374
3411
  const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
3375
3412
  const url = appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug });
3376
- await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt });
3413
+ await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt: updatedAt });
3377
3414
 
3378
3415
  const result = await this.query(
3379
3416
  `
@@ -3384,14 +3421,15 @@ export class PostgresAnonymousStore {
3384
3421
  expires_at = $4,
3385
3422
  public_root_url = $5,
3386
3423
  app_base_domain = $6,
3387
- url = $7
3424
+ url = $7,
3425
+ updated_at = $8
3388
3426
  where id = $1
3389
3427
  returning *
3390
3428
  `,
3391
- [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url]
3429
+ [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url, updatedAt]
3392
3430
  );
3393
3431
  if (serverEnv !== undefined) {
3394
- await this.replaceServerEnv(deployId, serverEnv, createdAt);
3432
+ await this.replaceServerEnv(deployId, serverEnv, updatedAt);
3395
3433
  }
3396
3434
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3397
3435
  }
@@ -3410,17 +3448,20 @@ export class PostgresAnonymousStore {
3410
3448
  return { deploy: currentDeploy, status: "conflict" };
3411
3449
  }
3412
3450
 
3413
- const claimedAt = currentDeploy.claimedAt ?? now();
3451
+ const updatedAt = now();
3452
+ const claimedAt = currentDeploy.claimedAt ?? updatedAt;
3414
3453
  const result = await this.query(
3415
3454
  `
3416
3455
  update deploys
3417
3456
  set claimed_at = coalesce(claimed_at, $3),
3418
3457
  owner_id = $2,
3419
- owner_json = $4::jsonb
3458
+ owner_json = $4::jsonb,
3459
+ expires_at = null,
3460
+ updated_at = $5
3420
3461
  where id = $1
3421
3462
  returning *
3422
3463
  `,
3423
- [deployId, owner.id, claimedAt, JSON.stringify(owner)]
3464
+ [deployId, owner.id, claimedAt, JSON.stringify(owner), updatedAt]
3424
3465
  );
3425
3466
  await this.rememberUser(owner);
3426
3467
  return {
@@ -3430,14 +3471,16 @@ export class PostgresAnonymousStore {
3430
3471
  }
3431
3472
 
3432
3473
  async terminateDeploy(deployId) {
3474
+ const updatedAt = now();
3433
3475
  const result = await this.query(
3434
3476
  `
3435
3477
  update deploys
3436
- set status = 'terminated'
3478
+ set status = 'terminated',
3479
+ updated_at = $2
3437
3480
  where id = $1
3438
3481
  returning *
3439
3482
  `,
3440
- [deployId]
3483
+ [deployId, updatedAt]
3441
3484
  );
3442
3485
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3443
3486
  }
@@ -3454,7 +3497,7 @@ export class PostgresAnonymousStore {
3454
3497
  claimTokenHash: row.claim_token_hash,
3455
3498
  clientBundleHash: row.client_bundle_hash,
3456
3499
  createdAt: new Date(row.created_at).toISOString(),
3457
- expiresAt: new Date(row.expires_at).toISOString(),
3500
+ expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
3458
3501
  id: row.id,
3459
3502
  limits: row.limits_json,
3460
3503
  owner: row.owner_json ?? null,
@@ -3462,6 +3505,7 @@ export class PostgresAnonymousStore {
3462
3505
  publicRootUrl: row.public_root_url,
3463
3506
  slug: row.slug,
3464
3507
  status: row.status,
3508
+ updatedAt: new Date(row.updated_at).toISOString(),
3465
3509
  url: row.url
3466
3510
  };
3467
3511
  }
@@ -3808,6 +3852,7 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
3808
3852
  queries: Object.keys(artifact.server.queries ?? {}),
3809
3853
  schema: artifact.server.schema,
3810
3854
  slug: deploy.slug,
3855
+ updatedAt: deploy.updatedAt,
3811
3856
  url: deploy.url
3812
3857
  });
3813
3858
  return true;
@@ -4347,7 +4392,6 @@ export async function startAnonymousServer({
4347
4392
  clientBundleBase64: payload.clientBundleBase64,
4348
4393
  clientBundleHash: payload.clientBundleHash,
4349
4394
  publicRootUrl: resolvedPublicRootUrl,
4350
- requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
4351
4395
  serverEnv: payload.serverEnv
4352
4396
  });
4353
4397
  await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
@@ -4382,7 +4426,6 @@ export async function startAnonymousServer({
4382
4426
  clientBundleHash: payload.clientBundleHash,
4383
4427
  deployId,
4384
4428
  publicRootUrl: resolvedPublicRootUrl,
4385
- requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
4386
4429
  serverEnv: payload.serverEnv
4387
4430
  });
4388
4431
  if (!deploy) {
package/src/cli.js CHANGED
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
4
4
  import { existsSync, realpathSync } from "node:fs";
5
5
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { clearLine, cursorTo } from "node:readline";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { promisify } from "node:util";
9
10
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -16,7 +17,6 @@ import {
16
17
  SERVER_ENV_FILE,
17
18
  createAnonymousArtifact,
18
19
  createClaimedArtifact,
19
- parseTtlSeconds,
20
20
  stableStringify,
21
21
  validateServerEnvValues
22
22
  } from "./anonymous.js";
@@ -40,7 +40,7 @@ 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] [--ttl 7d] [--api <url>] [--json]
43
+ lakebed deploy [capsule-dir] [--api <url>] [--json]
44
44
  lakebed claim [capsule-dir] [--api <url>] [--json]
45
45
  lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
46
46
  lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
@@ -71,8 +71,7 @@ const optionsWithValues = new Set([
71
71
  "--port",
72
72
  "--public-root-url",
73
73
  "--target",
74
- "--template",
75
- "--ttl"
74
+ "--template"
76
75
  ]);
77
76
 
78
77
  function positionals(args) {
@@ -105,6 +104,89 @@ function hasFlag(args, name) {
105
104
  return args.includes(name);
106
105
  }
107
106
 
107
+ function formatDevUpdateAge(updatedAt, now = new Date()) {
108
+ const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - updatedAt.getTime()) / 1000));
109
+ if (elapsedSeconds < 30) {
110
+ return `${elapsedSeconds} ${elapsedSeconds === 1 ? "second" : "seconds"} ago`;
111
+ }
112
+
113
+ if (elapsedSeconds < 60) {
114
+ return "under 1 minute ago";
115
+ }
116
+
117
+ const elapsedMinutes = Math.floor(elapsedSeconds / 60);
118
+ return `${elapsedMinutes} ${elapsedMinutes === 1 ? "minute" : "minutes"} ago`;
119
+ }
120
+
121
+ function formatOptionalTimestamp(value, fallback = "never") {
122
+ return value || fallback;
123
+ }
124
+
125
+ function devUpdateRefreshDelay(updatedAt, now = new Date()) {
126
+ const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - updatedAt.getTime()) / 1000));
127
+ return elapsedSeconds < 30 ? 1_000 : 30_000;
128
+ }
129
+
130
+ function createDevStatusWriter({ quiet = false } = {}) {
131
+ let wroteStatusLine = false;
132
+ let lastUpdatedAt = null;
133
+ let refreshTimer = null;
134
+
135
+ function clearRefreshTimer() {
136
+ if (refreshTimer) {
137
+ clearTimeout(refreshTimer);
138
+ refreshTimer = null;
139
+ }
140
+ }
141
+
142
+ function render() {
143
+ if (quiet || !process.stdout.isTTY || !lastUpdatedAt) {
144
+ return;
145
+ }
146
+
147
+ const message = `Live updated with your changes ${formatDevUpdateAge(lastUpdatedAt)}`;
148
+ clearLine(process.stdout, 0);
149
+ cursorTo(process.stdout, 0);
150
+ process.stdout.write(message);
151
+ wroteStatusLine = true;
152
+ }
153
+
154
+ function scheduleRefresh() {
155
+ clearRefreshTimer();
156
+ if (quiet || !process.stdout.isTTY || !lastUpdatedAt) {
157
+ return;
158
+ }
159
+
160
+ refreshTimer = setTimeout(() => {
161
+ refreshTimer = null;
162
+ render();
163
+ scheduleRefresh();
164
+ }, devUpdateRefreshDelay(lastUpdatedAt));
165
+ }
166
+
167
+ return {
168
+ close() {
169
+ clearRefreshTimer();
170
+ this.finish();
171
+ },
172
+ finish() {
173
+ if (wroteStatusLine && process.stdout.isTTY) {
174
+ process.stdout.write("\n");
175
+ }
176
+ wroteStatusLine = false;
177
+ },
178
+ update(date = new Date()) {
179
+ if (quiet || !process.stdout.isTTY) {
180
+ return;
181
+ }
182
+
183
+ lastUpdatedAt = date;
184
+ render();
185
+ scheduleRefresh();
186
+ }
187
+ };
188
+ }
189
+
108
190
  function resolveCapsuleDir(value) {
109
191
  if (!value) {
110
192
  return root;
@@ -446,7 +528,8 @@ export async function startDevServer({
446
528
  let currentBuild = await buildCapsule({ capsuleDir: resolvedCapsuleDir, sourceStore, capsuleId });
447
529
  const defaultAuth = await readAuth();
448
530
  const stateCell = new StateCell(currentBuild.app.schema);
449
- const logs = new LogBuffer();
531
+ const devStatus = createDevStatusWriter({ quiet });
532
+ const logs = new LogBuffer({ beforeConsoleWrite: () => devStatus.finish() });
450
533
  const subscriptions = new Map();
451
534
  let fileFingerprint = sourceStore ? "" : await capsuleFileFingerprint(resolvedCapsuleDir);
452
535
  let rebuildTimer = null;
@@ -457,7 +540,7 @@ export async function startDevServer({
457
540
  const nextBuild = await buildCapsule({ capsuleDir: resolvedCapsuleDir, sourceStore, capsuleId });
458
541
  currentBuild = nextBuild;
459
542
  stateCell.updateSchema(nextBuild.app.schema);
460
- logs.append("info", "dev server rebuilt capsule", { capsuleDir: resolvedCapsuleDir });
543
+ devStatus.update();
461
544
  for (const client of wss.clients) {
462
545
  sendJson(client, { op: "refresh" });
463
546
  }
@@ -675,6 +758,7 @@ export async function startDevServer({
675
758
  clearTimeout(rebuildTimer);
676
759
  }
677
760
  await rebuildPromise.catch(() => {});
761
+ devStatus.close();
678
762
  for (const client of wss.clients) {
679
763
  client.close();
680
764
  }
@@ -862,12 +946,39 @@ function claimUrlFromDeployMetadata(metadata) {
862
946
  return `${String(metadata.api).replace(/\/+$/g, "")}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
863
947
  }
864
948
 
865
- function deployRequestBody(envelope, ttl, { serverEnv } = {}) {
949
+ function claimCommandText({ api, capsuleArg }) {
950
+ const parts = ["lakebed", "claim"];
951
+ if (capsuleArg) {
952
+ parts.push(capsuleArg);
953
+ }
954
+ if (api !== defaultDeployApiUrl) {
955
+ parts.push("--api", api);
956
+ }
957
+ return parts.map(shellQuote).join(" ");
958
+ }
959
+
960
+ function browserOpenInvocation(url) {
961
+ if (process.platform === "darwin") {
962
+ return { command: "open", args: [url] };
963
+ }
964
+
965
+ if (process.platform === "win32") {
966
+ return { command: "cmd", args: ["/c", "start", "", url] };
967
+ }
968
+
969
+ return { command: "xdg-open", args: [url] };
970
+ }
971
+
972
+ async function openUrlInBrowser(url) {
973
+ const invocation = browserOpenInvocation(url);
974
+ await execFileAsync(invocation.command, invocation.args, { windowsHide: true });
975
+ }
976
+
977
+ function deployRequestBody(envelope, { serverEnv } = {}) {
866
978
  const body = {
867
979
  artifact: envelope.artifact,
868
980
  clientBundle: envelope.clientBundle,
869
- clientVersion: LAKEBED_VERSION,
870
- requestedTtlSeconds: ttl
981
+ clientVersion: LAKEBED_VERSION
871
982
  };
872
983
  if (serverEnv !== undefined) {
873
984
  body.serverEnv = {
@@ -926,6 +1037,10 @@ async function readResponseJson(response) {
926
1037
  }
927
1038
 
928
1039
  async function deployCommand(args) {
1040
+ if (args.some((arg) => arg === "--ttl" || arg.startsWith("--ttl="))) {
1041
+ throw new Error("lakebed deploy no longer accepts --ttl. Deploy expiry is set by the server.");
1042
+ }
1043
+
929
1044
  const [capsuleArg] = positionals(args);
930
1045
  const capsuleDir = resolveCapsuleDir(capsuleArg);
931
1046
  const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
@@ -933,7 +1048,6 @@ async function deployCommand(args) {
933
1048
  const serverEnv = await readCapsuleServerEnv(sourceStore);
934
1049
  const serverEnvKeys = Object.keys(serverEnv).sort();
935
1050
  const hasServerEnvValues = serverEnvKeys.length > 0;
936
- const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
937
1051
  const api = deployApiUrl(args);
938
1052
  const metadata = await readDeployMetadata(capsuleDir);
939
1053
  const canUpdate =
@@ -985,7 +1099,7 @@ async function deployCommand(args) {
985
1099
 
986
1100
  if (canUpdate) {
987
1101
  response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`, {
988
- body: deployRequestBody(envelope, ttl, { serverEnv: serverEnvForUpdate }),
1102
+ body: deployRequestBody(envelope, { serverEnv: serverEnvForUpdate }),
989
1103
  headers: {
990
1104
  "Authorization": `Bearer ${metadata.claimToken}`,
991
1105
  "Content-Type": "application/json"
@@ -1006,7 +1120,7 @@ async function deployCommand(args) {
1006
1120
  }
1007
1121
 
1008
1122
  response ??= await fetch(`${api}/v1/anonymous-deploys`, {
1009
- body: deployRequestBody(envelope, ttl),
1123
+ body: deployRequestBody(envelope),
1010
1124
  headers: {
1011
1125
  "Content-Type": "application/json"
1012
1126
  },
@@ -1044,9 +1158,10 @@ async function deployCommand(args) {
1044
1158
  console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
1045
1159
  }
1046
1160
  console.log(`App: ${deployed.url}`);
1047
- console.log(`Expires: ${deployed.expiresAt}`);
1161
+ console.log(`Updated: ${formatOptionalTimestamp(deployed.updatedAt, "unknown")}`);
1162
+ console.log(`Expires: ${formatOptionalTimestamp(deployed.expiresAt)}`);
1048
1163
  if (deployed.claimUrl) {
1049
- console.log(`Claim: ${deployed.claimUrl}`);
1164
+ console.log(`Claim: ${claimCommandText({ api, capsuleArg })}`);
1050
1165
  }
1051
1166
  console.log(`Inspect: lakebed inspect ${deployed.deployId}`);
1052
1167
  console.log("\nLimits:");
@@ -1063,7 +1178,7 @@ async function deployCommand(args) {
1063
1178
  }
1064
1179
  if (envelope.claimRequired) {
1065
1180
  console.log("\nThis app needs a claimed deploy before server-side fetch or server env can run.");
1066
- console.log("Open the claim URL, then run lakebed deploy again.");
1181
+ console.log(`Run ${claimCommandText({ api, capsuleArg })}, then run lakebed deploy again.`);
1067
1182
  }
1068
1183
  }
1069
1184
 
@@ -1116,8 +1231,15 @@ async function claimCommand(args) {
1116
1231
  return;
1117
1232
  }
1118
1233
 
1119
- console.log("Open this URL to claim the current project's deploy:");
1120
- console.log(claimUrl);
1234
+ try {
1235
+ await openUrlInBrowser(claimUrl);
1236
+ } catch (error) {
1237
+ throw new Error(
1238
+ `Unable to open the claim page in your browser: ${error instanceof Error ? error.message : String(error)}\n\nRun ${claimCommandText({ api, capsuleArg })} --json to read the claim URL.`
1239
+ );
1240
+ }
1241
+
1242
+ console.log(`Opened claim page for deploy ${result.deployId} in your browser.`);
1121
1243
  }
1122
1244
 
1123
1245
  async function anonymousServerCommand(args) {
@@ -1163,7 +1285,8 @@ async function inspectCommand(args) {
1163
1285
 
1164
1286
  console.log(`Deploy: ${manifest.deployId}`);
1165
1287
  console.log(`URL: ${manifest.url}`);
1166
- console.log(`Expires: ${manifest.expiresAt}`);
1288
+ console.log(`Updated: ${formatOptionalTimestamp(manifest.updatedAt, "unknown")}`);
1289
+ console.log(`Expires: ${formatOptionalTimestamp(manifest.expiresAt)}`);
1167
1290
  console.log(`Artifact: ${manifest.artifactHash}`);
1168
1291
  console.log(`Queries: ${manifest.queries.join(", ") || "(none)"}`);
1169
1292
  console.log(`Mutations: ${manifest.mutations.join(", ") || "(none)"}`);
@@ -1477,11 +1600,12 @@ async function isInsideGitWorkTree(cwd) {
1477
1600
  }
1478
1601
 
1479
1602
  function shellQuote(value) {
1480
- if (/^[A-Za-z0-9_./:-]+$/.test(value)) {
1481
- return value;
1603
+ const text = String(value);
1604
+ if (/^[A-Za-z0-9_./:=,@%+-]+$/.test(text)) {
1605
+ return text;
1482
1606
  }
1483
1607
 
1484
- return `'${value.replaceAll("'", "'\\''")}'`;
1608
+ return `'${text.replaceAll("'", "'\\''")}'`;
1485
1609
  }
1486
1610
 
1487
1611
  async function promptForCapsuleName() {
package/src/client.js CHANGED
@@ -6,6 +6,7 @@ const AUTH_STORAGE_KEY = "lakebed_identity";
6
6
  const LEGACY_SHOO_STORAGE_KEY = "shoo_identity";
7
7
  const PKCE_STORAGE_KEY = "lakebed_google_pkce";
8
8
  const RETURN_TO_STORAGE_KEY = "lakebed_google_return_to";
9
+ const AUTH_RESUME_STORAGE_KEY = "lakebed_google_resume_attempt";
9
10
  const PKCE_MAX_AGE_MS = 10 * 60 * 1000;
10
11
  const encoder = new TextEncoder();
11
12
 
@@ -19,6 +20,7 @@ const pending = new Map();
19
20
  const activeSubscriptions = new Set();
20
21
  let authInitPromise = null;
21
22
  let authInitialized = false;
23
+ let authResumeStarted = false;
22
24
  let refreshRequested = false;
23
25
 
24
26
  function toGuestName(name) {
@@ -67,6 +69,18 @@ function browserStorage() {
67
69
  }
68
70
  }
69
71
 
72
+ function browserSessionStorage() {
73
+ if (typeof window === "undefined") {
74
+ return null;
75
+ }
76
+
77
+ try {
78
+ return window.sessionStorage;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
70
84
  function currentGuestName() {
71
85
  if (typeof window === "undefined") {
72
86
  return "local";
@@ -236,7 +250,11 @@ export function decodeIdentityClaims(idToken) {
236
250
  return parseJson(decodeBase64Url(parts[1]));
237
251
  }
238
252
 
239
- function readStoredIdentity() {
253
+ function isExpiredClaims(claims) {
254
+ return typeof claims?.exp === "number" && claims.exp * 1000 <= Date.now();
255
+ }
256
+
257
+ function readStoredIdentity({ allowExpired = false } = {}) {
240
258
  const storage = browserStorage();
241
259
  if (!storage) {
242
260
  return { userId: null };
@@ -260,12 +278,13 @@ function readStoredIdentity() {
260
278
 
261
279
  const token = typeof parsed.token === "string" ? parsed.token : undefined;
262
280
  const claims = decodeIdentityClaims(token);
263
- if (typeof claims?.exp === "number" && claims.exp * 1000 <= Date.now()) {
264
- clearStoredIdentity();
265
- return { userId: null };
281
+ const expired = isExpiredClaims(claims);
282
+ if (expired && !allowExpired) {
283
+ return { expired, userId: null };
266
284
  }
267
285
 
268
286
  return {
287
+ expired,
269
288
  token,
270
289
  userId: typeof parsed.userId === "string" ? parsed.userId : (typeof parsed.pairwiseSub === "string" ? parsed.pairwiseSub : null)
271
290
  };
@@ -306,6 +325,19 @@ function clearStoredIdentity() {
306
325
  }
307
326
  }
308
327
 
328
+ function clearAuthResumeAttempt() {
329
+ const storage = browserSessionStorage();
330
+ if (!storage) {
331
+ return;
332
+ }
333
+
334
+ try {
335
+ storage.removeItem(AUTH_RESUME_STORAGE_KEY);
336
+ } catch {
337
+ // Ignore storage failures; resume attempts are best effort.
338
+ }
339
+ }
340
+
309
341
  function storedAuthToken() {
310
342
  return readStoredIdentity().token ?? "";
311
343
  }
@@ -431,6 +463,7 @@ async function handleGoogleCallback() {
431
463
  throw new Error("Google sign-in token response was missing identity claims.");
432
464
  }
433
465
  persistIdentity(token.pairwise_sub, token.id_token, token.expires_in);
466
+ clearAuthResumeAttempt();
434
467
  window.sessionStorage.removeItem(PKCE_STORAGE_KEY);
435
468
 
436
469
  const localAuth = createGoogleAuthFromToken(token.id_token);
@@ -445,11 +478,78 @@ async function handleGoogleCallback() {
445
478
  return token;
446
479
  }
447
480
 
481
+ function authResumeAttemptKey(identity) {
482
+ const claims = decodeIdentityClaims(identity.token);
483
+ return [identity.userId, claims?.jti, claims?.exp].filter(Boolean).join(":") || identity.token;
484
+ }
485
+
486
+ function beginStoredGoogleSessionResume() {
487
+ if (typeof window === "undefined" || authResumeStarted) {
488
+ return false;
489
+ }
490
+
491
+ if (parseCallback()) {
492
+ return false;
493
+ }
494
+
495
+ const search = new URLSearchParams(window.location.search);
496
+ if (search.has("error") || window.location.pathname === callbackPath()) {
497
+ return false;
498
+ }
499
+
500
+ if (search.has("lakebed_guest") || search.has("guest")) {
501
+ return false;
502
+ }
503
+
504
+ const identity = readStoredIdentity({ allowExpired: true });
505
+ if (!identity.token || !identity.expired) {
506
+ return false;
507
+ }
508
+
509
+ const claims = decodeIdentityClaims(identity.token);
510
+ if (!claims?.pairwise_sub && !claims?.sub) {
511
+ clearStoredIdentity();
512
+ return false;
513
+ }
514
+
515
+ const storage = browserSessionStorage();
516
+ const attemptKey = authResumeAttemptKey(identity);
517
+ try {
518
+ if (storage?.getItem(AUTH_RESUME_STORAGE_KEY) === attemptKey) {
519
+ return false;
520
+ }
521
+ storage?.setItem(AUTH_RESUME_STORAGE_KEY, attemptKey);
522
+ } catch {
523
+ // Without sessionStorage, the page navigation below is still safe.
524
+ }
525
+
526
+ authResumeStarted = true;
527
+ auth = withAuthLoading(createGuestAuth(currentGuestName()), true);
528
+ emitAuth();
529
+
530
+ void signInWithGoogle({ returnTo: currentRoute() }).catch((error) => {
531
+ console.error("[lakebed] Google session resume failed", error);
532
+ authResumeStarted = false;
533
+ clearStoredIdentity();
534
+ clearAuthResumeAttempt();
535
+ auth = withAuthLoading(createGuestAuth(currentGuestName()), false);
536
+ emitAuth();
537
+ reconnect();
538
+ });
539
+
540
+ return true;
541
+ }
542
+
448
543
  function ensureAuthInitialized() {
449
544
  if (authInitialized) {
450
545
  return Promise.resolve();
451
546
  }
452
547
 
548
+ if (beginStoredGoogleSessionResume()) {
549
+ authInitialized = true;
550
+ return Promise.resolve();
551
+ }
552
+
453
553
  authInitPromise ??= handleGoogleCallback()
454
554
  .catch((error) => {
455
555
  console.error("[lakebed] Google sign-in failed", error);
@@ -501,6 +601,9 @@ function connect() {
501
601
  const message = JSON.parse(String(event.data));
502
602
 
503
603
  if (message.op === "auth.result") {
604
+ if (authResumeStarted) {
605
+ return;
606
+ }
504
607
  auth = withAuthLoading(message.auth, false);
505
608
  emitAuth();
506
609
  return;
@@ -592,6 +695,7 @@ export async function signInWithGoogle(options = {}) {
592
695
 
593
696
  export function signOut() {
594
697
  clearStoredIdentity();
698
+ clearAuthResumeAttempt();
595
699
  auth = withAuthLoading(createGuestAuth(currentGuestName()), true);
596
700
  emitAuth();
597
701
  reconnect();
package/src/runtime.js CHANGED
@@ -243,8 +243,9 @@ export class StateCell {
243
243
  }
244
244
 
245
245
  export class LogBuffer {
246
- constructor() {
246
+ constructor({ beforeConsoleWrite } = {}) {
247
247
  this.entries = [];
248
+ this.beforeConsoleWrite = beforeConsoleWrite;
248
249
  }
249
250
 
250
251
  append(level, message, data) {
@@ -255,6 +256,7 @@ export class LogBuffer {
255
256
  at: now()
256
257
  };
257
258
  this.entries.push(entry);
259
+ this.beforeConsoleWrite?.();
258
260
  console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](`[lakebed:${level}] ${message}`, data ?? "");
259
261
  }
260
262
 
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.13";
1
+ export const LAKEBED_VERSION = "0.0.15";