Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/decent-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Pick the scenario that matches the task, run it verbatim, and finish before call
| Bengle firmware wake-schedule sync | `scenarios/bengle-wake-schedule.md` |
| Account-proxy CORS pinned to skin origin | `scenarios/account-proxy-cors.md` |
| Account-proxy write forwarding + write-scope gate | `scenarios/account-proxy-write.md` |
| Account-proxy native consent gate | `scenarios/account-proxy-consent.md` |
| Plugin Decent-account proxy bridge (host.decentProxy) | `scenarios/plugin-decent-proxy.md` |

## Authoritative sources
Expand Down
84 changes: 84 additions & 0 deletions .agents/skills/decent-app/scenarios/account-proxy-consent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Scenario: Account-proxy native consent gate

Verifies that the first linked-account proxy request for a skin pauses for a
trusted native prompt, denial returns `403` without contacting the upstream,
and an explicit session trust grant forwards without a prompt. Use a linked
Decent account and the read-only `support/api/sn` endpoint.

## Preconditions

Run this on a desktop with a linked account and an installed `streamline.js`
skin. The denial check uses a disposable custom skin path so it cannot lock the
normal installed skin out. `flutter run` uses `--dart-entrypoint-args` because
consent trust is a process argument, not a Dart define.

```bash
TMP=$(mktemp -d)
printf '<!doctype html><html><head></head><body>Consent smoke</body></html>\n' \
> "$TMP/index.html"
./flutter_with_commit.sh run -d macos --dart-define=simulate=1 \
--dart-entrypoint-args=--skin-path="$TMP"
```

In another terminal, wait for the servers and obtain the injected skin token:

```bash
until curl -sf http://localhost:8080/api/v1/info >/dev/null; do sleep 1; done
P=/api/v1/account/proxy/support/api/sn
TOK=$(curl -sL http://localhost:3000/ \
| sed -n 's/.*name="reaprime-proxy-token" content="\([^"]*\)".*/\1/p' \
| head -1)
test -n "$TOK"
```

## Steps

Start a request and leave it waiting while the native dialog is visible:

```bash
curl -sS -w '\nHTTP %{http_code}\n' \
-H "Authorization: Bearer $TOK" "http://localhost:8080$P"
```

Choose **Deny** on the Decaid device. The request must finish with:

```text
{"error":"Account access was not granted"}
HTTP 403
```

Stop the app, then start it with explicit session trust:

```bash
./flutter_with_commit.sh run -d macos --dart-define=simulate=1 \
--dart-entrypoint-args=--skin=streamline.js \
--dart-entrypoint-args=--trust-consent=skin:streamline.js
```

Fetch the new process token and repeat the request:

```bash
until curl -sf http://localhost:8080/api/v1/info >/dev/null; do sleep 1; done
TOK=$(curl -sL http://localhost:3000/ \
| sed -n 's/.*name="reaprime-proxy-token" content="\([^"]*\)".*/\1/p' \
| head -1)
status=$(curl -sS -o /tmp/decaid-consent-body -w '%{http_code}' \
-H "Authorization: Bearer $TOK" "http://localhost:8080$P")
test "$status" != "403"
cat /tmp/decaid-consent-body
```

No consent dialog should appear in the trusted run. With a valid linked account,
the response is the upstream serial-number result rather than Decaid's consent
error.

## Postconditions

Stop `flutter run` with `q`, then remove the disposable skin:

```bash
rm -rf "$TMP"
```

The deny remains scoped to the hash of that disposable path and cannot affect
an installed skin. The session trust override is gone when the process exits.
25 changes: 14 additions & 11 deletions .agents/skills/decent-app/scenarios/account-proxy-cors.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Scenario: Account-proxy CORS pinned to skin origin

Verifies the defense-in-depth CORS hardening (#301): on `/api/v1/account/proxy/*`
the `Access-Control-Allow-Origin` is **pinned** to the known skin origin(s)
(loopback + the device LAN IP, on the skin port `:3000`) instead of the global
permissive value. Non-proxy API paths keep their existing permissive CORS.
the `Access-Control-Allow-Origin` is **pinned** to the active skin origin(s)
(loopback + the device LAN IP, on the current per-generation skin port) instead
of the global permissive value. Non-proxy API paths keep their existing
permissive CORS.

The CORS headers are applied by an outer middleware that post-processes every
response on the proxy path, so the behaviour is observable **without a valid proxy
Expand All @@ -15,15 +16,17 @@ token** — an unauthenticated `401` on the proxy path still carries the pinned
```bash
scripts/sb-dev.sh start --platform macos --connect-machine MockDe1
P=/api/v1/account/proxy/support/api/sn
SKIN_PORT=$(curl -sf http://localhost:8080/api/v1/webui/server/status | jq -r '.port')
SKIN_ORIGIN="http://localhost:$SKIN_PORT"
```

## Steps

```bash
# 1. Allowed skin origin -> ACAO echoes that origin (not '*') + Vary: Origin
curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" \
curl -s -D - -o /dev/null -H "Origin: $SKIN_ORIGIN" \
"http://localhost:8080$P" | grep -iE "access-control-allow-origin|^vary"
# -> access-control-allow-origin: http://localhost:3000
# -> access-control-allow-origin: $SKIN_ORIGIN
# -> vary: Origin

# 2. Disallowed origin on the proxy path -> NO permissive ACAO at all
Expand All @@ -38,9 +41,9 @@ curl -s -D - -o /dev/null -H "Origin: http://evil.example:3000" \

# 4. OPTIONS preflight follows the same rule
curl -s -D - -o /dev/null -X OPTIONS \
-H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: GET" \
-H "Origin: $SKIN_ORIGIN" -H "Access-Control-Request-Method: GET" \
"http://localhost:8080$P" | grep -iE "access-control-allow-origin|^vary"
# -> access-control-allow-origin: http://localhost:3000 ; vary: Origin
# -> access-control-allow-origin: $SKIN_ORIGIN ; vary: Origin
curl -s -D - -o /dev/null -X OPTIONS \
-H "Origin: http://evil.example:3000" -H "Access-Control-Request-Method: GET" \
"http://localhost:8080$P" | grep -iE "access-control-allow-origin" \
Expand All @@ -50,17 +53,17 @@ curl -s -D - -o /dev/null -X OPTIONS \
One-shot assertion:

```bash
allowed=$(curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" "http://localhost:8080$P" \
allowed=$(curl -s -D - -o /dev/null -H "Origin: $SKIN_ORIGIN" "http://localhost:8080$P" \
| awk 'BEGIN{IGNORECASE=1}/access-control-allow-origin:/{print $2}' | tr -d '\r')
denied=$(curl -s -D - -o /dev/null -H "Origin: http://evil.example:3000" "http://localhost:8080$P" \
| awk 'BEGIN{IGNORECASE=1}/access-control-allow-origin:/{print $2}' | tr -d '\r')
test "$allowed" = "http://localhost:3000" || { echo "FAIL allowed: '$allowed'"; exit 1; }
test "$allowed" = "$SKIN_ORIGIN" || { echo "FAIL allowed: '$allowed'"; exit 1; }
test -z "$denied" || { echo "FAIL denied leaked: '$denied'"; exit 1; }
echo OK
```

The device LAN-IP origin (`http://<device-ip>:3000`) is also allowed — the allowlist
is rebuilt per request, so an IP learned after startup works. Loopback variants
The device LAN-IP origin (`http://<device-ip>:<skin-port>`) is also allowed. The
allowlist is rebuilt per request, so an IP learned after startup works. Loopback variants
(`127.0.0.1`, `[::1]`) are included. mDNS/`*.local` hostnames are intentionally not
in the allowlist (open question carried from the design doc).

Expand Down
15 changes: 9 additions & 6 deletions assets/api/rest_v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3927,14 +3927,15 @@ paths:
port:
type: integer
nullable: true
description: Active per-generation skin origin port
ip:
type: string
nullable: true

/api/v1/webui/server/start:
post:
summary: Start the WebUI server
description: Starts serving the default WebUI skin
description: Starts serving the default WebUI skin through the stable port 3000 entry point
tags: [WebUI]
responses:
"200":
Expand Down Expand Up @@ -5168,7 +5169,8 @@ paths:
token (injected into served skin pages) or a user-managed API-client
token. Unauthenticated/unknown tokens get 401; known but unscoped get
403; a path outside the allowed prefix gets 403; no linked account gets
401.
401. A caller's first linked-account request pauses for native consent;
denial or a 30-second timeout gets 403 before any upstream request.
tags: [Account]
parameters:
- name: endpoint
Expand All @@ -5194,7 +5196,7 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Token not scoped for account:proxy, or path not allowed
description: Token unscoped, path disallowed, or account consent not granted
content:
application/json:
schema:
Expand All @@ -5211,7 +5213,8 @@ paths:
Requires `Authorization: Bearer <token>` scoped
`account:proxy:write`. The read-only skin token cannot write — it gets
403. Other rejection rules match the GET form (401 unknown token / no
linked account; 403 unscoped or disallowed path).
linked account; 403 unscoped, disallowed path, or account consent not
granted).
tags: [Account]
parameters:
- name: endpoint
Expand Down Expand Up @@ -5245,7 +5248,7 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Token not scoped for account:proxy:write, or path not allowed
description: Token unscoped, path disallowed, or account consent not granted
content:
application/json:
schema:
Expand Down Expand Up @@ -5288,7 +5291,7 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Token not scoped for account:proxy:write, or path not allowed
description: Token unscoped, path disallowed, or account consent not granted
content:
application/json:
schema:
Expand Down
4 changes: 3 additions & 1 deletion doc/AI_BUILD_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,11 @@ The app supports several command-line flags for headless/calibration-station use
--skin=<id> # Pre-select skin by ID
--skin-path=<path> # Pre-select skin by filesystem path
--no-account # Skip DecentAccountService (headless Linux with no desktop session)
--trust-consent=<key> # Trust one account-proxy caller for this process; repeatable
--trust-all-consent # Trust every account-proxy caller for this process
```

All flags are optional. Combine as needed. `--no-account` is specifically for headless Linux stations where `libsecret` blocks on XDG secrets portal.
All flags are optional. Combine as needed. `--no-account` is specifically for headless Linux stations where `libsecret` blocks on XDG secrets portal. Consent keys use `skin:<installed-id>`, `plugin:<id>`, or `api:<token-id>`; API token labels are presentation-only. Both trust flags are session-only and are never persisted. With `flutter run`, pass each app flag separately as `--dart-entrypoint-args=<flag>`; `--dart-define` does not populate `main()` arguments.

## Dev-Loop Skill

Expand Down
2 changes: 1 addition & 1 deletion doc/Api.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ archive is also bounded by the 2 GiB import request limit.

Linking/unlinking a Decent account is **native-only** — there are no network login/logout routes. The webserver is unauthenticated with `Access-Control-Allow-Origin: *`, so exposing credential operations would let any LAN client or browser origin store attacker credentials or unlink the account. The status response omits the linked email (PII).

The **proxy** lets clients *use* the account without ever seeing the credentials: it attaches the linked account's Basic auth server-side, forwards to `decentespresso.com`, and relays the upstream status + body verbatim. It requires `Authorization: Bearer <token>` and is enforced only on this path. `GET` requires `account:proxy` (including the skin token injected into served skin pages); `POST`/`PUT` require the stronger `account:proxy:write` scope, so the read-only skin token cannot write. Forwarding is restricted to the `support/api/` prefix. The OpenAPI spec documents the generated-client-safe `/support/api/{endpoint}` form; use this raw catch-all route when a Decent backend path contains additional slashes. Responses: 401 (missing/invalid token or no linked account), 403 (token unscoped or path not allowed). Write-scoped tokens are minted from the account page's API-token UI by enabling "Allow write access".
The **proxy** lets clients *use* the account without ever seeing the credentials: it attaches the linked account's Basic auth server-side, forwards to `decentespresso.com`, and relays the upstream status + body verbatim. It requires `Authorization: Bearer <token>` and is enforced only on this path. `GET` requires `account:proxy` (including the skin token injected into served skin pages); `POST`/`PUT` require the stronger `account:proxy:write` scope, so the read-only skin token cannot write. Forwarding is restricted to the `support/api/` prefix. The OpenAPI spec documents the generated-client-safe `/support/api/{endpoint}` form; use this raw catch-all route when a Decent backend path contains additional slashes. Each served skin generation gets a fresh origin and token bound to that skin's immutable consent key; switching or stopping the skin server revokes the previous token. The stable port 3000 entry point redirects without caching to the active origin. The first request from each skin, plugin, or named API client pauses for native approval on the Decaid device. Explicit allow and deny decisions are remembered; a 30-second timeout denies only that request. Responses: 401 (missing/invalid token or no linked account), 403 (token unscoped, path not allowed, or account access not granted). Write-scoped tokens are minted from the account page's API-token UI by enabling "Allow write access". Headless operators can grant session-only access with `--trust-consent=<caller-key>` or `--trust-all-consent`.

### Other

Expand Down
2 changes: 1 addition & 1 deletion doc/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ host.storage({
**Note:** namespace is not used by Decaid internally, the plugin storage is namespaced to the plugins' identifier.

### `host.decentProxy(path, options)`
Call the Decent account proxy without exposing stored credentials to plugin code. `GET` requires the read-only `proxy.decent_api` permission. `POST` requires the distinct write permission `proxy.decent_api.write` **and** is restricted to an explicit path allowlist (currently only `support/api/shot_upload`); other methods/paths are rejected and logged.
Call the Decent account proxy without exposing stored credentials to plugin code. `GET` requires the read-only `proxy.decent_api` permission. `POST` requires the distinct write permission `proxy.decent_api.write` **and** is restricted to an explicit path allowlist (currently only `support/api/shot_upload`); other methods/paths are rejected and logged. The first request from each plugin id pauses for approval in Decaid's native UI. Explicit allow and deny decisions are remembered; denial or timeout rejects the call before any upstream request.

```javascript
const response = await host.decentProxy("support/api/sn", {
Expand Down
6 changes: 3 additions & 3 deletions doc/Skins.md
Original file line number Diff line number Diff line change
Expand Up @@ -3316,9 +3316,9 @@ http://<host>:8080/api/v1/plugins/settings.reaplugin/ui?backName=MySkin

This shows "Back to MySkin" in the settings plugin's nav bar. When clicked, it navigates to `http://<host>:3000/?_=<timestamp>` (with cache busting). This allows skins to provide a "Settings" link that returns to the skin after configuration changes.

**External links:** When a skin runs inside the embedded webview (mobile/desktop app), navigations to `localhost:3000` and the settings plugin load in place; any other `http`/`https` link opens in the **system browser** while the skin stays loaded. A plain `<a href="https://…">` works, but the in-app webview blocks `target="_blank"` popups (`javaScriptCanOpenWindowsAutomatically: false`), so for JS-driven links route through a delegated click handler `window.open(url, '_blank')` with a `location.href` fallback so the navigation reaches `shouldOverrideUrlLoading` and is handed off to the OS.
**External links:** When a skin runs inside the embedded webview (mobile/desktop app), navigations to the active skin origin, `localhost:3000`, and the settings plugin load in place; any other `http`/`https` link opens in the **system browser** while the skin stays loaded. A plain `<a href="https://…">` works, but the in-app webview blocks `target="_blank"` popups (`javaScriptCanOpenWindowsAutomatically: false`). For JS-driven links, use a delegated click handler with `window.open(url, '_blank')` and a `location.href` fallback so the navigation reaches `shouldOverrideUrlLoading` and is handed off to the OS.

**Return to the dashboard:** Skin pages served on port 3000 load the tokenless `/__decent/skin-api.js` from an absolute same-origin URL, which exposes `window.decentApp.exitToDashboard()`. ReaPrime stores the account-proxy token in escaped page metadata that only the same-origin script reads. Token injection accepts loopback and IP addresses currently assigned to the device, including Ethernet and secondary adapters; arbitrary hostnames and stale addresses are rejected. If local interface enumeration is unavailable, the WiFi address cached for the server link is used as a fallback. The script response also uses `Cross-Origin-Resource-Policy: same-origin`. In the embedded webview the callback closes the skin and reveals the Decent dashboard. In an external browser it is a no-op. The script works with `script-src 'self'`; policies that reject all same-origin scripts, such as `script-src 'none'` or nonce-only policies without `'self'`, also reject this API.
**Return to the dashboard:** Port 3000 is a stable no-store entry point that redirects to a fresh browser origin each time Decaid serves a skin. The active origin loads the tokenless `/__decent/skin-api.js` from an absolute same-origin URL, which exposes `window.decentApp.exitToDashboard()`. ReaPrime stores a newly rotated, skin-bound account-proxy token in escaped page metadata that only the same-origin script reads; switching or stopping the server revokes it. This prevents a stale skin tab from reading or using the next skin's token. Token injection accepts loopback and IP addresses currently assigned to the device, including Ethernet and secondary adapters; arbitrary hostnames and stale addresses are rejected. If local interface enumeration is unavailable, the WiFi address cached for the server link is used as a fallback. The script response also uses `Cross-Origin-Resource-Policy: same-origin`. In the embedded webview the callback closes the skin and reveals the Decent dashboard. In an external browser it is a no-op. The script works with `script-src 'self'`; policies that reject all same-origin scripts, such as `script-src 'none'` or nonce-only policies without `'self'`, also reject this API.

The embedded webview also shows a platform-specific navigation guide when a skin opens. On Windows, choose **Back to Dashboard** from the system menu, available from the window icon or by right-clicking the title bar. Disable or restore the guide in **Settings** under **General** with **Skin navigation guide**.

Expand All @@ -3327,7 +3327,7 @@ The embedded webview also shows a platform-specific navigation guide when a skin
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/webui/server/status` | Current status (`{serving, path, port, ip}`) |
| POST | `/api/v1/webui/server/start` | Start serving the default skin on port 3000 |
| POST | `/api/v1/webui/server/start` | Start serving the default skin through the port 3000 entry point |
| POST | `/api/v1/webui/server/stop` | Stop serving |
| POST | `/api/v1/webui/skins/update` | Check all remote skin sources for updates |

Expand Down
Loading