From 84fa9e70e6dbab3f9b8441bfc3caef446bbc15db Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:57:22 +0000 Subject: [PATCH] Finish Persims consolidation --- backend/juice.py | 195 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 backend/juice.py diff --git a/backend/juice.py b/backend/juice.py new file mode 100644 index 0000000..f65d97c --- /dev/null +++ b/backend/juice.py @@ -0,0 +1,195 @@ +"""Named-vault Juice. Only the pinned extension can submit derived PATs. + +Actions variables hold an allowlisted, value-free operation ledger. There is no +secret GET, raw source credential input, MCP registration, or bearer fallback. +""" +import asyncio +from contextlib import closing +from datetime import datetime, timezone +import json +import os +import re +import sqlite3 + +from starlette.concurrency import run_in_threadpool +from starlette.responses import JSONResponse, Response +from . import gitea, human_vault + +ORIGINS = {'https://teahub.xyz'} +SCOPES = {'read:repository', 'read:issue', 'write:issue'} +ID = re.compile(r'[0-9a-f]{32}') +USERNAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}') +GATE = asyncio.Lock() +FIELDS = {'id', 'source_id', 'source_origin', 'source_username', 'provider', 'scopes', + 'scope_boundary', 'created_at', 'status', 'provider_status', 'target_status', + 'repo_id', 'replaces'} + + +def scopes(value): + if (not isinstance(value, list) or not 1 <= len(value) <= 3 or + any(not isinstance(s, str) or s not in SCOPES for s in value) or + len(set(value)) != len(value) or {'read:issue', 'write:issue'} <= set(value)): + raise ValueError() + return sorted(value) + + +def safe(value, vault): + if (not isinstance(value, dict) or set(value) != FIELDS or + value['provider'] != 'gitea' or value['source_origin'] not in ORIGINS or + not ID.fullmatch(value['id']) or not ID.fullmatch(value['source_id']) or + not USERNAME.fullmatch(value['source_username']) or + value['repo_id'] != vault['id'] or + value['scope_boundary'] != 'provider-api-class-wide' or + value['status'] not in ('pending', 'active', 'revoked', 'partial-failure') or + value['provider_status'] not in ('unknown', 'active', 'revoked') or + value['target_status'] not in ('absent', 'unknown', 'stored') or + (value['replaces'] is not None and not ID.fullmatch(value['replaces']))): + raise ValueError() + scopes(value['scopes']) + datetime.fromisoformat(value['created_at']) + return {key: value[key] for key in FIELDS} + + +def variable(user, vault, item_id): + result = gitea.actions(user, vault, 'variables', 'PERSIM_JUICE_' + item_id) + return safe(json.loads(result['data']), vault) + + +def save(user, vault, record, method='PUT'): + gitea.actions(user, vault, 'variables', 'PERSIM_JUICE_' + record['id'], method, + {'value': json.dumps(safe(record, vault), separators=(',', ':'))}) + + +def operate(user, vault, operation, data): + if operation == 'list': + rows = gitea.actions(user, vault, 'variables') + items = [] + for row in rows: + if re.fullmatch(r'PERSIM_JUICE_[0-9A-F]{32}', row.get('name', '')): + item = safe(json.loads(row['data']), vault) + if row['name'] != 'PERSIM_JUICE_' + item['id'].upper(): + raise ValueError() + items.append(item) + return {'items': items} + item_id = data.get('id') + if not isinstance(item_id, str) or not ID.fullmatch(item_id): + raise ValueError() + if operation == 'prepare': + if set(data) != {'target', 'id', 'source_id', 'source_username', 'scopes', 'replaces'}: + raise ValueError() + source_id = data['source_id'] + if not isinstance(source_id, str) or not ID.fullmatch(source_id): + raise ValueError() + # Metadata only: never fetch or decrypt the source ciphertext here. + found, code = human_vault.Store(os.environ['PERSIMMONS_HUMAN_VAULT_DB']).operate(user['owner'], 'GET') + source = next((i for i in found['items'] if i['id'] == source_id), None) + if code != 200 or not source or source['origin'] not in ORIGINS: + raise ValueError() + if not isinstance(data['source_username'], str) or not USERNAME.fullmatch(data['source_username']): + raise ValueError() + if source.get('username') and source['username'] != data['source_username']: + raise ValueError() + replaces = data['replaces'] + if replaces is not None: + if not isinstance(replaces, str) or not ID.fullmatch(replaces): + raise ValueError() + old = variable(user, vault, replaces) + if old['status'] != 'active' or old['source_id'] != source_id or old['scopes'] != scopes(data['scopes']): + raise ValueError() + record = dict(id=item_id, source_id=source_id, source_origin=source['origin'], + source_username=data['source_username'], provider='gitea', scopes=scopes(data['scopes']), + scope_boundary='provider-api-class-wide', created_at=datetime.now(timezone.utc).isoformat(), + status='pending', provider_status='unknown', target_status='absent', repo_id=vault['id'], replaces=replaces) + # POST conflicts instead of adopting/replacing a prior operation. + save(user, vault, record, 'POST') + return {'item': record} + record = variable(user, vault, item_id) + if operation == 'write': + if set(data) != {'target', 'id', 'pat'} or record['status'] != 'pending': + raise ValueError() + if not isinstance(data['pat'], str) or not re.fullmatch(r'[0-9a-f]{40}', data['pat']): + raise ValueError() + record.update(target_status='unknown', provider_status='active', status='partial-failure') + save(user, vault, record) + try: + gitea.actions(user, vault, 'secrets', 'PERSIM_JUICE_' + item_id, 'PUT', {'data': data['pat']}) + finally: + data['pat'] = '' + record.update(target_status='stored', status='active') + save(user, vault, record) + elif operation in ('remove', 'cleanup'): + expected = {'target', 'id'} | ({'provider_status'} if operation == 'cleanup' else set()) + if set(data) != expected or (operation == 'cleanup' and data['provider_status'] not in ('revoked', 'unknown')): + raise ValueError() + try: + gitea.actions(user, vault, 'secrets', 'PERSIM_JUICE_' + item_id, 'DELETE') + except gitea.Failure as error: + if error.code != 404: + raise + record['target_status'] = 'absent' + if operation == 'cleanup': + record['provider_status'] = data['provider_status'] + record['status'] = 'revoked' if record['provider_status'] == 'revoked' else 'partial-failure' + save(user, vault, record) + else: + raise ValueError() + return {'item': record} + + +def endpoint(resolve, database): + async def handler(request): + operation = request.path_params['operation'] + origin = request.headers.getlist('origin') + extension = origin == [human_vault.EXTENSION_ORIGIN] + page = origin == ['https://persimmons.app'] + headers = {'Cache-Control': 'no-store', 'Vary': 'Origin'} + def reply(data, code=200): + return JSONResponse(data, code, headers=headers) + if (request.url.scheme != 'https' or request.url.query or request.headers.get('authorization') or + not (extension or (page and operation in ('list', 'remove')))): + return reply({'error': 'forbidden'}, 403) + if extension: + headers.update({'Access-Control-Allow-Origin': human_vault.EXTENSION_ORIGIN, + 'Access-Control-Allow-Credentials': 'true'}) + if request.method == 'OPTIONS': + requested = {h.strip().lower() for h in request.headers.get('access-control-request-headers', '').split(',') if h.strip()} + if not extension or request.headers.get('access-control-request-method') != 'POST' or not requested <= {'content-type', 'x-vault-owner'}: + return reply({'error': 'forbidden'}, 403) + return Response(status_code=204, headers={**headers, 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type, X-Vault-Owner'}) + user = await run_in_threadpool(resolve, request) + if not user: + return reply({'error': 'unauthorized'}, 401) + if (request.headers.getlist('x-vault-owner') != [user['owner']] or + (page and request.headers.get('x-requested-with') != 'Persimmons')): + return reply({'error': 'owner mismatch'}, 403) + if request.headers.get('content-type') != 'application/json': + return reply({'error': 'JSON required'}, 415) + try: + raw = bytearray() + async for chunk in request.stream(): + raw.extend(chunk) + if len(raw) > 4096: + raise ValueError() + data = json.loads(raw) + raw.clear() + target = data['target'] + if not isinstance(target, dict) or set(target) != {'id', 'name'} or type(target['id']) is not int: + raise ValueError() + if gitea.repo_name(target['name']) != target['name']: + raise ValueError() + with closing(database()) as db: + row = db.execute('SELECT repo_id FROM named_vaults WHERE owner=? AND name=?', (user['owner'], target['name'])).fetchone() + if not row or row[0] != target['id']: + return reply({'error': 'target mismatch'}, 403) + if operation == 'list' and set(data) != {'target'}: + raise ValueError() + async with GATE: + payload = await run_in_threadpool(operate, user, target, operation, data) + return reply({'owner': user['owner'], **payload}) + except (ValueError, KeyError, TypeError, AttributeError): + return reply({'error': 'Invalid Juice request or metadata.'}, 400) + except (gitea.Failure, sqlite3.Error, OSError): + # A failed mutation is deliberately ambiguous; extension compensates. + return reply({'error': 'Juice unavailable. Check operation status before retrying.'}, 503) + return handler