// 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://persimmons.app/api/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.'}; } }