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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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 校验未通过)。")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user