Compare commits

..

19 Commits

Author SHA1 Message Date
Giordano Ricci
5933aa59c1 Merge pull request #1242 from garden-co/changeset-release/main
Version Packages
2025-01-22 17:33:31 +00:00
github-actions[bot]
da96bec465 Version Packages 2025-01-22 17:30:19 +00:00
Guido D'Orsi
483422c0e9 Merge pull request #1241 from garden-co/guido/test
feat: API to setup a test sync in the test environment
2025-01-22 18:28:38 +01:00
Guido D'Orsi
3df93cc147 chore: changeset 2025-01-22 18:22:16 +01:00
Guido D'Orsi
d686edfa6c chore: add tests and use ref to track the current sync server 2025-01-22 18:22:15 +01:00
Giordano Ricci
497b0ade1f feat: API to setup a test sync in the test environment 2025-01-22 18:21:48 +01:00
Guido D'Orsi
86acbcd0d6 Merge pull request #1237 from garden-co/changeset-release/main
Version Packages
2025-01-22 12:51:45 +01:00
github-actions[bot]
9111c85445 Version Packages 2025-01-22 11:46:06 +00:00
Guido D'Orsi
1d87879787 Merge pull request #1231 from garden-co/revert-1229-revert-custom-logger
Restore cutom logger
2025-01-22 12:44:53 +01:00
Guido D'Orsi
7c777f2bdf test: cover the storage regression with a test 2025-01-22 12:41:51 +01:00
Benjamin S. Leveritt
aa8067b8d0 Merge pull request #1234 from garden-co/fix-clerk-example
chore: simplify clerk example auth-context
2025-01-22 10:15:21 +00:00
Guido D'Orsi
bd66cdeb78 chore: simplify clerk example auth-context 2025-01-22 10:29:54 +01:00
Guido D'Orsi
8d29e50669 feat: adapt the logger interface to pino 2025-01-21 17:52:40 +01:00
Guido D'Orsi
5a7398d242 Revert "Revert custom logger" 2025-01-21 17:08:37 +01:00
Guido D'Orsi
74b984fbe6 Merge pull request #1230 from garden-co/changeset-release/main
Version Packages
2025-01-21 16:53:36 +01:00
github-actions[bot]
f8e00204b4 Version Packages 2025-01-21 15:49:31 +00:00
Guido D'Orsi
76543df765 Merge pull request #1229 from garden-co/revert-custom-logger
Revert custom logger
2025-01-21 16:48:17 +01:00
Guido D'Orsi
15d4b2a5f7 chore: changeset 2025-01-21 16:44:37 +01:00
Guido D'Orsi
2e67f91fe0 Revert "feat: make it possible to customize the logger in cojson"
This reverts commit 5863badbb0.
2025-01-21 16:42:54 +01:00
164 changed files with 2135 additions and 3067 deletions

View File

@@ -1,5 +1,33 @@
# chat-rn-clerk
## 1.0.55
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-react-native@0.9.14
- jazz-react-native-auth-clerk@0.9.14
- jazz-react-native-media-images@0.9.14
## 1.0.54
### Patch Changes
- jazz-react-native@0.9.13
- jazz-react-native-auth-clerk@0.9.13
- jazz-tools@0.9.13
- jazz-react-native-media-images@0.9.13
## 1.0.53
### Patch Changes
- jazz-react-native@0.9.12
- jazz-react-native-auth-clerk@0.9.12
- jazz-tools@0.9.12
- jazz-react-native-media-images@0.9.12
## 1.0.52
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "chat-rn-clerk",
"main": "index.js",
"version": "1.0.52",
"version": "1.0.55",
"scripts": {
"build": "expo export -p ios",
"start": "expo start",

View File

@@ -1,13 +1,7 @@
import { useClerk, useUser } from "@clerk/clerk-expo";
import { JazzProvider, setupKvStore } from "jazz-react-native";
import { useJazzClerkAuth } from "jazz-react-native-auth-clerk";
import React, {
createContext,
PropsWithChildren,
useContext,
useEffect,
useState,
} from "react";
import React, { createContext, PropsWithChildren, useContext } from "react";
import { Text, View } from "react-native";
const AuthContext = createContext<{
isAuthenticated: boolean;
@@ -27,15 +21,7 @@ export function JazzAndAuth({ children }: PropsWithChildren) {
const { isSignedIn, isLoaded: isClerkLoaded } = useUser();
const clerk = useClerk();
const [auth, state] = useJazzClerkAuth(clerk, kvStore);
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
if (isSignedIn && isClerkLoaded && auth) {
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
}, [isSignedIn, isClerkLoaded, auth]);
const isAuthenticated = Boolean(isSignedIn && isClerkLoaded && auth);
return (
<AuthContext.Provider

View File

@@ -1,5 +1,27 @@
# chat-rn
## 1.0.52
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-react-native@0.9.14
## 1.0.51
### Patch Changes
- jazz-react-native@0.9.13
- jazz-tools@0.9.13
## 1.0.50
### Patch Changes
- jazz-react-native@0.9.12
- jazz-tools@0.9.12
## 1.0.49
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-rn",
"version": "1.0.49",
"version": "1.0.52",
"main": "index.js",
"scripts": {
"build": "expo export -p ios",

View File

@@ -1,5 +1,30 @@
# chat-vue
## 0.0.39
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser@0.9.14
- jazz-vue@0.9.14
## 0.0.38
### Patch Changes
- jazz-browser@0.9.13
- jazz-tools@0.9.13
- jazz-vue@0.9.13
## 0.0.37
### Patch Changes
- jazz-browser@0.9.12
- jazz-tools@0.9.12
- jazz-vue@0.9.12
## 0.0.36
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-vue",
"version": "0.0.36",
"version": "0.0.39",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,30 @@
# jazz-example-chat
## 0.0.135
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser-media-images@0.9.14
- jazz-react@0.9.14
## 0.0.134
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
- jazz-browser-media-images@0.9.13
## 0.0.133
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
- jazz-browser-media-images@0.9.12
## 0.0.132
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-chat",
"private": true,
"version": "0.0.132",
"version": "0.0.135",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,30 @@
# minimal-auth-clerk
## 0.0.34
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-react@0.9.14
- jazz-react-auth-clerk@0.9.14
## 0.0.33
### Patch Changes
- jazz-react@0.9.13
- jazz-react-auth-clerk@0.9.13
- jazz-tools@0.9.13
## 0.0.32
### Patch Changes
- jazz-react@0.9.12
- jazz-react-auth-clerk@0.9.12
- jazz-tools@0.9.12
## 0.0.31
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "clerk",
"private": true,
"version": "0.0.31",
"version": "0.0.34",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,7 +13,7 @@
"dependencies": {
"@clerk/clerk-react": "^5.4.1",
"jazz-react": "workspace:*",
"jazz-react-auth-clerk": "workspace:0.9.11",
"jazz-react-auth-clerk": "workspace:0.9.14",
"jazz-tools": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View File

@@ -1,5 +1,27 @@
# file-share-svelte
## 0.0.19
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-svelte@0.9.14
## 0.0.18
### Patch Changes
- jazz-svelte@0.9.13
- jazz-tools@0.9.13
## 0.0.17
### Patch Changes
- jazz-svelte@0.9.12
- jazz-tools@0.9.12
## 0.0.16
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "file-share-svelte",
"version": "0.0.16",
"version": "0.0.19",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,30 @@
# form
## 0.0.30
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser-media-images@0.9.14
- jazz-react@0.9.14
## 0.0.29
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
- jazz-browser-media-images@0.9.13
## 0.0.28
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
- jazz-browser-media-images@0.9.12
## 0.0.27
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "form",
"private": true,
"version": "0.0.27",
"version": "0.0.30",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,30 @@
# image-upload
## 0.0.32
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser-media-images@0.9.14
- jazz-react@0.9.14
## 0.0.31
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
- jazz-browser-media-images@0.9.13
## 0.0.30
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
- jazz-browser-media-images@0.9.12
## 0.0.29
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "image-upload",
"private": true,
"version": "0.0.29",
"version": "0.0.32",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# jazz-example-inspector
## 0.0.98
### Patch Changes
- Updated dependencies [8d29e50]
- cojson-transport-ws@0.9.13
- cojson@0.9.13
## 0.0.97
### Patch Changes
- Updated dependencies [15d4b2a]
- cojson-transport-ws@0.9.12
- cojson@0.9.12
## 0.0.96
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-inspector-app",
"private": true,
"version": "0.0.96",
"version": "0.0.98",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,8 +16,8 @@
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cojson": "workspace:0.9.11",
"cojson-transport-ws": "workspace:0.9.11",
"cojson": "workspace:0.9.13",
"cojson-transport-ws": "workspace:0.9.13",
"hash-slash": "workspace:0.2.1",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",

View File

@@ -1,5 +1,30 @@
# jazz-example-musicplayer
## 0.0.55
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-inspector@0.9.14
- jazz-react@0.9.14
## 0.0.54
### Patch Changes
- jazz-inspector@0.9.13
- jazz-react@0.9.13
- jazz-tools@0.9.13
## 0.0.53
### Patch Changes
- jazz-inspector@0.9.12
- jazz-react@0.9.12
- jazz-tools@0.9.12
## 0.0.52
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-music-player",
"private": true,
"version": "0.0.52",
"version": "0.0.55",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,16 +13,13 @@
"test:ui": "playwright test --ui"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "workspace:0.9.11",
"jazz-tools": "workspace:0.9.11",
"jazz-react": "workspace:0.9.14",
"jazz-tools": "workspace:0.9.14",
"jazz-inspector": "workspace:*",
"lucide-react": "^0.274.0",
"react": "^18.3.1",

View File

@@ -11,7 +11,7 @@ import { PlayerControls } from "./components/PlayerControls";
import "./index.css";
import { MusicaAccount } from "@/1_schema";
import { JazzProvider } from "jazz-react";
import { DemoAuthBasicUI, JazzProvider, useDemoAuth } from "jazz-react";
import { useUploadExampleData } from "./lib/useUploadExampleData";
/**
@@ -54,11 +54,30 @@ function Main() {
);
}
const peer =
(new URL(window.location.href).searchParams.get(
"peer",
) as `ws://${string}`) ??
"wss://cloud.jazz.tools/?key=music-player-example-jazz@garden.co";
function JazzAndAuth({ children }: { children: React.ReactNode }) {
const [auth, state] = useDemoAuth();
const peer =
(new URL(window.location.href).searchParams.get(
"peer",
) as `ws://${string}`) ??
"wss://cloud.jazz.tools/?key=music-player-example-jazz@garden.co";
return (
<>
<JazzProvider
storage="indexedDB"
auth={auth}
peer={peer}
AccountSchema={MusicaAccount}
>
{children}
<JazzInspector />
</JazzProvider>
<DemoAuthBasicUI appName="Jazz Music Player" state={state} />
</>
);
}
declare module "jazz-react" {
interface Register {
@@ -68,14 +87,8 @@ declare module "jazz-react" {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<JazzProvider
peer={peer}
storage="indexedDB"
localOnly="anonymous" // This makes the app work in local mode when the user is anonymous
AccountSchema={MusicaAccount}
>
<JazzAndAuth>
<Main />
<JazzInspector />
</JazzProvider>
</JazzAndAuth>
</React.StrictMode>,
);

View File

@@ -1,17 +1,12 @@
import { useToast } from "@/hooks/use-toast";
import {
createInviteLink,
useAccount,
useCoState,
useIsAnonymousUser,
} from "jazz-react";
import { createInviteLink, useAccount, useCoState } from "jazz-react";
import { ID } from "jazz-tools";
import { useNavigate, useParams } from "react-router";
import { Playlist } from "./1_schema";
import { createNewPlaylist, uploadMusicTracks } from "./4_actions";
import { MediaPlayer } from "./5_useMediaPlayer";
import { AuthButton } from "./components/AuthButton";
import { FileUploadButton } from "./components/FileUploadButton";
import { LogoutButton } from "./components/LogoutButton";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
import { SidePanel } from "./components/SidePanel";
@@ -71,8 +66,6 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
});
};
const isAnonymousUser = useIsAnonymousUser();
return (
<div className="flex flex-col h-screen text-gray-800 bg-blue-50">
<div className="flex flex-1 overflow-hidden">
@@ -93,12 +86,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
<Button onClick={handleCreatePlaylist}>New playlist</Button>
</>
)}
{!isRootPlaylist && !isAnonymousUser && (
{!isRootPlaylist && (
<Button onClick={handlePlaylistShareClick}>
Share playlist
</Button>
)}
<AuthButton />
<LogoutButton />
</div>
</div>
<ul className="flex flex-col">

View File

@@ -1,40 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { useAccount, useIsAnonymousUser } from "jazz-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { AuthModal } from "./AuthModal";
export function AuthButton() {
const [open, setOpen] = useState(false);
const { logOut } = useAccount();
const navigate = useNavigate();
const isAnonymousUser = useIsAnonymousUser();
function handleSignOut() {
logOut();
navigate("/");
}
if (!isAnonymousUser) {
return (
<Button variant="outline" onClick={handleSignOut}>
Sign out
</Button>
);
}
return (
<>
<Button
onClick={() => setOpen(true)}
className="bg-white text-black hover:bg-gray-100"
>
Sign up
</Button>
<AuthModal open={open} onOpenChange={setOpen} />
</>
);
}

View File

@@ -1,112 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAccount, usePasskeyAuth } from "jazz-react";
import { Loader2 } from "lucide-react";
import { useState } from "react";
interface AuthModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AuthModal({ open, onOpenChange }: AuthModalProps) {
const [username, setUsername] = useState("");
const [isSignUp, setIsSignUp] = useState(true);
const { me } = useAccount();
const [, authState] = usePasskeyAuth({
appName: "Jazz Music Player",
onAnonymousUserUpgrade: ({ username, isSignUp }) => {
if (isSignUp) {
me.profile!.name = username;
}
onOpenChange(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (authState.state === "ready") {
if (isSignUp) {
authState.signUp(username);
} else {
authState.logIn();
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isSignUp ? "Create account" : "Welcome back"}
</DialogTitle>
{authState.state === "ready" && (
<DialogDescription>
{isSignUp
? "Sign up to enable network sync and share your playlists with others"
: "Changes done before logging in will be lost"}
</DialogDescription>
)}
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isSignUp && (
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
/>
</div>
)}
{authState.errors.length > 0 && (
<div className="text-sm text-red-500">
{authState.errors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
<div className="space-y-4">
<Button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700"
disabled={authState.state === "loading"}
>
{authState.state === "loading" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : isSignUp ? (
"Sign up with passkey"
) : (
"Login with passkey"
)}
</Button>
<div className="text-center text-sm">
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
<button
type="button"
onClick={() => setIsSignUp(!isSignUp)}
className="text-blue-600 hover:underline"
>
{isSignUp ? "Login" : "Sign up"}
</button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,37 +0,0 @@
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsAnonymousUser } from "jazz-react";
import { Info } from "lucide-react";
export function LocalOnlyTag() {
const isAnonymousUser = useIsAnonymousUser();
if (!isAnonymousUser) {
return null;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex items-center gap-1.5 cursor-help">
<Badge variant="default" className="h-5 text-xs font-normal">
Local only
</Badge>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>
Sign up to enable network sync and share your playlists with others
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -1,6 +1,5 @@
import { useAccount } from "jazz-react";
import { useNavigate, useParams } from "react-router";
import { LocalOnlyTag } from "./LocalOnlyTag";
export function SidePanel() {
const { playlistId } = useParams();
@@ -26,7 +25,7 @@ export function SidePanel() {
return (
<aside className="w-64 p-6 bg-white overflow-y-auto">
<div className="flex items-center mb-1">
<div className="flex items-center mb-6">
<svg
className="w-8 h-8 mr-2"
viewBox="0 0 24 24"
@@ -47,9 +46,6 @@ export function SidePanel() {
</svg>
<span className="text-xl font-bold text-blue-600">Music Player</span>
</div>
<div className="mb-6">
<LocalOnlyTag />
</div>
<nav>
<h2 className="mb-2 text-sm font-semibold text-gray-600">Playlists</h2>
<ul className="space-y-1">

View File

@@ -1,36 +0,0 @@
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -1,120 +0,0 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,22 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -1,24 +0,0 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -1,30 +0,0 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,81 +1,44 @@
import { BrowserContext, test } from "@playwright/test";
import { test } from "@playwright/test";
import { HomePage } from "./pages/HomePage";
import { LoginPage } from "./pages/LoginPage";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function mockAuthenticator(context: BrowserContext) {
await context.addInitScript(() => {
Object.defineProperty(window.navigator, "credentials", {
value: {
...window.navigator.credentials,
create: async () => ({
type: "public-key",
id: new Uint8Array([1, 2, 3, 4]),
rawId: new Uint8Array([1, 2, 3, 4]),
response: {
clientDataJSON: new Uint8Array([1]),
attestationObject: new Uint8Array([2]),
},
}),
get: async () => ({
type: "public-key",
id: new Uint8Array([1, 2, 3, 4]),
rawId: new Uint8Array([1, 2, 3, 4]),
response: {
authenticatorData: new Uint8Array([1]),
clientDataJSON: new Uint8Array([2]),
signature: new Uint8Array([3]),
},
}),
},
configurable: true,
});
});
}
test("create a new playlist and share", async ({ page }) => {
const loginPage = new LoginPage(page);
// Configure the authenticator
test.beforeEach(async ({ context }) => {
// Enable virtual authenticator environment
await mockAuthenticator(context);
});
await loginPage.goto();
await loginPage.fillUsername("S. Mario");
await loginPage.signup();
test("create a new playlist and share", async ({
page: marioPage,
browser,
}) => {
await marioPage.goto("/");
const marioHome = new HomePage(marioPage);
const homePage = new HomePage(page);
// The example song should be loaded
await marioHome.expectMusicTrack("Example song");
await marioHome.editTrackTitle("Example song", "Super Mario World");
await homePage.expectMusicTrack("Example song");
await homePage.editTrackTitle("Example song", "Super Mario World");
await marioHome.createPlaylist();
await marioHome.editPlaylistTitle("Save the princess");
await homePage.createPlaylist();
await homePage.editPlaylistTitle("Save the princess");
await marioHome.navigateToPlaylist("All tracks");
await marioHome.addTrackToPlaylist("Super Mario World", "Save the princess");
await homePage.navigateToPlaylist("All tracks");
await homePage.addTrackToPlaylist("Super Mario World", "Save the princess");
await marioHome.navigateToPlaylist("Save the princess");
await marioHome.expectMusicTrack("Super Mario World");
await homePage.navigateToPlaylist("Save the princess");
await homePage.expectMusicTrack("Super Mario World");
await marioHome.signUp("Mario");
const url = await marioHome.getShareLink();
const url = await homePage.getShareLink();
await sleep(4000); // Wait for the sync to complete
const luigiPage = await (await browser.newContext()).newPage();
await luigiPage.goto("/");
await homePage.logout();
const luigiHome = new HomePage(luigiPage);
await loginPage.goto();
await loginPage.fillUsername("Luigi");
await loginPage.signup();
await luigiHome.signUp("Luigi");
await page.goto(url);
await luigiPage.goto(url);
await luigiHome.expectMusicTrack("Super Mario World");
await luigiHome.playMusicTrack("Super Mario World");
await luigiHome.expectActiveTrackPlaying();
await homePage.expectMusicTrack("Super Mario World");
await homePage.playMusicTrack("Super Mario World");
await homePage.expectActiveTrackPlaying();
});

View File

@@ -95,14 +95,6 @@ export class HomePage {
.click();
}
async signUp(name: string) {
await this.page.getByRole("button", { name: "Sign up" }).click();
await this.page.getByRole("textbox", { name: "Username" }).fill(name);
await this.page
.getByRole("button", { name: "Sign up with passkey" })
.click();
}
async logout() {
await this.logoutButton.click();
}

View File

@@ -0,0 +1,40 @@
import { Locator, Page, expect } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly signupButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByRole("textbox");
this.signupButton = page.getByRole("button", {
name: "Sign up",
});
}
async goto() {
this.page.goto("/");
}
async fillUsername(value: string) {
await this.usernameInput.clear();
await this.usernameInput.fill(value);
}
async loginAs(value: string) {
await this.page
.getByRole("button", {
name: value,
})
.click();
}
async signup() {
await this.signupButton.click();
}
async expectLoaded() {
await expect(this.signupButton).toBeVisible();
}
}

View File

@@ -1,5 +1,30 @@
# jazz-example-onboarding
## 0.0.36
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser-media-images@0.9.14
- jazz-react@0.9.14
## 0.0.35
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
- jazz-browser-media-images@0.9.13
## 0.0.34
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
- jazz-browser-media-images@0.9.12
## 0.0.33
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-onboarding",
"private": true,
"version": "0.0.33",
"version": "0.0.36",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,27 @@
# organization
## 0.0.28
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-react@0.9.14
## 0.0.27
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
## 0.0.26
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
## 0.0.25
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "organization",
"private": true,
"version": "0.0.25",
"version": "0.0.28",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,23 @@
# passkey-svelte
## 0.0.23
### Patch Changes
- jazz-svelte@0.9.14
## 0.0.22
### Patch Changes
- jazz-svelte@0.9.13
## 0.0.21
### Patch Changes
- jazz-svelte@0.9.12
## 0.0.20
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "passkey-svelte",
"version": "0.0.20",
"version": "0.0.23",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,5 +1,27 @@
# minimal-auth-passkey
## 0.0.33
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-react@0.9.14
## 0.0.32
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
## 0.0.31
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
## 0.0.30
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "passkey",
"private": true,
"version": "0.0.30",
"version": "0.0.33",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,27 @@
# jazz-password-manager
## 0.0.54
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-react@0.9.14
## 0.0.53
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
## 0.0.52
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
## 0.0.51
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-password-manager",
"private": true,
"version": "0.0.51",
"version": "0.0.54",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,8 +12,8 @@
"clean-install": "rm -rf node_modules pnpm-lock.yaml && pnpm install"
},
"dependencies": {
"jazz-react": "workspace:0.9.11",
"jazz-tools": "workspace:0.9.11",
"jazz-react": "workspace:0.9.14",
"jazz-tools": "workspace:0.9.14",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.41.5",

View File

@@ -1,5 +1,30 @@
# jazz-example-pets
## 0.0.152
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser-media-images@0.9.14
- jazz-react@0.9.14
## 0.0.151
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
- jazz-browser-media-images@0.9.13
## 0.0.150
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
- jazz-browser-media-images@0.9.12
## 0.0.149
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.149",
"version": "0.0.152",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,9 +19,9 @@
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-browser-media-images": "workspace:0.9.11",
"jazz-react": "workspace:0.9.11",
"jazz-tools": "workspace:0.9.11",
"jazz-browser-media-images": "workspace:0.9.14",
"jazz-react": "workspace:0.9.14",
"jazz-tools": "workspace:0.9.14",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.3.1",
@@ -41,7 +41,7 @@
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.20",
"is-ci": "^3.0.1",
"jazz-run": "workspace:0.9.11",
"jazz-run": "workspace:0.9.14",
"postcss": "^8.4.27",
"tailwindcss": "^3.4.15",
"typescript": "~5.6.2",

View File

@@ -1,5 +1,30 @@
# reactions
## 0.0.32
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser-media-images@0.9.14
- jazz-react@0.9.14
## 0.0.31
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
- jazz-browser-media-images@0.9.13
## 0.0.30
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
- jazz-browser-media-images@0.9.12
## 0.0.29
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "reactions",
"private": true,
"version": "0.0.29",
"version": "0.0.32",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,30 @@
# todo-vue
## 0.0.37
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser@0.9.14
- jazz-vue@0.9.14
## 0.0.36
### Patch Changes
- jazz-browser@0.9.13
- jazz-tools@0.9.13
- jazz-vue@0.9.13
## 0.0.35
### Patch Changes
- jazz-browser@0.9.12
- jazz-tools@0.9.12
- jazz-vue@0.9.12
## 0.0.34
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "todo-vue",
"version": "0.0.34",
"version": "0.0.37",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,27 @@
# jazz-example-todo
## 0.0.151
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-react@0.9.14
## 0.0.150
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
## 0.0.149
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
## 0.0.148
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.148",
"version": "0.0.151",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,8 +16,8 @@
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "workspace:0.9.11",
"jazz-tools": "workspace:0.9.11",
"jazz-react": "workspace:0.9.14",
"jazz-tools": "workspace:0.9.14",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.3.1",

View File

@@ -1,5 +1,27 @@
# version-history
## 0.0.29
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-react@0.9.14
## 0.0.28
### Patch Changes
- jazz-react@0.9.13
- jazz-tools@0.9.13
## 0.0.27
### Patch Changes
- jazz-react@0.9.12
- jazz-tools@0.9.12
## 0.0.26
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "version-history",
"private": true,
"version": "0.0.26",
"version": "0.0.29",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# cojson-storage-indexeddb
## 0.9.13
### Patch Changes
- Updated dependencies [8d29e50]
- cojson-storage@0.9.13
- cojson@0.9.13
## 0.9.12
### Patch Changes
- Updated dependencies [15d4b2a]
- cojson-storage@0.9.12
- cojson@0.9.12
## 0.9.11
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.9.11",
"version": "0.9.13",
"main": "dist/index.js",
"type": "module",
"types": "src/index.ts",

View File

@@ -1,5 +1,21 @@
# cojson-storage-sqlite
## 0.8.56
### Patch Changes
- Updated dependencies [8d29e50]
- cojson-storage@0.9.13
- cojson@0.9.13
## 0.8.55
### Patch Changes
- Updated dependencies [15d4b2a]
- cojson-storage@0.9.12
- cojson@0.9.12
## 0.8.54
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-storage-rn-sqlite",
"type": "module",
"version": "0.8.54",
"version": "0.8.56",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",

View File

@@ -1,5 +1,23 @@
# cojson-storage-sqlite
## 0.9.13
### Patch Changes
- 8d29e50: Restore the logger wrapper and adapt the API to pino
- Updated dependencies [8d29e50]
- cojson-storage@0.9.13
- cojson@0.9.13
## 0.9.12
### Patch Changes
- 15d4b2a: Revert the custom logger
- Updated dependencies [15d4b2a]
- cojson-storage@0.9.12
- cojson@0.9.12
## 0.9.11
### Patch Changes

View File

@@ -1,13 +1,13 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.9.11",
"version": "0.9.13",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.7.0",
"cojson": "workspace:0.9.11",
"cojson": "workspace:0.9.13",
"cojson-storage": "workspace:*"
},
"devDependencies": {

View File

@@ -28,6 +28,10 @@ export type RawTransactionRow = {
tx: string;
};
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "Unknown error";
}
export class SQLiteClient implements DBClientInterface {
private readonly db: DatabaseT;
private readonly toLocalNode: OutgoingSyncQueue;
@@ -53,7 +57,10 @@ export class SQLiteClient implements DBClientInterface {
header: parsedHeader,
};
} catch (e) {
logger.warn(coValueId, "Invalid JSON in header", e, coValueRow?.header);
const headerValue = coValueRow?.header ?? "";
logger.warn("Invalid JSON in header: " + headerValue, {
id: coValueId,
});
return;
}
}
@@ -80,7 +87,7 @@ export class SQLiteClient implements DBClientInterface {
tx: JSON.parse(transactionRow.tx) as Transaction,
}));
} catch (e) {
logger.warn("Invalid JSON in transaction", e);
logger.warn("Invalid JSON in transaction");
return [];
}
}

View File

@@ -49,15 +49,14 @@ export class SQLiteNode {
? v.slice(0, 20) + "..."
: v,
)}`,
e,
);
}
}
processMessages().catch((e) =>
logger.error("Error in processMessages in sqlite", e),
);
};
processMessages().catch((e) =>
logger.error("Error in processMessages in sqlite", e),
);
}
static async asPeer({

View File

@@ -1,5 +1,21 @@
# cojson-storage
## 0.9.13
### Patch Changes
- 8d29e50: Restore the logger wrapper and adapt the API to pino
- Updated dependencies [8d29e50]
- cojson@0.9.13
## 0.9.12
### Patch Changes
- 15d4b2a: Revert the custom logger
- Updated dependencies [15d4b2a]
- cojson@0.9.12
## 0.9.11
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage",
"version": "0.9.11",
"version": "0.9.13",
"main": "dist/index.js",
"type": "module",
"types": "src/index.ts",

View File

@@ -1,5 +1,21 @@
# cojson-transport-nodejs-ws
## 0.9.13
### Patch Changes
- 8d29e50: Restore the logger wrapper and adapt the API to pino
- Updated dependencies [8d29e50]
- cojson@0.9.13
## 0.9.12
### Patch Changes
- 15d4b2a: Revert the custom logger
- Updated dependencies [15d4b2a]
- cojson@0.9.12
## 0.9.11
### Patch Changes

View File

@@ -1,12 +1,12 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.9.11",
"version": "0.9.13",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:0.9.11",
"cojson": "workspace:0.9.13",
"typescript": "~5.6.2"
},
"scripts": {

View File

@@ -7,7 +7,7 @@ import {
logger,
} from "cojson";
import { BatchedOutgoingMessages } from "./BatchedOutgoingMessages.js";
import { deserializeMessages } from "./serialization.js";
import { deserializeMessages, getErrorMessage } from "./serialization.js";
import { AnyWebSocket } from "./types.js";
export const BUFFER_LIMIT = 100_000;
@@ -164,9 +164,7 @@ export function createWebSocketPeer({
if (!result.ok) {
logger.warn(
"Error while deserializing messages",
event.data,
result.error,
"Error while deserializing messages: " + getErrorMessage(result.error),
);
return;
}

View File

@@ -1,6 +1,10 @@
import { SyncMessage, logger } from "cojson";
import { PingMsg } from "./types.js";
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "Unknown error";
}
export function addMessageToBacklog(backlog: string, message: SyncMessage) {
if (!backlog) {
return JSON.stringify(message);
@@ -24,7 +28,7 @@ export function deserializeMessages(messages: unknown) {
| PingMsg[],
} as const;
} catch (e) {
logger.error("Error while deserializing messages", e);
logger.error("Error while deserializing messages: " + getErrorMessage(e));
return {
ok: false,
error: e,

View File

@@ -1,5 +1,17 @@
# cojson
## 0.9.13
### Patch Changes
- 8d29e50: Restore the logger wrapper and adapt the API to pino
## 0.9.12
### Patch Changes
- 15d4b2a: Revert the custom logger
## 0.9.11
### Patch Changes

View File

@@ -24,7 +24,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.9.11",
"version": "0.9.13",
"devDependencies": {
"@opentelemetry/sdk-metrics": "^1.29.0",
"@types/jest": "^29.5.3",

View File

@@ -138,7 +138,10 @@ export class PeerState {
}
gracefulShutdown() {
logger.debug("Gracefully closing", this.id);
logger.debug("Gracefully closing", {
peerId: this.id,
peerRole: this.role,
});
this.closeQueue();
this.peer.outgoing.close();
this.closed = true;

View File

@@ -304,7 +304,10 @@ async function loadCoValueFromPeers(
if (coValueEntry.state.type === "loading") {
const timeout = setTimeout(() => {
if (coValueEntry.state.type === "loading") {
logger.warn("Failed to load coValue from peer", peer.id);
logger.warn("Failed to load coValue from peer", {
peerId: peer.id,
peerRole: peer.role,
});
coValueEntry.dispatch({
type: "not-found-in-peer",
peerId: peer.id,

View File

@@ -60,7 +60,7 @@ export class RawAccount<
);
if (agents.length !== 1) {
logger.warn("Account has " + agents.length + " agents", this.id);
logger.warn("Account has " + agents.length + " agents", { id: this.id });
}
this._cachedCurrentAgentID = agents[0];

View File

@@ -322,7 +322,7 @@ export class RawGroup<
const secret = this.core.getReadKey(keyID);
if (!secret) {
logger.error("Can't find key", keyID);
logger.error("Can't find key " + keyID);
continue;
}

View File

@@ -193,7 +193,9 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
try {
return JSON.parse(textDecoder.decode(plaintext));
} catch (e) {
logger.error("Failed to decrypt/parse sealed message", e);
logger.error(
"Failed to decrypt/parse sealed message: " + (e as Error)?.message,
);
return undefined;
}
}

View File

@@ -241,8 +241,9 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
try {
return JSON.parse(textDecoder.decode(plaintext));
} catch (e) {
logger.error("Failed to decrypt/parse sealed message", e);
return undefined;
logger.error(
"Failed to decrypt/parse sealed message: " + (e as Error)?.message,
);
}
}
}

View File

@@ -160,7 +160,7 @@ export abstract class CryptoProvider<Blake3State = any> {
try {
return parseJSON(this.decryptRaw(encrypted, keySecret, nOnceMaterial));
} catch (e) {
logger.error("Decryption error", e);
logger.error("Decryption error: " + (e as Error)?.message);
return undefined;
}
}

View File

@@ -231,7 +231,7 @@ export class LocalNode {
return node;
} catch (e) {
logger.error("Error withLoadedAccount", e);
logger.error("Error withLoadedAccount: " + (e as Error)?.message);
throw e;
}
}
@@ -270,7 +270,9 @@ export class LocalNode {
this.syncManager.getServerAndStoragePeers(skipLoadingFromPeer);
await entry.loadFromPeers(peers).catch((e) => {
logger.error("Error loading from peers", id, e);
logger.error("Error loading from peers: " + (e as Error)?.message, {
id,
});
});
}
@@ -324,7 +326,9 @@ export class LocalNode {
unsubscribe = coValue.subscribe(callback);
})
.catch((e) => {
logger.error("Error subscribing to ", id, e);
logger.error(
"Error subscribing to " + id + ": " + (e as Error)?.message,
);
});
return () => {

View File

@@ -1,3 +1,5 @@
import type { JsonValue } from "./jsonValue.js";
export enum LogLevel {
DEBUG = 0,
INFO = 1,
@@ -7,25 +9,25 @@ export enum LogLevel {
}
export interface LogSystem {
debug(message: string, ...args: any[]): void;
info(message: string, ...args: any[]): void;
warn(message: string, ...args: any[]): void;
error(message: string, ...args: any[]): void;
debug(message: string, attributes?: Record<string, JsonValue>): void;
info(message: string, attributes?: Record<string, JsonValue>): void;
warn(message: string, attributes?: Record<string, JsonValue>): void;
error(message: string, attributes?: Record<string, JsonValue>): void;
}
// Default console-based logging system
export class ConsoleLogSystem implements LogSystem {
debug(message: string, ...args: any[]) {
console.debug(message, ...args);
debug(message: string, attributes?: Record<string, JsonValue>) {
console.debug(message, attributes);
}
info(message: string, ...args: any[]) {
console.info(message, ...args);
info(message: string, attributes?: Record<string, JsonValue>) {
console.info(message, attributes);
}
warn(message: string, ...args: any[]) {
console.warn(message, ...args);
warn(message: string, attributes?: Record<string, JsonValue>) {
console.warn(message, attributes);
}
error(message: string, ...args: any[]) {
console.error(message, ...args);
error(message: string, attributes?: Record<string, JsonValue>) {
console.error(message, attributes);
}
}
@@ -49,27 +51,27 @@ export class Logger {
this.logSystem = logSystem;
}
debug(message: string, ...args: any[]) {
debug(message: string, attributes?: Record<string, JsonValue>) {
if (this.level <= LogLevel.DEBUG) {
this.logSystem.debug(message, ...args);
this.logSystem.debug(message, attributes);
}
}
info(message: string, ...args: any[]) {
info(message: string, attributes?: Record<string, JsonValue>) {
if (this.level <= LogLevel.INFO) {
this.logSystem.info(message, ...args);
this.logSystem.info(message, attributes);
}
}
warn(message: string, ...args: any[]) {
warn(message: string, attributes?: Record<string, JsonValue>) {
if (this.level <= LogLevel.WARN) {
this.logSystem.warn(message, ...args);
this.logSystem.warn(message, attributes);
}
}
error(message: string, ...args: any[]) {
error(message: string, attributes?: Record<string, JsonValue>) {
if (this.level <= LogLevel.ERROR) {
this.logSystem.error(message, ...args);
this.logSystem.error(message, attributes);
}
}
}

View File

@@ -42,12 +42,15 @@ export function disablePermissionErrors() {
logPermissionErrors = false;
}
function logPermissionError(...args: unknown[]) {
function logPermissionError(
message: string,
attributes?: Record<string, JsonValue>,
) {
if (logPermissionErrors === false) {
return;
}
logger.warn("Permission error", ...args);
logger.warn("Permission error: " + message, attributes);
}
export function determineValidTransactions(
@@ -227,17 +230,10 @@ function determineValidTransactionsForGroup(
try {
changes = parseJSON(tx.changes);
} catch (e) {
logPermissionError(
coValue.id,
"Invalid JSON in transaction",
e,
logPermissionError("Invalid JSON in transaction", {
id: coValue.id,
tx,
JSON.stringify(tx.changes, (k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
),
);
});
continue;
}

View File

@@ -90,8 +90,9 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
)}`,
e,
)}
Error: ${e instanceof Error ? e.message : "Unknown error"},
`,
);
}
nMsg++;
@@ -241,7 +242,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
} else {
const merged = mergeChunks(coValue, newContentAsChunk);
if (merged === "nonContigous") {
logger.warn(
console.warn(
"Non-contigous new content for " + newContent.id,
Object.entries(coValue.sessionEntries).map(([session, entries]) =>
entries.map((entry) => ({
@@ -316,7 +317,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
const merged = mergeChunks(result, nextChunk);
if (merged === "nonContigous") {
logger.warn(
console.warn(
"Non-contigous chunks while loading " + id,
result,
nextChunk,
@@ -375,7 +376,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
if (existingChunk) {
const merged = mergeChunks(existingChunk, chunk);
if (merged === "nonContigous") {
logger.info(
console.log(
"Non-contigous chunks in " + chunk.id + ", " + fileName,
existingChunk,
chunk,
@@ -449,7 +450,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
if (existingChunk) {
const merged = mergeChunks(existingChunk, chunk);
if (merged === "nonContigous") {
logger.info(
console.log(
"Non-contigous chunks in " + entry.id + ", " + blockFile,
existingChunk,
chunk,

View File

@@ -23,6 +23,10 @@ export function emptyKnownState(id: RawCoID): CoValueKnownState {
};
}
function getErrorMessage(e: unknown) {
return e instanceof Error ? e.message : "Unknown error";
}
export type SyncMessage =
| LoadMessage
| KnownStateMessage
@@ -183,7 +187,7 @@ export class SyncManager {
if (entry.state.type !== "available") {
entry.loadFromPeers([peer]).catch((e: unknown) => {
logger.error("Error sending load", e);
logger.error("Error sending load: " + getErrorMessage(e));
});
return;
}
@@ -200,7 +204,7 @@ export class SyncManager {
action: "load",
...coValue.knownState(),
}).catch((e: unknown) => {
logger.error("Error sending load", e);
logger.error("Error sending load: " + getErrorMessage(e));
});
}
}
@@ -230,7 +234,7 @@ export class SyncManager {
asDependencyOf,
...coValue.knownState(),
}).catch((e: unknown) => {
logger.error("Error sending known state", e);
logger.error("Error sending known state: " + getErrorMessage(e));
});
peer.toldKnownState.add(id);
@@ -258,7 +262,7 @@ export class SyncManager {
let lastYield = performance.now();
for (const [_i, piece] of newContentPieces.entries()) {
this.trySendToPeer(peer, piece).catch((e: unknown) => {
logger.error("Error sending content piece", e);
logger.error("Error sending content piece: " + getErrorMessage(e));
});
if (performance.now() - lastYield > 10) {
@@ -331,7 +335,10 @@ export class SyncManager {
return;
}
if (msg === "PingTimeout") {
logger.error("Ping timeout from peer", peer.id);
logger.error("Ping timeout from peer", {
peerId: peer.id,
peerRole: peer.role,
});
return;
}
try {
@@ -354,13 +361,22 @@ export class SyncManager {
processMessages()
.then(() => {
if (peer.crashOnClose) {
logger.warn("Unexepcted close from peer", peer.id);
logger.error("Unexepcted close from peer", {
peerId: peer.id,
peerRole: peer.role,
});
this.local.crashed = new Error("Unexpected close from peer");
throw new Error("Unexpected close from peer");
}
})
.catch((e) => {
logger.error("Error processing messages from peer", peer.id, e);
logger.error(
"Error processing messages from peer: " + getErrorMessage(e),
{
peerId: peer.id,
peerRole: peer.role,
},
);
if (peer.crashOnClose) {
this.local.crashed = e;
throw new Error(e);
@@ -596,17 +612,11 @@ export class SyncManager {
// );
if (result.isErr()) {
logger.error(
"Failed to add transactions from",
peer.id,
result.error,
msg.id,
newTransactions.length + " new transactions",
"after: " + newContentForSession.after,
"our last known tx idx initially: " + ourKnownTxIdx,
"our last known tx idx now: " +
coValue.sessionLogs.get(sessionID)?.transactions.length,
);
logger.error("Failed to add transactions: " + result.error.type, {
peerId: peer.id,
peerRole: peer.role,
id: msg.id,
});
peer.erroredCoValues.set(msg.id, result.error);
continue;
}
@@ -627,7 +637,13 @@ export class SyncManager {
isCorrection: true,
...coValue.knownState(),
}).catch((e) => {
logger.error("Error sending known state correction", e);
logger.error(
"Error sending known state correction: " + getErrorMessage(e),
{
peerId: peer.id,
peerRole: peer.role,
},
);
});
} else {
/**
@@ -641,7 +657,10 @@ export class SyncManager {
action: "known",
...coValue.knownState(),
}).catch((e: unknown) => {
logger.error("Error sending known state", e);
logger.error("Error sending known state: " + getErrorMessage(e), {
peerId: peer.id,
peerRole: peer.role,
});
});
}

View File

@@ -20,8 +20,14 @@ describe("Logger", () => {
expect(mockLogSystem.debug).not.toHaveBeenCalled();
expect(mockLogSystem.info).not.toHaveBeenCalled();
expect(mockLogSystem.warn).toHaveBeenCalledWith("Warning message");
expect(mockLogSystem.error).toHaveBeenCalledWith("Error message");
expect(mockLogSystem.warn).toHaveBeenCalledWith(
"Warning message",
undefined,
);
expect(mockLogSystem.error).toHaveBeenCalledWith(
"Error message",
undefined,
);
});
test("should pass additional arguments to log system", () => {
@@ -33,14 +39,12 @@ describe("Logger", () => {
};
const logger = new Logger(LogLevel.DEBUG, mockLogSystem);
const additionalArgs = [{ foo: "bar" }, 42, "extra"];
logger.debug("Debug message", ...additionalArgs);
logger.debug("Debug message", { foo: "bar" });
expect(mockLogSystem.debug).toHaveBeenCalledWith(
"Debug message",
...additionalArgs,
);
expect(mockLogSystem.debug).toHaveBeenCalledWith("Debug message", {
foo: "bar",
});
});
});
@@ -60,7 +64,7 @@ describe("Logger", () => {
logger.setLevel(LogLevel.WARN);
logger.warn("Warning 2"); // Should log
expect(mockLogSystem.warn).toHaveBeenCalledWith("Warning 2");
expect(mockLogSystem.warn).toHaveBeenCalledWith("Warning 2", undefined);
});
test("should allow changing log system at runtime", () => {
@@ -81,12 +85,12 @@ describe("Logger", () => {
const logger = new Logger(LogLevel.INFO, mockLogSystem1);
logger.info("Message 1");
expect(mockLogSystem1.info).toHaveBeenCalledWith("Message 1");
expect(mockLogSystem1.info).toHaveBeenCalledWith("Message 1", undefined);
expect(mockLogSystem2.info).not.toHaveBeenCalled();
logger.setLogSystem(mockLogSystem2);
logger.info("Message 2");
expect(mockLogSystem2.info).toHaveBeenCalledWith("Message 2");
expect(mockLogSystem2.info).toHaveBeenCalledWith("Message 2", undefined);
expect(mockLogSystem1.info).toHaveBeenCalledTimes(1);
});
});
@@ -103,17 +107,17 @@ describe("Logger", () => {
const logger = new Logger();
logger.setLevel(LogLevel.DEBUG);
const testMessage = "Test message";
const testArgs = [{ data: "test" }, 123];
const testArgs = { data: "test" };
logger.debug(testMessage, ...testArgs);
logger.info(testMessage, ...testArgs);
logger.warn(testMessage, ...testArgs);
logger.error(testMessage, ...testArgs);
logger.debug(testMessage, testArgs);
logger.info(testMessage, testArgs);
logger.warn(testMessage, testArgs);
logger.error(testMessage, testArgs);
expect(consoleSpy.debug).toHaveBeenCalledWith(testMessage, ...testArgs);
expect(consoleSpy.info).toHaveBeenCalledWith(testMessage, ...testArgs);
expect(consoleSpy.warn).toHaveBeenCalledWith(testMessage, ...testArgs);
expect(consoleSpy.error).toHaveBeenCalledWith(testMessage, ...testArgs);
expect(consoleSpy.debug).toHaveBeenCalledWith(testMessage, testArgs);
expect(consoleSpy.info).toHaveBeenCalledWith(testMessage, testArgs);
expect(consoleSpy.warn).toHaveBeenCalledWith(testMessage, testArgs);
expect(consoleSpy.error).toHaveBeenCalledWith(testMessage, testArgs);
// Cleanup
Object.values(consoleSpy).forEach((spy) => spy.mockRestore());

View File

@@ -1,5 +1,31 @@
# jazz-browser-media-images
## 0.9.14
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser@0.9.14
## 0.9.13
### Patch Changes
- Updated dependencies [8d29e50]
- cojson@0.9.13
- jazz-browser@0.9.13
- jazz-tools@0.9.13
## 0.9.12
### Patch Changes
- Updated dependencies [15d4b2a]
- cojson@0.9.12
- jazz-browser@0.9.12
- jazz-tools@0.9.12
## 0.9.11
### Patch Changes

View File

@@ -1,14 +1,14 @@
{
"name": "jazz-browser-auth-clerk",
"version": "0.9.11",
"version": "0.9.14",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:0.9.11",
"jazz-browser": "workspace:0.9.11",
"jazz-tools": "workspace:0.9.11"
"cojson": "workspace:0.9.13",
"jazz-browser": "workspace:0.9.14",
"jazz-tools": "workspace:0.9.14"
},
"scripts": {
"format-and-lint": "biome check .",

View File

@@ -1,7 +1,8 @@
import { AgentSecret, CryptoProvider } from "cojson";
import { AuthSecretStorage } from "jazz-browser";
import { AgentSecret } from "cojson";
import { Account, AuthMethod, AuthResult, Credentials, ID } from "jazz-tools";
const localStorageKey = "jazz-clerk-auth";
export type MinimalClerkClient = {
user:
| {
@@ -20,11 +21,15 @@ export type MinimalClerkClient = {
signOut: () => Promise<void>;
};
type ClerkCredentials = {
jazzAccountID: ID<Account>;
jazzAccountSecret: AgentSecret;
jazzAccountSeed?: number[];
};
function saveCredentialsToLocalStorage(credentials: Credentials) {
localStorage.setItem(
localStorageKey,
JSON.stringify({
accountID: credentials.accountID,
secret: credentials.secret,
}),
);
}
export class BrowserClerkAuth implements AuthMethod {
constructor(
@@ -32,28 +37,23 @@ export class BrowserClerkAuth implements AuthMethod {
private readonly clerkClient: MinimalClerkClient,
) {}
async start(crypto: CryptoProvider): Promise<AuthResult> {
AuthSecretStorage.migrate();
async start(): Promise<AuthResult> {
// Check local storage for credentials
const credentials = AuthSecretStorage.get();
const isAnonymous = AuthSecretStorage.isAnonymous();
const locallyStoredCredentials = localStorage.getItem(localStorageKey);
if (credentials && !isAnonymous) {
if (locallyStoredCredentials) {
try {
const credentials = JSON.parse(locallyStoredCredentials) as Credentials;
return {
type: "existing",
credentials: {
accountID: credentials.accountID,
secret: credentials.accountSecret,
},
credentials,
saveCredentials: async () => {}, // No need to save credentials when recovering from local storage
onSuccess: () => {},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
AuthSecretStorage.clear();
localStorage.removeItem(localStorageKey);
void this.clerkClient.signOut();
},
};
@@ -63,65 +63,22 @@ export class BrowserClerkAuth implements AuthMethod {
}
if (this.clerkClient.user) {
const username =
this.clerkClient.user.fullName ||
this.clerkClient.user.username ||
this.clerkClient.user.id;
// Check clerk user metadata for credentials
const clerkCredentials = this.clerkClient.user
.unsafeMetadata as ClerkCredentials;
if (clerkCredentials.jazzAccountID) {
if (!clerkCredentials.jazzAccountSecret) {
this.driver.onError("No secret for existing user");
const storedCredentials = this.clerkClient.user.unsafeMetadata;
if (storedCredentials.jazzAccountID) {
if (!storedCredentials.jazzAccountSecret) {
throw new Error("No secret for existing user");
}
return {
type: "existing",
credentials: {
accountID: clerkCredentials.jazzAccountID as ID<Account>,
secret: clerkCredentials.jazzAccountSecret as AgentSecret,
accountID: storedCredentials.jazzAccountID as ID<Account>,
secret: storedCredentials.jazzAccountSecret as AgentSecret,
},
saveCredentials: async ({ accountID, secret }: Credentials) => {
AuthSecretStorage.set({
saveCredentialsToLocalStorage({
accountID,
accountSecret: secret,
secretSeed: clerkCredentials.jazzAccountSeed
? Uint8Array.from(clerkCredentials.jazzAccountSeed)
: undefined,
provider: "clerk",
});
},
onSuccess: () => {},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
void this.clerkClient.signOut();
},
};
} else if (credentials && isAnonymous) {
return {
type: "existing",
username,
credentials: {
accountID: credentials.accountID,
secret: credentials.accountSecret,
},
saveCredentials: async ({ accountID, secret }: Credentials) => {
AuthSecretStorage.set({
accountID,
accountSecret: secret,
secretSeed: credentials.secretSeed,
provider: "clerk",
});
await this.clerkClient.user?.update({
unsafeMetadata: {
jazzAccountID: accountID,
jazzAccountSecret: secret,
jazzAccountSeed: credentials.secretSeed
? Array.from(credentials.secretSeed)
: undefined,
} satisfies ClerkCredentials,
secret,
});
},
onSuccess: () => {},
@@ -133,27 +90,25 @@ export class BrowserClerkAuth implements AuthMethod {
},
};
} else {
const secretSeed = crypto.newRandomSecretSeed();
// No credentials found, so we need to create new credentials
return {
type: "new",
creationProps: {
name: username,
name:
this.clerkClient.user.fullName ||
this.clerkClient.user.username ||
this.clerkClient.user.id,
},
initialSecret: crypto.agentSecretFromSecretSeed(secretSeed),
saveCredentials: async ({ accountID, secret }: Credentials) => {
AuthSecretStorage.set({
saveCredentialsToLocalStorage({
accountID,
secretSeed,
accountSecret: secret,
provider: "clerk",
secret,
});
await this.clerkClient.user?.update({
unsafeMetadata: {
jazzAccountID: accountID,
jazzAccountSecret: secret,
jazzAccountSeed: Array.from(secretSeed),
} satisfies ClerkCredentials,
},
});
},
onSuccess: () => {},

View File

@@ -1,212 +1,119 @@
// @vitest-environment happy-dom
import { AgentSecret } from "cojson";
import { AuthSecretStorage } from "jazz-browser";
import { Account } from "jazz-tools";
import { ID } from "jazz-tools";
import { Account, ID } from "jazz-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BrowserClerkAuth } from "../index";
beforeEach(() => {
AuthSecretStorage.clear();
});
import { BrowserClerkAuth, MinimalClerkClient } from "../index.js";
describe("BrowserClerkAuth", () => {
function createDriver() {
return {
onError: vi.fn(),
} satisfies BrowserClerkAuth.Driver;
}
let mockLocalStorage: { [key: string]: string };
let mockClerkClient: MinimalClerkClient;
let mockDriver: BrowserClerkAuth.Driver;
function createMockClerkClient(user?: any) {
return {
user,
beforeEach(() => {
// Mock localStorage
mockLocalStorage = {};
global.localStorage = {
getItem: vi.fn((key: string) => mockLocalStorage[key] || null),
setItem: vi.fn((key: string, value: string) => {
mockLocalStorage[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete mockLocalStorage[key];
}),
clear: vi.fn(),
length: 0,
key: vi.fn(),
};
// Mock Clerk client
mockClerkClient = {
user: {
unsafeMetadata: {},
fullName: "Test User",
username: "testuser",
id: "test-id",
update: vi.fn(),
},
signOut: vi.fn(),
};
}
describe("initialization", () => {
it("should handle existing non-anonymous user from storage", async () => {
const driver = createDriver();
const mockClerkClient = createMockClerkClient();
const auth = new BrowserClerkAuth(driver, mockClerkClient);
// Mock driver
mockDriver = {
onError: vi.fn(),
};
});
// Set up existing user in storage
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1]),
accountSecret: "fromAuthStorage" as AgentSecret,
provider: "clerk",
describe("clerk credentials in localStorage", () => {
it("should get credentials from localStorage when clerk user is not signed in", async () => {
mockLocalStorage["jazz-clerk-auth"] = JSON.stringify({
accountID: "test-account-id",
accountSecret: "test-secret",
});
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([2]),
agentSecretFromSecretSeed: () => "xxxxx" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
const auth = new BrowserClerkAuth(mockDriver, {
...mockClerkClient,
user: null,
});
const result = await auth.start();
expect(result.type).toBe("existing");
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
expect(result.credentials).toEqual({
accountID: "test123",
secret: "fromAuthStorage",
});
});
});
it("should handle existing clerk user with credentials", async () => {
const driver = createDriver();
const mockUser = {
id: "clerk123",
fullName: "Test User",
unsafeMetadata: {
jazzAccountID: "test123",
jazzAccountSecret: "clerkSecret",
jazzAccountSeed: [1, 2, 3],
},
update: vi.fn(),
};
const mockClerkClient = createMockClerkClient(mockUser);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([2]),
agentSecretFromSecretSeed: () => "xxxxx" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
expect(result.type).toBe("existing");
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
expect(result.credentials).toEqual({
accountID: "test123",
secret: "clerkSecret",
});
});
it("should handle anonymous user upgrade", async () => {
const driver = createDriver();
const mockUser = {
id: "clerk123",
fullName: "Test User",
unsafeMetadata: {},
update: vi.fn(),
};
const mockClerkClient = createMockClerkClient(mockUser);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
// Set up anonymous user in storage
AuthSecretStorage.set({
accountID: "anon123" as ID<Account>,
secretSeed: new Uint8Array([1]),
accountSecret: "anonSecret" as AgentSecret,
provider: "anonymous",
});
const result = await auth.start({} as any);
expect(result.type).toBe("existing");
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
expect(result.username).toBe("Test User");
expect(result.credentials).toEqual({
accountID: "anon123",
secret: "anonSecret",
});
// Test saving credentials updates both storage and clerk metadata
await result.saveCredentials?.({
accountID: "anon123" as ID<Account>,
secret: "newSecret" as AgentSecret,
});
expect(mockUser.update).toHaveBeenCalledWith({
unsafeMetadata: {
jazzAccountID: "anon123",
jazzAccountSecret: "newSecret",
jazzAccountSeed: expect.any(Array),
},
});
});
it("should handle new user creation", async () => {
const driver = createDriver();
const mockUser = {
id: "clerk123",
fullName: "Test User",
unsafeMetadata: {},
update: vi.fn(),
};
const mockClerkClient = createMockClerkClient(mockUser);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "newSecret" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
describe("clerk credentials not in localStorage", () => {
it("should return new credentials when clerk user signs up", async () => {
const auth = new BrowserClerkAuth(mockDriver, mockClerkClient);
const result = await auth.start();
expect(result.type).toBe("new");
});
if (result.type !== "new") {
throw new Error("Expected new user login");
}
expect(result.creationProps).toEqual({
name: "Test User",
});
expect(result.initialSecret).toBe("newSecret");
await result.saveCredentials({
accountID: "new123" as ID<Account>,
secret: "newSecret" as AgentSecret,
});
expect(mockUser.update).toHaveBeenCalledWith({
unsafeMetadata: {
jazzAccountID: "new123",
jazzAccountSecret: "newSecret",
jazzAccountSeed: [1, 2, 3],
it("should return existing credentials when clerk user is signed in", async () => {
mockClerkClient = {
user: {
unsafeMetadata: {
jazzAccountID: "test-account-id",
jazzAccountSecret: "test-secret",
},
fullName: "Test User",
username: "testuser",
id: "test-id",
update: vi.fn(),
},
});
signOut: vi.fn(),
};
const auth = new BrowserClerkAuth(mockDriver, mockClerkClient);
const result = await auth.start();
expect(result.type).toBe("existing");
});
it("should throw error when not signed in", async () => {
const driver = createDriver();
const mockClerkClient = createMockClerkClient(undefined);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
const auth = new BrowserClerkAuth(mockDriver, {
...mockClerkClient,
user: null,
});
await expect(auth.start({} as any)).rejects.toThrow("Not signed in");
await expect(auth.start()).rejects.toThrow("Not signed in");
});
it("should throw error when clerk user has ID but no secret", async () => {
const driver = createDriver();
const mockUser = {
id: "clerk123",
fullName: "Test User",
unsafeMetadata: {
jazzAccountID: "test123",
},
update: vi.fn(),
};
const mockClerkClient = createMockClerkClient(mockUser);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
it("should save credentials to localStorage", async () => {
const auth = new BrowserClerkAuth(mockDriver, mockClerkClient);
const result = await auth.start();
if (result.saveCredentials) {
await result.saveCredentials({
accountID: "test-account-id" as ID<Account>,
secret: "test-secret" as AgentSecret,
});
}
await expect(auth.start({} as any)).rejects.toThrow(
"No secret for existing user",
);
expect(mockLocalStorage["jazz-clerk-auth"]).toBeDefined();
});
it("should call clerk signOut when logging out", async () => {
const auth = new BrowserClerkAuth(mockDriver, mockClerkClient);
const result = await auth.start();
result.logOut();
expect(mockClerkClient.signOut).toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,27 @@
# jazz-browser-media-images
## 0.9.14
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
- jazz-browser@0.9.14
## 0.9.13
### Patch Changes
- jazz-browser@0.9.13
- jazz-tools@0.9.13
## 0.9.12
### Patch Changes
- jazz-browser@0.9.12
- jazz-tools@0.9.12
## 0.9.11
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-browser-media-images",
"version": "0.9.11",
"version": "0.9.14",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -8,8 +8,8 @@
"dependencies": {
"@types/image-blob-reduce": "^4.1.1",
"image-blob-reduce": "^4.1.0",
"jazz-browser": "workspace:0.9.11",
"jazz-tools": "workspace:0.9.11",
"jazz-browser": "workspace:0.9.14",
"jazz-tools": "workspace:0.9.14",
"pica": "^9.0.1",
"typescript": "~5.6.2"
},

View File

@@ -44,7 +44,6 @@ vi.mock("image-blob-reduce", () => {
describe("createImage", () => {
it("should create an image definition with correct dimensions", async () => {
vi.useFakeTimers();
// Create a test blob that simulates a 400x300 image
const blob = new Blob(["fake-image-data"], { type: "image/jpeg" });
Object.defineProperty(blob, "size", { value: 1024 * 50 }); // 50KB

View File

@@ -1,5 +1,32 @@
# jazz-browser
## 0.9.14
### Patch Changes
- Updated dependencies [3df93cc]
- jazz-tools@0.9.14
## 0.9.13
### Patch Changes
- Updated dependencies [8d29e50]
- cojson-transport-ws@0.9.13
- cojson@0.9.13
- cojson-storage-indexeddb@0.9.13
- jazz-tools@0.9.13
## 0.9.12
### Patch Changes
- Updated dependencies [15d4b2a]
- cojson-transport-ws@0.9.12
- cojson@0.9.12
- cojson-storage-indexeddb@0.9.12
- jazz-tools@0.9.12
## 0.9.11
### Patch Changes

View File

@@ -1,19 +1,16 @@
{
"name": "jazz-browser",
"version": "0.9.11",
"version": "0.9.14",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"@scure/bip39": "^1.3.0",
"cojson": "workspace:*",
"cojson-storage-indexeddb": "workspace:*",
"cojson-transport-ws": "workspace:*",
"jazz-tools": "workspace:*"
},
"devDependencies": {
"fake-indexeddb": "^6.0.0",
"cojson": "workspace:0.9.13",
"cojson-storage-indexeddb": "workspace:0.9.13",
"cojson-transport-ws": "workspace:0.9.13",
"jazz-tools": "workspace:0.9.14",
"typescript": "~5.6.2"
},
"scripts": {

View File

@@ -1,96 +0,0 @@
import { AgentSecret } from "cojson";
import { Account, ID } from "jazz-tools";
const STORAGE_KEY = "jazz-logged-in-secret";
export type AuthCredentials = {
accountID: ID<Account>;
secretSeed?: Uint8Array;
accountSecret: AgentSecret;
provider?: "anonymous" | "clerk" | "demo" | "passkey" | "passphrase" | string;
};
export type AuthSetPayload = {
accountID: ID<Account>;
secretSeed?: Uint8Array;
accountSecret: AgentSecret;
provider: "anonymous" | "clerk" | "demo" | "passkey" | "passphrase" | string;
};
export const AuthSecretStorage = {
migrate() {
if (!localStorage[STORAGE_KEY]) {
const demoAuthSecret = localStorage["demo-auth-logged-in-secret"];
if (demoAuthSecret) {
localStorage[STORAGE_KEY] = demoAuthSecret;
delete localStorage["demo-auth-logged-in-secret"];
}
const clerkAuthSecret = localStorage["jazz-clerk-auth"];
if (clerkAuthSecret) {
localStorage[STORAGE_KEY] = clerkAuthSecret;
delete localStorage["jazz-clerk-auth"];
}
}
},
get(): AuthCredentials | null {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return null;
const parsed = JSON.parse(data);
if (!parsed.accountID || !parsed.accountSecret) {
throw new Error("Invalid auth secret storage data");
}
return {
accountID: parsed.accountID,
secretSeed: parsed.secretSeed
? new Uint8Array(parsed.secretSeed)
: undefined,
accountSecret: parsed.accountSecret,
provider: parsed.provider,
};
},
set(payload: AuthSetPayload) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
accountID: payload.accountID,
secretSeed: payload.secretSeed
? Array.from(payload.secretSeed)
: undefined,
accountSecret: payload.accountSecret,
provider: payload.provider,
}),
);
this.emitUpdate();
},
isAnonymous() {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return false;
const parsed = JSON.parse(data);
return parsed.provider === "anonymous";
},
onUpdate(handler: () => void) {
window.addEventListener("jazz-auth-update", handler);
return () => window.removeEventListener("jazz-auth-update", handler);
},
emitUpdate() {
window.dispatchEvent(new Event("jazz-auth-update"));
},
clear() {
localStorage.removeItem(STORAGE_KEY);
this.emitUpdate();
},
};

View File

@@ -1,12 +1,13 @@
import { AgentSecret, CryptoProvider } from "cojson";
import { AgentSecret } from "cojson";
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
import { AuthSecretStorage } from "./AuthSecretStorage.js";
type StorageData = {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const localStorageKey = "jazz-logged-in-secret";
/**
* `BrowserDemoAuth` provides a `JazzAuth` object for demo authentication.
*
@@ -52,39 +53,44 @@ export class BrowserDemoAuth implements AuthMethod {
/**
* @returns A `JazzAuth` object
*/
async start(crypto: CryptoProvider) {
AuthSecretStorage.migrate();
async start() {
// migrate old localStorage key to new one
if (localStorage["demo-auth-logged-in-secret"]) {
if (!localStorage[localStorageKey]) {
localStorage[localStorageKey] =
localStorage["demo-auth-logged-in-secret"];
}
delete localStorage["demo-auth-logged-in-secret"];
}
const credentials = AuthSecretStorage.get();
if (localStorage[localStorageKey]) {
const localStorageData = JSON.parse(
localStorage[localStorageKey],
) as StorageData;
if (credentials) {
const accountID = credentials.accountID;
const secret = credentials.accountSecret;
const accountID = localStorageData.accountID as ID<Account>;
const secret = localStorageData.accountSecret;
return {
type: "existing",
credentials: { accountID, secret },
onSuccess: () => {
this.driver.onSignedIn({ logOut, isSignUp: false });
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
},
} satisfies AuthResult;
} else {
return new Promise<AuthResult>((resolve) => {
this.driver.onReady({
signUp: async (username) => {
const secretSeed = crypto.newRandomSecretSeed();
const accountSecret = crypto.agentSecretFromSecretSeed(secretSeed);
resolve({
type: "new",
creationProps: { name: username },
initialSecret: accountSecret,
saveCredentials: async (credentials: {
accountID: ID<Account>;
secret: AgentSecret;
@@ -94,13 +100,7 @@ export class BrowserDemoAuth implements AuthMethod {
accountSecret: credentials.secret,
} satisfies StorageData);
AuthSecretStorage.set({
accountID: credentials.accountID,
secretSeed,
accountSecret,
provider: "demo",
});
localStorage[localStorageKey] = storageData;
localStorage["demo-auth-existing-users-" + username] =
storageData;
@@ -111,13 +111,13 @@ export class BrowserDemoAuth implements AuthMethod {
: username;
},
onSuccess: () => {
this.driver.onSignedIn({ logOut, isSignUp: true });
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
},
});
},
@@ -128,11 +128,7 @@ export class BrowserDemoAuth implements AuthMethod {
localStorage["demo-auth-existing-users-" + existingUser],
) as StorageData;
AuthSecretStorage.set({
accountID: storageData.accountID,
accountSecret: storageData.accountSecret,
provider: "demo",
});
localStorage[localStorageKey] = JSON.stringify(storageData);
resolve({
type: "existing",
@@ -141,13 +137,13 @@ export class BrowserDemoAuth implements AuthMethod {
secret: storageData.accountSecret,
},
onSuccess: () => {
this.driver.onSignedIn({ logOut, isSignUp: false });
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
},
});
},
@@ -166,14 +162,11 @@ export namespace BrowserDemoAuth {
existingUsers: string[];
logInAs: (existingUser: string) => Promise<void>;
}) => void;
onSignedIn: (next: {
logOut: () => void;
isSignUp: boolean;
}) => void;
onSignedIn: (next: { logOut: () => void }) => void;
onError: (error: string | Error) => void;
}
}
function logOut() {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
}

View File

@@ -1,37 +1,43 @@
import { AgentSecret, CryptoProvider } from "cojson";
import { AgentSecret } from "cojson";
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
import { AuthSecretStorage } from "./AuthSecretStorage.js";
type StorageData = {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const STORAGE_KEY = "jazz-logged-in-secret";
/**
* `BrowserAnonymousAuth` provides a `JazzAuth` object for demo authentication.
* `BrowserOnboardingAuth` provides a `JazzAuth` object for demo authentication.
*
* Demo authentication is useful for quickly testing your app, as it allows you to create new accounts and log in as existing ones. The authentication persists across page reloads, with the credentials stored in `localStorage`.
*
* ```
* import { BrowserAnonymousAuth } from "jazz-browser";
* import { BrowserOnboardingAuth } from "jazz-browser";
*
* const auth = new BrowserAnonymousAuth(driver);
* const auth = new BrowserOnboardingAuth(driver);
* ```
*
* @category Auth Providers
*/
export class BrowserAnonymousAuth implements AuthMethod {
export class BrowserOnboardingAuth implements AuthMethod {
constructor(
public defaultUserName: string,
public driver: BrowserAnonymousAuth.Driver,
public driver: BrowserOnboardingAuth.Driver,
) {}
/**
* @returns A `JazzAuth` object
*/
async start(crypto: CryptoProvider) {
AuthSecretStorage.migrate();
const existingUser = AuthSecretStorage.get();
async start() {
const existingUser = localStorage[STORAGE_KEY];
if (existingUser) {
const accountID = existingUser.accountID;
const secret = existingUser.accountSecret;
const existingUserData = JSON.parse(existingUser) as StorageData;
const accountID = existingUserData.accountID as ID<Account>;
const secret = existingUserData.accountSecret;
return {
type: "existing",
@@ -45,22 +51,19 @@ export class BrowserAnonymousAuth implements AuthMethod {
logOut,
} satisfies AuthResult;
} else {
const secretSeed = crypto.newRandomSecretSeed();
return {
type: "new",
creationProps: { name: this.defaultUserName, anonymous: true },
initialSecret: crypto.agentSecretFromSecretSeed(secretSeed),
saveCredentials: async (credentials: {
accountID: ID<Account>;
secret: AgentSecret;
}) => {
AuthSecretStorage.set({
const storageData = JSON.stringify({
accountID: credentials.accountID,
secretSeed,
accountSecret: credentials.secret,
provider: "anonymous",
});
} satisfies StorageData);
localStorage[STORAGE_KEY] = storageData;
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
@@ -76,7 +79,7 @@ export class BrowserAnonymousAuth implements AuthMethod {
/** @internal */
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace BrowserAnonymousAuth {
export namespace BrowserOnboardingAuth {
export interface Driver {
onSignedIn: (next: { logOut: () => void }) => void;
onError: (error: string | Error) => void;
@@ -84,5 +87,5 @@ export namespace BrowserAnonymousAuth {
}
function logOut() {
AuthSecretStorage.clear();
delete localStorage[STORAGE_KEY];
}

View File

@@ -1,6 +1,17 @@
import { CryptoProvider, RawAccountID, cojsonInternals } from "cojson";
import {
AgentSecret,
CryptoProvider,
RawAccountID,
cojsonInternals,
} from "cojson";
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
import { AuthSecretStorage } from "./AuthSecretStorage.js";
type LocalStorageData = {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const localStorageKey = "jazz-logged-in-secret";
/**
* `BrowserPasskeyAuth` provides a `JazzAuth` object for passkey authentication.
@@ -21,18 +32,25 @@ export class BrowserPasskeyAuth implements AuthMethod {
public appHostname: string = window.location.hostname,
) {}
accountLoaded() {
this.driver.onSignedIn({ logOut });
}
onError(error: string | Error) {
this.driver.onError(error);
}
/**
* @returns A `JazzAuth` object
*/
async start(crypto: CryptoProvider): Promise<AuthResult> {
AuthSecretStorage.migrate();
if (localStorage[localStorageKey]) {
const localStorageData = JSON.parse(
localStorage[localStorageKey],
) as LocalStorageData;
const credentials = AuthSecretStorage.get();
const isAnonymous = AuthSecretStorage.isAnonymous();
if (credentials && !isAnonymous) {
const accountID = credentials.accountID;
const secret = credentials.accountSecret;
const accountID = localStorageData.accountID as ID<Account>;
const secret = localStorageData.accountSecret;
return {
type: "existing",
@@ -44,96 +62,85 @@ export class BrowserPasskeyAuth implements AuthMethod {
this.driver.onError(error);
},
logOut: () => {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
},
} satisfies AuthResult;
} else {
return new Promise<AuthResult>((resolve) => {
this.driver.onReady({
signUp: async (username) => {
if (credentials && isAnonymous && credentials.secretSeed) {
const secretSeed = credentials.secretSeed;
const secretSeed = crypto.newRandomSecretSeed();
resolve({
type: "existing",
username,
credentials: {
accountID: credentials.accountID,
secret: credentials.accountSecret,
},
saveCredentials: async ({ accountID, secret }) => {
await this.createPasskeyCredentials({
accountID,
secretSeed,
username,
});
resolve({
type: "new",
creationProps: { name: username },
initialSecret: crypto.agentSecretFromSecretSeed(secretSeed),
saveCredentials: async ({ accountID, secret }) => {
const webAuthNCredentialPayload = new Uint8Array(
cojsonInternals.secretSeedLength +
cojsonInternals.shortHashLength,
);
AuthSecretStorage.set({
accountID,
secretSeed,
accountSecret: secret,
provider: "passkey",
});
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut,
});
return;
} else {
const secretSeed = crypto.newRandomSecretSeed();
webAuthNCredentialPayload.set(secretSeed);
webAuthNCredentialPayload.set(
cojsonInternals.rawCoIDtoBytes(
accountID as unknown as RawAccountID,
),
cojsonInternals.secretSeedLength,
);
resolve({
type: "new",
creationProps: { name: username },
initialSecret: crypto.agentSecretFromSecretSeed(secretSeed),
saveCredentials: async ({ accountID, secret }) => {
await this.createPasskeyCredentials({
accountID,
secretSeed,
username,
});
await navigator.credentials.create({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rp: {
name: this.appName,
id: this.appHostname,
},
user: {
id: webAuthNCredentialPayload,
name: username + ` (${new Date().toLocaleString()})`,
displayName: username,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: true,
residentKey: "required",
},
timeout: 60000,
attestation: "direct",
},
});
AuthSecretStorage.set({
accountID,
secretSeed,
accountSecret: secret,
provider: "passkey",
});
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut,
});
}
localStorage[localStorageKey] = JSON.stringify({
accountID,
accountSecret: secret,
} satisfies LocalStorageData);
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
},
});
},
logIn: async () => {
const webAuthNCredential = await this.getPasskeyCredentials().catch(
() => {
this.driver.onError(
"Error while accessing the passkey credentials",
);
return "rejected" as const;
const webAuthNCredential = (await navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rpId: this.appHostname,
allowCredentials: [],
timeout: 60000,
},
);
if (webAuthNCredential === "rejected") {
return;
}
})) as unknown as {
response: { userHandle: ArrayBuffer };
};
if (!webAuthNCredential) {
this.driver.onError(
"Error while accessing the passkey credentials",
);
return;
throw new Error("Couldn't log in");
}
const webAuthNCredentialPayload = new Uint8Array(
@@ -158,12 +165,10 @@ export class BrowserPasskeyAuth implements AuthMethod {
type: "existing",
credentials: { accountID, secret },
saveCredentials: async ({ accountID, secret }) => {
AuthSecretStorage.set({
localStorage[localStorageKey] = JSON.stringify({
accountID,
accountSecret: secret,
secretSeed: accountSecretSeed,
provider: "passkey",
});
} satisfies LocalStorageData);
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
@@ -171,79 +176,15 @@ export class BrowserPasskeyAuth implements AuthMethod {
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut,
logOut: () => {
delete localStorage[localStorageKey];
},
});
},
});
});
}
}
private async createPasskeyCredentials({
accountID,
secretSeed,
username,
}: {
accountID: ID<Account>;
secretSeed: Uint8Array;
username: string;
}) {
const webAuthNCredentialPayload = new Uint8Array(
cojsonInternals.secretSeedLength + cojsonInternals.shortHashLength,
);
webAuthNCredentialPayload.set(secretSeed);
webAuthNCredentialPayload.set(
cojsonInternals.rawCoIDtoBytes(accountID as unknown as RawAccountID),
cojsonInternals.secretSeedLength,
);
try {
await navigator.credentials.create({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rp: {
name: this.appName,
id: this.appHostname,
},
user: {
id: webAuthNCredentialPayload,
name: username + ` (${new Date().toLocaleString()})`,
displayName: username,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: true,
residentKey: "required",
},
timeout: 60000,
attestation: "direct",
},
});
} catch (error) {
if (error instanceof DOMException && error.name === "NotAllowedError") {
throw new Error("Passkey creation not allowed");
}
throw error;
}
}
private async getPasskeyCredentials() {
const value = await navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rpId: this.appHostname,
allowCredentials: [],
timeout: 60000,
},
});
return value as
| (Credential & { response: { userHandle: ArrayBuffer } })
| null;
}
}
/** @internal */
@@ -260,5 +201,5 @@ export namespace BrowserPasskeyAuth {
}
function logOut() {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
}

View File

@@ -1,7 +1,13 @@
import * as bip39 from "@scure/bip39";
import { CryptoProvider, cojsonInternals } from "cojson";
import { AgentSecret, CryptoProvider, cojsonInternals } from "cojson";
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
import { AuthSecretStorage } from "./AuthSecretStorage.js";
type LocalStorageData = {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const localStorageKey = "jazz-logged-in-secret";
/**
* `BrowserPassphraseAuth` provides a `JazzAuth` object for passphrase authentication.
@@ -9,7 +15,7 @@ import { AuthSecretStorage } from "./AuthSecretStorage.js";
* ```ts
* import { BrowserPassphraseAuth } from "jazz-browser";
*
* const auth = new BrowserPassphraseAuth(driver, wordlist);
* const auth = new BrowserPassphraseAuth(driver, wordlist, appName);
* ```
*
* @category Auth Providers
@@ -18,20 +24,22 @@ export class BrowserPassphraseAuth implements AuthMethod {
constructor(
public driver: BrowserPassphraseAuth.Driver,
public wordlist: string[],
public appName: string,
// TODO: is this a safe default?
public appHostname: string = window.location.hostname,
) {}
/**
* @returns A `JazzAuth` object
*/
async start(crypto: CryptoProvider): Promise<AuthResult> {
AuthSecretStorage.migrate();
if (localStorage[localStorageKey]) {
const localStorageData = JSON.parse(
localStorage[localStorageKey],
) as LocalStorageData;
const credentials = AuthSecretStorage.get();
const isAnonymous = AuthSecretStorage.isAnonymous();
if (credentials && !isAnonymous) {
const accountID = credentials.accountID;
const secret = credentials.accountSecret;
const accountID = localStorageData.accountID as ID<Account>;
const secret = localStorageData.accountSecret;
return {
type: "existing",
@@ -43,19 +51,13 @@ export class BrowserPassphraseAuth implements AuthMethod {
this.driver.onError(error);
},
logOut: () => {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
},
} satisfies AuthResult;
} else {
return new Promise<AuthResult>((resolve) => {
this.driver.onReady({
signUp: async (username, passphrase) => {
if (credentials && isAnonymous) {
console.warn(
"Anonymous user upgrade is currently not supported on passphrase auth",
);
}
const secretSeed = bip39.mnemonicToEntropy(
passphrase,
this.wordlist,
@@ -71,12 +73,10 @@ export class BrowserPassphraseAuth implements AuthMethod {
creationProps: { name: username },
initialSecret: accountSecret,
saveCredentials: async (credentials) => {
AuthSecretStorage.set({
localStorage[localStorageKey] = JSON.stringify({
accountID: credentials.accountID,
secretSeed,
accountSecret,
provider: "passphrase",
});
accountSecret: credentials.secret,
} satisfies LocalStorageData);
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
@@ -85,7 +85,7 @@ export class BrowserPassphraseAuth implements AuthMethod {
this.driver.onError(error);
},
logOut: () => {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
},
});
},
@@ -112,13 +112,11 @@ export class BrowserPassphraseAuth implements AuthMethod {
resolve({
type: "existing",
credentials: { accountID, secret: accountSecret },
saveCredentials: async ({ accountID }) => {
AuthSecretStorage.set({
saveCredentials: async ({ accountID, secret }) => {
localStorage[localStorageKey] = JSON.stringify({
accountID,
secretSeed,
accountSecret,
provider: "passphrase",
});
accountSecret: secret,
} satisfies LocalStorageData);
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
@@ -127,7 +125,7 @@ export class BrowserPassphraseAuth implements AuthMethod {
this.driver.onError(error);
},
logOut: () => {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
},
});
},
@@ -150,5 +148,5 @@ export namespace BrowserPassphraseAuth {
}
function logOut() {
AuthSecretStorage.clear();
delete localStorage[localStorageKey];
}

View File

@@ -1,13 +1,17 @@
import { Peer } from "cojson";
import { createWebSocketPeer } from "cojson-transport-ws";
import type { Peer } from "jazz-tools";
export function createWebSocketPeerWithReconnection(
peer: string,
reconnectionTimeout: number | undefined,
addPeer: (peer: Peer) => void,
removePeer: (peer: Peer) => void,
) {
let currentPeer: Peer | undefined = undefined;
const firstWsPeer = createWebSocketPeer({
websocket: new WebSocket(peer),
id: peer,
role: "server",
onClose: reconnectWebSocket,
});
let shouldTryToReconnect = true;
let currentReconnectionTimeout = reconnectionTimeout || 500;
@@ -22,49 +26,35 @@ export function createWebSocketPeerWithReconnection(
async function reconnectWebSocket() {
if (!shouldTryToReconnect) return;
if (currentPeer) {
removePeer(currentPeer);
console.log(
"Websocket disconnected, trying to reconnect in " +
currentReconnectionTimeout +
"ms",
);
currentReconnectionTimeout = Math.min(
currentReconnectionTimeout * 2,
30000,
);
console.log(
"Websocket disconnected, trying to reconnect in " +
currentReconnectionTimeout +
"ms",
);
currentReconnectionTimeout = Math.min(
currentReconnectionTimeout * 2,
30000,
);
await waitForOnline(currentReconnectionTimeout);
}
await waitForOnline(currentReconnectionTimeout);
if (!shouldTryToReconnect) return;
currentPeer = createWebSocketPeer({
websocket: new WebSocket(peer),
id: peer,
role: "server",
onClose: reconnectWebSocket,
});
addPeer(currentPeer);
addPeer(
createWebSocketPeer({
websocket: new WebSocket(peer),
id: peer,
role: "server",
onClose: reconnectWebSocket,
}),
);
}
return {
enable: () => {
shouldTryToReconnect = true;
if (!currentPeer) {
reconnectWebSocket();
}
},
disable: () => {
peer: firstWsPeer,
done: () => {
shouldTryToReconnect = false;
window.removeEventListener("online", onOnline);
if (currentPeer) {
removePeer(currentPeer);
currentPeer = undefined;
}
},
};
}

View File

@@ -18,12 +18,10 @@ import {
import { OPFSFilesystem } from "./OPFSFilesystem.js";
import { createWebSocketPeerWithReconnection } from "./createWebSocketPeerWithReconnection.js";
import { StorageConfig, getStorageOptions } from "./storageOptions.js";
export { AuthSecretStorage } from "./auth/AuthSecretStorage.js";
export { BrowserDemoAuth } from "./auth/DemoAuth.js";
export { BrowserPasskeyAuth } from "./auth/PasskeyAuth.js";
export { BrowserPassphraseAuth } from "./auth/PassphraseAuth.js";
export { BrowserAnonymousAuth } from "./auth/AnonymousAuth.js";
import { BrowserAnonymousAuth } from "./auth/AnonymousAuth.js";
export { BrowserOnboardingAuth } from "./auth/OnboardingAuth.js";
import { setupInspector } from "./utils/export-account-inspector.js";
setupInspector();
@@ -31,7 +29,6 @@ setupInspector();
/** @category Context Creation */
export type BrowserContext<Acc extends Account> = {
me: Acc;
toggleNetwork: (enabled: boolean) => void;
logOut: () => void;
// TODO: Symbol.dispose?
done: () => void;
@@ -39,21 +36,15 @@ export type BrowserContext<Acc extends Account> = {
export type BrowserGuestContext = {
guest: AnonymousJazzAgent;
toggleNetwork: (enabled: boolean) => void;
logOut: () => void;
done: () => void;
};
export type BrowserContextOptions<Acc extends Account> = {
auth?: AuthMethod;
AccountSchema?: CoValueClass<Acc> & {
auth: AuthMethod;
AccountSchema: CoValueClass<Acc> & {
fromNode: (typeof Account)["fromNode"];
};
guest: false;
} & BaseBrowserContextOptions;
export type BrowserGuestContextOptions = {
guest: true;
} & BaseBrowserContextOptions;
export type BaseBrowserContextOptions = {
@@ -61,25 +52,34 @@ export type BaseBrowserContextOptions = {
reconnectionTimeout?: number;
storage?: StorageConfig;
crypto?: CryptoProvider;
localOnly?: boolean;
};
function getAnonymousUserAuth() {
const auth = new BrowserAnonymousAuth("Anonymous user", {
onSignedIn: () => {},
onError: () => {},
});
return auth;
}
/** @category Context Creation */
export async function createJazzBrowserContext<Acc extends Account>(
options: BrowserContextOptions<Acc> | BrowserGuestContextOptions,
options: BrowserContextOptions<Acc>,
): Promise<BrowserContext<Acc>>;
export async function createJazzBrowserContext(
options: BaseBrowserContextOptions,
): Promise<BrowserGuestContext>;
export async function createJazzBrowserContext<Acc extends Account>(
options: BrowserContextOptions<Acc> | BaseBrowserContextOptions,
): Promise<BrowserContext<Acc> | BrowserGuestContext>;
export async function createJazzBrowserContext<Acc extends Account>(
options: BrowserContextOptions<Acc> | BaseBrowserContextOptions,
): Promise<BrowserContext<Acc> | BrowserGuestContext> {
const crypto = options.crypto || (await WasmCrypto.create());
let node: LocalNode | undefined = undefined;
const wsPeer = createWebSocketPeerWithReconnection(
options.peer,
options.reconnectionTimeout,
(peer) => {
if (node) {
node.syncManager.addPeer(peer);
}
},
);
const { useSingleTabOPFS, useIndexedDB } = getStorageOptions(options.storage);
const peersToLoadFrom: Peer[] = [];
@@ -97,37 +97,13 @@ export async function createJazzBrowserContext<Acc extends Account>(
peersToLoadFrom.push(await IDBStorage.asPeer());
}
const wsPeer = createWebSocketPeerWithReconnection(
options.peer,
options.reconnectionTimeout,
(peer) => {
if (node) {
node.syncManager.addPeer(peer);
} else {
peersToLoadFrom.push(peer);
}
},
(peer) => {
peersToLoadFrom.splice(peersToLoadFrom.indexOf(peer), 1);
},
);
function toggleNetwork(enabled: boolean) {
if (enabled) {
wsPeer.enable();
} else {
wsPeer.disable();
}
}
toggleNetwork(!options.localOnly);
peersToLoadFrom.push(wsPeer.peer);
const context =
options.guest !== true
"auth" in options
? await createJazzContext({
AccountSchema:
"AccountSchema" in options ? options.AccountSchema : undefined,
auth: options.auth ?? getAnonymousUserAuth(),
AccountSchema: options.AccountSchema,
auth: options.auth,
crypto,
peersToLoadFrom,
sessionProvider: provideBrowserLockSession,
@@ -143,9 +119,8 @@ export async function createJazzBrowserContext<Acc extends Account>(
return "account" in context
? {
me: context.account,
toggleNetwork,
done: () => {
wsPeer.disable();
wsPeer.done();
context.done();
},
logOut: () => {
@@ -154,9 +129,8 @@ export async function createJazzBrowserContext<Acc extends Account>(
}
: {
guest: context.agent,
toggleNetwork,
done: () => {
wsPeer.disable();
wsPeer.done();
context.done();
},
logOut: () => {
@@ -196,9 +170,9 @@ export function provideBrowserLockSession(
if (!lock) return "noLock";
const sessionID =
localStorage.getItem(accountID + "_" + idx) ||
localStorage[accountID + "_" + idx] ||
crypto.newRandomSessionID(accountID as RawAccountID | AgentID);
localStorage.setItem(accountID + "_" + idx, sessionID);
localStorage[accountID + "_" + idx] = sessionID;
// console.debug(
// "Got lock",
@@ -206,7 +180,7 @@ export function provideBrowserLockSession(
// sessionID
// );
resolveSession(sessionID as SessionID);
resolveSession(sessionID);
await donePromise;
console.log("Done with lock", accountID + "_" + idx, sessionID);

Some files were not shown because too many files have changed in this diff Show More