lakebed 0.0.2 → 0.0.3
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 +61 -5
- package/package.json +6 -5
- package/src/anonymous-server.js +2013 -70
- package/src/anonymous.js +14 -5
- package/src/auth.js +155 -0
- package/src/cli.js +135 -53
- package/src/client.d.ts +55 -0
- package/src/client.js +445 -9
- package/src/server.d.ts +7 -0
package/src/anonymous.js
CHANGED
|
@@ -16,6 +16,7 @@ export const DEFAULT_ANONYMOUS_LIMITS = {
|
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
const expressionOps = new Set(["arg", "auth", "call", "row"]);
|
|
19
|
+
const authFields = new Set(["displayName", "email", "emailVerified", "isAuthenticated", "isGuest", "name", "picture", "provider", "userId"]);
|
|
19
20
|
|
|
20
21
|
export class AnonymousCompilerError extends Error {
|
|
21
22
|
constructor(diagnostics) {
|
|
@@ -131,9 +132,17 @@ function createSymbolicArg(index) {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
function createSymbolicAuth() {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
return {
|
|
136
|
+
displayName: new SymbolicValue(["auth", "displayName"], "Trace Guest"),
|
|
137
|
+
email: new SymbolicValue(["auth", "email"], "trace@example.test"),
|
|
138
|
+
emailVerified: new SymbolicValue(["auth", "emailVerified"], true),
|
|
139
|
+
isAuthenticated: new SymbolicValue(["auth", "isAuthenticated"], true),
|
|
140
|
+
isGuest: new SymbolicValue(["auth", "isGuest"], false),
|
|
141
|
+
name: new SymbolicValue(["auth", "name"], "Trace Guest"),
|
|
142
|
+
picture: new SymbolicValue(["auth", "picture"], "https://example.test/avatar.png"),
|
|
143
|
+
provider: new SymbolicValue(["auth", "provider"], "google"),
|
|
144
|
+
userId: new SymbolicValue(["auth", "userId"], "guest:trace")
|
|
145
|
+
};
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
function createSymbolicRow({ auth, idExpr, scanId, schema, tableName }) {
|
|
@@ -484,7 +493,7 @@ function compileServerToIr(app, schema) {
|
|
|
484
493
|
return { diagnostics, mutations, queries };
|
|
485
494
|
}
|
|
486
495
|
|
|
487
|
-
export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = "0.0.
|
|
496
|
+
export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = "0.0.3" }) {
|
|
488
497
|
const sourceFiles = await readSourceFiles(sourceStore);
|
|
489
498
|
const diagnostics = forbiddenSourceDiagnostics(sourceFiles);
|
|
490
499
|
const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
|
|
@@ -561,7 +570,7 @@ function validateExpression(expr, path, diagnostics) {
|
|
|
561
570
|
}
|
|
562
571
|
|
|
563
572
|
if (op === "auth") {
|
|
564
|
-
if ((expr[1]
|
|
573
|
+
if (!authFields.has(expr[1]) || expr.length !== 2) {
|
|
565
574
|
diagnostics.push(diagnostic(path, "Invalid auth expression."));
|
|
566
575
|
}
|
|
567
576
|
return;
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const DEFAULT_SHOO_BASE_URL = "https://shoo.dev";
|
|
2
|
+
|
|
3
|
+
export function shooBaseUrlFromEnv(env = process.env) {
|
|
4
|
+
return String(env.LAKEBED_SHOO_BASE_URL ?? env.SHOO_BASE_URL ?? DEFAULT_SHOO_BASE_URL).replace(/\/+$/g, "");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function toGuestName(name) {
|
|
8
|
+
return (
|
|
9
|
+
String(name ?? "local")
|
|
10
|
+
.replace(/^guest:/, "")
|
|
11
|
+
.trim()
|
|
12
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
13
|
+
.replace(/^-+|-+$/g, "")
|
|
14
|
+
.toLowerCase() || "local"
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function toDisplayName(name) {
|
|
19
|
+
return toGuestName(name)
|
|
20
|
+
.split(/[-_\s.]+/)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
|
23
|
+
.join(" ");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createGuestAuth(name) {
|
|
27
|
+
const guestName = toGuestName(name);
|
|
28
|
+
return {
|
|
29
|
+
displayName: toDisplayName(guestName),
|
|
30
|
+
isAuthenticated: false,
|
|
31
|
+
isGuest: true,
|
|
32
|
+
provider: "guest",
|
|
33
|
+
userId: `guest:${guestName}`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function requestOrigin(req, fallbackUrl) {
|
|
38
|
+
const forwardedHost = String(req.headers["x-forwarded-host"] ?? "")
|
|
39
|
+
.split(",")[0]
|
|
40
|
+
.trim();
|
|
41
|
+
const host = forwardedHost || req.headers.host || (fallbackUrl ? new URL(fallbackUrl).host : "localhost");
|
|
42
|
+
const forwardedProto = String(req.headers["x-forwarded-proto"] ?? "")
|
|
43
|
+
.split(",")[0]
|
|
44
|
+
.trim();
|
|
45
|
+
const protocol = forwardedProto || (fallbackUrl ? new URL(fallbackUrl).protocol.replace(/:$/g, "") : "http");
|
|
46
|
+
return `${protocol}://${host}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decodeBase64UrlJson(value) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function decodeIdentityClaims(idToken) {
|
|
58
|
+
if (!idToken || typeof idToken !== "string") {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parts = idToken.split(".");
|
|
63
|
+
if (parts.length < 2) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return decodeBase64UrlJson(parts[1]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stringClaim(claims, name) {
|
|
71
|
+
return typeof claims?.[name] === "string" ? claims[name] : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function booleanClaim(claims, name) {
|
|
75
|
+
return typeof claims?.[name] === "boolean" ? claims[name] : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function authFromClaims(claims) {
|
|
79
|
+
const pairwiseSub = stringClaim(claims, "pairwise_sub") ?? stringClaim(claims, "sub");
|
|
80
|
+
if (!pairwiseSub) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const name = stringClaim(claims, "name");
|
|
85
|
+
const email = stringClaim(claims, "email");
|
|
86
|
+
return {
|
|
87
|
+
displayName: name ?? email ?? "Google User",
|
|
88
|
+
email,
|
|
89
|
+
emailVerified: booleanClaim(claims, "email_verified"),
|
|
90
|
+
isAuthenticated: true,
|
|
91
|
+
isGuest: false,
|
|
92
|
+
name,
|
|
93
|
+
picture: stringClaim(claims, "picture"),
|
|
94
|
+
provider: "google",
|
|
95
|
+
userId: `google:${pairwiseSub}`
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isLocallyPlausibleShooToken(claims, origin, shooBaseUrl) {
|
|
100
|
+
if (!claims || typeof claims !== "object") {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (claims.aud !== `origin:${new URL(origin).origin}`) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeof claims.iss !== "string" || claims.iss.replace(/\/+$/g, "") !== shooBaseUrl.replace(/\/+$/g, "")) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return typeof claims.exp !== "number" || claims.exp * 1000 > Date.now();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function verifyShooAuth({ origin, shooBaseUrl = shooBaseUrlFromEnv(), token }) {
|
|
116
|
+
if (!token) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const response = await fetch(new URL("/session/check", shooBaseUrl), {
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${token}`,
|
|
123
|
+
Origin: new URL(origin).origin
|
|
124
|
+
},
|
|
125
|
+
method: "POST"
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const claims = decodeIdentityClaims(token);
|
|
133
|
+
if (!isLocallyPlausibleShooToken(claims, origin, shooBaseUrl)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return authFromClaims(claims);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function authFromUrl({ defaultAuth = createGuestAuth("local"), onError, origin, shooBaseUrl, url }) {
|
|
141
|
+
const token = url.searchParams.get("lakebed_token") ?? url.searchParams.get("auth_token") ?? "";
|
|
142
|
+
if (token) {
|
|
143
|
+
try {
|
|
144
|
+
const shooAuth = await verifyShooAuth({ origin, shooBaseUrl, token });
|
|
145
|
+
if (shooAuth) {
|
|
146
|
+
return shooAuth;
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await onError?.(error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const guestName = url.searchParams.get("lakebed_guest") ?? url.searchParams.get("guest");
|
|
154
|
+
return guestName ? createGuestAuth(guestName) : defaultAuth;
|
|
155
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
stableStringify
|
|
15
15
|
} from "./anonymous.js";
|
|
16
16
|
import { startAnonymousServer } from "./anonymous-server.js";
|
|
17
|
+
import { authFromUrl as resolveAuthFromUrl, createGuestAuth, requestOrigin, shooBaseUrlFromEnv } from "./auth.js";
|
|
17
18
|
import { LogBuffer, StateCell } from "./runtime.js";
|
|
18
19
|
import { createMemorySourceStoreFromDirectory, sourcePathDirname, sourcePathJoin } from "./source-store.js";
|
|
19
20
|
|
|
@@ -30,8 +31,8 @@ Usage:
|
|
|
30
31
|
lakebed new <name> [--template todo]
|
|
31
32
|
lakebed dev <capsule-dir> [--port 3000]
|
|
32
33
|
lakebed build <capsule-dir> --target anonymous [--out .lakebed/artifacts/app.json] [--json]
|
|
33
|
-
lakebed deploy
|
|
34
|
-
lakebed anonymous-server [--port 8787] [--public-root-url <url>]
|
|
34
|
+
lakebed deploy [capsule-dir] [--ttl 7d] [--api <url>] [--json]
|
|
35
|
+
lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
|
|
35
36
|
lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
|
|
36
37
|
lakebed run-many <capsule-dir> [--count 20] [--base-port 4000]
|
|
37
38
|
lakebed auth as <name>
|
|
@@ -91,31 +92,6 @@ function authFile() {
|
|
|
91
92
|
return resolve(root, ".lakebed/auth.json");
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
function toGuestName(name) {
|
|
95
|
-
return String(name ?? "local")
|
|
96
|
-
.replace(/^guest:/, "")
|
|
97
|
-
.trim()
|
|
98
|
-
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
99
|
-
.replace(/^-+|-+$/g, "")
|
|
100
|
-
.toLowerCase() || "local";
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function toDisplayName(name) {
|
|
104
|
-
return toGuestName(name)
|
|
105
|
-
.split(/[-_\s.]+/)
|
|
106
|
-
.filter(Boolean)
|
|
107
|
-
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
|
108
|
-
.join(" ");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function createGuestAuth(name) {
|
|
112
|
-
const guestName = toGuestName(name);
|
|
113
|
-
return {
|
|
114
|
-
userId: `guest:${guestName}`,
|
|
115
|
-
displayName: toDisplayName(guestName)
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
95
|
async function readAuth() {
|
|
120
96
|
try {
|
|
121
97
|
return JSON.parse(await readFile(authFile(), "utf8"));
|
|
@@ -129,11 +105,6 @@ async function writeAuth(auth) {
|
|
|
129
105
|
await writeFile(authFile(), `${JSON.stringify(auth, null, 2)}\n`);
|
|
130
106
|
}
|
|
131
107
|
|
|
132
|
-
function authFromUrl(url, defaultAuth) {
|
|
133
|
-
const guestName = url.searchParams.get("lakebed_guest") ?? url.searchParams.get("guest");
|
|
134
|
-
return guestName ? createGuestAuth(guestName) : defaultAuth;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
108
|
function isBareSpecifier(path) {
|
|
138
109
|
return !path.startsWith(".") && !path.startsWith("/") && !/^[a-zA-Z]:/.test(path);
|
|
139
110
|
}
|
|
@@ -332,7 +303,7 @@ export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev"
|
|
|
332
303
|
};
|
|
333
304
|
}
|
|
334
305
|
|
|
335
|
-
function html(title) {
|
|
306
|
+
function html(title, { shooBaseUrl } = {}) {
|
|
336
307
|
return `<!doctype html>
|
|
337
308
|
<html lang="en">
|
|
338
309
|
<head>
|
|
@@ -342,6 +313,7 @@ function html(title) {
|
|
|
342
313
|
</head>
|
|
343
314
|
<body>
|
|
344
315
|
<div id="app"></div>
|
|
316
|
+
<script>window.__LAKEBED_AUTH__ = ${JSON.stringify({ shooBaseUrl })};</script>
|
|
345
317
|
<script type="module" src="/client.js"></script>
|
|
346
318
|
<script>
|
|
347
319
|
const tailwind = document.createElement("script");
|
|
@@ -394,7 +366,14 @@ async function runMutation({ app, stateCell, auth, logs, env, name, args }) {
|
|
|
394
366
|
);
|
|
395
367
|
}
|
|
396
368
|
|
|
397
|
-
export async function startDevServer({
|
|
369
|
+
export async function startDevServer({
|
|
370
|
+
capsuleDir,
|
|
371
|
+
sourceStore,
|
|
372
|
+
port = 3000,
|
|
373
|
+
capsuleId = "dev",
|
|
374
|
+
quiet = false,
|
|
375
|
+
shooBaseUrl = shooBaseUrlFromEnv()
|
|
376
|
+
} = {}) {
|
|
398
377
|
const resolvedCapsuleDir = resolveCapsuleDir(capsuleDir);
|
|
399
378
|
const built = await buildCapsule({ capsuleDir: resolvedCapsuleDir, sourceStore, capsuleId });
|
|
400
379
|
const defaultAuth = await readAuth();
|
|
@@ -406,9 +385,9 @@ export async function startDevServer({ capsuleDir, sourceStore, port = 3000, cap
|
|
|
406
385
|
try {
|
|
407
386
|
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
408
387
|
|
|
409
|
-
if (requestUrl.pathname === "/" || requestUrl.pathname === "/index.html") {
|
|
388
|
+
if (requestUrl.pathname === "/" || requestUrl.pathname === "/index.html" || requestUrl.pathname === "/auth/callback") {
|
|
410
389
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
411
|
-
res.end(html(built.app.name ?? "Lakebed Capsule"));
|
|
390
|
+
res.end(html(built.app.name ?? "Lakebed Capsule", { shooBaseUrl }));
|
|
412
391
|
return;
|
|
413
392
|
}
|
|
414
393
|
|
|
@@ -529,14 +508,22 @@ export async function startDevServer({ capsuleDir, sourceStore, port = 3000, cap
|
|
|
529
508
|
});
|
|
530
509
|
});
|
|
531
510
|
|
|
532
|
-
server.on("upgrade", (req, socket, head) => {
|
|
511
|
+
server.on("upgrade", async (req, socket, head) => {
|
|
533
512
|
const requestUrl = new URL(req.url ?? "/", "http://lakebed.local");
|
|
534
513
|
if (requestUrl.pathname !== "/__lakebed/ws") {
|
|
535
514
|
socket.destroy();
|
|
536
515
|
return;
|
|
537
516
|
}
|
|
538
517
|
|
|
539
|
-
const auth =
|
|
518
|
+
const auth = await resolveAuthFromUrl({
|
|
519
|
+
defaultAuth,
|
|
520
|
+
onError: (error) => {
|
|
521
|
+
logs.append("warn", "google auth verification failed", { error: error instanceof Error ? error.message : String(error) });
|
|
522
|
+
},
|
|
523
|
+
origin: requestOrigin(req),
|
|
524
|
+
shooBaseUrl,
|
|
525
|
+
url: requestUrl
|
|
526
|
+
});
|
|
540
527
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
541
528
|
wss.emit("connection", ws, req, auth);
|
|
542
529
|
});
|
|
@@ -615,6 +602,55 @@ function defaultArtifactPath(capsuleDir) {
|
|
|
615
602
|
return resolve(root, ".lakebed/artifacts", `${basename(capsuleDir)}.anonymous.json`);
|
|
616
603
|
}
|
|
617
604
|
|
|
605
|
+
function deployMetadataPath(capsuleDir) {
|
|
606
|
+
return resolve(capsuleDir, ".lakebed/deploy.json");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function readDeployMetadata(capsuleDir) {
|
|
610
|
+
try {
|
|
611
|
+
return JSON.parse(await readFile(deployMetadataPath(capsuleDir), "utf8"));
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (error?.code === "ENOENT") {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
throw new Error(`Unable to read Lakebed deploy metadata: ${error instanceof Error ? error.message : String(error)}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function writeDeployMetadata(capsuleDir, metadata) {
|
|
622
|
+
const path = deployMetadataPath(capsuleDir);
|
|
623
|
+
await mkdir(dirname(path), { recursive: true });
|
|
624
|
+
await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function claimTokenFromDeployResponse(deployed) {
|
|
628
|
+
if (!deployed?.claimUrl || !deployed?.deployId) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
const url = new URL(deployed.claimUrl);
|
|
634
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
635
|
+
if (segments[0] === "claim" && segments[1] === deployed.deployId) {
|
|
636
|
+
return segments[2] ?? null;
|
|
637
|
+
}
|
|
638
|
+
} catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function deployRequestBody(envelope, ttl) {
|
|
646
|
+
return JSON.stringify({
|
|
647
|
+
artifact: envelope.artifact,
|
|
648
|
+
clientBundle: envelope.clientBundle,
|
|
649
|
+
clientVersion: "0.0.3",
|
|
650
|
+
requestedTtlSeconds: ttl
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
618
654
|
async function buildCommand(args) {
|
|
619
655
|
const [capsuleArg] = positionals(args);
|
|
620
656
|
const target = readArg(args, "--target", "anonymous");
|
|
@@ -663,32 +699,65 @@ async function readResponseJson(response) {
|
|
|
663
699
|
|
|
664
700
|
async function deployCommand(args) {
|
|
665
701
|
const [capsuleArg] = positionals(args);
|
|
666
|
-
const
|
|
702
|
+
const capsuleDir = capsuleArg ? resolveCapsuleDir(capsuleArg) : root;
|
|
703
|
+
const envelope = await buildAnonymousEnvelope(capsuleDir);
|
|
667
704
|
const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
|
|
668
705
|
const api = deployApiUrl(args);
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
706
|
+
const body = deployRequestBody(envelope, ttl);
|
|
707
|
+
const metadata = await readDeployMetadata(capsuleDir);
|
|
708
|
+
const canUpdate =
|
|
709
|
+
metadata?.api === api && typeof metadata?.deployId === "string" && typeof metadata?.claimToken === "string";
|
|
710
|
+
let mode = "created";
|
|
711
|
+
let response;
|
|
712
|
+
|
|
713
|
+
if (canUpdate) {
|
|
714
|
+
response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`, {
|
|
715
|
+
body,
|
|
716
|
+
headers: {
|
|
717
|
+
"Authorization": `Bearer ${metadata.claimToken}`,
|
|
718
|
+
"Content-Type": "application/json"
|
|
719
|
+
},
|
|
720
|
+
method: "PUT"
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
if (response.status === 404 || response.status === 410) {
|
|
724
|
+
mode = "created";
|
|
725
|
+
response = null;
|
|
726
|
+
} else {
|
|
727
|
+
mode = "updated";
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
response ??= await fetch(`${api}/v1/anonymous-deploys`, {
|
|
732
|
+
body,
|
|
676
733
|
headers: {
|
|
677
734
|
"Content-Type": "application/json"
|
|
678
735
|
},
|
|
679
736
|
method: "POST"
|
|
680
737
|
});
|
|
681
738
|
const deployed = await readResponseJson(response);
|
|
739
|
+
const claimToken = claimTokenFromDeployResponse(deployed) ?? metadata?.claimToken;
|
|
740
|
+
if (claimToken) {
|
|
741
|
+
await writeDeployMetadata(capsuleDir, {
|
|
742
|
+
api,
|
|
743
|
+
claimToken,
|
|
744
|
+
deployId: deployed.deployId,
|
|
745
|
+
updatedAt: new Date().toISOString(),
|
|
746
|
+
url: deployed.url
|
|
747
|
+
});
|
|
748
|
+
}
|
|
682
749
|
|
|
683
750
|
if (hasFlag(args, "--json")) {
|
|
684
751
|
console.log(JSON.stringify(deployed, null, 2));
|
|
685
752
|
return;
|
|
686
753
|
}
|
|
687
754
|
|
|
688
|
-
console.log("
|
|
755
|
+
console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
|
|
689
756
|
console.log(`App: ${deployed.url}`);
|
|
690
757
|
console.log(`Expires: ${deployed.expiresAt}`);
|
|
691
|
-
|
|
758
|
+
if (deployed.claimUrl) {
|
|
759
|
+
console.log(`Claim: ${deployed.claimUrl}`);
|
|
760
|
+
}
|
|
692
761
|
console.log(`Inspect: lakebed inspect ${deployed.deployId}`);
|
|
693
762
|
console.log("\nLimits:");
|
|
694
763
|
console.log(` source/artifact: ${deployed.limits.artifactBytes} bytes`);
|
|
@@ -833,7 +902,7 @@ lakebed dev .
|
|
|
833
902
|
Deploy:
|
|
834
903
|
|
|
835
904
|
\`\`\`sh
|
|
836
|
-
lakebed deploy
|
|
905
|
+
lakebed deploy
|
|
837
906
|
\`\`\`
|
|
838
907
|
|
|
839
908
|
Inspect local state while \`lakebed dev\` is running:
|
|
@@ -852,14 +921,15 @@ lakebed logs --port 3000
|
|
|
852
921
|
- Do not use Node built-ins in app code.
|
|
853
922
|
- Use Tailwind classes directly in JSX.
|
|
854
923
|
- Do not add a CSS, PostCSS, or Tailwind build pipeline.
|
|
855
|
-
- Use
|
|
924
|
+
- Use auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
|
|
925
|
+
- Add Google sign-in with \`<SignInWithGoogle />\` or \`signInWithGoogle()\` from \`lakebed/client\`.
|
|
856
926
|
- Keep \`shared/\` free of DOM, Node, env, and Lakebed runtime imports.
|
|
857
927
|
|
|
858
928
|
## Current Limits
|
|
859
929
|
|
|
860
930
|
- One server entry.
|
|
861
931
|
- One client entry.
|
|
862
|
-
- Guest auth
|
|
932
|
+
- Guest auth locally, with built-in Google sign-in through Shoo.
|
|
863
933
|
- No file storage.
|
|
864
934
|
- No outbound fetch in anonymous deploys.
|
|
865
935
|
- Local state resets when \`lakebed dev\` restarts.
|
|
@@ -905,13 +975,14 @@ export default capsule({
|
|
|
905
975
|
}
|
|
906
976
|
});
|
|
907
977
|
`,
|
|
908
|
-
"client/index.tsx": `import { useAuth, useMutation, useQuery } from "lakebed/client";
|
|
978
|
+
"client/index.tsx": `import { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
|
|
909
979
|
import { cleanTodoText, type Todo } from "../shared/todo";
|
|
910
980
|
|
|
911
981
|
export function App() {
|
|
912
982
|
const auth = useAuth();
|
|
913
983
|
const todos = useQuery<Todo[]>("todos");
|
|
914
984
|
const addTodo = useMutation<[text: string], void>("addTodo");
|
|
985
|
+
const authLabel = auth.email ?? auth.displayName;
|
|
915
986
|
|
|
916
987
|
async function onSubmit(event: SubmitEvent) {
|
|
917
988
|
event.preventDefault();
|
|
@@ -929,7 +1000,16 @@ export function App() {
|
|
|
929
1000
|
return (
|
|
930
1001
|
<main className="min-h-screen bg-black px-6 py-10 text-white">
|
|
931
1002
|
<section className="mx-auto max-w-2xl">
|
|
932
|
-
<
|
|
1003
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
1004
|
+
<p className="font-mono text-sm text-neutral-500">signed in as {authLabel}</p>
|
|
1005
|
+
{auth.isGuest ? (
|
|
1006
|
+
<SignInWithGoogle className="border border-neutral-700 px-3 py-1.5 text-sm font-medium text-neutral-200 hover:border-white hover:text-white" />
|
|
1007
|
+
) : (
|
|
1008
|
+
<button className="text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
|
|
1009
|
+
Sign out
|
|
1010
|
+
</button>
|
|
1011
|
+
)}
|
|
1012
|
+
</div>
|
|
933
1013
|
<h1 className="mb-8 text-5xl font-bold tracking-tight">${title}</h1>
|
|
934
1014
|
<form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
|
|
935
1015
|
<input className="min-w-0 flex-1 border border-neutral-700 bg-black px-3 py-2 text-white outline-none focus:border-white" name="text" placeholder="Add a todo" />
|
|
@@ -957,6 +1037,8 @@ export function App() {
|
|
|
957
1037
|
export function cleanTodoText(value: string): string {
|
|
958
1038
|
return value.trim().slice(0, 160);
|
|
959
1039
|
}
|
|
1040
|
+
`,
|
|
1041
|
+
".gitignore": `.lakebed/
|
|
960
1042
|
`,
|
|
961
1043
|
"README.md": `# ${title}
|
|
962
1044
|
|
package/src/client.d.ts
CHANGED
|
@@ -1,8 +1,63 @@
|
|
|
1
|
+
import type { ComponentChildren, JSX } from "preact";
|
|
2
|
+
|
|
1
3
|
export type Auth = {
|
|
2
4
|
userId: string;
|
|
3
5
|
displayName: string;
|
|
6
|
+
provider: "guest" | "google";
|
|
7
|
+
isGuest: boolean;
|
|
8
|
+
isAuthenticated: boolean;
|
|
9
|
+
email?: string;
|
|
10
|
+
emailVerified?: boolean;
|
|
11
|
+
name?: string;
|
|
12
|
+
picture?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type Identity = {
|
|
16
|
+
userId: string | null;
|
|
17
|
+
token?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type IdentityClaims = {
|
|
21
|
+
iss?: string;
|
|
22
|
+
aud?: string;
|
|
23
|
+
sub?: string;
|
|
24
|
+
iat?: number;
|
|
25
|
+
exp?: number;
|
|
26
|
+
jti?: string;
|
|
27
|
+
pairwise_sub?: string;
|
|
28
|
+
email?: string;
|
|
29
|
+
email_verified?: boolean;
|
|
30
|
+
name?: string;
|
|
31
|
+
picture?: string;
|
|
4
32
|
};
|
|
5
33
|
|
|
34
|
+
export type SignInWithGoogleOptions = {
|
|
35
|
+
callbackPath?: string;
|
|
36
|
+
clientId?: string;
|
|
37
|
+
redirectUri?: string;
|
|
38
|
+
returnTo?: string;
|
|
39
|
+
shooBaseUrl?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type SignInWithGoogleResult = {
|
|
43
|
+
url: string;
|
|
44
|
+
bundle: {
|
|
45
|
+
state: string;
|
|
46
|
+
verifier: string;
|
|
47
|
+
challenge: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type SignInWithGoogleProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "children"> &
|
|
52
|
+
SignInWithGoogleOptions & {
|
|
53
|
+
children?: ComponentChildren;
|
|
54
|
+
};
|
|
55
|
+
|
|
6
56
|
export function useAuth(): Auth;
|
|
57
|
+
export function signInWithGoogle(options?: SignInWithGoogleOptions): Promise<SignInWithGoogleResult>;
|
|
58
|
+
export function signOut(): void;
|
|
59
|
+
export function getIdentity(): Identity;
|
|
60
|
+
export function decodeIdentityClaims(idToken?: string): IdentityClaims | null;
|
|
61
|
+
export function SignInWithGoogle(props?: SignInWithGoogleProps): JSX.Element;
|
|
7
62
|
export function useQuery<T>(name: string): T;
|
|
8
63
|
export function useMutation<TArgs extends unknown[], TResult>(name: string): (...args: TArgs) => Promise<TResult>;
|