lakebed 0.0.4 → 0.0.6

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
@@ -86,6 +86,26 @@ mutations: {
86
86
  }
87
87
  ```
88
88
 
89
+ ## Server Env
90
+
91
+ Server handlers can read capsule-specific environment variables through `ctx.env`.
92
+
93
+ ```txt
94
+ # .env.lakebed.server
95
+ OPENAI_API_KEY=sk-...
96
+ STRIPE_WEBHOOK_SECRET=whsec_...
97
+ ```
98
+
99
+ ```ts
100
+ queries: {
101
+ settings: query((ctx) => ({
102
+ hasOpenAiKey: Boolean(ctx.env.OPENAI_API_KEY)
103
+ }));
104
+ }
105
+ ```
106
+
107
+ `lakebed dev` loads `.env.lakebed.server` locally. For hosted apps, claim the deploy, then run `lakebed deploy` to replace the deploy's server env with the file contents. Env values are not included in anonymous artifacts or source manifests.
108
+
89
109
  ## Commands
90
110
 
91
111
  ```sh
@@ -109,6 +129,7 @@ lakebed logs [deploy-id-or-url] [--port 3000]
109
129
  - Tailwind classes in JSX are the only styling path in v0.
110
130
  - Guest auth works by default. Google sign-in is built in through Shoo and exposes verified identity on `ctx.auth`.
111
131
  - Anonymous deploys disable outbound `fetch`.
132
+ - Non-empty `.env.lakebed.server` files require a claimed deploy before they can sync to hosted server code.
112
133
 
113
134
  ## Hosted Deploys
114
135
 
@@ -142,9 +163,10 @@ Deploy responses include a claim URL. Configure GitHub OAuth on the runner, then
142
163
  LAKEBED_GITHUB_CLIENT_ID=...
143
164
  LAKEBED_GITHUB_CLIENT_SECRET=...
144
165
  LAKEBED_SESSION_SECRET=...
166
+ LAKEBED_SERVER_ENV_SECRET=...
145
167
  ```
146
168
 
147
- 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`; after a deploy is claimed, `lakebed deploy` can update it with a source-backed server artifact that supports async handlers and server-side fetch.
169
+ 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.
148
170
 
149
171
  ## Admin Dashboard
150
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -29,7 +29,8 @@
29
29
  "src/runtime.js",
30
30
  "src/server.d.ts",
31
31
  "src/server.js",
32
- "src/source-store.js"
32
+ "src/source-store.js",
33
+ "src/version.js"
33
34
  ],
34
35
  "exports": {
35
36
  "./package.json": "./package.json",
@@ -48,6 +49,9 @@
48
49
  "publishConfig": {
49
50
  "access": "public"
50
51
  },
52
+ "scripts": {
53
+ "check": "node --check src/cli.js && node --check src/runtime.js && node --check src/server.js && node --check src/client.js && node --check src/source-store.js && node --check src/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js && node --check src/version.js"
54
+ },
51
55
  "dependencies": {
52
56
  "esbuild": "^0.27.1",
53
57
  "pg": "^8.16.3",
@@ -56,8 +60,5 @@
56
60
  },
57
61
  "devDependencies": {
58
62
  "@types/ws": "^8.18.1"
59
- },
60
- "scripts": {
61
- "check": "node --check src/cli.js && node --check src/runtime.js && node --check src/server.js && node --check src/client.js && node --check src/source-store.js && node --check src/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js"
62
63
  }
63
- }
64
+ }
@@ -1,4 +1,4 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import {
4
4
  createClaimToken,
@@ -22,6 +22,46 @@ function dayWindowStart() {
22
22
  return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
23
23
  }
24
24
 
25
+ function serverEnvSecretFromEnv(env = process.env) {
26
+ return env.LAKEBED_SERVER_ENV_SECRET ?? env.LAKEBED_SESSION_SECRET ?? env.SPAN_SESSION_SECRET ?? "";
27
+ }
28
+
29
+ function serverEnvEncryptionKey(secret) {
30
+ return createHash("sha256").update(String(secret)).digest();
31
+ }
32
+
33
+ function encryptServerEnvValue(value, secret) {
34
+ if (!secret) {
35
+ return `plain:${Buffer.from(value, "utf8").toString("base64")}`;
36
+ }
37
+
38
+ const iv = randomBytes(12);
39
+ const cipher = createCipheriv("aes-256-gcm", serverEnvEncryptionKey(secret), iv);
40
+ const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
41
+ const tag = cipher.getAuthTag();
42
+ return `v1:${iv.toString("base64url")}:${tag.toString("base64url")}:${ciphertext.toString("base64url")}`;
43
+ }
44
+
45
+ function decryptServerEnvValue(value, secret) {
46
+ const stored = String(value ?? "");
47
+ if (stored.startsWith("plain:")) {
48
+ return Buffer.from(stored.slice("plain:".length), "base64").toString("utf8");
49
+ }
50
+
51
+ if (!stored.startsWith("v1:")) {
52
+ return stored;
53
+ }
54
+
55
+ if (!secret) {
56
+ throw new Error("LAKEBED_SERVER_ENV_SECRET is required to decrypt hosted server env.");
57
+ }
58
+
59
+ const [, ivBase64, tagBase64, ciphertextBase64] = stored.split(":");
60
+ const decipher = createDecipheriv("aes-256-gcm", serverEnvEncryptionKey(secret), Buffer.from(ivBase64, "base64url"));
61
+ decipher.setAuthTag(Buffer.from(tagBase64, "base64url"));
62
+ return Buffer.concat([decipher.update(Buffer.from(ciphertextBase64, "base64url")), decipher.final()]).toString("utf8");
63
+ }
64
+
25
65
  function html(title, basePath, { shooBaseUrl } = {}) {
26
66
  return `<!doctype html>
27
67
  <html lang="en">
@@ -1434,6 +1474,7 @@ export class MemoryAnonymousStore {
1434
1474
  this.quotaEvents = new Map();
1435
1475
  this.queues = new Map();
1436
1476
  this.rows = new Map();
1477
+ this.serverEnv = new Map();
1437
1478
  }
1438
1479
 
1439
1480
  async initialize() {}
@@ -1451,7 +1492,7 @@ export class MemoryAnonymousStore {
1451
1492
  });
1452
1493
  }
1453
1494
 
1454
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
1495
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds, serverEnv }) {
1455
1496
  const deployId = createDeployId();
1456
1497
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
1457
1498
  let slug = createSlug();
@@ -1492,6 +1533,9 @@ export class MemoryAnonymousStore {
1492
1533
  };
1493
1534
  this.deploys.set(deployId, deploy);
1494
1535
  this.deploysBySlug.set(slug, deployId);
1536
+ if (serverEnv !== undefined) {
1537
+ this.serverEnv.set(deployId, { ...serverEnv });
1538
+ }
1495
1539
  return { deploy, token };
1496
1540
  }
1497
1541
 
@@ -1503,7 +1547,8 @@ export class MemoryAnonymousStore {
1503
1547
  clientBundleHash,
1504
1548
  deployId,
1505
1549
  publicRootUrl,
1506
- requestedTtlSeconds
1550
+ requestedTtlSeconds,
1551
+ serverEnv
1507
1552
  }) {
1508
1553
  const currentDeploy = await this.getDeployById(deployId);
1509
1554
  if (!currentDeploy) {
@@ -1533,6 +1578,9 @@ export class MemoryAnonymousStore {
1533
1578
  createdAt
1534
1579
  });
1535
1580
  this.deploys.set(deployId, deploy);
1581
+ if (serverEnv !== undefined) {
1582
+ this.serverEnv.set(deployId, { ...serverEnv });
1583
+ }
1536
1584
  return deploy;
1537
1585
  }
1538
1586
 
@@ -1624,6 +1672,14 @@ export class MemoryAnonymousStore {
1624
1672
  this.tableRows(deployId, tableName).delete(rowId);
1625
1673
  }
1626
1674
 
1675
+ async replaceServerEnv(deployId, serverEnv) {
1676
+ this.serverEnv.set(deployId, { ...serverEnv });
1677
+ }
1678
+
1679
+ async getServerEnv(deployId) {
1680
+ return { ...(this.serverEnv.get(deployId) ?? {}) };
1681
+ }
1682
+
1627
1683
  async transaction(deployId, handler) {
1628
1684
  const run = () => handler(this);
1629
1685
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
@@ -1768,10 +1824,11 @@ export class MemoryAnonymousStore {
1768
1824
  }
1769
1825
 
1770
1826
  export class PostgresAnonymousStore {
1771
- constructor({ connectionString }) {
1827
+ constructor({ connectionString, serverEnvSecret = "" }) {
1772
1828
  this.connectionString = connectionString;
1773
1829
  this.pool = null;
1774
1830
  this.queues = new Map();
1831
+ this.serverEnvSecret = serverEnvSecret;
1775
1832
  }
1776
1833
 
1777
1834
  async initialize() {
@@ -1841,6 +1898,15 @@ export class PostgresAnonymousStore {
1841
1898
  primary key (deploy_id, bucket, window_start)
1842
1899
  )
1843
1900
  `);
1901
+ await this.query(`
1902
+ create table if not exists deploy_server_env(
1903
+ deploy_id text not null references deploys(id) on delete cascade,
1904
+ env_key text not null,
1905
+ env_value text not null,
1906
+ updated_at timestamptz not null,
1907
+ primary key (deploy_id, env_key)
1908
+ )
1909
+ `);
1844
1910
  }
1845
1911
 
1846
1912
  async query(sql, params = []) {
@@ -1862,7 +1928,7 @@ export class PostgresAnonymousStore {
1862
1928
  );
1863
1929
  }
1864
1930
 
1865
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
1931
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds, serverEnv }) {
1866
1932
  const createdAt = now();
1867
1933
  const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
1868
1934
  const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
@@ -1917,6 +1983,9 @@ export class PostgresAnonymousStore {
1917
1983
  deploy.url
1918
1984
  ]
1919
1985
  );
1986
+ if (serverEnv !== undefined) {
1987
+ await this.replaceServerEnv(deploy.id, serverEnv, createdAt);
1988
+ }
1920
1989
  return { deploy, token };
1921
1990
  } catch (error) {
1922
1991
  if (error?.code !== "23505" || attempt === 7) {
@@ -1936,7 +2005,8 @@ export class PostgresAnonymousStore {
1936
2005
  clientBundleHash,
1937
2006
  deployId,
1938
2007
  publicRootUrl,
1939
- requestedTtlSeconds
2008
+ requestedTtlSeconds,
2009
+ serverEnv
1940
2010
  }) {
1941
2011
  const currentDeploy = await this.getDeployById(deployId);
1942
2012
  if (!currentDeploy) {
@@ -1966,6 +2036,9 @@ export class PostgresAnonymousStore {
1966
2036
  `,
1967
2037
  [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url]
1968
2038
  );
2039
+ if (serverEnv !== undefined) {
2040
+ await this.replaceServerEnv(deployId, serverEnv, createdAt);
2041
+ }
1969
2042
  return this.rowToDeploy(result.rows[0]);
1970
2043
  }
1971
2044
 
@@ -2107,6 +2180,24 @@ export class PostgresAnonymousStore {
2107
2180
  await this.query("delete from state_rows where deploy_id = $1 and table_name = $2 and row_id = $3", [deployId, tableName, rowId]);
2108
2181
  }
2109
2182
 
2183
+ async replaceServerEnv(deployId, serverEnv, updatedAt = now()) {
2184
+ await this.query("delete from deploy_server_env where deploy_id = $1", [deployId]);
2185
+ for (const [key, value] of Object.entries(serverEnv)) {
2186
+ await this.query(
2187
+ `
2188
+ insert into deploy_server_env(deploy_id, env_key, env_value, updated_at)
2189
+ values($1, $2, $3, $4)
2190
+ `,
2191
+ [deployId, key, encryptServerEnvValue(value, this.serverEnvSecret), updatedAt]
2192
+ );
2193
+ }
2194
+ }
2195
+
2196
+ async getServerEnv(deployId) {
2197
+ const result = await this.query("select env_key, env_value from deploy_server_env where deploy_id = $1", [deployId]);
2198
+ return Object.fromEntries(result.rows.map((row) => [row.env_key, decryptServerEnvValue(row.env_value, this.serverEnvSecret)]));
2199
+ }
2200
+
2110
2201
  async transaction(deployId, handler) {
2111
2202
  const run = () => handler(this);
2112
2203
  const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
@@ -2301,7 +2392,10 @@ export class PostgresAnonymousStore {
2301
2392
 
2302
2393
  export async function createAnonymousStoreFromEnv(env = process.env) {
2303
2394
  if (env.DATABASE_URL) {
2304
- const store = new PostgresAnonymousStore({ connectionString: env.DATABASE_URL });
2395
+ const store = new PostgresAnonymousStore({
2396
+ connectionString: env.DATABASE_URL,
2397
+ serverEnvSecret: serverEnvSecretFromEnv(env)
2398
+ });
2305
2399
  await store.initialize();
2306
2400
  return store;
2307
2401
  }
@@ -2766,6 +2860,10 @@ export async function startAnonymousServer({
2766
2860
  if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
2767
2861
  const body = await readJsonBody(req);
2768
2862
  const payload = validateAnonymousDeployPayload(body);
2863
+ if (payload.serverEnv !== undefined && Object.keys(payload.serverEnv).length > 0) {
2864
+ sendJson(res, 400, { error: "Server env requires a claimed deploy." });
2865
+ return;
2866
+ }
2769
2867
  const { deploy, token } = await resolvedStore.createDeploy({
2770
2868
  appBaseDomain: resolvedAppBaseDomain,
2771
2869
  artifact: payload.artifact,
@@ -2773,7 +2871,8 @@ export async function startAnonymousServer({
2773
2871
  clientBundleBase64: payload.clientBundleBase64,
2774
2872
  clientBundleHash: payload.clientBundleHash,
2775
2873
  publicRootUrl: resolvedPublicRootUrl,
2776
- requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined
2874
+ requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
2875
+ serverEnv: payload.serverEnv
2777
2876
  });
2778
2877
  await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
2779
2878
  sendJson(res, 201, responseForDeploy({ deploy, token }));
@@ -2795,6 +2894,10 @@ export async function startAnonymousServer({
2795
2894
 
2796
2895
  const body = await readJsonBody(req);
2797
2896
  const payload = validateAnonymousDeployPayload(body, { allowClaimedSource: Boolean(currentDeploy.ownerId) });
2897
+ if (payload.serverEnv !== undefined && !currentDeploy.ownerId) {
2898
+ sendJson(res, 400, { error: "Server env requires a claimed deploy." });
2899
+ return;
2900
+ }
2798
2901
  const deploy = await resolvedStore.updateDeploy({
2799
2902
  appBaseDomain: resolvedAppBaseDomain,
2800
2903
  artifact: payload.artifact,
@@ -2803,7 +2906,8 @@ export async function startAnonymousServer({
2803
2906
  clientBundleHash: payload.clientBundleHash,
2804
2907
  deployId,
2805
2908
  publicRootUrl: resolvedPublicRootUrl,
2806
- requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined
2909
+ requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
2910
+ serverEnv: payload.serverEnv
2807
2911
  });
2808
2912
  if (!deploy) {
2809
2913
  sendJson(res, 404, { error: "Unknown deploy." });
package/src/anonymous.js CHANGED
@@ -1,8 +1,18 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { LAKEBED_VERSION } from "./version.js";
3
4
 
4
5
  export const ANONYMOUS_ARTIFACT_FORMAT = "lakebed.capsule.artifact.v1";
5
6
  export const ANONYMOUS_ARTIFACT_MEDIA_TYPE = "application/vnd.lakebed.artifact+json";
7
+ export { LAKEBED_VERSION };
8
+
9
+ export const SERVER_ENV_FILE = ".env.lakebed.server";
10
+ export const SERVER_ENV_LIMITS = {
11
+ maxKeyBytes: 128,
12
+ maxKeys: 64,
13
+ maxTotalBytes: 64 * 1024,
14
+ maxValueBytes: 16 * 1024
15
+ };
6
16
 
7
17
  export const DEFAULT_ANONYMOUS_LIMITS = {
8
18
  artifactBytes: 1024 * 1024,
@@ -65,6 +75,65 @@ function isPlainObject(value) {
65
75
  return Boolean(value) && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
66
76
  }
67
77
 
78
+ export function validateServerEnvValues(values, path = "serverEnv.values") {
79
+ if (!isPlainObject(values)) {
80
+ throw new Error(`${path} must be a JSON object.`);
81
+ }
82
+
83
+ const entries = Object.entries(values).sort(([left], [right]) => left.localeCompare(right));
84
+ if (entries.length > SERVER_ENV_LIMITS.maxKeys) {
85
+ throw new Error(`${path} may include at most ${SERVER_ENV_LIMITS.maxKeys} keys.`);
86
+ }
87
+
88
+ const env = {};
89
+ let totalBytes = 0;
90
+ for (const [key, value] of entries) {
91
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
92
+ throw new Error(`${path}.${key} is not a valid server env key.`);
93
+ }
94
+ if (key.startsWith("LAKEBED_")) {
95
+ throw new Error(`${path}.${key} uses the reserved LAKEBED_ prefix.`);
96
+ }
97
+ if (typeof value !== "string") {
98
+ throw new Error(`${path}.${key} must be a string.`);
99
+ }
100
+
101
+ const keyBytes = byteLength(key);
102
+ const valueBytes = byteLength(value);
103
+ if (keyBytes > SERVER_ENV_LIMITS.maxKeyBytes) {
104
+ throw new Error(`${path}.${key} exceeds ${SERVER_ENV_LIMITS.maxKeyBytes} bytes.`);
105
+ }
106
+ if (valueBytes > SERVER_ENV_LIMITS.maxValueBytes) {
107
+ throw new Error(`${path}.${key} exceeds ${SERVER_ENV_LIMITS.maxValueBytes} bytes.`);
108
+ }
109
+
110
+ totalBytes += keyBytes + valueBytes;
111
+ env[key] = value;
112
+ }
113
+
114
+ if (totalBytes > SERVER_ENV_LIMITS.maxTotalBytes) {
115
+ throw new Error(`${path} exceeds ${SERVER_ENV_LIMITS.maxTotalBytes} bytes.`);
116
+ }
117
+
118
+ return env;
119
+ }
120
+
121
+ export function validateServerEnvPayload(payload) {
122
+ if (payload === undefined) {
123
+ return undefined;
124
+ }
125
+
126
+ if (!isPlainObject(payload)) {
127
+ throw new Error("serverEnv must be a JSON object.");
128
+ }
129
+
130
+ if (payload.mode !== "replace") {
131
+ throw new Error("serverEnv.mode must be replace.");
132
+ }
133
+
134
+ return validateServerEnvValues(payload.values);
135
+ }
136
+
68
137
  function isExpression(value) {
69
138
  return Array.isArray(value) && expressionOps.has(value[0]);
70
139
  }
@@ -315,7 +384,7 @@ function diagnostic(file, message) {
315
384
  }
316
385
 
317
386
  async function readSourceFiles(sourceStore) {
318
- const paths = (await sourceStore.listFiles()).filter((path) => !path.startsWith("__lakebed/"));
387
+ const paths = (await sourceStore.listFiles()).filter((path) => !path.startsWith("__lakebed/") && path !== SERVER_ENV_FILE);
319
388
  const files = [];
320
389
 
321
390
  for (const path of paths) {
@@ -494,7 +563,7 @@ function compileServerToIr(app, schema) {
494
563
  return { diagnostics, mutations, queries };
495
564
  }
496
565
 
497
- export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = "0.0.3" }) {
566
+ export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = LAKEBED_VERSION }) {
498
567
  const sourceFiles = await readSourceFiles(sourceStore);
499
568
  const diagnostics = forbiddenSourceDiagnostics(sourceFiles);
500
569
  const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
@@ -556,7 +625,7 @@ export async function createAnonymousArtifact({ app, clientOut, sourceStore, ver
556
625
  };
557
626
  }
558
627
 
559
- export async function createClaimedArtifact({ app, clientOut, serverOut, sourceStore, version = "0.0.3" }) {
628
+ export async function createClaimedArtifact({ app, clientOut, serverOut, sourceStore, version = LAKEBED_VERSION }) {
560
629
  if (!serverOut) {
561
630
  throw new AnonymousCompilerError([diagnostic("server/index.ts", "Claimed deploys require a bundled server module.")]);
562
631
  }
@@ -841,12 +910,14 @@ export function validateAnonymousDeployPayload(payload, options = {}) {
841
910
  }
842
911
 
843
912
  const artifactHash = sha256(stableStringify(payload.artifact));
913
+ const serverEnv = validateServerEnvPayload(payload.serverEnv);
844
914
  return {
845
915
  artifact: cloneJson(payload.artifact),
846
916
  artifactHash,
847
917
  clientBundle,
848
918
  clientBundleBase64: clientBundle.toString("base64"),
849
- clientBundleHash
919
+ clientBundleHash,
920
+ serverEnv
850
921
  };
851
922
  }
852
923
 
@@ -1100,6 +1171,7 @@ function createSourceQuery(rows, tableName) {
1100
1171
  async function createSourceContext({ artifact, auth, deployId, state }) {
1101
1172
  const rows = {};
1102
1173
  const operations = [];
1174
+ const env = typeof state.getServerEnv === "function" ? await state.getServerEnv(deployId) : {};
1103
1175
  for (const tableName of Object.keys(artifact.server.schema ?? {})) {
1104
1176
  rows[tableName] = await state.listRows(deployId, tableName);
1105
1177
  }
@@ -1141,7 +1213,7 @@ async function createSourceContext({ artifact, auth, deployId, state }) {
1141
1213
  ctx: {
1142
1214
  auth,
1143
1215
  db,
1144
- env: {},
1216
+ env,
1145
1217
  log: {
1146
1218
  error() {},
1147
1219
  info() {},
package/src/cli.js CHANGED
@@ -9,15 +9,18 @@ import { WebSocketServer } from "ws";
9
9
  import {
10
10
  ANONYMOUS_ARTIFACT_MEDIA_TYPE,
11
11
  AnonymousCompilerError,
12
+ LAKEBED_VERSION,
13
+ SERVER_ENV_FILE,
12
14
  createAnonymousArtifact,
13
15
  createClaimedArtifact,
14
16
  parseTtlSeconds,
15
- stableStringify
17
+ stableStringify,
18
+ validateServerEnvValues
16
19
  } from "./anonymous.js";
17
20
  import { startAnonymousServer } from "./anonymous-server.js";
18
21
  import { authFromUrl as resolveAuthFromUrl, createGuestAuth, requestOrigin, shooBaseUrlFromEnv } from "./auth.js";
19
22
  import { LogBuffer, StateCell } from "./runtime.js";
20
- import { createMemorySourceStoreFromDirectory, sourcePathDirname, sourcePathJoin } from "./source-store.js";
23
+ import { MemorySourceStore, createMemorySourceStoreFromDirectory, sourcePathDirname, sourcePathJoin } from "./source-store.js";
21
24
 
22
25
  const root = process.cwd();
23
26
  const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -220,29 +223,45 @@ function createSourcePlugin(sourceStore, target) {
220
223
  };
221
224
  }
222
225
 
223
- async function readServerEnv(sourceStore) {
224
- const env = { ...process.env };
225
- if (!sourceStore.hasFile(".env.lakebed.server")) {
226
- return env;
226
+ function unquoteServerEnvValue(value) {
227
+ if (value.length < 2) {
228
+ return value;
227
229
  }
228
230
 
229
- const contents = await sourceStore.readFile(".env.lakebed.server");
230
- for (const rawLine of contents.split(/\r?\n/)) {
231
+ const quote = value[0];
232
+ if ((quote !== `"` && quote !== `'`) || value[value.length - 1] !== quote) {
233
+ return value;
234
+ }
235
+
236
+ return value.slice(1, -1);
237
+ }
238
+
239
+ function parseServerEnvFile(contents) {
240
+ const env = {};
241
+ for (const [index, rawLine] of contents.split(/\r?\n/).entries()) {
231
242
  const line = rawLine.trim();
232
243
  if (!line || line.startsWith("#")) {
233
244
  continue;
234
245
  }
235
246
 
236
- const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
247
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
237
248
  if (!match) {
238
- continue;
249
+ throw new Error(`Invalid ${SERVER_ENV_FILE} line ${index + 1}. Use KEY=value.`);
239
250
  }
240
251
 
241
252
  const [, key, rawValue] = match;
242
- env[key] = rawValue.replace(/^['"]|['"]$/g, "");
253
+ env[key] = unquoteServerEnvValue(rawValue.trim());
243
254
  }
244
255
 
245
- return env;
256
+ return validateServerEnvValues(env, SERVER_ENV_FILE);
257
+ }
258
+
259
+ async function readCapsuleServerEnv(sourceStore) {
260
+ if (!sourceStore.hasFile(SERVER_ENV_FILE)) {
261
+ return {};
262
+ }
263
+
264
+ return parseServerEnvFile(await sourceStore.readFile(SERVER_ENV_FILE));
246
265
  }
247
266
 
248
267
  export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev" } = {}) {
@@ -300,7 +319,7 @@ export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev"
300
319
  app: capsuleModule.default,
301
320
  buildDir,
302
321
  clientOut,
303
- env: await readServerEnv(workingStore),
322
+ env: await readCapsuleServerEnv(workingStore),
304
323
  serverOut,
305
324
  sourceStore: workingStore
306
325
  };
@@ -578,9 +597,9 @@ async function dev(args) {
578
597
  });
579
598
  }
580
599
 
581
- async function buildAnonymousEnvelope(capsuleArg) {
600
+ async function buildAnonymousEnvelope(capsuleArg, sourceStore) {
582
601
  const capsuleDir = resolveCapsuleDir(capsuleArg);
583
- const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
602
+ sourceStore ??= await createMemorySourceStoreFromDirectory(capsuleDir);
584
603
  const built = await buildCapsule({
585
604
  capsuleDir,
586
605
  capsuleId: `anonymous-${Date.now()}`,
@@ -601,9 +620,74 @@ async function buildAnonymousEnvelope(capsuleArg) {
601
620
  };
602
621
  }
603
622
 
604
- async function buildClaimedEnvelope(capsuleArg) {
623
+ const claimRequiredDiagnosticMessages = new Set([
624
+ "Outbound fetch is disabled for anonymous deploys.",
625
+ "Async server handlers are not part of the anonymous IR yet. Use synchronous Lakebed database operations."
626
+ ]);
627
+
628
+ function canDeployAfterClaim(error) {
629
+ return (
630
+ error instanceof AnonymousCompilerError &&
631
+ error.diagnostics.length > 0 &&
632
+ error.diagnostics.every((entry) => claimRequiredDiagnosticMessages.has(entry.message))
633
+ );
634
+ }
635
+
636
+ async function buildClaimRequiredEnvelope({ capsuleDir, feature = "claimed server features" }) {
637
+ const sourceStore = new MemorySourceStore();
638
+ await sourceStore.writeFile(
639
+ "server/index.ts",
640
+ `import { capsule } from "lakebed/server";
641
+
642
+ export default capsule({
643
+ name: "Claim Required",
644
+ schema: {},
645
+ queries: {},
646
+ mutations: {}
647
+ });
648
+ `
649
+ );
650
+ await sourceStore.writeFile(
651
+ "client/index.tsx",
652
+ `export function App() {
653
+ return (
654
+ <main className="min-h-screen bg-neutral-950 px-6 py-12 text-neutral-100">
655
+ <section className="mx-auto max-w-2xl rounded-lg border border-neutral-800 bg-neutral-900 p-8 shadow-2xl">
656
+ <p className="text-sm font-semibold uppercase tracking-wide text-cyan-300">Lakebed deploy</p>
657
+ <h1 className="mt-3 text-3xl font-semibold">Claim required</h1>
658
+ <p className="mt-4 text-neutral-300">
659
+ This capsule uses ${feature}. Claim this deploy, then run lakebed deploy again to publish the app.
660
+ </p>
661
+ </section>
662
+ </main>
663
+ );
664
+ }
665
+ `
666
+ );
667
+ const built = await buildCapsule({
668
+ capsuleDir,
669
+ capsuleId: `claim-required-${Date.now()}`,
670
+ sourceStore
671
+ });
672
+ const artifact = await createAnonymousArtifact({
673
+ app: built.app,
674
+ clientOut: built.clientOut,
675
+ sourceStore
676
+ });
677
+
678
+ return {
679
+ artifact: artifact.artifact,
680
+ artifactHash: artifact.artifactHash,
681
+ clientBundle: artifact.clientBundle,
682
+ clientBundleHash: artifact.clientBundleHash,
683
+ claimRequired: true,
684
+ mediaType: ANONYMOUS_ARTIFACT_MEDIA_TYPE
685
+ };
686
+ }
687
+
688
+ async function buildClaimedEnvelope(capsuleArg, sourceStore) {
605
689
  const capsuleDir = resolveCapsuleDir(capsuleArg);
606
- const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
690
+ sourceStore ??= await createMemorySourceStoreFromDirectory(capsuleDir);
607
691
  const built = await buildCapsule({
608
692
  capsuleDir,
609
693
  capsuleId: `claimed-${Date.now()}`,
@@ -677,13 +761,21 @@ function claimUrlFromDeployMetadata(metadata) {
677
761
  return `${String(metadata.api).replace(/\/+$/g, "")}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
678
762
  }
679
763
 
680
- function deployRequestBody(envelope, ttl) {
681
- return JSON.stringify({
764
+ function deployRequestBody(envelope, ttl, { serverEnv } = {}) {
765
+ const body = {
682
766
  artifact: envelope.artifact,
683
767
  clientBundle: envelope.clientBundle,
684
- clientVersion: "0.0.3",
768
+ clientVersion: LAKEBED_VERSION,
685
769
  requestedTtlSeconds: ttl
686
- });
770
+ };
771
+ if (serverEnv !== undefined) {
772
+ body.serverEnv = {
773
+ mode: "replace",
774
+ values: serverEnv
775
+ };
776
+ }
777
+
778
+ return JSON.stringify(body);
687
779
  }
688
780
 
689
781
  async function buildCommand(args) {
@@ -735,6 +827,11 @@ async function readResponseJson(response) {
735
827
  async function deployCommand(args) {
736
828
  const [capsuleArg] = positionals(args);
737
829
  const capsuleDir = capsuleArg ? resolveCapsuleDir(capsuleArg) : root;
830
+ const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
831
+ const serverEnvFileExists = sourceStore.hasFile(SERVER_ENV_FILE);
832
+ const serverEnv = await readCapsuleServerEnv(sourceStore);
833
+ const serverEnvKeys = Object.keys(serverEnv).sort();
834
+ const hasServerEnvValues = serverEnvKeys.length > 0;
738
835
  const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
739
836
  const api = deployApiUrl(args);
740
837
  const metadata = await readDeployMetadata(capsuleDir);
@@ -749,23 +846,45 @@ async function deployCommand(args) {
749
846
  }
750
847
 
751
848
  let envelope;
752
- try {
753
- envelope = currentDeploy?.claimed ? await buildClaimedEnvelope(capsuleDir) : await buildAnonymousEnvelope(capsuleDir);
754
- } catch (error) {
755
- if (error instanceof AnonymousCompilerError && canUpdate && !currentDeploy?.claimed) {
849
+ if (!currentDeploy?.claimed && hasServerEnvValues) {
850
+ if (canUpdate && currentDeploy) {
756
851
  throw new Error(
757
- `${error.message}\n\nThis deploy is still anonymous. Claim it first, then run lakebed deploy again to use server-side fetch.`
852
+ `This capsule defines server env in ${SERVER_ENV_FILE}.\n\nThis deploy is still anonymous. Claim it first, then run lakebed deploy again to sync server env.`
758
853
  );
759
854
  }
760
- throw error;
855
+ try {
856
+ await buildAnonymousEnvelope(capsuleDir, sourceStore);
857
+ } catch (error) {
858
+ if (!canDeployAfterClaim(error)) {
859
+ throw error;
860
+ }
861
+ }
862
+ envelope = await buildClaimRequiredEnvelope({ capsuleDir, feature: "server env" });
863
+ } else {
864
+ try {
865
+ envelope = currentDeploy?.claimed ? await buildClaimedEnvelope(capsuleDir, sourceStore) : await buildAnonymousEnvelope(capsuleDir, sourceStore);
866
+ } catch (error) {
867
+ if (error instanceof AnonymousCompilerError && canUpdate && currentDeploy && !currentDeploy.claimed) {
868
+ throw new Error(
869
+ `${error.message}\n\nThis deploy is still anonymous. Claim it first, then run lakebed deploy again to use server-side fetch.`
870
+ );
871
+ }
872
+ if ((!canUpdate || !currentDeploy) && canDeployAfterClaim(error)) {
873
+ envelope = await buildClaimRequiredEnvelope({ capsuleDir, feature: "server-side fetch" });
874
+ } else {
875
+ throw error;
876
+ }
877
+ }
761
878
  }
762
- const body = deployRequestBody(envelope, ttl);
879
+ const syncServerEnv = Boolean(currentDeploy?.claimed && serverEnvFileExists);
880
+ const serverEnvForUpdate = syncServerEnv ? serverEnv : undefined;
881
+ let serverEnvSynced = false;
763
882
  let mode = "created";
764
883
  let response;
765
884
 
766
885
  if (canUpdate) {
767
886
  response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`, {
768
- body,
887
+ body: deployRequestBody(envelope, ttl, { serverEnv: serverEnvForUpdate }),
769
888
  headers: {
770
889
  "Authorization": `Bearer ${metadata.claimToken}`,
771
890
  "Content-Type": "application/json"
@@ -776,13 +895,17 @@ async function deployCommand(args) {
776
895
  if (response.status === 404 || response.status === 410) {
777
896
  mode = "created";
778
897
  response = null;
898
+ if (serverEnvForUpdate !== undefined && hasServerEnvValues) {
899
+ envelope = await buildClaimRequiredEnvelope({ capsuleDir, feature: "server env" });
900
+ }
779
901
  } else {
780
902
  mode = "updated";
903
+ serverEnvSynced = serverEnvForUpdate !== undefined;
781
904
  }
782
905
  }
783
906
 
784
907
  response ??= await fetch(`${api}/v1/anonymous-deploys`, {
785
- body,
908
+ body: deployRequestBody(envelope, ttl),
786
909
  headers: {
787
910
  "Content-Type": "application/json"
788
911
  },
@@ -801,11 +924,24 @@ async function deployCommand(args) {
801
924
  }
802
925
 
803
926
  if (hasFlag(args, "--json")) {
804
- console.log(JSON.stringify(deployed, null, 2));
927
+ console.log(
928
+ JSON.stringify(
929
+ {
930
+ ...(envelope.claimRequired ? { ...deployed, claimRequired: true } : deployed),
931
+ ...(serverEnvSynced ? { serverEnv: { keys: serverEnvKeys, mode: "replace", synced: true } } : {})
932
+ },
933
+ null,
934
+ 2
935
+ )
936
+ );
805
937
  return;
806
938
  }
807
939
 
808
- console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
940
+ if (envelope.claimRequired && mode !== "updated") {
941
+ console.log("Created claim-required preview.\n");
942
+ } else {
943
+ console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
944
+ }
809
945
  console.log(`App: ${deployed.url}`);
810
946
  console.log(`Expires: ${deployed.expiresAt}`);
811
947
  if (deployed.claimUrl) {
@@ -818,6 +954,16 @@ async function deployCommand(args) {
818
954
  console.log(` requests: ${deployed.limits.requestsPerDay} / day`);
819
955
  console.log(` mutations: ${deployed.limits.mutationsPerDay} / day`);
820
956
  console.log(` outbound fetch: ${envelope.artifact.deployTarget === "claimed-source" ? "enabled" : "disabled"}`);
957
+ if (serverEnvSynced) {
958
+ console.log(`\nServer env: ${serverEnvKeys.length} synced`);
959
+ for (const key of serverEnvKeys) {
960
+ console.log(` ${key}`);
961
+ }
962
+ }
963
+ if (envelope.claimRequired) {
964
+ console.log("\nThis app needs a claimed deploy before server-side fetch or server env can run.");
965
+ console.log("Open the claim URL, then run lakebed deploy again.");
966
+ }
821
967
  }
822
968
 
823
969
  async function claimCommand(args) {
@@ -1028,6 +1174,7 @@ lakebed logs --port 3000
1028
1174
  - Use Tailwind classes directly in JSX.
1029
1175
  - Do not add a CSS, PostCSS, or Tailwind build pipeline.
1030
1176
  - Use auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
1177
+ - Read server-only environment variables through \`ctx.env\`; define them in \`.env.lakebed.server\`.
1031
1178
  - Add Google sign-in with \`<SignInWithGoogle />\` or \`signInWithGoogle()\` from \`lakebed/client\`.
1032
1179
  - Keep \`shared/\` free of DOM, Node, env, and Lakebed runtime imports.
1033
1180
 
@@ -1038,6 +1185,7 @@ lakebed logs --port 3000
1038
1185
  - Guest auth locally, with built-in Google sign-in through Shoo.
1039
1186
  - No file storage.
1040
1187
  - No outbound fetch in anonymous deploys. Claim the deploy before using server-side fetch.
1188
+ - Non-empty \`.env.lakebed.server\` files sync only after a deploy is claimed.
1041
1189
  - Local state resets when \`lakebed dev\` restarts.
1042
1190
  `;
1043
1191
  }
@@ -1145,6 +1293,7 @@ export function cleanTodoText(value: string): string {
1145
1293
  }
1146
1294
  `,
1147
1295
  ".gitignore": `.lakebed/
1296
+ .env.lakebed.server
1148
1297
  `,
1149
1298
  "README.md": `# ${title}
1150
1299
 
package/src/version.js ADDED
@@ -0,0 +1 @@
1
+ export const LAKEBED_VERSION = "0.0.6";