diff --git a/extension/core.mjs b/extension/core.mjs new file mode 100644 index 0000000..1494f05 --- /dev/null +++ b/extension/core.mjs @@ -0,0 +1,49 @@ +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.'); + } +}