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/src/client.js CHANGED
@@ -1,16 +1,54 @@
1
+ import { h } from "preact";
1
2
  import { useEffect, useState } from "preact/hooks";
2
3
 
4
+ const DEFAULT_SHOO_BASE_URL = "https://shoo.dev";
5
+ const AUTH_STORAGE_KEY = "lakebed_identity";
6
+ const LEGACY_SHOO_STORAGE_KEY = "shoo_identity";
7
+ const PKCE_STORAGE_KEY = "lakebed_google_pkce";
8
+ const RETURN_TO_STORAGE_KEY = "lakebed_google_return_to";
9
+ const PKCE_MAX_AGE_MS = 10 * 60 * 1000;
10
+ const encoder = new TextEncoder();
11
+
3
12
  let socket = null;
4
13
  let nextRequestId = 1;
5
- let auth = {
6
- userId: "guest:local",
7
- displayName: "Local Guest"
8
- };
14
+ let auth = createGuestAuth("local");
9
15
  const authListeners = new Set();
10
16
  const queryValues = new Map();
11
17
  const queryListeners = new Map();
12
18
  const pending = new Map();
13
19
  const activeSubscriptions = new Set();
20
+ let authInitPromise = null;
21
+ let authInitialized = false;
22
+
23
+ function toGuestName(name) {
24
+ return (
25
+ String(name ?? "local")
26
+ .replace(/^guest:/, "")
27
+ .trim()
28
+ .replace(/[^a-zA-Z0-9_.-]+/g, "-")
29
+ .replace(/^-+|-+$/g, "")
30
+ .toLowerCase() || "local"
31
+ );
32
+ }
33
+
34
+ function toDisplayName(name) {
35
+ return toGuestName(name)
36
+ .split(/[-_\s.]+/)
37
+ .filter(Boolean)
38
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
39
+ .join(" ");
40
+ }
41
+
42
+ function createGuestAuth(name) {
43
+ const guestName = toGuestName(name);
44
+ return {
45
+ displayName: toDisplayName(guestName),
46
+ isAuthenticated: false,
47
+ isGuest: true,
48
+ provider: "guest",
49
+ userId: `guest:${guestName}`
50
+ };
51
+ }
14
52
 
15
53
  function emitAuth() {
16
54
  for (const listener of authListeners) {
@@ -47,6 +85,308 @@ function send(message) {
47
85
  );
48
86
  }
49
87
 
88
+ function basePath() {
89
+ return window.__LAKEBED_BASE_PATH__ ?? "";
90
+ }
91
+
92
+ function authConfig() {
93
+ return window.__LAKEBED_AUTH__ ?? {};
94
+ }
95
+
96
+ function callbackPath() {
97
+ return `${basePath()}/auth/callback`.replace(/\/{2,}/g, "/");
98
+ }
99
+
100
+ function currentRoute() {
101
+ return `${window.location.pathname}${window.location.search}${window.location.hash}`;
102
+ }
103
+
104
+ function normalizeReturnTo(value) {
105
+ if (!value) {
106
+ return null;
107
+ }
108
+
109
+ try {
110
+ const parsed = new URL(value, window.location.origin);
111
+ if (parsed.origin !== window.location.origin) {
112
+ return null;
113
+ }
114
+
115
+ const route = `${parsed.pathname}${parsed.search}${parsed.hash}`;
116
+ if (!route.startsWith("/") || route.startsWith("//")) {
117
+ return null;
118
+ }
119
+
120
+ return route;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function fallbackRoute() {
127
+ return basePath() || "/";
128
+ }
129
+
130
+ function deriveRedirectUri(path) {
131
+ return new URL(path, window.location.origin).toString();
132
+ }
133
+
134
+ function deriveClientIdFromRedirectUri(redirectUri) {
135
+ return `origin:${new URL(redirectUri).origin}`;
136
+ }
137
+
138
+ function resolveGoogleAuthOptions(options = {}) {
139
+ const resolvedCallbackPath = normalizeReturnTo(options.callbackPath) ?? callbackPath();
140
+ const redirectUri = options.redirectUri ?? deriveRedirectUri(resolvedCallbackPath);
141
+ return {
142
+ callbackPath: resolvedCallbackPath,
143
+ clientId: options.clientId ?? deriveClientIdFromRedirectUri(redirectUri),
144
+ redirectUri,
145
+ returnTo: normalizeReturnTo(options.returnTo) ?? currentRoute(),
146
+ shooBaseUrl: String(options.shooBaseUrl ?? authConfig().shooBaseUrl ?? DEFAULT_SHOO_BASE_URL).replace(/\/+$/g, "")
147
+ };
148
+ }
149
+
150
+ function randomString(length = 64) {
151
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
152
+ const random = crypto.getRandomValues(new Uint8Array(length));
153
+ let value = "";
154
+ for (let index = 0; index < random.length; index += 1) {
155
+ value += chars[random[index] % chars.length];
156
+ }
157
+ return value;
158
+ }
159
+
160
+ function bytesToBase64Url(bytes) {
161
+ let binary = "";
162
+ for (const byte of bytes) {
163
+ binary += String.fromCharCode(byte);
164
+ }
165
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
166
+ }
167
+
168
+ function decodeBase64Url(value) {
169
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
170
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
171
+ return atob(padded);
172
+ }
173
+
174
+ function parseJson(value) {
175
+ try {
176
+ return JSON.parse(value);
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ export function decodeIdentityClaims(idToken) {
183
+ if (!idToken) {
184
+ return null;
185
+ }
186
+
187
+ const parts = idToken.split(".");
188
+ if (parts.length < 2) {
189
+ return null;
190
+ }
191
+
192
+ return parseJson(decodeBase64Url(parts[1]));
193
+ }
194
+
195
+ function readStoredIdentity() {
196
+ const raw = window.localStorage.getItem(AUTH_STORAGE_KEY) ?? window.localStorage.getItem(LEGACY_SHOO_STORAGE_KEY);
197
+ if (!raw) {
198
+ return { userId: null };
199
+ }
200
+
201
+ const parsed = parseJson(raw);
202
+ if (!parsed || typeof parsed !== "object") {
203
+ return { userId: null };
204
+ }
205
+
206
+ const token = typeof parsed.token === "string" ? parsed.token : undefined;
207
+ const claims = decodeIdentityClaims(token);
208
+ if (typeof claims?.exp === "number" && claims.exp * 1000 <= Date.now()) {
209
+ clearStoredIdentity();
210
+ return { userId: null };
211
+ }
212
+
213
+ return {
214
+ token,
215
+ userId: typeof parsed.userId === "string" ? parsed.userId : (typeof parsed.pairwiseSub === "string" ? parsed.pairwiseSub : null)
216
+ };
217
+ }
218
+
219
+ function persistIdentity(userId, token, expiresIn) {
220
+ window.localStorage.setItem(
221
+ AUTH_STORAGE_KEY,
222
+ JSON.stringify({
223
+ expiresIn,
224
+ receivedAt: Date.now(),
225
+ token,
226
+ userId
227
+ })
228
+ );
229
+ }
230
+
231
+ function clearStoredIdentity() {
232
+ window.localStorage.removeItem(AUTH_STORAGE_KEY);
233
+ window.localStorage.removeItem(LEGACY_SHOO_STORAGE_KEY);
234
+ }
235
+
236
+ function storedAuthToken() {
237
+ return readStoredIdentity().token ?? "";
238
+ }
239
+
240
+ function createGoogleAuthFromToken(token) {
241
+ const claims = decodeIdentityClaims(token);
242
+ const pairwiseSub = claims?.pairwise_sub ?? claims?.sub;
243
+ if (!pairwiseSub) {
244
+ return null;
245
+ }
246
+
247
+ return {
248
+ displayName: claims.name ?? claims.email ?? "Google User",
249
+ email: claims.email,
250
+ emailVerified: claims.email_verified,
251
+ isAuthenticated: true,
252
+ isGuest: false,
253
+ name: claims.name,
254
+ picture: claims.picture,
255
+ provider: "google",
256
+ userId: `google:${pairwiseSub}`
257
+ };
258
+ }
259
+
260
+ async function createPkceBundle() {
261
+ const verifier = randomString(64);
262
+ const state = randomString(32);
263
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(verifier));
264
+ return {
265
+ challenge: bytesToBase64Url(new Uint8Array(digest)),
266
+ state,
267
+ verifier
268
+ };
269
+ }
270
+
271
+ function createSignInUrl(options, bundle) {
272
+ const url = new URL("/authorize", options.shooBaseUrl);
273
+ url.searchParams.set("client_id", options.clientId);
274
+ url.searchParams.set("redirect_uri", options.redirectUri);
275
+ url.searchParams.set("state", bundle.state);
276
+ url.searchParams.set("code_challenge", bundle.challenge);
277
+ url.searchParams.set("code_challenge_method", "S256");
278
+ url.searchParams.set("pii", "true");
279
+ return url.toString();
280
+ }
281
+
282
+ function parseCallback(url = window.location.href) {
283
+ const parsed = new URL(url);
284
+ const code = parsed.searchParams.get("code");
285
+ const state = parsed.searchParams.get("state");
286
+ if (!code || !state) {
287
+ return null;
288
+ }
289
+ return { code, state };
290
+ }
291
+
292
+ function clearCallbackParams(url = window.location.href) {
293
+ const next = new URL(url);
294
+ next.searchParams.delete("code");
295
+ next.searchParams.delete("state");
296
+ next.searchParams.delete("error");
297
+ window.history.replaceState({}, "", next.toString());
298
+ }
299
+
300
+ function popReturnTo() {
301
+ const value = normalizeReturnTo(window.sessionStorage.getItem(RETURN_TO_STORAGE_KEY));
302
+ window.sessionStorage.removeItem(RETURN_TO_STORAGE_KEY);
303
+ return value;
304
+ }
305
+
306
+ async function exchangeCode({ code, codeVerifier, options }) {
307
+ const body = new URLSearchParams({
308
+ client_id: options.clientId,
309
+ code,
310
+ code_verifier: codeVerifier,
311
+ grant_type: "authorization_code",
312
+ redirect_uri: options.redirectUri
313
+ });
314
+ const response = await fetch(new URL("/token", options.shooBaseUrl), {
315
+ body,
316
+ headers: {
317
+ "Content-Type": "application/x-www-form-urlencoded"
318
+ },
319
+ method: "POST"
320
+ });
321
+
322
+ if (!response.ok) {
323
+ const details = await response.text();
324
+ throw new Error(`Google sign-in token exchange failed (${response.status}): ${details || "no details"}`);
325
+ }
326
+
327
+ return response.json();
328
+ }
329
+
330
+ async function handleGoogleCallback() {
331
+ const callback = parseCallback();
332
+ if (!callback) {
333
+ return null;
334
+ }
335
+
336
+ const rawPkce = window.sessionStorage.getItem(PKCE_STORAGE_KEY);
337
+ const parsedPkce = rawPkce ? parseJson(rawPkce) : null;
338
+ if (!parsedPkce?.state || !parsedPkce?.verifier) {
339
+ throw new Error("Missing Google sign-in verifier. Start sign-in again.");
340
+ }
341
+
342
+ if (typeof parsedPkce.createdAt === "number" && Date.now() - parsedPkce.createdAt > PKCE_MAX_AGE_MS) {
343
+ window.sessionStorage.removeItem(PKCE_STORAGE_KEY);
344
+ throw new Error("Google sign-in verifier expired. Start sign-in again.");
345
+ }
346
+
347
+ if (parsedPkce.state !== callback.state) {
348
+ throw new Error("Google sign-in state mismatch.");
349
+ }
350
+
351
+ const options = resolveGoogleAuthOptions();
352
+ const token = await exchangeCode({
353
+ code: callback.code,
354
+ codeVerifier: parsedPkce.verifier,
355
+ options
356
+ });
357
+ if (!token?.id_token || !token?.pairwise_sub) {
358
+ throw new Error("Google sign-in token response was missing identity claims.");
359
+ }
360
+ persistIdentity(token.pairwise_sub, token.id_token, token.expires_in);
361
+ window.sessionStorage.removeItem(PKCE_STORAGE_KEY);
362
+
363
+ const localAuth = createGoogleAuthFromToken(token.id_token);
364
+ if (localAuth) {
365
+ auth = localAuth;
366
+ emitAuth();
367
+ }
368
+
369
+ const returnTo = popReturnTo() ?? fallbackRoute();
370
+ clearCallbackParams();
371
+ window.location.replace(returnTo);
372
+ return token;
373
+ }
374
+
375
+ function ensureAuthInitialized() {
376
+ if (authInitialized) {
377
+ return Promise.resolve();
378
+ }
379
+
380
+ authInitPromise ??= handleGoogleCallback()
381
+ .catch((error) => {
382
+ console.error("[lakebed] Google sign-in failed", error);
383
+ })
384
+ .finally(() => {
385
+ authInitialized = true;
386
+ });
387
+ return authInitPromise;
388
+ }
389
+
50
390
  function request(op, payload) {
51
391
  const id = nextRequestId++;
52
392
  send({ id, op, ...payload });
@@ -61,24 +401,30 @@ function connect() {
61
401
  return socket;
62
402
  }
63
403
 
404
+ void ensureAuthInitialized();
405
+
64
406
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
65
- const basePath = window.__LAKEBED_BASE_PATH__ ?? "";
66
- const url = new URL(`${protocol}//${window.location.host}${basePath}/__lakebed/ws`);
407
+ const url = new URL(`${protocol}//${window.location.host}${basePath()}/__lakebed/ws`);
67
408
  const guestName = new URLSearchParams(window.location.search).get("lakebed_guest");
68
409
  if (guestName) {
69
410
  url.searchParams.set("lakebed_guest", guestName);
70
411
  }
412
+ const token = storedAuthToken();
413
+ if (token) {
414
+ url.searchParams.set("lakebed_token", token);
415
+ }
71
416
 
72
417
  socket = new WebSocket(url);
418
+ const currentSocket = socket;
73
419
 
74
- socket.addEventListener("open", () => {
420
+ currentSocket.addEventListener("open", () => {
75
421
  send({ op: "auth.get" });
76
422
  for (const name of activeSubscriptions) {
77
423
  send({ op: "query.subscribe", name });
78
424
  }
79
425
  });
80
426
 
81
- socket.addEventListener("message", (event) => {
427
+ currentSocket.addEventListener("message", (event) => {
82
428
  const message = JSON.parse(String(event.data));
83
429
 
84
430
  if (message.op === "auth.result") {
@@ -104,8 +450,16 @@ function connect() {
104
450
  }
105
451
  });
106
452
 
107
- socket.addEventListener("close", () => {
453
+ currentSocket.addEventListener("close", () => {
454
+ if (socket !== currentSocket) {
455
+ return;
456
+ }
457
+
108
458
  window.setTimeout(() => {
459
+ if (socket !== currentSocket) {
460
+ return;
461
+ }
462
+
109
463
  socket = null;
110
464
  connect();
111
465
  }, 500);
@@ -114,10 +468,19 @@ function connect() {
114
468
  return socket;
115
469
  }
116
470
 
471
+ function reconnect() {
472
+ if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
473
+ socket.close();
474
+ }
475
+ socket = null;
476
+ connect();
477
+ }
478
+
117
479
  export function useAuth() {
118
480
  const [value, setValue] = useState(auth);
119
481
 
120
482
  useEffect(() => {
483
+ void ensureAuthInitialized();
121
484
  connect();
122
485
  authListeners.add(setValue);
123
486
  return () => {
@@ -128,6 +491,79 @@ export function useAuth() {
128
491
  return value;
129
492
  }
130
493
 
494
+ export async function signInWithGoogle(options = {}) {
495
+ const resolved = resolveGoogleAuthOptions(options);
496
+ const bundle = await createPkceBundle();
497
+ window.sessionStorage.setItem(
498
+ PKCE_STORAGE_KEY,
499
+ JSON.stringify({
500
+ createdAt: Date.now(),
501
+ state: bundle.state,
502
+ verifier: bundle.verifier
503
+ })
504
+ );
505
+ window.sessionStorage.setItem(
506
+ RETURN_TO_STORAGE_KEY,
507
+ normalizeReturnTo(resolved.returnTo) === resolved.callbackPath ? fallbackRoute() : (normalizeReturnTo(resolved.returnTo) ?? fallbackRoute())
508
+ );
509
+
510
+ const url = createSignInUrl(resolved, bundle);
511
+ window.location.assign(url);
512
+ return { bundle, url };
513
+ }
514
+
515
+ export function signOut() {
516
+ clearStoredIdentity();
517
+ auth = createGuestAuth(new URLSearchParams(window.location.search).get("lakebed_guest") ?? "local");
518
+ emitAuth();
519
+ reconnect();
520
+ }
521
+
522
+ export function getIdentity() {
523
+ return readStoredIdentity();
524
+ }
525
+
526
+ export function SignInWithGoogle({
527
+ children = "Sign in with Google",
528
+ className = "",
529
+ clientId,
530
+ callbackPath,
531
+ disabled,
532
+ onClick,
533
+ redirectUri,
534
+ requestPii: _requestPii,
535
+ requestProfile: _requestProfile,
536
+ returnTo,
537
+ shooBaseUrl,
538
+ type = "button",
539
+ ...props
540
+ } = {}) {
541
+ return h(
542
+ "button",
543
+ {
544
+ className,
545
+ disabled,
546
+ onClick: (event) => {
547
+ onClick?.(event);
548
+ if (event.defaultPrevented || disabled) {
549
+ return;
550
+ }
551
+
552
+ void signInWithGoogle({
553
+ callbackPath,
554
+ clientId,
555
+ redirectUri,
556
+ returnTo,
557
+ shooBaseUrl
558
+ });
559
+ },
560
+ type,
561
+ ...props
562
+ },
563
+ children
564
+ );
565
+ }
566
+
131
567
  export function useQuery(name) {
132
568
  const [value, setValue] = useState(queryValues.get(name) ?? []);
133
569
 
package/src/server.d.ts CHANGED
@@ -12,6 +12,13 @@ export type TableDefinition = {
12
12
  export type AuthContext = {
13
13
  userId: string;
14
14
  displayName: string;
15
+ provider: "guest" | "google";
16
+ isGuest: boolean;
17
+ isAuthenticated: boolean;
18
+ email?: string;
19
+ emailVerified?: boolean;
20
+ name?: string;
21
+ picture?: string;
15
22
  };
16
23
 
17
24
  export type LogContext = {