lakebed 0.0.5 → 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. If the first deploy already needs server-side `fetch`, `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.
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.5",
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",
@@ -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
@@ -6,6 +6,14 @@ export const ANONYMOUS_ARTIFACT_FORMAT = "lakebed.capsule.artifact.v1";
6
6
  export const ANONYMOUS_ARTIFACT_MEDIA_TYPE = "application/vnd.lakebed.artifact+json";
7
7
  export { LAKEBED_VERSION };
8
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
+ };
16
+
9
17
  export const DEFAULT_ANONYMOUS_LIMITS = {
10
18
  artifactBytes: 1024 * 1024,
11
19
  stateBytes: 1024 * 1024,
@@ -67,6 +75,65 @@ function isPlainObject(value) {
67
75
  return Boolean(value) && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
68
76
  }
69
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
+
70
137
  function isExpression(value) {
71
138
  return Array.isArray(value) && expressionOps.has(value[0]);
72
139
  }
@@ -317,7 +384,7 @@ function diagnostic(file, message) {
317
384
  }
318
385
 
319
386
  async function readSourceFiles(sourceStore) {
320
- 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);
321
388
  const files = [];
322
389
 
323
390
  for (const path of paths) {
@@ -843,12 +910,14 @@ export function validateAnonymousDeployPayload(payload, options = {}) {
843
910
  }
844
911
 
845
912
  const artifactHash = sha256(stableStringify(payload.artifact));
913
+ const serverEnv = validateServerEnvPayload(payload.serverEnv);
846
914
  return {
847
915
  artifact: cloneJson(payload.artifact),
848
916
  artifactHash,
849
917
  clientBundle,
850
918
  clientBundleBase64: clientBundle.toString("base64"),
851
- clientBundleHash
919
+ clientBundleHash,
920
+ serverEnv
852
921
  };
853
922
  }
854
923
 
@@ -1102,6 +1171,7 @@ function createSourceQuery(rows, tableName) {
1102
1171
  async function createSourceContext({ artifact, auth, deployId, state }) {
1103
1172
  const rows = {};
1104
1173
  const operations = [];
1174
+ const env = typeof state.getServerEnv === "function" ? await state.getServerEnv(deployId) : {};
1105
1175
  for (const tableName of Object.keys(artifact.server.schema ?? {})) {
1106
1176
  rows[tableName] = await state.listRows(deployId, tableName);
1107
1177
  }
@@ -1143,7 +1213,7 @@ async function createSourceContext({ artifact, auth, deployId, state }) {
1143
1213
  ctx: {
1144
1214
  auth,
1145
1215
  db,
1146
- env: {},
1216
+ env,
1147
1217
  log: {
1148
1218
  error() {},
1149
1219
  info() {},
package/src/cli.js CHANGED
@@ -10,10 +10,12 @@ import {
10
10
  ANONYMOUS_ARTIFACT_MEDIA_TYPE,
11
11
  AnonymousCompilerError,
12
12
  LAKEBED_VERSION,
13
+ SERVER_ENV_FILE,
13
14
  createAnonymousArtifact,
14
15
  createClaimedArtifact,
15
16
  parseTtlSeconds,
16
- stableStringify
17
+ stableStringify,
18
+ validateServerEnvValues
17
19
  } from "./anonymous.js";
18
20
  import { startAnonymousServer } from "./anonymous-server.js";
19
21
  import { authFromUrl as resolveAuthFromUrl, createGuestAuth, requestOrigin, shooBaseUrlFromEnv } from "./auth.js";
@@ -221,29 +223,45 @@ function createSourcePlugin(sourceStore, target) {
221
223
  };
222
224
  }
223
225
 
224
- async function readServerEnv(sourceStore) {
225
- const env = { ...process.env };
226
- if (!sourceStore.hasFile(".env.lakebed.server")) {
227
- return env;
226
+ function unquoteServerEnvValue(value) {
227
+ if (value.length < 2) {
228
+ return value;
228
229
  }
229
230
 
230
- const contents = await sourceStore.readFile(".env.lakebed.server");
231
- 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()) {
232
242
  const line = rawLine.trim();
233
243
  if (!line || line.startsWith("#")) {
234
244
  continue;
235
245
  }
236
246
 
237
- 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_]*)=(.*)$/);
238
248
  if (!match) {
239
- continue;
249
+ throw new Error(`Invalid ${SERVER_ENV_FILE} line ${index + 1}. Use KEY=value.`);
240
250
  }
241
251
 
242
252
  const [, key, rawValue] = match;
243
- env[key] = rawValue.replace(/^['"]|['"]$/g, "");
253
+ env[key] = unquoteServerEnvValue(rawValue.trim());
244
254
  }
245
255
 
246
- 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));
247
265
  }
248
266
 
249
267
  export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev" } = {}) {
@@ -301,7 +319,7 @@ export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev"
301
319
  app: capsuleModule.default,
302
320
  buildDir,
303
321
  clientOut,
304
- env: await readServerEnv(workingStore),
322
+ env: await readCapsuleServerEnv(workingStore),
305
323
  serverOut,
306
324
  sourceStore: workingStore
307
325
  };
@@ -579,9 +597,9 @@ async function dev(args) {
579
597
  });
580
598
  }
581
599
 
582
- async function buildAnonymousEnvelope(capsuleArg) {
600
+ async function buildAnonymousEnvelope(capsuleArg, sourceStore) {
583
601
  const capsuleDir = resolveCapsuleDir(capsuleArg);
584
- const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
602
+ sourceStore ??= await createMemorySourceStoreFromDirectory(capsuleDir);
585
603
  const built = await buildCapsule({
586
604
  capsuleDir,
587
605
  capsuleId: `anonymous-${Date.now()}`,
@@ -615,7 +633,7 @@ function canDeployAfterClaim(error) {
615
633
  );
616
634
  }
617
635
 
618
- async function buildClaimRequiredEnvelope({ capsuleDir }) {
636
+ async function buildClaimRequiredEnvelope({ capsuleDir, feature = "claimed server features" }) {
619
637
  const sourceStore = new MemorySourceStore();
620
638
  await sourceStore.writeFile(
621
639
  "server/index.ts",
@@ -638,7 +656,7 @@ export default capsule({
638
656
  <p className="text-sm font-semibold uppercase tracking-wide text-cyan-300">Lakebed deploy</p>
639
657
  <h1 className="mt-3 text-3xl font-semibold">Claim required</h1>
640
658
  <p className="mt-4 text-neutral-300">
641
- This capsule uses server-side fetch. Claim this deploy, then run lakebed deploy again to publish the app.
659
+ This capsule uses ${feature}. Claim this deploy, then run lakebed deploy again to publish the app.
642
660
  </p>
643
661
  </section>
644
662
  </main>
@@ -667,9 +685,9 @@ export default capsule({
667
685
  };
668
686
  }
669
687
 
670
- async function buildClaimedEnvelope(capsuleArg) {
688
+ async function buildClaimedEnvelope(capsuleArg, sourceStore) {
671
689
  const capsuleDir = resolveCapsuleDir(capsuleArg);
672
- const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
690
+ sourceStore ??= await createMemorySourceStoreFromDirectory(capsuleDir);
673
691
  const built = await buildCapsule({
674
692
  capsuleDir,
675
693
  capsuleId: `claimed-${Date.now()}`,
@@ -743,13 +761,21 @@ function claimUrlFromDeployMetadata(metadata) {
743
761
  return `${String(metadata.api).replace(/\/+$/g, "")}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
744
762
  }
745
763
 
746
- function deployRequestBody(envelope, ttl) {
747
- return JSON.stringify({
764
+ function deployRequestBody(envelope, ttl, { serverEnv } = {}) {
765
+ const body = {
748
766
  artifact: envelope.artifact,
749
767
  clientBundle: envelope.clientBundle,
750
768
  clientVersion: LAKEBED_VERSION,
751
769
  requestedTtlSeconds: ttl
752
- });
770
+ };
771
+ if (serverEnv !== undefined) {
772
+ body.serverEnv = {
773
+ mode: "replace",
774
+ values: serverEnv
775
+ };
776
+ }
777
+
778
+ return JSON.stringify(body);
753
779
  }
754
780
 
755
781
  async function buildCommand(args) {
@@ -801,6 +827,11 @@ async function readResponseJson(response) {
801
827
  async function deployCommand(args) {
802
828
  const [capsuleArg] = positionals(args);
803
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;
804
835
  const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
805
836
  const api = deployApiUrl(args);
806
837
  const metadata = await readDeployMetadata(capsuleDir);
@@ -815,27 +846,45 @@ async function deployCommand(args) {
815
846
  }
816
847
 
817
848
  let envelope;
818
- try {
819
- envelope = currentDeploy?.claimed ? await buildClaimedEnvelope(capsuleDir) : await buildAnonymousEnvelope(capsuleDir);
820
- } catch (error) {
821
- if (error instanceof AnonymousCompilerError && canUpdate && currentDeploy && !currentDeploy.claimed) {
849
+ if (!currentDeploy?.claimed && hasServerEnvValues) {
850
+ if (canUpdate && currentDeploy) {
822
851
  throw new Error(
823
- `${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.`
824
853
  );
825
854
  }
826
- if ((!canUpdate || !currentDeploy) && canDeployAfterClaim(error)) {
827
- envelope = await buildClaimRequiredEnvelope({ capsuleDir });
828
- } else {
829
- 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
+ }
830
877
  }
831
878
  }
832
- const body = deployRequestBody(envelope, ttl);
879
+ const syncServerEnv = Boolean(currentDeploy?.claimed && serverEnvFileExists);
880
+ const serverEnvForUpdate = syncServerEnv ? serverEnv : undefined;
881
+ let serverEnvSynced = false;
833
882
  let mode = "created";
834
883
  let response;
835
884
 
836
885
  if (canUpdate) {
837
886
  response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`, {
838
- body,
887
+ body: deployRequestBody(envelope, ttl, { serverEnv: serverEnvForUpdate }),
839
888
  headers: {
840
889
  "Authorization": `Bearer ${metadata.claimToken}`,
841
890
  "Content-Type": "application/json"
@@ -846,13 +895,17 @@ async function deployCommand(args) {
846
895
  if (response.status === 404 || response.status === 410) {
847
896
  mode = "created";
848
897
  response = null;
898
+ if (serverEnvForUpdate !== undefined && hasServerEnvValues) {
899
+ envelope = await buildClaimRequiredEnvelope({ capsuleDir, feature: "server env" });
900
+ }
849
901
  } else {
850
902
  mode = "updated";
903
+ serverEnvSynced = serverEnvForUpdate !== undefined;
851
904
  }
852
905
  }
853
906
 
854
907
  response ??= await fetch(`${api}/v1/anonymous-deploys`, {
855
- body,
908
+ body: deployRequestBody(envelope, ttl),
856
909
  headers: {
857
910
  "Content-Type": "application/json"
858
911
  },
@@ -871,7 +924,16 @@ async function deployCommand(args) {
871
924
  }
872
925
 
873
926
  if (hasFlag(args, "--json")) {
874
- console.log(JSON.stringify(envelope.claimRequired ? { ...deployed, claimRequired: true } : 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
+ );
875
937
  return;
876
938
  }
877
939
 
@@ -892,8 +954,14 @@ async function deployCommand(args) {
892
954
  console.log(` requests: ${deployed.limits.requestsPerDay} / day`);
893
955
  console.log(` mutations: ${deployed.limits.mutationsPerDay} / day`);
894
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
+ }
895
963
  if (envelope.claimRequired) {
896
- console.log("\nThis app needs a claimed deploy before server-side fetch can run.");
964
+ console.log("\nThis app needs a claimed deploy before server-side fetch or server env can run.");
897
965
  console.log("Open the claim URL, then run lakebed deploy again.");
898
966
  }
899
967
  }
@@ -1106,6 +1174,7 @@ lakebed logs --port 3000
1106
1174
  - Use Tailwind classes directly in JSX.
1107
1175
  - Do not add a CSS, PostCSS, or Tailwind build pipeline.
1108
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\`.
1109
1178
  - Add Google sign-in with \`<SignInWithGoogle />\` or \`signInWithGoogle()\` from \`lakebed/client\`.
1110
1179
  - Keep \`shared/\` free of DOM, Node, env, and Lakebed runtime imports.
1111
1180
 
@@ -1116,6 +1185,7 @@ lakebed logs --port 3000
1116
1185
  - Guest auth locally, with built-in Google sign-in through Shoo.
1117
1186
  - No file storage.
1118
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.
1119
1189
  - Local state resets when \`lakebed dev\` restarts.
1120
1190
  `;
1121
1191
  }
@@ -1223,6 +1293,7 @@ export function cleanTodoText(value: string): string {
1223
1293
  }
1224
1294
  `,
1225
1295
  ".gitignore": `.lakebed/
1296
+ .env.lakebed.server
1226
1297
  `,
1227
1298
  "README.md": `# ${title}
1228
1299
 
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.5";
1
+ export const LAKEBED_VERSION = "0.0.6";