From 388731973097d3632a249bc5ce65fa39ca4d2543 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:45:59 +0000 Subject: [PATCH] Consolidate Persimmons extension --- extension/juice.mjs | 149 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 extension/juice.mjs diff --git a/extension/juice.mjs b/extension/juice.mjs new file mode 100644 index 0000000..6c0c555 --- /dev/null +++ b/extension/juice.mjs @@ -0,0 +1,149 @@ +// Gitea v1.27.3: API-class scopes, not repository restrictions. Worker only. +export const JUICE_ORIGINS = ['https://teahub.xyz']; +export const JUICE_SCOPES = ['read:repository', 'read:issue', 'write:issue']; +const idPattern = /^[0-9a-f]{32}$/; +const usernamePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/; +export function trustedJuice(message, sender) { + try { + return sender.origin === 'https://persimmons.app' && new URL(sender.url).origin === sender.origin && + sender.frameId === 0 && message?.type === 'persimmons.juice.v1' && + ['create', 'rotate', 'revoke'].includes(message.action) && + /^[a-z0-9][a-z0-9_.-]{0,99}$/.test(message.owner) && + message.target && Object.keys(message.target).sort().join() === 'id,name' && + Number.isSafeInteger(message.target.id) && message.target.id > 0 && + /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(message.target.name) && + Object.keys(message).sort().join() === (message.action === 'create' ? + 'action,owner,policy,source_id,target,type' : 'action,id,owner,target,type'); + } catch { return false; } +} +export function checkedScopes(value) { + if (!Array.isArray(value) || !value.length || value.length > 3 || + value.some(s => !JUICE_SCOPES.includes(s)) || new Set(value).size !== value.length || + (value.includes('read:issue') && value.includes('write:issue'))) throw new Error(); + return [...value].sort(); +} +export async function juiceRequest(operation, owner, data, fetcher = fetch) { + const response = await fetcher('https://teahub.xyz/tea/site/chat/persimmons/juice/' + operation, { + method: 'POST', credentials: 'include', cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(20000), + headers: {'Content-Type': 'application/json', 'X-Vault-Owner': owner}, body: JSON.stringify(data) + }); + if (!response.ok) throw new Error(); + const result = await response.json(); + if (result.owner !== owner) throw new Error(); + return result; +} +export async function giteaToken(origin, account, name, scopes, fetcher = fetch) { + if (!JUICE_ORIGINS.includes(origin) || !usernamePattern.test(account.username) || + !/^persimmons-juice-[0-9a-f]{32}$/.test(name)) throw new Error(); + const bytes = new TextEncoder().encode(account.username + ':' + account.password); + let auth; + try { + auth = 'Basic ' + btoa(String.fromCharCode(...bytes)); + const response = await fetcher(origin + '/api/v1/users/' + encodeURIComponent(account.username) + '/tokens' + + (scopes ? '' : '/' + name), { + method: scopes ? 'POST' : 'DELETE', credentials: 'omit', cache: 'no-store', redirect: 'error', + signal: AbortSignal.timeout(15000), headers: {'Content-Type': 'application/json', Authorization: auth}, + ...(scopes ? {body: JSON.stringify({name, scopes: checkedScopes(scopes)})} : {}) + }); + if (!scopes) { + if (response.status !== 204 && response.status !== 404) throw new Error(); + return true; + } + if (response.status !== 201) throw new Error(); + const token = await response.json(); + if (!/^[0-9a-f]{40}$/.test(token.sha1) || token.name !== name || + checkedScopes(token.scopes).join() !== checkedScopes(scopes).join()) throw new Error(); + return token.sha1; + } finally { bytes.fill(0); auth = ''; } +} + +// Dependencies keep crypto and Chrome state out of the adapter's test surface. +// No error object, provider response or credential can be returned to the page. +export async function runJuice(message, sender, deps) { + if (!trustedJuice(message, sender)) return null; + const {owner, target, action} = message; + const request = (op, data) => deps.request(op, owner, {target, ...data}); + let newId, source, previous, prepared = false, attempted = false; + try { + await deps.live(owner); + if (action !== 'create') { + if (!idPattern.test(message.id)) throw new Error(); + previous = (await request('list', {})).items.find(i => i.id === message.id); + if (!previous) throw new Error(); + } + if (action === 'revoke') { + // Target removal does not depend on source availability or unlock state. + await request('remove', {id: previous.id}); + let confirmed = false; + try { + await deps.unlocked(owner); + source = await deps.source(owner, previous.source_id); + if (source.origin !== previous.source_origin) throw new Error(); + await deps.use(owner, source, async (account, check) => { + if (account.username !== previous.source_username) throw new Error(); + await deps.live(owner); check(); + await deps.provider(source.origin, account, 'persimmons-juice-' + previous.id); + confirmed = true; + }); + } catch {} + await request('cleanup', {id: previous.id, provider_status: confirmed ? 'revoked' : 'unknown'}); + return {ok: confirmed, id: previous.id, status: confirmed ? 'revoked' : 'partial-failure', + message: confirmed ? 'Revoked.' : 'ANDON: Target secret removed; provider revocation is unconfirmed. Unlock the source and retry Revoke.'}; + } + await deps.unlocked(owner); + const sourceId = previous?.source_id || message.source_id; + if (!idPattern.test(sourceId)) throw new Error(); + const scopes = checkedScopes(previous?.scopes || message.policy?.scopes); + if (action === 'create' && Object.keys(message.policy).join() !== 'scopes') throw new Error(); + if (previous && previous.status !== 'active') throw new Error(); + source = await deps.source(owner, sourceId); + if (!JUICE_ORIGINS.includes(source.origin)) return {ok: false, message: 'Juice adapter not available yet.'}; + if (previous && previous.source_origin !== source.origin) throw new Error(); + newId = deps.id(); + return await deps.use(owner, source, async (account, check) => { + if (!usernamePattern.test(account.username) || (previous && account.username !== previous.source_username)) throw new Error(); + let pat = ''; + try { + await deps.live(owner); check(); + await request('prepare', {id: newId, source_id: sourceId, source_username: account.username, + scopes, replaces: previous?.id || null}); + prepared = true; + await deps.live(owner); check(); + attempted = true; + pat = await deps.provider(source.origin, account, 'persimmons-juice-' + newId, scopes); + await deps.live(owner); check(); + await request('write', {id: newId, pat}); + pat = ''; + } catch { + pat = ''; + let confirmed = !attempted; + if (attempted) { + try { check(); await deps.provider(source.origin, account, 'persimmons-juice-' + newId); confirmed = true; } catch {} + } + let targetClean = !prepared; + if (prepared) { + try { await request('cleanup', {id: newId, provider_status: confirmed ? 'revoked' : 'unknown'}); targetClean = true; } catch {} + } + return {ok: false, id: newId, status: confirmed && targetClean ? 'revoked' : 'partial-failure', + message: confirmed && targetClean ? 'Juice failed; new token and target secret cleaned up.' : + 'ANDON: Juice failed; cleanup is unconfirmed. Inspect the pending Juice card and retry Revoke.'}; + } finally { pat = ''; } + if (previous) { + let confirmed = false; + try { + await request('remove', {id: previous.id}); + await deps.live(owner); check(); + await deps.provider(source.origin, account, 'persimmons-juice-' + previous.id); + confirmed = true; + await request('cleanup', {id: previous.id, provider_status: 'revoked'}); + } catch { + return {ok: false, id: newId, status: 'partial-failure', + message: 'ANDON: Replacement stored; old token cleanup is ' + (confirmed ? 'not recorded' : 'unconfirmed') + '. Retry Revoke on the old card.'}; + } + } + return {ok: true, id: newId, status: 'active'}; + }); + } catch { + return {ok: false, status: 'unavailable', message: 'Juice unavailable. Check connection, owner, source and unlocked vault.'}; + } +}