| author | |
| committer | |
| log | 11f051373e79b38fa16f3ec1af825f5164907c1b |
| tree | b10d0efb3fa0d0fe2d85e16595f51161e4dada8c |
| parent | 035058a23e651ea5f407c9ab73cf5329cc40c102 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>22 files changed, 485 insertions(+), 22 deletions(-)
apps/mobile/src/connection/platform.ts+2-1| ... | ... | @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; |
| 21 | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | import * as Queue from "effect/Queue"; |
| 23 | 23 | import * as Stream from "effect/Stream"; |
| 24 | import Constants from "expo-constants"; | |
| 24 | 25 | import * as Network from "expo-network"; |
| 25 | 26 | import { AppState } from "react-native"; |
| 26 | 27 | |
| ... | ... | @@ -166,7 +167,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 166 | 167 | Context.add( |
| 167 | 168 | ClientPresentation, |
| 168 | 169 | ClientPresentation.of({ |
| 169 | metadata: authClientMetadata(), | |
| 170 | metadata: authClientMetadata(Constants.expoConfig?.version), | |
| 170 | 171 | scopes: AuthStandardClientScopes, |
| 171 | 172 | }), |
| 172 | 173 | ), |
apps/mobile/src/lib/authClientMetadata.ts+3-1| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; |
| 2 | 2 | import { Platform } from "react-native"; |
| 3 | 3 | |
| 4 | export function authClientMetadata(): AuthClientPresentationMetadata { | |
| 4 | export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { | |
| 5 | 5 | return { |
| 6 | 6 | label: "T3 Code Mobile", |
| 7 | 7 | deviceType: "mobile", |
| 8 | 8 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), |
| 9 | surface: "mobile", | |
| 10 | ...(appVersion ? { appVersion } : {}), | |
| 9 | 11 | }; |
| 10 | 12 | } |
apps/mobile/src/lib/connection.test.ts+8| ... | ... | @@ -26,6 +26,14 @@ describe("mobile remote connection records", () => { |
| 26 | 26 | label: "T3 Code Mobile", |
| 27 | 27 | deviceType: "mobile", |
| 28 | 28 | os: "iOS", |
| 29 | surface: "mobile", | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | it("includes the mobile app version when the client provides it", () => { | |
| 34 | expect(authClientMetadata("1.2.3")).toMatchObject({ | |
| 35 | surface: "mobile", | |
| 36 | appVersion: "1.2.3", | |
| 29 | 37 | }); |
| 30 | 38 | }); |
| 31 | 39 |
apps/server/src/auth/SessionStore.test.ts+33| ... | ... | @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as TestClock from "effect/testing/TestClock"; |
| 7 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 7 | 8 | |
| 8 | 9 | import * as ServerConfig from "../config.ts"; |
| 9 | 10 | import { PersistenceSqlError } from "../persistence/Errors.ts"; |
| ... | ... | @@ -47,6 +48,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi |
| 47 | 48 | revoke: () => Effect.fail(repositoryFailure), |
| 48 | 49 | revokeAllExcept: () => Effect.fail(repositoryFailure), |
| 49 | 50 | setLastConnectedAt: () => Effect.void, |
| 51 | setClientConnection: () => Effect.void, | |
| 50 | 52 | }); |
| 51 | 53 | |
| 52 | 54 | const failingSessionLookupCredentialLayer = Layer.effect( |
| ... | ... | @@ -315,4 +317,35 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 315 | 317 | expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); |
| 316 | 318 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), |
| 317 | 319 | ); |
| 320 | it.effect("records client connection metadata without clearing prior values", () => | |
| 321 | Effect.gen(function* () { | |
| 322 | const sessions = yield* SessionStore.SessionStore; | |
| 323 | const sql = yield* SqlClient.SqlClient; | |
| 324 | const issued = yield* sessions.issue({ | |
| 325 | subject: "client-connection-test", | |
| 326 | method: "bearer-access-token", | |
| 327 | }); | |
| 328 | const readRow = sql<{ | |
| 329 | readonly surface: string | null; | |
| 330 | readonly appVersion: string | null; | |
| 331 | }>` | |
| 332 | SELECT client_surface AS "surface", client_app_version AS "appVersion" | |
| 333 | FROM auth_sessions | |
| 334 | WHERE session_id = ${issued.sessionId} | |
| 335 | `; | |
| 336 | ||
| 337 | yield* sessions.recordClientConnection(issued.sessionId, { | |
| 338 | surface: "mobile", | |
| 339 | appVersion: "1.2.0", | |
| 340 | }); | |
| 341 | expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" }); | |
| 342 | ||
| 343 | // A partial report (old or minimal client) must not null out stored data. | |
| 344 | yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" }); | |
| 345 | expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); | |
| 346 | ||
| 347 | yield* sessions.recordClientConnection(issued.sessionId, {}); | |
| 348 | expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); | |
| 349 | }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), | |
| 350 | ); | |
| 318 | 351 | }); |
apps/server/src/auth/SessionStore.ts+31| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | type AuthClientMetadata, |
| 6 | 6 | type AuthClientSession, |
| 7 | 7 | type AuthEnvironmentScope, |
| 8 | type ClientSurface, | |
| 8 | 9 | type ServerAuthSessionMethod, |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | import * as Context from "effect/Context"; |
| ... | ... | @@ -396,6 +397,13 @@ export class SessionStore extends Context.Service< |
| 396 | 397 | ) => Effect.Effect<number, SessionCredentialInternalError>; |
| 397 | 398 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; |
| 398 | 399 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; |
| 400 | readonly recordClientConnection: ( | |
| 401 | sessionId: AuthSessionId, | |
| 402 | client: { | |
| 403 | readonly surface?: ClientSurface | undefined; | |
| 404 | readonly appVersion?: string | undefined; | |
| 405 | }, | |
| 406 | ) => Effect.Effect<void, never>; | |
| 399 | 407 | } |
| 400 | 408 | >()("t3/auth/SessionStore") {} |
| 401 | 409 | |
| ... | ... | @@ -544,6 +552,28 @@ export const make = Effect.gen(function* () { |
| 544 | 552 | Effect.withSpan("SessionStore.markConnected"), |
| 545 | 553 | ); |
| 546 | 554 | |
| 555 | // Best-effort: connection metadata must never block or fail a connect. | |
| 556 | const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = ( | |
| 557 | sessionId, | |
| 558 | client, | |
| 559 | ) => | |
| 560 | client.surface === undefined && client.appVersion === undefined | |
| 561 | ? Effect.void | |
| 562 | : authSessions | |
| 563 | .setClientConnection({ | |
| 564 | sessionId, | |
| 565 | surface: client.surface ?? null, | |
| 566 | appVersion: client.appVersion ?? null, | |
| 567 | }) | |
| 568 | .pipe( | |
| 569 | Effect.catchCause((cause) => | |
| 570 | Effect.logWarning("Failed to record session client connection metadata.").pipe( | |
| 571 | Effect.annotateLogs({ sessionId, cause }), | |
| 572 | ), | |
| 573 | ), | |
| 574 | Effect.withSpan("SessionStore.recordClientConnection"), | |
| 575 | ); | |
| 576 | ||
| 547 | 577 | const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => |
| 548 | 578 | Ref.update(connectedSessionsRef, (current) => { |
| 549 | 579 | const next = new Map(current); |
| ... | ... | @@ -912,6 +942,7 @@ export const make = Effect.gen(function* () { |
| 912 | 942 | revokeAllExcept, |
| 913 | 943 | markConnected, |
| 914 | 944 | markDisconnected, |
| 945 | recordClientConnection, | |
| 915 | 946 | }); |
| 916 | 947 | }); |
| 917 | 948 |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+51| ... | ... | @@ -1379,4 +1379,55 @@ describe("OrchestrationEngine", () => { |
| 1379 | 1379 | |
| 1380 | 1380 | await system.dispose(); |
| 1381 | 1381 | }); |
| 1382 | ||
| 1383 | it("stamps the dispatching client's origin onto persisted event metadata", async () => { | |
| 1384 | const createdAt = now(); | |
| 1385 | const system = await createOrchestrationSystem(); | |
| 1386 | const { engine } = system; | |
| 1387 | ||
| 1388 | await system.run( | |
| 1389 | engine.dispatch( | |
| 1390 | { | |
| 1391 | type: "project.create", | |
| 1392 | commandId: CommandId.make("cmd-origin-project-create"), | |
| 1393 | projectId: asProjectId("project-origin"), | |
| 1394 | title: "Origin Project", | |
| 1395 | workspaceRoot: "/tmp/project-origin", | |
| 1396 | defaultModelSelection: { | |
| 1397 | instanceId: ProviderInstanceId.make("codex"), | |
| 1398 | model: "gpt-5-codex", | |
| 1399 | }, | |
| 1400 | createdAt, | |
| 1401 | }, | |
| 1402 | { origin: { surface: "mobile", appVersion: "1.2.3" } }, | |
| 1403 | ), | |
| 1404 | ); | |
| 1405 | await system.run( | |
| 1406 | engine.dispatch({ | |
| 1407 | type: "project.create", | |
| 1408 | commandId: CommandId.make("cmd-no-origin-project-create"), | |
| 1409 | projectId: asProjectId("project-no-origin"), | |
| 1410 | title: "No Origin Project", | |
| 1411 | workspaceRoot: "/tmp/project-no-origin", | |
| 1412 | defaultModelSelection: { | |
| 1413 | instanceId: ProviderInstanceId.make("codex"), | |
| 1414 | model: "gpt-5-codex", | |
| 1415 | }, | |
| 1416 | createdAt, | |
| 1417 | }), | |
| 1418 | ); | |
| 1419 | ||
| 1420 | const events = await system.run( | |
| 1421 | Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), | |
| 1422 | ); | |
| 1423 | const withOrigin = events.find((event) => event.commandId === "cmd-origin-project-create"); | |
| 1424 | const withoutOrigin = events.find( | |
| 1425 | (event) => event.commandId === "cmd-no-origin-project-create", | |
| 1426 | ); | |
| 1427 | ||
| 1428 | expect(withOrigin?.metadata.origin).toEqual({ surface: "mobile", appVersion: "1.2.3" }); | |
| 1429 | expect(withoutOrigin?.metadata.origin).toBeUndefined(); | |
| 1430 | ||
| 1431 | await system.dispose(); | |
| 1432 | }); | |
| 1382 | 1433 | }); |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+14-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { |
| 2 | OrchestrationClientOrigin, | |
| 2 | 3 | OrchestrationEvent, |
| 3 | 4 | OrchestrationReadModel, |
| 4 | 5 | ProjectId, |
| ... | ... | @@ -54,6 +55,7 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar |
| 54 | 55 | |
| 55 | 56 | interface CommandEnvelope { |
| 56 | 57 | command: OrchestrationCommand; |
| 58 | origin: OrchestrationClientOrigin | undefined; | |
| 57 | 59 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; |
| 58 | 60 | startedAtMs: number; |
| 59 | 61 | } |
| ... | ... | @@ -182,7 +184,16 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 182 | 184 | }), |
| 183 | 185 | ), |
| 184 | 186 | ); |
| 185 | const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; | |
| 187 | const plannedEvents = Array.isArray(eventBase) ? eventBase : [eventBase]; | |
| 188 | // Stamp the dispatching client's origin onto every event the command | |
| 189 | // produced. The decider stays pure; attribution is an engine concern. | |
| 190 | const eventBases = | |
| 191 | envelope.origin === undefined | |
| 192 | ? plannedEvents | |
| 193 | : plannedEvents.map((planned) => ({ | |
| 194 | ...planned, | |
| 195 | metadata: { ...planned.metadata, origin: envelope.origin }, | |
| 196 | })); | |
| 186 | 197 | const committedCommand = yield* sql |
| 187 | 198 | .withTransaction( |
| 188 | 199 | Effect.gen(function* () { |
| ... | ... | @@ -329,11 +340,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 329 | 340 | const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => |
| 330 | 341 | eventStore.readFromSequence(fromSequenceExclusive, limit); |
| 331 | 342 | |
| 332 | const dispatch: OrchestrationEngineShape["dispatch"] = (command) => | |
| 343 | const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) => | |
| 333 | 344 | Effect.gen(function* () { |
| 334 | 345 | const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); |
| 335 | 346 | yield* Queue.offer(commandQueue, { |
| 336 | 347 | command, |
| 348 | origin: options?.origin, | |
| 337 | 349 | result, |
| 338 | 350 | startedAtMs: yield* Clock.currentTimeMillis, |
| 339 | 351 | }); |
apps/server/src/orchestration/Services/OrchestrationEngine.ts+8-1| ... | ... | @@ -10,7 +10,11 @@ |
| 10 | 10 | * |
| 11 | 11 | * @module OrchestrationEngineService |
| 12 | 12 | */ |
| 13 | import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts"; | |
| 13 | import type { | |
| 14 | OrchestrationClientOrigin, | |
| 15 | OrchestrationCommand, | |
| 16 | OrchestrationEvent, | |
| 17 | } from "@t3tools/contracts"; | |
| 14 | 18 | import * as Context from "effect/Context"; |
| 15 | 19 | import type * as Effect from "effect/Effect"; |
| 16 | 20 | import type * as Stream from "effect/Stream"; |
| ... | ... | @@ -41,6 +45,8 @@ export interface OrchestrationEngineShape { |
| 41 | 45 | * Dispatch a validated orchestration command. |
| 42 | 46 | * |
| 43 | 47 | * @param command - Valid orchestration command. |
| 48 | * @param options - Optional client origin (surface/app version) stamped into | |
| 49 | * the metadata of every event the command produces. | |
| 44 | 50 | * @returns Effect containing the sequence of the persisted event. |
| 45 | 51 | * |
| 46 | 52 | * Dispatch is serialized through an internal queue and deduplicated via |
| ... | ... | @@ -48,6 +54,7 @@ export interface OrchestrationEngineShape { |
| 48 | 54 | */ |
| 49 | 55 | readonly dispatch: ( |
| 50 | 56 | command: OrchestrationCommand, |
| 57 | options?: { readonly origin?: OrchestrationClientOrigin }, | |
| 51 | 58 | ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; |
| 52 | 59 | |
| 53 | 60 | /** |
apps/server/src/persistence/AuthSessions.ts+37| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | AuthClientMetadataDeviceType, |
| 11 | 11 | AuthEnvironmentScopes, |
| 12 | 12 | AuthSessionId, |
| 13 | ClientSurface, | |
| 13 | 14 | ServerAuthSessionMethod, |
| 14 | 15 | } from "@t3tools/contracts"; |
| 15 | 16 | |
| ... | ... | @@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ |
| 82 | 83 | }); |
| 83 | 84 | export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; |
| 84 | 85 | |
| 86 | export const SetAuthSessionClientConnectionInput = Schema.Struct({ | |
| 87 | sessionId: AuthSessionId, | |
| 88 | surface: Schema.NullOr(ClientSurface), | |
| 89 | appVersion: Schema.NullOr(Schema.String), | |
| 90 | }); | |
| 91 | export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type; | |
| 92 | ||
| 85 | 93 | export class AuthSessionRepository extends Context.Service< |
| 86 | 94 | AuthSessionRepository, |
| 87 | 95 | { |
| ... | ... | @@ -103,6 +111,9 @@ export class AuthSessionRepository extends Context.Service< |
| 103 | 111 | readonly setLastConnectedAt: ( |
| 104 | 112 | input: SetAuthSessionLastConnectedAtInput, |
| 105 | 113 | ) => Effect.Effect<void, AuthSessionRepositoryError>; |
| 114 | readonly setClientConnection: ( | |
| 115 | input: SetAuthSessionClientConnectionInput, | |
| 116 | ) => Effect.Effect<void, AuthSessionRepositoryError>; | |
| 106 | 117 | } |
| 107 | 118 | >()("t3/persistence/AuthSessions/AuthSessionRepository") {} |
| 108 | 119 | |
| ... | ... | @@ -281,6 +292,20 @@ export const make = Effect.gen(function* () { |
| 281 | 292 | `, |
| 282 | 293 | }); |
| 283 | 294 | |
| 295 | // COALESCE keeps the previous value when a client reports only one field, so | |
| 296 | // a partial report never nulls out data a fuller client stored earlier. | |
| 297 | const setClientConnectionRow = SqlSchema.void({ | |
| 298 | Request: SetAuthSessionClientConnectionInput, | |
| 299 | execute: ({ sessionId, surface, appVersion }) => | |
| 300 | sql` | |
| 301 | UPDATE auth_sessions | |
| 302 | SET client_surface = COALESCE(${surface}, client_surface), | |
| 303 | client_app_version = COALESCE(${appVersion}, client_app_version) | |
| 304 | WHERE session_id = ${sessionId} | |
| 305 | AND revoked_at IS NULL | |
| 306 | `, | |
| 307 | }); | |
| 308 | ||
| 284 | 309 | const revokeSessionRows = SqlSchema.findAll({ |
| 285 | 310 | Request: RevokeAuthSessionInput, |
| 286 | 311 | Result: Schema.Struct({ sessionId: AuthSessionId }), |
| ... | ... | @@ -404,6 +429,17 @@ export const make = Effect.gen(function* () { |
| 404 | 429 | ), |
| 405 | 430 | ); |
| 406 | 431 | |
| 432 | const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) => | |
| 433 | setClientConnectionRow(input).pipe( | |
| 434 | Effect.mapError( | |
| 435 | toPersistenceSqlOrDecodeError( | |
| 436 | "AuthSessionRepository.setClientConnection:query", | |
| 437 | "AuthSessionRepository.setClientConnection:encodeRequest", | |
| 438 | { sessionId: input.sessionId }, | |
| 439 | ), | |
| 440 | ), | |
| 441 | ); | |
| 442 | ||
| 407 | 443 | return { |
| 408 | 444 | create, |
| 409 | 445 | getById, |
| ... | ... | @@ -411,6 +447,7 @@ export const make = Effect.gen(function* () { |
| 411 | 447 | revoke, |
| 412 | 448 | revokeAllExcept, |
| 413 | 449 | setLastConnectedAt, |
| 450 | setClientConnection, | |
| 414 | 451 | } satisfies AuthSessionRepository["Service"]; |
| 415 | 452 | }); |
| 416 | 453 |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; |
| 53 | 53 | import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; |
| 54 | 54 | import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; |
| 55 | 55 | import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; |
| 56 | import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; | |
| 56 | 57 | |
| 57 | 58 | /** |
| 58 | 59 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -105,6 +106,7 @@ export const migrationEntries = [ |
| 105 | 106 | [38, "ProjectionThreadsPinOrderKey", Migration0038], |
| 106 | 107 | [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], |
| 107 | 108 | [40, "ProjectionProjectFaviconPath", Migration0040], |
| 109 | [41, "AuthSessionClientConnection", Migration0041], | |
| 108 | 110 | ] as const; |
| 109 | 111 | |
| 110 | 112 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 5 | ||
| 6 | import { runMigrations } from "../Migrations.ts"; | |
| 7 | import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | |
| 10 | ||
| 11 | layer("041_AuthSessionClientConnection", (it) => { | |
| 12 | it.effect("adds nullable client surface and app version columns to auth sessions", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const sql = yield* SqlClient.SqlClient; | |
| 15 | ||
| 16 | yield* runMigrations({ toMigrationInclusive: 40 }); | |
| 17 | yield* runMigrations({ toMigrationInclusive: 41 }); | |
| 18 | ||
| 19 | const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` | |
| 20 | PRAGMA table_info(auth_sessions) | |
| 21 | `; | |
| 22 | const surface = columns.find((column) => column.name === "client_surface"); | |
| 23 | const appVersion = columns.find((column) => column.name === "client_app_version"); | |
| 24 | ||
| 25 | assert.equal(surface?.name, "client_surface"); | |
| 26 | assert.equal(surface?.notnull, 0); | |
| 27 | assert.equal(appVersion?.name, "client_app_version"); | |
| 28 | assert.equal(appVersion?.notnull, 0); | |
| 29 | }), | |
| 30 | ); | |
| 31 | }); |
apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | // Client-declared surface (web/desktop/mobile) and app version, refreshed on | |
| 5 | // every WebSocket connect so the row tracks the client's current build instead | |
| 6 | // of freezing at session issuance. Nullable: old clients never report them. | |
| 7 | export default Effect.gen(function* () { | |
| 8 | const sql = yield* SqlClient.SqlClient; | |
| 9 | const columns = yield* sql<{ readonly name: string }>` | |
| 10 | PRAGMA table_info(auth_sessions) | |
| 11 | `; | |
| 12 | ||
| 13 | if (!columns.some((column) => column.name === "client_surface")) { | |
| 14 | yield* sql` | |
| 15 | ALTER TABLE auth_sessions | |
| 16 | ADD COLUMN client_surface TEXT | |
| 17 | `; | |
| 18 | } | |
| 19 | ||
| 20 | if (!columns.some((column) => column.name === "client_app_version")) { | |
| 21 | yield* sql` | |
| 22 | ALTER TABLE auth_sessions | |
| 23 | ADD COLUMN client_app_version TEXT | |
| 24 | `; | |
| 25 | } | |
| 26 | }); |
apps/server/src/server.test.ts+96| ... | ... | @@ -151,6 +151,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien |
| 151 | 151 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; |
| 152 | 152 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 153 | 153 | import * as UsageService from "./usage/UsageService.ts"; |
| 154 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | |
| 154 | 155 | import * as Data from "effect/Data"; |
| 155 | 156 | |
| 156 | 157 | import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; |
| ... | ... | @@ -402,6 +403,7 @@ const buildAppUnderTest = (options?: { |
| 402 | 403 | >; |
| 403 | 404 | terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>; |
| 404 | 405 | orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>; |
| 406 | analyticsService?: Partial<AnalyticsService.AnalyticsService["Service"]>; | |
| 405 | 407 | projectionSnapshotQuery?: Partial<ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]>; |
| 406 | 408 | checkpointDiffQuery?: Partial<CheckpointDiffQuery.CheckpointDiffQuery["Service"]>; |
| 407 | 409 | browserTraceCollector?: Partial<BrowserTraceCollector.BrowserTraceCollector["Service"]>; |
| ... | ... | @@ -833,6 +835,13 @@ const buildAppUnderTest = (options?: { |
| 833 | 835 | const appLayer = servedRoutesLayer.pipe( |
| 834 | 836 | Layer.provide(resourceTelemetryLayer), |
| 835 | 837 | Layer.provide(UsageService.layerTest), |
| 838 | Layer.provide( | |
| 839 | Layer.mock(AnalyticsService.AnalyticsService)({ | |
| 840 | record: () => Effect.void, | |
| 841 | flush: Effect.void, | |
| 842 | ...options?.layers?.analyticsService, | |
| 843 | }), | |
| 844 | ), | |
| 836 | 845 | Layer.provide( |
| 837 | 846 | Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ |
| 838 | 847 | record: () => Effect.void, |
| ... | ... | @@ -5035,6 +5044,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5035 | 5044 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 5036 | 5045 | ); |
| 5037 | 5046 | |
| 5047 | it.effect("records thread analytics only after a client command succeeds", () => | |
| 5048 | Effect.gen(function* () { | |
| 5049 | const effects: string[] = []; | |
| 5050 | const analyticsProperties: Array<Readonly<Record<string, unknown>> | undefined> = []; | |
| 5051 | const failedCommandId = CommandId.make("cmd-thread-create-failed"); | |
| 5052 | ||
| 5053 | yield* buildAppUnderTest({ | |
| 5054 | layers: { | |
| 5055 | analyticsService: { | |
| 5056 | record: (event, properties) => | |
| 5057 | Effect.sync(() => { | |
| 5058 | effects.push(`analytics:${event}`); | |
| 5059 | analyticsProperties.push(properties); | |
| 5060 | }), | |
| 5061 | }, | |
| 5062 | orchestrationEngine: { | |
| 5063 | dispatch: (command) => | |
| 5064 | Effect.sync(() => effects.push(`dispatch:${command.commandId}`)).pipe( | |
| 5065 | Effect.flatMap(() => | |
| 5066 | command.commandId === failedCommandId | |
| 5067 | ? Effect.fail( | |
| 5068 | new OrchestrationListenerCallbackError({ | |
| 5069 | listener: "domain-event", | |
| 5070 | detail: "thread creation failed", | |
| 5071 | }), | |
| 5072 | ) | |
| 5073 | : Effect.succeed({ sequence: 1 }), | |
| 5074 | ), | |
| 5075 | ), | |
| 5076 | }, | |
| 5077 | }, | |
| 5078 | }); | |
| 5079 | ||
| 5080 | const createThreadCommand = (commandId: CommandId, threadId: ThreadId) => | |
| 5081 | ({ | |
| 5082 | type: "thread.create", | |
| 5083 | commandId, | |
| 5084 | threadId, | |
| 5085 | projectId: defaultProjectId, | |
| 5086 | title: "Analytics test", | |
| 5087 | modelSelection: defaultModelSelection, | |
| 5088 | runtimeMode: "full-access", | |
| 5089 | interactionMode: "default", | |
| 5090 | branch: null, | |
| 5091 | worktreePath: null, | |
| 5092 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 5093 | }) as const; | |
| 5094 | ||
| 5095 | const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3"); | |
| 5096 | yield* Effect.scoped( | |
| 5097 | withWsRpcClient(wsUrl, (client) => | |
| 5098 | Effect.gen(function* () { | |
| 5099 | const failed = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( | |
| 5100 | createThreadCommand(failedCommandId, ThreadId.make("thread-create-failed")), | |
| 5101 | ).pipe(Effect.result); | |
| 5102 | ||
| 5103 | assert.equal(failed._tag, "Failure"); | |
| 5104 | assert.deepEqual(effects, [ | |
| 5105 | "analytics:client.connected", | |
| 5106 | "dispatch:cmd-thread-create-failed", | |
| 5107 | ]); | |
| 5108 | ||
| 5109 | const succeeded = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( | |
| 5110 | createThreadCommand( | |
| 5111 | CommandId.make("cmd-thread-create-succeeded"), | |
| 5112 | ThreadId.make("thread-create-succeeded"), | |
| 5113 | ), | |
| 5114 | ); | |
| 5115 | ||
| 5116 | assert.equal(succeeded.sequence, 1); | |
| 5117 | }), | |
| 5118 | ), | |
| 5119 | ); | |
| 5120 | ||
| 5121 | assert.deepEqual(effects, [ | |
| 5122 | "analytics:client.connected", | |
| 5123 | "dispatch:cmd-thread-create-failed", | |
| 5124 | "dispatch:cmd-thread-create-succeeded", | |
| 5125 | "analytics:client.thread.started", | |
| 5126 | ]); | |
| 5127 | assert.deepEqual(analyticsProperties, [ | |
| 5128 | { surface: "mobile", appVersion: "1.2.3" }, | |
| 5129 | { surface: "mobile", appVersion: "1.2.3" }, | |
| 5130 | ]); | |
| 5131 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 5132 | ); | |
| 5133 | ||
| 5038 | 5134 | it.effect("routes websocket rpc projects.writeFile errors", () => |
| 5039 | 5135 | Effect.gen(function* () { |
| 5040 | 5136 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/ws.ts+77-13| ... | ... | @@ -15,9 +15,11 @@ import { |
| 15 | 15 | type AuthAccessStreamEvent, |
| 16 | 16 | type AuthEnvironmentScope, |
| 17 | 17 | AuthSessionId, |
| 18 | ClientSurface, | |
| 18 | 19 | CommandId, |
| 19 | 20 | type DiscoveredLocalServerList, |
| 20 | 21 | EventId, |
| 22 | type OrchestrationClientOrigin, | |
| 21 | 23 | type OrchestrationCommand, |
| 22 | 24 | type GitActionProgressEvent, |
| 23 | 25 | type GitManagerServiceError, |
| ... | ... | @@ -106,6 +108,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; |
| 106 | 108 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 107 | 109 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 108 | 110 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 111 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | |
| 109 | 112 | import * as UsageService from "./usage/UsageService.ts"; |
| 110 | 113 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 111 | 114 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; |
| ... | ... | @@ -349,8 +352,37 @@ function toAuthAccessStreamEvent( |
| 349 | 352 | } |
| 350 | 353 | } |
| 351 | 354 | |
| 355 | const isClientSurface = Schema.is(ClientSurface); | |
| 356 | const MAX_CLIENT_APP_VERSION_LENGTH = 64; | |
| 357 | ||
| 358 | // Optional client identity announced on the /ws upgrade URL next to wsTicket. | |
| 359 | // Lenient by design: absent or malformed values degrade to {} so a connection | |
| 360 | // never fails over attribution metadata. | |
| 361 | function readClientConnectionOrigin( | |
| 362 | request: HttpServerRequest.HttpServerRequest, | |
| 363 | ): OrchestrationClientOrigin { | |
| 364 | const url = HttpServerRequest.toURL(request); | |
| 365 | if (Option.isNone(url)) { | |
| 366 | return {}; | |
| 367 | } | |
| 368 | const surface = url.value.searchParams.get("clientSurface"); | |
| 369 | const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; | |
| 370 | return { | |
| 371 | ...(isClientSurface(surface) ? { surface } : {}), | |
| 372 | ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH | |
| 373 | ? { appVersion } | |
| 374 | : {}), | |
| 375 | }; | |
| 376 | } | |
| 377 | ||
| 378 | const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ | |
| 379 | ...(origin.surface !== undefined ? { surface: origin.surface } : {}), | |
| 380 | ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), | |
| 381 | }); | |
| 382 | ||
| 352 | 383 | const makeWsRpcLayer = ( |
| 353 | 384 | currentSession: EnvironmentAuth.AuthenticatedSession, |
| 385 | clientOrigin: OrchestrationClientOrigin, | |
| 354 | 386 | previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], |
| 355 | 387 | ) => |
| 356 | 388 | WsRpcGroup.toLayer( |
| ... | ... | @@ -359,6 +391,35 @@ const makeWsRpcLayer = ( |
| 359 | 391 | const crypto = yield* Crypto.Crypto; |
| 360 | 392 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 361 | 393 | const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; |
| 394 | const analytics = yield* AnalyticsService.AnalyticsService; | |
| 395 | // Every command dispatched on this connection carries the connecting | |
| 396 | // client's origin, including server-generated bootstrap sub-commands: | |
| 397 | // the client's request caused them. | |
| 398 | const hasClientOrigin = | |
| 399 | clientOrigin.surface !== undefined || clientOrigin.appVersion !== undefined; | |
| 400 | const dispatchFromClient: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = ( | |
| 401 | command, | |
| 402 | ) => | |
| 403 | orchestrationEngine.dispatch( | |
| 404 | command, | |
| 405 | hasClientOrigin ? { origin: clientOrigin } : undefined, | |
| 406 | ); | |
| 407 | const originProps = clientOriginAnalyticsProps(clientOrigin); | |
| 408 | const recordClientCommandAnalytics = (command: OrchestrationCommand) => { | |
| 409 | switch (command.type) { | |
| 410 | case "thread.create": | |
| 411 | return analytics.record("client.thread.started", originProps); | |
| 412 | case "thread.turn.start": | |
| 413 | return command.bootstrap?.createThread | |
| 414 | ? Effect.andThen( | |
| 415 | analytics.record("client.thread.started", originProps), | |
| 416 | analytics.record("client.turn.requested", originProps), | |
| 417 | ) | |
| 418 | : analytics.record("client.turn.requested", originProps); | |
| 419 | default: | |
| 420 | return Effect.void; | |
| 421 | } | |
| 422 | }; | |
| 362 | 423 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 363 | 424 | const keybindings = yield* Keybindings.Keybindings; |
| 364 | 425 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| ... | ... | @@ -514,7 +575,7 @@ const makeWsRpcLayer = ( |
| 514 | 575 | activityId: serverEventId, |
| 515 | 576 | }).pipe( |
| 516 | 577 | Effect.flatMap(({ commandId, activityId }) => |
| 517 | orchestrationEngine.dispatch({ | |
| 578 | dispatchFromClient({ | |
| 518 | 579 | type: "thread.activity.append", |
| 519 | 580 | commandId, |
| 520 | 581 | threadId: input.threadId, |
| ... | ... | @@ -769,7 +830,7 @@ const makeWsRpcLayer = ( |
| 769 | 830 | createdThread |
| 770 | 831 | ? serverCommandId("bootstrap-thread-delete").pipe( |
| 771 | 832 | Effect.flatMap((commandId) => |
| 772 | orchestrationEngine.dispatch({ | |
| 833 | dispatchFromClient({ | |
| 773 | 834 | type: "thread.delete", |
| 774 | 835 | commandId, |
| 775 | 836 | threadId: command.threadId, |
| ... | ... | @@ -896,7 +957,7 @@ const makeWsRpcLayer = ( |
| 896 | 957 | |
| 897 | 958 | const bootstrapProgram = Effect.gen(function* () { |
| 898 | 959 | if (bootstrap?.createThread) { |
| 899 | yield* orchestrationEngine.dispatch({ | |
| 960 | yield* dispatchFromClient({ | |
| 900 | 961 | type: "thread.create", |
| 901 | 962 | commandId: yield* serverCommandId("bootstrap-thread-create"), |
| 902 | 963 | threadId: command.threadId, |
| ... | ... | @@ -943,7 +1004,7 @@ const makeWsRpcLayer = ( |
| 943 | 1004 | path: null, |
| 944 | 1005 | }); |
| 945 | 1006 | targetWorktreePath = worktree.worktree.path; |
| 946 | yield* orchestrationEngine.dispatch({ | |
| 1007 | yield* dispatchFromClient({ | |
| 947 | 1008 | type: "thread.meta.update", |
| 948 | 1009 | commandId: yield* serverCommandId("bootstrap-thread-meta-update"), |
| 949 | 1010 | threadId: command.threadId, |
| ... | ... | @@ -955,7 +1016,7 @@ const makeWsRpcLayer = ( |
| 955 | 1016 | |
| 956 | 1017 | yield* runSetupProgram(); |
| 957 | 1018 | |
| 958 | return yield* orchestrationEngine.dispatch(finalTurnStartCommand); | |
| 1019 | return yield* dispatchFromClient(finalTurnStartCommand); | |
| 959 | 1020 | }); |
| 960 | 1021 | |
| 961 | 1022 | return yield* bootstrapProgram.pipe( |
| ... | ... | @@ -995,13 +1056,11 @@ const makeWsRpcLayer = ( |
| 995 | 1056 | const dispatchEffect = |
| 996 | 1057 | normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap |
| 997 | 1058 | ? dispatchBootstrapTurnStart(normalizedCommand) |
| 998 | : orchestrationEngine | |
| 999 | .dispatch(normalizedCommand) | |
| 1000 | .pipe( | |
| 1001 | Effect.mapError((cause) => | |
| 1002 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), | |
| 1003 | ), | |
| 1004 | ); | |
| 1059 | : dispatchFromClient(normalizedCommand).pipe( | |
| 1060 | Effect.mapError((cause) => | |
| 1061 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), | |
| 1062 | ), | |
| 1063 | ); | |
| 1005 | 1064 | |
| 1006 | 1065 | return startup |
| 1007 | 1066 | .enqueueCommand(dispatchEffect) |
| ... | ... | @@ -1098,6 +1157,7 @@ const makeWsRpcLayer = ( |
| 1098 | 1157 | ) |
| 1099 | 1158 | : false; |
| 1100 | 1159 | const result = yield* dispatchNormalizedCommand(normalizedCommand); |
| 1160 | yield* recordClientCommandAnalytics(normalizedCommand); | |
| 1101 | 1161 | if (parkingCommand) { |
| 1102 | 1162 | const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; |
| 1103 | 1163 | if (shouldStopSessionAfterCommand) { |
| ... | ... | @@ -2330,6 +2390,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2330 | 2390 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 2331 | 2391 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 2332 | 2392 | const sessions = yield* SessionStore.SessionStore; |
| 2393 | const analytics = yield* AnalyticsService.AnalyticsService; | |
| 2333 | 2394 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( |
| 2334 | 2395 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 2335 | 2396 | failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), |
| ... | ... | @@ -2338,11 +2399,14 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2338 | 2399 | failEnvironmentInternal("internal_error", error), |
| 2339 | 2400 | ), |
| 2340 | 2401 | ); |
| 2402 | const clientOrigin = readClientConnectionOrigin(request); | |
| 2403 | yield* sessions.recordClientConnection(session.sessionId, clientOrigin); | |
| 2404 | yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin)); | |
| 2341 | 2405 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { |
| 2342 | 2406 | disableTracing: true, |
| 2343 | 2407 | }).pipe( |
| 2344 | 2408 | Effect.provide( |
| 2345 | makeWsRpcLayer(session, previewAutomationBroker).pipe( | |
| 2409 | makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( | |
| 2346 | 2410 | Layer.provideMerge(RpcSerialization.layerJson), |
| 2347 | 2411 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 2348 | 2412 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), |
apps/web/src/connection/platform.ts+3| ... | ... | @@ -42,6 +42,7 @@ import * as Ref from "effect/Ref"; |
| 42 | 42 | import * as Stream from "effect/Stream"; |
| 43 | 43 | import { FetchHttpClient } from "effect/unstable/http"; |
| 44 | 44 | |
| 45 | import { APP_VERSION } from "../branding"; | |
| 45 | 46 | import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; |
| 46 | 47 | import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; |
| 47 | 48 | import { |
| ... | ... | @@ -120,6 +121,8 @@ function clientMetadata() { |
| 120 | 121 | label: desktop ? "T3 Code Desktop" : "T3 Code Web", |
| 121 | 122 | deviceType: "desktop" as const, |
| 122 | 123 | ...(platform === "" ? {} : { os: platform }), |
| 124 | surface: desktop ? ("desktop" as const) : ("web" as const), | |
| 125 | ...(APP_VERSION === "0.0.0" ? {} : { appVersion: APP_VERSION }), | |
| 123 | 126 | }; |
| 124 | 127 | } |
| 125 | 128 |
packages/client-runtime/src/authorization/remote.ts+18| ... | ... | @@ -32,6 +32,20 @@ const clientMetadataTokenExchangeFields = ( |
| 32 | 32 | ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), |
| 33 | 33 | }); |
| 34 | 34 | |
| 35 | // 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. | |
| 37 | export const appendClientConnectionParams = ( | |
| 38 | url: URL, | |
| 39 | clientMetadata: AuthClientPresentationMetadata | undefined, | |
| 40 | ): void => { | |
| 41 | if (clientMetadata?.surface) { | |
| 42 | url.searchParams.set("clientSurface", clientMetadata.surface); | |
| 43 | } | |
| 44 | if (clientMetadata?.appVersion) { | |
| 45 | url.searchParams.set("clientAppVersion", clientMetadata.appVersion); | |
| 46 | } | |
| 47 | }; | |
| 48 | ||
| 35 | 49 | export const exchangeRemoteDpopAccessToken = Effect.fn( |
| 36 | 50 | "clientRuntime.authorization.exchangeRemoteDpopAccessToken", |
| 37 | 51 | )(function* (input: { |
| ... | ... | @@ -174,6 +188,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 174 | 188 | readonly wsBaseUrl: string; |
| 175 | 189 | readonly httpBaseUrl: string; |
| 176 | 190 | readonly bearerToken: string; |
| 191 | readonly clientMetadata?: AuthClientPresentationMetadata; | |
| 177 | 192 | readonly timeoutMs?: number; |
| 178 | 193 | }) { |
| 179 | 194 | const issued = yield* issueRemoteWebSocketTicket({ |
| ... | ... | @@ -187,6 +202,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 187 | 202 | url.pathname = "/ws"; |
| 188 | 203 | } |
| 189 | 204 | url.searchParams.set("wsTicket", issued.ticket); |
| 205 | appendClientConnectionParams(url, input.clientMetadata); | |
| 190 | 206 | return url.toString(); |
| 191 | 207 | }); |
| 192 | 208 | |
| ... | ... | @@ -197,6 +213,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 197 | 213 | readonly httpBaseUrl: string; |
| 198 | 214 | readonly accessToken: string; |
| 199 | 215 | readonly dpopProof: string; |
| 216 | readonly clientMetadata?: AuthClientPresentationMetadata; | |
| 200 | 217 | readonly timeoutMs?: number; |
| 201 | 218 | }) { |
| 202 | 219 | const issued = yield* issueRemoteDpopWebSocketTicket({ |
| ... | ... | @@ -210,5 +227,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 210 | 227 | url.pathname = "/ws"; |
| 211 | 228 | } |
| 212 | 229 | url.searchParams.set("wsTicket", issued.ticket); |
| 230 | appendClientConnectionParams(url, input.clientMetadata); | |
| 213 | 231 | return url.toString(); |
| 214 | 232 | }); |
packages/client-runtime/src/authorization/service.ts+2| ... | ... | @@ -131,6 +131,7 @@ export const make = Effect.gen(function* () { |
| 131 | 131 | wsBaseUrl: input.wsBaseUrl, |
| 132 | 132 | httpBaseUrl: input.httpBaseUrl, |
| 133 | 133 | bearerToken: input.bearerToken, |
| 134 | clientMetadata: presentation.metadata, | |
| 134 | 135 | }).pipe( |
| 135 | 136 | Effect.mapError(mapRemoteEnvironmentError), |
| 136 | 137 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| ... | ... | @@ -170,6 +171,7 @@ export const make = Effect.gen(function* () { |
| 170 | 171 | httpBaseUrl: token.endpoint.httpBaseUrl, |
| 171 | 172 | accessToken: token.accessToken, |
| 172 | 173 | dpopProof: ticketProof, |
| 174 | clientMetadata: presentation.metadata, | |
| 173 | 175 | ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 174 | 176 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); |
| 175 | 177 | }, |
packages/client-runtime/src/connection/resolver.test.ts+8-1| ... | ... | @@ -174,6 +174,13 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 174 | 174 | bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), |
| 175 | 175 | }), |
| 176 | 176 | ), |
| 177 | Layer.succeed( | |
| 178 | ClientCapabilities.ClientPresentation, | |
| 179 | ClientCapabilities.ClientPresentation.of({ | |
| 180 | metadata: { label: "Test Client", deviceType: "desktop", surface: "web" }, | |
| 181 | scopes: [], | |
| 182 | }), | |
| 183 | ), | |
| 177 | 184 | Layer.succeed( |
| 178 | 185 | ClientCapabilities.RelayDeviceIdentity, |
| 179 | 186 | ClientCapabilities.RelayDeviceIdentity.of({ |
| ... | ... | @@ -216,7 +223,7 @@ describe("ConnectionResolver", () => { |
| 216 | 223 | environmentId: ENVIRONMENT_ID, |
| 217 | 224 | label: "Primary", |
| 218 | 225 | httpBaseUrl: "http://127.0.0.1:3777", |
| 219 | socketUrl: "ws://127.0.0.1:3777/ws", | |
| 226 | socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web", | |
| 220 | 227 | httpAuthorization: null, |
| 221 | 228 | target, |
| 222 | 229 | }); |
packages/client-runtime/src/connection/resolver.ts+9-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; | |
| 1 | 2 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; |
| 2 | 3 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 3 | 4 | import * as Context from "effect/Context"; |
| ... | ... | @@ -6,6 +7,7 @@ import * as Layer from "effect/Layer"; |
| 6 | 7 | import * as Option from "effect/Option"; |
| 7 | 8 | import * as Schema from "effect/Schema"; |
| 8 | 9 | |
| 10 | import { appendClientConnectionParams } from "../authorization/remote.ts"; | |
| 9 | 11 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 10 | 12 | import * as ManagedRelay from "../relay/managedRelay.ts"; |
| 11 | 13 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| ... | ... | @@ -46,16 +48,21 @@ const isBearerProfile = Schema.is(BearerConnectionProfile); |
| 46 | 48 | const isSshProfile = Schema.is(SshConnectionProfile); |
| 47 | 49 | const isBearerCredential = Schema.is(BearerConnectionCredential); |
| 48 | 50 | |
| 49 | function primarySocketUrl(target: PrimaryConnectionTarget): string { | |
| 51 | function primarySocketUrl( | |
| 52 | target: PrimaryConnectionTarget, | |
| 53 | clientMetadata: AuthClientPresentationMetadata | undefined, | |
| 54 | ): string { | |
| 50 | 55 | const url = new URL(target.wsBaseUrl); |
| 51 | 56 | if (url.pathname === "" || url.pathname === "/") { |
| 52 | 57 | url.pathname = "/ws"; |
| 53 | 58 | } |
| 59 | appendClientConnectionParams(url, clientMetadata); | |
| 54 | 60 | return url.toString(); |
| 55 | 61 | } |
| 56 | 62 | |
| 57 | 63 | const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { |
| 58 | 64 | const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth; |
| 65 | const presentation = yield* ClientCapabilities.ClientPresentation; | |
| 59 | 66 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 60 | 67 | |
| 61 | 68 | return Effect.fn("clientRuntime.connection.broker.primary")(function* ( |
| ... | ... | @@ -67,7 +74,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary |
| 67 | 74 | environmentId: target.environmentId, |
| 68 | 75 | label: target.label, |
| 69 | 76 | httpBaseUrl: target.httpBaseUrl, |
| 70 | socketUrl: primarySocketUrl(target), | |
| 77 | socketUrl: primarySocketUrl(target, presentation.metadata), | |
| 71 | 78 | httpAuthorization: null, |
| 72 | 79 | target, |
| 73 | 80 | } satisfies PreparedConnection; |
packages/contracts/src/auth.ts+3-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; |
| 3 | 3 | |
| 4 | import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | import { AuthSessionId, ClientSurface, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 5 | 5 | |
| 6 | 6 | /** |
| 7 | 7 | * Declares the server's overall authentication posture. |
| ... | ... | @@ -169,6 +169,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({ |
| 169 | 169 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 170 | 170 | deviceType: Schema.optionalKey(AuthClientMetadataDeviceType), |
| 171 | 171 | os: Schema.optionalKey(TrimmedNonEmptyString), |
| 172 | surface: Schema.optionalKey(ClientSurface), | |
| 173 | appVersion: Schema.optionalKey(TrimmedNonEmptyString), | |
| 172 | 174 | }); |
| 173 | 175 | export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; |
| 174 | 176 |
packages/contracts/src/baseSchemas.ts+9| ... | ... | @@ -71,6 +71,15 @@ export type AuthSessionId = typeof AuthSessionId.Type; |
| 71 | 71 | export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); |
| 72 | 72 | export type RpcClientId = typeof RpcClientId.Type; |
| 73 | 73 | |
| 74 | /** | |
| 75 | * Which client app a connection comes from. Unlike | |
| 76 | * `AuthClientMetadataDeviceType` (a UA-style device class where web and | |
| 77 | * desktop are both "desktop"), this names the actual product surface. | |
| 78 | * Optional everywhere it appears: old clients never send it. | |
| 79 | */ | |
| 80 | export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]); | |
| 81 | export type ClientSurface = typeof ClientSurface.Type; | |
| 82 | ||
| 74 | 83 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 75 | 84 | export type ProviderItemId = typeof ProviderItemId.Type; |
| 76 | 85 | export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); |
packages/contracts/src/orchestration.ts+14| ... | ... | @@ -8,6 +8,7 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; |
| 8 | 8 | import { |
| 9 | 9 | ApprovalRequestId, |
| 10 | 10 | CheckpointRef, |
| 11 | ClientSurface, | |
| 11 | 12 | CommandId, |
| 12 | 13 | EventId, |
| 13 | 14 | IsoDateTime, |
| ... | ... | @@ -1319,12 +1320,25 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ |
| 1319 | 1320 | activity: OrchestrationThreadActivity, |
| 1320 | 1321 | }); |
| 1321 | 1322 | |
| 1323 | /** | |
| 1324 | * Which client connection dispatched the command that produced an event. | |
| 1325 | * Stamped by the orchestration engine on client-dispatched commands; absent on | |
| 1326 | * provider/server-originated events and on commands from clients too old to | |
| 1327 | * report it. | |
| 1328 | */ | |
| 1329 | export const OrchestrationClientOrigin = Schema.Struct({ | |
| 1330 | surface: Schema.optional(ClientSurface), | |
| 1331 | appVersion: Schema.optional(TrimmedNonEmptyString), | |
| 1332 | }); | |
| 1333 | export type OrchestrationClientOrigin = typeof OrchestrationClientOrigin.Type; | |
| 1334 | ||
| 1322 | 1335 | export const OrchestrationEventMetadata = Schema.Struct({ |
| 1323 | 1336 | providerTurnId: Schema.optional(TrimmedNonEmptyString), |
| 1324 | 1337 | providerItemId: Schema.optional(ProviderItemId), |
| 1325 | 1338 | adapterKey: Schema.optional(TrimmedNonEmptyString), |
| 1326 | 1339 | requestId: Schema.optional(ApprovalRequestId), |
| 1327 | 1340 | ingestedAt: Schema.optional(IsoDateTime), |
| 1341 | origin: Schema.optional(OrchestrationClientOrigin), | |
| 1328 | 1342 | }); |
| 1329 | 1343 | export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; |
| 1330 | 1344 |