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 +23 -1
- package/package.json +1 -1
- package/src/anonymous-server.js +113 -9
- package/src/anonymous.js +73 -3
- package/src/cli.js +106 -35
- package/src/version.js +1 -1
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
|
|
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
package/src/anonymous-server.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return env;
|
|
226
|
+
function unquoteServerEnvValue(value) {
|
|
227
|
+
if (value.length < 2) {
|
|
228
|
+
return value;
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
const
|
|
231
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
}
|
|
829
|
-
|
|
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
|
|
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(
|
|
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.
|
|
1
|
+
export const LAKEBED_VERSION = "0.0.6";
|