diff --git a/apps/android/app/src/main/kotlin/com/zer0state/zzthat/account/Account.kt b/apps/android/app/src/main/kotlin/com/zer0state/zzthat/account/Account.kt index 36c04c7..e5886a5 100644 --- a/apps/android/app/src/main/kotlin/com/zer0state/zzthat/account/Account.kt +++ b/apps/android/app/src/main/kotlin/com/zer0state/zzthat/account/Account.kt @@ -331,8 +331,9 @@ fun SignInContent(state: SignInScreenState, onSignIn: (TokenRequest.Provider) -> ButtonKind.SECONDARY, enabled = enabled, ) { onSignIn(TokenRequest.Provider.dev) } - // Android does not offer Apple (ZQ23). - TokenRequest.Provider.apple -> Unit + // Android does not offer Apple (ZQ23). SignInProviders.shown + // never lists the generator's unknown case (T106). + TokenRequest.Provider.apple, TokenRequest.Provider.unknown_default_open_api -> Unit } } } @@ -396,7 +397,8 @@ suspend fun signIn( when (provider) { TokenRequest.Provider.google -> if (GoogleIdTokens.isConfigured(app.config.googleWebClientId)) google(nonce) else ProviderToken.Failed TokenRequest.Provider.dev -> FlavorFeatures.developerIdToken?.let(ProviderToken::Token) ?: ProviderToken.Failed - TokenRequest.Provider.apple -> ProviderToken.Failed + // The app never signs in with the generator's unknown case (T106). + TokenRequest.Provider.apple, TokenRequest.Provider.unknown_default_open_api -> ProviderToken.Failed } } if (outcome == SignInFlow.Outcome.SignedIn) app.completeSignIn(provider) diff --git a/apps/android/app/src/main/kotlin/com/zer0state/zzthat/app/AppModel.kt b/apps/android/app/src/main/kotlin/com/zer0state/zzthat/app/AppModel.kt index 8f5fd18..44b2af9 100644 --- a/apps/android/app/src/main/kotlin/com/zer0state/zzthat/app/AppModel.kt +++ b/apps/android/app/src/main/kotlin/com/zer0state/zzthat/app/AppModel.kt @@ -86,8 +86,13 @@ class AppModel( /** The read pipeline with the bundled lists and the server's list version (FR-044). */ val pipeline: ReadPipeline get() = ReadPipeline.of(bundledLists, discovery?.wordlistVersion) - /** Discovery decides the sign-in buttons (FR-040). */ - val authProviders: List get() = discovery?.authProviders.orEmpty().map { it.value } + /** + * Discovery decides the sign-in buttons (FR-040). A provider the contract + * does not list yet has no button, so it is dropped (T106), as on iOS. + */ + val authProviders: List get() = discovery?.authProviders.orEmpty() + .filter { it != Discovery.AuthProviders.unknown_default_open_api } + .map { it.value } // MARK: Launch and foreground @@ -124,7 +129,8 @@ class AppModel( /** Account shows the provider the server reports (runtime.md, Localization). */ suspend fun noteAccount(account: Account) { - val first = account.providers.firstOrNull() ?: return + // A provider the contract does not list yet has no name to show (T106). + val first = account.providers.firstOrNull { it != Account.Providers.unknown_default_open_api } ?: return providerStore.set(first.value) provider = first.value } diff --git a/apps/android/app/src/main/kotlin/com/zer0state/zzthat/app/Copy.kt b/apps/android/app/src/main/kotlin/com/zer0state/zzthat/app/Copy.kt index 39ea0cc..724d043 100644 --- a/apps/android/app/src/main/kotlin/com/zer0state/zzthat/app/Copy.kt +++ b/apps/android/app/src/main/kotlin/com/zer0state/zzthat/app/Copy.kt @@ -50,6 +50,11 @@ val OwnedCode.Status.res: Int OwnedCode.Status.used -> R.string.codes_status_used OwnedCode.Status.expired -> R.string.codes_status_expired OwnedCode.Status.revoked -> R.string.codes_status_revoked + // A status the contract does not list yet (T106). It is not active, so + // the screens offer no Revoke. There is no copy for it, and copy comes + // only from zzThis, so it shows as expired, the nearest not-active + // state. iOS decodes the same value to expired, so both apps agree. + OwnedCode.Status.unknown_default_open_api -> R.string.codes_status_expired } @get:StringRes @@ -59,4 +64,7 @@ val ReportRequest.Reason.res: Int ReportRequest.Reason.harassment -> R.string.report_reason_harassment ReportRequest.Reason.personalMinusData -> R.string.report_reason_personal_data ReportRequest.Reason.other -> R.string.report_reason_other + // A request enum: the app picks the reason from REASONS and never + // sends this generated case (T106). Labelled as other if it ever did. + ReportRequest.Reason.unknown_default_open_api -> R.string.report_reason_other } diff --git a/apps/android/core-api/build.gradle.kts b/apps/android/core-api/build.gradle.kts index 8899166..d087db7 100644 --- a/apps/android/core-api/build.gradle.kts +++ b/apps/android/core-api/build.gradle.kts @@ -47,7 +47,18 @@ val openApiGenerate = tasks.register("openApiGenerate") { "dateLibrary=java8", "sourceFolder=src/main/kotlin", "omitGradleWrapper=true", + // zzThis RM-075, D-2026-10-13-09 (tasks.md T106, #54): a value the + // contract does not list yet decodes to the generated + // unknown_default_open_api case instead of failing the whole + // response. Every `when` over a generated enum handles that case. + "enumUnknownDefaultCase=true", ).joinToString(","), + // The generator escapes Discovery's `contract` property as a reserved + // word. With enumUnknownDefaultCase, its enum serializer template then + // writes the escaped name HTML-encoded (``Contract`Serializer`), + // which does not compile (OpenAPI Generator 7.25.0). Only the Kotlin + // name changes; the wire name stays "contract". + "--name-mappings", "contract=contractVersion", "--global-property", "apiTests=false,modelTests=false,apiDocs=false,modelDocs=false", // Generator configuration, not hand-written types (runtime.md, API // client step 6). The app builds its own Retrofit and OkHttp diff --git a/apps/android/core-api/src/main/kotlin/com/zer0state/zzthat/api/DemoApi.kt b/apps/android/core-api/src/main/kotlin/com/zer0state/zzthat/api/DemoApi.kt index 949f01e..c4cf257 100644 --- a/apps/android/core-api/src/main/kotlin/com/zer0state/zzthat/api/DemoApi.kt +++ b/apps/android/core-api/src/main/kotlin/com/zer0state/zzthat/api/DemoApi.kt @@ -196,7 +196,7 @@ class DemoApi : MetaApi, AuthApi, CodesApi, RecordsApi, ReportsApi { ) val DISCOVERY = Discovery( - contract = Discovery.Contract._1, + contractVersion = Discovery.ContractVersion._1, freePublic = true, authProviders = listOf(Discovery.AuthProviders.apple, Discovery.AuthProviders.google), scopes = listOf(Discovery.Scopes.free_public), diff --git a/apps/android/core-api/src/main/kotlin/com/zer0state/zzthat/api/ZZService.kt b/apps/android/core-api/src/main/kotlin/com/zer0state/zzthat/api/ZZService.kt index 26b4e46..ccbd200 100644 --- a/apps/android/core-api/src/main/kotlin/com/zer0state/zzthat/api/ZZService.kt +++ b/apps/android/core-api/src/main/kotlin/com/zer0state/zzthat/api/ZZService.kt @@ -234,18 +234,24 @@ class ZZService( } return try { when (status) { + // An error code the contract does not list decodes to the + // generator's unknown case (T106). An error body is not a + // record, so it stays what it was before T106: a body that + // does not match its schema, which is unavailable. 404 -> { - ZZJson.strict.decodeFromString(NotFoundError.serializer(), text) - ApiFailure.NotFound + val error = ZZJson.strict.decodeFromString(NotFoundError.serializer(), text) + if (error.error == NotFoundError.Error.unknown_default_open_api) ApiFailure.Unavailable else ApiFailure.NotFound } 429 -> { - ZZJson.strict.decodeFromString(RateLimitedError.serializer(), text) + val error = ZZJson.strict.decodeFromString(RateLimitedError.serializer(), text) + if (error.error == RateLimitedError.Error.unknown_default_open_api) return ApiFailure.Unavailable val retryAfter = response.headers()["Retry-After"]?.trim()?.toIntOrNull()?.takeIf { it >= 1 } if (retryAfter == null) ApiFailure.Unavailable else ApiFailure.RateLimited(retryAfter) } else -> { val error = ZZJson.strict.decodeFromString(Error.serializer(), text) when { + error.error == Error.Error.unknown_default_open_api -> ApiFailure.Unavailable status == 401 -> ApiFailure.Unauthorized error.error == Error.Error.contractMinusVersion -> ApiFailure.ContractVersion error.error == Error.Error.notMinusFound -> ApiFailure.NotFound diff --git a/apps/android/core-api/src/test/kotlin/com/zer0state/zzthat/api/DecodingTest.kt b/apps/android/core-api/src/test/kotlin/com/zer0state/zzthat/api/DecodingTest.kt index c111111..57a00c3 100644 --- a/apps/android/core-api/src/test/kotlin/com/zer0state/zzthat/api/DecodingTest.kt +++ b/apps/android/core-api/src/test/kotlin/com/zer0state/zzthat/api/DecodingTest.kt @@ -85,10 +85,17 @@ class DecodingTest { } } + /** + * Before T106 this body was refused. zzThis D-2026-10-13-09 (RM-075, #54) + * reverses that: a value contract 1 does not list decodes to the + * generator's unknown case, and the rest of the body is kept. + */ @Test - fun anEnumValueOutsideContract1IsRefused() = runBlocking { + fun anEnumValueOutsideContract1DecodesToTheUnknownCase() = runBlocking { ApiStub { ApiStub.Reply(200, Fixtures.ACCOUNT.replace("\"dev\"", "\"github\"")) }.use { stub -> - assertEquals(ApiFailure.Unavailable, stub.service(accessToken = "a").me().failureOrNull) + val account = (stub.service(accessToken = "a").me() as ApiResult.Ok).value + assertEquals(listOf(Account.Providers.unknown_default_open_api), account.providers) + assertEquals("account-1", account.id) } } } diff --git a/apps/android/core-api/src/test/kotlin/com/zer0state/zzthat/api/TolerantReaderTest.kt b/apps/android/core-api/src/test/kotlin/com/zer0state/zzthat/api/TolerantReaderTest.kt index 854f6c7..8f1b2a6 100644 --- a/apps/android/core-api/src/test/kotlin/com/zer0state/zzthat/api/TolerantReaderTest.kt +++ b/apps/android/core-api/src/test/kotlin/com/zer0state/zzthat/api/TolerantReaderTest.kt @@ -1,5 +1,8 @@ package com.zer0state.zzthat.api +import com.zer0state.zzthat.api.generated.models.Discovery +import com.zer0state.zzthat.api.generated.models.OwnedCode +import com.zer0state.zzthat.api.generated.models.Resolve import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test @@ -7,8 +10,9 @@ import org.junit.Test /** * tasks.md T095, zzThis RM-075 (D-2026-10-10-12): the generated client is a * tolerant reader, so an additive, optional field such as the planned - * Resolve `creator` (RM-112) does not break a shipped app. An unknown enum - * value is not tolerated yet: issue #54. + * Resolve `creator` (RM-112) does not break a shipped app. A value a + * response enum does not list yet decodes to the generator's + * unknown_default_open_api case (T106, #54, D-2026-10-13-09). */ class TolerantReaderTest { private fun resolve(body: String) = runBlocking { @@ -24,4 +28,45 @@ class TolerantReaderTest { ) assertEquals("Lost cat", (result as ApiResult.Ok).value.record.record.title) } + + @Test + fun resolveWithAnUnknownViewShowsTheRecord() { + val result = resolve( + """{"view":"auditor","canonical":"zz-kathy-lost-cat-zz",""" + + """"record":{"title":"Lost cat","body":"Answers on this page.","updated_at":"2026-10-04T00:00:00.123Z"},""" + + """"share":{"text":"zz-kathy-lost-cat-zz","url":null}}""", + ) + val record = (result as ApiResult.Ok).value.record + assertEquals(Resolve.View.unknown_default_open_api, record.view) + assertEquals("Lost cat", record.record.title) + assertEquals("Answers on this page.", record.record.body) + assertEquals("zz-kathy-lost-cat-zz", record.share.text) + } + + @Test + fun codesWithAnUnknownStatusAndScopeDecode() = runBlocking { + val page = Fixtures.CODE_PAGE.replace("\"active\"", "\"paused\"").replace("\"free_public\"", "\"galaxy\"") + val result = ApiStub { ApiStub.Reply(200, page) }.use { stub -> stub.service(accessToken = "a").myCodes() } + val row = (result as ApiResult.Ok).value.codes.single() + assertEquals(OwnedCode.Status.unknown_default_open_api, row.status) + assertEquals(OwnedCode.Scope.unknown_default_open_api, row.scope) + assertEquals("Lost cat", row.title) + } + + @Test + fun discoveryWithAnUnknownProviderDecodes() = runBlocking { + val body = Fixtures.DISCOVERY.replace("[\"dev\"]", "[\"passkey\",\"dev\"]") + val result = ApiStub { ApiStub.Reply(200, body) }.use { stub -> stub.service().discovery() } + val discovery = (result as ApiResult.Ok).value + assertEquals(listOf(Discovery.AuthProviders.unknown_default_open_api, Discovery.AuthProviders.dev), discovery.authProviders) + } + + /** An error body is not a record: an error code the contract does not list stays unavailable, as before T106. */ + @Test + fun anUnknownErrorCodeIsUnavailable() = runBlocking { + val result = ApiStub { ApiStub.Reply(403, """{"error":"brand-new"}""") }.use { stub -> + stub.service(accessToken = "a").myCodes() + } + assertEquals(ApiFailure.Unavailable, result.failureOrNull) + } } diff --git a/apps/ios/Packages/ZZAPI/Sources/ZZAPI/EnumTolerance.swift b/apps/ios/Packages/ZZAPI/Sources/ZZAPI/EnumTolerance.swift new file mode 100644 index 0000000..4d28bf4 --- /dev/null +++ b/apps/ios/Packages/ZZAPI/Sources/ZZAPI/EnumTolerance.swift @@ -0,0 +1,176 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime + +/// zzThis RM-075 and D-2026-10-13-09 (tasks.md T106, #54): a value that a +/// response enum does not list yet must not fail the whole response. +/// +/// Swift OpenAPI Generator 1.13.1 has no fallback case for a closed enum, and +/// the generated types are never edited (runtime.md, API client). So this +/// middleware rewrites an unknown value to a fallback case before the +/// generated client decodes the response. The known values come from each +/// generated enum's `allCases`, so a value the contract adds is known as soon +/// as the contract pin moves. Only the JSON paths are written here: one for +/// each response enum of an operation that `ZZService` calls. +/// +/// A value of the wrong JSON type is left alone, so it still fails decoding. +/// Error bodies are not rewritten: an error code the contract does not list +/// stays `unavailable`, as on Android. +public struct EnumToleranceMiddleware: ClientMiddleware { + public init() {} + + public func intercept( + _ request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + var (response, responseBody) = try await next(request, body, baseURL) + Self.tolerateCacheControl(&response, operationID: operationID) + guard response.status.kind == .successful, let responseBody, + let fields = Self.bodyFields[operationID] else { return (response, responseBody) } + let data = try await Data(collecting: responseBody, upTo: Self.maxBody) + guard let rewritten = Self.rewrite(data, fields) else { return (response, HTTPBody(data)) } + if response.headerFields[.contentLength] != nil { + response.headerFields[.contentLength] = String(rewritten.count) + } + return (response, HTTPBody(rewritten)) + } + + /// Far above any contract 1 body; a 50-row page of codes is a few KB. + static let maxBody = 8 * 1024 * 1024 + + // MARK: Fallbacks + + typealias Schemas = Components.Schemas + + /// The fallback for each response enum, by operation. Each choice is the + /// safest reading of a value the app cannot know yet. + static let bodyFields: [String: [Field]] = [ + "getDiscovery": [ + // The X-ZZ-Contract header is the version check (FR-032), and it + // stays strict. No code reads this body field. + .replace("contract", with: Schemas.Discovery.ContractPayload._1), + // A provider or scope the app cannot know has no button and no + // use, so it is dropped from its list. + .drop("auth_providers", "*", of: Schemas.Discovery.AuthProvidersPayloadPayload.self), + .drop("scopes", "*", of: Schemas.Discovery.ScopesPayloadPayload.self), + ], + "exchangeToken": tokenFields, + "refreshToken": tokenFields, + // A provider without a name to show is dropped; Account shows the + // first one the app knows. + "getMe": [.drop("providers", "*", of: Schemas.Account.ProvidersPayloadPayload.self)], + // A view the app cannot know reads like `public`: the app shows the + // record the server returned and does not branch on the view. + "resolveCode": [.replace("view", with: Schemas.Resolve.ViewPayload._public)], + "mintCode": codeFields, + "rerollCode": codeFields, + "listMyCodes": [ + .replace("codes", "*", "status", with: Schemas.OwnedCode.StatusPayload.expired), + .replace("codes", "*", "scope", with: Schemas.OwnedCode.ScopePayload.freePublic), + ], + // A 200 from revoke means the code is revoked. + "revokeCode": [.replace("status", with: Operations.RevokeCode.Output.Ok.Body.JsonPayload.StatusPayload.revoked)], + ] + + /// The app sends the access token as a bearer token and never reads its + /// lifetime: a 401 starts the refresh (Auth step 7). + static let tokenFields: [Field] = [ + .replace("token_type", with: Schemas.TokenResponse.TokenTypePayload.bearer), + .replace("expires_in", with: Schemas.TokenResponse.ExpiresInPayload._900), + ] + + /// A status the app cannot know is not active, so there is no Revoke. It + /// reads as expired, the nearest not-active state with copy, because copy + /// comes only from zzThis; Android shows the same. Only `free_public` + /// codes are minted, and no screen reads the scope. + static let codeFields: [Field] = [ + .replace("status", with: Schemas.Code.StatusPayload.expired), + .replace("scope", with: Schemas.Code.ScopePayload.freePublic), + ] + + /// `Cache-Control` is a response enum too. An unknown value becomes + /// `no-store`, which every response may carry and which only means that + /// FR-041 does not keep this resolve. + static func tolerateCacheControl(_ response: inout HTTPResponse, operationID: String) { + guard let value = response.headerFields[.cacheControl] else { return } + let known = operationID == "resolveCode" && response.status == .ok + ? Components.Headers.ResolveCache.allCases.map(\.rawValue) + : Components.Headers.NoStore.allCases.map(\.rawValue) + if !known.contains(value) { + response.headerFields[.cacheControl] = Components.Headers.NoStore.noStore.rawValue + } + } + + // MARK: Rewriting + + /// One response enum: where it sits in the body, which values the + /// generated enum lists, and what an unknown value becomes. + struct Field: Sendable { + /// Keys from the body's root; `*` is every element of an array. + let path: [String] + /// True for a value of the enum's raw type that the enum does not list. + let isUnknown: @Sendable (Any) -> Bool + /// The raw value to write, or nil to drop the element from its array. + let fallback: (any Sendable)? + + static func replace(_ path: String..., with fallback: E) -> Field + where E.RawValue: Hashable & Sendable { + Field(path: path, isUnknown: unknown(E.self), fallback: fallback.rawValue) + } + + static func drop(_ path: String..., of _: E.Type) -> Field + where E.RawValue: Hashable & Sendable { + precondition(path.last == "*", "only an array element can be dropped") + return Field(path: path, isUnknown: unknown(E.self), fallback: nil) + } + + private static func unknown(_: E.Type) -> @Sendable (Any) -> Bool + where E.RawValue: Hashable & Sendable { + let known = Set(E.allCases.map(\.rawValue)) + return { value in (value as? E.RawValue).map { !known.contains($0) } ?? false } + } + } + + /// The body with every unknown value replaced or dropped, or nil when + /// nothing changed (or it is not JSON), so the original bytes pass through. + static func rewrite(_ data: Data, _ fields: [Field]) -> Data? { + guard var root = try? JSONSerialization.jsonObject(with: data) else { return nil } + var changed = false + for field in fields where apply(field, to: &root, at: field.path[...]) { changed = true } + guard changed else { return nil } + return try? JSONSerialization.data(withJSONObject: root) + } + + private static func apply(_ field: Field, to value: inout Any, at path: ArraySlice) -> Bool { + guard let step = path.first else { return false } + let rest = path.dropFirst() + if step == "*" { + guard var array = value as? [Any] else { return false } + var changed = false + if !rest.isEmpty { + for index in array.indices where apply(field, to: &array[index], at: rest) { changed = true } + } else if let fallback = field.fallback { + for index in array.indices where field.isUnknown(array[index]) { + array[index] = fallback + changed = true + } + } else { + let count = array.count + array.removeAll(where: field.isUnknown) + changed = array.count != count + } + if changed { value = array } + return changed + } + guard var object = value as? [String: Any], var child = object[step] else { return false } + if rest.isEmpty { + guard let fallback = field.fallback, field.isUnknown(child) else { return false } + child = fallback + } else if !apply(field, to: &child, at: rest) { + return false + } + object[step] = child + value = object + return true + } +} diff --git a/apps/ios/Packages/ZZAPI/Sources/ZZAPI/ZZService.swift b/apps/ios/Packages/ZZAPI/Sources/ZZAPI/ZZService.swift index 2af0a47..a71be60 100644 --- a/apps/ios/Packages/ZZAPI/Sources/ZZAPI/ZZService.swift +++ b/apps/ios/Packages/ZZAPI/Sources/ZZAPI/ZZService.swift @@ -80,11 +80,13 @@ public struct ZZService: Sendable { let transport = transport ?? URLSessionTransport( configuration: .init(session: URLSession(configuration: sessionConfiguration()))) let configuration = Configuration(dateTranscoder: LenientISO8601Transcoder()) + // EnumToleranceMiddleware is first, so it sees the final response, + // after the one 401 retry (T106). let refresh = Client(serverURL: origin, configuration: configuration, transport: transport, - middlewares: [ContractMiddleware()]) + middlewares: [EnumToleranceMiddleware(), ContractMiddleware()]) return (refresh, { tokens in Client(serverURL: origin, configuration: configuration, transport: transport, - middlewares: [ContractMiddleware(), AuthMiddleware(tokens: tokens)]) + middlewares: [EnumToleranceMiddleware(), ContractMiddleware(), AuthMiddleware(tokens: tokens)]) }) } diff --git a/apps/ios/Packages/ZZAPI/Tests/ZZAPITests/TolerantReaderTests.swift b/apps/ios/Packages/ZZAPI/Tests/ZZAPITests/TolerantReaderTests.swift index 45de1bd..357c803 100644 --- a/apps/ios/Packages/ZZAPI/Tests/ZZAPITests/TolerantReaderTests.swift +++ b/apps/ios/Packages/ZZAPI/Tests/ZZAPITests/TolerantReaderTests.swift @@ -4,12 +4,23 @@ import Testing /// tasks.md T095, zzThis RM-075 (D-2026-10-10-12): the generated client is /// a tolerant reader, so an additive, optional field such as the planned -/// Resolve `creator` (RM-112) does not break a shipped app. An unknown enum -/// value is not tolerated yet: issue #54. +/// Resolve `creator` (RM-112) does not break a shipped app. A value a +/// response enum does not list yet decodes to a fallback case (T106, #54, +/// D-2026-10-13-09; `EnumToleranceMiddleware`). @Suite("Tolerant reader") struct TolerantReaderTests { - static func make(body: String) async throws -> ZZService { - try await service(StubTransport { _ in StubTransport.Reply(200, nil).withBody(body) }) + static func make(body: String, status: Int = 200, headers: [String: String] = [:]) async throws -> ZZService { + try await service(StubTransport { _ in StubTransport.Reply(status, nil, headers: headers).withBody(body) }, + accessToken: "a") + } + + static func fixture(_ name: String, _ replacements: [String: String]) throws -> String { + var text = try Fixture.text(name) + for (old, new) in replacements { + #expect(text.contains(old), "\(name) has \(old)") + text = text.replacingOccurrences(of: old, with: new) + } + return text } @Test func ignoresUnknownKeys() async throws { @@ -22,4 +33,83 @@ struct TolerantReaderTests { #expect(response.record.record.title == "Lost cat") #expect(response.record.share.text == "zz-kathy-lost-cat-zz") } + + @Test func resolveWithAnUnknownViewShowsTheRecord() async throws { + let body = """ + {"view":"auditor","canonical":"zz-kathy-lost-cat-zz",\ + "record":{"title":"Lost cat","body":"Answers on this page.","updated_at":"2026-10-04T00:00:00.123Z"},\ + "share":{"text":"zz-kathy-lost-cat-zz","url":null}} + """ + let response = try await Self.make(body: body).resolve("zz-kathy-lost-cat-zz").get() + #expect(response.record.view == ._public) + #expect(response.record.record.title == "Lost cat") + #expect(response.record.record.body == "Answers on this page.") + #expect(response.record.share.text == "zz-kathy-lost-cat-zz") + } + + @Test func anUnknownCacheControlIsNotKept() async throws { + let body = try Fixture.text("resolve") + let service = try await Self.make(body: body, headers: ["Cache-Control": "public, max-age=120"]) + let response = try await service.resolve("zz-kathy-lost-cat-zz").get() + #expect(response.cacheable == false) + } + + @Test func codesWithAnUnknownStatusAndScopeReadAsExpired() async throws { + let body = try Self.fixture("code-page", [#""active""#: #""paused""#, #""free_public""#: #""galaxy""#]) + let page = try await Self.make(body: body).myCodes().get() + #expect(page.codes.count == 1) + #expect(page.codes[0].status == .expired) + #expect(page.codes[0].scope == .freePublic) + #expect(page.codes[0].title == "Lost cat") + } + + @Test func aMintedCodeWithAnUnknownStatusDecodes() async throws { + let body = try Self.fixture("code", [#""active""#: #""paused""#]) + let code = try await Self.make(body: body, status: 201).mint(title: "t", body: "").get() + #expect(code.status == .expired) + #expect(code.canonical == "zz-copper-lantern-sky-zz") + } + + @Test func discoveryDropsAProviderAndAScopeItCannotKnow() async throws { + let body = try Self.fixture("discovery", [#"["dev"]"#: #"["passkey","dev"]"#, + #"["free_public"]"#: #"["free_public","galaxy"]"#]) + let discovery = try await Self.make(body: body).discovery().get() + #expect(discovery.authProviders == [.dev]) + #expect(discovery.scopes == [.freePublic]) + } + + @Test func anAccountWithAnUnknownProviderDecodes() async throws { + let body = try Self.fixture("account", [#"["dev"]"#: #"["github","dev"]"#]) + let account = try await Self.make(body: body).me().get() + #expect(account.providers == [.dev]) + #expect(account.id == "account-1") + } + + @Test func tokensWithAnUnknownTypeAndLifetimeSignIn() async throws { + let body = try Self.fixture("tokens", [#""Bearer""#: #""DPoP""#, "900": "600"]) + let tokens = TokenStore(storage: MemoryRefreshTokenStorage(token: nil)) + let transport = StubTransport { _ in StubTransport.Reply(200, nil).withBody(body) } + let service = await ZZService.live(origin: URL(string: "http://localhost:8787")!, tokens: tokens, transport: transport) + try await service.exchange(provider: .dev, idToken: "dev:local-tester", nonce: "nonce-1").get() + #expect(await tokens.currentAccessToken == "access-1") + } + + @Test func revokeWithAnUnknownStatusSucceeds() async throws { + try await Self.make(body: #"{"status":"gone"}"#).revoke(codeID: "code-1").get() + } + + /// Only an unknown value is rewritten. A value of the wrong JSON type is + /// still refused. + @Test func anEnumValueOfTheWrongTypeIsStillRefused() async throws { + let body = try Self.fixture("resolve", [#""view":"public""#: #""view":5"#]) + let result = await try Self.make(body: body).resolve("zz-kathy-lost-cat-zz") + #expect(result.failure == .unavailable) + } + + /// An error body is not a record: an error code the contract does not + /// list stays unavailable, as on Android. + @Test func anUnknownErrorCodeIsUnavailable() async throws { + let result = await try Self.make(body: #"{"error":"brand-new"}"#, status: 403).myCodes() + #expect(result.failure == .unavailable) + } } diff --git a/specs/001-zzthat-apps/runtime.md b/specs/001-zzthat-apps/runtime.md index 85d1661..93fe4de 100644 --- a/specs/001-zzthat-apps/runtime.md +++ b/specs/001-zzthat-apps/runtime.md @@ -182,8 +182,8 @@ iOS Google setup [INFERRED]: the Local and Release xcconfigs define `ZZ_GOOGLE_I The client is generated. Hand-written request types are a lint failure. -1. iOS: Swift OpenAPI Generator as a build plugin in `ZZAPI`, with `generate: [types, client]` and `accessModifier: public`. Transport: swift-openapi-urlsession on an ephemeral session with no URL cache (FR-049). The `Client` gets `Configuration(dateTranscoder: LenientISO8601Transcoder())`, a small transcoder in `ZZAPI` that decodes with fractional seconds first, then whole seconds, and encodes with fractional seconds. Call sites pass the generated `._1` value for the required `X-ZZ-Contract` parameter. The middleware then sets the header by replacing it, `request.headerFields[HTTPField.Name("X-ZZ-Contract")!] = "1"`, never by appending. It sets `Authorization` the same way. An `actor TokenStore` holds the access token and the one shared refresh `Task` (step 8). -2. Android: OpenAPI Generator 7.25.0, the command-line jar from Maven Central, run by the `:core-api:openApiGenerate` task in `:core-api`, generator `kotlin`, library `jvm-retrofit2`, serialization `kotlinx_serialization`, coroutines on, input `contracts/zzthis-v1.openapi.yaml`, output in the build folder. Pin the generator version. OkHttp has no cache. The generated Retrofit enum for `X-ZZ-Contract` serializes as `_1`, so the interceptor replaces the header with `request.newBuilder().header("X-ZZ-Contract", "1")`. It sets `Authorization` the same way and never uses `addHeader`. A synchronized `Authenticator` runs the refresh (step 8). +1. iOS: Swift OpenAPI Generator as a build plugin in `ZZAPI`, with `generate: [types, client]` and `accessModifier: public`. Transport: swift-openapi-urlsession on an ephemeral session with no URL cache (FR-049). The `Client` gets `Configuration(dateTranscoder: LenientISO8601Transcoder())`, a small transcoder in `ZZAPI` that decodes with fractional seconds first, then whole seconds, and encodes with fractional seconds. Call sites pass the generated `._1` value for the required `X-ZZ-Contract` parameter. The middleware then sets the header by replacing it, `request.headerFields[HTTPField.Name("X-ZZ-Contract")!] = "1"`, never by appending. It sets `Authorization` the same way. An `actor TokenStore` holds the access token and the one shared refresh `Task` (step 8). A value that a response enum does not list yet does not fail the response (T106, zzThis D-2026-10-13-09): `EnumToleranceMiddleware`, first in the middleware list, rewrites it before decoding to the fallback case named for that enum, or drops it from a list, with the known values read from each generated enum's `allCases`, and an unknown `Cache-Control` becomes `no-store`. +2. Android: OpenAPI Generator 7.25.0, the command-line jar from Maven Central, run by the `:core-api:openApiGenerate` task in `:core-api`, generator `kotlin`, library `jvm-retrofit2`, serialization `kotlinx_serialization`, coroutines on, input `contracts/zzthis-v1.openapi.yaml`, output in the build folder. Pin the generator version. OkHttp has no cache. The generated Retrofit enum for `X-ZZ-Contract` serializes as `_1`, so the interceptor replaces the header with `request.newBuilder().header("X-ZZ-Contract", "1")`. It sets `Authorization` the same way and never uses `addHeader`. A synchronized `Authenticator` runs the refresh (step 8). The generator option `enumUnknownDefaultCase=true` decodes a value that a response enum does not list yet to the generated `unknown_default_open_api` case, which every `when` over a generated enum handles (T106, zzThis D-2026-10-13-09), and `--name-mappings contract=contractVersion` renames Discovery's `contract` property in Kotlin only, because the generator's enum serializer does not compile for that escaped name. 3. The bearer token is added to every call except resolve, nonce, token, refresh, revoke, and a signed-out report (FR-016). 4. Demo builds use an in-memory implementation of the generated API protocol (iOS) and interfaces (Android) that returns fixtures. 5. Fixture tests cover every row of the spec error table, including `content-refused`, `wrong-length`, `contract-version`, and `rate-limited`. The 404 fixture is exactly `{ "error": "not-found" }` for unknown, expired, used, revoked, and a signed-out read of a private record. The 429 fixture is exactly `{ "error": "rate-limited" }`. Neither body has another field. diff --git a/specs/001-zzthat-apps/tasks.md b/specs/001-zzthat-apps/tasks.md index 1cd869a..ca4289d 100644 --- a/specs/001-zzthat-apps/tasks.md +++ b/specs/001-zzthat-apps/tasks.md @@ -134,7 +134,7 @@ Accepted by Danny on 2026-10-09 (zzThis D-2026-10-10-25). The zzThis backlog ids - [x] T092 [RM-107] The privacy manifest adds Other Data Types for the IP-based rate-limit HMAC (not linked, not tracking, App Functionality). A Release archive's privacy report shows a manifest for every linked SDK and no undeclared required-reason API. - [x] T093 [RM-106] A `release-android` job, run by hand only and behind a protected environment, builds `bundleProdRelease`, signs it with the upload key from Actions secrets, and uploads the `.aab` as a workflow artifact. No key, password, or keystore in the tree. Danny enrolls in Play App Signing at the first upload. - [x] T094 [RM-101] Hide this code, the one-day review sentence in the Report sheet, and the Account screen reset, on both platforms (FR-035). Needs the new copy keys in zzThis `design/copy.json` first. -- [ ] T095 [RM-075] A contract test shows that both generated clients ignore an unknown response key and tolerate an unknown enum value. +- [x] T095 [RM-075] A contract test shows that both generated clients ignore an unknown response key and tolerate an unknown enum value. - [x] T096 [RM-101] Block this creator, after zzThis ships the Resolve `creator` field (RM-112, merged in zzThis #161). The unknown-key half of T095 (#58) is what this field needs; the unknown-enum half (#54) stays open under T095. - [ ] T097 [RM-101] Terms acceptance before the first create, after Michael approves the Terms of Use and content policy (zzThis RM-103, RM-124; drafted in `apps/store/terms-draft.md`), with the 18+ statement (D-2026-10-11-05). - [x] T098 [RM-115] Saved (FR-050): Save and Remove from Saved on Resolve, the Saved list from My codes signed in or out, and Clear saved codes on Account; device only, canonical codes only, no thumbnail. Cleared by account deletion and by `account.clear_saved`. `account.unhide_all` does not clear it. zzThis D-2026-10-11-01 row 17. #65. @@ -165,4 +165,4 @@ From zzThis `specs/analysis-2026-10-13-spec-depth.md`. Each task names the zzThi - [ ] T103 [D-2026-10-11-15] Idle-expired codes show `codes.status_expired` in My codes and turn back on after the owner signs in again. No new route and no new copy if zzThis accepts D-2026-10-13-01 as proposed; a public resolve of an expired code stays not-found (FR-010). - [ ] T104 [RM-120, RM-103, RM-124] One step before the first create on a device: the person confirms they are 18 or older and accepts the Terms. The current version is `terms.version` from zzThis `design/copy.json` at the pin; the accepted version is kept on the device only and cleared by account deletion. Scanning and typing need neither. Waits on zzThis D-2026-10-13-04, the copy keys, and Michael's approval of the Terms. Replaces T097 when built. - [ ] T105 [D-2026-10-10-23] The Retry state names one reason, chosen by signal: `scan.retry_marker_cut`, `scan.retry_blur`, `scan.retry_glare`, `scan.retry_closer` (small in the frame), or `scan.retry_angle` (skew or perspective), on both platforms, with one test per reason. Copy keys from zzThis #169. Waits on T023 (the `RecognitionResult` adapter) and on the reason-code mapping that `recognition-adapter.md` freezes in versioned qualification configuration; the signals come from the adapter's normalized evidence, never from vendor objects or the confidence number alone. -- [ ] T106 [RM-075] Unknown enum values decode to a fallback on both platforms (#54): Android `enumUnknownDefaultCase=true`, iOS a fallback case. Gate on the first store build (zzThis D-2026-10-13-09). +- [x] T106 [RM-075] Unknown enum values decode to a fallback on both platforms (#54): Android `enumUnknownDefaultCase=true`, iOS a fallback case. Gate on the first store build (zzThis D-2026-10-13-09).