lakebed 0.0.17 → 0.0.19

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/src/anonymous.js CHANGED
@@ -29,7 +29,8 @@ 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", "name", "picture", "provider", "userId"]);
32
+ const authFields = new Set(["displayName", "email", "emailVerified", "isAuthenticated", "isGuest", "picture", "provider", "userId"]);
33
+ const endpointMethodPattern = /^[A-Z0-9!#$%&'*+.^_`|~-]+$/;
33
34
  const sourceModuleCache = new Map();
34
35
 
35
36
  export class AnonymousCompilerError extends Error {
@@ -228,7 +229,6 @@ function createSymbolicAuth() {
228
229
  emailVerified: new SymbolicValue(["auth", "emailVerified"], true),
229
230
  isAuthenticated: new SymbolicValue(["auth", "isAuthenticated"], true),
230
231
  isGuest: new SymbolicValue(["auth", "isGuest"], false),
231
- name: new SymbolicValue(["auth", "name"], "Trace Guest"),
232
232
  picture: new SymbolicValue(["auth", "picture"], "https://example.test/avatar.png"),
233
233
  provider: new SymbolicValue(["auth", "provider"], "google"),
234
234
  userId: new SymbolicValue(["auth", "userId"], "guest:trace")
@@ -248,6 +248,8 @@ function createSymbolicRow({ auth, idExpr, scanId, schema, tableName }) {
248
248
  row[fieldName] = auth.userId;
249
249
  } else if (fieldName === "authorName") {
250
250
  row[fieldName] = auth.displayName;
251
+ } else if (fieldName === "authorPicture") {
252
+ row[fieldName] = auth.picture;
251
253
  } else if (field.kind === "boolean") {
252
254
  row[fieldName] = true;
253
255
  } else {
@@ -486,6 +488,69 @@ function serializeSchema(schema) {
486
488
  return { diagnostics, schema: cleanSchema };
487
489
  }
488
490
 
491
+ function isReservedEndpointPath(path) {
492
+ return (
493
+ path === "/" ||
494
+ path === "/index.html" ||
495
+ path === "/client.js" ||
496
+ path === "/auth/callback" ||
497
+ path.startsWith("/auth/") ||
498
+ path === "/__lakebed" ||
499
+ path.startsWith("/__lakebed/") ||
500
+ path === "/__span" ||
501
+ path.startsWith("/__span/")
502
+ );
503
+ }
504
+
505
+ function validateEndpointRoute({ method, path }, diagnosticPath, diagnostics) {
506
+ if (typeof method !== "string" || !endpointMethodPattern.test(method)) {
507
+ diagnostics.push(diagnostic(diagnosticPath, "Endpoint method must be a valid uppercase HTTP method."));
508
+ }
509
+
510
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\") || path.includes("?") || path.includes("#")) {
511
+ diagnostics.push(diagnostic(diagnosticPath, "Endpoint path must be an absolute app path like /webhooks/stripe."));
512
+ return;
513
+ }
514
+
515
+ if (isReservedEndpointPath(path)) {
516
+ diagnostics.push(diagnostic(diagnosticPath, `Endpoint path ${path} is reserved by Lakebed.`));
517
+ }
518
+ }
519
+
520
+ function serializeEndpoints(endpoints) {
521
+ const diagnostics = [];
522
+ const cleanEndpoints = {};
523
+ const seenRoutes = new Map();
524
+
525
+ for (const [name, endpoint] of Object.entries(endpoints ?? {})) {
526
+ const diagnosticPath = `server.index.endpoints.${name}`;
527
+ if (!endpoint || endpoint.kind !== "endpoint" || typeof endpoint.handler !== "function") {
528
+ diagnostics.push(diagnostic("server/index.ts", `Endpoint ${name} must be defined with endpoint({ method, path }, handler).`));
529
+ continue;
530
+ }
531
+
532
+ const method = String(endpoint.method ?? "").toUpperCase();
533
+ const path = String(endpoint.path ?? "");
534
+ validateEndpointRoute({ method, path }, diagnosticPath, diagnostics);
535
+
536
+ const routeKey = `${method} ${path}`;
537
+ const existing = seenRoutes.get(routeKey);
538
+ if (existing) {
539
+ diagnostics.push(diagnostic("server/index.ts", `Endpoint ${name} duplicates ${existing} at ${routeKey}.`));
540
+ continue;
541
+ }
542
+ seenRoutes.set(routeKey, name);
543
+
544
+ cleanEndpoints[name] = {
545
+ method,
546
+ op: "source",
547
+ path
548
+ };
549
+ }
550
+
551
+ return { diagnostics, endpoints: cleanEndpoints };
552
+ }
553
+
489
554
  function compileQueryHandler({ handler, name, schema }) {
490
555
  const { ctx, recorder } = createTraceContext({ mode: "query", schema });
491
556
  try {
@@ -614,7 +679,12 @@ export async function createAnonymousArtifact({ app, clientOut, serverOut, sourc
614
679
  const sourceFiles = await readSourceFiles(sourceStore);
615
680
  const diagnostics = forbiddenSourceDiagnostics(sourceFiles, { allowAsync: Boolean(serverOut) });
616
681
  const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
682
+ const { diagnostics: endpointDiagnostics, endpoints } = serializeEndpoints(app.endpoints);
617
683
  diagnostics.push(...schemaDiagnostics);
684
+ diagnostics.push(...endpointDiagnostics);
685
+ if (!serverOut && Object.keys(endpoints).length > 0) {
686
+ diagnostics.push(diagnostic("server/index.ts", "Endpoints require the source runtime. Build with a bundled server module."));
687
+ }
618
688
 
619
689
  if (diagnostics.length > 0) {
620
690
  throw new AnonymousCompilerError(diagnostics);
@@ -630,6 +700,7 @@ export async function createAnonymousArtifact({ app, clientOut, serverOut, sourc
630
700
  const sourceSnapshotHash = sha256(stableStringify(sourceManifest));
631
701
  const server = serverBundle
632
702
  ? {
703
+ endpoints,
633
704
  helpers: {},
634
705
  imports: ["lakebed/server"],
635
706
  mutations: Object.fromEntries(Object.keys(app.mutations ?? {}).map((name) => [name, { op: "source" }])),
@@ -673,6 +744,7 @@ export async function createAnonymousArtifact({ app, clientOut, serverOut, sourc
673
744
  maxValueBytes: DEFAULT_ANONYMOUS_LIMITS.maxValueBytes
674
745
  },
675
746
  server: server ?? {
747
+ endpoints: {},
676
748
  helpers: {},
677
749
  imports: ["lakebed/server"],
678
750
  mutations: compiled.mutations,
@@ -706,7 +778,9 @@ export async function createClaimedArtifact({ app, clientOut, serverOut, sourceS
706
778
  entry.message !== "Async server handlers are not part of the anonymous IR yet. Use synchronous Lakebed database operations."
707
779
  );
708
780
  const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
781
+ const { diagnostics: endpointDiagnostics, endpoints } = serializeEndpoints(app.endpoints);
709
782
  diagnostics.push(...schemaDiagnostics);
783
+ diagnostics.push(...endpointDiagnostics);
710
784
  if (diagnostics.length > 0) {
711
785
  throw new AnonymousCompilerError(diagnostics);
712
786
  }
@@ -738,6 +812,7 @@ export async function createClaimedArtifact({ app, clientOut, serverOut, sourceS
738
812
  maxValueBytes: DEFAULT_ANONYMOUS_LIMITS.maxValueBytes
739
813
  },
740
814
  server: {
815
+ endpoints,
741
816
  helpers: {},
742
817
  imports: ["lakebed/server"],
743
818
  mutations: Object.fromEntries(Object.keys(app.mutations ?? {}).map((name) => [name, { op: "source" }])),
@@ -921,6 +996,30 @@ export function validateAnonymousArtifact(artifact, { allowClaimedSource = false
921
996
  }
922
997
  }
923
998
 
999
+ const seenEndpointRoutes = new Map();
1000
+ for (const [name, endpoint] of Object.entries(artifact.server?.endpoints ?? {})) {
1001
+ const path = `artifact.server.endpoints.${name}`;
1002
+ if (!isPlainObject(endpoint) || endpoint.op !== "source") {
1003
+ diagnostics.push(diagnostic(path, "Endpoint must be source-backed."));
1004
+ continue;
1005
+ }
1006
+
1007
+ validateEndpointRoute(endpoint, path, diagnostics);
1008
+ const routeKey = `${endpoint.method} ${endpoint.path}`;
1009
+ const existing = seenEndpointRoutes.get(routeKey);
1010
+ if (existing) {
1011
+ diagnostics.push(diagnostic(path, `Endpoint route duplicates ${existing}.`));
1012
+ }
1013
+ seenEndpointRoutes.set(routeKey, name);
1014
+
1015
+ if (artifact.server?.source === undefined) {
1016
+ diagnostics.push(diagnostic(path, "Source endpoint requires artifact.server.source."));
1017
+ }
1018
+ if (!sourceDeployTargets.has(artifact.deployTarget)) {
1019
+ diagnostics.push(diagnostic(path, "Source endpoint requires deployTarget anonymous-source or claimed-source."));
1020
+ }
1021
+ }
1022
+
924
1023
  if (artifact.server?.source !== undefined) {
925
1024
  if (!sourceDeployTargets.has(artifact.deployTarget)) {
926
1025
  diagnostics.push(diagnostic("artifact.server.source", "Server source bundles require deployTarget anonymous-source or claimed-source."));
@@ -1448,3 +1547,146 @@ export async function executeAnonymousMutation({ args = [], artifact, auth, depl
1448
1547
  return null;
1449
1548
  }, mutationTransactionOptions(limits));
1450
1549
  }
1550
+
1551
+ function normalizeEndpointHeaders(headers = {}) {
1552
+ if (!headers) {
1553
+ return {};
1554
+ }
1555
+
1556
+ if (typeof headers.entries === "function") {
1557
+ return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key), String(value)]));
1558
+ }
1559
+
1560
+ if (Array.isArray(headers)) {
1561
+ return Object.fromEntries(headers.map(([key, value]) => [String(key), String(value)]));
1562
+ }
1563
+
1564
+ if (isPlainObject(headers)) {
1565
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [String(key), String(value)]));
1566
+ }
1567
+
1568
+ return {};
1569
+ }
1570
+
1571
+ function endpointBodyToBase64(body) {
1572
+ if (body === undefined || body === null) {
1573
+ return "";
1574
+ }
1575
+ if (typeof body === "string") {
1576
+ return Buffer.from(body, "utf8").toString("base64");
1577
+ }
1578
+ if (body instanceof ArrayBuffer) {
1579
+ return Buffer.from(body).toString("base64");
1580
+ }
1581
+ if (ArrayBuffer.isView(body)) {
1582
+ return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString("base64");
1583
+ }
1584
+ return Buffer.from(JSON.stringify(body ?? null), "utf8").toString("base64");
1585
+ }
1586
+
1587
+ function endpointStatus(status, fallback = 200) {
1588
+ const parsed = Number(status);
1589
+ return Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : fallback;
1590
+ }
1591
+
1592
+ async function normalizeEndpointResponse(result) {
1593
+ if (result === undefined || result === null) {
1594
+ return { bodyBase64: "", headers: {}, status: 204 };
1595
+ }
1596
+
1597
+ if (typeof Response !== "undefined" && result instanceof Response) {
1598
+ const body = Buffer.from(await result.arrayBuffer());
1599
+ return {
1600
+ bodyBase64: body.toString("base64"),
1601
+ headers: normalizeEndpointHeaders(result.headers),
1602
+ status: endpointStatus(result.status)
1603
+ };
1604
+ }
1605
+
1606
+ if (isPlainObject(result) && result.kind === "response") {
1607
+ return {
1608
+ bodyBase64: endpointBodyToBase64(result.body),
1609
+ headers: normalizeEndpointHeaders(result.headers),
1610
+ status: endpointStatus(result.status)
1611
+ };
1612
+ }
1613
+
1614
+ if (typeof result === "string") {
1615
+ return {
1616
+ bodyBase64: endpointBodyToBase64(result),
1617
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
1618
+ status: 200
1619
+ };
1620
+ }
1621
+
1622
+ return {
1623
+ bodyBase64: endpointBodyToBase64(result),
1624
+ headers: { "Content-Type": "application/json; charset=utf-8" },
1625
+ status: 200
1626
+ };
1627
+ }
1628
+
1629
+ function createEndpointRequest(request) {
1630
+ const url = new URL(request.url ?? request.path ?? "/", "http://lakebed.local");
1631
+ const body = Buffer.from(request.bodyBase64 ?? "", "base64");
1632
+ const headers = new Map(
1633
+ Object.entries(normalizeEndpointHeaders(request.headers)).map(([key, value]) => [key.toLowerCase(), String(value)])
1634
+ );
1635
+
1636
+ return {
1637
+ headers: {
1638
+ entries() {
1639
+ return headers.entries();
1640
+ },
1641
+ get(name) {
1642
+ return headers.get(String(name).toLowerCase()) ?? null;
1643
+ },
1644
+ has(name) {
1645
+ return headers.has(String(name).toLowerCase());
1646
+ }
1647
+ },
1648
+ method: String(request.method ?? "GET").toUpperCase(),
1649
+ path: request.path ?? url.pathname,
1650
+ query: new URLSearchParams(url.searchParams),
1651
+ url: url.href,
1652
+ async bytes() {
1653
+ return new Uint8Array(body);
1654
+ },
1655
+ async json() {
1656
+ return JSON.parse(body.toString("utf8"));
1657
+ },
1658
+ async text() {
1659
+ return body.toString("utf8");
1660
+ }
1661
+ };
1662
+ }
1663
+
1664
+ export async function executeAnonymousEndpoint({ artifact, auth, deployId, limits = DEFAULT_ANONYMOUS_LIMITS, name, request, sourceRuntime, state }) {
1665
+ const endpoint = artifact.server?.endpoints?.[name];
1666
+ if (!endpoint) {
1667
+ throw new Error(`Unknown endpoint: ${name}`);
1668
+ }
1669
+
1670
+ if (!artifact.server?.source) {
1671
+ throw new Error(`Endpoint ${name} requires the source runtime.`);
1672
+ }
1673
+
1674
+ if (sourceRuntime) {
1675
+ return sourceRuntime.executeEndpoint({ artifact, auth, deployId, limits, name, request, state });
1676
+ }
1677
+
1678
+ const sourceApp = await loadSourceApp(artifact);
1679
+ const definition = sourceApp.endpoints?.[name];
1680
+ const handler = definition?.handler ?? definition;
1681
+ if (typeof handler !== "function") {
1682
+ throw new Error(`Unknown endpoint: ${name}`);
1683
+ }
1684
+
1685
+ return state.transaction(deployId, async (tx) => {
1686
+ const source = await createSourceContext({ artifact, auth, deployId, state: tx });
1687
+ const result = await handler(source.ctx, createEndpointRequest(request));
1688
+ const response = await normalizeEndpointResponse(result);
1689
+ await source.flush(tx);
1690
+ return response;
1691
+ }, mutationTransactionOptions(limits));
1692
+ }
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 ?? email ?? "Google User",
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}`