ethagent 0.2.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { EthagentConfig, SelectableNetwork } from '../../storage/config.js'
|
|
2
|
+
import {
|
|
3
|
+
chainIdForNetwork,
|
|
4
|
+
DEFAULT_ERC8004_CHAIN_ID,
|
|
5
|
+
DEFAULT_ETHEREUM_RPC_URL,
|
|
6
|
+
DEFAULT_ERC8004_IDENTITY_REGISTRY_ADDRESS,
|
|
7
|
+
MissingRegistryAddressError,
|
|
8
|
+
networkForChainId,
|
|
9
|
+
normalizeErc8004RegistryConfig,
|
|
10
|
+
supportedErc8004ChainForId,
|
|
11
|
+
type Erc8004RegistryConfig,
|
|
12
|
+
} from './erc8004.js'
|
|
13
|
+
|
|
14
|
+
export type RegistryResolution = {
|
|
15
|
+
config: Erc8004RegistryConfig | null
|
|
16
|
+
network: SelectableNetwork
|
|
17
|
+
chainId: number
|
|
18
|
+
needsRegistryAddress: boolean
|
|
19
|
+
defaultRpcUrl: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveSelectedNetwork(config?: EthagentConfig): SelectableNetwork {
|
|
23
|
+
if (config?.selectedNetwork) return config.selectedNetwork
|
|
24
|
+
if (config?.erc8004?.chainId) {
|
|
25
|
+
const inferred = networkForChainId(config.erc8004.chainId)
|
|
26
|
+
if (inferred) return inferred
|
|
27
|
+
}
|
|
28
|
+
return 'mainnet'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registryConfigFromConfig(config?: EthagentConfig): RegistryResolution {
|
|
32
|
+
const network = resolveSelectedNetwork(config)
|
|
33
|
+
const chainId = chainIdForNetwork(network)
|
|
34
|
+
const chain = supportedErc8004ChainForId(chainId)
|
|
35
|
+
const overrideMatchesChain = config?.erc8004?.chainId === chainId
|
|
36
|
+
const defaultRpcUrl = chain?.rpcUrl ?? (chainId === DEFAULT_ERC8004_CHAIN_ID ? DEFAULT_ETHEREUM_RPC_URL : '')
|
|
37
|
+
|
|
38
|
+
const inputAddress = overrideMatchesChain ? config?.erc8004?.identityRegistryAddress : undefined
|
|
39
|
+
const inputRpc = overrideMatchesChain ? config?.erc8004?.rpcUrl : undefined
|
|
40
|
+
const inputFromBlock = overrideMatchesChain ? config?.erc8004?.fromBlock : undefined
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const resolved = normalizeErc8004RegistryConfig({
|
|
44
|
+
chainId,
|
|
45
|
+
rpcUrl: inputRpc ?? process.env.ETHAGENT_RPC_URL,
|
|
46
|
+
identityRegistryAddress: inputAddress ?? chain?.identityRegistryAddress
|
|
47
|
+
?? (chainId === DEFAULT_ERC8004_CHAIN_ID ? DEFAULT_ERC8004_IDENTITY_REGISTRY_ADDRESS : undefined),
|
|
48
|
+
fromBlock: inputFromBlock,
|
|
49
|
+
})
|
|
50
|
+
return {
|
|
51
|
+
config: resolved,
|
|
52
|
+
network,
|
|
53
|
+
chainId,
|
|
54
|
+
needsRegistryAddress: false,
|
|
55
|
+
defaultRpcUrl,
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err instanceof MissingRegistryAddressError) {
|
|
59
|
+
return {
|
|
60
|
+
config: null,
|
|
61
|
+
network,
|
|
62
|
+
chainId,
|
|
63
|
+
needsRegistryAddress: true,
|
|
64
|
+
defaultRpcUrl,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
throw err
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
export const PINATA_UPLOAD_API_URL = 'https://uploads.pinata.cloud/v3/files'
|
|
2
|
+
export const PINATA_AUTH_TEST_URL = 'https://api.pinata.cloud/data/testAuthentication'
|
|
3
|
+
export const DEFAULT_PINATA_GATEWAY_URL = 'https://gateway.pinata.cloud'
|
|
4
|
+
export const DEFAULT_IPFS_API_URL = process.env.ETHAGENT_IPFS_API_URL?.trim() || PINATA_UPLOAD_API_URL
|
|
5
|
+
|
|
6
|
+
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>
|
|
7
|
+
|
|
8
|
+
export type IpfsClient = {
|
|
9
|
+
apiUrl: string
|
|
10
|
+
add: (content: string | Uint8Array) => Promise<IpfsAddResult>
|
|
11
|
+
cat: (cid: string) => Promise<Uint8Array>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type IpfsAddResult = {
|
|
15
|
+
cid: string
|
|
16
|
+
pinVerified: boolean
|
|
17
|
+
provider: 'pinata' | 'ipfs'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type IpfsOptions = {
|
|
21
|
+
pinataJwt?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createIpfsClient(apiUrl = DEFAULT_IPFS_API_URL, fetchImpl: FetchLike = fetch, options: IpfsOptions = {}): IpfsClient {
|
|
25
|
+
const base = normalizeApiUrl(apiUrl)
|
|
26
|
+
return {
|
|
27
|
+
apiUrl: base,
|
|
28
|
+
add: content => addToIpfs(base, content, fetchImpl, options),
|
|
29
|
+
cat: cid => catFromIpfs(base, cid, fetchImpl),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function needsPinataJwt(apiUrl = DEFAULT_IPFS_API_URL, options: IpfsOptions = {}): boolean {
|
|
34
|
+
return isPinataUploadUrl(apiUrl) && !pinataJwt(options)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function extractPinataJwt(input: string): string {
|
|
38
|
+
const trimmed = input.trim()
|
|
39
|
+
const matches = trimmed.match(/\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g) ?? []
|
|
40
|
+
const jwt = matches.find(isWellFormedJwt)
|
|
41
|
+
if (jwt) return jwt
|
|
42
|
+
if (/api\s*key|api\s*secret|secret\s*key/i.test(trimmed)) {
|
|
43
|
+
throw new Error('Use the JWT, not the API key or secret.')
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Paste the JWT from Pinata.')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function validatePinataJwt(
|
|
49
|
+
input: string,
|
|
50
|
+
fetchImpl: FetchLike = fetch,
|
|
51
|
+
): Promise<string> {
|
|
52
|
+
const jwt = extractPinataJwt(input)
|
|
53
|
+
let response: Response
|
|
54
|
+
try {
|
|
55
|
+
response = await fetchImpl(PINATA_AUTH_TEST_URL, {
|
|
56
|
+
method: 'GET',
|
|
57
|
+
headers: {
|
|
58
|
+
accept: 'application/json',
|
|
59
|
+
Authorization: `Bearer ${jwt}`,
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error('Could not validate Pinata JWT. Check your connection, then try again.')
|
|
64
|
+
}
|
|
65
|
+
if (response.status === 401 || response.status === 403) {
|
|
66
|
+
throw new Error('Pinata rejected this JWT. Paste a valid Pinata JWT.')
|
|
67
|
+
}
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error(`Pinata credential validation failed: ${response.status} ${response.statusText}`)
|
|
70
|
+
}
|
|
71
|
+
return jwt
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function addToIpfs(
|
|
75
|
+
apiUrl: string,
|
|
76
|
+
content: string | Uint8Array,
|
|
77
|
+
fetchImpl: FetchLike = fetch,
|
|
78
|
+
options: IpfsOptions = {},
|
|
79
|
+
): Promise<IpfsAddResult> {
|
|
80
|
+
if (isPinataUploadUrl(apiUrl)) return addToPinata(apiUrl, content, fetchImpl, options)
|
|
81
|
+
return addFileToIpfs(apiUrl, content, 'ethagent-identity-backup.json', 'application/json', fetchImpl, options)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function addFileToIpfs(
|
|
85
|
+
apiUrl: string,
|
|
86
|
+
content: string | Uint8Array,
|
|
87
|
+
filename: string,
|
|
88
|
+
contentType: string,
|
|
89
|
+
fetchImpl: FetchLike = fetch,
|
|
90
|
+
options: IpfsOptions = {},
|
|
91
|
+
): Promise<IpfsAddResult> {
|
|
92
|
+
if (isPinataUploadUrl(apiUrl)) return addFileToPinata(apiUrl, content, filename, contentType, fetchImpl, options)
|
|
93
|
+
const body = new FormData()
|
|
94
|
+
const blobPart: BlobPart = typeof content === 'string'
|
|
95
|
+
? content
|
|
96
|
+
: new Uint8Array(content).buffer as ArrayBuffer
|
|
97
|
+
const blob = new Blob([blobPart], { type: contentType })
|
|
98
|
+
body.append('file', blob, filename)
|
|
99
|
+
const response = await fetchImpl(`${normalizeApiUrl(apiUrl)}/api/v0/add?pin=true`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body,
|
|
102
|
+
})
|
|
103
|
+
if (!response.ok) throw new Error(`IPFS add failed: ${response.status} ${response.statusText}`)
|
|
104
|
+
const data = await response.json() as { Hash?: string; Cid?: string; Name?: string }
|
|
105
|
+
const cid = data.Hash ?? data.Cid
|
|
106
|
+
if (!cid) throw new Error('IPFS add response did not include a CID')
|
|
107
|
+
return { cid, pinVerified: true, provider: 'ipfs' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function catFromIpfs(
|
|
111
|
+
apiUrl: string,
|
|
112
|
+
cid: string,
|
|
113
|
+
fetchImpl: FetchLike = fetch,
|
|
114
|
+
): Promise<Uint8Array> {
|
|
115
|
+
if (isPinataUploadUrl(apiUrl)) return catFromPinata(cid, fetchImpl)
|
|
116
|
+
const arg = encodeURIComponent(cid.trim())
|
|
117
|
+
const response = await fetchImpl(`${normalizeApiUrl(apiUrl)}/api/v0/cat?arg=${arg}`, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
})
|
|
120
|
+
if (!response.ok) throw new Error(`IPFS cat failed: ${response.status} ${response.statusText}`)
|
|
121
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeApiUrl(apiUrl: string): string {
|
|
125
|
+
const trimmed = apiUrl.trim() || DEFAULT_IPFS_API_URL
|
|
126
|
+
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function addToPinata(
|
|
130
|
+
apiUrl: string,
|
|
131
|
+
content: string | Uint8Array,
|
|
132
|
+
fetchImpl: FetchLike,
|
|
133
|
+
options: IpfsOptions,
|
|
134
|
+
): Promise<IpfsAddResult> {
|
|
135
|
+
return addFileToPinata(apiUrl, content, 'ethagent-agent-state.json', 'application/json', fetchImpl, options)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function addFileToPinata(
|
|
139
|
+
apiUrl: string,
|
|
140
|
+
content: string | Uint8Array,
|
|
141
|
+
filename: string,
|
|
142
|
+
contentType: string,
|
|
143
|
+
fetchImpl: FetchLike,
|
|
144
|
+
options: IpfsOptions,
|
|
145
|
+
): Promise<IpfsAddResult> {
|
|
146
|
+
const jwt = pinataJwt(options)
|
|
147
|
+
if (!jwt) throw new Error('IPFS storage credential is missing')
|
|
148
|
+
const body = new FormData()
|
|
149
|
+
const blobPart: BlobPart = typeof content === 'string'
|
|
150
|
+
? content
|
|
151
|
+
: new Uint8Array(content).buffer as ArrayBuffer
|
|
152
|
+
const blob = new Blob([blobPart], { type: contentType })
|
|
153
|
+
body.append('network', 'public')
|
|
154
|
+
body.append('file', blob, filename)
|
|
155
|
+
const response = await fetchImpl(normalizeApiUrl(apiUrl), {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: {
|
|
158
|
+
Authorization: `Bearer ${jwt}`,
|
|
159
|
+
},
|
|
160
|
+
body,
|
|
161
|
+
})
|
|
162
|
+
if (!response.ok) throw new Error(`IPFS upload failed: ${response.status} ${response.statusText}`)
|
|
163
|
+
const data = await response.json() as { data?: { cid?: string }; IpfsHash?: string; Hash?: string; Cid?: string }
|
|
164
|
+
const cid = data.data?.cid ?? data.IpfsHash ?? data.Hash ?? data.Cid
|
|
165
|
+
if (!cid) throw new Error('IPFS upload response did not include a CID')
|
|
166
|
+
return { cid, pinVerified: true, provider: 'pinata' }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pinataJwt(options: IpfsOptions): string | undefined {
|
|
170
|
+
return options.pinataJwt?.trim() || process.env.PINATA_JWT?.trim() || undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function catFromPinata(cid: string, fetchImpl: FetchLike): Promise<Uint8Array> {
|
|
174
|
+
const gateway = normalizeApiUrl(process.env.PINATA_GATEWAY_URL?.trim() || DEFAULT_PINATA_GATEWAY_URL)
|
|
175
|
+
const path = cid.trim().split('/').map(part => encodeURIComponent(part)).join('/')
|
|
176
|
+
const response = await fetchImpl(`${gateway}/ipfs/${path}`)
|
|
177
|
+
if (!response.ok) throw new Error(`IPFS fetch failed: ${response.status} ${response.statusText}`)
|
|
178
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isPinataUploadUrl(apiUrl: string): boolean {
|
|
182
|
+
try {
|
|
183
|
+
const url = new URL(normalizeApiUrl(apiUrl))
|
|
184
|
+
return url.hostname === 'uploads.pinata.cloud'
|
|
185
|
+
|| url.hostname === 'api.pinata.cloud'
|
|
186
|
+
} catch {
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isWellFormedJwt(input: string): boolean {
|
|
192
|
+
const parts = input.split('.')
|
|
193
|
+
if (parts.length !== 3 || parts.some(part => part.length === 0)) return false
|
|
194
|
+
const [header, payload] = parts
|
|
195
|
+
return isJsonObjectBase64Url(header!) && isJsonObjectBase64Url(payload!)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isJsonObjectBase64Url(value: string): boolean {
|
|
199
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) return false
|
|
200
|
+
try {
|
|
201
|
+
const json = Buffer.from(base64UrlToBase64(value), 'base64').toString('utf8')
|
|
202
|
+
const parsed = JSON.parse(json) as unknown
|
|
203
|
+
return Boolean(parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
204
|
+
} catch {
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function base64UrlToBase64(value: string): string {
|
|
210
|
+
const normalized = value.replaceAll('-', '+').replaceAll('_', '/')
|
|
211
|
+
return normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '=')
|
|
212
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getSecret, hasSecret, rmSecret, setSecret, type KeyBackend } from '../../storage/secrets.js'
|
|
2
|
+
import { extractPinataJwt, validatePinataJwt, type FetchLike } from './ipfs.js'
|
|
3
|
+
|
|
4
|
+
const ACCOUNT = 'pinata:jwt'
|
|
5
|
+
|
|
6
|
+
let cached: string | null | undefined
|
|
7
|
+
|
|
8
|
+
type SavePinataJwtOptions = {
|
|
9
|
+
fetchImpl?: FetchLike
|
|
10
|
+
validate?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function getPinataJwt(): Promise<string | null> {
|
|
14
|
+
return getSecret(ACCOUNT)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function hasPinataJwt(): Promise<boolean> {
|
|
18
|
+
return hasSecret(ACCOUNT)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function savePinataJwt(input: string, options: SavePinataJwtOptions = {}): Promise<{ jwt: string; backend: KeyBackend }> {
|
|
22
|
+
const jwt = extractPinataJwt(input)
|
|
23
|
+
if (options.validate !== false) await validatePinataJwt(jwt, options.fetchImpl)
|
|
24
|
+
const backend = await setSecret(ACCOUNT, jwt)
|
|
25
|
+
cached = jwt
|
|
26
|
+
return { jwt, backend }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function clearPinataJwt(): Promise<void> {
|
|
30
|
+
await rmSecret(ACCOUNT)
|
|
31
|
+
cached = null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function resolvePinataJwt(): Promise<string | undefined> {
|
|
35
|
+
if (cached !== undefined) return cached ?? envJwt()
|
|
36
|
+
cached = await getSecret(ACCOUNT)
|
|
37
|
+
return cached ?? envJwt()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function resolveValidatedPinataJwt(fetchImpl: FetchLike = fetch): Promise<string | undefined> {
|
|
41
|
+
const jwt = await resolvePinataJwt()
|
|
42
|
+
if (!jwt) return undefined
|
|
43
|
+
return validatePinataJwt(jwt, fetchImpl)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function invalidatePinataJwtCache(): void {
|
|
47
|
+
cached = undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function envJwt(): string | undefined {
|
|
51
|
+
const v = process.env.PINATA_JWT?.trim()
|
|
52
|
+
return v ? v : undefined
|
|
53
|
+
}
|