diff --git a/adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/PlatformApiToadlet.java b/adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/PlatformApiToadlet.java
index 962768515e..ea0ddf67d2 100644
--- a/adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/PlatformApiToadlet.java
+++ b/adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/PlatformApiToadlet.java
@@ -8,13 +8,16 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import network.crypta.platform.api.PlatformApiException;
import network.crypta.platform.api.PlatformApiPaths;
+import network.crypta.platform.api.PlatformApiPrincipal;
import network.crypta.platform.api.PlatformApiRequest;
import network.crypta.platform.api.PlatformApiResponse;
import network.crypta.platform.api.PlatformApiRouter;
import network.crypta.platform.appcatalog.AppCatalogManager;
import network.crypta.platform.apphost.AppHost;
+import network.crypta.platform.apphost.AppTokenPrincipal;
import network.crypta.runtime.spi.RuntimePorts;
import network.crypta.support.MultiValueTable;
import network.crypta.support.URLDecoder;
@@ -29,8 +32,9 @@
*
*
This toadlet keeps all transport-neutral routing and JSON construction inside {@code
* :platform-api}. Its responsibility is limited to enforcing the existing full-access expectation
- * for admin-facing routes, translating legacy HTTP request state into a {@link PlatformApiRequest},
- * and writing the resulting JSON back through the legacy HTTP shell.
+ * for host/operator routes, authenticating AppHost launch-token headers when present, translating
+ * legacy HTTP request state into a {@link PlatformApiRequest}, and writing the resulting JSON back
+ * through the legacy HTTP shell.
*/
@SuppressWarnings("unused")
public final class PlatformApiToadlet extends Toadlet {
@@ -50,6 +54,9 @@ public final class PlatformApiToadlet extends Toadlet {
private static final String URL_ENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded";
+ private static final String APP_TOKEN_HEADER = "x-crypta-app-token";
+ private static final String AUTHORIZATION_HEADER = "authorization";
+ private static final String BEARER_PREFIX = "bearer";
private static final String DELETE_METHOD = "DELETE";
private static final String ALERTS_SEGMENT = "alerts";
private static final String APP_CATALOGS_SEGMENT = "app-catalogs";
@@ -67,6 +74,9 @@ public final class PlatformApiToadlet extends Toadlet {
*/
private final PlatformApiRouter router;
+ /** Optional AppHost used to authenticate process-originated app launch tokens. */
+ private final AppHost appHost;
+
/**
* Creates a platform API toadlet backed by the supplied runtime ports.
*
@@ -74,8 +84,8 @@ public final class PlatformApiToadlet extends Toadlet {
*/
public PlatformApiToadlet(RuntimePorts runtimePorts) {
this(
- new PlatformApiRouter(
- runtimePorts, null, null, LegacyAdminUsageRecorder.defaultRecorder()));
+ new PlatformApiRouter(runtimePorts, null, null, LegacyAdminUsageRecorder.defaultRecorder()),
+ null);
}
/**
@@ -87,7 +97,8 @@ public PlatformApiToadlet(RuntimePorts runtimePorts) {
public PlatformApiToadlet(RuntimePorts runtimePorts, AppHost appHost) {
this(
new PlatformApiRouter(
- runtimePorts, appHost, null, LegacyAdminUsageRecorder.defaultRecorder()));
+ runtimePorts, appHost, null, LegacyAdminUsageRecorder.defaultRecorder()),
+ appHost);
}
/**
@@ -101,7 +112,8 @@ public PlatformApiToadlet(
RuntimePorts runtimePorts, AppHost appHost, AppCatalogManager appCatalogManager) {
this(
new PlatformApiRouter(
- runtimePorts, appHost, appCatalogManager, LegacyAdminUsageRecorder.defaultRecorder()));
+ runtimePorts, appHost, appCatalogManager, LegacyAdminUsageRecorder.defaultRecorder()),
+ appHost);
}
/**
@@ -114,8 +126,13 @@ public PlatformApiToadlet(
* @param router transport-neutral router that handles request validation and response generation
*/
PlatformApiToadlet(PlatformApiRouter router) {
+ this(router, null);
+ }
+
+ PlatformApiToadlet(PlatformApiRouter router, AppHost appHost) {
super();
this.router = Objects.requireNonNull(router, "router");
+ this.appHost = appHost;
}
@Override
@@ -269,11 +286,13 @@ private void writePlatformApiResponse(
/**
* Routes a request through the Platform API and writes either a full or header-only reply.
*
- *
Legacy HTTP integration keeps full-access enforcement at the bridge boundary. Mutating
- * requests then pass through the legacy form-password check before the bridge converts them into
- * a transport-neutral {@link PlatformApiRequest} and delegates to the router. Unexpected runtime
- * failures are converted into a structured {@code 500} response so callers keep the Platform API
- * JSON contract even when the bridge logs the underlying error.
+ *
Legacy HTTP integration keeps host/operator full-access enforcement at the bridge boundary.
+ * Mutating host/operator requests then pass through the legacy form-password check before the
+ * bridge converts them into a transport-neutral {@link PlatformApiRequest}. App-token requests
+ * skip that host/operator form guard and carry a token-free app principal into the router, where
+ * manifest capability enforcement happens centrally. Unexpected runtime failures are converted
+ * into a structured {@code 500} response so callers keep the Platform API JSON contract even when
+ * the bridge logs the underlying error.
*
* @param method HTTP method name forwarded into the Platform API router
* @param uri request target supplied by the legacy HTTP shell
@@ -288,17 +307,8 @@ private void writePlatformApiResponse(
String method, URI uri, HTTPRequest request, ToadletContext ctx, boolean includeBody)
throws ToadletContextClosedException, IOException {
PlatformApiResponse response;
- if (!ctx.isAllowedFullAccess()) {
- response = PlatformApiResponse.error(403, "forbidden", "Full access is required.");
- writeJsonReply(ctx, response, includeBody);
- return;
- }
- if (!authorizeMutationRequest(method, uri, request, ctx)) {
- return;
- }
-
try {
- response = router.route(toPlatformApiRequest(method, uri, request));
+ response = resolveAndRoutePlatformApiRequest(method, uri, request, ctx);
} catch (URLEncodedFormatException _) {
response =
PlatformApiResponse.error(
@@ -310,9 +320,48 @@ private void writePlatformApiResponse(
response =
PlatformApiResponse.error(500, "internal_error", "Unexpected platform API failure.");
}
+ if (response == null) {
+ return;
+ }
writeJsonReply(ctx, response, includeBody);
}
+ /**
+ * Resolves the request principal, enforces bridge-local host guards, and routes the API request.
+ *
+ *
The caller wraps this method in the Platform API error guard so unexpected failures in token
+ * authentication, host access checks, form-password checks, request conversion, or router
+ * dispatch all produce the same structured JSON error contract.
+ *
+ * @param method HTTP method name forwarded into the Platform API router
+ * @param uri request target supplied by the legacy HTTP shell
+ * @param request decoded legacy HTTP request wrapper
+ * @param ctx current toadlet context used for access checks
+ * @return Platform API response to write, or {@code null} when a bridge-local guard already wrote
+ * the response
+ * @throws ToadletContextClosedException if the client disconnects while a guard response is sent
+ * @throws IOException if the legacy HTTP shell fails while writing a guard response
+ * @throws URLEncodedFormatException if the request path contains malformed percent-encoding
+ */
+ private PlatformApiResponse resolveAndRoutePlatformApiRequest(
+ String method, URI uri, HTTPRequest request, ToadletContext ctx)
+ throws ToadletContextClosedException, IOException, URLEncodedFormatException {
+ PrincipalResolution principalResolution = resolvePrincipal(request);
+ if (principalResolution.failureResponse() != null) {
+ return principalResolution.failureResponse();
+ }
+ PlatformApiPrincipal principal = principalResolution.principal();
+
+ if (!principal.isApp() && !ctx.isAllowedFullAccess()) {
+ return PlatformApiResponse.error(403, "forbidden", "Full access is required.");
+ }
+ if (!principal.isApp() && !authorizeMutationRequest(method, uri, request, ctx)) {
+ return null;
+ }
+
+ return router.route(toPlatformApiRequest(method, uri, request, principal));
+ }
+
/**
* Returns whether the current request targets one of the mutating app-management routes.
*
@@ -603,7 +652,8 @@ private boolean authorizeMutationRequest(
* @return immutable Platform API request built from the method, relative path, and query values
* @throws URLEncodedFormatException if the request path contains malformed percent-encoding
*/
- private PlatformApiRequest toPlatformApiRequest(String method, URI uri, HTTPRequest request)
+ private PlatformApiRequest toPlatformApiRequest(
+ String method, URI uri, HTTPRequest request, PlatformApiPrincipal principal)
throws URLEncodedFormatException {
LinkedHashMap> queryParameters =
LinkedHashMap.newLinkedHashMap(request.getParameterNames().size());
@@ -614,9 +664,109 @@ private PlatformApiRequest toPlatformApiRequest(String method, URI uri, HTTPRequ
queryParameters.put(parameterName, List.of(request.getMultipleParam(parameterName)));
}
copyBodyParametersIfPresent(request, queryParameters);
- return new PlatformApiRequest(method, relativeApiPath(requestPath(uri)), queryParameters);
+ return new PlatformApiRequest(
+ method, relativeApiPath(requestPath(uri)), queryParameters, principal);
+ }
+
+ private PrincipalResolution resolvePrincipal(HTTPRequest request) {
+ String directToken = directAppTokenFromHeader(request);
+ if (directToken != null) {
+ return authenticateExplicitAppToken(directToken);
+ }
+
+ String bearerToken = bearerTokenFromAuthorization(request);
+ if (bearerToken == null) {
+ return hostOperatorPrincipal();
+ }
+ return authenticateBearerTokenOrHostOperator(bearerToken);
}
+ private static PrincipalResolution unauthenticatedAppToken(String message) {
+ return new PrincipalResolution(
+ null, PlatformApiResponse.error(401, "invalid_app_token", message));
+ }
+
+ private static PrincipalResolution hostOperatorPrincipal() {
+ return new PrincipalResolution(PlatformApiPrincipal.hostOperator(), null);
+ }
+
+ /**
+ * Authenticates a token from the app-specific header.
+ *
+ * {@code X-Crypta-App-Token} is an explicit app-token assertion, so blank, stale, unknown, or
+ * unavailable token validation returns a structured authentication failure.
+ *
+ * @param token token value after header normalization
+ * @return app principal for a live token, or an authentication failure response
+ */
+ private PrincipalResolution authenticateExplicitAppToken(String token) {
+ if (appHost == null) {
+ return unauthenticatedAppToken("App token authentication is unavailable.");
+ }
+ Optional appPrincipal = appHost.authenticateLaunchToken(token);
+ if (appPrincipal.isEmpty()) {
+ return unauthenticatedAppToken("Invalid app token.");
+ }
+ return appTokenPrincipal(appPrincipal.get());
+ }
+
+ /**
+ * Authenticates a Bearer token when it matches a live app token.
+ *
+ * {@code Authorization: Bearer} is intentionally opportunistic because the same header is also
+ * used by reverse proxies and shared HTTP client configurations for unrelated host/operator
+ * credentials. A matching live AppHost token becomes an app principal. A non-matching Bearer
+ * value keeps the existing host/operator path instead of converting the request into a failed
+ * app-token attempt. Lookup failures are allowed to propagate to the guarded Platform API error
+ * path so a token-bearing request cannot silently widen into a host/operator principal when
+ * AppHost state is unavailable.
+ *
+ * @param token bearer token value after header normalization
+ * @return app principal for a live token, otherwise the host/operator principal
+ */
+ private PrincipalResolution authenticateBearerTokenOrHostOperator(String token) {
+ if (appHost == null) {
+ return hostOperatorPrincipal();
+ }
+ Optional appPrincipal = appHost.authenticateLaunchToken(token);
+ if (appPrincipal.isEmpty()) {
+ return hostOperatorPrincipal();
+ }
+ return appTokenPrincipal(appPrincipal.get());
+ }
+
+ private static PrincipalResolution appTokenPrincipal(AppTokenPrincipal principal) {
+ return new PrincipalResolution(
+ PlatformApiPrincipal.appToken(principal.appId(), principal.permissions()), null);
+ }
+
+ private static String directAppTokenFromHeader(HTTPRequest request) {
+ String directToken = request.getHeader(APP_TOKEN_HEADER);
+ return directToken == null ? null : directToken.trim();
+ }
+
+ private static String bearerTokenFromAuthorization(HTTPRequest request) {
+ String authorization = request.getHeader(AUTHORIZATION_HEADER);
+ if (authorization == null) {
+ return null;
+ }
+ String trimmed = authorization.trim();
+ if (trimmed.length() < BEARER_PREFIX.length()
+ || !trimmed.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) {
+ return null;
+ }
+ if (trimmed.length() == BEARER_PREFIX.length()) {
+ return "";
+ }
+ if (!Character.isWhitespace(trimmed.charAt(BEARER_PREFIX.length()))) {
+ return null;
+ }
+ return trimmed.substring(BEARER_PREFIX.length()).trim();
+ }
+
+ private record PrincipalResolution(
+ PlatformApiPrincipal principal, PlatformApiResponse failureResponse) {}
+
/**
* Copies body-backed form values into the query-like parameter map when present.
*
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index c742d47d84..f26c2757b5 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -77,11 +77,24 @@ data/cache/run directories, and a per-launch `CRYPTAD_APP_TOKEN` for app-origina
authentication. It does not currently provide containers, WASM isolation, seccomp, chroot, jails,
Windows Job Object restrictions, network isolation, or browser-scoped app sessions.
+App-originated Platform API calls authenticate with the launch token in `X-Crypta-App-Token`.
+`Authorization: Bearer` is accepted only when the Bearer value matches a live app token; unrelated
+Bearer credentials continue through the host/operator path so reverse proxies and shared clients do
+not accidentally convert local management requests into failed app-token attempts. Valid app
+principals are denied by default unless the route is covered by manifest-declared capabilities such
+as `queue.read`, `queue.write`, or `content.insert`. Invalid or stale `X-Crypta-App-Token` values
+fail authentication, and missing capabilities fail authorization without echoing the token.
+
Runtime status and process-log Platform API responses must remain token-free and path-free. Log
tail responses redact the current launch token and obvious `CRYPTAD_APP_TOKEN=...` text before
returning app output to the Web Shell. This is defense in depth for operator visibility; it is not a
general secret scanner for arbitrary app output.
+App-originated allowed and denied Platform API decisions are recorded in a bounded process-local
+audit log. Audit events keep route family, action, required capabilities, decision, status, and a
+short reason code. They must not include raw launch tokens, query strings, request bodies, form
+passwords, or filesystem paths.
+
First-party static app UIs can fetch `/apps/{appId}/.well-known/cryptad-bootstrap.json` to discover
local route roots and the existing local-admin form password used for mutating Platform API calls.
That bootstrap is host/operator scoped. It must not contain `CRYPTAD_APP_TOKEN`, AppHost launch
@@ -93,5 +106,7 @@ Treat a bypass that serves host files, follows symlink escapes, executes JavaScr
UI has JavaScript disabled, exposes AppHost launch tokens to browser code, or allows bundled UI to
exfiltrate operator-entered data off-node as security-relevant.
-For the detailed runtime boundary, exposed environment variables, restart policy semantics, and
-remaining limitations, see [apphost-runtime-hardening.md](apphost-runtime-hardening.md).
+For the detailed runtime boundary, exposed environment variables, restart policy semantics,
+permission matrix, audit model, and remaining limitations, see
+[apphost-runtime-hardening.md](apphost-runtime-hardening.md) and
+[app-permissions-and-audit.md](app-permissions-and-audit.md).
diff --git a/docs/app-catalogs.md b/docs/app-catalogs.md
index d5ca14a349..fbe5d1f5f0 100644
--- a/docs/app-catalogs.md
+++ b/docs/app-catalogs.md
@@ -116,5 +116,6 @@ and security boundary.
## Future work
-This PR does not add permission enforcement, container or WASM sandboxing, public app-store
-governance, or background app update scheduling.
+Manifest permissions are enforced for app-process Platform API calls as described in
+[app-permissions-and-audit.md](app-permissions-and-audit.md). Container or WASM sandboxing, public
+app-store governance, and background app update scheduling remain future work.
diff --git a/docs/app-owned-ui.md b/docs/app-owned-ui.md
index a163c259d6..3e15519439 100644
--- a/docs/app-owned-ui.md
+++ b/docs/app-owned-ui.md
@@ -86,10 +86,11 @@ App-owned UI uses stable URLs across reinstall and update operations. Responses
with non-public no-cache headers instead of the legacy admin adapter's long-lived static cache
policy.
-The route remains same-origin with the local admin UI and Platform API. Permission enforcement,
-stronger isolation, and audit logging belong to later platform work. Until stronger browser
-isolation exists, install static UI bundles only from sources trusted to run JavaScript in the local
-admin origin.
+The route remains same-origin with the local admin UI and Platform API. Static browser UI does not
+receive `CRYPTAD_APP_TOKEN` and cannot authenticate as the app process in this model. App
+permission enforcement and audit apply to process-originated Platform API calls that present the
+launch token. Stronger browser isolation remains later platform work. Until that exists, install
+static UI bundles only from sources trusted to run JavaScript in the local admin origin.
## First-party app bootstrap
@@ -163,6 +164,8 @@ Runtime hardening APIs are separate from the app summary:
```text
GET /api/v1/apps/{appId}/runtime
GET /api/v1/apps/{appId}/logs?maxBytes=65536
+GET /api/v1/apps/{appId}/permissions
+GET /api/v1/apps/{appId}/audit
```
The runtime endpoint reports process state, PID, start time, last exit metadata, restart attempt
@@ -173,6 +176,11 @@ installed/data/cache/run filesystem paths.
The Web Shell uses those endpoints for operator visibility. Static app UIs must not treat the
runtime endpoint as proof of app-level health; it is process status only.
+The permissions and audit endpoints expose manifest-declared permissions, recent app-originated
+Platform API decisions, and retained denied-call counts. Audit entries are bounded and
+process-local, and they omit tokens, query strings, request bodies, form passwords, and filesystem
+paths.
+
## Examples
Static UI bundle:
diff --git a/docs/app-permissions-and-audit.md b/docs/app-permissions-and-audit.md
new file mode 100644
index 0000000000..5f83468224
--- /dev/null
+++ b/docs/app-permissions-and-audit.md
@@ -0,0 +1,118 @@
+# App permissions and audit
+
+This document describes how Cryptad authorizes app-originated Platform API requests and records
+recent app decisions.
+
+## Scope
+
+The app permission boundary applies to local AppHost child processes that authenticate with their
+current launch token. It does not add containers, WASM isolation, seccomp, chroot, browser-scoped
+app sessions, FCP changes, wire-protocol changes, or persistent compliance-grade audit storage.
+
+Host/operator Web Shell requests keep the existing local-admin model. They do not need app
+permissions, and they are not recorded in the app audit log.
+
+## Process token authentication
+
+Every AppHost launch receives a fresh opaque `CRYPTAD_APP_TOKEN` in the child process environment.
+The token is valid only while that specific app run is currently tracked as live. Blank, unknown,
+stopped, or stale tokens do not authenticate. Restarting an app creates a new token; the old token
+stops working.
+
+An app process can present the token to Platform API v1 with either header:
+
+```text
+X-Crypta-App-Token:
+Authorization: Bearer
+```
+
+`X-Crypta-App-Token` is the explicit app-token header: blank, unknown, stopped, or stale values
+fail authentication with `401 invalid_app_token`. `Authorization: Bearer` is opportunistic for
+clients that already use Bearer token plumbing. If the Bearer value matches a live app token, the
+request becomes an app principal; otherwise it remains a normal host/operator request so unrelated
+Bearer credentials from proxies or shared client configuration do not break existing management
+flows.
+
+Tokens are not read from query parameters. Static app UI bootstrap JSON, Web Shell bootstrap JSON,
+app summaries, runtime status, process-log responses, audit entries, diagnostic strings, and
+`toString()` output must not expose raw launch tokens.
+
+## App principals
+
+When a token authenticates, the Platform API receives a token-free app principal:
+
+- app id;
+- manifest-declared permissions;
+- authentication source `APP_TOKEN`.
+
+The principal does not carry the raw token. The permission list is immutable and sorted before the
+router checks it.
+
+## Capability checks
+
+App principals are denied by default. A request must match the central route-to-capability matrix,
+and the app principal must include every required capability. A valid app token without the
+required capability receives `403 Forbidden`. An invalid or stale app token receives
+`401 Unauthorized`.
+
+The current capabilities are intentionally conservative:
+
+| Capability | Example covered routes |
+| --- | --- |
+| `node.read` | `GET /api/v1/node/**` |
+| `connectivity.read` | `GET /api/v1/connectivity` |
+| `queue.read` | `GET /api/v1/queue/**` |
+| `queue.write` | queue download creation, request mutation, cleanup |
+| `content.insert` | local file/directory insert routes, together with `queue.write` |
+| `peers.read` | `GET /api/v1/peers/**` |
+| `peers.write` | peer add, settings, note, removal |
+| `config.read` | `GET /api/v1/config` |
+| `config.write` | config overrides and persist |
+| `security.read` | `GET /api/v1/security-levels/**` |
+| `security.write` | network and physical threat-level mutations |
+| `updates.read` | `GET /api/v1/updates/**` |
+| `updates.write` | core update download trigger |
+| `wizard.read` | `GET /api/v1/wizard/**` |
+| `wizard.write` | first-time wizard apply |
+| `alerts.read` | `GET /api/v1/alerts` |
+| `alerts.write` | alert dismiss |
+| `diagnostics.read` | `GET /api/v1/diagnostics` |
+| `apps.read` | app inventory, runtime, logs, permissions, audit reads |
+| `apps.manage` | app install, start, stop, update, uninstall |
+| `catalogs.read` | catalog and catalog-app reads |
+| `catalogs.manage` | catalog add/remove/refresh and catalog app install/update |
+
+## Audit trail
+
+The Platform API records app-originated allowed and denied authorization decisions in a bounded
+process-local audit log. The default bound is the most recent 512 events per router instance. When
+the log is full, the oldest entries are dropped.
+
+Each event records:
+
+- timestamp;
+- app id;
+- request method;
+- endpoint family;
+- route/action label;
+- required capability names;
+- decision;
+- HTTP status;
+- short reason code.
+
+Audit entries do not record raw launch tokens, full query strings, request bodies, form passwords,
+local filesystem paths, or large payloads. The log is useful for operator visibility and debugging;
+it is not durable evidence storage.
+
+## Operator surfaces
+
+The Apps API exposes declared permissions and recent audit data:
+
+```text
+GET /api/v1/apps/{appId}/permissions
+GET /api/v1/apps/{appId}/audit
+```
+
+Installed app summaries also include a small audit object and a retained denied-count snapshot.
+The Web Shell displays declared permissions, recent app-originated audit events, and denied-call
+counts on installed app cards.
diff --git a/docs/apphost-runtime-hardening.md b/docs/apphost-runtime-hardening.md
index ea26329dce..0e48cf15bb 100644
--- a/docs/apphost-runtime-hardening.md
+++ b/docs/apphost-runtime-hardening.md
@@ -42,9 +42,17 @@ CRYPTAD_APP_UI_MODE
CRYPTAD_APP_UI_ENTRY # only when the manifest declares a UI entry
```
-`CRYPTAD_APP_TOKEN` is for app-originated Platform API authentication. It is injected only into the
-child process environment. It is not exposed to static browser UI, Web Shell bootstrap JSON, app
-API summaries, runtime status JSON, or process-log tail responses.
+`CRYPTAD_APP_TOKEN` is for process-originated Platform API authentication. It is injected only into
+the child process environment. App processes should present it with `X-Crypta-App-Token`; the bridge
+also accepts `Authorization: Bearer` when the Bearer value matches a live app token. Unrelated
+Bearer credentials stay on the host/operator path. The token is not exposed to static browser UI,
+Web Shell bootstrap JSON, app API summaries, runtime status JSON, process-log tail responses, or app
+audit entries.
+
+The token authenticates only the currently running app instance. A stopped app or a previous run's
+token does not authenticate. The verified app principal contains the app id and manifest
+permissions, never the token itself. See [app-permissions-and-audit.md](app-permissions-and-audit.md)
+for the capability matrix and audit surface.
AppHost does not inject daemon datastore paths, trusted-key files, catalog roots, signing material,
or the daemon's current working directory. The child process working directory is the installed app
@@ -88,6 +96,10 @@ Process-log tailing is bounded. The default is small, and AppHost clamps oversiz
hard maximum. Missing logs return a stable unavailable snapshot. Log responses include text and
metadata only; they do not include the runtime log path.
+App-originated Platform API decisions are recorded separately in a bounded process-local audit log.
+The Apps API exposes recent entries through `GET /api/v1/apps/{appId}/audit`; those entries omit
+tokens, query strings, request bodies, form passwords, and filesystem paths.
+
Before returning process-log text, AppHost redacts:
- the exact current launch token when the app is running;
diff --git a/docs/phase-3-platform-primacy-closeout.md b/docs/phase-3-platform-primacy-closeout.md
index 0b7171d458..185948d7df 100644
--- a/docs/phase-3-platform-primacy-closeout.md
+++ b/docs/phase-3-platform-primacy-closeout.md
@@ -156,7 +156,8 @@ Phase 4 candidates were plans, not PR-194 implementation scope. Current status:
- App-owned static UI routes have landed for installed static bundles; stronger isolation remains
future work.
- Broader first-party app catalog remains future work.
-- Better app permission enforcement and audit surfaces remain future work.
+- App permission enforcement and app-origin audit landed after this Phase 3 closeout; see
+ [app-permissions-and-audit.md](app-permissions-and-audit.md).
- Legacy admin and browse retirement plan.
- Portable full-node Hyphanet baseline artifacts for macOS and Windows.
- Lightweight performance and regression smoke coverage now lives under
diff --git a/docs/platform-api-surface.md b/docs/platform-api-surface.md
index 727eea3b8b..7351051f65 100644
--- a/docs/platform-api-surface.md
+++ b/docs/platform-api-surface.md
@@ -44,14 +44,23 @@ Current error handling uses HTTP-style status codes:
| Status | Current use |
| --- | --- |
| `400 Bad Request` | Malformed paths, malformed percent-encoding, or invalid query/form values. |
+| `401 Unauthorized` | App-token authentication failed because a supplied launch token is invalid, blank, stale, or unavailable. |
| `403 Forbidden` | The legacy bridge rejects a caller without full access. |
| `404 Not Found` | Unknown routes or missing resources. |
| `405 Method Not Allowed` | A known route was called with the wrong method; the response includes `Allow`. |
| `409 Conflict` | Stateful conflicts, confirmation requirements, or temporarily unavailable control paths. |
| `500 Internal Server Error` | Unexpected failures after routing. |
-Mutating routes still rely on the legacy admin bridge for full-access checks and the form-password
-guard. Current Web Shell mutations submit URL-encoded form data.
+Host/operator mutating routes still rely on the legacy admin bridge for full-access checks and the
+form-password guard. Current Web Shell mutations submit URL-encoded form data.
+
+App processes can authenticate with the per-launch `CRYPTAD_APP_TOKEN` injected by AppHost. The
+legacy HTTP bridge accepts the token in `X-Crypta-App-Token`; it also accepts Bearer credentials in
+the `Authorization` header only when the Bearer value verifies as a live app token. Query parameters
+are ignored for token authentication, and unrelated Bearer credentials remain host/operator
+requests. Authenticated app requests are checked against manifest-declared capabilities before
+routing and are denied by default. See [app-permissions-and-audit.md](app-permissions-and-audit.md)
+for the matrix and audit model.
## Endpoint families
@@ -70,7 +79,7 @@ parameter; check the handler and tests when adding or changing a specific contra
| Wizard/welcome | `GET /api/v1/wizard/first-time` exposes the detached first-time setup snapshot. `POST /api/v1/wizard/first-time/apply` submits the shell-native onboarding/reset model. There is no separate `/welcome` Platform API family in Phase 3; welcome-page fallback behavior remains on the legacy/admin side. |
| Alerts | `GET /api/v1/alerts` lists current alerts. `POST /api/v1/alerts/{alertId}/dismiss` dismisses one alert by detached identifier. |
| Diagnostics | `GET /api/v1/diagnostics` exposes the ordered diagnostic snapshot and plain-text export. When served through the legacy HTTP bridge, it also includes process-local `legacyAdmin.surfaces[]` counters for legacy admin retirement planning. |
-| Apps | `GET /api/v1/apps` lists installed apps when `AppHost` is wired into the router. The family also covers local staged-bundle install, app lookup, start, stop, update, uninstall, token-free runtime status at `GET /api/v1/apps/{appId}/runtime`, and bounded token-redacted process logs at `GET /api/v1/apps/{appId}/logs`. |
+| Apps | `GET /api/v1/apps` lists installed apps when `AppHost` is wired into the router. The family also covers local staged-bundle install, app lookup, start, stop, update, uninstall, declared permissions at `GET /api/v1/apps/{appId}/permissions`, recent app audit at `GET /api/v1/apps/{appId}/audit`, token-free runtime status at `GET /api/v1/apps/{appId}/runtime`, and bounded token-redacted process logs at `GET /api/v1/apps/{appId}/logs`. |
| App catalogs | `GET /api/v1/app-catalogs` lists configured signed catalogs when catalog support is wired into the router. The family also covers source add/remove, refresh, catalog app listing/detail, and install/update from a verified catalog artifact. |
## Web Shell relationship
diff --git a/platform-api/src/main/java/network/crypta/platform/api/AppAuditDecision.java b/platform-api/src/main/java/network/crypta/platform/api/AppAuditDecision.java
new file mode 100644
index 0000000000..3b72666152
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/AppAuditDecision.java
@@ -0,0 +1,44 @@
+package network.crypta.platform.api;
+
+/**
+ * High-level authorization outcome for one app-originated Platform API attempt.
+ *
+ * The audit log stores this value after the bridge and router have decided whether a process
+ * that presented an app launch token may reach an endpoint handler. The enum is intentionally
+ * small: it captures the operator-relevant decision without storing request bodies, headers, raw
+ * tokens, or other transport details. Downstream JSON projections can therefore show the recent
+ * security posture of an app while keeping bearer credentials out of diagnostics and browser
+ * surfaces.
+ *
+ *
Most events are produced by the router after it maps a route to a capability. The
+ * authentication-failure value is reserved for callers that can detect a token-bearing request
+ * before a token-free app principal exists.
+ */
+public enum AppAuditDecision {
+ /**
+ * The app principal carried every capability required by the matched Platform API action.
+ *
+ *
An allowed event means the request passed the capability gate and was dispatched to the
+ * endpoint family. The final HTTP status may still be a validation or runtime error from the
+ * handler; the decision records authorization, not business-operation success.
+ */
+ ALLOWED,
+
+ /**
+ * The app principal authenticated successfully but lacked a required capability.
+ *
+ *
Denied events are recorded before handler dispatch. They are suitable for Web Shell
+ * summaries such as recent denied counts because they represent policy enforcement rather than
+ * malformed input inside an otherwise authorized endpoint.
+ */
+ DENIED,
+
+ /**
+ * Token authentication failed before the request could be represented as an app principal.
+ *
+ *
This decision is for transport layers that choose to audit failed bearer-token attempts.
+ * Events using it must still avoid recording the raw token and may omit the app id when no
+ * trusted identity was established.
+ */
+ AUTH_FAILED
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/AppAuditEvent.java b/platform-api/src/main/java/network/crypta/platform/api/AppAuditEvent.java
new file mode 100644
index 0000000000..7950aaacd2
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/AppAuditEvent.java
@@ -0,0 +1,61 @@
+package network.crypta.platform.api;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Token-free audit record for one app-originated Platform API decision.
+ *
+ *
Events deliberately keep only route-family metadata and capability names. Query parameters,
+ * request bodies, launch tokens, local filesystem paths, and peer/request identifiers are excluded
+ * so the process-local audit trail is useful for operators without becoming a secondary secret
+ * store.
+ *
+ *
The record is a snapshot value. Its capability list is copied on construction, and callers
+ * receive the record through bounded log snapshots rather than a mutable live view. Keep the fields
+ * coarse when adding new audit uses: the event should explain which policy gate fired, not replay
+ * enough request detail to reconstruct sensitive operator or app data.
+ *
+ * @param timestamp UTC timestamp captured when the log accepted the event
+ * @param appId normalized app id when known, or {@code null} before identity exists
+ * @param method HTTP-style method name after bridge normalization, such as {@code GET}
+ * @param endpointFamily top-level Platform API endpoint family used for grouping
+ * @param action short deterministic route label selected by the capability matrix
+ * @param requiredCapabilities immutable capabilities required for the selected action
+ * @param decision authorization outcome recorded for operator-facing audit summaries
+ * @param statusCode HTTP-style status returned for the decision or completed request
+ * @param reasonCode stable machine-readable reason such as {@code missing_capability}
+ */
+public record AppAuditEvent(
+ Instant timestamp,
+ String appId,
+ String method,
+ String endpointFamily,
+ String action,
+ List requiredCapabilities,
+ AppAuditDecision decision,
+ int statusCode,
+ String reasonCode) {
+ /**
+ * Creates a validated token-free audit event.
+ *
+ * The constructor accepts a {@code null} app id only for authentication-failure cases where no
+ * app identity has been established. All other fields are required because Web Shell and Apps API
+ * projections depend on them for stable display and filtering. The capability list is copied so a
+ * caller cannot mutate retained audit history after recording an event.
+ *
+ * @throws NullPointerException if timestamp, method, action metadata, decision, or reason is
+ * {@code null}
+ */
+ public AppAuditEvent {
+ Objects.requireNonNull(timestamp, "timestamp");
+ Objects.requireNonNull(method, "method");
+ Objects.requireNonNull(endpointFamily, "endpointFamily");
+ Objects.requireNonNull(action, "action");
+ requiredCapabilities =
+ List.copyOf(Objects.requireNonNull(requiredCapabilities, "requiredCapabilities"));
+ Objects.requireNonNull(decision, "decision");
+ Objects.requireNonNull(reasonCode, "reasonCode");
+ }
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/AppAuditLog.java b/platform-api/src/main/java/network/crypta/platform/api/AppAuditLog.java
new file mode 100644
index 0000000000..20e155caef
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/AppAuditLog.java
@@ -0,0 +1,183 @@
+package network.crypta.platform.api;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Bounded process-local audit log for app-originated Platform API decisions.
+ *
+ *
The log is intentionally in-memory only. It keeps the most recent {@link #DEFAULT_CAPACITY}
+ * events by default and drops the oldest entries when full.
+ *
+ *
This type is owned by a {@link PlatformApiRouter} instance and is not a durable compliance
+ * store. It exists to give the Web Shell and Apps API a recent, token-free view of capability
+ * enforcement for currently managed apps. All mutating and snapshot methods synchronize on the log
+ * instance, so callers can record and read from different request threads without seeing partial
+ * updates. The trade-off is simple bounded retention rather than persistence, indexing, or
+ * cross-process aggregation.
+ */
+public final class AppAuditLog {
+ /**
+ * Default maximum number of audit events retained by one router instance.
+ *
+ *
The value is intentionally small enough for cheap in-memory snapshots while still giving
+ * operators useful recent context. Older events are discarded silently when this bound is
+ * exceeded.
+ */
+ public static final int DEFAULT_CAPACITY = 512;
+
+ /**
+ * Default number of recent events returned for one app through the Apps API.
+ *
+ *
The Apps API uses this value for per-app detail views so the browser receives a concise
+ * recent-history slice instead of the full process-local ring buffer.
+ */
+ public static final int DEFAULT_APP_EVENT_LIMIT = 25;
+
+ private final int capacity;
+ private final Clock clock;
+ private final Deque events = new ArrayDeque<>();
+
+ /**
+ * Creates an audit log using the default capacity and system UTC clock.
+ *
+ * This constructor is the normal production path. Tests that need deterministic timestamps can
+ * use the explicit constructor with a fixed {@link Clock}.
+ */
+ public AppAuditLog() {
+ this(DEFAULT_CAPACITY, Clock.systemUTC());
+ }
+
+ /**
+ * Creates an audit log with an explicit capacity and clock.
+ *
+ *
The supplied capacity applies to the whole log, not to each app. When the log is full,
+ * recording one more event removes the oldest retained event regardless of app id. This keeps
+ * memory use fixed and predictable for long-running daemons.
+ *
+ * @param capacity maximum retained events across all app principals
+ * @param clock event timestamp source used when router decisions are recorded
+ * @throws IllegalArgumentException if {@code capacity} is zero or negative
+ */
+ public AppAuditLog(int capacity, Clock clock) {
+ if (capacity <= 0) {
+ throw new IllegalArgumentException("capacity must be positive");
+ }
+ this.capacity = capacity;
+ this.clock = Objects.requireNonNull(clock, "clock");
+ }
+
+ /**
+ * Appends one completed authorization decision.
+ *
+ *
The event is appended to the newest end of the log. If appending exceeds the configured
+ * capacity, the oldest retained events are dropped until the bound is restored. The method stores
+ * the already constructed event as a value object; callers should build events without raw
+ * tokens, request bodies, or local filesystem paths.
+ *
+ * @param event token-free audit event to append to the bounded log
+ */
+ public synchronized void append(AppAuditEvent event) {
+ events.addLast(Objects.requireNonNull(event, "event"));
+ while (events.size() > capacity) {
+ events.removeFirst();
+ }
+ }
+
+ /**
+ * Appends an app-originated router decision.
+ *
+ *
Host/operator requests are ignored because this log is scoped to app-originated security
+ * decisions. For app principals, the method builds a token-free event from the request principal,
+ * selected action, HTTP-style status, and stable reason code. If no action is available, it falls
+ * back to an unmapped route label so denied default-deny cases are still visible.
+ *
+ * @param request authenticated request metadata already stripped of raw credentials
+ * @param authorization capability decision produced before endpoint dispatch
+ * @param decision audit decision to record for the app-originated request
+ * @param statusCode HTTP-style status code returned to the caller
+ * @param reasonCode stable machine-readable reason for the decision
+ */
+ void appendDecision(
+ PlatformApiRequest request,
+ PlatformApiAuthorizationDecision authorization,
+ AppAuditDecision decision,
+ int statusCode,
+ String reasonCode) {
+ if (!request.principal().isApp()) {
+ return;
+ }
+ PlatformApiAction action =
+ authorization.optionalAction().orElseGet(() -> fallbackAction(request));
+ append(
+ new AppAuditEvent(
+ Instant.now(clock),
+ request.principal().appId(),
+ request.method(),
+ action.endpointFamily(),
+ action.label(),
+ action.requiredCapabilities(),
+ decision,
+ statusCode,
+ reasonCode));
+ }
+
+ /**
+ * Returns recent events for one app in newest-first order.
+ *
+ *
The method scans the bounded log from newest to oldest and returns only events whose app id
+ * exactly matches the supplied id. A non-positive limit returns an empty list. The returned list
+ * is an immutable snapshot; subsequent log writes do not change it.
+ *
+ * @param appId normalized app id to match against retained events
+ * @param limit maximum number of matching events to return
+ * @return immutable newest-first event snapshot for the requested app
+ */
+ public synchronized List recentForApp(String appId, int limit) {
+ Objects.requireNonNull(appId, "appId");
+ if (limit <= 0) {
+ return List.of();
+ }
+ ArrayList matches = new ArrayList<>(Math.min(limit, events.size()));
+ var descending = events.descendingIterator();
+ while (descending.hasNext() && matches.size() < limit) {
+ AppAuditEvent event = descending.next();
+ if (appId.equals(event.appId())) {
+ matches.add(event);
+ }
+ }
+ return List.copyOf(matches);
+ }
+
+ /**
+ * Counts currently retained denied decisions for one app.
+ *
+ * The count is derived only from events that still fit inside the bounded log. It is intended
+ * for recent operator context in app cards, not as an all-time denied-request counter.
+ *
+ * @param appId normalized app id to match against retained events
+ * @return denied event count within the current bounded in-memory log
+ */
+ public synchronized long deniedCountForApp(String appId) {
+ Objects.requireNonNull(appId, "appId");
+ return events.stream()
+ .filter(event -> appId.equals(event.appId()))
+ .filter(event -> event.decision() == AppAuditDecision.DENIED)
+ .count();
+ }
+
+ synchronized int size() {
+ return events.size();
+ }
+
+ private static PlatformApiAction fallbackAction(PlatformApiRequest request) {
+ List segments = request.pathSegments();
+ String endpointFamily = segments.isEmpty() ? "unknown" : segments.getFirst();
+ return PlatformApiAction.of(endpointFamily, endpointFamily + ".unmapped", List.of("unmapped"));
+ }
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAction.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAction.java
new file mode 100644
index 0000000000..a4fe4588f7
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAction.java
@@ -0,0 +1,93 @@
+package network.crypta.platform.api;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.TreeSet;
+
+/**
+ * Capability-protected Platform API action selected from request method and route shape.
+ *
+ * An action is the router-independent authorization description for one request shape. The
+ * capability matrix creates these values before endpoint dispatch so app principals can be checked
+ * consistently and audit events can use the same labels for allowed and denied decisions. The
+ * endpoint family is intentionally coarse, while the label is specific enough to distinguish
+ * mutations such as {@code queue.requests.remove} from read-only access.
+ *
+ *
Capabilities are normalized into sorted immutable order during construction. That keeps JSON
+ * output, audit entries, and tests deterministic even when a route requires more than one
+ * capability, such as a queue insert that needs both content-insert and queue-write authority.
+ *
+ * @param endpointFamily top-level endpoint family such as {@code queue}
+ * @param label short deterministic route/action label for audit output
+ * @param requiredCapabilities sorted capabilities required from an app principal
+ */
+public record PlatformApiAction(
+ String endpointFamily, String label, List requiredCapabilities) {
+ /**
+ * Creates an immutable route-action descriptor.
+ *
+ * The constructor trims textual fields, rejects blank values, and copies the capability list
+ * into sorted immutable order. At least one capability is required because app principals are
+ * default-deny and every mapped action must name the manifest permission that grants access.
+ *
+ * @throws IllegalArgumentException if the endpoint, label, or capability set is blank or empty
+ * @throws NullPointerException if any required value or capability element is {@code null}
+ */
+ public PlatformApiAction {
+ endpointFamily = requireText(endpointFamily, "endpointFamily");
+ label = requireText(label, "label");
+ requiredCapabilities = sortedCapabilities(requiredCapabilities);
+ if (requiredCapabilities.isEmpty()) {
+ throw new IllegalArgumentException("requiredCapabilities must not be empty");
+ }
+ }
+
+ /**
+ * Creates one action descriptor.
+ *
+ *
This factory is used by the capability matrix to keep route declarations compact. It does
+ * not bypass validation; the canonical record constructor still enforces non-blank labels and
+ * immutable sorted capabilities.
+ *
+ * @param endpointFamily top-level endpoint family used for grouping audit entries
+ * @param label deterministic audit label for the matched request shape
+ * @param requiredCapabilities capabilities required from app principals
+ * @return validated route-action descriptor for authorization and audit logging
+ */
+ static PlatformApiAction of(
+ String endpointFamily, String label, Collection requiredCapabilities) {
+ return new PlatformApiAction(endpointFamily, label, List.copyOf(requiredCapabilities));
+ }
+
+ /**
+ * Returns the immutable sorted capability list required by this action.
+ *
+ * A defensive copy is returned even though the record stores an immutable list. That keeps the
+ * accessor behavior explicit and prevents callers from depending on the concrete list instance
+ * retained by the record.
+ *
+ * @return immutable sorted capabilities required from an app principal
+ */
+ @Override
+ public List requiredCapabilities() {
+ return List.copyOf(this.requiredCapabilities);
+ }
+
+ private static List sortedCapabilities(Collection source) {
+ Objects.requireNonNull(source, "requiredCapabilities");
+ TreeSet sorted = new TreeSet<>();
+ for (String capability : source) {
+ sorted.add(requireText(capability, "requiredCapabilities value"));
+ }
+ return List.copyOf(sorted);
+ }
+
+ private static String requireText(String value, String label) {
+ String text = Objects.requireNonNull(value, label).trim();
+ if (text.isEmpty()) {
+ throw new IllegalArgumentException(label + " must not be blank");
+ }
+ return text;
+ }
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthSource.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthSource.java
new file mode 100644
index 0000000000..a6ca8f8aef
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthSource.java
@@ -0,0 +1,42 @@
+package network.crypta.platform.api;
+
+/**
+ * Transport-side mechanism that established a Platform API principal.
+ *
+ * The source is recorded separately from the principal type so tests and diagnostics can
+ * distinguish legacy local host access from launch-token based app access without carrying raw
+ * credentials through the transport-neutral API layer.
+ *
+ *
This enum describes how trust was established, not which authorization policy applies. The
+ * policy still follows the principal type: host/operator requests keep the existing local
+ * management behavior, while app principals are checked against manifest-declared capabilities.
+ * Keeping the source explicit helps bridges and tests verify that raw bearer tokens were consumed
+ * before a request reached the router.
+ */
+public enum PlatformApiAuthSource {
+ /**
+ * Legacy local host/operator access accepted by the HTTP bridge.
+ *
+ *
This value represents the current trusted local management model. It does not imply a named
+ * human user or role because host/operator RBAC is outside the app-permission enforcement phase.
+ */
+ HOST_LOCAL,
+
+ /**
+ * Opaque AppHost launch token verified by the transport bridge.
+ *
+ *
Requests with this source must carry only a token-free app principal after authentication.
+ * The raw token remains in the transport layer and must not appear in router state, audit events,
+ * JSON responses, or Web Shell bootstrap data.
+ */
+ APP_TOKEN,
+
+ /**
+ * No authenticated identity was established.
+ *
+ *
This value is available for tests and failure paths that need to model authentication before
+ * the bridge can construct a usable principal. Normal routed requests should use either
+ * host-local or app-token sources.
+ */
+ UNAUTHENTICATED
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthorizationDecision.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthorizationDecision.java
new file mode 100644
index 0000000000..2851ff34d3
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthorizationDecision.java
@@ -0,0 +1,92 @@
+package network.crypta.platform.api;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Result of applying the app capability policy to a Platform API request.
+ *
+ *
The capability matrix produces this value before the router dispatches to an endpoint handler.
+ * For app principals, the decision carries the matched action so denied and allowed audit events
+ * can use the same endpoint family, label, and capability list. Host/operator requests are
+ * represented as allowed without an action because they bypass app capability checks under the
+ * current local-management model.
+ *
+ *
The reason code is intentionally stable and short. It is suitable for JSON error bodies, audit
+ * entries, and focused tests, but it is not a replacement for the operator-facing response message
+ * that explains the returned HTTP status.
+ *
+ * @param allowed whether the request may proceed to endpoint dispatch
+ * @param action route/action descriptor when the request matched a capability rule
+ * @param reasonCode stable audit/error reason code
+ */
+record PlatformApiAuthorizationDecision(
+ boolean allowed, PlatformApiAction action, String reasonCode) {
+ /**
+ * Creates a capability decision with a required reason code.
+ *
+ *
The action may be {@code null} only for host/operator allow decisions. Denied app decisions
+ * normally carry either a mapped route action or an unmapped fallback action so audit events can
+ * still identify the endpoint family that was blocked.
+ *
+ * @throws NullPointerException if {@code reasonCode} is {@code null}
+ */
+ PlatformApiAuthorizationDecision {
+ Objects.requireNonNull(reasonCode, "reasonCode");
+ }
+
+ /**
+ * Creates an allowed app-principal decision for a matched action.
+ *
+ *
The caller has already confirmed that the app principal carries every capability listed by
+ * the action. The router will dispatch the request and record the eventual handler status as an
+ * allowed authorization decision.
+ *
+ * @param action matched action whose required capabilities were present
+ * @return allowed capability decision for app-originated routing
+ */
+ static PlatformApiAuthorizationDecision allowed(PlatformApiAction action) {
+ return new PlatformApiAuthorizationDecision(true, action, "capability_present");
+ }
+
+ /**
+ * Creates the compatibility allow decision for host/operator requests.
+ *
+ *
Host/operator authorization is enforced by the transport bridge before routing. The
+ * capability matrix therefore returns an allowed decision with no app action and a reason code
+ * that makes tests and diagnostics explicit about the bypass.
+ *
+ * @return allowed decision for trusted local host/operator traffic
+ */
+ static PlatformApiAuthorizationDecision hostAllowed() {
+ return new PlatformApiAuthorizationDecision(true, null, "host_operator");
+ }
+
+ /**
+ * Creates a denied app-principal decision.
+ *
+ *
The action describes either the mapped route whose capabilities were missing or the
+ * synthetic unmapped action used by default-deny. The reason code should stay stable because it
+ * is written to audit entries and may be used by Web Shell summaries.
+ *
+ * @param action action that explains which route or fallback policy denied the request
+ * @param reasonCode stable machine-readable reason for the denial
+ * @return denied capability decision that must not be dispatched to handlers
+ */
+ static PlatformApiAuthorizationDecision denied(PlatformApiAction action, String reasonCode) {
+ return new PlatformApiAuthorizationDecision(false, action, reasonCode);
+ }
+
+ /**
+ * Returns the matched action when one was part of the decision.
+ *
+ *
Host/operator allow decisions intentionally have no action because they are outside the app
+ * capability matrix. Audit code can use this optional form to choose between a mapped action and
+ * a fallback label without inspecting the nullable record component directly.
+ *
+ * @return optional action associated with app-principal decisions
+ */
+ Optional optionalAction() {
+ return Optional.ofNullable(action);
+ }
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java
new file mode 100644
index 0000000000..2a2b5090a7
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java
@@ -0,0 +1,575 @@
+package network.crypta.platform.api;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Central app-principal capability matrix for Platform API v1.
+ *
+ * Host/operator requests bypass this matrix to preserve the existing local-management model. App
+ * principals are default-deny: a request must match one of the route rules below and the app
+ * principal must carry every required manifest permission.
+ *
+ *
The matrix is intentionally explicit instead of reflection- or annotation-driven. Each helper
+ * mirrors one top-level endpoint family so reviewers can see which routes grant read access and
+ * which routes require write or manage authority. Unrecognized methods, unsupported sub-routes, and
+ * malformed route shapes return no action and are denied for app principals before endpoint
+ * dispatch.
+ */
+final class PlatformApiCapabilities {
+ /** Manifest permission that allows app principals to read node identity and status endpoints. */
+ static final String NODE_READ = "node.read";
+
+ /** Manifest permission that allows app principals to read connectivity diagnostics. */
+ static final String CONNECTIVITY_READ = "connectivity.read";
+
+ /** Manifest permission that allows app principals to read queue pages and completion state. */
+ static final String QUEUE_READ = "queue.read";
+
+ /** Manifest permission that allows app principals to mutate existing queue entries. */
+ static final String QUEUE_WRITE = "queue.write";
+
+ /** Manifest permission that allows app principals to create local file or directory inserts. */
+ static final String CONTENT_INSERT = "content.insert";
+
+ /** Manifest permission that allows app principals to read peer summaries and peer details. */
+ static final String PEERS_READ = "peers.read";
+
+ /** Manifest permission that allows app principals to add, update, annotate, or remove peers. */
+ static final String PEERS_WRITE = "peers.write";
+
+ /** Manifest permission that allows app principals to read configuration projections. */
+ static final String CONFIG_READ = "config.read";
+
+ /** Manifest permission that allows app principals to change or persist configuration values. */
+ static final String CONFIG_WRITE = "config.write";
+
+ /** Manifest permission that allows app principals to read security-level state. */
+ static final String SECURITY_READ = "security.read";
+
+ /** Manifest permission that allows app principals to change security-level settings. */
+ static final String SECURITY_WRITE = "security.write";
+
+ /** Manifest permission that allows app principals to read core update state. */
+ static final String UPDATES_READ = "updates.read";
+
+ /** Manifest permission that allows app principals to trigger core update actions. */
+ static final String UPDATES_WRITE = "updates.write";
+
+ /** Manifest permission that allows app principals to read first-time wizard state. */
+ static final String WIZARD_READ = "wizard.read";
+
+ /** Manifest permission that allows app principals to submit first-time wizard choices. */
+ static final String WIZARD_WRITE = "wizard.write";
+
+ /** Manifest permission that allows app principals to read current runtime alerts. */
+ static final String ALERTS_READ = "alerts.read";
+
+ /** Manifest permission that allows app principals to dismiss operator-visible alerts. */
+ static final String ALERTS_WRITE = "alerts.write";
+
+ /** Manifest permission that allows app principals to read runtime diagnostic summaries. */
+ static final String DIAGNOSTICS_READ = "diagnostics.read";
+
+ /** Manifest permission that allows app principals to read installed app summaries. */
+ static final String APPS_READ = "apps.read";
+
+ /**
+ * Manifest permission that allows app principals to install, update, start, stop, or remove apps.
+ */
+ static final String APPS_MANAGE = "apps.manage";
+
+ /**
+ * Manifest permission that allows app principals to read signed app-catalog sources and entries.
+ */
+ static final String CATALOGS_READ = "catalogs.read";
+
+ /** Manifest permission that allows app principals to add, refresh, or install from catalogs. */
+ static final String CATALOGS_MANAGE = "catalogs.manage";
+
+ private static final String FAMILY_ALERTS = "alerts";
+ private static final String FAMILY_APP_CATALOGS = "app-catalogs";
+ private static final String FAMILY_APPS = "apps";
+ private static final String FAMILY_CONFIG = "config";
+ private static final String FAMILY_PEERS = "peers";
+ private static final String FAMILY_QUEUE = "queue";
+ private static final String FAMILY_SECURITY_LEVELS = "security-levels";
+ private static final String FAMILY_UPDATES = "updates";
+ private static final String FAMILY_WIZARD = "wizard";
+
+ /** Prevents construction of this stateless capability-mapping utility. */
+ private PlatformApiCapabilities() {}
+
+ /**
+ * Authorizes one transport-neutral Platform API request.
+ *
+ *
Host/operator principals are allowed immediately because the HTTP bridge has already
+ * enforced the legacy local management checks. App principals must match a route rule and must
+ * contain every capability required by that action. Unsupported app routes are denied with an
+ * unmapped action so the audit log can still record which endpoint family was attempted.
+ *
+ * @param request request metadata with an already established principal
+ * @return capability decision used by the router before endpoint dispatch
+ */
+ static PlatformApiAuthorizationDecision authorize(PlatformApiRequest request) {
+ if (!request.principal().isApp()) {
+ return PlatformApiAuthorizationDecision.hostAllowed();
+ }
+
+ PlatformApiAction action = actionFor(request);
+ if (action == null) {
+ return PlatformApiAuthorizationDecision.denied(unknownAction(request), "unmapped_route");
+ }
+ Set permissions = Set.copyOf(request.principal().permissions());
+ if (permissions.containsAll(action.requiredCapabilities())) {
+ return PlatformApiAuthorizationDecision.allowed(action);
+ }
+ return PlatformApiAuthorizationDecision.denied(action, "missing_capability");
+ }
+
+ /**
+ * Resolves the route/action descriptor for one request.
+ *
+ * The first path segment selects the endpoint family. Each family helper is responsible for
+ * checking method and route shape. Returning {@code null} is deliberate: app principals treat
+ * every unmatched route as default-deny rather than falling through to handler-level 404 or 405
+ * responses.
+ *
+ * @param request request metadata whose path is relative to the Platform API v1 prefix
+ * @return matched action descriptor, or {@code null} when no app capability rule applies
+ */
+ private static PlatformApiAction actionFor(PlatformApiRequest request) {
+ List segments = request.pathSegments();
+ if (segments.isEmpty()) {
+ return null;
+ }
+ String family = segments.getFirst();
+ String method = request.method();
+ return switch (family) {
+ case "node" -> nodeAction(method, segments);
+ case "connectivity" ->
+ exactGet(method, family, CONNECTIVITY_READ, CONNECTIVITY_READ, segments.size() == 1);
+ case "diagnostics" ->
+ exactGet(method, family, DIAGNOSTICS_READ, DIAGNOSTICS_READ, segments.size() == 1);
+ case FAMILY_ALERTS -> alertsAction(method, segments);
+ case FAMILY_CONFIG -> configAction(method, segments);
+ case FAMILY_SECURITY_LEVELS -> securityAction(method, segments);
+ case FAMILY_UPDATES -> updatesAction(method, segments);
+ case FAMILY_WIZARD -> wizardAction(method, segments);
+ case FAMILY_QUEUE -> queueAction(method, segments);
+ case FAMILY_PEERS -> peersAction(method, segments);
+ case FAMILY_APPS -> appsAction(method, segments);
+ case FAMILY_APP_CATALOGS -> catalogsAction(method, segments);
+ default -> null;
+ };
+ }
+
+ /**
+ * Maps the node identity and reference reads.
+ *
+ * The router exposes only named node read resources, not a generic node subtree. Unsupported
+ * child paths therefore stay unmapped for app principals.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched node read action, or {@code null} for unsupported node routes
+ */
+ private static PlatformApiAction nodeAction(String method, List segments) {
+ return exactGet(
+ method,
+ "node",
+ NODE_READ,
+ NODE_READ,
+ segments.size() == 2
+ && ("greeting".equals(segments.get(1)) || "reference".equals(segments.get(1))));
+ }
+
+ /**
+ * Maps alert feed and dismissal routes.
+ *
+ * All alert reads require {@link #ALERTS_READ}. The only current alert mutation exposed
+ * through Platform API v1 is dismissal, which requires {@link #ALERTS_WRITE}.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched alerts action, or {@code null} for unsupported alert routes
+ */
+ private static PlatformApiAction alertsAction(String method, List segments) {
+ if ("GET".equals(method) && segments.size() == 1) {
+ return action(FAMILY_ALERTS, ALERTS_READ, ALERTS_READ);
+ }
+ if ("POST".equals(method) && segments.size() == 3 && "dismiss".equals(segments.get(2))) {
+ return action(FAMILY_ALERTS, "alerts.dismiss", ALERTS_WRITE);
+ }
+ return null;
+ }
+
+ /**
+ * Maps configuration read and write routes.
+ *
+ * The root configuration export requires {@link #CONFIG_READ}. Writes are limited to the
+ * explicit override and persist endpoints and require {@link #CONFIG_WRITE}; unsupported shapes
+ * remain unmapped for app principals.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched configuration action, or {@code null} for unsupported config routes
+ */
+ private static PlatformApiAction configAction(String method, List segments) {
+ if ("GET".equals(method) && segments.size() == 1) {
+ return action(FAMILY_CONFIG, CONFIG_READ, CONFIG_READ);
+ }
+ if ("POST".equals(method)
+ && segments.size() == 2
+ && ("overrides".equals(segments.get(1)) || "persist".equals(segments.get(1)))) {
+ return action(FAMILY_CONFIG, "config." + segments.get(1), CONFIG_WRITE);
+ }
+ return null;
+ }
+
+ /**
+ * Maps security-level read and mutation routes.
+ *
+ * Security-level reads require {@link #SECURITY_READ}. Mutations are restricted to the network
+ * and physical threat-level endpoints and require {@link #SECURITY_WRITE}. Keeping every allowed
+ * route name explicit avoids granting future security routes by accident.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched security action, or {@code null} for unsupported security routes
+ */
+ private static PlatformApiAction securityAction(String method, List segments) {
+ if ("GET".equals(method)) {
+ return securityReadAction(segments);
+ }
+ if ("POST".equals(method)
+ && segments.size() == 2
+ && ("network".equals(segments.get(1)) || "physical".equals(segments.get(1)))) {
+ return action(FAMILY_SECURITY_LEVELS, "security." + segments.get(1), SECURITY_WRITE);
+ }
+ return null;
+ }
+
+ private static PlatformApiAction securityReadAction(List segments) {
+ if (segments.size() == 1) {
+ return action(FAMILY_SECURITY_LEVELS, SECURITY_READ, SECURITY_READ);
+ }
+ if (segments.size() == 2 && "network-warning".equals(segments.get(1))) {
+ return action(FAMILY_SECURITY_LEVELS, "security.network-warning", SECURITY_READ);
+ }
+ return null;
+ }
+
+ /**
+ * Maps core update read and trigger routes.
+ *
+ * Update status reads require {@link #UPDATES_READ}. The current write surface is the core
+ * package download trigger, which requires {@link #UPDATES_WRITE}. Other updater operations stay
+ * host/operator-only until they are deliberately added to this matrix.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched updater action, or {@code null} for unsupported updater routes
+ */
+ private static PlatformApiAction updatesAction(String method, List segments) {
+ if ("GET".equals(method) && segments.size() == 2 && "core".equals(segments.get(1))) {
+ return action(FAMILY_UPDATES, UPDATES_READ, UPDATES_READ);
+ }
+ if ("POST".equals(method)
+ && segments.size() == 3
+ && "core".equals(segments.get(1))
+ && "download".equals(segments.get(2))) {
+ return action(FAMILY_UPDATES, "updates.core.download", UPDATES_WRITE);
+ }
+ return null;
+ }
+
+ /**
+ * Maps first-time wizard read and apply routes.
+ *
+ * Wizard reads require {@link #WIZARD_READ}. Applying submitted wizard choices is the only
+ * mapped mutation and requires {@link #WIZARD_WRITE}. The path check includes the {@code
+ * first-time} segment so future wizard families do not inherit this permission accidentally.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched wizard action, or {@code null} for unsupported wizard routes
+ */
+ private static PlatformApiAction wizardAction(String method, List segments) {
+ if ("GET".equals(method) && segments.size() == 2 && "first-time".equals(segments.get(1))) {
+ return action(FAMILY_WIZARD, WIZARD_READ, WIZARD_READ);
+ }
+ if ("POST".equals(method)
+ && segments.size() == 3
+ && "first-time".equals(segments.get(1))
+ && "apply".equals(segments.get(2))) {
+ return action(FAMILY_WIZARD, "wizard.first-time.apply", WIZARD_WRITE);
+ }
+ return null;
+ }
+
+ /**
+ * Maps queue read, download, insert, cleanup, and request-control routes.
+ *
+ * Queue reads require {@link #QUEUE_READ}. Mutations that operate on existing requests require
+ * {@link #QUEUE_WRITE}. Insert creation is stricter because it can publish local content, so file
+ * and directory insert routes require both {@link #CONTENT_INSERT} and {@link #QUEUE_WRITE}.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched queue action, or {@code null} for unsupported queue routes
+ */
+ private static PlatformApiAction queueAction(String method, List segments) {
+ if ("GET".equals(method)) {
+ return queueReadAction(segments);
+ }
+ if (!"POST".equals(method)) {
+ return null;
+ }
+ if (segments.size() == 2 && "downloads".equals(segments.get(1))) {
+ return action(FAMILY_QUEUE, "queue.downloads.create", QUEUE_WRITE);
+ }
+ if (segments.size() != 3) {
+ return null;
+ }
+ String resource = segments.get(1);
+ String action = segments.get(2);
+ if ("inserts".equals(resource) && ("file".equals(action) || "directory".equals(action))) {
+ return queueInsertAction("queue.inserts." + action);
+ }
+ if ("requests".equals(resource)
+ && ("remove".equals(action) || "restart".equals(action) || "priority".equals(action))) {
+ return action(FAMILY_QUEUE, "queue.requests." + action, QUEUE_WRITE);
+ }
+ if ("cleanup".equals(resource) && ("uploads".equals(action) || "downloads".equals(action))) {
+ return action(FAMILY_QUEUE, "queue.cleanup." + action, QUEUE_WRITE);
+ }
+ return null;
+ }
+
+ /**
+ * Maps explicit read-only queue routes.
+ *
+ * The queue family also contains mutation-shaped resources such as {@code requests/remove} and
+ * creation resources such as {@code downloads}. Those routes are not valid GET reads, so app
+ * principals must not receive a blanket {@code queue.read} grant for every queue path.
+ *
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return queue read action for supported read shapes, or {@code null} for unsupported routes
+ */
+ private static PlatformApiAction queueReadAction(List segments) {
+ if (segments.size() == 1
+ || (segments.size() == 2
+ && ("count".equals(segments.get(1)) || "keys".equals(segments.get(1))))) {
+ return action(FAMILY_QUEUE, QUEUE_READ, QUEUE_READ);
+ }
+ return null;
+ }
+
+ /**
+ * Maps peer read and peer-management routes.
+ *
+ * Peer reads require {@link #PEERS_READ}. Adding peers and changing an existing peer's
+ * settings, note, or removal state require {@link #PEERS_WRITE}. The helper intentionally checks
+ * exact terminal route names because peer identifiers occupy the middle segment.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched peer action, or {@code null} for unsupported peer routes
+ */
+ private static PlatformApiAction peersAction(String method, List segments) {
+ if ("GET".equals(method) && (segments.size() == 1 || segments.size() == 2)) {
+ return action(FAMILY_PEERS, PEERS_READ, PEERS_READ);
+ }
+ if ("POST".equals(method) && segments.size() == 2 && "add".equals(segments.get(1))) {
+ return action(FAMILY_PEERS, "peers.add", PEERS_WRITE);
+ }
+ if ("POST".equals(method)
+ && segments.size() == 3
+ && ("settings".equals(segments.get(2))
+ || "note".equals(segments.get(2))
+ || "remove".equals(segments.get(2)))) {
+ return action(FAMILY_PEERS, "peers." + segments.get(2), PEERS_WRITE);
+ }
+ return null;
+ }
+
+ /**
+ * Maps installed-app inventory and lifecycle routes.
+ *
+ * Reading app summaries, permissions, audit entries, logs, and runtime state all fall under
+ * {@link #APPS_READ}. Lifecycle and installation actions require {@link #APPS_MANAGE}. This route
+ * family is powerful because it can affect other apps, so new mutations should be added here only
+ * when they are intended for app principals.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched app-management action, or {@code null} for unsupported app routes
+ */
+ private static PlatformApiAction appsAction(String method, List segments) {
+ if ("GET".equals(method)) {
+ return appsReadAction(segments);
+ }
+ if ("DELETE".equals(method) && segments.size() == 2) {
+ return action(FAMILY_APPS, "apps.uninstall", APPS_MANAGE);
+ }
+ if (!"POST".equals(method)) {
+ return null;
+ }
+ if (segments.size() == 2 && "install".equals(segments.get(1))) {
+ return action(FAMILY_APPS, "apps.install", APPS_MANAGE);
+ }
+ if (segments.size() == 3
+ && ("start".equals(segments.get(2))
+ || "stop".equals(segments.get(2))
+ || "update".equals(segments.get(2)))) {
+ return action(FAMILY_APPS, "apps." + segments.get(2), APPS_MANAGE);
+ }
+ return null;
+ }
+
+ /**
+ * Maps explicit read-only app routes.
+ *
+ * GET is deliberately not treated as a blanket grant for {@code /apps/**}. Lifecycle action
+ * names such as {@code start} and unsupported child resources must stay unmapped for app
+ * principals so the router records a default-deny authorization decision instead of allowing the
+ * request to reach handler-level 404 or 405 handling.
+ *
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return app read action for supported read shapes, or {@code null} for unsupported routes
+ */
+ private static PlatformApiAction appsReadAction(List segments) {
+ if (segments.size() == 1 || segments.size() == 2) {
+ return action(FAMILY_APPS, APPS_READ, APPS_READ);
+ }
+ if (segments.size() == 3
+ && ("runtime".equals(segments.get(2))
+ || "logs".equals(segments.get(2))
+ || "permissions".equals(segments.get(2))
+ || "audit".equals(segments.get(2)))) {
+ return action(FAMILY_APPS, APPS_READ, APPS_READ);
+ }
+ return null;
+ }
+
+ /**
+ * Maps signed app-catalog read and management routes.
+ *
+ * Catalog source and entry reads require {@link #CATALOGS_READ}. Adding, refreshing, removing,
+ * installing from, or updating from a catalog requires {@link #CATALOGS_MANAGE}. The installation
+ * and update route shape includes both catalog id and app id, so this helper checks the fixed
+ * structural segments and ignores identifier values.
+ *
+ * @param method normalized HTTP-style method name
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return matched catalog action, or {@code null} for unsupported catalog routes
+ */
+ private static PlatformApiAction catalogsAction(String method, List segments) {
+ if ("GET".equals(method)) {
+ return catalogsReadAction(segments);
+ }
+ if ("DELETE".equals(method) && segments.size() == 2) {
+ return action(FAMILY_APP_CATALOGS, "catalogs.remove", CATALOGS_MANAGE);
+ }
+ if (!"POST".equals(method)) {
+ return null;
+ }
+ if (segments.size() == 2 && "add".equals(segments.get(1))) {
+ return action(FAMILY_APP_CATALOGS, "catalogs.add", CATALOGS_MANAGE);
+ }
+ if (segments.size() == 3 && "refresh".equals(segments.get(2))) {
+ return action(FAMILY_APP_CATALOGS, "catalogs.refresh", CATALOGS_MANAGE);
+ }
+ if (segments.size() == 5
+ && "apps".equals(segments.get(2))
+ && ("install".equals(segments.get(4)) || "update".equals(segments.get(4)))) {
+ return action(FAMILY_APP_CATALOGS, "catalogs.apps." + segments.get(4), CATALOGS_MANAGE);
+ }
+ return null;
+ }
+
+ /**
+ * Maps explicit read-only app-catalog routes.
+ *
+ * The catalog family has management resources such as {@code /app-catalogs/{catalogId}} and
+ * action resources such as {@code refresh}. Those are not valid GET reads in the router, so app
+ * principals must not receive a blanket {@code catalogs.read} grant for every catalog path.
+ *
+ * @param segments decoded route segments beneath the API v1 prefix
+ * @return catalog read action for supported read shapes, or {@code null} for unsupported routes
+ */
+ private static PlatformApiAction catalogsReadAction(List segments) {
+ if (segments.size() == 1 || isCatalogAppRead(segments)) {
+ return action(FAMILY_APP_CATALOGS, CATALOGS_READ, CATALOGS_READ);
+ }
+ return null;
+ }
+
+ private static boolean isCatalogAppRead(List segments) {
+ return (segments.size() == 3 || segments.size() == 4) && "apps".equals(segments.get(2));
+ }
+
+ /**
+ * Builds an action only when the request method is {@code GET} and the route shape is supported.
+ *
+ * Read-only endpoint families use this helper after checking the exact route shape.
+ * Unsupported methods or shapes return {@code null} so app principals are denied before dispatch
+ * rather than reaching a handler-level method or not-found check.
+ *
+ * @param method normalized HTTP-style method name
+ * @param endpointFamily top-level endpoint family used in audit output
+ * @param label deterministic action label used in audit output
+ * @param capability single required manifest capability
+ * @param routeMatches whether the request path is one supported read route
+ * @return read action for supported {@code GET} routes, or {@code null} otherwise
+ */
+ private static PlatformApiAction exactGet(
+ String method, String endpointFamily, String label, String capability, boolean routeMatches) {
+ return "GET".equals(method) && routeMatches ? action(endpointFamily, label, capability) : null;
+ }
+
+ /**
+ * Builds an action requiring one capability.
+ *
+ * @param endpointFamily top-level endpoint family used in audit output
+ * @param label deterministic action label used in audit output
+ * @param capability required manifest capability for the action
+ * @return action descriptor with one required capability
+ */
+ private static PlatformApiAction action(String endpointFamily, String label, String capability) {
+ return PlatformApiAction.of(endpointFamily, label, List.of(capability));
+ }
+
+ /**
+ * Builds the action for queue insert routes.
+ *
+ *
Queue inserts are intentionally stricter than ordinary queue mutations: a manifest must
+ * grant both permission to insert content and permission to mutate the local queue. Keeping the
+ * fixed family and capability pair here makes that policy visible at the route helper boundary
+ * while the action record still sorts the capabilities for deterministic audit output.
+ *
+ * @param label deterministic queue insert action label used in audit output
+ * @return action descriptor requiring both content insertion and queue mutation capabilities
+ */
+ private static PlatformApiAction queueInsertAction(String label) {
+ return PlatformApiAction.of(FAMILY_QUEUE, label, List.of(CONTENT_INSERT, QUEUE_WRITE));
+ }
+
+ /**
+ * Builds the synthetic action used for default-deny app routes.
+ *
+ *
The returned action is not grantable by a normal manifest permission. Its purpose is to
+ * provide audit entries with an endpoint family and an {@code unmapped} capability label when an
+ * app principal attempts a route that the matrix does not intentionally expose.
+ *
+ * @param request request whose first path segment identifies the attempted endpoint family
+ * @return synthetic unmapped action for denied app-principal audit events
+ */
+ private static PlatformApiAction unknownAction(PlatformApiRequest request) {
+ List segments = request.pathSegments();
+ String endpointFamily = segments.isEmpty() ? "unknown" : segments.getFirst();
+ String label = segments.isEmpty() ? "unmapped" : endpointFamily + ".unmapped";
+ return PlatformApiAction.of(endpointFamily, label, List.of("unmapped"));
+ }
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java
new file mode 100644
index 0000000000..8c51a8ce69
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java
@@ -0,0 +1,141 @@
+package network.crypta.platform.api;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.TreeSet;
+
+/**
+ * Token-free identity attached to one Platform API request.
+ *
+ * The principal intentionally stores only stable identity metadata and manifest-declared
+ * permissions. Raw launch tokens, headers, and transport details remain outside this model so they
+ * cannot appear in router JSON, audit entries, snapshots, or diagnostic strings by accident.
+ *
+ *
There are two valid shapes. A host/operator principal has no app id and no app permissions; it
+ * represents the existing trusted local management path. An app principal has a normalized app id,
+ * an {@link PlatformApiAuthSource#APP_TOKEN} source, and the manifest permissions from the
+ * currently running app snapshot. The router uses that immutable permission list for default-deny
+ * capability checks.
+ *
+ * @param type principal category used by the router's authorization path
+ * @param authSource transport-side authentication source that established the identity
+ * @param appId normalized app id for app principals, or {@code null} for host/operator principals
+ * @param permissions immutable sorted app permissions for app principals
+ */
+public record PlatformApiPrincipal(
+ PlatformApiPrincipalType type,
+ PlatformApiAuthSource authSource,
+ String appId,
+ List permissions) {
+ /**
+ * Creates a validated token-free principal.
+ *
+ * The constructor enforces the two supported principal shapes and normalizes app permissions
+ * into sorted immutable order. It trims app ids and permission strings, rejects blank app ids for
+ * app principals, and rejects any host/operator principal that accidentally carries app identity
+ * or capabilities.
+ *
+ * @throws IllegalArgumentException if the identity fields do not match the principal type
+ * @throws NullPointerException if {@code type}, {@code authSource}, or {@code permissions} is
+ * {@code null}, or if a permission element is {@code null}
+ */
+ public PlatformApiPrincipal {
+ Objects.requireNonNull(type, "type");
+ Objects.requireNonNull(authSource, "authSource");
+ permissions = sortedPermissions(permissions);
+ if (type == PlatformApiPrincipalType.APP) {
+ if (appId == null || appId.isBlank()) {
+ throw new IllegalArgumentException("app principal requires an app id");
+ }
+ appId = appId.trim();
+ if (authSource != PlatformApiAuthSource.APP_TOKEN) {
+ throw new IllegalArgumentException("app principal requires APP_TOKEN auth source");
+ }
+ } else {
+ if (appId != null) {
+ throw new IllegalArgumentException("host principal must not carry an app id");
+ }
+ if (!permissions.isEmpty()) {
+ throw new IllegalArgumentException("host principal must not carry app permissions");
+ }
+ if (authSource != PlatformApiAuthSource.HOST_LOCAL) {
+ throw new IllegalArgumentException("host principal requires HOST_LOCAL auth source");
+ }
+ }
+ }
+
+ /**
+ * Returns the default host/operator principal used by existing tests and call sites.
+ *
+ *
This factory preserves compatibility for transport bridges and tests that predate app
+ * principals. Requests built with this principal continue through the legacy host/operator path
+ * and do not require manifest capability strings.
+ *
+ * @return trusted local host/operator principal with no app id or permissions
+ */
+ public static PlatformApiPrincipal hostOperator() {
+ return new PlatformApiPrincipal(
+ PlatformApiPrincipalType.HOST_OPERATOR, PlatformApiAuthSource.HOST_LOCAL, null, List.of());
+ }
+
+ /**
+ * Builds an app principal from an AppHost-verified launch token identity.
+ *
+ *
The caller must authenticate the launch token before invoking this factory. The raw token is
+ * deliberately absent from the resulting value; only the app id and manifest permission strings
+ * needed for authorization cross the transport-neutral boundary.
+ *
+ * @param appId normalized app id associated with the verified live app process
+ * @param permissions manifest-declared permissions for the currently running app
+ * @return token-free app principal ready for Platform API capability checks
+ */
+ public static PlatformApiPrincipal appToken(String appId, Collection permissions) {
+ return new PlatformApiPrincipal(
+ PlatformApiPrincipalType.APP,
+ PlatformApiAuthSource.APP_TOKEN,
+ appId,
+ List.copyOf(permissions));
+ }
+
+ /**
+ * Returns whether this request was authenticated as an app process.
+ *
+ * This convenience method is used by the router and audit log to separate app-originated
+ * decisions from host/operator traffic. It does not re-check the authentication source; the
+ * constructor already enforces that app principals use the app-token source.
+ *
+ * @return {@code true} for app principals and {@code false} for host/operator principals
+ */
+ public boolean isApp() {
+ return type == PlatformApiPrincipalType.APP;
+ }
+
+ /**
+ * Returns immutable sorted manifest permissions carried by this principal.
+ *
+ *
Host/operator principals always return an empty list. App principals return the permissions
+ * captured after launch-token authentication. A defensive copy is returned so callers cannot
+ * mutate the record's retained authorization view.
+ *
+ * @return immutable sorted permission strings for app principals, or an empty list for host
+ * principals
+ */
+ @Override
+ public List permissions() {
+ return List.copyOf(this.permissions);
+ }
+
+ private static List sortedPermissions(Collection source) {
+ Objects.requireNonNull(source, "permissions");
+ TreeSet sorted = new TreeSet<>();
+ for (String permission : source) {
+ String normalized = Objects.requireNonNull(permission, "permissions value").trim();
+ if (normalized.isEmpty()) {
+ throw new IllegalArgumentException("permissions must not contain blank values");
+ }
+ sorted.add(normalized);
+ }
+ return List.copyOf(sorted);
+ }
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipalType.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipalType.java
new file mode 100644
index 0000000000..d0ddf8b6f4
--- /dev/null
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipalType.java
@@ -0,0 +1,32 @@
+package network.crypta.platform.api;
+
+/**
+ * Identity category used for Platform API authorization decisions.
+ *
+ * The current Platform API distinguishes trusted local host/operator requests from
+ * app-originated process requests. Host/operator authorization remains the legacy local-management
+ * model, while app principals are capability checked against manifest-declared permissions.
+ *
+ *
The type is intentionally independent of the transport source. A bridge can record how a
+ * principal was established with {@link PlatformApiAuthSource}, while the router uses this enum to
+ * choose the authorization path. That separation keeps the model ready for future transport sources
+ * without changing the current app-principal default-deny rule.
+ */
+public enum PlatformApiPrincipalType {
+ /**
+ * Trusted local operator request accepted by the host-facing transport bridge.
+ *
+ *
This category keeps existing local Web Shell and management flows working. It does not carry
+ * app permissions and is not audited as app-originated traffic by the app audit log.
+ */
+ HOST_OPERATOR,
+
+ /**
+ * Request authenticated as a running app process.
+ *
+ *
App principals are authorized against the immutable manifest permissions attached to the
+ * principal. They are denied by default when a route is not mapped or the required capability is
+ * absent.
+ */
+ APP
+}
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRequest.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRequest.java
index a15bc4551e..76ac119df8 100644
--- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRequest.java
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRequest.java
@@ -11,16 +11,37 @@
*
*
The record intentionally captures only the small amount of information that the current API
* needs: the HTTP-style method, the relative path split into path segments beneath {@link
- * PlatformApiPaths#API_V1_PREFIX}, and the decoded query parameters with all supplied values.
- * Bridges remain responsible for any transport-specific parsing, authentication, or header handling
- * before constructing this request.
+ * PlatformApiPaths#API_V1_PREFIX}, the decoded query parameters with all supplied values, and the
+ * token-free principal established by the transport bridge. Bridges remain responsible for any
+ * transport-specific parsing, authentication, credential handling, or header parsing before
+ * constructing this request.
*
* @param method request method name such as {@code GET}
* @param pathSegments relative path segments beneath the API v1 mount point
* @param queryParameters decoded query parameters in encounter order
+ * @param principal token-free request principal
*/
public record PlatformApiRequest(
- String method, List pathSegments, Map> queryParameters) {
+ String method,
+ List pathSegments,
+ Map> queryParameters,
+ PlatformApiPrincipal principal) {
+ /**
+ * Creates a host/operator request descriptor.
+ *
+ * This overload preserves existing tests and call sites that predate app-principal
+ * authorization. Requests built this way keep the legacy local host/operator authorization
+ * behavior.
+ *
+ * @param method request method name such as {@code GET}
+ * @param pathSegments relative path segments beneath the API v1 mount point
+ * @param queryParameters decoded query parameters in encounter order
+ */
+ public PlatformApiRequest(
+ String method, List pathSegments, Map> queryParameters) {
+ this(method, pathSegments, queryParameters, PlatformApiPrincipal.hostOperator());
+ }
+
/**
* Creates an immutable platform API request descriptor.
*
@@ -39,6 +60,7 @@ public record PlatformApiRequest(
.map(segment -> Objects.requireNonNull(segment, "pathSegments value"))
.toList());
queryParameters = immutableQueryParameters(queryParameters);
+ Objects.requireNonNull(principal, "principal");
}
/**
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiResponse.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiResponse.java
index a2d51a4077..47a1086a20 100644
--- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiResponse.java
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiResponse.java
@@ -104,6 +104,7 @@ static String reasonPhrase(int statusCode) {
case 201 -> "Created";
case 200 -> "OK";
case 400 -> "Bad Request";
+ case 401 -> "Unauthorized";
case 403 -> "Forbidden";
case 404 -> "Not Found";
case 405 -> "Method Not Allowed";
diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRouter.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRouter.java
index 8a11dece42..cc98bd2b22 100644
--- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRouter.java
+++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRouter.java
@@ -77,6 +77,9 @@ public final class PlatformApiRouter {
/** Handler for the {@code /apps/...} endpoint family, when AppHost support is available. */
private final AppsApiHandler appsApiHandler;
+ /** Bounded process-local audit log for app-originated authorization decisions. */
+ private final AppAuditLog appAuditLog;
+
/**
* Handler for the {@code /app-catalogs/...} endpoint family, when catalog support is available.
*/
@@ -132,7 +135,17 @@ public PlatformApiRouter(
AppHost appHost,
AppCatalogManager appCatalogManager,
LegacyAdminUsagePort legacyAdminUsage) {
+ this(runtimePorts, appHost, appCatalogManager, legacyAdminUsage, new AppAuditLog());
+ }
+
+ PlatformApiRouter(
+ RuntimePorts runtimePorts,
+ AppHost appHost,
+ AppCatalogManager appCatalogManager,
+ LegacyAdminUsagePort legacyAdminUsage,
+ AppAuditLog appAuditLog) {
Objects.requireNonNull(runtimePorts, "runtimePorts");
+ this.appAuditLog = Objects.requireNonNull(appAuditLog, "appAuditLog");
nodeApiHandler = new NodeApiHandler(runtimePorts.nodeInfo());
peersApiHandler = new PeersApiHandler(runtimePorts.peer(), runtimePorts.darknetConnections());
configApiHandler = new ConfigApiHandler(runtimePorts.config());
@@ -152,7 +165,7 @@ public PlatformApiRouter(
runtimePorts.queueCompletion());
alertsApiHandler = new AlertsApiHandler(runtimePorts.alertFeed(), runtimePorts.alertMutation());
diagnosticsApiHandler = new DiagnosticsApiHandler(runtimePorts.diagnostic(), legacyAdminUsage);
- appsApiHandler = appHost == null ? null : new AppsApiHandler(appHost);
+ appsApiHandler = appHost == null ? null : new AppsApiHandler(appHost, this.appAuditLog);
appCatalogsApiHandler =
appHost == null || appCatalogManager == null
? null
@@ -170,13 +183,52 @@ public PlatformApiRouter(
* @return JSON response for the routed endpoint
*/
public PlatformApiResponse route(PlatformApiRequest request) {
+ PlatformApiRequest checkedRequest = Objects.requireNonNull(request, "request");
+ PlatformApiAuthorizationDecision authorization =
+ PlatformApiCapabilities.authorize(checkedRequest);
+ if (!authorization.allowed()) {
+ PlatformApiResponse response =
+ PlatformApiResponse.error(
+ 403, "forbidden", "App principal lacks the required Platform API capability.");
+ appAuditLog.appendDecision(
+ checkedRequest,
+ authorization,
+ AppAuditDecision.DENIED,
+ response.statusCode(),
+ authorization.reasonCode());
+ return response;
+ }
+
try {
- return routeInternal(Objects.requireNonNull(request, "request"));
+ PlatformApiResponse response = routeInternal(checkedRequest);
+ appAuditLog.appendDecision(
+ checkedRequest,
+ authorization,
+ AppAuditDecision.ALLOWED,
+ response.statusCode(),
+ "route_completed");
+ return response;
} catch (PlatformApiException e) {
- return PlatformApiResponse.error(e.statusCode(), e.errorCode(), e.getMessage());
+ PlatformApiResponse response =
+ PlatformApiResponse.error(e.statusCode(), e.errorCode(), e.getMessage());
+ appAuditLog.appendDecision(
+ checkedRequest,
+ authorization,
+ AppAuditDecision.ALLOWED,
+ response.statusCode(),
+ e.errorCode());
+ return response;
} catch (RuntimeException e) {
LOG.log(System.Logger.Level.ERROR, "Unexpected Platform API failure", e);
- return PlatformApiResponse.error(500, "internal_error", "Unexpected platform API failure.");
+ PlatformApiResponse response =
+ PlatformApiResponse.error(500, "internal_error", "Unexpected platform API failure.");
+ appAuditLog.appendDecision(
+ checkedRequest,
+ authorization,
+ AppAuditDecision.ALLOWED,
+ response.statusCode(),
+ "internal_error");
+ return response;
}
}
@@ -535,6 +587,18 @@ private PlatformApiResponse routeAppsAction(
yield PlatformApiResponse.ok(
envelope("logs", appsApiHandler.logs(appId, request.queryParameters())));
}
+ case "permissions" -> {
+ if (!"GET".equals(method)) {
+ yield methodNotAllowed("GET", GET_ONLY_MESSAGE);
+ }
+ yield PlatformApiResponse.ok(envelope("permissions", appsApiHandler.permissions(appId)));
+ }
+ case "audit" -> {
+ if (!"GET".equals(method)) {
+ yield methodNotAllowed("GET", GET_ONLY_MESSAGE);
+ }
+ yield PlatformApiResponse.ok(envelope("audit", appsApiHandler.audit(appId)));
+ }
case "start" -> {
if (!"POST".equals(method)) {
yield methodNotAllowed("POST", POST_ONLY_MESSAGE);
diff --git a/platform-api/src/main/java/network/crypta/platform/api/apps/AppsApiHandler.java b/platform-api/src/main/java/network/crypta/platform/api/apps/AppsApiHandler.java
index eeb433eaea..8b2233e15b 100644
--- a/platform-api/src/main/java/network/crypta/platform/api/apps/AppsApiHandler.java
+++ b/platform-api/src/main/java/network/crypta/platform/api/apps/AppsApiHandler.java
@@ -10,6 +10,8 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import network.crypta.platform.api.AppAuditEvent;
+import network.crypta.platform.api.AppAuditLog;
import network.crypta.platform.api.PlatformApiException;
import network.crypta.platform.api.PlatformApiParameters;
import network.crypta.platform.apphost.AppBundleVerificationException;
@@ -55,6 +57,8 @@ public final class AppsApiHandler {
private static final String APP_NOT_INSTALLED_PREFIX = "app is not installed: ";
private static final String CANNOT_UPDATE_RUNNING_APP_PREFIX = "cannot update a running app: ";
private static final String FIELD_APP_ID = "appId";
+ private static final String FIELD_PERMISSIONS = "permissions";
+ private static final String FIELD_RECENT_DENIED_COUNT = "recentDeniedCount";
private static final String FIELD_RUNNING = "running";
private static final String FIELD_STARTED_AT = "startedAt";
private static final String MAX_BYTES_POSITIVE_INTEGER_MESSAGE =
@@ -65,6 +69,9 @@ public final class AppsApiHandler {
/** Detached AppHost core used for app lifecycle and inventory operations. */
private final AppHost appHost;
+ /** Shared bounded audit log for app-originated Platform API decisions. */
+ private final AppAuditLog auditLog;
+
/**
* Creates an app-management handler backed by the supplied AppHost.
*
@@ -76,7 +83,18 @@ public final class AppsApiHandler {
* @param appHost detached AppHost core used for app lifecycle operations
*/
public AppsApiHandler(AppHost appHost) {
+ this(appHost, new AppAuditLog());
+ }
+
+ /**
+ * Creates an app-management handler backed by the supplied AppHost and audit log.
+ *
+ * @param appHost detached AppHost core used for app lifecycle operations
+ * @param auditLog bounded process-local app audit log
+ */
+ public AppsApiHandler(AppHost appHost, AppAuditLog auditLog) {
this.appHost = Objects.requireNonNull(appHost, "appHost");
+ this.auditLog = Objects.requireNonNull(auditLog, "auditLog");
}
/**
@@ -160,6 +178,35 @@ public Map logs(String appId, Map> queryPar
}
}
+ /**
+ * Returns the declared permissions for one installed app.
+ *
+ * @param appId stable application identifier extracted from the request path
+ * @return JSON-compatible permissions summary
+ */
+ public Map permissions(String appId) {
+ String normalizedAppId = normalizeAppId(appId);
+ InstalledAppSnapshot installed = requireInstalled(normalizedAppId);
+ LinkedHashMap json = LinkedHashMap.newLinkedHashMap(4);
+ json.put(FIELD_APP_ID, normalizedAppId);
+ json.put(FIELD_PERMISSIONS, installed.manifest().permissions());
+ json.put(FIELD_RUNNING, appHost.status(normalizedAppId).isPresent());
+ json.put(FIELD_RECENT_DENIED_COUNT, auditLog.deniedCountForApp(normalizedAppId));
+ return json;
+ }
+
+ /**
+ * Returns recent bounded audit entries for one installed app.
+ *
+ * @param appId stable application identifier extracted from the request path
+ * @return JSON-compatible audit summary
+ */
+ public Map audit(String appId) {
+ String normalizedAppId = normalizeAppId(appId);
+ requireInstalled(normalizedAppId);
+ return auditSummary(normalizedAppId);
+ }
+
/**
* Installs one staged app bundle and returns the installed summary.
*
@@ -446,21 +493,23 @@ private static AppManifest parseManifest(Path stagedDir) {
* @param running running snapshot when the app is live, or {@code null}
* @return ordered JSON-compatible summary map
*/
- private static Map summarize(
+ private Map summarize(
AppManifest manifest, boolean installed, RunningAppSnapshot running) {
- LinkedHashMap json = LinkedHashMap.newLinkedHashMap(12);
+ LinkedHashMap json = LinkedHashMap.newLinkedHashMap(14);
json.put(FIELD_APP_ID, manifest.appId());
json.put("name", manifest.appName());
json.put("version", manifest.appVersion());
json.put("uiMode", manifest.uiMode().manifestValue());
json.put("uiEntry", manifest.uiEntry());
json.put("uiUrl", AppUiPaths.uiUrl(manifest));
- json.put("permissions", manifest.permissions());
+ json.put(FIELD_PERMISSIONS, manifest.permissions());
json.put("quota", quota(manifest));
json.put("installed", installed);
json.put(FIELD_RUNNING, running != null);
json.put("pid", running == null ? null : running.pid());
json.put(FIELD_STARTED_AT, running == null ? null : running.startedAt().toString());
+ json.put(FIELD_RECENT_DENIED_COUNT, auditLog.deniedCountForApp(manifest.appId()));
+ json.put("audit", auditSummary(manifest.appId()));
return json;
}
@@ -517,20 +566,46 @@ private static String instantText(Instant instant) {
* @param appId normalized application identifier
* @return ordered JSON-compatible summary map with unknown manifest fields set to {@code null}
*/
- private static Map summarizeUnknown(String appId, boolean installed) {
- LinkedHashMap json = LinkedHashMap.newLinkedHashMap(12);
+ private Map summarizeUnknown(String appId, boolean installed) {
+ LinkedHashMap json = LinkedHashMap.newLinkedHashMap(14);
json.put(FIELD_APP_ID, appId);
json.put("name", null);
json.put("version", null);
json.put("uiMode", "none");
json.put("uiEntry", null);
json.put("uiUrl", null);
- json.put("permissions", List.of());
+ json.put(FIELD_PERMISSIONS, List.of());
json.put("quota", unknownQuota());
json.put("installed", installed);
json.put(FIELD_RUNNING, false);
json.put("pid", null);
json.put(FIELD_STARTED_AT, null);
+ json.put(FIELD_RECENT_DENIED_COUNT, auditLog.deniedCountForApp(appId));
+ json.put("audit", auditSummary(appId));
+ return json;
+ }
+
+ private Map auditSummary(String appId) {
+ List recent = auditLog.recentForApp(appId, AppAuditLog.DEFAULT_APP_EVENT_LIMIT);
+ LinkedHashMap json = LinkedHashMap.newLinkedHashMap(4);
+ json.put(FIELD_APP_ID, appId);
+ json.put("boundedEventLimit", AppAuditLog.DEFAULT_APP_EVENT_LIMIT);
+ json.put(FIELD_RECENT_DENIED_COUNT, auditLog.deniedCountForApp(appId));
+ json.put("events", recent.stream().map(AppsApiHandler::summarizeAuditEvent).toList());
+ return json;
+ }
+
+ private static Map summarizeAuditEvent(AppAuditEvent event) {
+ LinkedHashMap json = LinkedHashMap.newLinkedHashMap(9);
+ json.put("timestamp", event.timestamp().toString());
+ json.put(FIELD_APP_ID, event.appId());
+ json.put("method", event.method());
+ json.put("endpointFamily", event.endpointFamily());
+ json.put("action", event.action());
+ json.put("requiredCapabilities", event.requiredCapabilities());
+ json.put("decision", event.decision().name());
+ json.put("statusCode", event.statusCode());
+ json.put("reasonCode", event.reasonCode());
return json;
}
diff --git a/platform-api/src/test/java/network/crypta/platform/api/AppAuditLogTest.java b/platform-api/src/test/java/network/crypta/platform/api/AppAuditLogTest.java
new file mode 100644
index 0000000000..1ca701b8bf
--- /dev/null
+++ b/platform-api/src/test/java/network/crypta/platform/api/AppAuditLogTest.java
@@ -0,0 +1,95 @@
+package network.crypta.platform.api;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SuppressWarnings("java:S100")
+class AppAuditLogTest {
+ @Test
+ void append_whenCapacityExceeded_expectOldestEventsDropped() {
+ AppAuditLog log =
+ new AppAuditLog(2, Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC));
+
+ log.append(event("demo-app", "queue.read", AppAuditDecision.ALLOWED, 200));
+ log.append(event("demo-app", "queue.write", AppAuditDecision.DENIED, 403));
+ log.append(event("demo-app", "alerts.read", AppAuditDecision.ALLOWED, 200));
+
+ List recent = log.recentForApp("demo-app", 10);
+ assertEquals(2, recent.size());
+ assertEquals("alerts.read", recent.get(0).action());
+ assertEquals("queue.write", recent.get(1).action());
+ assertEquals(1L, log.deniedCountForApp("demo-app"));
+ }
+
+ @Test
+ void constructor_whenCapacityIsNotPositive_expectIllegalArgumentException() {
+ Clock clock = Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC);
+
+ assertThrows(IllegalArgumentException.class, () -> new AppAuditLog(0, clock));
+ assertThrows(IllegalArgumentException.class, () -> new AppAuditLog(-1, clock));
+ }
+
+ @Test
+ void recentForApp_whenLimitIsNonPositive_expectEmptyResult() {
+ AppAuditLog log =
+ new AppAuditLog(3, Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC));
+ log.append(event("demo-app", "queue.read", AppAuditDecision.ALLOWED, 200));
+
+ assertTrue(log.recentForApp("demo-app", 0).isEmpty());
+ assertTrue(log.recentForApp("demo-app", -1).isEmpty());
+ }
+
+ @Test
+ void recentForApp_whenMultipleAppsRecorded_expectNewestMatchingEventsOnly() {
+ AppAuditLog log =
+ new AppAuditLog(4, Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC));
+ log.append(event("demo-app", "queue.read", AppAuditDecision.ALLOWED, 200));
+ log.append(event("other-app", "alerts.read", AppAuditDecision.DENIED, 403));
+ log.append(event("demo-app", "node.read", AppAuditDecision.ALLOWED, 200));
+
+ List recent = log.recentForApp("demo-app", 1);
+
+ assertEquals(1, recent.size());
+ assertEquals("node.read", recent.getFirst().action());
+ assertEquals(0L, log.deniedCountForApp("demo-app"));
+ }
+
+ @Test
+ void appendDecision_whenRequestIsHostOperator_expectNoAuditEvent() {
+ AppAuditLog log =
+ new AppAuditLog(3, Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC));
+ PlatformApiRequest request =
+ new PlatformApiRequest("GET", List.of("node", "greeting"), java.util.Map.of());
+
+ log.appendDecision(
+ request,
+ PlatformApiAuthorizationDecision.hostAllowed(),
+ AppAuditDecision.ALLOWED,
+ 200,
+ "route_completed");
+
+ assertEquals(0, log.size());
+ assertTrue(log.recentForApp("demo-app", 10).isEmpty());
+ }
+
+ private static AppAuditEvent event(
+ String appId, String action, AppAuditDecision decision, int statusCode) {
+ return new AppAuditEvent(
+ Instant.parse("2026-04-27T00:00:00Z"),
+ appId,
+ "GET",
+ "queue",
+ action,
+ List.of("queue.read"),
+ decision,
+ statusCode,
+ decision.name().toLowerCase(java.util.Locale.ROOT));
+ }
+}
diff --git a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java
new file mode 100644
index 0000000000..553bdbed49
--- /dev/null
+++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java
@@ -0,0 +1,259 @@
+package network.crypta.platform.api;
+
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SuppressWarnings("java:S100")
+class PlatformApiCapabilitiesTest {
+ @Test
+ void authorize_whenHostOperatorRequest_expectAllowedWithoutCapabilities() {
+ PlatformApiAuthorizationDecision decision =
+ PlatformApiCapabilities.authorize(
+ new PlatformApiRequest("POST", List.of("config", "persist"), Map.of()));
+
+ assertTrue(decision.allowed());
+ assertEquals("host_operator", decision.reasonCode());
+ }
+
+ @Test
+ void authorize_whenAppHasReadCapability_expectRepresentativeReadAllowed() {
+ PlatformApiAuthorizationDecision decision =
+ PlatformApiCapabilities.authorize(
+ appRequest("GET", List.of("queue"), List.of("queue.read")));
+
+ assertTrue(decision.allowed());
+ assertEquals(List.of("queue.read"), decision.action().requiredCapabilities());
+ }
+
+ @Test
+ void authorize_whenAppHasRequiredCapabilities_expectAllowed() {
+ for (RouteCase routeCase : allowedRoutes()) {
+ PlatformApiAuthorizationDecision decision =
+ PlatformApiCapabilities.authorize(
+ appRequest(routeCase.method(), routeCase.segments(), routeCase.capabilities()));
+
+ assertTrue(decision.allowed(), routeCase.actionLabel());
+ assertEquals("capability_present", decision.reasonCode(), routeCase.actionLabel());
+ assertEquals(routeCase.endpointFamily(), decision.action().endpointFamily());
+ assertEquals(routeCase.actionLabel(), decision.action().label());
+ assertEquals(routeCase.capabilities(), decision.action().requiredCapabilities());
+ }
+ }
+
+ @Test
+ void authorize_whenAppInsertLacksContentInsert_expectDenied() {
+ PlatformApiAuthorizationDecision decision =
+ PlatformApiCapabilities.authorize(
+ appRequest("POST", List.of("queue", "inserts", "file"), List.of("queue.write")));
+
+ assertFalse(decision.allowed());
+ assertEquals("missing_capability", decision.reasonCode());
+ assertEquals(
+ List.of("content.insert", "queue.write"), decision.action().requiredCapabilities());
+ }
+
+ @Test
+ void authorize_whenAppHitsUnmappedRoute_expectDeniedByDefault() {
+ PlatformApiAuthorizationDecision decision =
+ PlatformApiCapabilities.authorize(
+ appRequest("POST", List.of("node", "greeting"), List.of("node.read")));
+
+ assertFalse(decision.allowed());
+ assertEquals("unmapped_route", decision.reasonCode());
+ }
+
+ @Test
+ void authorize_whenAppRouteIsUnmapped_expectDeniedByDefault() {
+ for (UnmappedRouteCase routeCase : unmappedAppRoutes()) {
+ PlatformApiAuthorizationDecision decision =
+ PlatformApiCapabilities.authorize(
+ appRequest(
+ routeCase.method(),
+ routeCase.segments(),
+ List.of("apps.manage", "node.read", "queue.write")));
+
+ assertFalse(decision.allowed(), routeCase.segments().toString());
+ assertEquals("unmapped_route", decision.reasonCode(), routeCase.segments().toString());
+ assertEquals("unmapped", decision.action().requiredCapabilities().getFirst());
+ }
+ }
+
+ private static List allowedRoutes() {
+ return List.of(
+ route("GET", List.of("node", "greeting"), "node", "node.read", "node.read"),
+ route("GET", List.of("node", "reference"), "node", "node.read", "node.read"),
+ route(
+ "GET",
+ List.of("connectivity"),
+ "connectivity",
+ "connectivity.read",
+ "connectivity.read"),
+ route("GET", List.of("queue"), "queue", "queue.read", "queue.read"),
+ route("GET", List.of("queue", "count"), "queue", "queue.read", "queue.read"),
+ route("GET", List.of("queue", "keys"), "queue", "queue.read", "queue.read"),
+ route(
+ "POST",
+ List.of("queue", "downloads"),
+ "queue",
+ "queue.downloads.create",
+ "queue.write"),
+ route(
+ "POST",
+ List.of("queue", "inserts", "file"),
+ "queue",
+ "queue.inserts.file",
+ "content.insert",
+ "queue.write"),
+ route(
+ "POST",
+ List.of("queue", "requests", "remove"),
+ "queue",
+ "queue.requests.remove",
+ "queue.write"),
+ route(
+ "POST",
+ List.of("queue", "cleanup", "uploads"),
+ "queue",
+ "queue.cleanup.uploads",
+ "queue.write"),
+ route("GET", List.of("peers"), "peers", "peers.read", "peers.read"),
+ route("GET", List.of("peers", "peer-123"), "peers", "peers.read", "peers.read"),
+ route("POST", List.of("peers", "add"), "peers", "peers.add", "peers.write"),
+ route(
+ "POST",
+ List.of("peers", "peer-123", "settings"),
+ "peers",
+ "peers.settings",
+ "peers.write"),
+ route("GET", List.of("config"), "config", "config.read", "config.read"),
+ route("POST", List.of("config", "overrides"), "config", "config.overrides", "config.write"),
+ route(
+ "GET", List.of("security-levels"), "security-levels", "security.read", "security.read"),
+ route(
+ "GET",
+ List.of("security-levels", "network-warning"),
+ "security-levels",
+ "security.network-warning",
+ "security.read"),
+ route(
+ "POST",
+ List.of("security-levels", "network"),
+ "security-levels",
+ "security.network",
+ "security.write"),
+ route("GET", List.of("updates", "core"), "updates", "updates.read", "updates.read"),
+ route(
+ "POST",
+ List.of("updates", "core", "download"),
+ "updates",
+ "updates.core.download",
+ "updates.write"),
+ route("GET", List.of("wizard", "first-time"), "wizard", "wizard.read", "wizard.read"),
+ route(
+ "POST",
+ List.of("wizard", "first-time", "apply"),
+ "wizard",
+ "wizard.first-time.apply",
+ "wizard.write"),
+ route("GET", List.of("alerts"), "alerts", "alerts.read", "alerts.read"),
+ route(
+ "POST", List.of("alerts", "42", "dismiss"), "alerts", "alerts.dismiss", "alerts.write"),
+ route("GET", List.of("diagnostics"), "diagnostics", "diagnostics.read", "diagnostics.read"),
+ route("GET", List.of("apps"), "apps", "apps.read", "apps.read"),
+ route("GET", List.of("apps", "alpha"), "apps", "apps.read", "apps.read"),
+ route("GET", List.of("apps", "alpha", "runtime"), "apps", "apps.read", "apps.read"),
+ route("GET", List.of("apps", "alpha", "logs"), "apps", "apps.read", "apps.read"),
+ route("GET", List.of("apps", "alpha", "permissions"), "apps", "apps.read", "apps.read"),
+ route("GET", List.of("apps", "alpha", "audit"), "apps", "apps.read", "apps.read"),
+ route("POST", List.of("apps", "install"), "apps", "apps.install", "apps.manage"),
+ route("DELETE", List.of("apps", "alpha"), "apps", "apps.uninstall", "apps.manage"),
+ route("GET", List.of("app-catalogs"), "app-catalogs", "catalogs.read", "catalogs.read"),
+ route(
+ "GET",
+ List.of("app-catalogs", "default", "apps"),
+ "app-catalogs",
+ "catalogs.read",
+ "catalogs.read"),
+ route(
+ "GET",
+ List.of("app-catalogs", "default", "apps", "alpha"),
+ "app-catalogs",
+ "catalogs.read",
+ "catalogs.read"),
+ route(
+ "POST",
+ List.of("app-catalogs", "add"),
+ "app-catalogs",
+ "catalogs.add",
+ "catalogs.manage"),
+ route(
+ "POST",
+ List.of("app-catalogs", "default", "apps", "alpha", "install"),
+ "app-catalogs",
+ "catalogs.apps.install",
+ "catalogs.manage"),
+ route(
+ "DELETE",
+ List.of("app-catalogs", "default"),
+ "app-catalogs",
+ "catalogs.remove",
+ "catalogs.manage"));
+ }
+
+ private static List unmappedAppRoutes() {
+ return List.of(
+ new UnmappedRouteCase("POST", List.of("node", "greeting")),
+ new UnmappedRouteCase("GET", List.of("node", "unknown")),
+ new UnmappedRouteCase("GET", List.of("connectivity", "status")),
+ new UnmappedRouteCase("GET", List.of("diagnostics", "heap")),
+ new UnmappedRouteCase("PUT", List.of("queue", "requests", "remove")),
+ new UnmappedRouteCase("GET", List.of("queue", "downloads")),
+ new UnmappedRouteCase("GET", List.of("queue", "requests", "remove")),
+ new UnmappedRouteCase("POST", List.of("queue", "inserts", "unknown")),
+ new UnmappedRouteCase("GET", List.of("peers", "peer-123", "settings")),
+ new UnmappedRouteCase("GET", List.of("config", "overrides")),
+ new UnmappedRouteCase("GET", List.of("config", "persist")),
+ new UnmappedRouteCase("GET", List.of("security-levels", "network")),
+ new UnmappedRouteCase("GET", List.of("updates", "core", "download")),
+ new UnmappedRouteCase("GET", List.of("wizard", "first-time", "apply")),
+ new UnmappedRouteCase("GET", List.of("alerts", "42", "dismiss")),
+ new UnmappedRouteCase("GET", List.of("apps", "alpha", "start")),
+ new UnmappedRouteCase("GET", List.of("apps", "alpha", "unknown")),
+ new UnmappedRouteCase("POST", List.of("apps", "alpha", "logs")),
+ new UnmappedRouteCase("GET", List.of("app-catalogs", "default")),
+ new UnmappedRouteCase("GET", List.of("app-catalogs", "default", "refresh")),
+ new UnmappedRouteCase(
+ "GET", List.of("app-catalogs", "default", "apps", "alpha", "install")),
+ new UnmappedRouteCase(
+ "DELETE", List.of("app-catalogs", "default", "apps", "alpha", "install")));
+ }
+
+ private static RouteCase route(
+ String method,
+ List segments,
+ String endpointFamily,
+ String actionLabel,
+ String... capabilities) {
+ return new RouteCase(method, segments, endpointFamily, actionLabel, List.of(capabilities));
+ }
+
+ private static PlatformApiRequest appRequest(
+ String method, List segments, List permissions) {
+ return new PlatformApiRequest(
+ method, segments, Map.of(), PlatformApiPrincipal.appToken("demo-app", permissions));
+ }
+
+ private record RouteCase(
+ String method,
+ List segments,
+ String endpointFamily,
+ String actionLabel,
+ List capabilities) {}
+
+ private record UnmappedRouteCase(String method, List segments) {}
+}
diff --git a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java
new file mode 100644
index 0000000000..1181988723
--- /dev/null
+++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java
@@ -0,0 +1,118 @@
+package network.crypta.platform.api;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SuppressWarnings("java:S100")
+class PlatformApiRequestTest {
+ @Test
+ void constructor_whenPrincipalOmitted_expectHostOperatorPrincipal() {
+ PlatformApiRequest request =
+ new PlatformApiRequest("get", List.of("node", "greeting"), Map.of());
+
+ assertEquals("GET", request.method());
+ assertEquals(PlatformApiPrincipalType.HOST_OPERATOR, request.principal().type());
+ assertEquals(PlatformApiAuthSource.HOST_LOCAL, request.principal().authSource());
+ assertTrue(request.principal().permissions().isEmpty());
+ }
+
+ @Test
+ void constructor_whenAppPrincipalProvided_expectSortedImmutablePermissions() {
+ PlatformApiRequest request =
+ new PlatformApiRequest(
+ "GET",
+ List.of("queue"),
+ Map.of(),
+ PlatformApiPrincipal.appToken("demo-app", List.of("queue.write", "queue.read")));
+
+ assertEquals(PlatformApiPrincipalType.APP, request.principal().type());
+ assertEquals(PlatformApiAuthSource.APP_TOKEN, request.principal().authSource());
+ assertEquals("demo-app", request.principal().appId());
+ assertEquals(List.of("queue.read", "queue.write"), request.principal().permissions());
+ }
+
+ @Test
+ void constructor_whenMutableInputsChange_expectRequestKeepsImmutableCopies() {
+ ArrayList segments = new ArrayList<>(List.of("queue"));
+ ArrayList values = new ArrayList<>(List.of("downloads"));
+ LinkedHashMap> query = LinkedHashMap.newLinkedHashMap(1);
+ query.put("view", values);
+
+ PlatformApiRequest request = new PlatformApiRequest("get", segments, query);
+ segments.add("mutated");
+ values.add("mutated");
+ query.put("later", List.of("value"));
+
+ assertEquals(List.of("queue"), request.pathSegments());
+ assertEquals(Map.of("view", List.of("downloads")), request.queryParameters());
+ Map> copiedQuery = request.queryParameters();
+ List newValue = List.of("value");
+ assertThrows(UnsupportedOperationException.class, () -> copiedQuery.put("new", newValue));
+ }
+
+ @Test
+ void constructor_whenAppPrincipalHasInvalidShape_expectIllegalArgumentException() {
+ List permissions = List.of("queue.read");
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new PlatformApiPrincipal(
+ PlatformApiPrincipalType.APP,
+ PlatformApiAuthSource.HOST_LOCAL,
+ "demo-app",
+ permissions));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new PlatformApiPrincipal(
+ PlatformApiPrincipalType.APP, PlatformApiAuthSource.APP_TOKEN, " ", permissions));
+ }
+
+ @Test
+ void constructor_whenHostPrincipalCarriesAppFields_expectIllegalArgumentException() {
+ List noPermissions = List.of();
+ List permissions = List.of("queue.read");
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new PlatformApiPrincipal(
+ PlatformApiPrincipalType.HOST_OPERATOR,
+ PlatformApiAuthSource.HOST_LOCAL,
+ "demo-app",
+ noPermissions));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new PlatformApiPrincipal(
+ PlatformApiPrincipalType.HOST_OPERATOR,
+ PlatformApiAuthSource.HOST_LOCAL,
+ null,
+ permissions));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new PlatformApiPrincipal(
+ PlatformApiPrincipalType.HOST_OPERATOR,
+ PlatformApiAuthSource.APP_TOKEN,
+ null,
+ noPermissions));
+ }
+
+ @Test
+ void appToken_whenPermissionIsBlank_expectIllegalArgumentException() {
+ List permissions = List.of("queue.read", " ");
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> PlatformApiPrincipal.appToken("demo-app", permissions));
+ }
+}
diff --git a/platform-apphost/src/main/java/network/crypta/platform/apphost/AppHost.java b/platform-apphost/src/main/java/network/crypta/platform/apphost/AppHost.java
index 0a81437985..cc52f66b15 100644
--- a/platform-apphost/src/main/java/network/crypta/platform/apphost/AppHost.java
+++ b/platform-apphost/src/main/java/network/crypta/platform/apphost/AppHost.java
@@ -155,6 +155,22 @@ InstalledAppSnapshot updateFromDirectory(String appId, Path stagedAppDirectory)
*/
List listRunning();
+ /**
+ * Authenticates a bearer launch token against the host's current live app state.
+ *
+ * Blank, unknown, stopped, and stale launch tokens must not authenticate. Successful
+ * authentication returns a token-free principal containing the running app id and a sorted,
+ * immutable copy of its manifest permissions. Implementations must never return or expose the raw
+ * token through the principal.
+ *
+ * @param token opaque launch token presented by an app process
+ * @return token-free principal when the token belongs to a currently running app, or {@link
+ * Optional#empty()} otherwise
+ */
+ default Optional authenticateLaunchToken(String token) {
+ return Optional.empty();
+ }
+
/**
* Returns token-free process runtime status for one installed app.
*
diff --git a/platform-apphost/src/main/java/network/crypta/platform/apphost/AppTokenPrincipal.java b/platform-apphost/src/main/java/network/crypta/platform/apphost/AppTokenPrincipal.java
new file mode 100644
index 0000000000..0e48d5e76f
--- /dev/null
+++ b/platform-apphost/src/main/java/network/crypta/platform/apphost/AppTokenPrincipal.java
@@ -0,0 +1,46 @@
+package network.crypta.platform.apphost;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Token-free identity derived from a live AppHost launch token.
+ *
+ * The principal intentionally carries only stable app identity and manifest-declared
+ * permissions. It never stores the raw launch token, so diagnostic strings and downstream
+ * authorization decisions cannot accidentally expose bearer credentials.
+ *
+ *
{@link AppHost#authenticateLaunchToken(String)} returns this value after it confirms that a
+ * presented token belongs to a currently running app. The Platform API bridge can then convert it
+ * into its transport-neutral principal model without learning AppHost internals or returning the
+ * token to JSON surfaces. Permissions are sorted to make authorization decisions, logs, and tests
+ * deterministic.
+ *
+ * @param appId stable application identifier for the currently running app
+ * @param permissions immutable manifest permission list sorted lexicographically
+ */
+public record AppTokenPrincipal(String appId, List permissions) {
+ /**
+ * Creates a principal with a normalized, immutable permission view.
+ *
+ * The constructor rejects blank app ids and copies the permission stream into sorted immutable
+ * order. It does not validate permission names against the Platform API capability matrix; that
+ * remains the router's responsibility because manifests may contain permissions for other
+ * app-host features.
+ *
+ * @param appId stable application identifier for the authenticated app process
+ * @param permissions manifest permission list captured from the running app snapshot
+ * @throws IllegalArgumentException if {@code appId} is blank
+ * @throws NullPointerException if {@code appId}, {@code permissions}, or a permission element is
+ * {@code null}
+ */
+ public AppTokenPrincipal {
+ Objects.requireNonNull(appId, "appId");
+ Objects.requireNonNull(permissions, "permissions");
+ if (appId.isBlank()) {
+ throw new IllegalArgumentException("appId must not be blank");
+ }
+ permissions = permissions.stream().sorted(Comparator.naturalOrder()).toList();
+ }
+}
diff --git a/platform-apphost/src/main/java/network/crypta/platform/apphost/runtime/LocalProcessAppHost.java b/platform-apphost/src/main/java/network/crypta/platform/apphost/runtime/LocalProcessAppHost.java
index e55d594b34..f43f45ad53 100644
--- a/platform-apphost/src/main/java/network/crypta/platform/apphost/runtime/LocalProcessAppHost.java
+++ b/platform-apphost/src/main/java/network/crypta/platform/apphost/runtime/LocalProcessAppHost.java
@@ -45,6 +45,7 @@
import network.crypta.platform.apphost.AppProcessLogSnapshot;
import network.crypta.platform.apphost.AppRuntimeState;
import network.crypta.platform.apphost.AppRuntimeStatusSnapshot;
+import network.crypta.platform.apphost.AppTokenPrincipal;
import network.crypta.platform.apphost.InstalledAppPaths;
import network.crypta.platform.apphost.InstalledAppSnapshot;
import network.crypta.platform.apphost.OwnerOnlyFilePermissions;
@@ -658,6 +659,30 @@ public synchronized List listRunning() {
return List.copyOf(running);
}
+ /**
+ * Authenticates a launch token against refreshed live runtime state.
+ *
+ * @param token opaque launch token presented by an app process
+ * @return token-free principal when the token belongs to a currently running app
+ */
+ @Override
+ public synchronized Optional authenticateLaunchToken(String token) {
+ if (token == null || token.isBlank()) {
+ return Optional.empty();
+ }
+ List appIds = new ArrayList<>(runningApps.keySet());
+ appIds.sort(String::compareTo);
+ for (String appId : appIds) {
+ RunningProcess runningProcess = liveRunningProcess(appId);
+ if (runningProcess != null && token.equals(runningProcess.snapshot().token())) {
+ RunningAppSnapshot snapshot = runningProcess.snapshot();
+ return Optional.of(
+ new AppTokenPrincipal(snapshot.appId(), snapshot.manifest().permissions()));
+ }
+ }
+ return Optional.empty();
+ }
+
/**
* Returns token-free runtime status for one installed app.
*
diff --git a/platform-apphost/src/test/java/network/crypta/platform/apphost/runtime/LocalProcessAppHostTest.java b/platform-apphost/src/test/java/network/crypta/platform/apphost/runtime/LocalProcessAppHostTest.java
index ca90c558fc..1ddf8ea728 100644
--- a/platform-apphost/src/test/java/network/crypta/platform/apphost/runtime/LocalProcessAppHostTest.java
+++ b/platform-apphost/src/test/java/network/crypta/platform/apphost/runtime/LocalProcessAppHostTest.java
@@ -53,6 +53,7 @@
import network.crypta.platform.apphost.AppProcessLogSnapshot;
import network.crypta.platform.apphost.AppRuntimeState;
import network.crypta.platform.apphost.AppRuntimeStatusSnapshot;
+import network.crypta.platform.apphost.AppTokenPrincipal;
import network.crypta.platform.apphost.InstalledAppPaths;
import network.crypta.platform.apphost.InstalledAppSnapshot;
import network.crypta.platform.apphost.OwnerOnlyFilePermissions;
@@ -1078,6 +1079,91 @@ void lifecycle_whenInstalledScriptRuns_expectEnvTokenStopAndUninstall() throws I
assertTrue(host.listInstalled().isEmpty());
}
+ @Test
+ void authenticateLaunchToken_whenTokenBelongsToRunningApp_expectTokenFreePrincipal()
+ throws IOException {
+ AppHost host = allowUnsignedHost();
+ host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID));
+ RunningAppSnapshot running = host.start(RUNNER_APP_ID);
+
+ try {
+ AppTokenPrincipal principal = host.authenticateLaunchToken(running.token()).orElseThrow();
+
+ assertEquals(RUNNER_APP_ID, principal.appId());
+ assertEquals(
+ List.of(FILE_READ_PERMISSION, NETWORK_ACCESS_PERMISSION), principal.permissions());
+ assertFalse(principal.toString().contains(running.token()));
+ } finally {
+ host.stop(RUNNER_APP_ID);
+ }
+ }
+
+ @Test
+ void authenticateLaunchToken_whenTokenIsBlankOrUnknown_expectEmpty() throws IOException {
+ AppHost host = allowUnsignedHost();
+ host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID));
+ RunningAppSnapshot running = host.start(RUNNER_APP_ID);
+
+ try {
+ assertTrue(host.authenticateLaunchToken("").isEmpty());
+ assertTrue(host.authenticateLaunchToken(" ").isEmpty());
+ assertTrue(host.authenticateLaunchToken(null).isEmpty());
+ assertTrue(host.authenticateLaunchToken(running.token() + "-unknown").isEmpty());
+ } finally {
+ host.stop(RUNNER_APP_ID);
+ }
+ }
+
+ @Test
+ void authenticateLaunchToken_whenAppStops_expectOldTokenFails() throws IOException {
+ AppHost host = allowUnsignedHost();
+ host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID));
+ RunningAppSnapshot running = host.start(RUNNER_APP_ID);
+
+ assertTrue(host.authenticateLaunchToken(running.token()).isPresent());
+ assertTrue(host.stop(RUNNER_APP_ID));
+
+ assertTrue(host.authenticateLaunchToken(running.token()).isEmpty());
+ }
+
+ @Test
+ void authenticateLaunchToken_whenAppRestarts_expectOldTokenFailsAndNewTokenSucceeds()
+ throws IOException {
+ AppHost host = allowUnsignedHost();
+ host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID));
+ RunningAppSnapshot firstRun = host.start(RUNNER_APP_ID);
+ assertTrue(host.stop(RUNNER_APP_ID));
+
+ RunningAppSnapshot secondRun = host.start(RUNNER_APP_ID);
+
+ try {
+ assertNotEquals(firstRun.token(), secondRun.token());
+ assertTrue(host.authenticateLaunchToken(firstRun.token()).isEmpty());
+ assertEquals(
+ RUNNER_APP_ID, host.authenticateLaunchToken(secondRun.token()).orElseThrow().appId());
+ } finally {
+ host.stop(RUNNER_APP_ID);
+ }
+ }
+
+ @Test
+ void authenticateLaunchToken_whenReturningPermissions_expectImmutableSortedManifestPermissions()
+ throws IOException {
+ AppHost host = allowUnsignedHost();
+ host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID));
+ RunningAppSnapshot running = host.start(RUNNER_APP_ID);
+
+ try {
+ AppTokenPrincipal principal = host.authenticateLaunchToken(running.token()).orElseThrow();
+ List permissions = principal.permissions();
+
+ assertEquals(List.of(FILE_READ_PERMISSION, NETWORK_ACCESS_PERMISSION), permissions);
+ assertThrows(UnsupportedOperationException.class, () -> permissions.add("extra"));
+ } finally {
+ host.stop(RUNNER_APP_ID);
+ }
+ }
+
@Test
void start_whenAppRuns_expectWorkingDirectoryRuntimeStatusAndRedactedLog() throws IOException {
AppEnv appEnv = new AppEnv();
diff --git a/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.css b/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.css
index 44c2da16a4..7e27f0f34a 100644
--- a/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.css
+++ b/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.css
@@ -590,6 +590,46 @@ a {
overflow-wrap: anywhere;
}
+.permission-list {
+ margin: 0;
+ padding-left: 20px;
+ color: var(--shell-text);
+}
+
+.permission-list li {
+ overflow-wrap: anywhere;
+}
+
+.app-audit-list {
+ display: grid;
+ gap: 10px;
+}
+
+.app-audit-event {
+ display: grid;
+ gap: 8px;
+ padding: 10px 12px;
+ border-left: 3px solid rgba(83, 214, 160, 0.72);
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.app-audit-event.is-denied {
+ border-left-color: rgba(255, 104, 104, 0.82);
+}
+
+.app-audit-event-header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.app-audit-action {
+ color: var(--shell-text);
+ font-weight: 700;
+ overflow-wrap: anywhere;
+}
+
.diagnostics-lines,
.diagnostics-export {
margin: 0;
diff --git a/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.js b/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.js
index c06957426d..b3023acd1e 100644
--- a/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.js
+++ b/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.js
@@ -895,6 +895,12 @@
: null;
}
+ function appAuditPath(appId) {
+ return typeof appId === "string" && appId.length > 0
+ ? `apps/${encodeURIComponent(appId)}/audit`
+ : null;
+ }
+
function catalogMutationPath(catalogId, appId, action) {
if (typeof catalogId !== "string" || catalogId.length === 0) {
return null;
@@ -991,6 +997,7 @@
card.className = "app-card";
const runtime = app && app.runtime && typeof app.runtime === "object" ? app.runtime : null;
const logs = app && app.logs && typeof app.logs === "object" ? app.logs : null;
+ const audit = app && app.audit && typeof app.audit === "object" ? app.audit : null;
const runtimeError = typeof app.runtimeError === "string" ? app.runtimeError : "";
const runtimeState =
runtime && typeof runtime.state === "string" && runtime.state ? runtime.state : app.running ? "RUNNING" : "STOPPED";
@@ -1015,6 +1022,14 @@
if (app.uiUrl || app.uiEntry) {
pills.append(createPill(app.uiMode === "static" ? "Static UI" : "UI"));
}
+ const deniedCount = audit && typeof audit.recentDeniedCount === "number"
+ ? audit.recentDeniedCount
+ : typeof app.recentDeniedCount === "number"
+ ? app.recentDeniedCount
+ : 0;
+ if (deniedCount > 0) {
+ pills.append(createPill(`${deniedCount} denied`, "is-error"));
+ }
header.append(heading, pills);
card.append(header);
@@ -1033,10 +1048,13 @@
["Last exit code", runtime && runtime.lastExitCode != null ? scalar(runtime.lastExitCode) : "Unavailable"],
["Last exit at", runtime ? formatIsoTimestamp(runtime.lastExitAt) : "Unavailable"],
["Restart attempts", runtime ? scalar(runtime.currentRestartAttempt) : "Unavailable"],
+ ["Denied app calls", scalar(deniedCount)],
["Process log", logs && logs.available ? `${scalar(logs.sizeBytes)} bytes` : "Unavailable"],
["Runtime detail", runtimeError || (runtime ? "Available" : "Unavailable")],
];
card.append(definitionList(entries));
+ card.append(appPermissionsDetailsNode(app));
+ card.append(appAuditDetailsNode(audit, app.auditError));
const logDetails = appLogDetailsNode(logs, app.logsError);
if (logDetails) {
card.append(logDetails);
@@ -1067,6 +1085,71 @@
return card;
}
+ function appPermissionsDetailsNode(app) {
+ const details = document.createElement("details");
+ details.className = "json-details";
+ const summary = document.createElement("summary");
+ summary.textContent = "Declared permissions";
+ details.append(summary);
+ const permissions = Array.isArray(app.permissions) ? app.permissions : [];
+ if (!permissions.length) {
+ details.append(text("p", "empty-state", "No manifest permissions declared."));
+ return details;
+ }
+ const list = document.createElement("ul");
+ list.className = "permission-list";
+ permissions.forEach((permission) => {
+ list.append(text("li", "", scalar(permission)));
+ });
+ details.append(list);
+ return details;
+ }
+
+ function appAuditDetailsNode(audit, auditError) {
+ const details = document.createElement("details");
+ details.className = "json-details";
+ const summary = document.createElement("summary");
+ summary.textContent = "Recent app audit";
+ details.append(summary);
+ if (auditError) {
+ details.append(text("p", "error-state", auditError));
+ return details;
+ }
+ const events = audit && Array.isArray(audit.events) ? audit.events : [];
+ if (!events.length) {
+ details.append(text("p", "empty-state", "No app-originated Platform API calls recorded."));
+ return details;
+ }
+ const list = document.createElement("div");
+ list.className = "app-audit-list";
+ events.slice(0, 8).forEach((event) => {
+ const safeEvent = event && typeof event === "object" ? event : {};
+ const item = document.createElement("div");
+ const denied = safeEvent.decision === "DENIED";
+ item.className = "app-audit-event" + (denied ? " is-denied" : "");
+ const header = document.createElement("div");
+ header.className = "app-audit-event-header";
+ header.append(
+ createPill(scalar(safeEvent.decision), denied ? "is-error" : "is-success"),
+ text("span", "app-audit-action", scalar(safeEvent.action)),
+ );
+ item.append(header);
+ item.append(
+ definitionList([
+ ["Time", formatIsoTimestamp(safeEvent.timestamp)],
+ ["Method", scalar(safeEvent.method)],
+ ["Endpoint", scalar(safeEvent.endpointFamily)],
+ ["Required", formatPermissions(safeEvent.requiredCapabilities)],
+ ["Status", scalar(safeEvent.statusCode)],
+ ["Reason", scalar(safeEvent.reasonCode)],
+ ]),
+ );
+ list.append(item);
+ });
+ details.append(list);
+ return details;
+ }
+
function appLogDetailsNode(logs, logsError) {
const details = document.createElement("details");
details.className = "json-details";
@@ -2233,10 +2316,13 @@
}
const runtimePath = appRuntimePath(app.appId);
const logsPath = appLogsPath(app.appId, 65536);
+ const auditPath = appAuditPath(app.appId);
let runtime = null;
let runtimeError = "";
let logs = null;
let logsError = "";
+ let audit = app.audit && typeof app.audit === "object" ? app.audit : null;
+ let auditError = "";
try {
const runtimeSnapshot = runtimePath ? await loadOptionalJson(apiUrl(runtimePath)) : null;
runtime =
@@ -2257,7 +2343,17 @@
logsError =
error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
}
- return { ...app, runtime, runtimeError, logs, logsError };
+ try {
+ const auditSnapshot = auditPath ? await loadOptionalJson(apiUrl(auditPath)) : null;
+ audit =
+ auditSnapshot && auditSnapshot.audit && typeof auditSnapshot.audit === "object"
+ ? auditSnapshot.audit
+ : auditSnapshot || audit;
+ } catch (error) {
+ auditError =
+ error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
+ }
+ return { ...app, runtime, runtimeError, logs, logsError, audit, auditError };
}
async function loadCatalogApps(catalog) {
diff --git a/platform-web-shell/src/test/java/network/crypta/platform/webshell/WebShellResourcesTest.java b/platform-web-shell/src/test/java/network/crypta/platform/webshell/WebShellResourcesTest.java
index 4dee6cdaf8..b01a1f48c2 100644
--- a/platform-web-shell/src/test/java/network/crypta/platform/webshell/WebShellResourcesTest.java
+++ b/platform-web-shell/src/test/java/network/crypta/platform/webshell/WebShellResourcesTest.java
@@ -100,6 +100,8 @@ void readText_whenStylesheetResourceRequested_expectAlertTextPreservesNewlines()
assertTrue(stylesheet.contains(".app-card-list {"));
assertTrue(stylesheet.contains(".app-card-actions {"));
assertTrue(stylesheet.contains(".app-log-tail {"));
+ assertTrue(stylesheet.contains(".permission-list {"));
+ assertTrue(stylesheet.contains(".app-audit-event.is-denied {"));
assertTrue(stylesheet.contains(".catalog-app-card {"));
assertTrue(stylesheet.contains(".publisher-forms {"));
assertTrue(stylesheet.contains(".publisher-result-actions {"));
@@ -326,6 +328,7 @@ private static void assertAppsMarkersPresent(String script) {
assertTrue(script.contains("function appUiHref(app)"));
assertTrue(script.contains("function appRuntimePath(appId)"));
assertTrue(script.contains("function appLogsPath(appId, maxBytes)"));
+ assertTrue(script.contains("function appAuditPath(appId)"));
assertTrue(script.contains("async function loadAppRuntimeDetails(app)"));
assertTrue(script.contains("const explicitHref = normalizeAppUiEntryHref(app.uiUrl);"));
assertTrue(
@@ -339,6 +342,9 @@ private static void assertAppsMarkersPresent(String script) {
assertTrue(script.contains("runtimeStoppable ? \"Stop\" : \"Start\""));
assertTrue(script.contains("const uninstallForm = runtimeStoppable"));
assertTrue(script.contains("Runtime log tail"));
+ assertTrue(script.contains("Declared permissions"));
+ assertTrue(script.contains("Recent app audit"));
+ assertTrue(script.contains("function appAuditDetailsNode(audit, auditError)"));
assertTrue(script.contains("app-log-tail"));
assertTrue(script.contains("function renderApps(data)"));
assertTrue(script.contains("function renderCatalogs(catalogs, catalogError)"));
@@ -365,6 +371,7 @@ private static void assertAppsMarkersPresent(String script) {
assertTrue(script.contains("return `apps/${encodedAppId}`;"));
assertTrue(script.contains("`apps/${encodeURIComponent(appId)}/runtime`"));
assertTrue(script.contains("`apps/${encodeURIComponent(appId)}/logs?maxBytes="));
+ assertTrue(script.contains("`apps/${encodeURIComponent(appId)}/audit`"));
assertTrue(script.contains("return `app-catalogs/${encodedCatalogId}/refresh`;"));
assertTrue(
script.contains(
diff --git a/src/test/java/network/crypta/clients/http/PlatformApiToadletTest.java b/src/test/java/network/crypta/clients/http/PlatformApiToadletTest.java
index 6443775f00..0ba528c91b 100644
--- a/src/test/java/network/crypta/clients/http/PlatformApiToadletTest.java
+++ b/src/test/java/network/crypta/clients/http/PlatformApiToadletTest.java
@@ -5,10 +5,13 @@
import java.util.Arrays;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import network.crypta.platform.api.PlatformApiRequest;
import network.crypta.platform.api.PlatformApiResponse;
import network.crypta.platform.api.PlatformApiRouter;
+import network.crypta.platform.apphost.AppHost;
+import network.crypta.platform.apphost.AppTokenPrincipal;
import network.crypta.support.MultiValueTable;
import network.crypta.support.SimpleReadOnlyArrayBucket;
import network.crypta.support.api.Bucket;
@@ -23,8 +26,10 @@
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -34,6 +39,7 @@
class PlatformApiToadletTest {
@Mock private PlatformApiRouter router;
+ @Mock private AppHost appHost;
@Mock private ToadletContext ctx;
@Mock private HTTPRequest request;
@Mock private Bucket requestBody;
@@ -43,6 +49,207 @@ class PlatformApiToadletTest {
@BeforeEach
void setUp() {
toadlet = new PlatformApiToadlet(router);
+ lenient().when(request.getHeader("x-crypta-app-token")).thenReturn(null);
+ lenient().when(request.getHeader("authorization")).thenReturn(null);
+ }
+
+ @Test
+ void handleMethodGET_whenAppTokenHeaderAuthenticates_routesAppPrincipalRequest()
+ throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(request.getHeader("x-crypta-app-token")).thenReturn("secret-token");
+ when(appHost.authenticateLaunchToken("secret-token"))
+ .thenReturn(Optional.of(new AppTokenPrincipal("alpha", List.of("queue.read"))));
+ when(request.getParameterNames()).thenReturn(List.of());
+ when(router.route(any(PlatformApiRequest.class)))
+ .thenReturn(PlatformApiResponse.ok(Map.of("ok", true)));
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/queue"), request, ctx);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class);
+ verify(router).route(captor.capture());
+ assertEquals("alpha", captor.getValue().principal().appId());
+ assertEquals(List.of("queue.read"), captor.getValue().principal().permissions());
+ verify(ctx, never()).isAllowedFullAccess();
+ }
+
+ @Test
+ void handleMethodGET_whenBearerTokenAuthenticates_routesAppPrincipalRequest() throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(request.getHeader("authorization")).thenReturn("Bearer secret-token");
+ when(appHost.authenticateLaunchToken("secret-token"))
+ .thenReturn(Optional.of(new AppTokenPrincipal("alpha", List.of("node.read"))));
+ when(request.getParameterNames()).thenReturn(List.of());
+ when(router.route(any(PlatformApiRequest.class)))
+ .thenReturn(PlatformApiResponse.ok(Map.of("ok", true)));
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class);
+ verify(router).route(captor.capture());
+ assertEquals("alpha", captor.getValue().principal().appId());
+ assertEquals(List.of("node.read"), captor.getValue().principal().permissions());
+ }
+
+ @Test
+ void handleMethodGET_whenBearerTokenDoesNotAuthenticate_routesHostOperatorRequest()
+ throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(request.getHeader("authorization")).thenReturn("Bearer proxy-token");
+ when(appHost.authenticateLaunchToken("proxy-token")).thenReturn(Optional.empty());
+ when(ctx.isAllowedFullAccess()).thenReturn(true);
+ when(request.getParameterNames()).thenReturn(List.of());
+ when(router.route(any(PlatformApiRequest.class)))
+ .thenReturn(PlatformApiResponse.ok(Map.of("ok", true)));
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class);
+ verify(router).route(captor.capture());
+ assertEquals("HOST_OPERATOR", captor.getValue().principal().type().name());
+ verify(ctx).isAllowedFullAccess();
+ }
+
+ @Test
+ void handleMethodGET_whenBearerTokenLookupFails_expectJson500WithoutHostFallback()
+ throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(request.getHeader("authorization")).thenReturn("Bearer proxy-token");
+ when(appHost.authenticateLaunchToken("proxy-token"))
+ .thenThrow(new IllegalStateException("apphost state unavailable"));
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx);
+
+ verifyNoInteractions(router);
+ verify(ctx, never()).isAllowedFullAccess();
+ ReplyHeadersCapture replyHeaders = captureReplyHeaders();
+ assertEquals(500, replyHeaders.statusCode());
+ assertEquals("Internal Server Error", replyHeaders.reasonPhrase());
+ BodyWriteCapture bodyWrite = captureBodyWrite();
+ assertEquals(
+ "{\"error\":{\"code\":\"internal_error\",\"message\":\"Unexpected platform API"
+ + " failure.\"}}",
+ bodyWrite.bodyText());
+ assertFalse(bodyWrite.bodyText().contains("proxy-token"));
+ }
+
+ @Test
+ void handleMethodGET_whenTokenOnlyAppearsInQuery_routesHostRequestWithoutTokenAuth()
+ throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(ctx.isAllowedFullAccess()).thenReturn(true);
+ when(request.getParameterNames()).thenReturn(List.of("token"));
+ when(request.getMultipleParam("token")).thenReturn(new String[] {"secret-token"});
+ when(router.route(any(PlatformApiRequest.class)))
+ .thenReturn(PlatformApiResponse.ok(Map.of("ok", true)));
+
+ toadlet.handleMethodGET(
+ URI.create("http://localhost/api/v1/node/greeting?token=secret-token"), request, ctx);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class);
+ verify(router).route(captor.capture());
+ assertEquals("HOST_OPERATOR", captor.getValue().principal().type().name());
+ assertEquals(Map.of("token", List.of("secret-token")), captor.getValue().queryParameters());
+ verify(appHost, never()).authenticateLaunchToken(any());
+ }
+
+ @Test
+ void handleMethodGET_whenAppTokenInvalid_expect401WithoutTokenLeak() throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(request.getHeader("x-crypta-app-token")).thenReturn("secret-token");
+ when(appHost.authenticateLaunchToken("secret-token")).thenReturn(Optional.empty());
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx);
+
+ verifyNoInteractions(router);
+ ReplyHeadersCapture replyHeaders = captureReplyHeaders();
+ assertEquals(401, replyHeaders.statusCode());
+ assertEquals("Unauthorized", replyHeaders.reasonPhrase());
+ BodyWriteCapture bodyWrite = captureBodyWrite();
+ assertEquals(
+ "{\"error\":{\"code\":\"invalid_app_token\",\"message\":\"Invalid app token.\"}}",
+ bodyWrite.bodyText());
+ assertFalse(bodyWrite.bodyText().contains("secret-token"));
+ }
+
+ @Test
+ void handleMethodGET_whenAppTokenAuthenticationFails_expectJson500WithoutRouting()
+ throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(request.getHeader("x-crypta-app-token")).thenReturn("secret-token");
+ when(appHost.authenticateLaunchToken("secret-token"))
+ .thenThrow(new IllegalStateException("apphost unavailable"));
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx);
+
+ verifyNoInteractions(router);
+ verify(ctx, never()).isAllowedFullAccess();
+ ReplyHeadersCapture replyHeaders = captureReplyHeaders();
+ assertEquals(500, replyHeaders.statusCode());
+ assertEquals("Internal Server Error", replyHeaders.reasonPhrase());
+ BodyWriteCapture bodyWrite = captureBodyWrite();
+ assertEquals(
+ "{\"error\":{\"code\":\"internal_error\",\"message\":\"Unexpected platform API"
+ + " failure.\"}}",
+ bodyWrite.bodyText());
+ assertFalse(bodyWrite.bodyText().contains("secret-token"));
+ }
+
+ @Test
+ void handleMethodGET_whenAppTokenProvidedButAppHostUnavailable_expect401WithoutRouting()
+ throws Exception {
+ when(request.getHeader("x-crypta-app-token")).thenReturn("secret-token");
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx);
+
+ verifyNoInteractions(router);
+ verify(ctx, never()).isAllowedFullAccess();
+ ReplyHeadersCapture replyHeaders = captureReplyHeaders();
+ assertEquals(401, replyHeaders.statusCode());
+ assertEquals("Unauthorized", replyHeaders.reasonPhrase());
+ BodyWriteCapture bodyWrite = captureBodyWrite();
+ assertEquals(
+ "{\"error\":{\"code\":\"invalid_app_token\",\"message\":\"App token authentication is"
+ + " unavailable.\"}}",
+ bodyWrite.bodyText());
+ assertFalse(bodyWrite.bodyText().contains("secret-token"));
+ }
+
+ @Test
+ void handleMethodGET_whenAppTokenHeaderBlank_expect401WithoutRouting() throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(request.getHeader("x-crypta-app-token")).thenReturn(" ");
+ when(appHost.authenticateLaunchToken("")).thenReturn(Optional.empty());
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx);
+
+ verifyNoInteractions(router);
+ verify(ctx, never()).isAllowedFullAccess();
+ verify(appHost).authenticateLaunchToken("");
+ ReplyHeadersCapture replyHeaders = captureReplyHeaders();
+ assertEquals(401, replyHeaders.statusCode());
+ BodyWriteCapture bodyWrite = captureBodyWrite();
+ assertEquals(
+ "{\"error\":{\"code\":\"invalid_app_token\",\"message\":\"Invalid app token.\"}}",
+ bodyWrite.bodyText());
+ }
+
+ @Test
+ void handleMethodGET_whenAuthorizationHeaderIsMalformedBearer_expectHostOperatorRequest()
+ throws Exception {
+ toadlet = new PlatformApiToadlet(router, appHost);
+ when(request.getHeader("authorization")).thenReturn("BearerToken secret-token");
+ when(ctx.isAllowedFullAccess()).thenReturn(true);
+ when(request.getParameterNames()).thenReturn(List.of());
+ when(router.route(any(PlatformApiRequest.class)))
+ .thenReturn(PlatformApiResponse.ok(Map.of("ok", true)));
+
+ toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class);
+ verify(router).route(captor.capture());
+ assertEquals("HOST_OPERATOR", captor.getValue().principal().type().name());
+ verify(appHost, never()).authenticateLaunchToken(any());
}
@Test
@@ -476,6 +683,7 @@ void handleMethodPOST_whenProtectedMutationPasswordMissing_expectJson403WithoutR
toadlet.handleMethodPOST(URI.create(requestUri), request, ctx);
verifyNoInteractions(router);
+ verify(ctx, never()).checkFormPassword(request, URI.create(requestUri).getPath());
assertForbiddenBody();
}
diff --git a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java
index 720ec7d01b..eb01fa2dde 100644
--- a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java
+++ b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java
@@ -2056,6 +2056,201 @@ void route_whenAppsDescribeRequested_expectAppEnvelopeAndMergedRunningState() th
response.body());
}
+ @Test
+ void route_whenAppPermissionsRequested_expectDeclaredPermissionsAndDeniedCount()
+ throws Exception {
+ AppAuditLog auditLog = new AppAuditLog();
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog);
+ auditLog.append(
+ new AppAuditEvent(
+ STARTED_AT,
+ APP_ID,
+ "POST",
+ "queue",
+ "queue.requests.remove",
+ List.of("queue.write"),
+ AppAuditDecision.DENIED,
+ 403,
+ "missing_capability"));
+ when(appHost.describe(APP_ID)).thenReturn(Optional.of(installedSnapshot()));
+ when(appHost.status(APP_ID)).thenReturn(Optional.empty());
+
+ PlatformApiResponse response =
+ auditedRouter.route(request("GET", List.of("apps", APP_ID, "permissions"), Map.of()));
+
+ assertEquals(200, response.statusCode());
+ assertTrue(response.body().contains("\"permissions\":[\"network.access\",\"file.read\"]"));
+ assertTrue(response.body().contains("\"recentDeniedCount\":1"));
+ assertFalse(response.body().contains("token-"));
+ }
+
+ @Test
+ void route_whenAppAuditRequested_expectRecentTokenFreeAuditEntries() throws Exception {
+ AppAuditLog auditLog = new AppAuditLog();
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog);
+ auditLog.append(
+ new AppAuditEvent(
+ STARTED_AT,
+ APP_ID,
+ "GET",
+ "node",
+ "node.read",
+ List.of("node.read"),
+ AppAuditDecision.ALLOWED,
+ 200,
+ "route_completed"));
+ when(appHost.describe(APP_ID)).thenReturn(Optional.of(installedSnapshot()));
+
+ PlatformApiResponse response =
+ auditedRouter.route(request("GET", List.of("apps", APP_ID, "audit"), Map.of()));
+
+ assertEquals(200, response.statusCode());
+ assertTrue(response.body().contains("\"decision\":\"ALLOWED\""));
+ assertTrue(response.body().contains("\"requiredCapabilities\":[\"node.read\"]"));
+ assertFalse(response.body().contains("token-"));
+ }
+
+ @Test
+ void route_whenAppPrincipalHasCapability_expectDispatchAndAllowedAudit() {
+ AppAuditLog auditLog = new AppAuditLog();
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog);
+ when(nodeInfoPort.greeting())
+ .thenReturn(new NodeGreetingSnapshot("Crypta", "1.0", 7, "abc123", true, "gzip", "en"));
+
+ PlatformApiResponse response =
+ auditedRouter.route(
+ appRequest(List.of("node", "greeting"), Map.of(), List.of("node.read")));
+
+ assertEquals(200, response.statusCode());
+ List events = auditLog.recentForApp(APP_ID, 10);
+ assertEquals(1, events.size());
+ assertEquals(AppAuditDecision.ALLOWED, events.getFirst().decision());
+ assertEquals("node.read", events.getFirst().action());
+ }
+
+ @Test
+ void route_whenAppPrincipalMissingCapability_expectForbiddenAndDeniedAudit() {
+ AppAuditLog auditLog = new AppAuditLog();
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog);
+
+ PlatformApiResponse response =
+ auditedRouter.route(
+ appRequest(List.of("node", "greeting"), Map.of(), List.of("queue.read")));
+
+ assertEquals(403, response.statusCode());
+ assertTrue(response.body().contains("\"code\":\"forbidden\""));
+ assertFalse(response.body().contains("secret-token"));
+ verifyNoInteractions(nodeInfoPort);
+ List events = auditLog.recentForApp(APP_ID, 10);
+ assertEquals(1, events.size());
+ assertEquals(AppAuditDecision.DENIED, events.getFirst().decision());
+ assertEquals("missing_capability", events.getFirst().reasonCode());
+ }
+
+ @Test
+ void route_whenAppPrincipalHitsUnsupportedQueueReadShape_expectForbiddenAndDeniedAudit() {
+ AppAuditLog auditLog = new AppAuditLog();
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog);
+
+ PlatformApiResponse response =
+ auditedRouter.route(
+ appRequest(List.of("queue", "requests", "remove"), Map.of(), List.of("queue.read")));
+
+ assertEquals(403, response.statusCode());
+ assertTrue(response.body().contains("\"code\":\"forbidden\""));
+ List events = auditLog.recentForApp(APP_ID, 10);
+ assertEquals(1, events.size());
+ assertEquals(AppAuditDecision.DENIED, events.getFirst().decision());
+ assertEquals("queue.unmapped", events.getFirst().action());
+ assertEquals("unmapped_route", events.getFirst().reasonCode());
+ }
+
+ @Test
+ void route_whenAppPrincipalHitsUnsupportedAlertsReadShape_expectForbiddenAndDeniedAudit() {
+ AppAuditLog auditLog = new AppAuditLog();
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog);
+
+ PlatformApiResponse response =
+ auditedRouter.route(
+ appRequest(List.of("alerts", "42", "dismiss"), Map.of(), List.of("alerts.read")));
+
+ assertEquals(403, response.statusCode());
+ assertTrue(response.body().contains("\"code\":\"forbidden\""));
+ verifyNoInteractions(alertMutationPort);
+ List events = auditLog.recentForApp(APP_ID, 10);
+ assertEquals(1, events.size());
+ assertEquals(AppAuditDecision.DENIED, events.getFirst().decision());
+ assertEquals("alerts.unmapped", events.getFirst().action());
+ assertEquals("unmapped_route", events.getFirst().reasonCode());
+ }
+
+ @Test
+ void route_whenAppPrincipalHitsUnsupportedConfigReadShape_expectForbiddenAndDeniedAudit() {
+ AppAuditLog auditLog = new AppAuditLog();
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog);
+
+ PlatformApiResponse response =
+ auditedRouter.route(
+ appRequest(List.of("config", "overrides"), Map.of(), List.of("config.read")));
+
+ assertEquals(403, response.statusCode());
+ assertTrue(response.body().contains("\"code\":\"forbidden\""));
+ verifyNoInteractions(configPort);
+ List events = auditLog.recentForApp(APP_ID, 10);
+ assertEquals(1, events.size());
+ assertEquals(AppAuditDecision.DENIED, events.getFirst().decision());
+ assertEquals("config.unmapped", events.getFirst().action());
+ assertEquals("unmapped_route", events.getFirst().reasonCode());
+ }
+
+ @Test
+ void route_whenAppPrincipalHitsUnsupportedAppsReadShape_expectForbiddenAndDeniedAudit() {
+ AppAuditLog auditLog = new AppAuditLog();
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog);
+
+ PlatformApiResponse response =
+ auditedRouter.route(
+ appRequest(List.of("apps", APP_ID, "start"), Map.of(), List.of("apps.read")));
+
+ assertEquals(403, response.statusCode());
+ assertTrue(response.body().contains("\"code\":\"forbidden\""));
+ verifyNoInteractions(appHost);
+ List events = auditLog.recentForApp(APP_ID, 10);
+ assertEquals(1, events.size());
+ assertEquals(AppAuditDecision.DENIED, events.getFirst().decision());
+ assertEquals("apps.unmapped", events.getFirst().action());
+ assertEquals("unmapped_route", events.getFirst().reasonCode());
+ }
+
+ @Test
+ void route_whenAppPrincipalHitsUnsupportedCatalogsReadShape_expectForbiddenAndDeniedAudit() {
+ AppAuditLog auditLog = new AppAuditLog();
+ AppCatalogManager catalogManager = mock(AppCatalogManager.class);
+ PlatformApiRouter auditedRouter =
+ new PlatformApiRouter(runtimePorts, appHost, catalogManager, null, auditLog);
+
+ PlatformApiResponse response =
+ auditedRouter.route(
+ appRequest(List.of("app-catalogs", "core"), Map.of(), List.of("catalogs.read")));
+
+ assertEquals(403, response.statusCode());
+ assertTrue(response.body().contains("\"code\":\"forbidden\""));
+ verifyNoInteractions(catalogManager);
+ List events = auditLog.recentForApp(APP_ID, 10);
+ assertEquals(1, events.size());
+ assertEquals(AppAuditDecision.DENIED, events.getFirst().decision());
+ assertEquals("app-catalogs.unmapped", events.getFirst().action());
+ assertEquals("unmapped_route", events.getFirst().reasonCode());
+ }
+
@Test
void route_whenAppRuntimeRequested_expectRuntimeEnvelopeWithoutToken() throws Exception {
when(appHost.runtimeStatus(APP_ID))
@@ -2869,6 +3064,14 @@ private static PlatformApiRequest request(
return new PlatformApiRequest(method, pathSegments, queryParameters);
}
+ private static PlatformApiRequest appRequest(
+ List pathSegments,
+ Map> queryParameters,
+ List permissions) {
+ return new PlatformApiRequest(
+ "GET", pathSegments, queryParameters, PlatformApiPrincipal.appToken(APP_ID, permissions));
+ }
+
@SafeVarargs
private static Map orderedJson(Map.Entry... entries) {
LinkedHashMap json = LinkedHashMap.newLinkedHashMap(entries.length);
@@ -3033,7 +3236,7 @@ private static Map summaryFor(
boolean running,
Long pid,
Instant startedAt) {
- LinkedHashMap summary = LinkedHashMap.newLinkedHashMap(12);
+ LinkedHashMap summary = LinkedHashMap.newLinkedHashMap(14);
summary.put("appId", appId);
summary.put("name", appName);
summary.put("version", appVersion);
@@ -3049,11 +3252,13 @@ private static Map summaryFor(
summary.put("running", running);
summary.put("pid", pid);
summary.put("startedAt", startedAt == null ? null : startedAt.toString());
+ summary.put("recentDeniedCount", 0L);
+ summary.put("audit", emptyAuditSummary(appId));
return summary;
}
private static Map unknownSummary() {
- LinkedHashMap summary = LinkedHashMap.newLinkedHashMap(12);
+ LinkedHashMap summary = LinkedHashMap.newLinkedHashMap(14);
summary.put("appId", APP_ID);
summary.put("name", null);
summary.put("version", null);
@@ -3069,9 +3274,20 @@ private static Map unknownSummary() {
summary.put("running", false);
summary.put("pid", null);
summary.put("startedAt", null);
+ summary.put("recentDeniedCount", 0L);
+ summary.put("audit", emptyAuditSummary(APP_ID));
return summary;
}
+ private static Map emptyAuditSummary(String appId) {
+ LinkedHashMap audit = LinkedHashMap.newLinkedHashMap(4);
+ audit.put("appId", appId);
+ audit.put("boundedEventLimit", AppAuditLog.DEFAULT_APP_EVENT_LIMIT);
+ audit.put("recentDeniedCount", 0L);
+ audit.put("events", List.of());
+ return audit;
+ }
+
private static String uiMode(String appUiEntry) {
if (appUiEntry == null) {
return "none";