This repository has been archived on 2026-10-14. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

149 lines
7.8 KiB
JavaScript
Raw Permalink Normal View History

// 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.'};
}
}