130 lines
7.8 KiB
Markdown
130 lines
7.8 KiB
Markdown
# 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.
|