| author | |
| committer | |
| log | 88be5631ff6a48b98ead48fa5af6a2be0a4d63e4 |
| tree | d1148d617aa9125f2d721281de09f65dcbfbeeff |
| parent | 49f6241ddeee2a5d20ec181b5b94ec0a9ac72f7b |
| signature | Signed by PGP key B5690EEEBB952194 |
29 files changed, 1005 insertions(+), 60 deletions(-)
.github/workflows/ci.yml+4-4| ... | @@ -6,6 +6,9 @@ on: | ... | @@ -6,6 +6,9 @@ on: |
| 6 | branches: | 6 | branches: |
| 7 | - main | 7 | - main |
| 8 | 8 | ||
| 9 | permissions: | ||
| 10 | contents: read | ||
| 11 | |||
| 9 | concurrency: | 12 | concurrency: |
| 10 | group: ci-${{ github.event.pull_request.number || github.sha }} | 13 | group: ci-${{ github.event.pull_request.number || github.sha }} |
| 11 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} | 14 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} |
| ... | @@ -52,10 +55,7 @@ jobs: | ... | @@ -52,10 +55,7 @@ jobs: |
| 52 | run: vp run build:desktop | 55 | run: vp run build:desktop |
| 53 | 56 | ||
| 54 | - name: Verify preload bundle output | 57 | - name: Verify preload bundle output |
| 55 | run: | | 58 | run: node apps/desktop/scripts/verify-preload-bundle.mjs |
| 56 | test -f apps/desktop/dist-electron/preload.cjs | ||
| 57 | grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs | ||
| 58 | grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs | ||
| 59 | 59 | ||
| 60 | # Everything except `t3` (apps/server). `--parallel` drops the package | 60 | # Everything except `t3` (apps/server). `--parallel` drops the package |
| 61 | # dependency ordering that `vp run` applies by default: these `test` tasks | 61 | # dependency ordering that `vp run` applies by default: these `test` tasks |
apps/desktop/package.json+1| ... | @@ -30,6 +30,7 @@ | ... | @@ -30,6 +30,7 @@ |
| 30 | "devDependencies": { | 30 | "devDependencies": { |
| 31 | "@effect/vitest": "catalog:", | 31 | "@effect/vitest": "catalog:", |
| 32 | "@types/node": "catalog:", | 32 | "@types/node": "catalog:", |
| 33 | "acorn": "8.16.0", | ||
| 33 | "cross-env": "^10.1.0", | 34 | "cross-env": "^10.1.0", |
| 34 | "electron-builder": "26.15.6", | 35 | "electron-builder": "26.15.6", |
| 35 | "tailwindcss": "^4.0.0", | 36 | "tailwindcss": "^4.0.0", |
apps/desktop/scripts/verify-preload-bundle.mjs created+147| ... | @@ -0,0 +1,147 @@ | ||
| 1 | import * as NodeEvents from "node:events"; | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | ||
| 4 | import * as NodeTimers from "node:timers"; | ||
| 5 | import * as NodeURL from "node:url"; | ||
| 6 | import * as NodeVM from "node:vm"; | ||
| 7 | import { parse } from "acorn"; | ||
| 8 | |||
| 9 | const expectedDesktopBridgeApis = [ | ||
| 10 | "getClientPlatform", | ||
| 11 | "getLocalEnvironmentBootstraps", | ||
| 12 | "pickFolder", | ||
| 13 | ]; | ||
| 14 | const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; | ||
| 15 | const preloadExecutionTimeoutMs = 1_000; | ||
| 16 | const desktopPackage = JSON.parse( | ||
| 17 | NodeFS.readFileSync(new URL("../package.json", import.meta.url), "utf8"), | ||
| 18 | ); | ||
| 19 | const electronVersion = desktopPackage.dependencies.electron; | ||
| 20 | |||
| 21 | const isSyntaxNode = (value) => | ||
| 22 | typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; | ||
| 23 | |||
| 24 | const inspectBundle = (source) => { | ||
| 25 | const runtimeImports = []; | ||
| 26 | const visit = (node) => { | ||
| 27 | if (node.type === "ImportExpression") { | ||
| 28 | throw new Error("Desktop preload bundle contains a dynamic import() call"); | ||
| 29 | } | ||
| 30 | |||
| 31 | if (node.type === "CallExpression" && node.callee.type === "Identifier") { | ||
| 32 | if (node.callee.name === "require") { | ||
| 33 | const [argument] = node.arguments; | ||
| 34 | if (node.arguments.length !== 1 || argument?.type !== "Literal") { | ||
| 35 | throw new Error("Desktop preload bundle contains a dynamic require() call"); | ||
| 36 | } | ||
| 37 | if (typeof argument.value !== "string") { | ||
| 38 | throw new Error("Desktop preload bundle contains a dynamic require() call"); | ||
| 39 | } | ||
| 40 | runtimeImports.push(argument.value); | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | for (const child of Object.values(node)) { | ||
| 45 | if (Array.isArray(child)) { | ||
| 46 | for (const item of child) { | ||
| 47 | if (isSyntaxNode(item)) visit(item); | ||
| 48 | } | ||
| 49 | } else if (isSyntaxNode(child)) { | ||
| 50 | visit(child); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | }; | ||
| 54 | |||
| 55 | visit(parse(source, { ecmaVersion: "latest", sourceType: "script" })); | ||
| 56 | return runtimeImports; | ||
| 57 | }; | ||
| 58 | |||
| 59 | const createSandboxModules = (exposedGlobals) => { | ||
| 60 | const ipcRenderer = { | ||
| 61 | invoke: () => Promise.resolve(undefined), | ||
| 62 | on: () => undefined, | ||
| 63 | removeListener: () => undefined, | ||
| 64 | sendSync: () => undefined, | ||
| 65 | }; | ||
| 66 | const electron = { | ||
| 67 | contextBridge: { | ||
| 68 | exposeInMainWorld: (name, api) => exposedGlobals.set(name, api), | ||
| 69 | }, | ||
| 70 | ipcRenderer, | ||
| 71 | }; | ||
| 72 | |||
| 73 | return new Map([ | ||
| 74 | ["electron", electron], | ||
| 75 | ["electron/common", electron], | ||
| 76 | ["electron/renderer", electron], | ||
| 77 | ["events", NodeEvents.default], | ||
| 78 | ["node:events", NodeEvents.default], | ||
| 79 | ["timers", NodeTimers.default], | ||
| 80 | ["node:timers", NodeTimers.default], | ||
| 81 | ["url", NodeURL.default], | ||
| 82 | ["node:url", NodeURL.default], | ||
| 83 | ]); | ||
| 84 | }; | ||
| 85 | |||
| 86 | const executeBundle = (source, sandboxModules) => { | ||
| 87 | const sandboxProcess = { | ||
| 88 | contextIsolated: true, | ||
| 89 | // oxlint-disable-next-line t3code/no-global-process-runtime -- This standalone CI verifier supplies the preload's host platform without loading Effect. | ||
| 90 | platform: process.platform, | ||
| 91 | versions: { electron: electronVersion }, | ||
| 92 | }; | ||
| 93 | const requireSandboxModule = (moduleName) => { | ||
| 94 | if (!sandboxModules.has(moduleName)) { | ||
| 95 | throw new Error( | ||
| 96 | `Unsupported sandbox module requested during preload execution: ${moduleName}`, | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | return sandboxModules.get(moduleName); | ||
| 100 | }; | ||
| 101 | |||
| 102 | NodeVM.runInNewContext( | ||
| 103 | source, | ||
| 104 | { | ||
| 105 | process: sandboxProcess, | ||
| 106 | require: requireSandboxModule, | ||
| 107 | }, | ||
| 108 | { | ||
| 109 | filename: "desktop-preload.cjs", | ||
| 110 | timeout: preloadExecutionTimeoutMs, | ||
| 111 | }, | ||
| 112 | ); | ||
| 113 | }; | ||
| 114 | |||
| 115 | export const verifyPreloadBundle = (source) => { | ||
| 116 | const runtimeImports = inspectBundle(source); | ||
| 117 | const exposedGlobals = new Map(); | ||
| 118 | const sandboxModules = createSandboxModules(exposedGlobals); | ||
| 119 | const unsupportedImports = [...new Set(runtimeImports)] | ||
| 120 | .filter((moduleName) => !sandboxModules.has(moduleName)) | ||
| 121 | .toSorted(); | ||
| 122 | |||
| 123 | if (unsupportedImports.length > 0) { | ||
| 124 | throw new Error( | ||
| 125 | `Desktop preload bundle contains unsupported sandbox imports: ${unsupportedImports.join(", ")}`, | ||
| 126 | ); | ||
| 127 | } | ||
| 128 | |||
| 129 | executeBundle(source, sandboxModules); | ||
| 130 | |||
| 131 | const desktopBridge = exposedGlobals.get("desktopBridge"); | ||
| 132 | const missingApis = expectedDesktopBridgeApis.filter( | ||
| 133 | (api) => typeof desktopBridge?.[api] !== "function", | ||
| 134 | ); | ||
| 135 | if (!exposedGlobals.has("desktopBridge")) missingApis.unshift("desktopBridge exposure"); | ||
| 136 | if (!exposedGlobals.has(clerkPasskeysGlobal)) missingApis.push(`${clerkPasskeysGlobal} exposure`); | ||
| 137 | |||
| 138 | if (missingApis.length > 0) { | ||
| 139 | throw new Error(`Desktop preload bundle is missing executable APIs: ${missingApis.join(", ")}`); | ||
| 140 | } | ||
| 141 | }; | ||
| 142 | |||
| 143 | if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { | ||
| 144 | const preloadUrl = new URL("../dist-electron/preload.cjs", import.meta.url); | ||
| 145 | const source = await NodeFSP.readFile(preloadUrl, "utf8"); | ||
| 146 | verifyPreloadBundle(source); | ||
| 147 | } | ||
apps/desktop/scripts/verify-preload-bundle.test.mjs created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import { assert, describe, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { verifyPreloadBundle } from "./verify-preload-bundle.mjs"; | ||
| 4 | |||
| 5 | const validPreload = ` | ||
| 6 | const electron = require("electron"); | ||
| 7 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | ||
| 8 | electron.contextBridge.exposeInMainWorld("__clerk_internal_electron_passkeys", {}); | ||
| 9 | electron.contextBridge.exposeInMainWorld("desktopBridge", { | ||
| 10 | getClientPlatform: () => process.platform, | ||
| 11 | getLocalEnvironmentBootstraps: () => [], | ||
| 12 | pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), | ||
| 13 | }); | ||
| 14 | `; | ||
| 15 | |||
| 16 | describe("desktop preload bundle verifier", () => { | ||
| 17 | it("rejects required API names that only appear in strings", () => { | ||
| 18 | assert.throws( | ||
| 19 | () => | ||
| 20 | verifyPreloadBundle(` | ||
| 21 | "desktopBridge getClientPlatform getLocalEnvironmentBootstraps pickFolder"; | ||
| 22 | "__clerk_internal_electron_passkeys"; | ||
| 23 | require("electron"); | ||
| 24 | `), | ||
| 25 | /missing executable APIs/, | ||
| 26 | ); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("rejects a required API whose exposed value is not callable", () => { | ||
| 30 | assert.throws( | ||
| 31 | () => | ||
| 32 | verifyPreloadBundle( | ||
| 33 | validPreload.replace( | ||
| 34 | "getClientPlatform: () => process.platform,", | ||
| 35 | "getClientPlatform: undefined,", | ||
| 36 | ), | ||
| 37 | ), | ||
| 38 | /missing executable APIs: getClientPlatform/, | ||
| 39 | ); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("accepts a required API exposed through a function alias", () => { | ||
| 43 | assert.doesNotThrow(() => | ||
| 44 | verifyPreloadBundle(` | ||
| 45 | const readClientPlatform = () => process.platform; | ||
| 46 | ${validPreload.replace( | ||
| 47 | "getClientPlatform: () => process.platform,", | ||
| 48 | "getClientPlatform: readClientPlatform,", | ||
| 49 | )} | ||
| 50 | `), | ||
| 51 | ); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("rejects dynamic imports with comments before the opening parenthesis", () => { | ||
| 55 | assert.throws( | ||
| 56 | () => | ||
| 57 | verifyPreloadBundle(`${validPreload}\nimport /* @vite-ignore */("unsupported-module");`), | ||
| 58 | /dynamic import\(\)/, | ||
| 59 | ); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("ignores import-like text in strings", () => { | ||
| 63 | assert.doesNotThrow(() => | ||
| 64 | verifyPreloadBundle(`${validPreload}\nconst message = 'import /* comment */("module")';`), | ||
| 65 | ); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("rejects unsupported require calls with comments before the opening parenthesis", () => { | ||
| 69 | assert.throws( | ||
| 70 | () => verifyPreloadBundle(`${validPreload}\nrequire /* @__PURE__ */ ("node:fs");`), | ||
| 71 | /unsupported sandbox imports: node:fs/, | ||
| 72 | ); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("rejects unsupported optional require calls", () => { | ||
| 76 | assert.throws( | ||
| 77 | () => verifyPreloadBundle(`${validPreload}\nrequire?.("node:fs");`), | ||
| 78 | /unsupported sandbox imports: node:fs/, | ||
| 79 | ); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("accepts Electron sandbox module aliases", () => { | ||
| 83 | assert.doesNotThrow(() => | ||
| 84 | verifyPreloadBundle(` | ||
| 85 | ${validPreload} | ||
| 86 | require("electron/common"); | ||
| 87 | require("electron/renderer"); | ||
| 88 | require("node:events"); | ||
| 89 | require("node:timers"); | ||
| 90 | require("node:url"); | ||
| 91 | `), | ||
| 92 | ); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("ignores require-like text in strings and comments", () => { | ||
| 96 | assert.doesNotThrow(() => | ||
| 97 | verifyPreloadBundle(` | ||
| 98 | ${validPreload} | ||
| 99 | const message = 'require("node:fs")'; | ||
| 100 | // require("node:path") | ||
| 101 | `), | ||
| 102 | ); | ||
| 103 | }); | ||
| 104 | }); | ||
apps/desktop/src/preload.ts+4| ... | @@ -11,6 +11,9 @@ import * as IpcChannels from "./ipc/channels.ts"; | ... | @@ -11,6 +11,9 @@ import * as IpcChannels from "./ipc/channels.ts"; |
| 11 | 11 | ||
| 12 | exposeClerkBridge({ passkeys: true }); | 12 | exposeClerkBridge({ passkeys: true }); |
| 13 | 13 | ||
| 14 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. | ||
| 15 | const clientPlatform = process.platform; | ||
| 16 | |||
| 14 | function unwrapEnsureSshEnvironmentResult(result: unknown) { | 17 | function unwrapEnsureSshEnvironmentResult(result: unknown) { |
| 15 | if ( | 18 | if ( |
| 16 | typeof result === "object" && | 19 | typeof result === "object" && |
| ... | @@ -35,6 +38,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -35,6 +38,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 35 | } | 38 | } |
| 36 | return result as ReturnType<DesktopBridge["getAppBranding"]>; | 39 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 37 | }, | 40 | }, |
| 41 | getClientPlatform: () => clientPlatform, | ||
| 38 | getSystemLocale: () => { | 42 | getSystemLocale: () => { |
| 39 | const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); | 43 | const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); |
| 40 | return typeof result === "string" ? result : null; | 44 | return typeof result === "string" ? result : null; |
apps/mobile/src/features/cloud/linkEnvironment.test.ts+8| ... | @@ -34,6 +34,14 @@ vi.mock("expo-constants", () => ({ | ... | @@ -34,6 +34,14 @@ vi.mock("expo-constants", () => ({ |
| 34 | })); | 34 | })); |
| 35 | 35 | ||
| 36 | vi.mock("expo-device", () => ({ | 36 | vi.mock("expo-device", () => ({ |
| 37 | deviceType: 1, | ||
| 38 | DeviceType: { | ||
| 39 | UNKNOWN: 0, | ||
| 40 | PHONE: 1, | ||
| 41 | TABLET: 2, | ||
| 42 | DESKTOP: 3, | ||
| 43 | TV: 4, | ||
| 44 | }, | ||
| 37 | osVersion: "18.4.1", | 45 | osVersion: "18.4.1", |
| 38 | modelName: "iPhone 15 Pro", | 46 | modelName: "iPhone 15 Pro", |
| 39 | })); | 47 | })); |
apps/mobile/src/lib/authClientMetadata.ts+6-1| ... | @@ -8,7 +8,12 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM | ... | @@ -8,7 +8,12 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM |
| 8 | 8 | ||
| 9 | return { | 9 | return { |
| 10 | label: "T3 Code Mobile", | 10 | label: "T3 Code Mobile", |
| 11 | deviceType: "mobile", | 11 | deviceType: |
| 12 | Device.deviceType === Device.DeviceType.TABLET | ||
| 13 | ? "tablet" | ||
| 14 | : Device.deviceType === Device.DeviceType.PHONE | ||
| 15 | ? "mobile" | ||
| 16 | : "unknown", | ||
| 12 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), | 17 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), |
| 13 | ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), | 18 | ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), |
| 14 | ...(deviceModel ? { deviceModel } : {}), | 19 | ...(deviceModel ? { deviceModel } : {}), |
apps/mobile/src/lib/connection.test.ts+20| ... | @@ -10,6 +10,14 @@ import { authClientMetadata } from "./authClientMetadata"; | ... | @@ -10,6 +10,14 @@ import { authClientMetadata } from "./authClientMetadata"; |
| 10 | 10 | ||
| 11 | const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); | 11 | const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); |
| 12 | const mobileDevice = vi.hoisted(() => ({ | 12 | const mobileDevice = vi.hoisted(() => ({ |
| 13 | deviceType: 1, | ||
| 14 | DeviceType: { | ||
| 15 | UNKNOWN: 0, | ||
| 16 | PHONE: 1, | ||
| 17 | TABLET: 2, | ||
| 18 | DESKTOP: 3, | ||
| 19 | TV: 4, | ||
| 20 | }, | ||
| 13 | osVersion: "18.4.1", | 21 | osVersion: "18.4.1", |
| 14 | modelName: "iPhone 15 Pro", | 22 | modelName: "iPhone 15 Pro", |
| 15 | })); | 23 | })); |
| ... | @@ -29,6 +37,7 @@ vi.mock("expo-device", () => mobileDevice); | ... | @@ -29,6 +37,7 @@ vi.mock("expo-device", () => mobileDevice); |
| 29 | describe("mobile remote connection records", () => { | 37 | describe("mobile remote connection records", () => { |
| 30 | afterEach(() => { | 38 | afterEach(() => { |
| 31 | mobilePlatform.OS = "ios"; | 39 | mobilePlatform.OS = "ios"; |
| 40 | mobileDevice.deviceType = mobileDevice.DeviceType.PHONE; | ||
| 32 | mobileDevice.osVersion = "18.4.1"; | 41 | mobileDevice.osVersion = "18.4.1"; |
| 33 | mobileDevice.modelName = "iPhone 15 Pro"; | 42 | mobileDevice.modelName = "iPhone 15 Pro"; |
| 34 | }); | 43 | }); |
| ... | @@ -56,6 +65,17 @@ describe("mobile remote connection records", () => { | ... | @@ -56,6 +65,17 @@ describe("mobile remote connection records", () => { |
| 56 | }); | 65 | }); |
| 57 | }); | 66 | }); |
| 58 | 67 | ||
| 68 | it("identifies native tablets separately from phones", () => { | ||
| 69 | mobileDevice.deviceType = mobileDevice.DeviceType.TABLET; | ||
| 70 | mobileDevice.modelName = "iPad Pro 13-inch"; | ||
| 71 | |||
| 72 | expect(authClientMetadata()).toMatchObject({ | ||
| 73 | deviceType: "tablet", | ||
| 74 | os: "iOS", | ||
| 75 | deviceModel: "iPad Pro 13-inch", | ||
| 76 | }); | ||
| 77 | }); | ||
| 78 | |||
| 59 | it("includes the mobile app version when the client provides it", () => { | 79 | it("includes the mobile app version when the client provides it", () => { |
| 60 | expect(authClientMetadata("1.2.3")).toMatchObject({ | 80 | expect(authClientMetadata("1.2.3")).toMatchObject({ |
| 61 | surface: "mobile", | 81 | surface: "mobile", |
apps/server/src/server.test.ts+131-2| ... | @@ -5261,7 +5261,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -5261,7 +5261,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5261 | }) as const; | 5261 | }) as const; |
| 5262 | 5262 | ||
| 5263 | const wsUrl = yield* getWsServerUrl( | 5263 | const wsUrl = yield* getWsServerUrl( |
| 5264 | "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro", | 5264 | "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientDeviceType=phone&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro&connectionMethod=relay", |
| 5265 | ); | 5265 | ); |
| 5266 | yield* Effect.scoped( | 5266 | yield* Effect.scoped( |
| 5267 | withWsRpcClient(wsUrl, (client) => | 5267 | withWsRpcClient(wsUrl, (client) => |
| ... | @@ -5298,15 +5298,144 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -5298,15 +5298,144 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5298 | { | 5298 | { |
| 5299 | surface: "mobile", | 5299 | surface: "mobile", |
| 5300 | appVersion: "1.2.3", | 5300 | appVersion: "1.2.3", |
| 5301 | clientAppVersion: "1.2.3", | ||
| 5302 | clientOs: "iOS", | ||
| 5301 | os: "iOS", | 5303 | os: "iOS", |
| 5304 | clientDeviceType: "phone", | ||
| 5302 | osMajorVersion: 18, | 5305 | osMajorVersion: 18, |
| 5306 | clientOsMajorVersion: 18, | ||
| 5303 | deviceModel: "iPhone 15 Pro", | 5307 | deviceModel: "iPhone 15 Pro", |
| 5308 | clientDeviceModel: "iPhone 15 Pro", | ||
| 5309 | connectionMethod: "relay", | ||
| 5310 | }, | ||
| 5311 | { | ||
| 5312 | surface: "mobile", | ||
| 5313 | appVersion: "1.2.3", | ||
| 5314 | clientAppVersion: "1.2.3", | ||
| 5315 | clientOs: "iOS", | ||
| 5316 | os: "iOS", | ||
| 5317 | clientDeviceType: "phone", | ||
| 5318 | osMajorVersion: 18, | ||
| 5319 | clientOsMajorVersion: 18, | ||
| 5320 | deviceModel: "iPhone 15 Pro", | ||
| 5321 | clientDeviceModel: "iPhone 15 Pro", | ||
| 5322 | connectionMethod: "relay", | ||
| 5304 | }, | 5323 | }, |
| 5305 | { surface: "mobile", appVersion: "1.2.3" }, | ||
| 5306 | ]); | 5324 | ]); |
| 5307 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 5325 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 5308 | ); | 5326 | ); |
| 5309 | 5327 | ||
| 5328 | it.effect("keeps telemetry separate for simultaneous clients", () => | ||
| 5329 | Effect.gen(function* () { | ||
| 5330 | const analyticsEvents: Array<{ | ||
| 5331 | event: string; | ||
| 5332 | properties: Readonly<Record<string, unknown>> | undefined; | ||
| 5333 | }> = []; | ||
| 5334 | |||
| 5335 | yield* buildAppUnderTest({ | ||
| 5336 | layers: { | ||
| 5337 | analyticsService: { | ||
| 5338 | record: (event, properties) => | ||
| 5339 | Effect.sync(() => analyticsEvents.push({ event, properties })), | ||
| 5340 | }, | ||
| 5341 | orchestrationEngine: { | ||
| 5342 | dispatch: () => Effect.succeed({ sequence: 1 }), | ||
| 5343 | }, | ||
| 5344 | }, | ||
| 5345 | }); | ||
| 5346 | |||
| 5347 | const webUrl = yield* getWsServerUrl( | ||
| 5348 | "/ws?clientSurface=web&clientAppVersion=2.0.0&clientDeviceType=desktop&clientOs=Windows&clientWebDeployment=hosted&clientBrowser=Chrome&connectionMethod=direct", | ||
| 5349 | ); | ||
| 5350 | const mobileUrl = yield* getWsServerUrl( | ||
| 5351 | "/ws?clientSurface=mobile&clientAppVersion=3.0.0&clientDeviceType=tablet&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+Tablet&connectionMethod=relay", | ||
| 5352 | ); | ||
| 5353 | const turnCommand = (client: string) => ({ | ||
| 5354 | type: "thread.turn.start" as const, | ||
| 5355 | commandId: CommandId.make(`cmd-${client}-turn`), | ||
| 5356 | threadId: ThreadId.make(`thread-${client}`), | ||
| 5357 | message: { | ||
| 5358 | messageId: MessageId.make(`message-${client}`), | ||
| 5359 | role: "user" as const, | ||
| 5360 | text: "hello", | ||
| 5361 | attachments: [], | ||
| 5362 | }, | ||
| 5363 | modelSelection: defaultModelSelection, | ||
| 5364 | runtimeMode: "full-access" as const, | ||
| 5365 | interactionMode: "default" as const, | ||
| 5366 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 5367 | }); | ||
| 5368 | |||
| 5369 | yield* Effect.scoped( | ||
| 5370 | withWsRpcClient(webUrl, (webClient) => | ||
| 5371 | withWsRpcClient(mobileUrl, (mobileClient) => | ||
| 5372 | Effect.gen(function* () { | ||
| 5373 | yield* mobileClient[ORCHESTRATION_WS_METHODS.dispatchCommand](turnCommand("mobile")); | ||
| 5374 | yield* webClient[ORCHESTRATION_WS_METHODS.dispatchCommand](turnCommand("web")); | ||
| 5375 | }), | ||
| 5376 | ), | ||
| 5377 | ), | ||
| 5378 | ); | ||
| 5379 | |||
| 5380 | assert.deepEqual( | ||
| 5381 | analyticsEvents | ||
| 5382 | .filter(({ event }) => event === "client.turn.requested") | ||
| 5383 | .map(({ properties }) => properties), | ||
| 5384 | [ | ||
| 5385 | { | ||
| 5386 | surface: "mobile", | ||
| 5387 | appVersion: "3.0.0", | ||
| 5388 | clientAppVersion: "3.0.0", | ||
| 5389 | clientOs: "Android", | ||
| 5390 | os: "Android", | ||
| 5391 | clientDeviceType: "tablet", | ||
| 5392 | osMajorVersion: 15, | ||
| 5393 | clientOsMajorVersion: 15, | ||
| 5394 | deviceModel: "Pixel Tablet", | ||
| 5395 | clientDeviceModel: "Pixel Tablet", | ||
| 5396 | connectionMethod: "relay", | ||
| 5397 | }, | ||
| 5398 | { | ||
| 5399 | surface: "web", | ||
| 5400 | appVersion: "2.0.0", | ||
| 5401 | clientAppVersion: "2.0.0", | ||
| 5402 | clientOs: "Windows", | ||
| 5403 | clientDeviceType: "desktop", | ||
| 5404 | webDeployment: "hosted", | ||
| 5405 | clientBrowser: "Chrome", | ||
| 5406 | connectionMethod: "direct", | ||
| 5407 | }, | ||
| 5408 | ], | ||
| 5409 | ); | ||
| 5410 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 5411 | ); | ||
| 5412 | |||
| 5413 | it.effect("ignores invalid client telemetry without rejecting the connection", () => | ||
| 5414 | Effect.gen(function* () { | ||
| 5415 | const connectedProperties: Array<Readonly<Record<string, unknown>> | undefined> = []; | ||
| 5416 | |||
| 5417 | yield* buildAppUnderTest({ | ||
| 5418 | layers: { | ||
| 5419 | analyticsService: { | ||
| 5420 | record: (event, properties) => | ||
| 5421 | event === "client.connected" | ||
| 5422 | ? Effect.sync(() => connectedProperties.push(properties)) | ||
| 5423 | : Effect.void, | ||
| 5424 | }, | ||
| 5425 | }, | ||
| 5426 | }); | ||
| 5427 | |||
| 5428 | const invalidUrl = yield* getWsServerUrl( | ||
| 5429 | "/ws?clientSurface=watch&clientDeviceType=television&clientOs=Plan9&clientWebDeployment=cdn&clientBrowser=&clientOsMajorVersion=-1&connectionMethod=teleport", | ||
| 5430 | ); | ||
| 5431 | yield* Effect.scoped( | ||
| 5432 | withWsRpcClient(invalidUrl, (client) => client[WS_METHODS.serverGetSettings]({})), | ||
| 5433 | ); | ||
| 5434 | |||
| 5435 | assert.deepEqual(connectedProperties, [{}]); | ||
| 5436 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 5437 | ); | ||
| 5438 | |||
| 5310 | it.effect("routes websocket rpc projects.writeFile errors", () => | 5439 | it.effect("routes websocket rpc projects.writeFile errors", () => |
| 5311 | Effect.gen(function* () { | 5440 | Effect.gen(function* () { |
| 5312 | const fs = yield* FileSystem.FileSystem; | 5441 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/telemetry/AnalyticsService.test.ts+29| ... | @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; |
| 7 | import * as HttpServer from "effect/unstable/http/HttpServer"; | 7 | import * as HttpServer from "effect/unstable/http/HttpServer"; |
| 8 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 8 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 9 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; | 9 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; |
| 10 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 10 | 11 | ||
| 11 | import * as ServerConfig from "../config.ts"; | 12 | import * as ServerConfig from "../config.ts"; |
| 12 | import { getTelemetryIdentifier } from "./Identify.ts"; | 13 | import { getTelemetryIdentifier } from "./Identify.ts"; |
| ... | @@ -20,6 +21,11 @@ interface RecordedBatchRequest { | ... | @@ -20,6 +21,11 @@ interface RecordedBatchRequest { |
| 20 | readonly properties?: { | 21 | readonly properties?: { |
| 21 | readonly index?: number; | 22 | readonly index?: number; |
| 22 | readonly clientType?: string; | 23 | readonly clientType?: string; |
| 24 | readonly serverOs?: string; | ||
| 25 | readonly serverArch?: string; | ||
| 26 | readonly serverAppVersion?: string; | ||
| 27 | readonly serverMode?: string; | ||
| 28 | readonly t3CodeVersion?: string; | ||
| 23 | }; | 29 | }; |
| 24 | }>; | 30 | }>; |
| 25 | } | null; | 31 | } | null; |
| ... | @@ -31,6 +37,11 @@ interface RecordedBatchBody { | ... | @@ -31,6 +37,11 @@ interface RecordedBatchBody { |
| 31 | readonly properties?: { | 37 | readonly properties?: { |
| 32 | readonly index?: number; | 38 | readonly index?: number; |
| 33 | readonly clientType?: string; | 39 | readonly clientType?: string; |
| 40 | readonly serverOs?: string; | ||
| 41 | readonly serverArch?: string; | ||
| 42 | readonly serverAppVersion?: string; | ||
| 43 | readonly serverMode?: string; | ||
| 44 | readonly t3CodeVersion?: string; | ||
| 34 | }; | 45 | }; |
| 35 | }>; | 46 | }>; |
| 36 | } | 47 | } |
| ... | @@ -71,6 +82,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { | ... | @@ -71,6 +82,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { |
| 71 | ); | 82 | ); |
| 72 | const runtimeLayer = telemetryLayer.pipe( | 83 | const runtimeLayer = telemetryLayer.pipe( |
| 73 | Layer.provide(configLayer), | 84 | Layer.provide(configLayer), |
| 85 | Layer.provide( | ||
| 86 | Layer.mergeAll( | ||
| 87 | Layer.succeed(HostProcessPlatform, "linux"), | ||
| 88 | Layer.succeed(HostProcessArchitecture, "arm64"), | ||
| 89 | ), | ||
| 90 | ), | ||
| 74 | Layer.provideMerge(NodeHttpServer.layerTest), | 91 | Layer.provideMerge(NodeHttpServer.layerTest), |
| 75 | ); | 92 | ); |
| 76 | 93 | ||
| ... | @@ -117,6 +134,18 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { | ... | @@ -117,6 +134,18 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { |
| 117 | ), | 134 | ), |
| 118 | true, | 135 | true, |
| 119 | ); | 136 | ); |
| 137 | assert.equal( | ||
| 138 | batchRequests.every((request) => | ||
| 139 | request.body.batch.every( | ||
| 140 | (event) => | ||
| 141 | event.properties?.serverOs === "Linux" && | ||
| 142 | event.properties.serverArch === "arm64" && | ||
| 143 | event.properties.serverAppVersion === event.properties.t3CodeVersion && | ||
| 144 | event.properties.serverMode === "web", | ||
| 145 | ), | ||
| 146 | ), | ||
| 147 | true, | ||
| 148 | ); | ||
| 120 | }), | 149 | }), |
| 121 | ); | 150 | ); |
| 122 | }); | 151 | }); |
apps/server/src/telemetry/AnalyticsService.ts+21| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | * @module AnalyticsService | 7 | * @module AnalyticsService |
| 8 | */ | 8 | */ |
| 9 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 9 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 10 | import type { ClientOs } from "@t3tools/contracts"; | ||
| 10 | import * as Config from "effect/Config"; | 11 | import * as Config from "effect/Config"; |
| 11 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| 12 | import * as DateTime from "effect/DateTime"; | 13 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -66,6 +67,21 @@ export class AnalyticsService extends Context.Service< | ... | @@ -66,6 +67,21 @@ export class AnalyticsService extends Context.Service< |
| 66 | ); | 67 | ); |
| 67 | } | 68 | } |
| 68 | 69 | ||
| 70 | export function serverOsFromNodePlatform(platform: string): ClientOs { | ||
| 71 | switch (platform) { | ||
| 72 | case "darwin": | ||
| 73 | return "macOS"; | ||
| 74 | case "win32": | ||
| 75 | return "Windows"; | ||
| 76 | case "linux": | ||
| 77 | return "Linux"; | ||
| 78 | case "android": | ||
| 79 | return "Android"; | ||
| 80 | default: | ||
| 81 | return "other"; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 69 | export const make = Effect.gen(function* () { | 85 | export const make = Effect.gen(function* () { |
| 70 | const telemetryConfig = yield* TelemetryEnvConfig; | 86 | const telemetryConfig = yield* TelemetryEnvConfig; |
| 71 | const httpClient = yield* HttpClient.HttpClient; | 87 | const httpClient = yield* HttpClient.HttpClient; |
| ... | @@ -121,6 +137,11 @@ export const make = Effect.gen(function* () { | ... | @@ -121,6 +137,11 @@ export const make = Effect.gen(function* () { |
| 121 | arch: hostArchitecture, | 137 | arch: hostArchitecture, |
| 122 | t3CodeVersion: packageJson.version, | 138 | t3CodeVersion: packageJson.version, |
| 123 | clientType, | 139 | clientType, |
| 140 | serverOs: serverOsFromNodePlatform(hostPlatform), | ||
| 141 | serverArch: hostArchitecture, | ||
| 142 | serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName), | ||
| 143 | serverAppVersion: packageJson.version, | ||
| 144 | serverMode: serverConfig.mode, | ||
| 124 | }, | 145 | }, |
| 125 | timestamp: event.capturedAt, | 146 | timestamp: event.capturedAt, |
| 126 | })), | 147 | })), |
apps/server/src/ws.ts+53-22| ... | @@ -15,7 +15,11 @@ import { | ... | @@ -15,7 +15,11 @@ import { |
| 15 | type AuthAccessStreamEvent, | 15 | type AuthAccessStreamEvent, |
| 16 | type AuthEnvironmentScope, | 16 | type AuthEnvironmentScope, |
| 17 | AuthSessionId, | 17 | AuthSessionId, |
| 18 | ClientConnectionMethod, | ||
| 19 | ClientDeviceType, | ||
| 20 | ClientOs, | ||
| 18 | ClientSurface, | 21 | ClientSurface, |
| 22 | ClientWebDeployment, | ||
| 19 | CommandId, | 23 | CommandId, |
| 20 | type DiscoveredLocalServerList, | 24 | type DiscoveredLocalServerList, |
| 21 | EventId, | 25 | EventId, |
| ... | @@ -370,7 +374,12 @@ function toAuthAccessStreamEvent( | ... | @@ -370,7 +374,12 @@ function toAuthAccessStreamEvent( |
| 370 | } | 374 | } |
| 371 | 375 | ||
| 372 | const isClientSurface = Schema.is(ClientSurface); | 376 | const isClientSurface = Schema.is(ClientSurface); |
| 377 | const isClientConnectionMethod = Schema.is(ClientConnectionMethod); | ||
| 378 | const isClientDeviceType = Schema.is(ClientDeviceType); | ||
| 379 | const isClientOs = Schema.is(ClientOs); | ||
| 380 | const isClientWebDeployment = Schema.is(ClientWebDeployment); | ||
| 373 | const MAX_CLIENT_APP_VERSION_LENGTH = 64; | 381 | const MAX_CLIENT_APP_VERSION_LENGTH = 64; |
| 382 | const MAX_CLIENT_BROWSER_LENGTH = 64; | ||
| 374 | const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; | 383 | const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; |
| 375 | 384 | ||
| 376 | // Optional client identity announced on the /ws upgrade URL next to wsTicket. | 385 | // Optional client identity announced on the /ws upgrade URL next to wsTicket. |
| ... | @@ -393,36 +402,56 @@ function readClientConnectionOrigin( | ... | @@ -393,36 +402,56 @@ function readClientConnectionOrigin( |
| 393 | }; | 402 | }; |
| 394 | } | 403 | } |
| 395 | 404 | ||
| 396 | const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ | 405 | // Client telemetry stays in this socket's RPC layer. It must not become a |
| 397 | ...(origin.surface !== undefined ? { surface: origin.surface } : {}), | 406 | // server-global "current client" because several client types can connect at once. |
| 398 | ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), | 407 | function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { |
| 399 | }); | ||
| 400 | |||
| 401 | function readMobileDeviceAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { | ||
| 402 | const url = HttpServerRequest.toURL(request); | 408 | const url = HttpServerRequest.toURL(request); |
| 403 | if (Option.isNone(url) || url.value.searchParams.get("clientSurface") !== "mobile") { | 409 | if (Option.isNone(url)) { |
| 404 | return {}; | 410 | return {}; |
| 405 | } | 411 | } |
| 406 | 412 | ||
| 413 | const surface = url.value.searchParams.get("clientSurface"); | ||
| 414 | const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; | ||
| 415 | const deviceType = url.value.searchParams.get("clientDeviceType"); | ||
| 407 | const os = url.value.searchParams.get("clientOs"); | 416 | const os = url.value.searchParams.get("clientOs"); |
| 417 | const webDeployment = url.value.searchParams.get("clientWebDeployment"); | ||
| 418 | const browser = url.value.searchParams.get("clientBrowser")?.trim() ?? ""; | ||
| 419 | const connectionMethod = url.value.searchParams.get("connectionMethod"); | ||
| 408 | const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; | 420 | const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; |
| 409 | const osMajorVersion = Number(rawOsMajorVersion); | 421 | const osMajorVersion = Number(rawOsMajorVersion); |
| 410 | const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; | 422 | const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; |
| 423 | const isMobile = surface === "mobile"; | ||
| 424 | const hasOsMajorVersion = | ||
| 425 | isMobile && rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0; | ||
| 426 | const hasDeviceModel = | ||
| 427 | isMobile && deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH; | ||
| 411 | 428 | ||
| 412 | return { | 429 | return { |
| 413 | ...(os === "iOS" || os === "Android" ? { os } : {}), | 430 | ...(isClientSurface(surface) ? { surface } : {}), |
| 414 | ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0 | 431 | ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH |
| 415 | ? { osMajorVersion } | 432 | ? { appVersion, clientAppVersion: appVersion } |
| 433 | : {}), | ||
| 434 | ...(isClientOs(os) | ||
| 435 | ? { | ||
| 436 | clientOs: os, | ||
| 437 | ...(isMobile && (os === "iOS" || os === "Android") ? { os } : {}), | ||
| 438 | } | ||
| 416 | : {}), | 439 | : {}), |
| 417 | ...(deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH | 440 | ...(isClientDeviceType(deviceType) ? { clientDeviceType: deviceType } : {}), |
| 418 | ? { deviceModel } | 441 | ...(surface === "web" && isClientWebDeployment(webDeployment) ? { webDeployment } : {}), |
| 442 | ...(surface === "web" && browser !== "" && browser.length <= MAX_CLIENT_BROWSER_LENGTH | ||
| 443 | ? { clientBrowser: browser } | ||
| 419 | : {}), | 444 | : {}), |
| 445 | ...(hasOsMajorVersion ? { osMajorVersion, clientOsMajorVersion: osMajorVersion } : {}), | ||
| 446 | ...(hasDeviceModel ? { deviceModel, clientDeviceModel: deviceModel } : {}), | ||
| 447 | ...(isClientConnectionMethod(connectionMethod) ? { connectionMethod } : {}), | ||
| 420 | }; | 448 | }; |
| 421 | } | 449 | } |
| 422 | 450 | ||
| 423 | const makeWsRpcLayer = ( | 451 | const makeWsRpcLayer = ( |
| 424 | currentSession: EnvironmentAuth.AuthenticatedSession, | 452 | currentSession: EnvironmentAuth.AuthenticatedSession, |
| 425 | clientOrigin: OrchestrationClientOrigin, | 453 | clientOrigin: OrchestrationClientOrigin, |
| 454 | clientAnalyticsProps: Readonly<Record<string, unknown>>, | ||
| 426 | previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], | 455 | previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], |
| 427 | ) => | 456 | ) => |
| 428 | WsRpcGroup.toLayer( | 457 | WsRpcGroup.toLayer( |
| ... | @@ -444,18 +473,17 @@ const makeWsRpcLayer = ( | ... | @@ -444,18 +473,17 @@ const makeWsRpcLayer = ( |
| 444 | command, | 473 | command, |
| 445 | hasClientOrigin ? { origin: clientOrigin } : undefined, | 474 | hasClientOrigin ? { origin: clientOrigin } : undefined, |
| 446 | ); | 475 | ); |
| 447 | const originProps = clientOriginAnalyticsProps(clientOrigin); | ||
| 448 | const recordClientCommandAnalytics = (command: OrchestrationCommand) => { | 476 | const recordClientCommandAnalytics = (command: OrchestrationCommand) => { |
| 449 | switch (command.type) { | 477 | switch (command.type) { |
| 450 | case "thread.create": | 478 | case "thread.create": |
| 451 | return analytics.record("client.thread.started", originProps); | 479 | return analytics.record("client.thread.started", clientAnalyticsProps); |
| 452 | case "thread.turn.start": | 480 | case "thread.turn.start": |
| 453 | return command.bootstrap?.createThread | 481 | return command.bootstrap?.createThread |
| 454 | ? Effect.andThen( | 482 | ? Effect.andThen( |
| 455 | analytics.record("client.thread.started", originProps), | 483 | analytics.record("client.thread.started", clientAnalyticsProps), |
| 456 | analytics.record("client.turn.requested", originProps), | 484 | analytics.record("client.turn.requested", clientAnalyticsProps), |
| 457 | ) | 485 | ) |
| 458 | : analytics.record("client.turn.requested", originProps); | 486 | : analytics.record("client.turn.requested", clientAnalyticsProps); |
| 459 | default: | 487 | default: |
| 460 | return Effect.void; | 488 | return Effect.void; |
| 461 | } | 489 | } |
| ... | @@ -2479,16 +2507,19 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -2479,16 +2507,19 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2479 | ), | 2507 | ), |
| 2480 | ); | 2508 | ); |
| 2481 | const clientOrigin = readClientConnectionOrigin(request); | 2509 | const clientOrigin = readClientConnectionOrigin(request); |
| 2510 | const clientAnalyticsProps = readClientAnalyticsProps(request); | ||
| 2482 | yield* sessions.recordClientConnection(session.sessionId, clientOrigin); | 2511 | yield* sessions.recordClientConnection(session.sessionId, clientOrigin); |
| 2483 | yield* analytics.record("client.connected", { | 2512 | yield* analytics.record("client.connected", clientAnalyticsProps); |
| 2484 | ...clientOriginAnalyticsProps(clientOrigin), | ||
| 2485 | ...readMobileDeviceAnalyticsProps(request), | ||
| 2486 | }); | ||
| 2487 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { | 2513 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { |
| 2488 | disableTracing: true, | 2514 | disableTracing: true, |
| 2489 | }).pipe( | 2515 | }).pipe( |
| 2490 | Effect.provide( | 2516 | Effect.provide( |
| 2491 | makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( | 2517 | makeWsRpcLayer( |
| 2518 | session, | ||
| 2519 | clientOrigin, | ||
| 2520 | clientAnalyticsProps, | ||
| 2521 | previewAutomationBroker, | ||
| 2522 | ).pipe( | ||
| 2492 | Layer.provideMerge(RpcSerialization.layerJson), | 2523 | Layer.provideMerge(RpcSerialization.layerJson), |
| 2493 | Layer.provide(ProviderMaintenanceRunner.layer), | 2524 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 2494 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), | 2525 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), |
apps/web/src/connection/clientMetadata.test.ts created+90| ... | @@ -0,0 +1,90 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | browserClientOs, | ||
| 5 | browserDeviceType, | ||
| 6 | browserFamily, | ||
| 7 | clientPresentationMetadata, | ||
| 8 | } from "./clientMetadata"; | ||
| 9 | |||
| 10 | const desktopChrome = { | ||
| 11 | userAgent: | ||
| 12 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36", | ||
| 13 | platform: "Win32", | ||
| 14 | maxTouchPoints: 0, | ||
| 15 | }; | ||
| 16 | |||
| 17 | describe("client telemetry metadata", () => { | ||
| 18 | it("distinguishes hosted web from server-served web", () => { | ||
| 19 | expect( | ||
| 20 | clientPresentationMetadata({ | ||
| 21 | appVersion: "1.2.3", | ||
| 22 | hosted: true, | ||
| 23 | identity: desktopChrome, | ||
| 24 | desktopBridge: undefined, | ||
| 25 | }), | ||
| 26 | ).toMatchObject({ | ||
| 27 | surface: "web", | ||
| 28 | webDeployment: "hosted", | ||
| 29 | deviceType: "desktop", | ||
| 30 | os: "Windows", | ||
| 31 | browser: "Chrome", | ||
| 32 | appVersion: "1.2.3", | ||
| 33 | }); | ||
| 34 | |||
| 35 | expect( | ||
| 36 | clientPresentationMetadata({ | ||
| 37 | appVersion: "0.0.0", | ||
| 38 | hosted: false, | ||
| 39 | identity: desktopChrome, | ||
| 40 | desktopBridge: undefined, | ||
| 41 | }), | ||
| 42 | ).toMatchObject({ surface: "web", webDeployment: "server" }); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("identifies phone and tablet browsers", () => { | ||
| 46 | const iphone = { | ||
| 47 | userAgent: | ||
| 48 | "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1", | ||
| 49 | platform: "iPhone", | ||
| 50 | maxTouchPoints: 5, | ||
| 51 | }; | ||
| 52 | const androidTablet = { | ||
| 53 | userAgent: | ||
| 54 | "Mozilla/5.0 (Linux; Android 15; Pixel Tablet) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36", | ||
| 55 | platform: "Linux armv8l", | ||
| 56 | maxTouchPoints: 5, | ||
| 57 | }; | ||
| 58 | const ipadosDesktopUa = { | ||
| 59 | userAgent: | ||
| 60 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15", | ||
| 61 | platform: "MacIntel", | ||
| 62 | maxTouchPoints: 5, | ||
| 63 | }; | ||
| 64 | |||
| 65 | expect(browserDeviceType(iphone)).toBe("mobile"); | ||
| 66 | expect(browserClientOs(iphone)).toBe("iOS"); | ||
| 67 | expect(browserFamily(iphone.userAgent)).toBe("Safari"); | ||
| 68 | expect(browserDeviceType(androidTablet)).toBe("tablet"); | ||
| 69 | expect(browserClientOs(androidTablet)).toBe("Android"); | ||
| 70 | expect(browserDeviceType(ipadosDesktopUa)).toBe("tablet"); | ||
| 71 | expect(browserClientOs(ipadosDesktopUa)).toBe("iOS"); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("uses Electron's client platform for desktop", () => { | ||
| 75 | expect( | ||
| 76 | clientPresentationMetadata({ | ||
| 77 | appVersion: "1.2.3", | ||
| 78 | hosted: false, | ||
| 79 | identity: desktopChrome, | ||
| 80 | desktopBridge: { getClientPlatform: () => "darwin" }, | ||
| 81 | }), | ||
| 82 | ).toEqual({ | ||
| 83 | label: "T3 Code Desktop", | ||
| 84 | deviceType: "desktop", | ||
| 85 | os: "macOS", | ||
| 86 | surface: "desktop", | ||
| 87 | appVersion: "1.2.3", | ||
| 88 | }); | ||
| 89 | }); | ||
| 90 | }); | ||
apps/web/src/connection/clientMetadata.ts created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | import type { | ||
| 2 | AuthClientMetadataDeviceType, | ||
| 3 | AuthClientPresentationMetadata, | ||
| 4 | ClientOs, | ||
| 5 | DesktopBridge, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | interface BrowserIdentity { | ||
| 9 | readonly userAgent: string; | ||
| 10 | readonly platform: string; | ||
| 11 | readonly maxTouchPoints: number; | ||
| 12 | } | ||
| 13 | |||
| 14 | function clientOsFromElectronPlatform(platform: string | undefined): ClientOs { | ||
| 15 | switch (platform) { | ||
| 16 | case "darwin": | ||
| 17 | return "macOS"; | ||
| 18 | case "win32": | ||
| 19 | return "Windows"; | ||
| 20 | case "linux": | ||
| 21 | return "Linux"; | ||
| 22 | default: | ||
| 23 | return platform ? "other" : "unknown"; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | function isIpadosDesktopUserAgent(identity: BrowserIdentity): boolean { | ||
| 28 | return ( | ||
| 29 | /macintosh/i.test(identity.userAgent) && | ||
| 30 | /mac/i.test(identity.platform) && | ||
| 31 | identity.maxTouchPoints > 1 | ||
| 32 | ); | ||
| 33 | } | ||
| 34 | |||
| 35 | export function browserClientOs(identity: BrowserIdentity): ClientOs { | ||
| 36 | const userAgent = identity.userAgent; | ||
| 37 | if (userAgent.trim() === "") return "unknown"; | ||
| 38 | if (/iphone|ipad|ipod/i.test(userAgent) || isIpadosDesktopUserAgent(identity)) return "iOS"; | ||
| 39 | if (/android/i.test(userAgent)) return "Android"; | ||
| 40 | if (/cros/i.test(userAgent)) return "ChromeOS"; | ||
| 41 | if (/windows/i.test(userAgent)) return "Windows"; | ||
| 42 | if (/macintosh|mac os x/i.test(userAgent)) return "macOS"; | ||
| 43 | if (/linux|x11/i.test(userAgent)) return "Linux"; | ||
| 44 | return "other"; | ||
| 45 | } | ||
| 46 | |||
| 47 | export function browserFamily(userAgent: string): string { | ||
| 48 | if (userAgent.trim() === "") return "unknown"; | ||
| 49 | if (/edg(?:e|a|ios)?\//i.test(userAgent)) return "Edge"; | ||
| 50 | if (/opr\/|opios\//i.test(userAgent)) return "Opera"; | ||
| 51 | if (/samsungbrowser\//i.test(userAgent)) return "Samsung Internet"; | ||
| 52 | if (/firefox\/|fxios\//i.test(userAgent)) return "Firefox"; | ||
| 53 | if (/chrome\/|crios\//i.test(userAgent)) return "Chrome"; | ||
| 54 | if (/safari\//i.test(userAgent)) return "Safari"; | ||
| 55 | return "other"; | ||
| 56 | } | ||
| 57 | |||
| 58 | export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType { | ||
| 59 | const userAgent = identity.userAgent; | ||
| 60 | if (userAgent.trim() === "") return "unknown"; | ||
| 61 | if ( | ||
| 62 | /ipad|tablet|kindle|silk/i.test(userAgent) || | ||
| 63 | (/android/i.test(userAgent) && !/mobile/i.test(userAgent)) || | ||
| 64 | isIpadosDesktopUserAgent(identity) | ||
| 65 | ) { | ||
| 66 | return "tablet"; | ||
| 67 | } | ||
| 68 | if (/iphone|ipod|android.+mobile|mobile/i.test(userAgent)) return "mobile"; | ||
| 69 | return "desktop"; | ||
| 70 | } | ||
| 71 | |||
| 72 | export function clientPresentationMetadata(input: { | ||
| 73 | readonly appVersion: string; | ||
| 74 | readonly hosted: boolean; | ||
| 75 | readonly identity: BrowserIdentity; | ||
| 76 | readonly desktopBridge: Pick<DesktopBridge, "getClientPlatform"> | undefined; | ||
| 77 | }): AuthClientPresentationMetadata { | ||
| 78 | if (input.desktopBridge !== undefined) { | ||
| 79 | return { | ||
| 80 | label: "T3 Code Desktop", | ||
| 81 | deviceType: "desktop", | ||
| 82 | os: clientOsFromElectronPlatform(input.desktopBridge.getClientPlatform?.()), | ||
| 83 | surface: "desktop", | ||
| 84 | ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }), | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | return { | ||
| 89 | label: "T3 Code Web", | ||
| 90 | deviceType: browserDeviceType(input.identity), | ||
| 91 | os: browserClientOs(input.identity), | ||
| 92 | surface: "web", | ||
| 93 | webDeployment: input.hosted ? "hosted" : "server", | ||
| 94 | browser: browserFamily(input.identity.userAgent), | ||
| 95 | ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }), | ||
| 96 | }; | ||
| 97 | } | ||
apps/web/src/connection/platform.ts+11-9| ... | @@ -59,6 +59,7 @@ import { | ... | @@ -59,6 +59,7 @@ import { |
| 59 | type DesktopSecondaryBootstrapsRead, | 59 | type DesktopSecondaryBootstrapsRead, |
| 60 | } from "./desktopLocal"; | 60 | } from "./desktopLocal"; |
| 61 | import { connectionStorageLayer } from "./storage"; | 61 | import { connectionStorageLayer } from "./storage"; |
| 62 | import { clientPresentationMetadata } from "./clientMetadata"; | ||
| 62 | 63 | ||
| 63 | let nextObservedRpcRequestId = 0; | 64 | let nextObservedRpcRequestId = 0; |
| 64 | 65 | ||
| ... | @@ -115,15 +116,16 @@ const wakeupsLayer = Wakeups.layer({ | ... | @@ -115,15 +116,16 @@ const wakeupsLayer = Wakeups.layer({ |
| 115 | }); | 116 | }); |
| 116 | 117 | ||
| 117 | function clientMetadata() { | 118 | function clientMetadata() { |
| 118 | const desktop = window.desktopBridge !== undefined; | 119 | return clientPresentationMetadata({ |
| 119 | const platform = navigator.platform.trim(); | 120 | appVersion: APP_VERSION, |
| 120 | return { | 121 | hosted: isHostedStaticApp(), |
| 121 | label: desktop ? "T3 Code Desktop" : "T3 Code Web", | 122 | identity: { |
| 122 | deviceType: "desktop" as const, | 123 | userAgent: navigator.userAgent, |
| 123 | ...(platform === "" ? {} : { os: platform }), | 124 | platform: navigator.platform, |
| 124 | surface: desktop ? ("desktop" as const) : ("web" as const), | 125 | maxTouchPoints: navigator.maxTouchPoints, |
| 125 | ...(APP_VERSION === "0.0.0" ? {} : { appVersion: APP_VERSION }), | 126 | }, |
| 126 | }; | 127 | desktopBridge: window.desktopBridge, |
| 128 | }); | ||
| 127 | } | 129 | } |
| 128 | 130 | ||
| 129 | function sshPreparationError(cause: unknown) { | 131 | function sshPreparationError(cause: unknown) { |
docs/README.md+1| ... | @@ -33,6 +33,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A | ... | @@ -33,6 +33,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A |
| 33 | - [Remote environments](./internals/remote.md) | 33 | - [Remote environments](./internals/remote.md) |
| 34 | - [Server updates](./internals/server-updates.md) | 34 | - [Server updates](./internals/server-updates.md) |
| 35 | - [Resource telemetry](./internals/resource-telemetry.md) | 35 | - [Resource telemetry](./internals/resource-telemetry.md) |
| 36 | - [Product analytics](./internals/product-analytics.md) | ||
| 36 | - [Environment auth](./internals/environment-auth.md) | 37 | - [Environment auth](./internals/environment-auth.md) |
| 37 | - [T3 Connect](./internals/t3-connect.md) | 38 | - [T3 Connect](./internals/t3-connect.md) |
| 38 | - [CI gates](./internals/ci.md) | 39 | - [CI gates](./internals/ci.md) |
docs/internals/ci.md+2-1| ... | @@ -8,7 +8,8 @@ and pushes to `main`: | ... | @@ -8,7 +8,8 @@ and pushes to `main`: |
| 8 | - **Check**: `vp check` (format and lint; this repo sets `typeCheck: false` in its lint options), | 8 | - **Check**: `vp check` (format and lint; this repo sets `typeCheck: false` in its lint options), |
| 9 | then `vpr typecheck` for the workspace type check. The same job | 9 | then `vpr typecheck` for the workspace type check. The same job |
| 10 | builds the desktop pipeline (`vp run build:desktop`) and verifies the preload bundle exists and | 10 | builds the desktop pipeline (`vp run build:desktop`) and verifies the preload bundle exists and |
| 11 | still exports its expected symbols. | 11 | uses only imports that Electron's sandbox can load. The verifier parses imports, then executes the |
| 12 | trusted artifact with controlled bridge stubs to confirm that its required APIs are callable. | ||
| 12 | - **Test**: `vp run test` across the workspace. | 13 | - **Test**: `vp run test` across the workspace. |
| 13 | - **Mobile Native Static Analysis**: `vp run lint:mobile` on macOS, wrapping | 14 | - **Mobile Native Static Analysis**: `vp run lint:mobile` on macOS, wrapping |
| 14 | `scripts/mobile-native-static-check.ts`. A cheap Linux **Mobile Native Changes** job gates it: | 15 | `scripts/mobile-native-static-check.ts`. A cheap Linux **Mobile Native Changes** job gates it: |
docs/internals/overview.md+1| ... | @@ -134,6 +134,7 @@ already dispatch. | ... | @@ -134,6 +134,7 @@ already dispatch. |
| 134 | - [Workspace layout](./workspace-layout.md), [Glossary](./glossary.md) | 134 | - [Workspace layout](./workspace-layout.md), [Glossary](./glossary.md) |
| 135 | - [Remote environments](./remote.md), [Server updates](./server-updates.md) | 135 | - [Remote environments](./remote.md), [Server updates](./server-updates.md) |
| 136 | - [Resource telemetry](./resource-telemetry.md) | 136 | - [Resource telemetry](./resource-telemetry.md) |
| 137 | - [Product analytics](./product-analytics.md) | ||
| 137 | - [Scripts](./scripts.md), [CI gates](./ci.md) | 138 | - [Scripts](./scripts.md), [CI gates](./ci.md) |
| 138 | 139 | ||
| 139 | [rpc]: ../../packages/contracts/src/rpc.ts | 140 | [rpc]: ../../packages/contracts/src/rpc.ts |
docs/internals/product-analytics.md created+112| ... | @@ -0,0 +1,112 @@ | ||
| 1 | # Product analytics | ||
| 2 | |||
| 3 | T3 Code sends anonymous product events from the server to PostHog. The server | ||
| 4 | uses the first available hashed Codex account ID, hashed Claude user ID, or | ||
| 5 | installation-scoped anonymous ID as the distinct ID. It also keeps the | ||
| 6 | telemetry opt-out, event buffer, and batch delivery. Clients do not load the | ||
| 7 | PostHog browser SDK. | ||
| 8 | |||
| 9 | ## Client events | ||
| 10 | |||
| 11 | These events use the metadata from the WebSocket connection that caused them. | ||
| 12 | The metadata is not a person property or server-global current-client value. | ||
| 13 | Two clients connected to one server can report different values at the same | ||
| 14 | time. | ||
| 15 | |||
| 16 | | Event | Description | | ||
| 17 | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| 18 | | `client.connected` | The server accepted an authenticated WebSocket connection. Reconnects count again. Use this event for connection diagnostics, not active-use counts. | | ||
| 19 | | `client.thread.started` | The server accepted a command that created a thread. | | ||
| 20 | | `client.turn.requested` | The server accepted a turn request. This is the standard active-use event. | | ||
| 21 | |||
| 22 | `provider.turn.sent` stays a provider execution event. It does not receive | ||
| 23 | client metadata because a provider turn can continue after the requesting | ||
| 24 | client disconnects. | ||
| 25 | |||
| 26 | ## Recommended properties | ||
| 27 | |||
| 28 | Client properties appear on the three client events when the connected client | ||
| 29 | reports them. Older clients can omit every client property. | ||
| 30 | |||
| 31 | | Property | Values and meaning | | ||
| 32 | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| 33 | | `surface` | Product client: `web`, `desktop`, or `mobile`. | | ||
| 34 | | `webDeployment` | Web delivery: `hosted` for the hosted app or `server` for web files served by a T3 server. Web only. This does not describe connection distance. | | ||
| 35 | | `clientOs` | `macOS`, `Windows`, `Linux`, `iOS`, `Android`, `ChromeOS`, `other`, or `unknown`. | | ||
| 36 | | `clientDeviceType` | `desktop`, `phone`, `tablet`, or `unknown`. This is separate from `surface`. | | ||
| 37 | | `clientBrowser` | Normalized browser family. Web only. Browser detection is best effort. | | ||
| 38 | | `clientAppVersion` | Version of the connected client. | | ||
| 39 | | `clientOsMajorVersion` | Client OS major version when the native client reports it. Initially mobile only. | | ||
| 40 | | `clientDeviceModel` | Hardware model when the native client reports it. Initially mobile only. This is not a user-assigned device name. | | ||
| 41 | | `connectionMethod` | `direct`, `ssh`, `relay`, or `unknown`. `direct` means that the client connected to the server endpoint without an SSH or relay connection. It does not mean both processes run on one machine. | | ||
| 42 | |||
| 43 | Server properties appear on all events, including server boot and background | ||
| 44 | events. | ||
| 45 | |||
| 46 | | Property | Values and meaning | | ||
| 47 | | ------------------ | -------------------------------------------------------------- | | ||
| 48 | | `serverOs` | Server process OS, normalized to the same names as `clientOs`. | | ||
| 49 | | `serverArch` | Server process architecture. | | ||
| 50 | | `serverWslDistro` | WSL distribution from `WSL_DISTRO_NAME`, when present. | | ||
| 51 | | `serverAppVersion` | T3 server version. | | ||
| 52 | | `serverMode` | Server runtime mode: `desktop` or `web`. | | ||
| 53 | |||
| 54 | ## Legacy properties | ||
| 55 | |||
| 56 | Existing property meanings do not change: | ||
| 57 | |||
| 58 | - `clientType` describes how the server runs. It is `desktop-app` for a desktop | ||
| 59 | server and `cli-web-client` for a CLI web server. It does not describe the | ||
| 60 | connected client. Use `surface` and `webDeployment` for new reports. | ||
| 61 | - `platform`, `arch`, `wsl`, and `t3CodeVersion` describe the server. Use the | ||
| 62 | new `server*` names for new reports. | ||
| 63 | - `appVersion` describes the connected client. Use `clientAppVersion` for new | ||
| 64 | reports. | ||
| 65 | - Mobile connection events keep `os`, `osMajorVersion`, and `deviceModel`. | ||
| 66 | Use the new `client*` names for new reports. | ||
| 67 | |||
| 68 | ## PostHog dashboard | ||
| 69 | |||
| 70 | Create one saved dashboard named `Client and platform usage`. Set | ||
| 71 | `client.turn.requested` as the event for active-use reports. A user can appear | ||
| 72 | in several client groups during one period, so do not add breakdown values to | ||
| 73 | calculate a total. | ||
| 74 | |||
| 75 | Save these insights: | ||
| 76 | |||
| 77 | | Insight | Configuration | | ||
| 78 | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| 79 | | Active users, daily | Trends, `client.turn.requested`, unique users, daily interval. | | ||
| 80 | | Active users, weekly | Trends, `client.turn.requested`, unique users, weekly interval. | | ||
| 81 | | Active users, monthly | Trends, `client.turn.requested`, unique users, monthly interval. | | ||
| 82 | | Client usage | Trends, `client.turn.requested`, unique users. Save four filtered series: `surface = desktop`, `surface = mobile`, `surface = web` and `webDeployment = hosted`, and `surface = web` and `webDeployment = server`. Name them Desktop, Native mobile, Hosted web, and Server-served web. | | ||
| 83 | | Client OS, active users | Trends, `client.turn.requested`, unique users, breakdown by `clientOs`. | | ||
| 84 | | Client OS, turns | Trends, `client.turn.requested`, total events, breakdown by `clientOs`. | | ||
| 85 | | Client versus server OS | Table, `client.turn.requested`, breakdown by `clientOs` and `serverOs`. | | ||
| 86 | | Connection method, active users | Trends, `client.turn.requested`, unique users, breakdown by `connectionMethod`. | | ||
| 87 | | Connection method, turns | Trends, `client.turn.requested`, total events, breakdown by `connectionMethod`. | | ||
| 88 | | Mobile devices | Table, `client.turn.requested`, filter `surface = mobile`, breakdown by `clientOs`, `clientOsMajorVersion`, and `clientDeviceType`. | | ||
| 89 | | Client version adoption | Trends, `client.turn.requested`, unique users, breakdown by `clientAppVersion`. | | ||
| 90 | | Server version adoption | Trends, `client.turn.requested`, unique users, breakdown by `serverAppVersion`. | | ||
| 91 | | Missing metadata | Table or SQL insight that shows the percentage of `client.turn.requested` events where each of `surface`, `clientOs`, `clientDeviceType`, `clientAppVersion`, and `connectionMethod` is absent. Track `webDeployment` and `clientBrowser` only within `surface = web`. | | ||
| 92 | |||
| 93 | In PostHog Data management, use the event and property descriptions from this | ||
| 94 | document. Mark the recommended properties as verified. Keep `clientType` | ||
| 95 | visible with its legacy description so old reports remain understandable. | ||
| 96 | |||
| 97 | ## Collection and release boundary | ||
| 98 | |||
| 99 | Client values are best effort. Invalid values are ignored and never reject a | ||
| 100 | connection. Browser clients use user-agent data for broad OS, browser, phone, | ||
| 101 | and tablet groups. They do not infer CPU architecture or an exact OS version | ||
| 102 | from `navigator.platform`. | ||
| 103 | |||
| 104 | This change does not collect URLs, tokens, prompts, IP addresses, or | ||
| 105 | user-assigned device names. It measures authenticated product use. It does not | ||
| 106 | measure a person who visits the hosted app without connecting to a server. | ||
| 107 | |||
| 108 | The new fields start with the first client and server release that contains | ||
| 109 | this metadata path. Historical events cannot reliably identify the client OS, | ||
| 110 | hosted web use, device type, or connection method when the old client did not | ||
| 111 | send those fields. Reports must treat missing values as pre-release or older | ||
| 112 | client data instead of backfilling them from server fields. | ||
packages/client-runtime/src/authorization/layer.test.ts+3| ... | @@ -174,6 +174,7 @@ describe("RemoteEnvironmentAuthorization", () => { | ... | @@ -174,6 +174,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 174 | httpBaseUrl: ENDPOINT.httpBaseUrl, | 174 | httpBaseUrl: ENDPOINT.httpBaseUrl, |
| 175 | wsBaseUrl: ENDPOINT.wsBaseUrl, | 175 | wsBaseUrl: ENDPOINT.wsBaseUrl, |
| 176 | bearerToken: "bearer-token", | 176 | bearerToken: "bearer-token", |
| 177 | connectionMethod: "direct", | ||
| 177 | }); | 178 | }); |
| 178 | return [yield* authorize(), yield* authorize()] as const; | 179 | return [yield* authorize(), yield* authorize()] as const; |
| 179 | }).pipe(Effect.provide(harness.layer)); | 180 | }).pipe(Effect.provide(harness.layer)); |
| ... | @@ -211,6 +212,7 @@ describe("RemoteEnvironmentAuthorization", () => { | ... | @@ -211,6 +212,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 211 | httpBaseUrl: ENDPOINT.httpBaseUrl, | 212 | httpBaseUrl: ENDPOINT.httpBaseUrl, |
| 212 | wsBaseUrl: ENDPOINT.wsBaseUrl, | 213 | wsBaseUrl: ENDPOINT.wsBaseUrl, |
| 213 | bearerToken: "bearer-token", | 214 | bearerToken: "bearer-token", |
| 215 | connectionMethod: "direct", | ||
| 214 | }); | 216 | }); |
| 215 | 217 | ||
| 216 | yield* authorize(); | 218 | yield* authorize(); |
| ... | @@ -255,6 +257,7 @@ describe("RemoteEnvironmentAuthorization", () => { | ... | @@ -255,6 +257,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 255 | }).pipe(Effect.provide(harness.layer)); | 257 | }).pipe(Effect.provide(harness.layer)); |
| 256 | 258 | ||
| 257 | expect(authorized.socketUrl).toContain("wsTicket=cached-ticket"); | 259 | expect(authorized.socketUrl).toContain("wsTicket=cached-ticket"); |
| 260 | expect(authorized.socketUrl).toContain("connectionMethod=relay"); | ||
| 258 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); | 261 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); |
| 259 | expect(harness.fetch.calls).toHaveLength(1); | 262 | expect(harness.fetch.calls).toHaveLength(1); |
| 260 | expect(String(harness.fetch.calls[0]?.[0])).toBe( | 263 | expect(String(harness.fetch.calls[0]?.[0])).toBe( |
packages/client-runtime/src/authorization/remote.test.ts+45-1| ... | @@ -7,6 +7,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -7,6 +7,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 7 | 7 | ||
| 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; | 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; |
| 9 | import { | 9 | import { |
| 10 | appendClientConnectionParams, | ||
| 10 | bootstrapRemoteBearerSession, | 11 | bootstrapRemoteBearerSession, |
| 11 | exchangeRemoteDpopAccessToken, | 12 | exchangeRemoteDpopAccessToken, |
| 12 | fetchRemoteDpopSessionState, | 13 | fetchRemoteDpopSessionState, |
| ... | @@ -209,6 +210,47 @@ describe("remote environment authorization", () => { | ... | @@ -209,6 +210,47 @@ describe("remote environment authorization", () => { |
| 209 | }), | 210 | }), |
| 210 | ); | 211 | ); |
| 211 | 212 | ||
| 213 | it.effect("keeps OS sentinels in telemetry but out of display metadata", () => | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const tokenResponse = () => | ||
| 216 | Response.json( | ||
| 217 | { | ||
| 218 | access_token: "bearer-token", | ||
| 219 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 220 | token_type: "Bearer", | ||
| 221 | expires_in: 3600, | ||
| 222 | scope: "orchestration:read", | ||
| 223 | }, | ||
| 224 | { status: 200 }, | ||
| 225 | ); | ||
| 226 | const fetch = recordedFetch(tokenResponse(), tokenResponse()); | ||
| 227 | |||
| 228 | for (const os of ["unknown", "other"] as const) { | ||
| 229 | yield* bootstrapRemoteBearerSession({ | ||
| 230 | httpBaseUrl: "https://remote.example.com/", | ||
| 231 | credential: "pairing-token", | ||
| 232 | clientMetadata: { | ||
| 233 | label: "T3 Code Web", | ||
| 234 | deviceType: "desktop", | ||
| 235 | os, | ||
| 236 | }, | ||
| 237 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 238 | } | ||
| 239 | |||
| 240 | for (const [, init] of fetch.calls) { | ||
| 241 | expect(String(init.body)).not.toContain("client_os="); | ||
| 242 | } | ||
| 243 | |||
| 244 | const websocketUrl = new URL("wss://remote.example.com/ws"); | ||
| 245 | appendClientConnectionParams(websocketUrl, { | ||
| 246 | surface: "web", | ||
| 247 | deviceType: "desktop", | ||
| 248 | os: "unknown", | ||
| 249 | }); | ||
| 250 | expect(websocketUrl.searchParams.get("clientOs")).toBe("unknown"); | ||
| 251 | }), | ||
| 252 | ); | ||
| 253 | |||
| 212 | it.effect("allows a client to explicitly narrow a pairing grant", () => | 254 | it.effect("allows a client to explicitly narrow a pairing grant", () => |
| 213 | Effect.gen(function* () { | 255 | Effect.gen(function* () { |
| 214 | const fetch = recordedFetch( | 256 | const fetch = recordedFetch( |
| ... | @@ -470,14 +512,16 @@ describe("remote environment authorization", () => { | ... | @@ -470,14 +512,16 @@ describe("remote environment authorization", () => { |
| 470 | clientMetadata: { | 512 | clientMetadata: { |
| 471 | surface: "mobile", | 513 | surface: "mobile", |
| 472 | appVersion: "1.2.3", | 514 | appVersion: "1.2.3", |
| 515 | deviceType: "mobile", | ||
| 473 | os: "Android", | 516 | os: "Android", |
| 474 | osMajorVersion: 15, | 517 | osMajorVersion: 15, |
| 475 | deviceModel: "Pixel 9", | 518 | deviceModel: "Pixel 9", |
| 476 | }, | 519 | }, |
| 520 | connectionMethod: "relay", | ||
| 477 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | 521 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 478 | 522 | ||
| 479 | expect(url).toBe( | 523 | 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", | 524 | "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3&clientDeviceType=phone&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+9&connectionMethod=relay", |
| 481 | ); | 525 | ); |
| 482 | }), | 526 | }), |
| 483 | ); | 527 | ); |
packages/client-runtime/src/authorization/remote.ts+39-10| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | type AuthClientPresentationMetadata, | 3 | type AuthClientPresentationMetadata, |
| 4 | AuthEnvironmentBootstrapTokenType, | 4 | AuthEnvironmentBootstrapTokenType, |
| 5 | AuthTokenExchangeGrantType, | 5 | AuthTokenExchangeGrantType, |
| 6 | type ClientConnectionMethod, | ||
| 6 | type AuthEnvironmentScope, | 7 | type AuthEnvironmentScope, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 9 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| ... | @@ -26,17 +27,23 @@ const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; | ... | @@ -26,17 +27,23 @@ const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; |
| 26 | 27 | ||
| 27 | const clientMetadataTokenExchangeFields = ( | 28 | const clientMetadataTokenExchangeFields = ( |
| 28 | clientMetadata: AuthClientPresentationMetadata | undefined, | 29 | clientMetadata: AuthClientPresentationMetadata | undefined, |
| 29 | ) => ({ | 30 | ) => { |
| 30 | ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), | 31 | const displayOs = clientMetadata?.os; |
| 31 | ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), | 32 | return { |
| 32 | ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), | 33 | ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), |
| 33 | }); | 34 | ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), |
| 35 | ...(displayOs && displayOs !== "unknown" && displayOs !== "other" | ||
| 36 | ? { client_os: displayOs } | ||
| 37 | : {}), | ||
| 38 | }; | ||
| 39 | }; | ||
| 34 | 40 | ||
| 35 | // The server reads these off the /ws upgrade URL next to wsTicket. Optional on | 41 | // The server reads these off the /ws upgrade URL next to wsTicket. Optional on |
| 36 | // both ends: old servers ignore unknown params, old clients never send them. | 42 | // both ends: old servers ignore unknown params, old clients never send them. |
| 37 | export const appendClientConnectionParams = ( | 43 | export const appendClientConnectionParams = ( |
| 38 | url: URL, | 44 | url: URL, |
| 39 | clientMetadata: AuthClientPresentationMetadata | undefined, | 45 | clientMetadata: AuthClientPresentationMetadata | undefined, |
| 46 | connectionMethod?: ClientConnectionMethod, | ||
| 40 | ): void => { | 47 | ): void => { |
| 41 | if (clientMetadata?.surface) { | 48 | if (clientMetadata?.surface) { |
| 42 | url.searchParams.set("clientSurface", clientMetadata.surface); | 49 | url.searchParams.set("clientSurface", clientMetadata.surface); |
| ... | @@ -44,10 +51,27 @@ export const appendClientConnectionParams = ( | ... | @@ -44,10 +51,27 @@ export const appendClientConnectionParams = ( |
| 44 | if (clientMetadata?.appVersion) { | 51 | if (clientMetadata?.appVersion) { |
| 45 | url.searchParams.set("clientAppVersion", clientMetadata.appVersion); | 52 | url.searchParams.set("clientAppVersion", clientMetadata.appVersion); |
| 46 | } | 53 | } |
| 47 | if (clientMetadata?.surface === "mobile") { | 54 | if (clientMetadata?.deviceType) { |
| 48 | if (clientMetadata.os) { | 55 | const deviceType = |
| 49 | url.searchParams.set("clientOs", clientMetadata.os); | 56 | clientMetadata.deviceType === "mobile" |
| 57 | ? "phone" | ||
| 58 | : clientMetadata.deviceType === "desktop" || clientMetadata.deviceType === "tablet" | ||
| 59 | ? clientMetadata.deviceType | ||
| 60 | : "unknown"; | ||
| 61 | url.searchParams.set("clientDeviceType", deviceType); | ||
| 62 | } | ||
| 63 | if (clientMetadata?.os) { | ||
| 64 | url.searchParams.set("clientOs", clientMetadata.os); | ||
| 65 | } | ||
| 66 | if (clientMetadata?.surface === "web") { | ||
| 67 | if (clientMetadata.webDeployment) { | ||
| 68 | url.searchParams.set("clientWebDeployment", clientMetadata.webDeployment); | ||
| 50 | } | 69 | } |
| 70 | if (clientMetadata.browser) { | ||
| 71 | url.searchParams.set("clientBrowser", clientMetadata.browser); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | if (clientMetadata?.surface === "mobile") { | ||
| 51 | if (clientMetadata.osMajorVersion !== undefined) { | 75 | if (clientMetadata.osMajorVersion !== undefined) { |
| 52 | url.searchParams.set("clientOsMajorVersion", String(clientMetadata.osMajorVersion)); | 76 | url.searchParams.set("clientOsMajorVersion", String(clientMetadata.osMajorVersion)); |
| 53 | } | 77 | } |
| ... | @@ -55,6 +79,9 @@ export const appendClientConnectionParams = ( | ... | @@ -55,6 +79,9 @@ export const appendClientConnectionParams = ( |
| 55 | url.searchParams.set("clientDeviceModel", clientMetadata.deviceModel); | 79 | url.searchParams.set("clientDeviceModel", clientMetadata.deviceModel); |
| 56 | } | 80 | } |
| 57 | } | 81 | } |
| 82 | if (connectionMethod) { | ||
| 83 | url.searchParams.set("connectionMethod", connectionMethod); | ||
| 84 | } | ||
| 58 | }; | 85 | }; |
| 59 | 86 | ||
| 60 | export const exchangeRemoteDpopAccessToken = Effect.fn( | 87 | export const exchangeRemoteDpopAccessToken = Effect.fn( |
| ... | @@ -200,6 +227,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ... | @@ -200,6 +227,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 200 | readonly httpBaseUrl: string; | 227 | readonly httpBaseUrl: string; |
| 201 | readonly bearerToken: string; | 228 | readonly bearerToken: string; |
| 202 | readonly clientMetadata?: AuthClientPresentationMetadata; | 229 | readonly clientMetadata?: AuthClientPresentationMetadata; |
| 230 | readonly connectionMethod?: ClientConnectionMethod; | ||
| 203 | readonly timeoutMs?: number; | 231 | readonly timeoutMs?: number; |
| 204 | }) { | 232 | }) { |
| 205 | const issued = yield* issueRemoteWebSocketTicket({ | 233 | const issued = yield* issueRemoteWebSocketTicket({ |
| ... | @@ -213,7 +241,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ... | @@ -213,7 +241,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 213 | url.pathname = "/ws"; | 241 | url.pathname = "/ws"; |
| 214 | } | 242 | } |
| 215 | url.searchParams.set("wsTicket", issued.ticket); | 243 | url.searchParams.set("wsTicket", issued.ticket); |
| 216 | appendClientConnectionParams(url, input.clientMetadata); | 244 | appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); |
| 217 | return url.toString(); | 245 | return url.toString(); |
| 218 | }); | 246 | }); |
| 219 | 247 | ||
| ... | @@ -225,6 +253,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( | ... | @@ -225,6 +253,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 225 | readonly accessToken: string; | 253 | readonly accessToken: string; |
| 226 | readonly dpopProof: string; | 254 | readonly dpopProof: string; |
| 227 | readonly clientMetadata?: AuthClientPresentationMetadata; | 255 | readonly clientMetadata?: AuthClientPresentationMetadata; |
| 256 | readonly connectionMethod?: ClientConnectionMethod; | ||
| 228 | readonly timeoutMs?: number; | 257 | readonly timeoutMs?: number; |
| 229 | }) { | 258 | }) { |
| 230 | const issued = yield* issueRemoteDpopWebSocketTicket({ | 259 | const issued = yield* issueRemoteDpopWebSocketTicket({ |
| ... | @@ -238,6 +267,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( | ... | @@ -238,6 +267,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 238 | url.pathname = "/ws"; | 267 | url.pathname = "/ws"; |
| 239 | } | 268 | } |
| 240 | url.searchParams.set("wsTicket", issued.ticket); | 269 | url.searchParams.set("wsTicket", issued.ticket); |
| 241 | appendClientConnectionParams(url, input.clientMetadata); | 270 | appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); |
| 242 | return url.toString(); | 271 | return url.toString(); |
| 243 | }); | 272 | }); |
packages/client-runtime/src/authorization/service.ts+9-1| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | 1 | import { |
| 2 | type ClientConnectionMethod, | ||
| 3 | EnvironmentId, | ||
| 4 | type ExecutionEnvironmentDescriptor, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; | 6 | import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; |
| 3 | import { | 7 | import { |
| 4 | exchangeRemoteDpopAccessToken, | 8 | exchangeRemoteDpopAccessToken, |
| ... | @@ -46,6 +50,7 @@ export class RemoteEnvironmentAuthorization extends Context.Service< | ... | @@ -46,6 +50,7 @@ export class RemoteEnvironmentAuthorization extends Context.Service< |
| 46 | readonly httpBaseUrl: string; | 50 | readonly httpBaseUrl: string; |
| 47 | readonly wsBaseUrl: string; | 51 | readonly wsBaseUrl: string; |
| 48 | readonly bearerToken: string; | 52 | readonly bearerToken: string; |
| 53 | readonly connectionMethod: ClientConnectionMethod; | ||
| 49 | }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>; | 54 | }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>; |
| 50 | readonly authorizeDpop: (input: { | 55 | readonly authorizeDpop: (input: { |
| 51 | readonly expectedEnvironmentId: EnvironmentId; | 56 | readonly expectedEnvironmentId: EnvironmentId; |
| ... | @@ -99,6 +104,7 @@ export const make = Effect.gen(function* () { | ... | @@ -99,6 +104,7 @@ export const make = Effect.gen(function* () { |
| 99 | readonly httpBaseUrl: string; | 104 | readonly httpBaseUrl: string; |
| 100 | readonly wsBaseUrl: string; | 105 | readonly wsBaseUrl: string; |
| 101 | readonly bearerToken: string; | 106 | readonly bearerToken: string; |
| 107 | readonly connectionMethod: ClientConnectionMethod; | ||
| 102 | }) { | 108 | }) { |
| 103 | const now = yield* Clock.currentTimeMillis; | 109 | const now = yield* Clock.currentTimeMillis; |
| 104 | const cachedDescriptor = (yield* Ref.get(bearerDescriptors)).get(input.expectedEnvironmentId); | 110 | const cachedDescriptor = (yield* Ref.get(bearerDescriptors)).get(input.expectedEnvironmentId); |
| ... | @@ -132,6 +138,7 @@ export const make = Effect.gen(function* () { | ... | @@ -132,6 +138,7 @@ export const make = Effect.gen(function* () { |
| 132 | httpBaseUrl: input.httpBaseUrl, | 138 | httpBaseUrl: input.httpBaseUrl, |
| 133 | bearerToken: input.bearerToken, | 139 | bearerToken: input.bearerToken, |
| 134 | clientMetadata: presentation.metadata, | 140 | clientMetadata: presentation.metadata, |
| 141 | connectionMethod: input.connectionMethod, | ||
| 135 | }).pipe( | 142 | }).pipe( |
| 136 | Effect.mapError(mapRemoteEnvironmentError), | 143 | Effect.mapError(mapRemoteEnvironmentError), |
| 137 | Effect.provideService(HttpClient.HttpClient, httpClient), | 144 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| ... | @@ -172,6 +179,7 @@ export const make = Effect.gen(function* () { | ... | @@ -172,6 +179,7 @@ export const make = Effect.gen(function* () { |
| 172 | accessToken: token.accessToken, | 179 | accessToken: token.accessToken, |
| 173 | dpopProof: ticketProof, | 180 | dpopProof: ticketProof, |
| 174 | clientMetadata: presentation.metadata, | 181 | clientMetadata: presentation.metadata, |
| 182 | connectionMethod: "relay", | ||
| 175 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | 183 | ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 176 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); | 184 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); |
| 177 | }, | 185 | }, |
packages/client-runtime/src/connection/resolver.test.ts+29-7| ... | @@ -223,7 +223,8 @@ describe("ConnectionResolver", () => { | ... | @@ -223,7 +223,8 @@ describe("ConnectionResolver", () => { |
| 223 | environmentId: ENVIRONMENT_ID, | 223 | environmentId: ENVIRONMENT_ID, |
| 224 | label: "Primary", | 224 | label: "Primary", |
| 225 | httpBaseUrl: "http://127.0.0.1:3777", | 225 | httpBaseUrl: "http://127.0.0.1:3777", |
| 226 | socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web", | 226 | socketUrl: |
| 227 | "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct", | ||
| 227 | httpAuthorization: null, | 228 | httpAuthorization: null, |
| 228 | target, | 229 | target, |
| 229 | }); | 230 | }); |
| ... | @@ -232,11 +233,14 @@ describe("ConnectionResolver", () => { | ... | @@ -232,11 +233,14 @@ describe("ConnectionResolver", () => { |
| 232 | 233 | ||
| 233 | it.effect("authorizes a desktop primary environment with its platform bearer token", () => | 234 | it.effect("authorizes a desktop primary environment with its platform bearer token", () => |
| 234 | Effect.gen(function* () { | 235 | Effect.gen(function* () { |
| 235 | const bearerInputs = yield* Ref.make<ReadonlyArray<string>>([]); | 236 | const bearerInputs = yield* Ref.make<ReadonlyArray<{ token: string; method: string }>>([]); |
| 236 | const brokerLayer = yield* makeDependencies({ | 237 | const brokerLayer = yield* makeDependencies({ |
| 237 | primaryBearerToken: "desktop-bearer", | 238 | primaryBearerToken: "desktop-bearer", |
| 238 | authorizeBearer: (input) => | 239 | authorizeBearer: (input) => |
| 239 | Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( | 240 | Ref.update(bearerInputs, (values) => [ |
| 241 | ...values, | ||
| 242 | { token: input.bearerToken, method: input.connectionMethod }, | ||
| 243 | ]).pipe( | ||
| 240 | Effect.as({ | 244 | Effect.as({ |
| 241 | environmentId: input.expectedEnvironmentId, | 245 | environmentId: input.expectedEnvironmentId, |
| 242 | label: "Primary", | 246 | label: "Primary", |
| ... | @@ -262,13 +266,13 @@ describe("ConnectionResolver", () => { | ... | @@ -262,13 +266,13 @@ describe("ConnectionResolver", () => { |
| 262 | httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, | 266 | httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, |
| 263 | target, | 267 | target, |
| 264 | }); | 268 | }); |
| 265 | expect(yield* Ref.get(bearerInputs)).toEqual(["desktop-bearer"]); | 269 | expect(yield* Ref.get(bearerInputs)).toEqual([{ token: "desktop-bearer", method: "direct" }]); |
| 266 | }), | 270 | }), |
| 267 | ); | 271 | ); |
| 268 | 272 | ||
| 269 | it.effect("uses the registered bearer profile without re-reading the profile store", () => | 273 | it.effect("uses the registered bearer profile without re-reading the profile store", () => |
| 270 | Effect.gen(function* () { | 274 | Effect.gen(function* () { |
| 271 | const bearerInputs = yield* Ref.make<ReadonlyArray<string>>([]); | 275 | const bearerInputs = yield* Ref.make<ReadonlyArray<{ token: string; method: string }>>([]); |
| 272 | const target = new BearerConnectionTarget({ | 276 | const target = new BearerConnectionTarget({ |
| 273 | environmentId: ENVIRONMENT_ID, | 277 | environmentId: ENVIRONMENT_ID, |
| 274 | label: "Saved", | 278 | label: "Saved", |
| ... | @@ -284,7 +288,10 @@ describe("ConnectionResolver", () => { | ... | @@ -284,7 +288,10 @@ describe("ConnectionResolver", () => { |
| 284 | const brokerLayer = yield* makeDependencies({ | 288 | const brokerLayer = yield* makeDependencies({ |
| 285 | credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]], | 289 | credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]], |
| 286 | authorizeBearer: (input) => | 290 | authorizeBearer: (input) => |
| 287 | Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( | 291 | Ref.update(bearerInputs, (values) => [ |
| 292 | ...values, | ||
| 293 | { token: input.bearerToken, method: input.connectionMethod }, | ||
| 294 | ]).pipe( | ||
| 288 | Effect.as({ | 295 | Effect.as({ |
| 289 | environmentId: input.expectedEnvironmentId, | 296 | environmentId: input.expectedEnvironmentId, |
| 290 | label: "Saved", | 297 | label: "Saved", |
| ... | @@ -302,7 +309,7 @@ describe("ConnectionResolver", () => { | ... | @@ -302,7 +309,7 @@ describe("ConnectionResolver", () => { |
| 302 | expect( | 309 | expect( |
| 303 | (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, | 310 | (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, |
| 304 | ).toContain("wsTicket=ticket"); | 311 | ).toContain("wsTicket=ticket"); |
| 305 | expect(yield* Ref.get(bearerInputs)).toEqual(["secret-bearer"]); | 312 | expect(yield* Ref.get(bearerInputs)).toEqual([{ token: "secret-bearer", method: "direct" }]); |
| 306 | }), | 313 | }), |
| 307 | ); | 314 | ); |
| 308 | 315 | ||
| ... | @@ -411,6 +418,7 @@ describe("ConnectionResolver", () => { | ... | @@ -411,6 +418,7 @@ describe("ConnectionResolver", () => { |
| 411 | it.effect("delegates SSH launch to the platform gateway before remote authorization", () => | 418 | it.effect("delegates SSH launch to the platform gateway before remote authorization", () => |
| 412 | Effect.gen(function* () { | 419 | Effect.gen(function* () { |
| 413 | const preparedTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]); | 420 | const preparedTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]); |
| 421 | const connectionMethods = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 414 | const target = new SshConnectionTarget({ | 422 | const target = new SshConnectionTarget({ |
| 415 | environmentId: ENVIRONMENT_ID, | 423 | environmentId: ENVIRONMENT_ID, |
| 416 | label: "SSH", | 424 | label: "SSH", |
| ... | @@ -435,6 +443,19 @@ describe("ConnectionResolver", () => { | ... | @@ -435,6 +443,19 @@ describe("ConnectionResolver", () => { |
| 435 | bearerToken: "ssh-bearer", | 443 | bearerToken: "ssh-bearer", |
| 436 | }), | 444 | }), |
| 437 | ), | 445 | ), |
| 446 | authorizeBearer: (input) => | ||
| 447 | Ref.update(connectionMethods, (methods) => [...methods, input.connectionMethod]).pipe( | ||
| 448 | Effect.as({ | ||
| 449 | environmentId: input.expectedEnvironmentId, | ||
| 450 | label: "SSH", | ||
| 451 | httpBaseUrl: input.httpBaseUrl, | ||
| 452 | socketUrl: "wss://environment.example.test/ws?wsTicket=bearer", | ||
| 453 | httpAuthorization: { | ||
| 454 | _tag: "Bearer" as const, | ||
| 455 | token: input.bearerToken, | ||
| 456 | }, | ||
| 457 | }), | ||
| 458 | ), | ||
| 438 | }); | 459 | }); |
| 439 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); | 460 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); |
| 440 | 461 | ||
| ... | @@ -442,6 +463,7 @@ describe("ConnectionResolver", () => { | ... | @@ -442,6 +463,7 @@ describe("ConnectionResolver", () => { |
| 442 | (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, | 463 | (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, |
| 443 | ).toContain("wsTicket=bearer"); | 464 | ).toContain("wsTicket=bearer"); |
| 444 | expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]); | 465 | expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]); |
| 466 | expect(yield* Ref.get(connectionMethods)).toEqual(["ssh"]); | ||
| 445 | }), | 467 | }), |
| 446 | ); | 468 | ); |
| 447 | 469 |
packages/client-runtime/src/connection/resolver.ts+4-1| ... | @@ -56,7 +56,7 @@ function primarySocketUrl( | ... | @@ -56,7 +56,7 @@ function primarySocketUrl( |
| 56 | if (url.pathname === "" || url.pathname === "/") { | 56 | if (url.pathname === "" || url.pathname === "/") { |
| 57 | url.pathname = "/ws"; | 57 | url.pathname = "/ws"; |
| 58 | } | 58 | } |
| 59 | appendClientConnectionParams(url, clientMetadata); | 59 | appendClientConnectionParams(url, clientMetadata, "direct"); |
| 60 | return url.toString(); | 60 | return url.toString(); |
| 61 | } | 61 | } |
| 62 | 62 | ||
| ... | @@ -85,6 +85,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary | ... | @@ -85,6 +85,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary |
| 85 | httpBaseUrl: target.httpBaseUrl, | 85 | httpBaseUrl: target.httpBaseUrl, |
| 86 | wsBaseUrl: target.wsBaseUrl, | 86 | wsBaseUrl: target.wsBaseUrl, |
| 87 | bearerToken: bearerToken.value, | 87 | bearerToken: bearerToken.value, |
| 88 | connectionMethod: "direct", | ||
| 88 | }); | 89 | }); |
| 89 | return { | 90 | return { |
| 90 | ...authorized, | 91 | ...authorized, |
| ... | @@ -133,6 +134,7 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") | ... | @@ -133,6 +134,7 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") |
| 133 | httpBaseUrl: profile.httpBaseUrl, | 134 | httpBaseUrl: profile.httpBaseUrl, |
| 134 | wsBaseUrl: profile.wsBaseUrl, | 135 | wsBaseUrl: profile.wsBaseUrl, |
| 135 | bearerToken: credential.token, | 136 | bearerToken: credential.token, |
| 137 | connectionMethod: "direct", | ||
| 136 | }); | 138 | }); |
| 137 | return { | 139 | return { |
| 138 | environmentId: authorized.environmentId, | 140 | environmentId: authorized.environmentId, |
| ... | @@ -236,6 +238,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct | ... | @@ -236,6 +238,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct |
| 236 | httpBaseUrl: prepared.bootstrap.httpBaseUrl, | 238 | httpBaseUrl: prepared.bootstrap.httpBaseUrl, |
| 237 | wsBaseUrl: prepared.bootstrap.wsBaseUrl, | 239 | wsBaseUrl: prepared.bootstrap.wsBaseUrl, |
| 238 | bearerToken: prepared.bearerToken, | 240 | bearerToken: prepared.bearerToken, |
| 241 | connectionMethod: "ssh", | ||
| 239 | }); | 242 | }); |
| 240 | return { | 243 | return { |
| 241 | environmentId: authorized.environmentId, | 244 | environmentId: authorized.environmentId, |
packages/contracts/src/auth.ts+8-1| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; | 2 | import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; |
| 3 | 3 | ||
| 4 | import { AuthSessionId, ClientSurface, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 4 | import { |
| 5 | AuthSessionId, | ||
| 6 | ClientSurface, | ||
| 7 | ClientWebDeployment, | ||
| 8 | TrimmedNonEmptyString, | ||
| 9 | } from "./baseSchemas.ts"; | ||
| 5 | 10 | ||
| 6 | /** | 11 | /** |
| 7 | * Declares the server's overall authentication posture. | 12 | * Declares the server's overall authentication posture. |
| ... | @@ -172,6 +177,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({ | ... | @@ -172,6 +177,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({ |
| 172 | osMajorVersion: Schema.optionalKey(Schema.Int), | 177 | osMajorVersion: Schema.optionalKey(Schema.Int), |
| 173 | deviceModel: Schema.optionalKey(TrimmedNonEmptyString), | 178 | deviceModel: Schema.optionalKey(TrimmedNonEmptyString), |
| 174 | surface: Schema.optionalKey(ClientSurface), | 179 | surface: Schema.optionalKey(ClientSurface), |
| 180 | webDeployment: Schema.optionalKey(ClientWebDeployment), | ||
| 181 | browser: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 175 | appVersion: Schema.optionalKey(TrimmedNonEmptyString), | 182 | appVersion: Schema.optionalKey(TrimmedNonEmptyString), |
| 176 | }); | 183 | }); |
| 177 | export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; | 184 | export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; |
packages/contracts/src/baseSchemas.ts+21| ... | @@ -80,6 +80,27 @@ export type RpcClientId = typeof RpcClientId.Type; | ... | @@ -80,6 +80,27 @@ export type RpcClientId = typeof RpcClientId.Type; |
| 80 | export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]); | 80 | export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]); |
| 81 | export type ClientSurface = typeof ClientSurface.Type; | 81 | export type ClientSurface = typeof ClientSurface.Type; |
| 82 | 82 | ||
| 83 | export const ClientOs = Schema.Literals([ | ||
| 84 | "macOS", | ||
| 85 | "Windows", | ||
| 86 | "Linux", | ||
| 87 | "iOS", | ||
| 88 | "Android", | ||
| 89 | "ChromeOS", | ||
| 90 | "other", | ||
| 91 | "unknown", | ||
| 92 | ]); | ||
| 93 | export type ClientOs = typeof ClientOs.Type; | ||
| 94 | |||
| 95 | export const ClientDeviceType = Schema.Literals(["desktop", "phone", "tablet", "unknown"]); | ||
| 96 | export type ClientDeviceType = typeof ClientDeviceType.Type; | ||
| 97 | |||
| 98 | export const ClientWebDeployment = Schema.Literals(["hosted", "server"]); | ||
| 99 | export type ClientWebDeployment = typeof ClientWebDeployment.Type; | ||
| 100 | |||
| 101 | export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]); | ||
| 102 | export type ClientConnectionMethod = typeof ClientConnectionMethod.Type; | ||
| 103 | |||
| 83 | export const ProviderItemId = makeEntityId("ProviderItemId"); | 104 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 84 | export type ProviderItemId = typeof ProviderItemId.Type; | 105 | export type ProviderItemId = typeof ProviderItemId.Type; |
| 85 | export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); | 106 | export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); |
packages/contracts/src/ipc.ts+2| ... | @@ -1069,6 +1069,8 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ | ... | @@ -1069,6 +1069,8 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ |
| 1069 | 1069 | ||
| 1070 | export interface DesktopBridge { | 1070 | export interface DesktopBridge { |
| 1071 | getAppBranding: () => DesktopAppBranding | null; | 1071 | getAppBranding: () => DesktopAppBranding | null; |
| 1072 | /** The desktop client's OS platform, read from Electron's preload process. */ | ||
| 1073 | getClientPlatform?: () => string; | ||
| 1072 | /** | 1074 | /** |
| 1073 | * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: | 1075 | * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: |
| 1074 | * the packaged app ships only the `en-US` Chromium locale pak, so | 1076 | * the packaged app ships only the `en-US` Chromium locale pak, so |
pnpm-lock.yaml+3| ... | @@ -170,6 +170,9 @@ importers: | ... | @@ -170,6 +170,9 @@ importers: |
| 170 | '@types/node': | 170 | '@types/node': |
| 171 | specifier: 24.12.4 | 171 | specifier: 24.12.4 |
| 172 | version: 24.12.4 | 172 | version: 24.12.4 |
| 173 | acorn: | ||
| 174 | specifier: 8.16.0 | ||
| 175 | version: 8.16.0 | ||
| 173 | cross-env: | 176 | cross-env: |
| 174 | specifier: ^10.1.0 | 177 | specifier: ^10.1.0 |
| 175 | version: 10.1.0 | 178 | version: 10.1.0 |