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