authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-28 01:43:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 01:43:20-07:00
log88be5631ff6a48b98ead48fa5af6a2be0a4d63e4
treed1148d617aa9125f2d721281de09f65dcbfbeeff
parent49f6241ddeee2a5d20ec181b5b94ec0a9ac72f7b
signature Signed by PGP key B5690EEEBB952194

feat(analytics): report connected client platforms (#8481)


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 - main7 - main
88
9permissions:
10 contents: read
11
9concurrency:12concurrency:
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:desktop55 run: vp run build:desktop
5356
54 - name: Verify preload bundle output57 - 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
5959
60 # Everything except `t3` (apps/server). `--parallel` drops the package60 # Everything except `t3` (apps/server). `--parallel` drops the package
61 # dependency ordering that `vp run` applies by default: these `test` tasks61 # 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 @@
1import * as NodeEvents from "node:events";
2import * as NodeFS from "node:fs";
3import * as NodeFSP from "node:fs/promises";
4import * as NodeTimers from "node:timers";
5import * as NodeURL from "node:url";
6import * as NodeVM from "node:vm";
7import { parse } from "acorn";
8
9const expectedDesktopBridgeApis = [
10 "getClientPlatform",
11 "getLocalEnvironmentBootstraps",
12 "pickFolder",
13];
14const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys";
15const preloadExecutionTimeoutMs = 1_000;
16const desktopPackage = JSON.parse(
17 NodeFS.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
18);
19const electronVersion = desktopPackage.dependencies.electron;
20
21const isSyntaxNode = (value) =>
22 typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
23
24const 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
59const 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
86const 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
115export 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
143if (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 @@
1import { assert, describe, it } from "vite-plus/test";
2
3import { verifyPreloadBundle } from "./verify-preload-bundle.mjs";
4
5const 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
16describe("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";
1111
12exposeClerkBridge({ passkeys: true });12exposeClerkBridge({ passkeys: true });
1313
14// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process.
15const clientPlatform = process.platform;
16
14function unwrapEnsureSshEnvironmentResult(result: unknown) {17function 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}));
3535
36vi.mock("expo-device", () => ({36vi.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
88
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";
1010
11const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" }));11const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" }));
12const mobileDevice = vi.hoisted(() => ({12const 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);
29describe("mobile remote connection records", () => {37describe("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 });
5867
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;
52625262
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 );
53095327
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";
7import * as HttpServer from "effect/unstable/http/HttpServer";7import * as HttpServer from "effect/unstable/http/HttpServer";
8import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";8import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1011
11import * as ServerConfig from "../config.ts";12import * as ServerConfig from "../config.ts";
12import { getTelemetryIdentifier } from "./Identify.ts";13import { 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 );
7693
...@@ -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 AnalyticsService7 * @module AnalyticsService
8 */8 */
9import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";9import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
10import type { ClientOs } from "@t3tools/contracts";
10import * as Config from "effect/Config";11import * as Config from "effect/Config";
11import * as Context from "effect/Context";12import * as Context from "effect/Context";
12import * as DateTime from "effect/DateTime";13import * 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}
6869
70export 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
69export const make = Effect.gen(function* () {85export 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}
371375
372const isClientSurface = Schema.is(ClientSurface);376const isClientSurface = Schema.is(ClientSurface);
377const isClientConnectionMethod = Schema.is(ClientConnectionMethod);
378const isClientDeviceType = Schema.is(ClientDeviceType);
379const isClientOs = Schema.is(ClientOs);
380const isClientWebDeployment = Schema.is(ClientWebDeployment);
373const MAX_CLIENT_APP_VERSION_LENGTH = 64;381const MAX_CLIENT_APP_VERSION_LENGTH = 64;
382const MAX_CLIENT_BROWSER_LENGTH = 64;
374const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80;383const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80;
375384
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}
395404
396const 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 } : {}),407function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) {
399});
400
401function 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 }
406412
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;
411428
412 return {429 return {
413 ...(os === "iOS" || os === "Android" ? { os } : {}),430 ...(isClientSurface(surface) ? { surface } : {}),
414 ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0431 ...(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_LENGTH440 ...(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}
422450
423const makeWsRpcLayer = (451const 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?.createThread481 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 browserClientOs,
5 browserDeviceType,
6 browserFamily,
7 clientPresentationMetadata,
8} from "./clientMetadata";
9
10const 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
17describe("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 @@
1import type {
2 AuthClientMetadataDeviceType,
3 AuthClientPresentationMetadata,
4 ClientOs,
5 DesktopBridge,
6} from "@t3tools/contracts";
7
8interface BrowserIdentity {
9 readonly userAgent: string;
10 readonly platform: string;
11 readonly maxTouchPoints: number;
12}
13
14function 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
27function 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
35export 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
47export 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
58export 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
72export 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";
61import { connectionStorageLayer } from "./storage";61import { connectionStorageLayer } from "./storage";
62import { clientPresentationMetadata } from "./clientMetadata";
6263
63let nextObservedRpcRequestId = 0;64let nextObservedRpcRequestId = 0;
6465
...@@ -115,15 +116,16 @@ const wakeupsLayer = Wakeups.layer({...@@ -115,15 +116,16 @@ const wakeupsLayer = Wakeups.layer({
115});116});
116117
117function clientMetadata() {118function 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}
128130
129function sshPreparationError(cause: unknown) {131function 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 job9 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 and10 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, wrapping14- **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)
138139
139[rpc]: ../../packages/contracts/src/rpc.ts140[rpc]: ../../packages/contracts/src/rpc.ts
docs/internals/product-analytics.md created+112
...@@ -0,0 +1,112 @@
1# Product analytics
2
3T3 Code sends anonymous product events from the server to PostHog. The server
4uses the first available hashed Codex account ID, hashed Claude user ID, or
5installation-scoped anonymous ID as the distinct ID. It also keeps the
6telemetry opt-out, event buffer, and batch delivery. Clients do not load the
7PostHog browser SDK.
8
9## Client events
10
11These events use the metadata from the WebSocket connection that caused them.
12The metadata is not a person property or server-global current-client value.
13Two clients connected to one server can report different values at the same
14time.
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
23client metadata because a provider turn can continue after the requesting
24client disconnects.
25
26## Recommended properties
27
28Client properties appear on the three client events when the connected client
29reports 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
43Server properties appear on all events, including server boot and background
44events.
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
56Existing 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
70Create one saved dashboard named `Client and platform usage`. Set
71`client.turn.requested` as the event for active-use reports. A user can appear
72in several client groups during one period, so do not add breakdown values to
73calculate a total.
74
75Save 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
93In PostHog Data management, use the event and property descriptions from this
94document. Mark the recommended properties as verified. Keep `clientType`
95visible with its legacy description so old reports remain understandable.
96
97## Collection and release boundary
98
99Client values are best effort. Invalid values are ignored and never reject a
100connection. Browser clients use user-agent data for broad OS, browser, phone,
101and tablet groups. They do not infer CPU architecture or an exact OS version
102from `navigator.platform`.
103
104This change does not collect URLs, tokens, prompts, IP addresses, or
105user-assigned device names. It measures authenticated product use. It does not
106measure a person who visits the hosted app without connecting to a server.
107
108The new fields start with the first client and server release that contains
109this metadata path. Historical events cannot reliably identify the client OS,
110hosted web use, device type, or connection method when the old client did not
111send those fields. Reports must treat missing values as pre-release or older
112client 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 });
215217
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));
256258
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";
77
8import { EnvironmentAuthInvalidError } from "@t3tools/contracts";8import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
9import {9import {
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 );
211212
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));
478522
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";
8import { encodeOAuthScope } from "@t3tools/shared/oauthScope";9import { 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;
2627
27const clientMetadataTokenExchangeFields = (28const 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};
3440
35// The server reads these off the /ws upgrade URL next to wsTicket. Optional on41// 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.
37export const appendClientConnectionParams = (43export 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};
5986
60export const exchangeRemoteDpopAccessToken = Effect.fn(87export 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});
219247
...@@ -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 @@
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";1import {
2 type ClientConnectionMethod,
3 EnvironmentId,
4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";
2import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";6import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
3import {7import {
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", () => {
232233
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 );
268272
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 );
308315
...@@ -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));
440461
...@@ -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 );
447469
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}
6262
...@@ -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 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";2import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
33
4import { AuthSessionId, ClientSurface, TrimmedNonEmptyString } from "./baseSchemas.ts";4import {
5 AuthSessionId,
6 ClientSurface,
7 ClientWebDeployment,
8 TrimmedNonEmptyString,
9} from "./baseSchemas.ts";
510
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});
177export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;184export 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;
80export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]);80export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]);
81export type ClientSurface = typeof ClientSurface.Type;81export type ClientSurface = typeof ClientSurface.Type;
8282
83export const ClientOs = Schema.Literals([
84 "macOS",
85 "Windows",
86 "Linux",
87 "iOS",
88 "Android",
89 "ChromeOS",
90 "other",
91 "unknown",
92]);
93export type ClientOs = typeof ClientOs.Type;
94
95export const ClientDeviceType = Schema.Literals(["desktop", "phone", "tablet", "unknown"]);
96export type ClientDeviceType = typeof ClientDeviceType.Type;
97
98export const ClientWebDeployment = Schema.Literals(["hosted", "server"]);
99export type ClientWebDeployment = typeof ClientWebDeployment.Type;
100
101export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]);
102export type ClientConnectionMethod = typeof ClientConnectionMethod.Type;
103
83export const ProviderItemId = makeEntityId("ProviderItemId");104export const ProviderItemId = makeEntityId("ProviderItemId");
84export type ProviderItemId = typeof ProviderItemId.Type;105export type ProviderItemId = typeof ProviderItemId.Type;
85export const RuntimeSessionId = makeEntityId("RuntimeSessionId");106export 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({
10691069
1070export interface DesktopBridge {1070export 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, so1076 * 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.4171 specifier: 24.12.4
172 version: 24.12.4172 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.0177 specifier: ^10.1.0
175 version: 10.1.0178 version: 10.1.0