From c50ff52fcf80aa5590086ec0a29593a6f761847e Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 14 Oct 2026 00:21:52 -0700 Subject: [PATCH] port: from the old factory (tea-mcp/src/persimmons + human_vault), as it was, 2026-10-14 --- .gitignore | 5 + README.md | 16 ++ __init__.py | 0 build.py | 56 ++++ gitea.py | 152 +++++++++++ juice.py | 195 ++++++++++++++ requirements.txt | 2 + server.py | 280 ++++++++++++++++++++ web/app.js | 554 ++++++++++++++++++++++++++++++++++++++++ web/install.html | 1 + web/signin.html | 5 + web/signin.js | 26 ++ web/update-extension.py | 94 +++++++ web/update.html | 10 + 14 files changed, 1396 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 build.py create mode 100644 gitea.py create mode 100644 juice.py create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 web/app.js create mode 100644 web/install.html create mode 100644 web/signin.html create mode 100644 web/signin.js create mode 100644 web/update-extension.py create mode 100644 web/update.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2132b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +human_vault.py +web/index.html +web/extension.zip +web/version.json +web/version.mjs diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b1163f --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Persimmons β€” app + +Ported from the old factory (`tea-mcp/src/persimmons` + `tea_mcp/human_vault.py`, vendored) on 2026-10-14, as it was. +Persimmons is a client of Teahub: this org owns its code, its site (`persimmons/site`) and its domain, persimmons.app. + +## What it is +A Starlette host for vaults backed by Gitea repos, with "juice" (per-vault secrets and variables) and a human vault +(human_vault.py) that the browser extension (`persimmons/extension`) talks to. + +## Seam before it can run on prod +It signs people in with a Gitea username + password (`gitea.authenticate`). Teahub prod has no passwords β€” login is +GitHub (nostr next). So sign-in must move to Gitea OAuth (the Green Tea MCP already does this with FastMCP's +OAuthProxy; reuse that shape) before this app is deployed against `https://teahub.xyz`. Until then persimmons.app +serves a holding page from `persimmons/site`. + +Env: `PERSIMMONS_GITEA_ORIGIN` (default https://teahub.xyz), `PERSIMMONS_DATA` (/data), `PERSIMMONS_HUMAN_VAULT_DB`. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build.py b/build.py new file mode 100644 index 0000000..3997b08 --- /dev/null +++ b/build.py @@ -0,0 +1,56 @@ +"""Extract accepted assets without changing the Tea fallback; deterministic ZIP.""" +from pathlib import Path +import zipfile +import json +import re +ROOT = Path(__file__).resolve().parent +REPO = ROOT.parent +(ROOT / 'human_vault.py').write_bytes((REPO / 'tea_mcp/human_vault.py').read_bytes()) +html = (REPO / 'tea/web/🍊/index.html').read_text() +html = html.replace(' diff --git a/web/signin.js b/web/signin.js new file mode 100644 index 0000000..829179c --- /dev/null +++ b/web/signin.js @@ -0,0 +1,26 @@ +document.getElementById('signin').onsubmit = async event => { + event.preventDefault(); + const form = event.currentTarget; + const status = document.getElementById('status'); + const action = event.submitter.value; + const password = document.getElementById('password'); + const payload = {name: document.getElementById('name').value, password: password.value}; + password.value = ''; + for (const button of form.querySelectorAll('button')) button.disabled = true; + try { + const response = await fetch('/api/auth/' + action, { + method: 'POST', credentials: 'same-origin', + headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) + }); + payload.password = ''; + if (!response.ok) throw new Error(); + location.assign('/'); + } catch { + payload.password = ''; + status.textContent = 'Sign-in unavailable. Check details and retry.'; + for (const button of form.querySelectorAll('button')) button.disabled = false; + } +}; + +fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'}) + .then(response => { if (response.ok) location.replace('/'); }).catch(() => {}); diff --git a/web/update-extension.py b/web/update-extension.py new file mode 100644 index 0000000..cfa09f0 --- /dev/null +++ b/web/update-extension.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Replace a loaded Persimmons folder from the dedicated HTTPS release. + +Run: python3 update-extension.py /path/to/persimmons-extension +Close the popup first; afterward use chrome://extensions -> Reload. +No browser profile, local vault storage, credentials or native host is accessed. +""" +import argparse +import base64 +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import tempfile +import urllib.request +import zipfile + +EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg' +URL = 'https://persimmons.app/assets/extension.zip' +LIMIT = 4 * 1024 * 1024 + + +def identity(manifest): + digest = hashlib.sha256(base64.b64decode(manifest['key'], validate=True)).hexdigest()[:32] + return ''.join(chr(ord('a') + int(c, 16)) for c in digest) + + +def update(folder, raw): + folder = Path(folder).expanduser().absolute() + if folder.is_symlink() or any(p.is_symlink() for p in folder.parents): + raise ValueError('Use a real extension folder, not a symbolic link.') + installed = json.loads((folder / 'manifest.json').read_text()) + if identity(installed) != EXTENSION_ID: + raise ValueError('The installed extension identity does not match.') + files = {} + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + entries = archive.infolist() + if len(entries) > 100 or sum(e.file_size for e in entries) > LIMIT: + raise ValueError('Release exceeds size limit.') + for entry in entries: + path = PurePosixPath(entry.filename) + if (not path.parts or path.parts[0] != 'persimmons-extension' or + '..' in path.parts or '\\' in entry.filename or entry.is_dir() or + (entry.external_attr >> 16) & 0o170000 == 0o120000): + raise ValueError('Unexpected release path.') + relative = Path(*path.parts[1:]) + if relative.suffix not in {'.mjs', '.json', '.html', '.css', '.png'} or relative in files: + raise ValueError('Unexpected release file.') + destination = folder / relative + if destination.is_symlink() or any(p.is_symlink() for p in destination.parents): + raise ValueError('Refusing to replace symbolic links.') + files[relative] = archive.read(entry) + candidate = json.loads(files[Path('manifest.json')]) + if identity(candidate) != EXTENSION_ID or candidate['key'] != installed['key']: + raise ValueError('Release identity does not match; no files replaced.') + version = lambda value: tuple(int(n) for n in value.split('.')) + if version(candidate['version']) < version(installed['version']): + raise ValueError('Refusing an older release.') + # Validate the entire archive before any write. Stage beside the destination + # for atomic per-file replacements; manifest goes last. Retry after interruption. + with tempfile.TemporaryDirectory(prefix='.persimmons-update-', dir=folder.parent) as staging: + stage = Path(staging) + for relative, data in files.items(): + (stage / relative).parent.mkdir(parents=True, exist_ok=True) + (stage / relative).write_bytes(data) + for relative in sorted(files, key=lambda p: (p.name == 'manifest.json', str(p))): + (folder / relative).parent.mkdir(parents=True, exist_ok=True) + os.replace(stage / relative, folder / relative) + return candidate['version'] + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + raise ValueError('Release URL redirected; no files replaced.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('folder', help='Existing folder shown under chrome://extensions -> Details -> Loaded from') + args = parser.parse_args() + try: + with urllib.request.build_opener(NoRedirect).open(URL, timeout=30) as response: + raw = response.read(LIMIT + 1) + if len(raw) > LIMIT: + raise ValueError('Release exceeds size limit.') + release = update(args.folder, raw) + print(f'Installed files for {release}. Now click Reload at chrome://extensions, or Reload installed files in the popup.') + except Exception: + parser.exit(1, 'Update failed safely. Check the folder, network and release; retry before reloading.\n') + + +if __name__ == '__main__': + main() diff --git a/web/update.html b/web/update.html new file mode 100644 index 0000000..20d8890 --- /dev/null +++ b/web/update.html @@ -0,0 +1,10 @@ +Update Persimmons + +

Update your extension

+

Keep the same folder and browser profile. Your local wrapped vault key stays in the browser.

+
  1. Close the vault popup. Find your existing folder under chrome://extensions β†’ Persimmons β†’ Details β†’ Loaded from.
  2. +
  3. Save the update helper once. With Python 3 installed, run:

    python3 update-extension.py "/path/to/persimmons-extension"

    On Windows, use py in place of python3. Repeat this same command for future releases; no manual ZIP extraction or folder selection.
  4. +
  5. Click Reload on the Persimmons card at chrome://extensions. Version 0.3.4 also offers Update extension β†’ Reload installed files in the popup.
  6. +
  7. Reopen 🍊. Connection is checked automatically; enter your passphrase to unlock.
+
Prefer to replace files yourself?

Download the latest ZIP and copy its extension files over the same loaded folder, then Reload. Do not uninstall the extension or clear its storage.

+
Why a helper?

An unpacked extension cannot overwrite its installed source directory itself. Reload rereads the files already on disk; it does not download an update. This helper downloads only from persimmons.app over HTTPS, checks the pinned identity and replaces extension files. It never accesses your browser profile or vault. Close the popup during replacement; if interrupted, rerun before Reload.

Automatic Chrome Web Store updates are a future distribution step, pending approval and identity verification.