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.wire
/**
* The optional shared access token (`WEBTERM_TOKEN`) contract — the Android half of the FROZEN
* cross-client contract (ios-completion §1.1; server: `src/http/auth.ts`, `src/server.ts`).
*
* ### Why a hand-written header (and NOT an OkHttp `CookieJar`)
* A native client already KNOWS its token, so it stamps `Cookie: webterm_auth=<t>` itself and never
* parses `Set-Cookie` / relies on a cookie store. Reason (frozen): a cookie jar's behaviour on a **WS
* upgrade** differs between HTTP stacks and is hard to test, whereas a hand-written header is the same
* pattern as the existing `Origin` header ([HostEndpoint.originHeader]) and can be pinned by pure unit
* tests. [AuthCookie] is therefore the SINGLE point of derivation — assembling the header string
* anywhere else is a review CRITICAL, exactly like hand-assembling an Origin.
*
* ### Orthogonal to Origin
* The token does NOT replace the Origin/CSWSH check: the server runs Origin first, then the cookie
* (`src/server.ts` upgrade handler), so a request carries BOTH (Origin only on guarded routes, per the
* Origin-iff-guarded rule; the cookie on EVERY request plus the WS upgrade).
*
* ### Secret handling (non-negotiable, §1.1)
* The token is secret material: it lives only in Android-Keystore-backed storage, is NEVER logged,
* NEVER placed in a URL query, and NEVER put into a crash report. Nothing in this file logs.
*/
public object AuthCookie {
/** The server's auth cookie name (`src/http/auth.ts` `AUTH_COOKIE_NAME`). */
public const val NAME: String = "webterm_auth"
/** The request header the value is stamped on. */
public const val HEADER_NAME: String = "Cookie"
/**
* `webterm_auth=<token>` — the exact header value the server's `parseCookieHeader` expects. No
* attributes, no URL-encoding: the token charset ([AccessTokenRule]) is already cookie-safe, and
* the server reads the value verbatim.
*/
public fun headerValue(token: String): String = "$NAME=$token"
}
/**
* Client-side access-token validator (validate at the boundary). Mirrors the server's config check:
* `[A-Za-z0-9._~+/=-]`, 16512 chars — a server that fails this refuses to start, so a token outside
* it can never be correct and must be rejected before any network I/O (and before it reaches storage).
*
* The charset also guarantees the token cannot forge cookie syntax (no `;`, `,`, `=`-prefix, space or
* quote injection into the `Cookie` header).
*
* [normalize] trims ONCE here (a pasted/QR-sourced token often carries stray whitespace) and returns
* the trimmed token, or `null` when it is not a possible server token.
*/
public object AccessTokenRule {
/** Server-side minimum (CLAUDE.md / config validation). */
public const val MIN_LENGTH: Int = 16
/** Server-side maximum. */
public const val MAX_LENGTH: Int = 512
private val ALLOWED: Set<Char> =
(('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('.', '_', '~', '+', '/', '=', '-')).toSet()
/** The trimmed token when it could be a valid server token, else `null`. Never logs its input. */
public fun normalize(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.length < MIN_LENGTH || trimmed.length > MAX_LENGTH) return null
if (!trimmed.all { it in ALLOWED }) return null
return trimmed
}
}
/**
* The access-token injection seam: "what token, if any, should be presented to THIS host?".
*
* Defined here (top of the dependency graph) so all three consumers share ONE seam without new module
* edges: `:api-client` (every REST route + the pairing probe), `:transport-okhttp` (the WS upgrade),
* and `:app` (the Keystore-backed store that implements it). It is the token analogue of
* `:transport-okhttp`'s `ClientIdentityProvider` mTLS seam.
*
* `suspend` because the production implementation reads encrypted at-rest storage (Tink +
* AndroidKeyStore) and must be able to hop off the caller's thread; it is consulted per request so a
* token added, changed or removed later takes effect with no client rebuild.
*
* Returning `null` means "this host has no token" — the request then carries no `Cookie` header at
* all, which is exactly the zero-config LAN behaviour of a server with `WEBTERM_TOKEN` unset.
*/
public fun interface AccessTokenSource {
/** The access token for [endpoint], or `null` when none is stored. MUST NOT log the token. */
public suspend fun tokenFor(endpoint: HostEndpoint): String?
public companion object {
/** No token for any host — the default everywhere, so an unauthenticated host is unaffected. */
public val NONE: AccessTokenSource = AccessTokenSource { null }
}
}

View File

@@ -0,0 +1,31 @@
package wang.yaojia.webterm.wire
import java.io.IOException
/**
* The server answered the WebSocket upgrade with **HTTP 401** instead of 101 — a NON-retryable
* rejection (ios-completion §1.1).
*
* Declared in `:wire-protocol` so the three modules that must agree on it have no new dependency
* edges: `:transport-okhttp` throws it out of `TermTransport.connect`, `:session-core`'s
* `SessionEngine` maps it to a TERMINAL failure (never into the reconnect back-off ladder — retrying
* would 401 forever), and `:api-client`'s pairing probe can classify it.
*
* The server writes a bare `HTTP/1.1 401 Unauthorized` for BOTH of its two upgrade rejections — a
* foreign `Origin` and a missing/invalid access-token cookie (`src/server.ts` upgrade handler) — so
* this type deliberately does NOT claim which one it was. Both are permanent-until-reconfigured, so
* the engine's treatment (terminal, no back-off) is correct either way; the pairing probe disambiguates
* by ordering (it authenticates over HTTP first, so a 401 there is the token and a 401 on the later
* upgrade is the Origin).
*
* [cause] carries the transport's verbatim error so existing cause-chain classification
* (`PairingError.classify`) still sees the original type.
*/
public class UnauthorizedUpgradeException(
cause: Throwable? = null,
) : IOException(MESSAGE, cause) {
private companion object {
/** Fixed copy — never interpolate the token or any header value into an exception message. */
const val MESSAGE = "the server rejected the WebSocket upgrade with HTTP 401"
}
}

View File

@@ -0,0 +1,109 @@
package wang.yaojia.webterm.wire
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* B5 · the FROZEN access-token contract (ios-completion §1.1) as pure functions:
* - [AuthCookie] is the SINGLE point of `Cookie: webterm_auth=<t>` derivation (the token analogue of
* [HostEndpoint.originHeader] — never hand-assembled at a call site);
* - [AccessTokenRule] mirrors the server's config validator (`[A-Za-z0-9._~+/=-]`, 16512) so a
* malformed token is rejected BEFORE any network I/O;
* - [AccessTokenSource.NONE] is the "no token configured" default (LAN zero-config unchanged).
*/
class AccessTokenTest {
// ── AuthCookie: the one derivation point ────────────────────────────────────────────────────
@Test
fun `cookie name and header name match the server contract`() {
assertEquals("webterm_auth", AuthCookie.NAME)
assertEquals("Cookie", AuthCookie.HEADER_NAME)
}
@Test
fun `header value is name equals token with no extra whitespace or attributes`() {
assertEquals("webterm_auth=abcdefghijklmnop", AuthCookie.headerValue("abcdefghijklmnop"))
}
// ── AccessTokenRule: charset + length, trimmed once ─────────────────────────────────────────
@Test
fun `a valid token in the server charset normalizes to itself`() {
val token = "Aa0._~+/=-Aa0._~+/=-"
assertEquals(token, AccessTokenRule.normalize(token))
}
@Test
fun `surrounding whitespace is trimmed once at the boundary`() {
assertEquals("0123456789abcdef", AccessTokenRule.normalize(" 0123456789abcdef\n"))
}
@Test
fun `a token shorter than the minimum or longer than the maximum is rejected`() {
assertEquals(16, AccessTokenRule.MIN_LENGTH)
assertEquals(512, AccessTokenRule.MAX_LENGTH)
assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH - 1)))
assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH + 1)))
assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH)))
assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH)))
}
@Test
fun `an empty or blank token is rejected`() {
assertNull(AccessTokenRule.normalize(""))
assertNull(AccessTokenRule.normalize(" "))
}
@Test
fun `characters outside the server charset are rejected`() {
// A `;` would forge a second cookie attribute; a space/quote/CJK char is simply not in the set.
listOf(
"abcdefghijklmno;x",
"abcdefghijklmno x",
"abcdefghijklmno\"x",
"abcdefghijklmno\nx",
"令牌令牌令牌令牌令牌令牌令牌令牌",
).forEach { raw ->
assertNull(AccessTokenRule.normalize(raw), "must reject: $raw")
}
}
// ── AccessTokenSource ───────────────────────────────────────────────────────────────────────
@Test
fun `NONE yields no token so an unauthenticated LAN host keeps working`() = runTest {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
assertNull(AccessTokenSource.NONE.tokenFor(endpoint))
}
@Test
fun `a source can be a lambda keyed by the endpoint`() = runTest {
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
val other = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.6:3000"))
val source = AccessTokenSource { endpoint ->
if (endpoint == lan) "0123456789abcdef" else null
}
assertEquals("0123456789abcdef", source.tokenFor(lan))
assertNull(source.tokenFor(other))
}
// ── UnauthorizedUpgradeException ────────────────────────────────────────────────────────────
@Test
fun `the 401 upgrade error is an IOException keeping the verbatim cause`() {
val cause = IllegalStateException("expected 101, got 401")
val error: java.io.IOException = UnauthorizedUpgradeException(cause)
assertEquals(cause, error.cause, "the verbatim transport cause must survive for classify()")
assertEquals(
"the server rejected the WebSocket upgrade with HTTP 401",
error.message,
"fixed copy — no header/token value may ever be interpolated into the message",
)
}
}