Merge ios-completion: device builds unblocked, access token on both clients, P2 wave

Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 deletions

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.api.routes.Endpoints
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
*/
public sealed interface AuthProbeResult {
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
public data object Accepted : AuthProbeResult
/**
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
*/
public data object AuthDisabled : AuthProbeResult
/** **401** — wrong token. UI: "访问令牌不正确". */
public data object InvalidToken : AuthProbeResult
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
public data object RateLimited : AuthProbeResult
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
public data object Malformed : AuthProbeResult
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
public data class Unexpected(val status: Int) : AuthProbeResult
}
/**
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
*
* Transport-level failures propagate unwrapped so the caller can classify them with
* [PairingError.classify] (same convention as [runPairingProbe]).
*
* Never logs the token; the token appears only in the JSON body, never in the URL.
*/
public suspend fun probeAccessToken(
endpoint: HostEndpoint,
http: HttpTransport,
token: String,
): AuthProbeResult {
// Boundary validation first: a token outside the server's charset/length can never be correct, and
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
val response = http.send(request)
return classifyAuthResponse(response)
}
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
HTTP_NO_CONTENT ->
// The ONE discriminator between "token correct" and "this host has no auth at all".
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
else -> AuthProbeResult.Unexpected(response.status)
}
/**
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
* nothing about our token.
*/
private fun HttpResponse.carriesAuthCookie(): Boolean =
headers.entries.any { (name, value) ->
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
}
/**
* Sentinel status for "the request could not even be built" — unreachable for a validated
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
*/
private const val UNBUILDABLE_REQUEST = 0
private const val SET_COOKIE_HEADER = "Set-Cookie"
private const val HTTP_NO_CONTENT = 204
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_TOO_MANY_REQUESTS = 429

View File

@@ -52,6 +52,17 @@ public sealed interface PairingError {
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError
/**
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
*
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
*/
public data object AccessTokenRequired : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError

View File

@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
/**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
http: HttpTransport,
ws: TermTransport,
timeout: Duration?,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
?: PairingProbeResult.Failure(PairingError.Timeout)
}
@@ -75,10 +79,16 @@ private suspend fun performProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource,
): PairingProbeResult {
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
probeReachability(endpoint, http)?.let { return it }
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
// wants an access token we do not have.
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected.
@@ -105,7 +115,7 @@ private suspend fun performProbe(
return try {
when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
}
} finally {
withContext(NonCancellable) { runCatching { connection.close() } }
@@ -120,14 +130,20 @@ private suspend fun performProbe(
private suspend fun probeReachability(
endpoint: HostEndpoint,
http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult.Failure? {
val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
if (response.status == HTTP_UNAUTHORIZED) {
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
sessionId: String,
endpoint: HostEndpoint,
http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult {
val response = try {
http.send(
HttpRequest(
method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
),
)
} catch (cancel: CancellationException) {
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
}
return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
)
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
return "$scheme://$host$portPart"
}
/**
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
* derived from the single point ([AuthCookie]), never hand-assembled.
*/
private fun authHeaders(token: String?): Map<String, String> =
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404

View File

@@ -24,6 +24,7 @@ import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
@@ -41,10 +42,18 @@ import java.util.UUID
*
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
*
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
* an unauthenticated LAN host behaves exactly as before. A **401** becomes the typed
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
*/
public class ApiClient(
public val endpoint: HostEndpoint,
private val http: HttpTransport,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) {
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
@@ -343,9 +352,27 @@ public class ApiClient(
// ── Internals ──────────────────────────────────────────────────────────────────────────
/**
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
* status mapping — otherwise a 401 on a read route would degrade into a generic status error and
* the UI would never learn to ask for the token (ios-completion §1.1).
*
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
* family): there a 401 is the server classifying a HOST-side git credential failure
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
*/
private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
return http.send(request)
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
?: throw ApiClientError.InvalidRequest
val response = http.send(request)
if (response.status == HttpStatus.UNAUTHORIZED &&
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
) {
throw ApiClientError.Unauthorized
}
return response
}
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */

View File

@@ -20,6 +20,15 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/**
* **401** from a route whose 401 can only be the access-token gate (RO or guarded): this host has
* `WEBTERM_TOKEN` set and our request carried no cookie / a wrong one (ios-completion §1.1).
* Distinct from [Forbidden] (that is the Origin guard) — the UI must send the user to enter or fix
* the host's access token. The git-write family is excluded (it defines its own 401 — see
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
*/
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
/** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
@@ -10,6 +11,7 @@ internal object HttpStatus {
const val OK = 200
const val NO_CONTENT = 204
const val BAD_REQUEST = 400
const val UNAUTHORIZED = 401
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val CONFLICT = 409
@@ -27,6 +29,12 @@ internal object HttpStatus {
internal object HeaderName {
const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type"
/**
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
*/
const val ACCEPT = "Accept"
}
internal object ContentType {
@@ -47,6 +55,23 @@ internal enum class OriginPolicy {
GUARDED,
}
/**
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
* exception is visible instead of hidden in a call site. The Android analogue of iOS
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
*/
internal enum class UnauthorizedPolicy {
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
ACCESS_TOKEN_GATE,
/**
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
* family, where the server classifies a HOST-side git credential failure as
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
*/
ROUTE_DEFINED,
}
/**
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
@@ -58,19 +83,37 @@ internal class ApiRoute(
val originPolicy: OriginPolicy,
val body: ByteArray? = null,
val percentEncodedQuery: String? = null,
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
val accept: String? = null,
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
) {
/**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
*
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
* ones included, because the server's access-token gate sits in front of the whole app. A null
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
* The token is secret material: it is only ever placed in this header — never in [path], never in
* [percentEncodedQuery], never logged.
*/
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
val headers = LinkedHashMap<String, String>()
if (originPolicy == OriginPolicy.GUARDED) {
headers[HeaderName.ORIGIN] = endpoint.originHeader
}
if (accessToken != null) {
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
}
if (accept != null) {
headers[HeaderName.ACCEPT] = accept
}
if (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
}

View File

@@ -134,7 +134,40 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
/**
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
*
* Two non-obvious requirements, both server-driven (`src/server.ts`):
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
* auth cookie (the token is in the body — this IS the login).
*
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
*/
fun auth(token: String): ApiRoute {
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
return ApiRoute(
HttpMethod.POST,
AUTH_PATH,
OriginPolicy.GUARDED,
body = body,
accept = ContentType.JSON,
)
}
private const val AUTH_PATH = "/auth"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
//
// Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in
// `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and
// `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so
// they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface
// as a worktree failure instead of the token flow.
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
@@ -162,11 +195,11 @@ internal object Endpoints {
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
gitWriteRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/**
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
@@ -176,11 +209,11 @@ internal object Endpoints {
* It is NOT a pull: no working tree, no index, no merge.
*/
fun gitFetch(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
@@ -212,17 +245,48 @@ internal object Endpoints {
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
/**
* Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]).
*
* The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route
* whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree
* trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute].
*/
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
return ApiRoute(
method,
path,
OriginPolicy.GUARDED,
body = body,
unauthorizedPolicy = unauthorizedPolicy,
)
}
/**
* Build a GUARDED **git-ops** route with a `ModelJson`-encoded JSON body (Origin stamped in
* [ApiRoute]).
*
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
* must display verbatim instead of the access-token copy (mirrors iOS `gitOpsRoute`).
*
* Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`.
* The worktree trio must NOT be built here (F5): its handler has no 401 of its own.
*/
private fun <T> gitWriteRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED)
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
private const val GIT_LOG_MAX = 50
@@ -259,6 +323,10 @@ internal object Endpoints {
@Serializable
private data class FcmTokenBody(val token: String)
/** `POST /auth` body. Holds secret material — never log a request built from it. */
@Serializable
private data class AuthBody(val token: String)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)