Merge branch 'ditto-auth' into 'main'

Ditto auth

See merge request soapbox-pub/soapbox!2951
environments/review-main-yi2y9f/deployments/4455
Alex Gleason 2024-03-18 19:27:59 +00:00
commit 460e22ce2b
43 zmienionych plików z 794 dodań i 172 usunięć

Wyświetl plik

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' blob: https:; img-src 'self' data: blob: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self';">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self';">
<link href="/manifest.json" rel="manifest">
<!--server-generated-meta-->
<script type="module" src="./src/main.tsx"></script>

Wyświetl plik

@ -73,6 +73,8 @@
"@sentry/browser": "^7.74.1",
"@sentry/react": "^7.74.1",
"@soapbox.pub/wasmboy": "^0.8.0",
"@soapbox/nspec": "npm:@jsr/soapbox__nspec",
"@soapbox/weblock": "npm:@jsr/soapbox__weblock",
"@tabler/icons": "^2.0.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.7",
@ -134,8 +136,7 @@
"lodash": "^4.7.11",
"mini-css-extract-plugin": "^2.6.0",
"nostr-machina": "^0.1.0",
"nostr-tools": "^1.14.2",
"nspec": "^0.1.0",
"nostr-tools": "^2.3.0",
"path-browserify": "^1.0.1",
"postcss": "^8.4.29",
"process": "^0.11.10",
@ -185,7 +186,7 @@
"vite-plugin-require": "^1.1.10",
"vite-plugin-static-copy": "^1.0.0",
"wicg-inert": "^3.1.1",
"zod": "^3.21.4"
"zod": "^3.22.4"
},
"devDependencies": {
"@formatjs/cli": "^6.2.0",

Wyświetl plik

@ -1,8 +1,5 @@
import { nip19 } from 'nostr-tools';
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { signer } from 'soapbox/features/nostr/sign';
import { selectAccount } from 'soapbox/selectors';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features';
@ -132,14 +129,8 @@ const noOp = () => new Promise(f => f(undefined));
const createAccount = (params: Record<string, any>) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
const { instance } = getState();
const { nostrSignup } = getFeatures(instance);
const pubkey = (signer && nostrSignup) ? await signer.getPublicKey() : undefined;
dispatch({ type: ACCOUNT_CREATE_REQUEST, params });
return api(getState, 'app').post('/api/v1/accounts', params, {
headers: pubkey ? { authorization: `Bearer ${nip19.npubEncode(pubkey)}` } : undefined,
}).then(({ data: token }) => {
return api(getState, 'app').post('/api/v1/accounts', params).then(({ data: token }) => {
return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token });
}).catch(error => {
dispatch({ type: ACCOUNT_CREATE_FAIL, error, params });
@ -154,7 +145,7 @@ const fetchAccount = (id: string) =>
const account = selectAccount(getState(), id);
if (account) {
return null;
return Promise.resolve(null);
}
dispatch(fetchAccountRequest(id));

Wyświetl plik

@ -1,22 +1,26 @@
import { nip19 } from 'nostr-tools';
import { signer } from 'soapbox/features/nostr/sign';
import { type AppDispatch } from 'soapbox/store';
import { verifyCredentials } from './auth';
/** Log in with a Nostr pubkey. */
function nostrLogIn() {
return async (dispatch: AppDispatch) => {
if (!signer) {
throw new Error('No Nostr signer available');
}
const pubkey = await signer.getPublicKey();
function logInNostr(pubkey: string) {
return (dispatch: AppDispatch) => {
const npub = nip19.npubEncode(pubkey);
return dispatch(verifyCredentials(npub));
};
}
export { nostrLogIn };
/** Log in with a Nostr extension. */
function nostrExtensionLogIn() {
return async (dispatch: AppDispatch) => {
if (!window.nostr) {
throw new Error('No Nostr signer available');
}
const pubkey = await window.nostr.getPublicKey();
return dispatch(logInNostr(pubkey));
};
}
export { logInNostr, nostrExtensionLogIn };

Wyświetl plik

@ -1,23 +1,12 @@
import { NiceRelay } from 'nostr-machina';
import { type NostrEvent } from 'nspec';
import { useEffect, useMemo } from 'react';
import { type NostrEvent } from '@soapbox/nspec';
import { useEffect } from 'react';
import { signer } from 'soapbox/features/nostr/sign';
import { useInstance } from 'soapbox/hooks';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { connectRequestSchema, nwcRequestSchema } from 'soapbox/schemas/nostr';
import { jsonSchema } from 'soapbox/schemas/utils';
function useSignerStream() {
const instance = useInstance();
const relayUrl = instance.nostr?.relay;
const pubkey = instance.nostr?.pubkey;
const relay = useMemo(() => {
if (relayUrl && signer) {
return new NiceRelay(relayUrl);
}
}, [relayUrl, !!signer]);
const { relay, pubkey, signer } = useNostr();
async function handleConnectEvent(event: NostrEvent) {
if (!relay || !pubkey || !signer) return;
@ -42,7 +31,7 @@ function useSignerStream() {
created_at: Math.floor(Date.now() / 1000),
});
relay.send(['EVENT', respEvent]);
relay.event(respEvent);
}
async function handleWalletEvent(event: NostrEvent) {
@ -61,28 +50,26 @@ function useSignerStream() {
await window.webln?.sendPayment(reqMsg.data.params.invoice);
}
async function handleEvent(event: NostrEvent) {
switch (event.kind) {
case 24133:
await handleConnectEvent(event);
break;
case 23194:
await handleWalletEvent(event);
break;
}
}
useEffect(() => {
if (!relay || !pubkey) return;
const sub = relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }]);
const readEvents = async () => {
for await (const event of sub) {
switch (event.kind) {
case 24133:
await handleConnectEvent(event);
break;
case 23194:
await handleWalletEvent(event);
break;
}
(async() => {
for await (const msg of relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }])) {
if (msg[0] === 'EVENT') handleEvent(msg[2]);
}
};
})();
readEvents();
return () => {
relay?.close();
};
}, [relay, pubkey]);
}

Wyświetl plik

@ -6,10 +6,14 @@ import { Button, HStack, Input } from './ui';
interface ICopyableInput {
/** Text to be copied. */
value: string;
/** Input type. */
type?: 'text' | 'password';
/** Callback after the value has been copied. */
onCopy?(): void;
}
/** An input with copy abilities. */
const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
const CopyableInput: React.FC<ICopyableInput> = ({ value, type = 'text', onCopy }) => {
const input = useRef<HTMLInputElement>(null);
const selectInput = () => {
@ -20,13 +24,15 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
} else {
document.execCommand('copy');
}
onCopy?.();
};
return (
<HStack alignItems='center'>
<Input
ref={input}
type='text'
type={type}
value={value}
className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg'
outerClassName='grow'

Wyświetl plik

@ -0,0 +1,20 @@
import React from 'react';
import Emoji from 'soapbox/components/ui/emoji/emoji';
interface IEmojiGraphic {
emoji: string;
}
/** Large emoji with a background for display purposes (eg breaking up a page). */
const EmojiGraphic: React.FC<IEmojiGraphic> = ({ emoji }) => {
return (
<div className='flex items-center justify-center'>
<div className='rounded-full bg-gray-100 p-8 dark:bg-gray-800'>
<Emoji className='h-24 w-24' emoji={emoji} />
</div>
</div>
);
};
export default EmojiGraphic;

Wyświetl plik

@ -2,7 +2,9 @@ import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui';
import EmojiComponent from 'soapbox/components/ui/emoji/emoji';
import HStack from 'soapbox/components/ui/hstack/hstack';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
import { useClickOutside, useFeatures, useSoapboxConfig } from 'soapbox/hooks';

Wyświetl plik

@ -8,6 +8,7 @@ import HStack from '../hstack/hstack';
import IconButton from '../icon-button/icon-button';
const messages = defineMessages({
back: { id: 'card.back.label', defaultMessage: 'Back' },
close: { id: 'lightbox.close', defaultMessage: 'Close' },
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
});
@ -56,6 +57,7 @@ interface IModal {
width?: keyof typeof widths;
children?: React.ReactNode;
className?: string;
onBack?: () => void;
}
/** Displays a modal dialog box. */
@ -78,6 +80,7 @@ const Modal = React.forwardRef<HTMLDivElement, IModal>(({
title,
width = 'xl',
className,
onBack,
}, ref) => {
const intl = useIntl();
const buttonRef = React.useRef<HTMLButtonElement>(null);
@ -102,6 +105,15 @@ const Modal = React.forwardRef<HTMLDivElement, IModal>(({
'flex-row-reverse': closePosition === 'left',
})}
>
{onBack && (
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
title={intl.formatMessage(messages.back)}
onClick={onBack}
className='text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200'
/>
)}
<h3 className='grow truncate text-lg font-bold leading-6 text-gray-900 dark:text-white'>
{title}
</h3>

Wyświetl plik

@ -15,13 +15,15 @@ interface ITooltip {
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>;
/** Text to display in the tooltip. */
text: string;
/** If disabled, it will render the children without wrapping them. */
disabled?: boolean;
}
/**
* Tooltip
*/
const Tooltip: React.FC<ITooltip> = (props) => {
const { children, text } = props;
const { children, text, disabled = false } = props;
const [isOpen, setIsOpen] = useState<boolean>(false);
@ -55,6 +57,10 @@ const Tooltip: React.FC<ITooltip> = (props) => {
hover,
]);
if (disabled) {
return children;
}
return (
<>
{React.cloneElement(children, {

Wyświetl plik

@ -1,6 +1,9 @@
import React from 'react';
import { HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
import HStack from 'soapbox/components/ui/hstack/hstack';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import Stack from 'soapbox/components/ui/stack/stack';
import Text from 'soapbox/components/ui/text/text';
interface IWidgetTitle {
/** Title text for the widget. */

Wyświetl plik

@ -0,0 +1,54 @@
import { NRelay, NRelay1, NostrSigner } from '@soapbox/nspec';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useOwnAccount } from 'soapbox/hooks';
import { useInstance } from 'soapbox/hooks/useInstance';
interface NostrContextType {
relay?: NRelay;
pubkey?: string;
signer?: NostrSigner;
}
const NostrContext = createContext<NostrContextType | undefined>(undefined);
interface NostrProviderProps {
children: React.ReactNode;
}
export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
const instance = useInstance();
const [relay, setRelay] = useState<NRelay1>();
const { account } = useOwnAccount();
const url = instance.nostr?.relay;
const pubkey = instance.nostr?.pubkey;
const accountPubkey = account?.nostr.pubkey;
const signer = (accountPubkey ? NKeys.get(accountPubkey) : undefined) ?? window.nostr;
useEffect(() => {
if (url) {
setRelay(new NRelay1(url));
}
return () => {
relay?.close();
};
}, [url]);
return (
<NostrContext.Provider value={{ relay, pubkey, signer }}>
{children}
</NostrContext.Provider>
);
};
export const useNostr = () => {
const context = useContext(NostrContext);
if (context === undefined) {
throw new Error('useNostr must be used within a NostrProvider');
}
return context;
};

Wyświetl plik

@ -4,9 +4,9 @@ import { Redirect } from 'react-router-dom';
import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { closeModal } from 'soapbox/actions/modals';
import { closeModal, openModal } from 'soapbox/actions/modals';
import { BigCard } from 'soapbox/components/big-card';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { getRedirectUrl } from 'soapbox/utils/redirect';
import { isStandalone } from 'soapbox/utils/state';
@ -21,6 +21,7 @@ const LoginPage = () => {
const me = useAppSelector((state) => state.me);
const standalone = useAppSelector((state) => isStandalone(state));
const { nostrSignup } = useFeatures();
const token = new URLSearchParams(window.location.search).get('token');
@ -62,6 +63,11 @@ const LoginPage = () => {
event.preventDefault();
};
if (nostrSignup) {
setTimeout(() => dispatch(openModal('NOSTR_LOGIN')), 100);
return <Redirect to='/' />;
}
if (standalone) return <Redirect to='/login/external' />;
if (shouldRedirect) {

Wyświetl plik

@ -1,15 +1,24 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Redirect } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import { BigCard } from 'soapbox/components/big-card';
import { Text } from 'soapbox/components/ui';
import { useInstance, useRegistrationStatus } from 'soapbox/hooks';
import { useAppDispatch, useFeatures, useInstance, useRegistrationStatus } from 'soapbox/hooks';
import RegistrationForm from './registration-form';
const RegistrationPage: React.FC = () => {
const instance = useInstance();
const { isOpen } = useRegistrationStatus();
const { nostrSignup } = useFeatures();
const dispatch = useAppDispatch();
if (nostrSignup) {
setTimeout(() => dispatch(openModal('NOSTR_SIGNUP')), 100);
return <Redirect to='/' />;
}
if (!isOpen) {
return (

Wyświetl plik

@ -0,0 +1,112 @@
import { NSchema as n, NostrSigner, NSecSigner } from '@soapbox/nspec';
import { WebLock } from '@soapbox/weblock';
import { getPublicKey, nip19 } from 'nostr-tools';
import { z } from 'zod';
/**
* Gets Nostr keypairs from storage and returns a `Map`-like object of signers.
* When instantiated, it will lock the storage key to prevent tampering.
* Changes to the object will sync to storage.
*/
export class NKeyStorage implements ReadonlyMap<string, NostrSigner> {
#keypairs = new Map<string, Uint8Array>();
#storage: Storage;
#storageKey: string;
constructor(storage: Storage, storageKey: string) {
this.#storage = storage;
this.#storageKey = storageKey;
const data = this.#storage.getItem(storageKey);
WebLock.storages.lockKey(storageKey);
try {
const nsecs = new Set(this.#dataSchema().parse(data));
for (const nsec of nsecs) {
const { data: secretKey } = nip19.decode(nsec);
const pubkey = getPublicKey(secretKey);
this.#keypairs.set(pubkey, secretKey);
}
} catch (e) {
this.clear();
}
}
#dataSchema(): z.ZodType<`nsec1${string}`[]> {
return n.json().pipe(n.bech32('nsec').array());
}
#syncStorage() {
const secretKeys = [...this.#keypairs.values()].map(nip19.nsecEncode);
this.#storage.setItem(this.#storageKey, JSON.stringify(secretKeys));
}
get size(): number {
return this.#keypairs.size;
}
clear(): void {
this.#keypairs.clear();
this.#syncStorage();
}
delete(pubkey: string): boolean {
const result = this.#keypairs.delete(pubkey);
this.#syncStorage();
return result;
}
forEach(callbackfn: (signer: NostrSigner, pubkey: string, map: typeof this) => void, thisArg?: any): void {
for (const [pubkey] of this.#keypairs) {
const signer = this.get(pubkey);
if (signer) {
callbackfn.call(thisArg, signer, pubkey, this);
}
}
}
get(pubkey: string): NostrSigner | undefined {
const secretKey = this.#keypairs.get(pubkey);
if (secretKey) {
return new NSecSigner(secretKey);
}
}
has(pubkey: string): boolean {
return this.#keypairs.has(pubkey);
}
add(secretKey: Uint8Array): NostrSigner {
const pubkey = getPublicKey(secretKey);
this.#keypairs.set(pubkey, secretKey);
this.#syncStorage();
return this.get(pubkey)!;
}
*entries(): IterableIterator<[string, NostrSigner]> {
for (const [pubkey] of this.#keypairs) {
yield [pubkey, this.get(pubkey)!];
}
}
*keys(): IterableIterator<string> {
for (const pubkey of this.#keypairs.keys()) {
yield pubkey;
}
}
*values(): IterableIterator<NostrSigner> {
for (const pubkey of this.#keypairs.keys()) {
yield this.get(pubkey)!;
}
}
[Symbol.iterator](): IterableIterator<[string, NostrSigner]> {
return this.entries();
}
[Symbol.toStringTag] = 'NKeyStorage';
}

Wyświetl plik

@ -1,44 +0,0 @@
import { hexToBytes } from '@noble/hashes/utils';
import { type NostrSigner, type NostrEvent, NSecSigner } from 'nspec';
/** Use key from `localStorage` if available, falling back to NIP-07. */
export class SoapboxSigner implements NostrSigner {
#signer: NostrSigner;
constructor() {
const privateKey = localStorage.getItem('soapbox:nostr:privateKey');
const signer = privateKey ? new NSecSigner(hexToBytes(privateKey)) : window.nostr;
if (!signer) {
throw new Error('No Nostr signer available');
}
this.#signer = signer;
}
async getPublicKey(): Promise<string> {
return this.#signer.getPublicKey();
}
async signEvent(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> {
return this.#signer.signEvent(event);
}
nip04 = {
encrypt: (pubkey: string, plaintext: string): Promise<string> => {
if (!this.#signer.nip04) {
throw new Error('NIP-04 not supported by signer');
}
return this.#signer.nip04.encrypt(pubkey, plaintext);
},
decrypt: (pubkey: string, ciphertext: string): Promise<string> => {
if (!this.#signer.nip04) {
throw new Error('NIP-04 not supported by signer');
}
return this.#signer.nip04.decrypt(pubkey, ciphertext);
},
};
}

Wyświetl plik

@ -0,0 +1,59 @@
import { NostrEvent, NostrFilter } from '@soapbox/nspec';
import isEqual from 'lodash/isEqual';
import { useEffect, useRef, useState } from 'react';
import { useNostr } from 'soapbox/contexts/nostr-context';
/** Streams events from the relay for the given filters. */
export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } {
const { relay } = useNostr();
const [events, setEvents] = useState<NostrEvent[]>([]);
const [closed, setClosed] = useState(false);
const [eose, setEose] = useState(false);
const controller = useRef<AbortController>(new AbortController());
const signal = controller.current.signal;
const value = useValue(filters);
useEffect(() => {
if (relay && value.length) {
(async () => {
for await (const msg of relay.req(value, { signal })) {
if (msg[0] === 'EVENT') {
setEvents((prev) => [msg[2], ...prev]);
} else if (msg[0] === 'EOSE') {
setEose(true);
} else if (msg[0] === 'CLOSED') {
setClosed(true);
break;
}
}
})();
}
return () => {
controller.current.abort();
controller.current = new AbortController();
setEose(false);
setClosed(false);
};
}, [relay, value]);
return {
events,
eose,
closed,
};
}
/** Preserves the memory reference of a value across re-renders. */
function useValue<T>(value: T): T {
const ref = useRef<T>(value);
if (!isEqual(ref.current, value)) {
ref.current = value;
}
return ref.current;
}

Wyświetl plik

@ -0,0 +1,6 @@
import { NKeyStorage } from './NKeyStorage';
export const NKeys = new NKeyStorage(
localStorage,
'soapbox:nostr:keys',
);

Wyświetl plik

@ -1,13 +0,0 @@
import { type NostrSigner } from 'nspec';
import { SoapboxSigner } from './SoapboxSigner';
let signer: NostrSigner | undefined;
try {
signer = new SoapboxSigner();
} catch (_) {
// No signer available
}
export { signer };

Wyświetl plik

@ -1,18 +1,11 @@
import React from 'react';
import { Spinner } from 'soapbox/components/ui';
import { Modal, Spinner } from 'soapbox/components/ui';
const ModalLoading = () => (
<div className='modal-root__modal error-modal'>
<div className='error-modal__body'>
<Spinner />
</div>
<div className='error-modal__footer'>
<div>
<button className='error-modal__nav' />
</div>
</div>
</div>
<Modal>
<Spinner />
</Modal>
);
export default ModalLoading;

Wyświetl plik

@ -30,6 +30,8 @@ import {
MentionsModal,
MissingDescriptionModal,
MuteModal,
NostrLoginModal,
NostrSignupModal,
ReactionsModal,
ReblogsModal,
ReplyMentionsModal,
@ -70,6 +72,8 @@ const MODAL_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
'MENTIONS': MentionsModal,
'MISSING_DESCRIPTION': MissingDescriptionModal,
'MUTE': MuteModal,
'NOSTR_LOGIN': NostrLoginModal,
'NOSTR_SIGNUP': NostrSignupModal,
'REACTIONS': ReactionsModal,
'REBLOGS': ReblogsModal,
'REPLY_MENTIONS': ReplyMentionsModal,

Wyświetl plik

@ -0,0 +1,37 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { closeModal } from 'soapbox/actions/modals';
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
import Stack from 'soapbox/components/ui/stack/stack';
import Text from 'soapbox/components/ui/text/text';
import { useAppDispatch } from 'soapbox/hooks';
const NostrExtensionIndicator: React.FC = () => {
const dispatch = useAppDispatch();
const onClick = () => {
dispatch(nostrExtensionLogIn());
dispatch(closeModal());
};
return (
<Stack space={2} className='rounded-lg bg-gray-100 p-2 dark:bg-gray-800'>
<Text size='xs'>
{window.nostr ? (
<FormattedMessage
id='nostr_extension.found'
defaultMessage='<link>Sign in</link> with browser extension.'
values={{
link: (node) => <button type='button' className='underline' onClick={onClick}>{node}</button>,
}}
/>
) : (
<FormattedMessage id='nostr_extension.not_found' defaultMessage='Browser extension not found.' />
)}
</Text>
</Stack>
);
};
export default NostrExtensionIndicator;

Wyświetl plik

@ -0,0 +1,30 @@
import React, { useState } from 'react';
import ExtensionStep from './steps/extension-step';
import KeyAddStep from './steps/key-add-step';
type Step = 'extension' | 'key-add';
interface INostrLoginModal {
onClose: (type?: string) => void;
step?: Step;
}
const NostrLoginModal: React.FC<INostrLoginModal> = ({ onClose, step: firstStep }) => {
const [step, setStep] = useState<Step>(firstStep ?? (window.nostr ? 'extension' : 'key-add'));
const handleClose = () => onClose('NOSTR_LOGIN');
switch (step) {
case 'extension':
return <ExtensionStep onClickAlt={() => setStep('key-add')} onClose={handleClose} />;
case 'key-add':
return <KeyAddStep onClose={handleClose} />;
default:
return null;
}
};
export default NostrLoginModal;
export type { Step };

Wyświetl plik

@ -0,0 +1,41 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
interface IExtensionStep {
onClickAlt: () => void;
onClose(): void;
}
const ExtensionStep: React.FC<IExtensionStep> = ({ onClickAlt, onClose }) => {
const dispatch = useAppDispatch();
const onClick = () => {
dispatch(nostrExtensionLogIn());
onClose();
};
return (
<Modal title={<FormattedMessage id='nostr_signup.siwe.title' defaultMessage='Sign in' />} onClose={onClose}>
<Stack space={6}>
<EmojiGraphic emoji='🔐' />
<Stack space={3}>
<Button theme='accent' size='lg' onClick={onClick}>
<FormattedMessage id='nostr_signup.siwe.action' defaultMessage='Sign in with extension' />
</Button>
<Button theme='transparent' onClick={onClickAlt}>
<FormattedMessage id='nostr_signup.siwe.alt' defaultMessage='Sign in with key' />
</Button>
</Stack>
</Stack>
</Modal>
);
};
export default ExtensionStep;

Wyświetl plik

@ -0,0 +1,71 @@
import { nip19 } from 'nostr-tools';
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { logInNostr } from 'soapbox/actions/nostr';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, Input, FormGroup, Form } from 'soapbox/components/ui';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useAppDispatch } from 'soapbox/hooks';
import NostrExtensionIndicator from '../components/nostr-extension-indicator';
interface IKeyAddStep {
onClose(): void;
}
const KeyAddStep: React.FC<IKeyAddStep> = ({ onClose }) => {
const [nsec, setNsec] = useState('');
const [error, setError] = useState<string | undefined>();
const dispatch = useAppDispatch();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNsec(e.target.value);
setError(undefined);
};
const handleSubmit = async () => {
try {
const result = nip19.decode(nsec);
if (result.type === 'nsec') {
const seckey = result.data;
const signer = NKeys.add(seckey);
const pubkey = await signer.getPublicKey();
dispatch(logInNostr(pubkey));
onClose();
}
} catch (e) {
setError('Invalid nsec');
}
};
return (
<Modal title={<FormattedMessage id='nostr_signup.key-add.title' defaultMessage='Import Key' />} onClose={onClose}>
<Stack className='my-3' space={6}>
<NostrExtensionIndicator />
<EmojiGraphic emoji='🔑' />
<Form onSubmit={handleSubmit}>
<Stack space={6}>
<FormGroup labelText='Secret key' errors={error ? [error] : []}>
<Input
value={nsec}
type='password'
onChange={handleChange}
placeholder='nsec1…'
/>
</FormGroup>
<Button theme='accent' size='lg' type='submit' disabled={!nsec}>
Add Key
</Button>
</Stack>
</Form>
</Stack>
</Modal>
);
};
export default KeyAddStep;

Wyświetl plik

@ -0,0 +1,33 @@
import React, { useState } from 'react';
import ExtensionStep from '../nostr-login-modal/steps/extension-step';
import KeyStep from './steps/key-step';
import KeygenStep from './steps/keygen-step';
type Step = 'extension' | 'key' | 'keygen';
interface INostrSignupModal {
onClose: (type?: string) => void;
}
const NostrSigninModal: React.FC<INostrSignupModal> = ({ onClose }) => {
const [step, setStep] = useState<Step>(window.nostr ? 'extension' : 'key');
const handleClose = () => onClose('NOSTR_SIGNUP');
switch (step) {
case 'extension':
return <ExtensionStep onClickAlt={() => setStep('key')} onClose={handleClose} />;
case 'key':
return <KeyStep setStep={setStep} onClose={handleClose} />;
case 'keygen':
return <KeygenStep onClose={handleClose} />;
default:
return null;
}
};
export default NostrSigninModal;
export type { Step };

Wyświetl plik

@ -0,0 +1,46 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import NostrExtensionIndicator from '../../nostr-login-modal/components/nostr-extension-indicator';
import { Step } from '../nostr-signup-modal';
interface IKeyStep {
setStep(step: Step): void;
onClose(): void;
}
const KeyStep: React.FC<IKeyStep> = ({ setStep, onClose }) => {
const dispatch = useAppDispatch();
const onAltClick = () => {
onClose();
dispatch(openModal('NOSTR_LOGIN', { step: 'key-add' }));
};
return (
<Modal title={<FormattedMessage id='nostr_signup.key.title' defaultMessage='You need a key to continue' />} onClose={onClose}>
<Stack className='my-3' space={6}>
<NostrExtensionIndicator />
<EmojiGraphic emoji='🔑' />
<Stack space={3} alignItems='center'>
<Button theme='accent' size='lg' onClick={() => setStep('keygen')}>
Generate key
</Button>
<Button theme='transparent' onClick={onAltClick}>
I already have a key
</Button>
</Stack>
</Stack>
</Modal>
);
};
export default KeyStep;

Wyświetl plik

@ -0,0 +1,81 @@
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import React, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchAccount } from 'soapbox/actions/accounts';
import { logInNostr } from 'soapbox/actions/nostr';
import CopyableInput from 'soapbox/components/copyable-input';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, FormGroup, Text, Tooltip } from 'soapbox/components/ui';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { download } from 'soapbox/utils/download';
import { slugify } from 'soapbox/utils/input';
interface IKeygenStep {
onClose(): void;
}
const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
const instance = useInstance();
const dispatch = useAppDispatch();
const secretKey = useMemo(() => generateSecretKey(), []);
const pubkey = useMemo(() => getPublicKey(secretKey), [secretKey]);
const nsec = useMemo(() => nip19.nsecEncode(secretKey), [secretKey]);
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
const [downloaded, setDownloaded] = useState(false);
useEffect(() => {
// Pre-fetch into cache.
dispatch(fetchAccount(pubkey)).catch(() => {});
}, [pubkey]);
const handleDownload = () => {
download(nsec, `${slugify(instance.title)}-${npub.slice(5, 9)}.nsec.txt`);
setDownloaded(true);
};
const handleCopy = () => setDownloaded(true);
const handleNext = async () => {
const signer = NKeys.add(secretKey);
const pubkey = await signer.getPublicKey();
dispatch(logInNostr(pubkey));
onClose();
};
return (
<Modal title={<FormattedMessage id='nostr_signup.keygen.title' defaultMessage='Your new key' />} onClose={onClose}>
<Stack className='my-3' space={9}>
<EmojiGraphic emoji='🔑' />
<Stack alignItems='center'>
<Button theme='primary' size='lg' icon={require('@tabler/icons/download.svg')} onClick={handleDownload}>
Download key
</Button>
</Stack>
<FormGroup labelText='Secret key'>
<CopyableInput value={nsec} type='password' onCopy={handleCopy} />
</FormGroup>
<Stack className='rounded-xl bg-gray-100 p-4 dark:bg-gray-800'>
<Text>Back up your secret key in a secure place. If lost, your account cannot be recovered. Never share your secret key with anyone.</Text>
</Stack>
<Stack alignItems='end'>
<Tooltip text='Download your key to continue' disabled={downloaded}>
<Button theme='accent' disabled={!downloaded} size='lg' onClick={handleNext}>
Next
</Button>
</Tooltip>
</Stack>
</Stack>
</Modal>
);
};
export default KeygenStep;

Wyświetl plik

@ -5,7 +5,7 @@ import { Link, Redirect } from 'react-router-dom';
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { nostrLogIn } from 'soapbox/actions/nostr';
import { openModal } from 'soapbox/actions/modals';
import { openSidebar } from 'soapbox/actions/sidebar';
import SiteLogo from 'soapbox/components/site-logo';
import { Avatar, Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui';
@ -40,9 +40,7 @@ const Navbar = () => {
const onOpenSidebar = () => dispatch(openSidebar());
const handleNostrLogin = async () => {
setLoading(true);
await dispatch(nostrLogIn()).catch(console.error);
setLoading(false);
dispatch(openModal('NOSTR_LOGIN'));
};
const handleSubmit: React.FormEventHandler = (event) => {

Wyświetl plik

@ -1,13 +1,16 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { Button, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector, useInstance, useRegistrationStatus } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useFeatures, useInstance, useRegistrationStatus } from 'soapbox/hooks';
const SignUpPanel = () => {
const instance = useInstance();
const { nostrSignup } = useFeatures();
const { isOpen } = useRegistrationStatus();
const me = useAppSelector((state) => state.me);
const dispatch = useAppDispatch();
if (me || !isOpen) return null;
@ -23,7 +26,12 @@ const SignUpPanel = () => {
</Text>
</Stack>
<Button theme='primary' block to='/signup'>
<Button
theme='primary'
onClick={nostrSignup ? () => dispatch(openModal('NOSTR_SIGNUP')) : undefined}
to={nostrSignup ? undefined : '/signup'}
block
>
<FormattedMessage id='account.register' defaultMessage='Sign up' />
</Button>
</Stack>

Wyświetl plik

@ -162,3 +162,5 @@ export const EditAnnouncementModal = lazy(() => import('soapbox/features/ui/comp
export const FollowedTags = lazy(() => import('soapbox/features/followed-tags'));
export const AccountNotePanel = lazy(() => import('soapbox/features/ui/components/panels/account-note-panel'));
export const ComposeEditor = lazy(() => import('soapbox/features/compose/editor'));
export const NostrSignupModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-signup-modal/nostr-signup-modal'));
export const NostrLoginModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-login-modal/nostr-login-modal'));

Wyświetl plik

@ -1,5 +1,5 @@
import { TypedUseSelectorHook, useSelector } from 'react-redux';
import { RootState } from 'soapbox/store';
import type { RootState } from 'soapbox/store';
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Wyświetl plik

@ -2,6 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { Provider } from 'react-redux';
import { NostrProvider } from 'soapbox/contexts/nostr-context';
import { StatProvider } from 'soapbox/contexts/stat-context';
import { createGlobals } from 'soapbox/globals';
import { queryClient } from 'soapbox/queries/client';
@ -29,11 +30,13 @@ const Soapbox: React.FC = () => {
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<StatProvider>
<SoapboxHead>
<SoapboxLoad>
<SoapboxMount />
</SoapboxLoad>
</SoapboxHead>
<NostrProvider>
<SoapboxHead>
<SoapboxLoad>
<SoapboxMount />
</SoapboxLoad>
</SoapboxHead>
</NostrProvider>
</StatProvider>
</QueryClientProvider>
</Provider>

Wyświetl plik

@ -1066,6 +1066,14 @@
"new_group_panel.action": "Create Group",
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
"new_group_panel.title": "Create Group",
"nostr_extension.found": "<link>Sign in</link> with browser extension.",
"nostr_extension.not_found": "Browser extension not found.",
"nostr_signup.key-add.title": "Import Key",
"nostr_signup.key.title": "You need a key to continue",
"nostr_signup.keygen.title": "Your new key",
"nostr_signup.siwe.action": "Sign in with extension",
"nostr_signup.siwe.alt": "Sign in with key",
"nostr_signup.siwe.title": "Sign in",
"notification.favourite": "{name} liked your post",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",

Wyświetl plik

@ -17,6 +17,7 @@ import '@fontsource/inter/900.css';
import '@fontsource/roboto-mono/400.css';
import 'line-awesome/dist/font-awesome-line-awesome/css/all.css';
import 'react-datepicker/dist/react-datepicker.css';
import 'soapbox/features/nostr/keys';
import './iframe';
import './styles/i18n/arabic.css';

Wyświetl plik

@ -1,3 +1,4 @@
import { NSchema as n } from '@soapbox/nspec';
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import z from 'zod';
@ -32,6 +33,7 @@ const baseAccountSchema = z.object({
display_name: z.string().catch(''),
ditto: coerceObject({
accepts_zaps: z.boolean().catch(false),
is_registered: z.boolean().catch(false),
}),
emojis: filteredArray(customEmojiSchema),
fields: filteredArray(fieldSchema),
@ -50,6 +52,9 @@ const baseAccountSchema = z.object({
z.string(),
z.null(),
]).catch(null),
nostr: coerceObject({
pubkey: n.id().optional().catch(undefined),
}),
note: contentSchema,
/** Fedibird extra settings. */
other_settings: z.object({

Wyświetl plik

@ -1,4 +1,4 @@
import { verifySignature } from 'nostr-tools';
import { verifyEvent } from 'nostr-tools';
import { z } from 'zod';
/** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */
@ -22,7 +22,7 @@ const eventSchema = eventTemplateSchema.extend({
});
/** Nostr event schema that also verifies the event's signature. */
const signedEventSchema = eventSchema.refine(verifySignature);
const signedEventSchema = eventSchema.refine(verifyEvent);
/** NIP-46 signer options. */
const signEventOptsSchema = z.object({

Wyświetl plik

@ -1,4 +1,4 @@
import type { NostrSigner } from 'nspec';
import type { NostrSigner } from '@soapbox/nspec';
declare global {
interface Window {

Wyświetl plik

@ -8,6 +8,15 @@ const normalizeUsername = (username: string): string => {
}
};
function slugify(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[^\w]/g, '-') // replace non-word characters with a hyphen
.replace(/-+/g, '-'); // replace multiple hyphens with a single hyphen
}
export {
normalizeUsername,
slugify,
};

Wyświetl plik

@ -2,7 +2,7 @@ import * as Comlink from 'comlink';
import { nip13, type UnsignedEvent } from 'nostr-tools';
export const PowWorker = {
mine<K extends number>(event: UnsignedEvent<K>, difficulty: number) {
mine(event: UnsignedEvent, difficulty: number) {
return nip13.minePow(event, difficulty);
},
};

Wyświetl plik

@ -9,7 +9,7 @@
"target": "ESNext",
"jsx": "react",
"allowJs": true,
"moduleResolution": "node",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,

Wyświetl plik

@ -27,7 +27,7 @@ export default defineConfig(({ command }) => ({
},
assetsInclude: ['**/*.oga'],
server: {
port: 3036,
port: Number(process.env.PORT ?? 3036),
},
optimizeDeps: {
exclude: command === 'serve' ? ['@soapbox.pub/wasmboy'] : [],

Wyświetl plik

@ -1840,11 +1840,16 @@
lodash "^4.17.21"
mousetrap "^1.6.5"
"@noble/ciphers@0.2.0", "@noble/ciphers@^0.2.0":
"@noble/ciphers@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.2.0.tgz#a12cda60f3cf1ab5d7c77068c3711d2366649ed7"
integrity sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==
"@noble/ciphers@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.5.1.tgz#292f388b69c9ed80d49dca1a5cbfd4ff06852111"
integrity sha512-aNE06lbe36ifvMbbWvmmF/8jx6EQPu2HVg70V95T+iGjOuYwPpAccwAQc2HlXO2D0aiQ3zavbMga4jjWnrpiPA==
"@noble/curves@1.1.0", "@noble/curves@~1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
@ -2258,6 +2263,25 @@
raf "^3.4.0"
responsive-gamepad "1.1.0"
"@soapbox/nspec@npm:@jsr/soapbox__nspec":
version "0.6.0"
resolved "https://npm.jsr.io/~/6/@jsr/soapbox__nspec/0.6.0.tgz#60A75BCDBEC1B76DFA91BEFDF5505CCB8ADDAD3B"
integrity sha512-HY+MssBjm532J9SAqLek8YGxBlEaXdT1Eek3bOWkq4uLJxipJhYkdQrW+NzXhfVvGZUt6YXBobeSqRQx1JFgkQ==
dependencies:
"@noble/hashes" "^1.3.3"
"@scure/base" "^1.1.5"
"@scure/bip32" "^1.3.3"
"@scure/bip39" "^1.2.2"
lru-cache "^10.2.0"
nostr-tools "^2.3.1"
websocket-ts "^2.1.5"
zod "^3.22.4"
"@soapbox/weblock@npm:@jsr/soapbox__weblock":
version "0.1.0"
resolved "https://npm.jsr.io/~/7/@jsr/soapbox__weblock/0.1.0.tgz#749AEE0872D23CC4E37366D5F0D092B87986C5E1"
integrity sha512-FLLJL6xYk+k7f2bMXJ1nbcn3lhbEZXA0yboKLm8wns0hrcoEDOrWwmxkYF7xpVRndiAzFBctBGVbIAa3sA72ew==
"@surma/rollup-plugin-off-main-thread@^2.2.3":
version "2.2.3"
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053"
@ -6571,7 +6595,7 @@ nostr-machina@^0.1.0:
nostr-tools "^1.14.0"
zod "^3.21.0"
nostr-tools@^1.14.0, nostr-tools@^1.14.2:
nostr-tools@^1.14.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.16.0.tgz#5867f1d8bd055a5a3b27aadb199457dceb244314"
integrity sha512-sx/aOl0gmkeHVoIVbyOhEQhzF88NsrBXMC8bsjhPASqA6oZ8uSOAyEGgRLMfC3SKgzQD5Gr6KvDoAahaD6xKcg==
@ -6583,12 +6607,26 @@ nostr-tools@^1.14.0, nostr-tools@^1.14.2:
"@scure/bip32" "1.3.1"
"@scure/bip39" "1.2.1"
nostr-tools@^2.1.4:
version "2.1.5"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.1.5.tgz#d38ac1139343cf13654841b8727bab8dd70563eb"
integrity sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA==
nostr-tools@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.3.0.tgz#687d1af486a21e3e4805f0d4167c01221d871e65"
integrity sha512-jWD71y9JJ7DJ5/Si/DhREkjwyCWgMmY7x8qXfA9xC1HeosoGnaXuyYtspfYuiy8B8B2969C1iR6rWt6Fyf3IaA==
dependencies:
"@noble/ciphers" "0.2.0"
"@noble/ciphers" "^0.5.1"
"@noble/curves" "1.2.0"
"@noble/hashes" "1.3.1"
"@scure/base" "1.1.1"
"@scure/bip32" "1.3.1"
"@scure/bip39" "1.2.1"
optionalDependencies:
nostr-wasm v0.1.0
nostr-tools@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.3.1.tgz#348d3c4aab0ab00716f93d6e2a72333d8c7da982"
integrity sha512-qjKx2C3EzwiQOe2LPSPyCnp07pGz1pWaWjDXcm+L2y2c8iTECbvlzujDANm3nJUjWL5+LVRUVDovTZ1a/DC4Bg==
dependencies:
"@noble/ciphers" "^0.5.1"
"@noble/curves" "1.2.0"
"@noble/hashes" "1.3.1"
"@scure/base" "1.1.1"
@ -6616,18 +6654,6 @@ npm-run-path@^5.1.0:
dependencies:
path-key "^4.0.0"
nspec@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/nspec/-/nspec-0.1.0.tgz#abde817cf34cb042d7315a70cf515037e489401b"
integrity sha512-HPVyFFVR2x49K7HJzEjlvvBR7x5t79G6bh7/SQvfm25hXVFq9xvYBQ6i3nluwJkizcBxm+fvErM5yqJEnM/1tA==
dependencies:
"@scure/base" "^1.1.5"
"@scure/bip32" "^1.3.3"
"@scure/bip39" "^1.2.2"
lru-cache "^10.2.0"
nostr-tools "^2.1.4"
zod "^3.22.4"
nth-check@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
@ -9276,6 +9302,11 @@ webpack-sources@^3.2.3:
watchpack "^2.4.0"
webpack-sources "^3.2.3"
websocket-ts@^2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/websocket-ts/-/websocket-ts-2.1.5.tgz#b6b51f0afca89d6bc7ff71c9e74540f19ae0262c"
integrity sha512-rCNl9w6Hsir1azFm/pbjBEFzLD/gi7Th5ZgOxMifB6STUfTSovYAzryWw0TRvSZ1+Qu1Z5Plw4z42UfTNA9idA==
whatwg-encoding@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5"
@ -9649,7 +9680,7 @@ yocto-queue@^1.0.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
zod@^3.21.0, zod@^3.21.4:
zod@^3.21.0:
version "3.22.3"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060"
integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==