| author | |
| committer | |
| log | bce680926d38915f3590242c0d1a4309e7ca1b9a |
| tree | 6b1eb051adbe11289c82c2d57ffcb2aadbb6265f |
| parent | bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 122 insertions(+), 11 deletions(-)
apps/mobile/package.json+1| ... | ... | @@ -81,6 +81,7 @@ |
| 81 | 81 | "expo-constants": "~56.0.18", |
| 82 | 82 | "expo-crypto": "~56.0.4", |
| 83 | 83 | "expo-dev-client": "~56.0.20", |
| 84 | "expo-device": "~56.0.4", | |
| 84 | 85 | "expo-file-system": "~56.0.8", |
| 85 | 86 | "expo-font": "~56.0.7", |
| 86 | 87 | "expo-glass-effect": "~56.0.4", |
apps/mobile/src/features/cloud/linkEnvironment.test.ts+5| ... | ... | @@ -33,6 +33,11 @@ vi.mock("expo-constants", () => ({ |
| 33 | 33 | }, |
| 34 | 34 | })); |
| 35 | 35 | |
| 36 | vi.mock("expo-device", () => ({ | |
| 37 | osVersion: "18.4.1", | |
| 38 | modelName: "iPhone 15 Pro", | |
| 39 | })); | |
| 40 | ||
| 36 | 41 | vi.mock("react-native", () => ({ |
| 37 | 42 | Platform: { |
| 38 | 43 | OS: "ios", |
apps/mobile/src/lib/authClientMetadata.ts+6| ... | ... | @@ -1,11 +1,17 @@ |
| 1 | 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; |
| 2 | import * as Device from "expo-device"; | |
| 2 | 3 | import { Platform } from "react-native"; |
| 3 | 4 | |
| 4 | 5 | export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { |
| 6 | const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10); | |
| 7 | const deviceModel = Device.modelName?.trim(); | |
| 8 | ||
| 5 | 9 | return { |
| 6 | 10 | label: "T3 Code Mobile", |
| 7 | 11 | deviceType: "mobile", |
| 8 | 12 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), |
| 13 | ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), | |
| 14 | ...(deviceModel ? { deviceModel } : {}), | |
| 9 | 15 | surface: "mobile", |
| 10 | 16 | ...(appVersion ? { appVersion } : {}), |
| 11 | 17 | }; |
apps/mobile/src/lib/connection.test.ts+31-5| ... | ... | @@ -1,12 +1,18 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | isRelayManagedConnection, |
| 6 | authClientMetadata, | |
| 7 | 6 | redactPairingCredential, |
| 8 | 7 | toStableSavedRemoteConnection, |
| 9 | 8 | } from "./connection"; |
| 9 | import { authClientMetadata } from "./authClientMetadata"; | |
| 10 | ||
| 11 | const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); | |
| 12 | const mobileDevice = vi.hoisted(() => ({ | |
| 13 | osVersion: "18.4.1", | |
| 14 | modelName: "iPhone 15 Pro", | |
| 15 | })); | |
| 10 | 16 | |
| 11 | 17 | vi.mock("./runtime", () => ({ |
| 12 | 18 | runtime: { |
| ... | ... | @@ -15,21 +21,41 @@ vi.mock("./runtime", () => ({ |
| 15 | 21 | })); |
| 16 | 22 | |
| 17 | 23 | vi.mock("react-native", () => ({ |
| 18 | Platform: { | |
| 19 | OS: "ios", | |
| 20 | }, | |
| 24 | Platform: mobilePlatform, | |
| 21 | 25 | })); |
| 22 | 26 | |
| 27 | vi.mock("expo-device", () => mobileDevice); | |
| 28 | ||
| 23 | 29 | describe("mobile remote connection records", () => { |
| 30 | afterEach(() => { | |
| 31 | mobilePlatform.OS = "ios"; | |
| 32 | mobileDevice.osVersion = "18.4.1"; | |
| 33 | mobileDevice.modelName = "iPhone 15 Pro"; | |
| 34 | }); | |
| 35 | ||
| 24 | 36 | it("identifies mobile token exchanges for authorized-client presentation", () => { |
| 25 | 37 | expect(authClientMetadata()).toEqual({ |
| 26 | 38 | label: "T3 Code Mobile", |
| 27 | 39 | deviceType: "mobile", |
| 28 | 40 | os: "iOS", |
| 41 | osMajorVersion: 18, | |
| 42 | deviceModel: "iPhone 15 Pro", | |
| 29 | 43 | surface: "mobile", |
| 30 | 44 | }); |
| 31 | 45 | }); |
| 32 | 46 | |
| 47 | it("includes only the Android major version and hardware model", () => { | |
| 48 | mobilePlatform.OS = "android"; | |
| 49 | mobileDevice.osVersion = "15.2.1"; | |
| 50 | mobileDevice.modelName = "Pixel 9"; | |
| 51 | ||
| 52 | expect(authClientMetadata()).toMatchObject({ | |
| 53 | os: "Android", | |
| 54 | osMajorVersion: 15, | |
| 55 | deviceModel: "Pixel 9", | |
| 56 | }); | |
| 57 | }); | |
| 58 | ||
| 33 | 59 | it("includes the mobile app version when the client provides it", () => { |
| 34 | 60 | expect(authClientMetadata("1.2.3")).toMatchObject({ |
| 35 | 61 | surface: "mobile", |
apps/mobile/src/lib/connection.ts-2| ... | ... | @@ -2,8 +2,6 @@ import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; |
| 3 | 3 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 4 | 4 | |
| 5 | export { authClientMetadata } from "./authClientMetadata"; | |
| 6 | ||
| 7 | 5 | export interface SavedRemoteConnection { |
| 8 | 6 | readonly environmentId: EnvironmentId; |
| 9 | 7 | readonly environmentLabel: string; |
apps/server/src/server.test.ts+10-2| ... | ... | @@ -5209,7 +5209,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5209 | 5209 | createdAt: "2026-01-01T00:00:00.000Z", |
| 5210 | 5210 | }) as const; |
| 5211 | 5211 | |
| 5212 | const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3"); | |
| 5212 | const wsUrl = yield* getWsServerUrl( | |
| 5213 | "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro", | |
| 5214 | ); | |
| 5213 | 5215 | yield* Effect.scoped( |
| 5214 | 5216 | withWsRpcClient(wsUrl, (client) => |
| 5215 | 5217 | Effect.gen(function* () { |
| ... | ... | @@ -5242,7 +5244,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5242 | 5244 | "analytics:client.thread.started", |
| 5243 | 5245 | ]); |
| 5244 | 5246 | assert.deepEqual(analyticsProperties, [ |
| 5245 | { surface: "mobile", appVersion: "1.2.3" }, | |
| 5247 | { | |
| 5248 | surface: "mobile", | |
| 5249 | appVersion: "1.2.3", | |
| 5250 | os: "iOS", | |
| 5251 | osMajorVersion: 18, | |
| 5252 | deviceModel: "iPhone 15 Pro", | |
| 5253 | }, | |
| 5246 | 5254 | { surface: "mobile", appVersion: "1.2.3" }, |
| 5247 | 5255 | ]); |
| 5248 | 5256 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
apps/server/src/ws.ts+27-1| ... | ... | @@ -360,6 +360,7 @@ function toAuthAccessStreamEvent( |
| 360 | 360 | |
| 361 | 361 | const isClientSurface = Schema.is(ClientSurface); |
| 362 | 362 | const MAX_CLIENT_APP_VERSION_LENGTH = 64; |
| 363 | const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; | |
| 363 | 364 | |
| 364 | 365 | // Optional client identity announced on the /ws upgrade URL next to wsTicket. |
| 365 | 366 | // Lenient by design: absent or malformed values degrade to {} so a connection |
| ... | ... | @@ -386,6 +387,28 @@ const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ |
| 386 | 387 | ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), |
| 387 | 388 | }); |
| 388 | 389 | |
| 390 | function readMobileDeviceAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { | |
| 391 | const url = HttpServerRequest.toURL(request); | |
| 392 | if (Option.isNone(url) || url.value.searchParams.get("clientSurface") !== "mobile") { | |
| 393 | return {}; | |
| 394 | } | |
| 395 | ||
| 396 | const os = url.value.searchParams.get("clientOs"); | |
| 397 | const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; | |
| 398 | const osMajorVersion = Number(rawOsMajorVersion); | |
| 399 | const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; | |
| 400 | ||
| 401 | return { | |
| 402 | ...(os === "iOS" || os === "Android" ? { os } : {}), | |
| 403 | ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0 | |
| 404 | ? { osMajorVersion } | |
| 405 | : {}), | |
| 406 | ...(deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH | |
| 407 | ? { deviceModel } | |
| 408 | : {}), | |
| 409 | }; | |
| 410 | } | |
| 411 | ||
| 389 | 412 | const makeWsRpcLayer = ( |
| 390 | 413 | currentSession: EnvironmentAuth.AuthenticatedSession, |
| 391 | 414 | clientOrigin: OrchestrationClientOrigin, |
| ... | ... | @@ -2434,7 +2457,10 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2434 | 2457 | ); |
| 2435 | 2458 | const clientOrigin = readClientConnectionOrigin(request); |
| 2436 | 2459 | yield* sessions.recordClientConnection(session.sessionId, clientOrigin); |
| 2437 | yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin)); | |
| 2460 | yield* analytics.record("client.connected", { | |
| 2461 | ...clientOriginAnalyticsProps(clientOrigin), | |
| 2462 | ...readMobileDeviceAnalyticsProps(request), | |
| 2463 | }); | |
| 2438 | 2464 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { |
| 2439 | 2465 | disableTracing: true, |
| 2440 | 2466 | }).pipe( |
packages/client-runtime/src/authorization/remote.test.ts+10-1| ... | ... | @@ -467,9 +467,18 @@ describe("remote environment authorization", () => { |
| 467 | 467 | wsBaseUrl: "wss://remote.example.com/", |
| 468 | 468 | httpBaseUrl: "https://remote.example.com/", |
| 469 | 469 | bearerToken: "bearer-token", |
| 470 | clientMetadata: { | |
| 471 | surface: "mobile", | |
| 472 | appVersion: "1.2.3", | |
| 473 | os: "Android", | |
| 474 | osMajorVersion: 15, | |
| 475 | deviceModel: "Pixel 9", | |
| 476 | }, | |
| 470 | 477 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 471 | 478 | |
| 472 | expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); | |
| 479 | expect(url).toBe( | |
| 480 | "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+9", | |
| 481 | ); | |
| 473 | 482 | }), |
| 474 | 483 | ); |
| 475 | 484 | }); |
packages/client-runtime/src/authorization/remote.ts+11| ... | ... | @@ -44,6 +44,17 @@ export const appendClientConnectionParams = ( |
| 44 | 44 | if (clientMetadata?.appVersion) { |
| 45 | 45 | url.searchParams.set("clientAppVersion", clientMetadata.appVersion); |
| 46 | 46 | } |
| 47 | if (clientMetadata?.surface === "mobile") { | |
| 48 | if (clientMetadata.os) { | |
| 49 | url.searchParams.set("clientOs", clientMetadata.os); | |
| 50 | } | |
| 51 | if (clientMetadata.osMajorVersion !== undefined) { | |
| 52 | url.searchParams.set("clientOsMajorVersion", String(clientMetadata.osMajorVersion)); | |
| 53 | } | |
| 54 | if (clientMetadata.deviceModel) { | |
| 55 | url.searchParams.set("clientDeviceModel", clientMetadata.deviceModel); | |
| 56 | } | |
| 57 | } | |
| 47 | 58 | }; |
| 48 | 59 | |
| 49 | 60 | export const exchangeRemoteDpopAccessToken = Effect.fn( |
packages/contracts/src/auth.ts+2| ... | ... | @@ -169,6 +169,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({ |
| 169 | 169 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 170 | 170 | deviceType: Schema.optionalKey(AuthClientMetadataDeviceType), |
| 171 | 171 | os: Schema.optionalKey(TrimmedNonEmptyString), |
| 172 | osMajorVersion: Schema.optionalKey(Schema.Int), | |
| 173 | deviceModel: Schema.optionalKey(TrimmedNonEmptyString), | |
| 172 | 174 | surface: Schema.optionalKey(ClientSurface), |
| 173 | 175 | appVersion: Schema.optionalKey(TrimmedNonEmptyString), |
| 174 | 176 | }); |
pnpm-lock.yaml+19| ... | ... | @@ -318,6 +318,9 @@ importers: |
| 318 | 318 | expo-dev-client: |
| 319 | 319 | specifier: ~56.0.20 |
| 320 | 320 | version: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 321 | expo-device: | |
| 322 | specifier: ~56.0.4 | |
| 323 | version: 56.0.4(expo@56.0.12) | |
| 321 | 324 | expo-file-system: |
| 322 | 325 | specifier: ~56.0.8 |
| 323 | 326 | version: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| ... | ... | @@ -6586,6 +6589,11 @@ packages: |
| 6586 | 6589 | expo: '*' |
| 6587 | 6590 | react-native: '*' |
| 6588 | 6591 | |
| 6592 | expo-device@56.0.4: | |
| 6593 | resolution: {integrity: sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg==} | |
| 6594 | peerDependencies: | |
| 6595 | expo: '*' | |
| 6596 | ||
| 6589 | 6597 | expo-eas-client@56.0.1: |
| 6590 | 6598 | resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==} |
| 6591 | 6599 | |
| ... | ... | @@ -9857,6 +9865,10 @@ packages: |
| 9857 | 9865 | engines: {node: '>=14.17'} |
| 9858 | 9866 | hasBin: true |
| 9859 | 9867 | |
| 9868 | ua-parser-js@0.7.41: | |
| 9869 | resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} | |
| 9870 | hasBin: true | |
| 9871 | ||
| 9860 | 9872 | ufo@1.6.4: |
| 9861 | 9873 | resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} |
| 9862 | 9874 | |
| ... | ... | @@ -16867,6 +16879,11 @@ snapshots: |
| 16867 | 16879 | expo-dev-menu-interface: 56.0.1(expo@56.0.12) |
| 16868 | 16880 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16869 | 16881 | |
| 16882 | expo-device@56.0.4(expo@56.0.12): | |
| 16883 | dependencies: | |
| 16884 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | |
| 16885 | ua-parser-js: 0.7.41 | |
| 16886 | ||
| 16870 | 16887 | expo-eas-client@56.0.1: {} |
| 16871 | 16888 | |
| 16872 | 16889 | expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| ... | ... | @@ -21104,6 +21121,8 @@ snapshots: |
| 21104 | 21121 | |
| 21105 | 21122 | typescript@6.0.3: {} |
| 21106 | 21123 | |
| 21124 | ua-parser-js@0.7.41: {} | |
| 21125 | ||
| 21107 | 21126 | ufo@1.6.4: {} |
| 21108 | 21127 | |
| 21109 | 21128 | ultrahtml@1.6.0: {} |