Files
app/docs/persimmons-juice-v0.md
T

130 lines
7.8 KiB
Markdown
Raw Normal View History

2026-10-04 02:57:01 +00:00
# Persimmons Juice v0
Juice derives a Gitea PAT in the unlocked extension from an encrypted Master
credential and writes only that derived PAT into a separate named repository
vault. The product page sends source ID, target ID/name and selected scopes.
Source passwords never pass through the product page, Juice backend or MCP.
The extension's existing in-memory key and capture/fill crypto are unchanged.
Supported source origins are exactly `https://teahub.xyz` and
`https://git.persimmons.app`. Other origins display “Juice adapter not available
yet.” Extension host permissions and connect-src enumerate those providers;
externally_connectable remains only the Persimmons top frame. The service worker
checks the live owner connection, initialization and unlock before decrypting.
Provider calls use Basic auth, omit cookies, refuse redirects and have timeouts.
The product receives no PAT response. No new MCP surface exists.
Choices are `read:repository`, `read:issue`, and `write:issue` (which includes
issue reads). These are **provider API-class-wide scopes**, bounded by the source
account's authority, not permissions limited to the target repo or a single
source repository. No expiry is promised. `all`, admin, user, organization,
package and repository-write scopes are not accepted. The modal and every Juice
card state this scope boundary.
Backend operations are POST `/api/juice/{list,prepare,write,remove,cleanup}`.
Only the exact pinned extension Origin can prepare, submit a PAT or confirm
provider cleanup; the product page can list safe metadata or remove a target
secret. All operations require a live cookie session, matching X-Vault-Owner,
and a locally recorded named vault whose immutable repo ID, live owner and
privacy are checked against dedicated Gitea. There is no bearer fallback,
caller-selected provider host, secret-read operation or raw source input.
Actions secrets are named `PERSIM_JUICE_<operation ID>`. Safe operation metadata
uses the same name in Actions variables, which Gitea normalizes to uppercase.
The backend projects only its strict metadata schema. PATs enter only the
write-only secret PUT body, never metadata, sessions, logs, receipts or returns.
The dedicated runtime token adds `write:repository` via the existing independent
Persimmons launch/bootstrap workflow. Its versioned root-only token file keeps
bootstrap resumable; the previous bootstrap token is retained for rollback.
Actions workflow execution remains disabled.
An operation reserves metadata before creating `persimmons-juice-<ID>` on the
source provider. This deterministic name permits cleanup even if the create
response is lost. Write failure triggers best-effort source revocation and
target removal. Unconfirmed cleanup returns ANDON and leaves a visible ledger
entry. Worker loss leaves pending metadata for explicit Revoke; it does not
persist a plaintext PAT or automatically retry token creation.
Rotate first writes a new operation/secret, then removes and revokes the old
operation. Interrupted rotation can leave two provider tokens, represented by
separate cards; the result explicitly directs Revoke on the old card. Revoke
always attempts target removal first. Without an available unlocked source,
the target is absent but provider revocation remains unknown. Revoke can be
retried after reconnecting/unlocking; revocation tombstones remain visible.
Metadata describing a stored token records the last confirmed operation, not
continuous provider health monitoring.
Inspected before implementation: live Teahub Gitea **1.27.3** OpenAPI, dedicated
Gitea **1.27.3** authenticated API, and pinned source:
[API router](https://github.com/go-gitea/gitea/blob/v1.27.3/routers/api/v1/api.go),
[token create/delete](https://github.com/go-gitea/gitea/blob/v1.27.3/routers/api/v1/user/app.go),
and [Gitea API scope documentation](https://docs.gitea.com/development/api-usage/).
Validation commands:
```
python persimmons/build.py
python -m unittest tests.test_persimmons_host tests.test_persimmons_gitea tests.test_persimmons_ux tests.test_juice_target
node --test persimmons-extension/tests/*.test.mjs
node tests/juice-provider-integration.mjs
node tests/persimmons-host-browser.cjs
```
The provider probe uses a disposable generated non-admin account, checks scoped
read/write enforcement, confirms Actions secrets cannot be read, and revokes its
PAT. Credentials stay in process memory and never enter output or files. The
browser harness retains capture/fill/lock/signout acceptance and exercises Gitea
capture, drag, target secret/metadata, rotation and revoke in a fresh profile.
Deployment is restricted to `persimmons-launch.yml` after local checks pass.
## Accepted live receipt — 2026-10-01
Implemented in `dfa10102d94f1c962bebc042c80a7de577430897`; final deployed runtime
is **`a2ea1c770db8300c0da75db5894e6220ead8ccb4`**. Both releases used only the
independent Persimmons launch workflow. [Final launch run 1022](https://teahub.xyz/matcha/teahub/actions/runs/1022),
job 1024, completed successfully, including contract tests, provider authority,
deployment and HTTP acceptance. Before both publishes/deploys, explicit fetches
proved behind=0; dispatch receipts had HEAD=origin/main, ahead=0 and dirty=false.
No delegation occurred.
Local checks passed: **42 host/Gitea/UX/Juice Python tests**, **8 encrypted-vault
and legacy Juice/MCP regression tests**, and **24 extension tests across nine
files**. The launch workflow repeated the host/Gitea/UX/Juice and extension suites.
The disposable provider integration confirmed Gitea 1.27.3, read:repository
access, denied repository writes, write-only secrets, uppercase metadata names,
and failed authentication after revocation. Its accepted provider fixture was
`juice-probe-93b0fee3e1977e07`, repo ID 10; provider PAT and target test secret were
removed. Disposable test users/private repositories remain as test evidence.
Final browser acceptance used Chromium **153.0.8010.12**, extension **0.5.0**,
and a fresh profile with **no UI asset interception**. User
`browser-17b3e5a0b314d214` (non-admin ID 20) created private repo `work-test`,
immutable repo ID **19**. The actual Gitea login form was captured into Master,
then a native mouse drag emitted dragstart/drop/dragend and opened the modal.
The target stayed open after success. Source credential plaintext was neither
sent to the product page nor exposed in metadata.
The extension worker independently verified two newly issued PATs could read
the intended private repository and both returned 401 after their respective
revocations. Only boolean counters left that verification closure. The real
backend accepted two extension-only PAT writes. Target Actions secret listing
exposed names only; secret GET was unavailable. Rotate removed/revoked the old
operation and installed a working replacement. Revoke left **zero provider PATs
and zero target secrets**. Safe revocation metadata remains in Actions variables.
The accepted run removed its Master ciphertext, logged out, and removed its
browser profile. The preserved WordPress synthetic capture/fill test passed with
zero form submissions; lock/unlock and logout connection clearing also passed.
The harness pregrants the exact optional Persimmons host permission and does not
claim to automate Chromium's native permission prompt. Timed auto-lock was
selected through the existing extension setting to keep the vault unlocked
while interacting with the page.
Live `/healthz` returned the exact final runtime source above. Live artifact
bytes matched the local deterministic build:
- app.js SHA-256: `4fc7ab21660660f0d6a3e958c7c54c65bd6d941d12f753a035c7d89d2a074960`
- extension.zip SHA-256: `fefa7eeda05543b5216316e8792d32c7358a0f57284ed231490219ef8dd8eb2d`
This receipt-only follow-up does not change deployed runtime bytes. Provider
scope limitations, lack of automatic expiry, and explicit partial-failure
handling remain as documented above.