export const BASE = 'https://teahub.xyz/tea/site/chat/persimmons'; export const SCOPES = ['content:read', 'content:draft-write', 'manage:users', 'billing', 'destructive']; export const DEFAULT_SCOPES = SCOPES.slice(0, 2); const bytes = value => new TextEncoder().encode(value).length; export function pageTarget(url) { const parsed = new URL(url); if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new Error('Use an HTTPS website tab.'); return {origin: parsed.origin}; } export function metadata({origin, name, scopes}) { if (!name.trim() || name.length > 100) throw new Error('Enter a public name of at most 100 characters.'); if (!Array.isArray(scopes) || scopes.some(s => !SCOPES.includes(s))) throw new Error('Invalid scopes.'); return {name: name.trim(), provider: new URL(origin).hostname, account: origin, vault_kind: 'user', credential_type: 'api-token', scopes, notes: 'Persimmons extension v0. Requested scopes are metadata; provider authority is unchanged.'}; } export function publicSource(item) { if (!item || item.vault_kind !== 'user' || typeof item.id !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(item.id) || typeof item.name !== 'string' || item.name.length > 200) { throw new Error('Unexpected public source response. Refresh My vault.'); } return {id: item.id, name: item.name}; } export async function request(path, body, fetcher = fetch) { if (!['list', 'create', 'juice/list', 'juice/create', 'juice/revoke'].includes(path)) throw new Error('Invalid operation.'); const encoded = body === undefined ? undefined : JSON.stringify(body); if (encoded && bytes(encoded) > 24 * 1024) throw new Error('Request exceeds 24 KiB; nothing sent.'); const options = {credentials: 'include', cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(15000), headers: {Accept: 'application/json'}}; if (encoded !== undefined) Object.assign(options, {method: 'POST', body: encoded, headers: {...options.headers, 'Content-Type': 'text/plain;charset=UTF-8'}}); try { const response = await fetcher(`${BASE}/${path}`, options); if (response.status === 401 || response.status === 403) throw new Error('Sign in to teahub.xyz and retry; access may be denied.'); if (!response.ok) throw new Error(path.startsWith('juice/') ? 'Juice unavailable; no capability confirmed.' : 'My vault request failed; refresh before retrying.'); if (!(response.headers.get('content-type') || '').includes('application/json')) throw new Error('Service unavailable; expected JSON.'); const data = await response.json(); if (!data || typeof data !== 'object' || data.error || data.ok === false) throw new Error('Service did not confirm the operation.'); return data; } catch (error) { // Only our fixed messages leave this boundary, never server bodies or network errors. const safe = ['Sign in to teahub.xyz and retry; access may be denied.', 'Juice unavailable; no capability confirmed.', 'My vault request failed; refresh before retrying.', 'Service unavailable; expected JSON.', 'Service did not confirm the operation.']; throw new Error(safe.includes(error.message) ? error.message : 'Service unreachable or timed out; refresh before retrying.'); } }