port: from the old factory (tea-mcp/src/persimmons + human_vault), as it was, 2026-10-14

This commit is contained in:
teahubxyz committed 2026-10-14 00:21:52 -07:00
commit c50ff52fcf
14 files changed
+1396

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
human_vault.py
web/index.html
web/extension.zip
web/version.json
web/version.mjs
+16
View File
@@ -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`.
View File
Whitespace-only changes.
+56
View File
@@ -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('<script src="./app.js"', '<script type="module" src="./app.js"')
html = html.replace('src="./app.js"', 'src="/assets/app.js"')
html = html.replace('Available to you across Teahub.', '')
html = re.sub(r'<div class="heroSupport">.*?</div>', '', html, flags=re.S)
html = re.sub(r'<div class="scrim".*?<script', '<script', html, flags=re.S)
html = re.sub(r'<div class="vaultGroup">\s*<div class="groupHead">\s*<div><strong>📦.*?</section>', '</section>', html, flags=re.S)
html = html.replace('<div><h2>Your Persimmons</h2><div class="hint"><span id="userIdentity">Resolving signed-in user…</span></div></div>', '<h2>👤 My vault</h2>')
html = html.replace('<button class="primary" id="addButton">+ Add</button>', '<a id="signInLink" class="buttonLink" href="/signin" hidden>Sign in</a>')
html = re.sub(r'<div class="groupHead">\s*<div><strong>👤 My vault</strong>.*?<div class="groupRule" aria-hidden="true"></div>', '', html, count=1, flags=re.S)
html = html.replace('<div id="userState" class="state">Loading…</div>', '<div id="userState" class="state" hidden></div>')
html = html.replace('<span class="heroName">Persimmons</span>', '<span class="heroName"><span class="heroFruit">🍊</span>Persimmons</span>')
html = html.replace('<div class="heroFlow" aria-label="Orange to water to robot">🍊 → 💧 → 🤖</div>', '')
html = html.replace('</section>\n\n\n</main>', '''<div class="vaultGroup" id="namedVaults" hidden>
<div class="groupHead"><div><strong>📦 Vaults</strong><div class="hint">Private spaces for scoped Persimmons.</div></div><button id="newVault" class="primary">+ New vault</button></div>
<div class="groupRule" aria-hidden="true"></div>
<div id="namedVaultList" class="list" aria-live="polite"></div>
</div>
</section>
<footer class="persimmonsFooter"><strong>Passwords, PATs &amp; plenty of other useful P’s.</strong><div class="vaultFlow" aria-label="Orange to water to robot">🍊 → 💧 → 🤖</div></footer>
</main>''')
html = html.replace(
'<div class="shellTop">\n <h2>👤 My vault</h2>\n <div class="shellActions"><button id="refreshAll">Refresh</button><button id="signOutButton" hidden>Sign out</button><a id="signInLink" class="buttonLink" href="/signin" hidden>Sign in</a></div>\n</div>',
'<div class="shellTop">\n <div><h2>Vault</h2><div id="userIdentity" class="hint"></div></div>\n <div class="shellActions"><button class="primary" id="addCredential" hidden>+ Add credential</button><button id="signOutButton" hidden>Sign out</button><a id="signInLink" class="buttonLink" href="/signin" hidden>Sign in</a></div>\n</div>'
)
html = html.replace('</style>', '''
.juiceHover{outline:3px solid #ed9238;background:#fff1df}.connection[draggable=true]{cursor:grab}.juiceCard{padding:12px;margin-top:10px;border:1px solid var(--line);border-radius:10px}.juiceCard p{margin:6px 0;overflow-wrap:anywhere}\n.persimmonsFooter{text-align:center;margin:26px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.persimmonsFooter strong{display:block;color:var(--ink);font-size:13px;margin-bottom:4px}.vaultFlow{font-size:15px;line-height:1.4;opacity:.72}
.vaultSummary{display:flex;align-items:center;gap:10px;margin-bottom:12px}.lockIcon{font-size:22px;line-height:1}.vaultCount{color:var(--muted);font-size:12px}
.credentialSource{display:flex;gap:6px;align-items:baseline;margin-top:10px;color:var(--muted);font-size:12px;overflow-wrap:anywhere}.credentialSource a{color:inherit}.credentialId{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase}
#namedVaultList{display:grid;gap:10px}.namedVaultCard{padding:14px 16px}.namedVaultMeta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}.namedVaultBadge{display:inline-flex;align-items:center;padding:3px 7px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:11px}.namedVaultActions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.namedVaultBody{margin-top:12px;padding:14px;border:1px dashed var(--line);border-radius:11px;color:var(--muted);font-size:12px;line-height:1.5}.namedVaultBody strong{display:block;color:var(--ink);margin-bottom:3px}
@media(max-width:720px){.vaultSummary{align-items:flex-start}.shellActions .buttonLink,.shellActions button{flex:1}}
</style>''')
html = html.replace('<h2>Vault</h2>', '<h2>Master vault</h2>')
# Keep the hero visually clean; supporting product copy lives in the footer.
(ROOT / 'web/index.html').write_text(html)
version = json.loads((REPO / 'persimmons-extension/manifest.json').read_text())['version']
(ROOT / 'web/version.json').write_text(json.dumps({'version': version}) + '\n')
with zipfile.ZipFile(ROOT / 'web/extension.zip', 'w', compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted((REPO / 'persimmons-extension').rglob('*')):
if 'tests' not in path.relative_to(REPO / 'persimmons-extension').parts and path.is_file() and path.suffix in {'.mjs', '.json', '.html', '.css', '.png'}:
entry = zipfile.ZipInfo('persimmons-extension/' + path.relative_to(REPO / 'persimmons-extension').as_posix(), (2026, 1, 1, 0, 0, 0))
entry.compress_type = zipfile.ZIP_DEFLATED
entry.external_attr = 0o644 << 16
archive.writestr(entry, path.read_bytes())
(ROOT / 'web/version.mjs').write_bytes((REPO / 'persimmons-extension/version.mjs').read_bytes())
+152
View File
@@ -0,0 +1,152 @@
"""Fixed dedicated identity backend; no caller-selected hosts or admin proxy."""
import base64
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
ORIGIN = os.environ.get('PERSIMMONS_GITEA_ORIGIN', 'https://teahub.xyz').rstrip('/')
ALLOW_REGISTRATION = os.environ.get('PERSIMMONS_GITEA_ALLOW_REGISTRATION', '1') == '1'
class Failure(Exception):
def __init__(self, code=503):
self.code = code
super().__init__('Gitea request unavailable')
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def call(path, method='GET', body=None, login=None):
if login:
auth = 'Basic ' + base64.b64encode((login[0] + ':' + login[1]).encode()).decode()
else:
token = os.environ.get('PERSIMMONS_GITEA_TOKEN', '')
if not token:
raise Failure()
auth = 'token ' + token
request = urllib.request.Request(ORIGIN + '/api/v1' + path, method=method,
headers={'Authorization': auth, 'Accept': 'application/json', 'Content-Type': 'application/json'},
data=json.dumps(body).encode() if body is not None else None)
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=15) as response:
raw = response.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as error:
raise Failure(error.code) from None
except (OSError, ValueError):
raise Failure() from None
def identity(user):
if (not isinstance(user, dict) or type(user.get('id')) is not int or user['id'] < 1 or
user.get('is_admin') is not False or not isinstance(user.get('login'), str) or
not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}', user['login']) or
user.get('active') is False or user.get('prohibit_login') is True):
raise Failure(403)
return {'id': user['id'], 'login': user['login'], 'owner': 'pg-' + str(user['id'])}
def authenticate(name, password, register):
if register and not ALLOW_REGISTRATION:
raise Failure(403)
if register:
try:
created = call('/admin/users', 'POST', {
'username': name, 'password': password,
'email': name + '@users.persimmons.app', 'visibility': 'private',
'must_change_password': False, 'send_notify': False,
'restricted': False})
except Failure as error:
# Gitea reports duplicate usernames/emails with 422.
raise Failure(409 if error.code in (409, 422) else error.code) from None
identity(created) # Fail closed if the server ever creates an admin.
# Basic auth is sent only over HTTPS to the pinned dedicated host, never retained.
user = identity(call('/user', login=(name, password)))
if user['login'].lower() != name.lower():
raise Failure(403)
if register and user['id'] != created['id']:
raise Failure(403)
return user
def current(user):
found = identity(call('/users/' + urllib.parse.quote(user['login'], safe='')))
if found['id'] != user['id'] or found['login'] != user['login']:
raise Failure(401)
return found
def repo_name(value):
if not isinstance(value, str) or not 1 <= len(value) <= 100:
raise ValueError()
name = re.sub(r'\s+', '-', value.strip())
if (not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}', name) or
name.lower().endswith(('.git', '.wiki'))):
raise ValueError()
return name
def checked_repo(repo, user, name, empty=False, repo_id=None):
if (not isinstance(repo, dict) or type(repo.get('id')) is not int or
repo.get('owner', {}).get('id') != user['id'] or
repo.get('owner', {}).get('login') != user['login'] or
repo.get('name') != name or repo.get('private') is not True or
(empty and repo.get('empty') is not True) or
(repo_id is not None and repo.get('id') != repo_id)):
raise Failure(502)
return {'id': repo['id'], 'name': repo['name'], 'owner': user['login'],
'private': True, 'empty': repo.get('empty') is True,
'url': ORIGIN + '/' + urllib.parse.quote(user['login'], safe='') + '/' + urllib.parse.quote(name, safe='')}
def create_repo(user, name):
current(user)
try:
repo = call('/admin/users/' + urllib.parse.quote(user['login'], safe='') + '/repos', 'POST',
{'name': name, 'private': True, 'auto_init': False, 'template': False})
except Failure as error:
raise Failure(409 if error.code in (409, 422) else error.code) from None
checked_repo(repo, user, name, empty=True)
# Verify the persisted resource independently before recording it as a vault.
return checked_repo(call('/repos/' + user['login'] + '/' + name), user, name,
empty=True, repo_id=repo['id'])
def list_repos(user, rows):
current(user)
return [checked_repo(call('/repos/' + user['login'] + '/' + name), user, name, repo_id=repo_id)
for repo_id, name in rows]
def actions(user, vault, kind, name=None, method='GET', body=None):
"""Dedicated, identity-checked Actions storage. Secrets have no read helper."""
if kind not in ('secrets', 'variables') or (kind == 'secrets' and method not in ('PUT', 'DELETE')):
raise ValueError()
if name is not None and not re.fullmatch(r'PERSIM_JUICE_[0-9a-f]{32}', name):
raise ValueError()
current(user)
base = '/repos/' + user['login'] + '/' + vault['name']
checked_repo(call(base), user, vault['name'], repo_id=vault['id'])
path = base + '/actions/' + kind
if name:
result = call(path + '/' + name.upper(), method, body)
else:
result = []
for page in range(1, 11):
checked_repo(call(base), user, vault['name'], repo_id=vault['id'])
rows = call(path + '?limit=100&page=' + str(page))
if not isinstance(rows, list):
raise Failure(502)
result.extend(rows)
if len(rows) < 100:
break
else:
raise Failure(409) # Never silently omit cleanup records.
checked_repo(call(base), user, vault['name'], repo_id=vault['id'])
return result
+195
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
starlette==1.7.0
uvicorn==0.37.0
+280
View File
@@ -0,0 +1,280 @@
"""Independent Persimmons taste-test host. No MCP or Tea runtime imports."""
import asyncio
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import sqlite3
import time
from contextlib import closing
from starlette.applications import Starlette
from starlette.concurrency import run_in_threadpool
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import FileResponse, JSONResponse
from starlette.requests import Request
from starlette.routing import Route
from starlette.staticfiles import StaticFiles
from . import human_vault, gitea, juice
ORIGIN = 'https://persimmons.app'
COOKIE = '__Host-persimmons-session'
ROOT = Path(__file__).parent
DATA = Path(os.environ.get('PERSIMMONS_DATA', '/data'))
SESSION_SECONDS = 12 * 3600
AUTH_GATE = asyncio.Semaphore(1)
def database():
db = sqlite3.connect(DATA / 'sessions.sqlite', timeout=5)
db.execute('PRAGMA secure_delete=ON')
version = db.execute('PRAGMA user_version').fetchone()[0]
if version not in (0, 2):
db.close()
raise sqlite3.DatabaseError('Unsupported Persimmons schema')
if version == 0:
# One-time authorized reset, restricted to this app's disposable stores.
vault = DATA / 'human-vault.sqlite'
db.execute('ATTACH DATABASE ? AS human', (str(vault),))
with db:
db.execute('BEGIN IMMEDIATE')
if db.execute('PRAGMA user_version').fetchone()[0] != 2:
for table in ('users', 'sessions', 'attempts'):
db.execute('DROP TABLE IF EXISTS ' + table)
db.execute('DROP TABLE IF EXISTS human.human_items')
db.execute('CREATE TABLE sessions (digest TEXT PRIMARY KEY, owner TEXT NOT NULL, gitea_id INTEGER NOT NULL, login TEXT NOT NULL, expires INTEGER NOT NULL)')
db.execute('CREATE TABLE attempts (key TEXT PRIMARY KEY, count INTEGER, expires INTEGER)')
db.execute('CREATE TABLE named_vaults (owner TEXT NOT NULL, repo_id INTEGER NOT NULL UNIQUE, name TEXT NOT NULL, PRIMARY KEY(owner,name))')
db.execute('PRAGMA user_version=2')
db.execute('DETACH DATABASE human')
return db
def resolve(request):
token = request.cookies.get(COOKIE, '')
if not re.fullmatch(r'[A-Za-z0-9_-]{43}', token):
return None
with closing(database()) as db:
row = db.execute('SELECT owner,gitea_id,login FROM sessions WHERE digest=? AND expires>?',
(hashlib.sha256(token.encode()).hexdigest(), int(time.time()))).fetchone()
return {'owner': row[0], 'id': row[1], 'login': row[2]} if row else None
async def actor(request):
user = await run_in_threadpool(resolve, request)
return user['owner'] if user else None
def reply(data, code=200):
return JSONResponse(data, code, headers={'Cache-Control': 'no-store'})
def credentials(name, password, register, address):
now = int(time.time())
with closing(database()) as db, db:
db.execute('DELETE FROM attempts WHERE expires<?', (now,))
db.execute('DELETE FROM sessions WHERE expires<?', (now,))
for label, maximum in [('global', 120), ('ip:' + address, 20), ('name:' + name, 10)]:
key = hashlib.sha256(label.encode()).hexdigest()
row = db.execute('SELECT count FROM attempts WHERE key=?', (key,)).fetchone()
if row and row[0] >= maximum:
return None, 429
db.execute('INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1', (key, now + 600))
try:
user = gitea.authenticate(name, password, register)
except gitea.Failure as error:
return None, error.code if error.code in (400, 401, 403, 409, 429) else 503
with closing(database()) as db, db:
token = secrets.token_urlsafe(32)
db.execute('INSERT INTO sessions VALUES (?,?,?,?,?)',
(hashlib.sha256(token.encode()).hexdigest(), user['owner'], user['id'], user['login'], now + SESSION_SECONDS))
return token, 200
async def auth(request):
if request.url.scheme != 'https' or request.headers.getlist('origin') != [ORIGIN] or request.url.query:
return reply({'error': 'forbidden'}, 403)
if request.headers.get('content-type') != 'application/json':
return reply({'error': 'JSON required'}, 415)
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 4096:
return reply({'error': 'invalid request'}, 400)
try:
data = json.loads(raw)
name = data['name'].strip().lower()
password = data['password']
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', name) or not isinstance(password, str) or not 12 <= len(password) <= 1024:
raise ValueError()
except (ValueError, KeyError, TypeError, AttributeError):
return reply({'error': 'Use a 3–64 character username and a 12–1024 character password.'}, 400)
if AUTH_GATE.locked():
return reply({'error': 'Sign-in busy; retry shortly.'}, 429)
async with AUTH_GATE:
token, code = await run_in_threadpool(credentials, name, password, request.url.path.endswith('/register'), request.headers.get('fly-client-ip', request.client.host if request.client else 'local'))
if not token:
return reply({'error': 'Sign-in unavailable. Check details or wait before retrying.'}, code)
response = reply({'ok': True})
response.set_cookie(COOKIE, token, max_age=SESSION_SECONDS, secure=True, httponly=True, samesite='none', path='/')
return response
async def session(request):
user = await run_in_threadpool(resolve, request)
if not user:
return reply({'error': 'unauthorized'}, 401)
return reply({'owner': user['owner'], 'name': user['login'], 'gitea_id': user['id']})
async def named_vaults(request):
if request.url.scheme != 'https' or request.url.query or request.headers.get('authorization'):
return reply({'error': 'forbidden'}, 403)
if request.method == 'POST' and (request.headers.getlist('origin') != [ORIGIN] or
request.headers.get('x-requested-with') != 'Persimmons'):
return reply({'error': 'forbidden'}, 403)
user = await run_in_threadpool(resolve, request)
if not user:
return reply({'error': 'unauthorized'}, 401)
try:
if request.method == 'GET':
with closing(database()) as db:
rows = db.execute('SELECT repo_id,name FROM named_vaults WHERE owner=? ORDER BY name', (user['owner'],)).fetchall()
vaults = await run_in_threadpool(gitea.list_repos, user, rows)
return reply({'vaults': vaults})
if request.headers.get('content-type') != 'application/json':
return reply({'error': 'JSON required'}, 415)
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 1024:
raise ValueError()
data = json.loads(raw)
if not isinstance(data, dict) or set(data) != {'name'}:
raise ValueError()
name = gitea.repo_name(data['name'])
with closing(database()) as db:
if db.execute('SELECT 1 FROM named_vaults WHERE owner=? AND name=?', (user['owner'], name)).fetchone():
return reply({'error': 'A repository with this name already exists.'}, 409)
if db.execute('SELECT count(*) FROM named_vaults WHERE owner=?', (user['owner'],)).fetchone()[0] >= 100:
return reply({'error': 'Vault limit reached.'}, 409)
repo = await run_in_threadpool(gitea.create_repo, user, name)
with closing(database()) as db, db:
db.execute('INSERT INTO named_vaults VALUES (?,?,?)', (user['owner'], repo['id'], repo['name']))
return reply({'vault': repo}, 201)
except (ValueError, TypeError):
return reply({'error': 'Use a vault name with letters, numbers, spaces, dots, underscores or hyphens (100 characters maximum).'}, 400)
except gitea.Failure as error:
if error.code == 409:
return reply({'error': 'A repository with this name already exists.'}, 409)
return reply({'error': 'Vault service unavailable.'}, 503)
except (sqlite3.Error, OSError):
return reply({'error': 'Vault service unavailable.'}, 503)
async def logout(request):
if request.url.scheme != 'https' or request.headers.getlist('origin') != [ORIGIN]:
return reply({'error': 'forbidden'}, 403)
digest = hashlib.sha256(request.cookies.get(COOKIE, '').encode()).hexdigest()
with closing(database()) as db, db:
db.execute('DELETE FROM sessions WHERE digest=?', (digest,))
response = reply({'ok': True})
response.delete_cookie(COOKIE, path='/', secure=True, httponly=True, samesite='none')
return response
async def vault_index(request):
owner = await actor(request)
if not owner:
return reply({'error': 'unauthorized'}, 401)
path = os.environ.get('PERSIMMONS_HUMAN_VAULT_DB')
if not path:
return reply({'error': 'vault not configured'}, 503)
try:
payload, code = await run_in_threadpool(human_vault.Store(path).operate, owner.lower(), 'GET')
except (sqlite3.Error, OSError):
return reply({'error': 'vault unavailable'}, 503)
return reply(payload, code)
async def delete_vault_item(request):
if (request.url.scheme != 'https' or request.headers.getlist('origin') != [ORIGIN] or
request.headers.get('x-requested-with') != 'Persimmons' or request.url.query):
return reply({'error': 'forbidden'}, 403)
owner = await actor(request)
if not owner:
return reply({'error': 'unauthorized'}, 401)
item_id = request.path_params['item_id']
if not human_vault.ITEM_ID.fullmatch(item_id):
return reply({'error': 'not found'}, 404)
path = os.environ.get('PERSIMMONS_HUMAN_VAULT_DB')
if not path:
return reply({'error': 'vault not configured'}, 503)
try:
payload, code = await run_in_threadpool(
human_vault.Store(path).operate, owner.lower(), 'DELETE', item_id)
except (sqlite3.Error, OSError):
return reply({'error': 'vault unavailable'}, 503)
return reply(payload, code)
async def health(request):
try:
with closing(database()) as db:
db.execute('SELECT 1').fetchone()
return reply({'ok': True, 'service': 'persimmons', 'source_sha': os.environ.get('SOURCE_SHA', 'local')})
except sqlite3.Error:
return reply({'ok': False, 'service': 'persimmons'}, 503)
async def home(request):
return FileResponse(ROOT / 'web/index.html', headers={'Cache-Control': 'no-store'})
async def signin(request):
return FileResponse(ROOT / 'web/signin.html', headers={'Cache-Control': 'no-store'})
# Only the path changes; encryption validation, ownership, and exact extension
# origin enforcement are copied byte-for-byte from the accepted implementation.
vault_routes = [Route(route.path.replace('/chat/persimmons', '/api'), route.endpoint, methods=route.methods)
for route in human_vault.routes(actor)]
async def read_vault(request):
# Chromium sends its genuine extension Origin on POST, but omits it on
# permitted cross-origin GET. Adapt only empty read requests; the accepted
# handler still checks HTTPS, exact Origin, cookie session and owner itself.
if request.method == 'OPTIONS':
return await vault_routes[0].endpoint(request)
async for chunk in request.stream():
if chunk:
return reply({'error': 'read body must be empty'}, 400)
read_request = Request({**request.scope, 'method': 'GET'}, receive=request.receive)
return await vault_routes[0].endpoint(read_request)
app = Starlette()
app.routes.extend([Route('/', home), Route('/signin', signin), Route('/healthz', health),
Route('/api/juice/{operation}', juice.endpoint(resolve, database), methods=['POST', 'OPTIONS']),
Route('/api/session', session), Route('/api/vaults', named_vaults, methods=['GET', 'POST']), Route('/api/vault-index', vault_index),
Route('/api/vault-index/{item_id}', delete_vault_item, methods=['DELETE']),
Route('/api/logout', logout, methods=['POST']),
Route('/api/auth/register', auth, methods=['POST'], name='register'),
Route('/api/auth/login', auth, methods=['POST'], name='login'),
Route('/api/human-vault/items/read', read_vault, methods=['POST', 'OPTIONS']),
Route('/api/human-vault/items/{item_id}/read', read_vault, methods=['POST', 'OPTIONS']), *vault_routes])
app.mount('/assets', StaticFiles(directory=ROOT / 'web'), name='assets')
app.add_middleware(TrustedHostMiddleware, allowed_hosts=['persimmons.app', 'persimmons-app.fly.dev', 'localhost', '127.0.0.1'])
async def response_headers(request, call_next):
response = await call_next(request)
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
return response
app.add_middleware(BaseHTTPMiddleware, dispatch=response_headers)
+554
View File
@@ -0,0 +1,554 @@
import {updateState} from './version.mjs';
const $ = id => document.getElementById(id);
const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg';
let generation = 0, currentOwner, currentState, dragging = false, juiceBusy = false;
const masterItems = new Map();
const openVaults = new Set();
$('signOutButton').hidden = true;
$('signOutButton').textContent = 'Sign out';
$('userList').innerHTML = `
<div id="vaultSummary" class="vaultSummary" hidden>
<span id="extensionState" class="lockIcon" role="img"></span>
<div><strong id="vaultCount"></strong><div id="extensionHelp" class="hint"></div></div>
</div>
<div id="onboarding" class="empty">
<p id="onboardingHelp"></p>
<button id="createAccount" class="primary" hidden>Create account</button>
<button id="getExtension" class="primary" hidden>Get extension</button>
<button id="updateExtension" hidden>Update available</button>
</div>
<div id="vaultItems"></div>`;
function extensionMessage(type, owner, extra = {}) {
return new Promise(resolve => {
const timer = setTimeout(() => resolve(null), type === 'persimmons.juice.v1' ? 180000 : 2500);
try {
chrome.runtime.sendMessage(EXTENSION_ID, {type, owner, ...extra}, result => {
const failed = chrome.runtime.lastError;
clearTimeout(timer);
resolve(failed ? null : result);
});
} catch {
clearTimeout(timer);
resolve(null);
}
});
}
const extensionStatus = owner => extensionMessage('persimmons.connection.v1', owner);
const extensionInventory = owner => extensionMessage('persimmons.inventory.v1', owner);
async function latestVersion() {
try {
const response = await fetch('/assets/version.json', {cache: 'no-store'});
return response.ok ? (await response.json()).version : null;
} catch { return null; }
}
async function refresh() {
if (dragging || juiceBusy) return;
const current = ++generation;
const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'});
if (current !== generation) return;
if (!response.ok && response.status !== 401) throw new Error();
const signedIn = response.ok;
const identity = signedIn ? await response.json() : null;
const owner = identity?.owner || identity?.login?.toLowerCase() || identity?.user?.login?.toLowerCase() || identity?.name?.toLowerCase();
const [state, latest, vault, named] = await Promise.all([
extensionStatus(owner),
latestVersion(),
signedIn
? fetch('/api/vault-index', {credentials: 'same-origin', cache: 'no-store'})
.then(async response => response.ok ? response.json() : null)
.catch(() => null)
: Promise.resolve(null),
signedIn ? fetch('/api/vaults', {credentials: 'same-origin', cache: 'no-store'})
.then(async response => response.ok ? response.json() : null).catch(() => null) : null
]);
if (current !== generation) return;
const decrypted = state?.unlocked ? await extensionInventory(owner) : null;
if (current !== generation || dragging || juiceBusy) return;
$('signInLink').hidden = signedIn;
$('signOutButton').hidden = !signedIn;
$('addCredential').hidden = !signedIn || !state?.connected || !state?.initialized;
$('userState').hidden = true;
$('userState').textContent = '';
const displayName = identity?.name || identity?.login || identity?.user?.login || owner || '';
$('userIdentity').textContent = signedIn ? 'Signed in as ' + displayName : '';
currentOwner = owner; currentState = state;
if (!await renderVaults(signedIn, named, current)) return;
if (current !== generation || dragging || juiceBusy) return;
const stale = !!state && updateState(state.version, latest) === 'Update available';
$('updateExtension').hidden = !signedIn || !stale;
$('createAccount').hidden = signedIn;
$('getExtension').hidden = !signedIn || !!state;
if (!signedIn) {
$('vaultSummary').hidden = true;
$('onboarding').hidden = false;
$('onboardingHelp').textContent = 'Sign in or create an account to use your encrypted vault.';
$('vaultItems').replaceChildren();
return;
}
const linked = state?.connected === true;
const ready = linked && state.initialized;
$('vaultSummary').hidden = !ready;
$('onboarding').hidden = ready;
$('extensionState').textContent = state?.unlocked ? '🔓' : '🔒';
$('extensionState').ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked';
$('extensionState').title = $('extensionState').ariaLabel;
$('extensionHelp').textContent = state?.unlocked ? 'Drag a credential to a named vault to create Juice.' : 'Open 🍊 to unlock. Choose a timed auto-lock in Settings for Juice.';
$('onboardingHelp').textContent =
!state ? 'Install Persimmons in this browser.' :
!linked ? 'Open 🍊 and choose Link vault.' :
!state.initialized ? 'Open 🍊 and choose a vault passphrase.' :
'';
const inventory = $('vaultItems');
inventory.replaceChildren();
const serverItems = Array.isArray(vault?.items) ? vault.items : null;
const extensionItems = Array.isArray(decrypted?.items) ? decrypted.items : null;
const sourceItems = serverItems || extensionItems;
const vaultAvailable = Array.isArray(sourceItems);
const items = vaultAvailable ? sourceItems.filter(item => {
try { return /^[0-9a-f]{32}$/.test(item.id) && new URL(item.origin).protocol === 'https:'; }
catch { return false; }
}) : [];
$('vaultCount').textContent = !vaultAvailable ? 'Credential list unavailable' :
items.length === 1 ? '1 saved credential' : items.length + ' saved credentials';
masterItems.clear();
for (const item of items) masterItems.set(item.id, item);
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = vaultAvailable ?
'No saved credentials yet. Add one from the site where you use it.' :
'Credentials could not be loaded. The page will retry automatically.';
inventory.append(empty);
} else {
const usernames = new Map((decrypted?.items || []).map(item => [item.id, item.username]));
for (const item of items) {
const url = new URL(item.origin);
const username = item.username || usernames.get(item.id);
const card = document.createElement('article');
card.className = 'connection';
card.dataset.sourceId = item.id;
card.draggable = !!(state?.connected && state?.initialized && state?.unlocked);
card.ondragstart = event => {
dragging = true;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-persimmons-source', item.id);
};
card.ondragend = () => { dragging = false; };
const top = document.createElement('div');
top.className = 'cardtop';
const description = document.createElement('div');
const name = document.createElement('div');
name.className = 'title';
name.textContent = '🔑 ' + (username || url.hostname);
const label = document.createElement('div');
label.className = 'account';
label.textContent = username ? 'Website credential' : 'Credential · ID ' + item.id.slice(-6).toUpperCase();
description.append(name, label);
const status = document.createElement('span');
status.className = 'badge';
status.textContent = state?.unlocked ? '🔓' : '🔒';
status.ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked';
status.title = status.ariaLabel;
top.append(description, status);
const source = document.createElement('div');
source.className = 'credentialSource';
const sourceLabel = document.createElement('span');
sourceLabel.textContent = 'Source';
const sourceLink = document.createElement('a');
sourceLink.href = item.origin;
sourceLink.target = '_blank';
sourceLink.rel = 'noreferrer';
sourceLink.textContent = item.origin;
source.append(sourceLabel, sourceLink);
const mask = document.createElement('div');
mask.className = 'mask';
mask.textContent = '••••••••••••••••';
const capability = document.createElement('span');
capability.className = 'scope';
capability.textContent = 'Fill only on ' + url.hostname;
const metadata = document.createElement('div');
metadata.className = 'meta credentialId';
metadata.textContent = 'ID ' + item.id.slice(-6);
const actions = document.createElement('div');
actions.className = 'actions';
const open = document.createElement('a');
open.className = 'buttonLink';
open.href = item.origin;
open.target = '_blank';
open.rel = 'noreferrer';
open.textContent = 'Open site';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'danger';
remove.textContent = 'Delete';
remove.onclick = async () => {
if (!confirm('Delete this saved credential? This cannot be undone.')) return;
remove.disabled = true;
try {
const deleted = await fetch('/api/vault-index/' + item.id, {
method: 'DELETE', credentials: 'same-origin', headers: {'X-Requested-With': 'Persimmons'}
});
if (!deleted.ok) throw new Error();
await refresh();
} catch {
remove.disabled = false;
$('userState').hidden = false;
$('userState').textContent = 'Delete unavailable. Retry shortly.';
}
};
actions.append(open, remove);
card.append(top, source, mask, capability, metadata, actions);
inventory.append(card);
}
}
}
async function renderVaults(signedIn, result, current) {
const contents = new Map();
if (signedIn && Array.isArray(result?.vaults)) await Promise.all(result.vaults.map(async vault => {
try { contents.set(vault.id, await pageJuice('list', vault)); } catch {}
}));
// Resolve all network reads before touching cards. Never replace a drag target mid-gesture.
if (current !== generation || dragging || juiceBusy) return false;
$('namedVaults').hidden = !signedIn;
const list = $('namedVaultList');
list.replaceChildren();
if (!signedIn) return true;
if (!Array.isArray(result?.vaults) || !result.vaults.length) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = Array.isArray(result?.vaults) ? 'No named vaults yet. Create a private vault when you need one.' : 'Vaults could not be loaded. Retrying shortly.';
list.append(empty);
return true;
}
for (const vault of result.vaults) {
const card = document.createElement('article');
card.className = 'connection namedVaultCard';
card.dataset.vaultId = vault.id;
card.ondragover = event => {
if (!currentState?.connected || !currentState?.initialized || !currentState?.unlocked ||
!event.dataTransfer.types.includes('application/x-persimmons-source')) return;
event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; card.classList.add('juiceHover');
};
card.ondragleave = event => { if (!card.contains(event.relatedTarget)) card.classList.remove('juiceHover'); };
card.ondrop = event => {
event.preventDefault(); dragging = false; card.classList.remove('juiceHover');
const item = masterItems.get(event.dataTransfer.getData('application/x-persimmons-source'));
if (item && currentState?.connected && currentState?.initialized && currentState?.unlocked) openJuice(item, vault);
};
const top = document.createElement('div');
top.className = 'cardtop';
const description = document.createElement('div');
const name = document.createElement('div');
name.className = 'title';
name.textContent = '📦 ' + vault.name;
const owner = document.createElement('div');
owner.className = 'account';
owner.textContent = vault.owner + '/' + vault.name;
description.append(name, owner);
const badge = document.createElement('span');
badge.className = 'badge active';
badge.textContent = 'private ✓';
top.append(description, badge);
const meta = document.createElement('div');
meta.className = 'namedVaultMeta';
const state = document.createElement('span');
state.className = 'namedVaultBadge';
const juiceCount = contents.get(vault.id)?.items.length;
state.textContent = juiceCount ? juiceCount + ' Juice' : vault.empty ? 'Empty vault' : 'Vault';
meta.append(state);
const actions = document.createElement('div');
actions.className = 'namedVaultActions';
const open = document.createElement('button');
open.type = 'button';
open.textContent = 'Open vault';
const body = document.createElement('div');
body.className = 'namedVaultBody';
body.hidden = !openVaults.has(vault.owner + '/' + vault.name);
const bodyTitle = document.createElement('strong');
bodyTitle.textContent = vault.name;
const bodyText = document.createElement('span');
bodyText.textContent = 'Drop an unlocked Master credential here to create Juice.';
body.append(bodyTitle, bodyText);
try {
const result = contents.get(vault.id);
for (const item of result.items) body.append(juiceCard(item, vault));
if (result.items.length) bodyText.remove();
} catch { bodyText.textContent = 'Juice metadata unavailable. Retry shortly.'; }
open.textContent = body.hidden ? 'Open vault' : 'Close vault';
open.onclick = () => {
const key = vault.owner + '/' + vault.name;
if (openVaults.has(key)) openVaults.delete(key);
else openVaults.add(key);
body.hidden = !openVaults.has(key);
open.textContent = body.hidden ? 'Open vault' : 'Close vault';
};
actions.append(open);
card.append(top, meta, actions, body);
list.append(card);
}
return true;
}
async function pageJuice(operation, vault, extra = {}) {
const response = await fetch('/api/juice/' + operation, {method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: {'Content-Type': 'application/json', 'X-Vault-Owner': currentOwner, 'X-Requested-With': 'Persimmons'},
body: JSON.stringify({target: {id: vault.id, name: vault.name}, ...extra})});
if (!response.ok) throw new Error();
return response.json();
}
function juiceCard(item, vault) {
const card = document.createElement('article');
card.className = 'juiceCard'; card.dataset.juiceId = item.id;
const title = document.createElement('strong');
title.textContent = '💧 Gitea · ' + item.source_username + '@' + new URL(item.source_origin).hostname;
const details = document.createElement('p');
details.textContent = item.scopes.join(', ') + ' · ' + item.status + ' · ' + item.created_at;
const boundary = document.createElement('p');
boundary.textContent = 'Provider API-class scopes; not restricted to this vault or a single repository.';
const status = document.createElement('p');
status.textContent = 'Target secret: ' + item.target_status + ' · Provider token: ' + item.provider_status;
if (item.status === 'partial-failure' || item.status === 'pending') status.textContent += ' · ANDON: cleanup may be required. Retry Revoke.';
const actions = document.createElement('div'); actions.className = 'actions';
for (const action of ['rotate', 'revoke']) {
const button = document.createElement('button'); button.textContent = action === 'rotate' ? 'Rotate' : 'Revoke';
button.disabled = action === 'rotate' && (item.status !== 'active' || !currentState?.unlocked);
if (action === 'revoke' && item.status === 'revoked') button.disabled = true;
button.onclick = async () => {
juiceBusy = true; button.disabled = true;
try {
if (action === 'revoke') await pageJuice('remove', vault, {id: item.id});
const result = await extensionMessage('persimmons.juice.v1', currentOwner,
{action, target: {id: vault.id, name: vault.name}, id: item.id});
if (!result && action === 'revoke') {
status.textContent = 'ANDON: Target secret removed; provider revocation unconfirmed. Connect and unlock the source to retry.';
} else if (!result?.ok) {
status.textContent = (action === 'revoke' ? 'ANDON: Target secret removed; provider cleanup unconfirmed. ' : '') +
(result?.message || 'ANDON: Operation outcome unknown. Refresh and inspect Juice before retrying.');
} else {
openVaults.add(vault.owner + '/' + vault.name);
juiceBusy = false; await refresh();
}
} catch { status.textContent = 'ANDON: Operation outcome unknown. Inspect Juice before retrying.'; }
finally { juiceBusy = false; button.disabled = false; }
};
actions.append(button);
}
card.append(title, details, boundary, status, actions);
return card;
}
function openJuice(item, vault) {
const dialog = ensureDialog(), body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.style.cssText = 'font-size:20px;overflow-wrap:anywhere;line-height:1.3';
title.textContent = 'Juice ' + (item.username || 'credential') + '@' + new URL(item.origin).hostname + ' → ' + vault.name;
body.append(title);
if (item.origin !== 'https://teahub.xyz') {
const note = document.createElement('p'); note.textContent = 'Juice adapter not available yet.'; body.append(note);
} else {
const form = document.createElement('form');
form.innerHTML = `<label>Gitea PAT scopes<select name="scope"><option value="read:repository">Read repositories</option><option value="read:issue">Read issues</option><option value="write:issue">Read and write issues</option></select></label>
<p class="hint">Scopes apply across the source account’s accessible resources in that API category. They are not limited to this vault or one repository. No automatic expiry.</p>
<button type="submit" class="primary">Create Juice</button><p role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault(); juiceBusy = true; form.querySelector('button').disabled = true;
const result = await extensionMessage('persimmons.juice.v1', currentOwner, {action: 'create', source_id: item.id,
target: {id: vault.id, name: vault.name}, policy: {scopes: [form.elements.scope.value]}});
juiceBusy = false;
if (result?.ok) {
openVaults.add(vault.owner + '/' + vault.name); dialog.close(); await refresh();
} else {
form.querySelector('[role=status]').textContent = result?.message || 'ANDON: Outcome unknown. Close and inspect Juice before retrying.';
form.querySelector('button').disabled = false;
}
};
body.append(form);
}
dialog.showModal();
}
function openNewVault() {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = 'New vault';
const form = document.createElement('form');
form.innerHTML = `<label>Vault name<input id="vaultName" name="name" autocomplete="off" maxlength="100" required></label>
<p class="hint">Creates an empty private vault owned by you. Spaces become hyphens.</p>
<button type="submit" class="primary">Create vault</button><p id="vaultStatus" role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault();
form.querySelector('button').disabled = true;
try {
const response = await fetch('/api/vaults', {method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'Persimmons'},
body: JSON.stringify({name: form.querySelector('#vaultName').value})});
const result = await response.json();
if (!response.ok) {
form.querySelector('#vaultStatus').textContent = result.error || 'Vault creation unavailable. Retry shortly.';
form.querySelector('button').disabled = false;
return;
}
dialog.close();
await refresh();
} catch {
form.querySelector('#vaultStatus').textContent = 'Vault creation unavailable. Refresh the page before retrying.';
form.querySelector('button').disabled = false;
}
};
body.append(title, form);
dialog.showModal();
}
function ensureDialog() {
let dialog = $('siteModal');
if (dialog) return dialog;
dialog = document.createElement('dialog');
dialog.id = 'siteModal';
dialog.style.cssText = 'border:0;border-radius:16px;padding:0;width:min(460px,calc(100vw - 32px));box-shadow:0 24px 80px rgba(36,26,20,.24)';
dialog.innerHTML = '<div id="siteModalBody" style="padding:22px;background:#fffdfa"></div>';
document.body.append(dialog);
dialog.addEventListener('close', () => refresh().catch(failed));
return dialog;
}
function closeButton(dialog) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = '×';
button.setAttribute('aria-label', 'Close');
button.style.cssText = 'float:right;border:0;background:none;font-size:22px';
button.onclick = () => dialog.close();
return button;
}
function openAuth(mode = 'login') {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = mode === 'register' ? 'Create account' : 'Sign in';
const form = document.createElement('form');
form.autocomplete = 'on';
form.innerHTML = `
<label>Username<input id="modalName" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="64" required></label>
<label>Password<input id="modalPassword" name="password" type="password" autocomplete="current-password" minlength="12" maxlength="1024" required></label>
<button type="submit" class="primary">${mode === 'register' ? 'Create account' : 'Sign in'}</button>
<p id="modalStatus" role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault();
const password = form.querySelector('#modalPassword');
const payload = {name: form.querySelector('#modalName').value, password: password.value};
password.value = '';
form.querySelector('button').disabled = true;
try {
const response = await fetch('/api/auth/' + mode, {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
payload.password = '';
if (!response.ok) throw new Error();
dialog.close();
await refresh();
} catch {
payload.password = '';
form.querySelector('#modalStatus').textContent = 'Sign-in unavailable. Check details and retry.';
form.querySelector('button').disabled = false;
}
};
body.append(title, form);
dialog.showModal();
}
function openInstall(update = false) {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = update ? 'Update Persimmons' : 'Get Persimmons';
const text = document.createElement('p');
text.textContent = update ? 'Install the current build into your unpacked extension folder, then reload it in Chromium.' : 'Download the current build, unzip it, then use Chromium → Extensions → Developer mode → Load unpacked.';
const link = document.createElement('a');
link.href = '/assets/extension.zip';
link.className = 'buttonLink primary';
link.textContent = update ? 'Download current build' : 'Download extension';
body.append(title, text, link);
dialog.showModal();
}
function openAddCredential() {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = 'Add a credential';
const text = document.createElement('p');
text.textContent = 'Open the HTTPS site where you use the credential, enter it without submitting, then open 🍊 and choose Save for this site.';
const note = document.createElement('p');
note.className = 'hint';
note.textContent = 'The page updates automatically after the extension saves it.';
body.append(title, text, note);
dialog.showModal();
}
const failed = () => {
$('userState').hidden = false;
$('userState').textContent = 'Service unavailable. Retry shortly.';
$('extensionState').hidden = false;
$('extensionState').textContent = 'Connection could not be checked.';
};
$('signOutButton').onclick = async () => {
try {
const response = await fetch('/api/logout', {method: 'POST'});
if (!response.ok) throw new Error();
await refresh();
} catch {
$('userState').hidden = false;
$('userState').textContent = 'Sign-out unavailable. Retry shortly.';
}
};
$('signInLink').onclick = event => { event.preventDefault(); openAuth('login'); };
$('createAccount').onclick = () => openAuth('register');
$('getExtension').onclick = () => openInstall(false);
$('updateExtension').onclick = () => openInstall(true);
$('addCredential').onclick = openAddCredential;
$('newVault').onclick = openNewVault;
window.addEventListener('pointerdown', event => {
if (event.target.closest?.('.connection[draggable="true"]')) dragging = true;
}, true);
window.addEventListener('pointerup', () => { dragging = false; }, true);
window.addEventListener('pageshow', () => refresh().catch(failed));
window.addEventListener('focus', () => refresh().catch(failed));
setInterval(() => { if (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500);
refresh().catch(failed);
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Install · Persimmons</title><style>body{background:#fffdfa;color:#241a14;font:16px system-ui;max-width:36rem;margin:4rem auto;padding:1rem;line-height:1.6}a{color:#b8530d}li{margin:1rem 0}</style><h1>Install Persimmons</h1><p>This MVP installs as an unpacked Chromium extension.</p><ol><li><a href="/assets/extension.zip">Download the current extension</a> and extract the ZIP. Keep the folder.</li><li>Open <strong>chrome://extensions</strong>, turn on <strong>Developer mode</strong>, choose <strong>Load unpacked</strong>, and select the extracted <strong>persimmons-extension</strong> folder.</li><li>Pin 🍊 from your browser’s extensions menu. Open it, choose <strong>Link vault</strong>, allow access to Persimmons, then choose a vault passphrase and press Enter.</li></ol><p>Keep this browser profile, extension folder, and passphrase. Vault key sync and recovery are not available.</p><a href="/">Return to your vault</a></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in · Persimmons</title>
<style>body{background:#fffdfa;color:#241a14;font:16px system-ui;margin:4rem auto;padding:1rem;max-width:28rem}input,button{box-sizing:border-box;width:100%;padding:.8rem;margin:.5rem 0;border:1px solid #eadfd6;border-radius:.6rem;font:inherit}button{background:#f47b20;color:white;cursor:pointer}a{color:#b8530d}</style>
<h1>🍊 Persimmons</h1>
<form id="signin" autocomplete="on"><label>Username<input id="name" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}" required></label><label>Password<input id="password" name="password" type="password" autocomplete="current-password" minlength="12" maxlength="1024" required></label><button name="action" value="login">Sign in</button><button name="action" value="register">Create account</button></form>
<p id="status" role="status"></p><a href="/">Back to Persimmons</a><script src="/assets/signin.js"></script></html>
+26
View File
@@ -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(() => {});
+94
View File
@@ -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()
+10
View File
@@ -0,0 +1,10 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Update Persimmons</title>
<style>body{max-width:650px;margin:48px auto;padding:0 24px;font:16px/1.6 system-ui;color:#241a14;background:#fffdfa}a{color:#b8530d}h1{letter-spacing:-.04em}code{overflow-wrap:anywhere}li{margin:12px 0}</style>
<h1>Update your extension</h1>
<p>Keep the same folder and browser profile. Your local wrapped vault key stays in the browser.</p>
<ol><li>Close the vault popup. Find your existing folder under <code>chrome://extensions</code> → Persimmons → Details → Loaded from.</li>
<li>Save the <a href="/assets/update-extension.py" download>update helper</a> once. With Python 3 installed, run:<p><code>python3 update-extension.py "/path/to/persimmons-extension"</code></p>On Windows, use <code>py</code> in place of <code>python3</code>. Repeat this same command for future releases; no manual ZIP extraction or folder selection.</li>
<li>Click Reload on the Persimmons card at <code>chrome://extensions</code>. Version 0.3.4 also offers Update extension → Reload installed files in the popup.</li>
<li>Reopen 🍊. Connection is checked automatically; enter your passphrase to unlock.</li></ol>
<details><summary>Prefer to replace files yourself?</summary><p><a href="/assets/extension.zip">Download the latest ZIP</a> and copy its extension files over the same loaded folder, then Reload. Do not uninstall the extension or clear its storage.</p></details>
<details><summary>Why a helper?</summary><p>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.</p><p>Automatic Chrome Web Store updates are a future distribution step, pending approval and identity verification.</p></details></html>