| author | |
| committer | |
| log | 7caaa8c6b763db8a1e8934de235f956e13ff789d |
| tree | 43dbb2df2d5c92115e508d4f73e841dad2d3e74a |
| parent | 58df35b5187753f5f3de7499ef1287785ca6fbcc |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
Clover wants one server address for web and desktop sign-in, access to connected machines, and archived conversations that remain readable while laptops sleep. T3 Connect remains optional, and workspaces, agent execution, and provider credentials stay on each machine by default.
Use generic OIDC discovery with a confidential authorization-code client, S256 PKCE, and an exact HTTPS callback. Pin the configured issuer and immutable owner subject. Keep the client secret on the server; return desktop sign-in through a short-lived, single-use proof-bound handoff. Store rotating desktop credentials in OS-protected storage, renew access automatically, enforce interactive deadlines and issuer revalidation, and revoke active connections with their device. Keep local emergency recovery separate from normal login.
Connect each desktop through authenticated outbound sockets so the server requires no inbound laptop address. Preserve environment identities and existing manually paired connections. Limit background connection and archive credentials to dedicated scopes; source grants retain their own operation permissions. Show devices, revocation, connection status, upload retries, and reclaimed storage in Settings.
Persist the central inbox so settle, unsettle, snooze, wake, pin, and ordering work after a synced machine goes offline. Reconcile the latest intent only after its source command and snapshot are acknowledged, without replacing live source content or execution state.
Publish content-addressed transcript and media closures to an independently available archive mount. Require the configured durable store identity, verify every object, and flush the catalog before evicting local payloads. Keep retry queues across restarts and retain local data after failed verification, concurrent changes, or active fork references. Read cold transcripts and signed media directly from the server while the source sleeps; restore full local bodies and attachments before unarchiving. Reclaim SQLite and attachment disk blocks while reporting retained native provider logs separately.
Preserve shipped archive hashes, transcript ancestry, replay watermarks, media ranges, existing asset URLs, and managed account opt-in. Supply a generic configuration contract and optional read-only Compose secret mount without coupling Knife to a particular infrastructure repository.
Assisted-by: gpt-6.1-sol214 files changed, 17689 insertions(+), 759 deletions(-)
apps/desktop/src/app/DesktopApp.ts+2| ... | @@ -14,6 +14,7 @@ import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | ... | @@ -14,6 +14,7 @@ import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; |
| 14 | import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; | 14 | import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; |
| 15 | import * as DesktopAppActivation from "./DesktopAppActivation.ts"; | 15 | import * as DesktopAppActivation from "./DesktopAppActivation.ts"; |
| 16 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; | 16 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 17 | import * as DesktopHostedAuth from "./DesktopHostedAuth.ts"; | ||
| 17 | import * as DesktopClerk from "./DesktopClerk.ts"; | 18 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| 18 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; | 19 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; |
| 19 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 20 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| ... | @@ -320,6 +321,7 @@ const startup = Effect.gen(function* () { | ... | @@ -320,6 +321,7 @@ const startup = Effect.gen(function* () { |
| 320 | 321 | ||
| 321 | yield* appIdentity.configure; | 322 | yield* appIdentity.configure; |
| 322 | yield* lifecycle.register; | 323 | yield* lifecycle.register; |
| 324 | yield* (yield* DesktopHostedAuth.DesktopHostedAuth).configure; | ||
| 323 | yield* clerk.configure; | 325 | yield* clerk.configure; |
| 324 | 326 | ||
| 325 | yield* electronApp.whenReady.pipe( | 327 | yield* electronApp.whenReady.pipe( |
apps/desktop/src/app/DesktopHostedAuth.test.ts created+271| ... | @@ -0,0 +1,271 @@ | ||
| 1 | // @effect-diagnostics preferSchemaOverJson:off - The fake HTTPS peer inspects and emits JSON wire payloads. | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as NodePath from "@effect/platform-node/NodePath"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 9 | import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; | ||
| 10 | import * as DesktopHostedAuth from "./DesktopHostedAuth.ts"; | ||
| 11 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 12 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 13 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | ||
| 14 | import * as ElectronShell from "../electron/ElectronShell.ts"; | ||
| 15 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | ||
| 16 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | ||
| 17 | import type { DesktopBackendInstance } from "../backend/DesktopBackendPool.ts"; | ||
| 18 | import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; | ||
| 19 | |||
| 20 | const server = "https://knife.example.test"; | ||
| 21 | const encoder = new TextEncoder(); | ||
| 22 | const decoder = new TextDecoder(); | ||
| 23 | |||
| 24 | function harness() { | ||
| 25 | const files = new Map<string, Uint8Array>(); | ||
| 26 | const requests: Array<{ | ||
| 27 | path: string; | ||
| 28 | body: Record<string, unknown>; | ||
| 29 | authorization: string | null; | ||
| 30 | redirect: RequestRedirect | undefined; | ||
| 31 | }> = []; | ||
| 32 | const opened: string[] = []; | ||
| 33 | const control = { | ||
| 34 | available: true, | ||
| 35 | exchangeStatus: 200, | ||
| 36 | backend: false, | ||
| 37 | deleteStatus: 200, | ||
| 38 | redirectRefresh: false, | ||
| 39 | }; | ||
| 40 | const grant = { | ||
| 41 | deviceId: "device", | ||
| 42 | accessToken: "access-in-memory", | ||
| 43 | refreshToken: "private-refresh-v1", | ||
| 44 | expiresAt: "2035-01-01T00:00:00.000Z", | ||
| 45 | }; | ||
| 46 | const layer = Layer.mergeAll( | ||
| 47 | NodePath.layerPosix, | ||
| 48 | Layer.succeed(DesktopEnvironment.DesktopEnvironment, { | ||
| 49 | stateDir: "/isolated", | ||
| 50 | } as DesktopEnvironment.DesktopEnvironment["Service"]), | ||
| 51 | Layer.mock(ElectronApp.ElectronApp)({}), | ||
| 52 | Layer.mock(HostProcess.Arguments)([]), | ||
| 53 | Layer.mock(DesktopBackendPool.DesktopBackendPool)({ | ||
| 54 | list: Effect.sync(() => | ||
| 55 | control.backend | ||
| 56 | ? [ | ||
| 57 | { | ||
| 58 | id: PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 59 | currentConfig: Effect.succeedSome({ | ||
| 60 | httpBaseUrl: new URL("http://127.0.0.1:3773"), | ||
| 61 | }), | ||
| 62 | } as DesktopBackendInstance, | ||
| 63 | ] | ||
| 64 | : [], | ||
| 65 | ), | ||
| 66 | }), | ||
| 67 | Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth)({ | ||
| 68 | getBearerToken: Effect.succeed("local-admin"), | ||
| 69 | }), | ||
| 70 | Layer.succeed(ElectronShell.ElectronShell, { | ||
| 71 | openExternal: (url) => | ||
| 72 | Effect.sync(() => { | ||
| 73 | opened.push(String(url)); | ||
| 74 | return true; | ||
| 75 | }), | ||
| 76 | copyText: () => Effect.void, | ||
| 77 | openSystemSettings: () => Effect.succeed(false), | ||
| 78 | }), | ||
| 79 | Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { | ||
| 80 | isEncryptionAvailable: Effect.sync(() => control.available), | ||
| 81 | encryptString: (value) => Effect.succeed(encoder.encode(`encrypted:${value}`)), | ||
| 82 | decryptString: (value) => Effect.succeed(decoder.decode(value).slice("encrypted:".length)), | ||
| 83 | selectedStorageBackend: Effect.succeedNone, | ||
| 84 | }), | ||
| 85 | FileSystem.layerNoop({ | ||
| 86 | exists: (path) => Effect.sync(() => files.has(path)), | ||
| 87 | readFile: (path) => Effect.sync(() => files.get(path)!), | ||
| 88 | writeFile: (path, data) => | ||
| 89 | Effect.sync(() => { | ||
| 90 | files.set(path, data); | ||
| 91 | }), | ||
| 92 | rename: (from, to) => | ||
| 93 | Effect.sync(() => { | ||
| 94 | files.set(to, files.get(from)!); | ||
| 95 | files.delete(from); | ||
| 96 | }), | ||
| 97 | remove: (path) => | ||
| 98 | Effect.sync(() => { | ||
| 99 | files.delete(path); | ||
| 100 | }), | ||
| 101 | }), | ||
| 102 | layerRemoteHttpClient(async (input, init) => { | ||
| 103 | const request = new Request(input, init); | ||
| 104 | const path = new URL(request.url).pathname; | ||
| 105 | const body = | ||
| 106 | request.method === "DELETE" ? {} : ((await request.json()) as Record<string, unknown>); | ||
| 107 | requests.push({ | ||
| 108 | path, | ||
| 109 | body, | ||
| 110 | authorization: request.headers.get("authorization"), | ||
| 111 | redirect: init?.redirect, | ||
| 112 | }); | ||
| 113 | if (path.endsWith("/refresh") && control.redirectRefresh) { | ||
| 114 | if (init?.redirect === "error") throw new TypeError("Redirect blocked"); | ||
| 115 | return Response.json({ ...grant, accessToken: "redirected-credential" }); | ||
| 116 | } | ||
| 117 | if (path === "/api/auth/devices/token") | ||
| 118 | return Response.json({ accessToken: "source-narrow", expiresAt: "2035-01-01T00:00:00Z" }); | ||
| 119 | if (path === "/api/self-hosted/connection") | ||
| 120 | return Response.json( | ||
| 121 | { connected: true }, | ||
| 122 | { status: request.method === "DELETE" ? control.deleteStatus : 200 }, | ||
| 123 | ); | ||
| 124 | if (path.endsWith("/start")) | ||
| 125 | return Response.json({ authorizationUrl: `${server}/api/auth/oidc/start?request=one-use` }); | ||
| 126 | if (path.endsWith("/exchange")) | ||
| 127 | return Response.json(grant, { status: control.exchangeStatus }); | ||
| 128 | if (path.endsWith("/refresh")) | ||
| 129 | return Response.json({ ...grant, refreshToken: "private-refresh-v2" }); | ||
| 130 | return new Response(null, { status: 404 }); | ||
| 131 | }), | ||
| 132 | ); | ||
| 133 | const callback = () => { | ||
| 134 | const request = requests.findLast((value) => value.path.endsWith("/start"))!; | ||
| 135 | return `t3code://app/self-hosted/callback?${new URLSearchParams({ server, state: String(request.body.state), code: "x".repeat(43) })}`; | ||
| 136 | }; | ||
| 137 | return { files, requests, opened, control, layer, callback }; | ||
| 138 | } | ||
| 139 | |||
| 140 | describe("desktop hosted account custody", () => { | ||
| 141 | it.effect("rejects refresh redirects without replacing saved credentials", () => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const test = harness(); | ||
| 144 | const first = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 145 | yield* first.begin(server); | ||
| 146 | yield* first.receive(test.callback()); | ||
| 147 | const before = test.files.get("/isolated/hosted-account.bin"); | ||
| 148 | test.control.redirectRefresh = true; | ||
| 149 | const restarted = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 150 | const result = yield* restarted.accessToken(server).pipe(Effect.result); | ||
| 151 | expect(result._tag).toBe("Failure"); | ||
| 152 | expect(test.requests.findLast((request) => request.path.endsWith("/refresh"))?.redirect).toBe( | ||
| 153 | "error", | ||
| 154 | ); | ||
| 155 | expect(test.files.get("/isolated/hosted-account.bin")).toEqual(before); | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | |||
| 159 | it.effect("keeps credentials when the local source rejects disconnect", () => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const test = harness(); | ||
| 162 | test.control.backend = true; | ||
| 163 | const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 164 | yield* auth.begin(server); | ||
| 165 | yield* auth.receive(test.callback()); | ||
| 166 | test.control.deleteStatus = 503; | ||
| 167 | expect((yield* auth.disconnect.pipe(Effect.result))._tag).toBe("Failure"); | ||
| 168 | expect(test.files.has("/isolated/hosted-account.bin")).toBe(true); | ||
| 169 | expect(test.requests.every((request) => request.redirect === "error")).toBe(true); | ||
| 170 | test.control.deleteStatus = 200; | ||
| 171 | yield* auth.disconnect; | ||
| 172 | expect(test.files.size).toBe(0); | ||
| 173 | }), | ||
| 174 | ); | ||
| 175 | |||
| 176 | it.effect("gives the local source only a dedicated archive/connect grant", () => | ||
| 177 | Effect.gen(function* () { | ||
| 178 | const test = harness(); | ||
| 179 | test.control.backend = true; | ||
| 180 | const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 181 | yield* auth.begin(server); | ||
| 182 | yield* auth.receive(test.callback()); | ||
| 183 | const narrow = test.requests.find((request) => request.path === "/api/auth/devices/token"); | ||
| 184 | expect(narrow?.authorization).toBe("Bearer access-in-memory"); | ||
| 185 | expect(narrow?.body).toEqual({ | ||
| 186 | scopes: ["archive:read", "archive:write", "environment:connect"], | ||
| 187 | }); | ||
| 188 | const bridge = test.requests.find( | ||
| 189 | (request) => request.path === "/api/self-hosted/connection", | ||
| 190 | ); | ||
| 191 | expect(bridge?.authorization).toBe("Bearer local-admin"); | ||
| 192 | expect(bridge?.body).toEqual({ | ||
| 193 | serverUrl: server, | ||
| 194 | authorization: "Bearer source-narrow", | ||
| 195 | expiresAt: "2035-01-01T00:00:00.000Z", | ||
| 196 | }); | ||
| 197 | expect(JSON.stringify(bridge)).not.toContain("access-in-memory"); | ||
| 198 | expect(JSON.stringify(bridge)).not.toContain("refresh"); | ||
| 199 | }), | ||
| 200 | ); | ||
| 201 | |||
| 202 | it.effect( | ||
| 203 | "keeps refresh credentials main-only, consumes handoff once, and renews after restart", | ||
| 204 | () => | ||
| 205 | Effect.gen(function* () { | ||
| 206 | const test = harness(); | ||
| 207 | const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 208 | yield* auth.begin(server); | ||
| 209 | expect(test.opened).toEqual([`${server}/api/auth/oidc/start?request=one-use`]); | ||
| 210 | yield* auth.receive(test.callback()); | ||
| 211 | yield* auth.receive(test.callback()); | ||
| 212 | expect(test.requests.filter((value) => value.path.endsWith("/exchange"))).toHaveLength(1); | ||
| 213 | expect(yield* auth.accessToken(server)).toBe("access-in-memory"); | ||
| 214 | expect(yield* auth.state).toEqual({ serverUrl: server, phase: "connected", error: null }); | ||
| 215 | const saved = decoder.decode(test.files.get("/isolated/hosted-account.bin")!); | ||
| 216 | expect(saved).toContain("private-refresh-v1"); | ||
| 217 | expect(saved).not.toContain("access-in-memory"); | ||
| 218 | const restarted = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 219 | expect(yield* restarted.accessToken(server)).toBe("access-in-memory"); | ||
| 220 | expect(test.requests.findLast((value) => value.path.endsWith("/refresh"))!.body).toEqual({ | ||
| 221 | refreshToken: "private-refresh-v1", | ||
| 222 | }); | ||
| 223 | expect(decoder.decode(test.files.get("/isolated/hosted-account.bin")!)).toContain( | ||
| 224 | "private-refresh-v2", | ||
| 225 | ); | ||
| 226 | }), | ||
| 227 | ); | ||
| 228 | |||
| 229 | it.effect("preserves an existing credential when a new sign-in fails or is canceled", () => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | const test = harness(); | ||
| 232 | const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 233 | yield* auth.begin(server); | ||
| 234 | yield* auth.receive(test.callback()); | ||
| 235 | const saved = test.files.get("/isolated/hosted-account.bin"); | ||
| 236 | yield* auth.begin(server); | ||
| 237 | yield* auth.cancel; | ||
| 238 | yield* auth.receive(test.callback()); | ||
| 239 | expect(test.files.get("/isolated/hosted-account.bin")).toEqual(saved); | ||
| 240 | test.control.exchangeStatus = 401; | ||
| 241 | yield* auth.begin(server); | ||
| 242 | yield* auth.receive(test.callback()); | ||
| 243 | expect((yield* auth.state).phase).toBe("error"); | ||
| 244 | expect(test.files.get("/isolated/hosted-account.bin")).toEqual(saved); | ||
| 245 | }), | ||
| 246 | ); | ||
| 247 | |||
| 248 | it.effect("rejects callbacks for another state or server without exchanging proof", () => | ||
| 249 | Effect.gen(function* () { | ||
| 250 | const test = harness(); | ||
| 251 | const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 252 | yield* auth.begin(server); | ||
| 253 | const wrong = new URL(test.callback()); | ||
| 254 | wrong.searchParams.set("server", "https://other.example.test"); | ||
| 255 | yield* auth.receive(wrong.href); | ||
| 256 | expect(test.requests.filter((value) => value.path.endsWith("/exchange"))).toEqual([]); | ||
| 257 | expect(test.files.size).toBe(0); | ||
| 258 | }), | ||
| 259 | ); | ||
| 260 | |||
| 261 | it.effect("does not start sign-in when protected credential storage is unavailable", () => | ||
| 262 | Effect.gen(function* () { | ||
| 263 | const test = harness(); | ||
| 264 | test.control.available = false; | ||
| 265 | const auth = yield* DesktopHostedAuth.make.pipe(Effect.provide(test.layer)); | ||
| 266 | expect(Option.isSome(yield* Effect.option(auth.begin(server)))).toBe(false); | ||
| 267 | expect(test.requests).toEqual([]); | ||
| 268 | expect(test.opened).toEqual([]); | ||
| 269 | }), | ||
| 270 | ); | ||
| 271 | }); | ||
apps/desktop/src/app/DesktopHostedAuth.ts created+363| ... | @@ -0,0 +1,363 @@ | ||
| 1 | import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl"; | ||
| 2 | import { | ||
| 3 | DesktopHostedAuthState, | ||
| 4 | AuthArchiveReadScope, | ||
| 5 | AuthArchiveWriteScope, | ||
| 6 | AuthEnvironmentConnectScope, | ||
| 7 | HostedAuthDeviceStartResult, | ||
| 8 | HostedAuthGrant, | ||
| 9 | PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Clock from "effect/Clock"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as FileSystem from "effect/FileSystem"; | ||
| 16 | import * as Layer from "effect/Layer"; | ||
| 17 | import * as Option from "effect/Option"; | ||
| 18 | import * as Path from "effect/Path"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import * as Semaphore from "effect/Semaphore"; | ||
| 21 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; | ||
| 22 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 23 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | ||
| 24 | import * as ElectronShell from "../electron/ElectronShell.ts"; | ||
| 25 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | ||
| 26 | import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; | ||
| 27 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 28 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 29 | import { | ||
| 30 | createHostedAuthAttempt, | ||
| 31 | isHostedAuthCallback, | ||
| 32 | readHostedAuthCallback, | ||
| 33 | type HostedAuthAttempt, | ||
| 34 | } from "./hostedAuthHandoff.ts"; | ||
| 35 | |||
| 36 | const SavedCredential = Schema.Struct({ | ||
| 37 | serverUrl: Schema.String, | ||
| 38 | deviceId: Schema.String, | ||
| 39 | refreshToken: Schema.String, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const encodeSavedCredential = Schema.encodeEffect(Schema.fromJsonString(SavedCredential)); | ||
| 43 | |||
| 44 | export class DesktopHostedAuthError extends Schema.TaggedError<DesktopHostedAuthError>()( | ||
| 45 | "DesktopHostedAuthError", | ||
| 46 | { message: Schema.String }, | ||
| 47 | ) {} | ||
| 48 | |||
| 49 | export class DesktopHostedAuth extends Context.Service< | ||
| 50 | DesktopHostedAuth, | ||
| 51 | { | ||
| 52 | readonly state: Effect.Effect<DesktopHostedAuthState, DesktopHostedAuthError>; | ||
| 53 | readonly begin: (server: string) => Effect.Effect<void, DesktopHostedAuthError>; | ||
| 54 | readonly accessToken: (server: string) => Effect.Effect<string, DesktopHostedAuthError>; | ||
| 55 | readonly disconnect: Effect.Effect<void, DesktopHostedAuthError>; | ||
| 56 | readonly cancel: Effect.Effect<void>; | ||
| 57 | readonly receive: (url: string) => Effect.Effect<void>; | ||
| 58 | readonly configure: Effect.Effect<void, never, import("effect/Scope").Scope>; | ||
| 59 | } | ||
| 60 | >()("@t3tools/desktop/app/DesktopHostedAuth") {} | ||
| 61 | |||
| 62 | export const make = Effect.gen(function* () { | ||
| 63 | const fs = yield* FileSystem.FileSystem; | ||
| 64 | const path = yield* Path.Path; | ||
| 65 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 66 | const safe = yield* ElectronSafeStorage.ElectronSafeStorage; | ||
| 67 | const shell = yield* ElectronShell.ElectronShell; | ||
| 68 | const app = yield* ElectronApp.ElectronApp; | ||
| 69 | const http = yield* HttpClient.HttpClient; | ||
| 70 | const args = yield* HostProcess.Arguments; | ||
| 71 | const lock = yield* Semaphore.make(1); | ||
| 72 | const pool = yield* DesktopBackendPool.DesktopBackendPool; | ||
| 73 | const localAuth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; | ||
| 74 | const credentialPath = path.join(environment.stateDir, "hosted-account.bin"); | ||
| 75 | let pending: HostedAuthAttempt | null = null; | ||
| 76 | let access: { serverUrl: string; token: string; expiresAt: number } | null = null; | ||
| 77 | let source: { serverUrl: string; token: string; expiresAt: number } | null = null; | ||
| 78 | let state: DesktopHostedAuthState = { serverUrl: null, phase: "idle", error: null }; | ||
| 79 | |||
| 80 | const fail = () => | ||
| 81 | new DesktopHostedAuthError({ message: "Sign in to your server again to reconnect." }); | ||
| 82 | const read = Effect.gen(function* () { | ||
| 83 | if (!(yield* fs.exists(credentialPath))) return null; | ||
| 84 | const bytes = yield* fs.readFile(credentialPath); | ||
| 85 | return yield* safe | ||
| 86 | .decryptString(bytes) | ||
| 87 | .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(SavedCredential)))); | ||
| 88 | }).pipe(Effect.mapError(fail)); | ||
| 89 | |||
| 90 | const protectedStorageAvailable = Effect.gen(function* () { | ||
| 91 | return ( | ||
| 92 | (yield* safe.isEncryptionAvailable) && | ||
| 93 | Option.getOrNull(yield* safe.selectedStorageBackend) !== "basic_text" | ||
| 94 | ); | ||
| 95 | }); | ||
| 96 | |||
| 97 | const save = (serverUrl: string, grant: HostedAuthGrant) => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | if (!(yield* protectedStorageAvailable)) return yield* fail(); | ||
| 100 | const json = yield* encodeSavedCredential({ | ||
| 101 | serverUrl, | ||
| 102 | deviceId: grant.deviceId, | ||
| 103 | refreshToken: grant.refreshToken, | ||
| 104 | }); | ||
| 105 | const bytes = yield* safe.encryptString(json); | ||
| 106 | yield* fs.writeFile(`${credentialPath}.tmp`, bytes, { mode: 0o600 }); | ||
| 107 | yield* fs.rename(`${credentialPath}.tmp`, credentialPath); | ||
| 108 | access = { | ||
| 109 | serverUrl, | ||
| 110 | token: grant.accessToken, | ||
| 111 | expiresAt: DateTime.toEpochMillis(grant.expiresAt), | ||
| 112 | }; | ||
| 113 | }).pipe(Effect.mapError(fail)); | ||
| 114 | |||
| 115 | const post = <A, I>( | ||
| 116 | server: string, | ||
| 117 | endpoint: string, | ||
| 118 | body: unknown, | ||
| 119 | schema: Schema.Codec<A, I>, | ||
| 120 | authorization?: string, | ||
| 121 | ) => | ||
| 122 | Effect.scoped( | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const request = HttpClientRequest.post(`${server}${endpoint}`).pipe( | ||
| 125 | HttpClientRequest.bodyJsonUnsafe(body), | ||
| 126 | HttpClientRequest.setHeaders(authorization === undefined ? {} : { authorization }), | ||
| 127 | ); | ||
| 128 | const response = yield* HttpClient.withScope(http).execute(request); | ||
| 129 | if (response.status !== 200) return yield* fail(); | ||
| 130 | return yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema))); | ||
| 131 | }), | ||
| 132 | ).pipe( | ||
| 133 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 134 | Effect.mapError(fail), | ||
| 135 | Effect.timeout("30 seconds"), | ||
| 136 | Effect.mapError(fail), | ||
| 137 | ); | ||
| 138 | |||
| 139 | const deliver = (serverUrl: string, token: string) => | ||
| 140 | Effect.scoped( | ||
| 141 | Effect.gen(function* () { | ||
| 142 | const primary = (yield* pool.list).find((item) => item.id === PRIMARY_LOCAL_ENVIRONMENT_ID); | ||
| 143 | const config = primary === undefined ? Option.none() : yield* primary.currentConfig; | ||
| 144 | if (Option.isNone(config)) return; | ||
| 145 | if ( | ||
| 146 | source?.serverUrl !== serverUrl || | ||
| 147 | source.expiresAt <= (yield* Clock.currentTimeMillis) + 60_000 | ||
| 148 | ) { | ||
| 149 | const narrow = yield* post( | ||
| 150 | serverUrl, | ||
| 151 | "/api/auth/devices/token", | ||
| 152 | { | ||
| 153 | scopes: [AuthArchiveReadScope, AuthArchiveWriteScope, AuthEnvironmentConnectScope], | ||
| 154 | }, | ||
| 155 | Schema.toCodecJson( | ||
| 156 | Schema.Struct({ accessToken: Schema.String, expiresAt: Schema.DateTimeUtc }), | ||
| 157 | ), | ||
| 158 | `Bearer ${token}`, | ||
| 159 | ); | ||
| 160 | source = { | ||
| 161 | serverUrl, | ||
| 162 | token: narrow.accessToken, | ||
| 163 | expiresAt: DateTime.toEpochMillis(narrow.expiresAt), | ||
| 164 | }; | ||
| 165 | } | ||
| 166 | const localToken = yield* localAuth.getBearerToken; | ||
| 167 | const response = yield* HttpClient.withScope(http).execute( | ||
| 168 | HttpClientRequest.post( | ||
| 169 | new URL("/api/self-hosted/connection", config.value.httpBaseUrl).href, | ||
| 170 | ).pipe( | ||
| 171 | HttpClientRequest.setHeader("authorization", `Bearer ${localToken}`), | ||
| 172 | HttpClientRequest.bodyJsonUnsafe({ | ||
| 173 | serverUrl, | ||
| 174 | authorization: `Bearer ${source.token}`, | ||
| 175 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(source.expiresAt)), | ||
| 176 | }), | ||
| 177 | ), | ||
| 178 | ); | ||
| 179 | if (response.status < 200 || response.status >= 300) return yield* fail(); | ||
| 180 | }), | ||
| 181 | ).pipe( | ||
| 182 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 183 | Effect.mapError(fail), | ||
| 184 | ); | ||
| 185 | |||
| 186 | const resolveAccessToken = (server: string) => | ||
| 187 | Effect.gen(function* () { | ||
| 188 | const serverUrl = yield* Effect.try({ try: () => hostedServerUrl(server), catch: fail }); | ||
| 189 | const saved = yield* read; | ||
| 190 | if (saved === null || saved.serverUrl !== serverUrl) return yield* fail(); | ||
| 191 | if ( | ||
| 192 | access?.serverUrl === serverUrl && | ||
| 193 | access.expiresAt > (yield* Clock.currentTimeMillis) + 60_000 | ||
| 194 | ) | ||
| 195 | return access.token; | ||
| 196 | const grant = yield* post( | ||
| 197 | serverUrl, | ||
| 198 | "/api/auth/devices/refresh", | ||
| 199 | { refreshToken: saved.refreshToken }, | ||
| 200 | Schema.toCodecJson(HostedAuthGrant), | ||
| 201 | ); | ||
| 202 | yield* save(serverUrl, grant); | ||
| 203 | yield* deliver(serverUrl, grant.accessToken).pipe(Effect.ignore); | ||
| 204 | return grant.accessToken; | ||
| 205 | }); | ||
| 206 | |||
| 207 | const accessToken = (server: string) => lock.withPermits(1)(resolveAccessToken(server)); | ||
| 208 | |||
| 209 | const receive = (url: string) => | ||
| 210 | lock | ||
| 211 | .withPermits(1)( | ||
| 212 | Effect.gen(function* () { | ||
| 213 | if (pending === null || !isHostedAuthCallback(url)) return; | ||
| 214 | const attempt = pending; | ||
| 215 | const now = yield* Clock.currentTimeMillis; | ||
| 216 | const proof = yield* Effect.try({ | ||
| 217 | try: () => readHostedAuthCallback(url, attempt, now), | ||
| 218 | catch: fail, | ||
| 219 | }); | ||
| 220 | pending = null; | ||
| 221 | const grant = yield* post( | ||
| 222 | attempt.server, | ||
| 223 | "/api/auth/devices/exchange", | ||
| 224 | proof, | ||
| 225 | Schema.toCodecJson(HostedAuthGrant), | ||
| 226 | ); | ||
| 227 | yield* save(attempt.server, grant); | ||
| 228 | source = null; | ||
| 229 | yield* deliver(attempt.server, grant.accessToken).pipe(Effect.ignore); | ||
| 230 | state = { serverUrl: attempt.server, phase: "connected", error: null }; | ||
| 231 | }), | ||
| 232 | ) | ||
| 233 | .pipe( | ||
| 234 | Effect.catch(() => | ||
| 235 | Effect.sync(() => { | ||
| 236 | state = { ...state, phase: "error", error: "Sign-in did not finish. Sign in again." }; | ||
| 237 | }), | ||
| 238 | ), | ||
| 239 | ); | ||
| 240 | |||
| 241 | return DesktopHostedAuth.of({ | ||
| 242 | state: Effect.gen(function* () { | ||
| 243 | if (pending !== null && (yield* Clock.currentTimeMillis) >= pending.expiresAt) { | ||
| 244 | pending = null; | ||
| 245 | state = { ...state, phase: "error", error: "Sign-in expired. Sign in again." }; | ||
| 246 | } | ||
| 247 | if (state.phase !== "idle") return state; | ||
| 248 | const saved = yield* read; | ||
| 249 | return saved === null | ||
| 250 | ? state | ||
| 251 | : { serverUrl: saved.serverUrl, phase: "connected", error: null }; | ||
| 252 | }), | ||
| 253 | begin: (server) => | ||
| 254 | lock.withPermits(1)( | ||
| 255 | Effect.gen(function* () { | ||
| 256 | if (!(yield* protectedStorageAvailable)) return yield* fail(); | ||
| 257 | const now = yield* Clock.currentTimeMillis; | ||
| 258 | const attempt = yield* Effect.try({ | ||
| 259 | try: () => createHostedAuthAttempt(server, now), | ||
| 260 | catch: fail, | ||
| 261 | }); | ||
| 262 | const result = yield* post( | ||
| 263 | attempt.server, | ||
| 264 | "/api/auth/devices/start", | ||
| 265 | { | ||
| 266 | state: attempt.state, | ||
| 267 | codeChallenge: attempt.codeChallenge, | ||
| 268 | client: { label: "The Knife Desktop", deviceType: "desktop" }, | ||
| 269 | }, | ||
| 270 | HostedAuthDeviceStartResult, | ||
| 271 | ); | ||
| 272 | const url = new URL(result.authorizationUrl); | ||
| 273 | if ( | ||
| 274 | url.origin !== new URL(attempt.server).origin || | ||
| 275 | url.protocol !== "https:" || | ||
| 276 | url.username || | ||
| 277 | url.password | ||
| 278 | ) | ||
| 279 | return yield* fail(); | ||
| 280 | pending = attempt; | ||
| 281 | state = { serverUrl: attempt.server, phase: "waiting", error: null }; | ||
| 282 | if (!(yield* shell.openExternal(url.href))) { | ||
| 283 | pending = null; | ||
| 284 | state = { | ||
| 285 | ...state, | ||
| 286 | phase: "error", | ||
| 287 | error: "Couldn't open your browser. Sign in again.", | ||
| 288 | }; | ||
| 289 | return yield* fail(); | ||
| 290 | } | ||
| 291 | }).pipe(Effect.mapError(fail)), | ||
| 292 | ), | ||
| 293 | accessToken, | ||
| 294 | cancel: lock.withPermits(1)( | ||
| 295 | Effect.sync(() => { | ||
| 296 | if (pending === null) return; | ||
| 297 | pending = null; | ||
| 298 | state = { serverUrl: null, phase: "idle", error: null }; | ||
| 299 | }), | ||
| 300 | ), | ||
| 301 | disconnect: lock.withPermits(1)( | ||
| 302 | Effect.gen(function* () { | ||
| 303 | const primary = (yield* pool.list).find((item) => item.id === PRIMARY_LOCAL_ENVIRONMENT_ID); | ||
| 304 | const config = primary === undefined ? Option.none() : yield* primary.currentConfig; | ||
| 305 | if (Option.isSome(config)) { | ||
| 306 | const localToken = yield* localAuth.getBearerToken; | ||
| 307 | const response = yield* Effect.scoped( | ||
| 308 | HttpClient.withScope(http).execute( | ||
| 309 | HttpClientRequest.make("DELETE")( | ||
| 310 | new URL("/api/self-hosted/connection", config.value.httpBaseUrl).href, | ||
| 311 | ).pipe(HttpClientRequest.setHeader("authorization", `Bearer ${localToken}`)), | ||
| 312 | ), | ||
| 313 | ).pipe( | ||
| 314 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 315 | Effect.mapError(fail), | ||
| 316 | ); | ||
| 317 | if (response.status < 200 || response.status >= 300) return yield* fail(); | ||
| 318 | } | ||
| 319 | yield* fs.remove(credentialPath, { force: true }).pipe(Effect.mapError(fail)); | ||
| 320 | access = null; | ||
| 321 | source = null; | ||
| 322 | pending = null; | ||
| 323 | state = { serverUrl: null, phase: "idle", error: null }; | ||
| 324 | }).pipe(Effect.mapError(fail)), | ||
| 325 | ), | ||
| 326 | receive, | ||
| 327 | configure: Effect.gen(function* () { | ||
| 328 | const run = Effect.runPromiseWith(yield* Effect.context<never>()); | ||
| 329 | yield* app.on("open-url", (event: { preventDefault: () => void }, url: string) => { | ||
| 330 | if (!isHostedAuthCallback(url)) return; | ||
| 331 | event.preventDefault(); | ||
| 332 | void run(receive(url)); | ||
| 333 | }); | ||
| 334 | yield* app.on("second-instance", (_event: unknown, argv: readonly string[]) => { | ||
| 335 | const url = argv.find(isHostedAuthCallback); | ||
| 336 | if (url) void run(receive(url)); | ||
| 337 | }); | ||
| 338 | yield* Effect.forkScoped( | ||
| 339 | app.whenReady.pipe( | ||
| 340 | Effect.andThen( | ||
| 341 | Effect.forever( | ||
| 342 | lock | ||
| 343 | .withPermits(1)( | ||
| 344 | Effect.gen(function* () { | ||
| 345 | const saved = yield* read; | ||
| 346 | if (saved !== null) { | ||
| 347 | const token = yield* resolveAccessToken(saved.serverUrl); | ||
| 348 | yield* deliver(saved.serverUrl, token); | ||
| 349 | } | ||
| 350 | }), | ||
| 351 | ) | ||
| 352 | .pipe(Effect.ignore, Effect.andThen(Effect.sleep("1 minute"))), | ||
| 353 | ), | ||
| 354 | ), | ||
| 355 | ), | ||
| 356 | ); | ||
| 357 | const initial = args.find(isHostedAuthCallback); | ||
| 358 | if (initial) yield* receive(initial); | ||
| 359 | }), | ||
| 360 | }); | ||
| 361 | }); | ||
| 362 | |||
| 363 | export const layer = Layer.effect(DesktopHostedAuth, make); | ||
apps/desktop/src/app/hostedAuthHandoff.test.ts created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl"; | ||
| 2 | // @effect-diagnostics nodeBuiltinImport:off - Verify the desktop-only PKCE bytes independently. | ||
| 3 | import * as NodeCrypto from "node:crypto"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import { | ||
| 6 | createHostedAuthAttempt, | ||
| 7 | isHostedAuthCallback, | ||
| 8 | readHostedAuthCallback, | ||
| 9 | } from "./hostedAuthHandoff.ts"; | ||
| 10 | |||
| 11 | const server = "https://knife.example.test"; | ||
| 12 | const callback = (state: string, origin = server) => | ||
| 13 | `t3code://app/self-hosted/callback?${new URLSearchParams({ code: "one-time-proof", state, server: origin })}`; | ||
| 14 | |||
| 15 | describe("desktop hosted sign-in handoff", () => { | ||
| 16 | it("creates independent state and S256 proof material for each login", () => { | ||
| 17 | const first = createHostedAuthAttempt(server, 0); | ||
| 18 | const second = createHostedAuthAttempt(server, 0); | ||
| 19 | expect(first.verifier).toHaveLength(43); | ||
| 20 | expect(first.codeChallenge).toBe( | ||
| 21 | NodeCrypto.createHash("sha256").update(first.verifier).digest("base64url"), | ||
| 22 | ); | ||
| 23 | expect(first.state).not.toBe(second.state); | ||
| 24 | expect(first.verifier).not.toBe(second.verifier); | ||
| 25 | expect(readHostedAuthCallback(callback(first.state), first, 1)).toEqual({ | ||
| 26 | code: "one-time-proof", | ||
| 27 | codeVerifier: first.verifier, | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it.each([ | ||
| 32 | "http://knife.example.test", | ||
| 33 | "https://user:password@knife.example.test", | ||
| 34 | "https://knife.example.test?target=other", | ||
| 35 | "https://knife.example.test#token", | ||
| 36 | ])("rejects unsafe server addresses: %s", (address) => { | ||
| 37 | expect(() => hostedServerUrl(address)).toThrow(); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("rejects a mounted path and canonicalizes root HTTPS origins", () => { | ||
| 41 | expect(() => hostedServerUrl("https://knife.example.test/knife/")).toThrow(); | ||
| 42 | expect(hostedServerUrl(`${server}/`)).toBe(server); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it.each([ | ||
| 46 | "https://app/self-hosted/callback", | ||
| 47 | "t3code://other/self-hosted/callback", | ||
| 48 | "t3code://user@app/self-hosted/callback", | ||
| 49 | "t3code://app:123/self-hosted/callback", | ||
| 50 | "t3code://app/self-hosted/callback/extra", | ||
| 51 | ])("does not intercept unrelated links: %s", (url) => { | ||
| 52 | expect(isHostedAuthCallback(url)).toBe(false); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("rejects callbacks from another server, login, or expired attempt", () => { | ||
| 56 | const pending = createHostedAuthAttempt(server, 100); | ||
| 57 | expect(() => | ||
| 58 | readHostedAuthCallback(callback(pending.state, "https://other.example.test"), pending, 101), | ||
| 59 | ).toThrow(); | ||
| 60 | expect(() => readHostedAuthCallback(callback("wrong-state"), pending, 101)).toThrow(); | ||
| 61 | expect(() => | ||
| 62 | readHostedAuthCallback(callback(pending.state), pending, pending.expiresAt), | ||
| 63 | ).toThrow(); | ||
| 64 | expect(() => | ||
| 65 | readHostedAuthCallback(callback(pending.state) + "&code=other", pending, 101), | ||
| 66 | ).toThrow(); | ||
| 67 | expect(() => | ||
| 68 | readHostedAuthCallback(callback(pending.state) + "#proof", pending, 101), | ||
| 69 | ).toThrow(); | ||
| 70 | }); | ||
| 71 | }); | ||
apps/desktop/src/app/hostedAuthHandoff.ts created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Desktop login proofs never cross into the renderer. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | |||
| 4 | import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl"; | ||
| 5 | |||
| 6 | export function createHostedAuthAttempt(server: string, now: number) { | ||
| 7 | const verifier = NodeCrypto.randomBytes(32).toString("base64url"); | ||
| 8 | return { | ||
| 9 | server: hostedServerUrl(server), | ||
| 10 | state: NodeCrypto.randomBytes(32).toString("base64url"), | ||
| 11 | verifier, | ||
| 12 | codeChallenge: NodeCrypto.createHash("sha256").update(verifier).digest("base64url"), | ||
| 13 | expiresAt: now + 10 * 60_000, | ||
| 14 | }; | ||
| 15 | } | ||
| 16 | |||
| 17 | export type HostedAuthAttempt = ReturnType<typeof createHostedAuthAttempt>; | ||
| 18 | |||
| 19 | export function isHostedAuthCallback(value: string): boolean { | ||
| 20 | try { | ||
| 21 | const url = new URL(value); | ||
| 22 | return ( | ||
| 23 | url.protocol === "t3code:" && | ||
| 24 | url.host === "app" && | ||
| 25 | !url.username && | ||
| 26 | !url.password && | ||
| 27 | url.pathname === "/self-hosted/callback" | ||
| 28 | ); | ||
| 29 | } catch { | ||
| 30 | return false; | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | export function readHostedAuthCallback(value: string, pending: HostedAuthAttempt, now: number) { | ||
| 35 | if (!isHostedAuthCallback(value)) throw new Error("This sign-in link is invalid."); | ||
| 36 | const url = new URL(value); | ||
| 37 | const code = url.searchParams.get("code"); | ||
| 38 | const state = url.searchParams.get("state"); | ||
| 39 | const server = url.searchParams.get("server"); | ||
| 40 | if ( | ||
| 41 | url.hash || | ||
| 42 | url.searchParams.size !== 3 || | ||
| 43 | !code || | ||
| 44 | code.length > 512 || | ||
| 45 | !state || | ||
| 46 | !server || | ||
| 47 | now >= pending.expiresAt || | ||
| 48 | hostedServerUrl(server) !== pending.server | ||
| 49 | ) { | ||
| 50 | throw new Error("This sign-in link has expired or belongs to another sign-in. Sign in again."); | ||
| 51 | } | ||
| 52 | const actual = Buffer.from(state); | ||
| 53 | const expected = Buffer.from(pending.state); | ||
| 54 | if (actual.length !== expected.length || !NodeCrypto.timingSafeEqual(actual, expected)) { | ||
| 55 | throw new Error("This sign-in link belongs to another sign-in. Sign in again."); | ||
| 56 | } | ||
| 57 | return { code, codeVerifier: pending.verifier }; | ||
| 58 | } | ||
apps/desktop/src/ipc/DesktopIpcHandlers.ts+12| ... | @@ -1,3 +1,10 @@ | ... | @@ -1,3 +1,10 @@ |
| 1 | import { | ||
| 2 | cancelHostedAuth, | ||
| 3 | beginHostedAuth, | ||
| 4 | getHostedAuthState, | ||
| 5 | getHostedAccessToken, | ||
| 6 | disconnectHostedAuth, | ||
| 7 | } from "./methods/hostedAuth.ts"; | ||
| 1 | import { openVideo } from "./methods/media.ts"; | 8 | import { openVideo } from "./methods/media.ts"; |
| 2 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 3 | 10 | ||
| ... | @@ -104,6 +111,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -104,6 +111,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 104 | 111 | ||
| 105 | yield* ipc.handle(getClientSettings); | 112 | yield* ipc.handle(getClientSettings); |
| 106 | yield* ipc.handle(setClientSettings); | 113 | yield* ipc.handle(setClientSettings); |
| 114 | yield* ipc.handle(cancelHostedAuth); | ||
| 115 | yield* ipc.handle(beginHostedAuth); | ||
| 116 | yield* ipc.handle(getHostedAuthState); | ||
| 117 | yield* ipc.handle(getHostedAccessToken); | ||
| 118 | yield* ipc.handle(disconnectHostedAuth); | ||
| 107 | yield* ipc.handle(getConnectionCatalog); | 119 | yield* ipc.handle(getConnectionCatalog); |
| 108 | yield* ipc.handle(getSnapShotState); | 120 | yield* ipc.handle(getSnapShotState); |
| 109 | yield* ipc.handle(setupSnapShot); | 121 | yield* ipc.handle(setupSnapShot); |
apps/desktop/src/ipc/channels.ts+7| ... | @@ -126,3 +126,10 @@ export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-lo | ... | @@ -126,3 +126,10 @@ export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-lo |
| 126 | export const DESKTOP_TERMINAL_CHANNEL = "desktop:terminal"; | 126 | export const DESKTOP_TERMINAL_CHANNEL = "desktop:terminal"; |
| 127 | 127 | ||
| 128 | export const OPEN_VIDEO_CHANNEL = "desktop:open-video"; | 128 | export const OPEN_VIDEO_CHANNEL = "desktop:open-video"; |
| 129 | |||
| 130 | export const BEGIN_HOSTED_AUTH_CHANNEL = "desktop:hosted-auth:begin"; | ||
| 131 | export const GET_HOSTED_AUTH_STATE_CHANNEL = "desktop:hosted-auth:state"; | ||
| 132 | export const GET_HOSTED_ACCESS_TOKEN_CHANNEL = "desktop:hosted-auth:access-token"; | ||
| 133 | export const DISCONNECT_HOSTED_AUTH_CHANNEL = "desktop:hosted-auth:disconnect"; | ||
| 134 | |||
| 135 | export const CANCEL_HOSTED_AUTH_CHANNEL = "desktop:hosted-auth:cancel"; |
apps/desktop/src/ipc/methods/hostedAuth.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { DesktopHostedAuthState } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import * as DesktopHostedAuth from "../../app/DesktopHostedAuth.ts"; | ||
| 5 | import * as IpcChannels from "../channels.ts"; | ||
| 6 | import * as DesktopIpc from "../DesktopIpc.ts"; | ||
| 7 | |||
| 8 | export const beginHostedAuth = DesktopIpc.makeIpcMethod({ | ||
| 9 | channel: IpcChannels.BEGIN_HOSTED_AUTH_CHANNEL, | ||
| 10 | payload: Schema.String, | ||
| 11 | result: Schema.Void, | ||
| 12 | handler: Effect.fn("desktop.ipc.hostedAuth.begin")(function* (server) { | ||
| 13 | return yield* (yield* DesktopHostedAuth.DesktopHostedAuth).begin(server); | ||
| 14 | }), | ||
| 15 | }); | ||
| 16 | export const getHostedAuthState = DesktopIpc.makeIpcMethod({ | ||
| 17 | channel: IpcChannels.GET_HOSTED_AUTH_STATE_CHANNEL, | ||
| 18 | payload: Schema.Void, | ||
| 19 | result: DesktopHostedAuthState, | ||
| 20 | handler: Effect.fn("desktop.ipc.hostedAuth.state")(function* () { | ||
| 21 | return yield* (yield* DesktopHostedAuth.DesktopHostedAuth).state; | ||
| 22 | }), | ||
| 23 | }); | ||
| 24 | export const getHostedAccessToken = DesktopIpc.makeIpcMethod({ | ||
| 25 | channel: IpcChannels.GET_HOSTED_ACCESS_TOKEN_CHANNEL, | ||
| 26 | payload: Schema.String, | ||
| 27 | result: Schema.String, | ||
| 28 | handler: Effect.fn("desktop.ipc.hostedAuth.accessToken")(function* (server) { | ||
| 29 | return yield* (yield* DesktopHostedAuth.DesktopHostedAuth).accessToken(server); | ||
| 30 | }), | ||
| 31 | }); | ||
| 32 | export const disconnectHostedAuth = DesktopIpc.makeIpcMethod({ | ||
| 33 | channel: IpcChannels.DISCONNECT_HOSTED_AUTH_CHANNEL, | ||
| 34 | payload: Schema.Void, | ||
| 35 | result: Schema.Void, | ||
| 36 | handler: Effect.fn("desktop.ipc.hostedAuth.disconnect")(function* () { | ||
| 37 | return yield* (yield* DesktopHostedAuth.DesktopHostedAuth).disconnect; | ||
| 38 | }), | ||
| 39 | }); | ||
| 40 | |||
| 41 | export const cancelHostedAuth = DesktopIpc.makeIpcMethod({ | ||
| 42 | channel: IpcChannels.CANCEL_HOSTED_AUTH_CHANNEL, | ||
| 43 | payload: Schema.Void, | ||
| 44 | result: Schema.Void, | ||
| 45 | handler: Effect.fn("desktop.ipc.hostedAuth.cancel")(function* () { | ||
| 46 | return yield* (yield* DesktopHostedAuth.DesktopHostedAuth).cancel; | ||
| 47 | }), | ||
| 48 | }); | ||
apps/desktop/src/main.ts+2| ... | @@ -39,6 +39,7 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; | ... | @@ -39,6 +39,7 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; |
| 39 | import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; | 39 | import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; |
| 40 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; | 40 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; |
| 41 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; | 41 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; |
| 42 | import * as DesktopHostedAuth from "./app/DesktopHostedAuth.ts"; | ||
| 42 | import * as DesktopClerk from "./app/DesktopClerk.ts"; | 43 | import * as DesktopClerk from "./app/DesktopClerk.ts"; |
| 43 | import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; | 44 | import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; |
| 44 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; | 45 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; |
| ... | @@ -222,6 +223,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( | ... | @@ -222,6 +223,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( |
| 222 | ); | 223 | ); |
| 223 | 224 | ||
| 224 | const layerDesktopApplication = Layer.mergeAll( | 225 | const layerDesktopApplication = Layer.mergeAll( |
| 226 | DesktopHostedAuth.layer, | ||
| 225 | DesktopLifecycle.layer, | 227 | DesktopLifecycle.layer, |
| 226 | DesktopVideo.layer, | 228 | DesktopVideo.layer, |
| 227 | layerDesktopAppActivation, | 229 | layerDesktopAppActivation, |
apps/desktop/src/preload.ts+6| ... | @@ -161,6 +161,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -161,6 +161,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 161 | dismissSnapShotAnimation: (id) => | 161 | dismissSnapShotAnimation: (id) => |
| 162 | ipcRenderer.invoke(IpcChannels.DISMISS_SNAP_SHOT_ANIMATION_CHANNEL, id), | 162 | ipcRenderer.invoke(IpcChannels.DISMISS_SNAP_SHOT_ANIMATION_CHANNEL, id), |
| 163 | acknowledgeSnapShot: (id) => ipcRenderer.invoke(IpcChannels.ACKNOWLEDGE_SNAP_SHOT_CHANNEL, id), | 163 | acknowledgeSnapShot: (id) => ipcRenderer.invoke(IpcChannels.ACKNOWLEDGE_SNAP_SHOT_CHANNEL, id), |
| 164 | beginHostedAuth: (server) => ipcRenderer.invoke(IpcChannels.BEGIN_HOSTED_AUTH_CHANNEL, server), | ||
| 165 | getHostedAuthState: () => ipcRenderer.invoke(IpcChannels.GET_HOSTED_AUTH_STATE_CHANNEL), | ||
| 166 | getHostedAccessToken: (server) => | ||
| 167 | ipcRenderer.invoke(IpcChannels.GET_HOSTED_ACCESS_TOKEN_CHANNEL, server), | ||
| 168 | cancelHostedAuth: () => ipcRenderer.invoke(IpcChannels.CANCEL_HOSTED_AUTH_CHANNEL), | ||
| 169 | disconnectHostedAuth: () => ipcRenderer.invoke(IpcChannels.DISCONNECT_HOSTED_AUTH_CHANNEL), | ||
| 164 | getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), | 170 | getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), |
| 165 | setConnectionCatalog: (catalog) => | 171 | setConnectionCatalog: (catalog) => |
| 166 | ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), | 172 | ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), |
apps/mobile/src/App.tsx+20-5| ... | @@ -2,12 +2,12 @@ import { UiSettingsSyncProvider } from "./state/uiSettingsSync"; | ... | @@ -2,12 +2,12 @@ import { UiSettingsSyncProvider } from "./state/uiSettingsSync"; |
| 2 | import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice"; | 2 | import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice"; |
| 3 | import * as Linking from "expo-linking"; | 3 | import * as Linking from "expo-linking"; |
| 4 | import * as SplashScreen from "expo-splash-screen"; | 4 | import * as SplashScreen from "expo-splash-screen"; |
| 5 | import { useEffect } from "react"; | 5 | import { useCallback, useEffect, useRef, type RefObject } from "react"; |
| 6 | import { StatusBar } from "react-native"; | 6 | import { StatusBar } from "react-native"; |
| 7 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | 7 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 8 | import { KeyboardProvider } from "react-native-keyboard-controller"; | 8 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| 9 | import { SafeAreaProvider } from "react-native-safe-area-context"; | 9 | import { SafeAreaProvider } from "react-native-safe-area-context"; |
| 10 | import { createStaticNavigation } from "@react-navigation/native"; | 10 | import { createStaticNavigation, type NavigationState } from "@react-navigation/native"; |
| 11 | 11 | ||
| 12 | import { RegistryContext } from "@effect/atom-react"; | 12 | import { RegistryContext } from "@effect/atom-react"; |
| 13 | import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; | 13 | import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; |
| ... | @@ -61,13 +61,20 @@ function SplashScreenCoordinator() { | ... | @@ -61,13 +61,20 @@ function SplashScreenCoordinator() { |
| 61 | 61 | ||
| 62 | export default function App() { | 62 | export default function App() { |
| 63 | useUiRuntimeMemoryWarningGc(); | 63 | useUiRuntimeMemoryWarningGc(); |
| 64 | const navigationState = useRef<NavigationState | undefined>(undefined); | ||
| 65 | const saveNavigationState = useCallback((state: NavigationState | undefined) => { | ||
| 66 | navigationState.current = state; | ||
| 67 | }, []); | ||
| 64 | 68 | ||
| 65 | return ( | 69 | return ( |
| 66 | <RegistryContext.Provider value={appAtomRegistry}> | 70 | <RegistryContext.Provider value={appAtomRegistry}> |
| 67 | <CloudAuthProvider> | 71 | <CloudAuthProvider> |
| 68 | <AppearancePreferencesProvider> | 72 | <AppearancePreferencesProvider> |
| 69 | <UiSettingsSyncProvider> | 73 | <UiSettingsSyncProvider> |
| 70 | <AppContent /> | 74 | <AppContent |
| 75 | navigationState={navigationState} | ||
| 76 | saveNavigationState={saveNavigationState} | ||
| 77 | /> | ||
| 71 | </UiSettingsSyncProvider> | 78 | </UiSettingsSyncProvider> |
| 72 | </AppearancePreferencesProvider> | 79 | </AppearancePreferencesProvider> |
| 73 | </CloudAuthProvider> | 80 | </CloudAuthProvider> |
| ... | @@ -75,7 +82,10 @@ export default function App() { | ... | @@ -75,7 +82,10 @@ export default function App() { |
| 75 | ); | 82 | ); |
| 76 | } | 83 | } |
| 77 | 84 | ||
| 78 | function AppContent() { | 85 | function AppContent(props: { |
| 86 | readonly navigationState: RefObject<NavigationState | undefined>; | ||
| 87 | readonly saveNavigationState: (state: NavigationState | undefined) => void; | ||
| 88 | }) { | ||
| 79 | const { themeAppearance } = useAppearancePreferences(); | 89 | const { themeAppearance } = useAppearancePreferences(); |
| 80 | const navigationTheme = useMobileNavigationTheme(); | 90 | const navigationTheme = useMobileNavigationTheme(); |
| 81 | 91 | ||
| ... | @@ -97,7 +107,12 @@ function AppContent() { | ... | @@ -97,7 +107,12 @@ function AppContent() { |
| 97 | <GlobalVoiceInputControl> | 107 | <GlobalVoiceInputControl> |
| 98 | <NativeLayoutMetricsProvider> | 108 | <NativeLayoutMetricsProvider> |
| 99 | <IncomingShareProvider> | 109 | <IncomingShareProvider> |
| 100 | <Navigation linking={appLinking} theme={navigationTheme} /> | 110 | <Navigation |
| 111 | linking={appLinking} | ||
| 112 | theme={navigationTheme} | ||
| 113 | initialState={props.navigationState.current} | ||
| 114 | onStateChange={props.saveNavigationState} | ||
| 115 | /> | ||
| 101 | </IncomingShareProvider> | 116 | </IncomingShareProvider> |
| 102 | <ConfirmDialogHost /> | 117 | <ConfirmDialogHost /> |
| 103 | <ThreadArrangementHost /> | 118 | <ThreadArrangementHost /> |
apps/mobile/src/connection/hub.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createHubConnectionAtoms } from "@t3tools/client-runtime/state/hub"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "./runtime"; | ||
| 4 | |||
| 5 | export const hubConnection = createHubConnectionAtoms(connectionAtomRuntime); | ||
apps/mobile/src/connection/platform.ts+4-2| ... | @@ -254,11 +254,13 @@ const layerProvidedCapabilities = layerCapabilities.pipe(Layer.provide(Runtime.l | ... | @@ -254,11 +254,13 @@ const layerProvidedCapabilities = layerCapabilities.pipe(Layer.provide(Runtime.l |
| 254 | const layerEnvironmentOwnedDataCleanup = Layer.succeed( | 254 | const layerEnvironmentOwnedDataCleanup = Layer.succeed( |
| 255 | Persistence.EnvironmentOwnedDataCleanup, | 255 | Persistence.EnvironmentOwnedDataCleanup, |
| 256 | Persistence.EnvironmentOwnedDataCleanup.of({ | 256 | Persistence.EnvironmentOwnedDataCleanup.of({ |
| 257 | clear: (environmentId) => | 257 | clear: (environmentId, options) => |
| 258 | Effect.all( | 258 | Effect.all( |
| 259 | [ | 259 | [ |
| 260 | Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), | 260 | Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), |
| 261 | Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), | 261 | ...(options?.retainDrafts |
| 262 | ? [] | ||
| 263 | : [Effect.promise(() => clearComposerDraftsEnvironment(environmentId))]), | ||
| 262 | Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), | 264 | Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), |
| 263 | ], | 265 | ], |
| 264 | { concurrency: "unbounded", discard: true }, | 266 | { concurrency: "unbounded", discard: true }, |
apps/mobile/src/connection/storage.test.ts+68| ... | @@ -1,3 +1,14 @@ | ... | @@ -1,3 +1,14 @@ |
| 1 | import { | ||
| 2 | BearerConnectionCredential, | ||
| 3 | BearerConnectionProfile, | ||
| 4 | BearerConnectionRegistration, | ||
| 5 | BearerConnectionTarget, | ||
| 6 | CredentialStore, | ||
| 7 | HubConnectionCredential, | ||
| 8 | } from "@t3tools/client-runtime/connection"; | ||
| 9 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 10 | import { Persistence } from "@t3tools/client-runtime/platform"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 12 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 3 | import { vi } from "vite-plus/test"; | 14 | import { vi } from "vite-plus/test"; |
| ... | @@ -12,6 +23,7 @@ vi.mock("expo-secure-store", () => ({ | ... | @@ -12,6 +23,7 @@ vi.mock("expo-secure-store", () => ({ |
| 12 | setItemAsync: vi.fn(), | 23 | setItemAsync: vi.fn(), |
| 13 | })); | 24 | })); |
| 14 | 25 | ||
| 26 | import * as ConnectionStorage from "./storage"; | ||
| 15 | import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store"; | 27 | import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store"; |
| 16 | import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; | 28 | import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; |
| 17 | 29 | ||
| ... | @@ -34,6 +46,62 @@ function makeStorage(initial: Readonly<Record<string, string>>) { | ... | @@ -34,6 +46,62 @@ function makeStorage(initial: Readonly<Record<string, string>>) { |
| 34 | } | 46 | } |
| 35 | 47 | ||
| 36 | describe("mobile connection catalog storage", () => { | 48 | describe("mobile connection catalog storage", () => { |
| 49 | it.effect("restores hub route pointers and the single bearer after reopening storage", () => | ||
| 50 | Effect.gen(function* () { | ||
| 51 | const memory = makeStorage({}); | ||
| 52 | const connectionId = "hub:hub:source"; | ||
| 53 | const hubCredentialId = "hub-session:hub"; | ||
| 54 | const credential = new HubConnectionCredential({ connectionId: hubCredentialId }); | ||
| 55 | const bearer = new BearerConnectionCredential({ token: "single-hub-secret" }); | ||
| 56 | const registration = new BearerConnectionRegistration({ | ||
| 57 | target: new BearerConnectionTarget({ | ||
| 58 | connectionId, | ||
| 59 | environmentId: EnvironmentId.make("source"), | ||
| 60 | label: "Source", | ||
| 61 | }), | ||
| 62 | profile: new BearerConnectionProfile({ | ||
| 63 | connectionId, | ||
| 64 | environmentId: EnvironmentId.make("source"), | ||
| 65 | label: "Source", | ||
| 66 | httpBaseUrl: "https://hub.example/environments/source/", | ||
| 67 | wsBaseUrl: "wss://hub.example/environments/source/", | ||
| 68 | }), | ||
| 69 | credential, | ||
| 70 | }); | ||
| 71 | yield* Effect.gen(function* () { | ||
| 72 | const credentials = yield* CredentialStore.ConnectionCredentialStore; | ||
| 73 | const registrations = yield* Persistence.ConnectionRegistrationStore; | ||
| 74 | yield* credentials.put(hubCredentialId, bearer); | ||
| 75 | yield* registrations.register(registration, [registration.target]); | ||
| 76 | expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential)); | ||
| 77 | }).pipe( | ||
| 78 | Effect.provide(ConnectionStorage.layer), | ||
| 79 | Effect.provideService(MobileSecureStorage.MobileSecureStorage, memory.storage), | ||
| 80 | Effect.scoped, | ||
| 81 | ); | ||
| 82 | |||
| 83 | yield* Effect.gen(function* () { | ||
| 84 | const credentials = yield* CredentialStore.ConnectionCredentialStore; | ||
| 85 | expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential)); | ||
| 86 | expect(yield* credentials.get(hubCredentialId)).toEqual(Option.some(bearer)); | ||
| 87 | }).pipe( | ||
| 88 | Effect.provide(ConnectionStorage.layer), | ||
| 89 | Effect.provideService(MobileSecureStorage.MobileSecureStorage, memory.storage), | ||
| 90 | Effect.scoped, | ||
| 91 | ); | ||
| 92 | expect(JSON.parse(memory.values.get(CONNECTION_CATALOG_KEY)!).credentials).toEqual([ | ||
| 93 | { | ||
| 94 | connectionId: hubCredentialId, | ||
| 95 | credential: { _tag: "BearerConnectionCredential", token: bearer.token }, | ||
| 96 | }, | ||
| 97 | { | ||
| 98 | connectionId, | ||
| 99 | credential: { _tag: "HubConnectionCredential", connectionId: hubCredentialId }, | ||
| 100 | }, | ||
| 101 | ]); | ||
| 102 | }), | ||
| 103 | ); | ||
| 104 | |||
| 37 | it.effect("recovers from a corrupt current catalog", () => | 105 | it.effect("recovers from a corrupt current catalog", () => |
| 38 | Effect.gen(function* () { | 106 | Effect.gen(function* () { |
| 39 | const memory = makeStorage({ | 107 | const memory = makeStorage({ |
apps/mobile/src/features/cloud/CloudAuthProvider.test.ts+1| ... | @@ -27,6 +27,7 @@ vi.mock("../../connection/catalog", () => ({ | ... | @@ -27,6 +27,7 @@ vi.mock("../../connection/catalog", () => ({ |
| 27 | })); | 27 | })); |
| 28 | 28 | ||
| 29 | vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} })); | 29 | vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} })); |
| 30 | vi.mock("../../state/preferences", () => ({ mobilePreferencesAtom: {} })); | ||
| 30 | vi.mock("../../state/use-composer-drafts", () => ({ | 31 | vi.mock("../../state/use-composer-drafts", () => ({ |
| 31 | getComposerCloudAccountId: vi.fn(async () => null), | 32 | getComposerCloudAccountId: vi.fn(async () => null), |
| 32 | restoreCloudComposerDrafts: vi.fn(async () => undefined), | 33 | restoreCloudComposerDrafts: vi.fn(async () => undefined), |
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+24-4| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { ClerkProvider, useAuth } from "@clerk/expo"; | 1 | import { ClerkProvider, useAuth } from "@clerk/expo"; |
| 2 | import { tokenCache } from "@clerk/expo/token-cache"; | 2 | import { tokenCache } from "@clerk/expo/token-cache"; |
| 3 | import { useAtomValue } from "@effect/atom-react"; | ||
| 3 | import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; | 4 | import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; |
| 4 | import { | 5 | import { |
| 5 | reportAtomCommandResult, | 6 | reportAtomCommandResult, |
| ... | @@ -9,6 +10,7 @@ import { | ... | @@ -9,6 +10,7 @@ import { |
| 9 | } from "@t3tools/client-runtime/state/runtime"; | 10 | } from "@t3tools/client-runtime/state/runtime"; |
| 10 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 11 | import { type ReactNode, useEffect, useRef } from "react"; | 12 | import { type ReactNode, useEffect, useRef } from "react"; |
| 13 | import { AsyncResult } from "effect/reactivity"; | ||
| 12 | 14 | ||
| 13 | import { runtime } from "../../lib/runtime"; | 15 | import { runtime } from "../../lib/runtime"; |
| 14 | import { appAtomRegistry } from "../../state/atom-registry"; | 16 | import { appAtomRegistry } from "../../state/atom-registry"; |
| ... | @@ -25,6 +27,10 @@ import { | ... | @@ -25,6 +27,10 @@ import { |
| 25 | import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; | 27 | import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; |
| 26 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; | 28 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 27 | import { removeCloudEnvironments } from "./cloud-drafts"; | 29 | import { removeCloudEnvironments } from "./cloud-drafts"; |
| 30 | import { environmentCatalog } from "../../connection/catalog"; | ||
| 31 | import { mobilePreferencesAtom } from "../../state/preferences"; | ||
| 32 | import { CloudAuthAvailableContext } from "./cloudAvailability"; | ||
| 33 | import { shouldEnableCloudAuth } from "./connectionService"; | ||
| 28 | 34 | ||
| 29 | function resetManagedRelayTokenCache() { | 35 | function resetManagedRelayTokenCache() { |
| 30 | return settleAsyncResult(() => | 36 | return settleAsyncResult(() => |
| ... | @@ -196,23 +202,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | ... | @@ -196,23 +202,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 196 | } | 202 | } |
| 197 | 203 | ||
| 198 | export function CloudAuthProvider(props: { readonly children: ReactNode }) { | 204 | export function CloudAuthProvider(props: { readonly children: ReactNode }) { |
| 205 | const preferences = useAtomValue(mobilePreferencesAtom); | ||
| 206 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); | ||
| 199 | const config = resolveCloudPublicConfig(); | 207 | const config = resolveCloudPublicConfig(); |
| 200 | const publishableKey = config.clerk.publishableKey; | 208 | const publishableKey = config.clerk.publishableKey; |
| 201 | const relayUrl = config.relay.url; | 209 | const relayUrl = config.relay.url; |
| 210 | const enabled = | ||
| 211 | Boolean(publishableKey && relayUrl) && | ||
| 212 | shouldEnableCloudAuth( | ||
| 213 | AsyncResult.isSuccess(preferences) ? preferences.value.connectionService : undefined, | ||
| 214 | catalog.entries.values(), | ||
| 215 | ); | ||
| 202 | 216 | ||
| 203 | useEffect(() => { | 217 | useEffect(() => { |
| 204 | if (!publishableKey || !relayUrl) { | 218 | if (!enabled) { |
| 205 | deactivateCloudRelayAccount(); | 219 | deactivateCloudRelayAccount(); |
| 206 | } | 220 | } |
| 207 | }, [publishableKey, relayUrl]); | 221 | }, [enabled]); |
| 208 | 222 | ||
| 209 | if (!publishableKey || !relayUrl) { | 223 | if (!catalog.isReady || AsyncResult.isInitial(preferences)) { |
| 224 | return null; | ||
| 225 | } | ||
| 226 | |||
| 227 | if (!enabled || !publishableKey) { | ||
| 210 | return props.children; | 228 | return props.children; |
| 211 | } | 229 | } |
| 212 | 230 | ||
| 213 | return ( | 231 | return ( |
| 214 | <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}> | 232 | <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}> |
| 215 | <CloudAuthBridge>{props.children}</CloudAuthBridge> | 233 | <CloudAuthAvailableContext.Provider value> |
| 234 | <CloudAuthBridge>{props.children}</CloudAuthBridge> | ||
| 235 | </CloudAuthAvailableContext.Provider> | ||
| 216 | </ClerkProvider> | 236 | </ClerkProvider> |
| 217 | ); | 237 | ); |
| 218 | } | 238 | } |
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+5-4| ... | @@ -14,7 +14,7 @@ import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; | ... | @@ -14,7 +14,7 @@ import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; |
| 14 | import { splitEnvironmentSections } from "../connection/environmentSections"; | 14 | import { splitEnvironmentSections } from "../connection/environmentSections"; |
| 15 | import { useConnectionController } from "../connection/useConnectionController"; | 15 | import { useConnectionController } from "../connection/useConnectionController"; |
| 16 | import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; | 16 | import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; |
| 17 | import { hasCloudPublicConfig } from "./publicConfig"; | 17 | import { useCloudAuthAvailable } from "./cloudAvailability"; |
| 18 | 18 | ||
| 19 | /** | 19 | /** |
| 20 | * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes | 20 | * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes |
| ... | @@ -25,11 +25,12 @@ import { hasCloudPublicConfig } from "./publicConfig"; | ... | @@ -25,11 +25,12 @@ import { hasCloudPublicConfig } from "./publicConfig"; |
| 25 | */ | 25 | */ |
| 26 | export function ConnectOnboardingRouteScreen() { | 26 | export function ConnectOnboardingRouteScreen() { |
| 27 | const navigation = useNavigation(); | 27 | const navigation = useNavigation(); |
| 28 | const cloudAvailable = useCloudAuthAvailable(); | ||
| 28 | 29 | ||
| 29 | // The route is deep-linkable; without cloud config the sheet would present | 30 | // The route is deep-linkable; without cloud config the sheet would present |
| 30 | // empty with no chrome to dismiss it, so bail back out instead. | 31 | // empty with no chrome to dismiss it, so bail back out instead. |
| 31 | useEffect(() => { | 32 | useEffect(() => { |
| 32 | if (hasCloudPublicConfig()) { | 33 | if (cloudAvailable) { |
| 33 | return; | 34 | return; |
| 34 | } | 35 | } |
| 35 | if (navigation.canGoBack()) { | 36 | if (navigation.canGoBack()) { |
| ... | @@ -37,9 +38,9 @@ export function ConnectOnboardingRouteScreen() { | ... | @@ -37,9 +38,9 @@ export function ConnectOnboardingRouteScreen() { |
| 37 | } else { | 38 | } else { |
| 38 | navigation.dispatch(StackActions.replace("Home")); | 39 | navigation.dispatch(StackActions.replace("Home")); |
| 39 | } | 40 | } |
| 40 | }, [navigation]); | 41 | }, [cloudAvailable, navigation]); |
| 41 | 42 | ||
| 42 | return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null; | 43 | return cloudAvailable ? <ConfiguredConnectOnboardingRouteScreen /> : null; |
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | function ConfiguredConnectOnboardingRouteScreen() { | 46 | function ConfiguredConnectOnboardingRouteScreen() { |
apps/mobile/src/features/cloud/cloudAvailability.ts created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import { createContext, useContext } from "react"; | ||
| 2 | |||
| 3 | export const CloudAuthAvailableContext = createContext(false); | ||
| 4 | |||
| 5 | export function useCloudAuthAvailable(): boolean { | ||
| 6 | return useContext(CloudAuthAvailableContext); | ||
| 7 | } | ||
apps/mobile/src/features/cloud/connectionService.test.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import { | ||
| 2 | BearerConnectionTarget, | ||
| 3 | RelayConnectionTarget, | ||
| 4 | type ConnectionCatalogEntry, | ||
| 5 | } from "@t3tools/client-runtime/connection"; | ||
| 6 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { shouldEnableCloudAuth } from "./connectionService"; | ||
| 11 | |||
| 12 | const direct: ConnectionCatalogEntry = { | ||
| 13 | target: new BearerConnectionTarget({ | ||
| 14 | environmentId: EnvironmentId.make("machine"), | ||
| 15 | connectionId: "direct", | ||
| 16 | label: "Laptop", | ||
| 17 | }), | ||
| 18 | profile: Option.none(), | ||
| 19 | enabled: true, | ||
| 20 | }; | ||
| 21 | |||
| 22 | describe("optional mobile T3 Connect", () => { | ||
| 23 | it("keeps fresh, direct, and self-hosted sessions independent of cloud sign-in", () => { | ||
| 24 | expect(shouldEnableCloudAuth(undefined, [])).toBe(false); | ||
| 25 | expect(shouldEnableCloudAuth(undefined, [direct])).toBe(false); | ||
| 26 | expect(shouldEnableCloudAuth("self-hosted", [direct])).toBe(false); | ||
| 27 | expect(shouldEnableCloudAuth("t3-connect", [])).toBe(true); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("restores existing cloud routes even when a direct route is preferred", () => { | ||
| 31 | const entry: ConnectionCatalogEntry = { | ||
| 32 | ...direct, | ||
| 33 | alternateRoutes: [ | ||
| 34 | { | ||
| 35 | target: new RelayConnectionTarget({ | ||
| 36 | environmentId: direct.target.environmentId, | ||
| 37 | label: "Laptop", | ||
| 38 | }), | ||
| 39 | profile: Option.none(), | ||
| 40 | }, | ||
| 41 | ], | ||
| 42 | }; | ||
| 43 | expect(shouldEnableCloudAuth(undefined, [entry])).toBe(true); | ||
| 44 | expect(shouldEnableCloudAuth("self-hosted", [entry])).toBe(true); | ||
| 45 | }); | ||
| 46 | }); | ||
apps/mobile/src/features/cloud/connectionService.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import { connectionRoutes, type ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; | ||
| 2 | |||
| 3 | export function shouldEnableCloudAuth( | ||
| 4 | service: "t3-connect" | "self-hosted" | undefined, | ||
| 5 | entries: Iterable<ConnectionCatalogEntry>, | ||
| 6 | ): boolean { | ||
| 7 | return ( | ||
| 8 | service === "t3-connect" || | ||
| 9 | [...entries].some((entry) => | ||
| 10 | connectionRoutes(entry).some((route) => route.target._tag === "RelayConnectionTarget"), | ||
| 11 | ) | ||
| 12 | ); | ||
| 13 | } | ||
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+3-3| ... | @@ -21,7 +21,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ... | @@ -21,7 +21,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 21 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | 21 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 22 | import { serverEnvironment } from "../../state/server"; | 22 | import { serverEnvironment } from "../../state/server"; |
| 23 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; | 23 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; |
| 24 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | 24 | import { useCloudAuthAvailable } from "../cloud/cloudAvailability"; |
| 25 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | 25 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| 26 | import { environmentStatusText } from "./environmentStatusText"; | 26 | import { environmentStatusText } from "./environmentStatusText"; |
| 27 | import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; | 27 | import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; |
| ... | @@ -52,14 +52,14 @@ interface CloudEnvironmentRowsProps { | ... | @@ -52,14 +52,14 @@ interface CloudEnvironmentRowsProps { |
| 52 | * its errors) requires a signed-in session. | 52 | * its errors) requires a signed-in session. |
| 53 | */ | 53 | */ |
| 54 | export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { | 54 | export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { |
| 55 | const cloudAvailable = useCloudAuthAvailable(); | ||
| 55 | // Showcase captures run without a Clerk publishable key, so `ClerkProvider` | 56 | // Showcase captures run without a Clerk publishable key, so `ClerkProvider` |
| 56 | // is never mounted and any `useAuth` call throws — the fixture states whether | 57 | // is never mounted and any `useAuth` call throws — the fixture states whether |
| 57 | // the rows are signed in instead of asking Clerk. | 58 | // the rows are signed in instead of asking Clerk. |
| 58 | if (props.showcaseSignedIn !== undefined) { | 59 | if (props.showcaseSignedIn !== undefined) { |
| 59 | return props.showcaseSignedIn ? <CloudEnvironmentRowsContent {...props} /> : null; | 60 | return props.showcaseSignedIn ? <CloudEnvironmentRowsContent {...props} /> : null; |
| 60 | } | 61 | } |
| 61 | // No cloud config means no `ClerkProvider` either, so `useAuth` would throw. | 62 | if (!cloudAvailable) { |
| 62 | if (!hasCloudPublicConfig()) { | ||
| 63 | return <ConnectedOnlyCloudEnvironmentRows {...props} />; | 63 | return <ConnectedOnlyCloudEnvironmentRows {...props} />; |
| 64 | } | 64 | } |
| 65 | return <SignedInCloudEnvironmentRows {...props} />; | 65 | return <SignedInCloudEnvironmentRows {...props} />; |
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+155-19| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { useAuth } from "@clerk/expo"; | 2 | import { useAuth } from "@clerk/expo"; |
| 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 3 | import { CameraView, useCameraPermissions } from "expo-camera"; | 4 | import { CameraView, useCameraPermissions } from "expo-camera"; |
| 4 | import * as Haptics from "expo-haptics"; | 5 | import * as Haptics from "expo-haptics"; |
| 5 | import { | 6 | import { |
| ... | @@ -30,6 +31,11 @@ import { SymbolView } from "../../components/AppSymbol"; | ... | @@ -30,6 +31,11 @@ import { SymbolView } from "../../components/AppSymbol"; |
| 30 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | 31 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 31 | import { CloudEnvironmentRows } from "./CloudEnvironmentRows"; | 32 | import { CloudEnvironmentRows } from "./CloudEnvironmentRows"; |
| 32 | import { splitEnvironmentSections } from "./environmentSections"; | 33 | import { splitEnvironmentSections } from "./environmentSections"; |
| 34 | import { MaterialButton } from "../../components/MaterialButton"; | ||
| 35 | import { useCloudAuthAvailable } from "../cloud/cloudAvailability"; | ||
| 36 | import { hubConnection } from "../../connection/hub"; | ||
| 37 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 38 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| 33 | 39 | ||
| 34 | type ConnectionsNewRouteParams = { | 40 | type ConnectionsNewRouteParams = { |
| 35 | readonly mode?: string; | 41 | readonly mode?: string; |
| ... | @@ -51,9 +57,23 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -51,9 +57,23 @@ export function ConnectionsNewRouteScreen({ |
| 51 | const navigation = useNavigation(); | 57 | const navigation = useNavigation(); |
| 52 | const routeName = useRoute().name; | 58 | const routeName = useRoute().name; |
| 53 | const params = route.params ?? {}; | 59 | const params = route.params ?? {}; |
| 60 | const t3ConnectAvailable = hasCloudPublicConfig(); | ||
| 61 | const cloudAvailable = useCloudAuthAvailable(); | ||
| 62 | const connectHub = useAtomCommand(hubConnection.connect, { reportFailure: false }); | ||
| 63 | const hub = useAtomValue(hubConnection.stateValueAtom); | ||
| 64 | const preferences = useAtomValue(mobilePreferencesAtom); | ||
| 65 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| 54 | // Deep-link prefill exists for development automation only. A production | 66 | // Deep-link prefill exists for development automation only. A production |
| 55 | // link must not arrive with attacker-chosen host and token already filled. | 67 | // link must not arrive with attacker-chosen host and token already filled. |
| 56 | const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : ""; | 68 | const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : ""; |
| 69 | const [step, setStep] = useState<"choose" | "self-hosted" | "direct" | "cloud">( | ||
| 70 | params.routeFor || routePairingUrl ? "direct" : "choose", | ||
| 71 | ); | ||
| 72 | const [method, setMethod] = useState<"t3-connect" | "self-hosted">( | ||
| 73 | AsyncResult.isSuccess(preferences) && t3ConnectAvailable | ||
| 74 | ? (preferences.value.connectionService ?? "self-hosted") | ||
| 75 | : "self-hosted", | ||
| 76 | ); | ||
| 57 | const shouldAutoConnect = | 77 | const shouldAutoConnect = |
| 58 | __DEV__ && | 78 | __DEV__ && |
| 59 | routePairingUrl.length > 0 && | 79 | routePairingUrl.length > 0 && |
| ... | @@ -161,13 +181,20 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -161,13 +181,20 @@ export function ConnectionsNewRouteScreen({ |
| 161 | setIsSubmitting(true); | 181 | setIsSubmitting(true); |
| 162 | onChangeConnectionPairingUrl(pairingUrl); | 182 | onChangeConnectionPairingUrl(pairingUrl); |
| 163 | try { | 183 | try { |
| 164 | const result = await onConnectPress(pairingUrl, params.routeFor); | 184 | const result = |
| 185 | step === "self-hosted" | ||
| 186 | ? await connectHub({ pairingUrl }) | ||
| 187 | : await onConnectPress(pairingUrl, params.routeFor); | ||
| 165 | void Haptics.notificationAsync( | 188 | void Haptics.notificationAsync( |
| 166 | AsyncResult.isSuccess(result) | 189 | result._tag === "Success" |
| 167 | ? Haptics.NotificationFeedbackType.Success | 190 | ? Haptics.NotificationFeedbackType.Success |
| 168 | : Haptics.NotificationFeedbackType.Error, | 191 | : Haptics.NotificationFeedbackType.Error, |
| 169 | ); | 192 | ); |
| 170 | if (AsyncResult.isSuccess(result)) { | 193 | if (result._tag === "Success") { |
| 194 | if (step === "self-hosted") { | ||
| 195 | savePreferences({ connectionService: "self-hosted" }); | ||
| 196 | onChangeConnectionPairingUrl(""); | ||
| 197 | } | ||
| 171 | if (replaceWithHome || !navigation.canGoBack()) { | 198 | if (replaceWithHome || !navigation.canGoBack()) { |
| 172 | navigation.dispatch(StackActions.replace("Home")); | 199 | navigation.dispatch(StackActions.replace("Home")); |
| 173 | } else { | 200 | } else { |
| ... | @@ -178,7 +205,15 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -178,7 +205,15 @@ export function ConnectionsNewRouteScreen({ |
| 178 | setIsSubmitting(false); | 205 | setIsSubmitting(false); |
| 179 | } | 206 | } |
| 180 | }, | 207 | }, |
| 181 | [navigation, onChangeConnectionPairingUrl, onConnectPress, params.routeFor], | 208 | [ |
| 209 | connectHub, | ||
| 210 | navigation, | ||
| 211 | onChangeConnectionPairingUrl, | ||
| 212 | onConnectPress, | ||
| 213 | params.routeFor, | ||
| 214 | savePreferences, | ||
| 215 | step, | ||
| 216 | ], | ||
| 182 | ); | 217 | ); |
| 183 | 218 | ||
| 184 | const handleSubmit = useCallback(async () => { | 219 | const handleSubmit = useCallback(async () => { |
| ... | @@ -194,13 +229,106 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -194,13 +229,106 @@ export function ConnectionsNewRouteScreen({ |
| 194 | void connectAndClose(routePairingUrl, true); | 229 | void connectAndClose(routePairingUrl, true); |
| 195 | }, [connectAndClose, routePairingUrl, shouldAutoConnect]); | 230 | }, [connectAndClose, routePairingUrl, shouldAutoConnect]); |
| 196 | 231 | ||
| 232 | if (step === "choose") { | ||
| 233 | return ( | ||
| 234 | <SettingsScreen formSheet={routeName === "ConnectionsNew"} title="Connect"> | ||
| 235 | <ScrollView | ||
| 236 | contentInsetAdjustmentBehavior="automatic" | ||
| 237 | contentContainerClassName="gap-6 px-5 pt-4" | ||
| 238 | > | ||
| 239 | <Text className="text-base text-foreground-muted"> | ||
| 240 | Choose how to reach your computers. | ||
| 241 | </Text> | ||
| 242 | <View | ||
| 243 | accessibilityRole="radiogroup" | ||
| 244 | accessibilityLabel="Connection method" | ||
| 245 | className="overflow-hidden rounded-[26px] bg-grouped-card" | ||
| 246 | > | ||
| 247 | {( | ||
| 248 | [ | ||
| 249 | ["t3-connect", "T3 Connect", "Sign in with your T3 account."], | ||
| 250 | ["self-hosted", "Self-hosted server", "Connect through your own server."], | ||
| 251 | ] as const | ||
| 252 | ).map(([value, label, description]) => ( | ||
| 253 | <Pressable | ||
| 254 | key={value} | ||
| 255 | accessibilityRole="radio" | ||
| 256 | accessibilityLabel={label} | ||
| 257 | accessibilityState={{ | ||
| 258 | checked: method === value, | ||
| 259 | disabled: value === "t3-connect" && !t3ConnectAvailable, | ||
| 260 | }} | ||
| 261 | disabled={value === "t3-connect" && !t3ConnectAvailable} | ||
| 262 | onPress={() => setMethod(value)} | ||
| 263 | className="min-h-20 flex-row items-center gap-4 px-4 py-3 active:opacity-70 disabled:opacity-45" | ||
| 264 | > | ||
| 265 | <SymbolView | ||
| 266 | name={method === value ? "circle.inset.filled" : "circle"} | ||
| 267 | size={22} | ||
| 268 | tintColorClassName="accent-primary" | ||
| 269 | /> | ||
| 270 | <View className="min-w-0 flex-1 gap-1"> | ||
| 271 | <Text className="text-base font-t3-medium text-foreground">{label}</Text> | ||
| 272 | <Text className="text-sm text-foreground-muted"> | ||
| 273 | {value === "t3-connect" && !t3ConnectAvailable | ||
| 274 | ? "Unavailable in this app build." | ||
| 275 | : description} | ||
| 276 | </Text> | ||
| 277 | </View> | ||
| 278 | </Pressable> | ||
| 279 | ))} | ||
| 280 | </View> | ||
| 281 | <MaterialButton | ||
| 282 | label="Continue" | ||
| 283 | tone="primary" | ||
| 284 | fullWidth | ||
| 285 | disabled={method === "t3-connect" && !t3ConnectAvailable} | ||
| 286 | onPress={() => { | ||
| 287 | if (method === "t3-connect") { | ||
| 288 | if (cloudAvailable) setStep("cloud"); | ||
| 289 | else navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | ||
| 290 | } else setStep("self-hosted"); | ||
| 291 | }} | ||
| 292 | /> | ||
| 293 | <MaterialButton | ||
| 294 | label="Pair a machine" | ||
| 295 | tone="text" | ||
| 296 | fullWidth | ||
| 297 | onPress={() => setStep("direct")} | ||
| 298 | /> | ||
| 299 | </ScrollView> | ||
| 300 | </SettingsScreen> | ||
| 301 | ); | ||
| 302 | } | ||
| 303 | |||
| 304 | if (step === "cloud") { | ||
| 305 | return ( | ||
| 306 | <SettingsScreen formSheet={routeName === "ConnectionsNew"} title="T3 Connect"> | ||
| 307 | <ScrollView | ||
| 308 | contentInsetAdjustmentBehavior="automatic" | ||
| 309 | contentContainerClassName="px-5 pt-4" | ||
| 310 | > | ||
| 311 | <T3ConnectSection /> | ||
| 312 | <MaterialButton | ||
| 313 | label="Change method" | ||
| 314 | tone="text" | ||
| 315 | fullWidth | ||
| 316 | onPress={() => setStep("choose")} | ||
| 317 | /> | ||
| 318 | </ScrollView> | ||
| 319 | </SettingsScreen> | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | const pairingError = step === "self-hosted" ? hub.error : pairingConnectionError; | ||
| 324 | |||
| 197 | return ( | 325 | return ( |
| 198 | <SettingsScreen | 326 | <SettingsScreen |
| 199 | formSheet={routeName === "ConnectionsNew"} | 327 | formSheet={routeName === "ConnectionsNew"} |
| 200 | title="Add environment" | 328 | title={step === "self-hosted" ? "Self-hosted server" : "Pair a machine"} |
| 201 | actions={[ | 329 | actions={[ |
| 202 | { | 330 | { |
| 203 | accessibilityLabel: isSubmitting ? "Connecting" : "Add environment", | 331 | accessibilityLabel: isSubmitting ? "Connecting" : "Connect", |
| 204 | loading: isSubmitting, | 332 | loading: isSubmitting, |
| 205 | icon: "checkmark", | 333 | icon: "checkmark", |
| 206 | tintColor: headerIconColor, | 334 | tintColor: headerIconColor, |
| ... | @@ -238,19 +366,21 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -238,19 +366,21 @@ export function ConnectionsNewRouteScreen({ |
| 238 | collapsable={false} | 366 | collapsable={false} |
| 239 | className={cn( | 367 | className={cn( |
| 240 | "overflow-hidden rounded-[26px] border-continuous border bg-grouped-card", | 368 | "overflow-hidden rounded-[26px] border-continuous border bg-grouped-card", |
| 241 | pairingConnectionError ? "border-danger-foreground" : "border-transparent", | 369 | pairingError ? "border-danger-foreground" : "border-transparent", |
| 242 | )} | 370 | )} |
| 243 | > | 371 | > |
| 244 | <PairingInputRow | 372 | <PairingInputRow |
| 245 | label="Address" | 373 | label={step === "self-hosted" ? "Server address" : "Address"} |
| 246 | keyboardType="url" | 374 | keyboardType="url" |
| 247 | placeholder="192.168.1.100:3773" | 375 | placeholder={ |
| 376 | step === "self-hosted" ? "https://server.example.com" : "192.168.1.100:3773" | ||
| 377 | } | ||
| 248 | value={hostInput} | 378 | value={hostInput} |
| 249 | onChangeText={handleHostChange} | 379 | onChangeText={handleHostChange} |
| 250 | /> | 380 | /> |
| 251 | <View className="ml-4 border-t border-border-subtle" /> | 381 | <View className="ml-4 border-t border-border-subtle" /> |
| 252 | <PairingInputRow | 382 | <PairingInputRow |
| 253 | label="Code" | 383 | label="Pairing code" |
| 254 | placeholder="Pairing code" | 384 | placeholder="Pairing code" |
| 255 | returnKeyType="go" | 385 | returnKeyType="go" |
| 256 | value={codeInput} | 386 | value={codeInput} |
| ... | @@ -264,15 +394,24 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -264,15 +394,24 @@ export function ConnectionsNewRouteScreen({ |
| 264 | accessibilityLiveRegion="polite" | 394 | accessibilityLiveRegion="polite" |
| 265 | className={cn( | 395 | className={cn( |
| 266 | "px-4 text-sm leading-normal", | 396 | "px-4 text-sm leading-normal", |
| 267 | pairingConnectionError ? "text-danger-foreground" : "text-foreground-muted", | 397 | pairingError ? "text-danger-foreground" : "text-foreground-muted", |
| 268 | )} | 398 | )} |
| 269 | > | 399 | > |
| 270 | {pairingConnectionError ?? | 400 | {pairingError ?? |
| 271 | "For machines on your local network or tailnet. The machine keeps its own provider credentials."} | 401 | (step === "self-hosted" |
| 402 | ? "Create a pairing code on your server. Its machines will appear here." | ||
| 403 | : "For machines on your local network or tailnet. The machine keeps its own provider credentials.")} | ||
| 272 | </Text> | 404 | </Text> |
| 273 | </View> | 405 | </View> |
| 274 | 406 | ||
| 275 | {hasCloudPublicConfig() ? <T3ConnectSection /> : null} | 407 | {!params.routeFor ? ( |
| 408 | <MaterialButton | ||
| 409 | label="Change method" | ||
| 410 | tone="text" | ||
| 411 | fullWidth | ||
| 412 | onPress={() => setStep("choose")} | ||
| 413 | /> | ||
| 414 | ) : null} | ||
| 276 | </View> | 415 | </View> |
| 277 | </ScrollView> | 416 | </ScrollView> |
| 278 | </SettingsScreen> | 417 | </SettingsScreen> |
| ... | @@ -393,9 +532,6 @@ function T3ConnectSection() { | ... | @@ -393,9 +532,6 @@ function T3ConnectSection() { |
| 393 | 532 | ||
| 394 | return ( | 533 | return ( |
| 395 | <View collapsable={false} className="gap-2"> | 534 | <View collapsable={false} className="gap-2"> |
| 396 | <Text className="px-4 text-sm font-t3-medium text-foreground-muted"> | ||
| 397 | Or use the managed relay | ||
| 398 | </Text> | ||
| 399 | {isSignedIn ? ( | 535 | {isSignedIn ? ( |
| 400 | <CloudEnvironmentRows | 536 | <CloudEnvironmentRows |
| 401 | connectedCloudEnvironments={connectedCloudEnvironments} | 537 | connectedCloudEnvironments={connectedCloudEnvironments} |
| ... | @@ -437,11 +573,11 @@ function PairingInputRow({ | ... | @@ -437,11 +573,11 @@ function PairingInputRow({ |
| 437 | readonly label: string; | 573 | readonly label: string; |
| 438 | }) { | 574 | }) { |
| 439 | return ( | 575 | return ( |
| 440 | <View collapsable={false} className="h-13 flex-row items-center gap-3 px-4"> | 576 | <View collapsable={false} className="min-h-13 flex-row items-center gap-3 px-4 py-3"> |
| 441 | <Text | 577 | <Text |
| 442 | accessibilityElementsHidden | 578 | accessibilityElementsHidden |
| 443 | importantForAccessibility="no-hide-descendants" | 579 | importantForAccessibility="no-hide-descendants" |
| 444 | className="w-20 text-base leading-[23px] text-foreground-muted" | 580 | className="w-28 text-base leading-[23px] text-foreground-muted" |
| 445 | > | 581 | > |
| 446 | {label} | 582 | {label} |
| 447 | </Text> | 583 | </Text> |
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2| ... | @@ -10,6 +10,7 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ... | @@ -10,6 +10,7 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 10 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | 10 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 11 | import { LocalEnvironmentList } from "./LocalEnvironmentList"; | 11 | import { LocalEnvironmentList } from "./LocalEnvironmentList"; |
| 12 | import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; | 12 | import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; |
| 13 | import { SelfHostedServerSection } from "./SelfHostedServerSection"; | ||
| 13 | 14 | ||
| 14 | export function ConnectionsRouteScreen() { | 15 | export function ConnectionsRouteScreen() { |
| 15 | const { | 16 | const { |
| ... | @@ -59,6 +60,7 @@ export function ConnectionsRouteScreen() { | ... | @@ -59,6 +60,7 @@ export function ConnectionsRouteScreen() { |
| 59 | paddingTop: 16, | 60 | paddingTop: 16, |
| 60 | }} | 61 | }} |
| 61 | > | 62 | > |
| 63 | <SelfHostedServerSection /> | ||
| 62 | <LocalEnvironmentList | 64 | <LocalEnvironmentList |
| 63 | environments={connectedEnvironments} | 65 | environments={connectedEnvironments} |
| 64 | expandedId={expandedId} | 66 | expandedId={expandedId} |
apps/mobile/src/features/connection/LocalEnvironmentList.tsx+12-2| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import type { ComponentProps } from "react"; | 2 | import type { ComponentProps } from "react"; |
| 3 | import { useAtomValue } from "@effect/atom-react"; | ||
| 3 | import { View } from "react-native"; | 4 | import { View } from "react-native"; |
| 4 | 5 | ||
| 5 | import { SymbolView } from "../../components/AppSymbol"; | 6 | import { SymbolView } from "../../components/AppSymbol"; |
| 6 | import { AppText as Text } from "../../components/AppText"; | 7 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; | 8 | import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; |
| 9 | import { hubConnection } from "../../connection/hub"; | ||
| 8 | 10 | ||
| 9 | type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>; | 11 | type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>; |
| 10 | 12 | ||
| ... | @@ -22,6 +24,8 @@ export function LocalEnvironmentList({ | ... | @@ -22,6 +24,8 @@ export function LocalEnvironmentList({ |
| 22 | readonly expandedId: EnvironmentId | null; | 24 | readonly expandedId: EnvironmentId | null; |
| 23 | readonly onToggle: (environmentId: EnvironmentId) => void; | 25 | readonly onToggle: (environmentId: EnvironmentId) => void; |
| 24 | }) { | 26 | }) { |
| 27 | const hub = useAtomValue(hubConnection.stateValueAtom); | ||
| 28 | |||
| 25 | if (environments.length === 0) { | 29 | if (environments.length === 0) { |
| 26 | return ( | 30 | return ( |
| 27 | <View | 31 | <View |
| ... | @@ -37,8 +41,14 @@ export function LocalEnvironmentList({ | ... | @@ -37,8 +41,14 @@ export function LocalEnvironmentList({ |
| 37 | /> | 41 | /> |
| 38 | </View> | 42 | </View> |
| 39 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | 43 | <Text className="text-center text-sm leading-normal text-foreground-muted"> |
| 40 | No environments connected yet.{"\n"}Tap{" "} | 44 | {hub.connection ? ( |
| 41 | <Text className="font-t3-bold text-foreground">+</Text> to add one. | 45 | <>No machines connected.{"\n"}Join this server from a computer to add one.</> |
| 46 | ) : ( | ||
| 47 | <> | ||
| 48 | No environments connected yet.{"\n"}Tap{" "} | ||
| 49 | <Text className="font-t3-bold text-foreground">+</Text> to add one. | ||
| 50 | </> | ||
| 51 | )} | ||
| 42 | </Text> | 52 | </Text> |
| 43 | </View> | 53 | </View> |
| 44 | ); | 54 | ); |
apps/mobile/src/features/connection/SelfHostedServerSection.tsx created+60| ... | @@ -0,0 +1,60 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { Alert, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../components/AppText"; | ||
| 5 | import { ListRowLayout } from "../../components/ListRowLayout"; | ||
| 6 | import { hubConnection } from "../../connection/hub"; | ||
| 7 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 8 | import { SettingsRow } from "../settings/components/SettingsRow"; | ||
| 9 | import { SettingsSection } from "../settings/components/SettingsSection"; | ||
| 10 | |||
| 11 | export function SelfHostedServerSection() { | ||
| 12 | const hub = useAtomValue(hubConnection.stateValueAtom); | ||
| 13 | const refresh = useAtomCommand(hubConnection.refresh, { reportFailure: false }); | ||
| 14 | const disconnect = useAtomCommand(hubConnection.disconnect, { reportFailure: false }); | ||
| 15 | |||
| 16 | if (hub.connection === null) return null; | ||
| 17 | |||
| 18 | const connection = hub.connection; | ||
| 19 | |||
| 20 | return ( | ||
| 21 | <View className="mb-6 gap-2"> | ||
| 22 | <SettingsSection title="Self-hosted server"> | ||
| 23 | <ListRowLayout | ||
| 24 | icon="server.rack" | ||
| 25 | label={connection.label} | ||
| 26 | description={connection.httpBaseUrl} | ||
| 27 | /> | ||
| 28 | <SettingsRow | ||
| 29 | icon="arrow.clockwise" | ||
| 30 | label={hub.refreshing ? "Refreshing…" : "Refresh machines"} | ||
| 31 | disabled={hub.refreshing} | ||
| 32 | onPress={() => void refresh(undefined)} | ||
| 33 | /> | ||
| 34 | <SettingsRow | ||
| 35 | icon="xmark.circle.fill" | ||
| 36 | label="Disconnect" | ||
| 37 | onPress={() => | ||
| 38 | Alert.alert( | ||
| 39 | `Disconnect ${connection.label}?`, | ||
| 40 | "Remove this phone’s server access. Direct connections and running work stay in place.", | ||
| 41 | [ | ||
| 42 | { text: "Cancel", style: "cancel" }, | ||
| 43 | { | ||
| 44 | text: "Disconnect", | ||
| 45 | style: "destructive", | ||
| 46 | onPress: () => void disconnect(undefined), | ||
| 47 | }, | ||
| 48 | ], | ||
| 49 | ) | ||
| 50 | } | ||
| 51 | /> | ||
| 52 | </SettingsSection> | ||
| 53 | {hub.error ? ( | ||
| 54 | <Text accessibilityLiveRegion="polite" className="px-4 text-sm text-danger-foreground"> | ||
| 55 | {hub.error} | ||
| 56 | </Text> | ||
| 57 | ) : null} | ||
| 58 | </View> | ||
| 59 | ); | ||
| 60 | } | ||
apps/mobile/src/features/connection/pairing.test.ts+9| ... | @@ -51,6 +51,15 @@ describe("extractPairingUrlFromQrPayload", () => { | ... | @@ -51,6 +51,15 @@ describe("extractPairingUrlFromQrPayload", () => { |
| 51 | }); | 51 | }); |
| 52 | 52 | ||
| 53 | describe("parsePairingUrl", () => { | 53 | describe("parsePairingUrl", () => { |
| 54 | it.each(["", "pair", "pair/"])("preserves a proxied environment address before %s", (route) => { | ||
| 55 | expect( | ||
| 56 | parsePairingUrl(`https://hub.example/prefix/environments/one/${route}?old=1#token=code`), | ||
| 57 | ).toEqual({ | ||
| 58 | host: "https://hub.example/prefix/environments/one", | ||
| 59 | code: "code", | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 54 | it("reads hosted pairing links into backend host fields", () => { | 63 | it("reads hosted pairing links into backend host fields", () => { |
| 55 | expect( | 64 | expect( |
| 56 | parsePairingUrl( | 65 | parsePairingUrl( |
apps/mobile/src/features/connection/pairing.ts+1-1| ... | @@ -63,7 +63,7 @@ export function parsePairingUrl(url: string): { host: string; code: string } { | ... | @@ -63,7 +63,7 @@ export function parsePairingUrl(url: string): { host: string; code: string } { |
| 63 | 63 | ||
| 64 | parsed.hash = ""; | 64 | parsed.hash = ""; |
| 65 | parsed.search = ""; | 65 | parsed.search = ""; |
| 66 | parsed.pathname = "/"; | 66 | parsed.pathname = parsed.pathname.replace(/\/pair\/?$/, "/"); |
| 67 | return { host: parsed.toString().replace(/\/$/, ""), code }; | 67 | return { host: parsed.toString().replace(/\/$/, ""), code }; |
| 68 | } catch { | 68 | } catch { |
| 69 | return { host: trimmed, code: "" }; | 69 | return { host: trimmed, code: "" }; |
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx+9-2| ... | @@ -3,9 +3,12 @@ import { AuthView, type UserProfileCustomPage, UserProfileView } from "@clerk/ex | ... | @@ -3,9 +3,12 @@ import { AuthView, type UserProfileCustomPage, UserProfileView } from "@clerk/ex |
| 3 | import { StackActions, useNavigation } from "@react-navigation/native"; | 3 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 4 | import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; | 4 | import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; |
| 5 | import { View } from "react-native"; | 5 | import { View } from "react-native"; |
| 6 | import { useAtomSet } from "@effect/atom-react"; | ||
| 6 | 7 | ||
| 7 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 8 | import { T3ConnectProfilePage } from "../cloud/T3ConnectProfilePage"; | 9 | import { T3ConnectProfilePage } from "../cloud/T3ConnectProfilePage"; |
| 10 | import { useCloudAuthAvailable } from "../cloud/cloudAvailability"; | ||
| 11 | import { updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| 9 | 12 | ||
| 10 | // Custom rows in Clerk's native profile. Mirrors the web UserButton pages. | 13 | // Custom rows in Clerk's native profile. Mirrors the web UserButton pages. |
| 11 | const USER_PROFILE_CUSTOM_PAGES = [ | 14 | const USER_PROFILE_CUSTOM_PAGES = [ |
| ... | @@ -19,14 +22,18 @@ const USER_PROFILE_CUSTOM_PAGES = [ | ... | @@ -19,14 +22,18 @@ const USER_PROFILE_CUSTOM_PAGES = [ |
| 19 | 22 | ||
| 20 | export function SettingsAuthRouteScreen() { | 23 | export function SettingsAuthRouteScreen() { |
| 21 | const navigation = useNavigation(); | 24 | const navigation = useNavigation(); |
| 25 | const cloudAvailable = useCloudAuthAvailable(); | ||
| 26 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| 22 | 27 | ||
| 23 | useLayoutEffect(() => { | 28 | useLayoutEffect(() => { |
| 24 | if (!hasCloudPublicConfig()) { | 29 | if (!hasCloudPublicConfig()) { |
| 25 | navigation.dispatch(StackActions.replace("SettingsContent")); | 30 | navigation.dispatch(StackActions.replace("SettingsContent")); |
| 31 | } else if (!cloudAvailable) { | ||
| 32 | savePreferences({ connectionService: "t3-connect" }); | ||
| 26 | } | 33 | } |
| 27 | }, [navigation]); | 34 | }, [cloudAvailable, navigation, savePreferences]); |
| 28 | 35 | ||
| 29 | return hasCloudPublicConfig() ? <ConfiguredSettingsAuthRouteScreen /> : null; | 36 | return cloudAvailable ? <ConfiguredSettingsAuthRouteScreen /> : null; |
| 30 | } | 37 | } |
| 31 | 38 | ||
| 32 | function ConfiguredSettingsAuthRouteScreen() { | 39 | function ConfiguredSettingsAuthRouteScreen() { |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+2| ... | @@ -13,6 +13,7 @@ import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; | ... | @@ -13,6 +13,7 @@ import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; |
| 13 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; | 13 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; |
| 14 | import { LocalEnvironmentList } from "../connection/LocalEnvironmentList"; | 14 | import { LocalEnvironmentList } from "../connection/LocalEnvironmentList"; |
| 15 | import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; | 15 | import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; |
| 16 | import { SelfHostedServerSection } from "../connection/SelfHostedServerSection"; | ||
| 16 | import { splitEnvironmentSections } from "../connection/environmentSections"; | 17 | import { splitEnvironmentSections } from "../connection/environmentSections"; |
| 17 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 18 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 18 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | 19 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| ... | @@ -162,6 +163,7 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -162,6 +163,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 162 | ) : undefined | 163 | ) : undefined |
| 163 | } | 164 | } |
| 164 | > | 165 | > |
| 166 | <SelfHostedServerSection /> | ||
| 165 | <LocalEnvironmentList | 167 | <LocalEnvironmentList |
| 166 | environments={localEnvironments} | 168 | environments={localEnvironments} |
| 167 | expandedId={null} | 169 | expandedId={null} |
apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx+4-3| ... | @@ -32,7 +32,8 @@ import { | ... | @@ -32,7 +32,8 @@ import { |
| 32 | subscribeAgentAwarenessRegistrationStatus, | 32 | subscribeAgentAwarenessRegistrationStatus, |
| 33 | } from "../agent-awareness/remoteRegistration"; | 33 | } from "../agent-awareness/remoteRegistration"; |
| 34 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; | 34 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; |
| 35 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; | 35 | import { resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; |
| 36 | import { useCloudAuthAvailable } from "../cloud/cloudAvailability"; | ||
| 36 | import { runtime } from "../../lib/runtime"; | 37 | import { runtime } from "../../lib/runtime"; |
| 37 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 38 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 38 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 39 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| ... | @@ -59,7 +60,7 @@ function useDeviceRegistered(): boolean { | ... | @@ -59,7 +60,7 @@ function useDeviceRegistered(): boolean { |
| 59 | } | 60 | } |
| 60 | 61 | ||
| 61 | export function SettingsNotificationsRouteScreen() { | 62 | export function SettingsNotificationsRouteScreen() { |
| 62 | if (!hasCloudPublicConfig()) { | 63 | if (!useCloudAuthAvailable()) { |
| 63 | return ( | 64 | return ( |
| 64 | <SettingsScreen title="Notifications"> | 65 | <SettingsScreen title="Notifications"> |
| 65 | <ScrollView | 66 | <ScrollView |
| ... | @@ -67,7 +68,7 @@ export function SettingsNotificationsRouteScreen() { | ... | @@ -67,7 +68,7 @@ export function SettingsNotificationsRouteScreen() { |
| 67 | contentContainerClassName="px-5 pt-4" | 68 | contentContainerClassName="px-5 pt-4" |
| 68 | > | 69 | > |
| 69 | <Text className="text-base text-foreground-muted"> | 70 | <Text className="text-base text-foreground-muted"> |
| 70 | Notifications require T3 Connect in this app build. | 71 | Notifications aren’t available through self-hosted servers. T3 Connect is required. |
| 71 | </Text> | 72 | </Text> |
| 72 | </ScrollView> | 73 | </ScrollView> |
| 73 | </SettingsScreen> | 74 | </SettingsScreen> |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+2-2| ... | @@ -5,7 +5,7 @@ import { Platform, View } from "react-native"; | ... | @@ -5,7 +5,7 @@ import { Platform, View } from "react-native"; |
| 5 | import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; | 5 | import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 7 | ||
| 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | 8 | import { useCloudAuthAvailable } from "../cloud/cloudAvailability"; |
| 9 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 9 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 10 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 10 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 11 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 11 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| ... | @@ -21,7 +21,7 @@ import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; | ... | @@ -21,7 +21,7 @@ import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; |
| 21 | export function SettingsRouteScreen() { | 21 | export function SettingsRouteScreen() { |
| 22 | const navigation = useNavigation(); | 22 | const navigation = useNavigation(); |
| 23 | const { layout } = useAdaptiveWorkspaceLayout(); | 23 | const { layout } = useAdaptiveWorkspaceLayout(); |
| 24 | const content = hasCloudPublicConfig() ? ( | 24 | const content = useCloudAuthAvailable() ? ( |
| 25 | <ConfiguredSettingsRouteScreen /> | 25 | <ConfiguredSettingsRouteScreen /> |
| 26 | ) : ( | 26 | ) : ( |
| 27 | <LocalSettingsRouteScreen /> | 27 | <LocalSettingsRouteScreen /> |
apps/mobile/src/features/threads/ThreadArrangementSheet.tsx+24-18| ... | @@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | import { AppText as Text } from "../../components/AppText"; | 13 | import { AppText as Text } from "../../components/AppText"; |
| 14 | import { SymbolView } from "../../components/AppSymbol"; | 14 | import { SymbolView } from "../../components/AppSymbol"; |
| 15 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 15 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 16 | import { environmentServerConfigsAtom } from "../../state/server"; | 16 | import { environmentCapabilitiesAtom } from "../../state/server"; |
| 17 | import { environmentThreadShells } from "../../state/threads"; | 17 | import { environmentThreadShells } from "../../state/threads"; |
| 18 | import { | 18 | import { |
| 19 | pendingThreadOrderAtom, | 19 | pendingThreadOrderAtom, |
| ... | @@ -151,7 +151,7 @@ function DragHandle(props: { | ... | @@ -151,7 +151,7 @@ function DragHandle(props: { |
| 151 | export function ThreadArrangementSheet(props: { onClose: () => void }) { | 151 | export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 152 | const insets = useSafeAreaInsets(); | 152 | const insets = useSafeAreaInsets(); |
| 153 | const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); | 153 | const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); |
| 154 | const configs = useAtomValue(environmentServerConfigsAtom); | 154 | const capabilitiesByEnvironment = useAtomValue(environmentCapabilitiesAtom); |
| 155 | const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); | 155 | const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); |
| 156 | const pendingOrder = useAtomValue(pendingThreadOrderAtom); | 156 | const pendingOrder = useAtomValue(pendingThreadOrderAtom); |
| 157 | const dropBusy = useAtomValue(threadDropBusyAtom); | 157 | const dropBusy = useAtomValue(threadDropBusyAtom); |
| ... | @@ -180,13 +180,13 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -180,13 +180,13 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 180 | queuedThreadKeys, | 180 | queuedThreadKeys, |
| 181 | pendingOrder, | 181 | pendingOrder, |
| 182 | settlementEnvironmentIds: new Set( | 182 | settlementEnvironmentIds: new Set( |
| 183 | [...configs].flatMap(([id, config]) => | 183 | [...capabilitiesByEnvironment].flatMap(([id, supported]) => |
| 184 | config.environment.capabilities.threadSettlement ? [id] : [], | 184 | supported.threadSettlement ? [id] : [], |
| 185 | ), | 185 | ), |
| 186 | ), | 186 | ), |
| 187 | snoozeEnvironmentIds: new Set( | 187 | snoozeEnvironmentIds: new Set( |
| 188 | [...configs].flatMap(([id, config]) => | 188 | [...capabilitiesByEnvironment].flatMap(([id, supported]) => |
| 189 | config.environment.capabilities.threadSnooze ? [id] : [], | 189 | supported.threadSnooze ? [id] : [], |
| 190 | ), | 190 | ), |
| 191 | ), | 191 | ), |
| 192 | }; | 192 | }; |
| ... | @@ -205,7 +205,14 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -205,7 +205,14 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 205 | snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), | 205 | snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), |
| 206 | settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), | 206 | settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), |
| 207 | }; | 207 | }; |
| 208 | }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]); | 208 | }, [ |
| 209 | threads, | ||
| 210 | capabilitiesByEnvironment, | ||
| 211 | now, | ||
| 212 | queuedThreadKeys, | ||
| 213 | pendingOrder, | ||
| 214 | workingShelfEnabled, | ||
| 215 | ]); | ||
| 209 | const planners = useMemo(() => { | 216 | const planners = useMemo(() => { |
| 210 | const planner = (section: "pinned" | "active") => | 217 | const planner = (section: "pinned" | "active") => |
| 211 | createThreadMovePlanner({ | 218 | createThreadMovePlanner({ |
| ... | @@ -215,11 +222,11 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -215,11 +222,11 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 215 | // A time-ordered inbox has no slots, so Active takes no drops while | 222 | // A time-ordered inbox has no slots, so Active takes no drops while |
| 216 | // the Working beta is on. The saved arrangement stays untouched. | 223 | // the Working beta is on. The saved arrangement stays untouched. |
| 217 | reorderableEnvironmentIds: new Set( | 224 | reorderableEnvironmentIds: new Set( |
| 218 | [...configs].flatMap(([id, config]) => | 225 | [...capabilitiesByEnvironment].flatMap(([id, supported]) => |
| 219 | ( | 226 | ( |
| 220 | section === "pinned" | 227 | section === "pinned" |
| 221 | ? config.environment.capabilities.threadPinReorder | 228 | ? supported.threadPinReorder |
| 222 | : !workingShelfEnabled && config.environment.capabilities.threadActiveReorder | 229 | : !workingShelfEnabled && supported.threadActiveReorder |
| 223 | ) | 230 | ) |
| 224 | ? [id] | 231 | ? [id] |
| 225 | : [], | 232 | : [], |
| ... | @@ -227,7 +234,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -227,7 +234,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 227 | ), | 234 | ), |
| 228 | }); | 235 | }); |
| 229 | return { pinned: planner("pinned"), active: planner("active") }; | 236 | return { pinned: planner("pinned"), active: planner("active") }; |
| 230 | }, [sections, threads, configs, workingShelfEnabled]); | 237 | }, [sections, threads, capabilitiesByEnvironment, workingShelfEnabled]); |
| 231 | const rows = useMemo(() => { | 238 | const rows = useMemo(() => { |
| 232 | const result: Row[] = []; | 239 | const result: Row[] = []; |
| 233 | let offset = 0; | 240 | let offset = 0; |
| ... | @@ -298,7 +305,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -298,7 +305,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 298 | if (target.section === "settled") { | 305 | if (target.section === "settled") { |
| 299 | if ( | 306 | if ( |
| 300 | current.sourceSection !== "settled" && | 307 | current.sourceSection !== "settled" && |
| 301 | configs.get(current.thread.environmentId)?.environment.capabilities.threadSettlement | 308 | capabilitiesByEnvironment.get(current.thread.environmentId)?.threadSettlement |
| 302 | ) | 309 | ) |
| 303 | destination = { section: "settled", targetId: null, placement: "before" }; | 310 | destination = { section: "settled", targetId: null, placement: "before" }; |
| 304 | } else if (latest.current.planners[target.section](keyOf(current.thread), candidate) !== null) | 311 | } else if (latest.current.planners[target.section](keyOf(current.thread), candidate) !== null) |
| ... | @@ -421,8 +428,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -421,8 +428,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 421 | item.section === "pinned" || item.section === "active" | 428 | item.section === "pinned" || item.section === "active" |
| 422 | ? planners[item.section] | 429 | ? planners[item.section] |
| 423 | : null; | 430 | : null; |
| 424 | const capabilities = | 431 | const capabilities = thread && capabilitiesByEnvironment.get(thread.environmentId); |
| 425 | thread && configs.get(thread.environmentId)?.environment.capabilities; | ||
| 426 | const sectionActions = thread | 432 | const sectionActions = thread |
| 427 | ? (["pinned", "active", "settled"] as const).flatMap<{ | 433 | ? (["pinned", "active", "settled"] as const).flatMap<{ |
| 428 | name: "pinned" | "active" | "settled"; | 434 | name: "pinned" | "active" | "settled"; |
| ... | @@ -480,10 +486,10 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -480,10 +486,10 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 480 | dropBusy || | 486 | dropBusy || |
| 481 | pendingOrder !== null || | 487 | pendingOrder !== null || |
| 482 | !( | 488 | !( |
| 483 | configs.get(thread.environmentId)?.environment.capabilities | 489 | capabilitiesByEnvironment.get(thread.environmentId) |
| 484 | .threadPinReorder || | 490 | ?.threadPinReorder || |
| 485 | configs.get(thread.environmentId)?.environment.capabilities | 491 | capabilitiesByEnvironment.get(thread.environmentId) |
| 486 | .threadActiveReorder | 492 | ?.threadActiveReorder |
| 487 | ) | 493 | ) |
| 488 | } | 494 | } |
| 489 | sectionActions={sectionActions} | 495 | sectionActions={sectionActions} |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+34-24| ... | @@ -34,7 +34,7 @@ import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIco | ... | @@ -34,7 +34,7 @@ import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIco |
| 34 | import { cn } from "../../lib/cn"; | 34 | import { cn } from "../../lib/cn"; |
| 35 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 35 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 36 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 36 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 37 | import { useEnvironmentScope } from "../../state/session"; | 37 | import { useEnvironmentScope, useThreadOrganizationPermission } from "../../state/session"; |
| 38 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 38 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 39 | import { useThreadPr } from "../../state/use-thread-pr"; | 39 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 40 | import { useSwipeRowDormant } from "../home/swipe-row-activation"; | 40 | import { useSwipeRowDormant } from "../home/swipe-row-activation"; |
| ... | @@ -645,6 +645,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -645,6 +645,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 645 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); | 645 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 646 | const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); | 646 | const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); |
| 647 | const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); | 647 | const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); |
| 648 | const canOrganizeThread = useThreadOrganizationPermission(thread.environmentId); | ||
| 648 | const handleRegenerateTitle = useCallback( | 649 | const handleRegenerateTitle = useCallback( |
| 649 | () => onRegenerateThreadTitle(thread), | 650 | () => onRegenerateThreadTitle(thread), |
| 650 | [onRegenerateThreadTitle, thread], | 651 | [onRegenerateThreadTitle, thread], |
| ... | @@ -962,7 +963,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -962,7 +963,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 962 | : null, | 963 | : null, |
| 963 | [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], | 964 | [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], |
| 964 | ); | 965 | ); |
| 965 | const swipeAccessibilityHint = !canOperateThread | 966 | const swipeAccessibilityHint = !canOrganizeThread |
| 966 | ? "Opens the thread" | 967 | ? "Opens the thread" |
| 967 | : secondaryAction === null | 968 | : secondaryAction === null |
| 968 | ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` | 969 | ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` |
| ... | @@ -1301,7 +1302,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1301,7 +1302,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1301 | </RowPressable> | 1302 | </RowPressable> |
| 1302 | ); | 1303 | ); |
| 1303 | 1304 | ||
| 1304 | if (!canOperateThread) return rowContent(() => {}); | 1305 | if (!canOrganizeThread) return rowContent(() => {}); |
| 1305 | 1306 | ||
| 1306 | return ( | 1307 | return ( |
| 1307 | <View collapsable={false}> | 1308 | <View collapsable={false}> |
| ... | @@ -1330,27 +1331,36 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1330,27 +1331,36 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1330 | > | 1331 | > |
| 1331 | {(close) => ( | 1332 | {(close) => ( |
| 1332 | <ControlPillMenu | 1333 | <ControlPillMenu |
| 1333 | actions={[ | 1334 | actions={( |
| 1334 | ...(thread.branch | 1335 | [ |
| 1335 | ? [ | 1336 | ...(thread.branch |
| 1336 | { | 1337 | ? [ |
| 1337 | id: "new-thread-on-branch", | 1338 | { |
| 1338 | title: getThreadListV2NewBranchMenuTitle(thread.branch), | 1339 | id: "new-thread-on-branch", |
| 1339 | image: "square.and.pencil", | 1340 | title: getThreadListV2NewBranchMenuTitle(thread.branch), |
| 1340 | }, | 1341 | image: "square.and.pencil", |
| 1341 | ] | 1342 | }, |
| 1342 | : []), | 1343 | ] |
| 1343 | { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, | 1344 | : []), |
| 1344 | ...(snoozedRow | 1345 | { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, |
| 1345 | ? snoozedMenuActions | 1346 | ...(snoozedRow |
| 1346 | : !props.settlementSupported | 1347 | ? snoozedMenuActions |
| 1347 | ? legacyMenuActions | 1348 | : !props.settlementSupported |
| 1348 | : canUnsettle | 1349 | ? legacyMenuActions |
| 1349 | ? slimMenuActions | 1350 | : canUnsettle |
| 1350 | : swipeActions.secondary === "snooze" | 1351 | ? slimMenuActions |
| 1351 | ? snoozableCardMenuActions | 1352 | : swipeActions.secondary === "snooze" |
| 1352 | : cardMenuActions), | 1353 | ? snoozableCardMenuActions |
| 1353 | ]} | 1354 | : cardMenuActions), |
| 1355 | ] satisfies MenuAction[] | ||
| 1356 | ).map((action) => | ||
| 1357 | !canOperateThread && | ||
| 1358 | ["new-thread-on-branch", "rename", "regenerate-title", "archive", "delete"].includes( | ||
| 1359 | action.id ?? "", | ||
| 1360 | ) | ||
| 1361 | ? { ...action, attributes: { ...action.attributes, disabled: true } } | ||
| 1362 | : action, | ||
| 1363 | )} | ||
| 1354 | onPressAction={handleMenuAction} | 1364 | onPressAction={handleMenuAction} |
| 1355 | shouldOpenOnLongPress | 1365 | shouldOpenOnLongPress |
| 1356 | > | 1366 | > |
apps/mobile/src/persistence/mobile-preferences.ts+6| ... | @@ -55,6 +55,7 @@ const isThreadListFilters = Schema.is(ThreadListFilters); | ... | @@ -55,6 +55,7 @@ const isThreadListFilters = Schema.is(ThreadListFilters); |
| 55 | const isPullRequestListPreferences = Schema.is(PullRequestListPreferences); | 55 | const isPullRequestListPreferences = Schema.is(PullRequestListPreferences); |
| 56 | 56 | ||
| 57 | export interface Preferences { | 57 | export interface Preferences { |
| 58 | readonly connectionService?: "t3-connect" | "self-hosted"; | ||
| 58 | readonly uiSettingsSync?: UiSettingsSyncCache | null; | 59 | readonly uiSettingsSync?: UiSettingsSyncCache | null; |
| 59 | readonly pullRequestHideTestFiles?: boolean; | 60 | readonly pullRequestHideTestFiles?: boolean; |
| 60 | readonly pullRequestMergeMethod?: PullRequestMergeMethod; | 61 | readonly pullRequestMergeMethod?: PullRequestMergeMethod; |
| ... | @@ -139,6 +140,7 @@ export class MobilePreferencesStore extends Context.Service< | ... | @@ -139,6 +140,7 @@ export class MobilePreferencesStore extends Context.Service< |
| 139 | 140 | ||
| 140 | function sanitizePreferences(parsed: Preferences): Preferences { | 141 | function sanitizePreferences(parsed: Preferences): Preferences { |
| 141 | const preferences: { | 142 | const preferences: { |
| 143 | connectionService?: "t3-connect" | "self-hosted"; | ||
| 142 | uiSettingsSync?: UiSettingsSyncCache | null; | 144 | uiSettingsSync?: UiSettingsSyncCache | null; |
| 143 | pullRequestHideTestFiles?: boolean; | 145 | pullRequestHideTestFiles?: boolean; |
| 144 | pullRequestMergeMethod?: PullRequestMergeMethod; | 146 | pullRequestMergeMethod?: PullRequestMergeMethod; |
| ... | @@ -169,6 +171,10 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -169,6 +171,10 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 169 | pullRequestListFilters?: PullRequestListPreferences; | 171 | pullRequestListFilters?: PullRequestListPreferences; |
| 170 | } = {}; | 172 | } = {}; |
| 171 | 173 | ||
| 174 | if (parsed.connectionService === "t3-connect" || parsed.connectionService === "self-hosted") { | ||
| 175 | preferences.connectionService = parsed.connectionService; | ||
| 176 | } | ||
| 177 | |||
| 172 | if (typeof parsed.pullRequestHideTestFiles === "boolean") { | 178 | if (typeof parsed.pullRequestHideTestFiles === "boolean") { |
| 173 | preferences.pullRequestHideTestFiles = parsed.pullRequestHideTestFiles; | 179 | preferences.pullRequestHideTestFiles = parsed.pullRequestHideTestFiles; |
| 174 | } | 180 | } |
apps/mobile/src/state/server.ts+7| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { createHubEnvironmentCapabilitiesAtom } from "@t3tools/client-runtime/state/hub"; | ||
| 2 | import { hubConnection } from "../connection/hub"; | ||
| 1 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; | 3 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; |
| 2 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; | 4 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; |
| 3 | 5 | ||
| ... | @@ -15,7 +17,12 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ | ... | @@ -15,7 +17,12 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 15 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 17 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 16 | serverConfigValueAtom: serverEnvironment.configValueAtom, | 18 | serverConfigValueAtom: serverEnvironment.configValueAtom, |
| 17 | }); | 19 | }); |
| 20 | export const environmentCapabilitiesAtom = createHubEnvironmentCapabilitiesAtom( | ||
| 21 | environmentServerConfigsAtom, | ||
| 22 | hubConnection, | ||
| 23 | ); | ||
| 18 | 24 | ||
| 19 | export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom( | 25 | export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom( |
| 20 | environmentServerConfigsAtom, | 26 | environmentServerConfigsAtom, |
| 27 | environmentCapabilitiesAtom, | ||
| 21 | ); | 28 | ); |
apps/mobile/src/state/session.test.ts+7| ... | @@ -18,6 +18,13 @@ vi.mock("@effect/atom-react", async () => { | ... | @@ -18,6 +18,13 @@ vi.mock("@effect/atom-react", async () => { |
| 18 | }, | 18 | }, |
| 19 | }; | 19 | }; |
| 20 | }); | 20 | }); |
| 21 | vi.mock("../connection/hub", () => ({ | ||
| 22 | hubConnection: { | ||
| 23 | organizationPermissionAtom: Atom.family((_id: EnvironmentId | null) => | ||
| 24 | Atom.make(false).pipe(Atom.keepAlive), | ||
| 25 | ), | ||
| 26 | }, | ||
| 27 | })); | ||
| 21 | vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); | 28 | vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); |
| 22 | vi.mock("@t3tools/client-runtime/state/session", () => ({ | 29 | vi.mock("@t3tools/client-runtime/state/session", () => ({ |
| 23 | createEnvironmentSessionAtoms: () => ({ | 30 | createEnvironmentSessionAtoms: () => ({ |
apps/mobile/src/state/session.ts+15| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { hubConnection } from "../connection/hub"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; | 3 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; |
| 3 | import { | 4 | import { |
| 5 | AuthOrchestrationOperateScope, | ||
| 4 | type AuthEnvironmentScope, | 6 | type AuthEnvironmentScope, |
| 5 | type AuthSessionState, | 7 | type AuthSessionState, |
| 6 | type EnvironmentId, | 8 | type EnvironmentId, |
| ... | @@ -78,3 +80,16 @@ export function usePreparedConnection(environmentId: EnvironmentId | null) { | ... | @@ -78,3 +80,16 @@ export function usePreparedConnection(environmentId: EnvironmentId | null) { |
| 78 | : environmentSession.preparedConnectionValueAtom(environmentId), | 80 | : environmentSession.preparedConnectionValueAtom(environmentId), |
| 79 | ); | 81 | ); |
| 80 | } | 82 | } |
| 83 | |||
| 84 | export function useThreadOrganizationPermission(environmentId: EnvironmentId | null): boolean { | ||
| 85 | const source = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | ||
| 86 | const central = useAtomValue(hubConnection.organizationPermissionAtom(environmentId)); | ||
| 87 | return source || central; | ||
| 88 | } | ||
| 89 | |||
| 90 | export function readThreadOrganizationPermission(environmentId: EnvironmentId): boolean { | ||
| 91 | return ( | ||
| 92 | readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | ||
| 93 | appAtomRegistry.get(hubConnection.organizationPermissionAtom(environmentId)) | ||
| 94 | ); | ||
| 95 | } |
apps/mobile/src/state/thread-list-environments.test.ts+40-1| ... | @@ -38,7 +38,11 @@ function harness() { | ... | @@ -38,7 +38,11 @@ function harness() { |
| 38 | [OTHER_ID, config], | 38 | [OTHER_ID, config], |
| 39 | ]), | 39 | ]), |
| 40 | ); | 40 | ); |
| 41 | const list = createThreadListEnvironmentsAtom(configs); | 41 | const capabilities = Atom.make( |
| 42 | (get) => | ||
| 43 | new Map([...get(configs)].map(([id, config]) => [id, config.environment.capabilities])), | ||
| 44 | ); | ||
| 45 | const list = createThreadListEnvironmentsAtom(configs, capabilities); | ||
| 42 | let sourceNotifications = 0; | 46 | let sourceNotifications = 0; |
| 43 | let listNotifications = 0; | 47 | let listNotifications = 0; |
| 44 | registry.subscribe(configs, () => sourceNotifications++); | 48 | registry.subscribe(configs, () => sourceNotifications++); |
| ... | @@ -220,3 +224,38 @@ describe("thread list environment projection", () => { | ... | @@ -220,3 +224,38 @@ describe("thread list environment projection", () => { |
| 220 | } | 224 | } |
| 221 | }); | 225 | }); |
| 222 | }); | 226 | }); |
| 227 | |||
| 228 | it("shows organization affordances on a new client without a source server configuration", () => { | ||
| 229 | const registry = AtomRegistry.make(); | ||
| 230 | try { | ||
| 231 | const configs = Atom.make<ReadonlyMap<EnvironmentId, ServerConfig>>(new Map()); | ||
| 232 | const capabilities = Atom.make( | ||
| 233 | new Map([ | ||
| 234 | [ | ||
| 235 | ID, | ||
| 236 | { | ||
| 237 | ...config.environment.capabilities, | ||
| 238 | threadSettlement: true, | ||
| 239 | threadSnooze: true, | ||
| 240 | threadPinning: true, | ||
| 241 | threadPinReorder: true, | ||
| 242 | threadActiveReorder: true, | ||
| 243 | threadAutoSettleOptOut: true, | ||
| 244 | }, | ||
| 245 | ], | ||
| 246 | ]), | ||
| 247 | ); | ||
| 248 | const list = createThreadListEnvironmentsAtom(configs, capabilities); | ||
| 249 | const result = registry.get(list); | ||
| 250 | expect([...result.settlementEnvironmentIds]).toEqual([ID]); | ||
| 251 | expect([...result.snoozeEnvironmentIds]).toEqual([ID]); | ||
| 252 | expect([...result.pinningEnvironmentIds]).toEqual([ID]); | ||
| 253 | expect([...result.pinReorderEnvironmentIds]).toEqual([ID]); | ||
| 254 | expect([...result.activeReorderEnvironmentIds]).toEqual([ID]); | ||
| 255 | expect(result.titleRegenerationEnvironmentIds.size).toBe(0); | ||
| 256 | expect(result.providersByEnvironmentId.get(ID)).toEqual([]); | ||
| 257 | expect(registry.get(configs).size).toBe(0); | ||
| 258 | } finally { | ||
| 259 | registry.dispose(); | ||
| 260 | } | ||
| 261 | }); |
apps/mobile/src/state/thread-list-environments.ts+17-10| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | resolveEnvironmentMachineKind, | 2 | resolveEnvironmentMachineKind, |
| 3 | type EnvironmentId, | 3 | type EnvironmentId, |
| 4 | type ExecutionEnvironmentCapabilities, | ||
| 4 | type EnvironmentMachineKind, | 5 | type EnvironmentMachineKind, |
| 5 | type ServerConfig, | 6 | type ServerConfig, |
| 6 | type ServerProvider, | 7 | type ServerProvider, |
| ... | @@ -22,19 +23,23 @@ const capabilityKeys = [ | ... | @@ -22,19 +23,23 @@ const capabilityKeys = [ |
| 22 | "threadTitleRegeneration", | 23 | "threadTitleRegeneration", |
| 23 | ] as const; | 24 | ] as const; |
| 24 | 25 | ||
| 25 | function selectEnvironment(config: ServerConfig) { | 26 | const EMPTY_PROVIDERS: ThreadListProvider[] = []; |
| 27 | |||
| 28 | function selectEnvironment( | ||
| 29 | config: ServerConfig | undefined, | ||
| 30 | capabilities: ExecutionEnvironmentCapabilities, | ||
| 31 | ) { | ||
| 26 | return { | 32 | return { |
| 27 | providers: config.providers.map( | 33 | providers: |
| 28 | ({ instanceId, driver, displayName, accentColor, iconUrl }) => ({ | 34 | config?.providers.map(({ instanceId, driver, displayName, accentColor, iconUrl }) => ({ |
| 29 | instanceId, | 35 | instanceId, |
| 30 | driver, | 36 | driver, |
| 31 | displayName, | 37 | displayName, |
| 32 | accentColor, | 38 | accentColor, |
| 33 | iconUrl, | 39 | iconUrl, |
| 34 | }), | 40 | })) ?? EMPTY_PROVIDERS, |
| 35 | ), | 41 | machineKind: resolveEnvironmentMachineKind(config ?? null), |
| 36 | machineKind: resolveEnvironmentMachineKind(config), | 42 | capabilities, |
| 37 | capabilities: config.environment.capabilities, | ||
| 38 | }; | 43 | }; |
| 39 | } | 44 | } |
| 40 | 45 | ||
| ... | @@ -96,15 +101,17 @@ function collectEnvironments(environments: ReadonlyMap<EnvironmentId, ListEnviro | ... | @@ -96,15 +101,17 @@ function collectEnvironments(environments: ReadonlyMap<EnvironmentId, ListEnviro |
| 96 | /** Provider freshness and model catalogs do not affect the navigation lists. */ | 101 | /** Provider freshness and model catalogs do not affect the navigation lists. */ |
| 97 | export function createThreadListEnvironmentsAtom( | 102 | export function createThreadListEnvironmentsAtom( |
| 98 | configsAtom: Atom.Atom<ReadonlyMap<EnvironmentId, ServerConfig>>, | 103 | configsAtom: Atom.Atom<ReadonlyMap<EnvironmentId, ServerConfig>>, |
| 104 | capabilitiesAtom: Atom.Atom<ReadonlyMap<EnvironmentId, ExecutionEnvironmentCapabilities>>, | ||
| 99 | ) { | 105 | ) { |
| 100 | let previous = new Map<EnvironmentId, ListEnvironment>(); | 106 | let previous = new Map<EnvironmentId, ListEnvironment>(); |
| 101 | let result = collectEnvironments(previous); | 107 | let result = collectEnvironments(previous); |
| 102 | return Atom.make((get) => { | 108 | return Atom.make((get) => { |
| 109 | const capabilities = get(capabilitiesAtom); | ||
| 103 | const configs = get(configsAtom); | 110 | const configs = get(configsAtom); |
| 104 | const next = new Map<EnvironmentId, ListEnvironment>(); | 111 | const next = new Map<EnvironmentId, ListEnvironment>(); |
| 105 | let changed = configs.size !== previous.size; | 112 | let changed = capabilities.size !== previous.size; |
| 106 | for (const [id, config] of configs) { | 113 | for (const [id, supported] of capabilities) { |
| 107 | const selected = selectEnvironment(config); | 114 | const selected = selectEnvironment(configs.get(id), supported); |
| 108 | const prior = previous.get(id); | 115 | const prior = previous.get(id); |
| 109 | if (prior && sameProviders(prior.providers, selected.providers)) { | 116 | if (prior && sameProviders(prior.providers, selected.providers)) { |
| 110 | selected.providers = prior.providers; | 117 | selected.providers = prior.providers; |
apps/mobile/src/state/thread-order.test.ts+6-1| ... | @@ -23,7 +23,12 @@ vi.mock("./threads", async () => { | ... | @@ -23,7 +23,12 @@ vi.mock("./threads", async () => { |
| 23 | }); | 23 | }); |
| 24 | vi.mock("./server", async () => { | 24 | vi.mock("./server", async () => { |
| 25 | const { Atom } = await import("effect/reactivity"); | 25 | const { Atom } = await import("effect/reactivity"); |
| 26 | return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) }; | 26 | return { |
| 27 | threadListEnvironmentsAtom: Atom.make({ | ||
| 28 | settlementEnvironmentIds: new Set(), | ||
| 29 | snoozeEnvironmentIds: new Set(), | ||
| 30 | }).pipe(Atom.keepAlive), | ||
| 31 | }; | ||
| 27 | }); | 32 | }); |
| 28 | vi.mock("./use-thread-outbox", async () => { | 33 | vi.mock("./use-thread-outbox", async () => { |
| 29 | const { Atom } = await import("effect/reactivity"); | 34 | const { Atom } = await import("effect/reactivity"); |
apps/mobile/src/state/thread-order.ts+5-13| ... | @@ -8,7 +8,7 @@ import { | ... | @@ -8,7 +8,7 @@ import { |
| 8 | } from "../features/threads/threadOrder"; | 8 | } from "../features/threads/threadOrder"; |
| 9 | import { getThreadListV2OrderedSection } from "../features/threads/threadListV2"; | 9 | import { getThreadListV2OrderedSection } from "../features/threads/threadListV2"; |
| 10 | import { appAtomRegistry } from "./atom-registry"; | 10 | import { appAtomRegistry } from "./atom-registry"; |
| 11 | import { environmentServerConfigsAtom } from "./server"; | 11 | import { threadListEnvironmentsAtom } from "./server"; |
| 12 | import { environmentThreadShells } from "./threads"; | 12 | import { environmentThreadShells } from "./threads"; |
| 13 | import { queuedThreadKeysAtom } from "./use-thread-outbox"; | 13 | import { queuedThreadKeysAtom } from "./use-thread-outbox"; |
| 14 | 14 | ||
| ... | @@ -51,22 +51,14 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { | ... | @@ -51,22 +51,14 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { |
| 51 | if (refreshPendingOrder !== refresh) return; | 51 | if (refreshPendingOrder !== refresh) return; |
| 52 | const current = appAtomRegistry.get(pendingThreadOrderAtom); | 52 | const current = appAtomRegistry.get(pendingThreadOrderAtom); |
| 53 | if (current === null) return; | 53 | if (current === null) return; |
| 54 | const configs = appAtomRegistry.get(environmentServerConfigsAtom); | 54 | const capabilities = appAtomRegistry.get(threadListEnvironmentsAtom); |
| 55 | const ordered = getThreadListV2OrderedSection({ | 55 | const ordered = getThreadListV2OrderedSection({ |
| 56 | threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), | 56 | threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), |
| 57 | section: current.section, | 57 | section: current.section, |
| 58 | now: new Date().toISOString(), | 58 | now: new Date().toISOString(), |
| 59 | queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), | 59 | queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), |
| 60 | settlementEnvironmentIds: new Set( | 60 | settlementEnvironmentIds: capabilities.settlementEnvironmentIds, |
| 61 | [...configs].flatMap(([id, config]) => | 61 | snoozeEnvironmentIds: capabilities.snoozeEnvironmentIds, |
| 62 | config.environment.capabilities.threadSettlement === true ? [id] : [], | ||
| 63 | ), | ||
| 64 | ), | ||
| 65 | snoozeEnvironmentIds: new Set( | ||
| 66 | [...configs].flatMap(([id, config]) => | ||
| 67 | config.environment.capabilities.threadSnooze === true ? [id] : [], | ||
| 68 | ), | ||
| 69 | ), | ||
| 70 | }); | 62 | }); |
| 71 | const next = reconcilePendingThreadOrder(current, ordered); | 63 | const next = reconcilePendingThreadOrder(current, ordered); |
| 72 | if (next === null) cancel(); | 64 | if (next === null) cancel(); |
| ... | @@ -76,7 +68,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { | ... | @@ -76,7 +68,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { |
| 76 | appAtomRegistry.set(pendingThreadOrderAtom, pending); | 68 | appAtomRegistry.set(pendingThreadOrderAtom, pending); |
| 77 | unsubscribers.push( | 69 | unsubscribers.push( |
| 78 | appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), | 70 | appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), |
| 79 | appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), | 71 | appAtomRegistry.subscribe(threadListEnvironmentsAtom, refresh), |
| 80 | appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh), | 72 | appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh), |
| 81 | ); | 73 | ); |
| 82 | return { | 74 | return { |
apps/server/src/archive/ArchiveAssetResponse.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import { ArchiveError } from "@t3tools/contracts"; | ||
| 2 | import * as ArchiveClient from "./ArchiveClient.ts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | import { HttpServerResponse } from "effect/http"; | ||
| 6 | import { | ||
| 7 | injectMcpAppCookieBootstrap, | ||
| 8 | injectMcpAppThemeBootstrap, | ||
| 9 | MCP_APP_MAX_HTML_BYTES, | ||
| 10 | } from "@t3tools/shared/mcpApp"; | ||
| 11 | |||
| 12 | export const remoteResponse = Effect.fn("ArchiveAssetResponse.remoteResponse")(function* ( | ||
| 13 | asset: { | ||
| 14 | readonly location: string; | ||
| 15 | readonly hash: string; | ||
| 16 | readonly mimeType: string; | ||
| 17 | readonly download: boolean; | ||
| 18 | readonly fileName: string; | ||
| 19 | readonly renderIntent?: "mcp-app"; | ||
| 20 | }, | ||
| 21 | range: string | undefined, | ||
| 22 | ) { | ||
| 23 | const client = yield* ArchiveClient.ArchiveClient; | ||
| 24 | const result = yield* client.open(asset.location, asset.hash, range); | ||
| 25 | if (result.kind === "file") return result; | ||
| 26 | const response = result.response; | ||
| 27 | const headers: Record<string, string> = { | ||
| 28 | "content-type": asset.mimeType, | ||
| 29 | "cache-control": "private, no-store", | ||
| 30 | "x-content-type-options": "nosniff", | ||
| 31 | }; | ||
| 32 | for (const name of ["content-length", "content-range", "accept-ranges"]) { | ||
| 33 | const value = response.headers[name]; | ||
| 34 | if (value !== undefined) headers[name] = value; | ||
| 35 | } | ||
| 36 | if (asset.download) | ||
| 37 | headers["content-disposition"] = | ||
| 38 | `attachment; filename*=UTF-8''${encodeURIComponent(asset.fileName)}`; | ||
| 39 | if (asset.mimeType === "image/svg+xml") | ||
| 40 | headers["content-security-policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; | ||
| 41 | if ( | ||
| 42 | asset.renderIntent === "mcp-app" && | ||
| 43 | !asset.download && | ||
| 44 | asset.mimeType.startsWith("text/html") | ||
| 45 | ) { | ||
| 46 | const parts: Array<Uint8Array> = []; | ||
| 47 | let length = 0; | ||
| 48 | yield* response.stream.pipe( | ||
| 49 | Stream.runForEach((bytes) => | ||
| 50 | Effect.gen(function* () { | ||
| 51 | length += bytes.length; | ||
| 52 | if (length > MCP_APP_MAX_HTML_BYTES) | ||
| 53 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 54 | parts.push(bytes); | ||
| 55 | }), | ||
| 56 | ), | ||
| 57 | ); | ||
| 58 | const bytes = new Uint8Array(length); | ||
| 59 | let offset = 0; | ||
| 60 | for (const part of parts) { | ||
| 61 | bytes.set(part, offset); | ||
| 62 | offset += part.length; | ||
| 63 | } | ||
| 64 | const body = injectMcpAppCookieBootstrap( | ||
| 65 | injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes)), | ||
| 66 | ); | ||
| 67 | delete headers["content-length"]; | ||
| 68 | return { kind: "response" as const, response: HttpServerResponse.text(body, { headers }) }; | ||
| 69 | } | ||
| 70 | return { | ||
| 71 | kind: "response" as const, | ||
| 72 | response: HttpServerResponse.stream(response.stream, { status: response.status, headers }), | ||
| 73 | }; | ||
| 74 | }); | ||
apps/server/src/archive/ArchiveAssets.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | import { | ||
| 2 | ArchiveError, | ||
| 3 | ArchiveObjectHash, | ||
| 4 | AuthSessionId, | ||
| 5 | EnvironmentId, | ||
| 6 | ThreadId, | ||
| 7 | type AssetCreateUrlResult, | ||
| 8 | type AssetResource, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Clock from "effect/Clock"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as Option from "effect/Option"; | ||
| 15 | import * as Redacted from "effect/Redacted"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | import * as ArchiveCatalog from "./ArchiveCatalog.ts"; | ||
| 18 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 19 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 20 | import { | ||
| 21 | base64UrlDecodeUtf8, | ||
| 22 | base64UrlEncode, | ||
| 23 | signPayload, | ||
| 24 | timingSafeEqualBase64Url, | ||
| 25 | } from "../auth/utils.ts"; | ||
| 26 | |||
| 27 | const Claims = Schema.Struct({ | ||
| 28 | version: Schema.Literal(1), | ||
| 29 | environmentId: EnvironmentId, | ||
| 30 | threadId: ThreadId, | ||
| 31 | manifestHash: ArchiveObjectHash, | ||
| 32 | hash: ArchiveObjectHash, | ||
| 33 | sessionId: AuthSessionId, | ||
| 34 | expiresAt: Schema.Number, | ||
| 35 | fileName: Schema.String, | ||
| 36 | mimeType: Schema.String, | ||
| 37 | download: Schema.Boolean, | ||
| 38 | renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")), | ||
| 39 | }); | ||
| 40 | const encodeClaims = Schema.encodeSync(Schema.fromJsonString(Claims)); | ||
| 41 | const decodeClaims = Schema.decodeUnknownOption(Schema.fromJsonString(Claims)); | ||
| 42 | const storageError = (cause: unknown) => | ||
| 43 | new ArchiveError({ reason: "storage", cause: Redacted.make(cause) }); | ||
| 44 | |||
| 45 | export class ArchiveAssets extends Context.Service< | ||
| 46 | ArchiveAssets, | ||
| 47 | { | ||
| 48 | readonly createUrl: ( | ||
| 49 | environmentId: EnvironmentId, | ||
| 50 | resource: AssetResource, | ||
| 51 | sessionId: AuthSessionId, | ||
| 52 | ) => Effect.Effect<AssetCreateUrlResult, ArchiveError>; | ||
| 53 | readonly resolve: (token: string) => Effect.Effect< | ||
| 54 | Option.Option<{ | ||
| 55 | readonly path: string; | ||
| 56 | readonly fileName: string; | ||
| 57 | readonly mimeType: string; | ||
| 58 | readonly download: boolean; | ||
| 59 | readonly renderIntent?: "mcp-app"; | ||
| 60 | }>, | ||
| 61 | ArchiveError | ||
| 62 | >; | ||
| 63 | } | ||
| 64 | >()("t3/archive/ArchiveAssets") {} | ||
| 65 | |||
| 66 | export const layer = Layer.effect( | ||
| 67 | ArchiveAssets, | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const catalog = yield* ArchiveCatalog.ArchiveCatalog; | ||
| 70 | const sessions = yield* SessionStore.SessionStore; | ||
| 71 | const secret = yield* (yield* ServerSecretStore.ServerSecretStore) | ||
| 72 | .getOrCreateRandom("archive-asset-signing-key", 32) | ||
| 73 | .pipe(Effect.mapError(storageError)); | ||
| 74 | return ArchiveAssets.of({ | ||
| 75 | createUrl: (environmentId, resource, sessionId) => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const asset = yield* catalog.asset(environmentId, resource); | ||
| 78 | const expiresAt = (yield* Clock.currentTimeMillis) + 5 * 60_000; | ||
| 79 | const payload = base64UrlEncode( | ||
| 80 | encodeClaims({ version: 1, environmentId, ...asset, sessionId, expiresAt }), | ||
| 81 | ); | ||
| 82 | const token = `${payload}.${signPayload(payload, secret)}`; | ||
| 83 | return { | ||
| 84 | relativeUrl: `/api/archive/assets/${token}/${encodeURIComponent(asset.fileName)}`, | ||
| 85 | expiresAt, | ||
| 86 | }; | ||
| 87 | }), | ||
| 88 | resolve: (token) => | ||
| 89 | Effect.gen(function* () { | ||
| 90 | const separator = token.indexOf("."); | ||
| 91 | if (separator <= 0) return Option.none(); | ||
| 92 | const payload = token.slice(0, separator); | ||
| 93 | if (!timingSafeEqualBase64Url(token.slice(separator + 1), signPayload(payload, secret))) | ||
| 94 | return Option.none(); | ||
| 95 | const decoded = yield* Effect.try(() => decodeClaims(base64UrlDecodeUtf8(payload))).pipe( | ||
| 96 | Effect.orElseSucceed(() => Option.none()), | ||
| 97 | ); | ||
| 98 | if (Option.isNone(decoded)) return Option.none(); | ||
| 99 | const claims = decoded.value; | ||
| 100 | if (claims.expiresAt <= (yield* Clock.currentTimeMillis)) return Option.none(); | ||
| 101 | const active = yield* sessions.verifyActive(claims.sessionId).pipe( | ||
| 102 | Effect.as(true), | ||
| 103 | Effect.orElseSucceed(() => false), | ||
| 104 | ); | ||
| 105 | if (!active) return Option.none(); | ||
| 106 | const file = yield* catalog | ||
| 107 | .openAsset(claims.environmentId, claims.threadId, claims.manifestHash, claims.hash) | ||
| 108 | .pipe( | ||
| 109 | Effect.asSome, | ||
| 110 | Effect.catchTags({ | ||
| 111 | ArchiveError: (error) => | ||
| 112 | error.reason === "unavailable" || error.reason === "changed" | ||
| 113 | ? Effect.succeedNone | ||
| 114 | : Effect.fail(error), | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | if (Option.isNone(file)) return Option.none(); | ||
| 118 | return Option.some({ | ||
| 119 | path: file.value, | ||
| 120 | fileName: claims.fileName, | ||
| 121 | mimeType: claims.mimeType, | ||
| 122 | download: claims.download, | ||
| 123 | ...(claims.renderIntent ? { renderIntent: claims.renderIntent } : {}), | ||
| 124 | }); | ||
| 125 | }), | ||
| 126 | }); | ||
| 127 | }), | ||
| 128 | ); | ||
apps/server/src/archive/ArchiveCatalog.ts created+356| ... | @@ -0,0 +1,356 @@ | ||
| 1 | import { | ||
| 2 | ArchiveCatalogEntry, | ||
| 3 | type AssetResource, | ||
| 4 | ArchiveError, | ||
| 5 | ArchivePublication, | ||
| 6 | EnvironmentId, | ||
| 7 | ThreadId, | ||
| 8 | type OrchestrationV2ThreadDetailSnapshot, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | // Streaming verification keeps archive media memory bounded. | ||
| 11 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 12 | import * as NodeCrypto from "node:crypto"; | ||
| 13 | import * as NodePath from "@effect/platform-node/NodePath"; | ||
| 14 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | ||
| 15 | import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | ||
| 16 | import { parseAttachmentIdFromRelativePath } from "../attachmentStore.ts"; | ||
| 17 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 18 | import * as Context from "effect/Context"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as FileSystem from "effect/FileSystem"; | ||
| 21 | import * as Layer from "effect/Layer"; | ||
| 22 | import * as Option from "effect/Option"; | ||
| 23 | import * as Path from "effect/Path"; | ||
| 24 | import * as Redacted from "effect/Redacted"; | ||
| 25 | import * as Schema from "effect/Schema"; | ||
| 26 | import * as Semaphore from "effect/Semaphore"; | ||
| 27 | import * as Stream from "effect/Stream"; | ||
| 28 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 29 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ||
| 30 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 31 | import * as ArchiveObjects from "./ArchiveObjects.ts"; | ||
| 32 | import { Bundle, Row } from "./ArchiveFormat.ts"; | ||
| 33 | |||
| 34 | const decodeBundle = Schema.decodeEffect(Schema.fromJsonString(Bundle)); | ||
| 35 | const decodeRows = Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Row))); | ||
| 36 | const decodeEntry = Schema.decodeEffect( | ||
| 37 | Schema.fromJsonString(Schema.toCodecJson(ArchiveCatalogEntry)), | ||
| 38 | ); | ||
| 39 | const encodeEntry = Schema.encodeEffect( | ||
| 40 | Schema.fromJsonString(Schema.toCodecJson(ArchiveCatalogEntry)), | ||
| 41 | ); | ||
| 42 | const isArchiveError = Schema.is(ArchiveError); | ||
| 43 | const storageError = (cause: unknown) => | ||
| 44 | isArchiveError(cause) | ||
| 45 | ? cause | ||
| 46 | : new ArchiveError({ reason: "storage", cause: Redacted.make(cause) }); | ||
| 47 | |||
| 48 | export class ArchiveCatalog extends Context.Service< | ||
| 49 | ArchiveCatalog, | ||
| 50 | { | ||
| 51 | readonly publish: ( | ||
| 52 | input: ArchivePublication, | ||
| 53 | ) => Effect.Effect<ArchiveCatalogEntry, ArchiveError>; | ||
| 54 | readonly list: Effect.Effect< | ||
| 55 | { readonly entries: ReadonlyArray<ArchiveCatalogEntry> }, | ||
| 56 | ArchiveError | ||
| 57 | >; | ||
| 58 | readonly read: ( | ||
| 59 | environmentId: EnvironmentId, | ||
| 60 | threadId: ThreadId, | ||
| 61 | ) => Effect.Effect<OrchestrationV2ThreadDetailSnapshot, ArchiveError>; | ||
| 62 | readonly asset: ( | ||
| 63 | environmentId: EnvironmentId, | ||
| 64 | resource: AssetResource, | ||
| 65 | ) => Effect.Effect< | ||
| 66 | { | ||
| 67 | readonly threadId: ThreadId; | ||
| 68 | readonly manifestHash: string; | ||
| 69 | readonly hash: string; | ||
| 70 | readonly fileName: string; | ||
| 71 | readonly mimeType: string; | ||
| 72 | readonly download: boolean; | ||
| 73 | readonly renderIntent?: "mcp-app"; | ||
| 74 | }, | ||
| 75 | ArchiveError | ||
| 76 | >; | ||
| 77 | readonly openAsset: ( | ||
| 78 | environmentId: EnvironmentId, | ||
| 79 | threadId: ThreadId, | ||
| 80 | manifestHash: string, | ||
| 81 | hash: string, | ||
| 82 | ) => Effect.Effect<string, ArchiveError>; | ||
| 83 | readonly withdraw: ( | ||
| 84 | environmentId: EnvironmentId, | ||
| 85 | threadId: ThreadId, | ||
| 86 | manifestHash: string, | ||
| 87 | ) => Effect.Effect<void, ArchiveError>; | ||
| 88 | } | ||
| 89 | >()("t3/archive/ArchiveCatalog") {} | ||
| 90 | |||
| 91 | export const layer = Layer.effect( | ||
| 92 | ArchiveCatalog, | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const objects = yield* ArchiveObjects.ArchiveObjects; | ||
| 95 | const fs = yield* FileSystem.FileSystem; | ||
| 96 | const path = yield* Path.Path; | ||
| 97 | const windowsPath = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)); | ||
| 98 | const lock = yield* Semaphore.make(1); | ||
| 99 | const directory = Effect.gen(function* () { | ||
| 100 | if (Option.isNone(objects.root)) return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 101 | // Object lookup verifies the configured archive storage before index access. | ||
| 102 | yield* objects.directory; | ||
| 103 | const directory = path.join(objects.root.value, "catalog"); | ||
| 104 | yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 }); | ||
| 105 | return directory; | ||
| 106 | }); | ||
| 107 | const entryPath = (directory: string, environmentId: EnvironmentId, threadId: ThreadId) => | ||
| 108 | path.join( | ||
| 109 | directory, | ||
| 110 | NodeCrypto.createHash("sha256") | ||
| 111 | .update(JSON.stringify([environmentId, threadId])) | ||
| 112 | .digest("hex") + ".json", | ||
| 113 | ); | ||
| 114 | const verifiedFile = Effect.fn("ArchiveCatalog.verifiedFile")(function* (hash: string) { | ||
| 115 | const file = yield* objects.file(hash); | ||
| 116 | const digest = NodeCrypto.createHash("sha256"); | ||
| 117 | yield* fs.stream(file).pipe( | ||
| 118 | Stream.runForEach((bytes) => | ||
| 119 | Effect.sync(() => { | ||
| 120 | digest.update(bytes); | ||
| 121 | }), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | if (digest.digest("hex") !== hash) return yield* new ArchiveError({ reason: "integrity" }); | ||
| 125 | return file; | ||
| 126 | }); | ||
| 127 | const readManifest = (hash: string) => | ||
| 128 | verifiedFile(hash).pipe(Effect.flatMap(fs.readFileString), Effect.flatMap(decodeBundle)); | ||
| 129 | const load = Effect.fn("ArchiveCatalog.load")(function* (hash: string) { | ||
| 130 | const manifest = yield* readManifest(hash); | ||
| 131 | const hashes = new Set([ | ||
| 132 | manifest.events, | ||
| 133 | ...manifest.segments.map((segment) => segment.hash), | ||
| 134 | ...manifest.context.map((segment) => segment.hash), | ||
| 135 | ...manifest.assets.map((asset) => asset.hash), | ||
| 136 | ]); | ||
| 137 | for (const hash of hashes) yield* verifiedFile(hash); | ||
| 138 | return manifest; | ||
| 139 | }); | ||
| 140 | const get = Effect.fn("ArchiveCatalog.get")(function* ( | ||
| 141 | environmentId: EnvironmentId, | ||
| 142 | threadId: ThreadId, | ||
| 143 | ) { | ||
| 144 | const file = entryPath(yield* directory, environmentId, threadId); | ||
| 145 | if (!(yield* fs.exists(file))) return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 146 | const entry = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeEntry)); | ||
| 147 | if (entry.environmentId !== environmentId || entry.thread.id !== threadId) | ||
| 148 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 149 | return entry; | ||
| 150 | }); | ||
| 151 | const materialize = (manifest: typeof Bundle.Type) => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | const segments = yield* Effect.forEach( | ||
| 154 | [ | ||
| 155 | ...manifest.context, | ||
| 156 | ...manifest.segments.map((segment) => ({ | ||
| 157 | ...segment, | ||
| 158 | table: `orchestration_v2_projection_${segment.table}`, | ||
| 159 | })), | ||
| 160 | ], | ||
| 161 | (segment) => | ||
| 162 | Effect.gen(function* () { | ||
| 163 | const rows = yield* fs | ||
| 164 | .readFileString(yield* objects.file(segment.hash)) | ||
| 165 | .pipe(Effect.flatMap(decodeRows)); | ||
| 166 | return { table: segment.table, rows }; | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | const events = yield* fs | ||
| 170 | .readFileString(yield* objects.file(manifest.events)) | ||
| 171 | .pipe(Effect.flatMap(decodeRows)); | ||
| 172 | return yield* Effect.gen(function* () { | ||
| 173 | const sql = yield* SqlClient.SqlClient; | ||
| 174 | for (const segment of segments) | ||
| 175 | for (const row of segment.rows) | ||
| 176 | yield* sql`INSERT OR REPLACE INTO ${sql(segment.table)} ${sql.insert(row)}`; | ||
| 177 | for (const row of events) | ||
| 178 | yield* sql`INSERT OR REPLACE INTO orchestration_events ${sql.insert(row)}`; | ||
| 179 | return yield* (yield* ProjectionStore.ProjectionStoreV2).getThreadSnapshot( | ||
| 180 | manifest.threadId, | ||
| 181 | ); | ||
| 182 | }).pipe( | ||
| 183 | Effect.provide( | ||
| 184 | Layer.fresh( | ||
| 185 | ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), | ||
| 186 | ), | ||
| 187 | ), | ||
| 188 | ); | ||
| 189 | }); | ||
| 190 | const list = Effect.gen(function* () { | ||
| 191 | if (Option.isNone(objects.root)) return { entries: [] }; | ||
| 192 | const dir = yield* directory; | ||
| 193 | const names = (yield* fs.readDirectory(dir)).filter((name) => | ||
| 194 | /^[a-f0-9]{64}\.json$/.test(name), | ||
| 195 | ); | ||
| 196 | const entries = yield* Effect.forEach( | ||
| 197 | names, | ||
| 198 | (name) => fs.readFileString(path.join(dir, name)).pipe(Effect.flatMap(decodeEntry)), | ||
| 199 | { concurrency: 4 }, | ||
| 200 | ); | ||
| 201 | return { | ||
| 202 | entries: entries.sort( | ||
| 203 | (a, b) => b.thread.updatedAt.epochMilliseconds - a.thread.updatedAt.epochMilliseconds, | ||
| 204 | ), | ||
| 205 | }; | ||
| 206 | }).pipe(Effect.mapError(storageError)); | ||
| 207 | return ArchiveCatalog.of({ | ||
| 208 | publish: (input) => | ||
| 209 | lock | ||
| 210 | .withPermit( | ||
| 211 | Effect.gen(function* () { | ||
| 212 | yield* objects.verifyDurableStore; | ||
| 213 | const manifest = yield* load(input.manifestHash); | ||
| 214 | const snapshot = yield* materialize(manifest); | ||
| 215 | const thread = ProjectionStore.threadShellFromProjection(snapshot.projection); | ||
| 216 | if ( | ||
| 217 | thread.id !== manifest.threadId || | ||
| 218 | !thread.archivedAt || | ||
| 219 | thread.deletedAt || | ||
| 220 | thread.projectId !== input.project.id | ||
| 221 | ) | ||
| 222 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 223 | const entry = ArchiveCatalogEntry.make({ | ||
| 224 | environmentId: input.environmentId, | ||
| 225 | manifestHash: input.manifestHash, | ||
| 226 | throughSequence: manifest.throughSequence, | ||
| 227 | thread, | ||
| 228 | project: input.project, | ||
| 229 | }); | ||
| 230 | const dir = yield* directory; | ||
| 231 | const file = entryPath(dir, input.environmentId, thread.id); | ||
| 232 | if (yield* fs.exists(file)) { | ||
| 233 | const previous = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeEntry)); | ||
| 234 | if ( | ||
| 235 | previous.throughSequence > entry.throughSequence || | ||
| 236 | (previous.throughSequence === entry.throughSequence && | ||
| 237 | previous.manifestHash !== entry.manifestHash) | ||
| 238 | ) | ||
| 239 | return yield* new ArchiveError({ reason: "changed" }); | ||
| 240 | } | ||
| 241 | yield* Effect.scoped( | ||
| 242 | Effect.gen(function* () { | ||
| 243 | const temporary = yield* fs.makeTempFileScoped({ | ||
| 244 | directory: dir, | ||
| 245 | prefix: ".publish-", | ||
| 246 | }); | ||
| 247 | yield* fs.writeFileString(temporary, yield* encodeEntry(entry), { mode: 0o600 }); | ||
| 248 | yield* fs | ||
| 249 | .open(temporary, { flag: "r+" }) | ||
| 250 | .pipe(Effect.flatMap((file) => file.sync)); | ||
| 251 | yield* fs.rename(temporary, file); | ||
| 252 | if (!(yield* HostProcess.isWindows)) | ||
| 253 | yield* fs.open(dir).pipe(Effect.flatMap((file) => file.sync)); | ||
| 254 | }), | ||
| 255 | ); | ||
| 256 | return entry; | ||
| 257 | }), | ||
| 258 | ) | ||
| 259 | .pipe(Effect.mapError(storageError)), | ||
| 260 | list, | ||
| 261 | read: (environmentId, threadId) => | ||
| 262 | Effect.gen(function* () { | ||
| 263 | const entry = yield* get(environmentId, threadId); | ||
| 264 | const manifest = yield* load(entry.manifestHash); | ||
| 265 | if (manifest.threadId !== threadId || manifest.throughSequence !== entry.throughSequence) | ||
| 266 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 267 | return yield* materialize(manifest); | ||
| 268 | }).pipe(Effect.mapError(storageError)), | ||
| 269 | asset: (environmentId, resource) => | ||
| 270 | Effect.gen(function* () { | ||
| 271 | if ( | ||
| 272 | resource._tag !== "attachment" && | ||
| 273 | resource._tag !== "media-file" && | ||
| 274 | resource._tag !== "workspace-file" | ||
| 275 | ) | ||
| 276 | return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 277 | const entries = (yield* list).entries.filter( | ||
| 278 | (entry) => | ||
| 279 | entry.environmentId === environmentId && | ||
| 280 | (resource._tag === "attachment" || entry.thread.id === resource.threadId), | ||
| 281 | ); | ||
| 282 | for (const entry of entries) { | ||
| 283 | const manifest = yield* readManifest(entry.manifestHash); | ||
| 284 | const sourcePath = isWindowsAbsolutePath(entry.project.workspaceRoot) | ||
| 285 | ? windowsPath | ||
| 286 | : path; | ||
| 287 | const requested = | ||
| 288 | resource._tag === "attachment" | ||
| 289 | ? undefined | ||
| 290 | : sourcePath.resolve( | ||
| 291 | entry.thread.worktreePath ?? entry.project.workspaceRoot, | ||
| 292 | resource.path, | ||
| 293 | ); | ||
| 294 | const found = manifest.assets.find((asset) => | ||
| 295 | resource._tag === "attachment" | ||
| 296 | ? parseAttachmentIdFromRelativePath(sourcePath.basename(asset.path)) === | ||
| 297 | resource.attachmentId | ||
| 298 | : sourcePath.normalize(asset.path) === requested, | ||
| 299 | ); | ||
| 300 | if (!found) continue; | ||
| 301 | yield* verifiedFile(found.hash); | ||
| 302 | const fileName = | ||
| 303 | resource._tag === "attachment" | ||
| 304 | ? (resource.fileName ?? sourcePath.basename(found.path)) | ||
| 305 | : sourcePath.basename(found.path); | ||
| 306 | const mimeType = | ||
| 307 | hostPreviewMimeTypeFromExtension(sourcePath.extname(found.path)) ?? | ||
| 308 | hostPreviewMimeTypeFromExtension(sourcePath.extname(fileName)) ?? | ||
| 309 | "application/octet-stream"; | ||
| 310 | return { | ||
| 311 | threadId: entry.thread.id, | ||
| 312 | manifestHash: entry.manifestHash, | ||
| 313 | hash: found.hash, | ||
| 314 | fileName, | ||
| 315 | mimeType, | ||
| 316 | download: | ||
| 317 | resource._tag === "attachment" && | ||
| 318 | resource.disposition !== "inline" && | ||
| 319 | !/^(image|video)\//u.test(mimeType), | ||
| 320 | ...(resource._tag === "attachment" && resource.renderIntent === "mcp-app" | ||
| 321 | ? { renderIntent: "mcp-app" as const } | ||
| 322 | : {}), | ||
| 323 | }; | ||
| 324 | } | ||
| 325 | return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 326 | }).pipe(Effect.mapError(storageError)), | ||
| 327 | openAsset: (environmentId, threadId, manifestHash, hash) => | ||
| 328 | Effect.gen(function* () { | ||
| 329 | const entry = yield* get(environmentId, threadId); | ||
| 330 | if (entry.manifestHash !== manifestHash) | ||
| 331 | return yield* new ArchiveError({ reason: "changed" }); | ||
| 332 | const manifest = yield* readManifest(manifestHash); | ||
| 333 | if (!manifest.assets.some((asset) => asset.hash === hash)) | ||
| 334 | return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 335 | return yield* objects.file(hash); | ||
| 336 | }).pipe(Effect.mapError(storageError)), | ||
| 337 | withdraw: (environmentId, threadId, manifestHash) => | ||
| 338 | Effect.scoped( | ||
| 339 | lock | ||
| 340 | .withPermit( | ||
| 341 | Effect.gen(function* () { | ||
| 342 | const dir = yield* directory; | ||
| 343 | const file = entryPath(dir, environmentId, threadId); | ||
| 344 | if (!(yield* fs.exists(file))) return; | ||
| 345 | const current = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeEntry)); | ||
| 346 | if (current.manifestHash !== manifestHash) return; | ||
| 347 | yield* fs.remove(entryPath(dir, environmentId, threadId), { force: true }); | ||
| 348 | if (!(yield* HostProcess.isWindows)) | ||
| 349 | yield* fs.open(dir).pipe(Effect.flatMap((file) => file.sync)); | ||
| 350 | }), | ||
| 351 | ) | ||
| 352 | .pipe(Effect.mapError(storageError)), | ||
| 353 | ), | ||
| 354 | }); | ||
| 355 | }), | ||
| 356 | ); | ||
apps/server/src/archive/ArchiveClient.ts created+216| ... | @@ -0,0 +1,216 @@ | ||
| 1 | import { ArchiveError, ArchiveObjectHash, ArchiveObjectReceipt } from "@t3tools/contracts"; | ||
| 2 | // Incremental hashes keep media uploads bounded; Effect Crypto hashes whole buffers. | ||
| 3 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 4 | import * as NodeCrypto from "node:crypto"; | ||
| 5 | import * as Context from "effect/Context"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Redacted from "effect/Redacted"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as Stream from "effect/Stream"; | ||
| 13 | import type * as Scope from "effect/Scope"; | ||
| 14 | import { HttpClient, HttpClientRequest, HttpClientResponse, FetchHttpClient } from "effect/http"; | ||
| 15 | import * as ArchiveConnection from "./ArchiveConnection.ts"; | ||
| 16 | import * as ArchiveCatalog from "./ArchiveCatalog.ts"; | ||
| 17 | import { | ||
| 18 | ArchiveCatalogEntry, | ||
| 19 | type ArchivePublication, | ||
| 20 | type EnvironmentId, | ||
| 21 | type ThreadId, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 23 | import * as ArchiveObjects from "./ArchiveObjects.ts"; | ||
| 24 | |||
| 25 | const decodeHash = Schema.decodeEffect(ArchiveObjectHash); | ||
| 26 | const storageError = (cause: unknown) => | ||
| 27 | new ArchiveError({ reason: "storage", cause: Redacted.make(cause) }); | ||
| 28 | const connectionError = (cause: unknown) => | ||
| 29 | new ArchiveError({ reason: "unavailable", cause: Redacted.make(cause) }); | ||
| 30 | |||
| 31 | export class ArchiveClient extends Context.Service< | ||
| 32 | ArchiveClient, | ||
| 33 | { | ||
| 34 | readonly enabled: Effect.Effect<boolean, ArchiveError>; | ||
| 35 | readonly publish: ( | ||
| 36 | input: ArchivePublication, | ||
| 37 | ) => Effect.Effect<ArchiveCatalogEntry, ArchiveError>; | ||
| 38 | readonly withdraw: ( | ||
| 39 | environmentId: EnvironmentId, | ||
| 40 | threadId: ThreadId, | ||
| 41 | manifestHash: string, | ||
| 42 | ) => Effect.Effect<void, ArchiveError>; | ||
| 43 | readonly location: Effect.Effect<string, ArchiveError>; | ||
| 44 | readonly upload: ( | ||
| 45 | file: string, | ||
| 46 | ) => Effect.Effect<typeof ArchiveObjectReceipt.Type, ArchiveError>; | ||
| 47 | readonly open: ( | ||
| 48 | origin: string, | ||
| 49 | hash: string, | ||
| 50 | range?: string, | ||
| 51 | ) => Effect.Effect< | ||
| 52 | | { kind: "file"; path: string } | ||
| 53 | | { kind: "response"; response: HttpClientResponse.HttpClientResponse }, | ||
| 54 | ArchiveError, | ||
| 55 | Scope.Scope | ||
| 56 | >; | ||
| 57 | readonly download: ( | ||
| 58 | location: string, | ||
| 59 | hash: string, | ||
| 60 | destination: string, | ||
| 61 | ) => Effect.Effect<void, ArchiveError>; | ||
| 62 | } | ||
| 63 | >()("t3/archive/ArchiveClient") {} | ||
| 64 | |||
| 65 | export const layer = Layer.effect( | ||
| 66 | ArchiveClient, | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const fs = yield* FileSystem.FileSystem; | ||
| 69 | const http = yield* HttpClient.HttpClient; | ||
| 70 | const connection = yield* ArchiveConnection.ArchiveConnection; | ||
| 71 | const catalog = yield* ArchiveCatalog.ArchiveCatalog; | ||
| 72 | const local = yield* ArchiveObjects.ArchiveObjects; | ||
| 73 | const enabled = connection.enabled; | ||
| 74 | const target = connection.target; | ||
| 75 | const location = target.pipe( | ||
| 76 | Effect.flatMap((connection) => | ||
| 77 | Option.isSome(connection) | ||
| 78 | ? Effect.succeed(connection.value.url) | ||
| 79 | : Option.isSome(local.root) | ||
| 80 | ? Effect.succeed(`file:${local.root.value}`) | ||
| 81 | : Effect.fail(new ArchiveError({ reason: "unavailable" })), | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | const upload = Effect.fn("ArchiveClient.upload")(function* (file: string) { | ||
| 85 | const digest = NodeCrypto.createHash("sha256"); | ||
| 86 | yield* fs.stream(file).pipe( | ||
| 87 | Stream.runForEach((bytes) => | ||
| 88 | Effect.sync(() => { | ||
| 89 | digest.update(bytes); | ||
| 90 | }), | ||
| 91 | ), | ||
| 92 | Effect.mapError(storageError), | ||
| 93 | ); | ||
| 94 | const hash = digest.digest("hex"); | ||
| 95 | const connection = yield* target; | ||
| 96 | if (Option.isNone(connection)) | ||
| 97 | return yield* local.put(hash, fs.stream(file)).pipe(Effect.mapError(storageError)); | ||
| 98 | const receipt = yield* HttpClientRequest.put( | ||
| 99 | `${connection.value.url}/api/archive/objects/${hash}`, | ||
| 100 | ).pipe( | ||
| 101 | HttpClientRequest.setHeader("authorization", connection.value.authorization), | ||
| 102 | HttpClientRequest.bodyStream(fs.stream(file), { contentType: "application/octet-stream" }), | ||
| 103 | http.execute, | ||
| 104 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 105 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 106 | Effect.flatMap(HttpClientResponse.schemaBodyJson(ArchiveObjectReceipt)), | ||
| 107 | Effect.mapError(connectionError), | ||
| 108 | Effect.timeout("5 minutes"), | ||
| 109 | Effect.mapError(connectionError), | ||
| 110 | ); | ||
| 111 | const stat = yield* fs.stat(file).pipe(Effect.mapError(storageError)); | ||
| 112 | if (receipt.hash !== hash || BigInt(receipt.size) !== stat.size) | ||
| 113 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 114 | return receipt; | ||
| 115 | }); | ||
| 116 | const open = Effect.fn("ArchiveClient.open")(function* ( | ||
| 117 | origin: string, | ||
| 118 | input: string, | ||
| 119 | range?: string, | ||
| 120 | ) { | ||
| 121 | const hash = yield* decodeHash(input).pipe(Effect.mapError(storageError)); | ||
| 122 | if ((yield* location) !== origin) return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 123 | const connection = yield* target; | ||
| 124 | if (Option.isNone(connection)) | ||
| 125 | return { kind: "file" as const, path: yield* local.file(hash) }; | ||
| 126 | const response = yield* HttpClient.withScope(http) | ||
| 127 | .execute( | ||
| 128 | HttpClientRequest.get(`${connection.value.url}/api/archive/objects/${hash}`).pipe( | ||
| 129 | HttpClientRequest.setHeaders({ | ||
| 130 | authorization: connection.value.authorization, | ||
| 131 | ...(range ? { range } : {}), | ||
| 132 | "accept-encoding": "identity", | ||
| 133 | }), | ||
| 134 | ), | ||
| 135 | ) | ||
| 136 | .pipe( | ||
| 137 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 138 | Effect.timeout("10 seconds"), | ||
| 139 | Effect.mapError(connectionError), | ||
| 140 | ); | ||
| 141 | if (![200, 206, 416].includes(response.status)) | ||
| 142 | return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 143 | return { kind: "response" as const, response }; | ||
| 144 | }); | ||
| 145 | const download = Effect.fn("ArchiveClient.download")(function* ( | ||
| 146 | origin: string, | ||
| 147 | input: string, | ||
| 148 | destination: string, | ||
| 149 | ) { | ||
| 150 | const hash = yield* decodeHash(input).pipe(Effect.mapError(storageError)); | ||
| 151 | if ((yield* location) !== origin) return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 152 | const connection = yield* target; | ||
| 153 | const body: Stream.Stream<Uint8Array, unknown> = Option.isNone(connection) | ||
| 154 | ? fs.stream(yield* local.file(hash)) | ||
| 155 | : (yield* HttpClientRequest.get(`${connection.value.url}/api/archive/objects/${hash}`).pipe( | ||
| 156 | HttpClientRequest.setHeader("authorization", connection.value.authorization), | ||
| 157 | http.execute, | ||
| 158 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 159 | Effect.timeout("10 seconds"), | ||
| 160 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 161 | Effect.mapError(connectionError), | ||
| 162 | )).stream; | ||
| 163 | const digest = NodeCrypto.createHash("sha256"); | ||
| 164 | yield* body.pipe( | ||
| 165 | Stream.timeoutOrElse({ | ||
| 166 | duration: "10 seconds", | ||
| 167 | orElse: () => Stream.fail(new ArchiveError({ reason: "unavailable" })), | ||
| 168 | }), | ||
| 169 | Stream.tap((bytes) => | ||
| 170 | Effect.sync(() => { | ||
| 171 | digest.update(bytes); | ||
| 172 | }), | ||
| 173 | ), | ||
| 174 | Stream.run(fs.sink(destination, { flag: "wx", mode: 0o600 })), | ||
| 175 | Effect.mapError(connectionError), | ||
| 176 | ); | ||
| 177 | if (digest.digest("hex") !== hash) return yield* new ArchiveError({ reason: "integrity" }); | ||
| 178 | }); | ||
| 179 | const publish = (input: ArchivePublication) => | ||
| 180 | Effect.gen(function* () { | ||
| 181 | const connection = yield* target; | ||
| 182 | if (Option.isNone(connection)) return yield* catalog.publish(input); | ||
| 183 | return yield* HttpClientRequest.post(`${connection.value.url}/api/archive/catalog`).pipe( | ||
| 184 | HttpClientRequest.setHeader("authorization", connection.value.authorization), | ||
| 185 | HttpClientRequest.bodyJson(input), | ||
| 186 | Effect.flatMap(http.execute), | ||
| 187 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 188 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 189 | Effect.flatMap( | ||
| 190 | HttpClientResponse.schemaBodyJson(Schema.toCodecJson(ArchiveCatalogEntry)), | ||
| 191 | ), | ||
| 192 | Effect.timeout("5 minutes"), | ||
| 193 | Effect.mapError(connectionError), | ||
| 194 | ); | ||
| 195 | }); | ||
| 196 | const withdraw = (environmentId: EnvironmentId, threadId: ThreadId, manifestHash: string) => | ||
| 197 | Effect.gen(function* () { | ||
| 198 | const connection = yield* target; | ||
| 199 | if (Option.isNone(connection)) | ||
| 200 | return yield* catalog.withdraw(environmentId, threadId, manifestHash); | ||
| 201 | yield* HttpClientRequest.delete( | ||
| 202 | `${connection.value.url}/api/archive/catalog/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`, | ||
| 203 | ).pipe( | ||
| 204 | HttpClientRequest.setHeader("authorization", connection.value.authorization), | ||
| 205 | HttpClientRequest.bodyJson({ manifestHash }), | ||
| 206 | Effect.flatMap(http.execute), | ||
| 207 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 208 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 209 | Effect.mapError(connectionError), | ||
| 210 | Effect.timeout("10 seconds"), | ||
| 211 | Effect.mapError(connectionError), | ||
| 212 | ); | ||
| 213 | }); | ||
| 214 | return ArchiveClient.of({ enabled, location, upload, download, open, publish, withdraw }); | ||
| 215 | }), | ||
| 216 | ); | ||
apps/server/src/archive/ArchiveConnection.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import { ArchiveError } from "@t3tools/contracts"; | ||
| 2 | import * as Config from "effect/Config"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 8 | import * as SharedServer from "../environment/SharedServer.ts"; | ||
| 9 | |||
| 10 | export class ArchiveConnection extends Context.Service< | ||
| 11 | ArchiveConnection, | ||
| 12 | { | ||
| 13 | readonly enabled: Effect.Effect<boolean, ArchiveError>; | ||
| 14 | readonly target: Effect.Effect< | ||
| 15 | Option.Option<{ readonly url: string; readonly authorization: string }>, | ||
| 16 | ArchiveError | ||
| 17 | >; | ||
| 18 | } | ||
| 19 | >()("t3/archive/ArchiveConnection") {} | ||
| 20 | |||
| 21 | export const layerLegacy = Layer.effect( | ||
| 22 | ArchiveConnection, | ||
| 23 | Effect.gen(function* () { | ||
| 24 | const shared = yield* SharedServer.SharedServer; | ||
| 25 | const enabled = yield* Config.Boolean("T3CODE_ARCHIVE_OFFLOAD").pipe( | ||
| 26 | Config.withDefault(false), | ||
| 27 | Effect.orDie, | ||
| 28 | ); | ||
| 29 | return ArchiveConnection.of({ | ||
| 30 | enabled: Effect.succeed(enabled), | ||
| 31 | target: shared.connection.pipe( | ||
| 32 | Effect.map( | ||
| 33 | Option.map((connection) => ({ | ||
| 34 | url: connection.url, | ||
| 35 | authorization: `Bearer ${connection.token}`, | ||
| 36 | })), | ||
| 37 | ), | ||
| 38 | Effect.mapError( | ||
| 39 | (cause) => new ArchiveError({ reason: "unavailable", cause: Redacted.make(cause) }), | ||
| 40 | ), | ||
| 41 | ), | ||
| 42 | }); | ||
| 43 | }), | ||
| 44 | ); | ||
apps/server/src/archive/ArchiveFormat.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import { ArchiveObjectHash, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | export const payloadTables = ["messages", "turn_items", "subagents"] as const; | ||
| 5 | export const Table = Schema.Literals(payloadTables); | ||
| 6 | const Cell = Schema.Union([Schema.String, Schema.Number, Schema.Null]); | ||
| 7 | export const Row = Schema.Record(Schema.String, Cell); | ||
| 8 | export const Segment = Schema.Struct({ table: Table, rows: Schema.Array(Row) }); | ||
| 9 | export const Metadata = Schema.Array(Segment); | ||
| 10 | export const canonicalTables = [ | ||
| 11 | "threads", | ||
| 12 | "runs", | ||
| 13 | "run_attempts", | ||
| 14 | "nodes", | ||
| 15 | "subagents", | ||
| 16 | "provider_sessions", | ||
| 17 | "provider_session_bindings", | ||
| 18 | "provider_threads", | ||
| 19 | "provider_turns", | ||
| 20 | "runtime_requests", | ||
| 21 | "messages", | ||
| 22 | "plans", | ||
| 23 | "turn_items", | ||
| 24 | "checkpoint_scopes", | ||
| 25 | "checkpoints", | ||
| 26 | "context_handoffs", | ||
| 27 | "context_transfers", | ||
| 28 | ] as const; | ||
| 29 | export const CanonicalTable = Schema.Literals( | ||
| 30 | canonicalTables.map((name) => `orchestration_v2_projection_${name}`), | ||
| 31 | ); | ||
| 32 | export const Bundle = Schema.Struct({ | ||
| 33 | version: Schema.Literal(1), | ||
| 34 | threadId: ThreadId, | ||
| 35 | throughSequence: Schema.Number, | ||
| 36 | segments: Schema.Array(Schema.Struct({ table: Table, hash: ArchiveObjectHash })), | ||
| 37 | events: ArchiveObjectHash, | ||
| 38 | context: Schema.Array(Schema.Struct({ table: CanonicalTable, hash: ArchiveObjectHash })), | ||
| 39 | assets: Schema.Array(Schema.Struct({ path: Schema.String, hash: ArchiveObjectHash })), | ||
| 40 | }); | ||
apps/server/src/archive/ArchiveHttp.ts created+157| ... | @@ -0,0 +1,157 @@ | ||
| 1 | import { | ||
| 2 | ArchiveCatalogEntry, | ||
| 3 | ArchiveError, | ||
| 4 | ArchiveAssetCreateUrlInput, | ||
| 5 | AssetCreateUrlResult, | ||
| 6 | ArchiveObjectHash, | ||
| 7 | ArchiveCatalogResult, | ||
| 8 | ArchivePublication, | ||
| 9 | OrchestrationV2ThreadDetailSnapshot, | ||
| 10 | AuthArchiveReadScope, | ||
| 11 | AuthArchiveWriteScope, | ||
| 12 | EnvironmentId, | ||
| 13 | HostedAuthError, | ||
| 14 | ThreadId, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as Redacted from "effect/Redacted"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; | ||
| 21 | import * as HostedAuth from "../auth/HostedAuth.ts"; | ||
| 22 | import { archiveRouteErrors, authenticateRawRouteWithScope, assetFileResponse } from "../http.ts"; | ||
| 23 | import * as ArchiveAssets from "./ArchiveAssets.ts"; | ||
| 24 | import * as Option from "effect/Option"; | ||
| 25 | import * as ArchiveCatalog from "./ArchiveCatalog.ts"; | ||
| 26 | |||
| 27 | const json = <A, I>(schema: Schema.Codec<A, I>) => { | ||
| 28 | const encode = Schema.encodeEffect(Schema.toCodecJson(schema)); | ||
| 29 | return (value: A) => | ||
| 30 | encode(value).pipe( | ||
| 31 | Effect.flatMap((body) => | ||
| 32 | HttpServerResponse.json(body, { headers: { "cache-control": "no-store" } }), | ||
| 33 | ), | ||
| 34 | ); | ||
| 35 | }; | ||
| 36 | const decodeParams = Schema.decodeUnknownEffect( | ||
| 37 | Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId }), | ||
| 38 | ); | ||
| 39 | const params = HttpRouter.params.pipe(Effect.flatMap(decodeParams)); | ||
| 40 | const catalogJson = json(ArchiveCatalogResult); | ||
| 41 | const entryJson = json(ArchiveCatalogEntry); | ||
| 42 | const assetJson = json(AssetCreateUrlResult); | ||
| 43 | const snapshotJson = json(OrchestrationV2ThreadDetailSnapshot); | ||
| 44 | const isHostedAuthError = Schema.is(HostedAuthError); | ||
| 45 | const withdrawal = HttpServerRequest.schemaBodyJson( | ||
| 46 | Schema.Struct({ manifestHash: ArchiveObjectHash }), | ||
| 47 | ); | ||
| 48 | const errors = <E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) => | ||
| 49 | archiveRouteErrors(effect).pipe( | ||
| 50 | Effect.catch((error) => { | ||
| 51 | if (isHostedAuthError(error)) | ||
| 52 | return Effect.succeed(HttpServerResponse.empty({ status: 403 })); | ||
| 53 | if (Schema.isSchemaError(error)) | ||
| 54 | return Effect.succeed(HttpServerResponse.empty({ status: 400 })); | ||
| 55 | return Effect.fail(error); | ||
| 56 | }), | ||
| 57 | ); | ||
| 58 | |||
| 59 | export const layer = Layer.mergeAll( | ||
| 60 | HttpRouter.add( | ||
| 61 | "POST", | ||
| 62 | "/api/archive/assets", | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const session = yield* authenticateRawRouteWithScope(AuthArchiveReadScope); | ||
| 65 | const input = yield* HttpServerRequest.schemaBodyJson(ArchiveAssetCreateUrlInput); | ||
| 66 | return yield* (yield* ArchiveAssets.ArchiveAssets) | ||
| 67 | .createUrl(input.environmentId, input.resource, session.sessionId) | ||
| 68 | .pipe(Effect.flatMap(assetJson)); | ||
| 69 | }).pipe(errors), | ||
| 70 | ), | ||
| 71 | HttpRouter.add( | ||
| 72 | "GET", | ||
| 73 | "/api/archive/assets/*", | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 76 | const url = HttpServerRequest.toURL(request); | ||
| 77 | if (Option.isNone(url)) return HttpServerResponse.empty({ status: 404 }); | ||
| 78 | const suffix = url.value.pathname.slice("/api/archive/assets/".length); | ||
| 79 | const separator = suffix.indexOf("/"); | ||
| 80 | if (separator <= 0) return HttpServerResponse.empty({ status: 404 }); | ||
| 81 | const asset = yield* (yield* ArchiveAssets.ArchiveAssets).resolve(suffix.slice(0, separator)); | ||
| 82 | if (Option.isNone(asset)) return HttpServerResponse.empty({ status: 404 }); | ||
| 83 | return yield* assetFileResponse( | ||
| 84 | asset.value, | ||
| 85 | request.headers.range, | ||
| 86 | request.headers["if-range"], | ||
| 87 | request.method === "HEAD" ? "HEAD" : "GET", | ||
| 88 | ).pipe( | ||
| 89 | Effect.mapError( | ||
| 90 | (cause) => new ArchiveError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 91 | ), | ||
| 92 | Effect.map((response) => | ||
| 93 | HttpServerResponse.setHeaders(response, { | ||
| 94 | "cache-control": "private, no-store", | ||
| 95 | "content-type": | ||
| 96 | asset.value.mimeType === "text/html" | ||
| 97 | ? "text/html; charset=utf-8" | ||
| 98 | : asset.value.mimeType, | ||
| 99 | ...(asset.value.mimeType === "image/svg+xml" | ||
| 100 | ? { | ||
| 101 | "content-security-policy": | ||
| 102 | "default-src 'none'; style-src 'unsafe-inline'; sandbox", | ||
| 103 | } | ||
| 104 | : {}), | ||
| 105 | }), | ||
| 106 | ), | ||
| 107 | ); | ||
| 108 | }).pipe(errors), | ||
| 109 | ), | ||
| 110 | HttpRouter.add( | ||
| 111 | "GET", | ||
| 112 | "/api/archive/catalog", | ||
| 113 | Effect.gen(function* () { | ||
| 114 | yield* authenticateRawRouteWithScope(AuthArchiveReadScope); | ||
| 115 | return yield* (yield* ArchiveCatalog.ArchiveCatalog).list.pipe(Effect.flatMap(catalogJson)); | ||
| 116 | }).pipe(errors), | ||
| 117 | ), | ||
| 118 | HttpRouter.add( | ||
| 119 | "POST", | ||
| 120 | "/api/archive/catalog", | ||
| 121 | Effect.gen(function* () { | ||
| 122 | const session = yield* authenticateRawRouteWithScope(AuthArchiveWriteScope); | ||
| 123 | const input = yield* HttpServerRequest.schemaBodyJson(ArchivePublication); | ||
| 124 | yield* (yield* HostedAuth.HostedAuth).authorizeEnvironment(session, input.environmentId); | ||
| 125 | return yield* (yield* ArchiveCatalog.ArchiveCatalog) | ||
| 126 | .publish(input) | ||
| 127 | .pipe(Effect.flatMap(entryJson)); | ||
| 128 | }).pipe(errors), | ||
| 129 | ), | ||
| 130 | HttpRouter.add( | ||
| 131 | "GET", | ||
| 132 | "/api/archive/catalog/:environmentId/:threadId", | ||
| 133 | Effect.gen(function* () { | ||
| 134 | yield* authenticateRawRouteWithScope(AuthArchiveReadScope); | ||
| 135 | const input = yield* params; | ||
| 136 | return yield* (yield* ArchiveCatalog.ArchiveCatalog) | ||
| 137 | .read(input.environmentId, input.threadId) | ||
| 138 | .pipe(Effect.flatMap(snapshotJson)); | ||
| 139 | }).pipe(errors), | ||
| 140 | ), | ||
| 141 | HttpRouter.add( | ||
| 142 | "DELETE", | ||
| 143 | "/api/archive/catalog/:environmentId/:threadId", | ||
| 144 | Effect.gen(function* () { | ||
| 145 | const session = yield* authenticateRawRouteWithScope(AuthArchiveWriteScope); | ||
| 146 | const input = yield* params; | ||
| 147 | yield* (yield* HostedAuth.HostedAuth).authorizeEnvironment(session, input.environmentId); | ||
| 148 | const { manifestHash } = yield* withdrawal; | ||
| 149 | yield* (yield* ArchiveCatalog.ArchiveCatalog).withdraw( | ||
| 150 | input.environmentId, | ||
| 151 | input.threadId, | ||
| 152 | manifestHash, | ||
| 153 | ); | ||
| 154 | return HttpServerResponse.empty({ status: 204 }); | ||
| 155 | }).pipe(errors), | ||
| 156 | ), | ||
| 157 | ); | ||
apps/server/src/archive/ArchiveObjects.ts created+127| ... | @@ -0,0 +1,127 @@ | ||
| 1 | import { ArchiveError, ArchiveObjectHash, ArchiveObjectReceipt } from "@t3tools/contracts"; | ||
| 2 | // Incremental hashes keep media uploads bounded; Effect Crypto hashes whole buffers. | ||
| 3 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 4 | import * as NodeCrypto from "node:crypto"; | ||
| 5 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 6 | import * as Config from "effect/Config"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Path from "effect/Path"; | ||
| 13 | import * as Redacted from "effect/Redacted"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | |||
| 17 | const decodeHash = Schema.decodeEffect(ArchiveObjectHash); | ||
| 18 | const isArchiveError = Schema.is(ArchiveError); | ||
| 19 | const storageError = (cause: unknown) => | ||
| 20 | new ArchiveError({ reason: "storage", cause: Redacted.make(cause) }); | ||
| 21 | |||
| 22 | export class ArchiveObjects extends Context.Service< | ||
| 23 | ArchiveObjects, | ||
| 24 | { | ||
| 25 | readonly verifyDurableStore: Effect.Effect<void, ArchiveError>; | ||
| 26 | readonly directory: Effect.Effect<string, ArchiveError>; | ||
| 27 | readonly root: Option.Option<string>; | ||
| 28 | readonly file: (hash: string) => Effect.Effect<string, ArchiveError>; | ||
| 29 | readonly put: <E, R>( | ||
| 30 | hash: string, | ||
| 31 | body: Stream.Stream<Uint8Array, E, R>, | ||
| 32 | ) => Effect.Effect<typeof ArchiveObjectReceipt.Type, ArchiveError | E, R>; | ||
| 33 | } | ||
| 34 | >()("t3/archive/ArchiveObjects") {} | ||
| 35 | |||
| 36 | export const layer = Layer.effect( | ||
| 37 | ArchiveObjects, | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const fs = yield* FileSystem.FileSystem; | ||
| 40 | const path = yield* Path.Path; | ||
| 41 | const root = yield* Config.option(Config.String("T3CODE_ARCHIVE_DIR")).pipe(Effect.orDie); | ||
| 42 | const expectedStoreId = yield* Config.option(Config.String("T3CODE_ARCHIVE_STORE_ID")).pipe( | ||
| 43 | Effect.orDie, | ||
| 44 | ); | ||
| 45 | const directory = Effect.gen(function* () { | ||
| 46 | if (Option.isNone(root)) return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 47 | if (Option.isSome(expectedStoreId)) { | ||
| 48 | const actual = yield* fs | ||
| 49 | .readFileString(path.join(root.value, ".knife-archive-store-id")) | ||
| 50 | .pipe(Effect.mapError(storageError)); | ||
| 51 | if (actual.trim() !== expectedStoreId.value) | ||
| 52 | return yield* new ArchiveError({ reason: "storage" }); | ||
| 53 | } | ||
| 54 | const stat = yield* fs.stat(root.value).pipe(Effect.mapError(storageError)); | ||
| 55 | if (stat.type !== "Directory") return yield* new ArchiveError({ reason: "storage" }); | ||
| 56 | const dir = path.join(root.value, "objects"); | ||
| 57 | yield* fs | ||
| 58 | .makeDirectory(dir, { recursive: true, mode: 0o700 }) | ||
| 59 | .pipe(Effect.mapError(storageError)); | ||
| 60 | return dir; | ||
| 61 | }); | ||
| 62 | const file = Effect.fn("ArchiveObjects.file")(function* (input: string) { | ||
| 63 | const hash = yield* decodeHash(input).pipe(Effect.mapError(storageError)); | ||
| 64 | const name = path.join(yield* directory, hash); | ||
| 65 | const stat = yield* fs.stat(name).pipe(Effect.mapError(storageError)); | ||
| 66 | if (stat.type !== "File") return yield* new ArchiveError({ reason: "storage" }); | ||
| 67 | return name; | ||
| 68 | }); | ||
| 69 | const put = <E, R>(input: string, body: Stream.Stream<Uint8Array, E, R>) => | ||
| 70 | Effect.scoped( | ||
| 71 | Effect.gen(function* () { | ||
| 72 | const hash = yield* decodeHash(input).pipe(Effect.mapError(storageError)); | ||
| 73 | const dir = yield* directory; | ||
| 74 | const temporary = yield* fs | ||
| 75 | .makeTempDirectoryScoped({ directory: dir, prefix: ".upload-" }) | ||
| 76 | .pipe(Effect.mapError(storageError)); | ||
| 77 | const staged = path.join(temporary, "object"); | ||
| 78 | const digest = NodeCrypto.createHash("sha256"); | ||
| 79 | let size = 0; | ||
| 80 | yield* body.pipe( | ||
| 81 | Stream.tap((bytes) => | ||
| 82 | Effect.sync(() => { | ||
| 83 | digest.update(bytes); | ||
| 84 | size += bytes.length; | ||
| 85 | }), | ||
| 86 | ), | ||
| 87 | Stream.run(fs.sink(staged, { flag: "wx", mode: 0o600 })), | ||
| 88 | Effect.mapError((cause) => (isArchiveError(cause) ? cause : storageError(cause))), | ||
| 89 | ); | ||
| 90 | if (digest.digest("hex") !== hash) | ||
| 91 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 92 | const verified = NodeCrypto.createHash("sha256"); | ||
| 93 | yield* fs.stream(staged).pipe( | ||
| 94 | Stream.runForEach((bytes) => | ||
| 95 | Effect.sync(() => { | ||
| 96 | verified.update(bytes); | ||
| 97 | }), | ||
| 98 | ), | ||
| 99 | Effect.mapError(storageError), | ||
| 100 | ); | ||
| 101 | if (verified.digest("hex") !== hash) | ||
| 102 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 103 | yield* fs.open(staged, { flag: "r+" }).pipe( | ||
| 104 | Effect.flatMap((file) => file.sync), | ||
| 105 | Effect.mapError(storageError), | ||
| 106 | ); | ||
| 107 | yield* fs.rename(staged, path.join(dir, hash)).pipe(Effect.mapError(storageError)); | ||
| 108 | if (!(yield* HostProcess.isWindows)) | ||
| 109 | yield* fs.open(dir).pipe( | ||
| 110 | Effect.flatMap((file) => file.sync), | ||
| 111 | Effect.mapError(storageError), | ||
| 112 | ); | ||
| 113 | return { hash, size }; | ||
| 114 | }), | ||
| 115 | ); | ||
| 116 | return ArchiveObjects.of({ | ||
| 117 | root, | ||
| 118 | directory, | ||
| 119 | file, | ||
| 120 | put, | ||
| 121 | verifyDurableStore: Effect.gen(function* () { | ||
| 122 | if (Option.isNone(expectedStoreId)) return yield* new ArchiveError({ reason: "storage" }); | ||
| 123 | yield* directory; | ||
| 124 | }), | ||
| 125 | }); | ||
| 126 | }), | ||
| 127 | ); | ||
apps/server/src/archive/ArchiveObjectsHttp.test.ts created+92| ... | @@ -0,0 +1,92 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | AuthArchiveReadScope, | ||
| 5 | AuthArchiveWriteScope, | ||
| 6 | AuthOrchestrationReadScope, | ||
| 7 | AuthOrchestrationOperateScope, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 10 | import * as Crypto from "effect/Crypto"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import { HttpRouter } from "effect/http"; | ||
| 15 | import * as ServerConfig from "../config.ts"; | ||
| 16 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 17 | import { layerArchiveUploadRoute, layerArchiveDownloadRoute } from "../http.ts"; | ||
| 18 | import * as ArchiveObjects from "./ArchiveObjects.ts"; | ||
| 19 | |||
| 20 | it.live( | ||
| 21 | "requires archive scopes, acknowledges verified bytes, and serves authenticated byte ranges", | ||
| 22 | () => | ||
| 23 | Effect.gen(function* () { | ||
| 24 | const fs = yield* FileSystem.FileSystem; | ||
| 25 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "knife-archive-http-" }); | ||
| 26 | const layers = Layer.mergeAll(ArchiveObjects.layer, EnvironmentAuth.layerRuntime).pipe( | ||
| 27 | Layer.provideMerge( | ||
| 28 | ServerConfig.layerTest(process.cwd(), { prefix: "knife-archive-http-state-" }), | ||
| 29 | ), | ||
| 30 | Layer.provide( | ||
| 31 | ConfigProvider.layer(ConfigProvider.fromUnknown({ T3CODE_ARCHIVE_DIR: root })), | ||
| 32 | ), | ||
| 33 | ); | ||
| 34 | yield* Effect.gen(function* () { | ||
| 35 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 36 | const read = yield* auth.issueSession({ scopes: [AuthArchiveReadScope] }); | ||
| 37 | const legacy = yield* auth.issueSession({ | ||
| 38 | scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], | ||
| 39 | }); | ||
| 40 | const write = yield* auth.issueSession({ scopes: [AuthArchiveWriteScope] }); | ||
| 41 | const context = yield* Effect.context< | ||
| 42 | EnvironmentAuth.EnvironmentAuth | ArchiveObjects.ArchiveObjects | FileSystem.FileSystem | ||
| 43 | >(); | ||
| 44 | const app = yield* Effect.acquireRelease( | ||
| 45 | Effect.sync(() => | ||
| 46 | HttpRouter.toWebHandler( | ||
| 47 | Layer.merge(layerArchiveUploadRoute, layerArchiveDownloadRoute), | ||
| 48 | { disableLogger: true }, | ||
| 49 | ), | ||
| 50 | ), | ||
| 51 | (app) => Effect.promise(() => app.dispose()), | ||
| 52 | ); | ||
| 53 | const bytes = new TextEncoder().encode("0123456789"); | ||
| 54 | const hash = Buffer.from(yield* (yield* Crypto.Crypto).digest("SHA-256", bytes)).toString( | ||
| 55 | "hex", | ||
| 56 | ); | ||
| 57 | const request = (method: string, token?: string, range?: string, objectHash = hash) => | ||
| 58 | Effect.promise(() => | ||
| 59 | app.handler( | ||
| 60 | new Request(`http://127.0.0.1/api/archive/objects/${objectHash}`, { | ||
| 61 | method, | ||
| 62 | headers: { | ||
| 63 | ...(token ? { authorization: `Bearer ${token}` } : {}), | ||
| 64 | ...(range ? { range } : {}), | ||
| 65 | }, | ||
| 66 | ...(method === "PUT" ? { body: bytes } : {}), | ||
| 67 | }), | ||
| 68 | context, | ||
| 69 | ), | ||
| 70 | ); | ||
| 71 | assert.strictEqual((yield* request("PUT")).status, 401); | ||
| 72 | assert.strictEqual((yield* request("PUT", read.token)).status, 403); | ||
| 73 | assert.strictEqual((yield* request("PUT", legacy.token)).status, 200); | ||
| 74 | assert.strictEqual((yield* request("GET", legacy.token)).status, 200); | ||
| 75 | const uploaded = yield* request("PUT", write.token); | ||
| 76 | assert.strictEqual(uploaded.status, 200); | ||
| 77 | assert.deepStrictEqual(yield* Effect.promise(() => uploaded.json()), { hash, size: 10 }); | ||
| 78 | assert.strictEqual( | ||
| 79 | (yield* request("PUT", write.token, undefined, "0".repeat(64))).status, | ||
| 80 | 400, | ||
| 81 | ); | ||
| 82 | assert.strictEqual((yield* request("GET", write.token)).status, 403); | ||
| 83 | const partial = yield* request("GET", read.token, "bytes=3-6"); | ||
| 84 | assert.strictEqual(partial.status, 206); | ||
| 85 | assert.strictEqual(partial.headers.get("content-range"), "bytes 3-6/10"); | ||
| 86 | assert.strictEqual(yield* Effect.promise(() => partial.text()), "3456"); | ||
| 87 | assert.strictEqual((yield* request("GET", read.token, "bytes=20-")).status, 416); | ||
| 88 | const full = yield* request("GET", read.token); | ||
| 89 | assert.strictEqual(yield* Effect.promise(() => full.text()), "0123456789"); | ||
| 90 | }).pipe(Effect.provide(layers)); | ||
| 91 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 92 | ); | ||
apps/server/src/archive/ArchiveStorage.ts created+164| ... | @@ -0,0 +1,164 @@ | ||
| 1 | import { ArchiveError, type ArchiveStatus } from "@t3tools/contracts"; | ||
| 2 | // Allocated filesystem blocks are unavailable through Effect FileSystem. | ||
| 3 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 4 | import * as NodeFSP from "node:fs/promises"; | ||
| 5 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 6 | import * as Config from "effect/Config"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Clock from "effect/Clock"; | ||
| 9 | import * as Context from "effect/Context"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Path from "effect/Path"; | ||
| 13 | import * as Redacted from "effect/Redacted"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 16 | import * as ServerConfig from "../config.ts"; | ||
| 17 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 18 | |||
| 19 | const storageError = (cause: unknown) => | ||
| 20 | new ArchiveError({ reason: "storage", cause: Redacted.make(cause) }); | ||
| 21 | const isMissingFile = Schema.is(Schema.Struct({ code: Schema.Literal("ENOENT") })); | ||
| 22 | const Home = Schema.Struct({ | ||
| 23 | homePath: Schema.optionalKey(Schema.String), | ||
| 24 | shadowHomePath: Schema.optionalKey(Schema.String), | ||
| 25 | }); | ||
| 26 | const decodeHome = Schema.decodeOption(Home); | ||
| 27 | |||
| 28 | export class ArchiveStorage extends Context.Service< | ||
| 29 | ArchiveStorage, | ||
| 30 | { | ||
| 31 | readonly inspect: Effect.Effect<ArchiveStatus["storage"], ArchiveError>; | ||
| 32 | readonly reclaim: Effect.Effect<void, ArchiveError>; | ||
| 33 | } | ||
| 34 | >()("t3/archive/ArchiveStorage") {} | ||
| 35 | |||
| 36 | export const layer = Layer.effect( | ||
| 37 | ArchiveStorage, | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const config = yield* ServerConfig.ServerConfig; | ||
| 40 | const settings = yield* ServerSettings.ServerSettingsService; | ||
| 41 | const home = yield* HostProcess.HomeDirectory; | ||
| 42 | const path = yield* Path.Path; | ||
| 43 | const sql = yield* SqlClient.SqlClient; | ||
| 44 | const fs = yield* FileSystem.FileSystem; | ||
| 45 | const inheritedCodexHome = yield* Config.option(Config.String("CODEX_HOME")).pipe(Effect.orDie); | ||
| 46 | const inheritedClaudeHome = yield* Config.option(Config.String("CLAUDE_CONFIG_DIR")).pipe( | ||
| 47 | Effect.orDie, | ||
| 48 | ); | ||
| 49 | const measure = (roots: ReadonlyArray<string>) => | ||
| 50 | Effect.tryPromise({ | ||
| 51 | try: async () => { | ||
| 52 | let bytes = 0; | ||
| 53 | let allocated = 0; | ||
| 54 | const visited = new Set<string>(); | ||
| 55 | const pending = [...roots]; | ||
| 56 | while (pending.length) { | ||
| 57 | const file = pending.pop()!; | ||
| 58 | let stat; | ||
| 59 | try { | ||
| 60 | stat = await NodeFSP.lstat(file); | ||
| 61 | } catch (error) { | ||
| 62 | if (isMissingFile(error)) continue; | ||
| 63 | throw error; | ||
| 64 | } | ||
| 65 | if (stat.isSymbolicLink()) continue; | ||
| 66 | const identity = `${stat.dev}:${stat.ino}`; | ||
| 67 | if (visited.has(identity)) continue; | ||
| 68 | visited.add(identity); | ||
| 69 | if (stat.isDirectory()) | ||
| 70 | for (const name of await NodeFSP.readdir(file)) pending.push(path.join(file, name)); | ||
| 71 | else if (stat.isFile()) { | ||
| 72 | bytes += stat.size; | ||
| 73 | allocated += stat.blocks * 512; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | return { bytes, allocated }; | ||
| 77 | }, | ||
| 78 | catch: storageError, | ||
| 79 | }); | ||
| 80 | const databaseFiles = Effect.gen(function* () { | ||
| 81 | const databases = yield* sql<{ name: string; file: string }>`PRAGMA database_list`; | ||
| 82 | const file = databases.find((database) => database.name === "main")?.file; | ||
| 83 | return file ? [file, `${file}-wal`, `${file}-shm`] : []; | ||
| 84 | }); | ||
| 85 | const inspect = Effect.gen(function* () { | ||
| 86 | const configured = yield* settings.getSettings; | ||
| 87 | const roots = [config.providerLogsDir]; | ||
| 88 | const homes = new Map<string, Set<string>>([ | ||
| 89 | [ | ||
| 90 | "codex", | ||
| 91 | new Set([ | ||
| 92 | path.join(home, ".codex"), | ||
| 93 | path.join(config.stateDir, "providers/codex/shared"), | ||
| 94 | ]), | ||
| 95 | ], | ||
| 96 | [ | ||
| 97 | "claudeAgent", | ||
| 98 | new Set([ | ||
| 99 | path.join(home, ".claude"), | ||
| 100 | path.join(config.stateDir, "providers/claudeAgent/shared"), | ||
| 101 | ]), | ||
| 102 | ], | ||
| 103 | ]); | ||
| 104 | if (inheritedCodexHome._tag === "Some") | ||
| 105 | homes.get("codex")!.add(path.resolve(inheritedCodexHome.value)); | ||
| 106 | if (inheritedClaudeHome._tag === "Some") | ||
| 107 | homes.get("claudeAgent")!.add(path.resolve(inheritedClaudeHome.value)); | ||
| 108 | for (const [id, instance] of Object.entries(configured.providerInstances)) { | ||
| 109 | if (instance.driver !== "codex" && instance.driver !== "claudeAgent") continue; | ||
| 110 | const decoded = decodeHome(instance.config ?? {}); | ||
| 111 | const values = | ||
| 112 | decoded._tag === "Some" ? [decoded.value.homePath, decoded.value.shadowHomePath] : []; | ||
| 113 | for (const variable of instance.environment ?? []) | ||
| 114 | if (variable.name === (instance.driver === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR")) | ||
| 115 | values.push(variable.value); | ||
| 116 | if (instance.driver === "codex" && id !== "codex") | ||
| 117 | values.push(path.join(config.stateDir, "providers", "codex", id, "shadow")); | ||
| 118 | for (const value of values) { | ||
| 119 | const raw = value?.trim(); | ||
| 120 | if (!raw) continue; | ||
| 121 | const resolved = raw.startsWith("~/") ? path.join(home, raw.slice(2)) : path.resolve(raw); | ||
| 122 | homes.get(instance.driver)!.add(resolved); | ||
| 123 | } | ||
| 124 | } | ||
| 125 | for (const root of homes.get("codex")!) { | ||
| 126 | for (const directory of ["sessions", "archived_sessions", "sqlite", "logs", "log"]) | ||
| 127 | roots.push(path.join(root, directory)); | ||
| 128 | if (!(yield* fs.exists(root))) continue; | ||
| 129 | for (const name of yield* fs.readDirectory(root)) | ||
| 130 | if ( | ||
| 131 | /^(?:(?:state|logs)_\d+\.sqlite(?:-wal|-shm)?|history\.jsonl|session_index\.jsonl)$/.test( | ||
| 132 | name, | ||
| 133 | ) | ||
| 134 | ) | ||
| 135 | roots.push(path.join(root, name)); | ||
| 136 | } | ||
| 137 | for (const root of homes.get("claudeAgent")!) roots.push(path.join(root, "projects")); | ||
| 138 | const database = yield* measure(yield* databaseFiles); | ||
| 139 | const attachments = yield* measure([config.attachmentsDir]); | ||
| 140 | const native = yield* measure(roots); | ||
| 141 | return { | ||
| 142 | allocatedDatabaseBytes: database.allocated, | ||
| 143 | attachmentsBytes: attachments.bytes, | ||
| 144 | nativeProviderLogBytes: native.bytes, | ||
| 145 | }; | ||
| 146 | }).pipe(Effect.mapError(storageError)); | ||
| 147 | const reclaim = Effect.gen(function* () { | ||
| 148 | const pending = yield* sql<{ | ||
| 149 | pending: number; | ||
| 150 | }>`SELECT pending FROM transcript_archive_reclamation WHERE singleton = 1`; | ||
| 151 | if (!pending[0]?.pending) return; | ||
| 152 | const files = yield* databaseFiles; | ||
| 153 | const before = yield* measure(files); | ||
| 154 | // VACUUM runs on the existing SQLite writer, after the verified offload transaction commits. | ||
| 155 | yield* sql`VACUUM`; | ||
| 156 | const checkpoint = yield* sql<{ busy: number }>`PRAGMA wal_checkpoint(TRUNCATE)`; | ||
| 157 | if (checkpoint[0]?.busy) return yield* new ArchiveError({ reason: "storage" }); | ||
| 158 | const after = yield* measure(files); | ||
| 159 | const now = yield* Clock.currentTimeMillis; | ||
| 160 | yield* sql`UPDATE transcript_archive_reclamation SET pending = 0, completed_at = ${now}, before_allocated_bytes = ${before.allocated}, after_allocated_bytes = ${after.allocated} WHERE singleton = 1`; | ||
| 161 | }).pipe(Effect.mapError(storageError)); | ||
| 162 | return ArchiveStorage.of({ inspect, reclaim }); | ||
| 163 | }), | ||
| 164 | ); | ||
apps/server/src/archive/ArchivedProjectionStore.ts created+80| ... | @@ -0,0 +1,80 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as Stream from "effect/Stream"; | ||
| 4 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ||
| 5 | import * as TranscriptArchive from "./TranscriptArchive.ts"; | ||
| 6 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export const layer = Layer.effect( | ||
| 9 | ProjectionStore.ProjectionStoreV2, | ||
| 10 | Effect.gen(function* () { | ||
| 11 | const base = yield* ProjectionStore.ProjectionStoreV2; | ||
| 12 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 13 | const read = <A>( | ||
| 14 | id: ThreadId, | ||
| 15 | effect: Effect.Effect<A, ProjectionStore.ProjectionStoreV2Error>, | ||
| 16 | includeHistory = false, | ||
| 17 | ) => | ||
| 18 | archive | ||
| 19 | .read(id, effect, includeHistory) | ||
| 20 | .pipe( | ||
| 21 | Effect.mapError( | ||
| 22 | (cause) => new ProjectionStore.ProjectionStoreReadError({ threadId: id, cause }), | ||
| 23 | ), | ||
| 24 | ); | ||
| 25 | return ProjectionStore.ProjectionStoreV2.of({ | ||
| 26 | ...base, | ||
| 27 | apply: (event) => { | ||
| 28 | const restore = | ||
| 29 | event.type === "thread.unarchived" || | ||
| 30 | ["message.updated", "turn-item.updated", "subagent.updated"].includes(event.type); | ||
| 31 | return restore | ||
| 32 | ? archive.restore(event.threadId).pipe( | ||
| 33 | Effect.mapError( | ||
| 34 | (cause) => | ||
| 35 | new ProjectionStore.ProjectionStoreApplyEventError({ | ||
| 36 | eventType: event.type, | ||
| 37 | cause, | ||
| 38 | }), | ||
| 39 | ), | ||
| 40 | Effect.andThen(base.apply(event)), | ||
| 41 | ) | ||
| 42 | : base.apply(event); | ||
| 43 | }, | ||
| 44 | getThreadProjection: (id) => read(id, base.getThreadProjection(id), true), | ||
| 45 | getThreadRecords: (id, fields, filter) => | ||
| 46 | fields.some((field) => ["messages", "turnItems", "subagents"].includes(field)) | ||
| 47 | ? read(id, base.getThreadRecords(id, fields, filter)) | ||
| 48 | : base.getThreadRecords(id, fields, filter), | ||
| 49 | getThreadSnapshot: (id) => read(id, base.getThreadSnapshot(id), true), | ||
| 50 | getThreadSnapshotWindow: (id, options) => | ||
| 51 | read(id, base.getThreadSnapshotWindow(id, options), true), | ||
| 52 | getThreadHistoryPage: (id, cursor, through, conversation) => | ||
| 53 | read(id, base.getThreadHistoryPage(id, cursor, through, conversation), true), | ||
| 54 | getTimelinePage: (id, options) => read(id, base.getTimelinePage(id, options), true), | ||
| 55 | getTurnItem: (input) => read(input.threadId, base.getTurnItem(input)), | ||
| 56 | getThreadAttachmentIds: (id) => read(id, base.getThreadAttachmentIds(id)), | ||
| 57 | getTurnStartContext: (id, run) => read(id, base.getTurnStartContext(id, run)), | ||
| 58 | getTurnStartHistory: (id, runs) => read(id, base.getTurnStartHistory(id, runs)), | ||
| 59 | getRuntimeRecoveryProjection: (id) => read(id, base.getRuntimeRecoveryProjection(id)), | ||
| 60 | getPendingNativeUserInputs: (id, turn) => read(id, base.getPendingNativeUserInputs(id, turn)), | ||
| 61 | |||
| 62 | getProviderControlContext: (id, target) => | ||
| 63 | read(id, base.getProviderControlContext(id, target)), | ||
| 64 | |||
| 65 | getRuntimeResponseContext: (id, request) => | ||
| 66 | read(id, base.getRuntimeResponseContext(id, request)), | ||
| 67 | getRunMessage: (id, run) => read(id, base.getRunMessage(id, run)), | ||
| 68 | searchThread: (input) => read(input.threadId, base.searchThread(input), true), | ||
| 69 | searchThreadStream: (input) => | ||
| 70 | archive | ||
| 71 | .readStream(input.threadId, base.searchThreadStream(input)) | ||
| 72 | .pipe( | ||
| 73 | Stream.mapError( | ||
| 74 | (cause) => | ||
| 75 | new ProjectionStore.ProjectionStoreReadError({ threadId: input.threadId, cause }), | ||
| 76 | ), | ||
| 77 | ), | ||
| 78 | }); | ||
| 79 | }), | ||
| 80 | ).pipe(Layer.provide(ProjectionStore.layer)); | ||
apps/server/src/archive/HostedArchiveConnection.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { ArchiveError } from "@t3tools/contracts"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as SourceConnection from "../environment/SourceConnection.ts"; | ||
| 7 | import * as ArchiveConnection from "./ArchiveConnection.ts"; | ||
| 8 | |||
| 9 | export const layer = Layer.effect( | ||
| 10 | ArchiveConnection.ArchiveConnection, | ||
| 11 | Effect.gen(function* () { | ||
| 12 | const legacy = yield* ArchiveConnection.ArchiveConnection; | ||
| 13 | const source = yield* SourceConnection.SourceConnection; | ||
| 14 | return ArchiveConnection.ArchiveConnection.of({ | ||
| 15 | enabled: source.current.pipe( | ||
| 16 | Effect.flatMap((current) => | ||
| 17 | Option.isSome(current) ? Effect.succeed(true) : legacy.enabled, | ||
| 18 | ), | ||
| 19 | ), | ||
| 20 | target: Effect.gen(function* () { | ||
| 21 | const current = yield* source.current; | ||
| 22 | if (Option.isNone(current)) return yield* legacy.target; | ||
| 23 | const now = yield* DateTime.now; | ||
| 24 | if (current.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) | ||
| 25 | return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 26 | return Option.some({ | ||
| 27 | url: current.value.serverUrl, | ||
| 28 | authorization: current.value.authorization, | ||
| 29 | }); | ||
| 30 | }), | ||
| 31 | }); | ||
| 32 | }), | ||
| 33 | ).pipe(Layer.provide(ArchiveConnection.layerLegacy)); | ||
apps/server/src/archive/TranscriptArchive.test.ts created+1438| ... | @@ -0,0 +1,1438 @@ | ||
| 1 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { assert, it } from "@effect/vitest"; | ||
| 4 | import { | ||
| 5 | AssetCreateUrlResult, | ||
| 6 | ArchiveCatalogResult, | ||
| 7 | AuthAdministrativeScopes, | ||
| 8 | AuthSessionId, | ||
| 9 | HubError, | ||
| 10 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 11 | OrchestrationV2ThreadDetailSnapshot, | ||
| 12 | AuthArchiveReadScope, | ||
| 13 | AuthArchiveWriteScope, | ||
| 14 | AuthOrchestrationReadScope, | ||
| 15 | ArchiveError, | ||
| 16 | EnvironmentId, | ||
| 17 | EventId, | ||
| 18 | MessageId, | ||
| 19 | ProjectId, | ||
| 20 | ProviderInstanceId, | ||
| 21 | ThreadId, | ||
| 22 | TurnItemId, | ||
| 23 | ChatAttachmentId, | ||
| 24 | RunId, | ||
| 25 | NodeId, | ||
| 26 | ProviderDriverKind, | ||
| 27 | type OrchestrationV2AppThread, | ||
| 28 | } from "@t3tools/contracts"; | ||
| 29 | import * as TestClock from "effect/testing/TestClock"; | ||
| 30 | import * as ArchiveCatalog from "./ArchiveCatalog.ts"; | ||
| 31 | import * as Hub from "../environment/Hub.ts"; | ||
| 32 | import * as HubInbox from "../environment/HubInbox.ts"; | ||
| 33 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 34 | import * as HostedAuth from "../auth/HostedAuth.ts"; | ||
| 35 | import * as HostedAuthConfig from "../auth/HostedAuthConfig.ts"; | ||
| 36 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 37 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 38 | import * as ArchiveAssets from "./ArchiveAssets.ts"; | ||
| 39 | import * as ArchiveHttp from "./ArchiveHttp.ts"; | ||
| 40 | import * as Option from "effect/Option"; | ||
| 41 | import * as Redacted from "effect/Redacted"; | ||
| 42 | import * as Schema from "effect/Schema"; | ||
| 43 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 44 | import * as DateTime from "effect/DateTime"; | ||
| 45 | import * as Deferred from "effect/Deferred"; | ||
| 46 | import * as Fiber from "effect/Fiber"; | ||
| 47 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 48 | import * as ArchiveConnection from "./ArchiveConnection.ts"; | ||
| 49 | import * as SharedServer from "../environment/SharedServer.ts"; | ||
| 50 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 51 | import * as ArchiveClient from "./ArchiveClient.ts"; | ||
| 52 | import * as Effect from "effect/Effect"; | ||
| 53 | import * as FileSystem from "effect/FileSystem"; | ||
| 54 | import * as Layer from "effect/Layer"; | ||
| 55 | import * as Path from "effect/Path"; | ||
| 56 | import * as PlatformError from "effect/PlatformError"; | ||
| 57 | import * as Crypto from "effect/Crypto"; | ||
| 58 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 59 | import * as Stream from "effect/Stream"; | ||
| 60 | import { HttpClient, HttpRouter } from "effect/http"; | ||
| 61 | import * as ServerConfig from "../config.ts"; | ||
| 62 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 63 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 64 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ||
| 65 | import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; | ||
| 66 | import * as EventStore from "../orchestration-v2/EventStore.ts"; | ||
| 67 | import * as EventSink from "../orchestration-v2/EventSink.ts"; | ||
| 68 | import * as ApplicationEvents from "../persistence/OrchestrationEventStore.ts"; | ||
| 69 | import * as ArchivedProjectionStore from "./ArchivedProjectionStore.ts"; | ||
| 70 | import * as ArchiveObjects from "./ArchiveObjects.ts"; | ||
| 71 | import * as ArchiveRuntime from "./runtimeLayer.ts"; | ||
| 72 | import * as TranscriptArchive from "./TranscriptArchive.ts"; | ||
| 73 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | ||
| 74 | import { createDeterministicAttachmentId } from "../attachmentStore.ts"; | ||
| 75 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "../assets/AssetAccess.ts"; | ||
| 76 | import * as NativeAppIconResolver from "../assets/NativeAppIconResolver.ts"; | ||
| 77 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | ||
| 78 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | ||
| 79 | |||
| 80 | const decodeAssetUrl = Schema.decodeUnknownEffect(AssetCreateUrlResult); | ||
| 81 | const decodeCatalog = Schema.decodeUnknownEffect(Schema.toCodecJson(ArchiveCatalogResult)); | ||
| 82 | const decodeSnapshot = Schema.decodeUnknownEffect( | ||
| 83 | Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot), | ||
| 84 | ); | ||
| 85 | const threadId = ThreadId.make("archive-review"); | ||
| 86 | const fixture = (physical = false) => | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const fs = yield* FileSystem.FileSystem; | ||
| 89 | const path = yield* Path.Path; | ||
| 90 | const temporary = yield* fs.makeTempDirectoryScoped({ prefix: "knife-archive-test-" }); | ||
| 91 | const root = path.join(temporary, "nas"); | ||
| 92 | yield* fs.makeDirectory(root); | ||
| 93 | yield* fs.writeFileString(path.join(root, ".knife-archive-store-id"), "archive-test-store"); | ||
| 94 | const config = yield* ServerConfig.ServerConfig.pipe( | ||
| 95 | Effect.provide(ServerConfig.layerTest(temporary, { prefix: "knife-archive-test-" })), | ||
| 96 | ); | ||
| 97 | const database = physical | ||
| 98 | ? SqlitePersistence.layerFromPath(path.join(temporary, "source.sqlite")) | ||
| 99 | : SqlitePersistence.layerMemory; | ||
| 100 | const runtime = ArchiveRuntime.layer.pipe( | ||
| 101 | Layer.provide(Layer.succeed(HostProcess.HomeDirectory, temporary)), | ||
| 102 | Layer.provide(ArchiveConnection.layerLegacy.pipe(Layer.provide(SharedServer.layer))), | ||
| 103 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 104 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 105 | Layer.provideMerge(Layer.succeed(ServerConfig.ServerConfig, config)), | ||
| 106 | Layer.provideMerge(database), | ||
| 107 | Layer.provide( | ||
| 108 | Layer.succeed( | ||
| 109 | HttpClient.HttpClient, | ||
| 110 | HttpClient.make(() => Effect.die("Unexpected HTTP request")), | ||
| 111 | ), | ||
| 112 | ), | ||
| 113 | Layer.provide( | ||
| 114 | ConfigProvider.layer( | ||
| 115 | ConfigProvider.fromUnknown({ | ||
| 116 | T3CODE_ARCHIVE_DIR: root, | ||
| 117 | T3CODE_ARCHIVE_STORE_ID: "archive-test-store", | ||
| 118 | T3CODE_ARCHIVE_OFFLOAD: "true", | ||
| 119 | }), | ||
| 120 | ), | ||
| 121 | ), | ||
| 122 | ); | ||
| 123 | const stores = Layer.mergeAll( | ||
| 124 | ArchivedProjectionStore.layer, | ||
| 125 | EventStore.layerFromOrchestrationEventStore.pipe(Layer.provideMerge(ApplicationEvents.layer)), | ||
| 126 | ).pipe(Layer.provideMerge(runtime)); | ||
| 127 | const layer = Layer.mergeAll( | ||
| 128 | stores, | ||
| 129 | EventSink.layer.pipe(Layer.provide(stores)), | ||
| 130 | ProjectionMaintenance.layer.pipe(Layer.provide(ProjectionStore.layer), Layer.provide(stores)), | ||
| 131 | ); | ||
| 132 | return { layer, root, path, temporary }; | ||
| 133 | }); | ||
| 134 | |||
| 135 | const seed = Effect.gen(function* () { | ||
| 136 | const sink = yield* EventSink.EventSinkV2; | ||
| 137 | const now = yield* DateTime.now; | ||
| 138 | const instanceId = ProviderInstanceId.make("codex"); | ||
| 139 | yield* (yield* SqlClient.SqlClient)`INSERT OR IGNORE INTO projection_projects(project_id, title, workspace_root, scripts_json, created_at, updated_at) VALUES(${ProjectId.make("archive-project")}, 'Archive workspace', '/archive/workspace', '[]', ${DateTime.formatIso(now)}, ${DateTime.formatIso(now)})`; | ||
| 140 | const thread: OrchestrationV2AppThread = { | ||
| 141 | createdBy: "user", | ||
| 142 | creationSource: "web", | ||
| 143 | id: threadId, | ||
| 144 | projectId: ProjectId.make("archive-project"), | ||
| 145 | title: "Searchable archive title", | ||
| 146 | providerInstanceId: instanceId, | ||
| 147 | modelSelection: { instanceId, model: "model" }, | ||
| 148 | runtimeMode: "full-access", | ||
| 149 | interactionMode: "default", | ||
| 150 | branch: null, | ||
| 151 | worktreePath: null, | ||
| 152 | activeProviderThreadId: null, | ||
| 153 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | ||
| 154 | forkedFrom: null, | ||
| 155 | createdAt: now, | ||
| 156 | updatedAt: now, | ||
| 157 | archivedAt: null, | ||
| 158 | settledOverride: null, | ||
| 159 | settledAt: null, | ||
| 160 | lastVisitedAt: null, | ||
| 161 | deletedAt: null, | ||
| 162 | }; | ||
| 163 | yield* sink.write({ | ||
| 164 | events: [ | ||
| 165 | { | ||
| 166 | id: EventId.make("archive-created"), | ||
| 167 | type: "thread.created", | ||
| 168 | threadId, | ||
| 169 | occurredAt: now, | ||
| 170 | payload: thread, | ||
| 171 | }, | ||
| 172 | { | ||
| 173 | id: EventId.make("archive-message"), | ||
| 174 | type: "message.updated", | ||
| 175 | threadId, | ||
| 176 | occurredAt: now, | ||
| 177 | payload: { | ||
| 178 | id: MessageId.make("archive-message"), | ||
| 179 | threadId, | ||
| 180 | runId: null, | ||
| 181 | nodeId: null, | ||
| 182 | role: "assistant", | ||
| 183 | createdBy: "agent", | ||
| 184 | creationSource: "provider", | ||
| 185 | text: "private archived message body", | ||
| 186 | attachments: [], | ||
| 187 | streaming: false, | ||
| 188 | createdAt: now, | ||
| 189 | updatedAt: now, | ||
| 190 | }, | ||
| 191 | }, | ||
| 192 | { | ||
| 193 | id: EventId.make("archive-item"), | ||
| 194 | type: "turn-item.updated", | ||
| 195 | threadId, | ||
| 196 | occurredAt: now, | ||
| 197 | payload: { | ||
| 198 | id: TurnItemId.make("archive-item"), | ||
| 199 | threadId, | ||
| 200 | runId: null, | ||
| 201 | nodeId: null, | ||
| 202 | type: "assistant_message", | ||
| 203 | providerThreadId: null, | ||
| 204 | providerTurnId: null, | ||
| 205 | nativeItemRef: null, | ||
| 206 | parentItemId: null, | ||
| 207 | title: null, | ||
| 208 | startedAt: now, | ||
| 209 | completedAt: now, | ||
| 210 | streaming: false, | ||
| 211 | status: "completed", | ||
| 212 | ordinal: 1, | ||
| 213 | messageId: MessageId.make("archive-message"), | ||
| 214 | text: "private archived message body", | ||
| 215 | updatedAt: now, | ||
| 216 | }, | ||
| 217 | }, | ||
| 218 | { | ||
| 219 | id: EventId.make("archive-archived"), | ||
| 220 | type: "thread.archived", | ||
| 221 | threadId, | ||
| 222 | occurredAt: now, | ||
| 223 | payload: { ...thread, archivedAt: now }, | ||
| 224 | }, | ||
| 225 | ], | ||
| 226 | }); | ||
| 227 | return thread; | ||
| 228 | }); | ||
| 229 | |||
| 230 | it.layer(NodeServices.layer)("transcript archive", (it) => { | ||
| 231 | it.effect("reads the durable catalog after the source database is closed", () => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const f = yield* fixture(); | ||
| 234 | const environmentId = yield* Effect.gen(function* () { | ||
| 235 | yield* seed; | ||
| 236 | yield* (yield* TranscriptArchive.TranscriptArchive).offload(threadId); | ||
| 237 | return yield* (yield* ServerEnvironment.ServerEnvironmentIdentity).getEnvironmentId; | ||
| 238 | }).pipe(Effect.provide(f.layer)); | ||
| 239 | const central = ArchiveCatalog.layer.pipe( | ||
| 240 | Layer.provideMerge(ArchiveObjects.layer), | ||
| 241 | Layer.provide( | ||
| 242 | ConfigProvider.layer(ConfigProvider.fromUnknown({ T3CODE_ARCHIVE_DIR: f.root })), | ||
| 243 | ), | ||
| 244 | ); | ||
| 245 | yield* Effect.gen(function* () { | ||
| 246 | const catalog = yield* ArchiveCatalog.ArchiveCatalog; | ||
| 247 | const entries = (yield* catalog.list).entries; | ||
| 248 | assert.strictEqual(entries.length, 1); | ||
| 249 | assert.strictEqual(entries[0]?.environmentId, environmentId); | ||
| 250 | const snapshot = yield* catalog.read(environmentId, threadId); | ||
| 251 | assert.strictEqual(snapshot.projection.messages[0]?.text, "private archived message body"); | ||
| 252 | assert.strictEqual( | ||
| 253 | snapshot.projection.turnItems.find((item) => item.type === "assistant_message")?.text, | ||
| 254 | "private archived message body", | ||
| 255 | ); | ||
| 256 | assert.isNotNull(snapshot.projection.thread.archivedAt); | ||
| 257 | const descriptor = { | ||
| 258 | environmentId, | ||
| 259 | label: "Sleeping laptop", | ||
| 260 | platform: { os: "linux" as const, arch: "x64" as const }, | ||
| 261 | serverVersion: "test", | ||
| 262 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | ||
| 263 | capabilities: { repositoryIdentity: false }, | ||
| 264 | }; | ||
| 265 | const hub = Hub.Hub.of({ | ||
| 266 | directory: () => | ||
| 267 | Effect.succeed({ | ||
| 268 | hubEnvironmentId: EnvironmentId.make("central-nas"), | ||
| 269 | environments: [ | ||
| 270 | { | ||
| 271 | environmentId, | ||
| 272 | label: descriptor.label, | ||
| 273 | httpBaseUrl: "https://archive.example.test/environments/source", | ||
| 274 | scopes: AuthAdministrativeScopes, | ||
| 275 | descriptor, | ||
| 276 | }, | ||
| 277 | ], | ||
| 278 | }), | ||
| 279 | authorize: () => Effect.succeed(AuthAdministrativeScopes), | ||
| 280 | connect: () => Effect.fail(new HubError({ reason: "upstream" })), | ||
| 281 | descriptor: () => Effect.succeed(descriptor), | ||
| 282 | manage: () => Effect.die("Unexpected management"), | ||
| 283 | }); | ||
| 284 | const discovered = yield* Effect.gen(function* () { | ||
| 285 | const inbox = yield* HubInbox.HubInbox; | ||
| 286 | const sql = yield* SqlClient.SqlClient; | ||
| 287 | assert.strictEqual((yield* sql`SELECT 1 FROM hub_inbox`).length, 0); | ||
| 288 | return yield* inbox.read({ | ||
| 289 | sessionId: AuthSessionId.make("fresh-desktop"), | ||
| 290 | subject: "owner", | ||
| 291 | method: "bearer-access-token", | ||
| 292 | scopes: AuthAdministrativeScopes, | ||
| 293 | }); | ||
| 294 | }).pipe( | ||
| 295 | Effect.provide( | ||
| 296 | HubInbox.layer.pipe( | ||
| 297 | Layer.provide(Layer.succeed(Hub.Hub, hub)), | ||
| 298 | Layer.provide(Layer.succeed(ArchiveCatalog.ArchiveCatalog, catalog)), | ||
| 299 | Layer.provide( | ||
| 300 | Layer.succeed( | ||
| 301 | HttpClient.HttpClient, | ||
| 302 | HttpClient.make(() => Effect.die("Source must not be contacted")), | ||
| 303 | ), | ||
| 304 | ), | ||
| 305 | Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), | ||
| 306 | ), | ||
| 307 | ), | ||
| 308 | ); | ||
| 309 | assert.strictEqual(discovered.environments[0]?.environmentId, environmentId); | ||
| 310 | assert.strictEqual(discovered.environments[0]?.snapshot.archivedThreads[0]?.id, threadId); | ||
| 311 | assert.strictEqual( | ||
| 312 | discovered.environments[0]?.snapshot.projects[0]?.id, | ||
| 313 | ProjectId.make("archive-project"), | ||
| 314 | ); | ||
| 315 | assert.strictEqual( | ||
| 316 | discovered.environments[0]?.snapshot.projects[0]?.workspaceRoot, | ||
| 317 | "/archive/workspace", | ||
| 318 | ); | ||
| 319 | assert.strictEqual( | ||
| 320 | (yield* catalog.read(EnvironmentId.make("other-source"), threadId).pipe(Effect.flip)) | ||
| 321 | .reason, | ||
| 322 | "unavailable", | ||
| 323 | ); | ||
| 324 | const fs = yield* FileSystem.FileSystem; | ||
| 325 | yield* fs.writeFileString( | ||
| 326 | f.path.join(f.root, "objects", entries[0]!.manifestHash), | ||
| 327 | "corrupt", | ||
| 328 | ); | ||
| 329 | assert.strictEqual( | ||
| 330 | (yield* catalog.read(environmentId, threadId).pipe(Effect.flip)).reason, | ||
| 331 | "integrity", | ||
| 332 | ); | ||
| 333 | }).pipe(Effect.provide(central)); | ||
| 334 | }), | ||
| 335 | ); | ||
| 336 | |||
| 337 | it.effect( | ||
| 338 | "keeps source payloads when publication cannot verify the complete object closure", | ||
| 339 | () => | ||
| 340 | Effect.gen(function* () { | ||
| 341 | const f = yield* fixture(); | ||
| 342 | yield* Effect.gen(function* () { | ||
| 343 | yield* seed; | ||
| 344 | const client = yield* ArchiveClient.ArchiveClient; | ||
| 345 | const fs = yield* FileSystem.FileSystem; | ||
| 346 | const archive = yield* TranscriptArchive.TranscriptArchive.pipe( | ||
| 347 | Effect.provide(Layer.fresh(TranscriptArchive.layer)), | ||
| 348 | Effect.provideService(ArchiveClient.ArchiveClient, { | ||
| 349 | ...client, | ||
| 350 | publish: (input) => | ||
| 351 | Effect.gen(function* () { | ||
| 352 | const bundle = JSON.parse( | ||
| 353 | yield* fs.readFileString(f.path.join(f.root, "objects", input.manifestHash)), | ||
| 354 | ); | ||
| 355 | yield* fs.remove(f.path.join(f.root, "objects", bundle.events)); | ||
| 356 | return yield* client.publish(input); | ||
| 357 | }), | ||
| 358 | }), | ||
| 359 | ); | ||
| 360 | assert.strictEqual( | ||
| 361 | (yield* archive.offload(threadId).pipe(Effect.flip)).reason, | ||
| 362 | "storage", | ||
| 363 | ); | ||
| 364 | assert.strictEqual( | ||
| 365 | (yield* (yield* SqlClient.SqlClient)`SELECT 1 FROM transcript_archives`).length, | ||
| 366 | 0, | ||
| 367 | ); | ||
| 368 | assert.strictEqual( | ||
| 369 | (yield* (yield* ProjectionStore.ProjectionStoreV2).getThreadProjection(threadId)) | ||
| 370 | .messages[0]?.text, | ||
| 371 | "private archived message body", | ||
| 372 | ); | ||
| 373 | assert.strictEqual( | ||
| 374 | (yield* (yield* ArchiveCatalog.ArchiveCatalog).list).entries.length, | ||
| 375 | 0, | ||
| 376 | ); | ||
| 377 | }).pipe(Effect.provide(f.layer)); | ||
| 378 | }), | ||
| 379 | ); | ||
| 380 | |||
| 381 | it.effect("persists failed uploads across restart and retries after their backoff", () => | ||
| 382 | Effect.gen(function* () { | ||
| 383 | const f = yield* fixture(true); | ||
| 384 | const expectedEnvironmentId = yield* Effect.gen(function* () { | ||
| 385 | yield* seed; | ||
| 386 | const client = yield* ArchiveClient.ArchiveClient; | ||
| 387 | const archive = yield* TranscriptArchive.TranscriptArchive.pipe( | ||
| 388 | Effect.provide(Layer.fresh(TranscriptArchive.layer)), | ||
| 389 | Effect.provideService(ArchiveClient.ArchiveClient, { | ||
| 390 | ...client, | ||
| 391 | upload: () => Effect.fail(new ArchiveError({ reason: "unavailable" })), | ||
| 392 | }), | ||
| 393 | ); | ||
| 394 | yield* archive.sweep; | ||
| 395 | const status = yield* archive.manage({ type: "archive.status" }); | ||
| 396 | assert.strictEqual(status.failures[0]?.attempts, 1); | ||
| 397 | assert.strictEqual(status.offloaded, 0); | ||
| 398 | return yield* (yield* ServerEnvironment.ServerEnvironmentIdentity).getEnvironmentId; | ||
| 399 | }).pipe(Effect.provide(f.layer)); | ||
| 400 | yield* Effect.gen(function* () { | ||
| 401 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 402 | assert.strictEqual( | ||
| 403 | yield* (yield* ServerEnvironment.ServerEnvironmentIdentity).getEnvironmentId, | ||
| 404 | expectedEnvironmentId, | ||
| 405 | ); | ||
| 406 | yield* archive.sweep; | ||
| 407 | assert.strictEqual( | ||
| 408 | (yield* archive.manage({ type: "archive.status" })).failures[0]?.attempts, | ||
| 409 | 1, | ||
| 410 | ); | ||
| 411 | yield* TestClock.adjust("1 minute"); | ||
| 412 | yield* archive.sweep; | ||
| 413 | assert.strictEqual((yield* archive.manage({ type: "archive.status" })).offloaded, 1); | ||
| 414 | }).pipe(Effect.provide(Layer.fresh(f.layer))); | ||
| 415 | }), | ||
| 416 | ); | ||
| 417 | |||
| 418 | it.effect( | ||
| 419 | "reclaims allocated SQLite payload bytes and retains measured native provider logs", | ||
| 420 | () => | ||
| 421 | Effect.gen(function* () { | ||
| 422 | const f = yield* fixture(true); | ||
| 423 | const body = "large archived body ".repeat(100_000); | ||
| 424 | yield* Effect.gen(function* () { | ||
| 425 | yield* seed; | ||
| 426 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 427 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 428 | const sql = yield* SqlClient.SqlClient; | ||
| 429 | const fs = yield* FileSystem.FileSystem; | ||
| 430 | const config = yield* ServerConfig.ServerConfig; | ||
| 431 | const message = (yield* projections.getThreadProjection(threadId)).messages[0]!; | ||
| 432 | yield* (yield* EventSink.EventSinkV2).write({ | ||
| 433 | events: [ | ||
| 434 | { | ||
| 435 | id: EventId.make("large-body"), | ||
| 436 | type: "message.updated", | ||
| 437 | threadId, | ||
| 438 | occurredAt: yield* DateTime.now, | ||
| 439 | payload: { ...message, text: body }, | ||
| 440 | }, | ||
| 441 | ], | ||
| 442 | }); | ||
| 443 | yield* fs.makeDirectory(config.providerLogsDir, { recursive: true }); | ||
| 444 | const logFile = f.path.join(config.providerLogsDir, "provider.jsonl"); | ||
| 445 | yield* fs.writeFileString(logFile, "native provider log stays local"); | ||
| 446 | yield* sql`PRAGMA wal_checkpoint(TRUNCATE)`; | ||
| 447 | const before = yield* archive.manage({ type: "archive.status" }); | ||
| 448 | yield* archive.sweep; | ||
| 449 | const after = yield* archive.manage({ type: "archive.status" }); | ||
| 450 | assert.isAbove( | ||
| 451 | before.storage.allocatedDatabaseBytes, | ||
| 452 | after.storage.allocatedDatabaseBytes, | ||
| 453 | ); | ||
| 454 | assert.isAbove( | ||
| 455 | after.lastReclamation!.beforeAllocatedBytes, | ||
| 456 | after.lastReclamation!.afterAllocatedBytes, | ||
| 457 | ); | ||
| 458 | assert.strictEqual( | ||
| 459 | after.storage.nativeProviderLogBytes, | ||
| 460 | Buffer.byteLength("native provider log stays local"), | ||
| 461 | ); | ||
| 462 | assert.strictEqual(yield* fs.readFileString(logFile), "native provider log stays local"); | ||
| 463 | assert.strictEqual( | ||
| 464 | (yield* projections.getThreadProjection(threadId)).messages[0]?.text, | ||
| 465 | body, | ||
| 466 | ); | ||
| 467 | yield* (yield* ProjectionMaintenance.ProjectionMaintenanceV2).rebuild; | ||
| 468 | assert.strictEqual( | ||
| 469 | (yield* projections.getThreadProjection(threadId)).messages[0]?.text, | ||
| 470 | body, | ||
| 471 | ); | ||
| 472 | }).pipe(Effect.provide(f.layer)); | ||
| 473 | yield* Effect.gen(function* () { | ||
| 474 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 475 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 476 | assert.strictEqual( | ||
| 477 | (yield* projections.getThreadProjection(threadId)).messages[0]?.text, | ||
| 478 | body, | ||
| 479 | ); | ||
| 480 | yield* archive.restore(threadId); | ||
| 481 | const thread = (yield* projections.getThreadProjection(threadId)).thread; | ||
| 482 | yield* (yield* EventSink.EventSinkV2).write({ | ||
| 483 | events: [ | ||
| 484 | { | ||
| 485 | id: EventId.make("restart-unarchive"), | ||
| 486 | type: "thread.unarchived", | ||
| 487 | threadId, | ||
| 488 | occurredAt: yield* DateTime.now, | ||
| 489 | payload: { ...thread, archivedAt: null }, | ||
| 490 | }, | ||
| 491 | ], | ||
| 492 | }); | ||
| 493 | const restored = yield* projections.getThreadProjection(threadId); | ||
| 494 | assert.isNull(restored.thread.archivedAt); | ||
| 495 | assert.strictEqual(restored.messages[0]?.text, body); | ||
| 496 | }).pipe(Effect.provide(Layer.fresh(f.layer))); | ||
| 497 | }), | ||
| 498 | ); | ||
| 499 | |||
| 500 | it.effect( | ||
| 501 | "enforces narrow HTTP archive scopes and the authenticated device environment binding", | ||
| 502 | () => | ||
| 503 | Effect.gen(function* () { | ||
| 504 | const f = yield* fixture(); | ||
| 505 | const settings = { | ||
| 506 | publicUrl: "https://archive.example.test", | ||
| 507 | issuer: "https://identity.example.test", | ||
| 508 | clientId: "archive-test", | ||
| 509 | clientSecret: Redacted.make("test-only"), | ||
| 510 | ownerSub: "test-owner", | ||
| 511 | maxInteractiveAgeSeconds: 3600, | ||
| 512 | issuerRevalidationSeconds: 3600, | ||
| 513 | }; | ||
| 514 | const layers = ArchiveAssets.layer.pipe( | ||
| 515 | Layer.provideMerge(NodeHttpPlatform.layer), | ||
| 516 | Layer.provideMerge( | ||
| 517 | HostedAuth.layer.pipe( | ||
| 518 | Layer.provideMerge(EnvironmentAuth.layer), | ||
| 519 | Layer.provide( | ||
| 520 | Layer.succeed(HostedAuthConfig.HostedAuthConfig, Option.some(settings)), | ||
| 521 | ), | ||
| 522 | Layer.provide( | ||
| 523 | Layer.succeed( | ||
| 524 | HttpClient.HttpClient, | ||
| 525 | HttpClient.make(() => Effect.die("Unexpected identity request")), | ||
| 526 | ), | ||
| 527 | ), | ||
| 528 | Layer.provideMerge(f.layer), | ||
| 529 | ), | ||
| 530 | ), | ||
| 531 | ); | ||
| 532 | yield* Effect.gen(function* () { | ||
| 533 | yield* seed; | ||
| 534 | const fs = yield* FileSystem.FileSystem; | ||
| 535 | const config = yield* ServerConfig.ServerConfig; | ||
| 536 | const imageId = ChatAttachmentId.make( | ||
| 537 | createDeterministicAttachmentId(threadId, "nas-image")!, | ||
| 538 | ); | ||
| 539 | const movieId = ChatAttachmentId.make( | ||
| 540 | `${createDeterministicAttachmentId(threadId, "nas-movie")!}-mp4`, | ||
| 541 | ); | ||
| 542 | yield* fs.makeDirectory(config.attachmentsDir, { recursive: true }); | ||
| 543 | const imageFile = f.path.join(config.attachmentsDir, `${imageId}.png`); | ||
| 544 | const movieFile = f.path.join(config.attachmentsDir, `${movieId}.mp4`); | ||
| 545 | yield* fs.writeFile(imageFile, new Uint8Array([137, 80, 78, 71, 1, 2, 3])); | ||
| 546 | yield* fs.writeFileString(movieFile, "0123456789"); | ||
| 547 | const message = (yield* (yield* ProjectionStore.ProjectionStoreV2).getThreadProjection( | ||
| 548 | threadId, | ||
| 549 | )).messages[0]!; | ||
| 550 | yield* (yield* EventSink.EventSinkV2).write({ | ||
| 551 | events: [ | ||
| 552 | { | ||
| 553 | id: EventId.make("nas-attachments"), | ||
| 554 | type: "message.updated", | ||
| 555 | threadId, | ||
| 556 | occurredAt: yield* DateTime.now, | ||
| 557 | payload: { | ||
| 558 | ...message, | ||
| 559 | attachments: [ | ||
| 560 | { | ||
| 561 | type: "image", | ||
| 562 | id: imageId, | ||
| 563 | name: "picture.png", | ||
| 564 | mimeType: "image/png", | ||
| 565 | sizeBytes: 7, | ||
| 566 | }, | ||
| 567 | { | ||
| 568 | type: "file", | ||
| 569 | id: movieId, | ||
| 570 | name: "movie.mp4", | ||
| 571 | mimeType: "video/mp4", | ||
| 572 | sizeBytes: 10, | ||
| 573 | }, | ||
| 574 | ], | ||
| 575 | }, | ||
| 576 | }, | ||
| 577 | ], | ||
| 578 | }); | ||
| 579 | yield* (yield* TranscriptArchive.TranscriptArchive).sweep; | ||
| 580 | assert.isFalse(yield* fs.exists(imageFile)); | ||
| 581 | assert.isFalse(yield* fs.exists(movieFile)); | ||
| 582 | const environmentId = yield* (yield* ServerEnvironment.ServerEnvironmentIdentity) | ||
| 583 | .getEnvironmentId; | ||
| 584 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 585 | const read = yield* auth.issueSession({ scopes: [AuthArchiveReadScope] }); | ||
| 586 | const write = yield* auth.issueSession({ scopes: [AuthArchiveWriteScope] }); | ||
| 587 | const legacy = yield* auth.issueSession({ scopes: [AuthOrchestrationReadScope] }); | ||
| 588 | const sql = yield* SqlClient.SqlClient; | ||
| 589 | const accountHash = Buffer.from( | ||
| 590 | yield* (yield* Crypto.Crypto).digest( | ||
| 591 | "SHA-256", | ||
| 592 | new TextEncoder().encode(`${settings.issuer}\0${settings.ownerSub}`), | ||
| 593 | ), | ||
| 594 | ).toString("base64url"); | ||
| 595 | const now = yield* DateTime.now; | ||
| 596 | yield* sql`INSERT INTO auth_devices(device_id, account_id, environment_id, client_json, created_at, expires_at, validated_at) VALUES('archive-device', ${accountHash}, ${environmentId}, '{}', ${now.epochMilliseconds}, ${now.epochMilliseconds + 3600000}, ${now.epochMilliseconds})`; | ||
| 597 | const bound = yield* (yield* SessionStore.SessionStore).issue({ | ||
| 598 | deviceId: "archive-device", | ||
| 599 | method: "bearer-access-token", | ||
| 600 | scopes: [AuthArchiveReadScope, AuthArchiveWriteScope], | ||
| 601 | }); | ||
| 602 | const context = yield* Effect.context< | ||
| 603 | Layer.Success<typeof layers> | FileSystem.FileSystem | ||
| 604 | >(); | ||
| 605 | const app = yield* Effect.acquireRelease( | ||
| 606 | Effect.sync(() => HttpRouter.toWebHandler(ArchiveHttp.layer, { disableLogger: true })), | ||
| 607 | (app) => Effect.promise(() => app.dispose()), | ||
| 608 | ); | ||
| 609 | const request = ( | ||
| 610 | method: string, | ||
| 611 | suffix: string, | ||
| 612 | token?: string, | ||
| 613 | body?: unknown, | ||
| 614 | range?: string, | ||
| 615 | ) => | ||
| 616 | Effect.promise(() => | ||
| 617 | app.handler( | ||
| 618 | new Request( | ||
| 619 | `https://archive.example.test${suffix.startsWith("/api/archive/") ? suffix : `/api/archive/catalog${suffix}`}`, | ||
| 620 | { | ||
| 621 | method, | ||
| 622 | headers: { | ||
| 623 | ...(token ? { authorization: `Bearer ${token}` } : {}), | ||
| 624 | ...(range ? { range } : {}), | ||
| 625 | ...(body ? { "content-type": "application/json" } : {}), | ||
| 626 | }, | ||
| 627 | ...(body ? { body: JSON.stringify(body) } : {}), | ||
| 628 | }, | ||
| 629 | ), | ||
| 630 | context, | ||
| 631 | ), | ||
| 632 | ); | ||
| 633 | assert.strictEqual((yield* request("GET", "")).status, 401); | ||
| 634 | assert.strictEqual((yield* request("GET", "", legacy.token)).status, 403); | ||
| 635 | assert.strictEqual((yield* request("GET", "", write.token)).status, 403); | ||
| 636 | const response = yield* request("GET", "", read.token); | ||
| 637 | assert.strictEqual(response.status, 200); | ||
| 638 | const catalog = yield* decodeCatalog(yield* Effect.promise(() => response.json())); | ||
| 639 | const entry = catalog.entries[0]!; | ||
| 640 | const publication = { | ||
| 641 | environmentId, | ||
| 642 | manifestHash: entry.manifestHash, | ||
| 643 | project: entry.project, | ||
| 644 | }; | ||
| 645 | assert.strictEqual((yield* request("POST", "", read.token, publication)).status, 403); | ||
| 646 | assert.strictEqual((yield* request("POST", "", write.token, publication)).status, 403); | ||
| 647 | assert.strictEqual( | ||
| 648 | (yield* request("POST", "", bound.token, { | ||
| 649 | ...publication, | ||
| 650 | environmentId: "other-environment", | ||
| 651 | })).status, | ||
| 652 | 403, | ||
| 653 | ); | ||
| 654 | assert.strictEqual((yield* request("POST", "", bound.token, publication)).status, 200); | ||
| 655 | const suffix = `/${encodeURIComponent(environmentId)}/${threadId}`; | ||
| 656 | const detail = yield* request("GET", suffix, read.token); | ||
| 657 | assert.strictEqual(detail.status, 200); | ||
| 658 | const snapshot = yield* decodeSnapshot(yield* Effect.promise(() => detail.json())); | ||
| 659 | assert.strictEqual( | ||
| 660 | snapshot.projection.messages[0]?.text, | ||
| 661 | "private archived message body", | ||
| 662 | ); | ||
| 663 | const assetInput = { | ||
| 664 | environmentId, | ||
| 665 | resource: { _tag: "attachment", attachmentId: imageId, fileName: "picture.png" }, | ||
| 666 | }; | ||
| 667 | assert.strictEqual( | ||
| 668 | (yield* request("POST", "/api/archive/assets", write.token, assetInput)).status, | ||
| 669 | 403, | ||
| 670 | ); | ||
| 671 | assert.strictEqual( | ||
| 672 | (yield* request("POST", "/api/archive/assets", read.token, { | ||
| 673 | ...assetInput, | ||
| 674 | environmentId: "other-environment", | ||
| 675 | })).status, | ||
| 676 | 503, | ||
| 677 | ); | ||
| 678 | assert.strictEqual( | ||
| 679 | (yield* request("POST", "/api/archive/assets", read.token, { | ||
| 680 | environmentId, | ||
| 681 | resource: { _tag: "media-file", threadId, path: "/etc/passwd" }, | ||
| 682 | })).status, | ||
| 683 | 503, | ||
| 684 | ); | ||
| 685 | const issued = yield* request("POST", "/api/archive/assets", bound.token, assetInput); | ||
| 686 | assert.strictEqual(issued.status, 200); | ||
| 687 | const imageUrl = (yield* decodeAssetUrl(yield* Effect.promise(() => issued.json()))) | ||
| 688 | .relativeUrl; | ||
| 689 | const image = yield* request("GET", imageUrl); | ||
| 690 | assert.strictEqual(image.status, 200); | ||
| 691 | assert.strictEqual(image.headers.get("content-type"), "image/png"); | ||
| 692 | assert.strictEqual(image.headers.get("cache-control"), "private, no-store"); | ||
| 693 | assert.deepEqual( | ||
| 694 | new Uint8Array(yield* Effect.promise(() => image.arrayBuffer())), | ||
| 695 | new Uint8Array([137, 80, 78, 71, 1, 2, 3]), | ||
| 696 | ); | ||
| 697 | const movieIssued = yield* request("POST", "/api/archive/assets", bound.token, { | ||
| 698 | environmentId, | ||
| 699 | resource: { _tag: "attachment", attachmentId: movieId, fileName: "movie.mp4" }, | ||
| 700 | }); | ||
| 701 | assert.strictEqual(movieIssued.status, 200); | ||
| 702 | const movieUrl = (yield* decodeAssetUrl(yield* Effect.promise(() => movieIssued.json()))) | ||
| 703 | .relativeUrl; | ||
| 704 | const movie = yield* request("GET", movieUrl, undefined, undefined, "bytes=3-6"); | ||
| 705 | assert.strictEqual(movie.status, 206); | ||
| 706 | assert.strictEqual(movie.headers.get("content-type"), "video/mp4"); | ||
| 707 | assert.strictEqual(movie.headers.get("content-range"), "bytes 3-6/10"); | ||
| 708 | assert.strictEqual(yield* Effect.promise(() => movie.text()), "3456"); | ||
| 709 | assert.strictEqual( | ||
| 710 | (yield* request("GET", movieUrl, undefined, undefined, "bytes=20-")).status, | ||
| 711 | 416, | ||
| 712 | ); | ||
| 713 | yield* auth.revokeSession(bound.sessionId); | ||
| 714 | assert.strictEqual((yield* request("GET", imageUrl)).status, 404); | ||
| 715 | const renewed = yield* (yield* SessionStore.SessionStore).issue({ | ||
| 716 | deviceId: "archive-device", | ||
| 717 | method: "bearer-access-token", | ||
| 718 | scopes: [AuthArchiveReadScope, AuthArchiveWriteScope], | ||
| 719 | }); | ||
| 720 | const renewedAsset = yield* request( | ||
| 721 | "POST", | ||
| 722 | "/api/archive/assets", | ||
| 723 | renewed.token, | ||
| 724 | assetInput, | ||
| 725 | ); | ||
| 726 | const renewedUrl = (yield* decodeAssetUrl( | ||
| 727 | yield* Effect.promise(() => renewedAsset.json()), | ||
| 728 | )).relativeUrl; | ||
| 729 | yield* TestClock.adjust("5 minutes"); | ||
| 730 | assert.strictEqual((yield* request("GET", renewedUrl)).status, 404); | ||
| 731 | assert.strictEqual( | ||
| 732 | (yield* request("DELETE", suffix, read.token, { manifestHash: entry.manifestHash })) | ||
| 733 | .status, | ||
| 734 | 403, | ||
| 735 | ); | ||
| 736 | assert.strictEqual( | ||
| 737 | (yield* request("DELETE", suffix, renewed.token, { manifestHash: "0".repeat(64) })) | ||
| 738 | .status, | ||
| 739 | 204, | ||
| 740 | ); | ||
| 741 | assert.strictEqual( | ||
| 742 | (yield* (yield* ArchiveCatalog.ArchiveCatalog).list).entries.length, | ||
| 743 | 1, | ||
| 744 | ); | ||
| 745 | assert.strictEqual( | ||
| 746 | (yield* request("DELETE", suffix, renewed.token, { manifestHash: entry.manifestHash })) | ||
| 747 | .status, | ||
| 748 | 204, | ||
| 749 | ); | ||
| 750 | assert.strictEqual( | ||
| 751 | (yield* (yield* ArchiveCatalog.ArchiveCatalog).list).entries.length, | ||
| 752 | 0, | ||
| 753 | ); | ||
| 754 | yield* sql`UPDATE auth_devices SET revoked_at = ${now.epochMilliseconds} WHERE device_id = 'archive-device'`; | ||
| 755 | assert.strictEqual((yield* request("POST", "", renewed.token, publication)).status, 401); | ||
| 756 | }).pipe(Effect.provide(layers)); | ||
| 757 | }), | ||
| 758 | ); | ||
| 759 | |||
| 760 | it.effect( | ||
| 761 | "rejects an older publication and keeps a newer archive when an old withdrawal arrives", | ||
| 762 | () => | ||
| 763 | Effect.gen(function* () { | ||
| 764 | const f = yield* fixture(); | ||
| 765 | yield* Effect.gen(function* () { | ||
| 766 | yield* seed; | ||
| 767 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 768 | const catalog = yield* ArchiveCatalog.ArchiveCatalog; | ||
| 769 | const client = yield* ArchiveClient.ArchiveClient; | ||
| 770 | yield* archive.offload(threadId); | ||
| 771 | const old = (yield* catalog.list).entries[0]!; | ||
| 772 | yield* archive.restore(threadId); | ||
| 773 | const message = (yield* (yield* ProjectionStore.ProjectionStoreV2).getThreadProjection( | ||
| 774 | threadId, | ||
| 775 | )).messages[0]!; | ||
| 776 | yield* (yield* EventSink.EventSinkV2).write({ | ||
| 777 | events: [ | ||
| 778 | { | ||
| 779 | id: EventId.make("newer-archive"), | ||
| 780 | type: "message.updated", | ||
| 781 | threadId, | ||
| 782 | occurredAt: yield* DateTime.now, | ||
| 783 | payload: { ...message, text: "newer archived body" }, | ||
| 784 | }, | ||
| 785 | ], | ||
| 786 | }); | ||
| 787 | yield* archive.offload(threadId); | ||
| 788 | const current = (yield* catalog.list).entries[0]!; | ||
| 789 | assert.isAbove(current.throughSequence, old.throughSequence); | ||
| 790 | assert.strictEqual( | ||
| 791 | (yield* client | ||
| 792 | .publish({ | ||
| 793 | environmentId: old.environmentId, | ||
| 794 | manifestHash: old.manifestHash, | ||
| 795 | project: old.project, | ||
| 796 | }) | ||
| 797 | .pipe(Effect.flip)).reason, | ||
| 798 | "changed", | ||
| 799 | ); | ||
| 800 | yield* catalog.withdraw(old.environmentId, threadId, old.manifestHash); | ||
| 801 | assert.strictEqual((yield* catalog.list).entries[0]?.manifestHash, current.manifestHash); | ||
| 802 | assert.strictEqual( | ||
| 803 | (yield* catalog.read(current.environmentId, threadId)).projection.messages[0]?.text, | ||
| 804 | "newer archived body", | ||
| 805 | ); | ||
| 806 | }).pipe(Effect.provide(f.layer)); | ||
| 807 | }), | ||
| 808 | ); | ||
| 809 | |||
| 810 | it.effect( | ||
| 811 | "rejects an absent or wrong provisioned storage identity before creating archive directories", | ||
| 812 | () => | ||
| 813 | Effect.gen(function* () { | ||
| 814 | const fs = yield* FileSystem.FileSystem; | ||
| 815 | const path = yield* Path.Path; | ||
| 816 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "knife-archive-identity-" }); | ||
| 817 | const layer = ArchiveObjects.layer.pipe( | ||
| 818 | Layer.provide( | ||
| 819 | ConfigProvider.layer( | ||
| 820 | ConfigProvider.fromUnknown({ | ||
| 821 | T3CODE_ARCHIVE_DIR: root, | ||
| 822 | T3CODE_ARCHIVE_STORE_ID: "test-store", | ||
| 823 | }), | ||
| 824 | ), | ||
| 825 | ), | ||
| 826 | ); | ||
| 827 | yield* Effect.gen(function* () { | ||
| 828 | const objects = yield* ArchiveObjects.ArchiveObjects; | ||
| 829 | assert.strictEqual((yield* objects.directory.pipe(Effect.flip)).reason, "storage"); | ||
| 830 | assert.isFalse(yield* fs.exists(path.join(root, "objects"))); | ||
| 831 | yield* fs.writeFileString(path.join(root, ".knife-archive-store-id"), "different-store"); | ||
| 832 | assert.strictEqual((yield* objects.directory.pipe(Effect.flip)).reason, "storage"); | ||
| 833 | assert.isFalse(yield* fs.exists(path.join(root, "objects"))); | ||
| 834 | yield* fs.writeFileString(path.join(root, ".knife-archive-store-id"), "test-store\n"); | ||
| 835 | assert.strictEqual(yield* objects.directory, path.join(root, "objects")); | ||
| 836 | }).pipe(Effect.provide(layer)); | ||
| 837 | }), | ||
| 838 | ); | ||
| 839 | |||
| 840 | it.effect("retains archived payloads while a run is queued, running, or awaiting input", () => | ||
| 841 | Effect.gen(function* () { | ||
| 842 | const f = yield* fixture(); | ||
| 843 | yield* Effect.gen(function* () { | ||
| 844 | yield* seed; | ||
| 845 | const sql = yield* SqlClient.SqlClient; | ||
| 846 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 847 | for (const status of ["preparing", "queued", "starting", "running", "waiting"]) { | ||
| 848 | yield* sql`INSERT INTO orchestration_v2_projection_runs(run_id, thread_id, ordinal, provider, status, requested_at, payload_json) VALUES('busy-run', ${threadId}, 1, 'codex', ${status}, '2026-01-01T00:00:00Z', '{}')`; | ||
| 849 | yield* archive.offload(threadId); | ||
| 850 | yield* archive.sweep; | ||
| 851 | assert.strictEqual((yield* sql`SELECT 1 FROM transcript_archives`).length, 0); | ||
| 852 | yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = 'busy-run'`; | ||
| 853 | } | ||
| 854 | assert.strictEqual( | ||
| 855 | (yield* (yield* ProjectionStore.ProjectionStoreV2).getThreadProjection(threadId)) | ||
| 856 | .messages[0]?.text, | ||
| 857 | "private archived message body", | ||
| 858 | ); | ||
| 859 | yield* archive.sweep; | ||
| 860 | assert.strictEqual((yield* sql`SELECT 1 FROM transcript_archives`).length, 1); | ||
| 861 | }).pipe(Effect.provide(f.layer)); | ||
| 862 | }), | ||
| 863 | ); | ||
| 864 | |||
| 865 | it.effect("rejects a corrupt archive without unarchiving or replacing local indexes", () => | ||
| 866 | Effect.gen(function* () { | ||
| 867 | const f = yield* fixture(); | ||
| 868 | yield* Effect.gen(function* () { | ||
| 869 | const thread = yield* seed; | ||
| 870 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 871 | const sql = yield* SqlClient.SqlClient; | ||
| 872 | const fs = yield* FileSystem.FileSystem; | ||
| 873 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 874 | yield* archive.offload(threadId); | ||
| 875 | const entry = (yield* sql<{ | ||
| 876 | object_hash: string; | ||
| 877 | }>`SELECT object_hash FROM transcript_archives WHERE thread_id = ${threadId}`)[0]!; | ||
| 878 | yield* fs.writeFileString(f.path.join(f.root, "objects", entry.object_hash), "corrupt"); | ||
| 879 | assert.isTrue( | ||
| 880 | (yield* projections.getThreadProjection(threadId).pipe(Effect.exit))._tag === "Failure", | ||
| 881 | ); | ||
| 882 | const result = yield* (yield* EventSink.EventSinkV2) | ||
| 883 | .write({ | ||
| 884 | events: [ | ||
| 885 | { | ||
| 886 | id: EventId.make("corrupt-unarchive"), | ||
| 887 | type: "thread.unarchived", | ||
| 888 | threadId, | ||
| 889 | occurredAt: yield* DateTime.now, | ||
| 890 | payload: thread, | ||
| 891 | }, | ||
| 892 | ], | ||
| 893 | }) | ||
| 894 | .pipe(Effect.exit); | ||
| 895 | assert.strictEqual(result._tag, "Failure"); | ||
| 896 | assert.isNotNull((yield* projections.getThreadShell(threadId))?.archivedAt); | ||
| 897 | assert.strictEqual( | ||
| 898 | (yield* sql`SELECT 1 FROM transcript_archives WHERE thread_id = ${threadId}`).length, | ||
| 899 | 1, | ||
| 900 | ); | ||
| 901 | const message = (yield* sql<{ | ||
| 902 | payload_json: string; | ||
| 903 | }>`SELECT payload_json FROM orchestration_v2_projection_messages WHERE thread_id = ${threadId}`)[0]!; | ||
| 904 | assert.isFalse(message.payload_json.includes("private archived")); | ||
| 905 | }).pipe(Effect.provide(f.layer)); | ||
| 906 | }), | ||
| 907 | ); | ||
| 908 | |||
| 909 | it.effect("does not acknowledge an object when its durable file flush fails", () => | ||
| 910 | Effect.gen(function* () { | ||
| 911 | const f = yield* fixture(); | ||
| 912 | yield* Effect.gen(function* () { | ||
| 913 | const fs = yield* FileSystem.FileSystem; | ||
| 914 | const failure = PlatformError.systemError({ | ||
| 915 | _tag: "Unknown", | ||
| 916 | module: "FileSystem", | ||
| 917 | method: "sync", | ||
| 918 | }); | ||
| 919 | const objects = yield* ArchiveObjects.ArchiveObjects.pipe( | ||
| 920 | Effect.provide( | ||
| 921 | Layer.fresh(ArchiveObjects.layer).pipe( | ||
| 922 | Layer.provide( | ||
| 923 | ConfigProvider.layer(ConfigProvider.fromUnknown({ T3CODE_ARCHIVE_DIR: f.root })), | ||
| 924 | ), | ||
| 925 | ), | ||
| 926 | ), | ||
| 927 | Effect.provideService(FileSystem.FileSystem, { | ||
| 928 | ...fs, | ||
| 929 | open: (file, options) => | ||
| 930 | fs | ||
| 931 | .open(file, options) | ||
| 932 | .pipe(Effect.map((handle) => ({ ...handle, sync: Effect.fail(failure) }))), | ||
| 933 | }), | ||
| 934 | ); | ||
| 935 | const bytes = new TextEncoder().encode("not durable"); | ||
| 936 | const hash = Buffer.from(yield* (yield* Crypto.Crypto).digest("SHA-256", bytes)).toString( | ||
| 937 | "hex", | ||
| 938 | ); | ||
| 939 | assert.strictEqual( | ||
| 940 | (yield* objects.put(hash, Stream.succeed(bytes)).pipe(Effect.flip)).reason, | ||
| 941 | "storage", | ||
| 942 | ); | ||
| 943 | assert.isFalse(yield* fs.exists(f.path.join(f.root, "objects", hash))); | ||
| 944 | }).pipe(Effect.provide(f.layer)); | ||
| 945 | }), | ||
| 946 | ); | ||
| 947 | |||
| 948 | it.effect("retains source payloads when the archive store identity is absent or mismatched", () => | ||
| 949 | Effect.gen(function* () { | ||
| 950 | for (const mode of ["unconfigured", "missing", "mismatch"] as const) { | ||
| 951 | const f = yield* fixture(); | ||
| 952 | yield* Effect.gen(function* () { | ||
| 953 | yield* seed; | ||
| 954 | const fs = yield* FileSystem.FileSystem; | ||
| 955 | const client = yield* ArchiveClient.ArchiveClient; | ||
| 956 | let archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 957 | if (mode === "unconfigured") { | ||
| 958 | const catalog = yield* ArchiveCatalog.ArchiveCatalog.pipe( | ||
| 959 | Effect.provide( | ||
| 960 | Layer.fresh( | ||
| 961 | ArchiveCatalog.layer.pipe( | ||
| 962 | Layer.provide(ArchiveObjects.layer), | ||
| 963 | Layer.provide( | ||
| 964 | ConfigProvider.layer( | ||
| 965 | ConfigProvider.fromUnknown({ T3CODE_ARCHIVE_DIR: f.root }), | ||
| 966 | ), | ||
| 967 | ), | ||
| 968 | ), | ||
| 969 | ), | ||
| 970 | ), | ||
| 971 | ); | ||
| 972 | archive = yield* TranscriptArchive.TranscriptArchive.pipe( | ||
| 973 | Effect.provide(Layer.fresh(TranscriptArchive.layer)), | ||
| 974 | Effect.provideService(ArchiveClient.ArchiveClient, { | ||
| 975 | ...client, | ||
| 976 | publish: catalog.publish, | ||
| 977 | }), | ||
| 978 | ); | ||
| 979 | } else if (mode === "missing") | ||
| 980 | yield* fs.remove(f.path.join(f.root, ".knife-archive-store-id")); | ||
| 981 | else | ||
| 982 | yield* fs.writeFileString( | ||
| 983 | f.path.join(f.root, ".knife-archive-store-id"), | ||
| 984 | "wrong-store", | ||
| 985 | ); | ||
| 986 | assert.strictEqual( | ||
| 987 | (yield* archive.offload(threadId).pipe(Effect.flip)).reason, | ||
| 988 | "storage", | ||
| 989 | ); | ||
| 990 | assert.strictEqual( | ||
| 991 | (yield* (yield* SqlClient.SqlClient)`SELECT 1 FROM transcript_archives`).length, | ||
| 992 | 0, | ||
| 993 | ); | ||
| 994 | assert.strictEqual( | ||
| 995 | (yield* (yield* ProjectionStore.ProjectionStoreV2).getThreadProjection(threadId)) | ||
| 996 | .messages[0]?.text, | ||
| 997 | "private archived message body", | ||
| 998 | ); | ||
| 999 | }).pipe(Effect.provide(f.layer)); | ||
| 1000 | } | ||
| 1001 | }), | ||
| 1002 | ); | ||
| 1003 | |||
| 1004 | it.effect( | ||
| 1005 | "keeps existing attachment URLs playable after eviction and restores bytes on unarchive", | ||
| 1006 | () => | ||
| 1007 | Effect.gen(function* () { | ||
| 1008 | const f = yield* fixture(); | ||
| 1009 | yield* Effect.gen(function* () { | ||
| 1010 | const thread = yield* seed; | ||
| 1011 | const fs = yield* FileSystem.FileSystem; | ||
| 1012 | const config = yield* ServerConfig.ServerConfig; | ||
| 1013 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 1014 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 1015 | const sink = yield* EventSink.EventSinkV2; | ||
| 1016 | const now = yield* DateTime.now; | ||
| 1017 | const id = ChatAttachmentId.make(createDeterministicAttachmentId(threadId, "recording")!); | ||
| 1018 | const file = f.path.join(config.attachmentsDir, `${id}.png`); | ||
| 1019 | yield* fs.makeDirectory(config.attachmentsDir, { recursive: true }); | ||
| 1020 | const bytes = new Uint8Array([137, 80, 78, 71, 1, 2, 3]); | ||
| 1021 | yield* fs.writeFile(file, bytes); | ||
| 1022 | const message = (yield* projections.getThreadProjection(threadId)).messages[0]!; | ||
| 1023 | yield* sink.write({ | ||
| 1024 | events: [ | ||
| 1025 | { | ||
| 1026 | id: EventId.make("attachment-added"), | ||
| 1027 | type: "message.updated", | ||
| 1028 | threadId, | ||
| 1029 | occurredAt: now, | ||
| 1030 | payload: { | ||
| 1031 | ...message, | ||
| 1032 | attachments: [ | ||
| 1033 | { | ||
| 1034 | type: "image", | ||
| 1035 | id, | ||
| 1036 | name: "recording.png", | ||
| 1037 | mimeType: "image/png", | ||
| 1038 | sizeBytes: bytes.length, | ||
| 1039 | }, | ||
| 1040 | ], | ||
| 1041 | }, | ||
| 1042 | }, | ||
| 1043 | ], | ||
| 1044 | }); | ||
| 1045 | const issue = issueAssetUrl({ | ||
| 1046 | resource: { | ||
| 1047 | _tag: "attachment", | ||
| 1048 | attachmentId: id, | ||
| 1049 | fileName: "recording.png", | ||
| 1050 | mimeType: "image/png", | ||
| 1051 | }, | ||
| 1052 | }); | ||
| 1053 | const old = yield* issue; | ||
| 1054 | const resolve = (url: string) => { | ||
| 1055 | const suffix = url.slice(`${ASSET_ROUTE_PREFIX}/`.length); | ||
| 1056 | const separator = suffix.indexOf("/"); | ||
| 1057 | return resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); | ||
| 1058 | }; | ||
| 1059 | assert.strictEqual((yield* resolve(old.relativeUrl))?.kind, "file"); | ||
| 1060 | yield* archive.sweep; | ||
| 1061 | assert.isFalse(yield* fs.exists(file)); | ||
| 1062 | for (const url of [old.relativeUrl, (yield* issue).relativeUrl]) { | ||
| 1063 | const asset = yield* resolve(url); | ||
| 1064 | assert.strictEqual(asset?.kind, "archived-file"); | ||
| 1065 | if (asset?.kind !== "archived-file") | ||
| 1066 | return yield* Effect.die("Expected archived asset"); | ||
| 1067 | const opened = yield* (yield* ArchiveClient.ArchiveClient).open( | ||
| 1068 | asset.location, | ||
| 1069 | asset.hash, | ||
| 1070 | ); | ||
| 1071 | assert.strictEqual(opened.kind, "file"); | ||
| 1072 | if (opened.kind === "file") | ||
| 1073 | assert.deepStrictEqual(yield* fs.readFile(opened.path), bytes); | ||
| 1074 | } | ||
| 1075 | yield* sink.write({ | ||
| 1076 | events: [ | ||
| 1077 | { | ||
| 1078 | id: EventId.make("attachment-unarchive"), | ||
| 1079 | type: "thread.unarchived", | ||
| 1080 | threadId, | ||
| 1081 | occurredAt: now, | ||
| 1082 | payload: thread, | ||
| 1083 | }, | ||
| 1084 | ], | ||
| 1085 | }); | ||
| 1086 | assert.deepStrictEqual(yield* fs.readFile(file), bytes); | ||
| 1087 | assert.strictEqual((yield* resolve(old.relativeUrl))?.kind, "file"); | ||
| 1088 | }).pipe( | ||
| 1089 | Effect.provide( | ||
| 1090 | Layer.mergeAll( | ||
| 1091 | f.layer, | ||
| 1092 | WorkspacePaths.layer, | ||
| 1093 | Layer.mock(NativeAppIconResolver.NativeAppIconResolver)({}), | ||
| 1094 | Layer.mock(ProjectFaviconResolver.ProjectFaviconResolver)({}), | ||
| 1095 | Layer.mock(Orchestrator.OrchestratorV2)({}), | ||
| 1096 | ), | ||
| 1097 | ), | ||
| 1098 | ); | ||
| 1099 | }), | ||
| 1100 | ); | ||
| 1101 | |||
| 1102 | it.effect( | ||
| 1103 | "keeps fork history readable and its source attachment warm through offline rebuild", | ||
| 1104 | () => | ||
| 1105 | Effect.gen(function* () { | ||
| 1106 | const f = yield* fixture(); | ||
| 1107 | yield* Effect.gen(function* () { | ||
| 1108 | const thread = yield* seed; | ||
| 1109 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 1110 | const sink = yield* EventSink.EventSinkV2; | ||
| 1111 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 1112 | const fs = yield* FileSystem.FileSystem; | ||
| 1113 | const sql = yield* SqlClient.SqlClient; | ||
| 1114 | const now = yield* DateTime.now; | ||
| 1115 | const forkId = ThreadId.make("archive-fork"); | ||
| 1116 | const runId = RunId.make("archive-source-run"); | ||
| 1117 | const nodeId = NodeId.make("archive-source-node"); | ||
| 1118 | const message = (yield* projections.getThreadProjection(threadId)).messages[0]!; | ||
| 1119 | const attachmentId = ChatAttachmentId.make( | ||
| 1120 | createDeterministicAttachmentId(threadId, "source")!, | ||
| 1121 | ); | ||
| 1122 | const config = yield* ServerConfig.ServerConfig; | ||
| 1123 | const attachment = f.path.join(config.attachmentsDir, `${attachmentId}.png`); | ||
| 1124 | yield* fs.makeDirectory(config.attachmentsDir, { recursive: true }); | ||
| 1125 | yield* fs.writeFileString(attachment, "source attachment"); | ||
| 1126 | const fork = { | ||
| 1127 | ...thread, | ||
| 1128 | id: forkId, | ||
| 1129 | forkedFrom: { type: "run" as const, threadId, runId }, | ||
| 1130 | lineage: { | ||
| 1131 | parentThreadId: threadId, | ||
| 1132 | relationshipToParent: "fork" as const, | ||
| 1133 | rootThreadId: threadId, | ||
| 1134 | }, | ||
| 1135 | }; | ||
| 1136 | const item = (yield* projections.getThreadProjection(threadId)).turnItems[0]!; | ||
| 1137 | yield* sink.write({ | ||
| 1138 | events: [ | ||
| 1139 | { | ||
| 1140 | id: EventId.make("source-run"), | ||
| 1141 | type: "run.created", | ||
| 1142 | threadId, | ||
| 1143 | runId, | ||
| 1144 | nodeId, | ||
| 1145 | driver: ProviderDriverKind.make("codex"), | ||
| 1146 | occurredAt: now, | ||
| 1147 | payload: { | ||
| 1148 | id: runId, | ||
| 1149 | threadId, | ||
| 1150 | ordinal: 1, | ||
| 1151 | providerInstanceId: thread.providerInstanceId, | ||
| 1152 | modelSelection: thread.modelSelection, | ||
| 1153 | providerThreadId: null, | ||
| 1154 | userMessageId: message.id, | ||
| 1155 | rootNodeId: nodeId, | ||
| 1156 | activeAttemptId: null, | ||
| 1157 | status: "completed", | ||
| 1158 | requestedAt: now, | ||
| 1159 | startedAt: now, | ||
| 1160 | completedAt: now, | ||
| 1161 | checkpointId: null, | ||
| 1162 | contextHandoffId: null, | ||
| 1163 | }, | ||
| 1164 | }, | ||
| 1165 | { | ||
| 1166 | id: EventId.make("source-run-message"), | ||
| 1167 | type: "message.updated", | ||
| 1168 | threadId, | ||
| 1169 | occurredAt: now, | ||
| 1170 | payload: { | ||
| 1171 | ...message, | ||
| 1172 | runId, | ||
| 1173 | attachments: [ | ||
| 1174 | { | ||
| 1175 | type: "image", | ||
| 1176 | id: attachmentId, | ||
| 1177 | name: "source.png", | ||
| 1178 | mimeType: "image/png", | ||
| 1179 | sizeBytes: 17, | ||
| 1180 | }, | ||
| 1181 | ], | ||
| 1182 | }, | ||
| 1183 | }, | ||
| 1184 | { | ||
| 1185 | id: EventId.make("source-run-item"), | ||
| 1186 | type: "turn-item.updated", | ||
| 1187 | threadId, | ||
| 1188 | occurredAt: now, | ||
| 1189 | payload: { ...item, runId }, | ||
| 1190 | }, | ||
| 1191 | { | ||
| 1192 | id: EventId.make("fork-created"), | ||
| 1193 | type: "thread.created", | ||
| 1194 | threadId: forkId, | ||
| 1195 | occurredAt: now, | ||
| 1196 | payload: fork, | ||
| 1197 | }, | ||
| 1198 | ], | ||
| 1199 | }); | ||
| 1200 | const before = yield* projections.getThreadProjection(forkId); | ||
| 1201 | assert.isTrue( | ||
| 1202 | before.visibleTurnItems.some( | ||
| 1203 | (item) => | ||
| 1204 | item.item.type === "assistant_message" && | ||
| 1205 | item.item.text === "private archived message body", | ||
| 1206 | ), | ||
| 1207 | ); | ||
| 1208 | yield* archive.sweep; | ||
| 1209 | assert.isTrue(yield* fs.exists(attachment)); | ||
| 1210 | assert.deepStrictEqual(yield* projections.getThreadProjection(forkId), before); | ||
| 1211 | const objects = f.path.join(f.root, "objects"); | ||
| 1212 | yield* fs.rename(objects, `${objects}-offline`); | ||
| 1213 | assert.isTrue( | ||
| 1214 | (yield* (yield* ProjectionMaintenance.ProjectionMaintenanceV2).rebuild).valid, | ||
| 1215 | ); | ||
| 1216 | yield* fs.rename(`${objects}-offline`, objects); | ||
| 1217 | assert.deepStrictEqual(yield* projections.getThreadProjection(forkId), before); | ||
| 1218 | yield* sink.write({ | ||
| 1219 | events: [ | ||
| 1220 | { | ||
| 1221 | id: EventId.make("fork-archived"), | ||
| 1222 | type: "thread.archived", | ||
| 1223 | threadId: forkId, | ||
| 1224 | occurredAt: now, | ||
| 1225 | payload: { ...fork, archivedAt: now }, | ||
| 1226 | }, | ||
| 1227 | ], | ||
| 1228 | }); | ||
| 1229 | yield* archive.sweep; | ||
| 1230 | assert.isFalse(yield* fs.exists(attachment)); | ||
| 1231 | const sourceManifest = (yield* sql<{ | ||
| 1232 | object_hash: string; | ||
| 1233 | }>`SELECT object_hash FROM transcript_archives WHERE thread_id = ${threadId}`)[0]!; | ||
| 1234 | const forkManifest = (yield* sql<{ | ||
| 1235 | object_hash: string; | ||
| 1236 | }>`SELECT object_hash FROM transcript_archives WHERE thread_id = ${forkId}`)[0]!; | ||
| 1237 | const source = JSON.parse( | ||
| 1238 | yield* fs.readFileString(f.path.join(objects, sourceManifest.object_hash)), | ||
| 1239 | ); | ||
| 1240 | const savedFork = JSON.parse( | ||
| 1241 | yield* fs.readFileString(f.path.join(objects, forkManifest.object_hash)), | ||
| 1242 | ); | ||
| 1243 | assert.strictEqual( | ||
| 1244 | savedFork.context.find( | ||
| 1245 | (s: { table: string }) => s.table === "orchestration_v2_projection_messages", | ||
| 1246 | ).hash, | ||
| 1247 | source.segments.find((s: { table: string }) => s.table === "messages").hash, | ||
| 1248 | ); | ||
| 1249 | assert.deepStrictEqual(savedFork.assets, source.assets); | ||
| 1250 | assert.deepStrictEqual( | ||
| 1251 | (yield* projections.getThreadProjection(forkId)).visibleTurnItems, | ||
| 1252 | before.visibleTurnItems, | ||
| 1253 | ); | ||
| 1254 | }).pipe(Effect.provide(f.layer)); | ||
| 1255 | }), | ||
| 1256 | ); | ||
| 1257 | |||
| 1258 | it.effect( | ||
| 1259 | "offloads bodies only after verification, reads without retaining them, rebuilds offline, and restores before unarchive", | ||
| 1260 | () => | ||
| 1261 | Effect.gen(function* () { | ||
| 1262 | const f = yield* fixture(); | ||
| 1263 | yield* Effect.gen(function* () { | ||
| 1264 | const thread = yield* seed; | ||
| 1265 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 1266 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 1267 | const sql = yield* SqlClient.SqlClient; | ||
| 1268 | const events = yield* ApplicationEvents.OrchestrationEventStore; | ||
| 1269 | const before = yield* projections.getThreadProjection(threadId); | ||
| 1270 | const highWater = yield* events.latestAgentSequence(threadId); | ||
| 1271 | yield* archive.offload(threadId); | ||
| 1272 | const raw = yield* sql<{ | ||
| 1273 | payload_json: string; | ||
| 1274 | }>`SELECT payload_json FROM orchestration_v2_projection_messages WHERE thread_id = ${threadId}`; | ||
| 1275 | assert.isFalse(raw[0]?.payload_json.includes("private archived")); | ||
| 1276 | const shell = yield* projections.getThreadShell(threadId); | ||
| 1277 | assert.strictEqual(shell?.title, "Searchable archive title"); | ||
| 1278 | assert.strictEqual(shell?.visibleItemCount, before.visibleTurnItems.length); | ||
| 1279 | assert.strictEqual(yield* events.latestAgentSequence(threadId), highWater); | ||
| 1280 | assert.isFalse( | ||
| 1281 | (yield* events.getAgentReplayStats({ | ||
| 1282 | threadId, | ||
| 1283 | afterSequence: 0, | ||
| 1284 | throughSequence: highWater, | ||
| 1285 | maxEvents: 100, | ||
| 1286 | })).hasCreateEvent, | ||
| 1287 | ); | ||
| 1288 | assert.deepStrictEqual(yield* projections.getThreadProjection(threadId), before); | ||
| 1289 | assert.strictEqual( | ||
| 1290 | (yield* sql`SELECT 1 FROM transcript_archives WHERE thread_id = ${threadId}`).length, | ||
| 1291 | 1, | ||
| 1292 | ); | ||
| 1293 | assert.isFalse( | ||
| 1294 | (yield* sql<{ | ||
| 1295 | payload_json: string; | ||
| 1296 | }>`SELECT payload_json FROM orchestration_v2_projection_messages WHERE thread_id = ${threadId}`)[0]?.payload_json.includes( | ||
| 1297 | "private archived", | ||
| 1298 | ), | ||
| 1299 | ); | ||
| 1300 | const fs = yield* FileSystem.FileSystem; | ||
| 1301 | const objects = f.path.join(f.root, "objects"); | ||
| 1302 | yield* fs.rename(objects, `${objects}-offline`); | ||
| 1303 | assert.isTrue( | ||
| 1304 | (yield* (yield* ProjectionMaintenance.ProjectionMaintenanceV2).rebuild).valid, | ||
| 1305 | ); | ||
| 1306 | assert.isTrue( | ||
| 1307 | (yield* projections.getThreadProjection(threadId).pipe(Effect.exit))._tag === "Failure", | ||
| 1308 | ); | ||
| 1309 | assert.strictEqual( | ||
| 1310 | (yield* projections.getThreadShell(threadId))?.title, | ||
| 1311 | "Searchable archive title", | ||
| 1312 | ); | ||
| 1313 | yield* fs.rename(`${objects}-offline`, objects); | ||
| 1314 | const now = yield* DateTime.now; | ||
| 1315 | yield* (yield* EventSink.EventSinkV2).write({ | ||
| 1316 | events: [ | ||
| 1317 | { | ||
| 1318 | id: EventId.make("archive-unarchived"), | ||
| 1319 | type: "thread.unarchived", | ||
| 1320 | threadId, | ||
| 1321 | occurredAt: now, | ||
| 1322 | payload: thread, | ||
| 1323 | }, | ||
| 1324 | ], | ||
| 1325 | }); | ||
| 1326 | assert.strictEqual( | ||
| 1327 | (yield* sql`SELECT 1 FROM transcript_archives WHERE thread_id = ${threadId}`).length, | ||
| 1328 | 0, | ||
| 1329 | ); | ||
| 1330 | assert.strictEqual( | ||
| 1331 | (yield* projections.getThreadProjection(threadId)).messages[0]?.text, | ||
| 1332 | "private archived message body", | ||
| 1333 | ); | ||
| 1334 | assert.isTrue( | ||
| 1335 | (yield* (yield* ProjectionMaintenance.ProjectionMaintenanceV2).rebuild).valid, | ||
| 1336 | ); | ||
| 1337 | assert.strictEqual( | ||
| 1338 | (yield* projections.getThreadProjection(threadId)).messages[0]?.text, | ||
| 1339 | "private archived message body", | ||
| 1340 | ); | ||
| 1341 | }).pipe(Effect.provide(f.layer)); | ||
| 1342 | }), | ||
| 1343 | ); | ||
| 1344 | |||
| 1345 | it.effect("never evicts local bodies when the destination is missing", () => | ||
| 1346 | Effect.gen(function* () { | ||
| 1347 | const f = yield* fixture(); | ||
| 1348 | yield* Effect.gen(function* () { | ||
| 1349 | yield* seed; | ||
| 1350 | const fs = yield* FileSystem.FileSystem; | ||
| 1351 | yield* fs.remove(f.root, { recursive: true }); | ||
| 1352 | assert.strictEqual( | ||
| 1353 | (yield* (yield* TranscriptArchive.TranscriptArchive).offload(threadId).pipe(Effect.flip)) | ||
| 1354 | .reason, | ||
| 1355 | "storage", | ||
| 1356 | ); | ||
| 1357 | assert.strictEqual( | ||
| 1358 | (yield* (yield* ProjectionStore.ProjectionStoreV2).getThreadProjection(threadId)) | ||
| 1359 | .messages[0]?.text, | ||
| 1360 | "private archived message body", | ||
| 1361 | ); | ||
| 1362 | assert.strictEqual( | ||
| 1363 | (yield* (yield* SqlClient.SqlClient)`SELECT 1 FROM transcript_archives`).length, | ||
| 1364 | 0, | ||
| 1365 | ); | ||
| 1366 | }).pipe(Effect.provide(f.layer)); | ||
| 1367 | }), | ||
| 1368 | ); | ||
| 1369 | |||
| 1370 | it.effect("rejects a wrong object hash without publishing partial bytes", () => | ||
| 1371 | Effect.gen(function* () { | ||
| 1372 | const f = yield* fixture(); | ||
| 1373 | yield* Effect.gen(function* () { | ||
| 1374 | const store = yield* ArchiveObjects.ArchiveObjects; | ||
| 1375 | const hash = "0".repeat(64); | ||
| 1376 | const error = yield* store | ||
| 1377 | .put(hash, Stream.make(new TextEncoder().encode("wrong bytes"))) | ||
| 1378 | .pipe(Effect.flip); | ||
| 1379 | assert.strictEqual(error.reason, "integrity"); | ||
| 1380 | assert.isFalse( | ||
| 1381 | yield* (yield* FileSystem.FileSystem).exists(f.path.join(f.root, "objects", hash)), | ||
| 1382 | ); | ||
| 1383 | }).pipe(Effect.provide(f.layer)); | ||
| 1384 | }), | ||
| 1385 | ); | ||
| 1386 | it.effect("an unarchive committed during upload wins over offloading", () => | ||
| 1387 | Effect.gen(function* () { | ||
| 1388 | const f = yield* fixture(); | ||
| 1389 | yield* Effect.gen(function* () { | ||
| 1390 | const thread = yield* seed; | ||
| 1391 | const uploaded = yield* Deferred.make<void>(); | ||
| 1392 | const resume = yield* Deferred.make<void>(); | ||
| 1393 | const client = yield* ArchiveClient.ArchiveClient; | ||
| 1394 | const archive = yield* TranscriptArchive.TranscriptArchive.pipe( | ||
| 1395 | Effect.provide(Layer.fresh(TranscriptArchive.layer)), | ||
| 1396 | Effect.provideService(ArchiveClient.ArchiveClient, { | ||
| 1397 | ...client, | ||
| 1398 | upload: (file) => | ||
| 1399 | client.upload(file).pipe( | ||
| 1400 | Effect.tap(() => Deferred.succeed(uploaded, undefined)), | ||
| 1401 | Effect.tap(() => Deferred.await(resume)), | ||
| 1402 | ), | ||
| 1403 | }), | ||
| 1404 | ); | ||
| 1405 | const offload = yield* archive.offload(threadId).pipe(Effect.flip, Effect.forkScoped); | ||
| 1406 | yield* Effect.raceFirst( | ||
| 1407 | Deferred.await(uploaded), | ||
| 1408 | Fiber.await(offload).pipe(Effect.flatMap((exit) => Effect.die(exit))), | ||
| 1409 | ); | ||
| 1410 | const now = yield* DateTime.now; | ||
| 1411 | yield* (yield* EventSink.EventSinkV2).write({ | ||
| 1412 | events: [ | ||
| 1413 | { | ||
| 1414 | id: EventId.make("race-unarchive"), | ||
| 1415 | type: "thread.unarchived", | ||
| 1416 | threadId, | ||
| 1417 | occurredAt: now, | ||
| 1418 | payload: thread, | ||
| 1419 | }, | ||
| 1420 | ], | ||
| 1421 | }); | ||
| 1422 | yield* Deferred.succeed(resume, undefined); | ||
| 1423 | assert.strictEqual((yield* Fiber.join(offload)).reason, "changed"); | ||
| 1424 | assert.strictEqual( | ||
| 1425 | (yield* (yield* SqlClient.SqlClient)`SELECT 1 FROM transcript_archives`).length, | ||
| 1426 | 0, | ||
| 1427 | ); | ||
| 1428 | assert.strictEqual( | ||
| 1429 | (yield* (yield* ProjectionStore.ProjectionStoreV2).getThreadProjection(threadId)) | ||
| 1430 | .messages[0]?.text, | ||
| 1431 | "private archived message body", | ||
| 1432 | ); | ||
| 1433 | yield* archive.sweep; | ||
| 1434 | assert.strictEqual((yield* (yield* ArchiveCatalog.ArchiveCatalog).list).entries.length, 0); | ||
| 1435 | }).pipe(Effect.provide(f.layer)); | ||
| 1436 | }), | ||
| 1437 | ); | ||
| 1438 | }); | ||
apps/server/src/archive/TranscriptArchive.ts created+778| ... | @@ -0,0 +1,778 @@ | ||
| 1 | import { ArchiveError, ProjectId } from "@t3tools/contracts"; | ||
| 2 | import * as ArchiveClient from "./ArchiveClient.ts"; | ||
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 4 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 5 | import { type ArchiveManagementRequest, type ArchiveStatus, ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | extractMarkdownLinkHrefs, | ||
| 8 | resolveMarkdownFileLinkTarget, | ||
| 9 | } from "@t3tools/shared/markdownLinks"; | ||
| 10 | import { | ||
| 11 | isWorkspaceImagePreviewPath, | ||
| 12 | audioMimeTypeFromExtension, | ||
| 13 | isWorkspaceVideoPreviewPath, | ||
| 14 | } from "@t3tools/shared/filePreview"; | ||
| 15 | import * as Clock from "effect/Clock"; | ||
| 16 | import * as Context from "effect/Context"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as Exit from "effect/Exit"; | ||
| 19 | import * as FileSystem from "effect/FileSystem"; | ||
| 20 | import * as Layer from "effect/Layer"; | ||
| 21 | import * as Option from "effect/Option"; | ||
| 22 | import * as Path from "effect/Path"; | ||
| 23 | import * as Redacted from "effect/Redacted"; | ||
| 24 | import * as Schema from "effect/Schema"; | ||
| 25 | import * as Stream from "effect/Stream"; | ||
| 26 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 27 | import { | ||
| 28 | resolveAttachmentPathById, | ||
| 29 | attachmentPathsById, | ||
| 30 | threadHtmlRenderAttachmentIds, | ||
| 31 | parseAttachmentIdFromRelativePath, | ||
| 32 | parseThreadSegmentFromAttachmentId, | ||
| 33 | toSafeThreadAttachmentSegment, | ||
| 34 | } from "../attachmentStore.ts"; | ||
| 35 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ||
| 36 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 37 | import * as ArchiveStorage from "./ArchiveStorage.ts"; | ||
| 38 | import * as ServerConfig from "../config.ts"; | ||
| 39 | |||
| 40 | import { | ||
| 41 | payloadTables, | ||
| 42 | Table, | ||
| 43 | Segment, | ||
| 44 | Row, | ||
| 45 | Metadata, | ||
| 46 | canonicalTables, | ||
| 47 | CanonicalTable, | ||
| 48 | Bundle, | ||
| 49 | } from "./ArchiveFormat.ts"; | ||
| 50 | |||
| 51 | type LoadedBundle = Omit<typeof Bundle.Type, "segments" | "events" | "context"> & { | ||
| 52 | segments: typeof Metadata.Type; | ||
| 53 | events: ReadonlyArray<typeof Row.Type>; | ||
| 54 | context: ReadonlyArray<{ | ||
| 55 | table: typeof CanonicalTable.Type; | ||
| 56 | rows: ReadonlyArray<typeof Row.Type>; | ||
| 57 | }>; | ||
| 58 | }; | ||
| 59 | const decodeMetadata = Schema.decodeEffect(Schema.fromJsonString(Metadata)); | ||
| 60 | const decodeBundle = Schema.decodeEffect(Schema.fromJsonString(Bundle)); | ||
| 61 | const encodeBundle = Schema.encodeEffect(Schema.fromJsonString(Bundle)); | ||
| 62 | const encodeRows = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Row))); | ||
| 63 | const decodeRows = Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Row))); | ||
| 64 | const RecordJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); | ||
| 65 | const decodeRecord = Schema.decodeSync(RecordJson); | ||
| 66 | const encodeRecord = Schema.encodeSync(RecordJson); | ||
| 67 | const encodeMetadata = Schema.encodeSync(Schema.fromJsonString(Metadata)); | ||
| 68 | const ids = { | ||
| 69 | messages: "message_id", | ||
| 70 | turn_items: "turn_item_id", | ||
| 71 | subagents: "subagent_id", | ||
| 72 | } as const; | ||
| 73 | const isArchiveError = Schema.is(ArchiveError); | ||
| 74 | const archiveError = (cause: unknown) => | ||
| 75 | isArchiveError(cause) | ||
| 76 | ? cause | ||
| 77 | : new ArchiveError({ reason: "storage", cause: Redacted.make(cause) }); | ||
| 78 | |||
| 79 | /** Local columns keep timeline ordering and shell state; missing bodies never decode as transcripts. */ | ||
| 80 | function metadataPayload(table: typeof Table.Type, json: string) { | ||
| 81 | const payload = decodeRecord(json); | ||
| 82 | if (table === "turn_items" && (payload.type === "error" || payload.type === "secret_request")) | ||
| 83 | return json; | ||
| 84 | const keys = | ||
| 85 | table === "turn_items" | ||
| 86 | ? [ | ||
| 87 | "id", | ||
| 88 | "threadId", | ||
| 89 | "runId", | ||
| 90 | "nodeId", | ||
| 91 | "type", | ||
| 92 | "status", | ||
| 93 | "ordinal", | ||
| 94 | "messageId", | ||
| 95 | "inputIntent", | ||
| 96 | "createdBy", | ||
| 97 | "subagentId", | ||
| 98 | "requestId", | ||
| 99 | "createdAt", | ||
| 100 | "updatedAt", | ||
| 101 | ] | ||
| 102 | : table === "messages" | ||
| 103 | ? ["id", "threadId", "role", "createdBy", "createdAt", "updatedAt", "attachments"] | ||
| 104 | : [ | ||
| 105 | "id", | ||
| 106 | "threadId", | ||
| 107 | "status", | ||
| 108 | "childThreadId", | ||
| 109 | "origin", | ||
| 110 | "providerThreadId", | ||
| 111 | "createdAt", | ||
| 112 | "updatedAt", | ||
| 113 | ]; | ||
| 114 | return encodeRecord({ | ||
| 115 | archivedPayload: true, | ||
| 116 | ...Object.fromEntries(keys.filter((key) => key in payload).map((key) => [key, payload[key]])), | ||
| 117 | }); | ||
| 118 | } | ||
| 119 | |||
| 120 | export class TranscriptArchive extends Context.Service< | ||
| 121 | TranscriptArchive, | ||
| 122 | { | ||
| 123 | readonly enabled: Effect.Effect<boolean, ArchiveError>; | ||
| 124 | readonly manage: ( | ||
| 125 | request: ArchiveManagementRequest, | ||
| 126 | ) => Effect.Effect<ArchiveStatus, ArchiveError>; | ||
| 127 | readonly offload: (threadId: ThreadId) => Effect.Effect<void, ArchiveError>; | ||
| 128 | readonly restore: (threadId: ThreadId) => Effect.Effect<void, ArchiveError>; | ||
| 129 | readonly read: <A, E, R>( | ||
| 130 | threadId: ThreadId, | ||
| 131 | read: Effect.Effect<A, E, R>, | ||
| 132 | includeHistory?: boolean, | ||
| 133 | ) => Effect.Effect<A, E | ArchiveError, R>; | ||
| 134 | readonly readStream: <A, E, R>( | ||
| 135 | threadId: ThreadId, | ||
| 136 | stream: Stream.Stream<A, E, R>, | ||
| 137 | ) => Stream.Stream<A, E | ArchiveError, R>; | ||
| 138 | readonly sweep: Effect.Effect<void, ArchiveError>; | ||
| 139 | readonly asset: ( | ||
| 140 | paths: ReadonlyArray<string>, | ||
| 141 | ) => Effect.Effect< | ||
| 142 | Option.Option<{ path: string; location: string; hash: string }>, | ||
| 143 | ArchiveError | ||
| 144 | >; | ||
| 145 | } | ||
| 146 | >()("t3/archive/TranscriptArchive") {} | ||
| 147 | |||
| 148 | const make = Effect.gen(function* () { | ||
| 149 | const sql = yield* SqlClient.SqlClient; | ||
| 150 | const fs = yield* FileSystem.FileSystem; | ||
| 151 | const path = yield* Path.Path; | ||
| 152 | const config = yield* ServerConfig.ServerConfig; | ||
| 153 | const client = yield* ArchiveClient.ArchiveClient; | ||
| 154 | const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; | ||
| 155 | const projects = yield* ProjectStore.ProjectStoreV2; | ||
| 156 | const storage = yield* ArchiveStorage.ArchiveStorage; | ||
| 157 | const activeThreads = sql`SELECT thread_id FROM orchestration_v2_projection_runs WHERE status IN ('preparing', 'queued', 'starting', 'running', 'waiting') UNION SELECT thread_id FROM orchestration_v2_effect_outbox WHERE status IN ('pending', 'running') UNION SELECT thread_id FROM orchestration_v2_projection_turn_items WHERE type IN ('command_execution', 'dynamic_tool', 'subagent') AND status IN ('pending', 'running', 'waiting') UNION SELECT thread_id FROM orchestration_v2_projection_runtime_requests WHERE status = 'pending'`; | ||
| 158 | const catalog = (threadId: ThreadId) => | ||
| 159 | sql<{ | ||
| 160 | thread_id: ThreadId; | ||
| 161 | location: string; | ||
| 162 | object_hash: string; | ||
| 163 | through_sequence: number; | ||
| 164 | }>`SELECT thread_id, location, object_hash, through_sequence FROM transcript_archives WHERE thread_id = ${threadId}`; | ||
| 165 | const ancestry = (threadId: ThreadId) => sql`WITH RECURSIVE ancestry(thread_id) AS ( | ||
| 166 | SELECT ${threadId} UNION SELECT json_extract(t.payload_json, '$.forkedFrom.threadId') | ||
| 167 | FROM orchestration_v2_projection_threads t JOIN ancestry a ON t.thread_id = a.thread_id | ||
| 168 | WHERE json_extract(t.payload_json, '$.forkedFrom.type') = 'run' | ||
| 169 | )`; | ||
| 170 | const dependencies = (threadId: ThreadId) => | ||
| 171 | sql<{ thread_id: string }>`${ancestry(threadId)} SELECT thread_id FROM ancestry`; | ||
| 172 | const entries = (threadId: ThreadId, includeHistory = true) => | ||
| 173 | includeHistory | ||
| 174 | ? sql<{ | ||
| 175 | thread_id: ThreadId; | ||
| 176 | location: string; | ||
| 177 | object_hash: string; | ||
| 178 | through_sequence: number; | ||
| 179 | }>`${ancestry(threadId)} SELECT a.thread_id, a.location, a.object_hash, a.through_sequence FROM transcript_archives a JOIN ancestry s ON s.thread_id = a.thread_id ORDER BY a.thread_id = ${threadId} DESC` | ||
| 180 | : catalog(threadId); | ||
| 181 | |||
| 182 | const load = ( | ||
| 183 | threadId: ThreadId, | ||
| 184 | entry: { location: string; object_hash: string; through_sequence: number }, | ||
| 185 | ) => | ||
| 186 | Effect.scoped( | ||
| 187 | Effect.gen(function* () { | ||
| 188 | const temporary = yield* fs.makeTempDirectoryScoped({ prefix: "knife-archive-read-" }); | ||
| 189 | const file = path.join(temporary, "transcript"); | ||
| 190 | yield* client.download(entry.location, entry.object_hash, file); | ||
| 191 | const manifest = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeBundle)); | ||
| 192 | const hashes = new Set([ | ||
| 193 | manifest.events, | ||
| 194 | ...manifest.segments.map((row) => row.hash), | ||
| 195 | ...manifest.context.map((row) => row.hash), | ||
| 196 | ]); | ||
| 197 | const loaded = yield* Effect.forEach( | ||
| 198 | [...hashes], | ||
| 199 | (hash) => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const file = path.join(temporary, hash); | ||
| 202 | yield* client.download(entry.location, hash, file); | ||
| 203 | return [ | ||
| 204 | hash, | ||
| 205 | yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRows)), | ||
| 206 | ] as const; | ||
| 207 | }), | ||
| 208 | { concurrency: 4 }, | ||
| 209 | ); | ||
| 210 | const chunks = new Map(loaded); | ||
| 211 | const segments: Array<typeof Segment.Type> = []; | ||
| 212 | for (const segment of manifest.segments) { | ||
| 213 | const rows = chunks.get(segment.hash); | ||
| 214 | if (!rows) return yield* new ArchiveError({ reason: "integrity" }); | ||
| 215 | segments.push({ table: segment.table, rows }); | ||
| 216 | } | ||
| 217 | const context: Array<LoadedBundle["context"][number]> = []; | ||
| 218 | for (const segment of manifest.context) { | ||
| 219 | const rows = chunks.get(segment.hash); | ||
| 220 | if (!rows) return yield* new ArchiveError({ reason: "integrity" }); | ||
| 221 | context.push({ table: segment.table, rows }); | ||
| 222 | } | ||
| 223 | const events = chunks.get(manifest.events); | ||
| 224 | if (!events) return yield* new ArchiveError({ reason: "integrity" }); | ||
| 225 | const bundle = { ...manifest, segments, context, events }; | ||
| 226 | if (bundle.threadId !== threadId || bundle.throughSequence !== entry.through_sequence) | ||
| 227 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 228 | for (const segment of bundle.segments) | ||
| 229 | for (const row of segment.rows) { | ||
| 230 | if ( | ||
| 231 | row.thread_id !== threadId || | ||
| 232 | typeof row[ids[segment.table]] !== "string" || | ||
| 233 | typeof row.payload_json !== "string" | ||
| 234 | ) | ||
| 235 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 236 | } | ||
| 237 | for (const event of bundle.events) { | ||
| 238 | if ( | ||
| 239 | typeof event.sequence !== "number" || | ||
| 240 | typeof event.payload_json !== "string" || | ||
| 241 | event.stream_id !== threadId | ||
| 242 | ) | ||
| 243 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 244 | } | ||
| 245 | return bundle; | ||
| 246 | }), | ||
| 247 | ).pipe(Effect.mapError(archiveError)); | ||
| 248 | const install = (bundle: LoadedBundle) => | ||
| 249 | Effect.gen(function* () { | ||
| 250 | for (const segment of bundle.segments) | ||
| 251 | for (const row of segment.rows) { | ||
| 252 | yield* sql`UPDATE ${sql(`orchestration_v2_projection_${segment.table}`)} SET payload_json = ${row.payload_json} WHERE thread_id = ${bundle.threadId} AND ${sql(ids[segment.table])} = ${row[ids[segment.table]]}`; | ||
| 253 | } | ||
| 254 | for (const event of bundle.events) | ||
| 255 | yield* sql`UPDATE orchestration_events SET payload_json = ${event.payload_json} WHERE sequence = ${event.sequence} AND stream_id = ${bundle.threadId} AND payload_json = 'null'`; | ||
| 256 | yield* sql`DELETE FROM transcript_archives WHERE thread_id = ${bundle.threadId}`; | ||
| 257 | }); | ||
| 258 | const restore = Effect.fn("TranscriptArchive.restore")(function* (threadId: ThreadId) { | ||
| 259 | const entry = (yield* catalog(threadId))[0]; | ||
| 260 | if (!entry) return; | ||
| 261 | const bundle = yield* load(threadId, entry); | ||
| 262 | yield* sql.withTransaction( | ||
| 263 | Effect.gen(function* () { | ||
| 264 | const current = (yield* catalog(threadId))[0]; | ||
| 265 | if (!current) return; | ||
| 266 | if (current.object_hash !== entry.object_hash) | ||
| 267 | return yield* new ArchiveError({ reason: "changed" }); | ||
| 268 | for (const asset of bundle.assets) { | ||
| 269 | const relative = path.relative(config.attachmentsDir, asset.path); | ||
| 270 | if ( | ||
| 271 | relative.startsWith("..") || | ||
| 272 | path.isAbsolute(relative) || | ||
| 273 | (yield* fs.exists(asset.path)) | ||
| 274 | ) | ||
| 275 | continue; | ||
| 276 | yield* Effect.scoped( | ||
| 277 | Effect.gen(function* () { | ||
| 278 | yield* fs.makeDirectory(config.attachmentsDir, { recursive: true, mode: 0o700 }); | ||
| 279 | const temp = yield* fs.makeTempDirectoryScoped({ | ||
| 280 | directory: config.attachmentsDir, | ||
| 281 | prefix: ".restore-", | ||
| 282 | }); | ||
| 283 | const file = path.join(temp, "attachment"); | ||
| 284 | yield* client.download(entry.location, asset.hash, file); | ||
| 285 | yield* fs.makeDirectory(path.dirname(asset.path), { recursive: true, mode: 0o700 }); | ||
| 286 | yield* fs.open(file, { flag: "r+" }).pipe(Effect.flatMap((handle) => handle.sync)); | ||
| 287 | yield* fs.rename(file, asset.path); | ||
| 288 | if (!(yield* HostProcess.isWindows)) | ||
| 289 | yield* fs.open(config.attachmentsDir).pipe(Effect.flatMap((handle) => handle.sync)); | ||
| 290 | }), | ||
| 291 | ); | ||
| 292 | } | ||
| 293 | yield* install(bundle); | ||
| 294 | }), | ||
| 295 | ); | ||
| 296 | }, Effect.mapError(archiveError)); | ||
| 297 | const read = <A, E, R>( | ||
| 298 | threadId: ThreadId, | ||
| 299 | operation: Effect.Effect<A, E, R>, | ||
| 300 | includeHistory = true, | ||
| 301 | ) => | ||
| 302 | Effect.gen(function* () { | ||
| 303 | const ancestors = yield* entries(threadId, includeHistory).pipe( | ||
| 304 | Effect.mapError(archiveError), | ||
| 305 | ); | ||
| 306 | const bundles: Array<{ payload: LoadedBundle; hash: string }> = []; | ||
| 307 | const covered = new Set<string>(); | ||
| 308 | for (const ancestor of ancestors) { | ||
| 309 | if (covered.has(ancestor.thread_id)) continue; | ||
| 310 | const bundle = yield* load(ancestor.thread_id, ancestor); | ||
| 311 | bundles.push({ payload: bundle, hash: ancestor.object_hash }); | ||
| 312 | covered.add(ancestor.thread_id); | ||
| 313 | for (const segment of bundle.context) | ||
| 314 | for (const row of segment.rows) { | ||
| 315 | if ( | ||
| 316 | segment.table === "orchestration_v2_projection_threads" && | ||
| 317 | typeof row.thread_id === "string" | ||
| 318 | ) | ||
| 319 | covered.add(row.thread_id); | ||
| 320 | } | ||
| 321 | } | ||
| 322 | if (bundles.length === 0) return yield* operation; | ||
| 323 | const rollback = Symbol(); | ||
| 324 | let result: Exit.Exit<A, E> | undefined; | ||
| 325 | yield* sql | ||
| 326 | .withTransaction( | ||
| 327 | Effect.gen(function* () { | ||
| 328 | for (const { payload: bundle, hash } of bundles) { | ||
| 329 | const current = (yield* catalog(bundle.threadId))[0]; | ||
| 330 | if (!current) continue; | ||
| 331 | if ( | ||
| 332 | current.object_hash !== hash || | ||
| 333 | current.through_sequence !== bundle.throughSequence | ||
| 334 | ) | ||
| 335 | return yield* new ArchiveError({ reason: "changed" }); | ||
| 336 | yield* install(bundle); | ||
| 337 | for (const segment of bundle.context) | ||
| 338 | for (const row of segment.rows) { | ||
| 339 | if (row.thread_id === bundle.threadId) continue; | ||
| 340 | yield* sql`INSERT OR REPLACE INTO ${sql(segment.table)} ${sql.insert(row)}`; | ||
| 341 | } | ||
| 342 | } | ||
| 343 | result = yield* Effect.exit(operation); | ||
| 344 | return yield* Effect.fail(rollback); | ||
| 345 | }), | ||
| 346 | ) | ||
| 347 | .pipe( | ||
| 348 | Effect.catchIf( | ||
| 349 | (error) => error === rollback, | ||
| 350 | () => Effect.void, | ||
| 351 | ), | ||
| 352 | Effect.mapError(archiveError), | ||
| 353 | ); | ||
| 354 | if (!result) return yield* Effect.die("An archived read must produce a result"); | ||
| 355 | return yield* result; | ||
| 356 | }); | ||
| 357 | const publish = Effect.fn("TranscriptArchive.publish")(function* ( | ||
| 358 | threadId: ThreadId, | ||
| 359 | manifestHash: string, | ||
| 360 | ) { | ||
| 361 | const row = (yield* sql<{ | ||
| 362 | project_id: string; | ||
| 363 | }>`SELECT project_id FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId}`)[0]; | ||
| 364 | if (!row) return yield* new ArchiveError({ reason: "changed" }); | ||
| 365 | const project = yield* projects.getShell(ProjectId.make(row.project_id)); | ||
| 366 | if (Option.isNone(project)) return yield* new ArchiveError({ reason: "changed" }); | ||
| 367 | return yield* client.publish({ | ||
| 368 | environmentId: yield* identity.getEnvironmentId, | ||
| 369 | manifestHash, | ||
| 370 | project: project.value, | ||
| 371 | }); | ||
| 372 | }, Effect.mapError(archiveError)); | ||
| 373 | const offload = Effect.fn("TranscriptArchive.offload")(function* (threadId: ThreadId) { | ||
| 374 | if (!(yield* client.enabled) || (yield* catalog(threadId)).length > 0) return; | ||
| 375 | const location = yield* client.location; | ||
| 376 | const snapshot = yield* read( | ||
| 377 | threadId, | ||
| 378 | sql | ||
| 379 | .withTransaction( | ||
| 380 | Effect.gen(function* () { | ||
| 381 | const thread = (yield* sql<{ | ||
| 382 | archived_at: string | null; | ||
| 383 | deleted_at: string | null; | ||
| 384 | }>`SELECT archived_at, deleted_at FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId}`)[0]; | ||
| 385 | if (!thread?.archived_at || thread.deleted_at) return undefined; | ||
| 386 | const busy = | ||
| 387 | yield* sql`SELECT 1 FROM (${activeThreads}) WHERE thread_id = ${threadId} LIMIT 1`; | ||
| 388 | if (busy.length) return undefined; | ||
| 389 | const bounds = yield* sql<{ | ||
| 390 | sequence: number; | ||
| 391 | }>`SELECT COALESCE(MAX(sequence), 0) AS sequence FROM orchestration_events WHERE stream_id = ${threadId} AND aggregate_kind = 'thread' AND application_event_version = 2`; | ||
| 392 | const segments = []; | ||
| 393 | for (const table of payloadTables) { | ||
| 394 | const rows = yield* sql< | ||
| 395 | typeof Row.Type | ||
| 396 | >`SELECT * FROM ${sql(`orchestration_v2_projection_${table}`)} WHERE thread_id = ${threadId} ORDER BY rowid`; | ||
| 397 | segments.push({ table, rows }); | ||
| 398 | } | ||
| 399 | const events = yield* sql<typeof Row.Type>`SELECT *, | ||
| 400 | CASE WHEN event_type IN ('subagent.updated', 'turn-item.updated') AND json_type(payload_json, '$.promptSequence') = 'integer' | ||
| 401 | THEN json_set(json_remove(payload_json, '$.promptSequence'), '$.prompt', (SELECT json_extract(a.payload_json, '$.prompt') FROM orchestration_events a WHERE a.sequence = json_extract(orchestration_events.payload_json, '$.promptSequence'))) | ||
| 402 | ELSE payload_json END AS payload_json | ||
| 403 | FROM orchestration_events WHERE stream_id = ${threadId} AND aggregate_kind = 'thread' AND application_event_version = 2 ORDER BY sequence`; | ||
| 404 | const ancestors = yield* dependencies(threadId); | ||
| 405 | const sourceIds = ancestors.map((row) => row.thread_id); | ||
| 406 | const context: Array<{ table: string; rows: Array<typeof Row.Type> }> = []; | ||
| 407 | for (const name of canonicalTables) { | ||
| 408 | const predicate = | ||
| 409 | name === "context_transfers" | ||
| 410 | ? sql`source_thread_id IN ${sql.in(sourceIds)} OR target_thread_id IN ${sql.in(sourceIds)}` | ||
| 411 | : name === "provider_sessions" | ||
| 412 | ? sql`thread_id IN ${sql.in(sourceIds)} OR provider_session_id IN (SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings WHERE thread_id IN ${sql.in(sourceIds)})` | ||
| 413 | : name === "provider_threads" | ||
| 414 | ? sql`thread_id IN ${sql.in(sourceIds)} OR owner_node_id IN (SELECT node_id FROM orchestration_v2_projection_nodes WHERE thread_id IN ${sql.in(sourceIds)}) OR provider_thread_id IN (SELECT provider_thread_id FROM orchestration_v2_projection_subagents WHERE thread_id IN ${sql.in(sourceIds)})` | ||
| 415 | : sql`thread_id IN ${sql.in(sourceIds)}`; | ||
| 416 | context.push({ | ||
| 417 | table: `orchestration_v2_projection_${name}`, | ||
| 418 | rows: [ | ||
| 419 | ...(yield* sql< | ||
| 420 | typeof Row.Type | ||
| 421 | >`SELECT * FROM ${sql(`orchestration_v2_projection_${name}`)} WHERE (${predicate}) ${payloadTables.some((table) => table === name) ? sql`AND thread_id <> ${threadId}` : sql``} ORDER BY rowid`), | ||
| 422 | ], | ||
| 423 | }); | ||
| 424 | } | ||
| 425 | const revisions = yield* sql<{ | ||
| 426 | thread_id: string; | ||
| 427 | sequence: number; | ||
| 428 | }>`SELECT stream_id AS thread_id, MAX(sequence) AS sequence FROM orchestration_events WHERE aggregate_kind = 'thread' AND application_event_version = 2 AND stream_id IN ${sql.in(sourceIds)} GROUP BY stream_id`; | ||
| 429 | return { | ||
| 430 | segments, | ||
| 431 | events, | ||
| 432 | context, | ||
| 433 | revisions, | ||
| 434 | throughSequence: bounds[0]?.sequence ?? 0, | ||
| 435 | }; | ||
| 436 | }), | ||
| 437 | ) | ||
| 438 | .pipe(NodeSqliteClient.readOnly), | ||
| 439 | ); | ||
| 440 | if (!snapshot) return; | ||
| 441 | const files = new Set<string>(); | ||
| 442 | const addAttachment = (attachmentId: string) => | ||
| 443 | Effect.gen(function* () { | ||
| 444 | const input = { attachmentsDir: config.attachmentsDir, attachmentId }; | ||
| 445 | const local = resolveAttachmentPathById(input); | ||
| 446 | if (local) { | ||
| 447 | files.add(local); | ||
| 448 | return; | ||
| 449 | } | ||
| 450 | const candidates = attachmentPathsById(input); | ||
| 451 | const archived = (yield* sql<{ | ||
| 452 | path: string; | ||
| 453 | }>`SELECT path FROM transcript_archive_assets WHERE path IN ${sql.in(candidates)} AND location = ${location} LIMIT 1`)[0]; | ||
| 454 | if (!archived) return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 455 | files.add(archived.path); | ||
| 456 | }); | ||
| 457 | const workspaces = yield* sql<{ | ||
| 458 | thread_id: string; | ||
| 459 | cwd: string | null; | ||
| 460 | }>`SELECT t.thread_id, COALESCE(json_extract(t.payload_json, '$.worktreePath'), p.workspace_root) AS cwd FROM orchestration_v2_projection_threads t LEFT JOIN projection_projects p ON p.project_id = t.project_id WHERE t.thread_id IN ${sql.in(snapshot.revisions.map((row) => row.thread_id))}`; | ||
| 461 | const roots = new Map(workspaces.map((row) => [row.thread_id, row.cwd ?? undefined])); | ||
| 462 | const assetSegments = [ | ||
| 463 | ...snapshot.segments.map((segment) => ({ | ||
| 464 | ...segment, | ||
| 465 | table: `orchestration_v2_projection_${segment.table}`, | ||
| 466 | })), | ||
| 467 | ...snapshot.context.filter((segment) => | ||
| 468 | ["orchestration_v2_projection_messages", "orchestration_v2_projection_turn_items"].includes( | ||
| 469 | segment.table, | ||
| 470 | ), | ||
| 471 | ), | ||
| 472 | ]; | ||
| 473 | for (const segment of assetSegments) | ||
| 474 | for (const row of segment.rows) { | ||
| 475 | const owner = ThreadId.make(String(row.thread_id)); | ||
| 476 | const cwd = roots.get(owner); | ||
| 477 | const payload = decodeRecord(String(row.payload_json)); | ||
| 478 | if ( | ||
| 479 | segment.table === "orchestration_v2_projection_messages" && | ||
| 480 | Array.isArray(payload.attachments) | ||
| 481 | ) | ||
| 482 | for (const attachment of payload.attachments) { | ||
| 483 | if ( | ||
| 484 | attachment && | ||
| 485 | typeof attachment === "object" && | ||
| 486 | "id" in attachment && | ||
| 487 | typeof attachment.id === "string" | ||
| 488 | ) { | ||
| 489 | yield* addAttachment(attachment.id); | ||
| 490 | } | ||
| 491 | } | ||
| 492 | if (segment.table === "orchestration_v2_projection_turn_items") | ||
| 493 | for (const id of threadHtmlRenderAttachmentIds(owner, [ | ||
| 494 | { | ||
| 495 | toolName: typeof payload.toolName === "string" ? payload.toolName : null, | ||
| 496 | output: payload.output, | ||
| 497 | }, | ||
| 498 | ])) { | ||
| 499 | yield* addAttachment(id); | ||
| 500 | } | ||
| 501 | for (const text of Object.values(payload)) | ||
| 502 | if (typeof text === "string") | ||
| 503 | for (const href of extractMarkdownLinkHrefs(text)) { | ||
| 504 | const target = resolveMarkdownFileLinkTarget(href, cwd); | ||
| 505 | if ( | ||
| 506 | target && | ||
| 507 | (isWorkspaceImagePreviewPath(target) || | ||
| 508 | audioMimeTypeFromExtension(path.extname(target)) || | ||
| 509 | isWorkspaceVideoPreviewPath(target)) | ||
| 510 | ) | ||
| 511 | files.add(target); | ||
| 512 | } | ||
| 513 | } | ||
| 514 | const assets = yield* Effect.forEach([...files], (file) => | ||
| 515 | Effect.gen(function* () { | ||
| 516 | if (yield* fs.exists(file)) return { path: file, hash: (yield* client.upload(file)).hash }; | ||
| 517 | const previous = (yield* sql<{ | ||
| 518 | location: string; | ||
| 519 | object_hash: string; | ||
| 520 | }>`SELECT location, object_hash FROM transcript_archive_assets WHERE path = ${file} LIMIT 1`)[0]; | ||
| 521 | if (!previous || previous.location !== location) | ||
| 522 | return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 523 | return { path: file, hash: previous.object_hash }; | ||
| 524 | }), | ||
| 525 | ); | ||
| 526 | const bundle = { | ||
| 527 | version: 1 as const, | ||
| 528 | threadId, | ||
| 529 | throughSequence: snapshot.throughSequence, | ||
| 530 | segments: snapshot.segments, | ||
| 531 | events: snapshot.events, | ||
| 532 | context: snapshot.context, | ||
| 533 | assets, | ||
| 534 | }; | ||
| 535 | const receipt = yield* Effect.scoped( | ||
| 536 | Effect.gen(function* () { | ||
| 537 | const temp = yield* fs.makeTempDirectoryScoped({ prefix: "knife-archive-write-" }); | ||
| 538 | let ordinal = 0; | ||
| 539 | const storeRows = (rows: ReadonlyArray<typeof Row.Type>) => | ||
| 540 | Effect.gen(function* () { | ||
| 541 | const file = path.join(temp, `rows-${ordinal++}`); | ||
| 542 | yield* fs.writeFileString(file, yield* encodeRows(rows), { mode: 0o600 }); | ||
| 543 | return (yield* client.upload(file)).hash; | ||
| 544 | }); | ||
| 545 | const segments = []; | ||
| 546 | for (const segment of bundle.segments) | ||
| 547 | if (segment.rows.length) | ||
| 548 | segments.push({ table: segment.table, hash: yield* storeRows(segment.rows) }); | ||
| 549 | const context = []; | ||
| 550 | for (const segment of bundle.context) { | ||
| 551 | for (const rows of Map.groupBy( | ||
| 552 | segment.rows, | ||
| 553 | (row) => row.thread_id ?? row.source_thread_id ?? "shared", | ||
| 554 | ).values()) { | ||
| 555 | context.push({ table: segment.table, hash: yield* storeRows(rows) }); | ||
| 556 | } | ||
| 557 | } | ||
| 558 | const manifest = { ...bundle, segments, context, events: yield* storeRows(bundle.events) }; | ||
| 559 | const file = path.join(temp, "transcript"); | ||
| 560 | yield* fs.writeFileString(file, yield* encodeBundle(manifest), { mode: 0o600 }); | ||
| 561 | const uploaded = yield* client.upload(file); | ||
| 562 | yield* client.download(location, uploaded.hash, path.join(temp, "verified")); | ||
| 563 | return uploaded; | ||
| 564 | }), | ||
| 565 | ); | ||
| 566 | const environmentId = yield* identity.getEnvironmentId; | ||
| 567 | const published = yield* publish(threadId, receipt.hash); | ||
| 568 | if ( | ||
| 569 | published.environmentId !== environmentId || | ||
| 570 | published.thread.id !== threadId || | ||
| 571 | published.manifestHash !== receipt.hash || | ||
| 572 | published.throughSequence !== snapshot.throughSequence | ||
| 573 | ) | ||
| 574 | return yield* new ArchiveError({ reason: "integrity" }); | ||
| 575 | yield* sql`INSERT INTO transcript_archive_publications(thread_id, location, object_hash) VALUES(${threadId}, ${location}, ${receipt.hash}) ON CONFLICT(thread_id) DO UPDATE SET location = excluded.location, object_hash = excluded.object_hash`; | ||
| 576 | yield* sql.withTransaction( | ||
| 577 | Effect.gen(function* () { | ||
| 578 | const rows = | ||
| 579 | yield* sql`SELECT 1 FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} AND archived_at IS NOT NULL AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM transcript_archives WHERE thread_id = ${threadId}) AND NOT EXISTS (SELECT 1 FROM (${activeThreads}) WHERE thread_id = ${threadId}) AND ${snapshot.throughSequence} = (SELECT MAX(sequence) FROM orchestration_events WHERE stream_id = ${threadId} AND aggregate_kind = 'thread' AND application_event_version = 2)`; | ||
| 580 | if (!rows.length) return yield* new ArchiveError({ reason: "changed" }); | ||
| 581 | for (const revision of snapshot.revisions) { | ||
| 582 | const changed = | ||
| 583 | yield* sql`SELECT 1 WHERE ${revision.sequence} <> (SELECT MAX(sequence) FROM orchestration_events WHERE aggregate_kind = 'thread' AND application_event_version = 2 AND stream_id = ${revision.thread_id})`; | ||
| 584 | if (changed.length) return yield* new ArchiveError({ reason: "changed" }); | ||
| 585 | } | ||
| 586 | const metadata: typeof Metadata.Type = snapshot.segments.map((segment) => ({ | ||
| 587 | ...segment, | ||
| 588 | rows: segment.rows.map((row) => ({ | ||
| 589 | ...row, | ||
| 590 | payload_json: metadataPayload(segment.table, String(row.payload_json)), | ||
| 591 | })), | ||
| 592 | })); | ||
| 593 | yield* sql`INSERT INTO transcript_archives(thread_id, location, object_hash, through_sequence, metadata_json) VALUES(${threadId}, ${location}, ${receipt.hash}, ${snapshot.throughSequence}, ${encodeMetadata(metadata)})`; | ||
| 594 | for (const segment of metadata) | ||
| 595 | for (const row of segment.rows) | ||
| 596 | yield* sql`UPDATE ${sql(`orchestration_v2_projection_${segment.table}`)} SET payload_json = ${row.payload_json} WHERE ${sql(ids[segment.table])} = ${row[ids[segment.table]]} AND thread_id = ${threadId}`; | ||
| 597 | yield* sql`UPDATE orchestration_events SET payload_json = json_set(json_remove(payload_json, '$.promptSequence'), '$.prompt', (SELECT json_extract(a.payload_json, '$.prompt') FROM orchestration_events a WHERE a.sequence = json_extract(orchestration_events.payload_json, '$.promptSequence'))) WHERE json_type(payload_json, '$.promptSequence') = 'integer' AND json_extract(payload_json, '$.promptSequence') IN (SELECT sequence FROM orchestration_events WHERE stream_id = ${threadId})`; | ||
| 598 | yield* sql`UPDATE orchestration_events SET payload_json = 'null' WHERE stream_id = ${threadId} AND application_event_version = 2 AND aggregate_kind = 'thread' AND sequence <= ${snapshot.throughSequence} AND event_type IN ('message.updated', 'turn-item.updated', 'subagent.updated')`; | ||
| 599 | for (const asset of assets) | ||
| 600 | yield* sql`INSERT INTO transcript_archive_assets(thread_id, path, location, object_hash) VALUES(${threadId}, ${asset.path}, ${location}, ${asset.hash}) ON CONFLICT(thread_id, path) DO UPDATE SET location = excluded.location, object_hash = excluded.object_hash`; | ||
| 601 | yield* sql`DELETE FROM transcript_archive_failures WHERE thread_id = ${threadId}`; | ||
| 602 | yield* sql`UPDATE transcript_archive_reclamation SET pending = 1 WHERE singleton = 1`; | ||
| 603 | }), | ||
| 604 | ); | ||
| 605 | }, Effect.mapError(archiveError)); | ||
| 606 | const evictAttachments = sql.withTransaction( | ||
| 607 | Effect.gen(function* () { | ||
| 608 | const assets = yield* sql<{ | ||
| 609 | thread_id: ThreadId; | ||
| 610 | path: string; | ||
| 611 | }>`SELECT a.thread_id, a.path FROM transcript_archive_assets a JOIN transcript_archives c ON c.thread_id = a.thread_id`; | ||
| 612 | const references = yield* sql<{ | ||
| 613 | id: string; | ||
| 614 | }>`SELECT DISTINCT json_extract(a.value, '$.id') AS id FROM orchestration_v2_projection_messages m JOIN orchestration_v2_projection_threads t ON t.thread_id = m.thread_id, json_each(m.payload_json, '$.attachments') a WHERE t.archived_at IS NULL AND t.deleted_at IS NULL`; | ||
| 615 | const warmSources = yield* sql<{ thread_id: string }>`WITH RECURSIVE sources(thread_id) AS ( | ||
| 616 | SELECT thread_id FROM orchestration_v2_projection_threads WHERE archived_at IS NULL AND deleted_at IS NULL | ||
| 617 | UNION SELECT json_extract(t.payload_json, '$.forkedFrom.threadId') FROM orchestration_v2_projection_threads t JOIN sources s ON s.thread_id = t.thread_id WHERE json_extract(t.payload_json, '$.forkedFrom.type') = 'run' | ||
| 618 | ) SELECT thread_id FROM sources`; | ||
| 619 | const referenced = new Set(references.map((row) => row.id)); | ||
| 620 | const warm = new Set(warmSources.map((row) => row.thread_id)); | ||
| 621 | for (const asset of assets) { | ||
| 622 | const relative = path.relative(config.attachmentsDir, asset.path); | ||
| 623 | if (relative.startsWith("..") || path.isAbsolute(relative) || warm.has(asset.thread_id)) | ||
| 624 | continue; | ||
| 625 | const id = parseAttachmentIdFromRelativePath(relative); | ||
| 626 | if ( | ||
| 627 | !id || | ||
| 628 | referenced.has(id) || | ||
| 629 | parseThreadSegmentFromAttachmentId(id) !== toSafeThreadAttachmentSegment(asset.thread_id) | ||
| 630 | ) | ||
| 631 | continue; | ||
| 632 | yield* fs.remove(asset.path, { force: true }); | ||
| 633 | } | ||
| 634 | }), | ||
| 635 | ); | ||
| 636 | const sweep = Effect.gen(function* () { | ||
| 637 | if (!(yield* client.enabled)) return; | ||
| 638 | const environmentId = yield* identity.getEnvironmentId; | ||
| 639 | const now = yield* Clock.currentTimeMillis; | ||
| 640 | const attempt = (threadId: ThreadId, operation: Effect.Effect<void, ArchiveError>) => | ||
| 641 | operation.pipe( | ||
| 642 | Effect.andThen(sql`DELETE FROM transcript_archive_failures WHERE thread_id = ${threadId}`), | ||
| 643 | Effect.catchTags({ | ||
| 644 | ArchiveError: (error) => | ||
| 645 | error.reason === "changed" | ||
| 646 | ? Effect.void | ||
| 647 | : sql`INSERT INTO transcript_archive_failures(thread_id, attempts, reason, next_attempt_at) VALUES(${threadId}, 1, ${error.reason}, ${now + 60_000}) ON CONFLICT(thread_id) DO UPDATE SET attempts = attempts + 1, reason = excluded.reason, next_attempt_at = ${now} + MIN(3600000, 60000 * (1 << MIN(attempts, 6)))`.pipe( | ||
| 648 | Effect.andThen( | ||
| 649 | Effect.logWarning("Transcript archive sync will retry", { | ||
| 650 | threadId, | ||
| 651 | reason: error.reason, | ||
| 652 | }), | ||
| 653 | ), | ||
| 654 | ), | ||
| 655 | }), | ||
| 656 | ); | ||
| 657 | const unpublished = yield* sql<{ | ||
| 658 | thread_id: ThreadId; | ||
| 659 | object_hash: string; | ||
| 660 | location: string; | ||
| 661 | }>`SELECT a.thread_id, a.object_hash, a.location FROM transcript_archives a LEFT JOIN transcript_archive_publications p ON p.thread_id = a.thread_id LEFT JOIN transcript_archive_failures f ON f.thread_id = a.thread_id WHERE p.thread_id IS NULL AND COALESCE(f.next_attempt_at, 0) <= ${now} LIMIT 10`; | ||
| 662 | for (const entry of unpublished) { | ||
| 663 | if (entry.location !== (yield* client.location)) continue; | ||
| 664 | yield* attempt( | ||
| 665 | entry.thread_id, | ||
| 666 | Effect.gen(function* () { | ||
| 667 | yield* publish(entry.thread_id, entry.object_hash); | ||
| 668 | yield* sql`INSERT INTO transcript_archive_publications(thread_id, location, object_hash) VALUES(${entry.thread_id}, ${entry.location}, ${entry.object_hash}) ON CONFLICT(thread_id) DO UPDATE SET location = excluded.location, object_hash = excluded.object_hash`; | ||
| 669 | }).pipe(Effect.mapError(archiveError)), | ||
| 670 | ); | ||
| 671 | } | ||
| 672 | const warm = yield* sql<{ | ||
| 673 | thread_id: ThreadId; | ||
| 674 | location: string; | ||
| 675 | object_hash: string; | ||
| 676 | }>`SELECT p.thread_id, p.location, p.object_hash FROM transcript_archive_publications p JOIN orchestration_v2_projection_threads t ON t.thread_id = p.thread_id LEFT JOIN transcript_archive_failures f ON f.thread_id = p.thread_id WHERE (t.archived_at IS NULL OR t.deleted_at IS NOT NULL) AND COALESCE(f.next_attempt_at, 0) <= ${now}`; | ||
| 677 | for (const entry of warm) { | ||
| 678 | if (entry.location !== (yield* client.location)) continue; | ||
| 679 | yield* attempt( | ||
| 680 | entry.thread_id, | ||
| 681 | Effect.gen(function* () { | ||
| 682 | yield* client.withdraw(environmentId, entry.thread_id, entry.object_hash); | ||
| 683 | yield* sql`DELETE FROM transcript_archive_publications WHERE thread_id = ${entry.thread_id} AND object_hash = ${entry.object_hash}`; | ||
| 684 | }).pipe(Effect.mapError(archiveError)), | ||
| 685 | ); | ||
| 686 | } | ||
| 687 | const candidates = yield* sql<{ | ||
| 688 | thread_id: ThreadId; | ||
| 689 | }>`SELECT t.thread_id FROM orchestration_v2_projection_threads t LEFT JOIN transcript_archive_failures f ON f.thread_id = t.thread_id WHERE t.archived_at IS NOT NULL AND t.deleted_at IS NULL AND COALESCE(f.next_attempt_at, 0) <= ${now} AND NOT EXISTS (SELECT 1 FROM transcript_archives a WHERE a.thread_id = t.thread_id) AND NOT EXISTS (SELECT 1 FROM (${activeThreads}) active WHERE active.thread_id = t.thread_id) ORDER BY t.archived_at LIMIT 10`; | ||
| 690 | for (const row of candidates) yield* attempt(row.thread_id, offload(row.thread_id)); | ||
| 691 | yield* evictAttachments; | ||
| 692 | yield* storage.reclaim; | ||
| 693 | }).pipe(Effect.mapError(archiveError)); | ||
| 694 | const queuedThreads = sql`SELECT t.thread_id FROM orchestration_v2_projection_threads t LEFT JOIN transcript_archives a ON a.thread_id = t.thread_id LEFT JOIN transcript_archive_publications p ON p.thread_id = t.thread_id WHERE (t.archived_at IS NOT NULL AND t.deleted_at IS NULL AND (a.thread_id IS NULL OR p.thread_id IS NULL)) OR ((t.archived_at IS NULL OR t.deleted_at IS NOT NULL) AND p.thread_id IS NOT NULL)`; | ||
| 695 | const manage = Effect.fn("TranscriptArchive.manage")(function* ( | ||
| 696 | request: ArchiveManagementRequest, | ||
| 697 | ) { | ||
| 698 | if (request.type === "archive.retry") | ||
| 699 | yield* sql`DELETE FROM transcript_archive_failures ${request.threadId ? sql`WHERE thread_id = ${request.threadId}` : sql``}`; | ||
| 700 | const counts = yield* sql<{ | ||
| 701 | offloaded: number; | ||
| 702 | pending: number; | ||
| 703 | }>`SELECT (SELECT COUNT(*) FROM transcript_archives) AS offloaded, (SELECT COUNT(*) FROM (${queuedThreads})) AS pending`; | ||
| 704 | const failures = yield* sql<{ | ||
| 705 | threadId: ThreadId; | ||
| 706 | attempts: number; | ||
| 707 | reason: string; | ||
| 708 | }>`SELECT thread_id AS threadId, attempts, reason FROM transcript_archive_failures ORDER BY thread_id`; | ||
| 709 | return { | ||
| 710 | enabled: yield* client.enabled, | ||
| 711 | queue: yield* sql<{ | ||
| 712 | threadId: ThreadId; | ||
| 713 | nextAttemptAt: number; | ||
| 714 | }>`SELECT t.thread_id AS threadId, COALESCE(f.next_attempt_at, 0) AS nextAttemptAt FROM (${queuedThreads}) t LEFT JOIN transcript_archive_failures f ON f.thread_id = t.thread_id ORDER BY nextAttemptAt, t.thread_id`, | ||
| 715 | storage: yield* storage.inspect, | ||
| 716 | lastReclamation: | ||
| 717 | (yield* sql<{ | ||
| 718 | beforeAllocatedBytes: number; | ||
| 719 | afterAllocatedBytes: number; | ||
| 720 | }>`SELECT before_allocated_bytes AS beforeAllocatedBytes, after_allocated_bytes AS afterAllocatedBytes FROM transcript_archive_reclamation WHERE singleton = 1 AND completed_at IS NOT NULL`)[0] ?? | ||
| 721 | null, | ||
| 722 | offloaded: counts[0]?.offloaded ?? 0, | ||
| 723 | pending: counts[0]?.pending ?? 0, | ||
| 724 | failures, | ||
| 725 | }; | ||
| 726 | }, Effect.mapError(archiveError)); | ||
| 727 | return TranscriptArchive.of({ | ||
| 728 | enabled: client.enabled, | ||
| 729 | manage, | ||
| 730 | offload, | ||
| 731 | restore, | ||
| 732 | read, | ||
| 733 | readStream: (id, stream) => | ||
| 734 | Stream.unwrap( | ||
| 735 | entries(id).pipe( | ||
| 736 | Effect.mapError(archiveError), | ||
| 737 | Effect.flatMap((found) => | ||
| 738 | found.length === 0 | ||
| 739 | ? Effect.succeed(stream) | ||
| 740 | : read(id, Stream.runCollect(stream)).pipe(Effect.map(Stream.fromIterable)), | ||
| 741 | ), | ||
| 742 | ), | ||
| 743 | ), | ||
| 744 | sweep, | ||
| 745 | asset: (paths) => | ||
| 746 | sql<{ | ||
| 747 | path: string; | ||
| 748 | location: string; | ||
| 749 | object_hash: string; | ||
| 750 | }>`SELECT path, location, object_hash FROM transcript_archive_assets WHERE path IN ${sql.in(paths)} ORDER BY rowid DESC LIMIT 1`.pipe( | ||
| 751 | Effect.map((rows) => | ||
| 752 | Option.fromUndefinedOr(rows[0]).pipe( | ||
| 753 | Option.map((row) => ({ | ||
| 754 | path: row.path, | ||
| 755 | location: row.location, | ||
| 756 | hash: row.object_hash, | ||
| 757 | })), | ||
| 758 | ), | ||
| 759 | ), | ||
| 760 | Effect.mapError(archiveError), | ||
| 761 | ), | ||
| 762 | }); | ||
| 763 | }); | ||
| 764 | |||
| 765 | export const layer = Layer.effect(TranscriptArchive, make).pipe(Layer.provide(ProjectStore.layer)); | ||
| 766 | |||
| 767 | /** Rebuild cold timeline indexes locally; no NAS request or transcript body is needed. */ | ||
| 768 | export const rebuildMetadata = Effect.gen(function* () { | ||
| 769 | const sql = yield* SqlClient.SqlClient; | ||
| 770 | const rows = yield* sql<{ | ||
| 771 | metadata_json: string; | ||
| 772 | }>`SELECT metadata_json FROM transcript_archives ORDER BY through_sequence`; | ||
| 773 | for (const entry of rows) | ||
| 774 | for (const segment of yield* decodeMetadata(entry.metadata_json)) { | ||
| 775 | for (const row of segment.rows) | ||
| 776 | yield* sql`INSERT INTO ${sql(`orchestration_v2_projection_${segment.table}`)} ${sql.insert(row)} ON CONFLICT DO NOTHING`; | ||
| 777 | } | ||
| 778 | }); | ||
apps/server/src/archive/runtimeLayer.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import * as Layer from "effect/Layer"; | ||
| 2 | import * as SharedServer from "../environment/SharedServer.ts"; | ||
| 3 | import * as ArchiveObjects from "./ArchiveObjects.ts"; | ||
| 4 | import * as ArchiveClient from "./ArchiveClient.ts"; | ||
| 5 | import * as ArchiveCatalog from "./ArchiveCatalog.ts"; | ||
| 6 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 7 | import * as ArchiveStorage from "./ArchiveStorage.ts"; | ||
| 8 | import * as TranscriptArchive from "./TranscriptArchive.ts"; | ||
| 9 | |||
| 10 | export const layer = TranscriptArchive.layer.pipe( | ||
| 11 | Layer.provideMerge(ArchiveClient.layer.pipe(Layer.provideMerge(ArchiveCatalog.layer))), | ||
| 12 | Layer.provideMerge(ArchiveObjects.layer), | ||
| 13 | Layer.provideMerge(ArchiveStorage.layer), | ||
| 14 | Layer.provideMerge(SharedServer.layer), | ||
| 15 | Layer.provideMerge(ServerEnvironment.layerIdentity), | ||
| 16 | ); | ||
apps/server/src/assets/AssetAccess.test.ts+6| ... | @@ -26,6 +26,8 @@ import { vi } from "vite-plus/test"; | ... | @@ -26,6 +26,8 @@ import { vi } from "vite-plus/test"; |
| 26 | 26 | ||
| 27 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 27 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 28 | import * as ServerConfig from "../config.ts"; | 28 | import * as ServerConfig from "../config.ts"; |
| 29 | import * as TranscriptArchive from "../archive/TranscriptArchive.ts"; | ||
| 30 | import * as Option from "effect/Option"; | ||
| 29 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | 31 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 30 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 32 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 31 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; | 33 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; |
| ... | @@ -106,6 +108,10 @@ const oversizedScreenshotItem = { | ... | @@ -106,6 +108,10 @@ const oversizedScreenshotItem = { |
| 106 | }; | 108 | }; |
| 107 | 109 | ||
| 108 | const layerTest = Layer.mergeAll( | 110 | const layerTest = Layer.mergeAll( |
| 111 | Layer.mock(TranscriptArchive.TranscriptArchive)({ | ||
| 112 | enabled: Effect.succeed(false), | ||
| 113 | asset: () => Effect.succeed(Option.none()), | ||
| 114 | }), | ||
| 109 | NodeHttpPlatform.layer, | 115 | NodeHttpPlatform.layer, |
| 110 | Layer.mock(Orchestrator.OrchestratorV2)({ | 116 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 111 | getTurnItem: ({ itemId }) => | 117 | getTurnItem: ({ itemId }) => |
apps/server/src/assets/AssetAccess.ts+176-29| ... | @@ -55,8 +55,13 @@ import { | ... | @@ -55,8 +55,13 @@ import { |
| 55 | timingSafeEqualBase64Url, | 55 | timingSafeEqualBase64Url, |
| 56 | } from "../auth/utils.ts"; | 56 | } from "../auth/utils.ts"; |
| 57 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 57 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 58 | import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; | 58 | import { |
| 59 | attachmentPathsById, | ||
| 60 | parseAttachmentFileExtension, | ||
| 61 | resolveAttachmentPathById, | ||
| 62 | } from "../attachmentStore.ts"; | ||
| 59 | import * as ServerConfig from "../config.ts"; | 63 | import * as ServerConfig from "../config.ts"; |
| 64 | import * as TranscriptArchive from "../archive/TranscriptArchive.ts"; | ||
| 60 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | 65 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 61 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | 66 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 62 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 67 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| ... | @@ -96,7 +101,20 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ | ... | @@ -96,7 +101,20 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ |
| 96 | ".woff2", | 101 | ".woff2", |
| 97 | ]); | 102 | ]); |
| 98 | 103 | ||
| 104 | const ArchivedFileClaims = Schema.Struct({ | ||
| 105 | version: Schema.Literal(1), | ||
| 106 | kind: Schema.Literal("archived-file"), | ||
| 107 | path: Schema.String, | ||
| 108 | location: Schema.String, | ||
| 109 | hash: Schema.String, | ||
| 110 | fileName: Schema.String, | ||
| 111 | mimeType: Schema.String, | ||
| 112 | download: Schema.Boolean, | ||
| 113 | renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")), | ||
| 114 | expiresAt: Schema.Number, | ||
| 115 | }); | ||
| 99 | const AssetClaimsSchema = Schema.Union([ | 116 | const AssetClaimsSchema = Schema.Union([ |
| 117 | ArchivedFileClaims, | ||
| 100 | Schema.Struct({ | 118 | Schema.Struct({ |
| 101 | version: Schema.Literal(1), | 119 | version: Schema.Literal(1), |
| 102 | kind: Schema.Literal("workspace-file"), | 120 | kind: Schema.Literal("workspace-file"), |
| ... | @@ -176,6 +194,7 @@ const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); | ... | @@ -176,6 +194,7 @@ const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); |
| 176 | const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); | 194 | const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); |
| 177 | 195 | ||
| 178 | export type ResolvedAsset = | 196 | export type ResolvedAsset = |
| 197 | | ({ readonly kind: "archived-file" } & Omit<typeof ArchivedFileClaims.Type, "kind" | "version">) | ||
| 179 | | { | 198 | | { |
| 180 | readonly kind: "file"; | 199 | readonly kind: "file"; |
| 181 | readonly path: string; | 200 | readonly path: string; |
| ... | @@ -511,6 +530,72 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA | ... | @@ -511,6 +530,72 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA |
| 511 | }, | 530 | }, |
| 512 | ); | 531 | ); |
| 513 | 532 | ||
| 533 | const archivedFile = Effect.fn("AssetAccess.archivedFile")(function* ( | ||
| 534 | paths: ReadonlyArray<string>, | ||
| 535 | options: { | ||
| 536 | readonly expiresAt: number; | ||
| 537 | readonly fileName?: string | undefined; | ||
| 538 | readonly mimeType?: string | undefined; | ||
| 539 | readonly download?: boolean | undefined; | ||
| 540 | readonly renderIntent?: "mcp-app" | undefined; | ||
| 541 | }, | ||
| 542 | ) { | ||
| 543 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 544 | const path = yield* Path.Path; | ||
| 545 | const found = yield* archive.asset(paths); | ||
| 546 | if (Option.isNone(found)) return null; | ||
| 547 | const fileName = options.fileName ?? path.basename(found.value.path); | ||
| 548 | return { | ||
| 549 | ...found.value, | ||
| 550 | version: 1 as const, | ||
| 551 | kind: "archived-file" as const, | ||
| 552 | fileName, | ||
| 553 | mimeType: | ||
| 554 | options.mimeType ?? | ||
| 555 | hostPreviewMimeTypeFromExtension(path.extname(fileName)) ?? | ||
| 556 | "application/octet-stream", | ||
| 557 | download: options.download ?? false, | ||
| 558 | ...(options.renderIntent ? { renderIntent: options.renderIntent } : {}), | ||
| 559 | expiresAt: options.expiresAt, | ||
| 560 | }; | ||
| 561 | }); | ||
| 562 | |||
| 563 | const issueClaimUrl = Effect.fn("AssetAccess.issueClaimUrl")(function* ( | ||
| 564 | resource: AssetResource, | ||
| 565 | initialClaims: AssetClaims, | ||
| 566 | fileName: string, | ||
| 567 | sourcePath: string | undefined, | ||
| 568 | imageDimensions: ImageDimensions | null, | ||
| 569 | ) { | ||
| 570 | let claims = initialClaims; | ||
| 571 | let expiresAt = claims.expiresAt; | ||
| 572 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| 573 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( | ||
| 574 | Effect.mapError( | ||
| 575 | (cause) => | ||
| 576 | new AssetSigningKeyLoadError({ | ||
| 577 | resource, | ||
| 578 | cause, | ||
| 579 | }), | ||
| 580 | ), | ||
| 581 | ); | ||
| 582 | if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") { | ||
| 583 | const issuedAt = yield* Clock.currentTimeMillis; | ||
| 584 | expiresAt = | ||
| 585 | (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * | ||
| 586 | PROJECT_FAVICON_TOKEN_BUCKET_MS; | ||
| 587 | claims = { ...claims, expiresAt }; | ||
| 588 | } | ||
| 589 | const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); | ||
| 590 | const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; | ||
| 591 | return { | ||
| 592 | relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, | ||
| 593 | expiresAt, | ||
| 594 | ...(sourcePath !== undefined ? { sourcePath } : {}), | ||
| 595 | ...(imageDimensions !== null ? { imageDimensions } : {}), | ||
| 596 | }; | ||
| 597 | }); | ||
| 598 | |||
| 514 | export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { | 599 | export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { |
| 515 | readonly resource: AssetResource; | 600 | readonly resource: AssetResource; |
| 516 | readonly workspaceRoot?: string; | 601 | readonly workspaceRoot?: string; |
| ... | @@ -527,6 +612,68 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -527,6 +612,68 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 527 | let sourcePath: string | undefined; | 612 | let sourcePath: string | undefined; |
| 528 | let imageDimensions: ImageDimensions | null = null; | 613 | let imageDimensions: ImageDimensions | null = null; |
| 529 | 614 | ||
| 615 | const resource = input.resource; | ||
| 616 | let archivePaths: ReadonlyArray<string> = []; | ||
| 617 | if (resource._tag === "attachment") { | ||
| 618 | const input = { | ||
| 619 | attachmentsDir: (yield* ServerConfig.ServerConfig).attachmentsDir, | ||
| 620 | attachmentId: resource.attachmentId, | ||
| 621 | }; | ||
| 622 | if (!resolveAttachmentPathById(input)) archivePaths = attachmentPathsById(input); | ||
| 623 | } else if (resource._tag === "media-file" || resource._tag === "workspace-file") { | ||
| 624 | const expanded = expandHomePath(resource.path, yield* HostProcess.HomeDirectory); | ||
| 625 | if (path.isAbsolute(expanded)) archivePaths = [expanded]; | ||
| 626 | else if (input.workspaceRoot) archivePaths = [path.resolve(input.workspaceRoot, expanded)]; | ||
| 627 | } | ||
| 628 | const present = | ||
| 629 | resource._tag === "attachment" | ||
| 630 | ? [] | ||
| 631 | : yield* Effect.forEach(archivePaths, (file) => | ||
| 632 | fileSystem | ||
| 633 | .exists(file) | ||
| 634 | .pipe( | ||
| 635 | Effect.mapError( | ||
| 636 | (cause) => new AssetWorkspaceAssetInspectionError({ resource, cause }), | ||
| 637 | ), | ||
| 638 | ), | ||
| 639 | ); | ||
| 640 | const archived = | ||
| 641 | archivePaths.length > 0 && !present.some(Boolean) | ||
| 642 | ? yield* (yield* TranscriptArchive.TranscriptArchive) | ||
| 643 | .asset(archivePaths) | ||
| 644 | .pipe( | ||
| 645 | Effect.mapError((cause) => new AssetWorkspaceAssetInspectionError({ resource, cause })), | ||
| 646 | ) | ||
| 647 | : Option.none(); | ||
| 648 | if (Option.isSome(archived)) { | ||
| 649 | const archivedPath = archived.value.path; | ||
| 650 | fileName = | ||
| 651 | resource._tag === "attachment" | ||
| 652 | ? (resource.fileName ?? path.basename(archivedPath)) | ||
| 653 | : path.basename(archivedPath); | ||
| 654 | const mimeType = | ||
| 655 | (resource._tag === "attachment" ? resource.mimeType : undefined) ?? | ||
| 656 | hostPreviewMimeTypeFromExtension(path.extname(fileName)) ?? | ||
| 657 | "application/octet-stream"; | ||
| 658 | claims = { | ||
| 659 | version: 1, | ||
| 660 | kind: "archived-file", | ||
| 661 | path: archivedPath, | ||
| 662 | location: archived.value.location, | ||
| 663 | hash: archived.value.hash, | ||
| 664 | fileName, | ||
| 665 | mimeType, | ||
| 666 | download: | ||
| 667 | resource._tag === "attachment" && | ||
| 668 | resource.disposition !== "inline" && | ||
| 669 | !/^(image|video)\//u.test(mimeType), | ||
| 670 | ...(resource._tag === "attachment" && resource.renderIntent === "mcp-app" | ||
| 671 | ? { renderIntent: "mcp-app" as const } | ||
| 672 | : {}), | ||
| 673 | expiresAt, | ||
| 674 | }; | ||
| 675 | return yield* issueClaimUrl(resource, claims, fileName, archivedPath, null); | ||
| 676 | } | ||
| 530 | switch (input.resource._tag) { | 677 | switch (input.resource._tag) { |
| 531 | case "media-file": { | 678 | case "media-file": { |
| 532 | let requestedPath = expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory); | 679 | let requestedPath = expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory); |
| ... | @@ -819,31 +966,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -819,31 +966,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 819 | } | 966 | } |
| 820 | } | 967 | } |
| 821 | 968 | ||
| 822 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | 969 | return yield* issueClaimUrl(input.resource, claims, fileName, sourcePath, imageDimensions); |
| 823 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( | ||
| 824 | Effect.mapError( | ||
| 825 | (cause) => | ||
| 826 | new AssetSigningKeyLoadError({ | ||
| 827 | resource: input.resource, | ||
| 828 | cause, | ||
| 829 | }), | ||
| 830 | ), | ||
| 831 | ); | ||
| 832 | if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") { | ||
| 833 | const issuedAt = yield* Clock.currentTimeMillis; | ||
| 834 | expiresAt = | ||
| 835 | (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * | ||
| 836 | PROJECT_FAVICON_TOKEN_BUCKET_MS; | ||
| 837 | claims = { ...claims, expiresAt }; | ||
| 838 | } | ||
| 839 | const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); | ||
| 840 | const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; | ||
| 841 | return { | ||
| 842 | relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, | ||
| 843 | expiresAt, | ||
| 844 | ...(sourcePath !== undefined ? { sourcePath } : {}), | ||
| 845 | ...(imageDimensions !== null ? { imageDimensions } : {}), | ||
| 846 | }; | ||
| 847 | }); | 970 | }); |
| 848 | 971 | ||
| 849 | export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( | 972 | export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| ... | @@ -864,13 +987,25 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( | ... | @@ -864,13 +987,25 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 864 | const claims = decodeClaims(encodedPayload); | 987 | const claims = decodeClaims(encodedPayload); |
| 865 | if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; | 988 | if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; |
| 866 | 989 | ||
| 990 | if (claims.kind === "archived-file") { | ||
| 991 | if (decodeRelativePath(relativePath) !== claims.fileName) return null; | ||
| 992 | return { ...claims, kind: "archived-file" } satisfies ResolvedAsset; | ||
| 993 | } | ||
| 994 | |||
| 867 | if (claims.kind === "attachment") { | 995 | if (claims.kind === "attachment") { |
| 868 | const config = yield* ServerConfig.ServerConfig; | 996 | const config = yield* ServerConfig.ServerConfig; |
| 869 | const attachmentPath = resolveAttachmentPathById({ | 997 | const attachmentPath = resolveAttachmentPathById({ |
| 870 | attachmentsDir: config.attachmentsDir, | 998 | attachmentsDir: config.attachmentsDir, |
| 871 | attachmentId: claims.attachmentId, | 999 | attachmentId: claims.attachmentId, |
| 872 | }); | 1000 | }); |
| 873 | if (!attachmentPath) return null; | 1001 | if (!attachmentPath) |
| 1002 | return yield* archivedFile( | ||
| 1003 | attachmentPathsById({ | ||
| 1004 | attachmentsDir: config.attachmentsDir, | ||
| 1005 | attachmentId: claims.attachmentId, | ||
| 1006 | }), | ||
| 1007 | claims, | ||
| 1008 | ).pipe(Effect.orElseSucceed(() => null)); | ||
| 874 | const fileSystem = yield* FileSystem.FileSystem; | 1009 | const fileSystem = yield* FileSystem.FileSystem; |
| 875 | const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe( | 1010 | const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe( |
| 876 | Effect.tapError((cause) => | 1011 | Effect.tapError((cause) => |
| ... | @@ -959,6 +1094,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( | ... | @@ -959,6 +1094,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 959 | ), | 1094 | ), |
| 960 | Effect.orElseSucceed(() => null), | 1095 | Effect.orElseSucceed(() => null), |
| 961 | ); | 1096 | ); |
| 1097 | if (canonicalFile === null) | ||
| 1098 | return yield* archivedFile([claims.filePath], { expiresAt: claims.expiresAt }).pipe( | ||
| 1099 | Effect.orElseSucceed(() => null), | ||
| 1100 | ); | ||
| 962 | if (canonicalFile !== claims.filePath) return null; | 1101 | if (canonicalFile !== claims.filePath) return null; |
| 963 | const isIcns = canonicalFile.toLowerCase().endsWith(".icns"); | 1102 | const isIcns = canonicalFile.toLowerCase().endsWith(".icns"); |
| 964 | const mimeType = isIcns | 1103 | const mimeType = isIcns |
| ... | @@ -982,7 +1121,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( | ... | @@ -982,7 +1121,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 982 | workspaceRoot: claims.workspaceRoot, | 1121 | workspaceRoot: claims.workspaceRoot, |
| 983 | relativePath: claims.relativePath, | 1122 | relativePath: claims.relativePath, |
| 984 | }); | 1123 | }); |
| 985 | return exactWorkspaceFile ? yield* imagePreviewAsset(exactWorkspaceFile) : null; | 1124 | return exactWorkspaceFile |
| 1125 | ? yield* imagePreviewAsset(exactWorkspaceFile) | ||
| 1126 | : yield* archivedFile([path.resolve(claims.workspaceRoot, claims.relativePath)], { | ||
| 1127 | expiresAt: claims.expiresAt, | ||
| 1128 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 986 | } | 1129 | } |
| 987 | const segments = decodedPath.split(/[\\/]/); | 1130 | const segments = decodedPath.split(/[\\/]/); |
| 988 | if ( | 1131 | if ( |
| ... | @@ -999,5 +1142,9 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( | ... | @@ -999,5 +1142,9 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 999 | workspaceRoot: claims.workspaceRoot, | 1142 | workspaceRoot: claims.workspaceRoot, |
| 1000 | relativePath: joinedRelativePath, | 1143 | relativePath: joinedRelativePath, |
| 1001 | }); | 1144 | }); |
| 1002 | return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null; | 1145 | return workspaceFile |
| 1146 | ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) | ||
| 1147 | : yield* archivedFile([path.resolve(claims.workspaceRoot, joinedRelativePath)], { | ||
| 1148 | expiresAt: claims.expiresAt, | ||
| 1149 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 1003 | }); | 1150 | }); |
apps/server/src/attachmentStore.ts+18-20| ... | @@ -182,32 +182,30 @@ export function resolveAttachmentPath(input: { | ... | @@ -182,32 +182,30 @@ export function resolveAttachmentPath(input: { |
| 182 | }); | 182 | }); |
| 183 | } | 183 | } |
| 184 | 184 | ||
| 185 | export function resolveAttachmentPathById(input: { | 185 | export function attachmentPathsById(input: { |
| 186 | readonly attachmentsDir: string; | 186 | readonly attachmentsDir: string; |
| 187 | readonly attachmentId: string; | 187 | readonly attachmentId: string; |
| 188 | }): string | null { | 188 | }): ReadonlyArray<string> { |
| 189 | const normalizedId = normalizeAttachmentRelativePath(input.attachmentId); | 189 | const normalizedId = normalizeAttachmentRelativePath(input.attachmentId); |
| 190 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { | 190 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) return []; |
| 191 | return null; | ||
| 192 | } | ||
| 193 | const fileExtension = parseAttachmentFileExtension(normalizedId); | 191 | const fileExtension = parseAttachmentFileExtension(normalizedId); |
| 194 | if (fileExtension) { | 192 | const extensions = fileExtension |
| 195 | const filePath = resolveAttachmentRelativePath({ | 193 | ? [`.${fileExtension.toLowerCase()}`] |
| 196 | attachmentsDir: input.attachmentsDir, | 194 | : ATTACHMENT_FILENAME_EXTENSIONS; |
| 197 | relativePath: `${normalizedId}.${fileExtension.toLowerCase()}`, | 195 | return extensions.flatMap((extension) => { |
| 198 | }); | 196 | const file = resolveAttachmentRelativePath({ |
| 199 | return filePath && NodeFS.existsSync(filePath) ? filePath : null; | ||
| 200 | } | ||
| 201 | for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { | ||
| 202 | const maybePath = resolveAttachmentRelativePath({ | ||
| 203 | attachmentsDir: input.attachmentsDir, | 197 | attachmentsDir: input.attachmentsDir, |
| 204 | relativePath: `${normalizedId}${extension}`, | 198 | relativePath: `${normalizedId}${extension}`, |
| 205 | }); | 199 | }); |
| 206 | if (maybePath && NodeFS.existsSync(maybePath)) { | 200 | return file ? [file] : []; |
| 207 | return maybePath; | 201 | }); |
| 208 | } | 202 | } |
| 209 | } | 203 | |
| 210 | return null; | 204 | export function resolveAttachmentPathById(input: { |
| 205 | readonly attachmentsDir: string; | ||
| 206 | readonly attachmentId: string; | ||
| 207 | }): string | null { | ||
| 208 | return attachmentPathsById(input).find((file) => NodeFS.existsSync(file)) ?? null; | ||
| 211 | } | 209 | } |
| 212 | 210 | ||
| 213 | export type AttachmentClaimPlan = | 211 | export type AttachmentClaimPlan = |
| ... | @@ -310,7 +308,7 @@ export function sweepStalePendingAttachments(input: { | ... | @@ -310,7 +308,7 @@ export function sweepStalePendingAttachments(input: { |
| 310 | return { deleted }; | 308 | return { deleted }; |
| 311 | } | 309 | } |
| 312 | 310 | ||
| 313 | function parseAttachmentIdFromRelativePath(relativePath: string): string | null { | 311 | export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { |
| 314 | const normalized = normalizeAttachmentRelativePath(relativePath); | 312 | const normalized = normalizeAttachmentRelativePath(relativePath); |
| 315 | if (!normalized || normalized.includes("/")) { | 313 | if (!normalized || normalized.includes("/")) { |
| 316 | return null; | 314 | return null; |
apps/server/src/auth/EnvironmentAuth.test.ts+35| ... | @@ -708,4 +708,39 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -708,4 +708,39 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 708 | expect(verified.sessionId).toBe(bearer.sessionId); | 708 | expect(verified.sessionId).toBe(bearer.sessionId); |
| 709 | }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))), | 709 | }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))), |
| 710 | ); | 710 | ); |
| 711 | it.effect("delegates only the parent grant and rejects access after its revocation", () => | ||
| 712 | Effect.gen(function* () { | ||
| 713 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 714 | const parent = yield* auth.issueSession(); | ||
| 715 | const issued = yield* auth.issueDelegatedSession(parent, { | ||
| 716 | scopes: ["orchestration:read", "access:write"], | ||
| 717 | }); | ||
| 718 | const child = yield* auth.authenticateHttpRequest(makeBearerRequest(issued.access_token)); | ||
| 719 | expect(child.parentSessionId).toBe(parent.sessionId); | ||
| 720 | expect(issued.token_type).toBe("Bearer"); | ||
| 721 | expect(issued.expires_in).toBe(3600); | ||
| 722 | expect( | ||
| 723 | (yield* auth | ||
| 724 | .issueDelegatedSession(child, { scopes: ["orchestration:read"] }) | ||
| 725 | .pipe(Effect.flip))._tag, | ||
| 726 | ).toBe("ServerAuthScopeNotGrantedError"); | ||
| 727 | const limited = yield* auth.issueSession({ scopes: ["access:write", "orchestration:read"] }); | ||
| 728 | expect( | ||
| 729 | (yield* auth | ||
| 730 | .issueDelegatedSession(limited, { scopes: ["orchestration:operate"] }) | ||
| 731 | .pipe(Effect.flip))._tag, | ||
| 732 | ).toBe("ServerAuthScopeNotGrantedError"); | ||
| 733 | expect( | ||
| 734 | (yield* auth | ||
| 735 | .issueDelegatedSession(parent, { scopes: ["orchestration:read", "orchestration:read"] }) | ||
| 736 | .pipe(Effect.flip))._tag, | ||
| 737 | ).toBe("ServerAuthInvalidScopeError"); | ||
| 738 | yield* auth.revokeSession(parent.sessionId); | ||
| 739 | expect( | ||
| 740 | (yield* auth | ||
| 741 | .authenticateHttpRequest(makeBearerRequest(issued.access_token)) | ||
| 742 | .pipe(Effect.flip))._tag, | ||
| 743 | ).toBe("ServerAuthInvalidCredentialError"); | ||
| 744 | }).pipe(Effect.provide(layerEnvironmentAuth())), | ||
| 745 | ); | ||
| 711 | }); | 746 | }); |
apps/server/src/auth/EnvironmentAuth.ts+112| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | type AuthClientMetadata, | 12 | type AuthClientMetadata, |
| 13 | type AuthClientSession, | 13 | type AuthClientSession, |
| 14 | type AuthCreatePairingCredentialInput, | 14 | type AuthCreatePairingCredentialInput, |
| 15 | type AuthCreateDelegatedSessionInput, | ||
| 15 | type AuthEnvironmentScope, | 16 | type AuthEnvironmentScope, |
| 16 | type AuthMcpClientAccess, | 17 | type AuthMcpClientAccess, |
| 17 | type AuthPairingLink, | 18 | type AuthPairingLink, |
| ... | @@ -46,6 +47,8 @@ import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./Reusa | ... | @@ -46,6 +47,8 @@ import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./Reusa |
| 46 | import { verifyRequestDpopProof } from "./dpop.ts"; | 47 | import { verifyRequestDpopProof } from "./dpop.ts"; |
| 47 | import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; | 48 | import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; |
| 48 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 49 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 50 | import * as HostedAuthConfig from "./HostedAuthConfig.ts"; | ||
| 51 | import { signPayload, timingSafeEqualBase64Url } from "./utils.ts"; | ||
| 49 | 52 | ||
| 50 | const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | 53 | const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; |
| 51 | export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; | 54 | export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; |
| ... | @@ -106,6 +109,8 @@ export interface McpClientSession { | ... | @@ -106,6 +109,8 @@ export interface McpClientSession { |
| 106 | } | 109 | } |
| 107 | 110 | ||
| 108 | export interface AuthenticatedSession { | 111 | export interface AuthenticatedSession { |
| 112 | readonly parentSessionId?: AuthSessionId; | ||
| 113 | readonly deviceId?: string; | ||
| 109 | readonly sessionId: AuthSessionId; | 114 | readonly sessionId: AuthSessionId; |
| 110 | readonly subject: string; | 115 | readonly subject: string; |
| 111 | readonly method: ServerAuthSessionMethod; | 116 | readonly method: ServerAuthSessionMethod; |
| ... | @@ -501,6 +506,13 @@ export class EnvironmentAuth extends Context.Service< | ... | @@ -501,6 +506,13 @@ export class EnvironmentAuth extends Context.Service< |
| 501 | AuthAccessTokenResult, | 506 | AuthAccessTokenResult, |
| 502 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError | 507 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError |
| 503 | >; | 508 | >; |
| 509 | readonly issueDelegatedSession: ( | ||
| 510 | parent: AuthenticatedSession, | ||
| 511 | input: AuthCreateDelegatedSessionInput, | ||
| 512 | ) => Effect.Effect< | ||
| 513 | AuthAccessTokenResult, | ||
| 514 | ServerAuthInvalidRequestError | ServerAuthInternalError | ||
| 515 | >; | ||
| 504 | readonly createPairingLink: (input?: { | 516 | readonly createPairingLink: (input?: { |
| 505 | readonly ttl?: Duration.Duration; | 517 | readonly ttl?: Duration.Duration; |
| 506 | readonly label?: string; | 518 | readonly label?: string; |
| ... | @@ -738,6 +750,9 @@ export const make = Effect.gen(function* () { | ... | @@ -738,6 +750,9 @@ export const make = Effect.gen(function* () { |
| 738 | const crypto = yield* Crypto.Crypto; | 750 | const crypto = yield* Crypto.Crypto; |
| 739 | const descriptor = yield* policy.getDescriptor(); | 751 | const descriptor = yield* policy.getDescriptor(); |
| 740 | const config = yield* ServerConfig.ServerConfig; | 752 | const config = yield* ServerConfig.ServerConfig; |
| 753 | const hostedConfiguration = Option.flatten( | ||
| 754 | yield* Effect.serviceOption(HostedAuthConfig.HostedAuthConfig), | ||
| 755 | ); | ||
| 741 | const devAuth = resolveReusableDevAuth(config); | 756 | const devAuth = resolveReusableDevAuth(config); |
| 742 | 757 | ||
| 743 | const authenticateToken = ( | 758 | const authenticateToken = ( |
| ... | @@ -760,6 +775,10 @@ export const make = Effect.gen(function* () { | ... | @@ -760,6 +775,10 @@ export const make = Effect.gen(function* () { |
| 760 | Effect.flatMap(rejectMcpClientAudience), | 775 | Effect.flatMap(rejectMcpClientAudience), |
| 761 | Effect.map((session) => ({ | 776 | Effect.map((session) => ({ |
| 762 | sessionId: session.sessionId, | 777 | sessionId: session.sessionId, |
| 778 | ...(session.parentSessionId === undefined | ||
| 779 | ? {} | ||
| 780 | : { parentSessionId: session.parentSessionId }), | ||
| 781 | ...(session.deviceId === undefined ? {} : { deviceId: session.deviceId }), | ||
| 763 | subject: session.subject, | 782 | subject: session.subject, |
| 764 | method: session.method, | 783 | method: session.method, |
| 765 | scopes: session.scopes, | 784 | scopes: session.scopes, |
| ... | @@ -769,6 +788,14 @@ export const make = Effect.gen(function* () { | ... | @@ -769,6 +788,14 @@ export const make = Effect.gen(function* () { |
| 769 | ); | 788 | ); |
| 770 | 789 | ||
| 771 | const resolveRequestCredential = (request: HttpServerRequest.HttpServerRequest) => { | 790 | const resolveRequestCredential = (request: HttpServerRequest.HttpServerRequest) => { |
| 791 | const hostedCookie = request.cookies[HostedAuthConfig.HOSTED_SESSION_COOKIE]; | ||
| 792 | if ( | ||
| 793 | Option.isSome(hostedConfiguration) && | ||
| 794 | hostedCookie !== undefined && | ||
| 795 | request.headers.authorization === undefined | ||
| 796 | ) { | ||
| 797 | return { token: hostedCookie, source: "cookie" as const }; | ||
| 798 | } | ||
| 772 | const selectedCredential = selectRequestCredential( | 799 | const selectedCredential = selectRequestCredential( |
| 773 | request, | 800 | request, |
| 774 | sessions.cookieName, | 801 | sessions.cookieName, |
| ... | @@ -788,6 +815,7 @@ export const make = Effect.gen(function* () { | ... | @@ -788,6 +815,7 @@ export const make = Effect.gen(function* () { |
| 788 | // site (another localhost port, a LAN IP's other ports) still get the cookie, | 815 | // site (another localhost port, a LAN IP's other ports) still get the cookie, |
| 789 | // so cookie sessions must not authenticate their requests or sockets. | 816 | // so cookie sessions must not authenticate their requests or sockets. |
| 790 | const trustedBrowserOrigins = new Set<string>([ | 817 | const trustedBrowserOrigins = new Set<string>([ |
| 818 | ...(Option.isSome(hostedConfiguration) ? [hostedConfiguration.value.publicUrl] : []), | ||
| 791 | ...(config.devUrl ? [config.devUrl.origin] : []), | 819 | ...(config.devUrl ? [config.devUrl.origin] : []), |
| 792 | ...DESKTOP_RENDERER_ORIGINS, | 820 | ...DESKTOP_RENDERER_ORIGINS, |
| 793 | ...config.devAllowedOrigins, | 821 | ...config.devAllowedOrigins, |
| ... | @@ -812,6 +840,48 @@ export const make = Effect.gen(function* () { | ... | @@ -812,6 +840,48 @@ export const make = Effect.gen(function* () { |
| 812 | ); | 840 | ); |
| 813 | } | 841 | } |
| 814 | return authenticateToken(credential.token).pipe( | 842 | return authenticateToken(credential.token).pipe( |
| 843 | Effect.tap( | ||
| 844 | Effect.fnUntraced(function* (session) { | ||
| 845 | if (!session.deviceId || !isBrowserCookieCredential(credential.source)) return; | ||
| 846 | const origin = Option.isSome(hostedConfiguration) | ||
| 847 | ? hostedConfiguration.value.publicUrl | ||
| 848 | : undefined; | ||
| 849 | const unsafe = !["GET", "HEAD", "OPTIONS"].includes(request.method); | ||
| 850 | const websocket = request.headers.upgrade?.toLowerCase() === "websocket"; | ||
| 851 | if ( | ||
| 852 | !origin || | ||
| 853 | ((unsafe || websocket || request.headers.origin !== undefined) && | ||
| 854 | request.headers.origin !== origin) | ||
| 855 | ) { | ||
| 856 | return yield* new ServerAuthInvalidCredentialError({ | ||
| 857 | diagnostic: "Hosted browser sessions require the configured origin.", | ||
| 858 | }); | ||
| 859 | } | ||
| 860 | if (unsafe) { | ||
| 861 | const secret = yield* secretStore | ||
| 862 | .getOrCreateRandom(HostedAuthConfig.HOSTED_CSRF_SECRET, 32) | ||
| 863 | .pipe( | ||
| 864 | Effect.mapError( | ||
| 865 | (cause) => | ||
| 866 | new ServerAuthInvalidCredentialError({ | ||
| 867 | diagnostic: "Browser request verification is unavailable.", | ||
| 868 | cause, | ||
| 869 | }), | ||
| 870 | ), | ||
| 871 | ); | ||
| 872 | if ( | ||
| 873 | !timingSafeEqualBase64Url( | ||
| 874 | request.headers["x-csrf-token"] ?? "", | ||
| 875 | signPayload(session.deviceId, secret), | ||
| 876 | ) | ||
| 877 | ) { | ||
| 878 | return yield* new ServerAuthInvalidCredentialError({ | ||
| 879 | diagnostic: "Browser request verification failed.", | ||
| 880 | }); | ||
| 881 | } | ||
| 882 | } | ||
| 883 | }), | ||
| 884 | ), | ||
| 815 | Effect.flatMap((session) => { | 885 | Effect.flatMap((session) => { |
| 816 | if (session.proofKeyThumbprint) { | 886 | if (session.proofKeyThumbprint) { |
| 817 | if (!dpopToken || dpopToken !== credential.token) { | 887 | if (!dpopToken || dpopToken !== credential.token) { |
| ... | @@ -1032,6 +1102,43 @@ export const make = Effect.gen(function* () { | ... | @@ -1032,6 +1102,43 @@ export const make = Effect.gen(function* () { |
| 1032 | ); | 1102 | ); |
| 1033 | }; | 1103 | }; |
| 1034 | 1104 | ||
| 1105 | const issueDelegatedSession: EnvironmentAuth["Service"]["issueDelegatedSession"] = Effect.fn( | ||
| 1106 | "EnvironmentAuth.issueDelegatedSession", | ||
| 1107 | )(function* (parent, input) { | ||
| 1108 | if (parent.parentSessionId !== undefined || !parent.scopes.includes(AuthAccessWriteScope)) { | ||
| 1109 | return yield* new ServerAuthScopeNotGrantedError({}); | ||
| 1110 | } | ||
| 1111 | if (input.scopes.length === 0 || new Set(input.scopes).size !== input.scopes.length) { | ||
| 1112 | return yield* new ServerAuthInvalidScopeError({}); | ||
| 1113 | } | ||
| 1114 | if (input.scopes.some((scope) => !parent.scopes.includes(scope))) { | ||
| 1115 | return yield* new ServerAuthScopeNotGrantedError({}); | ||
| 1116 | } | ||
| 1117 | const issued = yield* sessions | ||
| 1118 | .issue({ | ||
| 1119 | parentSessionId: parent.sessionId, | ||
| 1120 | subject: "hub-delegated-session", | ||
| 1121 | method: "bearer-access-token", | ||
| 1122 | ttl: Duration.hours(1), | ||
| 1123 | scopes: input.scopes, | ||
| 1124 | client: { deviceType: "bot", ...(input.label ? { label: input.label } : {}) }, | ||
| 1125 | }) | ||
| 1126 | .pipe( | ||
| 1127 | Effect.mapError((cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause })), | ||
| 1128 | ); | ||
| 1129 | const now = yield* DateTime.now; | ||
| 1130 | return { | ||
| 1131 | access_token: issued.token, | ||
| 1132 | issued_token_type: AuthAccessTokenType, | ||
| 1133 | token_type: "Bearer", | ||
| 1134 | expires_in: Math.max( | ||
| 1135 | 0, | ||
| 1136 | Math.floor((issued.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000), | ||
| 1137 | ), | ||
| 1138 | scope: encodeOAuthScope(issued.scopes), | ||
| 1139 | } satisfies AuthAccessTokenResult; | ||
| 1140 | }); | ||
| 1141 | |||
| 1035 | const issuePairingCredentialForSubject = (input: { | 1142 | const issuePairingCredentialForSubject = (input: { |
| 1036 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 1143 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 1037 | readonly subject: string; | 1144 | readonly subject: string; |
| ... | @@ -1259,6 +1366,10 @@ export const make = Effect.gen(function* () { | ... | @@ -1259,6 +1366,10 @@ export const make = Effect.gen(function* () { |
| 1259 | Effect.flatMap(rejectMcpClientAudience), | 1366 | Effect.flatMap(rejectMcpClientAudience), |
| 1260 | Effect.map((session) => ({ | 1367 | Effect.map((session) => ({ |
| 1261 | sessionId: session.sessionId, | 1368 | sessionId: session.sessionId, |
| 1369 | ...(session.parentSessionId === undefined | ||
| 1370 | ? {} | ||
| 1371 | : { parentSessionId: session.parentSessionId }), | ||
| 1372 | ...(session.deviceId === undefined ? {} : { deviceId: session.deviceId }), | ||
| 1262 | subject: session.subject, | 1373 | subject: session.subject, |
| 1263 | method: session.method, | 1374 | method: session.method, |
| 1264 | scopes: session.scopes, | 1375 | scopes: session.scopes, |
| ... | @@ -1355,6 +1466,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1355,6 +1466,7 @@ export const make = Effect.gen(function* () { |
| 1355 | getSessionState, | 1466 | getSessionState, |
| 1356 | createBrowserSession, | 1467 | createBrowserSession, |
| 1357 | exchangeBootstrapCredentialForAccessToken, | 1468 | exchangeBootstrapCredentialForAccessToken, |
| 1469 | issueDelegatedSession, | ||
| 1358 | createPairingLink, | 1470 | createPairingLink, |
| 1359 | issuePairingCredential, | 1471 | issuePairingCredential, |
| 1360 | issueStartupPairingCredential, | 1472 | issueStartupPairingCredential, |
apps/server/src/auth/HostedAuth.test.ts created+696| ... | @@ -0,0 +1,696 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { EnvironmentId, HostedAuthAccount } from "@t3tools/contracts"; | ||
| 4 | import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose"; | ||
| 5 | import * as Crypto from "effect/Crypto"; | ||
| 6 | import * as Base64Url from "effect/encoding/Base64Url"; | ||
| 7 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Fiber from "effect/Fiber"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Redacted from "effect/Redacted"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | import * as TestClock from "effect/testing/TestClock"; | ||
| 19 | import { HttpClient, HttpClientResponse, HttpRouter, HttpServerRequest } from "effect/http"; | ||
| 20 | import * as ServerConfig from "../config.ts"; | ||
| 21 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 22 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 23 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | ||
| 24 | import * as HostedAuth from "./HostedAuth.ts"; | ||
| 25 | import * as HostedAuthConfig from "./HostedAuthConfig.ts"; | ||
| 26 | import * as HostedAuthHttp from "./HostedAuthHttp.ts"; | ||
| 27 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | ||
| 28 | import * as SessionStore from "./SessionStore.ts"; | ||
| 29 | |||
| 30 | const settings = { | ||
| 31 | publicUrl: "https://knife.example", | ||
| 32 | issuer: "https://issuer.example", | ||
| 33 | clientId: "knife-test", | ||
| 34 | clientSecret: Redacted.make("test-client-secret"), | ||
| 35 | ownerSub: "configured-owner", | ||
| 36 | maxInteractiveAgeSeconds: 86_400, | ||
| 37 | issuerRevalidationSeconds: 60, | ||
| 38 | }; | ||
| 39 | const client = { label: "Test computer", deviceType: "desktop" } as const; | ||
| 40 | const verifier = "v".repeat(43); | ||
| 41 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 42 | |||
| 43 | const fixture = Effect.gen(function* () { | ||
| 44 | const key = yield* Effect.promise(() => generateKeyPair("RS256")); | ||
| 45 | const jwk = yield* Effect.promise(() => exportJWK(key.publicKey)); | ||
| 46 | let nonce = ""; | ||
| 47 | let claims: JWTPayload = {}; | ||
| 48 | let refreshDenied = false; | ||
| 49 | let refreshCalls = 0; | ||
| 50 | let refreshGate: | ||
| 51 | | { entered: Deferred.Deferred<void>; release: Deferred.Deferred<void> } | ||
| 52 | | undefined; | ||
| 53 | let lastTokenBody: URLSearchParams | undefined; | ||
| 54 | const http = HttpClient.make((request) => | ||
| 55 | Effect.gen(function* () { | ||
| 56 | const url = new URL(request.url); | ||
| 57 | if (url.pathname.endsWith("openid-configuration")) | ||
| 58 | return HttpClientResponse.fromWeb( | ||
| 59 | request, | ||
| 60 | Response.json({ | ||
| 61 | issuer: settings.issuer, | ||
| 62 | authorization_endpoint: `${settings.issuer}/authorize`, | ||
| 63 | token_endpoint: `${settings.issuer}/token`, | ||
| 64 | jwks_uri: `${settings.issuer}/jwks`, | ||
| 65 | code_challenge_methods_supported: ["S256"], | ||
| 66 | response_types_supported: ["code"], | ||
| 67 | token_endpoint_auth_methods_supported: ["client_secret_basic"], | ||
| 68 | id_token_signing_alg_values_supported: ["RS256"], | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | if (url.pathname === "/jwks") | ||
| 72 | return HttpClientResponse.fromWeb( | ||
| 73 | request, | ||
| 74 | Response.json({ keys: [{ ...jwk, kid: "test" }] }), | ||
| 75 | ); | ||
| 76 | if (request.body._tag !== "Uint8Array") return yield* Effect.die("Expected token form"); | ||
| 77 | lastTokenBody = new URLSearchParams(new TextDecoder().decode(request.body.body)); | ||
| 78 | if (lastTokenBody.get("grant_type") === "refresh_token") { | ||
| 79 | refreshCalls++; | ||
| 80 | if (refreshGate) { | ||
| 81 | yield* Deferred.succeed(refreshGate.entered, undefined); | ||
| 82 | yield* Deferred.await(refreshGate.release); | ||
| 83 | } | ||
| 84 | if (refreshDenied) | ||
| 85 | return HttpClientResponse.fromWeb( | ||
| 86 | request, | ||
| 87 | Response.json({ error: "invalid_grant" }, { status: 400 }), | ||
| 88 | ); | ||
| 89 | } | ||
| 90 | const now = Math.floor((yield* DateTime.now).epochMilliseconds / 1_000); | ||
| 91 | const token = yield* Effect.promise(() => | ||
| 92 | new SignJWT({ | ||
| 93 | nonce, | ||
| 94 | sub: settings.ownerSub, | ||
| 95 | iss: settings.issuer, | ||
| 96 | aud: settings.clientId, | ||
| 97 | iat: now, | ||
| 98 | exp: now + 3_600, | ||
| 99 | ...claims, | ||
| 100 | }) | ||
| 101 | .setProtectedHeader({ alg: "RS256", kid: "test" }) | ||
| 102 | .sign(key.privateKey), | ||
| 103 | ); | ||
| 104 | return HttpClientResponse.fromWeb( | ||
| 105 | request, | ||
| 106 | Response.json({ | ||
| 107 | access_token: "issuer-access-never-delivered", | ||
| 108 | refresh_token: "issuer-refresh-never-delivered", | ||
| 109 | id_token: token, | ||
| 110 | token_type: "Bearer", | ||
| 111 | }), | ||
| 112 | ); | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | const layer = HostedAuth.layer.pipe( | ||
| 116 | Layer.provideMerge(EnvironmentAuth.layer), | ||
| 117 | Layer.provideMerge(SqlitePersistence.layerMemory), | ||
| 118 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 119 | Layer.provide( | ||
| 120 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | ||
| 121 | getEnvironmentId: Effect.succeed(EnvironmentId.make("test-hub")), | ||
| 122 | }), | ||
| 123 | ), | ||
| 124 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "knife-hosted-auth-" })), | ||
| 125 | Layer.provideMerge(Layer.succeed(HostedAuthConfig.HostedAuthConfig, Option.some(settings))), | ||
| 126 | Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), | ||
| 127 | ); | ||
| 128 | return { | ||
| 129 | layer, | ||
| 130 | setNonce: (value: string) => { | ||
| 131 | nonce = value; | ||
| 132 | }, | ||
| 133 | setClaims: (value: JWTPayload) => { | ||
| 134 | claims = value; | ||
| 135 | }, | ||
| 136 | denyRefresh: () => { | ||
| 137 | refreshDenied = true; | ||
| 138 | }, | ||
| 139 | pauseRefresh: (entered: Deferred.Deferred<void>, release: Deferred.Deferred<void>) => { | ||
| 140 | refreshGate = { entered, release }; | ||
| 141 | }, | ||
| 142 | refreshCalls: () => refreshCalls, | ||
| 143 | lastTokenBody: () => lastTokenBody, | ||
| 144 | }; | ||
| 145 | }); | ||
| 146 | |||
| 147 | const beginLogin = (f: Effect.Success<typeof fixture>, native = true) => | ||
| 148 | Effect.gen(function* () { | ||
| 149 | const auth = yield* HostedAuth.HostedAuth; | ||
| 150 | let request: string | undefined; | ||
| 151 | if (native) { | ||
| 152 | const challenge = Base64Url.encode( | ||
| 153 | yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(verifier)), | ||
| 154 | ); | ||
| 155 | const start = yield* auth.startDevice({ | ||
| 156 | codeChallenge: challenge, | ||
| 157 | state: "s".repeat(43), | ||
| 158 | client, | ||
| 159 | }); | ||
| 160 | request = new URL(start.authorizationUrl).searchParams.get("request") ?? undefined; | ||
| 161 | } | ||
| 162 | const begun = yield* auth.begin(request ? { request } : { returnTo: "/projects?selected=one" }); | ||
| 163 | const url = new URL(begun.url); | ||
| 164 | f.setNonce(url.searchParams.get("nonce") ?? ""); | ||
| 165 | return { | ||
| 166 | ...begun, | ||
| 167 | state: url.searchParams.get("state") ?? "", | ||
| 168 | challenge: url.searchParams.get("code_challenge") ?? "", | ||
| 169 | }; | ||
| 170 | }); | ||
| 171 | const nativeLogin = (f: Effect.Success<typeof fixture>) => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const auth = yield* HostedAuth.HostedAuth; | ||
| 174 | const begun = yield* beginLogin(f); | ||
| 175 | const callback = yield* auth.callback({ ...begun, code: "issuer-code", client }); | ||
| 176 | const url = new URL(callback.url); | ||
| 177 | expect(url.protocol).toBe("t3code:"); | ||
| 178 | expect(url.host + url.pathname).toBe("app/self-hosted/callback"); | ||
| 179 | expect(url.searchParams.get("state")).toBe("s".repeat(43)); | ||
| 180 | expect(url.searchParams.get("server")).toBe(settings.publicUrl); | ||
| 181 | const code = url.searchParams.get("code") ?? ""; | ||
| 182 | return { grant: yield* auth.exchange({ code, codeVerifier: verifier }), code }; | ||
| 183 | }); | ||
| 184 | |||
| 185 | it.layer(NodeServices.layer)("HostedAuth", (it) => { | ||
| 186 | it.effect( | ||
| 187 | "keeps issuer credentials server-side and binds a single-use handoff to the native proof", | ||
| 188 | () => | ||
| 189 | Effect.gen(function* () { | ||
| 190 | const f = yield* fixture; | ||
| 191 | yield* Effect.gen(function* () { | ||
| 192 | const auth = yield* HostedAuth.HostedAuth; | ||
| 193 | const begun = yield* beginLogin(f); | ||
| 194 | const error = yield* auth | ||
| 195 | .callback({ ...begun, browserCookie: "another-browser", code: "code", client }) | ||
| 196 | .pipe(Effect.flip); | ||
| 197 | expect(error.reason).toBe("transaction"); | ||
| 198 | const second = yield* beginLogin(f); | ||
| 199 | const callback = yield* auth.callback({ ...second, code: "code", client }); | ||
| 200 | const code = new URL(callback.url).searchParams.get("code") ?? ""; | ||
| 201 | expect( | ||
| 202 | (yield* auth.exchange({ code, codeVerifier: "x".repeat(43) }).pipe(Effect.flip)).reason, | ||
| 203 | ).toBe("credential"); | ||
| 204 | const grant = yield* auth.exchange({ code, codeVerifier: verifier }); | ||
| 205 | expect(grant.refreshToken).not.toContain("issuer-"); | ||
| 206 | expect(grant.accessToken).not.toContain("issuer-"); | ||
| 207 | const submitted = f.lastTokenBody()?.get("code_verifier") ?? ""; | ||
| 208 | expect( | ||
| 209 | Base64Url.encode( | ||
| 210 | yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(submitted)), | ||
| 211 | ), | ||
| 212 | ).toBe(second.challenge); | ||
| 213 | expect(f.lastTokenBody()?.get("redirect_uri")).toBe( | ||
| 214 | `${settings.publicUrl}/api/auth/oidc/callback`, | ||
| 215 | ); | ||
| 216 | expect( | ||
| 217 | (yield* auth.exchange({ code, codeVerifier: verifier }).pipe(Effect.flip)).reason, | ||
| 218 | ).toBe("credential"); | ||
| 219 | const session = yield* (yield* SessionStore.SessionStore).verify(grant.accessToken); | ||
| 220 | expect(session.deviceId).toBe(grant.deviceId); | ||
| 221 | }).pipe(Effect.provide(f.layer)); | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | |||
| 225 | it.effect.each([ | ||
| 226 | { sub: "another-user" }, | ||
| 227 | { iss: "https://another-issuer.example" }, | ||
| 228 | { aud: "another-client" }, | ||
| 229 | { azp: "another-client" }, | ||
| 230 | { nonce: "another-transaction" }, | ||
| 231 | { aud: [settings.clientId, "another-client"], azp: undefined }, | ||
| 232 | ])("rejects an untrusted ID token %#", (claims) => | ||
| 233 | Effect.gen(function* () { | ||
| 234 | const f = yield* fixture; | ||
| 235 | f.setClaims(claims); | ||
| 236 | yield* Effect.gen(function* () { | ||
| 237 | const auth = yield* HostedAuth.HostedAuth; | ||
| 238 | const begun = yield* beginLogin(f); | ||
| 239 | expect( | ||
| 240 | (yield* auth.callback({ ...begun, code: "code", client }).pipe(Effect.flip)).reason, | ||
| 241 | ).toBe("identity"); | ||
| 242 | }).pipe(Effect.provide(f.layer)); | ||
| 243 | }), | ||
| 244 | ); | ||
| 245 | |||
| 246 | it.effect("rotates refresh tokens and revokes the device family on authentic reuse", () => | ||
| 247 | Effect.gen(function* () { | ||
| 248 | const f = yield* fixture; | ||
| 249 | yield* Effect.gen(function* () { | ||
| 250 | const auth = yield* HostedAuth.HostedAuth; | ||
| 251 | const sessions = yield* SessionStore.SessionStore; | ||
| 252 | const { grant } = yield* nativeLogin(f); | ||
| 253 | const renewed = yield* auth.refresh(grant.refreshToken); | ||
| 254 | expect(renewed.deviceId).toBe(grant.deviceId); | ||
| 255 | expect(renewed.refreshToken).not.toBe(grant.refreshToken); | ||
| 256 | expect((yield* auth.refresh(grant.refreshToken).pipe(Effect.flip)).reason).toBe( | ||
| 257 | "credential", | ||
| 258 | ); | ||
| 259 | expect((yield* sessions.verify(renewed.accessToken).pipe(Effect.result))._tag).toBe( | ||
| 260 | "Failure", | ||
| 261 | ); | ||
| 262 | expect((yield* auth.refresh(renewed.refreshToken).pipe(Effect.result))._tag).toBe( | ||
| 263 | "Failure", | ||
| 264 | ); | ||
| 265 | }).pipe(Effect.provide(f.layer)); | ||
| 266 | }), | ||
| 267 | ); | ||
| 268 | |||
| 269 | it.effect( | ||
| 270 | "device revocation invalidates HTTP, websocket tickets and live streams while retaining manual sessions", | ||
| 271 | () => | ||
| 272 | Effect.gen(function* () { | ||
| 273 | const f = yield* fixture; | ||
| 274 | yield* Effect.gen(function* () { | ||
| 275 | const auth = yield* HostedAuth.HostedAuth; | ||
| 276 | const sessions = yield* SessionStore.SessionStore; | ||
| 277 | const { grant } = yield* nativeLogin(f); | ||
| 278 | const session = yield* sessions.verify(grant.accessToken); | ||
| 279 | const manual = yield* sessions.issue({ subject: "existing-paired-client" }); | ||
| 280 | const child = yield* sessions.issue({ | ||
| 281 | parentSessionId: session.sessionId, | ||
| 282 | scopes: ["orchestration:read"], | ||
| 283 | }); | ||
| 284 | expect((yield* sessions.verify(child.token)).deviceId).toBe(grant.deviceId); | ||
| 285 | const ticket = yield* sessions.issueWebSocketToken(session.sessionId); | ||
| 286 | const stopped = yield* sessions | ||
| 287 | .awaitInvalidation(session.sessionId) | ||
| 288 | .pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 289 | yield* sessions.verifyActive(session.sessionId); | ||
| 290 | yield* sessions.verifyActive((yield* sessions.verify(child.token)).sessionId); | ||
| 291 | yield* auth.revoke(session, grant.deviceId); | ||
| 292 | expect((yield* sessions.verifyActive(session.sessionId).pipe(Effect.result))._tag).toBe( | ||
| 293 | "Failure", | ||
| 294 | ); | ||
| 295 | yield* Fiber.join(stopped); | ||
| 296 | for (const token of [grant.accessToken, child.token]) | ||
| 297 | expect((yield* sessions.verify(token).pipe(Effect.result))._tag).toBe("Failure"); | ||
| 298 | expect( | ||
| 299 | (yield* sessions.verifyWebSocketToken(ticket.token).pipe(Effect.result))._tag, | ||
| 300 | ).toBe("Failure"); | ||
| 301 | expect((yield* sessions.verify(manual.token)).subject).toBe("existing-paired-client"); | ||
| 302 | }).pipe(Effect.provide(f.layer)); | ||
| 303 | }), | ||
| 304 | ); | ||
| 305 | |||
| 306 | it.effect("revalidates issuer renewal and stops a disabled issuer grant", () => | ||
| 307 | Effect.gen(function* () { | ||
| 308 | const f = yield* fixture; | ||
| 309 | yield* Effect.gen(function* () { | ||
| 310 | const auth = yield* HostedAuth.HostedAuth; | ||
| 311 | const { grant } = yield* nativeLogin(f); | ||
| 312 | yield* TestClock.adjust(Duration.seconds(61)); | ||
| 313 | f.denyRefresh(); | ||
| 314 | expect((yield* auth.refresh(grant.refreshToken).pipe(Effect.flip)).reason).toBe( | ||
| 315 | "reauthentication", | ||
| 316 | ); | ||
| 317 | expect(f.refreshCalls()).toBe(1); | ||
| 318 | expect( | ||
| 319 | (yield* (yield* SessionStore.SessionStore).verify(grant.accessToken).pipe(Effect.result)) | ||
| 320 | ._tag, | ||
| 321 | ).toBe("Failure"); | ||
| 322 | }).pipe(Effect.provide(f.layer)); | ||
| 323 | }), | ||
| 324 | ); | ||
| 325 | |||
| 326 | it.effect("attenuated tokens cannot renew their caller's access lifetime", () => | ||
| 327 | Effect.gen(function* () { | ||
| 328 | const f = yield* fixture; | ||
| 329 | yield* Effect.gen(function* () { | ||
| 330 | const auth = yield* HostedAuth.HostedAuth; | ||
| 331 | const sessions = yield* SessionStore.SessionStore; | ||
| 332 | const { grant } = yield* nativeLogin(f); | ||
| 333 | const parent = yield* sessions.verify(grant.accessToken); | ||
| 334 | yield* TestClock.adjust(Duration.minutes(5)); | ||
| 335 | const first = yield* auth.token(parent, ["archive:read"]); | ||
| 336 | expect(first.expiresAt.epochMilliseconds).toBe(grant.expiresAt.epochMilliseconds); | ||
| 337 | yield* TestClock.adjust(Duration.minutes(5)); | ||
| 338 | const narrowed = yield* sessions.verify(first.token); | ||
| 339 | const second = yield* auth.token(narrowed, ["archive:read"]); | ||
| 340 | expect(second.expiresAt.epochMilliseconds).toBe(grant.expiresAt.epochMilliseconds); | ||
| 341 | yield* TestClock.adjust(Duration.minutes(6)); | ||
| 342 | expect((yield* sessions.verifyActive(second.sessionId).pipe(Effect.result))._tag).toBe( | ||
| 343 | "Failure", | ||
| 344 | ); | ||
| 345 | expect((yield* auth.token(narrowed, ["archive:read"]).pipe(Effect.result))._tag).toBe( | ||
| 346 | "Failure", | ||
| 347 | ); | ||
| 348 | expect(f.refreshCalls()).toBe(0); | ||
| 349 | }).pipe(Effect.provide(f.layer)); | ||
| 350 | }), | ||
| 351 | ); | ||
| 352 | |||
| 353 | it.effect( | ||
| 354 | "binds only one active device to a source and prevents narrow grants from escalating", | ||
| 355 | () => | ||
| 356 | Effect.gen(function* () { | ||
| 357 | const f = yield* fixture; | ||
| 358 | yield* Effect.gen(function* () { | ||
| 359 | const auth = yield* HostedAuth.HostedAuth; | ||
| 360 | const sessions = yield* SessionStore.SessionStore; | ||
| 361 | const first = (yield* nativeLogin(f)).grant; | ||
| 362 | const second = (yield* nativeLogin(f)).grant; | ||
| 363 | const source = EnvironmentId.make("existing-source-id"); | ||
| 364 | const a = yield* sessions.verify(first.accessToken); | ||
| 365 | const b = yield* sessions.verify(second.accessToken); | ||
| 366 | yield* auth.bindEnvironment(a, source); | ||
| 367 | yield* auth.bindEnvironment(a, source); | ||
| 368 | const narrow = yield* auth.token(a, ["archive:read", "archive:write"]); | ||
| 369 | const narrowed = yield* sessions.verify(narrow.token); | ||
| 370 | yield* auth.authorizeEnvironment(narrowed, source); | ||
| 371 | expect( | ||
| 372 | (yield* auth | ||
| 373 | .authorizeEnvironment(narrowed, EnvironmentId.make("another-source")) | ||
| 374 | .pipe(Effect.flip)).reason, | ||
| 375 | ).toBe("forbidden"); | ||
| 376 | expect((yield* auth.token(narrowed, ["providers:manage"]).pipe(Effect.flip)).reason).toBe( | ||
| 377 | "forbidden", | ||
| 378 | ); | ||
| 379 | expect((yield* auth.revoke(narrowed, b.deviceId ?? "").pipe(Effect.flip)).reason).toBe( | ||
| 380 | "forbidden", | ||
| 381 | ); | ||
| 382 | yield* auth.bindEnvironment(b, source); | ||
| 383 | yield* auth.authorizeEnvironment(b, source); | ||
| 384 | expect((yield* sessions.verify(first.accessToken).pipe(Effect.result))._tag).toBe( | ||
| 385 | "Failure", | ||
| 386 | ); | ||
| 387 | expect((yield* auth.refresh(first.refreshToken).pipe(Effect.result))._tag).toBe( | ||
| 388 | "Failure", | ||
| 389 | ); | ||
| 390 | }).pipe(Effect.provide(f.layer)); | ||
| 391 | }), | ||
| 392 | ); | ||
| 393 | |||
| 394 | it.effect( | ||
| 395 | "enforces canonical origin and CSRF on hosted cookies without trusting forwarded hosts", | ||
| 396 | () => | ||
| 397 | Effect.gen(function* () { | ||
| 398 | const f = yield* fixture; | ||
| 399 | yield* Effect.gen(function* () { | ||
| 400 | const hosted = yield* HostedAuth.HostedAuth; | ||
| 401 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 402 | const sessions = yield* SessionStore.SessionStore; | ||
| 403 | const begun = yield* beginLogin(f, false); | ||
| 404 | const result = yield* hosted.callback({ ...begun, code: "code", client }); | ||
| 405 | if (!result.session) return yield* Effect.die("Expected web session"); | ||
| 406 | const session = yield* sessions.verify(result.session.token); | ||
| 407 | const account = yield* hosted.account(session); | ||
| 408 | const request = (origin?: string, csrf?: string, extra = {}) => | ||
| 409 | HttpServerRequest.fromWeb( | ||
| 410 | new Request(`${settings.publicUrl}/api/hub/manage`, { | ||
| 411 | method: "POST", | ||
| 412 | headers: { | ||
| 413 | cookie: `${HostedAuthConfig.HOSTED_SESSION_COOKIE}=${result.session?.token}`, | ||
| 414 | ...(origin ? { origin } : {}), | ||
| 415 | ...(csrf ? { "x-csrf-token": csrf } : {}), | ||
| 416 | ...extra, | ||
| 417 | }, | ||
| 418 | }), | ||
| 419 | ); | ||
| 420 | for (const invalid of [ | ||
| 421 | request(), | ||
| 422 | request(settings.publicUrl), | ||
| 423 | request("https://evil.example", account.csrfToken, { | ||
| 424 | "x-forwarded-host": "evil.example", | ||
| 425 | "sec-fetch-site": "same-origin", | ||
| 426 | }), | ||
| 427 | ]) | ||
| 428 | expect((yield* auth.authenticateHttpRequest(invalid).pipe(Effect.result))._tag).toBe( | ||
| 429 | "Failure", | ||
| 430 | ); | ||
| 431 | expect( | ||
| 432 | (yield* auth.authenticateHttpRequest(request(settings.publicUrl, account.csrfToken))) | ||
| 433 | .deviceId, | ||
| 434 | ).toBe(session.deviceId); | ||
| 435 | }).pipe(Effect.provide(f.layer)); | ||
| 436 | }), | ||
| 437 | ); | ||
| 438 | it.effect( | ||
| 439 | "expires a handoff after sixty seconds and requires interactive enrollment after the configured maximum age", | ||
| 440 | () => | ||
| 441 | Effect.gen(function* () { | ||
| 442 | const f = yield* fixture; | ||
| 443 | yield* Effect.gen(function* () { | ||
| 444 | const auth = yield* HostedAuth.HostedAuth; | ||
| 445 | const begun = yield* beginLogin(f); | ||
| 446 | const callback = yield* auth.callback({ ...begun, code: "code", client }); | ||
| 447 | const code = new URL(callback.url).searchParams.get("code") ?? ""; | ||
| 448 | yield* TestClock.adjust(Duration.seconds(60)); | ||
| 449 | expect( | ||
| 450 | (yield* auth.exchange({ code, codeVerifier: verifier }).pipe(Effect.flip)).reason, | ||
| 451 | ).toBe("credential"); | ||
| 452 | const { grant } = yield* nativeLogin(f); | ||
| 453 | yield* TestClock.adjust(Duration.seconds(settings.maxInteractiveAgeSeconds)); | ||
| 454 | expect((yield* auth.refresh(grant.refreshToken).pipe(Effect.flip)).reason).toBe( | ||
| 455 | "reauthentication", | ||
| 456 | ); | ||
| 457 | }).pipe(Effect.provide(f.layer)); | ||
| 458 | }), | ||
| 459 | ); | ||
| 460 | |||
| 461 | it.effect("cannot mint access when a device is revoked during issuer renewal", () => | ||
| 462 | Effect.gen(function* () { | ||
| 463 | const f = yield* fixture; | ||
| 464 | yield* Effect.gen(function* () { | ||
| 465 | const auth = yield* HostedAuth.HostedAuth; | ||
| 466 | const sessions = yield* SessionStore.SessionStore; | ||
| 467 | const { grant } = yield* nativeLogin(f); | ||
| 468 | const current = yield* sessions.verify(grant.accessToken); | ||
| 469 | yield* TestClock.adjust(Duration.seconds(61)); | ||
| 470 | const entered = yield* Deferred.make<void>(); | ||
| 471 | const release = yield* Deferred.make<void>(); | ||
| 472 | f.pauseRefresh(entered, release); | ||
| 473 | const refreshing = yield* auth | ||
| 474 | .refresh(grant.refreshToken) | ||
| 475 | .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); | ||
| 476 | yield* Deferred.await(entered); | ||
| 477 | yield* auth.revoke(current, grant.deviceId); | ||
| 478 | yield* Deferred.succeed(release, undefined); | ||
| 479 | expect((yield* Fiber.join(refreshing))._tag).toBe("Failure"); | ||
| 480 | expect( | ||
| 481 | Option.isNone( | ||
| 482 | yield* (yield* ServerSecretStore.ServerSecretStore).get( | ||
| 483 | `hosted-oidc-${grant.deviceId}`, | ||
| 484 | ), | ||
| 485 | ), | ||
| 486 | ).toBe(true); | ||
| 487 | }).pipe(Effect.provide(f.layer)); | ||
| 488 | }), | ||
| 489 | ); | ||
| 490 | |||
| 491 | it.effect("delivers secure cookies and JSON dates through the actual HTTP routes", () => | ||
| 492 | Effect.gen(function* () { | ||
| 493 | const f = yield* fixture; | ||
| 494 | yield* Effect.gen(function* () { | ||
| 495 | const context = yield* Effect.context< | ||
| 496 | | HostedAuth.HostedAuth | ||
| 497 | | EnvironmentAuth.EnvironmentAuth | ||
| 498 | | HostedAuthConfig.HostedAuthConfig | ||
| 499 | >(); | ||
| 500 | const app = yield* Effect.acquireRelease( | ||
| 501 | Effect.sync(() => HttpRouter.toWebHandler(HostedAuthHttp.layer, { disableLogger: true })), | ||
| 502 | (app) => Effect.promise(() => app.dispose()), | ||
| 503 | ); | ||
| 504 | const request = (path: string, headers: Record<string, string> = {}, method = "GET") => | ||
| 505 | Effect.promise(() => | ||
| 506 | app.handler(new Request(`${settings.publicUrl}${path}`, { method, headers }), context), | ||
| 507 | ); | ||
| 508 | const failed = yield* request("/api/auth/oidc/callback?error=access_denied"); | ||
| 509 | expect(failed.status).toBe(303); | ||
| 510 | expect(failed.headers.get("location")).toBe(`${settings.publicUrl}/pair?signInError=1`); | ||
| 511 | expect(failed.headers.getSetCookie()[0]).toContain( | ||
| 512 | HostedAuthConfig.HOSTED_TRANSACTION_COOKIE, | ||
| 513 | ); | ||
| 514 | const start = yield* request("/api/auth/oidc/start?returnTo=%2Fprojects"); | ||
| 515 | expect(start.status).toBe(303); | ||
| 516 | expect(start.headers.getSetCookie()[0]).toContain("Max-Age=300"); | ||
| 517 | const issuerUrl = new URL(start.headers.get("location") ?? ""); | ||
| 518 | f.setNonce(issuerUrl.searchParams.get("nonce") ?? ""); | ||
| 519 | const transactionCookie = start.headers.getSetCookie()[0]?.split(";")[0] ?? ""; | ||
| 520 | const callback = yield* request( | ||
| 521 | `/api/auth/oidc/callback?code=issuer-code&state=${issuerUrl.searchParams.get("state")}`, | ||
| 522 | { cookie: transactionCookie }, | ||
| 523 | ); | ||
| 524 | expect(callback.status).toBe(303); | ||
| 525 | expect(callback.headers.get("location")).toBe(`${settings.publicUrl}/projects`); | ||
| 526 | const rawCookie = | ||
| 527 | callback.headers | ||
| 528 | .getSetCookie() | ||
| 529 | .find((value) => value.startsWith(HostedAuthConfig.HOSTED_SESSION_COOKIE + "=")) ?? ""; | ||
| 530 | for (const attribute of ["Secure", "HttpOnly", "SameSite=Lax", "Path=/"]) | ||
| 531 | expect(rawCookie).toContain(attribute); | ||
| 532 | expect(rawCookie).not.toContain("Domain="); | ||
| 533 | const cookie = rawCookie.split(";")[0] ?? ""; | ||
| 534 | const accountResponse = yield* request("/api/auth/account", { cookie }); | ||
| 535 | expect(accountResponse.status).toBe(200); | ||
| 536 | const account = yield* Effect.promise(() => accountResponse.json()).pipe( | ||
| 537 | Effect.flatMap(Schema.decodeUnknownEffect(Schema.toCodecJson(HostedAuthAccount))), | ||
| 538 | ); | ||
| 539 | expect( | ||
| 540 | (yield* request( | ||
| 541 | "/api/auth/session/refresh", | ||
| 542 | { cookie, origin: settings.publicUrl }, | ||
| 543 | "POST", | ||
| 544 | )).status, | ||
| 545 | ).toBe(401); | ||
| 546 | const renewed = yield* request( | ||
| 547 | "/api/auth/session/refresh", | ||
| 548 | { cookie, origin: settings.publicUrl, "x-csrf-token": account.csrfToken }, | ||
| 549 | "POST", | ||
| 550 | ); | ||
| 551 | expect(renewed.status).toBe(200); | ||
| 552 | expect( | ||
| 553 | renewed.headers | ||
| 554 | .getSetCookie() | ||
| 555 | .some((value) => value.startsWith(HostedAuthConfig.HOSTED_SESSION_COOKIE + "=")), | ||
| 556 | ).toBe(true); | ||
| 557 | expect((yield* request("/api/auth/account", { cookie })).status).toBe(401); | ||
| 558 | const rotatedCookie = | ||
| 559 | renewed.headers | ||
| 560 | .getSetCookie() | ||
| 561 | .find((value) => value.startsWith(HostedAuthConfig.HOSTED_SESSION_COOKIE + "=")) | ||
| 562 | ?.split(";")[0] ?? ""; | ||
| 563 | const otherTab = yield* request( | ||
| 564 | "/api/auth/session/refresh", | ||
| 565 | { | ||
| 566 | cookie: rotatedCookie, | ||
| 567 | origin: settings.publicUrl, | ||
| 568 | "x-csrf-token": account.csrfToken, | ||
| 569 | }, | ||
| 570 | "POST", | ||
| 571 | ); | ||
| 572 | expect(otherTab.status).toBe(200); | ||
| 573 | const currentCookie = | ||
| 574 | otherTab.headers | ||
| 575 | .getSetCookie() | ||
| 576 | .find((value) => value.startsWith(HostedAuthConfig.HOSTED_SESSION_COOKIE + "=")) | ||
| 577 | ?.split(";")[0] ?? ""; | ||
| 578 | const auth = yield* HostedAuth.HostedAuth; | ||
| 579 | const sessions = yield* SessionStore.SessionStore; | ||
| 580 | const another = yield* nativeLogin(f); | ||
| 581 | const anotherAccount = yield* auth.account( | ||
| 582 | yield* sessions.verify(another.grant.accessToken), | ||
| 583 | ); | ||
| 584 | expect( | ||
| 585 | (yield* request( | ||
| 586 | "/api/auth/session/refresh", | ||
| 587 | { | ||
| 588 | cookie: currentCookie, | ||
| 589 | origin: settings.publicUrl, | ||
| 590 | "x-csrf-token": anotherAccount.csrfToken, | ||
| 591 | }, | ||
| 592 | "POST", | ||
| 593 | )).status, | ||
| 594 | ).toBe(401); | ||
| 595 | const currentToken = decodeURIComponent( | ||
| 596 | currentCookie.slice(currentCookie.indexOf("=") + 1), | ||
| 597 | ); | ||
| 598 | const currentSession = yield* sessions.verify(currentToken); | ||
| 599 | yield* auth.revoke(currentSession, account.deviceId); | ||
| 600 | expect((yield* request("/api/auth/account", { cookie: currentCookie })).status).toBe(401); | ||
| 601 | expect( | ||
| 602 | (yield* request( | ||
| 603 | "/api/auth/session/refresh", | ||
| 604 | { | ||
| 605 | cookie: currentCookie, | ||
| 606 | origin: settings.publicUrl, | ||
| 607 | "x-csrf-token": account.csrfToken, | ||
| 608 | }, | ||
| 609 | "POST", | ||
| 610 | )).status, | ||
| 611 | ).toBe(401); | ||
| 612 | }).pipe(Effect.provide(f.layer)); | ||
| 613 | }), | ||
| 614 | ); | ||
| 615 | |||
| 616 | it.effect("keeps local emergency recovery independent of issuer availability", () => | ||
| 617 | Effect.gen(function* () { | ||
| 618 | const f = yield* fixture; | ||
| 619 | yield* Effect.gen(function* () { | ||
| 620 | const auth = yield* HostedAuth.HostedAuth; | ||
| 621 | const sessions = yield* SessionStore.SessionStore; | ||
| 622 | const a = (yield* nativeLogin(f)).grant; | ||
| 623 | const b = (yield* nativeLogin(f)).grant; | ||
| 624 | const manual = yield* sessions.issue({ subject: "local-recovery" }); | ||
| 625 | f.denyRefresh(); | ||
| 626 | expect(yield* auth.emergencyRevokeAll).toBe(2); | ||
| 627 | for (const grant of [a, b]) | ||
| 628 | expect((yield* sessions.verify(grant.accessToken).pipe(Effect.result))._tag).toBe( | ||
| 629 | "Failure", | ||
| 630 | ); | ||
| 631 | expect((yield* sessions.verify(manual.token)).subject).toBe("local-recovery"); | ||
| 632 | expect(yield* auth.emergencyRevokeAll).toBe(0); | ||
| 633 | }).pipe(Effect.provide(f.layer)); | ||
| 634 | }), | ||
| 635 | ); | ||
| 636 | |||
| 637 | it.effect("rejects configured SSO without an owner pin and disables SSO when unconfigured", () => | ||
| 638 | Effect.gen(function* () { | ||
| 639 | const fs = yield* FileSystem.FileSystem; | ||
| 640 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "knife-hosted-config-" }); | ||
| 641 | const path = `${directory}/sso.json`; | ||
| 642 | yield* fs.writeFileString( | ||
| 643 | path, | ||
| 644 | encodeJson({ | ||
| 645 | publicUrl: settings.publicUrl, | ||
| 646 | issuer: settings.issuer, | ||
| 647 | clientId: settings.clientId, | ||
| 648 | clientSecret: "test", | ||
| 649 | }), | ||
| 650 | ); | ||
| 651 | const read = Effect.gen(function* () { | ||
| 652 | return yield* HostedAuthConfig.HostedAuthConfig; | ||
| 653 | }); | ||
| 654 | const bad = yield* read.pipe( | ||
| 655 | Effect.provide( | ||
| 656 | HostedAuthConfig.layer.pipe( | ||
| 657 | Layer.provide( | ||
| 658 | ConfigProvider.layer(ConfigProvider.fromUnknown({ T3CODE_SSO_CONFIG: path })), | ||
| 659 | ), | ||
| 660 | ), | ||
| 661 | ), | ||
| 662 | Effect.flip, | ||
| 663 | ); | ||
| 664 | expect(bad._tag).toBe("HostedAuthError"); | ||
| 665 | const disabled = yield* read.pipe( | ||
| 666 | Effect.provide( | ||
| 667 | HostedAuthConfig.layer.pipe( | ||
| 668 | Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), | ||
| 669 | ), | ||
| 670 | ), | ||
| 671 | ); | ||
| 672 | expect(Option.isNone(disabled)).toBe(true); | ||
| 673 | }), | ||
| 674 | ); | ||
| 675 | it.effect("re-enrolls an expired device without changing its existing environment identity", () => | ||
| 676 | Effect.gen(function* () { | ||
| 677 | const f = yield* fixture; | ||
| 678 | yield* Effect.gen(function* () { | ||
| 679 | const auth = yield* HostedAuth.HostedAuth; | ||
| 680 | const sessions = yield* SessionStore.SessionStore; | ||
| 681 | const original = (yield* nativeLogin(f)).grant; | ||
| 682 | const id = EnvironmentId.make("stable-environment"); | ||
| 683 | yield* auth.bindEnvironment(yield* sessions.verify(original.accessToken), id); | ||
| 684 | yield* TestClock.adjust(Duration.seconds(settings.maxInteractiveAgeSeconds)); | ||
| 685 | const replacement = (yield* nativeLogin(f)).grant; | ||
| 686 | const session = yield* sessions.verify(replacement.accessToken); | ||
| 687 | yield* auth.bindEnvironment(session, id); | ||
| 688 | yield* auth.authorizeEnvironment(session, id); | ||
| 689 | expect( | ||
| 690 | (yield* auth.devices(session)).find((device) => device.deviceId === original.deviceId) | ||
| 691 | ?.revokedAt, | ||
| 692 | ).not.toBe(null); | ||
| 693 | }).pipe(Effect.provide(f.layer)); | ||
| 694 | }), | ||
| 695 | ); | ||
| 696 | }); | ||
apps/server/src/auth/HostedAuth.ts created+739| ... | @@ -0,0 +1,739 @@ | ||
| 1 | import { | ||
| 2 | AuthAdministrativeScopes, | ||
| 3 | AuthAccessReadScope, | ||
| 4 | AuthAccessWriteScope, | ||
| 5 | AuthEnvironmentConnectScope, | ||
| 6 | AuthClientPresentationMetadata, | ||
| 7 | AuthSessionId, | ||
| 8 | EnvironmentId, | ||
| 9 | HostedAuthError, | ||
| 10 | type AuthEnvironmentScope, | ||
| 11 | type HostedAuthAccount, | ||
| 12 | type HostedAuthDevice, | ||
| 13 | type HostedAuthDeviceExchange, | ||
| 14 | type HostedAuthDeviceStart, | ||
| 15 | type HostedAuthDeviceStartResult, | ||
| 16 | type HostedAuthGrant, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 18 | import { createLocalJWKSet, jwtVerify } from "jose"; | ||
| 19 | import * as Context from "effect/Context"; | ||
| 20 | import * as Crypto from "effect/Crypto"; | ||
| 21 | import * as DateTime from "effect/DateTime"; | ||
| 22 | import * as Duration from "effect/Duration"; | ||
| 23 | import * as Effect from "effect/Effect"; | ||
| 24 | import * as Base64Url from "effect/encoding/Base64Url"; | ||
| 25 | import * as Layer from "effect/Layer"; | ||
| 26 | import * as Option from "effect/Option"; | ||
| 27 | import * as Redacted from "effect/Redacted"; | ||
| 28 | import * as Schema from "effect/Schema"; | ||
| 29 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 30 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; | ||
| 31 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | ||
| 32 | import type { AuthenticatedSession } from "./EnvironmentAuth.ts"; | ||
| 33 | import * as HostedAuthConfig from "./HostedAuthConfig.ts"; | ||
| 34 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | ||
| 35 | import * as SessionStore from "./SessionStore.ts"; | ||
| 36 | import { signPayload } from "./utils.ts"; | ||
| 37 | |||
| 38 | const isHostedAuthError = Schema.is(HostedAuthError); | ||
| 39 | const encodeClient = Schema.encodeEffect(Schema.fromJsonString(AuthClientPresentationMetadata)); | ||
| 40 | |||
| 41 | const Discovery = Schema.Struct({ | ||
| 42 | issuer: Schema.String, | ||
| 43 | authorization_endpoint: Schema.String, | ||
| 44 | token_endpoint: Schema.String, | ||
| 45 | jwks_uri: Schema.String, | ||
| 46 | code_challenge_methods_supported: Schema.Array(Schema.String), | ||
| 47 | response_types_supported: Schema.Array(Schema.String), | ||
| 48 | token_endpoint_auth_methods_supported: Schema.Array(Schema.String), | ||
| 49 | id_token_signing_alg_values_supported: Schema.Array(Schema.String), | ||
| 50 | }); | ||
| 51 | const Tokens = Schema.Struct({ | ||
| 52 | access_token: Schema.NonEmptyString, | ||
| 53 | token_type: Schema.NonEmptyString, | ||
| 54 | refresh_token: Schema.optionalKey(Schema.NonEmptyString), | ||
| 55 | id_token: Schema.optionalKey(Schema.NonEmptyString), | ||
| 56 | }); | ||
| 57 | const Device = Schema.Struct({ | ||
| 58 | deviceId: Schema.String, | ||
| 59 | accountId: Schema.String, | ||
| 60 | environmentId: Schema.NullOr(EnvironmentId), | ||
| 61 | client: Schema.fromJsonString(AuthClientPresentationMetadata), | ||
| 62 | createdAt: Schema.Number, | ||
| 63 | expiresAt: Schema.Number, | ||
| 64 | validatedAt: Schema.Number, | ||
| 65 | revokedAt: Schema.NullOr(Schema.Number), | ||
| 66 | }); | ||
| 67 | type Device = typeof Device.Type; | ||
| 68 | type NativeRequest = { readonly input: HostedAuthDeviceStart; readonly expiresAt: number }; | ||
| 69 | type Login = { | ||
| 70 | readonly browserHash: string; | ||
| 71 | readonly nonce: string; | ||
| 72 | readonly verifier: string; | ||
| 73 | readonly returnTo: string; | ||
| 74 | readonly expiresAt: number; | ||
| 75 | readonly native?: HostedAuthDeviceStart; | ||
| 76 | }; | ||
| 77 | type Handoff = { | ||
| 78 | readonly input: HostedAuthDeviceStart; | ||
| 79 | readonly refreshToken: string; | ||
| 80 | readonly expiresAt: number; | ||
| 81 | }; | ||
| 82 | |||
| 83 | export class HostedAuth extends Context.Service< | ||
| 84 | HostedAuth, | ||
| 85 | { | ||
| 86 | readonly enabled: boolean; | ||
| 87 | readonly startDevice: ( | ||
| 88 | input: HostedAuthDeviceStart, | ||
| 89 | ) => Effect.Effect<HostedAuthDeviceStartResult, HostedAuthError>; | ||
| 90 | readonly begin: (input: { | ||
| 91 | request?: string; | ||
| 92 | returnTo?: string; | ||
| 93 | }) => Effect.Effect<{ url: string; browserCookie: string }, HostedAuthError>; | ||
| 94 | readonly callback: (input: { | ||
| 95 | state: string; | ||
| 96 | code: string; | ||
| 97 | browserCookie: string; | ||
| 98 | client: AuthClientPresentationMetadata; | ||
| 99 | }) => Effect.Effect<{ url: string; session?: SessionStore.IssuedSession }, HostedAuthError>; | ||
| 100 | readonly exchange: ( | ||
| 101 | input: HostedAuthDeviceExchange, | ||
| 102 | ) => Effect.Effect<HostedAuthGrant, HostedAuthError>; | ||
| 103 | readonly refresh: (refreshToken: string) => Effect.Effect<HostedAuthGrant, HostedAuthError>; | ||
| 104 | readonly renewBrowser: ( | ||
| 105 | session: AuthenticatedSession, | ||
| 106 | ) => Effect.Effect<SessionStore.IssuedSession, HostedAuthError>; | ||
| 107 | readonly account: ( | ||
| 108 | session: AuthenticatedSession, | ||
| 109 | ) => Effect.Effect<HostedAuthAccount, HostedAuthError>; | ||
| 110 | readonly devices: ( | ||
| 111 | session: AuthenticatedSession, | ||
| 112 | ) => Effect.Effect<ReadonlyArray<HostedAuthDevice>, HostedAuthError>; | ||
| 113 | readonly revoke: ( | ||
| 114 | session: AuthenticatedSession, | ||
| 115 | deviceId: string, | ||
| 116 | ) => Effect.Effect<void, HostedAuthError>; | ||
| 117 | readonly token: ( | ||
| 118 | session: AuthenticatedSession, | ||
| 119 | scopes: ReadonlyArray<AuthEnvironmentScope>, | ||
| 120 | ) => Effect.Effect<SessionStore.IssuedSession, HostedAuthError>; | ||
| 121 | readonly bindEnvironment: ( | ||
| 122 | session: AuthenticatedSession, | ||
| 123 | environmentId: EnvironmentId, | ||
| 124 | ) => Effect.Effect<void, HostedAuthError>; | ||
| 125 | readonly authorizeEnvironment: ( | ||
| 126 | session: AuthenticatedSession, | ||
| 127 | environmentId: EnvironmentId, | ||
| 128 | ) => Effect.Effect<void, HostedAuthError>; | ||
| 129 | readonly emergencyRevokeAll: Effect.Effect<number, HostedAuthError>; | ||
| 130 | } | ||
| 131 | >()("t3/auth/HostedAuth") {} | ||
| 132 | |||
| 133 | const make = Effect.gen(function* () { | ||
| 134 | const configuration = yield* HostedAuthConfig.HostedAuthConfig; | ||
| 135 | const http = yield* HttpClient.HttpClient; | ||
| 136 | const sql = yield* SqlClient.SqlClient; | ||
| 137 | const crypto = yield* Crypto.Crypto; | ||
| 138 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 139 | const sessions = yield* SessionStore.SessionStore; | ||
| 140 | const locks = yield* KeyedLock.make<string>(); | ||
| 141 | const requests = new Map<string, NativeRequest>(); | ||
| 142 | const logins = new Map<string, Login>(); | ||
| 143 | const handoffs = new Map<string, Handoff>(); | ||
| 144 | const config = Effect.fromOption(configuration).pipe( | ||
| 145 | Effect.mapError(() => new HostedAuthError({ reason: "disabled" })), | ||
| 146 | ); | ||
| 147 | const random = crypto.randomBytes(32).pipe( | ||
| 148 | Effect.map(Base64Url.encode), | ||
| 149 | Effect.mapError( | ||
| 150 | (cause) => new HostedAuthError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 151 | ), | ||
| 152 | ); | ||
| 153 | const hash = (text: string) => | ||
| 154 | crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe( | ||
| 155 | Effect.map(Base64Url.encode), | ||
| 156 | Effect.mapError( | ||
| 157 | (cause) => new HostedAuthError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 158 | ), | ||
| 159 | ); | ||
| 160 | const storage = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 161 | effect.pipe( | ||
| 162 | Effect.mapError((cause) => | ||
| 163 | isHostedAuthError(cause) | ||
| 164 | ? cause | ||
| 165 | : new HostedAuthError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 166 | ), | ||
| 167 | ); | ||
| 168 | const accountId = config.pipe( | ||
| 169 | Effect.flatMap((value) => hash(`${value.issuer}\0${value.ownerSub}`)), | ||
| 170 | ); | ||
| 171 | const prune = (now: number) => { | ||
| 172 | for (const entries of [requests, logins, handoffs]) | ||
| 173 | for (const [key, value] of entries) if (value.expiresAt <= now) entries.delete(key); | ||
| 174 | if (requests.size + logins.size + handoffs.size >= 1_000) | ||
| 175 | return Effect.fail(new HostedAuthError({ reason: "transaction" })); | ||
| 176 | return Effect.void; | ||
| 177 | }; | ||
| 178 | const requestJson = <A>( | ||
| 179 | request: HttpClientRequest.HttpClientRequest, | ||
| 180 | schema: Schema.Decoder<A>, | ||
| 181 | ) => | ||
| 182 | Effect.gen(function* () { | ||
| 183 | const response = yield* http | ||
| 184 | .execute(request) | ||
| 185 | .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" })); | ||
| 186 | if (response.status !== 200) | ||
| 187 | return yield* new HostedAuthError({ | ||
| 188 | reason: | ||
| 189 | response.status === 400 || response.status === 401 ? "reauthentication" : "upstream", | ||
| 190 | }); | ||
| 191 | return yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema))); | ||
| 192 | }).pipe( | ||
| 193 | Effect.timeout("15 seconds"), | ||
| 194 | Effect.mapError((cause) => | ||
| 195 | isHostedAuthError(cause) | ||
| 196 | ? cause | ||
| 197 | : new HostedAuthError({ reason: "upstream", cause: Redacted.make(cause) }), | ||
| 198 | ), | ||
| 199 | Effect.scoped, | ||
| 200 | ); | ||
| 201 | const discovery = Effect.gen(function* () { | ||
| 202 | const settings = yield* config; | ||
| 203 | const result = yield* requestJson( | ||
| 204 | HttpClientRequest.get( | ||
| 205 | `${settings.issuer.replace(/\/$/u, "")}/.well-known/openid-configuration`, | ||
| 206 | ), | ||
| 207 | Discovery, | ||
| 208 | ); | ||
| 209 | if ( | ||
| 210 | result.issuer !== settings.issuer || | ||
| 211 | !result.code_challenge_methods_supported.includes("S256") || | ||
| 212 | !result.response_types_supported.includes("code") || | ||
| 213 | !result.token_endpoint_auth_methods_supported.includes("client_secret_basic") || | ||
| 214 | !result.id_token_signing_alg_values_supported.includes("RS256") | ||
| 215 | ) | ||
| 216 | return yield* new HostedAuthError({ reason: "configuration" }); | ||
| 217 | for (const endpoint of [ | ||
| 218 | result.authorization_endpoint, | ||
| 219 | result.token_endpoint, | ||
| 220 | result.jwks_uri, | ||
| 221 | ]) { | ||
| 222 | const valid = yield* Effect.try({ | ||
| 223 | try: () => { | ||
| 224 | const url = new URL(endpoint); | ||
| 225 | return url.protocol === "https:" && !url.username && !url.password && !url.hash; | ||
| 226 | }, | ||
| 227 | catch: () => new HostedAuthError({ reason: "configuration" }), | ||
| 228 | }); | ||
| 229 | if (!valid) return yield* new HostedAuthError({ reason: "configuration" }); | ||
| 230 | } | ||
| 231 | return result; | ||
| 232 | }); | ||
| 233 | const exchangeTokens = (metadata: typeof Discovery.Type, body: Record<string, string>) => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const settings = yield* config; | ||
| 236 | const basic = Buffer.from( | ||
| 237 | `${encodeURIComponent(settings.clientId)}:${encodeURIComponent(Redacted.value(settings.clientSecret))}`, | ||
| 238 | ).toString("base64"); | ||
| 239 | return yield* requestJson( | ||
| 240 | HttpClientRequest.post(metadata.token_endpoint).pipe( | ||
| 241 | HttpClientRequest.setHeader("authorization", `Basic ${basic}`), | ||
| 242 | HttpClientRequest.bodyUrlParams(body), | ||
| 243 | ), | ||
| 244 | Tokens, | ||
| 245 | ); | ||
| 246 | }); | ||
| 247 | const verifyIdentity = (metadata: typeof Discovery.Type, token: string, nonce?: string) => | ||
| 248 | Effect.gen(function* () { | ||
| 249 | const settings = yield* config; | ||
| 250 | const jwks = yield* requestJson( | ||
| 251 | HttpClientRequest.get(metadata.jwks_uri), | ||
| 252 | Schema.Struct({ | ||
| 253 | keys: Schema.Array( | ||
| 254 | Schema.Struct({ | ||
| 255 | kty: Schema.String, | ||
| 256 | n: Schema.optionalKey(Schema.String), | ||
| 257 | e: Schema.optionalKey(Schema.String), | ||
| 258 | kid: Schema.optionalKey(Schema.String), | ||
| 259 | alg: Schema.optionalKey(Schema.String), | ||
| 260 | use: Schema.optionalKey(Schema.String), | ||
| 261 | }), | ||
| 262 | ), | ||
| 263 | }), | ||
| 264 | ); | ||
| 265 | const now = yield* DateTime.now; | ||
| 266 | const claims = yield* Effect.tryPromise({ | ||
| 267 | try: () => | ||
| 268 | jwtVerify(token, createLocalJWKSet({ keys: [...jwks.keys] }), { | ||
| 269 | algorithms: ["RS256"], | ||
| 270 | issuer: settings.issuer, | ||
| 271 | audience: settings.clientId, | ||
| 272 | currentDate: DateTime.toDate(now), | ||
| 273 | clockTolerance: 5, | ||
| 274 | requiredClaims: ["sub", "iss", "aud", "iat", "exp"], | ||
| 275 | }), | ||
| 276 | catch: () => new HostedAuthError({ reason: "identity" }), | ||
| 277 | }); | ||
| 278 | if ( | ||
| 279 | claims.payload.sub !== settings.ownerSub || | ||
| 280 | (nonce !== undefined && claims.payload.nonce !== nonce) || | ||
| 281 | (claims.payload.azp !== undefined && claims.payload.azp !== settings.clientId) || | ||
| 282 | (Array.isArray(claims.payload.aud) && | ||
| 283 | claims.payload.aud.length > 1 && | ||
| 284 | claims.payload.azp !== settings.clientId) | ||
| 285 | ) | ||
| 286 | return yield* new HostedAuthError({ reason: "identity" }); | ||
| 287 | }); | ||
| 288 | const loadDevice = (id: string) => | ||
| 289 | Effect.gen(function* () { | ||
| 290 | const rows = yield* storage( | ||
| 291 | sql`SELECT device_id AS "deviceId", account_id AS "accountId", environment_id AS "environmentId", client_json AS "client", created_at AS "createdAt", expires_at AS "expiresAt", validated_at AS "validatedAt", revoked_at AS "revokedAt" FROM auth_devices WHERE device_id = ${id}`.pipe( | ||
| 292 | Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Device))), | ||
| 293 | ), | ||
| 294 | ); | ||
| 295 | const device = rows[0]; | ||
| 296 | const now = yield* DateTime.now; | ||
| 297 | if ( | ||
| 298 | !device || | ||
| 299 | device.accountId !== (yield* accountId) || | ||
| 300 | device.revokedAt !== null || | ||
| 301 | device.expiresAt <= now.epochMilliseconds | ||
| 302 | ) | ||
| 303 | return yield* new HostedAuthError({ reason: "reauthentication" }); | ||
| 304 | return device; | ||
| 305 | }); | ||
| 306 | const sessionDevice = (session: AuthenticatedSession) => | ||
| 307 | session.deviceId === undefined | ||
| 308 | ? Effect.fail(new HostedAuthError({ reason: "forbidden" })) | ||
| 309 | : loadDevice(session.deviceId); | ||
| 310 | const revokeDevice = (deviceId: string) => | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const now = yield* DateTime.now; | ||
| 313 | const rows = yield* storage( | ||
| 314 | sql.withTransaction( | ||
| 315 | Effect.gen(function* () { | ||
| 316 | yield* sql`UPDATE auth_devices SET revoked_at = COALESCE(revoked_at, ${now.epochMilliseconds}) WHERE device_id = ${deviceId}`; | ||
| 317 | yield* sql`DELETE FROM auth_device_refresh WHERE device_id = ${deviceId}`; | ||
| 318 | return yield* sql`SELECT session_id AS "sessionId" FROM auth_sessions WHERE device_id = ${deviceId}`.pipe( | ||
| 319 | Effect.flatMap( | ||
| 320 | Schema.decodeUnknownEffect( | ||
| 321 | Schema.Array(Schema.Struct({ sessionId: AuthSessionId })), | ||
| 322 | ), | ||
| 323 | ), | ||
| 324 | ); | ||
| 325 | }), | ||
| 326 | ), | ||
| 327 | ); | ||
| 328 | for (const row of rows) yield* storage(sessions.revoke(row.sessionId)); | ||
| 329 | yield* storage(secrets.remove(`hosted-oidc-${deviceId}`)); | ||
| 330 | }); | ||
| 331 | const issueAccess = ( | ||
| 332 | device: Device, | ||
| 333 | method: "browser-session-cookie" | "bearer-access-token", | ||
| 334 | scopes: ReadonlyArray<AuthEnvironmentScope> = AuthAdministrativeScopes, | ||
| 335 | options?: { readonly replaceSessionId?: AuthSessionId; readonly expiresAt?: DateTime.DateTime }, | ||
| 336 | ) => | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const now = yield* DateTime.now; | ||
| 339 | return yield* storage( | ||
| 340 | sessions.issue({ | ||
| 341 | deviceId: device.deviceId, | ||
| 342 | subject: device.accountId, | ||
| 343 | method, | ||
| 344 | scopes, | ||
| 345 | ttl: Duration.millis( | ||
| 346 | Math.min( | ||
| 347 | 900_000, | ||
| 348 | device.expiresAt - now.epochMilliseconds, | ||
| 349 | options?.expiresAt === undefined | ||
| 350 | ? Infinity | ||
| 351 | : options.expiresAt.epochMilliseconds - now.epochMilliseconds, | ||
| 352 | ), | ||
| 353 | ), | ||
| 354 | client: { deviceType: device.client.deviceType ?? "unknown", ...device.client }, | ||
| 355 | ...(options?.replaceSessionId === undefined | ||
| 356 | ? {} | ||
| 357 | : { replaceSessionId: options.replaceSessionId }), | ||
| 358 | }), | ||
| 359 | ); | ||
| 360 | }); | ||
| 361 | const createDevice = (client: typeof AuthClientPresentationMetadata.Type, refreshToken: string) => | ||
| 362 | Effect.gen(function* () { | ||
| 363 | const settings = yield* config; | ||
| 364 | const now = yield* DateTime.now; | ||
| 365 | const id = yield* storage(crypto.randomUUIDv4()); | ||
| 366 | const device: Device = { | ||
| 367 | deviceId: id, | ||
| 368 | accountId: yield* accountId, | ||
| 369 | environmentId: null, | ||
| 370 | client, | ||
| 371 | createdAt: now.epochMilliseconds, | ||
| 372 | expiresAt: now.epochMilliseconds + settings.maxInteractiveAgeSeconds * 1_000, | ||
| 373 | validatedAt: now.epochMilliseconds, | ||
| 374 | revokedAt: null, | ||
| 375 | }; | ||
| 376 | yield* storage(secrets.set(`hosted-oidc-${id}`, new TextEncoder().encode(refreshToken))); | ||
| 377 | const encodedClient = yield* storage(encodeClient(client)); | ||
| 378 | yield* storage( | ||
| 379 | sql`INSERT INTO auth_devices(device_id, account_id, client_json, created_at, expires_at, validated_at) VALUES (${id}, ${device.accountId}, ${encodedClient}, ${device.createdAt}, ${device.expiresAt}, ${device.validatedAt})`, | ||
| 380 | ); | ||
| 381 | return device; | ||
| 382 | }); | ||
| 383 | const renewIssuer = (device: Device) => | ||
| 384 | Effect.gen(function* () { | ||
| 385 | const settings = yield* config; | ||
| 386 | const now = yield* DateTime.now; | ||
| 387 | if (device.validatedAt + settings.issuerRevalidationSeconds * 1_000 > now.epochMilliseconds) | ||
| 388 | return; | ||
| 389 | const refresh = yield* storage(secrets.get(`hosted-oidc-${device.deviceId}`)); | ||
| 390 | if (Option.isNone(refresh)) return yield* new HostedAuthError({ reason: "reauthentication" }); | ||
| 391 | const metadata = yield* discovery; | ||
| 392 | const tokens = yield* exchangeTokens(metadata, { | ||
| 393 | grant_type: "refresh_token", | ||
| 394 | refresh_token: new TextDecoder().decode(refresh.value), | ||
| 395 | }).pipe( | ||
| 396 | Effect.catchTags({ | ||
| 397 | HostedAuthError: (error) => | ||
| 398 | error.reason === "reauthentication" | ||
| 399 | ? revokeDevice(device.deviceId).pipe(Effect.andThen(Effect.fail(error))) | ||
| 400 | : Effect.fail(error), | ||
| 401 | }), | ||
| 402 | ); | ||
| 403 | if (tokens.id_token) | ||
| 404 | yield* verifyIdentity(metadata, tokens.id_token).pipe( | ||
| 405 | Effect.catchTags({ | ||
| 406 | HostedAuthError: (error) => | ||
| 407 | revokeDevice(device.deviceId).pipe(Effect.andThen(Effect.fail(error))), | ||
| 408 | }), | ||
| 409 | ); | ||
| 410 | if (tokens.token_type.toLowerCase() !== "bearer") | ||
| 411 | return yield* new HostedAuthError({ reason: "identity" }); | ||
| 412 | if (tokens.refresh_token) | ||
| 413 | yield* storage( | ||
| 414 | secrets.set( | ||
| 415 | `hosted-oidc-${device.deviceId}`, | ||
| 416 | new TextEncoder().encode(tokens.refresh_token), | ||
| 417 | ), | ||
| 418 | ); | ||
| 419 | const updated = yield* storage( | ||
| 420 | sql`UPDATE auth_devices SET validated_at = ${now.epochMilliseconds} WHERE device_id = ${device.deviceId} AND revoked_at IS NULL RETURNING device_id`, | ||
| 421 | ); | ||
| 422 | if (updated.length === 0) { | ||
| 423 | yield* storage(secrets.remove(`hosted-oidc-${device.deviceId}`)); | ||
| 424 | return yield* new HostedAuthError({ reason: "reauthentication" }); | ||
| 425 | } | ||
| 426 | }); | ||
| 427 | const makeGrant = (device: Device) => | ||
| 428 | Effect.gen(function* () { | ||
| 429 | const refreshToken = yield* random; | ||
| 430 | const tokenHash = yield* hash(refreshToken); | ||
| 431 | yield* storage( | ||
| 432 | sql`INSERT INTO auth_device_refresh(token_hash, device_id, expires_at) VALUES (${tokenHash}, ${device.deviceId}, ${device.expiresAt})`, | ||
| 433 | ); | ||
| 434 | const issued = yield* issueAccess(device, "bearer-access-token"); | ||
| 435 | return { | ||
| 436 | deviceId: device.deviceId, | ||
| 437 | accessToken: issued.token, | ||
| 438 | refreshToken, | ||
| 439 | expiresAt: DateTime.toUtc(issued.expiresAt), | ||
| 440 | } satisfies HostedAuthGrant; | ||
| 441 | }); | ||
| 442 | |||
| 443 | return HostedAuth.of({ | ||
| 444 | enabled: Option.isSome(configuration), | ||
| 445 | startDevice: (input) => | ||
| 446 | Effect.gen(function* () { | ||
| 447 | const settings = yield* config; | ||
| 448 | const now = yield* DateTime.now; | ||
| 449 | yield* prune(now.epochMilliseconds); | ||
| 450 | const encoded = yield* storage(encodeClient(input.client)); | ||
| 451 | if (encoded.length > 2_048) return yield* new HostedAuthError({ reason: "transaction" }); | ||
| 452 | const id = yield* random; | ||
| 453 | requests.set(id, { input, expiresAt: now.epochMilliseconds + 300_000 }); | ||
| 454 | const url = new URL("/api/auth/oidc/start", settings.publicUrl); | ||
| 455 | url.searchParams.set("request", id); | ||
| 456 | return { authorizationUrl: url.href }; | ||
| 457 | }), | ||
| 458 | begin: (input) => | ||
| 459 | Effect.gen(function* () { | ||
| 460 | const settings = yield* config; | ||
| 461 | const now = yield* DateTime.now; | ||
| 462 | yield* prune(now.epochMilliseconds); | ||
| 463 | const native = input.request ? requests.get(input.request) : undefined; | ||
| 464 | if (input.request && !native) return yield* new HostedAuthError({ reason: "transaction" }); | ||
| 465 | if (input.request) requests.delete(input.request); | ||
| 466 | const returnTo = yield* Effect.try({ | ||
| 467 | try: () => { | ||
| 468 | const url = new URL(input.returnTo ?? "/", settings.publicUrl); | ||
| 469 | if (url.origin !== settings.publicUrl) throw new Error(); | ||
| 470 | return `${url.pathname}${url.search}${url.hash}`; | ||
| 471 | }, | ||
| 472 | catch: () => new HostedAuthError({ reason: "transaction" }), | ||
| 473 | }); | ||
| 474 | const metadata = yield* discovery; | ||
| 475 | const state = yield* random; | ||
| 476 | const browserCookie = yield* random; | ||
| 477 | const nonce = yield* random; | ||
| 478 | const verifier = yield* random; | ||
| 479 | logins.set(state, { | ||
| 480 | browserHash: yield* hash(browserCookie), | ||
| 481 | nonce, | ||
| 482 | verifier, | ||
| 483 | returnTo, | ||
| 484 | expiresAt: now.epochMilliseconds + 300_000, | ||
| 485 | ...(native ? { native: native.input } : {}), | ||
| 486 | }); | ||
| 487 | const url = new URL(metadata.authorization_endpoint); | ||
| 488 | for (const [key, value] of Object.entries({ | ||
| 489 | client_id: settings.clientId, | ||
| 490 | response_type: "code", | ||
| 491 | scope: "openid offline_access", | ||
| 492 | redirect_uri: `${settings.publicUrl}/api/auth/oidc/callback`, | ||
| 493 | state, | ||
| 494 | nonce, | ||
| 495 | code_challenge: yield* hash(verifier), | ||
| 496 | code_challenge_method: "S256", | ||
| 497 | })) | ||
| 498 | url.searchParams.set(key, value); | ||
| 499 | return { url: url.href, browserCookie }; | ||
| 500 | }), | ||
| 501 | callback: (input) => | ||
| 502 | Effect.gen(function* () { | ||
| 503 | const settings = yield* config; | ||
| 504 | const now = yield* DateTime.now; | ||
| 505 | yield* prune(now.epochMilliseconds); | ||
| 506 | const login = logins.get(input.state); | ||
| 507 | logins.delete(input.state); | ||
| 508 | if (!login || login.browserHash !== (yield* hash(input.browserCookie))) | ||
| 509 | return yield* new HostedAuthError({ reason: "transaction" }); | ||
| 510 | const metadata = yield* discovery; | ||
| 511 | const tokens = yield* exchangeTokens(metadata, { | ||
| 512 | grant_type: "authorization_code", | ||
| 513 | code: input.code, | ||
| 514 | code_verifier: login.verifier, | ||
| 515 | redirect_uri: `${settings.publicUrl}/api/auth/oidc/callback`, | ||
| 516 | }); | ||
| 517 | if ( | ||
| 518 | !tokens.id_token || | ||
| 519 | !tokens.refresh_token || | ||
| 520 | tokens.token_type.toLowerCase() !== "bearer" | ||
| 521 | ) | ||
| 522 | return yield* new HostedAuthError({ reason: "identity" }); | ||
| 523 | yield* verifyIdentity(metadata, tokens.id_token, login.nonce); | ||
| 524 | if (login.native) { | ||
| 525 | const code = yield* random; | ||
| 526 | handoffs.set(yield* hash(code), { | ||
| 527 | input: login.native, | ||
| 528 | refreshToken: tokens.refresh_token, | ||
| 529 | expiresAt: now.epochMilliseconds + 60_000, | ||
| 530 | }); | ||
| 531 | const url = new URL("t3code://app/self-hosted/callback"); | ||
| 532 | for (const [key, value] of Object.entries({ | ||
| 533 | code, | ||
| 534 | state: login.native.state, | ||
| 535 | server: settings.publicUrl, | ||
| 536 | })) | ||
| 537 | url.searchParams.set(key, value); | ||
| 538 | return { url: url.href }; | ||
| 539 | } | ||
| 540 | const device = yield* createDevice(input.client, tokens.refresh_token); | ||
| 541 | const session = yield* issueAccess(device, "browser-session-cookie"); | ||
| 542 | return { url: new URL(login.returnTo, settings.publicUrl).href, session }; | ||
| 543 | }), | ||
| 544 | exchange: (input) => | ||
| 545 | Effect.gen(function* () { | ||
| 546 | const now = yield* DateTime.now; | ||
| 547 | yield* prune(now.epochMilliseconds); | ||
| 548 | const key = yield* hash(input.code); | ||
| 549 | const handoff = handoffs.get(key); | ||
| 550 | if (!handoff || handoff.input.codeChallenge !== (yield* hash(input.codeVerifier))) | ||
| 551 | return yield* new HostedAuthError({ reason: "credential" }); | ||
| 552 | handoffs.delete(key); | ||
| 553 | const device = yield* createDevice(handoff.input.client, handoff.refreshToken); | ||
| 554 | return yield* storage(sql.withTransaction(makeGrant(device))); | ||
| 555 | }), | ||
| 556 | refresh: (refreshToken) => | ||
| 557 | Effect.gen(function* () { | ||
| 558 | const tokenHash = yield* hash(refreshToken); | ||
| 559 | const lookup = | ||
| 560 | sql`SELECT device_id AS "deviceId", consumed_at AS "consumedAt" FROM auth_device_refresh WHERE token_hash = ${tokenHash}`.pipe( | ||
| 561 | Effect.flatMap( | ||
| 562 | Schema.decodeUnknownEffect( | ||
| 563 | Schema.Array( | ||
| 564 | Schema.Struct({ | ||
| 565 | deviceId: Schema.String, | ||
| 566 | consumedAt: Schema.NullOr(Schema.Number), | ||
| 567 | }), | ||
| 568 | ), | ||
| 569 | ), | ||
| 570 | ), | ||
| 571 | ); | ||
| 572 | const rows = yield* storage(lookup); | ||
| 573 | const found = rows[0]; | ||
| 574 | if (!found) return yield* new HostedAuthError({ reason: "credential" }); | ||
| 575 | return yield* locks.withLock( | ||
| 576 | found.deviceId, | ||
| 577 | Effect.gen(function* () { | ||
| 578 | const current = (yield* storage(lookup))[0]; | ||
| 579 | if (!current) return yield* new HostedAuthError({ reason: "credential" }); | ||
| 580 | if (current.consumedAt !== null) { | ||
| 581 | yield* revokeDevice(current.deviceId); | ||
| 582 | return yield* new HostedAuthError({ reason: "credential" }); | ||
| 583 | } | ||
| 584 | const device = yield* loadDevice(current.deviceId); | ||
| 585 | yield* renewIssuer(device); | ||
| 586 | return yield* storage( | ||
| 587 | sql.withTransaction( | ||
| 588 | Effect.gen(function* () { | ||
| 589 | yield* loadDevice(current.deviceId); | ||
| 590 | const now = yield* DateTime.now; | ||
| 591 | const changed = | ||
| 592 | yield* sql`UPDATE auth_device_refresh SET consumed_at = ${now.epochMilliseconds} WHERE token_hash = ${tokenHash} AND consumed_at IS NULL RETURNING token_hash`; | ||
| 593 | if (changed.length !== 1) | ||
| 594 | return yield* new HostedAuthError({ reason: "credential" }); | ||
| 595 | return yield* makeGrant(device); | ||
| 596 | }), | ||
| 597 | ), | ||
| 598 | ); | ||
| 599 | }), | ||
| 600 | ); | ||
| 601 | }), | ||
| 602 | renewBrowser: (session) => | ||
| 603 | Effect.gen(function* () { | ||
| 604 | const device = yield* sessionDevice(session); | ||
| 605 | return yield* locks.withLock( | ||
| 606 | device.deviceId, | ||
| 607 | Effect.gen(function* () { | ||
| 608 | yield* renewIssuer(device); | ||
| 609 | return yield* storage( | ||
| 610 | sql.withTransaction( | ||
| 611 | Effect.gen(function* () { | ||
| 612 | const current = yield* loadDevice(device.deviceId); | ||
| 613 | return yield* issueAccess(current, "browser-session-cookie", session.scopes, { | ||
| 614 | replaceSessionId: session.sessionId, | ||
| 615 | }); | ||
| 616 | }), | ||
| 617 | ), | ||
| 618 | ); | ||
| 619 | }), | ||
| 620 | ); | ||
| 621 | }), | ||
| 622 | account: (session) => | ||
| 623 | Effect.gen(function* () { | ||
| 624 | const device = yield* sessionDevice(session); | ||
| 625 | const secret = yield* storage( | ||
| 626 | secrets.getOrCreateRandom(HostedAuthConfig.HOSTED_CSRF_SECRET, 32), | ||
| 627 | ); | ||
| 628 | return { | ||
| 629 | accountId: device.accountId, | ||
| 630 | deviceId: device.deviceId, | ||
| 631 | csrfToken: signPayload(device.deviceId, secret), | ||
| 632 | expiresAt: DateTime.toUtc(session.expiresAt ?? DateTime.makeUnsafe(device.expiresAt)), | ||
| 633 | }; | ||
| 634 | }), | ||
| 635 | devices: (session) => | ||
| 636 | Effect.gen(function* () { | ||
| 637 | if (!session.scopes.includes(AuthAccessReadScope)) | ||
| 638 | return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 639 | const device = yield* sessionDevice(session); | ||
| 640 | const rows = yield* storage( | ||
| 641 | sql`SELECT device_id AS "deviceId", account_id AS "accountId", environment_id AS "environmentId", client_json AS "client", created_at AS "createdAt", expires_at AS "expiresAt", validated_at AS "validatedAt", revoked_at AS "revokedAt" FROM auth_devices WHERE account_id = ${device.accountId} ORDER BY created_at DESC`.pipe( | ||
| 642 | Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Device))), | ||
| 643 | ), | ||
| 644 | ); | ||
| 645 | return rows.map((row) => ({ | ||
| 646 | deviceId: row.deviceId, | ||
| 647 | client: row.client, | ||
| 648 | createdAt: DateTime.makeUnsafe(row.createdAt), | ||
| 649 | expiresAt: DateTime.makeUnsafe(row.expiresAt), | ||
| 650 | revokedAt: row.revokedAt === null ? null : DateTime.makeUnsafe(row.revokedAt), | ||
| 651 | current: row.deviceId === device.deviceId, | ||
| 652 | ...(row.environmentId === null ? {} : { environmentId: row.environmentId }), | ||
| 653 | })); | ||
| 654 | }), | ||
| 655 | revoke: (session, deviceId) => | ||
| 656 | Effect.gen(function* () { | ||
| 657 | if (session.deviceId !== deviceId && !session.scopes.includes(AuthAccessWriteScope)) | ||
| 658 | return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 659 | const current = yield* sessionDevice(session); | ||
| 660 | const matches = yield* storage( | ||
| 661 | sql`SELECT 1 FROM auth_devices WHERE device_id = ${deviceId} AND account_id = ${current.accountId}`, | ||
| 662 | ); | ||
| 663 | if (matches.length !== 1) return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 664 | yield* revokeDevice(deviceId); | ||
| 665 | }), | ||
| 666 | token: (session, scopes) => | ||
| 667 | Effect.gen(function* () { | ||
| 668 | const device = yield* sessionDevice(session); | ||
| 669 | if ( | ||
| 670 | !session.expiresAt || | ||
| 671 | scopes.length === 0 || | ||
| 672 | scopes.some((scope) => !session.scopes.includes(scope)) | ||
| 673 | ) | ||
| 674 | return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 675 | const expiresAt = session.expiresAt; | ||
| 676 | return yield* storage( | ||
| 677 | sql.withTransaction( | ||
| 678 | Effect.gen(function* () { | ||
| 679 | yield* sessions.verifyActive(session.sessionId); | ||
| 680 | yield* loadDevice(device.deviceId); | ||
| 681 | return yield* issueAccess(device, "bearer-access-token", scopes, { expiresAt }); | ||
| 682 | }), | ||
| 683 | ), | ||
| 684 | ); | ||
| 685 | }), | ||
| 686 | bindEnvironment: (session, environmentId) => | ||
| 687 | Effect.gen(function* () { | ||
| 688 | if (!session.scopes.includes(AuthEnvironmentConnectScope)) | ||
| 689 | return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 690 | const device = yield* sessionDevice(session); | ||
| 691 | if (device.environmentId !== null && device.environmentId !== environmentId) | ||
| 692 | return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 693 | const replaced = yield* storage( | ||
| 694 | sql.withTransaction( | ||
| 695 | Effect.gen(function* () { | ||
| 696 | yield* loadDevice(device.deviceId); | ||
| 697 | const conflicts = | ||
| 698 | yield* sql`SELECT device_id AS "deviceId", account_id AS "accountId" FROM auth_devices WHERE environment_id = ${environmentId} AND device_id != ${device.deviceId} AND revoked_at IS NULL`.pipe( | ||
| 699 | Effect.flatMap( | ||
| 700 | Schema.decodeUnknownEffect( | ||
| 701 | Schema.Array( | ||
| 702 | Schema.Struct({ deviceId: Schema.String, accountId: Schema.String }), | ||
| 703 | ), | ||
| 704 | ), | ||
| 705 | ), | ||
| 706 | ); | ||
| 707 | if (conflicts.some((previous) => previous.accountId !== device.accountId)) | ||
| 708 | return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 709 | const now = yield* DateTime.now; | ||
| 710 | for (const previous of conflicts) | ||
| 711 | yield* sql`UPDATE auth_devices SET revoked_at = ${now.epochMilliseconds} WHERE device_id = ${previous.deviceId}`; | ||
| 712 | yield* sql`UPDATE auth_devices SET environment_id = ${environmentId} WHERE device_id = ${device.deviceId} AND revoked_at IS NULL`; | ||
| 713 | return conflicts; | ||
| 714 | }), | ||
| 715 | ), | ||
| 716 | ); | ||
| 717 | for (const previous of replaced) yield* revokeDevice(previous.deviceId); | ||
| 718 | }), | ||
| 719 | authorizeEnvironment: (session, environmentId) => | ||
| 720 | Effect.gen(function* () { | ||
| 721 | const device = yield* sessionDevice(session); | ||
| 722 | if (device.environmentId !== environmentId) | ||
| 723 | return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 724 | }), | ||
| 725 | emergencyRevokeAll: Effect.gen(function* () { | ||
| 726 | const rows = yield* storage( | ||
| 727 | sql`SELECT device_id AS "deviceId" FROM auth_devices WHERE revoked_at IS NULL`.pipe( | ||
| 728 | Effect.flatMap( | ||
| 729 | Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ deviceId: Schema.String }))), | ||
| 730 | ), | ||
| 731 | ), | ||
| 732 | ); | ||
| 733 | for (const row of rows) yield* revokeDevice(row.deviceId); | ||
| 734 | return rows.length; | ||
| 735 | }), | ||
| 736 | }); | ||
| 737 | }); | ||
| 738 | |||
| 739 | export const layer = Layer.effect(HostedAuth, make); | ||
apps/server/src/auth/HostedAuthConfig.ts created+79| ... | @@ -0,0 +1,79 @@ | ||
| 1 | import * as Config from "effect/Config"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import { HostedAuthError } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | export const HOSTED_SESSION_COOKIE = "__Host-knife-session"; | ||
| 12 | export const HOSTED_TRANSACTION_COOKIE = "__Host-knife-oidc"; | ||
| 13 | export const HOSTED_CSRF_SECRET = "hosted-auth-csrf"; | ||
| 14 | |||
| 15 | const Settings = Schema.Struct({ | ||
| 16 | publicUrl: Schema.NonEmptyString, | ||
| 17 | issuer: Schema.NonEmptyString, | ||
| 18 | clientId: Schema.NonEmptyString, | ||
| 19 | clientSecret: Schema.NonEmptyString, | ||
| 20 | ownerSub: Schema.NonEmptyString, | ||
| 21 | maxInteractiveAgeSeconds: Schema.optionalKey( | ||
| 22 | Schema.Int.check(Schema.isBetween({ minimum: 900, maximum: 31_536_000 })), | ||
| 23 | ), | ||
| 24 | issuerRevalidationSeconds: Schema.optionalKey( | ||
| 25 | Schema.Int.check(Schema.isBetween({ minimum: 60, maximum: 86_400 })), | ||
| 26 | ), | ||
| 27 | }); | ||
| 28 | |||
| 29 | export class HostedAuthConfig extends Context.Service< | ||
| 30 | HostedAuthConfig, | ||
| 31 | Option.Option<{ | ||
| 32 | readonly publicUrl: string; | ||
| 33 | readonly issuer: string; | ||
| 34 | readonly clientId: string; | ||
| 35 | readonly clientSecret: Redacted.Redacted<string>; | ||
| 36 | readonly ownerSub: string; | ||
| 37 | readonly maxInteractiveAgeSeconds: number; | ||
| 38 | readonly issuerRevalidationSeconds: number; | ||
| 39 | }> | ||
| 40 | >()("t3/auth/HostedAuthConfig") {} | ||
| 41 | |||
| 42 | const make = Effect.gen(function* () { | ||
| 43 | const path = yield* Config.option(Config.String("T3CODE_SSO_CONFIG")); | ||
| 44 | if (Option.isNone(path)) return Option.none(); | ||
| 45 | const fs = yield* FileSystem.FileSystem; | ||
| 46 | const settings = yield* fs.readFileString(path.value).pipe( | ||
| 47 | Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Settings))), | ||
| 48 | Effect.mapError(() => new HostedAuthError({ reason: "configuration" })), | ||
| 49 | ); | ||
| 50 | const valid = yield* Effect.try({ | ||
| 51 | try: () => { | ||
| 52 | const origin = new URL(settings.publicUrl); | ||
| 53 | const issuer = new URL(settings.issuer); | ||
| 54 | if ( | ||
| 55 | origin.protocol !== "https:" || | ||
| 56 | origin.origin !== settings.publicUrl || | ||
| 57 | issuer.protocol !== "https:" || | ||
| 58 | issuer.username || | ||
| 59 | issuer.password || | ||
| 60 | issuer.search || | ||
| 61 | issuer.hash || | ||
| 62 | origin.username || | ||
| 63 | origin.password | ||
| 64 | ) | ||
| 65 | throw new Error(); | ||
| 66 | return origin.origin; | ||
| 67 | }, | ||
| 68 | catch: () => new HostedAuthError({ reason: "configuration" }), | ||
| 69 | }); | ||
| 70 | return Option.some({ | ||
| 71 | ...settings, | ||
| 72 | publicUrl: valid, | ||
| 73 | clientSecret: Redacted.make(settings.clientSecret), | ||
| 74 | maxInteractiveAgeSeconds: settings.maxInteractiveAgeSeconds ?? 2_592_000, | ||
| 75 | issuerRevalidationSeconds: settings.issuerRevalidationSeconds ?? 3_600, | ||
| 76 | }); | ||
| 77 | }); | ||
| 78 | |||
| 79 | export const layer = Layer.effect(HostedAuthConfig, make); | ||
apps/server/src/auth/HostedAuthHttp.ts created+255| ... | @@ -0,0 +1,255 @@ | ||
| 1 | import { | ||
| 2 | HostedAuthAccount, | ||
| 3 | HostedAuthDevice, | ||
| 4 | HostedAuthDeviceExchange, | ||
| 5 | HostedAuthDeviceRefresh, | ||
| 6 | HostedAuthDeviceStart, | ||
| 7 | HostedAuthDeviceStartResult, | ||
| 8 | HostedAuthError, | ||
| 9 | HostedAuthGrant, | ||
| 10 | HostedAuthTokenRequest, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import * as DateTime from "effect/DateTime"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; | ||
| 18 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | ||
| 19 | import * as HostedAuth from "./HostedAuth.ts"; | ||
| 20 | import { | ||
| 21 | HostedAuthConfig, | ||
| 22 | HOSTED_SESSION_COOKIE, | ||
| 23 | HOSTED_TRANSACTION_COOKIE, | ||
| 24 | } from "./HostedAuthConfig.ts"; | ||
| 25 | import { deriveAuthClientMetadata } from "./utils.ts"; | ||
| 26 | |||
| 27 | const isHostedAuthError = Schema.is(HostedAuthError); | ||
| 28 | |||
| 29 | const headers = { | ||
| 30 | "cache-control": "no-store", | ||
| 31 | pragma: "no-cache", | ||
| 32 | "referrer-policy": "no-referrer", | ||
| 33 | }; | ||
| 34 | const cookieOptions = { secure: true, httpOnly: true, sameSite: "lax", path: "/" } as const; | ||
| 35 | const errors = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 36 | effect.pipe( | ||
| 37 | Effect.catch((error) => { | ||
| 38 | if (isHostedAuthError(error)) | ||
| 39 | return Effect.succeed( | ||
| 40 | HttpServerResponse.jsonUnsafe( | ||
| 41 | { _tag: error._tag, reason: error.reason }, | ||
| 42 | { | ||
| 43 | headers, | ||
| 44 | status: | ||
| 45 | error.reason === "disabled" | ||
| 46 | ? 404 | ||
| 47 | : error.reason === "forbidden" | ||
| 48 | ? 403 | ||
| 49 | : error.reason === "credential" || error.reason === "reauthentication" | ||
| 50 | ? 401 | ||
| 51 | : error.reason === "transaction" || error.reason === "identity" | ||
| 52 | ? 400 | ||
| 53 | : 503, | ||
| 54 | }, | ||
| 55 | ), | ||
| 56 | ); | ||
| 57 | if (EnvironmentAuth.isServerAuthCredentialError(error)) | ||
| 58 | return Effect.succeed( | ||
| 59 | HttpServerResponse.jsonUnsafe({ code: "unauthorized" }, { status: 401, headers }), | ||
| 60 | ); | ||
| 61 | if (Schema.isSchemaError(error)) | ||
| 62 | return Effect.succeed(HttpServerResponse.empty({ status: 400, headers })); | ||
| 63 | return Effect.fail(error); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | const authenticate = Effect.gen(function* () { | ||
| 67 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 68 | return yield* (yield* EnvironmentAuth.EnvironmentAuth).authenticateHttpRequest(request); | ||
| 69 | }); | ||
| 70 | const browser = authenticate.pipe( | ||
| 71 | Effect.flatMap((session) => | ||
| 72 | session.method === "browser-session-cookie" | ||
| 73 | ? Effect.succeed(session) | ||
| 74 | : Effect.fail(new HostedAuthError({ reason: "forbidden" })), | ||
| 75 | ), | ||
| 76 | ); | ||
| 77 | const query = Effect.gen(function* () { | ||
| 78 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 79 | const url = new URL(request.url, "https://request.invalid"); | ||
| 80 | return url.searchParams; | ||
| 81 | }); | ||
| 82 | |||
| 83 | export const layer = Layer.mergeAll( | ||
| 84 | HttpRouter.add( | ||
| 85 | "GET", | ||
| 86 | "/api/auth/oidc/config", | ||
| 87 | Effect.gen(function* () { | ||
| 88 | return HttpServerResponse.jsonUnsafe( | ||
| 89 | { enabled: (yield* HostedAuth.HostedAuth).enabled }, | ||
| 90 | { headers }, | ||
| 91 | ); | ||
| 92 | }), | ||
| 93 | ), | ||
| 94 | HttpRouter.add( | ||
| 95 | "POST", | ||
| 96 | "/api/auth/devices/start", | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const input = yield* HttpServerRequest.schemaBodyJson(HostedAuthDeviceStart); | ||
| 99 | const result = yield* (yield* HostedAuth.HostedAuth).startDevice(input); | ||
| 100 | return yield* HttpServerResponse.schemaJson(HostedAuthDeviceStartResult)(result, { headers }); | ||
| 101 | }).pipe(errors), | ||
| 102 | ), | ||
| 103 | HttpRouter.add( | ||
| 104 | "GET", | ||
| 105 | "/api/auth/oidc/start", | ||
| 106 | Effect.gen(function* () { | ||
| 107 | const params = yield* query; | ||
| 108 | const result = yield* (yield* HostedAuth.HostedAuth).begin({ | ||
| 109 | ...(params.has("request") ? { request: params.get("request") ?? "" } : {}), | ||
| 110 | ...(params.has("returnTo") ? { returnTo: params.get("returnTo") ?? "" } : {}), | ||
| 111 | }); | ||
| 112 | return HttpServerResponse.redirect(result.url, { status: 303, headers }).pipe( | ||
| 113 | HttpServerResponse.setCookieUnsafe(HOSTED_TRANSACTION_COOKIE, result.browserCookie, { | ||
| 114 | ...cookieOptions, | ||
| 115 | maxAge: "5 minutes", | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | }).pipe(errors), | ||
| 119 | ), | ||
| 120 | HttpRouter.add( | ||
| 121 | "GET", | ||
| 122 | "/api/auth/oidc/callback", | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const params = yield* query; | ||
| 125 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 126 | const result = yield* (yield* HostedAuth.HostedAuth).callback({ | ||
| 127 | state: params.get("state") ?? "", | ||
| 128 | code: params.get("code") ?? "", | ||
| 129 | browserCookie: request.cookies[HOSTED_TRANSACTION_COOKIE] ?? "", | ||
| 130 | client: deriveAuthClientMetadata({ request }), | ||
| 131 | }); | ||
| 132 | let response = HttpServerResponse.redirect(result.url, { status: 303, headers }).pipe( | ||
| 133 | HttpServerResponse.expireCookieUnsafe(HOSTED_TRANSACTION_COOKIE, cookieOptions), | ||
| 134 | ); | ||
| 135 | if (result.session) | ||
| 136 | response = HttpServerResponse.setCookieUnsafe( | ||
| 137 | response, | ||
| 138 | HOSTED_SESSION_COOKIE, | ||
| 139 | result.session.token, | ||
| 140 | { ...cookieOptions, expires: DateTime.toDate(result.session.expiresAt) }, | ||
| 141 | ); | ||
| 142 | return response; | ||
| 143 | }).pipe( | ||
| 144 | Effect.catchIf(isHostedAuthError, (error) => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | const config = yield* HostedAuthConfig; | ||
| 147 | if (Option.isNone(config)) return yield* error; | ||
| 148 | return HttpServerResponse.redirect( | ||
| 149 | new URL("/pair?signInError=1", config.value.publicUrl).href, | ||
| 150 | { status: 303, headers }, | ||
| 151 | ).pipe(HttpServerResponse.expireCookieUnsafe(HOSTED_TRANSACTION_COOKIE, cookieOptions)); | ||
| 152 | }), | ||
| 153 | ), | ||
| 154 | errors, | ||
| 155 | ), | ||
| 156 | ), | ||
| 157 | HttpRouter.add( | ||
| 158 | "POST", | ||
| 159 | "/api/auth/devices/exchange", | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const input = yield* HttpServerRequest.schemaBodyJson(HostedAuthDeviceExchange); | ||
| 162 | const result = yield* (yield* HostedAuth.HostedAuth).exchange(input); | ||
| 163 | return yield* HttpServerResponse.schemaJson(Schema.toCodecJson(HostedAuthGrant))(result, { | ||
| 164 | headers, | ||
| 165 | }); | ||
| 166 | }).pipe(errors), | ||
| 167 | ), | ||
| 168 | HttpRouter.add( | ||
| 169 | "POST", | ||
| 170 | "/api/auth/devices/refresh", | ||
| 171 | Effect.gen(function* () { | ||
| 172 | const input = yield* HttpServerRequest.schemaBodyJson(HostedAuthDeviceRefresh); | ||
| 173 | const result = yield* (yield* HostedAuth.HostedAuth).refresh(input.refreshToken); | ||
| 174 | return yield* HttpServerResponse.schemaJson(Schema.toCodecJson(HostedAuthGrant))(result, { | ||
| 175 | headers, | ||
| 176 | }); | ||
| 177 | }).pipe(errors), | ||
| 178 | ), | ||
| 179 | HttpRouter.add( | ||
| 180 | "POST", | ||
| 181 | "/api/auth/session/refresh", | ||
| 182 | Effect.gen(function* () { | ||
| 183 | const current = yield* browser; | ||
| 184 | const auth = yield* HostedAuth.HostedAuth; | ||
| 185 | const session = yield* auth.renewBrowser(current); | ||
| 186 | const account = yield* auth.account({ | ||
| 187 | ...current, | ||
| 188 | sessionId: session.sessionId, | ||
| 189 | expiresAt: session.expiresAt, | ||
| 190 | }); | ||
| 191 | return (yield* HttpServerResponse.schemaJson(Schema.toCodecJson(HostedAuthAccount))(account, { | ||
| 192 | headers, | ||
| 193 | })).pipe( | ||
| 194 | HttpServerResponse.setCookieUnsafe(HOSTED_SESSION_COOKIE, session.token, { | ||
| 195 | ...cookieOptions, | ||
| 196 | expires: DateTime.toDate(session.expiresAt), | ||
| 197 | }), | ||
| 198 | ); | ||
| 199 | }).pipe(errors), | ||
| 200 | ), | ||
| 201 | HttpRouter.add( | ||
| 202 | "GET", | ||
| 203 | "/api/auth/account", | ||
| 204 | Effect.gen(function* () { | ||
| 205 | const account = yield* (yield* HostedAuth.HostedAuth).account(yield* authenticate); | ||
| 206 | return yield* HttpServerResponse.schemaJson(Schema.toCodecJson(HostedAuthAccount))(account, { | ||
| 207 | headers, | ||
| 208 | }); | ||
| 209 | }).pipe(errors), | ||
| 210 | ), | ||
| 211 | HttpRouter.add( | ||
| 212 | "GET", | ||
| 213 | "/api/auth/devices", | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const devices = yield* (yield* HostedAuth.HostedAuth).devices(yield* authenticate); | ||
| 216 | return yield* HttpServerResponse.schemaJson( | ||
| 217 | Schema.toCodecJson(Schema.Array(HostedAuthDevice)), | ||
| 218 | )(devices, { headers }); | ||
| 219 | }).pipe(errors), | ||
| 220 | ), | ||
| 221 | HttpRouter.add( | ||
| 222 | "POST", | ||
| 223 | "/api/auth/devices/:deviceId/revoke", | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const params = yield* HttpRouter.params; | ||
| 226 | yield* (yield* HostedAuth.HostedAuth).revoke(yield* authenticate, params.deviceId ?? ""); | ||
| 227 | return HttpServerResponse.empty({ headers }); | ||
| 228 | }).pipe(errors), | ||
| 229 | ), | ||
| 230 | HttpRouter.add( | ||
| 231 | "POST", | ||
| 232 | "/api/auth/devices/token", | ||
| 233 | Effect.gen(function* () { | ||
| 234 | const input = yield* HttpServerRequest.schemaBodyJson(HostedAuthTokenRequest); | ||
| 235 | const issued = yield* (yield* HostedAuth.HostedAuth).token(yield* authenticate, input.scopes); | ||
| 236 | return yield* HttpServerResponse.schemaJson( | ||
| 237 | Schema.toCodecJson( | ||
| 238 | Schema.Struct({ accessToken: Schema.String, expiresAt: Schema.DateTimeUtc }), | ||
| 239 | ), | ||
| 240 | )({ accessToken: issued.token, expiresAt: DateTime.toUtc(issued.expiresAt) }, { headers }); | ||
| 241 | }).pipe(errors), | ||
| 242 | ), | ||
| 243 | HttpRouter.add( | ||
| 244 | "POST", | ||
| 245 | "/api/auth/logout", | ||
| 246 | Effect.gen(function* () { | ||
| 247 | const session = yield* authenticate; | ||
| 248 | if (!session.deviceId) return yield* new HostedAuthError({ reason: "forbidden" }); | ||
| 249 | yield* (yield* HostedAuth.HostedAuth).revoke(session, session.deviceId); | ||
| 250 | return HttpServerResponse.empty({ headers }).pipe( | ||
| 251 | HttpServerResponse.expireCookieUnsafe(HOSTED_SESSION_COOKIE, cookieOptions), | ||
| 252 | ); | ||
| 253 | }).pipe(errors), | ||
| 254 | ), | ||
| 255 | ); | ||
apps/server/src/auth/SessionStore.test.ts+102-1| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; | 2 | import { |
| 3 | AuthAdministrativeScopes, | ||
| 4 | AuthStandardClientScopes, | ||
| 5 | EnvironmentId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 3 | import { expect, it } from "@effect/vitest"; | 7 | import { expect, it } from "@effect/vitest"; |
| 4 | import * as Duration from "effect/Duration"; | 8 | import * as Duration from "effect/Duration"; |
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; | 11 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as Deferred from "effect/Deferred"; | 12 | import * as Deferred from "effect/Deferred"; |
| ... | @@ -853,4 +858,100 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | ... | @@ -853,4 +858,100 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 853 | expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); | 858 | expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); |
| 854 | }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))), | 859 | }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))), |
| 855 | ); | 860 | ); |
| 861 | it.effect("binds delegated credentials and active sockets to the machine grant", () => | ||
| 862 | Effect.gen(function* () { | ||
| 863 | const sessions = yield* SessionStore.SessionStore; | ||
| 864 | const parent = yield* sessions.issue({ | ||
| 865 | scopes: AuthAdministrativeScopes, | ||
| 866 | method: "bearer-access-token", | ||
| 867 | }); | ||
| 868 | const child = yield* sessions.issue({ | ||
| 869 | parentSessionId: parent.sessionId, | ||
| 870 | scopes: ["orchestration:read"], | ||
| 871 | method: "bearer-access-token", | ||
| 872 | }); | ||
| 873 | const verified = yield* sessions.verify(child.token); | ||
| 874 | expect(verified.parentSessionId).toBe(parent.sessionId); | ||
| 875 | const ticket = yield* sessions.issueWebSocketToken(child.sessionId); | ||
| 876 | expect((yield* sessions.verifyWebSocketToken(ticket.token)).parentSessionId).toBe( | ||
| 877 | parent.sessionId, | ||
| 878 | ); | ||
| 879 | const invalidation = yield* sessions | ||
| 880 | .awaitInvalidation(child.sessionId) | ||
| 881 | .pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 882 | yield* sessions.revoke(parent.sessionId); | ||
| 883 | yield* Fiber.join(invalidation); | ||
| 884 | expect((yield* sessions.verify(child.token).pipe(Effect.flip))._tag).toBe( | ||
| 885 | "SessionTokenRevokedError", | ||
| 886 | ); | ||
| 887 | expect((yield* sessions.verifyWebSocketToken(ticket.token).pipe(Effect.flip))._tag).toBe( | ||
| 888 | "WebSocketSessionRevokedError", | ||
| 889 | ); | ||
| 890 | expect(yield* sessions.listActive()).toEqual([]); | ||
| 891 | yield* sessions.awaitInvalidation(child.sessionId); | ||
| 892 | }).pipe(Effect.scoped, Effect.provide(layerSessionStore())), | ||
| 893 | ); | ||
| 894 | |||
| 895 | it.effect("caps delegated session expiry and rejects nested or expanded grants", () => | ||
| 896 | Effect.gen(function* () { | ||
| 897 | const sessions = yield* SessionStore.SessionStore; | ||
| 898 | const parent = yield* sessions.issue({ | ||
| 899 | scopes: ["access:write", "orchestration:read"], | ||
| 900 | ttl: Duration.seconds(1), | ||
| 901 | }); | ||
| 902 | const child = yield* sessions.issue({ | ||
| 903 | parentSessionId: parent.sessionId, | ||
| 904 | scopes: ["orchestration:read"], | ||
| 905 | ttl: Duration.hours(1), | ||
| 906 | }); | ||
| 907 | expect(child.expiresAt.epochMilliseconds).toBe(parent.expiresAt.epochMilliseconds); | ||
| 908 | expect( | ||
| 909 | (yield* sessions | ||
| 910 | .issue({ parentSessionId: child.sessionId, scopes: ["orchestration:read"] }) | ||
| 911 | .pipe(Effect.flip))._tag, | ||
| 912 | ).toBe("SessionCredentialIssueError"); | ||
| 913 | expect( | ||
| 914 | (yield* sessions | ||
| 915 | .issue({ parentSessionId: parent.sessionId, scopes: ["orchestration:operate"] }) | ||
| 916 | .pipe(Effect.flip))._tag, | ||
| 917 | ).toBe("SessionCredentialIssueError"); | ||
| 918 | const invalidation = yield* sessions | ||
| 919 | .awaitInvalidation(child.sessionId) | ||
| 920 | .pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 921 | yield* TestClock.adjust(Duration.seconds(1)); | ||
| 922 | yield* Fiber.join(invalidation); | ||
| 923 | expect((yield* sessions.verify(child.token).pipe(Effect.flip))._tag).toBe( | ||
| 924 | "SessionTokenExpiredError", | ||
| 925 | ); | ||
| 926 | expect(yield* sessions.listActive()).toEqual([]); | ||
| 927 | }).pipe(Effect.scoped, Effect.provide(layerSessionStore())), | ||
| 928 | ); | ||
| 929 | it.effect("closes a delegated socket when the CLI revokes its parent in the database", () => | ||
| 930 | Effect.gen(function* () { | ||
| 931 | const sessions = yield* SessionStore.SessionStore; | ||
| 932 | const repository = yield* AuthSessions.AuthSessionRepository; | ||
| 933 | const parent = yield* sessions.issue({ scopes: AuthAdministrativeScopes }); | ||
| 934 | const child = yield* sessions.issue({ | ||
| 935 | parentSessionId: parent.sessionId, | ||
| 936 | scopes: ["orchestration:read"], | ||
| 937 | }); | ||
| 938 | const invalidated = yield* Deferred.make<void>(); | ||
| 939 | yield* sessions | ||
| 940 | .awaitInvalidation(child.sessionId) | ||
| 941 | .pipe( | ||
| 942 | Effect.andThen(Deferred.succeed(invalidated, undefined)), | ||
| 943 | Effect.forkScoped({ startImmediately: true }), | ||
| 944 | ); | ||
| 945 | expect(yield* Deferred.isDone(invalidated)).toBe(false); | ||
| 946 | yield* repository.revoke({ sessionId: parent.sessionId, revokedAt: yield* DateTime.now }); | ||
| 947 | yield* TestClock.adjust(Duration.seconds(5)); | ||
| 948 | yield* Deferred.await(invalidated); | ||
| 949 | expect((yield* sessions.verify(child.token).pipe(Effect.flip))._tag).toBe( | ||
| 950 | "SessionTokenRevokedError", | ||
| 951 | ); | ||
| 952 | }).pipe( | ||
| 953 | Effect.scoped, | ||
| 954 | Effect.provide(layerSessionStore().pipe(Layer.provideMerge(SqlitePersistence.layerMemory))), | ||
| 955 | ), | ||
| 956 | ); | ||
| 856 | }); | 957 | }); |
apps/server/src/auth/SessionStore.ts+152-23| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthSessionId, | 2 | AuthSessionId, |
| 3 | AuthAdministrativeScopes, | 3 | AuthAdministrativeScopes, |
| 4 | AuthAccessWriteScope, | ||
| 4 | AuthStandardClientScopes, | 5 | AuthStandardClientScopes, |
| 5 | AuthEnvironmentScopes, | 6 | AuthEnvironmentScopes, |
| 6 | type AuthClientMetadata, | 7 | type AuthClientMetadata, |
| ... | @@ -51,6 +52,8 @@ export interface IssuedSession { | ... | @@ -51,6 +52,8 @@ export interface IssuedSession { |
| 51 | } | 52 | } |
| 52 | 53 | ||
| 53 | export interface VerifiedSession { | 54 | export interface VerifiedSession { |
| 55 | readonly parentSessionId?: AuthSessionId; | ||
| 56 | readonly deviceId?: string; | ||
| 54 | readonly sessionId: AuthSessionId; | 57 | readonly sessionId: AuthSessionId; |
| 55 | readonly token: string; | 58 | readonly token: string; |
| 56 | readonly method: ServerAuthSessionMethod; | 59 | readonly method: ServerAuthSessionMethod; |
| ... | @@ -371,6 +374,8 @@ export class SessionStore extends Context.Service< | ... | @@ -371,6 +374,8 @@ export class SessionStore extends Context.Service< |
| 371 | readonly cookieName: string; | 374 | readonly cookieName: string; |
| 372 | readonly legacyCookieName: string | undefined; | 375 | readonly legacyCookieName: string | undefined; |
| 373 | readonly issue: (input?: { | 376 | readonly issue: (input?: { |
| 377 | readonly parentSessionId?: AuthSessionId; | ||
| 378 | readonly deviceId?: string; | ||
| 374 | readonly ttl?: Duration.Duration; | 379 | readonly ttl?: Duration.Duration; |
| 375 | readonly subject?: string; | 380 | readonly subject?: string; |
| 376 | readonly method?: ServerAuthSessionMethod; | 381 | readonly method?: ServerAuthSessionMethod; |
| ... | @@ -387,6 +392,9 @@ export class SessionStore extends Context.Service< | ... | @@ -387,6 +392,9 @@ export class SessionStore extends Context.Service< |
| 387 | readonly replaceSessionId?: AuthSessionId; | 392 | readonly replaceSessionId?: AuthSessionId; |
| 388 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; | 393 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; |
| 389 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | 394 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 395 | readonly verifyActive: ( | ||
| 396 | sessionId: AuthSessionId, | ||
| 397 | ) => Effect.Effect<void, SessionCredentialError>; | ||
| 390 | readonly issueWebSocketToken: ( | 398 | readonly issueWebSocketToken: ( |
| 391 | sessionId: AuthSessionId, | 399 | sessionId: AuthSessionId, |
| 392 | input?: { | 400 | input?: { |
| ... | @@ -546,6 +554,56 @@ export const make = Effect.gen(function* () { | ... | @@ -546,6 +554,56 @@ export const make = Effect.gen(function* () { |
| 546 | sessionId, | 554 | sessionId, |
| 547 | }).pipe(Effect.asVoid); | 555 | }).pipe(Effect.asVoid); |
| 548 | 556 | ||
| 557 | const verifyDelegationParent = Effect.fn("SessionStore.verifyDelegationParent")(function* ( | ||
| 558 | parentSessionId: AuthSessionId, | ||
| 559 | observedAt: DateTime.Utc, | ||
| 560 | ) { | ||
| 561 | const parent = yield* authSessions | ||
| 562 | .getById({ sessionId: parentSessionId }) | ||
| 563 | .pipe( | ||
| 564 | Effect.mapError( | ||
| 565 | (cause) => new SessionCredentialVerificationError({ sessionId: parentSessionId, cause }), | ||
| 566 | ), | ||
| 567 | ); | ||
| 568 | if (Option.isNone(parent) || parent.value.parentSessionId !== undefined) { | ||
| 569 | return yield* new UnknownSessionTokenError({ sessionId: parentSessionId }); | ||
| 570 | } | ||
| 571 | if (parent.value.revokedAt !== null) { | ||
| 572 | return yield* new SessionTokenRevokedError({ | ||
| 573 | sessionId: parentSessionId, | ||
| 574 | revokedAt: parent.value.revokedAt, | ||
| 575 | }); | ||
| 576 | } | ||
| 577 | if (parent.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { | ||
| 578 | return yield* new SessionTokenExpiredError({ | ||
| 579 | sessionId: parentSessionId, | ||
| 580 | expiresAt: parent.value.expiresAt, | ||
| 581 | observedAt, | ||
| 582 | }); | ||
| 583 | } | ||
| 584 | return parent.value; | ||
| 585 | }); | ||
| 586 | |||
| 587 | const verifyActive = Effect.fn("SessionStore.verifyActive")(function* (sessionId: AuthSessionId) { | ||
| 588 | const observedAt = yield* DateTime.now; | ||
| 589 | const row = yield* authSessions | ||
| 590 | .getById({ sessionId }) | ||
| 591 | .pipe( | ||
| 592 | Effect.mapError((cause) => new SessionCredentialVerificationError({ sessionId, cause })), | ||
| 593 | ); | ||
| 594 | if (Option.isNone(row)) return yield* new UnknownSessionTokenError({ sessionId }); | ||
| 595 | if (row.value.revokedAt !== null) | ||
| 596 | return yield* new SessionTokenRevokedError({ sessionId, revokedAt: row.value.revokedAt }); | ||
| 597 | if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) | ||
| 598 | return yield* new SessionTokenExpiredError({ | ||
| 599 | sessionId, | ||
| 600 | expiresAt: row.value.expiresAt, | ||
| 601 | observedAt, | ||
| 602 | }); | ||
| 603 | if (row.value.parentSessionId !== undefined) | ||
| 604 | yield* verifyDelegationParent(row.value.parentSessionId, observedAt); | ||
| 605 | }); | ||
| 606 | |||
| 549 | const loadActiveSession = (sessionId: AuthSessionId) => | 607 | const loadActiveSession = (sessionId: AuthSessionId) => |
| 550 | Effect.gen(function* () { | 608 | Effect.gen(function* () { |
| 551 | const row = yield* authSessions.getById({ sessionId }); | 609 | const row = yield* authSessions.getById({ sessionId }); |
| ... | @@ -559,6 +617,12 @@ export const make = Effect.gen(function* () { | ... | @@ -559,6 +617,12 @@ export const make = Effect.gen(function* () { |
| 559 | if (!connected && row.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) { | 617 | if (!connected && row.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) { |
| 560 | return Option.none<AuthClientSession>(); | 618 | return Option.none<AuthClientSession>(); |
| 561 | } | 619 | } |
| 620 | if (row.value.parentSessionId !== undefined) { | ||
| 621 | const parent = yield* verifyDelegationParent(row.value.parentSessionId, now).pipe( | ||
| 622 | Effect.catchIf(isSessionCredentialInvalidError, () => Effect.succeed(undefined)), | ||
| 623 | ); | ||
| 624 | if (parent === undefined) return Option.none<AuthClientSession>(); | ||
| 625 | } | ||
| 562 | return Option.some( | 626 | return Option.some( |
| 563 | toAuthClientSession({ | 627 | toAuthClientSession({ |
| 564 | sessionId: row.value.sessionId, | 628 | sessionId: row.value.sessionId, |
| ... | @@ -665,9 +729,27 @@ export const make = Effect.gen(function* () { | ... | @@ -665,9 +729,27 @@ export const make = Effect.gen(function* () { |
| 665 | .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ cause }))), | 729 | .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ cause }))), |
| 666 | ); | 730 | ); |
| 667 | const issuedAt = yield* DateTime.now; | 731 | const issuedAt = yield* DateTime.now; |
| 668 | const expiresAt = DateTime.add(issuedAt, { | 732 | const requestedExpiresAt = DateTime.add(issuedAt, { |
| 669 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), | 733 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), |
| 670 | }); | 734 | }); |
| 735 | const parent = | ||
| 736 | input?.parentSessionId === undefined | ||
| 737 | ? undefined | ||
| 738 | : yield* verifyDelegationParent(input.parentSessionId, issuedAt).pipe( | ||
| 739 | Effect.flatMap((parent) => | ||
| 740 | parent.scopes.includes(AuthAccessWriteScope) && | ||
| 741 | (input.scopes ?? AuthStandardClientScopes).every((scope) => | ||
| 742 | parent.scopes.includes(scope), | ||
| 743 | ) | ||
| 744 | ? Effect.succeed(parent) | ||
| 745 | : Effect.fail(new UnknownSessionTokenError({ sessionId: parent.sessionId })), | ||
| 746 | ), | ||
| 747 | Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })), | ||
| 748 | ); | ||
| 749 | const expiresAt = | ||
| 750 | parent && parent.expiresAt.epochMilliseconds < requestedExpiresAt.epochMilliseconds | ||
| 751 | ? parent.expiresAt | ||
| 752 | : requestedExpiresAt; | ||
| 671 | const claims: SessionClaims = { | 753 | const claims: SessionClaims = { |
| 672 | v: 1, | 754 | v: 1, |
| 673 | kind: "session", | 755 | kind: "session", |
| ... | @@ -697,8 +779,11 @@ export const make = Effect.gen(function* () { | ... | @@ -697,8 +779,11 @@ export const make = Effect.gen(function* () { |
| 697 | ); | 779 | ); |
| 698 | const signature = signPayload(encodedPayload, signingSecret); | 780 | const signature = signPayload(encodedPayload, signingSecret); |
| 699 | const client = input?.client ?? createDefaultClientMetadata(); | 781 | const client = input?.client ?? createDefaultClientMetadata(); |
| 782 | const deviceId = input?.deviceId ?? parent?.deviceId; | ||
| 700 | const sessionRecord = { | 783 | const sessionRecord = { |
| 701 | sessionId, | 784 | sessionId, |
| 785 | ...(input?.parentSessionId === undefined ? {} : { parentSessionId: input.parentSessionId }), | ||
| 786 | ...(deviceId === undefined ? {} : { deviceId }), | ||
| 702 | subject: claims.sub, | 787 | subject: claims.sub, |
| 703 | scopes: claims.scopes, | 788 | scopes: claims.scopes, |
| 704 | method: claims.method, | 789 | method: claims.method, |
| ... | @@ -848,8 +933,15 @@ export const make = Effect.gen(function* () { | ... | @@ -848,8 +933,15 @@ export const make = Effect.gen(function* () { |
| 848 | }); | 933 | }); |
| 849 | } | 934 | } |
| 850 | 935 | ||
| 936 | if (row.value.parentSessionId !== undefined) { | ||
| 937 | yield* verifyDelegationParent(row.value.parentSessionId, observedAt); | ||
| 938 | } | ||
| 851 | return { | 939 | return { |
| 852 | sessionId: claims.sid, | 940 | sessionId: claims.sid, |
| 941 | ...(row.value.parentSessionId === undefined | ||
| 942 | ? {} | ||
| 943 | : { parentSessionId: row.value.parentSessionId }), | ||
| 944 | ...(row.value.deviceId === undefined ? {} : { deviceId: row.value.deviceId }), | ||
| 853 | token, | 945 | token, |
| 854 | method: claims.method, | 946 | method: claims.method, |
| 855 | client: toClientMetadata(row.value.client), | 947 | client: toClientMetadata(row.value.client), |
| ... | @@ -958,8 +1050,15 @@ export const make = Effect.gen(function* () { | ... | @@ -958,8 +1050,15 @@ export const make = Effect.gen(function* () { |
| 958 | }); | 1050 | }); |
| 959 | } | 1051 | } |
| 960 | 1052 | ||
| 1053 | if (row.value.parentSessionId !== undefined) { | ||
| 1054 | yield* verifyDelegationParent(row.value.parentSessionId, observedAt); | ||
| 1055 | } | ||
| 961 | return { | 1056 | return { |
| 962 | sessionId: row.value.sessionId, | 1057 | sessionId: row.value.sessionId, |
| 1058 | ...(row.value.parentSessionId === undefined | ||
| 1059 | ? {} | ||
| 1060 | : { parentSessionId: row.value.parentSessionId }), | ||
| 1061 | ...(row.value.deviceId === undefined ? {} : { deviceId: row.value.deviceId }), | ||
| 963 | token, | 1062 | token, |
| 964 | method: row.value.method, | 1063 | method: row.value.method, |
| 965 | client: toClientMetadata(row.value.client), | 1064 | client: toClientMetadata(row.value.client), |
| ... | @@ -998,21 +1097,24 @@ export const make = Effect.gen(function* () { | ... | @@ -998,21 +1097,24 @@ export const make = Effect.gen(function* () { |
| 998 | const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( | 1097 | const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( |
| 999 | function* (sessionId) { | 1098 | function* (sessionId) { |
| 1000 | const revokedAt = yield* DateTime.now; | 1099 | const revokedAt = yield* DateTime.now; |
| 1001 | const revoked = yield* authSessions | 1100 | const revokedSessionIds = yield* authSessions |
| 1002 | .revoke({ | 1101 | .revoke({ |
| 1003 | sessionId, | 1102 | sessionId, |
| 1004 | revokedAt, | 1103 | revokedAt, |
| 1005 | }) | 1104 | }) |
| 1006 | .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); | 1105 | .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); |
| 1007 | if (revoked) { | 1106 | if (revokedSessionIds.length > 0) { |
| 1008 | yield* Ref.update(connectedSessionsRef, (current) => { | 1107 | yield* Ref.update(connectedSessionsRef, (current) => { |
| 1009 | const next = new Map(current); | 1108 | const next = new Map(current); |
| 1010 | next.delete(sessionId); | 1109 | for (const revokedSessionId of revokedSessionIds) next.delete(revokedSessionId); |
| 1011 | return next; | 1110 | return next; |
| 1012 | }); | 1111 | }); |
| 1013 | yield* emitRemoved(sessionId); | 1112 | yield* Effect.forEach(revokedSessionIds, emitRemoved, { |
| 1113 | concurrency: "unbounded", | ||
| 1114 | discard: true, | ||
| 1115 | }); | ||
| 1014 | } | 1116 | } |
| 1015 | return revoked; | 1117 | return revokedSessionIds.length > 0; |
| 1016 | }, | 1118 | }, |
| 1017 | ); | 1119 | ); |
| 1018 | 1120 | ||
| ... | @@ -1055,26 +1157,52 @@ export const make = Effect.gen(function* () { | ... | @@ -1055,26 +1157,52 @@ export const make = Effect.gen(function* () { |
| 1055 | Effect.gen(function* () { | 1157 | Effect.gen(function* () { |
| 1056 | // Subscribe before reading: revocation can race with the WebSocket upgrade. | 1158 | // Subscribe before reading: revocation can race with the WebSocket upgrade. |
| 1057 | const subscription = yield* PubSub.subscribe(changesPubSub); | 1159 | const subscription = yield* PubSub.subscribe(changesPubSub); |
| 1058 | const row = yield* authSessions | 1160 | while (true) { |
| 1059 | .getById({ sessionId }) | 1161 | const row = yield* authSessions |
| 1060 | .pipe( | 1162 | .getById({ sessionId }) |
| 1061 | Effect.mapError( | 1163 | .pipe( |
| 1062 | (cause) => new SessionCredentialVerificationError({ sessionId, cause }), | 1164 | Effect.mapError( |
| 1063 | ), | 1165 | (cause) => new SessionCredentialVerificationError({ sessionId, cause }), |
| 1064 | ); | 1166 | ), |
| 1065 | if (Option.isNone(row) || row.value.revokedAt !== null) return; | 1167 | ); |
| 1066 | const now = yield* DateTime.now; | 1168 | if (Option.isNone(row) || row.value.revokedAt !== null) return; |
| 1067 | const remaining = row.value.expiresAt.epochMilliseconds - now.epochMilliseconds; | 1169 | const now = yield* DateTime.now; |
| 1068 | if (remaining <= 0) return; | 1170 | const parent = |
| 1069 | yield* Effect.raceFirst( | 1171 | row.value.parentSessionId === undefined |
| 1070 | Stream.fromSubscription(subscription).pipe( | 1172 | ? undefined |
| 1173 | : yield* authSessions | ||
| 1174 | .getById({ sessionId: row.value.parentSessionId }) | ||
| 1175 | .pipe( | ||
| 1176 | Effect.mapError( | ||
| 1177 | (cause) => new SessionCredentialVerificationError({ sessionId, cause }), | ||
| 1178 | ), | ||
| 1179 | ); | ||
| 1180 | if (parent !== undefined && (Option.isNone(parent) || parent.value.revokedAt !== null)) | ||
| 1181 | return; | ||
| 1182 | const expiresAt = | ||
| 1183 | parent !== undefined && Option.isSome(parent) | ||
| 1184 | ? Math.min( | ||
| 1185 | row.value.expiresAt.epochMilliseconds, | ||
| 1186 | parent.value.expiresAt.epochMilliseconds, | ||
| 1187 | ) | ||
| 1188 | : row.value.expiresAt.epochMilliseconds; | ||
| 1189 | const remaining = expiresAt - now.epochMilliseconds; | ||
| 1190 | if (remaining <= 0) return; | ||
| 1191 | const removed = Stream.fromSubscription(subscription).pipe( | ||
| 1071 | Stream.filter( | 1192 | Stream.filter( |
| 1072 | (change) => change.type === "clientRemoved" && change.sessionId === sessionId, | 1193 | (change) => |
| 1194 | change.type === "clientRemoved" && | ||
| 1195 | (change.sessionId === sessionId || change.sessionId === row.value.parentSessionId), | ||
| 1073 | ), | 1196 | ), |
| 1074 | Stream.runHead, | 1197 | Stream.runHead, |
| 1075 | ), | 1198 | Effect.as(true), |
| 1076 | Effect.sleep(Duration.millis(remaining)), | 1199 | ); |
| 1077 | ); | 1200 | // CLI revocation writes the same database from another process. |
| 1201 | const recheck = Effect.sleep(Duration.millis(Math.min(remaining, 5_000))).pipe( | ||
| 1202 | Effect.as(false), | ||
| 1203 | ); | ||
| 1204 | if (yield* Effect.raceFirst(removed, recheck)) return; | ||
| 1205 | } | ||
| 1078 | }), | 1206 | }), |
| 1079 | ); | 1207 | ); |
| 1080 | }); | 1208 | }); |
| ... | @@ -1084,6 +1212,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1084,6 +1212,7 @@ export const make = Effect.gen(function* () { |
| 1084 | legacyCookieName, | 1212 | legacyCookieName, |
| 1085 | issue, | 1213 | issue, |
| 1086 | verify, | 1214 | verify, |
| 1215 | verifyActive, | ||
| 1087 | issueWebSocketToken, | 1216 | issueWebSocketToken, |
| 1088 | verifyWebSocketToken, | 1217 | verifyWebSocketToken, |
| 1089 | listActive, | 1218 | listActive, |
apps/server/src/auth/http.test.ts+48-7| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | AuthTokenExchangeGrantType, | 4 | AuthTokenExchangeGrantType, |
| 5 | AuthEnvironmentBootstrapTokenType, | 5 | AuthEnvironmentBootstrapTokenType, |
| 6 | AuthAccessTokenType, | 6 | AuthAccessTokenType, |
| 7 | AuthAccessTokenResult, | ||
| 7 | EnvironmentAuthenticatedAuth, | 8 | EnvironmentAuthenticatedAuth, |
| 8 | EnvironmentHttpApi, | 9 | EnvironmentHttpApi, |
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| ... | @@ -31,6 +32,14 @@ import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | ... | @@ -31,6 +32,14 @@ import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 31 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 32 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 32 | import * as AuthHttp from "./http.ts"; | 33 | import * as AuthHttp from "./http.ts"; |
| 33 | 34 | ||
| 35 | const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({ | ||
| 36 | get: () => Effect.succeedNone, | ||
| 37 | set: () => Effect.void, | ||
| 38 | create: () => Effect.void, | ||
| 39 | getOrCreateRandom: () => Effect.die("Not used by these routes."), | ||
| 40 | remove: () => Effect.void, | ||
| 41 | }); | ||
| 42 | |||
| 34 | const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough"; | 43 | const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough"; |
| 35 | class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} | 44 | class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} |
| 36 | 45 | ||
| ... | @@ -68,6 +77,7 @@ const layerRoutes = HttpApiBuilder.layer(AuthTestApi).pipe( | ... | @@ -68,6 +77,7 @@ const layerRoutes = HttpApiBuilder.layer(AuthTestApi).pipe( |
| 68 | ); | 77 | ); |
| 69 | 78 | ||
| 70 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 79 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 80 | const decodeAccessToken = Schema.decodeUnknownSync(AuthAccessTokenResult); | ||
| 71 | const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, string>>) => | 81 | const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, string>>) => |
| 72 | new Request(`http://127.0.0.1${path}`, { | 82 | new Request(`http://127.0.0.1${path}`, { |
| 73 | method: "POST", | 83 | method: "POST", |
| ... | @@ -78,13 +88,6 @@ const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, | ... | @@ -78,13 +88,6 @@ const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, |
| 78 | it.effect("sets the selected browser session cookies through the HTTP route", () => | 88 | it.effect("sets the selected browser session cookies through the HTTP route", () => |
| 79 | Effect.gen(function* () { | 89 | Effect.gen(function* () { |
| 80 | const crypto = yield* Crypto.Crypto; | 90 | const crypto = yield* Crypto.Crypto; |
| 81 | const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({ | ||
| 82 | get: () => Effect.succeedNone, | ||
| 83 | set: () => Effect.void, | ||
| 84 | create: () => Effect.void, | ||
| 85 | getOrCreateRandom: () => Effect.die("Not used by these routes."), | ||
| 86 | remove: () => Effect.void, | ||
| 87 | }); | ||
| 88 | const requestContext = Context.make(Crypto.Crypto, crypto).pipe( | 91 | const requestContext = Context.make(Crypto.Crypto, crypto).pipe( |
| 89 | Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore), | 92 | Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore), |
| 90 | ); | 93 | ); |
| ... | @@ -237,3 +240,41 @@ it.effect("exports only verified T3 Connect requests", () => | ... | @@ -237,3 +240,41 @@ it.effect("exports only verified T3 Connect requests", () => |
| 237 | expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]); | 240 | expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]); |
| 238 | }).pipe(Effect.scoped), | 241 | }).pipe(Effect.scoped), |
| 239 | ); | 242 | ); |
| 243 | |||
| 244 | it.effect("delegated credentials cannot mint an independent grant", () => | ||
| 245 | Effect.gen(function* () { | ||
| 246 | const crypto = yield* Crypto.Crypto; | ||
| 247 | const requestContext = Context.make(Crypto.Crypto, crypto).pipe( | ||
| 248 | Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore), | ||
| 249 | ); | ||
| 250 | return yield* Effect.acquireUseRelease( | ||
| 251 | Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })), | ||
| 252 | (server) => | ||
| 253 | Effect.promise(async () => { | ||
| 254 | const response = await server.handler( | ||
| 255 | postJson( | ||
| 256 | "/api/auth/delegated-session", | ||
| 257 | { | ||
| 258 | scopes: ["orchestration:read", "access:write"], | ||
| 259 | }, | ||
| 260 | { authorization: `Bearer ${DEV_TOKEN}` }, | ||
| 261 | ), | ||
| 262 | requestContext, | ||
| 263 | ); | ||
| 264 | expect(response.status).toBe(200); | ||
| 265 | expect(response.headers.get("cache-control")).toContain("no-store"); | ||
| 266 | const issued = decodeAccessToken(await response.json()); | ||
| 267 | const authorization = `Bearer ${issued.access_token}`; | ||
| 268 | for (const path of ["/api/auth/pairing-token", "/api/auth/delegated-session"]) { | ||
| 269 | const rejected = await server.handler( | ||
| 270 | postJson(path, { scopes: ["orchestration:read"] }, { authorization }), | ||
| 271 | requestContext, | ||
| 272 | ); | ||
| 273 | expect(rejected.status).toBe(400); | ||
| 274 | expect(await rejected.json()).toMatchObject({ reason: "scope_not_granted" }); | ||
| 275 | } | ||
| 276 | }), | ||
| 277 | (server) => Effect.promise(() => server.dispose()), | ||
| 278 | ); | ||
| 279 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 280 | ); |
apps/server/src/auth/http.ts+22| ... | @@ -432,6 +432,8 @@ export const layer = HttpApiBuilder.group( | ... | @@ -432,6 +432,8 @@ export const layer = HttpApiBuilder.group( |
| 432 | function* (args) { | 432 | function* (args) { |
| 433 | yield* annotateEnvironmentRequest(args.endpoint.name); | 433 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 434 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); | 434 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); |
| 435 | if (session.parentSessionId !== undefined) | ||
| 436 | return yield* failEnvironmentInvalidRequest("scope_not_granted"); | ||
| 435 | const delegatedScopes = args.payload.scopes ?? AuthStandardClientScopes; | 437 | const delegatedScopes = args.payload.scopes ?? AuthStandardClientScopes; |
| 436 | if ( | 438 | if ( |
| 437 | delegatedScopes.length === 0 || | 439 | delegatedScopes.length === 0 || |
| ... | @@ -451,6 +453,26 @@ export const layer = HttpApiBuilder.group( | ... | @@ -451,6 +453,26 @@ export const layer = HttpApiBuilder.group( |
| 451 | ), | 453 | ), |
| 452 | ), | 454 | ), |
| 453 | ) | 455 | ) |
| 456 | .handle( | ||
| 457 | "delegatedSession", | ||
| 458 | Effect.fn("environment.auth.delegatedSession")( | ||
| 459 | function* (args) { | ||
| 460 | yield* annotateEnvironmentRequest(args.endpoint.name); | ||
| 461 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); | ||
| 462 | yield* appendCredentialResponseHeaders; | ||
| 463 | return yield* serverAuth.issueDelegatedSession( | ||
| 464 | { ...session, scopes: [...session.scopes] }, | ||
| 465 | args.payload, | ||
| 466 | ); | ||
| 467 | }, | ||
| 468 | Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => | ||
| 469 | failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), | ||
| 470 | ), | ||
| 471 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => | ||
| 472 | failEnvironmentInternal("access_token_issuance_failed", error), | ||
| 473 | ), | ||
| 474 | ), | ||
| 475 | ) | ||
| 454 | .handle( | 476 | .handle( |
| 455 | "pairingLinks", | 477 | "pairingLinks", |
| 456 | Effect.fn("environment.auth.pairingLinks")( | 478 | Effect.fn("environment.auth.pairingLinks")( |
apps/server/src/cli/auth.ts+32-1| ... | @@ -8,9 +8,12 @@ import * as Effect from "effect/Effect"; | ... | @@ -8,9 +8,12 @@ import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 10 | import * as References from "effect/References"; | 10 | import * as References from "effect/References"; |
| 11 | import { FetchHttpClient } from "effect/http"; | ||
| 11 | import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; | 12 | import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; |
| 12 | 13 | ||
| 13 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 14 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 15 | import * as HostedAuth from "../auth/HostedAuth.ts"; | ||
| 16 | import * as HostedAuthConfig from "../auth/HostedAuthConfig.ts"; | ||
| 14 | 17 | ||
| 15 | import { | 18 | import { |
| 16 | formatIssuedPairingCredential, | 19 | formatIssuedPairingCredential, |
| ... | @@ -243,7 +246,35 @@ const sessionCommand = Command.make("session").pipe( | ... | @@ -243,7 +246,35 @@ const sessionCommand = Command.make("session").pipe( |
| 243 | Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), | 246 | Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), |
| 244 | ); | 247 | ); |
| 245 | 248 | ||
| 249 | const devicesCommand = Command.make("devices").pipe( | ||
| 250 | Command.withSubcommands([ | ||
| 251 | Command.make("revoke-all", authLocationFlags).pipe( | ||
| 252 | Command.withDescription("Revoke all SSO devices and sessions using local server access."), | ||
| 253 | Command.withHandler((flags) => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel); | ||
| 256 | const count = yield* Effect.gen(function* () { | ||
| 257 | return yield* (yield* HostedAuth.HostedAuth).emergencyRevokeAll; | ||
| 258 | }).pipe( | ||
| 259 | Effect.provide( | ||
| 260 | HostedAuth.layer.pipe( | ||
| 261 | Layer.provide(EnvironmentAuth.layerRuntime), | ||
| 262 | Layer.provide(ServerConfig.layer(config)), | ||
| 263 | Layer.provide(Layer.succeed(HostedAuthConfig.HostedAuthConfig, Option.none())), | ||
| 264 | Layer.provide(FetchHttpClient.layer), | ||
| 265 | ), | ||
| 266 | ), | ||
| 267 | ); | ||
| 268 | yield* Console.log( | ||
| 269 | `Revoked ${count} SSO devices. Existing pairing credentials remain valid.`, | ||
| 270 | ); | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ), | ||
| 274 | ]), | ||
| 275 | ); | ||
| 276 | |||
| 246 | export const authCommand = Command.make("auth").pipe( | 277 | export const authCommand = Command.make("auth").pipe( |
| 247 | Command.withDescription("Manage the local auth control plane for headless deployments."), | 278 | Command.withDescription("Manage the local auth control plane for headless deployments."), |
| 248 | Command.withSubcommands([pairingCommand, sessionCommand]), | 279 | Command.withSubcommands([pairingCommand, sessionCommand, devicesCommand]), |
| 249 | ); | 280 | ); |
apps/server/src/cli/project.ts+9| ... | @@ -29,6 +29,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ... | @@ -29,6 +29,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 29 | import * as ProcessRunner from "../processRunner.ts"; | 29 | import * as ProcessRunner from "../processRunner.ts"; |
| 30 | import * as ServerOwnership from "../serverOwnership.ts"; | 30 | import * as ServerOwnership from "../serverOwnership.ts"; |
| 31 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; | 31 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 32 | import * as ArchiveRuntime from "../archive/runtimeLayer.ts"; | ||
| 33 | import * as ArchiveConnection from "../archive/ArchiveConnection.ts"; | ||
| 34 | import * as SharedServer from "../environment/SharedServer.ts"; | ||
| 32 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | 35 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 33 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 36 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 34 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; | 37 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| ... | @@ -201,6 +204,12 @@ const projectCommandUuid = Crypto.Crypto.pipe( | ... | @@ -201,6 +204,12 @@ const projectCommandUuid = Crypto.Crypto.pipe( |
| 201 | ); | 204 | ); |
| 202 | 205 | ||
| 203 | const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( | 206 | const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( |
| 207 | Layer.provideMerge( | ||
| 208 | ArchiveRuntime.layer.pipe( | ||
| 209 | Layer.provide(ArchiveConnection.layerLegacy.pipe(Layer.provide(SharedServer.layer))), | ||
| 210 | Layer.provide(ServerSecretStore.layer), | ||
| 211 | ), | ||
| 212 | ), | ||
| 204 | Layer.provideMerge(ProjectEnrichmentService.layer), | 213 | Layer.provideMerge(ProjectEnrichmentService.layer), |
| 205 | Layer.provideMerge(RepositoryIdentityResolver.layer), | 214 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 206 | Layer.provideMerge( | 215 | Layer.provideMerge( |
apps/server/src/cli/shared.ts+103-13| ... | @@ -1,5 +1,16 @@ | ... | @@ -1,5 +1,16 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthProvidersManageScope, | 2 | AuthProvidersManageScope, |
| 3 | AuthAccessWriteScope, | ||
| 4 | EnvironmentId, | ||
| 5 | HubDirectory, | ||
| 6 | HubError, | ||
| 7 | type HubManagementRequest, | ||
| 8 | AuthOrchestrationReadScope, | ||
| 9 | AuthOrchestrationOperateScope, | ||
| 10 | ArchiveStatus, | ||
| 11 | ArchiveError, | ||
| 12 | type ArchiveManagementRequest, | ||
| 13 | ThreadId, | ||
| 3 | ProviderInstanceId, | 14 | ProviderInstanceId, |
| 4 | SharedAccountError, | 15 | SharedAccountError, |
| 5 | SharedAccountId, | 16 | SharedAccountId, |
| ... | @@ -19,9 +30,14 @@ import * as ServerConfig from "../config.ts"; | ... | @@ -19,9 +30,14 @@ import * as ServerConfig from "../config.ts"; |
| 19 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; | 30 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 20 | import { authLocationFlags, resolveCliAuthConfig, type CliAuthLocationFlags } from "./config.ts"; | 31 | import { authLocationFlags, resolveCliAuthConfig, type CliAuthLocationFlags } from "./config.ts"; |
| 21 | 32 | ||
| 22 | const encodeStatus = Schema.encodeEffect(Schema.fromJsonString(SharedProfilesStatus)); | 33 | const encodeStatus = Schema.encodeEffect( |
| 34 | Schema.fromJsonString(Schema.Union([SharedProfilesStatus, ArchiveStatus, HubDirectory])), | ||
| 35 | ); | ||
| 23 | 36 | ||
| 24 | const run = (flags: CliAuthLocationFlags, request: SharedProfilesRequest) => | 37 | const run = ( |
| 38 | flags: CliAuthLocationFlags, | ||
| 39 | request: SharedProfilesRequest | ArchiveManagementRequest | HubManagementRequest, | ||
| 40 | ) => | ||
| 25 | Effect.gen(function* () { | 41 | Effect.gen(function* () { |
| 26 | const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel); | 42 | const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel); |
| 27 | const state = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); | 43 | const state = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); |
| ... | @@ -29,19 +45,32 @@ const run = (flags: CliAuthLocationFlags, request: SharedProfilesRequest) => | ... | @@ -29,19 +45,32 @@ const run = (flags: CliAuthLocationFlags, request: SharedProfilesRequest) => |
| 29 | return yield* Effect.gen(function* () { | 45 | return yield* Effect.gen(function* () { |
| 30 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | 46 | const auth = yield* EnvironmentAuth.EnvironmentAuth; |
| 31 | const http = yield* HttpClient.HttpClient; | 47 | const http = yield* HttpClient.HttpClient; |
| 48 | const [endpoint, scopes] = request.type.startsWith("hub.") | ||
| 49 | ? (["/api/hub/manage", [AuthAccessWriteScope]] as const) | ||
| 50 | : request.type.startsWith("archive.") | ||
| 51 | ? ([ | ||
| 52 | "/api/archive/manage", | ||
| 53 | [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], | ||
| 54 | ] as const) | ||
| 55 | : (["/api/shared/profiles", [AuthProvidersManageScope]] as const); | ||
| 32 | const result = yield* Effect.acquireUseRelease( | 56 | const result = yield* Effect.acquireUseRelease( |
| 33 | auth.issueSession({ scopes: [AuthProvidersManageScope], label: "t3 shared cli" }), | 57 | auth.issueSession({ |
| 58 | scopes, | ||
| 59 | label: "t3 shared cli", | ||
| 60 | }), | ||
| 34 | (session) => | 61 | (session) => |
| 35 | HttpClientRequest.post(`${state.value.origin}/api/shared/profiles`).pipe( | 62 | HttpClientRequest.post(`${state.value.origin}${endpoint}`).pipe( |
| 36 | HttpClientRequest.setHeader("authorization", `Bearer ${session.token}`), | 63 | HttpClientRequest.setHeader("authorization", `Bearer ${session.token}`), |
| 37 | HttpClientRequest.bodyJson(request), | 64 | HttpClientRequest.bodyJson(request), |
| 38 | Effect.flatMap(http.execute), | 65 | Effect.flatMap(http.execute), |
| 39 | Effect.flatMap((response) => | 66 | Effect.flatMap((response) => |
| 40 | response.status === 200 | 67 | response.status === 200 |
| 41 | ? HttpClientResponse.schemaBodyJson(SharedProfilesStatus)(response) | 68 | ? HttpClientResponse.schemaBodyJson( |
| 42 | : HttpClientResponse.schemaBodyJson(SharedAccountError)(response).pipe( | 69 | Schema.Union([SharedProfilesStatus, ArchiveStatus, HubDirectory]), |
| 43 | Effect.flatMap(Effect.fail), | 70 | )(response) |
| 44 | ), | 71 | : HttpClientResponse.schemaBodyJson( |
| 72 | Schema.Union([SharedAccountError, ArchiveError, HubError]), | ||
| 73 | )(response).pipe(Effect.flatMap(Effect.fail)), | ||
| 45 | ), | 74 | ), |
| 46 | Effect.timeout("30 seconds"), | 75 | Effect.timeout("30 seconds"), |
| 47 | ), | 76 | ), |
| ... | @@ -51,15 +80,35 @@ const run = (flags: CliAuthLocationFlags, request: SharedProfilesRequest) => | ... | @@ -51,15 +80,35 @@ const run = (flags: CliAuthLocationFlags, request: SharedProfilesRequest) => |
| 51 | }).pipe( | 80 | }).pipe( |
| 52 | Effect.scoped, | 81 | Effect.scoped, |
| 53 | Effect.provide( | 82 | Effect.provide( |
| 54 | EnvironmentAuth.layerRuntime.pipe( | 83 | Layer.mergeAll( |
| 55 | Layer.provide(ServerConfig.layer(config)), | 84 | EnvironmentAuth.layerRuntime.pipe( |
| 56 | Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), | 85 | Layer.provide(ServerConfig.layer(config)), |
| 86 | Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), | ||
| 87 | ), | ||
| 88 | FetchHttpClient.layer, | ||
| 57 | ), | 89 | ), |
| 58 | ), | 90 | ), |
| 59 | Effect.provide(FetchHttpClient.layer), | ||
| 60 | ); | 91 | ); |
| 61 | }); | 92 | }); |
| 62 | 93 | ||
| 94 | const archiveStatus = Command.make("status", authLocationFlags).pipe( | ||
| 95 | Command.withHandler((flags) => run(flags, { type: "archive.status" })), | ||
| 96 | ); | ||
| 97 | const archiveRetry = Command.make("retry", { | ||
| 98 | ...authLocationFlags, | ||
| 99 | threadId: Argument.String("thread-id").pipe(Argument.withSchema(ThreadId), Argument.optional), | ||
| 100 | }).pipe( | ||
| 101 | Command.withDescription("Retry transcript uploads that stopped after failures."), | ||
| 102 | Command.withHandler((flags) => | ||
| 103 | run(flags, { | ||
| 104 | type: "archive.retry", | ||
| 105 | ...(Option.isSome(flags.threadId) ? { threadId: flags.threadId.value } : {}), | ||
| 106 | }), | ||
| 107 | ), | ||
| 108 | ); | ||
| 109 | const archive = Command.make("archive").pipe( | ||
| 110 | Command.withSubcommands([archiveStatus, archiveRetry]), | ||
| 111 | ); | ||
| 63 | const status = Command.make("status", authLocationFlags).pipe( | 112 | const status = Command.make("status", authLocationFlags).pipe( |
| 64 | Command.withDescription("List shared accounts on this host."), | 113 | Command.withDescription("List shared accounts on this host."), |
| 65 | Command.withHandler((flags) => run(flags, { type: "status" })), | 114 | Command.withHandler((flags) => run(flags, { type: "status" })), |
| ... | @@ -115,7 +164,48 @@ const remove = Command.make("remove", { | ... | @@ -115,7 +164,48 @@ const remove = Command.make("remove", { |
| 115 | Command.withHandler((flags) => run(flags, { type: "remove", id: flags.id })), | 164 | Command.withHandler((flags) => run(flags, { type: "remove", id: flags.id })), |
| 116 | ); | 165 | ); |
| 117 | 166 | ||
| 167 | const hub = Command.make("hub").pipe( | ||
| 168 | Command.withDescription("Manage machines reached through this server."), | ||
| 169 | Command.withSubcommands([ | ||
| 170 | Command.make("configure", { | ||
| 171 | ...authLocationFlags, | ||
| 172 | publicUrl: Argument.String("public-url"), | ||
| 173 | }).pipe( | ||
| 174 | Command.withHandler((flags) => | ||
| 175 | run(flags, { type: "hub.configure", publicUrl: flags.publicUrl }), | ||
| 176 | ), | ||
| 177 | ), | ||
| 178 | Command.make("status", authLocationFlags).pipe( | ||
| 179 | Command.withHandler((flags) => run(flags, { type: "hub.status" })), | ||
| 180 | ), | ||
| 181 | Command.make("enroll", { | ||
| 182 | ...authLocationFlags, | ||
| 183 | url: Argument.String("private-machine-url"), | ||
| 184 | environmentId: Argument.String("environment-id").pipe(Argument.withSchema(EnvironmentId)), | ||
| 185 | tokenFile: Flag.String("token-file"), | ||
| 186 | }).pipe( | ||
| 187 | Command.withDescription("Enroll a machine using its private administrative bearer grant."), | ||
| 188 | Command.withHandler((flags) => | ||
| 189 | run(flags, { | ||
| 190 | type: "hub.enroll", | ||
| 191 | url: flags.url, | ||
| 192 | environmentId: flags.environmentId, | ||
| 193 | tokenFile: flags.tokenFile, | ||
| 194 | }), | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | Command.make("remove", { | ||
| 198 | ...authLocationFlags, | ||
| 199 | environmentId: Argument.String("environment-id").pipe(Argument.withSchema(EnvironmentId)), | ||
| 200 | }).pipe( | ||
| 201 | Command.withHandler((flags) => | ||
| 202 | run(flags, { type: "hub.remove", environmentId: flags.environmentId }), | ||
| 203 | ), | ||
| 204 | ), | ||
| 205 | ]), | ||
| 206 | ); | ||
| 207 | |||
| 118 | export const sharedCommand = Command.make("shared").pipe( | 208 | export const sharedCommand = Command.make("shared").pipe( |
| 119 | Command.withDescription("Manage shared provider accounts."), | 209 | Command.withDescription("Manage shared provider accounts."), |
| 120 | Command.withSubcommands([status, connect, disconnect, importAccount, sync, remove]), | 210 | Command.withSubcommands([status, connect, disconnect, importAccount, sync, remove, archive, hub]), |
| 121 | ); | 211 | ); |
apps/server/src/environment/Hub.test.ts created+326| ... | @@ -0,0 +1,326 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | AuthAccessReadScope, | ||
| 4 | AuthAccessWriteScope, | ||
| 5 | AuthAdministrativeScopes, | ||
| 6 | AuthFilesystemReadScope, | ||
| 7 | AuthPreviewOperateScope, | ||
| 8 | authScopeResponse, | ||
| 9 | AuthOrchestrationReadScope, | ||
| 10 | AuthOrchestrationOperateScope, | ||
| 11 | AuthSessionId, | ||
| 12 | EnvironmentId, | ||
| 13 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 14 | type AuthEnvironmentScope, | ||
| 15 | type ExecutionEnvironmentDescriptor, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Layer from "effect/Layer"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 22 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 23 | import * as ServerEnvironment from "./ServerEnvironment.ts"; | ||
| 24 | import * as Hub from "./Hub.ts"; | ||
| 25 | import * as OutboundHub from "./OutboundHub.ts"; | ||
| 26 | import * as HostedAuthConfig from "../auth/HostedAuthConfig.ts"; | ||
| 27 | |||
| 28 | const leafId = EnvironmentId.make("leaf-a"); | ||
| 29 | const descriptor: ExecutionEnvironmentDescriptor = { | ||
| 30 | environmentId: leafId, | ||
| 31 | label: "Laptop", | ||
| 32 | platform: { os: "linux", arch: "x64" }, | ||
| 33 | serverVersion: "test", | ||
| 34 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | ||
| 35 | capabilities: { repositoryIdentity: true }, | ||
| 36 | }; | ||
| 37 | const session = (scopes: ReadonlyArray<AuthEnvironmentScope> = AuthAdministrativeScopes) => ({ | ||
| 38 | sessionId: AuthSessionId.make("phone"), | ||
| 39 | subject: "phone", | ||
| 40 | method: "bearer-access-token" as const, | ||
| 41 | scopes, | ||
| 42 | }); | ||
| 43 | |||
| 44 | const fixture = ( | ||
| 45 | grant: ReadonlyArray<AuthEnvironmentScope> = AuthAdministrativeScopes, | ||
| 46 | permissions: "modern" | "legacy" | "empty" = "modern", | ||
| 47 | ) => | ||
| 48 | Effect.gen(function* () { | ||
| 49 | const data = new Map<string, Uint8Array>(); | ||
| 50 | const requests: Array<{ url: string; authorization?: string; body: string }> = []; | ||
| 51 | let identity = leafId; | ||
| 52 | let serverVersion = "test"; | ||
| 53 | let unavailable = false; | ||
| 54 | let malformed = false; | ||
| 55 | const client = HttpClient.make((request) => | ||
| 56 | Effect.sync(() => { | ||
| 57 | requests.push({ | ||
| 58 | url: request.url, | ||
| 59 | ...(request.headers.authorization | ||
| 60 | ? { authorization: request.headers.authorization } | ||
| 61 | : {}), | ||
| 62 | body: | ||
| 63 | request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "", | ||
| 64 | }); | ||
| 65 | if (unavailable) | ||
| 66 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); | ||
| 67 | let result: unknown; | ||
| 68 | if (request.url.endsWith("/.well-known/t3/environment")) | ||
| 69 | result = malformed ? {} : { ...descriptor, environmentId: identity, serverVersion }; | ||
| 70 | else if (request.url.endsWith("/api/auth/session")) | ||
| 71 | result = { | ||
| 72 | authenticated: true, | ||
| 73 | auth: { | ||
| 74 | policy: "remote-reachable", | ||
| 75 | bootstrapMethods: ["one-time-token"], | ||
| 76 | sessionMethods: ["bearer-access-token"], | ||
| 77 | sessionCookieName: "session", | ||
| 78 | }, | ||
| 79 | scopes: authScopeResponse(grant).scopes, | ||
| 80 | ...(permissions === "legacy" | ||
| 81 | ? {} | ||
| 82 | : { permissions: permissions === "empty" ? [] : grant }), | ||
| 83 | }; | ||
| 84 | else if (request.url.endsWith("/api/auth/delegated-session")) | ||
| 85 | result = { | ||
| 86 | access_token: "private-leaf-session", | ||
| 87 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 88 | token_type: "Bearer", | ||
| 89 | expires_in: 3600, | ||
| 90 | scope: JSON.parse(requests.at(-1)?.body ?? "{}").scopes?.join(" "), | ||
| 91 | }; | ||
| 92 | else throw new Error(`Unexpected request ${request.url}`); | ||
| 93 | return HttpClientResponse.fromWeb(request, Response.json(result)); | ||
| 94 | }), | ||
| 95 | ); | ||
| 96 | const store = ServerSecretStore.ServerSecretStore.of({ | ||
| 97 | get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | ||
| 98 | set: (key, value) => | ||
| 99 | Effect.sync(() => { | ||
| 100 | data.set(key, value); | ||
| 101 | }), | ||
| 102 | remove: (key) => | ||
| 103 | Effect.sync(() => { | ||
| 104 | data.delete(key); | ||
| 105 | }), | ||
| 106 | create: () => Effect.die("unused"), | ||
| 107 | getOrCreateRandom: () => Effect.die("unused"), | ||
| 108 | }); | ||
| 109 | const layer = Hub.layer.pipe( | ||
| 110 | Layer.provide(Layer.succeed(HostedAuthConfig.HostedAuthConfig, Option.none())), | ||
| 111 | Layer.provide( | ||
| 112 | Layer.succeed(OutboundHub.OutboundHub, { | ||
| 113 | enrollments: Effect.succeed([]), | ||
| 114 | connect: () => Effect.die("unused"), | ||
| 115 | remove: () => Effect.die("unused"), | ||
| 116 | accept: () => Effect.die("unused"), | ||
| 117 | channel: () => Effect.die("unused"), | ||
| 118 | }), | ||
| 119 | ), | ||
| 120 | Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, store)), | ||
| 121 | Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), | ||
| 122 | Layer.provide( | ||
| 123 | Layer.succeed(ServerEnvironment.ServerEnvironment, { | ||
| 124 | getEnvironmentId: Effect.succeed(EnvironmentId.make("hub")), | ||
| 125 | getDescriptor: Effect.succeed({ | ||
| 126 | ...descriptor, | ||
| 127 | environmentId: EnvironmentId.make("hub"), | ||
| 128 | }), | ||
| 129 | }), | ||
| 130 | ), | ||
| 131 | Layer.provide( | ||
| 132 | Layer.succeed( | ||
| 133 | FileSystem.FileSystem, | ||
| 134 | FileSystem.makeNoop({ readFileString: () => Effect.succeed("leaf-admin-token\n") }), | ||
| 135 | ), | ||
| 136 | ), | ||
| 137 | ); | ||
| 138 | const service = yield* Hub.Hub.pipe(Effect.provide(layer)); | ||
| 139 | const enroll = service.manage({ | ||
| 140 | type: "hub.enroll", | ||
| 141 | url: "http://leaf.private/base/", | ||
| 142 | environmentId: leafId, | ||
| 143 | tokenFile: "/private/grant", | ||
| 144 | }); | ||
| 145 | return { | ||
| 146 | service, | ||
| 147 | requests, | ||
| 148 | data, | ||
| 149 | enroll, | ||
| 150 | restart: Hub.Hub.pipe(Effect.provide(layer)), | ||
| 151 | changeVersion: (value: string) => { | ||
| 152 | serverVersion = value; | ||
| 153 | }, | ||
| 154 | setUnavailable: () => { | ||
| 155 | unavailable = true; | ||
| 156 | }, | ||
| 157 | setMalformed: () => { | ||
| 158 | malformed = true; | ||
| 159 | }, | ||
| 160 | changeIdentity: (id: EnvironmentId) => { | ||
| 161 | identity = id; | ||
| 162 | }, | ||
| 163 | }; | ||
| 164 | }); | ||
| 165 | |||
| 166 | it.effect( | ||
| 167 | "requires an explicit canonical public URL and persists an identity-checked private enrollment", | ||
| 168 | () => | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const f = yield* fixture(); | ||
| 171 | assert.strictEqual((yield* f.enroll.pipe(Effect.flip)).reason, "configuration"); | ||
| 172 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/prefix" }); | ||
| 173 | yield* f.enroll; | ||
| 174 | const directory = yield* (yield* f.restart).directory(session()); | ||
| 175 | assert.strictEqual( | ||
| 176 | directory.environments[0]?.httpBaseUrl, | ||
| 177 | "https://hub.example/prefix/environments/leaf-a/", | ||
| 178 | ); | ||
| 179 | assert.strictEqual(directory.hubEnvironmentId, "hub"); | ||
| 180 | assert.strictEqual(JSON.stringify(directory).includes("leaf-admin-token"), false); | ||
| 181 | assert.strictEqual(JSON.stringify(directory).includes("leaf.private"), false); | ||
| 182 | assert.strictEqual(f.requests[0]?.authorization, undefined); | ||
| 183 | }), | ||
| 184 | ); | ||
| 185 | |||
| 186 | it.effect("does not send a private grant to a different machine identity", () => | ||
| 187 | Effect.gen(function* () { | ||
| 188 | const f = yield* fixture(); | ||
| 189 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/" }); | ||
| 190 | f.changeIdentity(EnvironmentId.make("impostor")); | ||
| 191 | assert.strictEqual((yield* f.enroll.pipe(Effect.flip)).reason, "identity"); | ||
| 192 | assert.strictEqual(f.requests.length, 1); | ||
| 193 | assert.strictEqual(f.requests[0]?.authorization, undefined); | ||
| 194 | }), | ||
| 195 | ); | ||
| 196 | |||
| 197 | it.effect( | ||
| 198 | "bounds delegated sessions by both client and enrolled machine grants and keeps leaf credentials private", | ||
| 199 | () => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const f = yield* fixture([ | ||
| 202 | AuthAccessReadScope, | ||
| 203 | AuthAccessWriteScope, | ||
| 204 | AuthOrchestrationReadScope, | ||
| 205 | ]); | ||
| 206 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/" }); | ||
| 207 | yield* f.enroll; | ||
| 208 | const connected = yield* f.service.connect( | ||
| 209 | leafId, | ||
| 210 | session([AuthOrchestrationReadScope, AuthOrchestrationOperateScope, AuthAccessWriteScope]), | ||
| 211 | ); | ||
| 212 | assert.strictEqual(connected.token, "private-leaf-session"); | ||
| 213 | const pairing = f.requests.find((r) => r.url.endsWith("delegated-session")); | ||
| 214 | assert.deepStrictEqual(JSON.parse(pairing?.body ?? "{}").scopes, [ | ||
| 215 | AuthOrchestrationReadScope, | ||
| 216 | ]); | ||
| 217 | assert.deepStrictEqual((yield* f.service.directory(session())).environments[0]?.scopes, [ | ||
| 218 | AuthOrchestrationReadScope, | ||
| 219 | ]); | ||
| 220 | const count = f.requests.length; | ||
| 221 | yield* f.service.connect( | ||
| 222 | leafId, | ||
| 223 | session([AuthOrchestrationReadScope, AuthOrchestrationOperateScope, AuthAccessWriteScope]), | ||
| 224 | ); | ||
| 225 | assert.strictEqual(f.requests.length, count); | ||
| 226 | }), | ||
| 227 | ); | ||
| 228 | |||
| 229 | it.effect( | ||
| 230 | "removing or replacing a machine invalidates its active proxy and rejects subsequent connections", | ||
| 231 | () => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const f = yield* fixture(); | ||
| 234 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/" }); | ||
| 235 | yield* f.enroll; | ||
| 236 | const connected = yield* f.service.connect(leafId, session()); | ||
| 237 | yield* f.service.manage({ type: "hub.remove", environmentId: leafId }); | ||
| 238 | yield* connected.invalidated; | ||
| 239 | assert.strictEqual( | ||
| 240 | (yield* f.service.connect(leafId, session()).pipe(Effect.flip)).reason, | ||
| 241 | "not-found", | ||
| 242 | ); | ||
| 243 | assert.deepStrictEqual((yield* f.service.directory(session())).environments, []); | ||
| 244 | }), | ||
| 245 | ); | ||
| 246 | |||
| 247 | it.effect("checks machine identity again before releasing its saved delegation grant", () => | ||
| 248 | Effect.gen(function* () { | ||
| 249 | const f = yield* fixture(); | ||
| 250 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/" }); | ||
| 251 | yield* f.enroll; | ||
| 252 | f.changeIdentity(EnvironmentId.make("replacement")); | ||
| 253 | const count = f.requests.length; | ||
| 254 | assert.strictEqual( | ||
| 255 | (yield* f.service.connect(leafId, session()).pipe(Effect.flip)).reason, | ||
| 256 | "identity", | ||
| 257 | ); | ||
| 258 | assert.strictEqual(f.requests.length, count + 1); | ||
| 259 | assert.strictEqual(f.requests.at(-1)?.authorization, undefined); | ||
| 260 | }), | ||
| 261 | ); | ||
| 262 | |||
| 263 | it.effect( | ||
| 264 | "refreshes changed machine descriptors, persists them for offline discovery, and never hides an identity mismatch", | ||
| 265 | () => | ||
| 266 | Effect.gen(function* () { | ||
| 267 | const f = yield* fixture(); | ||
| 268 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/" }); | ||
| 269 | yield* f.enroll; | ||
| 270 | f.changeVersion("updated"); | ||
| 271 | assert.strictEqual((yield* f.service.descriptor(leafId)).serverVersion, "updated"); | ||
| 272 | assert.strictEqual( | ||
| 273 | (yield* f.service.directory(session())).environments[0]?.descriptor.serverVersion, | ||
| 274 | "updated", | ||
| 275 | ); | ||
| 276 | f.setUnavailable(); | ||
| 277 | const restarted = yield* f.restart; | ||
| 278 | assert.strictEqual((yield* restarted.descriptor(leafId)).serverVersion, "updated"); | ||
| 279 | }), | ||
| 280 | ); | ||
| 281 | |||
| 282 | it.effect( | ||
| 283 | "rejects malformed or changed discovery identities instead of treating them as offline", | ||
| 284 | () => | ||
| 285 | Effect.gen(function* () { | ||
| 286 | const f = yield* fixture(); | ||
| 287 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/" }); | ||
| 288 | yield* f.enroll; | ||
| 289 | f.changeIdentity(EnvironmentId.make("replacement")); | ||
| 290 | assert.strictEqual( | ||
| 291 | (yield* f.service.directory(session()).pipe(Effect.flip)).reason, | ||
| 292 | "identity", | ||
| 293 | ); | ||
| 294 | f.setMalformed(); | ||
| 295 | assert.strictEqual( | ||
| 296 | (yield* f.service.descriptor(leafId).pipe(Effect.flip)).reason, | ||
| 297 | "identity", | ||
| 298 | ); | ||
| 299 | }), | ||
| 300 | ); | ||
| 301 | |||
| 302 | it.effect.each(["modern", "legacy"] as const)( | ||
| 303 | "delegates granular permissions from a %s machine's auth response", | ||
| 304 | (permissions) => | ||
| 305 | Effect.gen(function* () { | ||
| 306 | const f = yield* fixture(AuthAdministrativeScopes, permissions); | ||
| 307 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/" }); | ||
| 308 | yield* f.enroll; | ||
| 309 | yield* f.service.connect(leafId, session()); | ||
| 310 | const pairing = f.requests.find((request) => request.url.endsWith("delegated-session")); | ||
| 311 | const scopes = JSON.parse(pairing?.body ?? "{}").scopes; | ||
| 312 | assert.include(scopes, AuthFilesystemReadScope); | ||
| 313 | assert.include(scopes, AuthPreviewOperateScope); | ||
| 314 | const directory = yield* f.service.directory(session()); | ||
| 315 | assert.strictEqual(directory.environments[0]?.scopes.includes(AuthFilesystemReadScope), true); | ||
| 316 | assert.strictEqual(directory.environments[0]?.scopes.includes(AuthPreviewOperateScope), true); | ||
| 317 | }), | ||
| 318 | ); | ||
| 319 | |||
| 320 | it.effect("does not expand an explicitly empty machine permission grant", () => | ||
| 321 | Effect.gen(function* () { | ||
| 322 | const f = yield* fixture(AuthAdministrativeScopes, "empty"); | ||
| 323 | yield* f.service.manage({ type: "hub.configure", publicUrl: "https://hub.example/" }); | ||
| 324 | assert.strictEqual((yield* f.enroll.pipe(Effect.flip)).reason, "credential"); | ||
| 325 | }), | ||
| 326 | ); | ||
apps/server/src/environment/Hub.ts created+417| ... | @@ -0,0 +1,417 @@ | ||
| 1 | import { | ||
| 2 | AuthAccessWriteScope, | ||
| 3 | AuthAccessReadScope, | ||
| 4 | AuthAdministrativeScopes, | ||
| 5 | AuthAccessTokenResult, | ||
| 6 | AuthEnvironmentScopes, | ||
| 7 | AuthSessionState, | ||
| 8 | AuthStandardClientScopes, | ||
| 9 | sessionGrantsScope, | ||
| 10 | EnvironmentId, | ||
| 11 | ExecutionEnvironmentDescriptor, | ||
| 12 | HubError, | ||
| 13 | type AuthEnvironmentScope, | ||
| 14 | type HubDirectory, | ||
| 15 | type HubManagementRequest, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import * as Context from "effect/Context"; | ||
| 18 | import * as Clock from "effect/Clock"; | ||
| 19 | import * as Deferred from "effect/Deferred"; | ||
| 20 | import * as Effect from "effect/Effect"; | ||
| 21 | import * as FileSystem from "effect/FileSystem"; | ||
| 22 | import * as Layer from "effect/Layer"; | ||
| 23 | import * as Option from "effect/Option"; | ||
| 24 | import * as Schema from "effect/Schema"; | ||
| 25 | import * as Redacted from "effect/Redacted"; | ||
| 26 | import * as Semaphore from "effect/Semaphore"; | ||
| 27 | import { | ||
| 28 | FetchHttpClient, | ||
| 29 | HttpBody, | ||
| 30 | HttpClient, | ||
| 31 | HttpClientRequest, | ||
| 32 | HttpClientResponse, | ||
| 33 | } from "effect/http"; | ||
| 34 | |||
| 35 | import type { AuthenticatedSession } from "../auth/EnvironmentAuth.ts"; | ||
| 36 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 37 | import * as ServerEnvironment from "./ServerEnvironment.ts"; | ||
| 38 | import * as OutboundHub from "./OutboundHub.ts"; | ||
| 39 | import * as HostedAuthConfig from "../auth/HostedAuthConfig.ts"; | ||
| 40 | |||
| 41 | const isHubError = Schema.is(HubError); | ||
| 42 | const descriptorsEqual = Schema.toEquivalence(ExecutionEnvironmentDescriptor); | ||
| 43 | |||
| 44 | const Machine = Schema.Struct({ | ||
| 45 | url: Schema.String, | ||
| 46 | token: Schema.NonEmptyString, | ||
| 47 | descriptor: ExecutionEnvironmentDescriptor, | ||
| 48 | scopes: AuthEnvironmentScopes, | ||
| 49 | }); | ||
| 50 | type Machine = typeof Machine.Type; | ||
| 51 | const Configuration = Schema.Struct({ | ||
| 52 | publicUrl: Schema.String, | ||
| 53 | machines: Schema.Array(Machine), | ||
| 54 | }); | ||
| 55 | const jsonConfiguration = Schema.fromJsonString(Configuration); | ||
| 56 | |||
| 57 | export class Hub extends Context.Service< | ||
| 58 | Hub, | ||
| 59 | { | ||
| 60 | readonly directory: ( | ||
| 61 | session: AuthenticatedSession, | ||
| 62 | options?: { readonly refresh?: boolean }, | ||
| 63 | ) => Effect.Effect<HubDirectory, HubError>; | ||
| 64 | readonly manage: (input: HubManagementRequest) => Effect.Effect<void, HubError>; | ||
| 65 | readonly descriptor: ( | ||
| 66 | id: EnvironmentId, | ||
| 67 | ) => Effect.Effect<ExecutionEnvironmentDescriptor, HubError>; | ||
| 68 | readonly authorize: ( | ||
| 69 | id: EnvironmentId, | ||
| 70 | session: AuthenticatedSession, | ||
| 71 | ) => Effect.Effect<ReadonlyArray<AuthEnvironmentScope>, HubError>; | ||
| 72 | readonly connect: ( | ||
| 73 | id: EnvironmentId, | ||
| 74 | session: AuthenticatedSession, | ||
| 75 | ) => Effect.Effect< | ||
| 76 | { | ||
| 77 | readonly url: string; | ||
| 78 | readonly token: string; | ||
| 79 | readonly invalidated: Effect.Effect<void>; | ||
| 80 | }, | ||
| 81 | HubError | ||
| 82 | >; | ||
| 83 | } | ||
| 84 | >()("t3/environment/Hub") {} | ||
| 85 | |||
| 86 | const normalizeUrl = (value: string) => | ||
| 87 | Effect.try({ | ||
| 88 | try: () => { | ||
| 89 | const url = new URL(value); | ||
| 90 | if ( | ||
| 91 | !["http:", "https:"].includes(url.protocol) || | ||
| 92 | url.username || | ||
| 93 | url.password || | ||
| 94 | url.search || | ||
| 95 | url.hash | ||
| 96 | ) { | ||
| 97 | throw new Error("Expected an HTTP base URL without credentials, query, or fragment."); | ||
| 98 | } | ||
| 99 | url.pathname = `${url.pathname.replace(/\/+$/, "")}/`; | ||
| 100 | return url.href; | ||
| 101 | }, | ||
| 102 | catch: (cause) => new HubError({ reason: "address", cause: Redacted.make(cause) }), | ||
| 103 | }); | ||
| 104 | |||
| 105 | const grantScopes = (machine: Machine, session: AuthenticatedSession) => | ||
| 106 | AuthStandardClientScopes.filter( | ||
| 107 | (scope) => machine.scopes.includes(scope) && session.scopes.includes(scope), | ||
| 108 | ); | ||
| 109 | |||
| 110 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 111 | export const make = Effect.gen(function* () { | ||
| 112 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 113 | const files = yield* FileSystem.FileSystem; | ||
| 114 | const client = yield* HttpClient.HttpClient; | ||
| 115 | const environment = yield* ServerEnvironment.ServerEnvironment; | ||
| 116 | const outbound = yield* OutboundHub.OutboundHub; | ||
| 117 | const hosted = yield* HostedAuthConfig.HostedAuthConfig; | ||
| 118 | const lock = yield* Semaphore.make(1); | ||
| 119 | const stored = yield* secrets | ||
| 120 | .get("hub") | ||
| 121 | .pipe( | ||
| 122 | Effect.mapError((cause) => new HubError({ reason: "storage", cause: Redacted.make(cause) })), | ||
| 123 | ); | ||
| 124 | let configuration = Option.isSome(stored) | ||
| 125 | ? yield* Schema.decodeEffect(jsonConfiguration)(new TextDecoder().decode(stored.value)).pipe( | ||
| 126 | Effect.mapError( | ||
| 127 | (cause) => new HubError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 128 | ), | ||
| 129 | ) | ||
| 130 | : ({ publicUrl: "", machines: [] } satisfies typeof Configuration.Type); | ||
| 131 | const removals = new Map<EnvironmentId, Deferred.Deferred<void>>(); | ||
| 132 | for (const machine of configuration.machines) { | ||
| 133 | removals.set(machine.descriptor.environmentId, yield* Deferred.make<void>()); | ||
| 134 | } | ||
| 135 | const credentials = new Map<string, { token: string; expiresAt: number }>(); | ||
| 136 | const publicUrl = () => | ||
| 137 | Option.isSome(hosted) ? `${hosted.value.publicUrl}/` : configuration.publicUrl; | ||
| 138 | |||
| 139 | const request = <A>( | ||
| 140 | machine: Pick<Machine, "url" | "token">, | ||
| 141 | path: string, | ||
| 142 | schema: Schema.Decoder<A>, | ||
| 143 | body?: HttpBody.HttpBody, | ||
| 144 | ) => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | let outgoing = HttpClientRequest.make(body ? "POST" : "GET")(new URL(path, machine.url).href); | ||
| 147 | if (machine.token) outgoing = HttpClientRequest.bearerToken(outgoing, machine.token); | ||
| 148 | if (body) outgoing = HttpClientRequest.setBody(outgoing, body); | ||
| 149 | const response = yield* client | ||
| 150 | .execute(outgoing) | ||
| 151 | .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); | ||
| 152 | if (response.status !== 200) | ||
| 153 | return yield* new HubError({ | ||
| 154 | reason: response.status === 401 || response.status === 403 ? "credential" : "upstream", | ||
| 155 | }); | ||
| 156 | return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe( | ||
| 157 | Effect.mapError( | ||
| 158 | (cause) => | ||
| 159 | new HubError({ | ||
| 160 | reason: path === ".well-known/t3/environment" ? "identity" : "upstream", | ||
| 161 | cause: Redacted.make(cause), | ||
| 162 | }), | ||
| 163 | ), | ||
| 164 | ); | ||
| 165 | }).pipe( | ||
| 166 | Effect.timeout("10 seconds"), | ||
| 167 | Effect.mapError((cause) => | ||
| 168 | isHubError(cause) | ||
| 169 | ? cause | ||
| 170 | : new HubError({ reason: "upstream", cause: Redacted.make(cause) }), | ||
| 171 | ), | ||
| 172 | Effect.scoped, | ||
| 173 | ); | ||
| 174 | |||
| 175 | const find = (id: EnvironmentId): Effect.Effect<Machine, HubError> => { | ||
| 176 | const machine = configuration.machines.find((value) => value.descriptor.environmentId === id); | ||
| 177 | return machine ? Effect.succeed(machine) : Effect.fail(new HubError({ reason: "not-found" })); | ||
| 178 | }; | ||
| 179 | const persist = (next: typeof Configuration.Type) => | ||
| 180 | Schema.encodeEffect(jsonConfiguration)(next).pipe( | ||
| 181 | Effect.flatMap((json) => secrets.set("hub", new TextEncoder().encode(json))), | ||
| 182 | Effect.mapError((cause) => new HubError({ reason: "storage", cause: Redacted.make(cause) })), | ||
| 183 | Effect.tap(() => | ||
| 184 | Effect.sync(() => { | ||
| 185 | configuration = next; | ||
| 186 | }), | ||
| 187 | ), | ||
| 188 | ); | ||
| 189 | |||
| 190 | const refreshDescriptor = (id: EnvironmentId) => | ||
| 191 | Effect.gen(function* () { | ||
| 192 | const remote = (yield* outbound.enrollments).find( | ||
| 193 | (value) => value.descriptor.environmentId === id, | ||
| 194 | ); | ||
| 195 | if (remote) return remote.descriptor; | ||
| 196 | const machine = yield* find(id); | ||
| 197 | const descriptor = yield* request( | ||
| 198 | { ...machine, token: "" }, | ||
| 199 | ".well-known/t3/environment", | ||
| 200 | ExecutionEnvironmentDescriptor, | ||
| 201 | ).pipe( | ||
| 202 | Effect.catchTags({ | ||
| 203 | HubError: (error) => | ||
| 204 | error.reason === "upstream" ? Effect.succeed(machine.descriptor) : Effect.fail(error), | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | if (descriptor.environmentId !== id) return yield* new HubError({ reason: "identity" }); | ||
| 208 | if (!descriptorsEqual(descriptor, machine.descriptor)) { | ||
| 209 | yield* lock.withPermit( | ||
| 210 | Effect.gen(function* () { | ||
| 211 | const current = yield* find(id); | ||
| 212 | if (current.url !== machine.url) return; | ||
| 213 | yield* persist({ | ||
| 214 | ...configuration, | ||
| 215 | machines: configuration.machines.map((value) => | ||
| 216 | value.descriptor.environmentId === id ? { ...value, descriptor } : value, | ||
| 217 | ), | ||
| 218 | }); | ||
| 219 | }), | ||
| 220 | ); | ||
| 221 | } | ||
| 222 | return descriptor; | ||
| 223 | }); | ||
| 224 | |||
| 225 | return Hub.of({ | ||
| 226 | directory: (session, options) => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | const baseUrl = publicUrl(); | ||
| 229 | if (!baseUrl) return yield* new HubError({ reason: "configuration" }); | ||
| 230 | const registered = yield* outbound.enrollments; | ||
| 231 | return { | ||
| 232 | hubEnvironmentId: yield* environment.getEnvironmentId, | ||
| 233 | environments: yield* Effect.forEach( | ||
| 234 | [ | ||
| 235 | ...configuration.machines.filter( | ||
| 236 | (machine) => | ||
| 237 | !registered.some( | ||
| 238 | (value) => value.descriptor.environmentId === machine.descriptor.environmentId, | ||
| 239 | ), | ||
| 240 | ), | ||
| 241 | ...registered.map((value) => ({ | ||
| 242 | descriptor: value.descriptor, | ||
| 243 | scopes: AuthStandardClientScopes, | ||
| 244 | })), | ||
| 245 | ], | ||
| 246 | (machine) => | ||
| 247 | Effect.gen(function* () { | ||
| 248 | const descriptor = | ||
| 249 | options?.refresh === false | ||
| 250 | ? machine.descriptor | ||
| 251 | : yield* refreshDescriptor(machine.descriptor.environmentId); | ||
| 252 | return { | ||
| 253 | environmentId: machine.descriptor.environmentId, | ||
| 254 | label: descriptor.label, | ||
| 255 | descriptor, | ||
| 256 | httpBaseUrl: new URL( | ||
| 257 | `environments/${encodeURIComponent(machine.descriptor.environmentId)}/`, | ||
| 258 | baseUrl, | ||
| 259 | ).href, | ||
| 260 | scopes: AuthStandardClientScopes.filter( | ||
| 261 | (scope) => machine.scopes.includes(scope) && session.scopes.includes(scope), | ||
| 262 | ), | ||
| 263 | }; | ||
| 264 | }), | ||
| 265 | { concurrency: "unbounded" }, | ||
| 266 | ), | ||
| 267 | }; | ||
| 268 | }), | ||
| 269 | descriptor: refreshDescriptor, | ||
| 270 | authorize: (id, session) => | ||
| 271 | Effect.gen(function* () { | ||
| 272 | if ((yield* outbound.enrollments).some((value) => value.descriptor.environmentId === id)) { | ||
| 273 | const scopes = AuthStandardClientScopes.filter((scope) => session.scopes.includes(scope)); | ||
| 274 | if (scopes.length === 0) return yield* new HubError({ reason: "credential" }); | ||
| 275 | return scopes; | ||
| 276 | } | ||
| 277 | return yield* find(id).pipe( | ||
| 278 | Effect.flatMap((machine) => { | ||
| 279 | const scopes = grantScopes(machine, session); | ||
| 280 | return scopes.length > 0 | ||
| 281 | ? Effect.succeed(scopes) | ||
| 282 | : Effect.fail(new HubError({ reason: "credential" })); | ||
| 283 | }), | ||
| 284 | ); | ||
| 285 | }), | ||
| 286 | manage: (input) => | ||
| 287 | lock.withPermit( | ||
| 288 | Effect.gen(function* () { | ||
| 289 | if (input.type === "hub.status") return; | ||
| 290 | if (input.type === "hub.configure") { | ||
| 291 | const publicUrl = yield* normalizeUrl(input.publicUrl); | ||
| 292 | yield* persist({ ...configuration, publicUrl }); | ||
| 293 | return; | ||
| 294 | } | ||
| 295 | if (!publicUrl()) return yield* new HubError({ reason: "configuration" }); | ||
| 296 | if (input.type === "hub.remove") { | ||
| 297 | if ( | ||
| 298 | (yield* outbound.enrollments).some( | ||
| 299 | (value) => value.descriptor.environmentId === input.environmentId, | ||
| 300 | ) | ||
| 301 | ) { | ||
| 302 | yield* outbound.remove(input.environmentId); | ||
| 303 | return; | ||
| 304 | } | ||
| 305 | yield* find(input.environmentId); | ||
| 306 | yield* persist({ | ||
| 307 | ...configuration, | ||
| 308 | machines: configuration.machines.filter( | ||
| 309 | (machine) => machine.descriptor.environmentId !== input.environmentId, | ||
| 310 | ), | ||
| 311 | }); | ||
| 312 | const removal = removals.get(input.environmentId); | ||
| 313 | if (removal) yield* Deferred.succeed(removal, undefined); | ||
| 314 | removals.delete(input.environmentId); | ||
| 315 | for (const key of credentials.keys()) | ||
| 316 | if (key.startsWith(`${input.environmentId}:`)) credentials.delete(key); | ||
| 317 | return; | ||
| 318 | } | ||
| 319 | const url = yield* normalizeUrl(input.url); | ||
| 320 | const token = (yield* files | ||
| 321 | .readFileString(input.tokenFile) | ||
| 322 | .pipe( | ||
| 323 | Effect.mapError( | ||
| 324 | (cause) => new HubError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 325 | ), | ||
| 326 | )).trim(); | ||
| 327 | const descriptor = yield* request( | ||
| 328 | { url, token: "" }, | ||
| 329 | ".well-known/t3/environment", | ||
| 330 | ExecutionEnvironmentDescriptor, | ||
| 331 | ); | ||
| 332 | if ( | ||
| 333 | descriptor.environmentId !== input.environmentId || | ||
| 334 | descriptor.environmentId === (yield* environment.getEnvironmentId) | ||
| 335 | ) { | ||
| 336 | return yield* new HubError({ reason: "identity" }); | ||
| 337 | } | ||
| 338 | const state = yield* request({ url, token }, "api/auth/session", AuthSessionState); | ||
| 339 | if ( | ||
| 340 | !sessionGrantsScope(state, AuthAccessReadScope) || | ||
| 341 | !sessionGrantsScope(state, AuthAccessWriteScope) | ||
| 342 | ) { | ||
| 343 | return yield* new HubError({ reason: "credential" }); | ||
| 344 | } | ||
| 345 | yield* persist({ | ||
| 346 | ...configuration, | ||
| 347 | machines: [ | ||
| 348 | ...configuration.machines.filter( | ||
| 349 | (machine) => machine.descriptor.environmentId !== input.environmentId, | ||
| 350 | ), | ||
| 351 | { | ||
| 352 | url, | ||
| 353 | token, | ||
| 354 | descriptor, | ||
| 355 | scopes: AuthAdministrativeScopes.filter((scope) => | ||
| 356 | sessionGrantsScope(state, scope), | ||
| 357 | ), | ||
| 358 | }, | ||
| 359 | ], | ||
| 360 | }); | ||
| 361 | const removal = removals.get(input.environmentId); | ||
| 362 | if (removal) yield* Deferred.succeed(removal, undefined); | ||
| 363 | removals.set(input.environmentId, yield* Deferred.make<void>()); | ||
| 364 | for (const key of credentials.keys()) | ||
| 365 | if (key.startsWith(`${input.environmentId}:`)) credentials.delete(key); | ||
| 366 | }), | ||
| 367 | ), | ||
| 368 | connect: (id, session) => | ||
| 369 | lock.withPermit( | ||
| 370 | Effect.gen(function* () { | ||
| 371 | if ( | ||
| 372 | (yield* outbound.enrollments).some((value) => value.descriptor.environmentId === id) | ||
| 373 | ) { | ||
| 374 | return yield* outbound.connect(id, session); | ||
| 375 | } | ||
| 376 | const machine = yield* find(id); | ||
| 377 | const scopes = grantScopes(machine, session); | ||
| 378 | if (scopes.length === 0) return yield* new HubError({ reason: "credential" }); | ||
| 379 | const key = `${id}:${session.sessionId}:${scopes.toSorted().join(" ")}`; | ||
| 380 | const now = yield* Clock.currentTimeMillis; | ||
| 381 | for (const [key, credential] of credentials) { | ||
| 382 | if (credential.expiresAt <= now + 5_000) credentials.delete(key); | ||
| 383 | } | ||
| 384 | const cached = credentials.get(key); | ||
| 385 | let token = cached && cached.expiresAt > now + 5_000 ? cached.token : undefined; | ||
| 386 | if (!token) { | ||
| 387 | // A changed endpoint must prove the enrolled identity before receiving its private grant. | ||
| 388 | const descriptor = yield* request( | ||
| 389 | { ...machine, token: "" }, | ||
| 390 | ".well-known/t3/environment", | ||
| 391 | ExecutionEnvironmentDescriptor, | ||
| 392 | ); | ||
| 393 | if (descriptor.environmentId !== id) return yield* new HubError({ reason: "identity" }); | ||
| 394 | const issued = yield* request( | ||
| 395 | machine, | ||
| 396 | "api/auth/delegated-session", | ||
| 397 | AuthAccessTokenResult, | ||
| 398 | HttpBody.jsonUnsafe({ scopes, label: "Self-hosted server client" }), | ||
| 399 | ); | ||
| 400 | if ( | ||
| 401 | issued.token_type !== "Bearer" || | ||
| 402 | issued.scope.split(" ").some((scope) => !scopes.some((granted) => granted === scope)) | ||
| 403 | ) { | ||
| 404 | return yield* new HubError({ reason: "credential" }); | ||
| 405 | } | ||
| 406 | token = issued.access_token; | ||
| 407 | credentials.set(key, { token, expiresAt: now + issued.expires_in * 1_000 }); | ||
| 408 | } | ||
| 409 | const removal = removals.get(id); | ||
| 410 | if (!removal) return yield* new HubError({ reason: "not-found" }); | ||
| 411 | return { url: machine.url, token, invalidated: Deferred.await(removal) }; | ||
| 412 | }), | ||
| 413 | ), | ||
| 414 | }); | ||
| 415 | }); | ||
| 416 | |||
| 417 | export const layer = Layer.effect(Hub, make); | ||
apps/server/src/environment/HubHttp.ts created+89| ... | @@ -0,0 +1,89 @@ | ||
| 1 | import { | ||
| 2 | AuthAccessWriteScope, | ||
| 3 | EnvironmentId, | ||
| 4 | HubError, | ||
| 5 | HubManagementRequest, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; | ||
| 10 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 11 | import * as HubProxy from "./HubProxy.ts"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import * as Hub from "./Hub.ts"; | ||
| 14 | |||
| 15 | const isHubError = Schema.is(HubError); | ||
| 16 | |||
| 17 | const errors = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 18 | effect.pipe( | ||
| 19 | Effect.catch((error) => { | ||
| 20 | if (isHubError(error)) | ||
| 21 | return Effect.succeed( | ||
| 22 | HttpServerResponse.jsonUnsafe( | ||
| 23 | { _tag: error._tag, reason: error.reason, message: error.message }, | ||
| 24 | { | ||
| 25 | status: | ||
| 26 | error.reason === "not-found" | ||
| 27 | ? 404 | ||
| 28 | : error.reason === "credential" | ||
| 29 | ? 403 | ||
| 30 | : error.reason === "address" | ||
| 31 | ? 400 | ||
| 32 | : 502, | ||
| 33 | }, | ||
| 34 | ), | ||
| 35 | ); | ||
| 36 | if (EnvironmentAuth.isServerAuthCredentialError(error)) | ||
| 37 | return Effect.succeed( | ||
| 38 | HttpServerResponse.jsonUnsafe({ code: "unauthorized" }, { status: 401 }), | ||
| 39 | ); | ||
| 40 | return Effect.succeed( | ||
| 41 | HttpServerResponse.jsonUnsafe({ code: "internal_error" }, { status: 500 }), | ||
| 42 | ); | ||
| 43 | }), | ||
| 44 | ); | ||
| 45 | |||
| 46 | const authenticate = Effect.gen(function* () { | ||
| 47 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 48 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 49 | return yield* auth.authenticateWebSocketUpgrade(request); | ||
| 50 | }); | ||
| 51 | |||
| 52 | const directory = Effect.gen(function* () { | ||
| 53 | const session = yield* authenticate; | ||
| 54 | return HttpServerResponse.jsonUnsafe( | ||
| 55 | yield* (yield* Hub.Hub).directory(session, { refresh: false }), | ||
| 56 | { | ||
| 57 | headers: { "cache-control": "no-store" }, | ||
| 58 | }, | ||
| 59 | ); | ||
| 60 | }); | ||
| 61 | |||
| 62 | const manage = Effect.gen(function* () { | ||
| 63 | const session = yield* authenticate; | ||
| 64 | if (!session.scopes.includes(AuthAccessWriteScope)) | ||
| 65 | return HttpServerResponse.empty({ status: 403 }); | ||
| 66 | const input = yield* HttpServerRequest.schemaBodyJson(HubManagementRequest); | ||
| 67 | const hub = yield* Hub.Hub; | ||
| 68 | yield* hub.manage(input); | ||
| 69 | return HttpServerResponse.jsonUnsafe(yield* hub.directory(session, { refresh: false }), { | ||
| 70 | headers: { "cache-control": "no-store" }, | ||
| 71 | }); | ||
| 72 | }); | ||
| 73 | |||
| 74 | const proxy = Effect.gen(function* () { | ||
| 75 | const params = yield* HttpRouter.params; | ||
| 76 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 77 | const proxy = yield* HubProxy.HubProxy; | ||
| 78 | return yield* proxy.forward( | ||
| 79 | request, | ||
| 80 | EnvironmentId.make(params.environmentId ?? ""), | ||
| 81 | `/${params["*"] ?? ""}`, | ||
| 82 | ); | ||
| 83 | }); | ||
| 84 | |||
| 85 | export const layer = Layer.mergeAll( | ||
| 86 | HttpRouter.add("GET", "/api/hub/environments", directory.pipe(errors)), | ||
| 87 | HttpRouter.add("POST", "/api/hub/manage", manage.pipe(errors)), | ||
| 88 | HttpRouter.add("*", "/environments/:environmentId/*", proxy.pipe(errors)), | ||
| 89 | ); | ||
apps/server/src/environment/HubInbox.test.ts created+536| ... | @@ -0,0 +1,536 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | AuthAdministrativeScopes, | ||
| 4 | AuthOrchestrationOperateScope, | ||
| 5 | AuthOrchestrationReadScope, | ||
| 6 | AuthSessionId, | ||
| 7 | CommandId, | ||
| 8 | EnvironmentId, | ||
| 9 | HubError, | ||
| 10 | HubOrganizationCommand, | ||
| 11 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 12 | OrchestrationV2ShellSnapshot, | ||
| 13 | OrchestrationV2ThreadShell, | ||
| 14 | ThreadId, | ||
| 15 | type AuthEnvironmentScope, | ||
| 16 | type ExecutionEnvironmentDescriptor, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 18 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 19 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 20 | import * as ArchiveCatalog from "../archive/ArchiveCatalog.ts"; | ||
| 21 | import * as ArchiveObjects from "../archive/ArchiveObjects.ts"; | ||
| 22 | import * as Context from "effect/Context"; | ||
| 23 | import * as DateTime from "effect/DateTime"; | ||
| 24 | import * as Deferred from "effect/Deferred"; | ||
| 25 | import * as Effect from "effect/Effect"; | ||
| 26 | import * as Fiber from "effect/Fiber"; | ||
| 27 | import * as Layer from "effect/Layer"; | ||
| 28 | import * as Schema from "effect/Schema"; | ||
| 29 | import * as TestClock from "effect/testing/TestClock"; | ||
| 30 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 31 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 32 | import * as Hub from "./Hub.ts"; | ||
| 33 | import * as HubInbox from "./HubInbox.ts"; | ||
| 34 | import { hubInboxThread as thread } from "./HubInbox.testkit.ts"; | ||
| 35 | |||
| 36 | const encodeSnapshot = Schema.encodeEffect(Schema.toCodecJson(OrchestrationV2ShellSnapshot)); | ||
| 37 | const decodeCommand = Schema.decodeEffect( | ||
| 38 | Schema.fromJsonString(Schema.toCodecJson(HubOrganizationCommand)), | ||
| 39 | ); | ||
| 40 | |||
| 41 | const environmentId = EnvironmentId.make("laptop"); | ||
| 42 | const threadId = ThreadId.make("chat"); | ||
| 43 | const session = { | ||
| 44 | sessionId: AuthSessionId.make("phone"), | ||
| 45 | subject: "phone", | ||
| 46 | method: "bearer-access-token" as const, | ||
| 47 | scopes: AuthAdministrativeScopes, | ||
| 48 | }; | ||
| 49 | |||
| 50 | const fixture = Effect.gen(function* () { | ||
| 51 | let snapshot: OrchestrationV2ShellSnapshot = { | ||
| 52 | schemaVersion: 2, | ||
| 53 | snapshotSequence: 0, | ||
| 54 | projects: [], | ||
| 55 | threads: [thread], | ||
| 56 | archivedThreads: [], | ||
| 57 | }; | ||
| 58 | let online = true; | ||
| 59 | let scopes: ReadonlyArray<AuthEnvironmentScope> = AuthAdministrativeScopes; | ||
| 60 | let rejection = false; | ||
| 61 | let beforeDispatch: Effect.Effect<void> = Effect.void; | ||
| 62 | let afterDispatch: Effect.Effect<void> = Effect.void; | ||
| 63 | const commands: Array<HubOrganizationCommand> = []; | ||
| 64 | const client = HttpClient.make((request) => | ||
| 65 | Effect.gen(function* () { | ||
| 66 | if (!online) return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); | ||
| 67 | assert.equal(request.headers.authorization, "Bearer delegated-token"); | ||
| 68 | if (request.method === "GET") | ||
| 69 | return HttpClientResponse.fromWeb(request, Response.json(yield* encodeSnapshot(snapshot))); | ||
| 70 | if (rejection) | ||
| 71 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 409 })); | ||
| 72 | assert.equal(request.url, "http://source.private/api/hub/organization"); | ||
| 73 | if (request.body._tag !== "Uint8Array") return yield* Effect.die("Missing command body"); | ||
| 74 | const command = yield* decodeCommand(new TextDecoder().decode(request.body.body)); | ||
| 75 | commands.push(command); | ||
| 76 | yield* beforeDispatch; | ||
| 77 | const now = yield* DateTime.now; | ||
| 78 | snapshot = { | ||
| 79 | ...snapshot, | ||
| 80 | snapshotSequence: snapshot.snapshotSequence + 1, | ||
| 81 | threads: snapshot.threads.map((value) => { | ||
| 82 | switch (command.type) { | ||
| 83 | case "thread.settle": | ||
| 84 | return { | ||
| 85 | ...value, | ||
| 86 | settledOverride: "settled" as const, | ||
| 87 | settledAt: command.settledAt ?? now, | ||
| 88 | pinnedAt: null, | ||
| 89 | pinOrderKey: null, | ||
| 90 | activeOrderKey: null, | ||
| 91 | }; | ||
| 92 | case "thread.unsettle": | ||
| 93 | return { | ||
| 94 | ...value, | ||
| 95 | settledOverride: "active" as const, | ||
| 96 | settledAt: null, | ||
| 97 | unsettledAt: now, | ||
| 98 | }; | ||
| 99 | case "thread.pin": | ||
| 100 | return { | ||
| 101 | ...value, | ||
| 102 | pinnedAt: now, | ||
| 103 | pinOrderKey: command.orderKey ?? null, | ||
| 104 | snoozedUntil: null, | ||
| 105 | snoozedAt: null, | ||
| 106 | }; | ||
| 107 | case "thread.unpin": | ||
| 108 | return { ...value, pinnedAt: null, pinOrderKey: null }; | ||
| 109 | case "thread.snooze": | ||
| 110 | return { | ||
| 111 | ...value, | ||
| 112 | snoozedUntil: DateTime.makeUnsafe(command.snoozedUntil), | ||
| 113 | snoozedAt: now, | ||
| 114 | }; | ||
| 115 | case "thread.unsnooze": | ||
| 116 | return { ...value, snoozedUntil: null, snoozedAt: null }; | ||
| 117 | case "thread.pin.reorder": | ||
| 118 | return { ...value, pinOrderKey: command.orderKey }; | ||
| 119 | case "thread.active.reorder": | ||
| 120 | return { ...value, activeOrderKey: command.orderKey }; | ||
| 121 | case "thread.auto-settle.set": | ||
| 122 | return { ...value, autoSettleDisabledAt: command.enabled ? null : now }; | ||
| 123 | } | ||
| 124 | }), | ||
| 125 | }; | ||
| 126 | const sequence = snapshot.snapshotSequence; | ||
| 127 | yield* afterDispatch; | ||
| 128 | return HttpClientResponse.fromWeb(request, Response.json({ sequence })); | ||
| 129 | }).pipe(Effect.orDie), | ||
| 130 | ); | ||
| 131 | const descriptor: ExecutionEnvironmentDescriptor = { | ||
| 132 | environmentId, | ||
| 133 | label: "Laptop", | ||
| 134 | platform: { os: "linux", arch: "x64" }, | ||
| 135 | serverVersion: "test", | ||
| 136 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | ||
| 137 | capabilities: { repositoryIdentity: true }, | ||
| 138 | }; | ||
| 139 | const hub = Hub.Hub.of({ | ||
| 140 | directory: (current, options) => { | ||
| 141 | if (current.subject !== "hub-inbox") assert.equal(options?.refresh, false); | ||
| 142 | return Effect.succeed({ | ||
| 143 | hubEnvironmentId: EnvironmentId.make("hub"), | ||
| 144 | environments: [ | ||
| 145 | { | ||
| 146 | environmentId, | ||
| 147 | label: "Laptop", | ||
| 148 | httpBaseUrl: "http://hub/environments/laptop/", | ||
| 149 | descriptor, | ||
| 150 | scopes: scopes.filter((scope) => current.scopes.includes(scope)), | ||
| 151 | }, | ||
| 152 | ], | ||
| 153 | }); | ||
| 154 | }, | ||
| 155 | authorize: (id, current) => | ||
| 156 | id === environmentId | ||
| 157 | ? Effect.succeed(scopes.filter((scope) => current.scopes.includes(scope))) | ||
| 158 | : Effect.fail(new HubError({ reason: "not-found" })), | ||
| 159 | connect: () => | ||
| 160 | Effect.succeed({ | ||
| 161 | url: "http://source.private/", | ||
| 162 | token: "delegated-token", | ||
| 163 | invalidated: Effect.never, | ||
| 164 | }), | ||
| 165 | descriptor: () => Effect.succeed(descriptor), | ||
| 166 | manage: () => Effect.die("Unused"), | ||
| 167 | }); | ||
| 168 | const layer = HubInbox.layer.pipe( | ||
| 169 | Layer.provide( | ||
| 170 | ArchiveCatalog.layer.pipe( | ||
| 171 | Layer.provide(ArchiveObjects.layer), | ||
| 172 | Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), | ||
| 173 | Layer.provide(NodeServices.layer), | ||
| 174 | ), | ||
| 175 | ), | ||
| 176 | Layer.provide(Layer.succeed(Hub.Hub, hub)), | ||
| 177 | Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), | ||
| 178 | ); | ||
| 179 | const restart = Layer.build(layer).pipe( | ||
| 180 | Effect.map((context) => Context.get(context, HubInbox.HubInbox)), | ||
| 181 | ); | ||
| 182 | const inbox = yield* restart; | ||
| 183 | yield* inbox.refresh; | ||
| 184 | yield* inbox.refresh; | ||
| 185 | let sequence = 0; | ||
| 186 | type CommandInput = { | ||
| 187 | [T in HubOrganizationCommand["type"]]: Omit< | ||
| 188 | Extract<HubOrganizationCommand, { type: T }>, | ||
| 189 | "commandId" | "threadId" | ||
| 190 | >; | ||
| 191 | }[HubOrganizationCommand["type"]]; | ||
| 192 | const organize = (command: CommandInput) => | ||
| 193 | inbox.organize( | ||
| 194 | { | ||
| 195 | environmentId, | ||
| 196 | command: { commandId: CommandId.make(`command-${++sequence}`), threadId, ...command }, | ||
| 197 | }, | ||
| 198 | session, | ||
| 199 | ); | ||
| 200 | const read = inbox.read(session).pipe(Effect.map((value) => value.environments[0]!)); | ||
| 201 | return { | ||
| 202 | inbox, | ||
| 203 | restart, | ||
| 204 | organize, | ||
| 205 | read, | ||
| 206 | commands, | ||
| 207 | online: (value: boolean) => { | ||
| 208 | online = value; | ||
| 209 | }, | ||
| 210 | reject: (value: boolean) => { | ||
| 211 | rejection = value; | ||
| 212 | }, | ||
| 213 | scopes: (value: ReadonlyArray<AuthEnvironmentScope>) => { | ||
| 214 | scopes = value; | ||
| 215 | }, | ||
| 216 | source: (update: Partial<OrchestrationV2ThreadShell>) => { | ||
| 217 | snapshot = { | ||
| 218 | ...snapshot, | ||
| 219 | snapshotSequence: snapshot.snapshotSequence + 1, | ||
| 220 | threads: [{ ...snapshot.threads[0]!, ...update }], | ||
| 221 | }; | ||
| 222 | }, | ||
| 223 | beforeDispatch: (effect: Effect.Effect<void>) => { | ||
| 224 | beforeDispatch = effect; | ||
| 225 | }, | ||
| 226 | afterDispatch: (effect: Effect.Effect<void>) => { | ||
| 227 | afterDispatch = effect; | ||
| 228 | }, | ||
| 229 | removeThread: () => { | ||
| 230 | snapshot = { ...snapshot, threads: [] }; | ||
| 231 | }, | ||
| 232 | restoreThread: () => { | ||
| 233 | snapshot = { ...snapshot, threads: [thread] }; | ||
| 234 | }, | ||
| 235 | }; | ||
| 236 | }); | ||
| 237 | |||
| 238 | it.effect("discovers a newly created source chat when organized before the next catalog poll", () => | ||
| 239 | Effect.gen(function* () { | ||
| 240 | const f = yield* fixture; | ||
| 241 | f.removeThread(); | ||
| 242 | yield* f.inbox.refresh; | ||
| 243 | assert.deepEqual((yield* f.read).snapshot.threads, []); | ||
| 244 | f.restoreThread(); | ||
| 245 | const entry = yield* f.organize({ type: "thread.settle" }); | ||
| 246 | assert.equal(entry.snapshot.threads[0]?.settledOverride, "settled"); | ||
| 247 | assert.deepEqual(entry.pendingThreadIds, [threadId]); | ||
| 248 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 249 | ); | ||
| 250 | |||
| 251 | it.effect( | ||
| 252 | "supports the organization commands and reversals without changing execution metadata", | ||
| 253 | () => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const f = yield* fixture; | ||
| 256 | const now = yield* DateTime.now; | ||
| 257 | f.online(false); | ||
| 258 | yield* f.organize({ | ||
| 259 | type: "thread.snooze", | ||
| 260 | snoozedUntil: DateTime.formatIso(DateTime.add(now, { hours: 1 })), | ||
| 261 | }); | ||
| 262 | const settled = yield* f.organize({ type: "thread.settle" }); | ||
| 263 | assert.isNull(settled.snapshot.threads[0]?.snoozedUntil); | ||
| 264 | assert.isNull(settled.snapshot.threads[0]?.snoozedAt); | ||
| 265 | yield* f.organize({ type: "thread.unsettle", reason: "user" }); | ||
| 266 | yield* f.organize({ | ||
| 267 | type: "thread.snooze", | ||
| 268 | snoozedUntil: DateTime.formatIso(DateTime.add(now, { hours: 1 })), | ||
| 269 | }); | ||
| 270 | yield* f.organize({ type: "thread.unsnooze", reason: "user" }); | ||
| 271 | yield* f.organize({ type: "thread.pin", orderKey: "a0" }); | ||
| 272 | const reordered = yield* f.organize({ type: "thread.pin.reorder", orderKey: "a1" }); | ||
| 273 | assert.equal(reordered.snapshot.threads[0]?.pinOrderKey, "a1"); | ||
| 274 | yield* f.organize({ type: "thread.unpin" }); | ||
| 275 | yield* f.organize({ type: "thread.active.reorder", orderKey: "a2" }); | ||
| 276 | yield* f.organize({ type: "thread.auto-settle.set", enabled: false }); | ||
| 277 | const enabled = yield* f.organize({ type: "thread.auto-settle.set", enabled: true }); | ||
| 278 | assert.isNull(enabled.snapshot.threads[0]?.autoSettleDisabledAt); | ||
| 279 | assert.isNull(enabled.snapshot.threads[0]?.pinnedAt); | ||
| 280 | assert.equal(enabled.snapshot.threads[0]?.activeOrderKey, "a2"); | ||
| 281 | assert.equal(enabled.snapshot.threads[0]?.status, "idle"); | ||
| 282 | f.online(true); | ||
| 283 | yield* f.inbox.refresh; | ||
| 284 | assert.deepEqual((yield* f.read).pendingThreadIds, []); | ||
| 285 | assert.equal((yield* f.read).snapshot.threads[0]?.activeOrderKey, "a2"); | ||
| 286 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 287 | ); | ||
| 288 | |||
| 289 | it.effect( | ||
| 290 | "removes pending organization when a fresh source shell no longer contains the chat", | ||
| 291 | () => | ||
| 292 | Effect.gen(function* () { | ||
| 293 | const f = yield* fixture; | ||
| 294 | f.online(false); | ||
| 295 | yield* f.organize({ type: "thread.settle" }); | ||
| 296 | f.removeThread(); | ||
| 297 | f.online(true); | ||
| 298 | yield* f.inbox.refresh; | ||
| 299 | const entry = yield* f.read; | ||
| 300 | assert.deepEqual(entry.pendingThreadIds, []); | ||
| 301 | assert.deepEqual(entry.snapshot.threads, []); | ||
| 302 | assert.deepEqual(f.commands, []); | ||
| 303 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 304 | ); | ||
| 305 | |||
| 306 | it.effect("accepts a newer direct source change after the central command committed", () => | ||
| 307 | Effect.gen(function* () { | ||
| 308 | const f = yield* fixture; | ||
| 309 | yield* f.organize({ type: "thread.settle" }); | ||
| 310 | f.afterDispatch(Effect.sync(() => f.source({ settledOverride: "active", settledAt: null }))); | ||
| 311 | yield* f.inbox.refresh; | ||
| 312 | const entry = yield* f.read; | ||
| 313 | assert.deepEqual(entry.pendingThreadIds, []); | ||
| 314 | assert.equal(entry.snapshot.threads[0]?.settledOverride, "active"); | ||
| 315 | assert.equal(f.commands.filter((value) => value.type === "thread.settle").length, 1); | ||
| 316 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 317 | ); | ||
| 318 | |||
| 319 | it.effect("recognizes new source-authored work without comparing clocks across machines", () => | ||
| 320 | Effect.gen(function* () { | ||
| 321 | const f = yield* fixture; | ||
| 322 | f.source({ latestUserAuthoredMessageAt: DateTime.makeUnsafe("2020-01-01T00:00:00Z") }); | ||
| 323 | yield* f.inbox.refresh; | ||
| 324 | f.online(false); | ||
| 325 | yield* f.organize({ type: "thread.settle" }); | ||
| 326 | f.source({ | ||
| 327 | latestUserAuthoredMessageAt: DateTime.makeUnsafe("2020-01-01T00:00:01Z"), | ||
| 328 | status: "running", | ||
| 329 | }); | ||
| 330 | f.online(true); | ||
| 331 | yield* f.inbox.refresh; | ||
| 332 | assert.equal((yield* f.read).snapshot.threads[0]?.settledOverride, "active"); | ||
| 333 | assert.isFalse(f.commands.some((value) => value.type === "thread.settle")); | ||
| 334 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 335 | ); | ||
| 336 | |||
| 337 | it.effect( | ||
| 338 | "persists offline organization, shares it with another client, and restores it after restart", | ||
| 339 | () => | ||
| 340 | Effect.gen(function* () { | ||
| 341 | const f = yield* fixture; | ||
| 342 | f.online(false); | ||
| 343 | yield* f.inbox.refresh; | ||
| 344 | const settled = yield* f.organize({ type: "thread.settle" }); | ||
| 345 | assert.equal(settled.snapshot.threads[0]?.settledOverride, "settled"); | ||
| 346 | assert.deepEqual(settled.pendingThreadIds, [threadId]); | ||
| 347 | const second = { ...session, sessionId: AuthSessionId.make("second-phone") }; | ||
| 348 | assert.equal( | ||
| 349 | (yield* f.inbox.read(second)).environments[0]?.snapshot.threads[0]?.settledOverride, | ||
| 350 | "settled", | ||
| 351 | ); | ||
| 352 | const restarted = yield* f.restart; | ||
| 353 | assert.equal( | ||
| 354 | (yield* restarted.read(session)).environments[0]?.snapshot.threads[0]?.settledOverride, | ||
| 355 | "settled", | ||
| 356 | ); | ||
| 357 | assert.deepEqual(f.commands, []); | ||
| 358 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 359 | ); | ||
| 360 | |||
| 361 | it.effect( | ||
| 362 | "wakes a settle when a new source message arrives during dispatch and reconciles it active", | ||
| 363 | () => | ||
| 364 | Effect.gen(function* () { | ||
| 365 | const f = yield* fixture; | ||
| 366 | yield* f.organize({ type: "thread.settle" }); | ||
| 367 | const now = yield* DateTime.now; | ||
| 368 | f.beforeDispatch( | ||
| 369 | Effect.sync(() => f.source({ latestUserMessageAt: DateTime.add(now, { seconds: 1 }) })), | ||
| 370 | ); | ||
| 371 | yield* f.inbox.refresh; | ||
| 372 | const raced = yield* f.read; | ||
| 373 | assert.equal(raced.snapshot.threads[0]?.settledOverride, "active"); | ||
| 374 | assert.deepEqual(raced.pendingThreadIds, [threadId]); | ||
| 375 | assert.equal(f.commands.filter((value) => value.type === "thread.settle").length, 1); | ||
| 376 | f.beforeDispatch(Effect.void); | ||
| 377 | yield* f.inbox.refresh; | ||
| 378 | const confirmed = yield* f.read; | ||
| 379 | assert.equal(confirmed.snapshot.threads[0]?.settledOverride, "active"); | ||
| 380 | assert.deepEqual(confirmed.pendingThreadIds, []); | ||
| 381 | assert.equal(f.commands.filter((value) => value.type === "thread.unsettle").length, 1); | ||
| 382 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 383 | ); | ||
| 384 | |||
| 385 | it.effect("reconciles current intent after reconnect without replaying an undone settle", () => | ||
| 386 | Effect.gen(function* () { | ||
| 387 | const f = yield* fixture; | ||
| 388 | f.online(false); | ||
| 389 | yield* f.organize({ type: "thread.settle" }); | ||
| 390 | yield* f.organize({ type: "thread.unsettle", reason: "user" }); | ||
| 391 | yield* f.organize({ type: "thread.pin", orderKey: "a0" }); | ||
| 392 | f.online(true); | ||
| 393 | yield* f.inbox.refresh; | ||
| 394 | assert.isFalse(f.commands.some((value) => value.type === "thread.settle")); | ||
| 395 | const result = yield* f.read; | ||
| 396 | assert.deepEqual(result.pendingThreadIds, []); | ||
| 397 | assert.isNotNull(result.snapshot.threads[0]?.pinnedAt); | ||
| 398 | assert.equal(result.snapshot.threads[0]?.settledOverride, "active"); | ||
| 399 | f.source({ pinnedAt: null, pinOrderKey: null }); | ||
| 400 | yield* f.inbox.refresh; | ||
| 401 | assert.isNull((yield* f.read).snapshot.threads[0]?.pinnedAt); | ||
| 402 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 403 | ); | ||
| 404 | |||
| 405 | it.effect("keeps execution state and pending intent when source settlement is blocked", () => | ||
| 406 | Effect.gen(function* () { | ||
| 407 | const f = yield* fixture; | ||
| 408 | f.source({ status: "running" }); | ||
| 409 | yield* f.inbox.refresh; | ||
| 410 | f.reject(true); | ||
| 411 | yield* f.organize({ type: "thread.settle" }); | ||
| 412 | yield* f.inbox.refresh; | ||
| 413 | const blocked = yield* f.read; | ||
| 414 | assert.equal(blocked.snapshot.threads[0]?.status, "running"); | ||
| 415 | assert.equal(blocked.snapshot.threads[0]?.settledOverride, "settled"); | ||
| 416 | assert.deepEqual(blocked.pendingThreadIds, [threadId]); | ||
| 417 | f.source({ status: "completed" }); | ||
| 418 | f.reject(false); | ||
| 419 | yield* f.inbox.refresh; | ||
| 420 | assert.deepEqual((yield* f.read).pendingThreadIds, []); | ||
| 421 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 422 | ); | ||
| 423 | |||
| 424 | it.effect("expires snooze while the source is offline and never resends an elapsed snooze", () => | ||
| 425 | Effect.gen(function* () { | ||
| 426 | const f = yield* fixture; | ||
| 427 | const now = yield* DateTime.now; | ||
| 428 | f.online(false); | ||
| 429 | yield* f.organize({ | ||
| 430 | type: "thread.snooze", | ||
| 431 | snoozedUntil: DateTime.formatIso(DateTime.add(now, { minutes: 1 })), | ||
| 432 | }); | ||
| 433 | yield* TestClock.adjust("2 minutes"); | ||
| 434 | assert.isNull((yield* f.read).snapshot.threads[0]?.snoozedUntil); | ||
| 435 | f.online(true); | ||
| 436 | yield* f.inbox.refresh; | ||
| 437 | assert.isFalse(f.commands.some((value) => value.type === "thread.snooze")); | ||
| 438 | assert.deepEqual((yield* f.read).pendingThreadIds, []); | ||
| 439 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 440 | ); | ||
| 441 | |||
| 442 | it.effect("checks current hub and enrollment grants before accepting offline changes", () => | ||
| 443 | Effect.gen(function* () { | ||
| 444 | const f = yield* fixture; | ||
| 445 | f.online(false); | ||
| 446 | f.scopes([AuthOrchestrationReadScope]); | ||
| 447 | assert.equal((yield* f.organize({ type: "thread.settle" }).pipe(Effect.flip))._tag, "HubError"); | ||
| 448 | f.scopes(AuthAdministrativeScopes); | ||
| 449 | assert.equal( | ||
| 450 | (yield* f.inbox | ||
| 451 | .organize( | ||
| 452 | { | ||
| 453 | environmentId, | ||
| 454 | command: { type: "thread.settle", commandId: CommandId.make("denied"), threadId }, | ||
| 455 | }, | ||
| 456 | { ...session, scopes: [AuthOrchestrationReadScope] }, | ||
| 457 | ) | ||
| 458 | .pipe(Effect.flip))._tag, | ||
| 459 | "HubError", | ||
| 460 | ); | ||
| 461 | assert.deepEqual( | ||
| 462 | (yield* f.inbox.read({ ...session, scopes: [AuthOrchestrationOperateScope] })).environments, | ||
| 463 | [], | ||
| 464 | ); | ||
| 465 | assert.isNull((yield* f.read).snapshot.threads[0]?.settledOverride); | ||
| 466 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 467 | ); | ||
| 468 | |||
| 469 | it.effect("does not acknowledge a newer command while an older source request is in flight", () => | ||
| 470 | Effect.gen(function* () { | ||
| 471 | const f = yield* fixture; | ||
| 472 | yield* f.organize({ type: "thread.settle" }); | ||
| 473 | const started = yield* Deferred.make<void>(); | ||
| 474 | const release = yield* Deferred.make<void>(); | ||
| 475 | f.beforeDispatch( | ||
| 476 | Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), | ||
| 477 | ); | ||
| 478 | const refresh = yield* f.inbox.refresh.pipe(Effect.forkScoped); | ||
| 479 | yield* Deferred.await(started); | ||
| 480 | yield* f.organize({ type: "thread.unsettle", reason: "user" }); | ||
| 481 | const releaseReversal = yield* Deferred.make<void>(); | ||
| 482 | f.beforeDispatch(Deferred.await(releaseReversal)); | ||
| 483 | yield* Deferred.succeed(release, undefined); | ||
| 484 | const pending = yield* f.read; | ||
| 485 | assert.equal(pending.snapshot.threads[0]?.settledOverride, "active"); | ||
| 486 | assert.deepEqual(pending.pendingThreadIds, [threadId]); | ||
| 487 | yield* Deferred.succeed(releaseReversal, undefined); | ||
| 488 | yield* Fiber.join(refresh); | ||
| 489 | yield* f.inbox.refresh; | ||
| 490 | assert.deepEqual((yield* f.read).pendingThreadIds, []); | ||
| 491 | assert.equal((yield* f.read).snapshot.threads[0]?.settledOverride, "active"); | ||
| 492 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 493 | ); | ||
| 494 | |||
| 495 | it.effect( | ||
| 496 | "deduplicates retried commands after later organization changes and acknowledgement", | ||
| 497 | () => | ||
| 498 | Effect.gen(function* () { | ||
| 499 | const f = yield* fixture; | ||
| 500 | const command = { | ||
| 501 | type: "thread.settle" as const, | ||
| 502 | threadId, | ||
| 503 | commandId: CommandId.make("retry-settle"), | ||
| 504 | }; | ||
| 505 | yield* f.inbox.organize({ environmentId, command }, session); | ||
| 506 | yield* f.inbox.refresh; | ||
| 507 | yield* f.organize({ type: "thread.unsettle", reason: "user" }); | ||
| 508 | yield* f.inbox.refresh; | ||
| 509 | const retry = yield* f.inbox.organize({ environmentId, command }, session); | ||
| 510 | assert.equal(retry.snapshot.threads[0]?.settledOverride, "active"); | ||
| 511 | assert.deepEqual(retry.pendingThreadIds, []); | ||
| 512 | assert.equal(f.commands.filter((value) => value.type === "thread.settle").length, 1); | ||
| 513 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 514 | ); | ||
| 515 | |||
| 516 | it.effect( | ||
| 517 | "wakes a pending snooze on fresh source completion and preserves its execution status", | ||
| 518 | () => | ||
| 519 | Effect.gen(function* () { | ||
| 520 | const f = yield* fixture; | ||
| 521 | const now = yield* DateTime.now; | ||
| 522 | f.online(false); | ||
| 523 | yield* f.organize({ | ||
| 524 | type: "thread.snooze", | ||
| 525 | snoozedUntil: DateTime.formatIso(DateTime.add(now, { hours: 1 })), | ||
| 526 | }); | ||
| 527 | f.source({ status: "completed", latestRunCompletedAt: DateTime.add(now, { minutes: 1 }) }); | ||
| 528 | f.online(true); | ||
| 529 | yield* f.inbox.refresh; | ||
| 530 | const entry = yield* f.read; | ||
| 531 | assert.isNull(entry.snapshot.threads[0]?.snoozedUntil); | ||
| 532 | assert.equal(entry.snapshot.threads[0]?.status, "completed"); | ||
| 533 | assert.deepEqual(entry.pendingThreadIds, []); | ||
| 534 | assert.isFalse(f.commands.some((value) => value.type === "thread.snooze")); | ||
| 535 | }).pipe(Effect.provide(SqlitePersistence.layerMemory), Effect.scoped), | ||
| 536 | ); | ||
apps/server/src/environment/HubInbox.testkit.ts created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import { OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | const threadId = ThreadId.make("chat"); | ||
| 6 | export const hubInboxThread = Schema.decodeSync(OrchestrationV2ThreadShell)({ | ||
| 7 | id: threadId, | ||
| 8 | projectId: "project", | ||
| 9 | title: "Chat on a sleeping computer", | ||
| 10 | providerInstanceId: "codex", | ||
| 11 | modelSelection: { instanceId: "codex", model: "gpt-6" }, | ||
| 12 | runtimeMode: "full-access", | ||
| 13 | interactionMode: "default", | ||
| 14 | branch: null, | ||
| 15 | worktreePath: null, | ||
| 16 | activeProviderThreadId: null, | ||
| 17 | lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null }, | ||
| 18 | forkedFrom: null, | ||
| 19 | createdBy: "user", | ||
| 20 | creationSource: "web", | ||
| 21 | createdAt: DateTime.makeUnsafe(0), | ||
| 22 | updatedAt: DateTime.makeUnsafe(0), | ||
| 23 | archivedAt: null, | ||
| 24 | settledOverride: null, | ||
| 25 | settledAt: null, | ||
| 26 | snoozedUntil: null, | ||
| 27 | snoozedAt: null, | ||
| 28 | pinnedAt: null, | ||
| 29 | pinOrderKey: null, | ||
| 30 | activeOrderKey: null, | ||
| 31 | autoSettleDisabledAt: null, | ||
| 32 | lastVisitedAt: null, | ||
| 33 | deletedAt: null, | ||
| 34 | latestRunId: null, | ||
| 35 | activeRunId: null, | ||
| 36 | status: "idle", | ||
| 37 | pendingRuntimeRequest: null, | ||
| 38 | latestVisibleMessage: null, | ||
| 39 | latestUserMessageAt: null, | ||
| 40 | hasActionableProposedPlan: false, | ||
| 41 | itemCount: 0, | ||
| 42 | visibleItemCount: 0, | ||
| 43 | }); | ||
apps/server/src/environment/HubInbox.ts created+661| ... | @@ -0,0 +1,661 @@ | ||
| 1 | import { | ||
| 2 | AuthAdministrativeScopes, | ||
| 3 | AuthArchiveReadScope, | ||
| 4 | AuthOrchestrationOperateScope, | ||
| 5 | AuthOrchestrationReadScope, | ||
| 6 | AuthSessionId, | ||
| 7 | CommandId, | ||
| 8 | HubError, | ||
| 9 | HubInboxEnvironment, | ||
| 10 | HubOrganizationCommand, | ||
| 11 | HUB_THREAD_ORGANIZATION_FIELDS, | ||
| 12 | ORCHESTRATION_PROTOCOL_HEADER, | ||
| 13 | ORCHESTRATION_PROTOCOL_VERSION_TEXT, | ||
| 14 | OrchestrationV2ShellSnapshot, | ||
| 15 | OrchestrationV2ThreadShell, | ||
| 16 | type EnvironmentId, | ||
| 17 | type HubInbox as HubInboxResult, | ||
| 18 | type HubInboxCommand, | ||
| 19 | type ThreadId, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import * as Clock from "effect/Clock"; | ||
| 22 | import * as Context from "effect/Context"; | ||
| 23 | import * as DateTime from "effect/DateTime"; | ||
| 24 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Layer from "effect/Layer"; | ||
| 26 | import * as Redacted from "effect/Redacted"; | ||
| 27 | import * as Schedule from "effect/Schedule"; | ||
| 28 | import * as Schema from "effect/Schema"; | ||
| 29 | import * as Semaphore from "effect/Semaphore"; | ||
| 30 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 31 | import * as Struct from "effect/Struct"; | ||
| 32 | import { | ||
| 33 | FetchHttpClient, | ||
| 34 | HttpBody, | ||
| 35 | HttpClient, | ||
| 36 | HttpClientRequest, | ||
| 37 | HttpClientResponse, | ||
| 38 | } from "effect/http"; | ||
| 39 | import type { AuthenticatedSession } from "../auth/EnvironmentAuth.ts"; | ||
| 40 | import * as Hub from "./Hub.ts"; | ||
| 41 | import * as ArchiveCatalog from "../archive/ArchiveCatalog.ts"; | ||
| 42 | import { ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION } from "../orchestration-v2/ProjectionStore.ts"; | ||
| 43 | |||
| 44 | const Organization = Schema.Struct( | ||
| 45 | Struct.pick(OrchestrationV2ThreadShell.fields, HUB_THREAD_ORGANIZATION_FIELDS), | ||
| 46 | ); | ||
| 47 | type Organization = typeof Organization.Type; | ||
| 48 | const jsonOrganization = Schema.fromJsonString(Schema.toCodecJson(Organization)); | ||
| 49 | const jsonSnapshot = Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ShellSnapshot)); | ||
| 50 | const SourceActivity = Schema.Struct({ | ||
| 51 | userMessageAt: Schema.NullOr(Schema.Number), | ||
| 52 | completedAt: Schema.NullOr(Schema.Number), | ||
| 53 | pendingRequestId: Schema.NullOr(Schema.String), | ||
| 54 | }); | ||
| 55 | const jsonSourceActivity = Schema.fromJsonString(SourceActivity); | ||
| 56 | const encodeCommand = Schema.encodeEffect(Schema.toCodecJson(HubOrganizationCommand)); | ||
| 57 | const isHubError = Schema.is(HubError); | ||
| 58 | const sourceActivity = (thread: OrchestrationV2ThreadShell): typeof SourceActivity.Type => { | ||
| 59 | const authoredAt = thread.latestUserAuthoredMessageAt ?? thread.latestUserMessageAt; | ||
| 60 | return { | ||
| 61 | userMessageAt: authoredAt == null ? null : DateTime.toEpochMillis(authoredAt), | ||
| 62 | completedAt: | ||
| 63 | thread.latestRunCompletedAt == null | ||
| 64 | ? null | ||
| 65 | : DateTime.toEpochMillis(thread.latestRunCompletedAt), | ||
| 66 | pendingRequestId: thread.pendingRuntimeRequest?.id ?? null, | ||
| 67 | }; | ||
| 68 | }; | ||
| 69 | |||
| 70 | const internalSession: AuthenticatedSession = { | ||
| 71 | sessionId: AuthSessionId.make("hub-inbox"), | ||
| 72 | subject: "hub-inbox", | ||
| 73 | method: "bearer-access-token", | ||
| 74 | scopes: AuthAdministrativeScopes, | ||
| 75 | }; | ||
| 76 | |||
| 77 | export class HubInboxError extends Schema.TaggedError<HubInboxError>()("HubInboxError", { | ||
| 78 | reason: Schema.Literals(["thread-missing", "thread-state", "storage"]), | ||
| 79 | cause: Schema.optional(Schema.Redacted(Schema.Defect())), | ||
| 80 | }) { | ||
| 81 | override get message(): string { | ||
| 82 | switch (this.reason) { | ||
| 83 | case "thread-missing": | ||
| 84 | return "This chat is unavailable. Reconnect its computer to refresh the chat list."; | ||
| 85 | case "thread-state": | ||
| 86 | return "The chat changed. Refresh its status and try again."; | ||
| 87 | case "storage": | ||
| 88 | return "The server could not save the chat. Check its storage and try again."; | ||
| 89 | } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | export class HubInbox extends Context.Service< | ||
| 94 | HubInbox, | ||
| 95 | { | ||
| 96 | readonly read: ( | ||
| 97 | session: AuthenticatedSession, | ||
| 98 | ) => Effect.Effect<HubInboxResult, HubError | HubInboxError>; | ||
| 99 | readonly organize: ( | ||
| 100 | input: HubInboxCommand, | ||
| 101 | session: AuthenticatedSession, | ||
| 102 | ) => Effect.Effect<HubInboxEnvironment, HubError | HubInboxError>; | ||
| 103 | readonly refresh: Effect.Effect<void, HubError | HubInboxError>; | ||
| 104 | } | ||
| 105 | >()("t3/environment/HubInbox") {} | ||
| 106 | |||
| 107 | const applyCommand = ( | ||
| 108 | value: Organization, | ||
| 109 | command: HubOrganizationCommand, | ||
| 110 | now: DateTime.Utc, | ||
| 111 | ): Organization => { | ||
| 112 | switch (command.type) { | ||
| 113 | case "thread.settle": | ||
| 114 | return { | ||
| 115 | ...value, | ||
| 116 | settledOverride: "settled", | ||
| 117 | settledAt: | ||
| 118 | value.settledOverride === "settled" ? value.settledAt : (command.settledAt ?? now), | ||
| 119 | unsettledAt: null, | ||
| 120 | pinnedAt: null, | ||
| 121 | pinOrderKey: null, | ||
| 122 | activeOrderKey: null, | ||
| 123 | snoozedUntil: null, | ||
| 124 | snoozedAt: null, | ||
| 125 | }; | ||
| 126 | case "thread.unsettle": | ||
| 127 | return { | ||
| 128 | ...value, | ||
| 129 | settledOverride: "active", | ||
| 130 | settledAt: null, | ||
| 131 | unsettledAt: value.settledOverride === "active" ? value.unsettledAt : now, | ||
| 132 | }; | ||
| 133 | case "thread.snooze": | ||
| 134 | return { ...value, snoozedUntil: DateTime.makeUnsafe(command.snoozedUntil), snoozedAt: now }; | ||
| 135 | case "thread.unsnooze": | ||
| 136 | return { ...value, snoozedUntil: null, snoozedAt: null }; | ||
| 137 | case "thread.pin": | ||
| 138 | return { | ||
| 139 | ...value, | ||
| 140 | pinnedAt: value.pinnedAt ?? now, | ||
| 141 | pinOrderKey: | ||
| 142 | value.pinnedAt == null ? (command.orderKey ?? value.pinOrderKey) : value.pinOrderKey, | ||
| 143 | settledOverride: value.settledOverride === "settled" ? "active" : value.settledOverride, | ||
| 144 | settledAt: value.settledOverride === "settled" ? null : value.settledAt, | ||
| 145 | snoozedUntil: null, | ||
| 146 | snoozedAt: null, | ||
| 147 | }; | ||
| 148 | case "thread.unpin": | ||
| 149 | return { ...value, pinnedAt: null, pinOrderKey: null }; | ||
| 150 | case "thread.pin.reorder": | ||
| 151 | return { ...value, pinOrderKey: command.orderKey }; | ||
| 152 | case "thread.active.reorder": | ||
| 153 | return { ...value, activeOrderKey: command.orderKey }; | ||
| 154 | case "thread.auto-settle.set": | ||
| 155 | return { | ||
| 156 | ...value, | ||
| 157 | autoSettleDisabledAt: command.enabled ? null : (value.autoSettleDisabledAt ?? now), | ||
| 158 | }; | ||
| 159 | } | ||
| 160 | }; | ||
| 161 | |||
| 162 | const wakeForSourceActivity = ( | ||
| 163 | desired: Organization, | ||
| 164 | baseline: typeof SourceActivity.Type, | ||
| 165 | thread: OrchestrationV2ThreadShell, | ||
| 166 | now: DateTime.Utc, | ||
| 167 | ): Organization => { | ||
| 168 | const current = sourceActivity(thread); | ||
| 169 | const wakeSnooze = | ||
| 170 | desired.snoozedUntil != null && | ||
| 171 | (DateTime.toEpochMillis(desired.snoozedUntil) <= DateTime.toEpochMillis(now) || | ||
| 172 | (current.pendingRequestId !== null && | ||
| 173 | current.pendingRequestId !== baseline.pendingRequestId) || | ||
| 174 | (current.completedAt !== null && | ||
| 175 | current.completedAt !== baseline.completedAt && | ||
| 176 | (thread.status === "completed" || thread.status === "failed"))); | ||
| 177 | const wakeSettlement = | ||
| 178 | desired.settledOverride === "settled" && | ||
| 179 | current.userMessageAt !== null && | ||
| 180 | current.userMessageAt !== baseline.userMessageAt; | ||
| 181 | if (!wakeSnooze && !wakeSettlement) return desired; | ||
| 182 | return { | ||
| 183 | ...desired, | ||
| 184 | ...(wakeSnooze ? { snoozedUntil: null, snoozedAt: null } : {}), | ||
| 185 | ...(wakeSettlement | ||
| 186 | ? ({ settledOverride: "active", settledAt: null, unsettledAt: now } as const) | ||
| 187 | : {}), | ||
| 188 | }; | ||
| 189 | }; | ||
| 190 | |||
| 191 | const sourceCommands = ( | ||
| 192 | thread: OrchestrationV2ThreadShell, | ||
| 193 | desired: Organization, | ||
| 194 | revision: string, | ||
| 195 | ): ReadonlyArray<HubOrganizationCommand> => { | ||
| 196 | const commands: Array<HubOrganizationCommand> = []; | ||
| 197 | const base = { threadId: thread.id, commandId: CommandId.make(`${revision}:settle`) }; | ||
| 198 | if (desired.settledOverride !== thread.settledOverride) { | ||
| 199 | if (desired.settledOverride === "settled") | ||
| 200 | commands.push({ | ||
| 201 | ...base, | ||
| 202 | type: "thread.settle", | ||
| 203 | ...(desired.settledAt == null ? {} : { settledAt: desired.settledAt }), | ||
| 204 | }); | ||
| 205 | if (desired.settledOverride === "active") | ||
| 206 | commands.push({ ...base, type: "thread.unsettle", reason: "user" }); | ||
| 207 | } | ||
| 208 | if ((desired.pinnedAt != null) !== (thread.pinnedAt != null)) | ||
| 209 | commands.push( | ||
| 210 | desired.pinnedAt != null | ||
| 211 | ? { | ||
| 212 | ...base, | ||
| 213 | commandId: CommandId.make(`${revision}:pin`), | ||
| 214 | type: "thread.pin", | ||
| 215 | ...(desired.pinOrderKey == null ? {} : { orderKey: desired.pinOrderKey }), | ||
| 216 | } | ||
| 217 | : { ...base, commandId: CommandId.make(`${revision}:pin`), type: "thread.unpin" }, | ||
| 218 | ); | ||
| 219 | if ( | ||
| 220 | desired.pinnedAt != null && | ||
| 221 | thread.pinnedAt != null && | ||
| 222 | desired.pinOrderKey != null && | ||
| 223 | desired.pinOrderKey !== thread.pinOrderKey | ||
| 224 | ) | ||
| 225 | commands.push({ | ||
| 226 | ...base, | ||
| 227 | commandId: CommandId.make(`${revision}:pin-order`), | ||
| 228 | type: "thread.pin.reorder", | ||
| 229 | orderKey: desired.pinOrderKey, | ||
| 230 | }); | ||
| 231 | if ( | ||
| 232 | desired.pinnedAt == null && | ||
| 233 | desired.settledOverride !== "settled" && | ||
| 234 | desired.activeOrderKey != null && | ||
| 235 | desired.activeOrderKey !== thread.activeOrderKey | ||
| 236 | ) | ||
| 237 | commands.push({ | ||
| 238 | ...base, | ||
| 239 | commandId: CommandId.make(`${revision}:active-order`), | ||
| 240 | type: "thread.active.reorder", | ||
| 241 | orderKey: desired.activeOrderKey, | ||
| 242 | }); | ||
| 243 | if ((desired.autoSettleDisabledAt != null) !== (thread.autoSettleDisabledAt != null)) | ||
| 244 | commands.push({ | ||
| 245 | ...base, | ||
| 246 | commandId: CommandId.make(`${revision}:auto-settle`), | ||
| 247 | type: "thread.auto-settle.set", | ||
| 248 | enabled: desired.autoSettleDisabledAt == null, | ||
| 249 | }); | ||
| 250 | if ( | ||
| 251 | (desired.snoozedUntil == null ? null : DateTime.toEpochMillis(desired.snoozedUntil)) !== | ||
| 252 | (thread.snoozedUntil == null ? null : DateTime.toEpochMillis(thread.snoozedUntil)) | ||
| 253 | ) | ||
| 254 | commands.push( | ||
| 255 | desired.snoozedUntil == null | ||
| 256 | ? { | ||
| 257 | ...base, | ||
| 258 | commandId: CommandId.make(`${revision}:unsnooze`), | ||
| 259 | type: "thread.unsnooze", | ||
| 260 | reason: "user", | ||
| 261 | } | ||
| 262 | : { | ||
| 263 | ...base, | ||
| 264 | commandId: CommandId.make(`${revision}:snooze`), | ||
| 265 | type: "thread.snooze", | ||
| 266 | snoozedUntil: DateTime.formatIso(desired.snoozedUntil), | ||
| 267 | }, | ||
| 268 | ); | ||
| 269 | return commands; | ||
| 270 | }; | ||
| 271 | |||
| 272 | const storageError = (cause: unknown) => | ||
| 273 | new HubInboxError({ reason: "storage", cause: Redacted.make(cause) }); | ||
| 274 | |||
| 275 | const make = Effect.gen(function* () { | ||
| 276 | const hub = yield* Hub.Hub; | ||
| 277 | const archive = yield* ArchiveCatalog.ArchiveCatalog; | ||
| 278 | const sql = yield* SqlClient.SqlClient; | ||
| 279 | const client = yield* HttpClient.HttpClient; | ||
| 280 | const lock = yield* Semaphore.make(1); | ||
| 281 | const refreshLock = yield* Semaphore.make(1); | ||
| 282 | |||
| 283 | const expireSnoozes = Effect.fnUntraced(function* (id: EnvironmentId) { | ||
| 284 | const now = yield* Clock.currentTimeMillis; | ||
| 285 | const rows = yield* sql<{ | ||
| 286 | thread_id: string; | ||
| 287 | command_id: string; | ||
| 288 | organization_json: string; | ||
| 289 | }>`SELECT thread_id, command_id, organization_json FROM hub_inbox_pending WHERE environment_id = ${id}`.pipe( | ||
| 290 | Effect.mapError(storageError), | ||
| 291 | ); | ||
| 292 | for (const row of rows) { | ||
| 293 | const value = yield* Schema.decodeEffect(jsonOrganization)(row.organization_json).pipe( | ||
| 294 | Effect.mapError(storageError), | ||
| 295 | ); | ||
| 296 | if (value.snoozedUntil == null || DateTime.toEpochMillis(value.snoozedUntil) > now) continue; | ||
| 297 | const json = yield* Schema.encodeEffect(jsonOrganization)({ | ||
| 298 | ...value, | ||
| 299 | snoozedUntil: null, | ||
| 300 | snoozedAt: null, | ||
| 301 | }).pipe(Effect.mapError(storageError)); | ||
| 302 | yield* sql`UPDATE hub_inbox_pending SET organization_json = ${json}, command_id = ${`${row.command_id}:expired`} WHERE environment_id = ${id} AND thread_id = ${row.thread_id} AND command_id = ${row.command_id}`.pipe( | ||
| 303 | Effect.mapError(storageError), | ||
| 304 | ); | ||
| 305 | } | ||
| 306 | }); | ||
| 307 | |||
| 308 | const readEnvironment = Effect.fnUntraced(function* (id: EnvironmentId) { | ||
| 309 | yield* expireSnoozes(id); | ||
| 310 | const { rows, pending } = yield* sql | ||
| 311 | .withTransaction( | ||
| 312 | Effect.all({ | ||
| 313 | rows: sql<{ | ||
| 314 | snapshot_json: string; | ||
| 315 | }>`SELECT snapshot_json FROM hub_inbox WHERE environment_id = ${id}`, | ||
| 316 | pending: sql<{ | ||
| 317 | thread_id: ThreadId; | ||
| 318 | organization_json: string; | ||
| 319 | }>`SELECT thread_id, organization_json FROM hub_inbox_pending WHERE environment_id = ${id}`, | ||
| 320 | }), | ||
| 321 | ) | ||
| 322 | .pipe(Effect.mapError(storageError)); | ||
| 323 | const row = rows[0]; | ||
| 324 | if (!row) return undefined; | ||
| 325 | const snapshot = yield* Schema.decodeEffect(jsonSnapshot)(row.snapshot_json).pipe( | ||
| 326 | Effect.mapError(storageError), | ||
| 327 | ); | ||
| 328 | const overrides = new Map( | ||
| 329 | yield* Effect.forEach(pending, (value) => | ||
| 330 | Schema.decodeEffect(jsonOrganization)(value.organization_json).pipe( | ||
| 331 | Effect.map((state) => [value.thread_id, state] as const), | ||
| 332 | Effect.mapError(storageError), | ||
| 333 | ), | ||
| 334 | ), | ||
| 335 | ); | ||
| 336 | return HubInboxEnvironment.make({ | ||
| 337 | environmentId: id, | ||
| 338 | snapshot: { | ||
| 339 | ...snapshot, | ||
| 340 | threads: snapshot.threads.map((thread) => ({ ...thread, ...overrides.get(thread.id) })), | ||
| 341 | }, | ||
| 342 | pendingThreadIds: pending.map((value) => value.thread_id), | ||
| 343 | }); | ||
| 344 | }); | ||
| 345 | |||
| 346 | const request = Effect.fnUntraced(function* <A>( | ||
| 347 | id: EnvironmentId, | ||
| 348 | path: string, | ||
| 349 | decoder: Schema.Decoder<A>, | ||
| 350 | command?: HubOrganizationCommand, | ||
| 351 | ) { | ||
| 352 | const upstream = yield* hub.connect(id, internalSession); | ||
| 353 | let outgoing = HttpClientRequest.make(command ? "POST" : "GET")( | ||
| 354 | new URL(path, upstream.url).href, | ||
| 355 | ).pipe( | ||
| 356 | HttpClientRequest.bearerToken(upstream.token), | ||
| 357 | HttpClientRequest.setHeader( | ||
| 358 | ORCHESTRATION_PROTOCOL_HEADER, | ||
| 359 | ORCHESTRATION_PROTOCOL_VERSION_TEXT, | ||
| 360 | ), | ||
| 361 | ); | ||
| 362 | if (command) | ||
| 363 | outgoing = HttpClientRequest.setBody( | ||
| 364 | outgoing, | ||
| 365 | HttpBody.jsonUnsafe(yield* encodeCommand(command).pipe(Effect.mapError(storageError))), | ||
| 366 | ); | ||
| 367 | return yield* Effect.raceFirst( | ||
| 368 | client.execute(outgoing).pipe( | ||
| 369 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), | ||
| 370 | Effect.flatMap( | ||
| 371 | Effect.fnUntraced(function* (response) { | ||
| 372 | if (response.status !== 200) | ||
| 373 | return yield* new HubError({ | ||
| 374 | reason: | ||
| 375 | response.status === 401 || response.status === 403 ? "credential" : "upstream", | ||
| 376 | }); | ||
| 377 | return yield* HttpClientResponse.schemaBodyJson(decoder)(response); | ||
| 378 | }), | ||
| 379 | ), | ||
| 380 | Effect.timeout("10 seconds"), | ||
| 381 | Effect.mapError((cause) => | ||
| 382 | isHubError(cause) | ||
| 383 | ? cause | ||
| 384 | : new HubError({ reason: "upstream", cause: Redacted.make(cause) }), | ||
| 385 | ), | ||
| 386 | ), | ||
| 387 | upstream.invalidated.pipe( | ||
| 388 | Effect.andThen(Effect.fail(new HubError({ reason: "credential" }))), | ||
| 389 | ), | ||
| 390 | ).pipe(Effect.scoped); | ||
| 391 | }); | ||
| 392 | |||
| 393 | const refreshEnvironment = Effect.fnUntraced(function* (id: EnvironmentId) { | ||
| 394 | yield* expireSnoozes(id); | ||
| 395 | let snapshot = yield* request(id, "api/orchestration/shell", OrchestrationV2ShellSnapshot); | ||
| 396 | const pending = yield* sql<{ | ||
| 397 | thread_id: ThreadId; | ||
| 398 | command_id: string; | ||
| 399 | organization_json: string; | ||
| 400 | source_activity_json: string; | ||
| 401 | }>`SELECT thread_id, command_id, organization_json, source_activity_json FROM hub_inbox_pending WHERE environment_id = ${id}`.pipe( | ||
| 402 | Effect.mapError(storageError), | ||
| 403 | ); | ||
| 404 | const confirmedRevisions: Array<{ threadId: ThreadId; commandId: string; sequence: number }> = | ||
| 405 | []; | ||
| 406 | for (const row of pending) { | ||
| 407 | const thread = snapshot.threads.find((value) => value.id === row.thread_id); | ||
| 408 | if (!thread || thread.archivedAt !== null || thread.deletedAt !== null) { | ||
| 409 | confirmedRevisions.push({ | ||
| 410 | threadId: row.thread_id, | ||
| 411 | commandId: row.command_id, | ||
| 412 | sequence: snapshot.snapshotSequence, | ||
| 413 | }); | ||
| 414 | continue; | ||
| 415 | } | ||
| 416 | let desired = yield* Schema.decodeEffect(jsonOrganization)(row.organization_json).pipe( | ||
| 417 | Effect.mapError(storageError), | ||
| 418 | ); | ||
| 419 | const baseline = yield* Schema.decodeEffect(jsonSourceActivity)( | ||
| 420 | row.source_activity_json, | ||
| 421 | ).pipe(Effect.mapError(storageError)); | ||
| 422 | const updated = wakeForSourceActivity(desired, baseline, thread, yield* DateTime.now); | ||
| 423 | if (updated !== desired) { | ||
| 424 | desired = updated; | ||
| 425 | const json = yield* Schema.encodeEffect(jsonOrganization)(desired).pipe( | ||
| 426 | Effect.mapError(storageError), | ||
| 427 | ); | ||
| 428 | const revision = `${row.command_id}:activity`; | ||
| 429 | yield* sql`UPDATE hub_inbox_pending SET organization_json = ${json}, command_id = ${revision} WHERE environment_id = ${id} AND thread_id = ${row.thread_id} AND command_id = ${row.command_id}`.pipe( | ||
| 430 | Effect.mapError(storageError), | ||
| 431 | ); | ||
| 432 | row.command_id = revision; | ||
| 433 | row.organization_json = json; | ||
| 434 | } | ||
| 435 | const commands = sourceCommands(thread, desired, row.command_id); | ||
| 436 | let dispatched = true; | ||
| 437 | let sequence = snapshot.snapshotSequence; | ||
| 438 | for (const command of commands) { | ||
| 439 | const current = yield* sql<{ | ||
| 440 | command_id: string; | ||
| 441 | }>`SELECT command_id FROM hub_inbox_pending WHERE environment_id = ${id} AND thread_id = ${row.thread_id}`.pipe( | ||
| 442 | Effect.mapError(storageError), | ||
| 443 | ); | ||
| 444 | if (current[0]?.command_id !== row.command_id) { | ||
| 445 | dispatched = false; | ||
| 446 | break; | ||
| 447 | } | ||
| 448 | const result = yield* request( | ||
| 449 | id, | ||
| 450 | "api/hub/organization", | ||
| 451 | Schema.Struct({ sequence: Schema.Number }), | ||
| 452 | command, | ||
| 453 | ).pipe(Effect.result); | ||
| 454 | if (result._tag === "Failure") { | ||
| 455 | dispatched = false; | ||
| 456 | break; | ||
| 457 | } | ||
| 458 | sequence = Math.max(sequence, result.success.sequence); | ||
| 459 | } | ||
| 460 | if (!dispatched) continue; | ||
| 461 | confirmedRevisions.push({ threadId: row.thread_id, commandId: row.command_id, sequence }); | ||
| 462 | } | ||
| 463 | if (confirmedRevisions.some((value) => value.sequence > snapshot.snapshotSequence)) | ||
| 464 | snapshot = yield* request(id, "api/orchestration/shell", OrchestrationV2ShellSnapshot); | ||
| 465 | const json = yield* Schema.encodeEffect(jsonSnapshot)(snapshot).pipe( | ||
| 466 | Effect.mapError(storageError), | ||
| 467 | ); | ||
| 468 | yield* sql | ||
| 469 | .withTransaction( | ||
| 470 | Effect.gen(function* () { | ||
| 471 | yield* sql`INSERT INTO hub_inbox(environment_id, snapshot_json) VALUES (${id}, ${json}) ON CONFLICT(environment_id) DO UPDATE SET snapshot_json = excluded.snapshot_json`; | ||
| 472 | for (const revision of confirmedRevisions.filter( | ||
| 473 | (value) => value.sequence <= snapshot.snapshotSequence, | ||
| 474 | )) { | ||
| 475 | const row = pending.find( | ||
| 476 | (value) => | ||
| 477 | value.thread_id === revision.threadId && value.command_id === revision.commandId, | ||
| 478 | ); | ||
| 479 | const thread = snapshot.threads.find((value) => value.id === revision.threadId); | ||
| 480 | if (row && thread && thread.archivedAt === null && thread.deletedAt === null) { | ||
| 481 | const desired = yield* Schema.decodeEffect(jsonOrganization)( | ||
| 482 | row.organization_json, | ||
| 483 | ).pipe(Effect.mapError(storageError)); | ||
| 484 | const baseline = yield* Schema.decodeEffect(jsonSourceActivity)( | ||
| 485 | row.source_activity_json, | ||
| 486 | ).pipe(Effect.mapError(storageError)); | ||
| 487 | const updated = wakeForSourceActivity(desired, baseline, thread, yield* DateTime.now); | ||
| 488 | if (updated !== desired) { | ||
| 489 | const json = yield* Schema.encodeEffect(jsonOrganization)(updated).pipe( | ||
| 490 | Effect.mapError(storageError), | ||
| 491 | ); | ||
| 492 | yield* sql`UPDATE hub_inbox_pending SET organization_json = ${json}, command_id = ${`${revision.commandId}:activity`} WHERE environment_id = ${id} AND thread_id = ${revision.threadId} AND command_id = ${revision.commandId}`; | ||
| 493 | continue; | ||
| 494 | } | ||
| 495 | } | ||
| 496 | yield* sql`DELETE FROM hub_inbox_pending WHERE environment_id = ${id} AND thread_id = ${revision.threadId} AND command_id = ${revision.commandId}`; | ||
| 497 | } | ||
| 498 | }), | ||
| 499 | ) | ||
| 500 | .pipe(Effect.mapError(storageError)); | ||
| 501 | }); | ||
| 502 | |||
| 503 | const refresh = refreshLock.withPermit( | ||
| 504 | Effect.gen(function* () { | ||
| 505 | const directory = yield* hub.directory(internalSession); | ||
| 506 | yield* Effect.forEach( | ||
| 507 | directory.environments.filter((value) => value.scopes.includes(AuthOrchestrationReadScope)), | ||
| 508 | (value) => | ||
| 509 | refreshEnvironment(value.environmentId).pipe( | ||
| 510 | Effect.catchTags({ | ||
| 511 | HubError: () => Effect.void, | ||
| 512 | }), | ||
| 513 | ), | ||
| 514 | { concurrency: 4, discard: true }, | ||
| 515 | ); | ||
| 516 | }), | ||
| 517 | ); | ||
| 518 | |||
| 519 | yield* refresh.pipe( | ||
| 520 | Effect.catchTags({ | ||
| 521 | HubError: () => Effect.void, | ||
| 522 | HubInboxError: () => Effect.logWarning("Hub inbox storage is unavailable"), | ||
| 523 | }), | ||
| 524 | Effect.repeat(Schedule.spaced("15 seconds")), | ||
| 525 | Effect.forkScoped, | ||
| 526 | ); | ||
| 527 | |||
| 528 | const authorize = Effect.fnUntraced(function* (id: EnvironmentId, session: AuthenticatedSession) { | ||
| 529 | const scopes = yield* hub.authorize(id, session); | ||
| 530 | if ( | ||
| 531 | !scopes.includes(AuthOrchestrationReadScope) || | ||
| 532 | !scopes.includes(AuthOrchestrationOperateScope) | ||
| 533 | ) | ||
| 534 | return yield* new HubError({ reason: "credential" }); | ||
| 535 | }); | ||
| 536 | |||
| 537 | return HubInbox.of({ | ||
| 538 | refresh, | ||
| 539 | read: (session) => | ||
| 540 | Effect.gen(function* () { | ||
| 541 | const directory = yield* hub.directory(session, { refresh: false }); | ||
| 542 | const archived = session.scopes.includes(AuthArchiveReadScope) | ||
| 543 | ? (yield* archive.list.pipe(Effect.orElseSucceed(() => ({ entries: [] })))).entries | ||
| 544 | : []; | ||
| 545 | const values = yield* Effect.forEach( | ||
| 546 | directory.environments.filter((value) => | ||
| 547 | value.scopes.includes(AuthOrchestrationReadScope), | ||
| 548 | ), | ||
| 549 | (value) => | ||
| 550 | Effect.gen(function* () { | ||
| 551 | const cached = yield* readEnvironment(value.environmentId); | ||
| 552 | const entries = archived.filter( | ||
| 553 | (entry) => entry.environmentId === value.environmentId, | ||
| 554 | ); | ||
| 555 | if (!entries.length) return cached; | ||
| 556 | const snapshot = cached?.snapshot ?? { | ||
| 557 | schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, | ||
| 558 | snapshotSequence: 0, | ||
| 559 | projects: [], | ||
| 560 | threads: [], | ||
| 561 | archivedThreads: [], | ||
| 562 | }; | ||
| 563 | const fresh = entries.filter( | ||
| 564 | (entry) => | ||
| 565 | entry.throughSequence > snapshot.snapshotSequence || | ||
| 566 | ![...snapshot.threads, ...snapshot.archivedThreads].some( | ||
| 567 | (thread) => thread.id === entry.thread.id, | ||
| 568 | ), | ||
| 569 | ); | ||
| 570 | const replaced = new Set(fresh.map((entry) => entry.thread.id)); | ||
| 571 | const projects = new Map(snapshot.projects.map((project) => [project.id, project])); | ||
| 572 | for (const entry of fresh) | ||
| 573 | if (!projects.has(entry.project.id)) projects.set(entry.project.id, entry.project); | ||
| 574 | return HubInboxEnvironment.make({ | ||
| 575 | environmentId: value.environmentId, | ||
| 576 | pendingThreadIds: cached?.pendingThreadIds ?? [], | ||
| 577 | snapshot: { | ||
| 578 | ...snapshot, | ||
| 579 | snapshotSequence: Math.max( | ||
| 580 | snapshot.snapshotSequence, | ||
| 581 | ...fresh.map((entry) => entry.throughSequence), | ||
| 582 | ), | ||
| 583 | projects: [...projects.values()], | ||
| 584 | threads: snapshot.threads.filter((thread) => !replaced.has(thread.id)), | ||
| 585 | archivedThreads: [ | ||
| 586 | ...snapshot.archivedThreads.filter((thread) => !replaced.has(thread.id)), | ||
| 587 | ...fresh.map((entry) => entry.thread), | ||
| 588 | ], | ||
| 589 | }, | ||
| 590 | }); | ||
| 591 | }), | ||
| 592 | ); | ||
| 593 | return { environments: values.filter((value) => value !== undefined) }; | ||
| 594 | }), | ||
| 595 | organize: (input, session) => | ||
| 596 | Effect.gen(function* () { | ||
| 597 | yield* authorize(input.environmentId, session); | ||
| 598 | const cached = yield* readEnvironment(input.environmentId); | ||
| 599 | if (!cached?.snapshot.threads.some((value) => value.id === input.command.threadId)) | ||
| 600 | yield* refreshLock.withPermit(refreshEnvironment(input.environmentId)); | ||
| 601 | return yield* lock.withPermit( | ||
| 602 | Effect.gen(function* () { | ||
| 603 | yield* authorize(input.environmentId, session); | ||
| 604 | const entry = yield* readEnvironment(input.environmentId); | ||
| 605 | const receipt = | ||
| 606 | yield* sql`SELECT 1 FROM hub_inbox_commands WHERE environment_id = ${input.environmentId} AND command_id = ${input.command.commandId}`.pipe( | ||
| 607 | Effect.mapError(storageError), | ||
| 608 | ); | ||
| 609 | if (entry && receipt.length > 0) return entry; | ||
| 610 | const thread = entry?.snapshot.threads.find( | ||
| 611 | (value) => value.id === input.command.threadId, | ||
| 612 | ); | ||
| 613 | if (!entry || !thread || thread.deletedAt !== null) | ||
| 614 | return yield* new HubInboxError({ reason: "thread-missing" }); | ||
| 615 | const command = input.command; | ||
| 616 | const now = yield* DateTime.now; | ||
| 617 | if ( | ||
| 618 | thread.archivedAt !== null || | ||
| 619 | (command.type === "thread.pin.reorder" && thread.pinnedAt == null) || | ||
| 620 | (command.type === "thread.active.reorder" && | ||
| 621 | (thread.pinnedAt != null || thread.settledOverride === "settled")) || | ||
| 622 | (command.type === "thread.snooze" && | ||
| 623 | Date.parse(command.snoozedUntil) <= DateTime.toEpochMillis(now)) | ||
| 624 | ) | ||
| 625 | return yield* new HubInboxError({ reason: "thread-state" }); | ||
| 626 | const desired = applyCommand( | ||
| 627 | Struct.pick(thread, HUB_THREAD_ORGANIZATION_FIELDS), | ||
| 628 | command, | ||
| 629 | now, | ||
| 630 | ); | ||
| 631 | const json = yield* Schema.encodeEffect(jsonOrganization)(desired).pipe( | ||
| 632 | Effect.mapError(storageError), | ||
| 633 | ); | ||
| 634 | const baseline = yield* Schema.encodeEffect(jsonSourceActivity)( | ||
| 635 | sourceActivity(thread), | ||
| 636 | ).pipe(Effect.mapError(storageError)); | ||
| 637 | yield* sql | ||
| 638 | .withTransaction( | ||
| 639 | Effect.gen(function* () { | ||
| 640 | yield* sql`INSERT INTO hub_inbox_pending(environment_id, thread_id, command_id, organization_json, source_activity_json) VALUES (${input.environmentId}, ${command.threadId}, ${command.commandId}, ${json}, ${baseline}) ON CONFLICT(environment_id, thread_id) DO UPDATE SET command_id = excluded.command_id, organization_json = excluded.organization_json, source_activity_json = excluded.source_activity_json`; | ||
| 641 | yield* sql`INSERT INTO hub_inbox_commands(environment_id, command_id) VALUES (${input.environmentId}, ${command.commandId})`; | ||
| 642 | }), | ||
| 643 | ) | ||
| 644 | .pipe(Effect.mapError(storageError)); | ||
| 645 | return { | ||
| 646 | ...entry, | ||
| 647 | pendingThreadIds: [...new Set([...entry.pendingThreadIds, thread.id])], | ||
| 648 | snapshot: { | ||
| 649 | ...entry.snapshot, | ||
| 650 | threads: entry.snapshot.threads.map((value) => | ||
| 651 | value.id === thread.id ? { ...value, ...desired } : value, | ||
| 652 | ), | ||
| 653 | }, | ||
| 654 | }; | ||
| 655 | }), | ||
| 656 | ); | ||
| 657 | }), | ||
| 658 | }); | ||
| 659 | }); | ||
| 660 | |||
| 661 | export const layer = Layer.effect(HubInbox, make); | ||
apps/server/src/environment/HubInboxHttp.test.ts created+126| ... | @@ -0,0 +1,126 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { | ||
| 4 | AuthOrchestrationOperateScope, | ||
| 5 | AuthOrchestrationReadScope, | ||
| 6 | AuthSessionId, | ||
| 7 | type HubOrganizationCommand, | ||
| 8 | HubInbox as HubInboxSchema, | ||
| 9 | HubInboxEnvironment, | ||
| 10 | EnvironmentId, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import { HttpRouter } from "effect/http"; | ||
| 17 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 18 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | ||
| 19 | import * as HubInbox from "./HubInbox.ts"; | ||
| 20 | import * as HubInboxHttp from "./HubInboxHttp.ts"; | ||
| 21 | import { hubInboxThread } from "./HubInbox.testkit.ts"; | ||
| 22 | |||
| 23 | const decodeInbox = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInboxSchema)); | ||
| 24 | |||
| 25 | it.effect( | ||
| 26 | "authenticates inbox requests and restricts source reconciliation to organization commands", | ||
| 27 | () => | ||
| 28 | Effect.gen(function* () { | ||
| 29 | const dispatched: Array<HubOrganizationCommand["type"]> = []; | ||
| 30 | let entry: HubInboxEnvironment = { | ||
| 31 | environmentId: EnvironmentId.make("laptop"), | ||
| 32 | snapshot: { | ||
| 33 | schemaVersion: 2, | ||
| 34 | snapshotSequence: 1, | ||
| 35 | projects: [], | ||
| 36 | threads: [ | ||
| 37 | { | ||
| 38 | ...hubInboxThread, | ||
| 39 | snoozedUntil: DateTime.makeUnsafe("2030-01-01T00:00:00Z"), | ||
| 40 | snoozedAt: DateTime.makeUnsafe(0), | ||
| 41 | }, | ||
| 42 | ], | ||
| 43 | archivedThreads: [], | ||
| 44 | }, | ||
| 45 | pendingThreadIds: [hubInboxThread.id], | ||
| 46 | }; | ||
| 47 | const auth = { | ||
| 48 | authenticateWebSocketUpgrade: ( | ||
| 49 | request: import("effect/http/HttpServerRequest").HttpServerRequest, | ||
| 50 | ) => | ||
| 51 | request.headers.authorization | ||
| 52 | ? Effect.succeed({ | ||
| 53 | sessionId: AuthSessionId.make("phone"), | ||
| 54 | subject: "phone", | ||
| 55 | method: "bearer-access-token" as const, | ||
| 56 | scopes: | ||
| 57 | request.headers.authorization === "Bearer operate" | ||
| 58 | ? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] | ||
| 59 | : [AuthOrchestrationReadScope], | ||
| 60 | }) | ||
| 61 | : Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError({})), | ||
| 62 | } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]; | ||
| 63 | const orchestrator = { | ||
| 64 | dispatch: (command: HubOrganizationCommand) => | ||
| 65 | Effect.sync(() => { | ||
| 66 | dispatched.push(command.type); | ||
| 67 | return { sequence: 1 }; | ||
| 68 | }), | ||
| 69 | } as unknown as Orchestrator.OrchestratorV2["Service"]; | ||
| 70 | const { handler, dispose } = HttpRouter.toWebHandler( | ||
| 71 | HubInboxHttp.layer.pipe( | ||
| 72 | Layer.provideMerge( | ||
| 73 | Layer.mergeAll( | ||
| 74 | Layer.succeed(EnvironmentAuth.EnvironmentAuth, auth), | ||
| 75 | Layer.succeed(Orchestrator.OrchestratorV2, orchestrator), | ||
| 76 | Layer.succeed(HubInbox.HubInbox, { | ||
| 77 | read: () => Effect.succeed({ environments: [entry] }), | ||
| 78 | organize: () => Effect.succeed(entry), | ||
| 79 | refresh: Effect.void, | ||
| 80 | }), | ||
| 81 | ), | ||
| 82 | ), | ||
| 83 | Layer.provideMerge(NodeServices.layer), | ||
| 84 | ), | ||
| 85 | ); | ||
| 86 | yield* Effect.addFinalizer(() => Effect.promise(dispose)); | ||
| 87 | const send = (path: string, token?: string, type?: string, etag?: string) => | ||
| 88 | Effect.promise(() => | ||
| 89 | handler( | ||
| 90 | new Request(`http://hub${path}`, { | ||
| 91 | method: type ? "POST" : "GET", | ||
| 92 | headers: { | ||
| 93 | ...(token ? { authorization: `Bearer ${token}` } : {}), | ||
| 94 | "content-type": "application/json", | ||
| 95 | ...(etag ? { "if-none-match": etag } : {}), | ||
| 96 | }, | ||
| 97 | ...(type | ||
| 98 | ? { body: JSON.stringify({ type, threadId: "chat", commandId: "command" }) } | ||
| 99 | : {}), | ||
| 100 | }), | ||
| 101 | ), | ||
| 102 | ); | ||
| 103 | assert.equal((yield* send("/api/hub/inbox")).status, 401); | ||
| 104 | const first = yield* send("/api/hub/inbox", "read"); | ||
| 105 | assert.equal(first.status, 200); | ||
| 106 | const decoded = yield* decodeInbox(yield* Effect.promise(() => first.json())); | ||
| 107 | assert.equal( | ||
| 108 | DateTime.formatIso(decoded.environments[0]!.snapshot.threads[0]!.snoozedUntil!), | ||
| 109 | "2030-01-01T00:00:00.000Z", | ||
| 110 | ); | ||
| 111 | const etag = first.headers.get("etag")!; | ||
| 112 | for (const validator of [etag, `W/${etag}`, `"other", W/${etag}`, "*"]) | ||
| 113 | assert.equal((yield* send("/api/hub/inbox", "read", undefined, validator)).status, 304); | ||
| 114 | assert.equal( | ||
| 115 | (yield* send("/api/hub/inbox", "read", undefined, '"other", W/"different"')).status, | ||
| 116 | 200, | ||
| 117 | ); | ||
| 118 | entry = { ...entry, pendingThreadIds: [] }; | ||
| 119 | assert.equal((yield* send("/api/hub/inbox", "read", undefined, etag)).status, 200); | ||
| 120 | assert.equal((yield* send("/api/hub/organization", "read", "thread.settle")).status, 403); | ||
| 121 | assert.equal((yield* send("/api/hub/organization", "operate", "thread.stop")).status, 400); | ||
| 122 | assert.deepEqual(dispatched, []); | ||
| 123 | assert.equal((yield* send("/api/hub/organization", "operate", "thread.settle")).status, 200); | ||
| 124 | assert.deepEqual(dispatched, ["thread.settle"]); | ||
| 125 | }).pipe(Effect.scoped), | ||
| 126 | ); | ||
apps/server/src/environment/HubInboxHttp.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | import { | ||
| 2 | AuthOrchestrationOperateScope, | ||
| 3 | HubError, | ||
| 4 | HubInboxCommand, | ||
| 5 | HubOrganizationCommand, | ||
| 6 | HubInbox as HubInboxSchema, | ||
| 7 | HubInboxEnvironment, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Crypto from "effect/Crypto"; | ||
| 11 | import * as Hex from "effect/encoding/Hex"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Schema from "effect/Schema"; | ||
| 14 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; | ||
| 15 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 16 | import * as HubInbox from "./HubInbox.ts"; | ||
| 17 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | ||
| 18 | |||
| 19 | const isHubError = Schema.is(HubError); | ||
| 20 | const isInboxError = Schema.is(HubInbox.HubInboxError); | ||
| 21 | const isDispatchError = Schema.is(Orchestrator.OrchestratorDispatchError); | ||
| 22 | const encodeInbox = Schema.encodeEffect(Schema.fromJsonString(Schema.toCodecJson(HubInboxSchema))); | ||
| 23 | const encodeEnvironment = Schema.encodeEffect(Schema.toCodecJson(HubInboxEnvironment)); | ||
| 24 | |||
| 25 | const authenticate = Effect.gen(function* () { | ||
| 26 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 27 | return yield* auth.authenticateWebSocketUpgrade(yield* HttpServerRequest.HttpServerRequest); | ||
| 28 | }); | ||
| 29 | |||
| 30 | const errors = <E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) => | ||
| 31 | effect.pipe( | ||
| 32 | Effect.catch((error) => { | ||
| 33 | if (EnvironmentAuth.isServerAuthCredentialError(error)) | ||
| 34 | return Effect.succeed(HttpServerResponse.empty({ status: 401 })); | ||
| 35 | if (isHubError(error)) | ||
| 36 | return Effect.succeed( | ||
| 37 | HttpServerResponse.jsonUnsafe( | ||
| 38 | { message: error.message, reason: error.reason }, | ||
| 39 | { | ||
| 40 | status: | ||
| 41 | error.reason === "credential" | ||
| 42 | ? 403 | ||
| 43 | : error.reason === "configuration" || error.reason === "not-found" | ||
| 44 | ? 404 | ||
| 45 | : 502, | ||
| 46 | }, | ||
| 47 | ), | ||
| 48 | ); | ||
| 49 | if (isInboxError(error)) | ||
| 50 | return Effect.succeed( | ||
| 51 | HttpServerResponse.jsonUnsafe( | ||
| 52 | { message: error.message, reason: error.reason }, | ||
| 53 | { | ||
| 54 | status: | ||
| 55 | error.reason === "thread-missing" | ||
| 56 | ? 404 | ||
| 57 | : error.reason === "thread-state" | ||
| 58 | ? 409 | ||
| 59 | : 500, | ||
| 60 | }, | ||
| 61 | ), | ||
| 62 | ); | ||
| 63 | if (Schema.isSchemaError(error)) | ||
| 64 | return Effect.succeed(HttpServerResponse.empty({ status: 400 })); | ||
| 65 | if (isDispatchError(error)) | ||
| 66 | return Effect.succeed( | ||
| 67 | HttpServerResponse.jsonUnsafe( | ||
| 68 | { message: new HubInbox.HubInboxError({ reason: "thread-state" }).message }, | ||
| 69 | { status: 409 }, | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | return Effect.succeed(HttpServerResponse.empty({ status: 500 })); | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | |||
| 76 | export const layer = Layer.mergeAll( | ||
| 77 | HttpRouter.add( | ||
| 78 | "GET", | ||
| 79 | "/api/hub/inbox", | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const value = yield* (yield* HubInbox.HubInbox).read(yield* authenticate); | ||
| 82 | const body = yield* encodeInbox(value); | ||
| 83 | const digest = yield* (yield* Crypto.Crypto).digest( | ||
| 84 | "SHA-256", | ||
| 85 | new TextEncoder().encode(body), | ||
| 86 | ); | ||
| 87 | const etag = `"${Hex.encode(digest)}"`; | ||
| 88 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 89 | const headers = { etag, "cache-control": "no-store" }; | ||
| 90 | const unchanged = request.headers["if-none-match"]?.split(",").some((value) => { | ||
| 91 | const candidate = value.trim(); | ||
| 92 | return candidate === "*" || candidate.replace(/^W\//, "") === etag; | ||
| 93 | }); | ||
| 94 | return unchanged | ||
| 95 | ? HttpServerResponse.empty({ status: 304, headers }) | ||
| 96 | : HttpServerResponse.text(body, { | ||
| 97 | headers: { ...headers, "content-type": "application/json" }, | ||
| 98 | }); | ||
| 99 | }).pipe(errors), | ||
| 100 | ), | ||
| 101 | HttpRouter.add( | ||
| 102 | "POST", | ||
| 103 | "/api/hub/inbox", | ||
| 104 | Effect.gen(function* () { | ||
| 105 | const session = yield* authenticate; | ||
| 106 | const input = yield* HttpServerRequest.schemaBodyJson(HubInboxCommand); | ||
| 107 | const value = yield* (yield* HubInbox.HubInbox).organize(input, session); | ||
| 108 | return HttpServerResponse.jsonUnsafe(yield* encodeEnvironment(value), { | ||
| 109 | headers: { "cache-control": "no-store" }, | ||
| 110 | }); | ||
| 111 | }).pipe(errors), | ||
| 112 | ), | ||
| 113 | HttpRouter.add( | ||
| 114 | "POST", | ||
| 115 | "/api/hub/organization", | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const session = yield* authenticate; | ||
| 118 | if (!session.scopes.includes(AuthOrchestrationOperateScope)) | ||
| 119 | return yield* new HubError({ reason: "credential" }); | ||
| 120 | const command = yield* HttpServerRequest.schemaBodyJson(HubOrganizationCommand); | ||
| 121 | const result = yield* (yield* Orchestrator.OrchestratorV2).dispatch(command); | ||
| 122 | return HttpServerResponse.jsonUnsafe( | ||
| 123 | { sequence: result.sequence }, | ||
| 124 | { headers: { "cache-control": "no-store" } }, | ||
| 125 | ); | ||
| 126 | }).pipe(errors), | ||
| 127 | ), | ||
| 128 | ); | ||
apps/server/src/environment/HubProxy.auth.test.ts created+127| ... | @@ -0,0 +1,127 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { AuthOrchestrationReadScope, EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import { computeDpopAccessTokenHash, computeDpopJwkThumbprint } from "@t3tools/shared/dpop"; | ||
| 5 | import * as Clock from "effect/Clock"; | ||
| 6 | import * as Context from "effect/Context"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http"; | ||
| 10 | import { exportJWK, generateKeyPair, SignJWT } from "jose"; | ||
| 11 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 12 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 13 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 14 | import * as ServerConfig from "../config.ts"; | ||
| 15 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 16 | import * as ServerEnvironment from "./ServerEnvironment.ts"; | ||
| 17 | import * as Hub from "./Hub.ts"; | ||
| 18 | import * as HubHttp from "./HubHttp.ts"; | ||
| 19 | import * as HubProxy from "./HubProxy.ts"; | ||
| 20 | |||
| 21 | it.live( | ||
| 22 | "verifies DPoP against the prefixed hub URL, rejects replay and revoked clients, and never sends their proof to a leaf", | ||
| 23 | () => | ||
| 24 | Effect.gen(function* () { | ||
| 25 | const config = ServerConfig.layerTest(process.cwd(), { prefix: "t3-hub-auth-test-" }); | ||
| 26 | const services = yield* Layer.build( | ||
| 27 | EnvironmentAuth.layer.pipe( | ||
| 28 | Layer.provideMerge(SqlitePersistence.layerMemory), | ||
| 29 | Layer.provide(ServerSecretStore.layer), | ||
| 30 | Layer.provide(ServerEnvironment.layerIdentity), | ||
| 31 | Layer.provide(config), | ||
| 32 | ), | ||
| 33 | ); | ||
| 34 | const auth = Context.get(services, EnvironmentAuth.EnvironmentAuth); | ||
| 35 | const sessions = Context.get(services, SessionStore.SessionStore); | ||
| 36 | const keys = yield* Effect.promise(() => generateKeyPair("ES256")); | ||
| 37 | const exported = yield* Effect.promise(() => exportJWK(keys.publicKey)); | ||
| 38 | if (!exported.x || !exported.y) return yield* Effect.die("Missing public key coordinates"); | ||
| 39 | const jwk = { kty: "EC" as const, crv: "P-256" as const, x: exported.x, y: exported.y }; | ||
| 40 | const token = yield* sessions.issue({ | ||
| 41 | method: "dpop-access-token", | ||
| 42 | subject: "phone", | ||
| 43 | proofKeyThumbprint: computeDpopJwkThumbprint(jwk), | ||
| 44 | scopes: [AuthOrchestrationReadScope], | ||
| 45 | }); | ||
| 46 | const requests: Array<{ url: string; authorization?: string; proof?: string }> = []; | ||
| 47 | const hub = Hub.Hub.of({ | ||
| 48 | authorize: () => Effect.succeed([AuthOrchestrationReadScope]), | ||
| 49 | descriptor: () => Effect.die("unused"), | ||
| 50 | directory: () => | ||
| 51 | Effect.succeed({ hubEnvironmentId: EnvironmentId.make("hub"), environments: [] }), | ||
| 52 | manage: () => Effect.die("unused"), | ||
| 53 | connect: () => | ||
| 54 | Effect.succeed({ | ||
| 55 | url: "http://leaf.private/", | ||
| 56 | token: "leaf-private-token", | ||
| 57 | invalidated: Effect.never, | ||
| 58 | }), | ||
| 59 | }); | ||
| 60 | const client = HttpClient.make((request) => | ||
| 61 | Effect.sync(() => { | ||
| 62 | requests.push({ | ||
| 63 | url: request.url, | ||
| 64 | ...(request.headers.authorization | ||
| 65 | ? { authorization: request.headers.authorization } | ||
| 66 | : {}), | ||
| 67 | ...(request.headers.dpop ? { proof: request.headers.dpop } : {}), | ||
| 68 | }); | ||
| 69 | return HttpClientResponse.fromWeb(request, new Response("leaf response")); | ||
| 70 | }), | ||
| 71 | ); | ||
| 72 | const dependencies = Layer.mergeAll( | ||
| 73 | Layer.succeedContext(services), | ||
| 74 | Layer.succeed(Hub.Hub, hub), | ||
| 75 | Layer.succeed(HttpClient.HttpClient, client), | ||
| 76 | ); | ||
| 77 | const { handler, dispose } = HttpRouter.toWebHandler( | ||
| 78 | HubHttp.layer.pipe( | ||
| 79 | Layer.provideMerge(HubProxy.layer.pipe(Layer.provide(dependencies))), | ||
| 80 | Layer.provideMerge(dependencies), | ||
| 81 | ), | ||
| 82 | { disableLogger: true }, | ||
| 83 | ); | ||
| 84 | yield* Effect.addFinalizer(() => Effect.promise(dispose)); | ||
| 85 | const publicUrl = "https://hub.example/environments/leaf/api/example"; | ||
| 86 | const iat = Math.floor((yield* Clock.currentTimeMillis) / 1_000); | ||
| 87 | const proof = (url: string, id: string) => | ||
| 88 | Effect.promise(() => | ||
| 89 | new SignJWT({ | ||
| 90 | htm: "GET", | ||
| 91 | htu: url, | ||
| 92 | jti: id, | ||
| 93 | iat, | ||
| 94 | ath: computeDpopAccessTokenHash(token.token), | ||
| 95 | }) | ||
| 96 | .setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk }) | ||
| 97 | .sign(keys.privateKey), | ||
| 98 | ); | ||
| 99 | const request = (dpop: string) => | ||
| 100 | Effect.promise(() => | ||
| 101 | handler( | ||
| 102 | new Request(publicUrl, { | ||
| 103 | headers: { | ||
| 104 | authorization: `DPoP ${token.token}`, | ||
| 105 | host: "hub.example", | ||
| 106 | "x-forwarded-proto": "https", | ||
| 107 | dpop, | ||
| 108 | }, | ||
| 109 | }), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | const valid = yield* proof(publicUrl, "first"); | ||
| 113 | const response = yield* request(valid); | ||
| 114 | expect(response.status).toBe(200); | ||
| 115 | expect(yield* Effect.promise(() => response.text())).toBe("leaf response"); | ||
| 116 | expect(requests).toEqual([ | ||
| 117 | { url: "http://leaf.private/api/example", authorization: "Bearer leaf-private-token" }, | ||
| 118 | ]); | ||
| 119 | expect((yield* request(valid)).status).toBe(401); | ||
| 120 | expect( | ||
| 121 | (yield* request(yield* proof("https://hub.example/api/example", "wrong-prefix"))).status, | ||
| 122 | ).toBe(401); | ||
| 123 | yield* auth.revokeSession(token.sessionId); | ||
| 124 | expect((yield* request(yield* proof(publicUrl, "revoked"))).status).toBe(401); | ||
| 125 | expect(requests).toHaveLength(1); | ||
| 126 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 127 | ); | ||
apps/server/src/environment/HubProxy.stream.test.ts created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | ||
| 3 | import { NodeWS } from "@effect/platform-node/NodeSocket"; | ||
| 4 | import { AuthOrchestrationReadScope, AuthSessionId, EnvironmentId } from "@t3tools/contracts"; | ||
| 5 | import * as Context from "effect/Context"; | ||
| 6 | import * as Deferred from "effect/Deferred"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; | ||
| 10 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 11 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 12 | import * as Hub from "./Hub.ts"; | ||
| 13 | import * as HubHttp from "./HubHttp.ts"; | ||
| 14 | import * as HubProxy from "./HubProxy.ts"; | ||
| 15 | |||
| 16 | it.effect.each(["client", "machine"] as const)( | ||
| 17 | "streams opaque binary frames with a private leaf grant and closes both sockets on %s revocation", | ||
| 18 | (invalidation) => | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const upstream = yield* Effect.acquireRelease( | ||
| 21 | Effect.sync(() => new NodeWS.WebSocketServer({ port: 0 })), | ||
| 22 | (server) => | ||
| 23 | Effect.promise( | ||
| 24 | () => | ||
| 25 | new Promise<void>((resolve) => { | ||
| 26 | for (const client of server.clients) client.terminate(); | ||
| 27 | server.close(() => resolve()); | ||
| 28 | }), | ||
| 29 | ), | ||
| 30 | ); | ||
| 31 | if (!upstream.address()) | ||
| 32 | yield* Effect.promise( | ||
| 33 | () => new Promise<void>((resolve) => upstream.once("listening", resolve)), | ||
| 34 | ); | ||
| 35 | const address = upstream.address(); | ||
| 36 | if (!address || typeof address !== "object") throw new Error("Missing test port"); | ||
| 37 | const closedUpstream = Promise.withResolvers<void>(); | ||
| 38 | const requests: Array<{ path?: string; authorization?: string }> = []; | ||
| 39 | upstream.on("connection", (socket, request) => { | ||
| 40 | requests.push({ | ||
| 41 | ...(request.url ? { path: request.url } : {}), | ||
| 42 | ...(request.headers.authorization | ||
| 43 | ? { authorization: request.headers.authorization } | ||
| 44 | : {}), | ||
| 45 | }); | ||
| 46 | socket.on("message", (data, binary) => socket.send(data, { binary })); | ||
| 47 | socket.on("close", () => closedUpstream.resolve()); | ||
| 48 | socket.send(Uint8Array.of(0x83)); | ||
| 49 | }); | ||
| 50 | const revokedClient = yield* Deferred.make<void>(); | ||
| 51 | const revokedMachine = yield* Deferred.make<void>(); | ||
| 52 | const dependencies = Layer.mergeAll( | ||
| 53 | Layer.succeed(Hub.Hub, { | ||
| 54 | connect: () => | ||
| 55 | Effect.succeed({ | ||
| 56 | url: `http://127.0.0.1:${address.port}/base/`, | ||
| 57 | token: "leaf-private", | ||
| 58 | invalidated: Deferred.await(revokedMachine), | ||
| 59 | }), | ||
| 60 | } as unknown as Hub.Hub["Service"]), | ||
| 61 | Layer.succeed(EnvironmentAuth.EnvironmentAuth, { | ||
| 62 | authenticateWebSocketUpgrade: () => | ||
| 63 | Effect.succeed({ | ||
| 64 | sessionId: AuthSessionId.make("phone"), | ||
| 65 | subject: "phone", | ||
| 66 | method: "bearer-access-token", | ||
| 67 | scopes: [AuthOrchestrationReadScope], | ||
| 68 | }), | ||
| 69 | } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]), | ||
| 70 | Layer.succeed(SessionStore.SessionStore, { | ||
| 71 | awaitInvalidation: () => Deferred.await(revokedClient), | ||
| 72 | } as unknown as SessionStore.SessionStore["Service"]), | ||
| 73 | FetchHttpClient.layer, | ||
| 74 | ); | ||
| 75 | const services = yield* Layer.build( | ||
| 76 | HttpRouter.serve(HubHttp.layer, { disableListenLog: true }).pipe( | ||
| 77 | Layer.provideMerge(HubProxy.layer.pipe(Layer.provide(dependencies))), | ||
| 78 | Layer.provideMerge(dependencies), | ||
| 79 | Layer.provideMerge(NodeHttpServer.layerTest), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | const origin = HttpServer.formatAddress( | ||
| 83 | Context.get(services, HttpServer.HttpServer).address, | ||
| 84 | ).replace(/^http/, "ws"); | ||
| 85 | const client = yield* Effect.acquireRelease( | ||
| 86 | Effect.sync( | ||
| 87 | () => | ||
| 88 | new NodeWS.WebSocket( | ||
| 89 | `${origin}/environments/${EnvironmentId.make("leaf")}/ws?wsTicket=hub-private&protocolVersion=1`, | ||
| 90 | ), | ||
| 91 | ), | ||
| 92 | (socket) => Effect.sync(() => socket.close()), | ||
| 93 | ); | ||
| 94 | client.binaryType = "arraybuffer"; | ||
| 95 | const received = Promise.withResolvers<void>(); | ||
| 96 | const closedClient = Promise.withResolvers<void>(); | ||
| 97 | const frames: number[][] = []; | ||
| 98 | client.addEventListener("error", (event) => received.reject(event.error)); | ||
| 99 | client.addEventListener("close", () => closedClient.resolve()); | ||
| 100 | client.addEventListener("message", ({ data }) => { | ||
| 101 | frames.push(Array.from(new Uint8Array(data as ArrayBuffer))); | ||
| 102 | if (frames.length === 1) client.send(Uint8Array.of(0x03, 1)); | ||
| 103 | else received.resolve(); | ||
| 104 | }); | ||
| 105 | yield* Effect.promise(() => received.promise); | ||
| 106 | expect(frames).toEqual([[0x83], [0x03, 1]]); | ||
| 107 | expect(requests).toEqual([ | ||
| 108 | { path: "/base/ws?protocolVersion=1", authorization: "Bearer leaf-private" }, | ||
| 109 | ]); | ||
| 110 | yield* Deferred.succeed( | ||
| 111 | invalidation === "client" ? revokedClient : revokedMachine, | ||
| 112 | undefined, | ||
| 113 | ); | ||
| 114 | yield* Effect.promise(() => Promise.all([closedClient.promise, closedUpstream.promise])); | ||
| 115 | }), | ||
| 116 | ); | ||
apps/server/src/environment/HubProxy.test.ts created+324| ... | @@ -0,0 +1,324 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | AuthOrchestrationReadScope, | ||
| 4 | AuthOrchestrationOperateScope, | ||
| 5 | AuthSessionId, | ||
| 6 | EnvironmentId, | ||
| 7 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 8 | type ExecutionEnvironmentDescriptor, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Stream from "effect/Stream"; | ||
| 14 | import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http"; | ||
| 15 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 16 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 17 | import * as Hub from "./Hub.ts"; | ||
| 18 | import * as HubHttp from "./HubHttp.ts"; | ||
| 19 | import * as HubProxy from "./HubProxy.ts"; | ||
| 20 | |||
| 21 | const descriptor: ExecutionEnvironmentDescriptor = { | ||
| 22 | environmentId: EnvironmentId.make("leaf"), | ||
| 23 | label: "Laptop", | ||
| 24 | platform: { os: "linux", arch: "x64" }, | ||
| 25 | serverVersion: "test", | ||
| 26 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | ||
| 27 | capabilities: { repositoryIdentity: true }, | ||
| 28 | }; | ||
| 29 | const authDescriptor = { | ||
| 30 | policy: "remote-reachable" as const, | ||
| 31 | bootstrapMethods: ["one-time-token" as const], | ||
| 32 | sessionMethods: ["bearer-access-token" as const], | ||
| 33 | sessionCookieName: "hub-session", | ||
| 34 | }; | ||
| 35 | const session = { | ||
| 36 | sessionId: AuthSessionId.make("phone"), | ||
| 37 | subject: "phone", | ||
| 38 | method: "bearer-access-token" as const, | ||
| 39 | scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], | ||
| 40 | }; | ||
| 41 | |||
| 42 | const fixture = ( | ||
| 43 | responseFactory: ( | ||
| 44 | request: import("effect/http/HttpClientRequest").HttpClientRequest, | ||
| 45 | ) => Response = () => new Response("leaf"), | ||
| 46 | ) => | ||
| 47 | Effect.gen(function* () { | ||
| 48 | const removed = yield* Deferred.make<void>(); | ||
| 49 | const revoked = yield* Deferred.make<void>(); | ||
| 50 | const requests: Array<{ | ||
| 51 | url: string; | ||
| 52 | headers: Readonly<Record<string, string>>; | ||
| 53 | body: string; | ||
| 54 | }> = []; | ||
| 55 | let finalized = 0; | ||
| 56 | const client = HttpClient.make((request, _url, signal) => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | signal.addEventListener("abort", () => { | ||
| 59 | finalized++; | ||
| 60 | }); | ||
| 61 | const body = | ||
| 62 | request.body._tag === "Stream" | ||
| 63 | ? yield* request.body.stream.pipe( | ||
| 64 | Stream.runCollect, | ||
| 65 | Effect.orDie, | ||
| 66 | Effect.map((chunks) => | ||
| 67 | chunks.map((chunk) => new TextDecoder().decode(chunk)).join(""), | ||
| 68 | ), | ||
| 69 | ) | ||
| 70 | : ""; | ||
| 71 | requests.push({ url: request.url, headers: request.headers, body }); | ||
| 72 | return HttpClientResponse.fromWeb(request, responseFactory(request)); | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | const hub = Hub.Hub.of({ | ||
| 76 | authorize: () => Effect.succeed([AuthOrchestrationReadScope]), | ||
| 77 | descriptor: () => Effect.succeed(descriptor), | ||
| 78 | directory: () => | ||
| 79 | Effect.succeed({ | ||
| 80 | hubEnvironmentId: EnvironmentId.make("hub"), | ||
| 81 | environments: [ | ||
| 82 | { | ||
| 83 | ...descriptor, | ||
| 84 | descriptor, | ||
| 85 | httpBaseUrl: "https://hub.test/environments/leaf/", | ||
| 86 | scopes: [AuthOrchestrationReadScope], | ||
| 87 | }, | ||
| 88 | ], | ||
| 89 | }), | ||
| 90 | manage: () => Effect.die("unused"), | ||
| 91 | connect: () => | ||
| 92 | Effect.succeed({ | ||
| 93 | url: "http://leaf.private/base/", | ||
| 94 | token: "private-leaf-token", | ||
| 95 | invalidated: Deferred.await(removed), | ||
| 96 | }), | ||
| 97 | }); | ||
| 98 | const auth = { | ||
| 99 | authenticateWebSocketUpgrade: ( | ||
| 100 | request: import("effect/http/HttpServerRequest").HttpServerRequest, | ||
| 101 | ) => | ||
| 102 | request.headers.authorization || request.url.includes("wsTicket=hub-ticket") | ||
| 103 | ? Effect.succeed(session) | ||
| 104 | : Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError({})), | ||
| 105 | issueWebSocketTicket: () => | ||
| 106 | Effect.succeed({ ticket: "hub-ticket", expiresAt: "2030-01-01T00:00:00.000Z" }), | ||
| 107 | getDescriptor: () => Effect.succeed(authDescriptor), | ||
| 108 | } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]; | ||
| 109 | const dependencies = Layer.mergeAll( | ||
| 110 | Layer.succeed(Hub.Hub, hub), | ||
| 111 | Layer.succeed(EnvironmentAuth.EnvironmentAuth, auth), | ||
| 112 | Layer.succeed(SessionStore.SessionStore, { | ||
| 113 | awaitInvalidation: () => Deferred.await(revoked), | ||
| 114 | } as unknown as SessionStore.SessionStore["Service"]), | ||
| 115 | Layer.succeed(HttpClient.HttpClient, client), | ||
| 116 | ); | ||
| 117 | const { handler, dispose } = HttpRouter.toWebHandler( | ||
| 118 | HubHttp.layer.pipe( | ||
| 119 | Layer.provideMerge(HubProxy.layer.pipe(Layer.provide(dependencies))), | ||
| 120 | Layer.provideMerge(dependencies), | ||
| 121 | ), | ||
| 122 | { disableLogger: true }, | ||
| 123 | ); | ||
| 124 | yield* Effect.addFinalizer(() => Effect.promise(dispose)); | ||
| 125 | const request = (path: string, init: RequestInit = {}) => | ||
| 126 | handler( | ||
| 127 | new Request(`http://hub.test${path}`, { | ||
| 128 | ...init, | ||
| 129 | headers: { authorization: "Bearer hub-token", ...init.headers }, | ||
| 130 | }), | ||
| 131 | ); | ||
| 132 | return { | ||
| 133 | handler, | ||
| 134 | request, | ||
| 135 | requests, | ||
| 136 | revoke: Deferred.succeed(revoked, undefined), | ||
| 137 | remove: Deferred.succeed(removed, undefined), | ||
| 138 | finalized: () => finalized, | ||
| 139 | }; | ||
| 140 | }); | ||
| 141 | |||
| 142 | describe("self-hosted server proxy", () => { | ||
| 143 | it.effect("does not advertise a compressed upstream size for a decoded response", () => | ||
| 144 | Effect.gen(function* () { | ||
| 145 | const f = yield* fixture( | ||
| 146 | () => | ||
| 147 | new Response("expanded", { | ||
| 148 | headers: { "content-encoding": "gzip", "content-length": "1" }, | ||
| 149 | }), | ||
| 150 | ); | ||
| 151 | const response = yield* Effect.promise(() => f.request("/environments/leaf/api/example")); | ||
| 152 | expect(response.headers.get("content-encoding")).toBeNull(); | ||
| 153 | expect(response.headers.get("content-length")).toBeNull(); | ||
| 154 | expect(yield* Effect.promise(() => response.text())).toBe("expanded"); | ||
| 155 | }), | ||
| 156 | ); | ||
| 157 | |||
| 158 | it.effect("requires hub authentication and management permission", () => | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const f = yield* fixture(); | ||
| 161 | yield* Effect.promise(async () => { | ||
| 162 | expect((await f.handler(new Request("http://hub.test/api/hub/environments"))).status).toBe( | ||
| 163 | 401, | ||
| 164 | ); | ||
| 165 | expect((await f.request("/api/hub/manage", { method: "POST", body: "{}" })).status).toBe( | ||
| 166 | 403, | ||
| 167 | ); | ||
| 168 | expect((await f.request("/api/hub/environments")).status).toBe(200); | ||
| 169 | }); | ||
| 170 | }), | ||
| 171 | ); | ||
| 172 | |||
| 173 | it.effect( | ||
| 174 | "replaces hub credentials, preserves byte ranges, streams bodies, and releases upstream resources", | ||
| 175 | () => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const f = yield* fixture( | ||
| 178 | () => | ||
| 179 | new Response("bytes", { | ||
| 180 | status: 206, | ||
| 181 | headers: { | ||
| 182 | "content-range": "bytes 1-5/10", | ||
| 183 | "set-cookie": "leaf=private", | ||
| 184 | "x-private-secret": "private", | ||
| 185 | location: "/base/api/next", | ||
| 186 | }, | ||
| 187 | }), | ||
| 188 | ); | ||
| 189 | yield* Effect.promise(async () => { | ||
| 190 | const response = await f.request( | ||
| 191 | "/environments/leaf/api/assets?wsTicket=hub-ticket&asset=one", | ||
| 192 | { | ||
| 193 | method: "PUT", | ||
| 194 | headers: { | ||
| 195 | dpop: "hub-proof", | ||
| 196 | cookie: "hub-session=secret", | ||
| 197 | range: "bytes=1-5", | ||
| 198 | "x-forwarded-host": "evil.test", | ||
| 199 | }, | ||
| 200 | body: "upload", | ||
| 201 | }, | ||
| 202 | ); | ||
| 203 | expect(response.status).toBe(206); | ||
| 204 | expect(await response.text()).toBe("bytes"); | ||
| 205 | expect(response.headers.get("content-range")).toBe("bytes 1-5/10"); | ||
| 206 | expect(response.headers.get("set-cookie")).toBeNull(); | ||
| 207 | expect(response.headers.get("x-private-secret")).toBeNull(); | ||
| 208 | expect(response.headers.get("location")).toBe("/environments/leaf/api/next"); | ||
| 209 | expect(f.requests).toHaveLength(1); | ||
| 210 | expect(f.requests[0]).toMatchObject({ | ||
| 211 | url: "http://leaf.private/base/api/assets?asset=one", | ||
| 212 | body: "upload", | ||
| 213 | headers: { authorization: "Bearer private-leaf-token", range: "bytes=1-5" }, | ||
| 214 | }); | ||
| 215 | expect(f.requests[0]?.headers).not.toHaveProperty("dpop"); | ||
| 216 | expect(f.requests[0]?.headers).not.toHaveProperty("cookie"); | ||
| 217 | expect(f.requests[0]?.headers).not.toHaveProperty("x-forwarded-host"); | ||
| 218 | expect(f.finalized()).toBe(1); | ||
| 219 | }); | ||
| 220 | }), | ||
| 221 | ); | ||
| 222 | |||
| 223 | it.effect.each([ | ||
| 224 | "/api/auth/pairing-token", | ||
| 225 | "/api/auth/delegated-session", | ||
| 226 | "/api/auth/%64elegated-session", | ||
| 227 | "/api/auth/browser-session", | ||
| 228 | "/api/auth/clients", | ||
| 229 | "/oauth/token", | ||
| 230 | "/.well-known/oauth-authorization-server", | ||
| 231 | "/%61pi/auth/pairing-token", | ||
| 232 | "/api%2fauth/pairing-token", | ||
| 233 | "/%6fauth/token", | ||
| 234 | ])("does not expose leaf credentials through %s", (path) => | ||
| 235 | Effect.gen(function* () { | ||
| 236 | const f = yield* fixture(); | ||
| 237 | const response = yield* Effect.promise(() => | ||
| 238 | f.request(`/environments/leaf${path}`, { method: "POST" }), | ||
| 239 | ); | ||
| 240 | expect(response.status).toBe(404); | ||
| 241 | expect(f.requests).toHaveLength(0); | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | |||
| 245 | it.effect.each(["/%61pi/auth/websocket-ticket", "/api%2fauth/websocket-ticket"])( | ||
| 246 | "keeps encoded %s ticket requests on the hub", | ||
| 247 | (path) => | ||
| 248 | Effect.gen(function* () { | ||
| 249 | const f = yield* fixture(); | ||
| 250 | const response = yield* Effect.promise(() => | ||
| 251 | f.request(`/environments/leaf${path}`, { method: "POST" }), | ||
| 252 | ); | ||
| 253 | expect(response.status).toBe(200); | ||
| 254 | expect(yield* Effect.promise(() => response.json())).toMatchObject({ | ||
| 255 | ticket: "hub-ticket", | ||
| 256 | }); | ||
| 257 | expect(f.requests).toHaveLength(0); | ||
| 258 | }), | ||
| 259 | ); | ||
| 260 | |||
| 261 | it.effect( | ||
| 262 | "serves bounded hub session and ticket responses locally and preserves public leaf identity", | ||
| 263 | () => | ||
| 264 | Effect.gen(function* () { | ||
| 265 | const f = yield* fixture(); | ||
| 266 | yield* Effect.promise(async () => { | ||
| 267 | const state = await (await f.request("/environments/leaf/api/auth/session")).json(); | ||
| 268 | expect(state).toMatchObject({ | ||
| 269 | authenticated: true, | ||
| 270 | auth: authDescriptor, | ||
| 271 | scopes: [AuthOrchestrationReadScope], | ||
| 272 | }); | ||
| 273 | expect( | ||
| 274 | await ( | ||
| 275 | await f.request("/environments/leaf/api/auth/websocket-ticket", { method: "POST" }) | ||
| 276 | ).json(), | ||
| 277 | ).toMatchObject({ ticket: "hub-ticket" }); | ||
| 278 | expect( | ||
| 279 | await ( | ||
| 280 | await f.handler( | ||
| 281 | new Request("http://hub.test/environments/leaf/.well-known/t3/environment"), | ||
| 282 | ) | ||
| 283 | ).json(), | ||
| 284 | ).toMatchObject({ environmentId: "leaf" }); | ||
| 285 | expect(f.requests).toHaveLength(0); | ||
| 286 | }); | ||
| 287 | }), | ||
| 288 | ); | ||
| 289 | |||
| 290 | it.effect("never turns a wildcard path into an arbitrary upstream origin", () => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const f = yield* fixture(); | ||
| 293 | yield* Effect.promise(async () => { | ||
| 294 | await (await f.request("/environments/leaf/https://evil.test/steal")).text(); | ||
| 295 | }); | ||
| 296 | expect(new URL(f.requests[0]?.url ?? "").origin).toBe("http://leaf.private"); | ||
| 297 | }), | ||
| 298 | ); | ||
| 299 | |||
| 300 | it.effect.each(["revoke", "remove"] as const)( | ||
| 301 | "interrupts an active HTTP stream on %s", | ||
| 302 | (action) => | ||
| 303 | Effect.gen(function* () { | ||
| 304 | const f = yield* fixture( | ||
| 305 | () => | ||
| 306 | new Response( | ||
| 307 | new ReadableStream({ | ||
| 308 | start(controller) { | ||
| 309 | controller.enqueue(new TextEncoder().encode("first")); | ||
| 310 | }, | ||
| 311 | }), | ||
| 312 | ), | ||
| 313 | ); | ||
| 314 | const response = yield* Effect.promise(() => f.request("/environments/leaf/api/stream")); | ||
| 315 | if (!response.body) return yield* Effect.die("Missing response stream"); | ||
| 316 | const reader = response.body.getReader(); | ||
| 317 | const first = yield* Effect.promise(() => reader.read()); | ||
| 318 | expect(new TextDecoder().decode(first.value)).toBe("first"); | ||
| 319 | yield* f[action]; | ||
| 320 | expect((yield* Effect.promise(() => reader.read())).done).toBe(true); | ||
| 321 | expect(f.finalized()).toBe(1); | ||
| 322 | }), | ||
| 323 | ); | ||
| 324 | }); | ||
apps/server/src/environment/HubProxy.ts created+232| ... | @@ -0,0 +1,232 @@ | ||
| 1 | import { EnvironmentId, HubError, authScopeResponse } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 8 | import * as Scope from "effect/Scope"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | import * as Socket from "effect/socket/Socket"; | ||
| 11 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | ||
| 12 | import { | ||
| 13 | FetchHttpClient, | ||
| 14 | HttpClient, | ||
| 15 | HttpClientRequest, | ||
| 16 | HttpServerRequest, | ||
| 17 | HttpServerResponse, | ||
| 18 | } from "effect/http"; | ||
| 19 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 20 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 21 | import * as Hub from "./Hub.ts"; | ||
| 22 | |||
| 23 | const isHubError = Schema.is(HubError); | ||
| 24 | |||
| 25 | export class HubProxy extends Context.Service< | ||
| 26 | HubProxy, | ||
| 27 | { | ||
| 28 | readonly forward: ( | ||
| 29 | request: HttpServerRequest.HttpServerRequest, | ||
| 30 | id: EnvironmentId, | ||
| 31 | path: string, | ||
| 32 | ) => Effect.Effect< | ||
| 33 | HttpServerResponse.HttpServerResponse, | ||
| 34 | | HubError | ||
| 35 | | EnvironmentAuth.ServerAuthCredentialError | ||
| 36 | | EnvironmentAuth.ServerAuthInternalError, | ||
| 37 | Scope.Scope | ||
| 38 | >; | ||
| 39 | } | ||
| 40 | >()("t3/environment/HubProxy") {} | ||
| 41 | |||
| 42 | const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, token: string) => { | ||
| 43 | const headers: Record<string, string> = { | ||
| 44 | authorization: `Bearer ${token}`, | ||
| 45 | "accept-encoding": "identity", | ||
| 46 | }; | ||
| 47 | const dropped = new Set([ | ||
| 48 | "authorization", | ||
| 49 | "cookie", | ||
| 50 | "dpop", | ||
| 51 | "host", | ||
| 52 | "origin", | ||
| 53 | "connection", | ||
| 54 | "upgrade", | ||
| 55 | "sec-websocket-key", | ||
| 56 | "sec-websocket-version", | ||
| 57 | "sec-websocket-extensions", | ||
| 58 | "sec-websocket-protocol", | ||
| 59 | "content-length", | ||
| 60 | "accept-encoding", | ||
| 61 | "proxy-authorization", | ||
| 62 | "proxy-authenticate", | ||
| 63 | "te", | ||
| 64 | "trailer", | ||
| 65 | "keep-alive", | ||
| 66 | "forwarded", | ||
| 67 | "x-forwarded-host", | ||
| 68 | "x-forwarded-proto", | ||
| 69 | "x-forwarded-prefix", | ||
| 70 | ]); | ||
| 71 | for (const [name, value] of Object.entries(request.headers)) { | ||
| 72 | if (value !== undefined && !dropped.has(name)) headers[name] = value; | ||
| 73 | } | ||
| 74 | return headers; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const pump = (source: Socket.Socket, sink: Socket.Writer) => | ||
| 78 | Effect.gen(function* () { | ||
| 79 | const { pull } = yield* source.reader; | ||
| 80 | while (true) yield* sink.writeAll(yield* pull); | ||
| 81 | }); | ||
| 82 | |||
| 83 | const make = Effect.gen(function* () { | ||
| 84 | const hub = yield* Hub.Hub; | ||
| 85 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 86 | const sessions = yield* SessionStore.SessionStore; | ||
| 87 | const client = HttpClient.withScope(yield* HttpClient.HttpClient); | ||
| 88 | return HubProxy.of({ | ||
| 89 | forward: (request, id, path) => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const parsed = HttpServerRequest.toURL(request); | ||
| 92 | if (Option.isNone(parsed)) return HttpServerResponse.empty({ status: 400 }); | ||
| 93 | if (request.method === "GET" && path === "/.well-known/t3/environment") { | ||
| 94 | return HttpServerResponse.jsonUnsafe(yield* hub.descriptor(id)); | ||
| 95 | } | ||
| 96 | const session = yield* auth.authenticateWebSocketUpgrade(request); | ||
| 97 | const decodedPath = yield* Effect.try({ | ||
| 98 | try: () => decodeURIComponent(path), | ||
| 99 | catch: () => new HubError({ reason: "not-found" }), | ||
| 100 | }); | ||
| 101 | if ( | ||
| 102 | decodedPath !== path && | ||
| 103 | /^\/(api\/auth(?:\/|$)|oauth(?:\/|$)|\.well-known\/oauth)/i.test(decodedPath) | ||
| 104 | ) { | ||
| 105 | return HttpServerResponse.empty({ status: 404 }); | ||
| 106 | } | ||
| 107 | if (request.method === "GET" && path === "/api/auth/session") { | ||
| 108 | const scopes = yield* hub.authorize(id, session); | ||
| 109 | return HttpServerResponse.jsonUnsafe( | ||
| 110 | { | ||
| 111 | authenticated: true, | ||
| 112 | auth: yield* auth.getDescriptor(), | ||
| 113 | ...authScopeResponse(scopes), | ||
| 114 | sessionMethod: session.method, | ||
| 115 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | ||
| 116 | }, | ||
| 117 | { headers: { "cache-control": "no-store" } }, | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | if (request.method === "POST" && path === "/api/auth/websocket-ticket") { | ||
| 121 | yield* hub.authorize(id, session); | ||
| 122 | return HttpServerResponse.jsonUnsafe(yield* auth.issueWebSocketTicket(session), { | ||
| 123 | headers: { "cache-control": "no-store" }, | ||
| 124 | }); | ||
| 125 | } | ||
| 126 | // These responses can reveal or mint private leaf credentials and must stay on the hub. | ||
| 127 | if ( | ||
| 128 | path.startsWith("/api/auth/") || | ||
| 129 | path === "/oauth" || | ||
| 130 | path.startsWith("/oauth/") || | ||
| 131 | path.startsWith("/.well-known/oauth") | ||
| 132 | ) { | ||
| 133 | return HttpServerResponse.empty({ status: 404 }); | ||
| 134 | } | ||
| 135 | const upstream = yield* hub.connect(id, session); | ||
| 136 | const invalidated = Effect.raceFirst( | ||
| 137 | upstream.invalidated, | ||
| 138 | sessions.awaitInvalidation(session.sessionId), | ||
| 139 | ); | ||
| 140 | const url = new URL(upstream.url); | ||
| 141 | url.pathname += path.slice(1); | ||
| 142 | url.search = parsed.value.search; | ||
| 143 | url.searchParams.delete("wsTicket"); | ||
| 144 | const headers = forwardHeaders(request, upstream.token); | ||
| 145 | if (request.headers.upgrade?.toLowerCase() === "websocket") { | ||
| 146 | url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; | ||
| 147 | return yield* Effect.scoped( | ||
| 148 | Effect.gen(function* () { | ||
| 149 | const socket = yield* Socket.makeWebSocket(url.href, { | ||
| 150 | headers, | ||
| 151 | openTimeout: "10 seconds", | ||
| 152 | }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS)); | ||
| 153 | const client = yield* request.upgrade; | ||
| 154 | const clientWriter = yield* client.writer; | ||
| 155 | const upstreamWriter = yield* socket.writer; | ||
| 156 | yield* Effect.raceFirst( | ||
| 157 | Effect.raceFirst(pump(socket, clientWriter), pump(client, upstreamWriter)), | ||
| 158 | invalidated, | ||
| 159 | ).pipe(Effect.ignoreCause); | ||
| 160 | return HttpServerResponse.empty(); | ||
| 161 | }), | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | const outgoing = HttpClientRequest.make(request.method)(url.href).pipe( | ||
| 165 | HttpClientRequest.setHeaders(headers), | ||
| 166 | request.method === "GET" || request.method === "HEAD" | ||
| 167 | ? (self) => self | ||
| 168 | : HttpClientRequest.bodyStream(request.stream), | ||
| 169 | ); | ||
| 170 | const response = yield* Effect.raceFirst( | ||
| 171 | client | ||
| 172 | .execute(outgoing) | ||
| 173 | .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })), | ||
| 174 | invalidated.pipe(Effect.andThen(Effect.fail(new HubError({ reason: "credential" })))), | ||
| 175 | ); | ||
| 176 | const responseHeaders: Record<string, string> = {}; | ||
| 177 | for (const [name, value] of Object.entries(response.headers)) { | ||
| 178 | if ( | ||
| 179 | value === undefined || | ||
| 180 | ![ | ||
| 181 | "content-type", | ||
| 182 | "content-length", | ||
| 183 | "content-range", | ||
| 184 | "accept-ranges", | ||
| 185 | "content-disposition", | ||
| 186 | "etag", | ||
| 187 | "last-modified", | ||
| 188 | "vary", | ||
| 189 | "content-security-policy", | ||
| 190 | "x-content-type-options", | ||
| 191 | "retry-after", | ||
| 192 | ].includes(name) | ||
| 193 | ) | ||
| 194 | continue; | ||
| 195 | responseHeaders[name] = value; | ||
| 196 | } | ||
| 197 | if (response.headers["content-encoding"]) delete responseHeaders["content-length"]; | ||
| 198 | if (response.headers.location) { | ||
| 199 | const destination = new URL(response.headers.location, url); | ||
| 200 | const privateBase = new URL(upstream.url); | ||
| 201 | if ( | ||
| 202 | destination.origin === privateBase.origin && | ||
| 203 | destination.pathname.startsWith(privateBase.pathname) | ||
| 204 | ) { | ||
| 205 | const prefix = parsed.value.pathname.slice( | ||
| 206 | 0, | ||
| 207 | parsed.value.pathname.length - path.length + 1, | ||
| 208 | ); | ||
| 209 | responseHeaders.location = `${prefix}${destination.pathname.slice(privateBase.pathname.length)}${destination.search}${destination.hash}`; | ||
| 210 | } | ||
| 211 | } | ||
| 212 | responseHeaders["cache-control"] = "no-store, no-transform"; | ||
| 213 | return HttpServerResponse.stream(response.stream.pipe(Stream.interruptWhen(invalidated)), { | ||
| 214 | status: response.status, | ||
| 215 | headers: responseHeaders, | ||
| 216 | ...(responseHeaders["content-type"] | ||
| 217 | ? { contentType: responseHeaders["content-type"] } | ||
| 218 | : {}), | ||
| 219 | }); | ||
| 220 | }).pipe( | ||
| 221 | Effect.mapError((cause) => | ||
| 222 | isHubError(cause) || | ||
| 223 | EnvironmentAuth.isServerAuthCredentialError(cause) || | ||
| 224 | EnvironmentAuth.isServerAuthInternalError(cause) | ||
| 225 | ? cause | ||
| 226 | : new HubError({ reason: "upstream", cause: Redacted.make(cause) }), | ||
| 227 | ), | ||
| 228 | ), | ||
| 229 | }); | ||
| 230 | }); | ||
| 231 | |||
| 232 | export const layer = Layer.effect(HubProxy, make); | ||
apps/server/src/environment/OutboundHub.test.ts created+242| ... | @@ -0,0 +1,242 @@ | ||
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | ||
| 4 | import { expect, it } from "@effect/vitest"; | ||
| 5 | import { | ||
| 6 | AuthEnvironmentConnectScope, | ||
| 7 | AuthOrchestrationReadScope, | ||
| 8 | AuthAccessWriteScope, | ||
| 9 | AuthSessionId, | ||
| 10 | EnvironmentId, | ||
| 11 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 12 | OutboundHubMessage, | ||
| 13 | type OutboundSourceMessage, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as Context from "effect/Context"; | ||
| 16 | import * as Deferred from "effect/Deferred"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as Fiber from "effect/Fiber"; | ||
| 19 | import * as Layer from "effect/Layer"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import * as Queue from "effect/Queue"; | ||
| 22 | import * as Schema from "effect/Schema"; | ||
| 23 | import * as Socket from "effect/socket/Socket"; | ||
| 24 | import { | ||
| 25 | FetchHttpClient, | ||
| 26 | HttpClient, | ||
| 27 | HttpClientRequest, | ||
| 28 | HttpRouter, | ||
| 29 | HttpServer, | ||
| 30 | HttpServerRequest, | ||
| 31 | HttpServerResponse, | ||
| 32 | } from "effect/http"; | ||
| 33 | import * as HostedAuth from "../auth/HostedAuth.ts"; | ||
| 34 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 35 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 36 | import * as OutboundHub from "./OutboundHub.ts"; | ||
| 37 | import { bridge } from "./OutboundSocket.ts"; | ||
| 38 | |||
| 39 | const decodeHubMessage = Schema.decodeEffect(Schema.fromJsonString(OutboundHubMessage)); | ||
| 40 | |||
| 41 | const socketPair = Effect.gen(function* () { | ||
| 42 | const inbound = yield* Queue.unbounded<string | Uint8Array>(); | ||
| 43 | const outbound = yield* Queue.unbounded<string | Uint8Array>(); | ||
| 44 | const endpoint = (read: typeof inbound, write: typeof outbound) => { | ||
| 45 | const send = (frame: string | Uint8Array | Socket.CloseEvent) => | ||
| 46 | Socket.isCloseEvent(frame) ? Effect.void : Queue.offer(write, frame).pipe(Effect.asVoid); | ||
| 47 | return Socket.make({ | ||
| 48 | reader: Effect.succeed({ | ||
| 49 | pull: Queue.take(read).pipe(Effect.map((frame) => [frame] as const)), | ||
| 50 | upgrade: () => Effect.void, | ||
| 51 | }), | ||
| 52 | writer: Effect.succeed({ | ||
| 53 | write: send, | ||
| 54 | writeAll: (frames) => Effect.forEach(frames, send, { discard: true }), | ||
| 55 | }), | ||
| 56 | }); | ||
| 57 | }; | ||
| 58 | return { left: endpoint(inbound, outbound), right: endpoint(outbound, inbound) }; | ||
| 59 | }); | ||
| 60 | const session = { | ||
| 61 | sessionId: AuthSessionId.make("device-session"), | ||
| 62 | deviceId: "device-a", | ||
| 63 | subject: "owner", | ||
| 64 | method: "bearer-access-token" as const, | ||
| 65 | scopes: [AuthEnvironmentConnectScope], | ||
| 66 | }; | ||
| 67 | const descriptor = { | ||
| 68 | environmentId: EnvironmentId.make("existing-laptop-id"), | ||
| 69 | label: "Laptop", | ||
| 70 | platform: { os: "linux" as const, arch: "x64" as const }, | ||
| 71 | serverVersion: "test", | ||
| 72 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | ||
| 73 | capabilities: { repositoryIdentity: true }, | ||
| 74 | }; | ||
| 75 | |||
| 76 | const fixture = Effect.gen(function* () { | ||
| 77 | const data = new Map<string, Uint8Array>(); | ||
| 78 | const revoked = yield* Deferred.make<void>(); | ||
| 79 | const serviceLayer = OutboundHub.layer.pipe( | ||
| 80 | Layer.provide( | ||
| 81 | Layer.mergeAll( | ||
| 82 | Layer.succeed(ServerSecretStore.ServerSecretStore, { | ||
| 83 | get: (key: string) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | ||
| 84 | set: (key: string, value: Uint8Array) => | ||
| 85 | Effect.sync(() => { | ||
| 86 | data.set(key, value); | ||
| 87 | }), | ||
| 88 | } as unknown as ServerSecretStore.ServerSecretStore["Service"]), | ||
| 89 | Layer.succeed(HostedAuth.HostedAuth, { | ||
| 90 | bindEnvironment: () => Effect.void, | ||
| 91 | } as unknown as HostedAuth.HostedAuth["Service"]), | ||
| 92 | Layer.succeed(SessionStore.SessionStore, { | ||
| 93 | awaitInvalidation: () => Deferred.await(revoked), | ||
| 94 | } as unknown as SessionStore.SessionStore["Service"]), | ||
| 95 | ), | ||
| 96 | ), | ||
| 97 | ); | ||
| 98 | const hub = yield* OutboundHub.OutboundHub.pipe(Effect.provide(serviceLayer)); | ||
| 99 | const ready = yield* Deferred.make<void>(); | ||
| 100 | const requests: Array<OutboundHubMessage> = []; | ||
| 101 | const control = yield* socketPair; | ||
| 102 | const accepted = yield* hub.accept(session, control.left).pipe(Effect.forkScoped); | ||
| 103 | const reader = yield* control.right.reader; | ||
| 104 | const writer = yield* control.right.writer; | ||
| 105 | const send = (message: OutboundSourceMessage) => writer.write(JSON.stringify(message)); | ||
| 106 | const source = (port?: number) => | ||
| 107 | Effect.gen(function* () { | ||
| 108 | yield* send({ type: "register", descriptor }); | ||
| 109 | return yield* reader.pull.pipe( | ||
| 110 | Effect.flatMap((frames) => | ||
| 111 | Effect.forEach( | ||
| 112 | frames, | ||
| 113 | (frame) => | ||
| 114 | Effect.gen(function* () { | ||
| 115 | const message = yield* decodeHubMessage( | ||
| 116 | typeof frame === "string" ? frame : new TextDecoder().decode(frame), | ||
| 117 | ); | ||
| 118 | requests.push(message); | ||
| 119 | if (message.type === "ping") { | ||
| 120 | yield* Deferred.succeed(ready, undefined); | ||
| 121 | return yield* send({ type: "pong" }); | ||
| 122 | } | ||
| 123 | if (message.type === "grant") | ||
| 124 | return yield* send({ type: "grant", id: message.id, token: "scoped-leaf-token" }); | ||
| 125 | if (port === undefined) return; | ||
| 126 | const channel = yield* socketPair; | ||
| 127 | yield* hub.channel(session, message.id, channel.left).pipe(Effect.forkScoped); | ||
| 128 | yield* Effect.scoped( | ||
| 129 | Effect.gen(function* () { | ||
| 130 | const local = yield* NodeSocket.makeNet({ host: "127.0.0.1", port }); | ||
| 131 | return yield* bridge(local, channel.right); | ||
| 132 | }), | ||
| 133 | ).pipe(Effect.ignore, Effect.forkScoped); | ||
| 134 | }), | ||
| 135 | { discard: true }, | ||
| 136 | ), | ||
| 137 | ), | ||
| 138 | Effect.forever, | ||
| 139 | ); | ||
| 140 | }); | ||
| 141 | return { | ||
| 142 | hub, | ||
| 143 | data, | ||
| 144 | accepted, | ||
| 145 | requests, | ||
| 146 | source, | ||
| 147 | ready, | ||
| 148 | send, | ||
| 149 | revoke: Deferred.succeed(revoked, undefined), | ||
| 150 | restart: OutboundHub.OutboundHub.pipe(Effect.provide(serviceLayer)), | ||
| 151 | }; | ||
| 152 | }); | ||
| 153 | |||
| 154 | it.live( | ||
| 155 | "keeps enrolled IDs across restart and removes interactive authority on device revocation", | ||
| 156 | () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const f = yield* fixture; | ||
| 159 | yield* f.source().pipe(Effect.forkScoped); | ||
| 160 | yield* Deferred.await(f.ready); | ||
| 161 | const browser = { | ||
| 162 | ...session, | ||
| 163 | sessionId: AuthSessionId.make("browser"), | ||
| 164 | scopes: [AuthOrchestrationReadScope, AuthAccessWriteScope], | ||
| 165 | }; | ||
| 166 | const grant = yield* f.hub.connect(descriptor.environmentId, browser); | ||
| 167 | expect(grant.token).toBe("scoped-leaf-token"); | ||
| 168 | expect(f.requests.find((message) => message.type === "grant")).toMatchObject({ | ||
| 169 | scopes: [AuthOrchestrationReadScope], | ||
| 170 | }); | ||
| 171 | yield* f.hub.connect(descriptor.environmentId, browser); | ||
| 172 | expect(f.requests.filter((message) => message.type === "grant")).toHaveLength(1); | ||
| 173 | expect((yield* (yield* f.restart).enrollments)[0]?.descriptor.environmentId).toBe( | ||
| 174 | descriptor.environmentId, | ||
| 175 | ); | ||
| 176 | yield* f.revoke; | ||
| 177 | yield* Fiber.join(f.accepted); | ||
| 178 | yield* grant.invalidated; | ||
| 179 | expect( | ||
| 180 | (yield* f.hub.connect(descriptor.environmentId, browser).pipe(Effect.result))._tag, | ||
| 181 | ).toBe("Failure"); | ||
| 182 | expect((yield* f.hub.enrollments)[0]?.descriptor.environmentId).toBe( | ||
| 183 | descriptor.environmentId, | ||
| 184 | ); | ||
| 185 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 186 | ); | ||
| 187 | |||
| 188 | it.live("streams HTTP bytes through an outbound channel without a routable laptop URL", () => | ||
| 189 | Effect.gen(function* () { | ||
| 190 | const routes = HttpRouter.add( | ||
| 191 | "POST", | ||
| 192 | "/echo", | ||
| 193 | Effect.gen(function* () { | ||
| 194 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 195 | expect(request.headers.authorization).toBe("Bearer scoped-leaf-token"); | ||
| 196 | return HttpServerResponse.uint8Array(new Uint8Array(yield* request.arrayBuffer)); | ||
| 197 | }), | ||
| 198 | ); | ||
| 199 | const server = yield* Layer.build( | ||
| 200 | HttpRouter.serve(routes, { disableListenLog: true }).pipe( | ||
| 201 | Layer.provideMerge(NodeHttpServer.layerTest), | ||
| 202 | ), | ||
| 203 | ); | ||
| 204 | const address = Context.get(server, HttpServer.HttpServer).address; | ||
| 205 | if (address._tag !== "InetAddressV4" && address._tag !== "InetAddressV6") | ||
| 206 | return yield* Effect.die("Missing fixture port"); | ||
| 207 | const f = yield* fixture; | ||
| 208 | yield* f.source(address.port).pipe(Effect.forkScoped); | ||
| 209 | yield* Deferred.await(f.ready); | ||
| 210 | const grant = yield* f.hub.connect(descriptor.environmentId, { | ||
| 211 | ...session, | ||
| 212 | scopes: [AuthOrchestrationReadScope], | ||
| 213 | }); | ||
| 214 | const bytes = new Uint8Array(524_288).map((_, index) => index % 251); | ||
| 215 | const response = yield* HttpClient.execute( | ||
| 216 | HttpClientRequest.post(`${grant.url}echo`).pipe( | ||
| 217 | HttpClientRequest.bodyUint8Array(bytes), | ||
| 218 | HttpClientRequest.bearerToken(grant.token), | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | expect(new Uint8Array(yield* response.arrayBuffer)).toEqual(bytes); | ||
| 222 | }).pipe(Effect.provide(Layer.merge(NodeServices.layer, FetchHttpClient.layer))), | ||
| 223 | ); | ||
| 224 | |||
| 225 | it.live( | ||
| 226 | "requires a hosted device, a connect grant, and the original session for one-use channels", | ||
| 227 | () => | ||
| 228 | Effect.gen(function* () { | ||
| 229 | const f = yield* fixture; | ||
| 230 | const socket = yield* socketPair; | ||
| 231 | const { deviceId: _deviceId, ...unbound } = session; | ||
| 232 | expect((yield* f.hub.accept(unbound, socket.left).pipe(Effect.result))._tag).toBe("Failure"); | ||
| 233 | expect( | ||
| 234 | (yield* f.hub.accept({ ...session, scopes: [] }, socket.left).pipe(Effect.result))._tag, | ||
| 235 | ).toBe("Failure"); | ||
| 236 | expect( | ||
| 237 | (yield* f.hub | ||
| 238 | .channel({ ...session, deviceId: "device-b" }, "unissued", socket.left) | ||
| 239 | .pipe(Effect.result))._tag, | ||
| 240 | ).toBe("Failure"); | ||
| 241 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 242 | ); | ||
apps/server/src/environment/OutboundHub.ts created+295| ... | @@ -0,0 +1,295 @@ | ||
| 1 | import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer"; | ||
| 2 | import { | ||
| 3 | AuthEnvironmentConnectScope, | ||
| 4 | AuthStandardClientScopes, | ||
| 5 | EnvironmentId, | ||
| 6 | ExecutionEnvironmentDescriptor, | ||
| 7 | HubError, | ||
| 8 | OutboundSourceMessage, | ||
| 9 | OutboundHubMessage, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as Clock from "effect/Clock"; | ||
| 13 | import * as Crypto from "effect/Crypto"; | ||
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 15 | import * as Effect from "effect/Effect"; | ||
| 16 | import * as Layer from "effect/Layer"; | ||
| 17 | import * as Option from "effect/Option"; | ||
| 18 | import * as Redacted from "effect/Redacted"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import * as Scope from "effect/Scope"; | ||
| 21 | import * as Semaphore from "effect/Semaphore"; | ||
| 22 | import * as Socket from "effect/socket/Socket"; | ||
| 23 | import type { AuthenticatedSession } from "../auth/EnvironmentAuth.ts"; | ||
| 24 | import * as HostedAuth from "../auth/HostedAuth.ts"; | ||
| 25 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 26 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 27 | import { bridge } from "./OutboundSocket.ts"; | ||
| 28 | |||
| 29 | const encodeHub = Schema.encodeSync(Schema.fromJsonString(OutboundHubMessage)); | ||
| 30 | const decodeSource = Schema.decodeEffect(Schema.fromJsonString(OutboundSourceMessage)); | ||
| 31 | |||
| 32 | const Enrollment = Schema.Struct({ | ||
| 33 | deviceId: Schema.String, | ||
| 34 | descriptor: ExecutionEnvironmentDescriptor, | ||
| 35 | }); | ||
| 36 | const Stored = Schema.fromJsonString(Schema.Array(Enrollment)); | ||
| 37 | const decodeStored = Schema.decodeEffect(Stored); | ||
| 38 | const encodeStored = Schema.encodeEffect(Stored); | ||
| 39 | type Enrollment = typeof Enrollment.Type; | ||
| 40 | type Peer = { | ||
| 41 | readonly session: AuthenticatedSession; | ||
| 42 | readonly url: string; | ||
| 43 | readonly send: (message: OutboundHubMessage) => Effect.Effect<void, Socket.SocketError>; | ||
| 44 | readonly closed: Deferred.Deferred<void>; | ||
| 45 | readonly grants: Map<string, Deferred.Deferred<string>>; | ||
| 46 | readonly credentials: Map<string, { readonly token: string; readonly expiresAt: number }>; | ||
| 47 | }; | ||
| 48 | |||
| 49 | export class OutboundHub extends Context.Service< | ||
| 50 | OutboundHub, | ||
| 51 | { | ||
| 52 | readonly enrollments: Effect.Effect<ReadonlyArray<Enrollment>>; | ||
| 53 | readonly connect: ( | ||
| 54 | id: EnvironmentId, | ||
| 55 | session: AuthenticatedSession, | ||
| 56 | ) => Effect.Effect< | ||
| 57 | { | ||
| 58 | readonly url: string; | ||
| 59 | readonly token: string; | ||
| 60 | readonly invalidated: Effect.Effect<void>; | ||
| 61 | }, | ||
| 62 | HubError | ||
| 63 | >; | ||
| 64 | readonly remove: (id: EnvironmentId) => Effect.Effect<void, HubError>; | ||
| 65 | readonly accept: ( | ||
| 66 | session: AuthenticatedSession, | ||
| 67 | socket: Socket.Socket, | ||
| 68 | ) => Effect.Effect<void, HubError, Scope.Scope>; | ||
| 69 | readonly channel: ( | ||
| 70 | session: AuthenticatedSession, | ||
| 71 | id: string, | ||
| 72 | socket: Socket.Socket, | ||
| 73 | ) => Effect.Effect<void, HubError, Scope.Scope>; | ||
| 74 | } | ||
| 75 | >()("t3/environment/OutboundHub") {} | ||
| 76 | |||
| 77 | const isHubError = Schema.is(HubError); | ||
| 78 | const failure = (cause: unknown) => | ||
| 79 | isHubError(cause) ? cause : new HubError({ reason: "upstream", cause: Redacted.make(cause) }); | ||
| 80 | |||
| 81 | const make = Effect.gen(function* () { | ||
| 82 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 83 | const hosted = yield* HostedAuth.HostedAuth; | ||
| 84 | const sessions = yield* SessionStore.SessionStore; | ||
| 85 | const crypto = yield* Crypto.Crypto; | ||
| 86 | const lock = yield* Semaphore.make(1); | ||
| 87 | const stored = yield* secrets.get("outbound-environments").pipe(Effect.mapError(failure)); | ||
| 88 | let enrollments = Option.isSome(stored) | ||
| 89 | ? yield* decodeStored(new TextDecoder().decode(stored.value)).pipe(Effect.mapError(failure)) | ||
| 90 | : []; | ||
| 91 | const peers = new Map<EnvironmentId, Peer>(); | ||
| 92 | const channels = new Map< | ||
| 93 | string, | ||
| 94 | { | ||
| 95 | peer: Peer; | ||
| 96 | socket: Socket.Socket; | ||
| 97 | done: Deferred.Deferred<void>; | ||
| 98 | attached: Deferred.Deferred<void>; | ||
| 99 | claimed: boolean; | ||
| 100 | } | ||
| 101 | >(); | ||
| 102 | const persist = (next: ReadonlyArray<Enrollment>) => | ||
| 103 | encodeStored(next).pipe( | ||
| 104 | Effect.flatMap((json) => | ||
| 105 | secrets.set("outbound-environments", new TextEncoder().encode(json)), | ||
| 106 | ), | ||
| 107 | Effect.tap(() => | ||
| 108 | Effect.sync(() => { | ||
| 109 | enrollments = next; | ||
| 110 | }), | ||
| 111 | ), | ||
| 112 | Effect.mapError(failure), | ||
| 113 | ); | ||
| 114 | |||
| 115 | const accept: OutboundHub["Service"]["accept"] = (session, socket) => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | if (!session.deviceId || !session.scopes.includes(AuthEnvironmentConnectScope)) { | ||
| 118 | return yield* new HubError({ reason: "credential" }); | ||
| 119 | } | ||
| 120 | const deviceId = session.deviceId; | ||
| 121 | const reader = yield* socket.reader; | ||
| 122 | const writer = yield* socket.writer; | ||
| 123 | const writeLock = yield* Semaphore.make(1); | ||
| 124 | const send = (message: OutboundHubMessage) => | ||
| 125 | writeLock.withPermit(writer.write(encodeHub(message))); | ||
| 126 | const read = reader.pull.pipe( | ||
| 127 | Effect.flatMap((frames) => | ||
| 128 | Effect.forEach(frames, (frame) => { | ||
| 129 | if ((typeof frame === "string" ? Buffer.byteLength(frame) : frame.byteLength) > 65_536) | ||
| 130 | return Effect.fail(new HubError({ reason: "identity" })); | ||
| 131 | const text = typeof frame === "string" ? frame : new TextDecoder().decode(frame); | ||
| 132 | return decodeSource(text).pipe(Effect.mapError(failure)); | ||
| 133 | }), | ||
| 134 | ), | ||
| 135 | Effect.timeout("60 seconds"), | ||
| 136 | ); | ||
| 137 | const initial = yield* read.pipe(Effect.timeout("10 seconds")); | ||
| 138 | if (initial.length !== 1 || initial[0]?.type !== "register") | ||
| 139 | return yield* new HubError({ reason: "identity" }); | ||
| 140 | const descriptor = initial[0].descriptor; | ||
| 141 | const id = descriptor.environmentId; | ||
| 142 | yield* hosted.bindEnvironment(session, id); | ||
| 143 | yield* lock.withPermit( | ||
| 144 | Effect.gen(function* () { | ||
| 145 | yield* persist([ | ||
| 146 | ...enrollments.filter((value) => value.descriptor.environmentId !== id), | ||
| 147 | { deviceId, descriptor }, | ||
| 148 | ]); | ||
| 149 | }), | ||
| 150 | ); | ||
| 151 | const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }); | ||
| 152 | if (server.address._tag !== "InetAddressV4") | ||
| 153 | return yield* new HubError({ reason: "upstream" }); | ||
| 154 | const closed = yield* Deferred.make<void>(); | ||
| 155 | const peer: Peer = { | ||
| 156 | session, | ||
| 157 | url: `http://127.0.0.1:${server.address.port}/`, | ||
| 158 | send, | ||
| 159 | closed, | ||
| 160 | grants: new Map(), | ||
| 161 | credentials: new Map(), | ||
| 162 | }; | ||
| 163 | const previous = peers.get(id); | ||
| 164 | if (previous) yield* Deferred.succeed(previous.closed, undefined); | ||
| 165 | peers.set(id, peer); | ||
| 166 | yield* Effect.addFinalizer(() => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | if (peers.get(id) === peer) peers.delete(id); | ||
| 169 | yield* Deferred.succeed(closed, undefined); | ||
| 170 | for (const [key, channel] of channels) | ||
| 171 | if (channel.peer === peer) { | ||
| 172 | channels.delete(key); | ||
| 173 | yield* Deferred.succeed(channel.done, undefined); | ||
| 174 | } | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | yield* server | ||
| 178 | .run((local) => | ||
| 179 | Effect.scoped( | ||
| 180 | Effect.gen(function* () { | ||
| 181 | if (channels.size >= 512) return; | ||
| 182 | const channelId = yield* crypto.randomUUIDv4(); | ||
| 183 | const done = yield* Deferred.make<void>(); | ||
| 184 | const attached = yield* Deferred.make<void>(); | ||
| 185 | channels.set(channelId, { peer, socket: local, done, attached, claimed: false }); | ||
| 186 | yield* Effect.addFinalizer(() => | ||
| 187 | Effect.sync(() => { | ||
| 188 | channels.delete(channelId); | ||
| 189 | }), | ||
| 190 | ); | ||
| 191 | yield* send({ type: "connect", id: channelId }); | ||
| 192 | yield* Deferred.await(attached).pipe(Effect.timeout("10 seconds")); | ||
| 193 | yield* Deferred.await(done); | ||
| 194 | }), | ||
| 195 | ).pipe(Effect.ignore), | ||
| 196 | ) | ||
| 197 | .pipe(Effect.forkScoped); | ||
| 198 | yield* Effect.forever( | ||
| 199 | send({ type: "ping" }).pipe(Effect.andThen(Effect.sleep("20 seconds"))), | ||
| 200 | ).pipe(Effect.forkScoped); | ||
| 201 | const messages = Effect.forever( | ||
| 202 | read.pipe( | ||
| 203 | Effect.flatMap((messages) => | ||
| 204 | Effect.forEach( | ||
| 205 | messages, | ||
| 206 | (message) => { | ||
| 207 | if (message.type !== "grant") | ||
| 208 | return message.type === "pong" | ||
| 209 | ? Effect.void | ||
| 210 | : Effect.fail(new HubError({ reason: "identity" })); | ||
| 211 | const pending = peer.grants.get(message.id); | ||
| 212 | return pending | ||
| 213 | ? Deferred.succeed(pending, message.token).pipe(Effect.asVoid) | ||
| 214 | : Effect.void; | ||
| 215 | }, | ||
| 216 | { discard: true }, | ||
| 217 | ), | ||
| 218 | ), | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | yield* Effect.raceFirst( | ||
| 222 | messages, | ||
| 223 | Effect.raceFirst(Deferred.await(closed), sessions.awaitInvalidation(session.sessionId)), | ||
| 224 | ); | ||
| 225 | }).pipe(Effect.scoped, Effect.mapError(failure)); | ||
| 226 | |||
| 227 | return OutboundHub.of({ | ||
| 228 | enrollments: Effect.sync(() => enrollments), | ||
| 229 | accept, | ||
| 230 | remove: (id) => | ||
| 231 | lock.withPermit( | ||
| 232 | Effect.gen(function* () { | ||
| 233 | yield* persist(enrollments.filter((value) => value.descriptor.environmentId !== id)); | ||
| 234 | const peer = peers.get(id); | ||
| 235 | if (peer) yield* Deferred.succeed(peer.closed, undefined); | ||
| 236 | }), | ||
| 237 | ), | ||
| 238 | connect: (id, session) => | ||
| 239 | Effect.gen(function* () { | ||
| 240 | const peer = peers.get(id); | ||
| 241 | if (!peer) return yield* new HubError({ reason: "upstream" }); | ||
| 242 | const scopes = AuthStandardClientScopes.filter((scope) => session.scopes.includes(scope)); | ||
| 243 | if (scopes.length === 0) return yield* new HubError({ reason: "credential" }); | ||
| 244 | const now = yield* Clock.currentTimeMillis; | ||
| 245 | for (const [key, credential] of peer.credentials) | ||
| 246 | if (credential.expiresAt <= now) peer.credentials.delete(key); | ||
| 247 | const key = `${session.sessionId}:${scopes.join(" ")}`; | ||
| 248 | const cached = peer.credentials.get(key); | ||
| 249 | if (cached) | ||
| 250 | return { url: peer.url, token: cached.token, invalidated: Deferred.await(peer.closed) }; | ||
| 251 | if (peer.grants.size >= 128 || peer.credentials.size >= 512) | ||
| 252 | return yield* new HubError({ reason: "upstream" }); | ||
| 253 | const grantId = yield* crypto.randomUUIDv4(); | ||
| 254 | const grant = yield* Deferred.make<string>(); | ||
| 255 | peer.grants.set(grantId, grant); | ||
| 256 | const token = yield* Effect.gen(function* () { | ||
| 257 | yield* peer.send({ type: "grant", id: grantId, scopes }); | ||
| 258 | return yield* Effect.raceFirst( | ||
| 259 | Deferred.await(grant), | ||
| 260 | Deferred.await(peer.closed).pipe( | ||
| 261 | Effect.andThen(Effect.fail(new HubError({ reason: "upstream" }))), | ||
| 262 | ), | ||
| 263 | ); | ||
| 264 | }).pipe( | ||
| 265 | Effect.timeout("10 seconds"), | ||
| 266 | Effect.ensuring( | ||
| 267 | Effect.sync(() => { | ||
| 268 | peer.grants.delete(grantId); | ||
| 269 | }), | ||
| 270 | ), | ||
| 271 | ); | ||
| 272 | peer.credentials.set(key, { token, expiresAt: now + 60_000 }); | ||
| 273 | return { url: peer.url, token, invalidated: Deferred.await(peer.closed) }; | ||
| 274 | }).pipe(Effect.mapError(failure)), | ||
| 275 | channel: (session, id, socket) => | ||
| 276 | Effect.gen(function* () { | ||
| 277 | const channel = channels.get(id); | ||
| 278 | if ( | ||
| 279 | !channel || | ||
| 280 | channel.claimed || | ||
| 281 | session.deviceId !== channel.peer.session.deviceId || | ||
| 282 | session.sessionId !== channel.peer.session.sessionId | ||
| 283 | ) | ||
| 284 | return yield* new HubError({ reason: "credential" }); | ||
| 285 | channel.claimed = true; | ||
| 286 | yield* Deferred.succeed(channel.attached, undefined); | ||
| 287 | yield* Effect.raceFirst( | ||
| 288 | bridge(channel.socket, socket), | ||
| 289 | Deferred.await(channel.peer.closed), | ||
| 290 | ).pipe(Effect.ensuring(Deferred.succeed(channel.done, undefined)), Effect.ignore); | ||
| 291 | }), | ||
| 292 | }); | ||
| 293 | }); | ||
| 294 | |||
| 295 | export const layer = Layer.effect(OutboundHub, make); | ||
apps/server/src/environment/OutboundSocket.test.ts created+114| ... | @@ -0,0 +1,114 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Queue from "effect/Queue"; | ||
| 6 | import * as Socket from "effect/socket/Socket"; | ||
| 7 | import { bridge } from "./OutboundSocket.ts"; | ||
| 8 | |||
| 9 | type Frame = string | Uint8Array; | ||
| 10 | const endpoint = (incoming: Queue.Queue<Frame>, write: (frame: Frame) => Effect.Effect<void>) => { | ||
| 11 | const send = (frame: Frame | Socket.CloseEvent) => | ||
| 12 | Socket.isCloseEvent(frame) ? Effect.void : write(frame); | ||
| 13 | return Socket.make({ | ||
| 14 | reader: Effect.succeed({ | ||
| 15 | pull: Queue.take(incoming).pipe(Effect.map((frame) => [frame] as const)), | ||
| 16 | upgrade: () => Effect.void, | ||
| 17 | }), | ||
| 18 | writer: Effect.succeed({ | ||
| 19 | write: send, | ||
| 20 | writeAll: (frames) => Effect.forEach(frames, send, { discard: true }), | ||
| 21 | }), | ||
| 22 | }); | ||
| 23 | }; | ||
| 24 | const tunnelPair = Effect.gen(function* () { | ||
| 25 | const left = yield* Queue.unbounded<Frame>(); | ||
| 26 | const right = yield* Queue.unbounded<Frame>(); | ||
| 27 | const sent: Uint8Array[] = []; | ||
| 28 | const full = yield* Deferred.make<void>(); | ||
| 29 | return { | ||
| 30 | left: endpoint(left, (frame) => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | if (typeof frame !== "string" && frame[0] === 0) { | ||
| 33 | sent.push(frame); | ||
| 34 | if (sent.length === 16) yield* Deferred.succeed(full, undefined); | ||
| 35 | } | ||
| 36 | yield* Queue.offer(right, frame); | ||
| 37 | }), | ||
| 38 | ), | ||
| 39 | right: endpoint(right, (frame) => Queue.offer(left, frame).pipe(Effect.asVoid)), | ||
| 40 | injectLeft: (frame: Frame) => Queue.offer(left, frame), | ||
| 41 | sent, | ||
| 42 | full, | ||
| 43 | }; | ||
| 44 | }); | ||
| 45 | const tcp = (expectedBytes: number, stalled?: Deferred.Deferred<void>) => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const outgoing = yield* Queue.unbounded<Frame>(); | ||
| 48 | const received: Uint8Array[] = []; | ||
| 49 | const done = yield* Deferred.make<void>(); | ||
| 50 | const socket = endpoint(outgoing, (frame) => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | if (stalled) yield* Deferred.await(stalled); | ||
| 53 | received.push(typeof frame === "string" ? new TextEncoder().encode(frame) : frame); | ||
| 54 | if (received.reduce((size, chunk) => size + chunk.byteLength, 0) >= expectedBytes) | ||
| 55 | yield* Deferred.succeed(done, undefined); | ||
| 56 | }), | ||
| 57 | ); | ||
| 58 | return { socket, received, done, send: (frame: Frame) => Queue.offer(outgoing, frame) }; | ||
| 59 | }); | ||
| 60 | |||
| 61 | it.effect( | ||
| 62 | "bounds a stalled tunnel to sixteen 64KiB payloads and releases a sender waiting for credit on cancellation", | ||
| 63 | () => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const pair = yield* tunnelPair; | ||
| 66 | const left = yield* tcp(0); | ||
| 67 | const gate = yield* Deferred.make<void>(); | ||
| 68 | const right = yield* tcp(1, gate); | ||
| 69 | const finalized = yield* Deferred.make<void>(); | ||
| 70 | const sender = yield* bridge(left.socket, pair.left).pipe( | ||
| 71 | Effect.ensuring(Deferred.succeed(finalized, undefined)), | ||
| 72 | Effect.forkScoped({ startImmediately: true }), | ||
| 73 | ); | ||
| 74 | yield* bridge(right.socket, pair.right).pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 75 | yield* left.send(new Uint8Array(64 * 1024 * 40)); | ||
| 76 | yield* Deferred.await(pair.full); | ||
| 77 | expect(pair.sent).toHaveLength(16); | ||
| 78 | expect(pair.sent.reduce((size, frame) => size + frame.byteLength - 5, 0)).toBe(1024 * 1024); | ||
| 79 | expect(pair.sent.every((frame) => frame.byteLength <= 64 * 1024 + 5)).toBe(true); | ||
| 80 | yield* Fiber.interrupt(sender); | ||
| 81 | expect(yield* Deferred.isDone(finalized)).toBe(true); | ||
| 82 | }), | ||
| 83 | ); | ||
| 84 | |||
| 85 | it.effect("streams bursts in both directions without credit deadlock or byte loss", () => | ||
| 86 | Effect.gen(function* () { | ||
| 87 | const pair = yield* tunnelPair; | ||
| 88 | const bytes = new Uint8Array(64 * 1024 * 40).map((_, index) => index % 251); | ||
| 89 | const reverse = bytes.toReversed(); | ||
| 90 | const left = yield* tcp(bytes.length); | ||
| 91 | const right = yield* tcp(bytes.length); | ||
| 92 | yield* bridge(left.socket, pair.left).pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 93 | yield* bridge(right.socket, pair.right).pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 94 | yield* left.send(bytes); | ||
| 95 | yield* right.send(reverse); | ||
| 96 | yield* Deferred.await(left.done); | ||
| 97 | yield* Deferred.await(right.done); | ||
| 98 | expect(Buffer.concat(left.received)).toEqual(Buffer.from(reverse)); | ||
| 99 | expect(Buffer.concat(right.received)).toEqual(Buffer.from(bytes)); | ||
| 100 | }), | ||
| 101 | ); | ||
| 102 | |||
| 103 | it.effect("rejects unsolicited acknowledgments instead of manufacturing send credit", () => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | const pair = yield* tunnelPair; | ||
| 106 | const local = yield* tcp(0); | ||
| 107 | const running = yield* bridge(local.socket, pair.left).pipe( | ||
| 108 | Effect.result, | ||
| 109 | Effect.forkScoped({ startImmediately: true }), | ||
| 110 | ); | ||
| 111 | yield* pair.injectLeft(new Uint8Array([1, 0, 0, 0, 0])); | ||
| 112 | expect((yield* Fiber.join(running))._tag).toBe("Failure"); | ||
| 113 | }), | ||
| 114 | ); | ||
apps/server/src/environment/OutboundSocket.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | import { HubError } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Semaphore from "effect/Semaphore"; | ||
| 4 | import * as Socket from "effect/socket/Socket"; | ||
| 5 | |||
| 6 | const CHUNK_BYTES = 64 * 1024; | ||
| 7 | const WINDOW_CHUNKS = 16; | ||
| 8 | |||
| 9 | /** ACKs follow TCP drain, bounding WebSocket's otherwise unbounded send buffer. */ | ||
| 10 | export const bridge = (tcp: Socket.Socket, tunnel: Socket.Socket) => | ||
| 11 | Effect.gen(function* () { | ||
| 12 | const tcpReader = yield* tcp.reader; | ||
| 13 | const tunnelReader = yield* tunnel.reader; | ||
| 14 | const tcpWriter = yield* tcp.writer; | ||
| 15 | const tunnelWriter = yield* tunnel.writer; | ||
| 16 | const credits = yield* Semaphore.make(WINDOW_CHUNKS); | ||
| 17 | const writes = yield* Semaphore.make(1); | ||
| 18 | const pending = new Set<number>(); | ||
| 19 | let sequence = 0; | ||
| 20 | let expected = 0; | ||
| 21 | |||
| 22 | const send = tcpReader.pull.pipe( | ||
| 23 | Effect.flatMap((chunks) => | ||
| 24 | Effect.forEach( | ||
| 25 | chunks, | ||
| 26 | (chunk) => | ||
| 27 | Effect.gen(function* () { | ||
| 28 | const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk; | ||
| 29 | for (let offset = 0; offset < bytes.byteLength; offset += CHUNK_BYTES) { | ||
| 30 | yield* credits.take(1); | ||
| 31 | if (sequence > 0xffff_ffff) return yield* new HubError({ reason: "upstream" }); | ||
| 32 | const id = sequence++; | ||
| 33 | const payload = bytes.subarray(offset, offset + CHUNK_BYTES); | ||
| 34 | const frame = new Uint8Array(5 + payload.byteLength); | ||
| 35 | new DataView(frame.buffer).setUint32(1, id); | ||
| 36 | frame.set(payload, 5); | ||
| 37 | pending.add(id); | ||
| 38 | yield* writes.withPermit(tunnelWriter.write(frame)); | ||
| 39 | } | ||
| 40 | }), | ||
| 41 | { discard: true }, | ||
| 42 | ), | ||
| 43 | ), | ||
| 44 | Effect.forever, | ||
| 45 | ); | ||
| 46 | const receive = tunnelReader.pull.pipe( | ||
| 47 | Effect.flatMap((frames) => | ||
| 48 | Effect.forEach( | ||
| 49 | frames, | ||
| 50 | (frame) => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | if ( | ||
| 53 | typeof frame === "string" || | ||
| 54 | frame.byteLength < 5 || | ||
| 55 | frame.byteLength > CHUNK_BYTES + 5 | ||
| 56 | ) { | ||
| 57 | return yield* new HubError({ reason: "identity" }); | ||
| 58 | } | ||
| 59 | const id = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32( | ||
| 60 | 1, | ||
| 61 | ); | ||
| 62 | if (frame[0] === 1) { | ||
| 63 | if (frame.byteLength !== 5 || !pending.delete(id)) | ||
| 64 | return yield* new HubError({ reason: "identity" }); | ||
| 65 | yield* credits.release(1); | ||
| 66 | return; | ||
| 67 | } | ||
| 68 | if (frame[0] !== 0 || frame.byteLength === 5 || id !== expected++) { | ||
| 69 | return yield* new HubError({ reason: "identity" }); | ||
| 70 | } | ||
| 71 | yield* tcpWriter.write(frame.subarray(5)); | ||
| 72 | const ack = new Uint8Array(5); | ||
| 73 | ack[0] = 1; | ||
| 74 | new DataView(ack.buffer).setUint32(1, id); | ||
| 75 | yield* writes.withPermit(tunnelWriter.write(ack)); | ||
| 76 | }), | ||
| 77 | { discard: true }, | ||
| 78 | ), | ||
| 79 | ), | ||
| 80 | Effect.forever, | ||
| 81 | ); | ||
| 82 | return yield* Effect.raceFirst(send, receive); | ||
| 83 | }); | ||
apps/server/src/environment/OutboundSource.ts created+169| ... | @@ -0,0 +1,169 @@ | ||
| 1 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | ||
| 2 | import { | ||
| 3 | AuthStandardClientScopes, | ||
| 4 | HubError, | ||
| 5 | OutboundHubMessage, | ||
| 6 | type AuthSessionId, | ||
| 7 | type HostedSourceConnection, | ||
| 8 | OutboundSourceMessage, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as Option from "effect/Option"; | ||
| 15 | import * as Schedule from "effect/Schedule"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | import * as Semaphore from "effect/Semaphore"; | ||
| 18 | import * as Stream from "effect/Stream"; | ||
| 19 | import { HttpServer } from "effect/http"; | ||
| 20 | import * as Socket from "effect/socket/Socket"; | ||
| 21 | import * as SessionStore from "../auth/SessionStore.ts"; | ||
| 22 | import * as ServerActivation from "../serverActivation.ts"; | ||
| 23 | import { bridge } from "./OutboundSocket.ts"; | ||
| 24 | import * as ServerEnvironment from "./ServerEnvironment.ts"; | ||
| 25 | import * as SourceConnection from "./SourceConnection.ts"; | ||
| 26 | |||
| 27 | const encodeSource = Schema.encodeSync(Schema.fromJsonString(OutboundSourceMessage)); | ||
| 28 | const decodeHub = Schema.decodeEffect(Schema.fromJsonString(OutboundHubMessage)); | ||
| 29 | |||
| 30 | export const layer = Layer.effectDiscard( | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const connection = yield* SourceConnection.SourceConnection; | ||
| 33 | const sessions = yield* SessionStore.SessionStore; | ||
| 34 | const environment = yield* ServerEnvironment.ServerEnvironment; | ||
| 35 | const server = yield* HttpServer.HttpServer; | ||
| 36 | const address = server.address; | ||
| 37 | if (address._tag !== "InetAddressV4" && address._tag !== "InetAddressV6") return; | ||
| 38 | |||
| 39 | const connect = (target: HostedSourceConnection) => | ||
| 40 | Effect.scoped( | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const now = yield* DateTime.now; | ||
| 43 | const remaining = target.expiresAt.epochMilliseconds - now.epochMilliseconds; | ||
| 44 | if (remaining <= 0) return yield* Effect.never; | ||
| 45 | const url = new URL("api/hub/outbound", target.serverUrl); | ||
| 46 | url.protocol = "wss:"; | ||
| 47 | const socket = yield* Socket.makeWebSocket(url.href, { | ||
| 48 | headers: { authorization: target.authorization }, | ||
| 49 | openTimeout: "10 seconds", | ||
| 50 | highWaterMark: 1024 * 1024, | ||
| 51 | }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS)); | ||
| 52 | const reader = yield* socket.reader; | ||
| 53 | const writer = yield* socket.writer; | ||
| 54 | const lock = yield* Semaphore.make(1); | ||
| 55 | const send = (message: OutboundSourceMessage) => | ||
| 56 | lock.withPermit(writer.write(encodeSource(message))); | ||
| 57 | const grants = new Map<AuthSessionId, number>(); | ||
| 58 | const channels = yield* Semaphore.make(512); | ||
| 59 | yield* Effect.addFinalizer(() => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | yield* connection.setConnected(false); | ||
| 62 | yield* Effect.forEach( | ||
| 63 | grants.keys(), | ||
| 64 | (id) => sessions.revoke(id).pipe(Effect.ignore), | ||
| 65 | { concurrency: 8, discard: true }, | ||
| 66 | ); | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | yield* send({ type: "register", descriptor: yield* environment.getDescriptor }); | ||
| 70 | const run = reader.pull.pipe( | ||
| 71 | Effect.timeout("60 seconds"), | ||
| 72 | Effect.flatMap((frames) => | ||
| 73 | Effect.forEach( | ||
| 74 | frames, | ||
| 75 | (frame) => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | if ( | ||
| 78 | (typeof frame === "string" ? Buffer.byteLength(frame) : frame.byteLength) > | ||
| 79 | 65_536 | ||
| 80 | ) | ||
| 81 | return yield* new HubError({ reason: "identity" }); | ||
| 82 | const text = | ||
| 83 | typeof frame === "string" ? frame : new TextDecoder().decode(frame); | ||
| 84 | const message = yield* decodeHub(text); | ||
| 85 | yield* connection.setConnected(true); | ||
| 86 | if (message.type === "ping") return yield* send({ type: "pong" }); | ||
| 87 | if (message.type === "grant") { | ||
| 88 | if ( | ||
| 89 | message.scopes.length === 0 || | ||
| 90 | message.scopes.some( | ||
| 91 | (scope) => !AuthStandardClientScopes.some((allowed) => allowed === scope), | ||
| 92 | ) | ||
| 93 | ) { | ||
| 94 | return yield* new HubError({ reason: "credential" }); | ||
| 95 | } | ||
| 96 | const now = yield* DateTime.now; | ||
| 97 | for (const [id, expiresAt] of grants) | ||
| 98 | if (expiresAt <= now.epochMilliseconds) grants.delete(id); | ||
| 99 | if (grants.size >= 1_024) return yield* new HubError({ reason: "upstream" }); | ||
| 100 | const issued = yield* sessions.issue({ | ||
| 101 | subject: "self-hosted-outbound", | ||
| 102 | method: "bearer-access-token", | ||
| 103 | scopes: [...new Set(message.scopes)], | ||
| 104 | ttl: Duration.millis( | ||
| 105 | Math.min( | ||
| 106 | 120_000, | ||
| 107 | target.expiresAt.epochMilliseconds - now.epochMilliseconds, | ||
| 108 | ), | ||
| 109 | ), | ||
| 110 | }); | ||
| 111 | grants.set(issued.sessionId, issued.expiresAt.epochMilliseconds); | ||
| 112 | return yield* send({ type: "grant", id: message.id, token: issued.token }); | ||
| 113 | } | ||
| 114 | yield* Effect.scoped( | ||
| 115 | Effect.gen(function* () { | ||
| 116 | const channelUrl = new URL( | ||
| 117 | `api/hub/outbound/channel/${encodeURIComponent(message.id)}`, | ||
| 118 | target.serverUrl, | ||
| 119 | ); | ||
| 120 | channelUrl.protocol = "wss:"; | ||
| 121 | const channel = yield* Socket.makeWebSocket(channelUrl.href, { | ||
| 122 | headers: { authorization: target.authorization }, | ||
| 123 | openTimeout: "10 seconds", | ||
| 124 | highWaterMark: 1024 * 1024, | ||
| 125 | }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS)); | ||
| 126 | const local = yield* NodeSocket.makeNet({ | ||
| 127 | host: "127.0.0.1", | ||
| 128 | port: address.port, | ||
| 129 | openTimeout: "10 seconds", | ||
| 130 | }); | ||
| 131 | return yield* bridge(local, channel); | ||
| 132 | }), | ||
| 133 | ).pipe( | ||
| 134 | channels.withPermitsIfAvailable(1), | ||
| 135 | Effect.ignore, | ||
| 136 | Effect.forkScoped({ startImmediately: true }), | ||
| 137 | ); | ||
| 138 | }), | ||
| 139 | { discard: true }, | ||
| 140 | ), | ||
| 141 | ), | ||
| 142 | Effect.forever, | ||
| 143 | ); | ||
| 144 | yield* Effect.raceFirst(run, Effect.sleep(Duration.millis(remaining))); | ||
| 145 | }), | ||
| 146 | ); | ||
| 147 | |||
| 148 | yield* ServerActivation.forkParked( | ||
| 149 | connection.changes.pipe( | ||
| 150 | Stream.switchMap((target) => | ||
| 151 | Option.isNone(target) | ||
| 152 | ? Stream.never | ||
| 153 | : Stream.fromEffect( | ||
| 154 | connect(target.value).pipe( | ||
| 155 | Effect.retry( | ||
| 156 | Schedule.exponential("1 second").pipe( | ||
| 157 | Schedule.modifyDelay(({ duration }) => | ||
| 158 | Effect.succeed(Duration.min(duration, Duration.seconds(30))), | ||
| 159 | ), | ||
| 160 | ), | ||
| 161 | ), | ||
| 162 | ), | ||
| 163 | ), | ||
| 164 | ), | ||
| 165 | Stream.runDrain, | ||
| 166 | ), | ||
| 167 | ); | ||
| 168 | }), | ||
| 169 | ); | ||
apps/server/src/environment/SharedServer.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import { SharedAccountError } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Redacted from "effect/Redacted"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 9 | |||
| 10 | const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString }); | ||
| 11 | const decode = Schema.decodeEffect(Schema.fromJsonString(Connection)); | ||
| 12 | const encode = Schema.encodeEffect(Schema.fromJsonString(Connection)); | ||
| 13 | |||
| 14 | export class SharedServer extends Context.Service< | ||
| 15 | SharedServer, | ||
| 16 | { | ||
| 17 | readonly connection: Effect.Effect<Option.Option<typeof Connection.Type>, SharedAccountError>; | ||
| 18 | readonly save: (connection: typeof Connection.Type) => Effect.Effect<void, SharedAccountError>; | ||
| 19 | readonly disconnect: Effect.Effect<void, SharedAccountError>; | ||
| 20 | } | ||
| 21 | >()("t3/environment/SharedServer") {} | ||
| 22 | |||
| 23 | export const layer = Layer.effect( | ||
| 24 | SharedServer, | ||
| 25 | Effect.gen(function* () { | ||
| 26 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 27 | const storageError = (cause: unknown) => | ||
| 28 | new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }); | ||
| 29 | return SharedServer.of({ | ||
| 30 | connection: secrets.get("shared-server").pipe( | ||
| 31 | Effect.flatMap((bytes) => | ||
| 32 | Option.isNone(bytes) | ||
| 33 | ? Effect.succeedNone | ||
| 34 | : decode(new TextDecoder().decode(bytes.value)).pipe(Effect.asSome), | ||
| 35 | ), | ||
| 36 | Effect.mapError(storageError), | ||
| 37 | ), | ||
| 38 | save: (value) => | ||
| 39 | encode(value).pipe( | ||
| 40 | Effect.flatMap((json) => secrets.set("shared-server", new TextEncoder().encode(json))), | ||
| 41 | Effect.mapError(storageError), | ||
| 42 | ), | ||
| 43 | disconnect: secrets.remove("shared-server").pipe(Effect.mapError(storageError)), | ||
| 44 | }); | ||
| 45 | }), | ||
| 46 | ); | ||
apps/server/src/environment/SourceConnection.test.ts created+132| ... | @@ -0,0 +1,132 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { HostedSourceConnection } from "@t3tools/contracts"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 9 | import * as SourceConnection from "./SourceConnection.ts"; | ||
| 10 | |||
| 11 | const sourceCodec = Schema.toCodecJson(HostedSourceConnection); | ||
| 12 | const encodeSource = Schema.encodeEffect(sourceCodec); | ||
| 13 | const decodeSource = Schema.decodeEffect(sourceCodec); | ||
| 14 | const scopes = ["archive:read", "archive:write", "environment:connect"]; | ||
| 15 | const fixture = Effect.gen(function* () { | ||
| 16 | const now = yield* DateTime.now; | ||
| 17 | const expiresAt = DateTime.add(now, { minutes: 15 }); | ||
| 18 | const input = { | ||
| 19 | serverUrl: "https://host.example", | ||
| 20 | authorization: "Bearer narrow-token", | ||
| 21 | expiresAt, | ||
| 22 | }; | ||
| 23 | let requests = 0; | ||
| 24 | let permissions = scopes; | ||
| 25 | let authenticated = true; | ||
| 26 | let status = 200; | ||
| 27 | let remoteExpiry = DateTime.formatIso(expiresAt); | ||
| 28 | const http = HttpClient.make((request) => | ||
| 29 | Effect.sync(() => { | ||
| 30 | requests++; | ||
| 31 | expect(request.url).toBe("https://host.example/api/auth/session"); | ||
| 32 | expect(request.headers.authorization).toBe("Bearer narrow-token"); | ||
| 33 | return HttpClientResponse.fromWeb( | ||
| 34 | request, | ||
| 35 | Response.json( | ||
| 36 | { | ||
| 37 | authenticated, | ||
| 38 | permissions, | ||
| 39 | expiresAt: remoteExpiry, | ||
| 40 | sessionMethod: "bearer-access-token", | ||
| 41 | }, | ||
| 42 | { status }, | ||
| 43 | ), | ||
| 44 | ); | ||
| 45 | }), | ||
| 46 | ); | ||
| 47 | const source = yield* SourceConnection.SourceConnection.pipe( | ||
| 48 | Effect.provide( | ||
| 49 | SourceConnection.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, http))), | ||
| 50 | ), | ||
| 51 | ); | ||
| 52 | return { | ||
| 53 | source, | ||
| 54 | input, | ||
| 55 | requests: () => requests, | ||
| 56 | setPermissions: (value: string[]) => { | ||
| 57 | permissions = value; | ||
| 58 | }, | ||
| 59 | deny: () => { | ||
| 60 | status = 401; | ||
| 61 | }, | ||
| 62 | setAuthenticated: (value: boolean) => { | ||
| 63 | authenticated = value; | ||
| 64 | }, | ||
| 65 | setExpiry: (value: string) => { | ||
| 66 | remoteExpiry = value; | ||
| 67 | }, | ||
| 68 | }; | ||
| 69 | }); | ||
| 70 | |||
| 71 | it.effect( | ||
| 72 | "accepts only verified narrow source grants, normalizes URL and deduplicates replay", | ||
| 73 | () => | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const f = yield* fixture; | ||
| 76 | const wire = yield* encodeSource(f.input); | ||
| 77 | const input = yield* decodeSource(wire); | ||
| 78 | yield* f.source.update(input); | ||
| 79 | yield* f.source.setConnected(true); | ||
| 80 | yield* f.source.update({ ...input, serverUrl: "https://host.example/" }); | ||
| 81 | expect(f.requests()).toBe(1); | ||
| 82 | expect(yield* f.source.connected).toBe(true); | ||
| 83 | expect(Option.getOrThrow(yield* f.source.current).serverUrl).toBe("https://host.example/"); | ||
| 84 | yield* f.source.update(undefined); | ||
| 85 | expect(Option.isNone(yield* f.source.current)).toBe(true); | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | |||
| 89 | it.effect.each([ | ||
| 90 | { permissions: scopes.slice(0, 2) }, | ||
| 91 | { permissions: [...scopes, "providers:manage"] }, | ||
| 92 | { permissions: [...scopes, "access:write"] }, | ||
| 93 | { permissions: ["archive:read", "archive:read", "environment:connect"] }, | ||
| 94 | ])("rejects non-source permission set $permissions", ({ permissions }) => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const f = yield* fixture; | ||
| 97 | f.setPermissions(permissions); | ||
| 98 | expect((yield* f.source.update(f.input).pipe(Effect.flip)).reason).toBe("credential"); | ||
| 99 | expect(Option.isNone(yield* f.source.current)).toBe(true); | ||
| 100 | }), | ||
| 101 | ); | ||
| 102 | |||
| 103 | it.effect("rejects denied, anonymous and shorter-lived remote credentials", () => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | for (const change of [ | ||
| 106 | (f: Effect.Success<typeof fixture>) => f.deny(), | ||
| 107 | (f: Effect.Success<typeof fixture>) => f.setAuthenticated(false), | ||
| 108 | (f: Effect.Success<typeof fixture>) => f.setExpiry("1970-01-01T00:00:00.000Z"), | ||
| 109 | ]) { | ||
| 110 | const f = yield* fixture; | ||
| 111 | change(f); | ||
| 112 | expect((yield* f.source.update(f.input).pipe(Effect.flip)).reason).toBe("credential"); | ||
| 113 | expect(Option.isNone(yield* f.source.current)).toBe(true); | ||
| 114 | } | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | |||
| 118 | it.effect("rejects unsafe origins and invalid grant lifetimes before sending the token", () => | ||
| 119 | Effect.gen(function* () { | ||
| 120 | const f = yield* fixture; | ||
| 121 | const now = yield* DateTime.now; | ||
| 122 | for (const input of [ | ||
| 123 | { ...f.input, serverUrl: "http://host.example" }, | ||
| 124 | { ...f.input, serverUrl: "https://host.example/another-path" }, | ||
| 125 | { ...f.input, serverUrl: "https://user:password@host.example" }, | ||
| 126 | { ...f.input, expiresAt: now }, | ||
| 127 | { ...f.input, expiresAt: DateTime.add(now, { hours: 2 }) }, | ||
| 128 | ]) | ||
| 129 | expect((yield* f.source.update(input).pipe(Effect.result))._tag).toBe("Failure"); | ||
| 130 | expect(f.requests()).toBe(0); | ||
| 131 | }), | ||
| 132 | ); | ||
apps/server/src/environment/SourceConnection.ts created+115| ... | @@ -0,0 +1,115 @@ | ||
| 1 | import { | ||
| 2 | AuthArchiveReadScope, | ||
| 3 | AuthArchiveWriteScope, | ||
| 4 | AuthEnvironmentConnectScope, | ||
| 5 | HostedSourceConnection, | ||
| 6 | HubError, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import type * as Stream from "effect/Stream"; | ||
| 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Semaphore from "effect/Semaphore"; | ||
| 17 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; | ||
| 18 | |||
| 19 | const SourceSession = Schema.toCodecJson( | ||
| 20 | Schema.Struct({ | ||
| 21 | authenticated: Schema.Literal(true), | ||
| 22 | permissions: Schema.Array(Schema.String), | ||
| 23 | expiresAt: Schema.DateTimeUtc, | ||
| 24 | sessionMethod: Schema.Literal("bearer-access-token"), | ||
| 25 | }), | ||
| 26 | ); | ||
| 27 | const sourceScopes = [AuthArchiveReadScope, AuthArchiveWriteScope, AuthEnvironmentConnectScope]; | ||
| 28 | |||
| 29 | export class SourceConnection extends Context.Service< | ||
| 30 | SourceConnection, | ||
| 31 | { | ||
| 32 | readonly current: Effect.Effect<Option.Option<HostedSourceConnection>>; | ||
| 33 | readonly connected: Effect.Effect<boolean>; | ||
| 34 | readonly setConnected: (connected: boolean) => Effect.Effect<void>; | ||
| 35 | readonly update: (input: HostedSourceConnection | undefined) => Effect.Effect<void, HubError>; | ||
| 36 | readonly changes: Stream.Stream<Option.Option<HostedSourceConnection>>; | ||
| 37 | } | ||
| 38 | >()("t3/environment/SourceConnection") {} | ||
| 39 | |||
| 40 | const make = Effect.gen(function* () { | ||
| 41 | const current = yield* SubscriptionRef.make(Option.none<HostedSourceConnection>()); | ||
| 42 | const http = yield* HttpClient.HttpClient; | ||
| 43 | const lock = yield* Semaphore.make(1); | ||
| 44 | let connected = false; | ||
| 45 | return SourceConnection.of({ | ||
| 46 | current: SubscriptionRef.get(current), | ||
| 47 | connected: Effect.sync(() => connected), | ||
| 48 | setConnected: (value) => | ||
| 49 | Effect.sync(() => { | ||
| 50 | connected = value; | ||
| 51 | }), | ||
| 52 | changes: SubscriptionRef.changes(current), | ||
| 53 | update: (input) => | ||
| 54 | lock.withPermit( | ||
| 55 | Effect.gen(function* () { | ||
| 56 | if (input) { | ||
| 57 | const url = yield* Effect.try({ | ||
| 58 | try: () => new URL(input.serverUrl), | ||
| 59 | catch: () => new HubError({ reason: "address" }), | ||
| 60 | }); | ||
| 61 | const now = yield* DateTime.now; | ||
| 62 | if ( | ||
| 63 | url.protocol !== "https:" || | ||
| 64 | url.username || | ||
| 65 | url.password || | ||
| 66 | url.search || | ||
| 67 | url.hash || | ||
| 68 | url.pathname !== "/" || | ||
| 69 | !/^Bearer [A-Za-z0-9._~-]+$/u.test(input.authorization) || | ||
| 70 | input.expiresAt.epochMilliseconds <= now.epochMilliseconds || | ||
| 71 | input.expiresAt.epochMilliseconds > now.epochMilliseconds + 3_600_000 | ||
| 72 | ) { | ||
| 73 | return yield* new HubError({ reason: "credential" }); | ||
| 74 | } | ||
| 75 | const previous = yield* SubscriptionRef.get(current); | ||
| 76 | const serverUrl = `${url.origin}/`; | ||
| 77 | if ( | ||
| 78 | Option.isSome(previous) && | ||
| 79 | previous.value.serverUrl === serverUrl && | ||
| 80 | previous.value.authorization === input.authorization && | ||
| 81 | previous.value.expiresAt.epochMilliseconds === input.expiresAt.epochMilliseconds | ||
| 82 | ) | ||
| 83 | return; | ||
| 84 | const verified = yield* Effect.gen(function* () { | ||
| 85 | const response = yield* http | ||
| 86 | .execute( | ||
| 87 | HttpClientRequest.get(new URL("api/auth/session", serverUrl).href).pipe( | ||
| 88 | HttpClientRequest.setHeader("authorization", input.authorization), | ||
| 89 | ), | ||
| 90 | ) | ||
| 91 | .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" })); | ||
| 92 | if (response.status !== 200) return yield* new HubError({ reason: "credential" }); | ||
| 93 | return yield* response.json.pipe( | ||
| 94 | Effect.flatMap(Schema.decodeUnknownEffect(SourceSession)), | ||
| 95 | ); | ||
| 96 | }).pipe( | ||
| 97 | Effect.timeout("10 seconds"), | ||
| 98 | Effect.scoped, | ||
| 99 | Effect.mapError(() => new HubError({ reason: "credential" })), | ||
| 100 | ); | ||
| 101 | if ( | ||
| 102 | verified.expiresAt.epochMilliseconds < input.expiresAt.epochMilliseconds || | ||
| 103 | verified.permissions.length !== sourceScopes.length || | ||
| 104 | !sourceScopes.every((scope) => verified.permissions.includes(scope)) | ||
| 105 | ) { | ||
| 106 | return yield* new HubError({ reason: "credential" }); | ||
| 107 | } | ||
| 108 | yield* SubscriptionRef.set(current, Option.some({ ...input, serverUrl })); | ||
| 109 | } else yield* SubscriptionRef.set(current, Option.none()); | ||
| 110 | }), | ||
| 111 | ), | ||
| 112 | }); | ||
| 113 | }); | ||
| 114 | |||
| 115 | export const layer = Layer.effect(SourceConnection, make); | ||
apps/server/src/environment/SourceConnectionHttp.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | import { | ||
| 2 | AuthAccessReadScope, | ||
| 3 | AuthAccessWriteScope, | ||
| 4 | AuthEnvironmentConnectScope, | ||
| 5 | HostedSourceConnection, | ||
| 6 | HubError, | ||
| 7 | SelfHostedConnectionStatus, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Schema from "effect/Schema"; | ||
| 14 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; | ||
| 15 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 16 | import * as OutboundHub from "./OutboundHub.ts"; | ||
| 17 | import * as SourceConnection from "./SourceConnection.ts"; | ||
| 18 | |||
| 19 | const isHubError = Schema.is(HubError); | ||
| 20 | const statusResponse = HttpServerResponse.schemaJson(SelfHostedConnectionStatus); | ||
| 21 | |||
| 22 | const errors = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 23 | effect.pipe( | ||
| 24 | Effect.catch((error) => | ||
| 25 | Effect.succeed( | ||
| 26 | HttpServerResponse.empty({ | ||
| 27 | status: EnvironmentAuth.isServerAuthCredentialError(error) | ||
| 28 | ? 401 | ||
| 29 | : Schema.isSchemaError(error) | ||
| 30 | ? 400 | ||
| 31 | : isHubError(error) && error.reason === "credential" | ||
| 32 | ? 403 | ||
| 33 | : isHubError(error) && error.reason === "address" | ||
| 34 | ? 400 | ||
| 35 | : 503, | ||
| 36 | }), | ||
| 37 | ), | ||
| 38 | ), | ||
| 39 | ); | ||
| 40 | |||
| 41 | const connection = Effect.gen(function* () { | ||
| 42 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 43 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 44 | const session = yield* auth.authenticateHttpRequest(request); | ||
| 45 | const source = yield* SourceConnection.SourceConnection; | ||
| 46 | if (request.method === "GET") { | ||
| 47 | if (!session.scopes.includes(AuthAccessReadScope)) | ||
| 48 | return HttpServerResponse.empty({ status: 403 }); | ||
| 49 | const current = yield* source.current; | ||
| 50 | return yield* statusResponse( | ||
| 51 | { | ||
| 52 | configured: Option.isSome(current), | ||
| 53 | connected: yield* source.connected, | ||
| 54 | expiresAt: Option.isSome(current) ? DateTime.formatIso(current.value.expiresAt) : null, | ||
| 55 | }, | ||
| 56 | { headers: { "cache-control": "no-store" } }, | ||
| 57 | ); | ||
| 58 | } | ||
| 59 | if ( | ||
| 60 | session.subject !== "desktop-bootstrap" || | ||
| 61 | session.parentSessionId !== undefined || | ||
| 62 | !session.scopes.includes(AuthAccessWriteScope) | ||
| 63 | ) { | ||
| 64 | return HttpServerResponse.empty({ status: 403 }); | ||
| 65 | } | ||
| 66 | yield* source.update( | ||
| 67 | request.method === "DELETE" | ||
| 68 | ? undefined | ||
| 69 | : yield* HttpServerRequest.schemaBodyJson(Schema.toCodecJson(HostedSourceConnection)), | ||
| 70 | ); | ||
| 71 | return HttpServerResponse.empty({ status: 204 }); | ||
| 72 | }); | ||
| 73 | |||
| 74 | const outbound = Effect.gen(function* () { | ||
| 75 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 76 | const session = yield* (yield* EnvironmentAuth.EnvironmentAuth).authenticateWebSocketUpgrade( | ||
| 77 | request, | ||
| 78 | ); | ||
| 79 | const hub = yield* OutboundHub.OutboundHub; | ||
| 80 | const params = yield* HttpRouter.params; | ||
| 81 | if (!session.deviceId || !session.scopes.includes(AuthEnvironmentConnectScope)) | ||
| 82 | return HttpServerResponse.empty({ status: 403 }); | ||
| 83 | const socket = yield* request.upgrade; | ||
| 84 | if (params.channelId) yield* hub.channel(session, params.channelId, socket); | ||
| 85 | else yield* hub.accept(session, socket); | ||
| 86 | return HttpServerResponse.empty(); | ||
| 87 | }); | ||
| 88 | |||
| 89 | export const layer = Layer.mergeAll( | ||
| 90 | HttpRouter.add("GET", "/api/self-hosted/connection", connection.pipe(errors)), | ||
| 91 | HttpRouter.add("POST", "/api/self-hosted/connection", connection.pipe(errors)), | ||
| 92 | HttpRouter.add("DELETE", "/api/self-hosted/connection", connection.pipe(errors)), | ||
| 93 | HttpRouter.add("GET", "/api/hub/outbound", outbound.pipe(errors)), | ||
| 94 | HttpRouter.add("GET", "/api/hub/outbound/channel/:channelId", outbound.pipe(errors)), | ||
| 95 | ); | ||
apps/server/src/http.ts+149-2| ... | @@ -1,8 +1,15 @@ | ... | @@ -1,8 +1,15 @@ |
| 1 | import * as Mime from "effect/http/Mime"; | 1 | import * as Mime from "effect/http/Mime"; |
| 2 | import { | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | 3 | AuthOrchestrationOperateScope, |
| 4 | AuthArchiveReadScope, | ||
| 5 | AuthArchiveWriteScope, | ||
| 4 | AuthOrchestrationReadScope, | 6 | AuthOrchestrationReadScope, |
| 5 | AuthProvidersManageScope, | 7 | AuthProvidersManageScope, |
| 8 | ArchiveManagementRequest, | ||
| 9 | ArchiveError, | ||
| 10 | EnvironmentAuthInvalidError, | ||
| 11 | EnvironmentInternalError, | ||
| 12 | EnvironmentScopeRequiredError, | ||
| 6 | SharedAccountRequest, | 13 | SharedAccountRequest, |
| 7 | SharedProfilesRequest, | 14 | SharedProfilesRequest, |
| 8 | SharedAccountError, | 15 | SharedAccountError, |
| ... | @@ -35,6 +42,9 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; | ... | @@ -35,6 +42,9 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 35 | import { OtlpTracer, OtlpSerialization } from "effect/observability"; | 42 | import { OtlpTracer, OtlpSerialization } from "effect/observability"; |
| 36 | 43 | ||
| 37 | import * as ServerConfig from "./config.ts"; | 44 | import * as ServerConfig from "./config.ts"; |
| 45 | import * as ArchiveObjects from "./archive/ArchiveObjects.ts"; | ||
| 46 | import * as ArchiveAssetResponse from "./archive/ArchiveAssetResponse.ts"; | ||
| 47 | import * as TranscriptArchive from "./archive/TranscriptArchive.ts"; | ||
| 38 | import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; | 48 | import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; |
| 39 | import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; | 49 | import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; |
| 40 | import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; | 50 | import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; |
| ... | @@ -307,7 +317,10 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { | ... | @@ -307,7 +317,10 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { |
| 307 | return redirectUrl.toString(); | 317 | return redirectUrl.toString(); |
| 308 | } | 318 | } |
| 309 | 319 | ||
| 310 | const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) => | 320 | export const authenticateRawRouteWithScope = ( |
| 321 | scope: AuthEnvironmentScope, | ||
| 322 | legacyScope?: AuthEnvironmentScope, | ||
| 323 | ) => | ||
| 311 | Effect.gen(function* () { | 324 | Effect.gen(function* () { |
| 312 | const request = yield* HttpServerRequest.HttpServerRequest; | 325 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 313 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 326 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| ... | @@ -322,11 +335,126 @@ const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) => | ... | @@ -322,11 +335,126 @@ const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) => |
| 322 | failEnvironmentInternal("internal_error", error), | 335 | failEnvironmentInternal("internal_error", error), |
| 323 | ), | 336 | ), |
| 324 | ); | 337 | ); |
| 325 | if (!session.scopes.includes(scope)) { | 338 | if (!session.scopes.includes(scope) && !(legacyScope && session.scopes.includes(legacyScope))) { |
| 326 | return yield* failEnvironmentScopeRequired(scope); | 339 | return yield* failEnvironmentScopeRequired(scope); |
| 327 | } | 340 | } |
| 341 | return session; | ||
| 328 | }); | 342 | }); |
| 329 | 343 | ||
| 344 | const isArchiveError = Schema.is(ArchiveError); | ||
| 345 | const isArchiveAuthError = Schema.is( | ||
| 346 | Schema.Union([ | ||
| 347 | EnvironmentAuthInvalidError, | ||
| 348 | EnvironmentInternalError, | ||
| 349 | EnvironmentScopeRequiredError, | ||
| 350 | ]), | ||
| 351 | ); | ||
| 352 | export const archiveRouteErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 353 | effect.pipe( | ||
| 354 | Effect.catch((error): Effect.Effect<HttpServerResponse.HttpServerResponse, E> => { | ||
| 355 | if (isArchiveError(error)) | ||
| 356 | return Effect.succeed( | ||
| 357 | HttpServerResponse.jsonUnsafe( | ||
| 358 | { _tag: error._tag, reason: error.reason }, | ||
| 359 | { | ||
| 360 | status: error.reason === "integrity" ? 400 : 503, | ||
| 361 | headers: { "cache-control": "no-store" }, | ||
| 362 | }, | ||
| 363 | ), | ||
| 364 | ); | ||
| 365 | if (isArchiveAuthError(error)) return HttpServerRespondable.toResponse(error); | ||
| 366 | return Effect.fail(error); | ||
| 367 | }), | ||
| 368 | ); | ||
| 369 | |||
| 370 | export const layerArchiveManagementRoute = HttpRouter.add( | ||
| 371 | "POST", | ||
| 372 | "/api/archive/manage", | ||
| 373 | Effect.gen(function* () { | ||
| 374 | const input = yield* HttpServerRequest.schemaBodyJson(ArchiveManagementRequest).pipe( | ||
| 375 | Effect.mapError( | ||
| 376 | (cause) => new ArchiveError({ reason: "integrity", cause: Redacted.make(cause) }), | ||
| 377 | ), | ||
| 378 | ); | ||
| 379 | yield* authenticateRawRouteWithScope( | ||
| 380 | input.type === "archive.status" ? AuthArchiveReadScope : AuthArchiveWriteScope, | ||
| 381 | input.type === "archive.status" ? AuthOrchestrationReadScope : AuthOrchestrationOperateScope, | ||
| 382 | ); | ||
| 383 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 384 | return yield* archive | ||
| 385 | .manage(input) | ||
| 386 | .pipe( | ||
| 387 | Effect.flatMap((status) => | ||
| 388 | HttpServerResponse.json(status, { headers: { "cache-control": "no-store" } }), | ||
| 389 | ), | ||
| 390 | ); | ||
| 391 | }).pipe(archiveRouteErrors), | ||
| 392 | ); | ||
| 393 | |||
| 394 | export const layerArchiveUploadRoute = HttpRouter.add( | ||
| 395 | "PUT", | ||
| 396 | "/api/archive/objects/:hash", | ||
| 397 | Effect.gen(function* () { | ||
| 398 | yield* authenticateRawRouteWithScope(AuthArchiveWriteScope, AuthOrchestrationOperateScope); | ||
| 399 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 400 | const params = yield* HttpRouter.params; | ||
| 401 | const objects = yield* ArchiveObjects.ArchiveObjects; | ||
| 402 | return yield* objects | ||
| 403 | .put(params.hash ?? "", request.stream) | ||
| 404 | .pipe( | ||
| 405 | Effect.flatMap((receipt) => | ||
| 406 | HttpServerResponse.json(receipt, { headers: { "cache-control": "no-store" } }), | ||
| 407 | ), | ||
| 408 | ); | ||
| 409 | }).pipe(archiveRouteErrors, Effect.withTracerEnabled(false)), | ||
| 410 | ); | ||
| 411 | |||
| 412 | export const layerArchiveDownloadRoute = HttpRouter.add( | ||
| 413 | "GET", | ||
| 414 | "/api/archive/objects/:hash", | ||
| 415 | Effect.gen(function* () { | ||
| 416 | yield* authenticateRawRouteWithScope(AuthArchiveReadScope, AuthOrchestrationReadScope); | ||
| 417 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 418 | const params = yield* HttpRouter.params; | ||
| 419 | const objects = yield* ArchiveObjects.ArchiveObjects; | ||
| 420 | const fs = yield* FileSystem.FileSystem; | ||
| 421 | return yield* Effect.gen(function* () { | ||
| 422 | const file = yield* objects.file(params.hash ?? ""); | ||
| 423 | const stat = yield* fs.stat(file); | ||
| 424 | const range = | ||
| 425 | request.headers.range && !request.headers["if-range"] | ||
| 426 | ? assetByteRange(request.headers.range, stat.size) | ||
| 427 | : null; | ||
| 428 | if (range?._tag === "Unsatisfiable") | ||
| 429 | return HttpServerResponse.empty({ | ||
| 430 | status: 416, | ||
| 431 | headers: { "content-range": `bytes */${stat.size}` }, | ||
| 432 | }); | ||
| 433 | const bounded = range?._tag === "Range" ? range : undefined; | ||
| 434 | return HttpServerResponse.stream( | ||
| 435 | fs.stream(file, { | ||
| 436 | offset: bounded?.offset ?? 0n, | ||
| 437 | ...(bounded ? { bytesToRead: bounded.bytesToRead } : {}), | ||
| 438 | }), | ||
| 439 | { | ||
| 440 | status: bounded ? 206 : 200, | ||
| 441 | headers: { | ||
| 442 | "content-type": "application/octet-stream", | ||
| 443 | "content-length": String(bounded?.bytesToRead ?? stat.size), | ||
| 444 | "cache-control": "private, no-store", | ||
| 445 | "accept-ranges": "bytes", | ||
| 446 | ...(bounded ? { "content-range": bounded.contentRange } : {}), | ||
| 447 | }, | ||
| 448 | }, | ||
| 449 | ); | ||
| 450 | }).pipe( | ||
| 451 | Effect.orElseSucceed(() => | ||
| 452 | HttpServerResponse.empty({ status: 503, headers: { "cache-control": "no-store" } }), | ||
| 453 | ), | ||
| 454 | ); | ||
| 455 | }).pipe(archiveRouteErrors, Effect.withTracerEnabled(false)), | ||
| 456 | ); | ||
| 457 | |||
| 330 | export const layerSharedAccountRoute = HttpRouter.add( | 458 | export const layerSharedAccountRoute = HttpRouter.add( |
| 331 | "POST", | 459 | "POST", |
| 332 | "/api/shared/accounts", | 460 | "/api/shared/accounts", |
| ... | @@ -519,6 +647,25 @@ export const layerAssetRoute = HttpRouter.add( | ... | @@ -519,6 +647,25 @@ export const layerAssetRoute = HttpRouter.add( |
| 519 | headers: { "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff" }, | 647 | headers: { "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff" }, |
| 520 | }); | 648 | }); |
| 521 | } | 649 | } |
| 650 | if (asset.kind === "archived-file") { | ||
| 651 | return yield* ArchiveAssetResponse.remoteResponse( | ||
| 652 | asset, | ||
| 653 | request.headers["if-range"] ? undefined : request.headers.range, | ||
| 654 | ).pipe( | ||
| 655 | Effect.flatMap((result) => | ||
| 656 | result.kind === "response" | ||
| 657 | ? Effect.succeed(result.response) | ||
| 658 | : assetFileResponse( | ||
| 659 | { ...asset, path: result.path }, | ||
| 660 | request.headers.range, | ||
| 661 | request.headers["if-range"], | ||
| 662 | ), | ||
| 663 | ), | ||
| 664 | Effect.orElseSucceed(() => | ||
| 665 | HttpServerResponse.empty({ status: 503, headers: { "cache-control": "no-store" } }), | ||
| 666 | ), | ||
| 667 | ); | ||
| 668 | } | ||
| 522 | if (asset.kind === "github-media") { | 669 | if (asset.kind === "github-media") { |
| 523 | return yield* githubMediaResponse(asset, request.headers).pipe( | 670 | return yield* githubMediaResponse(asset, request.headers).pipe( |
| 524 | Effect.tapError((cause) => | 671 | Effect.tapError((cause) => |
apps/server/src/orchestration-v2/ProjectionMaintenance.ts+13-2| ... | @@ -8,6 +8,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -8,6 +8,7 @@ import * as Stream from "effect/Stream"; |
| 8 | import * as SqlClient from "effect/sql/SqlClient"; | 8 | import * as SqlClient from "effect/sql/SqlClient"; |
| 9 | 9 | ||
| 10 | import * as EventStore from "./EventStore.ts"; | 10 | import * as EventStore from "./EventStore.ts"; |
| 11 | import * as TranscriptArchive from "../archive/TranscriptArchive.ts"; | ||
| 11 | import * as ProjectionStore from "./ProjectionStore.ts"; | 12 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 12 | 13 | ||
| 13 | export interface ProjectionVerificationV2 { | 14 | export interface ProjectionVerificationV2 { |
| ... | @@ -171,6 +172,13 @@ export const layer: Layer.Layer< | ... | @@ -171,6 +172,13 @@ export const layer: Layer.Layer< |
| 171 | if (page.length < pageSize) break; | 172 | if (page.length < pageSize) break; |
| 172 | yield* Effect.yieldNow; | 173 | yield* Effect.yieldNow; |
| 173 | } | 174 | } |
| 175 | yield* TranscriptArchive.rebuildMetadata.pipe( | ||
| 176 | Effect.provideService(SqlClient.SqlClient, sql), | ||
| 177 | ); | ||
| 178 | yield* sql`INSERT INTO orchestration_v2_turn_item_positions(thread_id, turn_item_id, ordinal) | ||
| 179 | SELECT item.thread_id, item.turn_item_id, item.ordinal FROM orchestration_v2_projection_turn_items item | ||
| 180 | WHERE EXISTS (SELECT 1 FROM transcript_archives a WHERE a.thread_id = item.thread_id) | ||
| 181 | ON CONFLICT(thread_id, turn_item_id) DO UPDATE SET ordinal = excluded.ordinal`; | ||
| 174 | const now = DateTime.formatIso(yield* DateTime.now); | 182 | const now = DateTime.formatIso(yield* DateTime.now); |
| 175 | yield* sql` | 183 | yield* sql` |
| 176 | INSERT INTO orchestration_v2_projection_metadata ( | 184 | INSERT INTO orchestration_v2_projection_metadata ( |
| ... | @@ -182,7 +190,7 @@ export const layer: Layer.Layer< | ... | @@ -182,7 +190,7 @@ export const layer: Layer.Layer< |
| 182 | VALUES ( | 190 | VALUES ( |
| 183 | 'thread-projections', | 191 | 'thread-projections', |
| 184 | ${ProjectionStore.ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION}, | 192 | ${ProjectionStore.ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION}, |
| 185 | ${lastSequence}, | 193 | ${throughSequence}, |
| 186 | ${now} | 194 | ${now} |
| 187 | ) | 195 | ) |
| 188 | ON CONFLICT(projection_name) DO UPDATE SET | 196 | ON CONFLICT(projection_name) DO UPDATE SET |
| ... | @@ -302,7 +310,10 @@ export const layer: Layer.Layer< | ... | @@ -302,7 +310,10 @@ export const layer: Layer.Layer< |
| 302 | ) { | 310 | ) { |
| 303 | if (retainedThreadIds.has(row.stream_id)) obsolete.push(row.sequence); | 311 | if (retainedThreadIds.has(row.stream_id)) obsolete.push(row.sequence); |
| 304 | else retainedThreadIds.add(row.stream_id); | 312 | else retainedThreadIds.add(row.stream_id); |
| 305 | } else if (row.event_type === "message.updated" || row.event_type === "node.updated") { | 313 | } else if ( |
| 314 | row.entity_id !== null && | ||
| 315 | (row.event_type === "message.updated" || row.event_type === "node.updated") | ||
| 316 | ) { | ||
| 306 | const key = encodeEntityKey([row.event_type, row.stream_id, row.entity_id]); | 317 | const key = encodeEntityKey([row.event_type, row.stream_id, row.entity_id]); |
| 307 | if (retainedEntityKeys.has(key)) obsolete.push(row.sequence); | 318 | if (retainedEntityKeys.has(key)) obsolete.push(row.sequence); |
| 308 | else retainedEntityKeys.add(key); | 319 | else retainedEntityKeys.add(key); |
apps/server/src/orchestration-v2/ProjectionStore.ts+7| ... | @@ -3938,6 +3938,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -3938,6 +3938,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 3938 | payload_json | 3938 | payload_json |
| 3939 | FROM ${sql(`orchestration_v2_projection_${table.name}`)} | 3939 | FROM ${sql(`orchestration_v2_projection_${table.name}`)} |
| 3940 | WHERE rowid > ${afterRowId} | 3940 | WHERE rowid > ${afterRowId} |
| 3941 | ${ | ||
| 3942 | ["messages", "turn_items", "subagents"].includes(table.name) | ||
| 3943 | ? sql`AND NOT ( | ||
| 3944 | json_extract(payload_json, '$.archivedPayload') = 1 | ||
| 3945 | AND EXISTS (SELECT 1 FROM transcript_archives a WHERE a.thread_id = ${sql(`orchestration_v2_projection_${table.name}`)}.thread_id))` | ||
| 3946 | : sql`` | ||
| 3947 | } | ||
| 3941 | ORDER BY rowid ASC LIMIT ${pageSize} | 3948 | ORDER BY rowid ASC LIMIT ${pageSize} |
| 3942 | `; | 3949 | `; |
| 3943 | for (const row of rows) { | 3950 | for (const row of rows) { |
apps/server/src/orchestration-v2/runtimeLayer.ts+15-13| ... | @@ -27,6 +27,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | ... | @@ -27,6 +27,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 27 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; | 27 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; |
| 28 | import * as Orchestrator from "./Orchestrator.ts"; | 28 | import * as Orchestrator from "./Orchestrator.ts"; |
| 29 | import * as ProjectionStore from "./ProjectionStore.ts"; | 29 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 30 | import * as ArchivedProjectionStore from "../archive/ArchivedProjectionStore.ts"; | ||
| 30 | import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; | 31 | import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; |
| 31 | import * as ProjectStore from "./ProjectStore.ts"; | 32 | import * as ProjectStore from "./ProjectStore.ts"; |
| 32 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; | 33 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| ... | @@ -72,7 +73,7 @@ const layerCommandReceiptStoreProvided = CommandReceiptStore.layerFromApplicatio | ... | @@ -72,7 +73,7 @@ const layerCommandReceiptStoreProvided = CommandReceiptStore.layerFromApplicatio |
| 72 | const layerStores = Layer.mergeAll( | 73 | const layerStores = Layer.mergeAll( |
| 73 | layerEventInfrastructure, | 74 | layerEventInfrastructure, |
| 74 | layerEventStoreProvided, | 75 | layerEventStoreProvided, |
| 75 | ProjectionStore.layer, | 76 | ArchivedProjectionStore.layer, |
| 76 | ProjectStore.layer, | 77 | ProjectStore.layer, |
| 77 | layerCommandReceiptStoreProvided, | 78 | layerCommandReceiptStoreProvided, |
| 78 | EffectOutbox.layer, | 79 | EffectOutbox.layer, |
| ... | @@ -82,6 +83,7 @@ const layerStores = Layer.mergeAll( | ... | @@ -82,6 +83,7 @@ const layerStores = Layer.mergeAll( |
| 82 | export const layerEventSink = EventSink.layerFromStores.pipe(Layer.provide(layerStores)); | 83 | export const layerEventSink = EventSink.layerFromStores.pipe(Layer.provide(layerStores)); |
| 83 | const layerEventSinkProvided = layerEventSink; | 84 | const layerEventSinkProvided = layerEventSink; |
| 84 | const layerProjectionMaintenanceProvided = ProjectionMaintenance.layer.pipe( | 85 | const layerProjectionMaintenanceProvided = ProjectionMaintenance.layer.pipe( |
| 86 | Layer.provide(ProjectionStore.layer), | ||
| 85 | Layer.provide(layerStores), | 87 | Layer.provide(layerStores), |
| 86 | ); | 88 | ); |
| 87 | const layerLegacyV1ThreadImporterProvided = LegacyV1ThreadImporter.layer.pipe( | 89 | const layerLegacyV1ThreadImporterProvided = LegacyV1ThreadImporter.layer.pipe( |
| ... | @@ -92,7 +94,7 @@ export const layerProjectService = ProjectService.layer.pipe( | ... | @@ -92,7 +94,7 @@ export const layerProjectService = ProjectService.layer.pipe( |
| 92 | Layer.provide( | 94 | Layer.provide( |
| 93 | Layer.mergeAll( | 95 | Layer.mergeAll( |
| 94 | ProjectStore.layer, | 96 | ProjectStore.layer, |
| 95 | ProjectionStore.layer, | 97 | ArchivedProjectionStore.layer, |
| 96 | layerEventSinkProvided, | 98 | layerEventSinkProvided, |
| 97 | IdAllocator.layer, | 99 | IdAllocator.layer, |
| 98 | layerLegacyV1ThreadImporterProvided, | 100 | layerLegacyV1ThreadImporterProvided, |
| ... | @@ -105,7 +107,7 @@ const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( | ... | @@ -105,7 +107,7 @@ const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( |
| 105 | Layer.mergeAll( | 107 | Layer.mergeAll( |
| 106 | layerEventSinkProvided, | 108 | layerEventSinkProvided, |
| 107 | IdAllocator.layer, | 109 | IdAllocator.layer, |
| 108 | ProjectionStore.layer, | 110 | ArchivedProjectionStore.layer, |
| 109 | ThreadCommandExecutor.layer, | 111 | ThreadCommandExecutor.layer, |
| 110 | ), | 112 | ), |
| 111 | ), | 113 | ), |
| ... | @@ -131,13 +133,13 @@ const layerProviderSessionManagerProvided = ProviderSessionManager.layer.pipe( | ... | @@ -131,13 +133,13 @@ const layerProviderSessionManagerProvided = ProviderSessionManager.layer.pipe( |
| 131 | layerEventSinkProvided, | 133 | layerEventSinkProvided, |
| 132 | IdAllocator.layer, | 134 | IdAllocator.layer, |
| 133 | layerProviderEventIngestorProvided, | 135 | layerProviderEventIngestorProvided, |
| 134 | ProjectionStore.layer, | 136 | ArchivedProjectionStore.layer, |
| 135 | ), | 137 | ), |
| 136 | ), | 138 | ), |
| 137 | ); | 139 | ); |
| 138 | 140 | ||
| 139 | const layerProviderAuthServiceProvided = ProviderAuthService.layer.pipe( | 141 | const layerProviderAuthServiceProvided = ProviderAuthService.layer.pipe( |
| 140 | Layer.provide(Layer.merge(ProjectionStore.layer, layerProviderSessionManagerProvided)), | 142 | Layer.provide(Layer.merge(ArchivedProjectionStore.layer, layerProviderSessionManagerProvided)), |
| 141 | ); | 143 | ); |
| 142 | 144 | ||
| 143 | const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe( | 145 | const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe( |
| ... | @@ -158,7 +160,7 @@ const layerProviderTurnStartServiceProvided = ProviderTurnStartService.layer.pip | ... | @@ -158,7 +160,7 @@ const layerProviderTurnStartServiceProvided = ProviderTurnStartService.layer.pip |
| 158 | layerContextHandoffServiceProvided, | 160 | layerContextHandoffServiceProvided, |
| 159 | layerEventSinkProvided, | 161 | layerEventSinkProvided, |
| 160 | IdAllocator.layer, | 162 | IdAllocator.layer, |
| 161 | ProjectionStore.layer, | 163 | ArchivedProjectionStore.layer, |
| 162 | layerProviderSessionManagerProvided, | 164 | layerProviderSessionManagerProvided, |
| 163 | layerProviderAuthServiceProvided, | 165 | layerProviderAuthServiceProvided, |
| 164 | layerRunExecutionServiceProvided, | 166 | layerRunExecutionServiceProvided, |
| ... | @@ -168,10 +170,10 @@ const layerProviderTurnStartServiceProvided = ProviderTurnStartService.layer.pip | ... | @@ -168,10 +170,10 @@ const layerProviderTurnStartServiceProvided = ProviderTurnStartService.layer.pip |
| 168 | ); | 170 | ); |
| 169 | 171 | ||
| 170 | const layerProviderTurnControlServiceProvided = ProviderTurnControlService.layer.pipe( | 172 | const layerProviderTurnControlServiceProvided = ProviderTurnControlService.layer.pipe( |
| 171 | Layer.provide(Layer.merge(ProjectionStore.layer, layerProviderSessionManagerProvided)), | 173 | Layer.provide(Layer.merge(ArchivedProjectionStore.layer, layerProviderSessionManagerProvided)), |
| 172 | ); | 174 | ); |
| 173 | const layerRuntimeRequestServiceProvided = RuntimeRequestService.layer.pipe( | 175 | const layerRuntimeRequestServiceProvided = RuntimeRequestService.layer.pipe( |
| 174 | Layer.provide(Layer.merge(ProjectionStore.layer, layerProviderSessionManagerProvided)), | 176 | Layer.provide(Layer.merge(ArchivedProjectionStore.layer, layerProviderSessionManagerProvided)), |
| 175 | ); | 177 | ); |
| 176 | const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.pipe( | 178 | const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.pipe( |
| 177 | Layer.provide( | 179 | Layer.provide( |
| ... | @@ -180,7 +182,7 @@ const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.p | ... | @@ -180,7 +182,7 @@ const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.p |
| 180 | layerCheckpointServiceProvided, | 182 | layerCheckpointServiceProvided, |
| 181 | layerEventSinkProvided, | 183 | layerEventSinkProvided, |
| 182 | IdAllocator.layer, | 184 | IdAllocator.layer, |
| 183 | ProjectionStore.layer, | 185 | ArchivedProjectionStore.layer, |
| 184 | layerProviderSessionManagerProvided, | 186 | layerProviderSessionManagerProvided, |
| 185 | ThreadCommandExecutor.layer, | 187 | ThreadCommandExecutor.layer, |
| 186 | layerRuntimePolicyProvided, | 188 | layerRuntimePolicyProvided, |
| ... | @@ -193,12 +195,12 @@ const layerCheckpointCaptureServiceProvided = CheckpointCaptureService.layer.pip | ... | @@ -193,12 +195,12 @@ const layerCheckpointCaptureServiceProvided = CheckpointCaptureService.layer.pip |
| 193 | layerCheckpointServiceProvided, | 195 | layerCheckpointServiceProvided, |
| 194 | layerEventSinkProvided, | 196 | layerEventSinkProvided, |
| 195 | IdAllocator.layer, | 197 | IdAllocator.layer, |
| 196 | ProjectionStore.layer, | 198 | ArchivedProjectionStore.layer, |
| 197 | ), | 199 | ), |
| 198 | ), | 200 | ), |
| 199 | ); | 201 | ); |
| 200 | const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( | 202 | const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( |
| 201 | Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ProjectionStore.layer)), | 203 | Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ArchivedProjectionStore.layer)), |
| 202 | ); | 204 | ); |
| 203 | 205 | ||
| 204 | const layerOrchestratorProvided = Orchestrator.layer.pipe( | 206 | const layerOrchestratorProvided = Orchestrator.layer.pipe( |
| ... | @@ -320,7 +322,7 @@ const layerProviderRuntimeRecoveryProvided = ProviderRuntimeRecoveryService.laye | ... | @@ -320,7 +322,7 @@ const layerProviderRuntimeRecoveryProvided = ProviderRuntimeRecoveryService.laye |
| 320 | layerStores, | 322 | layerStores, |
| 321 | layerEventSinkProvided, | 323 | layerEventSinkProvided, |
| 322 | IdAllocator.layer, | 324 | IdAllocator.layer, |
| 323 | ProjectionStore.layer, | 325 | ArchivedProjectionStore.layer, |
| 324 | ), | 326 | ), |
| 325 | ), | 327 | ), |
| 326 | ); | 328 | ); |
| ... | @@ -357,7 +359,7 @@ export const layerProduction = Layer.mergeAll( | ... | @@ -357,7 +359,7 @@ export const layerProduction = Layer.mergeAll( |
| 357 | layerScheduledTaskProvided, | 359 | layerScheduledTaskProvided, |
| 358 | layerSecretRequestsProvided, | 360 | layerSecretRequestsProvided, |
| 359 | UsageLimitRecoveryWorker.layer.pipe( | 361 | UsageLimitRecoveryWorker.layer.pipe( |
| 360 | Layer.provide(Layer.mergeAll(ProjectionStore.layer, layerThreadManagementProvided)), | 362 | Layer.provide(Layer.mergeAll(ArchivedProjectionStore.layer, layerThreadManagementProvided)), |
| 361 | ), | 363 | ), |
| 362 | layerProviderContinuationWorkerProvided, | 364 | layerProviderContinuationWorkerProvided, |
| 363 | layerAgentSessionImporterProvided, | 365 | layerAgentSessionImporterProvided, |
apps/server/src/persistence/AuthSessions.ts+31-3| ... | @@ -33,6 +33,8 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe | ... | @@ -33,6 +33,8 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe |
| 33 | 33 | ||
| 34 | export const AuthSessionRecord = Schema.Struct({ | 34 | export const AuthSessionRecord = Schema.Struct({ |
| 35 | sessionId: AuthSessionId, | 35 | sessionId: AuthSessionId, |
| 36 | parentSessionId: Schema.optionalKey(AuthSessionId), | ||
| 37 | deviceId: Schema.optionalKey(Schema.String), | ||
| 36 | subject: Schema.String, | 38 | subject: Schema.String, |
| 37 | scopes: AuthEnvironmentScopes, | 39 | scopes: AuthEnvironmentScopes, |
| 38 | method: ServerAuthSessionMethod, | 40 | method: ServerAuthSessionMethod, |
| ... | @@ -46,6 +48,8 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type; | ... | @@ -46,6 +48,8 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type; |
| 46 | 48 | ||
| 47 | export const CreateAuthSessionInput = Schema.Struct({ | 49 | export const CreateAuthSessionInput = Schema.Struct({ |
| 48 | sessionId: AuthSessionId, | 50 | sessionId: AuthSessionId, |
| 51 | parentSessionId: Schema.optionalKey(AuthSessionId), | ||
| 52 | deviceId: Schema.optionalKey(Schema.String), | ||
| 49 | subject: Schema.String, | 53 | subject: Schema.String, |
| 50 | scopes: AuthEnvironmentScopes, | 54 | scopes: AuthEnvironmentScopes, |
| 51 | method: ServerAuthSessionMethod, | 55 | method: ServerAuthSessionMethod, |
| ... | @@ -119,7 +123,7 @@ export class AuthSessionRepository extends Context.Service< | ... | @@ -119,7 +123,7 @@ export class AuthSessionRepository extends Context.Service< |
| 119 | ) => Effect.Effect<ReadonlyArray<AuthSessionRecord>, AuthSessionRepositoryError>; | 123 | ) => Effect.Effect<ReadonlyArray<AuthSessionRecord>, AuthSessionRepositoryError>; |
| 120 | readonly revoke: ( | 124 | readonly revoke: ( |
| 121 | input: RevokeAuthSessionInput, | 125 | input: RevokeAuthSessionInput, |
| 122 | ) => Effect.Effect<boolean, AuthSessionRepositoryError>; | 126 | ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>; |
| 123 | readonly revokeAllExcept: ( | 127 | readonly revokeAllExcept: ( |
| 124 | input: RevokeOtherAuthSessionsInput, | 128 | input: RevokeOtherAuthSessionsInput, |
| 125 | ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>; | 129 | ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>; |
| ... | @@ -134,6 +138,8 @@ export class AuthSessionRepository extends Context.Service< | ... | @@ -134,6 +138,8 @@ export class AuthSessionRepository extends Context.Service< |
| 134 | 138 | ||
| 135 | const AuthSessionDbRow = Schema.Struct({ | 139 | const AuthSessionDbRow = Schema.Struct({ |
| 136 | sessionId: AuthSessionId, | 140 | sessionId: AuthSessionId, |
| 141 | parentSessionId: Schema.NullOr(AuthSessionId), | ||
| 142 | deviceId: Schema.NullOr(Schema.String), | ||
| 137 | subject: Schema.String, | 143 | subject: Schema.String, |
| 138 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), | 144 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), |
| 139 | method: ServerAuthSessionMethod, | 145 | method: ServerAuthSessionMethod, |
| ... | @@ -151,6 +157,8 @@ const AuthSessionDbRow = Schema.Struct({ | ... | @@ -151,6 +157,8 @@ const AuthSessionDbRow = Schema.Struct({ |
| 151 | 157 | ||
| 152 | const AuthSessionRawDbRow = Schema.Struct({ | 158 | const AuthSessionRawDbRow = Schema.Struct({ |
| 153 | sessionId: Schema.String, | 159 | sessionId: Schema.String, |
| 160 | parentSessionId: Schema.Unknown, | ||
| 161 | deviceId: Schema.Unknown, | ||
| 154 | subject: Schema.Unknown, | 162 | subject: Schema.Unknown, |
| 155 | scopes: Schema.Unknown, | 163 | scopes: Schema.Unknown, |
| 156 | method: Schema.Unknown, | 164 | method: Schema.Unknown, |
| ... | @@ -171,6 +179,8 @@ const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow); | ... | @@ -171,6 +179,8 @@ const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow); |
| 171 | function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord { | 179 | function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord { |
| 172 | return { | 180 | return { |
| 173 | sessionId: row.sessionId, | 181 | sessionId: row.sessionId, |
| 182 | ...(row.parentSessionId === null ? {} : { parentSessionId: row.parentSessionId }), | ||
| 183 | ...(row.deviceId === null ? {} : { deviceId: row.deviceId }), | ||
| 174 | subject: row.subject, | 184 | subject: row.subject, |
| 175 | scopes: row.scopes, | 185 | scopes: row.scopes, |
| 176 | method: row.method, | 186 | method: row.method, |
| ... | @@ -215,6 +225,8 @@ export const make = Effect.gen(function* () { | ... | @@ -215,6 +225,8 @@ export const make = Effect.gen(function* () { |
| 215 | sql` | 225 | sql` |
| 216 | INSERT INTO auth_sessions ( | 226 | INSERT INTO auth_sessions ( |
| 217 | session_id, | 227 | session_id, |
| 228 | parent_session_id, | ||
| 229 | device_id, | ||
| 218 | subject, | 230 | subject, |
| 219 | scopes, | 231 | scopes, |
| 220 | method, | 232 | method, |
| ... | @@ -230,6 +242,8 @@ export const make = Effect.gen(function* () { | ... | @@ -230,6 +242,8 @@ export const make = Effect.gen(function* () { |
| 230 | ) | 242 | ) |
| 231 | VALUES ( | 243 | VALUES ( |
| 232 | ${input.sessionId}, | 244 | ${input.sessionId}, |
| 245 | ${input.parentSessionId ?? null}, | ||
| 246 | ${input.deviceId ?? null}, | ||
| 233 | ${input.subject}, | 247 | ${input.subject}, |
| 234 | ${JSON.stringify(input.scopes)}, | 248 | ${JSON.stringify(input.scopes)}, |
| 235 | ${input.method}, | 249 | ${input.method}, |
| ... | @@ -256,6 +270,8 @@ export const make = Effect.gen(function* () { | ... | @@ -256,6 +270,8 @@ export const make = Effect.gen(function* () { |
| 256 | sql` | 270 | sql` |
| 257 | SELECT | 271 | SELECT |
| 258 | session_id AS "sessionId", | 272 | session_id AS "sessionId", |
| 273 | parent_session_id AS "parentSessionId", | ||
| 274 | device_id AS "deviceId", | ||
| 259 | subject AS "subject", | 275 | subject AS "subject", |
| 260 | scopes AS "scopes", | 276 | scopes AS "scopes", |
| 261 | method AS "method", | 277 | method AS "method", |
| ... | @@ -271,6 +287,10 @@ export const make = Effect.gen(function* () { | ... | @@ -271,6 +287,10 @@ export const make = Effect.gen(function* () { |
| 271 | revoked_at AS "revokedAt" | 287 | revoked_at AS "revokedAt" |
| 272 | FROM auth_sessions | 288 | FROM auth_sessions |
| 273 | WHERE session_id = ${sessionId} | 289 | WHERE session_id = ${sessionId} |
| 290 | AND (device_id IS NULL OR EXISTS ( | ||
| 291 | SELECT 1 FROM auth_devices AS device | ||
| 292 | WHERE device.device_id = auth_sessions.device_id AND device.revoked_at IS NULL | ||
| 293 | )) | ||
| 274 | `, | 294 | `, |
| 275 | }); | 295 | }); |
| 276 | 296 | ||
| ... | @@ -300,6 +320,8 @@ export const make = Effect.gen(function* () { | ... | @@ -300,6 +320,8 @@ export const make = Effect.gen(function* () { |
| 300 | sql` | 320 | sql` |
| 301 | SELECT | 321 | SELECT |
| 302 | session_id AS "sessionId", | 322 | session_id AS "sessionId", |
| 323 | parent_session_id AS "parentSessionId", | ||
| 324 | device_id AS "deviceId", | ||
| 303 | subject AS "subject", | 325 | subject AS "subject", |
| 304 | scopes AS "scopes", | 326 | scopes AS "scopes", |
| 305 | method AS "method", | 327 | method AS "method", |
| ... | @@ -315,6 +337,12 @@ export const make = Effect.gen(function* () { | ... | @@ -315,6 +337,12 @@ export const make = Effect.gen(function* () { |
| 315 | revoked_at AS "revokedAt" | 337 | revoked_at AS "revokedAt" |
| 316 | FROM auth_sessions | 338 | FROM auth_sessions |
| 317 | WHERE revoked_at IS NULL | 339 | WHERE revoked_at IS NULL |
| 340 | AND (parent_session_id IS NULL OR EXISTS ( | ||
| 341 | SELECT 1 FROM auth_sessions AS parent | ||
| 342 | WHERE parent.session_id = auth_sessions.parent_session_id | ||
| 343 | AND parent.revoked_at IS NULL | ||
| 344 | AND parent.expires_at > ${now} | ||
| 345 | )) | ||
| 318 | AND (expires_at > ${now} OR ${sql.in("session_id", connectedSessionIds)}) | 346 | AND (expires_at > ${now} OR ${sql.in("session_id", connectedSessionIds)}) |
| 319 | ORDER BY issued_at DESC, session_id DESC | 347 | ORDER BY issued_at DESC, session_id DESC |
| 320 | `, | 348 | `, |
| ... | @@ -352,7 +380,7 @@ export const make = Effect.gen(function* () { | ... | @@ -352,7 +380,7 @@ export const make = Effect.gen(function* () { |
| 352 | sql` | 380 | sql` |
| 353 | UPDATE auth_sessions | 381 | UPDATE auth_sessions |
| 354 | SET revoked_at = ${revokedAt} | 382 | SET revoked_at = ${revokedAt} |
| 355 | WHERE session_id = ${sessionId} | 383 | WHERE (session_id = ${sessionId} OR parent_session_id = ${sessionId}) |
| 356 | AND revoked_at IS NULL | 384 | AND revoked_at IS NULL |
| 357 | RETURNING session_id AS "sessionId" | 385 | RETURNING session_id AS "sessionId" |
| 358 | `, | 386 | `, |
| ... | @@ -476,7 +504,7 @@ export const make = Effect.gen(function* () { | ... | @@ -476,7 +504,7 @@ export const make = Effect.gen(function* () { |
| 476 | { sessionId: input.sessionId }, | 504 | { sessionId: input.sessionId }, |
| 477 | ), | 505 | ), |
| 478 | ), | 506 | ), |
| 479 | Effect.map((rows) => rows.length > 0), | 507 | Effect.map((rows) => rows.map((row) => row.sessionId)), |
| 480 | ); | 508 | ); |
| 481 | 509 | ||
| 482 | const revokeAllExcept: AuthSessionRepository["Service"]["revokeAllExcept"] = (input) => | 510 | const revokeAllExcept: AuthSessionRepository["Service"]["revokeAllExcept"] = (input) => |
apps/server/src/persistence/Migrations.ts+10| ... | @@ -76,6 +76,11 @@ import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; | ... | @@ -76,6 +76,11 @@ import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; |
| 76 | import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; | 76 | import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; |
| 77 | import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts"; | 77 | import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts"; |
| 78 | import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts"; | 78 | import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts"; |
| 79 | import Migration0063 from "./Migrations/063_TranscriptArchives.ts"; | ||
| 80 | import Migration0064 from "./Migrations/064_AuthSessionDelegation.ts"; | ||
| 81 | import Migration0065 from "./Migrations/065_HubInbox.ts"; | ||
| 82 | import Migration0066 from "./Migrations/066_HostedAuthDevices.ts"; | ||
| 83 | import Migration0067 from "./Migrations/067_ArchiveCatalogAndSync.ts"; | ||
| 79 | 84 | ||
| 80 | /** | 85 | /** |
| 81 | * Migration loader with all migrations defined inline. | 86 | * Migration loader with all migrations defined inline. |
| ... | @@ -152,6 +157,11 @@ export const migrationEntries = [ | ... | @@ -152,6 +157,11 @@ export const migrationEntries = [ |
| 152 | [60, "ThreadSnapshotWindowIndexes", Migration0060], | 157 | [60, "ThreadSnapshotWindowIndexes", Migration0060], |
| 153 | [61, "PullRequestReviewGuides", Migration0061], | 158 | [61, "PullRequestReviewGuides", Migration0061], |
| 154 | [62, "ThreadFailureLookupIndex", Migration0062], | 159 | [62, "ThreadFailureLookupIndex", Migration0062], |
| 160 | [63, "TranscriptArchives", Migration0063], | ||
| 161 | [64, "AuthSessionDelegation", Migration0064], | ||
| 162 | [65, "HubInbox", Migration0065], | ||
| 163 | [66, "HostedAuthDevices", Migration0066], | ||
| 164 | [67, "ArchiveCatalogAndSync", Migration0067], | ||
| 155 | ] as const; | 165 | ] as const; |
| 156 | 166 | ||
| 157 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 167 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/063_TranscriptArchives.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | yield* sql`CREATE TABLE transcript_archives ( | ||
| 7 | thread_id TEXT PRIMARY KEY, | ||
| 8 | location TEXT NOT NULL, | ||
| 9 | object_hash TEXT NOT NULL, | ||
| 10 | through_sequence INTEGER NOT NULL, | ||
| 11 | metadata_json TEXT NOT NULL | ||
| 12 | )`; | ||
| 13 | yield* sql`CREATE TABLE transcript_archive_assets ( | ||
| 14 | thread_id TEXT NOT NULL, | ||
| 15 | path TEXT NOT NULL, | ||
| 16 | location TEXT NOT NULL, | ||
| 17 | object_hash TEXT NOT NULL, | ||
| 18 | PRIMARY KEY(thread_id, path) | ||
| 19 | )`; | ||
| 20 | yield* sql`CREATE INDEX transcript_archive_assets_path_idx ON transcript_archive_assets(path)`; | ||
| 21 | yield* sql`CREATE TABLE transcript_archive_failures ( | ||
| 22 | thread_id TEXT PRIMARY KEY, | ||
| 23 | attempts INTEGER NOT NULL, | ||
| 24 | reason TEXT NOT NULL | ||
| 25 | )`; | ||
| 26 | }); | ||
apps/server/src/persistence/Migrations/064_AuthSessionDelegation.ts created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | yield* sql`ALTER TABLE auth_sessions ADD COLUMN parent_session_id TEXT`; | ||
| 7 | }); | ||
apps/server/src/persistence/Migrations/065_HubInbox.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | yield* sql`CREATE TABLE hub_inbox ( | ||
| 7 | environment_id TEXT PRIMARY KEY, | ||
| 8 | snapshot_json TEXT NOT NULL | ||
| 9 | )`; | ||
| 10 | yield* sql`CREATE TABLE hub_inbox_pending ( | ||
| 11 | environment_id TEXT NOT NULL, | ||
| 12 | thread_id TEXT NOT NULL, | ||
| 13 | command_id TEXT NOT NULL, | ||
| 14 | organization_json TEXT NOT NULL, | ||
| 15 | source_activity_json TEXT NOT NULL, | ||
| 16 | PRIMARY KEY(environment_id, thread_id) | ||
| 17 | )`; | ||
| 18 | yield* sql`CREATE TABLE hub_inbox_commands ( | ||
| 19 | environment_id TEXT NOT NULL, | ||
| 20 | command_id TEXT NOT NULL, | ||
| 21 | PRIMARY KEY(environment_id, command_id) | ||
| 22 | )`; | ||
| 23 | }); | ||
apps/server/src/persistence/Migrations/066_HostedAuthDevices.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | yield* sql`CREATE TABLE auth_devices ( | ||
| 7 | device_id TEXT PRIMARY KEY, | ||
| 8 | account_id TEXT NOT NULL, | ||
| 9 | environment_id TEXT, | ||
| 10 | client_json TEXT NOT NULL, | ||
| 11 | created_at INTEGER NOT NULL, | ||
| 12 | expires_at INTEGER NOT NULL, | ||
| 13 | validated_at INTEGER NOT NULL, | ||
| 14 | revoked_at INTEGER | ||
| 15 | )`; | ||
| 16 | yield* sql`CREATE UNIQUE INDEX auth_devices_environment ON auth_devices(environment_id) WHERE environment_id IS NOT NULL AND revoked_at IS NULL`; | ||
| 17 | yield* sql`CREATE TABLE auth_device_refresh ( | ||
| 18 | token_hash TEXT PRIMARY KEY, | ||
| 19 | device_id TEXT NOT NULL REFERENCES auth_devices(device_id), | ||
| 20 | expires_at INTEGER NOT NULL, | ||
| 21 | consumed_at INTEGER | ||
| 22 | )`; | ||
| 23 | yield* sql`CREATE INDEX auth_device_refresh_device ON auth_device_refresh(device_id)`; | ||
| 24 | yield* sql`ALTER TABLE auth_sessions ADD COLUMN device_id TEXT REFERENCES auth_devices(device_id)`; | ||
| 25 | yield* sql`CREATE INDEX auth_sessions_device ON auth_sessions(device_id)`; | ||
| 26 | }); | ||
apps/server/src/persistence/Migrations/067_ArchiveCatalogAndSync.ts created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | yield* sql`ALTER TABLE transcript_archive_failures ADD COLUMN next_attempt_at INTEGER NOT NULL DEFAULT 0`; | ||
| 7 | yield* sql`CREATE TABLE transcript_archive_publications (thread_id TEXT PRIMARY KEY, location TEXT NOT NULL, object_hash TEXT NOT NULL)`; | ||
| 8 | yield* sql`CREATE TABLE transcript_archive_reclamation (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), pending INTEGER NOT NULL, completed_at INTEGER, before_allocated_bytes INTEGER, after_allocated_bytes INTEGER)`; | ||
| 9 | yield* sql`INSERT INTO transcript_archive_reclamation(singleton, pending) VALUES(1, 0)`; | ||
| 10 | }); | ||
apps/server/src/persistence/OrchestrationEventStore.ts+5-2| ... | @@ -459,6 +459,9 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -459,6 +459,9 @@ const makeEventStore = Effect.gen(function* () { |
| 459 | : sql`INDEXED BY idx_orchestration_events_application_high_water` | 459 | : sql`INDEXED BY idx_orchestration_events_application_high_water` |
| 460 | } | 460 | } |
| 461 | WHERE sequence > ${input.afterSequence} | 461 | WHERE sequence > ${input.afterSequence} |
| 462 | AND NOT (payload_json = 'null' AND EXISTS ( | ||
| 463 | SELECT 1 FROM transcript_archives a WHERE a.thread_id = orchestration_events.stream_id AND orchestration_events.sequence <= a.through_sequence | ||
| 464 | )) | ||
| 462 | AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} | 465 | AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} |
| 463 | AND ( | 466 | AND ( |
| 464 | ${ | 467 | ${ |
| ... | @@ -640,7 +643,7 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -640,7 +643,7 @@ const makeEventStore = Effect.gen(function* () { |
| 640 | SELECT | 643 | SELECT |
| 641 | COUNT(*) AS "eventCount", | 644 | COUNT(*) AS "eventCount", |
| 642 | COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes", | 645 | COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes", |
| 643 | COALESCE(MAX(event_type = 'thread.created'), 0) AS "hasCreateEvent" | 646 | COALESCE(MAX(event_type = 'thread.created'), 0) * (1 - COALESCE(MAX(payload_json = 'null'), 0)) AS "hasCreateEvent" |
| 644 | FROM ( | 647 | FROM ( |
| 645 | SELECT ${restoredPayloadJson} AS payload_json, event_type | 648 | SELECT ${restoredPayloadJson} AS payload_json, event_type |
| 646 | FROM orchestration_events | 649 | FROM orchestration_events |
| ... | @@ -665,7 +668,7 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -665,7 +668,7 @@ const makeEventStore = Effect.gen(function* () { |
| 665 | sql<{ readonly eventCount: number; readonly rawPayloadBytes: number }>` | 668 | sql<{ readonly eventCount: number; readonly rawPayloadBytes: number }>` |
| 666 | SELECT | 669 | SELECT |
| 667 | COUNT(*) AS "eventCount", | 670 | COUNT(*) AS "eventCount", |
| 668 | COALESCE(SUM(octet_length(${restoredPayloadJson})), 0) AS "rawPayloadBytes" | 671 | CASE WHEN MAX(payload_json = 'null') THEN 9007199254740991 ELSE COALESCE(SUM(octet_length(${restoredPayloadJson})), 0) END AS "rawPayloadBytes" |
| 669 | FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water | 672 | FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water |
| 670 | WHERE sequence > ${input.afterSequence} | 673 | WHERE sequence > ${input.afterSequence} |
| 671 | AND sequence <= ${input.throughSequence} | 674 | AND sequence <= ${input.throughSequence} |
apps/server/src/provider/SharedAccountClient.ts+8-32| ... | @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; |
| 19 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | 19 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 20 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 20 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 21 | import * as SharedAccounts from "./SharedAccounts.ts"; | 21 | import * as SharedAccounts from "./SharedAccounts.ts"; |
| 22 | import * as SharedServer from "../environment/SharedServer.ts"; | ||
| 22 | 23 | ||
| 23 | const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString }); | 24 | const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString }); |
| 24 | const CachedAccess = Schema.Struct({ revision: Schema.Int, credentials: SharedAccountAccess }); | 25 | const CachedAccess = Schema.Struct({ revision: Schema.Int, credentials: SharedAccountAccess }); |
| ... | @@ -28,8 +29,6 @@ const Tokens = Schema.Struct({ | ... | @@ -28,8 +29,6 @@ const Tokens = Schema.Struct({ |
| 28 | id_token: Schema.optionalKey(Schema.NonEmptyString), | 29 | id_token: Schema.optionalKey(Schema.NonEmptyString), |
| 29 | }); | 30 | }); |
| 30 | 31 | ||
| 31 | const decodeConnection = Schema.decodeEffect(Schema.fromJsonString(Connection)); | ||
| 32 | const encodeConnection = Schema.encodeEffect(Schema.fromJsonString(Connection)); | ||
| 33 | const decodeAccess = Schema.decodeEffect(Schema.fromJsonString(CachedAccess)); | 32 | const decodeAccess = Schema.decodeEffect(Schema.fromJsonString(CachedAccess)); |
| 34 | const encodeAccess = Schema.encodeEffect(Schema.fromJsonString(CachedAccess)); | 33 | const encodeAccess = Schema.encodeEffect(Schema.fromJsonString(CachedAccess)); |
| 35 | const decodeCommit = Schema.decodeEffect(Schema.fromJsonString(SharedAccountCommit)); | 34 | const decodeCommit = Schema.decodeEffect(Schema.fromJsonString(SharedAccountCommit)); |
| ... | @@ -60,16 +59,8 @@ const make = Effect.gen(function* () { | ... | @@ -60,16 +59,8 @@ const make = Effect.gen(function* () { |
| 60 | const local = yield* SharedAccounts.SharedAccounts; | 59 | const local = yield* SharedAccounts.SharedAccounts; |
| 61 | const locks = yield* KeyedLock.make<string>(); | 60 | const locks = yield* KeyedLock.make<string>(); |
| 62 | 61 | ||
| 63 | const connection = secrets.get("shared-server").pipe( | 62 | const sharedServer = yield* SharedServer.SharedServer; |
| 64 | Effect.flatMap((bytes) => | 63 | const connection = sharedServer.connection; |
| 65 | Option.isNone(bytes) | ||
| 66 | ? Effect.succeedNone | ||
| 67 | : decodeConnection(new TextDecoder().decode(bytes.value)).pipe(Effect.map(Option.some)), | ||
| 68 | ), | ||
| 69 | Effect.mapError( | ||
| 70 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 71 | ), | ||
| 72 | ); | ||
| 73 | 64 | ||
| 74 | const requestRemote = (target: typeof Connection.Type, input: SharedAccountRequest) => | 65 | const requestRemote = (target: typeof Connection.Type, input: SharedAccountRequest) => |
| 75 | HttpClientRequest.post(`${target.url}/api/shared/accounts`).pipe( | 66 | HttpClientRequest.post(`${target.url}/api/shared/accounts`).pipe( |
| ... | @@ -121,18 +112,7 @@ const make = Effect.gen(function* () { | ... | @@ -121,18 +112,7 @@ const make = Effect.gen(function* () { |
| 121 | }); | 112 | }); |
| 122 | const target = { ...input, url }; | 113 | const target = { ...input, url }; |
| 123 | yield* requestRemote(target, { type: "list" }); | 114 | yield* requestRemote(target, { type: "list" }); |
| 124 | const json = yield* encodeConnection(target).pipe( | 115 | yield* sharedServer.save(target); |
| 125 | Effect.mapError( | ||
| 126 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 127 | ), | ||
| 128 | ); | ||
| 129 | yield* secrets | ||
| 130 | .set("shared-server", new TextEncoder().encode(json)) | ||
| 131 | .pipe( | ||
| 132 | Effect.mapError( | ||
| 133 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 134 | ), | ||
| 135 | ); | ||
| 136 | }); | 116 | }); |
| 137 | 117 | ||
| 138 | const access = Effect.fn("SharedAccountClient.access")(function* ( | 118 | const access = Effect.fn("SharedAccountClient.access")(function* ( |
| ... | @@ -309,14 +289,10 @@ const make = Effect.gen(function* () { | ... | @@ -309,14 +289,10 @@ const make = Effect.gen(function* () { |
| 309 | configure, | 289 | configure, |
| 310 | access: (id, force) => locks.withLock(id, access(id, force)), | 290 | access: (id, force) => locks.withLock(id, access(id, force)), |
| 311 | configured: connection.pipe(Effect.map(Option.isSome)), | 291 | configured: connection.pipe(Effect.map(Option.isSome)), |
| 312 | disconnect: secrets | 292 | disconnect: sharedServer.disconnect, |
| 313 | .remove("shared-server") | ||
| 314 | .pipe( | ||
| 315 | Effect.mapError( | ||
| 316 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | ||
| 317 | ), | ||
| 318 | ), | ||
| 319 | }); | 293 | }); |
| 320 | }); | 294 | }); |
| 321 | 295 | ||
| 322 | export const layer = Layer.effect(SharedAccountClient, make); | 296 | export const layer = Layer.effect(SharedAccountClient, make).pipe( |
| 297 | Layer.provide(SharedServer.layer), | ||
| 298 | ); |
apps/server/src/server.ts+116-51| ... | @@ -1,3 +1,21 @@ | ... | @@ -1,3 +1,21 @@ |
| 1 | import * as TranscriptArchive from "./archive/TranscriptArchive.ts"; | ||
| 2 | import * as Hub from "./environment/Hub.ts"; | ||
| 3 | import * as HubProxy from "./environment/HubProxy.ts"; | ||
| 4 | import * as HubHttp from "./environment/HubHttp.ts"; | ||
| 5 | import * as HubInbox from "./environment/HubInbox.ts"; | ||
| 6 | import * as HubInboxHttp from "./environment/HubInboxHttp.ts"; | ||
| 7 | import * as ArchiveRuntime from "./archive/runtimeLayer.ts"; | ||
| 8 | import * as ArchiveHttp from "./archive/ArchiveHttp.ts"; | ||
| 9 | import * as ArchiveAssets from "./archive/ArchiveAssets.ts"; | ||
| 10 | import * as HostedArchiveConnection from "./archive/HostedArchiveConnection.ts"; | ||
| 11 | import * as HostedAuth from "./auth/HostedAuth.ts"; | ||
| 12 | import * as HostedAuthConfig from "./auth/HostedAuthConfig.ts"; | ||
| 13 | import * as HostedAuthHttp from "./auth/HostedAuthHttp.ts"; | ||
| 14 | import * as OutboundHub from "./environment/OutboundHub.ts"; | ||
| 15 | import * as OutboundSource from "./environment/OutboundSource.ts"; | ||
| 16 | import * as SourceConnection from "./environment/SourceConnection.ts"; | ||
| 17 | import * as SourceConnectionHttp from "./environment/SourceConnectionHttp.ts"; | ||
| 18 | import * as SharedServer from "./environment/SharedServer.ts"; | ||
| 1 | import * as SharedAccounts from "./provider/SharedAccounts.ts"; | 19 | import * as SharedAccounts from "./provider/SharedAccounts.ts"; |
| 2 | import * as SharedAccountClient from "./provider/SharedAccountClient.ts"; | 20 | import * as SharedAccountClient from "./provider/SharedAccountClient.ts"; |
| 3 | import * as ManagedProviderHome from "./provider/ManagedProviderHome.ts"; | 21 | import * as ManagedProviderHome from "./provider/ManagedProviderHome.ts"; |
| ... | @@ -450,6 +468,7 @@ const layerProjectFaviconResolver = ProjectFaviconResolver.layer.pipe( | ... | @@ -450,6 +468,7 @@ const layerProjectFaviconResolver = ProjectFaviconResolver.layer.pipe( |
| 450 | const layerServerEnvironment = ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)); | 468 | const layerServerEnvironment = ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)); |
| 451 | 469 | ||
| 452 | const layerAuth = EnvironmentAuth.layer.pipe( | 470 | const layerAuth = EnvironmentAuth.layer.pipe( |
| 471 | Layer.provideMerge(HostedAuthConfig.layer), | ||
| 453 | Layer.provideMerge(layerPersistence), | 472 | Layer.provideMerge(layerPersistence), |
| 454 | Layer.provide(layerServerEnvironment), | 473 | Layer.provide(layerServerEnvironment), |
| 455 | Layer.provide(ServerSecretStore.layer), | 474 | Layer.provide(ServerSecretStore.layer), |
| ... | @@ -553,6 +572,19 @@ const layerProviderInstallationRefresh = Layer.effectDiscard( | ... | @@ -553,6 +572,19 @@ const layerProviderInstallationRefresh = Layer.effectDiscard( |
| 553 | ); | 572 | ); |
| 554 | 573 | ||
| 555 | const layerRuntimeCoreDependenciesBase = Layer.mergeAll( | 574 | const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 575 | Layer.effectDiscard( | ||
| 576 | Effect.gen(function* () { | ||
| 577 | const archive = yield* TranscriptArchive.TranscriptArchive; | ||
| 578 | yield* ServerActivation.forkParked( | ||
| 579 | Effect.forever( | ||
| 580 | archive.sweep.pipe( | ||
| 581 | Effect.ignore({ log: true }), | ||
| 582 | Effect.andThen(Effect.sleep("1 minute")), | ||
| 583 | ), | ||
| 584 | ), | ||
| 585 | ); | ||
| 586 | }), | ||
| 587 | ), | ||
| 556 | Layer.effectDiscard( | 588 | Layer.effectDiscard( |
| 557 | Effect.gen(function* () { | 589 | Effect.gen(function* () { |
| 558 | const profiles = yield* SharedProfiles.SharedProfiles; | 590 | const profiles = yield* SharedProfiles.SharedProfiles; |
| ... | @@ -647,63 +679,87 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( | ... | @@ -647,63 +679,87 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 647 | ), | 679 | ), |
| 648 | ); | 680 | ); |
| 649 | 681 | ||
| 650 | const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( | 682 | const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase |
| 651 | Layer.provideMerge(SharedProfiles.layer), | 683 | .pipe( |
| 652 | Layer.provideMerge(ManagedProviderHome.layer), | 684 | Layer.provideMerge( |
| 653 | Layer.provideMerge(SharedAccountClient.layer), | 685 | Layer.mergeAll(HubInbox.layer.pipe(Layer.provide(layerPersistence)), HubProxy.layer), |
| 654 | Layer.provideMerge(SharedAccounts.layer), | ||
| 655 | Layer.provideMerge(layerPtyAdapter), | ||
| 656 | // Search, prepare, status inspection, and turn launch share one registry | ||
| 657 | // cache so every client and provider instance sees the same prepared agents. | ||
| 658 | Layer.provideMerge(AcpRegistrySupport.layerFromHost.pipe(Layer.provide(ProviderHostLive.layer))), | ||
| 659 | // Provider event loggers are shared by every driver instance and the | ||
| 660 | // orchestration runtime, so they are provided once here. | ||
| 661 | // `ModelManifest.layer` is the model manifest, refreshed from the repo's | ||
| 662 | // `model-manifest.json` on `main`. Drivers read their entry through the | ||
| 663 | // `ModelCatalog` port it provides. | ||
| 664 | Layer.provideMerge( | ||
| 665 | Layer.mergeAll( | ||
| 666 | layerProviderEventLoggers, | ||
| 667 | ModelManifest.layerModelCatalog.pipe(Layer.provideMerge(ModelManifest.layer)), | ||
| 668 | ResetCreditCoordinator.layer, | ||
| 669 | ProviderLatestVersions.layer, | ||
| 670 | McpProviderSessions.layer, | ||
| 671 | ), | 686 | ), |
| 672 | ), | 687 | Layer.provideMerge(Hub.layer), |
| 673 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old | 688 | Layer.provideMerge(OutboundHub.layer), |
| 674 | // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but | 689 | Layer.provideMerge(HostedAuth.layer.pipe(Layer.provide(layerPersistence))), |
| 675 | // the rewritten registry reads snapshots off the instance registry and | 690 | Layer.provideMerge(HostedAuthConfig.layer), |
| 676 | // no longer transitively provides it. Exposing it at the runtime level | 691 | Layer.provideMerge(SharedProfiles.layer.pipe(Layer.provide(layerServerSettings))), |
| 677 | // keeps a single Live for all opencode consumers. | 692 | Layer.provideMerge(ManagedProviderHome.layer), |
| 678 | Layer.provideMerge( | 693 | Layer.provideMerge(SharedAccountClient.layer), |
| 679 | OpenCodeRuntime.layer.pipe( | 694 | Layer.provideMerge(SharedAccounts.layer), |
| 680 | Layer.provide( | 695 | Layer.provideMerge(ArchiveAssets.layer), |
| 681 | Layer.unwrap( | 696 | Layer.provideMerge( |
| 682 | Effect.gen(function* () { | 697 | ArchiveRuntime.layer.pipe( |
| 683 | const config = yield* ServerConfig.ServerConfig; | 698 | Layer.provide(layerPersistence), |
| 684 | return OpenCodeServerLedger.layer({ stateDir: config.stateDir }); | 699 | Layer.provide(HostedArchiveConnection.layer), |
| 685 | }), | 700 | Layer.provide(layerServerSettings), |
| 701 | ), | ||
| 702 | ), | ||
| 703 | ) | ||
| 704 | .pipe( | ||
| 705 | Layer.provideMerge(SourceConnection.layer), | ||
| 706 | Layer.provideMerge(SharedServer.layer), | ||
| 707 | Layer.provideMerge(layerPtyAdapter), | ||
| 708 | // Search, prepare, status inspection, and turn launch share one registry | ||
| 709 | // cache so every client and provider instance sees the same prepared agents. | ||
| 710 | Layer.provideMerge( | ||
| 711 | AcpRegistrySupport.layerFromHost.pipe(Layer.provide(ProviderHostLive.layer)), | ||
| 712 | ), | ||
| 713 | // Provider event loggers are shared by every driver instance and the | ||
| 714 | // orchestration runtime, so they are provided once here. | ||
| 715 | // `ModelManifest.layer` is the model manifest, refreshed from the repo's | ||
| 716 | // `model-manifest.json` on `main`. Drivers read their entry through the | ||
| 717 | // `ModelCatalog` port it provides. | ||
| 718 | Layer.provideMerge( | ||
| 719 | Layer.mergeAll( | ||
| 720 | layerProviderEventLoggers, | ||
| 721 | ModelManifest.layerModelCatalog.pipe(Layer.provideMerge(ModelManifest.layer)), | ||
| 722 | ResetCreditCoordinator.layer, | ||
| 723 | ProviderLatestVersions.layer, | ||
| 724 | McpProviderSessions.layer, | ||
| 725 | ), | ||
| 726 | ), | ||
| 727 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old | ||
| 728 | // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but | ||
| 729 | // the rewritten registry reads snapshots off the instance registry and | ||
| 730 | // no longer transitively provides it. Exposing it at the runtime level | ||
| 731 | // keeps a single Live for all opencode consumers. | ||
| 732 | Layer.provideMerge( | ||
| 733 | OpenCodeRuntime.layer.pipe( | ||
| 734 | Layer.provide( | ||
| 735 | Layer.unwrap( | ||
| 736 | Effect.gen(function* () { | ||
| 737 | const config = yield* ServerConfig.ServerConfig; | ||
| 738 | return OpenCodeServerLedger.layer({ stateDir: config.stateDir }); | ||
| 739 | }), | ||
| 740 | ), | ||
| 686 | ), | 741 | ), |
| 687 | ), | 742 | ), |
| 688 | ), | 743 | ), |
| 689 | ), | 744 | Layer.provideMerge(layerWorkspace), |
| 690 | Layer.provideMerge(layerWorkspace), | 745 | Layer.provideMerge(ProjectEnrichmentService.layer), |
| 691 | Layer.provideMerge(ProjectEnrichmentService.layer), | 746 | Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), |
| 692 | Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), | 747 | Layer.provideMerge(layerRepositoryIdentityResolver), |
| 693 | Layer.provideMerge(layerRepositoryIdentityResolver), | 748 | Layer.provideMerge(layerServerEnvironment), |
| 694 | Layer.provideMerge(layerServerEnvironment), | 749 | Layer.provideMerge(layerAuth), |
| 695 | Layer.provideMerge(layerAuth), | 750 | Layer.provideMerge(ServerSecretStore.layer), |
| 696 | Layer.provideMerge(ServerSecretStore.layer), | 751 | ) |
| 697 | Layer.provideMerge( | 752 | .pipe( |
| 698 | Layer.mergeAll( | 753 | Layer.provideMerge( |
| 699 | CloudCliTokenManager.layer.pipe( | 754 | Layer.mergeAll( |
| 700 | Layer.provide(ServerSecretStore.layer), | 755 | CloudCliTokenManager.layer.pipe( |
| 701 | Layer.provide(ExternalLauncher.layer), | 756 | Layer.provide(ServerSecretStore.layer), |
| 757 | Layer.provide(ExternalLauncher.layer), | ||
| 758 | ), | ||
| 759 | layerCloudManagedEndpointRuntime, | ||
| 702 | ), | 760 | ), |
| 703 | layerCloudManagedEndpointRuntime, | ||
| 704 | ), | 761 | ), |
| 705 | ), | 762 | ); |
| 706 | ); | ||
| 707 | 763 | ||
| 708 | const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( | 764 | const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( |
| 709 | // Misc. | 765 | // Misc. |
| ... | @@ -745,9 +801,17 @@ const layerMakeRoutes = Layer.mergeAll( | ... | @@ -745,9 +801,17 @@ const layerMakeRoutes = Layer.mergeAll( |
| 745 | ServerHttp.layerOtlpTracesProxyRoute, | 801 | ServerHttp.layerOtlpTracesProxyRoute, |
| 746 | ServerHttp.layerAssetRoute, | 802 | ServerHttp.layerAssetRoute, |
| 747 | ServerHttp.layerAttachmentUploadRoute, | 803 | ServerHttp.layerAttachmentUploadRoute, |
| 804 | ServerHttp.layerArchiveManagementRoute, | ||
| 805 | ServerHttp.layerArchiveUploadRoute, | ||
| 806 | ServerHttp.layerArchiveDownloadRoute, | ||
| 748 | ServerHttp.layerSharedAccountRoute, | 807 | ServerHttp.layerSharedAccountRoute, |
| 749 | ServerHttp.layerSharedProfilesRoute, | 808 | ServerHttp.layerSharedProfilesRoute, |
| 750 | DeviceHubProxy.layer, | 809 | DeviceHubProxy.layer, |
| 810 | HubHttp.layer, | ||
| 811 | HubInboxHttp.layer, | ||
| 812 | HostedAuthHttp.layer, | ||
| 813 | SourceConnectionHttp.layer, | ||
| 814 | ArchiveHttp.layer, | ||
| 751 | ServerBrowserStream.routeLayer, | 815 | ServerBrowserStream.routeLayer, |
| 752 | ServerHttp.layerStaticAndDevRoute, | 816 | ServerHttp.layerStaticAndDevRoute, |
| 753 | Ws.layer, | 817 | Ws.layer, |
| ... | @@ -1129,6 +1193,7 @@ const layerMakeServer = Layer.unwrap( | ... | @@ -1129,6 +1193,7 @@ const layerMakeServer = Layer.unwrap( |
| 1129 | layerTailscaleServe, | 1193 | layerTailscaleServe, |
| 1130 | layerCloudDesiredLinkReconcile, | 1194 | layerCloudDesiredLinkReconcile, |
| 1131 | HeapSnapshot.layer, | 1195 | HeapSnapshot.layer, |
| 1196 | OutboundSource.layer, | ||
| 1132 | ); | 1197 | ); |
| 1133 | 1198 | ||
| 1134 | return layerServerApplication.pipe( | 1199 | return layerServerApplication.pipe( |
apps/server/src/storageCleanup.test.ts+67-21| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | 5 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Fiber from "effect/Fiber"; | 6 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -241,14 +242,26 @@ const cleanupFixture = ( | ... | @@ -241,14 +242,26 @@ const cleanupFixture = ( |
| 241 | worktreeAfterDays: 8, | 242 | worktreeAfterDays: 8, |
| 242 | worktreeOnDelete: true, | 243 | worktreeOnDelete: true, |
| 243 | }, | 244 | }, |
| 245 | archiveOffload = false, | ||
| 244 | ) => | 246 | ) => |
| 245 | Effect.gen(function* () { | 247 | Effect.gen(function* () { |
| 246 | const fs = yield* FileSystem.FileSystem; | 248 | const fs = yield* FileSystem.FileSystem; |
| 247 | const config = yield* ServerConfig.ServerConfig; | 249 | const storedConfig = yield* ServerConfig.ServerConfig; |
| 250 | yield* fs.makeDirectory(storedConfig.browserArtifactsDir, { recursive: true }); | ||
| 251 | yield* fs.makeDirectory(storedConfig.logsDir, { recursive: true }); | ||
| 252 | const config = { | ||
| 253 | ...storedConfig, | ||
| 254 | browserArtifactsDir: yield* fs.realPath(storedConfig.browserArtifactsDir), | ||
| 255 | logsDir: yield* fs.realPath(storedConfig.logsDir), | ||
| 256 | }; | ||
| 248 | const git = yield* GitVcsDriver.GitVcsDriver; | 257 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 249 | const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); | 258 | const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); |
| 250 | const command = (cwd: string, args: string[]) => | 259 | const command = (cwd: string, args: string[]) => |
| 251 | git.execute({ operation: "cleanup-test", cwd, args }); | 260 | git.execute({ |
| 261 | operation: "cleanup-test", | ||
| 262 | cwd, | ||
| 263 | args: ["-c", "commit.gpgSign=false", "-c", "tag.gpgSign=false", ...args], | ||
| 264 | }); | ||
| 252 | yield* command(repo, ["init", "-b", "main"]); | 265 | yield* command(repo, ["init", "-b", "main"]); |
| 253 | yield* command(repo, ["config", "user.email", "cleanup@example.test"]); | 266 | yield* command(repo, ["config", "user.email", "cleanup@example.test"]); |
| 254 | yield* command(repo, ["config", "user.name", "Cleanup test"]); | 267 | yield* command(repo, ["config", "user.name", "Cleanup test"]); |
| ... | @@ -271,8 +284,18 @@ const cleanupFixture = ( | ... | @@ -271,8 +284,18 @@ const cleanupFixture = ( |
| 271 | let fullStatusCalls = 0; | 284 | let fullStatusCalls = 0; |
| 272 | let snapshotReads = 0; | 285 | let snapshotReads = 0; |
| 273 | let events: Stream.Stream<OrchestrationV2DomainEvent> = Stream.empty; | 286 | let events: Stream.Stream<OrchestrationV2DomainEvent> = Stream.empty; |
| 287 | let pendingArchive = false; | ||
| 274 | const activated = yield* Deferred.make<void>(); | 288 | const activated = yield* Deferred.make<void>(); |
| 275 | const context = yield* Layer.build(StorageCleanup.layer).pipe( | 289 | const context = yield* Layer.build( |
| 290 | StorageCleanup.layer.pipe( | ||
| 291 | Layer.provide( | ||
| 292 | ConfigProvider.layer( | ||
| 293 | ConfigProvider.fromUnknown({ T3CODE_ARCHIVE_OFFLOAD: archiveOffload }), | ||
| 294 | ), | ||
| 295 | ), | ||
| 296 | ), | ||
| 297 | ).pipe( | ||
| 298 | Effect.provideService(ServerConfig.ServerConfig, config), | ||
| 276 | Effect.provideService(GitVcsDriver.GitVcsDriver, { | 299 | Effect.provideService(GitVcsDriver.GitVcsDriver, { |
| 277 | ...git, | 300 | ...git, |
| 278 | execute: (input) => | 301 | execute: (input) => |
| ... | @@ -319,24 +342,26 @@ const cleanupFixture = ( | ... | @@ -319,24 +342,26 @@ const cleanupFixture = ( |
| 319 | } as unknown as Orchestrator.OrchestratorV2["Service"]), | 342 | } as unknown as Orchestrator.OrchestratorV2["Service"]), |
| 320 | Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) => | 343 | Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) => |
| 321 | Effect.sync(() => | 344 | Effect.sync(() => |
| 322 | openSessionCwd !== null && strings.join("").includes("projection_provider_sessions") | 345 | pendingArchive && strings.join("").includes("transcript_archives") |
| 323 | ? [ | 346 | ? [{}] |
| 324 | { | 347 | : openSessionCwd !== null && strings.join("").includes("projection_provider_sessions") |
| 325 | payload_json: JSON.stringify({ | 348 | ? [ |
| 326 | id: "session-1", | 349 | { |
| 327 | driver: "codex", | 350 | payload_json: JSON.stringify({ |
| 328 | providerInstanceId: "codex", | 351 | id: "session-1", |
| 329 | status: "ready", | 352 | driver: "codex", |
| 330 | cwd: openSessionCwd, | 353 | providerInstanceId: "codex", |
| 331 | model: null, | 354 | status: "ready", |
| 332 | capabilities: CodexProviderCapabilitiesV2, | 355 | cwd: openSessionCwd, |
| 333 | createdAt: "2026-06-01T12:00:00.000Z", | 356 | model: null, |
| 334 | updatedAt: "2026-06-01T12:00:00.000Z", | 357 | capabilities: CodexProviderCapabilitiesV2, |
| 335 | lastError: null, | 358 | createdAt: "2026-06-01T12:00:00.000Z", |
| 336 | }), | 359 | updatedAt: "2026-06-01T12:00:00.000Z", |
| 337 | }, | 360 | lastError: null, |
| 338 | ] | 361 | }), |
| 339 | : [], | 362 | }, |
| 363 | ] | ||
| 364 | : [], | ||
| 340 | )) as unknown as SqlClient.SqlClient), | 365 | )) as unknown as SqlClient.SqlClient), |
| 341 | Effect.provideService(GitManager.GitManager, { | 366 | Effect.provideService(GitManager.GitManager, { |
| 342 | invalidateStatus: () => Effect.void, | 367 | invalidateStatus: () => Effect.void, |
| ... | @@ -394,6 +419,9 @@ const cleanupFixture = ( | ... | @@ -394,6 +419,9 @@ const cleanupFixture = ( |
| 394 | setOpenSessionCwd: (cwd: string | null) => { | 419 | setOpenSessionCwd: (cwd: string | null) => { |
| 395 | openSessionCwd = cwd; | 420 | openSessionCwd = cwd; |
| 396 | }, | 421 | }, |
| 422 | setPendingArchive: (pending: boolean) => { | ||
| 423 | pendingArchive = pending; | ||
| 424 | }, | ||
| 397 | fullStatusCalls: () => fullStatusCalls, | 425 | fullStatusCalls: () => fullStatusCalls, |
| 398 | snapshotReads: () => snapshotReads, | 426 | snapshotReads: () => snapshotReads, |
| 399 | /** Starts the hourly sweep and the subscription to `domainEvents`, which wait until now. */ | 427 | /** Starts the hourly sweep and the subscription to `domainEvents`, which wait until now. */ |
| ... | @@ -464,6 +492,24 @@ const runCleanupTest = <A, E>( | ... | @@ -464,6 +492,24 @@ const runCleanupTest = <A, E>( |
| 464 | ) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); | 492 | ) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); |
| 465 | 493 | ||
| 466 | describe("storage cleanup reports and local file policies", () => { | 494 | describe("storage cleanup reports and local file policies", () => { |
| 495 | it.live.each([false, true])( | ||
| 496 | "keeps unverified transcript media before worktree cleanup only when offloading is enabled (%s)", | ||
| 497 | (enabled) => | ||
| 498 | runCleanupTest( | ||
| 499 | Effect.gen(function* () { | ||
| 500 | const fixture = yield* cleanupFixture(undefined, enabled); | ||
| 501 | fixture.setPendingArchive(true); | ||
| 502 | const report = yield* fixture.service.runNow; | ||
| 503 | expect(report.entries[0]?.outcome).toBe(enabled ? "kept" : "removed"); | ||
| 504 | if (enabled) { | ||
| 505 | expect(report.entries[0]?.reason).toContain("Transcript archive is not verified"); | ||
| 506 | fixture.setPendingArchive(false); | ||
| 507 | expect((yield* fixture.service.runNow).entries[0]?.outcome).toBe("removed"); | ||
| 508 | } | ||
| 509 | }), | ||
| 510 | ), | ||
| 511 | ); | ||
| 512 | |||
| 467 | it.live("measures nested regular files without following symlinks", () => | 513 | it.live("measures nested regular files without following symlinks", () => |
| 468 | runCleanupTest( | 514 | runCleanupTest( |
| 469 | Effect.gen(function* () { | 515 | Effect.gen(function* () { |
apps/server/src/storageCleanup.ts+10| ... | @@ -18,6 +18,7 @@ import type { | ... | @@ -18,6 +18,7 @@ import type { |
| 18 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | 18 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 19 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 19 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 20 | import * as Context from "effect/Context"; | 20 | import * as Context from "effect/Context"; |
| 21 | import * as Config from "effect/Config"; | ||
| 21 | import * as Deferred from "effect/Deferred"; | 22 | import * as Deferred from "effect/Deferred"; |
| 22 | import * as Layer from "effect/Layer"; | 23 | import * as Layer from "effect/Layer"; |
| 23 | import * as Clock from "effect/Clock"; | 24 | import * as Clock from "effect/Clock"; |
| ... | @@ -179,6 +180,10 @@ const make = Effect.gen(function* () { | ... | @@ -179,6 +180,10 @@ const make = Effect.gen(function* () { |
| 179 | const projections = yield* ProjectionStore.ProjectionStoreV2; | 180 | const projections = yield* ProjectionStore.ProjectionStoreV2; |
| 180 | const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; | 181 | const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; |
| 181 | const sql = yield* SqlClient.SqlClient; | 182 | const sql = yield* SqlClient.SqlClient; |
| 183 | const archiveOffload = yield* Config.Boolean("T3CODE_ARCHIVE_OFFLOAD").pipe( | ||
| 184 | Config.withDefault(false), | ||
| 185 | Effect.orDie, | ||
| 186 | ); | ||
| 182 | const git = yield* GitVcsDriver.GitVcsDriver; | 187 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 183 | const gitManager = yield* GitManager.GitManager; | 188 | const gitManager = yield* GitManager.GitManager; |
| 184 | const terminals = yield* TerminalManager.TerminalManager; | 189 | const terminals = yield* TerminalManager.TerminalManager; |
| ... | @@ -578,6 +583,11 @@ const make = Effect.gen(function* () { | ... | @@ -578,6 +583,11 @@ const make = Effect.gen(function* () { |
| 578 | // the removal. Read only this path and this thread: a full snapshot per | 583 | // the removal. Read only this path and this thread: a full snapshot per |
| 579 | // worktree takes seconds on large databases. | 584 | // worktree takes seconds on large databases. |
| 580 | const blockedSinceCheck = Effect.fnUntraced(function* () { | 585 | const blockedSinceCheck = Effect.fnUntraced(function* () { |
| 586 | if (archiveOffload) { | ||
| 587 | const pendingArchive = | ||
| 588 | yield* sql`SELECT 1 FROM orchestration_v2_projection_threads t WHERE t.archived_at IS NOT NULL AND t.deleted_at IS NULL AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath} AND NOT EXISTS (SELECT 1 FROM transcript_archives a WHERE a.thread_id = t.thread_id) LIMIT 1`; | ||
| 589 | if (pendingArchive.length) return "transcript archive is not verified"; | ||
| 590 | } | ||
| 581 | const latestProjects = yield* projectStore.listShells(); | 591 | const latestProjects = yield* projectStore.listShells(); |
| 582 | if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) | 592 | if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) |
| 583 | return "contains a project checkout"; | 593 | return "contains a project checkout"; |
apps/web/src/AppRoot.tsx+2| ... | @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; | ... | @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; |
| 2 | 2 | ||
| 3 | import { BrowserProfileReporter } from "./browser/BrowserProfileReporter"; | 3 | import { BrowserProfileReporter } from "./browser/BrowserProfileReporter"; |
| 4 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; | 4 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 5 | import { CloudAuthentication } from "./cloud/CloudAuthentication"; | ||
| 5 | import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; | 6 | import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; |
| 6 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | 7 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 7 | import type { AppRouter } from "./router"; | 8 | import type { AppRouter } from "./router"; |
| ... | @@ -15,6 +16,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { | ... | @@ -15,6 +16,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { |
| 15 | return ( | 16 | return ( |
| 16 | <AppAtomRegistryProvider> | 17 | <AppAtomRegistryProvider> |
| 17 | <RouterProvider router={router} /> | 18 | <RouterProvider router={router} /> |
| 19 | <CloudAuthentication /> | ||
| 18 | <ElectronBrowserHost /> | 20 | <ElectronBrowserHost /> |
| 19 | <BrowserProfileReporter /> | 21 | <BrowserProfileReporter /> |
| 20 | <QuitHoldOverlay /> | 22 | <QuitHoldOverlay /> |
apps/web/src/cloud/CloudAuthBoundary.test.tsx created+105| ... | @@ -0,0 +1,105 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | import { act, useEffect, type ReactNode } from "react"; | ||
| 3 | import { createRoot, type Root } from "react-dom/client"; | ||
| 4 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | const mocks = vi.hoisted(() => ({ loads: 0, mounts: 0 })); | ||
| 7 | vi.mock("../env", () => ({ isElectron: false })); | ||
| 8 | vi.mock("./publicConfig", () => ({ | ||
| 9 | hasCloudPublicConfig: () => true, | ||
| 10 | resolveCloudPublicConfig: () => ({ clerkPublishableKey: "test-key" }), | ||
| 11 | })); | ||
| 12 | vi.mock("../components/clerk/BrowserManagedAuthShell", () => { | ||
| 13 | mocks.loads += 1; | ||
| 14 | return { | ||
| 15 | default: ({ children }: { children: ReactNode }) => { | ||
| 16 | useEffect(() => { | ||
| 17 | mocks.mounts += 1; | ||
| 18 | }, []); | ||
| 19 | return children; | ||
| 20 | }, | ||
| 21 | }; | ||
| 22 | }); | ||
| 23 | |||
| 24 | let root: Root; | ||
| 25 | let container: HTMLDivElement; | ||
| 26 | |||
| 27 | beforeEach(() => { | ||
| 28 | localStorage.clear(); | ||
| 29 | vi.resetModules(); | ||
| 30 | mocks.loads = 0; | ||
| 31 | mocks.mounts = 0; | ||
| 32 | container = document.createElement("div"); | ||
| 33 | document.body.append(container); | ||
| 34 | root = createRoot(container); | ||
| 35 | }); | ||
| 36 | afterEach(async () => { | ||
| 37 | await act(async () => root.unmount()); | ||
| 38 | container.remove(); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("leaves Clerk dormant for local and self-hosted use and preserves the editor on activation", async () => { | ||
| 42 | const { CloudAuthBoundary } = await import("./CloudAuthBoundary"); | ||
| 43 | const { activateCloudAuthentication } = await import("./activation"); | ||
| 44 | await act(async () => | ||
| 45 | root.render( | ||
| 46 | <> | ||
| 47 | <input aria-label="Draft" defaultValue="" /> | ||
| 48 | <CloudAuthBoundary fallback={<button>Use T3 Connect</button>}> | ||
| 49 | <p>Cloud account</p> | ||
| 50 | </CloudAuthBoundary> | ||
| 51 | </>, | ||
| 52 | ), | ||
| 53 | ); | ||
| 54 | const editor = container.querySelector("input")!; | ||
| 55 | editor.value = "Unsent draft"; | ||
| 56 | editor.focus(); | ||
| 57 | expect(mocks.loads).toBe(0); | ||
| 58 | expect(mocks.mounts).toBe(0); | ||
| 59 | expect(container.textContent).toContain("Use T3 Connect"); | ||
| 60 | await act(async () => { | ||
| 61 | activateCloudAuthentication(); | ||
| 62 | await vi.dynamicImportSettled(); | ||
| 63 | }); | ||
| 64 | expect(mocks.loads).toBe(1); | ||
| 65 | expect(mocks.mounts).toBe(1); | ||
| 66 | expect(container.textContent).toContain("Cloud account"); | ||
| 67 | expect(container.querySelector("input")).toBe(editor); | ||
| 68 | expect(editor.value).toBe("Unsent draft"); | ||
| 69 | expect(document.activeElement).toBe(editor); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("restores an explicit Connect opt-in without a new sign-in request", async () => { | ||
| 73 | localStorage.setItem("knife:t3-connect-enabled", "true"); | ||
| 74 | const { useCloudAuthentication } = await import("./activation"); | ||
| 75 | function State() { | ||
| 76 | const state = useCloudAuthentication(); | ||
| 77 | return <p>{JSON.stringify(state)}</p>; | ||
| 78 | } | ||
| 79 | await act(async () => root.render(<State />)); | ||
| 80 | expect(container.textContent).toBe('{"enabled":true,"signInRequested":false}'); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("forgetting Connect opt-in returns cloud fragments to fallback without touching the editor", async () => { | ||
| 84 | const { CloudAuthBoundary } = await import("./CloudAuthBoundary"); | ||
| 85 | const { activateCloudAuthentication, deactivateCloudAuthentication } = | ||
| 86 | await import("./activation"); | ||
| 87 | await act(async () => { | ||
| 88 | activateCloudAuthentication(); | ||
| 89 | root.render( | ||
| 90 | <> | ||
| 91 | <input defaultValue="unsent" /> | ||
| 92 | <CloudAuthBoundary fallback={<p>Sign in</p>}> | ||
| 93 | <p>Account</p> | ||
| 94 | </CloudAuthBoundary> | ||
| 95 | </>, | ||
| 96 | ); | ||
| 97 | await vi.dynamicImportSettled(); | ||
| 98 | }); | ||
| 99 | const editor = container.querySelector("input")!; | ||
| 100 | await act(async () => deactivateCloudAuthentication()); | ||
| 101 | expect(localStorage.getItem("knife:t3-connect-enabled")).toBeNull(); | ||
| 102 | expect(container.textContent).toBe("Sign in"); | ||
| 103 | expect(container.querySelector("input")).toBe(editor); | ||
| 104 | expect(editor.value).toBe("unsent"); | ||
| 105 | }); | ||
apps/web/src/cloud/CloudAuthBoundary.tsx created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { lazy, Suspense, type ReactNode } from "react"; | ||
| 2 | |||
| 3 | import { isElectron } from "../env"; | ||
| 4 | import { useCloudAuthentication } from "./activation"; | ||
| 5 | import { hasCloudPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; | ||
| 6 | |||
| 7 | const ManagedAuthShell = lazy(() => | ||
| 8 | isElectron | ||
| 9 | ? import("../components/clerk/ElectronManagedAuthShell") | ||
| 10 | : import("../components/clerk/BrowserManagedAuthShell"), | ||
| 11 | ); | ||
| 12 | |||
| 13 | /** Only cloud fragments gain a provider, so activation preserves the surrounding app. */ | ||
| 14 | export function CloudAuthBoundary({ | ||
| 15 | children, | ||
| 16 | fallback = null, | ||
| 17 | }: { | ||
| 18 | readonly children: ReactNode; | ||
| 19 | readonly fallback?: ReactNode; | ||
| 20 | }) { | ||
| 21 | const { enabled } = useCloudAuthentication(); | ||
| 22 | const { clerkPublishableKey } = resolveCloudPublicConfig(); | ||
| 23 | if (!enabled || !clerkPublishableKey || !hasCloudPublicConfig()) return fallback; | ||
| 24 | return ( | ||
| 25 | <Suspense fallback={fallback}> | ||
| 26 | <ManagedAuthShell publishableKey={clerkPublishableKey}>{children}</ManagedAuthShell> | ||
| 27 | </Suspense> | ||
| 28 | ); | ||
| 29 | } | ||
apps/web/src/cloud/CloudAuthentication.tsx created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import { useAuth, useClerk } from "@clerk/react"; | ||
| 2 | import { useEffect } from "react"; | ||
| 3 | |||
| 4 | import { isElectron } from "../env"; | ||
| 5 | import { useEnvironments } from "../state/environments"; | ||
| 6 | import { resolveClerkSignInProps } from "../components/clerk/authRedirect"; | ||
| 7 | import { | ||
| 8 | activateCloudAuthentication, | ||
| 9 | finishCloudSignInRequest, | ||
| 10 | useCloudAuthentication, | ||
| 11 | } from "./activation"; | ||
| 12 | import { CloudAuthBoundary } from "./CloudAuthBoundary"; | ||
| 13 | import { ManagedRelayAuthProvider } from "./managedAuth"; | ||
| 14 | |||
| 15 | export function CloudAuthentication() { | ||
| 16 | const { environments } = useEnvironments(); | ||
| 17 | const savedRelay = environments.some((environment) => environment.relayManaged); | ||
| 18 | useEffect(() => { | ||
| 19 | if (savedRelay) activateCloudAuthentication(); | ||
| 20 | }, [savedRelay]); | ||
| 21 | return ( | ||
| 22 | <CloudAuthBoundary> | ||
| 23 | <ManagedRelayAuthProvider> | ||
| 24 | <SignInRequest /> | ||
| 25 | </ManagedRelayAuthProvider> | ||
| 26 | </CloudAuthBoundary> | ||
| 27 | ); | ||
| 28 | } | ||
| 29 | |||
| 30 | function SignInRequest() { | ||
| 31 | const { isLoaded, isSignedIn } = useAuth(); | ||
| 32 | const clerk = useClerk(); | ||
| 33 | const { signInRequested } = useCloudAuthentication(); | ||
| 34 | useEffect(() => { | ||
| 35 | if (!isLoaded || !signInRequested) return; | ||
| 36 | finishCloudSignInRequest(); | ||
| 37 | if (!isSignedIn) clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); | ||
| 38 | }, [clerk, isLoaded, isSignedIn, signInRequested]); | ||
| 39 | return null; | ||
| 40 | } | ||
apps/web/src/cloud/activation.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import { useSyncExternalStore } from "react"; | ||
| 2 | |||
| 3 | import { hasCloudPublicConfig } from "./publicConfig"; | ||
| 4 | |||
| 5 | const STORAGE_KEY = "knife:t3-connect-enabled"; | ||
| 6 | const listeners = new Set<() => void>(); | ||
| 7 | |||
| 8 | function restoredOptIn(): boolean { | ||
| 9 | try { | ||
| 10 | return globalThis.localStorage?.getItem(STORAGE_KEY) === "true"; | ||
| 11 | } catch { | ||
| 12 | return false; | ||
| 13 | } | ||
| 14 | } | ||
| 15 | |||
| 16 | let state = { enabled: restoredOptIn(), signInRequested: false }; | ||
| 17 | |||
| 18 | export function activateCloudAuthentication(signInRequested = false): void { | ||
| 19 | if (!hasCloudPublicConfig()) return; | ||
| 20 | const requested = state.signInRequested || signInRequested; | ||
| 21 | if (state.enabled && state.signInRequested === requested) return; | ||
| 22 | state = { enabled: true, signInRequested: requested }; | ||
| 23 | try { | ||
| 24 | localStorage.setItem(STORAGE_KEY, "true"); | ||
| 25 | } catch { | ||
| 26 | // The current session can still use Connect when browser storage is blocked. | ||
| 27 | } | ||
| 28 | for (const listener of listeners) listener(); | ||
| 29 | } | ||
| 30 | |||
| 31 | export function deactivateCloudAuthentication(): void { | ||
| 32 | state = { enabled: false, signInRequested: false }; | ||
| 33 | try { | ||
| 34 | localStorage.removeItem(STORAGE_KEY); | ||
| 35 | } catch { | ||
| 36 | // Blocked storage does not prevent signing out in this window. | ||
| 37 | } | ||
| 38 | for (const listener of listeners) listener(); | ||
| 39 | } | ||
| 40 | |||
| 41 | export function finishCloudSignInRequest(): void { | ||
| 42 | if (!state.signInRequested) return; | ||
| 43 | state = { ...state, signInRequested: false }; | ||
| 44 | for (const listener of listeners) listener(); | ||
| 45 | } | ||
| 46 | |||
| 47 | export function useCloudAuthentication() { | ||
| 48 | return useSyncExternalStore( | ||
| 49 | (listener) => { | ||
| 50 | listeners.add(listener); | ||
| 51 | return () => listeners.delete(listener); | ||
| 52 | }, | ||
| 53 | () => state, | ||
| 54 | ); | ||
| 55 | } | ||
apps/web/src/cloud/managedAuth.tsx+3| ... | @@ -12,6 +12,7 @@ import { environmentCatalog } from "../connection/catalog"; | ... | @@ -12,6 +12,7 @@ import { environmentCatalog } from "../connection/catalog"; |
| 12 | import { runtime } from "../lib/runtime"; | 12 | import { runtime } from "../lib/runtime"; |
| 13 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 13 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 14 | import { useAtomCommand } from "../state/use-atom-command"; | 14 | import { useAtomCommand } from "../state/use-atom-command"; |
| 15 | import { deactivateCloudAuthentication } from "./activation"; | ||
| 15 | import { resolveRelayClerkTokenOptions } from "./publicConfig"; | 16 | import { resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 16 | 17 | ||
| 17 | export function deactivateManagedRelayAuthentication(): void { | 18 | export function deactivateManagedRelayAuthentication(): void { |
| ... | @@ -71,6 +72,8 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac | ... | @@ -71,6 +72,8 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac |
| 71 | 72 | ||
| 72 | if (!isSignedIn || !userId) { | 73 | if (!isSignedIn || !userId) { |
| 73 | deactivateManagedRelayAuthentication(); | 74 | deactivateManagedRelayAuthentication(); |
| 75 | if (previousAccount !== undefined && previousAccount !== null) | ||
| 76 | deactivateCloudAuthentication(); | ||
| 74 | if (previousAccount !== null) { | 77 | if (previousAccount !== null) { |
| 75 | void queueAccountCleanup(); | 78 | void queueAccountCleanup(); |
| 76 | } | 79 | } |
apps/web/src/components/ChatView.tsx+21-18| ... | @@ -413,9 +413,10 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect | ... | @@ -413,9 +413,10 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect |
| 413 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 413 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 414 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; | 414 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 415 | import { useEnvironmentQuery } from "../state/query"; | 415 | import { useEnvironmentQuery } from "../state/query"; |
| 416 | import { useEnvironmentScope } from "~/state/session"; | 416 | import { useEnvironmentScope, useThreadOrganizationPermission } from "~/state/session"; |
| 417 | import { | 417 | import { |
| 418 | environmentServerConfigsAtom, | 418 | environmentServerConfigsAtom, |
| 419 | environmentCapabilitiesAtom, | ||
| 419 | primaryServerAvailableEditorsAtom, | 420 | primaryServerAvailableEditorsAtom, |
| 420 | primaryServerKeybindingsAtom, | 421 | primaryServerKeybindingsAtom, |
| 421 | serverEnvironment, | 422 | serverEnvironment, |
| ... | @@ -583,7 +584,11 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at | ... | @@ -583,7 +584,11 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at |
| 583 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 584 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 584 | import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; | 585 | import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; |
| 585 | import { assetEnvironment } from "../state/assets"; | 586 | import { assetEnvironment } from "../state/assets"; |
| 586 | import { readEnvironmentScope, readPreparedConnection } from "../state/session"; | 587 | import { |
| 588 | readEnvironmentScope, | ||
| 589 | readPreparedConnection, | ||
| 590 | readThreadOrganizationPermission, | ||
| 591 | } from "../state/session"; | ||
| 587 | import { useAtomCommand } from "../state/use-atom-command"; | 592 | import { useAtomCommand } from "../state/use-atom-command"; |
| 588 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | 593 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; |
| 589 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 594 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| ... | @@ -1591,6 +1596,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1591,6 +1596,8 @@ export default function ChatView(props: ChatViewProps) { |
| 1591 | forceExpandedMobileComposer = false, | 1596 | forceExpandedMobileComposer = false, |
| 1592 | } = props; | 1597 | } = props; |
| 1593 | const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | 1598 | const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); |
| 1599 | const canOrganizeThread = useThreadOrganizationPermission(environmentId); | ||
| 1600 | const organizationCapabilities = useAtomValue(environmentCapabilitiesAtom).get(environmentId); | ||
| 1594 | const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); | 1601 | const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); |
| 1595 | const hasTerminalWriteAccess = useCallback( | 1602 | const hasTerminalWriteAccess = useCallback( |
| 1596 | () => readEnvironmentScope(environmentId, AuthTerminalOperateScope), | 1603 | () => readEnvironmentScope(environmentId, AuthTerminalOperateScope), |
| ... | @@ -7205,9 +7212,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7205,9 +7212,9 @@ export default function ChatView(props: ChatViewProps) { |
| 7205 | useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); | 7212 | useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); |
| 7206 | }, [activeThreadRef, pullRequestPanelTarget, supportsPullRequests]); | 7213 | }, [activeThreadRef, pullRequestPanelTarget, supportsPullRequests]); |
| 7207 | const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; | 7214 | const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; |
| 7208 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; | 7215 | const supportsSettlement = organizationCapabilities?.threadSettlement === true; |
| 7209 | const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; | 7216 | const supportsSnooze = organizationCapabilities?.threadSnooze === true; |
| 7210 | const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; | 7217 | const supportsPinning = organizationCapabilities?.threadPinning === true; |
| 7211 | const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; | 7218 | const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; |
| 7212 | const activeThreadSnoozed = | 7219 | const activeThreadSnoozed = |
| 7213 | activeThreadShell !== null && | 7220 | activeThreadShell !== null && |
| ... | @@ -7271,6 +7278,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7271,6 +7278,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7271 | const activeThreadSettled = | 7278 | const activeThreadSettled = |
| 7272 | supportsSettlement && activeThreadShell?.settledOverride === "settled"; | 7279 | supportsSettlement && activeThreadShell?.settledOverride === "settled"; |
| 7273 | const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { | 7280 | const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { |
| 7281 | organization: true, | ||
| 7274 | reportFailure: false, | 7282 | reportFailure: false, |
| 7275 | }); | 7283 | }); |
| 7276 | // Keyed by thread, not a boolean: the pending state must follow the thread | 7284 | // Keyed by thread, not a boolean: the pending state must follow the thread |
| ... | @@ -7279,10 +7287,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7279,10 +7287,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7279 | const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null); | 7287 | const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null); |
| 7280 | const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; | 7288 | const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; |
| 7281 | const handleUnsettleActiveThread = useCallback(async () => { | 7289 | const handleUnsettleActiveThread = useCallback(async () => { |
| 7282 | if ( | 7290 | if (!activeThreadRef || !readThreadOrganizationPermission(activeThreadRef.environmentId)) |
| 7283 | !activeThreadRef || | ||
| 7284 | !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) | ||
| 7285 | ) | ||
| 7286 | return; | 7291 | return; |
| 7287 | const threadKey = scopedThreadKey(activeThreadRef); | 7292 | const threadKey = scopedThreadKey(activeThreadRef); |
| 7288 | setUnsettlingThreadKey(threadKey); | 7293 | setUnsettlingThreadKey(threadKey); |
| ... | @@ -7306,15 +7311,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7306,15 +7311,13 @@ export default function ChatView(props: ChatViewProps) { |
| 7306 | } | 7311 | } |
| 7307 | }, [activeThreadRef, unsettleThreadMutation]); | 7312 | }, [activeThreadRef, unsettleThreadMutation]); |
| 7308 | const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { | 7313 | const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { |
| 7314 | organization: true, | ||
| 7309 | reportFailure: false, | 7315 | reportFailure: false, |
| 7310 | }); | 7316 | }); |
| 7311 | const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null); | 7317 | const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null); |
| 7312 | const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; | 7318 | const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; |
| 7313 | const handleUnsnoozeActiveThread = useCallback(async () => { | 7319 | const handleUnsnoozeActiveThread = useCallback(async () => { |
| 7314 | if ( | 7320 | if (!activeThreadRef || !readThreadOrganizationPermission(activeThreadRef.environmentId)) |
| 7315 | !activeThreadRef || | ||
| 7316 | !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) | ||
| 7317 | ) | ||
| 7318 | return; | 7321 | return; |
| 7319 | const threadKey = scopedThreadKey(activeThreadRef); | 7322 | const threadKey = scopedThreadKey(activeThreadRef); |
| 7320 | setUnsnoozingThreadKey(threadKey); | 7323 | setUnsnoozingThreadKey(threadKey); |
| ... | @@ -7747,7 +7750,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7747,7 +7750,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7747 | : "Snoozed" | 7750 | : "Snoozed" |
| 7748 | } | 7751 | } |
| 7749 | actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} | 7752 | actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} |
| 7750 | actionDisabled={!canOperateThread || isUnsnoozing} | 7753 | actionDisabled={!canOrganizeThread || isUnsnoozing} |
| 7751 | onAction={() => void handleUnsnoozeActiveThread()} | 7754 | onAction={() => void handleUnsnoozeActiveThread()} |
| 7752 | /> | 7755 | /> |
| 7753 | ) : activeThreadSettled ? ( | 7756 | ) : activeThreadSettled ? ( |
| ... | @@ -7759,7 +7762,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7759,7 +7762,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7759 | : "Settled" | 7762 | : "Settled" |
| 7760 | } | 7763 | } |
| 7761 | actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"} | 7764 | actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"} |
| 7762 | actionDisabled={!canOperateThread || isUnsettling} | 7765 | actionDisabled={!canOrganizeThread || isUnsettling} |
| 7763 | onAction={() => void handleUnsettleActiveThread()} | 7766 | onAction={() => void handleUnsettleActiveThread()} |
| 7764 | /> | 7767 | /> |
| 7765 | ) : activeThreadWokeVisible ? ( | 7768 | ) : activeThreadWokeVisible ? ( |
| ... | @@ -7777,7 +7780,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7777,7 +7780,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7777 | activeThreadShell?.snoozedUntil, | 7780 | activeThreadShell?.snoozedUntil, |
| 7778 | activeThreadSnoozed, | 7781 | activeThreadSnoozed, |
| 7779 | activeThreadWokeVisible, | 7782 | activeThreadWokeVisible, |
| 7780 | canOperateThread, | 7783 | canOrganizeThread, |
| 7781 | handleUnsettleActiveThread, | 7784 | handleUnsettleActiveThread, |
| 7782 | handleUnsnoozeActiveThread, | 7785 | handleUnsnoozeActiveThread, |
| 7783 | isUnsettling, | 7786 | isUnsettling, |
| ... | @@ -8149,7 +8152,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8149,7 +8152,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8149 | } | 8152 | } |
| 8150 | 8153 | ||
| 8151 | if (command === "thread.settle") { | 8154 | if (command === "thread.settle") { |
| 8152 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | 8155 | if (!readThreadOrganizationPermission(environmentId)) return; |
| 8153 | event.preventDefault(); | 8156 | event.preventDefault(); |
| 8154 | event.stopPropagation(); | 8157 | event.stopPropagation(); |
| 8155 | if (!isServerThread || !activeThreadRef || !supportsSettlement) return; | 8158 | if (!isServerThread || !activeThreadRef || !supportsSettlement) return; |
| ... | @@ -8175,7 +8178,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8175,7 +8178,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8175 | } | 8178 | } |
| 8176 | 8179 | ||
| 8177 | if (command === "thread.pin") { | 8180 | if (command === "thread.pin") { |
| 8178 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | 8181 | if (!readThreadOrganizationPermission(environmentId)) return; |
| 8179 | event.preventDefault(); | 8182 | event.preventDefault(); |
| 8180 | event.stopPropagation(); | 8183 | event.stopPropagation(); |
| 8181 | if (!isServerThread || !activeThreadRef || !supportsPinning) return; | 8184 | if (!isServerThread || !activeThreadRef || !supportsPinning) return; |
apps/web/src/components/Sidebar.tsx+72-48| ... | @@ -164,14 +164,23 @@ import { | ... | @@ -164,14 +164,23 @@ import { |
| 164 | useProjects, | 164 | useProjects, |
| 165 | useThreadShells, | 165 | useThreadShells, |
| 166 | } from "../state/entities"; | 166 | } from "../state/entities"; |
| 167 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; | 167 | import { |
| 168 | environmentCapabilitiesAtom, | ||
| 169 | environmentServerConfigsAtom, | ||
| 170 | primaryServerKeybindingsAtom, | ||
| 171 | } from "../state/server"; | ||
| 168 | import { environmentProjects } from "../state/projects"; | 172 | import { environmentProjects } from "../state/projects"; |
| 169 | import { vcsEnvironment } from "../state/vcs"; | 173 | import { vcsEnvironment } from "../state/vcs"; |
| 170 | import { threadEnvironment } from "../state/threads"; | 174 | import { threadEnvironment } from "../state/threads"; |
| 171 | import { useEnvironmentQuery } from "../state/query"; | 175 | import { useEnvironmentQuery } from "../state/query"; |
| 172 | import { useThreadSearch } from "../state/queries"; | 176 | import { useThreadSearch } from "../state/queries"; |
| 173 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | 177 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; |
| 174 | import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; | 178 | import { |
| 179 | readEnvironmentScope, | ||
| 180 | useEnvironmentScope, | ||
| 181 | readThreadOrganizationPermission, | ||
| 182 | useThreadOrganizationPermission, | ||
| 183 | } from "../state/session"; | ||
| 175 | import { | 184 | import { |
| 176 | buildThreadRouteParams, | 185 | buildThreadRouteParams, |
| 177 | resolveActiveThreadRouteRef, | 186 | resolveActiveThreadRouteRef, |
| ... | @@ -188,6 +197,7 @@ import { | ... | @@ -188,6 +197,7 @@ import { |
| 188 | buildDraftActionMenuItems, | 197 | buildDraftActionMenuItems, |
| 189 | buildThreadActionMenuItems, | 198 | buildThreadActionMenuItems, |
| 190 | threadActionRequiresOperate, | 199 | threadActionRequiresOperate, |
| 200 | threadActionIsOrganization, | ||
| 191 | } from "./threadActionMenu.logic"; | 201 | } from "./threadActionMenu.logic"; |
| 192 | import { | 202 | import { |
| 193 | animateSidebarLayoutChanges, | 203 | animateSidebarLayoutChanges, |
| ... | @@ -315,16 +325,20 @@ const inboxReturns = createInboxReturnTracker(); | ... | @@ -315,16 +325,20 @@ const inboxReturns = createInboxReturnTracker(); |
| 315 | 325 | ||
| 316 | function canOperateThreads( | 326 | function canOperateThreads( |
| 317 | threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>, | 327 | threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>, |
| 328 | organization = false, | ||
| 318 | ): boolean { | 329 | ): boolean { |
| 319 | return threads.every((thread) => | 330 | return threads.every((thread) => |
| 320 | readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope), | 331 | organization |
| 332 | ? readThreadOrganizationPermission(thread.environmentId) | ||
| 333 | : readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope), | ||
| 321 | ); | 334 | ); |
| 322 | } | 335 | } |
| 323 | 336 | ||
| 324 | function checkThreadOperations( | 337 | function checkThreadOperations( |
| 325 | threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>, | 338 | threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>, |
| 339 | organization = false, | ||
| 326 | ): boolean { | 340 | ): boolean { |
| 327 | if (canOperateThreads(threads)) return true; | 341 | if (canOperateThreads(threads, organization)) return true; |
| 328 | toastManager.add({ | 342 | toastManager.add({ |
| 329 | type: "error", | 343 | type: "error", |
| 330 | title: "Thread action unavailable", | 344 | title: "Thread action unavailable", |
| ... | @@ -659,10 +673,10 @@ function SortableThreadRow(props: { | ... | @@ -659,10 +673,10 @@ function SortableThreadRow(props: { |
| 659 | environmentId: EnvironmentId; | 673 | environmentId: EnvironmentId; |
| 660 | children: (bag: SortableThreadRowBag) => ReactNode; | 674 | children: (bag: SortableThreadRowBag) => ReactNode; |
| 661 | }) { | 675 | }) { |
| 662 | const canOperateThread = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope); | 676 | const canOrganizeThread = useThreadOrganizationPermission(props.environmentId); |
| 663 | const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ | 677 | const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ |
| 664 | id: props.id, | 678 | id: props.id, |
| 665 | disabled: { draggable: props.disabled || !canOperateThread }, | 679 | disabled: { draggable: props.disabled || !canOrganizeThread }, |
| 666 | animateLayoutChanges: animateSidebarLayoutChanges, | 680 | animateLayoutChanges: animateSidebarLayoutChanges, |
| 667 | }); | 681 | }); |
| 668 | // dnd-kit memoizes each field but not the bag, so the memoized row would | 682 | // dnd-kit memoizes each field but not the bag, so the memoized row would |
| ... | @@ -1214,6 +1228,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1214,6 +1228,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1214 | ); | 1228 | ); |
| 1215 | const threadKey = scopedThreadKey(threadRef); | 1229 | const threadKey = scopedThreadKey(threadRef); |
| 1216 | const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); | 1230 | const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); |
| 1231 | const canOrganizeThread = useThreadOrganizationPermission(thread.environmentId); | ||
| 1217 | useEffect(() => { | 1232 | useEffect(() => { |
| 1218 | if (!canOperateThread && isRenaming) onCancelRename(); | 1233 | if (!canOperateThread && isRenaming) onCancelRename(); |
| 1219 | }, [canOperateThread, isRenaming, onCancelRename]); | 1234 | }, [canOperateThread, isRenaming, onCancelRename]); |
| ... | @@ -1549,11 +1564,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1549,11 +1564,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1549 | // Snooze is offered only where it can succeed: capability-gated and never | 1564 | // Snooze is offered only where it can succeed: capability-gated and never |
| 1550 | // on blocked-on-you work or queued turns (the server rejects both). | 1565 | // on blocked-on-you work or queued turns (the server rejects both). |
| 1551 | const showSnoozeButton = | 1566 | const showSnoozeButton = |
| 1552 | canOperateThread && | 1567 | canOrganizeThread && |
| 1553 | props.snoozeSupported && | 1568 | props.snoozeSupported && |
| 1554 | canSnooze(thread, { now: new Date().toISOString() }); | 1569 | canSnooze(thread, { now: new Date().toISOString() }); |
| 1555 | const showHoverActions = | 1570 | const showHoverActions = |
| 1556 | (canOperateThread && props.settlementSupported) || showSnoozeButton || hasUnsentDraft; | 1571 | (canOrganizeThread && props.settlementSupported) || showSnoozeButton || hasUnsentDraft; |
| 1557 | // If the thread becomes blocked while the popover is open, the button | 1572 | // If the thread becomes blocked while the popover is open, the button |
| 1558 | // unmounts without firing onOpenChange(false). Deriving the flag keeps a | 1573 | // unmounts without firing onOpenChange(false). Deriving the flag keeps a |
| 1559 | // stale true from permanently hiding the status label / pinning the | 1574 | // stale true from permanently hiding the status label / pinning the |
| ... | @@ -1757,7 +1772,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1757,7 +1772,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1757 | const showPin = | 1772 | const showPin = |
| 1758 | props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); | 1773 | props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); |
| 1759 | const pinIndicator = showPin ? ( | 1774 | const pinIndicator = showPin ? ( |
| 1760 | props.pinningSupported && canOperateThread && !sortable?.isDragging ? ( | 1775 | props.pinningSupported && canOrganizeThread && !sortable?.isDragging ? ( |
| 1761 | <Tooltip> | 1776 | <Tooltip> |
| 1762 | <TooltipTrigger | 1777 | <TooltipTrigger |
| 1763 | render={ | 1778 | render={ |
| ... | @@ -2517,6 +2532,7 @@ export default function Sidebar() { | ... | @@ -2517,6 +2532,7 @@ export default function Sidebar() { |
| 2517 | ); | 2532 | ); |
| 2518 | const environments = useEnvironmentIdentities(); | 2533 | const environments = useEnvironmentIdentities(); |
| 2519 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); | 2534 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); |
| 2535 | const environmentCapabilities = useAtomValue(environmentCapabilitiesAtom); | ||
| 2520 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 2536 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 2521 | const clearSelection = useThreadSelectionStore((s) => s.clearSelection); | 2537 | const clearSelection = useThreadSelectionStore((s) => s.clearSelection); |
| 2522 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); | 2538 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); |
| ... | @@ -2833,7 +2849,7 @@ export default function Sidebar() { | ... | @@ -2833,7 +2849,7 @@ export default function Sidebar() { |
| 2833 | const draggable = new Set<string>(); | 2849 | const draggable = new Set<string>(); |
| 2834 | const activeReorderable = new Set<string>(); | 2850 | const activeReorderable = new Set<string>(); |
| 2835 | for (const thread of visible) { | 2851 | for (const thread of visible) { |
| 2836 | const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; | 2852 | const capabilities = environmentCapabilities.get(thread.environmentId); |
| 2837 | // Threads on servers without the settlement capability (old server, | 2853 | // Threads on servers without the settlement capability (old server, |
| 2838 | // or descriptor not loaded yet) never classify as settled: the user | 2854 | // or descriptor not loaded yet) never classify as settled: the user |
| 2839 | // could neither un-settle nor pin them, so auto-settling them would | 2855 | // could neither un-settle nor pin them, so auto-settling them would |
| ... | @@ -2923,7 +2939,7 @@ export default function Sidebar() { | ... | @@ -2923,7 +2939,7 @@ export default function Sidebar() { |
| 2923 | nowMinute, | 2939 | nowMinute, |
| 2924 | optimisticDrop, | 2940 | optimisticDrop, |
| 2925 | scopedProjectKeys, | 2941 | scopedProjectKeys, |
| 2926 | serverConfigs, | 2942 | environmentCapabilities, |
| 2927 | snoozeWakeTick, | 2943 | snoozeWakeTick, |
| 2928 | threads, | 2944 | threads, |
| 2929 | workingShelfEnabled, | 2945 | workingShelfEnabled, |
| ... | @@ -3608,8 +3624,7 @@ export default function Sidebar() { | ... | @@ -3608,8 +3624,7 @@ export default function Sidebar() { |
| 3608 | const originSection = sectionByThreadKeyRef.current.get(originKey); | 3624 | const originSection = sectionByThreadKeyRef.current.get(originKey); |
| 3609 | const canApply = (key: string) => { | 3625 | const canApply = (key: string) => { |
| 3610 | const thread = threadByKeyRef.current.get(key); | 3626 | const thread = threadByKeyRef.current.get(key); |
| 3611 | const capabilities = | 3627 | const capabilities = thread && environmentCapabilities.get(thread.environmentId); |
| 3612 | thread && serverConfigs.get(thread.environmentId)?.environment.capabilities; | ||
| 3613 | return ( | 3628 | return ( |
| 3614 | thread !== undefined && | 3629 | thread !== undefined && |
| 3615 | sectionByThreadKeyRef.current.get(key) === originSection && | 3630 | sectionByThreadKeyRef.current.get(key) === originSection && |
| ... | @@ -3661,7 +3676,7 @@ export default function Sidebar() { | ... | @@ -3661,7 +3676,7 @@ export default function Sidebar() { |
| 3661 | onAbort: () => {}, | 3676 | onAbort: () => {}, |
| 3662 | }); | 3677 | }); |
| 3663 | }, | 3678 | }, |
| 3664 | [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads], | 3679 | [attemptUnsettle, attemptUnsnooze, environmentCapabilities, settleThreads], |
| 3665 | ); | 3680 | ); |
| 3666 | const pinnedKeys = useMemo( | 3681 | const pinnedKeys = useMemo( |
| 3667 | () => | 3682 | () => |
| ... | @@ -4004,8 +4019,7 @@ export default function Sidebar() { | ... | @@ -4004,8 +4019,7 @@ export default function Sidebar() { |
| 4004 | activePinned: source.pinnedAt != null, | 4019 | activePinned: source.pinnedAt != null, |
| 4005 | activeSettled: source.settledOverride === "settled", | 4020 | activeSettled: source.settledOverride === "settled", |
| 4006 | supportsSettlement: | 4021 | supportsSettlement: |
| 4007 | serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === | 4022 | environmentCapabilities.get(source.environmentId)?.threadSettlement === true, |
| 4008 | true, | ||
| 4009 | target, | 4023 | target, |
| 4010 | pinnedOrder: pinnedKeys, | 4024 | pinnedOrder: pinnedKeys, |
| 4011 | pinnedKeysById, | 4025 | pinnedKeysById, |
| ... | @@ -4025,7 +4039,7 @@ export default function Sidebar() { | ... | @@ -4025,7 +4039,7 @@ export default function Sidebar() { |
| 4025 | }, [ | 4039 | }, [ |
| 4026 | activeKeysById, | 4040 | activeKeysById, |
| 4027 | pinnedKeysById, | 4041 | pinnedKeysById, |
| 4028 | serverConfigs, | 4042 | environmentCapabilities, |
| 4029 | activeKeys, | 4043 | activeKeys, |
| 4030 | activeReorderableThreadKeys, | 4044 | activeReorderableThreadKeys, |
| 4031 | draggedThreadKey, | 4045 | draggedThreadKey, |
| ... | @@ -4054,8 +4068,7 @@ export default function Sidebar() { | ... | @@ -4054,8 +4068,7 @@ export default function Sidebar() { |
| 4054 | activePinned: activeThread.pinnedAt != null, | 4068 | activePinned: activeThread.pinnedAt != null, |
| 4055 | activeSettled: activeThread.settledOverride === "settled", | 4069 | activeSettled: activeThread.settledOverride === "settled", |
| 4056 | supportsSettlement: | 4070 | supportsSettlement: |
| 4057 | serverConfigs.get(activeThread.environmentId)?.environment.capabilities | 4071 | environmentCapabilities.get(activeThread.environmentId)?.threadSettlement === true, |
| 4058 | .threadSettlement === true, | ||
| 4059 | target, | 4072 | target, |
| 4060 | pinnedOrder: pinnedKeys, | 4073 | pinnedOrder: pinnedKeys, |
| 4061 | pinnedKeysById, | 4074 | pinnedKeysById, |
| ... | @@ -4084,6 +4097,7 @@ export default function Sidebar() { | ... | @@ -4084,6 +4097,7 @@ export default function Sidebar() { |
| 4084 | const thread = threadByKey.get(key); | 4097 | const thread = threadByKey.get(key); |
| 4085 | return thread ? [thread] : []; | 4098 | return thread ? [thread] : []; |
| 4086 | }), | 4099 | }), |
| 4100 | true, | ||
| 4087 | ) | 4101 | ) |
| 4088 | ) | 4102 | ) |
| 4089 | return; | 4103 | return; |
| ... | @@ -4192,7 +4206,7 @@ export default function Sidebar() { | ... | @@ -4192,7 +4206,7 @@ export default function Sidebar() { |
| 4192 | [ | 4206 | [ |
| 4193 | activeKeysById, | 4207 | activeKeysById, |
| 4194 | pinnedKeysById, | 4208 | pinnedKeysById, |
| 4195 | serverConfigs, | 4209 | environmentCapabilities, |
| 4196 | activeKeys, | 4210 | activeKeys, |
| 4197 | activeReorderableThreadKeys, | 4211 | activeReorderableThreadKeys, |
| 4198 | draggableThreadKeys, | 4212 | draggableThreadKeys, |
| ... | @@ -4306,13 +4320,12 @@ export default function Sidebar() { | ... | @@ -4306,13 +4320,12 @@ export default function Sidebar() { |
| 4306 | ); | 4320 | ); |
| 4307 | const canSnoozeSelection = selectedThreads.every( | 4321 | const canSnoozeSelection = selectedThreads.every( |
| 4308 | (thread) => | 4322 | (thread) => |
| 4309 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && | 4323 | environmentCapabilities.get(thread.environmentId)?.threadSnooze === true && |
| 4310 | canSnooze(thread, { now: selectionNow.toISOString() }), | 4324 | canSnooze(thread, { now: selectionNow.toISOString() }), |
| 4311 | ); | 4325 | ); |
| 4312 | const titleRegenerationThreads = selectedThreads.filter( | 4326 | const titleRegenerationThreads = selectedThreads.filter( |
| 4313 | (thread) => | 4327 | (thread) => |
| 4314 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 4328 | environmentCapabilities.get(thread.environmentId)?.threadTitleRegeneration === true, |
| 4315 | .threadTitleRegeneration === true, | ||
| 4316 | ); | 4329 | ); |
| 4317 | const regeneratableTitleThreads = titleRegenerationThreads.filter( | 4330 | const regeneratableTitleThreads = titleRegenerationThreads.filter( |
| 4318 | (thread) => thread.titleRegeneration == null, | 4331 | (thread) => thread.titleRegeneration == null, |
| ... | @@ -4326,8 +4339,8 @@ export default function Sidebar() { | ... | @@ -4326,8 +4339,8 @@ export default function Sidebar() { |
| 4326 | // is omitted entirely when nothing selected is pinned. | 4339 | // is omitted entirely when nothing selected is pinned. |
| 4327 | const pinnedSelectedThreads = selectedThreads.filter( | 4340 | const pinnedSelectedThreads = selectedThreads.filter( |
| 4328 | (thread) => | 4341 | (thread) => |
| 4329 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === | 4342 | environmentCapabilities.get(thread.environmentId)?.threadPinning === true && |
| 4330 | true && thread.pinnedAt != null, | 4343 | thread.pinnedAt != null, |
| 4331 | ); | 4344 | ); |
| 4332 | const unpinMenuItem = buildBulkUnpinContextMenuItem({ | 4345 | const unpinMenuItem = buildBulkUnpinContextMenuItem({ |
| 4333 | pinnedCount: pinnedSelectedThreads.length, | 4346 | pinnedCount: pinnedSelectedThreads.length, |
| ... | @@ -4337,30 +4350,30 @@ export default function Sidebar() { | ... | @@ -4337,30 +4350,30 @@ export default function Sidebar() { |
| 4337 | api.contextMenu.show( | 4350 | api.contextMenu.show( |
| 4338 | [ | 4351 | [ |
| 4339 | ...(unpinMenuItem | 4352 | ...(unpinMenuItem |
| 4340 | ? [{ ...unpinMenuItem, disabled: !canOperateThreads(pinnedSelectedThreads) }] | 4353 | ? [{ ...unpinMenuItem, disabled: !canOperateThreads(pinnedSelectedThreads, true) }] |
| 4341 | : []), | 4354 | : []), |
| 4342 | { | 4355 | { |
| 4343 | id: "settle", | 4356 | id: "settle", |
| 4344 | label: `Settle (${count})`, | 4357 | label: `Settle (${count})`, |
| 4345 | disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads), | 4358 | disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads, true), |
| 4346 | }, | 4359 | }, |
| 4347 | ...(canSnoozeSelection | 4360 | ...(canSnoozeSelection |
| 4348 | ? [ | 4361 | ? [ |
| 4349 | { | 4362 | { |
| 4350 | id: "snooze", | 4363 | id: "snooze", |
| 4351 | label: `Snooze (${count})`, | 4364 | label: `Snooze (${count})`, |
| 4352 | disabled: !canOperateThreads(selectedThreads), | 4365 | disabled: !canOperateThreads(selectedThreads, true), |
| 4353 | children: [ | 4366 | children: [ |
| 4354 | ...snoozePresets.map((preset) => ({ | 4367 | ...snoozePresets.map((preset) => ({ |
| 4355 | id: `snooze:${preset.id}`, | 4368 | id: `snooze:${preset.id}`, |
| 4356 | label: `${preset.label} (${preset.whenLabel})`, | 4369 | label: `${preset.label} (${preset.whenLabel})`, |
| 4357 | disabled: !canOperateThreads(selectedThreads), | 4370 | disabled: !canOperateThreads(selectedThreads, true), |
| 4358 | })), | 4371 | })), |
| 4359 | { | 4372 | { |
| 4360 | id: "snooze:custom", | 4373 | id: "snooze:custom", |
| 4361 | label: "Custom…", | 4374 | label: "Custom…", |
| 4362 | separatorBefore: true, | 4375 | separatorBefore: true, |
| 4363 | disabled: !canOperateThreads(selectedThreads), | 4376 | disabled: !canOperateThreads(selectedThreads, true), |
| 4364 | }, | 4377 | }, |
| 4365 | ], | 4378 | ], |
| 4366 | }, | 4379 | }, |
| ... | @@ -4402,7 +4415,16 @@ export default function Sidebar() { | ... | @@ -4402,7 +4415,16 @@ export default function Sidebar() { |
| 4402 | }) | 4415 | }) |
| 4403 | : selectedThreads; | 4416 | : selectedThreads; |
| 4404 | if (clicked.value === "settle" && actionTargets.length === 0) return; | 4417 | if (clicked.value === "settle" && actionTargets.length === 0) return; |
| 4405 | if (clicked.value !== "mark-unread" && !checkThreadOperations(actionTargets)) return; | 4418 | if ( |
| 4419 | clicked.value !== "mark-unread" && | ||
| 4420 | !checkThreadOperations( | ||
| 4421 | actionTargets, | ||
| 4422 | clicked.value === "settle" || | ||
| 4423 | clicked.value === "unpin" || | ||
| 4424 | clicked.value.startsWith("snooze:"), | ||
| 4425 | ) | ||
| 4426 | ) | ||
| 4427 | return; | ||
| 4406 | if (clicked.value?.startsWith("snooze:")) { | 4428 | if (clicked.value?.startsWith("snooze:")) { |
| 4407 | const preset = | 4429 | const preset = |
| 4408 | clicked.value === "snooze:custom" | 4430 | clicked.value === "snooze:custom" |
| ... | @@ -4548,7 +4570,7 @@ export default function Sidebar() { | ... | @@ -4548,7 +4570,7 @@ export default function Sidebar() { |
| 4548 | markThreadUnread, | 4570 | markThreadUnread, |
| 4549 | performSnooze, | 4571 | performSnooze, |
| 4550 | removeFromSelection, | 4572 | removeFromSelection, |
| 4551 | serverConfigs, | 4573 | environmentCapabilities, |
| 4552 | settleThreads, | 4574 | settleThreads, |
| 4553 | updateThreadMetadata, | 4575 | updateThreadMetadata, |
| 4554 | timestampFormat, | 4576 | timestampFormat, |
| ... | @@ -4629,18 +4651,15 @@ export default function Sidebar() { | ... | @@ -4629,18 +4651,15 @@ export default function Sidebar() { |
| 4629 | // Environments without | 4651 | // Environments without |
| 4630 | // the settlement capability get no lifecycle items at all. | 4652 | // the settlement capability get no lifecycle items at all. |
| 4631 | const supportsSettlement = | 4653 | const supportsSettlement = |
| 4632 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === | 4654 | environmentCapabilities.get(thread.environmentId)?.threadSettlement === true; |
| 4633 | true; | ||
| 4634 | const supportsSnooze = | 4655 | const supportsSnooze = |
| 4635 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | 4656 | environmentCapabilities.get(thread.environmentId)?.threadSnooze === true; |
| 4636 | const supportsPinning = | 4657 | const supportsPinning = |
| 4637 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; | 4658 | environmentCapabilities.get(thread.environmentId)?.threadPinning === true; |
| 4638 | const supportsAutoSettleOptOut = | 4659 | const supportsAutoSettleOptOut = |
| 4639 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 4660 | environmentCapabilities.get(thread.environmentId)?.threadAutoSettleOptOut === true; |
| 4640 | .threadAutoSettleOptOut === true; | ||
| 4641 | const supportsTitleRegeneration = | 4661 | const supportsTitleRegeneration = |
| 4642 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 4662 | environmentCapabilities.get(thread.environmentId)?.threadTitleRegeneration === true; |
| 4643 | .threadTitleRegeneration === true; | ||
| 4644 | const isRegeneratingTitle = thread.titleRegeneration != null; | 4663 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 4645 | const isSettled = settledThreadKeysRef.current.has(threadKey); | 4664 | const isSettled = settledThreadKeysRef.current.has(threadKey); |
| 4646 | const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); | 4665 | const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); |
| ... | @@ -4658,6 +4677,7 @@ export default function Sidebar() { | ... | @@ -4658,6 +4677,7 @@ export default function Sidebar() { |
| 4658 | const clicked = await settlePromise(() => | 4677 | const clicked = await settlePromise(() => |
| 4659 | api.contextMenu.show( | 4678 | api.contextMenu.show( |
| 4660 | buildThreadActionMenuItems({ | 4679 | buildThreadActionMenuItems({ |
| 4680 | canOrganize: readThreadOrganizationPermission(threadRef.environmentId), | ||
| 4661 | canOperate: readEnvironmentScope( | 4681 | canOperate: readEnvironmentScope( |
| 4662 | threadRef.environmentId, | 4682 | threadRef.environmentId, |
| 4663 | AuthOrchestrationOperateScope, | 4683 | AuthOrchestrationOperateScope, |
| ... | @@ -4689,7 +4709,11 @@ export default function Sidebar() { | ... | @@ -4689,7 +4709,11 @@ export default function Sidebar() { |
| 4689 | ), | 4709 | ), |
| 4690 | ); | 4710 | ); |
| 4691 | if (clicked._tag === "Failure" || clicked.value === null) return; | 4711 | if (clicked._tag === "Failure" || clicked.value === null) return; |
| 4692 | if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return; | 4712 | if ( |
| 4713 | threadActionRequiresOperate(clicked.value) && | ||
| 4714 | !checkThreadOperations([thread], threadActionIsOrganization(clicked.value)) | ||
| 4715 | ) | ||
| 4716 | return; | ||
| 4693 | if (clicked.value?.startsWith("snooze:")) { | 4717 | if (clicked.value?.startsWith("snooze:")) { |
| 4694 | const preset = | 4718 | const preset = |
| 4695 | clicked.value === "snooze:custom" | 4719 | clicked.value === "snooze:custom" |
| ... | @@ -4897,7 +4921,7 @@ export default function Sidebar() { | ... | @@ -4897,7 +4921,7 @@ export default function Sidebar() { |
| 4897 | openProjectSettings, | 4921 | openProjectSettings, |
| 4898 | projectScopeKey, | 4922 | projectScopeKey, |
| 4899 | projectByKey, | 4923 | projectByKey, |
| 4900 | serverConfigs, | 4924 | environmentCapabilities, |
| 4901 | setProjectScopeKey, | 4925 | setProjectScopeKey, |
| 4902 | setThreadAutoSettle, | 4926 | setThreadAutoSettle, |
| 4903 | startThreadRename, | 4927 | startThreadRename, |
| ... | @@ -5329,16 +5353,16 @@ export default function Sidebar() { | ... | @@ -5329,16 +5353,16 @@ export default function Sidebar() { |
| 5329 | : "settle" | 5353 | : "settle" |
| 5330 | } | 5354 | } |
| 5331 | settlementSupported={ | 5355 | settlementSupported={ |
| 5332 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 5356 | environmentCapabilities.get(thread.environmentId) |
| 5333 | .threadSettlement === true | 5357 | ?.threadSettlement === true |
| 5334 | } | 5358 | } |
| 5335 | snoozeSupported={ | 5359 | snoozeSupported={ |
| 5336 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 5360 | environmentCapabilities.get(thread.environmentId)?.threadSnooze === |
| 5337 | .threadSnooze === true | 5361 | true |
| 5338 | } | 5362 | } |
| 5339 | pinningSupported={ | 5363 | pinningSupported={ |
| 5340 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 5364 | environmentCapabilities.get(thread.environmentId)?.threadPinning === |
| 5341 | .threadPinning === true | 5365 | true |
| 5342 | } | 5366 | } |
| 5343 | isPinned={thread.pinnedAt != null} | 5367 | isPinned={thread.pinnedAt != null} |
| 5344 | sortable={sortable} | 5368 | sortable={sortable} |
apps/web/src/components/auth/PairingRouteSurface.tsx+58-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { beginHostedSignIn } from "../../connection/hostedAuth"; | ||
| 1 | import { Alert, AlertDescription } from "../ui/alert"; | 2 | import { Alert, AlertDescription } from "../ui/alert"; |
| 2 | import type { AuthSessionState } from "@t3tools/contracts"; | 3 | import type { AuthSessionState } from "@t3tools/contracts"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| ... | @@ -28,7 +29,63 @@ export function PairingPendingSurface() { | ... | @@ -28,7 +29,63 @@ export function PairingPendingSurface() { |
| 28 | ); | 29 | ); |
| 29 | } | 30 | } |
| 30 | 31 | ||
| 31 | export function PairingRouteSurface({ | 32 | export function PairingRouteSurface(props: Parameters<typeof LegacyPairingRouteSurface>[0]) { |
| 33 | const [enabled, setEnabled] = useState<boolean | null>(null); | ||
| 34 | const [error, setError] = useState<string | null>(() => | ||
| 35 | new URLSearchParams(window.location.search).has("signInError") | ||
| 36 | ? "Sign-in did not finish. Try again." | ||
| 37 | : null, | ||
| 38 | ); | ||
| 39 | const attemptedSignIn = useRef(false); | ||
| 40 | useEffect(() => { | ||
| 41 | let stopped = false; | ||
| 42 | void fetch("/api/auth/oidc/config", { credentials: "same-origin", redirect: "error" }) | ||
| 43 | .then(async (response) => response.ok && (await response.json()).enabled === true) | ||
| 44 | .then((value) => { | ||
| 45 | if (!stopped) setEnabled(value); | ||
| 46 | }) | ||
| 47 | .catch(() => { | ||
| 48 | if (!stopped) setEnabled(false); | ||
| 49 | }); | ||
| 50 | return () => { | ||
| 51 | stopped = true; | ||
| 52 | }; | ||
| 53 | }, []); | ||
| 54 | useEffect(() => { | ||
| 55 | if (!enabled || error || attemptedSignIn.current || peekPairingTokenFromUrl()) return; | ||
| 56 | attemptedSignIn.current = true; | ||
| 57 | void beginHostedSignIn(window.location.origin).catch(() => | ||
| 58 | setError("Couldn't start sign-in. Try again."), | ||
| 59 | ); | ||
| 60 | }, [enabled, error]); | ||
| 61 | if (enabled === false || peekPairingTokenFromUrl()) | ||
| 62 | return <LegacyPairingRouteSurface {...props} />; | ||
| 63 | return ( | ||
| 64 | <StandalonePage tone="pairing"> | ||
| 65 | <StandalonePageHeader | ||
| 66 | eyebrow={APP_DISPLAY_NAME} | ||
| 67 | title="Sign in" | ||
| 68 | description="Sign in to connect your devices and sync your chats." | ||
| 69 | /> | ||
| 70 | {enabled === null ? ( | ||
| 71 | <p role="status">Connecting…</p> | ||
| 72 | ) : ( | ||
| 73 | <Button | ||
| 74 | onClick={() => { | ||
| 75 | void beginHostedSignIn(window.location.origin).catch(() => | ||
| 76 | setError("Couldn't start sign-in. Try again."), | ||
| 77 | ); | ||
| 78 | }} | ||
| 79 | > | ||
| 80 | Sign in | ||
| 81 | </Button> | ||
| 82 | )} | ||
| 83 | {error ? <p role="alert">{error}</p> : null} | ||
| 84 | </StandalonePage> | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | |||
| 88 | function LegacyPairingRouteSurface({ | ||
| 32 | auth, | 89 | auth, |
| 33 | initialErrorMessage, | 90 | initialErrorMessage, |
| 34 | onAuthenticated, | 91 | onAuthenticated, |
apps/web/src/components/clerk/BrowserManagedAuthShell.tsx+1-8| ... | @@ -1,15 +1,8 @@ | ... | @@ -1,15 +1,8 @@ |
| 1 | import { ClerkProvider } from "@clerk/react"; | 1 | import { ClerkProvider } from "@clerk/react"; |
| 2 | import type { ReactNode } from "react"; | 2 | import type { ReactNode } from "react"; |
| 3 | 3 | ||
| 4 | import { ManagedRelayAuthProvider } from "../../cloud/managedAuth"; | ||
| 5 | import { clerkAppearance } from "./clerkAppearance"; | 4 | import { clerkAppearance } from "./clerkAppearance"; |
| 6 | 5 | ||
| 7 | /** | ||
| 8 | * Browser half of the managed-auth boundary, loaded lazily from the entry so | ||
| 9 | * cloudless local mode never downloads a Clerk runtime. The browser provider | ||
| 10 | * stays small on its own: it hotloads clerk-js at runtime instead of bundling | ||
| 11 | * it. | ||
| 12 | */ | ||
| 13 | export default function BrowserManagedAuthShell({ | 6 | export default function BrowserManagedAuthShell({ |
| 14 | publishableKey, | 7 | publishableKey, |
| 15 | children, | 8 | children, |
| ... | @@ -19,7 +12,7 @@ export default function BrowserManagedAuthShell({ | ... | @@ -19,7 +12,7 @@ export default function BrowserManagedAuthShell({ |
| 19 | }) { | 12 | }) { |
| 20 | return ( | 13 | return ( |
| 21 | <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey}> | 14 | <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey}> |
| 22 | <ManagedRelayAuthProvider>{children}</ManagedRelayAuthProvider> | 15 | {children} |
| 23 | </ClerkProvider> | 16 | </ClerkProvider> |
| 24 | ); | 17 | ); |
| 25 | } | 18 | } |
apps/web/src/components/clerk/ElectronManagedAuthShell.tsx+2-8| ... | @@ -2,15 +2,9 @@ import { passkeys } from "@clerk/electron/passkeys"; | ... | @@ -2,15 +2,9 @@ import { passkeys } from "@clerk/electron/passkeys"; |
| 2 | import { ClerkProvider } from "@clerk/electron/react"; | 2 | import { ClerkProvider } from "@clerk/electron/react"; |
| 3 | import type { ReactNode } from "react"; | 3 | import type { ReactNode } from "react"; |
| 4 | 4 | ||
| 5 | import { ManagedRelayAuthProvider } from "../../cloud/managedAuth"; | ||
| 6 | import { clerkAppearance } from "./clerkAppearance"; | 5 | import { clerkAppearance } from "./clerkAppearance"; |
| 7 | 6 | ||
| 8 | /** | 7 | /** Loaded after Connect activation to keep Clerk’s bundled runtime out of startup. */ |
| 9 | * Electron half of the managed-auth boundary. The Electron provider statically | ||
| 10 | * bundles the full clerk-js runtime, so this module must only ever load | ||
| 11 | * lazily, and only inside the desktop shell — importing it eagerly would put | ||
| 12 | * clerk-js back into every client's startup graph. | ||
| 13 | */ | ||
| 14 | export default function ElectronManagedAuthShell({ | 8 | export default function ElectronManagedAuthShell({ |
| 15 | publishableKey, | 9 | publishableKey, |
| 16 | children, | 10 | children, |
| ... | @@ -20,7 +14,7 @@ export default function ElectronManagedAuthShell({ | ... | @@ -20,7 +14,7 @@ export default function ElectronManagedAuthShell({ |
| 20 | }) { | 14 | }) { |
| 21 | return ( | 15 | return ( |
| 22 | <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey} passkeys={passkeys}> | 16 | <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey} passkeys={passkeys}> |
| 23 | <ManagedRelayAuthProvider>{children}</ManagedRelayAuthProvider> | 17 | {children} |
| 24 | </ClerkProvider> | 18 | </ClerkProvider> |
| 25 | ); | 19 | ); |
| 26 | } | 20 | } |
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+23-2| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary"; | ||
| 2 | import { activateCloudAuthentication } from "../../cloud/activation"; | ||
| 1 | import { UserButton, useAuth } from "@clerk/react"; | 3 | import { UserButton, useAuth } from "@clerk/react"; |
| 2 | import { LogInIcon } from "lucide-react"; | 4 | import { LogInIcon } from "lucide-react"; |
| 3 | 5 | ||
| ... | @@ -9,13 +11,32 @@ import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; | ... | @@ -9,13 +11,32 @@ import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; |
| 9 | export function T3ConnectSidebarSignIn() { | 11 | export function T3ConnectSidebarSignIn() { |
| 10 | if (!hasCloudPublicConfig()) return null; | 12 | if (!hasCloudPublicConfig()) return null; |
| 11 | 13 | ||
| 12 | return <ConfiguredT3ConnectSidebarSignIn />; | 14 | return ( |
| 15 | <CloudAuthBoundary | ||
| 16 | fallback={ | ||
| 17 | <SidebarMenu> | ||
| 18 | <SidebarMenuItem> | ||
| 19 | <SidebarMenuButton onClick={() => activateCloudAuthentication(true)}> | ||
| 20 | <LogInIcon /> | ||
| 21 | <span>Sign in to T3 Connect</span> | ||
| 22 | </SidebarMenuButton> | ||
| 23 | </SidebarMenuItem> | ||
| 24 | </SidebarMenu> | ||
| 25 | } | ||
| 26 | > | ||
| 27 | <ConfiguredT3ConnectSidebarSignIn /> | ||
| 28 | </CloudAuthBoundary> | ||
| 29 | ); | ||
| 13 | } | 30 | } |
| 14 | 31 | ||
| 15 | export function T3ConnectSidebarAvatar() { | 32 | export function T3ConnectSidebarAvatar() { |
| 16 | if (!hasCloudPublicConfig()) return null; | 33 | if (!hasCloudPublicConfig()) return null; |
| 17 | 34 | ||
| 18 | return <ConfiguredT3ConnectSidebarAvatar />; | 35 | return ( |
| 36 | <CloudAuthBoundary> | ||
| 37 | <ConfiguredT3ConnectSidebarAvatar /> | ||
| 38 | </CloudAuthBoundary> | ||
| 39 | ); | ||
| 19 | } | 40 | } |
| 20 | 41 | ||
| 21 | function ConfiguredT3ConnectSidebarAvatar() { | 42 | function ConfiguredT3ConnectSidebarAvatar() { |
apps/web/src/components/cloud/ConnectCliAuthSurface.tsx+18-1| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary"; | ||
| 2 | import { activateCloudAuthentication } from "../../cloud/activation"; | ||
| 1 | import { useAuth, useClerk } from "@clerk/react"; | 3 | import { useAuth, useClerk } from "@clerk/react"; |
| 2 | import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; | 4 | import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | 5 | import { useCallback, useEffect, useRef, useState } from "react"; |
| ... | @@ -44,7 +46,7 @@ const invalidLinkMessage = { | ... | @@ -44,7 +46,7 @@ const invalidLinkMessage = { |
| 44 | * with the loopback redirect URI so the code returns straight to the waiting | 46 | * with the loopback redirect URI so the code returns straight to the waiting |
| 45 | * CLI. Headless hosts use Clerk's device authorization page instead. | 47 | * CLI. Headless hosts use Clerk's device authorization page instead. |
| 46 | */ | 48 | */ |
| 47 | export function ConnectCliAuthorizeSurface() { | 49 | function ConfiguredConnectCliAuthorizeSurface() { |
| 48 | const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); | 50 | const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); |
| 49 | const clerk = useClerk(); | 51 | const clerk = useClerk(); |
| 50 | const { isLoaded, isSignedIn } = useAuth(); | 52 | const { isLoaded, isSignedIn } = useAuth(); |
| ... | @@ -111,3 +113,18 @@ export function ConnectCliAuthorizeSurface() { | ... | @@ -111,3 +113,18 @@ export function ConnectCliAuthorizeSurface() { |
| 111 | </AuthSurfaceShell> | 113 | </AuthSurfaceShell> |
| 112 | ); | 114 | ); |
| 113 | } | 115 | } |
| 116 | |||
| 117 | export function ConnectCliAuthorizeSurface() { | ||
| 118 | useEffect(() => activateCloudAuthentication(), []); | ||
| 119 | return ( | ||
| 120 | <CloudAuthBoundary | ||
| 121 | fallback={ | ||
| 122 | <AuthSurfaceShell> | ||
| 123 | <p role="status">Loading sign-in…</p> | ||
| 124 | </AuthSurfaceShell> | ||
| 125 | } | ||
| 126 | > | ||
| 127 | <ConfiguredConnectCliAuthorizeSurface /> | ||
| 128 | </CloudAuthBoundary> | ||
| 129 | ); | ||
| 130 | } |
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+7-7| ... | @@ -1,11 +1,7 @@ | ... | @@ -1,11 +1,7 @@ |
| 1 | import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary"; | ||
| 1 | import { useAuth } from "@clerk/react"; | 2 | import { useAuth } from "@clerk/react"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { | 4 | import { AuthRelayReadScope, AuthRelayWriteScope, type AuthSessionState } from "@t3tools/contracts"; |
| 4 | AuthAdministrativeScopes, | ||
| 5 | AuthRelayReadScope, | ||
| 6 | AuthRelayWriteScope, | ||
| 7 | type AuthSessionState, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { AsyncResult, Atom } from "effect/reactivity"; | 5 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 10 | import { useEffect, useRef, useState } from "react"; | 6 | import { useEffect, useRef, useState } from "react"; |
| 11 | 7 | ||
| ... | @@ -43,7 +39,11 @@ import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } fro | ... | @@ -43,7 +39,11 @@ import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } fro |
| 43 | export function ConnectOnboardingDialog() { | 39 | export function ConnectOnboardingDialog() { |
| 44 | if (!hasCloudPublicConfig()) return null; | 40 | if (!hasCloudPublicConfig()) return null; |
| 45 | 41 | ||
| 46 | return <ConfiguredConnectOnboardingDialog />; | 42 | return ( |
| 43 | <CloudAuthBoundary> | ||
| 44 | <ConfiguredConnectOnboardingDialog /> | ||
| 45 | </CloudAuthBoundary> | ||
| 46 | ); | ||
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | type OnboardingStep = "publish" | "devices"; | 49 | type OnboardingStep = "publish" | "devices"; |
apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx+1| ... | @@ -36,6 +36,7 @@ const state = vi.hoisted(() => ({ | ... | @@ -36,6 +36,7 @@ const state = vi.hoisted(() => ({ |
| 36 | refreshScan: vi.fn(), | 36 | refreshScan: vi.fn(), |
| 37 | })); | 37 | })); |
| 38 | 38 | ||
| 39 | vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null })); | ||
| 39 | vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined })); | 40 | vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined })); |
| 40 | vi.mock("@t3tools/client-runtime/state/session", () => ({ | 41 | vi.mock("@t3tools/client-runtime/state/session", () => ({ |
| 41 | createEnvironmentSessionAtoms: () => ({ | 42 | createEnvironmentSessionAtoms: () => ({ |
apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx+1| ... | @@ -49,6 +49,7 @@ const state = vi.hoisted(() => ({ | ... | @@ -49,6 +49,7 @@ const state = vi.hoisted(() => ({ |
| 49 | done: vi.fn(), | 49 | done: vi.fn(), |
| 50 | })); | 50 | })); |
| 51 | 51 | ||
| 52 | vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null })); | ||
| 52 | vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined })); | 53 | vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined })); |
| 53 | vi.mock("@t3tools/client-runtime/state/session", () => ({ | 54 | vi.mock("@t3tools/client-runtime/state/session", () => ({ |
| 54 | createEnvironmentSessionAtoms: () => ({ | 55 | createEnvironmentSessionAtoms: () => ({ |
apps/web/src/components/onboarding/WelcomeWizard.test.tsx+1| ... | @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ |
| 12 | toast: vi.fn(), | 12 | toast: vi.fn(), |
| 13 | projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>, | 13 | projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>, |
| 14 | })); | 14 | })); |
| 15 | vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null })); | ||
| 15 | vi.mock("../../state/session", () => ({ | 16 | vi.mock("../../state/session", () => ({ |
| 16 | useEnvironmentScope: () => true, | 17 | useEnvironmentScope: () => true, |
| 17 | useEnvironmentsWithScope: (environments: Array<{ environmentId: string }>) => | 18 | useEnvironmentsWithScope: (environments: Array<{ environmentId: string }>) => |
apps/web/src/components/onboarding/WelcomeWizard.tsx+42-44| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { RemoteConnectionFlow } from "../settings/SelfHostedServer"; | ||
| 2 | import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary"; | ||
| 1 | import { useAuth } from "@clerk/react"; | 3 | import { useAuth } from "@clerk/react"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 4 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import type { | 5 | import type { |
| ... | @@ -28,7 +30,6 @@ import { | ... | @@ -28,7 +30,6 @@ import { |
| 28 | CheckIcon, | 30 | CheckIcon, |
| 29 | ChevronRightIcon, | 31 | ChevronRightIcon, |
| 30 | CloudIcon, | 32 | CloudIcon, |
| 31 | LinkIcon, | ||
| 32 | MonitorIcon, | 33 | MonitorIcon, |
| 33 | TerminalIcon, | 34 | TerminalIcon, |
| 34 | } from "lucide-react"; | 35 | } from "lucide-react"; |
| ... | @@ -257,7 +258,6 @@ export function WelcomeWizard({ | ... | @@ -257,7 +258,6 @@ export function WelcomeWizard({ |
| 257 | <WizardPanel holdHeight={isLoadingProjects}> | 258 | <WizardPanel holdHeight={isLoadingProjects}> |
| 258 | {step === "connection" ? ( | 259 | {step === "connection" ? ( |
| 259 | <ConnectionStep | 260 | <ConnectionStep |
| 260 | expandPairingInitially={!localAvailable && !hasCloudPublicConfig()} | ||
| 261 | selectedIds={selectedIds} | 261 | selectedIds={selectedIds} |
| 262 | autoSelectedComputers={autoSelectedComputers.current} | 262 | autoSelectedComputers={autoSelectedComputers.current} |
| 263 | onSelectionChange={setSelection} | 263 | onSelectionChange={setSelection} |
| ... | @@ -297,7 +297,6 @@ export function WelcomeWizard({ | ... | @@ -297,7 +297,6 @@ export function WelcomeWizard({ |
| 297 | 297 | ||
| 298 | function ConnectionStep({ | 298 | function ConnectionStep({ |
| 299 | autoSelectedComputers, | 299 | autoSelectedComputers, |
| 300 | expandPairingInitially, | ||
| 301 | selectedIds, | 300 | selectedIds, |
| 302 | onSelectionChange, | 301 | onSelectionChange, |
| 303 | onToggleEnvironment, | 302 | onToggleEnvironment, |
| ... | @@ -305,7 +304,6 @@ function ConnectionStep({ | ... | @@ -305,7 +304,6 @@ function ConnectionStep({ |
| 305 | onPaired, | 304 | onPaired, |
| 306 | }: { | 305 | }: { |
| 307 | readonly autoSelectedComputers: Set<EnvironmentId>; | 306 | readonly autoSelectedComputers: Set<EnvironmentId>; |
| 308 | readonly expandPairingInitially: boolean; | ||
| 309 | readonly selectedIds: ReadonlySet<EnvironmentId>; | 307 | readonly selectedIds: ReadonlySet<EnvironmentId>; |
| 310 | readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId>) => void; | 308 | readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId>) => void; |
| 311 | readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void; | 309 | readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void; |
| ... | @@ -317,7 +315,7 @@ function ConnectionStep({ | ... | @@ -317,7 +315,7 @@ function ConnectionStep({ |
| 317 | const directEnvironments = environments.filter( | 315 | const directEnvironments = environments.filter( |
| 318 | (environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment), | 316 | (environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment), |
| 319 | ); | 317 | ); |
| 320 | const [pairingOpen, setPairingOpen] = useState(expandPairingInitially); | 318 | const [pairingOpen, setPairingOpen] = useState(false); |
| 321 | const [isPairing, setIsPairing] = useState(false); | 319 | const [isPairing, setIsPairing] = useState(false); |
| 322 | const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds); | 320 | const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds); |
| 323 | const skippedLabels = environments | 321 | const skippedLabels = environments |
| ... | @@ -382,47 +380,47 @@ function ConnectionStep({ | ... | @@ -382,47 +380,47 @@ function ConnectionStep({ |
| 382 | </fieldset> | 380 | </fieldset> |
| 383 | ) : null} | 381 | ) : null} |
| 384 | <div className="mt-4 space-y-2"> | 382 | <div className="mt-4 space-y-2"> |
| 385 | {cloudEnabled ? ( | 383 | {!pairingOpen ? ( |
| 386 | <ConnectAccountOption | 384 | <RemoteConnectionFlow |
| 387 | autoSelectedComputers={autoSelectedComputers} | 385 | onDirect={() => setPairingOpen(true)} |
| 388 | disabled={isPairing} | 386 | cloudContent={ |
| 389 | selectedIds={selectedIds} | 387 | <CloudAuthBoundary |
| 390 | onToggleEnvironment={onToggleEnvironment} | 388 | fallback={ |
| 389 | <p role="status" className="text-sm text-muted-foreground"> | ||
| 390 | Loading sign-in… | ||
| 391 | </p> | ||
| 392 | } | ||
| 393 | > | ||
| 394 | <ConnectAccountOption | ||
| 395 | autoSelectedComputers={autoSelectedComputers} | ||
| 396 | disabled={isPairing} | ||
| 397 | selectedIds={selectedIds} | ||
| 398 | onToggleEnvironment={onToggleEnvironment} | ||
| 399 | /> | ||
| 400 | </CloudAuthBoundary> | ||
| 401 | } | ||
| 391 | /> | 402 | /> |
| 392 | ) : null} | 403 | ) : ( |
| 393 | <div className="rounded-lg border border-border bg-background"> | 404 | <div className="space-y-3 rounded-lg border border-border bg-background p-3"> |
| 394 | <Collapsible open={pairingOpen} onOpenChange={setPairingOpen}> | 405 | <Button |
| 395 | <CollapsibleTrigger | 406 | variant="ghost" |
| 407 | size="sm" | ||
| 396 | disabled={isPairing} | 408 | disabled={isPairing} |
| 397 | render={ | 409 | onClick={() => setPairingOpen(false)} |
| 398 | <Button | ||
| 399 | variant="ghost" | ||
| 400 | size="sm-multiline" | ||
| 401 | className="min-h-14 w-full justify-start" | ||
| 402 | /> | ||
| 403 | } | ||
| 404 | > | 410 | > |
| 405 | <LinkIcon className="size-4 text-muted-foreground" /> | 411 | Back |
| 406 | <span className="flex-1 text-left">Add a computer</span> | 412 | </Button> |
| 407 | <ChevronRightIcon | 413 | <PairingForm |
| 408 | className={cn("size-4 text-muted-foreground", pairingOpen && "rotate-90")} | 414 | isPairing={isPairing} |
| 409 | /> | 415 | setIsPairing={setIsPairing} |
| 410 | </CollapsibleTrigger> | 416 | onPaired={(environmentId) => { |
| 411 | <CollapsiblePanel> | 417 | setPairingOpen(false); |
| 412 | <div className="px-3 pb-3"> | 418 | onPaired(environmentId); |
| 413 | <PairingForm | 419 | requestAnimationFrame(() => continueRef.current?.focus()); |
| 414 | isPairing={isPairing} | 420 | }} |
| 415 | setIsPairing={setIsPairing} | 421 | /> |
| 416 | onPaired={(environmentId) => { | 422 | </div> |
| 417 | setPairingOpen(false); | 423 | )} |
| 418 | onPaired(environmentId); | ||
| 419 | requestAnimationFrame(() => continueRef.current?.focus()); | ||
| 420 | }} | ||
| 421 | /> | ||
| 422 | </div> | ||
| 423 | </CollapsiblePanel> | ||
| 424 | </Collapsible> | ||
| 425 | </div> | ||
| 426 | </div> | 424 | </div> |
| 427 | {skippedLabels.length > 0 ? ( | 425 | {skippedLabels.length > 0 ? ( |
| 428 | <p className="mt-4 text-xs text-muted-foreground"> | 426 | <p className="mt-4 text-xs text-muted-foreground"> |
| ... | @@ -447,7 +445,7 @@ function ConnectionStep({ | ... | @@ -447,7 +445,7 @@ function ConnectionStep({ |
| 447 | <Button | 445 | <Button |
| 448 | className="shrink-0 self-end" | 446 | className="shrink-0 self-end" |
| 449 | ref={continueRef} | 447 | ref={continueRef} |
| 450 | autoFocus={!expandPairingInitially} | 448 | autoFocus={ready} |
| 451 | disabled={!ready || isPairing} | 449 | disabled={!ready || isPairing} |
| 452 | onClick={onContinue} | 450 | onClick={onContinue} |
| 453 | > | 451 | > |
apps/web/src/components/settings/ConnectionsSettings.tsx+69-40| ... | @@ -1,3 +1,6 @@ | ... | @@ -1,3 +1,6 @@ |
| 1 | import { RemoteConnectionFlow, SelfHostedServerSettings } from "./SelfHostedServer"; | ||
| 2 | import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary"; | ||
| 3 | import { activateCloudAuthentication } from "../../cloud/activation"; | ||
| 1 | import { SessionPermissions } from "./SessionPermissions"; | 4 | import { SessionPermissions } from "./SessionPermissions"; |
| 2 | import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; | 5 | import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; |
| 3 | import { | 6 | import { |
| ... | @@ -2003,10 +2006,22 @@ function CloudLinkRow({ | ... | @@ -2003,10 +2006,22 @@ function CloudLinkRow({ |
| 2003 | /> | 2006 | /> |
| 2004 | ); | 2007 | ); |
| 2005 | } | 2008 | } |
| 2006 | return <ConfiguredCloudLinkRow canManageRelay={canManageRelay} />; | 2009 | return ( |
| 2010 | <CloudAuthBoundary | ||
| 2011 | fallback={ | ||
| 2012 | <SettingsRow title="T3 Connect" description="Reach your computers through your T3 account."> | ||
| 2013 | <Button variant="outline" size="sm" onClick={() => activateCloudAuthentication(true)}> | ||
| 2014 | Sign in | ||
| 2015 | </Button> | ||
| 2016 | </SettingsRow> | ||
| 2017 | } | ||
| 2018 | > | ||
| 2019 | <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> | ||
| 2020 | </CloudAuthBoundary> | ||
| 2021 | ); | ||
| 2007 | } | 2022 | } |
| 2008 | 2023 | ||
| 2009 | function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { | 2024 | function EmptyRemoteEnvironments() { |
| 2010 | return ( | 2025 | return ( |
| 2011 | <Empty className="min-h-52"> | 2026 | <Empty className="min-h-52"> |
| 2012 | <EmptyMedia variant="icon"> | 2027 | <EmptyMedia variant="icon"> |
| ... | @@ -2014,11 +2029,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable | ... | @@ -2014,11 +2029,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable |
| 2014 | </EmptyMedia> | 2029 | </EmptyMedia> |
| 2015 | <EmptyHeader> | 2030 | <EmptyHeader> |
| 2016 | <EmptyTitle>No saved remote environments</EmptyTitle> | 2031 | <EmptyTitle>No saved remote environments</EmptyTitle> |
| 2017 | <EmptyDescription> | 2032 | <EmptyDescription>Add an environment to connect your computers.</EmptyDescription> |
| 2018 | {cloudEnabled | ||
| 2019 | ? "Click “Add environment” to pair another environment, or connect one from T3 Connect." | ||
| 2020 | : "Click “Add environment” to pair another environment."} | ||
| 2021 | </EmptyDescription> | ||
| 2022 | </EmptyHeader> | 2033 | </EmptyHeader> |
| 2023 | </Empty> | 2034 | </Empty> |
| 2024 | ); | 2035 | ); |
| ... | @@ -2038,12 +2049,13 @@ function CloudRemoteEnvironmentRows({ | ... | @@ -2038,12 +2049,13 @@ function CloudRemoteEnvironmentRows({ |
| 2038 | empty={<EmptyRemoteEnvironments />} | 2049 | empty={<EmptyRemoteEnvironments />} |
| 2039 | /> | 2050 | /> |
| 2040 | ) : savedEnvironments.length === 0 ? ( | 2051 | ) : savedEnvironments.length === 0 ? ( |
| 2041 | <EmptyRemoteEnvironments cloudEnabled={false} /> | 2052 | <EmptyRemoteEnvironments /> |
| 2042 | ) : null; | 2053 | ) : null; |
| 2043 | } | 2054 | } |
| 2044 | 2055 | ||
| 2045 | export function ConnectionsSettings() { | 2056 | export function ConnectionsSettings() { |
| 2046 | const desktopBridge = window.desktopBridge; | 2057 | const desktopBridge = window.desktopBridge; |
| 2058 | const [directPairing, setDirectPairing] = useState(false); | ||
| 2047 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 2059 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 2048 | const { environments } = useEnvironments(); | 2060 | const { environments } = useEnvironments(); |
| 2049 | const primaryEnvironment = usePrimaryEnvironment(); | 2061 | const primaryEnvironment = usePrimaryEnvironment(); |
| ... | @@ -4075,6 +4087,7 @@ export function ConnectionsSettings() { | ... | @@ -4075,6 +4087,7 @@ export function ConnectionsSettings() { |
| 4075 | return ( | 4087 | return ( |
| 4076 | <SettingsPageContainer> | 4088 | <SettingsPageContainer> |
| 4077 | {primarySettings} | 4089 | {primarySettings} |
| 4090 | <SelfHostedServerSettings /> | ||
| 4078 | <SettingsSection | 4091 | <SettingsSection |
| 4079 | {...searchableSetting("remote-environments")} | 4092 | {...searchableSetting("remote-environments")} |
| 4080 | title="Environments" | 4093 | title="Environments" |
| ... | @@ -4089,6 +4102,7 @@ export function ConnectionsSettings() { | ... | @@ -4089,6 +4102,7 @@ export function ConnectionsSettings() { |
| 4089 | setAddBackendDialogOpen(open); | 4102 | setAddBackendDialogOpen(open); |
| 4090 | if (open) { | 4103 | if (open) { |
| 4091 | setRouteTarget(null); | 4104 | setRouteTarget(null); |
| 4105 | setDirectPairing(false); | ||
| 4092 | } else { | 4106 | } else { |
| 4093 | setSavedBackendError(null); | 4107 | setSavedBackendError(null); |
| 4094 | } | 4108 | } |
| ... | @@ -4112,37 +4126,50 @@ export function ConnectionsSettings() { | ... | @@ -4112,37 +4126,50 @@ export function ConnectionsSettings() { |
| 4112 | <DialogPopup className="max-h-[80dvh] sm:max-w-3xl"> | 4126 | <DialogPopup className="max-h-[80dvh] sm:max-w-3xl"> |
| 4113 | <DialogHeader> | 4127 | <DialogHeader> |
| 4114 | <DialogTitle> | 4128 | <DialogTitle> |
| 4115 | {routeTarget ? `Add a route to ${routeTarget.label}` : "Add Environment"} | 4129 | {routeTarget ? `Add a route to ${routeTarget.label}` : "Connect"} |
| 4116 | </DialogTitle> | 4130 | </DialogTitle> |
| 4117 | <DialogDescription> | 4131 | <DialogDescription> |
| 4118 | {routeTarget | 4132 | {routeTarget |
| 4119 | ? "Pair this machine again over another address, such as its Tailscale name. It joins the existing routes instead of adding a second machine." | 4133 | ? "Pair this machine again over another address, such as its Tailscale name. It joins the existing routes instead of adding a second machine." |
| 4120 | : "Pair another environment to this client."} | 4134 | : "Connect to your computers through an account or your own server."} |
| 4121 | </DialogDescription> | 4135 | </DialogDescription> |
| 4122 | </DialogHeader> | 4136 | </DialogHeader> |
| 4123 | <DialogPanel> | 4137 | <DialogPanel> |
| 4124 | <div className="space-y-4"> | 4138 | {!routeTarget && !directPairing ? ( |
| 4125 | <div className="grid gap-3 sm:grid-cols-2"> | 4139 | <RemoteConnectionFlow |
| 4126 | {renderConnectionModeCard({ | 4140 | onDirect={() => setDirectPairing(true)} |
| 4127 | mode: "remote", | 4141 | onConnected={() => setAddBackendDialogOpen(false)} |
| 4128 | title: "Remote link", | 4142 | onCloudSelected={() => setAddBackendDialogOpen(false)} |
| 4129 | description: "Enter a backend host and pairing code.", | 4143 | /> |
| 4130 | icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />, | 4144 | ) : ( |
| 4131 | })} | 4145 | <div className="space-y-4"> |
| 4132 | {desktopBridge | 4146 | {!routeTarget ? ( |
| 4133 | ? renderConnectionModeCard({ | 4147 | <Button variant="ghost" size="sm" onClick={() => setDirectPairing(false)}> |
| 4134 | mode: "ssh", | 4148 | Back |
| 4135 | title: "SSH", | 4149 | </Button> |
| 4136 | description: | 4150 | ) : null} |
| 4137 | "Use local SSH config, agent, and tunnels for the backend.", | 4151 | <div className="grid gap-3 sm:grid-cols-2"> |
| 4138 | icon: <TerminalIcon aria-hidden className="size-4" />, | 4152 | {renderConnectionModeCard({ |
| 4139 | }) | 4153 | mode: "remote", |
| 4140 | : null} | 4154 | title: "Remote link", |
| 4155 | description: "Enter a backend host and pairing code.", | ||
| 4156 | icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />, | ||
| 4157 | })} | ||
| 4158 | {desktopBridge | ||
| 4159 | ? renderConnectionModeCard({ | ||
| 4160 | mode: "ssh", | ||
| 4161 | title: "SSH", | ||
| 4162 | description: | ||
| 4163 | "Use local SSH config, agent, and tunnels for the backend.", | ||
| 4164 | icon: <TerminalIcon aria-hidden className="size-4" />, | ||
| 4165 | }) | ||
| 4166 | : null} | ||
| 4167 | </div> | ||
| 4168 | <AnimatedHeight> | ||
| 4169 | {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} | ||
| 4170 | </AnimatedHeight> | ||
| 4141 | </div> | 4171 | </div> |
| 4142 | <AnimatedHeight> | 4172 | )} |
| 4143 | {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} | ||
| 4144 | </AnimatedHeight> | ||
| 4145 | </div> | ||
| 4146 | </DialogPanel> | 4173 | </DialogPanel> |
| 4147 | </DialogPopup> | 4174 | </DialogPopup> |
| 4148 | </Dialog> | 4175 | </Dialog> |
| ... | @@ -4169,15 +4196,17 @@ export function ConnectionsSettings() { | ... | @@ -4169,15 +4196,17 @@ export function ConnectionsSettings() { |
| 4169 | /> | 4196 | /> |
| 4170 | </SettingsSection> | 4197 | </SettingsSection> |
| 4171 | {hasCloudPublicConfig() ? ( | 4198 | {hasCloudPublicConfig() ? ( |
| 4172 | <RemoveT3ConnectEnvironmentDialog | 4199 | <CloudAuthBoundary> |
| 4173 | environmentLabel={pendingT3ConnectRemoval?.label ?? null} | 4200 | <RemoveT3ConnectEnvironmentDialog |
| 4174 | onCancel={() => setPendingT3ConnectRemoval(null)} | 4201 | environmentLabel={pendingT3ConnectRemoval?.label ?? null} |
| 4175 | onConfirm={() => { | 4202 | onCancel={() => setPendingT3ConnectRemoval(null)} |
| 4176 | if (!pendingT3ConnectRemoval) return; | 4203 | onConfirm={() => { |
| 4177 | setPendingT3ConnectRemoval(null); | 4204 | if (!pendingT3ConnectRemoval) return; |
| 4178 | void removeSavedBackend(pendingT3ConnectRemoval); | 4205 | setPendingT3ConnectRemoval(null); |
| 4179 | }} | 4206 | void removeSavedBackend(pendingT3ConnectRemoval); |
| 4180 | /> | 4207 | }} |
| 4208 | /> | ||
| 4209 | </CloudAuthBoundary> | ||
| 4181 | ) : null} | 4210 | ) : null} |
| 4182 | <LoadBalancingSettings environments={loadBalancingEnvironments} /> | 4211 | <LoadBalancingSettings environments={loadBalancingEnvironments} /> |
| 4183 | <GitHubRoutingSettings environments={loadBalancingEnvironments} /> | 4212 | <GitHubRoutingSettings environments={loadBalancingEnvironments} /> |
apps/web/src/components/settings/SelfHostedServer.test.tsx created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | import { StrictMode, act } from "react"; | ||
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 3 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | const state = vi.hoisted(() => ({ | ||
| 6 | connect: vi.fn(), | ||
| 7 | connection: null, | ||
| 8 | })); | ||
| 9 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => ({ connection: state.connection }) })); | ||
| 10 | vi.mock("../../connection/hub", () => ({ | ||
| 11 | hubConnection: { stateValueAtom: {}, connectAuthenticated: state.connect }, | ||
| 12 | })); | ||
| 13 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.connect })); | ||
| 14 | vi.mock("../../cloud/activation", () => ({ activateCloudAuthentication: vi.fn() })); | ||
| 15 | vi.mock("../../cloud/publicConfig", () => ({ | ||
| 16 | hasCloudPublicConfig: () => false, | ||
| 17 | resolveCloudPublicConfig: () => ({}), | ||
| 18 | })); | ||
| 19 | vi.mock("../../environments/primary/desktopAuth", () => ({ | ||
| 20 | readDesktopPrimaryBearerToken: vi.fn(), | ||
| 21 | })); | ||
| 22 | vi.mock("../../environments/primary/target", () => ({ resolvePrimaryEnvironmentHttpUrl: vi.fn() })); | ||
| 23 | |||
| 24 | vi.mock("./settingsLayout", () => ({ SettingsRow: () => null, SettingsSection: () => null })); | ||
| 25 | |||
| 26 | import { HostedServerAutoConnect } from "./SelfHostedServer"; | ||
| 27 | |||
| 28 | let renderer: ReactTestRenderer | undefined; | ||
| 29 | const server = "https://knife.example.test"; | ||
| 30 | |||
| 31 | beforeEach(() => { | ||
| 32 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 33 | vi.stubGlobal("window", { location: { origin: server } }); | ||
| 34 | state.connect.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); | ||
| 35 | }); | ||
| 36 | |||
| 37 | afterEach(async () => { | ||
| 38 | await act(() => renderer?.unmount()); | ||
| 39 | renderer = undefined; | ||
| 40 | vi.unstubAllGlobals(); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("registers the authenticated web server after StrictMode replaces the first pending effect", async () => { | ||
| 44 | const configuration: Array<(response: Response) => void> = []; | ||
| 45 | const fetch = vi.fn((url: string) => { | ||
| 46 | if (url.endsWith("/oidc/config")) | ||
| 47 | return new Promise<Response>((resolve) => configuration.push(resolve)); | ||
| 48 | return Promise.resolve(Response.json({ accountId: "owner", deviceId: "browser" })); | ||
| 49 | }); | ||
| 50 | vi.stubGlobal("fetch", fetch); | ||
| 51 | await act(() => { | ||
| 52 | renderer = create( | ||
| 53 | <StrictMode> | ||
| 54 | <HostedServerAutoConnect /> | ||
| 55 | </StrictMode>, | ||
| 56 | ); | ||
| 57 | }); | ||
| 58 | expect(configuration).toHaveLength(2); | ||
| 59 | await act(async () => { | ||
| 60 | for (const resolve of configuration) resolve(Response.json({ enabled: true })); | ||
| 61 | }); | ||
| 62 | expect(state.connect.mock.calls).toEqual([[{ httpBaseUrl: server }]]); | ||
| 63 | expect(fetch.mock.calls.filter(([url]) => url.endsWith("/account"))).toHaveLength(1); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("does not register a server when its account response arrives after unmount", async () => { | ||
| 67 | let accountResponse: ((response: Response) => void) | undefined; | ||
| 68 | vi.stubGlobal( | ||
| 69 | "fetch", | ||
| 70 | vi.fn((url: string) => | ||
| 71 | url.endsWith("/oidc/config") | ||
| 72 | ? Promise.resolve(Response.json({ enabled: true })) | ||
| 73 | : new Promise<Response>((resolve) => { | ||
| 74 | accountResponse = resolve; | ||
| 75 | }), | ||
| 76 | ), | ||
| 77 | ); | ||
| 78 | await act(() => { | ||
| 79 | renderer = create(<HostedServerAutoConnect />); | ||
| 80 | }); | ||
| 81 | expect(accountResponse).toBeDefined(); | ||
| 82 | await act(() => renderer?.unmount()); | ||
| 83 | renderer = undefined; | ||
| 84 | await act(async () => { | ||
| 85 | accountResponse!(Response.json({ accountId: "owner" })); | ||
| 86 | }); | ||
| 87 | expect(state.connect).not.toHaveBeenCalled(); | ||
| 88 | }); | ||
apps/web/src/components/settings/SelfHostedServer.tsx created+568| ... | @@ -0,0 +1,568 @@ | ||
| 1 | import { normalizeHostedServer } from "@t3tools/shared/hostedServerUrl"; | ||
| 2 | import { formatBytes } from "../../lib/formatBytes"; | ||
| 3 | import { ArchiveStatus, SelfHostedConnectionStatus } from "@t3tools/contracts"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import { readDesktopPrimaryBearerToken } from "../../environments/primary/desktopAuth"; | ||
| 6 | import { resolvePrimaryEnvironmentHttpUrl } from "../../environments/primary/target"; | ||
| 7 | import type { HostedAuthDevice } from "@t3tools/contracts"; | ||
| 8 | import { | ||
| 9 | beginHostedSignIn, | ||
| 10 | hostedDevices, | ||
| 11 | hostedAuthHeaders, | ||
| 12 | hostedAccountRequest, | ||
| 13 | forgetHostedWebSession, | ||
| 14 | } from "../../connection/hostedAuth"; | ||
| 15 | import { useAtomValue } from "@effect/atom-react"; | ||
| 16 | import { | ||
| 17 | isAtomCommandInterrupted, | ||
| 18 | squashAtomCommandFailure, | ||
| 19 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 20 | import { CloudIcon, LinkIcon, ServerIcon } from "lucide-react"; | ||
| 21 | import { useEffect, useEffectEvent, useId, useRef, useState, type ReactNode } from "react"; | ||
| 22 | |||
| 23 | import { activateCloudAuthentication } from "../../cloud/activation"; | ||
| 24 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; | ||
| 25 | import { hubConnection } from "../../connection/hub"; | ||
| 26 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 27 | import { Button } from "../ui/button"; | ||
| 28 | import { Input } from "../ui/input"; | ||
| 29 | import { Radio, RadioGroup } from "../ui/radio-group"; | ||
| 30 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 31 | |||
| 32 | export function RemoteConnectionFlow({ | ||
| 33 | onDirect, | ||
| 34 | onConnected, | ||
| 35 | onCloudSelected, | ||
| 36 | cloudContent, | ||
| 37 | }: { | ||
| 38 | readonly onDirect: () => void; | ||
| 39 | readonly onConnected?: () => void; | ||
| 40 | readonly onCloudSelected?: () => void; | ||
| 41 | readonly cloudContent?: ReactNode; | ||
| 42 | }) { | ||
| 43 | const cloudAvailable = hasCloudPublicConfig(); | ||
| 44 | const [method, setMethod] = useState("self"); | ||
| 45 | const [step, setStep] = useState<"choose" | "self" | "cloud">("choose"); | ||
| 46 | const headingId = useId(); | ||
| 47 | const choicesRef = useRef<HTMLDivElement>(null); | ||
| 48 | const showChoices = () => { | ||
| 49 | setStep("choose"); | ||
| 50 | requestAnimationFrame(() => | ||
| 51 | choicesRef.current | ||
| 52 | ?.querySelector<HTMLElement>('[role="radio"][aria-checked="true"]') | ||
| 53 | ?.focus(), | ||
| 54 | ); | ||
| 55 | }; | ||
| 56 | if (step === "self") | ||
| 57 | return ( | ||
| 58 | <div className="space-y-4"> | ||
| 59 | <Button variant="ghost" size="sm" onClick={showChoices}> | ||
| 60 | Back | ||
| 61 | </Button> | ||
| 62 | <SelfHostedServerForm | ||
| 63 | onConnected={() => { | ||
| 64 | showChoices(); | ||
| 65 | onConnected?.(); | ||
| 66 | }} | ||
| 67 | /> | ||
| 68 | </div> | ||
| 69 | ); | ||
| 70 | if (step === "cloud") | ||
| 71 | return ( | ||
| 72 | <div className="space-y-4"> | ||
| 73 | <Button variant="ghost" size="sm" onClick={showChoices}> | ||
| 74 | Back | ||
| 75 | </Button> | ||
| 76 | {cloudContent} | ||
| 77 | </div> | ||
| 78 | ); | ||
| 79 | return ( | ||
| 80 | <div className="space-y-4"> | ||
| 81 | <p id={headingId} className="text-sm text-muted-foreground"> | ||
| 82 | Choose how to reach your computers. | ||
| 83 | </p> | ||
| 84 | <RadioGroup | ||
| 85 | ref={choicesRef} | ||
| 86 | value={method} | ||
| 87 | onValueChange={setMethod} | ||
| 88 | aria-labelledby={headingId} | ||
| 89 | > | ||
| 90 | {cloudAvailable ? ( | ||
| 91 | <label className="flex min-h-20 cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-4 py-3"> | ||
| 92 | <CloudIcon aria-hidden className="size-5 shrink-0 text-muted-foreground" /> | ||
| 93 | <span className="min-w-0 flex-1"> | ||
| 94 | <span className="block text-sm font-medium">T3 Connect</span> | ||
| 95 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 96 | Sign in with your T3 account | ||
| 97 | </span> | ||
| 98 | </span> | ||
| 99 | <Radio value="cloud" /> | ||
| 100 | </label> | ||
| 101 | ) : null} | ||
| 102 | <label className="flex min-h-20 cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-4 py-3"> | ||
| 103 | <ServerIcon aria-hidden className="size-5 shrink-0 text-muted-foreground" /> | ||
| 104 | <span className="min-w-0 flex-1"> | ||
| 105 | <span className="block text-sm font-medium">Self-hosted server</span> | ||
| 106 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 107 | Connect to a server you run | ||
| 108 | </span> | ||
| 109 | </span> | ||
| 110 | <Radio value="self" /> | ||
| 111 | </label> | ||
| 112 | </RadioGroup> | ||
| 113 | <div className="flex flex-wrap items-center justify-between gap-3"> | ||
| 114 | <Button variant="ghost" onClick={onDirect}> | ||
| 115 | <LinkIcon aria-hidden className="size-4" /> | ||
| 116 | Other connections | ||
| 117 | </Button> | ||
| 118 | <Button | ||
| 119 | onClick={() => { | ||
| 120 | if (method === "self") setStep("self"); | ||
| 121 | else { | ||
| 122 | activateCloudAuthentication(true); | ||
| 123 | setStep("cloud"); | ||
| 124 | onCloudSelected?.(); | ||
| 125 | } | ||
| 126 | }} | ||
| 127 | > | ||
| 128 | Connect | ||
| 129 | </Button> | ||
| 130 | </div> | ||
| 131 | </div> | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | function SelfHostedServerForm({ onConnected }: { readonly onConnected: () => void }) { | ||
| 136 | const state = useAtomValue(hubConnection.stateValueAtom); | ||
| 137 | const connect = useAtomCommand(hubConnection.connectAuthenticated, { reportFailure: false }); | ||
| 138 | const addressId = useId(); | ||
| 139 | const errorId = useId(); | ||
| 140 | const connected = useEffectEvent(onConnected); | ||
| 141 | const [address, setAddress] = useState(state.connection?.httpBaseUrl ?? ""); | ||
| 142 | const [connecting, setConnecting] = useState(false); | ||
| 143 | const [error, setError] = useState<string | null>(null); | ||
| 144 | |||
| 145 | useEffect(() => { | ||
| 146 | const bridge = window.desktopBridge; | ||
| 147 | if (!connecting || !bridge?.getHostedAuthState) return; | ||
| 148 | let stopped = false; | ||
| 149 | let checking = false; | ||
| 150 | const check = async () => { | ||
| 151 | if (checking) return; | ||
| 152 | checking = true; | ||
| 153 | try { | ||
| 154 | const next = await bridge.getHostedAuthState!(); | ||
| 155 | if (stopped) return; | ||
| 156 | if (next.phase === "error") { | ||
| 157 | setConnecting(false); | ||
| 158 | setError(next.error ?? "Sign-in did not finish. Sign in again."); | ||
| 159 | } else if ( | ||
| 160 | next.phase === "connected" && | ||
| 161 | next.serverUrl === normalizeHostedServer(address.trim()) | ||
| 162 | ) { | ||
| 163 | const result = await connect({ httpBaseUrl: next.serverUrl }); | ||
| 164 | if (stopped) return; | ||
| 165 | setConnecting(false); | ||
| 166 | if (result._tag === "Success") connected(); | ||
| 167 | else if (!isAtomCommandInterrupted(result)) { | ||
| 168 | const cause = squashAtomCommandFailure(result); | ||
| 169 | setError(cause instanceof Error ? cause.message : "Couldn't connect. Sign in again."); | ||
| 170 | } | ||
| 171 | } | ||
| 172 | } catch { | ||
| 173 | if (!stopped) { | ||
| 174 | setConnecting(false); | ||
| 175 | setError("Couldn't finish sign-in. Try again."); | ||
| 176 | } | ||
| 177 | } finally { | ||
| 178 | checking = false; | ||
| 179 | } | ||
| 180 | }; | ||
| 181 | void check(); | ||
| 182 | const timer = window.setInterval(() => void check(), 1000); | ||
| 183 | return () => { | ||
| 184 | stopped = true; | ||
| 185 | window.clearInterval(timer); | ||
| 186 | void bridge.cancelHostedAuth?.(); | ||
| 187 | }; | ||
| 188 | }, [connecting, address, connect]); | ||
| 189 | |||
| 190 | return ( | ||
| 191 | <form | ||
| 192 | className="space-y-4" | ||
| 193 | onSubmit={(event) => { | ||
| 194 | event.preventDefault(); | ||
| 195 | if (connecting) return; | ||
| 196 | setError(null); | ||
| 197 | try { | ||
| 198 | const server = normalizeHostedServer(address.trim()); | ||
| 199 | setConnecting(true); | ||
| 200 | void beginHostedSignIn(server).catch(() => { | ||
| 201 | setConnecting(false); | ||
| 202 | setError("Couldn't start sign-in. Check the server address and try again."); | ||
| 203 | }); | ||
| 204 | } catch { | ||
| 205 | setError("Enter an HTTPS server address."); | ||
| 206 | } | ||
| 207 | }} | ||
| 208 | > | ||
| 209 | <div className="space-y-1.5"> | ||
| 210 | <label htmlFor={addressId} className="text-sm font-medium"> | ||
| 211 | Server address | ||
| 212 | </label> | ||
| 213 | <Input | ||
| 214 | id={addressId} | ||
| 215 | autoFocus | ||
| 216 | inputMode="url" | ||
| 217 | value={address} | ||
| 218 | onChange={(event) => setAddress(event.target.value)} | ||
| 219 | placeholder="https://server.example.net" | ||
| 220 | autoComplete="url" | ||
| 221 | autoCapitalize="none" | ||
| 222 | spellCheck={false} | ||
| 223 | required | ||
| 224 | disabled={connecting} | ||
| 225 | aria-describedby={error ? errorId : undefined} | ||
| 226 | /> | ||
| 227 | </div> | ||
| 228 | {connecting ? ( | ||
| 229 | <p role="status" className="text-sm text-muted-foreground"> | ||
| 230 | Finish signing in in your browser | ||
| 231 | </p> | ||
| 232 | ) : null} | ||
| 233 | {error ? ( | ||
| 234 | <p id={errorId} role="alert" className="text-sm text-destructive"> | ||
| 235 | {error} | ||
| 236 | </p> | ||
| 237 | ) : null} | ||
| 238 | <div className="flex justify-end gap-2"> | ||
| 239 | {connecting && window.desktopBridge ? ( | ||
| 240 | <Button | ||
| 241 | variant="outline" | ||
| 242 | type="button" | ||
| 243 | onClick={() => { | ||
| 244 | setConnecting(false); | ||
| 245 | void window.desktopBridge?.cancelHostedAuth?.(); | ||
| 246 | }} | ||
| 247 | > | ||
| 248 | Cancel | ||
| 249 | </Button> | ||
| 250 | ) : null} | ||
| 251 | <Button type="submit" disabled={connecting || !address.trim()}> | ||
| 252 | {connecting ? "Signing in…" : "Sign in"} | ||
| 253 | </Button> | ||
| 254 | </div> | ||
| 255 | </form> | ||
| 256 | ); | ||
| 257 | } | ||
| 258 | |||
| 259 | export function HostedServerAutoConnect() { | ||
| 260 | const state = useAtomValue(hubConnection.stateValueAtom); | ||
| 261 | const connect = useAtomCommand(hubConnection.connectAuthenticated, { reportFailure: false }); | ||
| 262 | useEffect(() => { | ||
| 263 | if (state.connection !== null) return; | ||
| 264 | let stopped = false; | ||
| 265 | void (async () => { | ||
| 266 | let server: string | null = null; | ||
| 267 | if (window.desktopBridge?.getHostedAuthState) { | ||
| 268 | const current = await window.desktopBridge.getHostedAuthState(); | ||
| 269 | if (current.phase === "connected") server = current.serverUrl; | ||
| 270 | } else { | ||
| 271 | const config = await fetch("/api/auth/oidc/config", { | ||
| 272 | credentials: "same-origin", | ||
| 273 | redirect: "error", | ||
| 274 | }); | ||
| 275 | if (stopped || !config.ok || !(await config.json()).enabled) return; | ||
| 276 | const response = await fetch("/api/auth/account", { | ||
| 277 | credentials: "same-origin", | ||
| 278 | redirect: "error", | ||
| 279 | }); | ||
| 280 | if (response.ok) server = window.location.origin; | ||
| 281 | } | ||
| 282 | if (!stopped && server !== null) await connect({ httpBaseUrl: server }); | ||
| 283 | })().catch(() => undefined); | ||
| 284 | return () => { | ||
| 285 | stopped = true; | ||
| 286 | }; | ||
| 287 | }, [state.connection, connect]); | ||
| 288 | useEffect(() => { | ||
| 289 | const connection = state.connection; | ||
| 290 | if (window.desktopBridge || connection?.authorization !== "hosted") return; | ||
| 291 | const renew = () => { | ||
| 292 | void hostedAuthHeaders(connection.httpBaseUrl).catch(() => undefined); | ||
| 293 | }; | ||
| 294 | renew(); | ||
| 295 | const timer = window.setInterval(renew, 30_000); | ||
| 296 | window.addEventListener("focus", renew); | ||
| 297 | return () => { | ||
| 298 | window.clearInterval(timer); | ||
| 299 | window.removeEventListener("focus", renew); | ||
| 300 | }; | ||
| 301 | }, [state.connection]); | ||
| 302 | return null; | ||
| 303 | } | ||
| 304 | |||
| 305 | const decodeSyncStatus = Schema.decodeUnknownSync(SelfHostedConnectionStatus); | ||
| 306 | const decodeArchiveStatus = Schema.decodeUnknownSync(Schema.toCodecJson(ArchiveStatus)); | ||
| 307 | |||
| 308 | const requestComputerSync = async (path: string, body?: unknown) => { | ||
| 309 | const token = await readDesktopPrimaryBearerToken(); | ||
| 310 | if (!token) throw new Error("This computer is unavailable."); | ||
| 311 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl(path), { | ||
| 312 | method: body === undefined ? "GET" : "POST", | ||
| 313 | headers: { | ||
| 314 | authorization: `Bearer ${token}`, | ||
| 315 | ...(body === undefined ? {} : { "content-type": "application/json" }), | ||
| 316 | }, | ||
| 317 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | ||
| 318 | redirect: "error", | ||
| 319 | }); | ||
| 320 | if (!response.ok) throw new Error("Couldn't load sync status. Try again."); | ||
| 321 | return response; | ||
| 322 | }; | ||
| 323 | |||
| 324 | function ComputerSyncStatus() { | ||
| 325 | const [status, setStatus] = useState<{ configured: boolean; connected: boolean } | null>(null); | ||
| 326 | const [archive, setArchive] = useState<ArchiveStatus | null>(null); | ||
| 327 | const [error, setError] = useState<string | null>(null); | ||
| 328 | const [retrying, setRetrying] = useState(false); | ||
| 329 | useEffect(() => { | ||
| 330 | let stopped = false; | ||
| 331 | let running = false; | ||
| 332 | const refresh = async () => { | ||
| 333 | if (running) return; | ||
| 334 | running = true; | ||
| 335 | try { | ||
| 336 | const [sync, archiveResponse] = await Promise.all([ | ||
| 337 | requestComputerSync("/api/self-hosted/connection").then((response) => response.json()), | ||
| 338 | requestComputerSync("/api/archive/manage", { type: "archive.status" }).then((response) => | ||
| 339 | response.json(), | ||
| 340 | ), | ||
| 341 | ]); | ||
| 342 | if (!stopped) { | ||
| 343 | setStatus(decodeSyncStatus(sync)); | ||
| 344 | setArchive(decodeArchiveStatus(archiveResponse)); | ||
| 345 | setError(null); | ||
| 346 | } | ||
| 347 | } catch { | ||
| 348 | if (!stopped) setError("Couldn't load sync status. Reconnecting…"); | ||
| 349 | } finally { | ||
| 350 | running = false; | ||
| 351 | } | ||
| 352 | }; | ||
| 353 | void refresh(); | ||
| 354 | const timer = window.setInterval(() => void refresh(), 15_000); | ||
| 355 | return () => { | ||
| 356 | stopped = true; | ||
| 357 | window.clearInterval(timer); | ||
| 358 | }; | ||
| 359 | }, []); | ||
| 360 | return ( | ||
| 361 | <> | ||
| 362 | <SettingsRow | ||
| 363 | title="This computer" | ||
| 364 | description={ | ||
| 365 | error ?? | ||
| 366 | (status === null | ||
| 367 | ? "Checking sync…" | ||
| 368 | : !status.configured | ||
| 369 | ? "Connecting to your server…" | ||
| 370 | : !status.connected | ||
| 371 | ? "Reconnecting…" | ||
| 372 | : archive === null | ||
| 373 | ? "Connected" | ||
| 374 | : archive.pending > 0 | ||
| 375 | ? `${archive.pending} chats waiting to sync` | ||
| 376 | : `${archive.offloaded} chats archived`) | ||
| 377 | } | ||
| 378 | control={ | ||
| 379 | archive && (archive.pending > 0 || archive.failures.length > 0) ? ( | ||
| 380 | <Button | ||
| 381 | variant="outline" | ||
| 382 | size="sm" | ||
| 383 | disabled={retrying} | ||
| 384 | onClick={() => { | ||
| 385 | setRetrying(true); | ||
| 386 | void requestComputerSync("/api/archive/manage", { type: "archive.retry" }) | ||
| 387 | .then(async (response) => { | ||
| 388 | setArchive(decodeArchiveStatus(await response.json())); | ||
| 389 | setError(null); | ||
| 390 | }) | ||
| 391 | .catch(() => setError("Couldn't retry sync. Try again.")) | ||
| 392 | .finally(() => setRetrying(false)); | ||
| 393 | }} | ||
| 394 | > | ||
| 395 | {retrying ? "Retrying…" : "Retry sync"} | ||
| 396 | </Button> | ||
| 397 | ) : undefined | ||
| 398 | } | ||
| 399 | /> | ||
| 400 | {archive ? ( | ||
| 401 | <p className="text-xs text-muted-foreground"> | ||
| 402 | {formatBytes(archive.storage.allocatedDatabaseBytes + archive.storage.attachmentsBytes)}{" "} | ||
| 403 | local archive storage. {formatBytes(archive.storage.nativeProviderLogBytes)} provider logs | ||
| 404 | retained. | ||
| 405 | {archive.lastReclamation | ||
| 406 | ? ` Last cleanup freed ${formatBytes(Math.max(0, archive.lastReclamation.beforeAllocatedBytes - archive.lastReclamation.afterAllocatedBytes))}.` | ||
| 407 | : ""} | ||
| 408 | {archive.queue.length > 0 | ||
| 409 | ? ` Next retry ${new Date(Math.min(...archive.queue.map((item) => item.nextAttemptAt))).toLocaleTimeString()}.` | ||
| 410 | : ""} | ||
| 411 | </p> | ||
| 412 | ) : null} | ||
| 413 | </> | ||
| 414 | ); | ||
| 415 | } | ||
| 416 | |||
| 417 | export function SelfHostedServerSettings() { | ||
| 418 | const state = useAtomValue(hubConnection.stateValueAtom); | ||
| 419 | const refresh = useAtomCommand(hubConnection.refresh, { reportFailure: false }); | ||
| 420 | const disconnect = useAtomCommand(hubConnection.disconnect, { reportFailure: false }); | ||
| 421 | const [signingIn, setSigningIn] = useState(false); | ||
| 422 | const [deviceList, setDeviceList] = useState<{ | ||
| 423 | serverUrl: string; | ||
| 424 | items: readonly HostedAuthDevice[]; | ||
| 425 | } | null>(null); | ||
| 426 | const [busy, setBusy] = useState(false); | ||
| 427 | const [error, setError] = useState<string | null>(null); | ||
| 428 | const serverUrl = state.connection?.httpBaseUrl; | ||
| 429 | const hosted = state.connection?.authorization === "hosted"; | ||
| 430 | useEffect(() => { | ||
| 431 | if (!hosted || !serverUrl) return; | ||
| 432 | let stopped = false; | ||
| 433 | void hostedDevices(serverUrl) | ||
| 434 | .then((items) => { | ||
| 435 | if (!stopped) setDeviceList({ serverUrl, items }); | ||
| 436 | }) | ||
| 437 | .catch(() => { | ||
| 438 | if (!stopped) setError("Couldn't load connected devices. Sign in again to reconnect."); | ||
| 439 | }); | ||
| 440 | return () => { | ||
| 441 | stopped = true; | ||
| 442 | }; | ||
| 443 | }, [serverUrl, hosted]); | ||
| 444 | const devices = deviceList !== null && deviceList.serverUrl === serverUrl ? deviceList.items : []; | ||
| 445 | if (state.connection === null) return null; | ||
| 446 | const run = async (command: typeof refresh) => { | ||
| 447 | if (busy) return; | ||
| 448 | setBusy(true); | ||
| 449 | setError(null); | ||
| 450 | const result = await command(); | ||
| 451 | setBusy(false); | ||
| 452 | if (result._tag !== "Success" && !isAtomCommandInterrupted(result)) { | ||
| 453 | const cause = squashAtomCommandFailure(result); | ||
| 454 | setError( | ||
| 455 | cause instanceof Error ? cause.message : "Couldn't update this connection. Try again.", | ||
| 456 | ); | ||
| 457 | } | ||
| 458 | }; | ||
| 459 | return ( | ||
| 460 | <SettingsSection title="Self-hosted server"> | ||
| 461 | <SettingsRow | ||
| 462 | title={state.connection.label} | ||
| 463 | description={state.connection.httpBaseUrl} | ||
| 464 | control={ | ||
| 465 | <div className="flex flex-wrap gap-2"> | ||
| 466 | {hosted && (state.error || error) ? ( | ||
| 467 | <Button | ||
| 468 | variant="outline" | ||
| 469 | size="sm" | ||
| 470 | disabled={busy} | ||
| 471 | onClick={() => setSigningIn(true)} | ||
| 472 | > | ||
| 473 | Sign in | ||
| 474 | </Button> | ||
| 475 | ) : null} | ||
| 476 | <Button | ||
| 477 | variant="outline" | ||
| 478 | size="sm" | ||
| 479 | disabled={busy || state.refreshing} | ||
| 480 | onClick={() => void run(refresh)} | ||
| 481 | > | ||
| 482 | {state.refreshing ? "Refreshing…" : "Refresh"} | ||
| 483 | </Button> | ||
| 484 | <Button | ||
| 485 | variant="outline" | ||
| 486 | size="sm" | ||
| 487 | disabled={busy} | ||
| 488 | onClick={() => { | ||
| 489 | if (!hosted || !serverUrl) { | ||
| 490 | void run(disconnect); | ||
| 491 | return; | ||
| 492 | } | ||
| 493 | setBusy(true); | ||
| 494 | void hostedAccountRequest(serverUrl, "/api/auth/logout", "POST") | ||
| 495 | .then(async () => { | ||
| 496 | await window.desktopBridge?.disconnectHostedAuth?.(); | ||
| 497 | forgetHostedWebSession(); | ||
| 498 | await run(disconnect); | ||
| 499 | }) | ||
| 500 | .catch(() => setError("Couldn't sign out. Try again.")) | ||
| 501 | .finally(() => setBusy(false)); | ||
| 502 | }} | ||
| 503 | > | ||
| 504 | Disconnect | ||
| 505 | </Button> | ||
| 506 | </div> | ||
| 507 | } | ||
| 508 | /> | ||
| 509 | {signingIn ? ( | ||
| 510 | <SelfHostedServerForm | ||
| 511 | onConnected={() => { | ||
| 512 | setSigningIn(false); | ||
| 513 | setError(null); | ||
| 514 | if (serverUrl) | ||
| 515 | void hostedDevices(serverUrl) | ||
| 516 | .then((items) => setDeviceList({ serverUrl, items })) | ||
| 517 | .catch(() => undefined); | ||
| 518 | }} | ||
| 519 | /> | ||
| 520 | ) : null} | ||
| 521 | {hosted && window.desktopBridge ? <ComputerSyncStatus /> : null} | ||
| 522 | {hosted | ||
| 523 | ? devices | ||
| 524 | .filter((device) => device.revokedAt === null) | ||
| 525 | .map(({ deviceId, current, client: presentation }) => ( | ||
| 526 | <SettingsRow | ||
| 527 | key={deviceId} | ||
| 528 | title={presentation.label ?? "Connected device"} | ||
| 529 | description={current ? "This device" : "Signed in"} | ||
| 530 | control={ | ||
| 531 | <Button | ||
| 532 | variant="outline" | ||
| 533 | size="sm" | ||
| 534 | disabled={busy} | ||
| 535 | onClick={() => { | ||
| 536 | if (!serverUrl) return; | ||
| 537 | setBusy(true); | ||
| 538 | void hostedAccountRequest( | ||
| 539 | serverUrl, | ||
| 540 | `/api/auth/devices/${encodeURIComponent(deviceId)}/revoke`, | ||
| 541 | "POST", | ||
| 542 | ) | ||
| 543 | .then(async () => { | ||
| 544 | if (current) { | ||
| 545 | await window.desktopBridge?.disconnectHostedAuth?.(); | ||
| 546 | forgetHostedWebSession(); | ||
| 547 | await disconnect(); | ||
| 548 | } else | ||
| 549 | setDeviceList({ serverUrl, items: await hostedDevices(serverUrl) }); | ||
| 550 | }) | ||
| 551 | .catch(() => setError("Couldn't revoke this device. Try again.")) | ||
| 552 | .finally(() => setBusy(false)); | ||
| 553 | }} | ||
| 554 | > | ||
| 555 | Revoke | ||
| 556 | </Button> | ||
| 557 | } | ||
| 558 | /> | ||
| 559 | )) | ||
| 560 | : null} | ||
| 561 | {error || state.error ? ( | ||
| 562 | <p role="alert" className="px-4 py-3 text-sm text-destructive"> | ||
| 563 | {error ?? state.error} | ||
| 564 | </p> | ||
| 565 | ) : null} | ||
| 566 | </SettingsSection> | ||
| 567 | ); | ||
| 568 | } | ||
apps/web/src/components/settings/pairingUrls.test.ts+6| ... | @@ -14,6 +14,12 @@ describe("settings pairing URL helpers", () => { | ... | @@ -14,6 +14,12 @@ describe("settings pairing URL helpers", () => { |
| 14 | ); | 14 | ); |
| 15 | }); | 15 | }); |
| 16 | 16 | ||
| 17 | it("keeps the environment prefix in direct pairing links", () => { | ||
| 18 | expect( | ||
| 19 | resolveDesktopPairingUrl("https://hub.example/prefix/environments/one/", "PAIRCODE"), | ||
| 20 | ).toBe("https://hub.example/prefix/environments/one/pair#token=PAIRCODE"); | ||
| 21 | }); | ||
| 22 | |||
| 17 | it("uses hosted pairing URLs for HTTPS endpoints", () => { | 23 | it("uses hosted pairing URLs for HTTPS endpoints", () => { |
| 18 | vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); | 24 | vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); |
| 19 | 25 |
apps/web/src/components/settings/pairingUrls.ts+2-2| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import { environmentEndpointUrl } from "@t3tools/shared/advertisedEndpoint"; | ||
| 1 | import { buildHostedPairingUrl } from "../../hostedPairing"; | 2 | import { buildHostedPairingUrl } from "../../hostedPairing"; |
| 2 | import { setPairingTokenOnUrl } from "../../pairingUrl"; | 3 | import { setPairingTokenOnUrl } from "../../pairingUrl"; |
| 3 | 4 | ||
| 4 | export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { | 5 | export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { |
| 5 | const url = new URL(endpointUrl); | 6 | const url = new URL(environmentEndpointUrl(endpointUrl, "/pair")); |
| 6 | url.pathname = "/pair"; | ||
| 7 | return setPairingTokenOnUrl(url, credential).toString(); | 7 | return setPairingTokenOnUrl(url, credential).toString(); |
| 8 | } | 8 | } |
| 9 | 9 |
apps/web/src/components/settings/settingsSearch.ts+3-1| ... | @@ -998,7 +998,9 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -998,7 +998,9 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 998 | id: "remote-environments", | 998 | id: "remote-environments", |
| 999 | title: "Environments", | 999 | title: "Environments", |
| 1000 | to: "/settings/connections", | 1000 | to: "/settings/connections", |
| 1001 | searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], | 1001 | searchTerms: [ |
| 1002 | "add pair backend host code ssh config agent tunnel saved t3 connect self hosted self-hosted server hub", | ||
| 1003 | ], | ||
| 1002 | }, | 1004 | }, |
| 1003 | { | 1005 | { |
| 1004 | id: "load-balancing", | 1006 | id: "load-balancing", |
apps/web/src/components/threadActionMenu.logic.test.ts+18-1| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | ||
| 9 | const baseState: ThreadActionMenuState = { | 9 | const baseState: ThreadActionMenuState = { |
| 10 | canOperate: true, | 10 | canOperate: true, |
| 11 | canOrganize: true, | ||
| 11 | branch: null, | 12 | branch: null, |
| 12 | projectFilter: null, | 13 | projectFilter: null, |
| 13 | isPinned: false, | 14 | isPinned: false, |
| ... | @@ -46,6 +47,7 @@ describe("buildThreadActionMenuItems", () => { | ... | @@ -46,6 +47,7 @@ describe("buildThreadActionMenuItems", () => { |
| 46 | const items = buildThreadActionMenuItems({ | 47 | const items = buildThreadActionMenuItems({ |
| 47 | ...baseState, | 48 | ...baseState, |
| 48 | canOperate: false, | 49 | canOperate: false, |
| 50 | canOrganize: false, | ||
| 49 | isPinned: reversed, | 51 | isPinned: reversed, |
| 50 | isSettled: reversed, | 52 | isSettled: reversed, |
| 51 | isSnoozed: reversed, | 53 | isSnoozed: reversed, |
| ... | @@ -80,7 +82,12 @@ describe("buildThreadActionMenuItems", () => { | ... | @@ -80,7 +82,12 @@ describe("buildThreadActionMenuItems", () => { |
| 80 | ); | 82 | ); |
| 81 | 83 | ||
| 82 | it("preserves local actions and restores mutations after a grant", () => { | 84 | it("preserves local actions and restores mutations after a grant", () => { |
| 83 | const denied = buildThreadActionMenuItems({ ...baseState, canOperate: false, branch: "main" }); | 85 | const denied = buildThreadActionMenuItems({ |
| 86 | ...baseState, | ||
| 87 | canOperate: false, | ||
| 88 | canOrganize: false, | ||
| 89 | branch: "main", | ||
| 90 | }); | ||
| 84 | expect(denied.filter((item) => !item.disabled).map((item) => item.id)).toEqual([ | 91 | expect(denied.filter((item) => !item.disabled).map((item) => item.id)).toEqual([ |
| 85 | "new-thread-on-branch", | 92 | "new-thread-on-branch", |
| 86 | "mark-unread", | 93 | "mark-unread", |
| ... | @@ -244,3 +251,13 @@ describe("buildDraftActionMenuItems", () => { | ... | @@ -244,3 +251,13 @@ describe("buildDraftActionMenuItems", () => { |
| 244 | expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true }); | 251 | expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true }); |
| 245 | }); | 252 | }); |
| 246 | }); | 253 | }); |
| 254 | |||
| 255 | it("permits central organization while execution and deletion remain disabled", () => { | ||
| 256 | const items = buildThreadActionMenuItems({ ...baseState, canOperate: false, canOrganize: true }); | ||
| 257 | for (const id of ["pin", "settle", "snooze", "auto-settle"]) { | ||
| 258 | expect(items.find((item) => item.id === id)?.disabled).not.toBe(true); | ||
| 259 | } | ||
| 260 | for (const id of ["rename", "regenerate-title", "archive", "delete"]) { | ||
| 261 | expect(items.find((item) => item.id === id)?.disabled).toBe(true); | ||
| 262 | } | ||
| 263 | }); |
apps/web/src/components/threadActionMenu.logic.ts+21-13| ... | @@ -71,6 +71,7 @@ export function buildDraftActionMenuItems(options: { | ... | @@ -71,6 +71,7 @@ export function buildDraftActionMenuItems(options: { |
| 71 | 71 | ||
| 72 | export interface ThreadActionMenuState { | 72 | export interface ThreadActionMenuState { |
| 73 | readonly canOperate: boolean; | 73 | readonly canOperate: boolean; |
| 74 | readonly canOrganize: boolean; | ||
| 74 | readonly branch: string | null; | 75 | readonly branch: string | null; |
| 75 | /** | 76 | /** |
| 76 | * Project scoping for the thread list. Null on surfaces with no scoped | 77 | * Project scoping for the thread list. Null on surfaces with no scoped |
| ... | @@ -114,6 +115,14 @@ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean | ... | @@ -114,6 +115,14 @@ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean |
| 114 | ].includes(action); | 115 | ].includes(action); |
| 115 | } | 116 | } |
| 116 | 117 | ||
| 118 | export function threadActionIsOrganization(action: ThreadActionMenuId): boolean { | ||
| 119 | return ( | ||
| 120 | ["pin", "unpin", "settle", "unsettle", "unsnooze", "snooze", "auto-settle"].includes(action) || | ||
| 121 | action.startsWith("snooze:") || | ||
| 122 | action.startsWith("auto-settle:") | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 117 | /** | 126 | /** |
| 118 | * Single source for the per-thread action menu: the sidebar row's right-click | 127 | * Single source for the per-thread action menu: the sidebar row's right-click |
| 119 | * menu and the chat header menu share labels, ordering, and capability gating. | 128 | * menu and the chat header menu share labels, ordering, and capability gating. |
| ... | @@ -249,17 +258,16 @@ export function buildThreadActionMenuItems( | ... | @@ -249,17 +258,16 @@ export function buildThreadActionMenuItems( |
| 249 | icon: "trash", | 258 | icon: "trash", |
| 250 | }, | 259 | }, |
| 251 | ]; | 260 | ]; |
| 252 | return state.canOperate | 261 | return items.map((item) => |
| 253 | ? items | 262 | threadActionRequiresOperate(item.id) && |
| 254 | : items.map((item) => | 263 | !(threadActionIsOrganization(item.id) ? state.canOrganize : state.canOperate) |
| 255 | threadActionRequiresOperate(item.id) | 264 | ? { |
| 256 | ? { | 265 | ...item, |
| 257 | ...item, | 266 | disabled: true, |
| 258 | disabled: true, | 267 | ...(item.children |
| 259 | ...(item.children | 268 | ? { children: item.children.map((child) => ({ ...child, disabled: true })) } |
| 260 | ? { children: item.children.map((child) => ({ ...child, disabled: true })) } | 269 | : {}), |
| 261 | : {}), | 270 | } |
| 262 | } | 271 | : item, |
| 263 | : item, | 272 | ); |
| 264 | ); | ||
| 265 | } | 273 | } |
apps/web/src/connection/hostedAuth.test.ts created+201| ... | @@ -0,0 +1,201 @@ | ||
| 1 | import { normalizeHostedServer } from "@t3tools/shared/hostedServerUrl"; | ||
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | import { forgetHostedWebSession, hostedAuthHeaders } from "./hostedAuth"; | ||
| 4 | |||
| 5 | afterEach(() => { | ||
| 6 | forgetHostedWebSession(); | ||
| 7 | vi.unstubAllGlobals(); | ||
| 8 | vi.useRealTimers(); | ||
| 9 | }); | ||
| 10 | |||
| 11 | const server = "https://knife.example.test"; | ||
| 12 | const account = (csrfToken: string, expiresAt: string) => ({ | ||
| 13 | accountId: "owner", | ||
| 14 | deviceId: "browser", | ||
| 15 | csrfToken, | ||
| 16 | expiresAt, | ||
| 17 | }); | ||
| 18 | |||
| 19 | describe("hosted account client custody", () => { | ||
| 20 | it("uses same-origin cookies and renews with CSRF without requesting a bearer token", async () => { | ||
| 21 | vi.useFakeTimers(); | ||
| 22 | vi.setSystemTime(new Date("2026-10-17T00:00:00Z")); | ||
| 23 | vi.stubGlobal("window", { location: { origin: server } }); | ||
| 24 | const fetch = vi | ||
| 25 | .fn() | ||
| 26 | .mockResolvedValueOnce(Response.json(account("first-csrf", "2026-10-17T00:00:30Z"))) | ||
| 27 | .mockResolvedValueOnce(Response.json(account("renewed-csrf", "2026-10-17T01:00:00Z"))); | ||
| 28 | vi.stubGlobal("fetch", fetch); | ||
| 29 | expect(await hostedAuthHeaders(server)).toEqual({ "x-csrf-token": "first-csrf" }); | ||
| 30 | expect(await hostedAuthHeaders(server)).toEqual({ "x-csrf-token": "renewed-csrf" }); | ||
| 31 | expect(fetch.mock.calls[0]).toEqual([ | ||
| 32 | `${server}/api/auth/account`, | ||
| 33 | { credentials: "same-origin", redirect: "error" }, | ||
| 34 | ]); | ||
| 35 | expect(fetch.mock.calls[1]).toEqual([ | ||
| 36 | `${server}/api/auth/session/refresh`, | ||
| 37 | { | ||
| 38 | method: "POST", | ||
| 39 | credentials: "same-origin", | ||
| 40 | redirect: "error", | ||
| 41 | headers: { "x-csrf-token": "first-csrf" }, | ||
| 42 | }, | ||
| 43 | ]); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("shares concurrent browser renewal requests", async () => { | ||
| 47 | vi.useFakeTimers(); | ||
| 48 | vi.setSystemTime(new Date("2026-10-17T00:00:00Z")); | ||
| 49 | vi.stubGlobal("window", { location: { origin: server } }); | ||
| 50 | const fetch = vi | ||
| 51 | .fn() | ||
| 52 | .mockResolvedValueOnce(Response.json(account("first", "2026-10-17T00:15:00Z"))) | ||
| 53 | .mockResolvedValueOnce(Response.json(account("renewed", "2026-10-17T00:29:30Z"))); | ||
| 54 | vi.stubGlobal("fetch", fetch); | ||
| 55 | await hostedAuthHeaders(server); | ||
| 56 | vi.setSystemTime(new Date("2026-10-17T00:14:30Z")); | ||
| 57 | expect(await Promise.all([hostedAuthHeaders(server), hostedAuthHeaders(server)])).toEqual([ | ||
| 58 | { "x-csrf-token": "renewed" }, | ||
| 59 | { "x-csrf-token": "renewed" }, | ||
| 60 | ]); | ||
| 61 | expect(fetch).toHaveBeenCalledTimes(2); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("recovers a second tab's stale CSRF after another tab rotates the shared cookie", async () => { | ||
| 65 | vi.useFakeTimers(); | ||
| 66 | vi.setSystemTime(new Date("2026-10-17T00:00:00Z")); | ||
| 67 | vi.stubGlobal("window", { location: { origin: server } }); | ||
| 68 | let session = 1; | ||
| 69 | const calls: Array<{ path: string; csrf: string | null }> = []; | ||
| 70 | vi.stubGlobal( | ||
| 71 | "fetch", | ||
| 72 | vi.fn(async (url: string, init: RequestInit) => { | ||
| 73 | const path = new URL(url).pathname; | ||
| 74 | const csrf = new Headers(init.headers).get("x-csrf-token"); | ||
| 75 | calls.push({ path, csrf }); | ||
| 76 | if (path === "/api/auth/account") | ||
| 77 | return Response.json(account(`opaque-${session}`, "2026-10-17T00:15:00Z")); | ||
| 78 | if (csrf !== `opaque-${session}`) return new Response(null, { status: 401 }); | ||
| 79 | if (path === "/api/auth/session/refresh") { | ||
| 80 | session++; | ||
| 81 | return Response.json(account(`opaque-${session}`, "2026-10-17T00:30:00Z")); | ||
| 82 | } | ||
| 83 | return Response.json({ revoked: true }); | ||
| 84 | }), | ||
| 85 | ); | ||
| 86 | vi.resetModules(); | ||
| 87 | const firstTab = await import("./hostedAuth"); | ||
| 88 | vi.resetModules(); | ||
| 89 | const secondTab = await import("./hostedAuth"); | ||
| 90 | expect(firstTab).not.toBe(secondTab); | ||
| 91 | await firstTab.hostedAuthHeaders(server); | ||
| 92 | await secondTab.hostedAuthHeaders(server); | ||
| 93 | await firstTab.hostedAccountRequest(server, "/api/auth/session/refresh", "POST"); | ||
| 94 | expect( | ||
| 95 | (await secondTab.hostedAccountRequest(server, "/api/auth/devices/other/revoke", "POST")).ok, | ||
| 96 | ).toBe(true); | ||
| 97 | expect(calls.slice(-3)).toEqual([ | ||
| 98 | { path: "/api/auth/devices/other/revoke", csrf: "opaque-1" }, | ||
| 99 | { path: "/api/auth/account", csrf: null }, | ||
| 100 | { path: "/api/auth/devices/other/revoke", csrf: "opaque-2" }, | ||
| 101 | ]); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("serializes each tab's concurrent renewals and repairs a shared-cookie rotation once", async () => { | ||
| 105 | vi.useFakeTimers(); | ||
| 106 | vi.setSystemTime(new Date("2026-10-17T00:00:00Z")); | ||
| 107 | vi.stubGlobal("window", { location: { origin: server } }); | ||
| 108 | let session = 1; | ||
| 109 | const refreshProofs: string[] = []; | ||
| 110 | const reads: number[] = []; | ||
| 111 | vi.stubGlobal( | ||
| 112 | "fetch", | ||
| 113 | vi.fn(async (url: string, init: RequestInit) => { | ||
| 114 | if (new URL(url).pathname === "/api/auth/account") { | ||
| 115 | reads.push(session); | ||
| 116 | return Response.json( | ||
| 117 | account( | ||
| 118 | `opaque-${session}`, | ||
| 119 | session === 1 ? "2026-10-17T00:15:00Z" : "2026-10-17T00:30:00Z", | ||
| 120 | ), | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | const csrf = new Headers(init.headers).get("x-csrf-token")!; | ||
| 124 | refreshProofs.push(csrf); | ||
| 125 | if (csrf !== `opaque-${session}`) return new Response(null, { status: 401 }); | ||
| 126 | session++; | ||
| 127 | return Response.json(account(`opaque-${session}`, "2026-10-17T00:30:00Z")); | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | vi.resetModules(); | ||
| 131 | const firstTab = await import("./hostedAuth"); | ||
| 132 | vi.resetModules(); | ||
| 133 | const secondTab = await import("./hostedAuth"); | ||
| 134 | await Promise.all([firstTab.hostedAuthHeaders(server), secondTab.hostedAuthHeaders(server)]); | ||
| 135 | vi.setSystemTime(new Date("2026-10-17T00:14:30Z")); | ||
| 136 | const results = await Promise.all([ | ||
| 137 | firstTab.hostedAuthHeaders(server), | ||
| 138 | firstTab.hostedAuthHeaders(server), | ||
| 139 | secondTab.hostedAuthHeaders(server), | ||
| 140 | secondTab.hostedAuthHeaders(server), | ||
| 141 | ]); | ||
| 142 | expect(results[0]).toEqual(results[1]); | ||
| 143 | expect(results[2]).toEqual(results[3]); | ||
| 144 | expect(refreshProofs).toEqual(["opaque-1", "opaque-1", "opaque-2"]); | ||
| 145 | expect(reads).toEqual([1, 1, 2]); | ||
| 146 | expect(results[2]).toEqual({ "x-csrf-token": "opaque-3" }); | ||
| 147 | }); | ||
| 148 | |||
| 149 | it("bounds expired-cookie recovery to one metadata request without repeating a mutation", async () => { | ||
| 150 | vi.stubGlobal("window", { location: { origin: server } }); | ||
| 151 | const fetch = vi | ||
| 152 | .fn() | ||
| 153 | .mockResolvedValueOnce(Response.json(account("expired-proof", "2035-01-01T00:00:00Z"))) | ||
| 154 | .mockResolvedValueOnce(new Response(null, { status: 401 })) | ||
| 155 | .mockResolvedValueOnce(new Response(null, { status: 401 })); | ||
| 156 | vi.stubGlobal("fetch", fetch); | ||
| 157 | vi.resetModules(); | ||
| 158 | const tab = await import("./hostedAuth"); | ||
| 159 | await expect( | ||
| 160 | tab.hostedAccountRequest(server, "/api/auth/devices/other/revoke", "POST"), | ||
| 161 | ).rejects.toThrow("Sign in"); | ||
| 162 | expect(fetch).toHaveBeenCalledTimes(3); | ||
| 163 | }); | ||
| 164 | |||
| 165 | it("never sends browser cookies or CSRF to another origin", async () => { | ||
| 166 | vi.stubGlobal("window", { location: { origin: server } }); | ||
| 167 | const fetch = vi.fn(); | ||
| 168 | vi.stubGlobal("fetch", fetch); | ||
| 169 | await expect(hostedAuthHeaders("https://other.example.test")).rejects.toThrow( | ||
| 170 | "Open this server", | ||
| 171 | ); | ||
| 172 | expect(fetch).not.toHaveBeenCalled(); | ||
| 173 | }); | ||
| 174 | |||
| 175 | it("obtains ephemeral native access from main and refreshes it on each request", async () => { | ||
| 176 | const getHostedAccessToken = vi | ||
| 177 | .fn() | ||
| 178 | .mockResolvedValueOnce("ephemeral-one") | ||
| 179 | .mockResolvedValueOnce("ephemeral-two"); | ||
| 180 | vi.stubGlobal("window", { | ||
| 181 | location: { origin: "http://localhost:3773" }, | ||
| 182 | desktopBridge: { getHostedAccessToken }, | ||
| 183 | }); | ||
| 184 | const fetch = vi.fn(); | ||
| 185 | vi.stubGlobal("fetch", fetch); | ||
| 186 | expect(await hostedAuthHeaders(server)).toEqual({ authorization: "Bearer ephemeral-one" }); | ||
| 187 | expect(await hostedAuthHeaders(server)).toEqual({ authorization: "Bearer ephemeral-two" }); | ||
| 188 | expect(fetch).not.toHaveBeenCalled(); | ||
| 189 | expect(getHostedAccessToken.mock.calls).toEqual([[server], [server]]); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it.each([ | ||
| 193 | "http://knife.example.test", | ||
| 194 | `${server}/path`, | ||
| 195 | "https://user:secret@knife.example.test", | ||
| 196 | `${server}?token=secret`, | ||
| 197 | `${server}#token`, | ||
| 198 | ])("rejects unsafe server addresses: %s", (value) => { | ||
| 199 | expect(() => normalizeHostedServer(value)).toThrow(); | ||
| 200 | }); | ||
| 201 | }); | ||
apps/web/src/connection/hostedAuth.ts created+112| ... | @@ -0,0 +1,112 @@ | ||
| 1 | import { HostedAuthAccount, HostedAuthDevice } from "@t3tools/contracts"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | const decodeAccount = Schema.decodeUnknownSync(Schema.toCodecJson(HostedAuthAccount)); | ||
| 6 | const decodeDevices = Schema.decodeUnknownSync(Schema.toCodecJson(Schema.Array(HostedAuthDevice))); | ||
| 7 | |||
| 8 | let account: { server: string; value: HostedAuthAccount } | null = null; | ||
| 9 | let browserRequest: { server: string; value: Promise<HostedAuthAccount> } | null = null; | ||
| 10 | |||
| 11 | export async function hostedAuthHeaders( | ||
| 12 | server: string, | ||
| 13 | rejectedCsrf?: string, | ||
| 14 | ): Promise<Record<string, string>> { | ||
| 15 | const desktop = window.desktopBridge; | ||
| 16 | if (desktop?.getHostedAccessToken) { | ||
| 17 | return { authorization: `Bearer ${await desktop.getHostedAccessToken(server)}` }; | ||
| 18 | } | ||
| 19 | if (new URL(server).origin !== window.location.origin) { | ||
| 20 | throw new Error("Open this server in your browser to sign in."); | ||
| 21 | } | ||
| 22 | if ( | ||
| 23 | rejectedCsrf !== undefined && | ||
| 24 | account?.server === server && | ||
| 25 | account.value.csrfToken === rejectedCsrf | ||
| 26 | ) { | ||
| 27 | account = null; | ||
| 28 | } | ||
| 29 | if ( | ||
| 30 | account?.server === server && | ||
| 31 | DateTime.toEpochMillis(account.value.expiresAt) > Date.now() + 60_000 | ||
| 32 | ) { | ||
| 33 | return { "x-csrf-token": account.value.csrfToken }; | ||
| 34 | } | ||
| 35 | if (browserRequest?.server !== server) { | ||
| 36 | const previous = account?.server === server ? account.value : null; | ||
| 37 | const pending = (async () => { | ||
| 38 | let response = await fetch( | ||
| 39 | `${server}/api/auth/${previous === null ? "account" : "session/refresh"}`, | ||
| 40 | { | ||
| 41 | ...(previous === null | ||
| 42 | ? {} | ||
| 43 | : { method: "POST", headers: { "x-csrf-token": previous.csrfToken } }), | ||
| 44 | credentials: "same-origin", | ||
| 45 | redirect: "error", | ||
| 46 | }, | ||
| 47 | ); | ||
| 48 | if (response.status === 401 && previous !== null) { | ||
| 49 | const current = await fetch(`${server}/api/auth/account`, { | ||
| 50 | credentials: "same-origin", | ||
| 51 | redirect: "error", | ||
| 52 | }); | ||
| 53 | if (!current.ok) throw new Error("Sign in to your server again to reconnect."); | ||
| 54 | const refreshed = decodeAccount(await current.json()); | ||
| 55 | response = await fetch(`${server}/api/auth/session/refresh`, { | ||
| 56 | method: "POST", | ||
| 57 | headers: { "x-csrf-token": refreshed.csrfToken }, | ||
| 58 | credentials: "same-origin", | ||
| 59 | redirect: "error", | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | if (!response.ok) throw new Error("Sign in to your server again to reconnect."); | ||
| 63 | return decodeAccount(await response.json()); | ||
| 64 | })(); | ||
| 65 | browserRequest = { server, value: pending }; | ||
| 66 | } | ||
| 67 | const current = browserRequest; | ||
| 68 | try { | ||
| 69 | const value = await current.value; | ||
| 70 | if (browserRequest === current) account = { server, value }; | ||
| 71 | return { "x-csrf-token": value.csrfToken }; | ||
| 72 | } finally { | ||
| 73 | if (browserRequest === current) browserRequest = null; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | export async function hostedAccountRequest(server: string, path: string, method = "GET") { | ||
| 78 | const request = (headers: Record<string, string>) => | ||
| 79 | fetch(`${server}${path}`, { | ||
| 80 | method, | ||
| 81 | headers, | ||
| 82 | credentials: "same-origin", | ||
| 83 | redirect: "error", | ||
| 84 | }); | ||
| 85 | const headers = await hostedAuthHeaders(server); | ||
| 86 | let response = await request(headers); | ||
| 87 | if (response.status === 401 && headers["x-csrf-token"]) { | ||
| 88 | response = await request(await hostedAuthHeaders(server, headers["x-csrf-token"])); | ||
| 89 | } | ||
| 90 | if (!response.ok) throw new Error("Couldn't update this server connection. Try again."); | ||
| 91 | return response; | ||
| 92 | } | ||
| 93 | |||
| 94 | export async function hostedDevices(server: string) { | ||
| 95 | const response = await hostedAccountRequest(server, "/api/auth/devices"); | ||
| 96 | return decodeDevices(await response.json()); | ||
| 97 | } | ||
| 98 | |||
| 99 | export async function beginHostedSignIn(server: string) { | ||
| 100 | if (window.desktopBridge?.beginHostedAuth) { | ||
| 101 | await window.desktopBridge.beginHostedAuth(server); | ||
| 102 | return; | ||
| 103 | } | ||
| 104 | const url = new URL(`${server}/api/auth/oidc/start`); | ||
| 105 | url.searchParams.set("returnTo", "/"); | ||
| 106 | window.location.assign(url.href); | ||
| 107 | } | ||
| 108 | |||
| 109 | export function forgetHostedWebSession() { | ||
| 110 | account = null; | ||
| 111 | browserRequest = null; | ||
| 112 | } | ||
apps/web/src/connection/hub.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createHubConnectionAtoms } from "@t3tools/client-runtime/state/hub"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "./runtime"; | ||
| 4 | |||
| 5 | export const hubConnection = createHubConnectionAtoms(connectionAtomRuntime); | ||
apps/web/src/connection/platform.ts+17-2| ... | @@ -40,6 +40,7 @@ import * as Ref from "effect/Ref"; | ... | @@ -40,6 +40,7 @@ import * as Ref from "effect/Ref"; |
| 40 | import * as Stream from "effect/Stream"; | 40 | import * as Stream from "effect/Stream"; |
| 41 | import { FetchHttpClient } from "effect/http"; | 41 | import { FetchHttpClient } from "effect/http"; |
| 42 | 42 | ||
| 43 | import { hostedAuthHeaders } from "./hostedAuth"; | ||
| 43 | import { APP_VERSION } from "../branding"; | 44 | import { APP_VERSION } from "../branding"; |
| 44 | import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; | 45 | import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; |
| 45 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; | 46 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; |
| ... | @@ -69,6 +70,18 @@ function currentNetworkStatus(): "unknown" | "offline" | "online" { | ... | @@ -69,6 +70,18 @@ function currentNetworkStatus(): "unknown" | "offline" | "online" { |
| 69 | return navigator.onLine ? "online" : "offline"; | 70 | return navigator.onLine ? "online" : "offline"; |
| 70 | } | 71 | } |
| 71 | 72 | ||
| 73 | const layerHostedAuthAccess = Layer.succeed(ClientCapabilities.HostedAuthAccess, { | ||
| 74 | headers: (serverUrl) => | ||
| 75 | Effect.tryPromise({ | ||
| 76 | try: () => hostedAuthHeaders(serverUrl), | ||
| 77 | catch: () => | ||
| 78 | new ConnectionBlockedError({ | ||
| 79 | reason: "authentication", | ||
| 80 | detail: "Sign in to your server again to reconnect.", | ||
| 81 | }), | ||
| 82 | }), | ||
| 83 | }); | ||
| 84 | |||
| 72 | const layerConnectivity = Connectivity.layer({ | 85 | const layerConnectivity = Connectivity.layer({ |
| 73 | status: Effect.sync(currentNetworkStatus), | 86 | status: Effect.sync(currentNetworkStatus), |
| 74 | changes: Stream.callback((queue) => | 87 | changes: Stream.callback((queue) => |
| ... | @@ -745,9 +758,9 @@ const layerPlatformConnectionSource = Layer.effect( | ... | @@ -745,9 +758,9 @@ const layerPlatformConnectionSource = Layer.effect( |
| 745 | const layerEnvironmentOwnedDataCleanup = Layer.succeed( | 758 | const layerEnvironmentOwnedDataCleanup = Layer.succeed( |
| 746 | Persistence.EnvironmentOwnedDataCleanup, | 759 | Persistence.EnvironmentOwnedDataCleanup, |
| 747 | Persistence.EnvironmentOwnedDataCleanup.of({ | 760 | Persistence.EnvironmentOwnedDataCleanup.of({ |
| 748 | clear: (environmentId) => | 761 | clear: (environmentId, options) => |
| 749 | Effect.sync(() => { | 762 | Effect.sync(() => { |
| 750 | clearComposerDraftsEnvironment(environmentId); | 763 | if (!options?.retainDrafts) clearComposerDraftsEnvironment(environmentId); |
| 751 | }), | 764 | }), |
| 752 | }), | 765 | }), |
| 753 | ); | 766 | ); |
| ... | @@ -770,6 +783,7 @@ const layerRpcRequestObserver = Layer.succeed( | ... | @@ -770,6 +783,7 @@ const layerRpcRequestObserver = Layer.succeed( |
| 770 | type ConnectionPlatformLayerSource = | 783 | type ConnectionPlatformLayerSource = |
| 771 | | typeof ConnectionStorage.layer | 784 | | typeof ConnectionStorage.layer |
| 772 | | typeof layerConnectivity | 785 | | typeof layerConnectivity |
| 786 | | typeof layerHostedAuthAccess | ||
| 773 | | typeof layerWakeups | 787 | | typeof layerWakeups |
| 774 | | typeof layerCapabilities | 788 | | typeof layerCapabilities |
| 775 | | typeof layerPlatformConnectionSource | 789 | | typeof layerPlatformConnectionSource |
| ... | @@ -782,6 +796,7 @@ export const layer: Layer.Layer< | ... | @@ -782,6 +796,7 @@ export const layer: Layer.Layer< |
| 782 | Layer.Services<ConnectionPlatformLayerSource> | 796 | Layer.Services<ConnectionPlatformLayerSource> |
| 783 | > = Layer.mergeAll( | 797 | > = Layer.mergeAll( |
| 784 | ConnectionStorage.layer, | 798 | ConnectionStorage.layer, |
| 799 | layerHostedAuthAccess, | ||
| 785 | layerConnectivity, | 800 | layerConnectivity, |
| 786 | layerWakeups, | 801 | layerWakeups, |
| 787 | layerCapabilities, | 802 | layerCapabilities, |
apps/web/src/connection/storage.test.ts+63| ... | @@ -1,4 +1,10 @@ | ... | @@ -1,4 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | BearerConnectionCredential, | ||
| 3 | BearerConnectionProfile, | ||
| 4 | BearerConnectionRegistration, | ||
| 5 | BearerConnectionTarget, | ||
| 6 | CredentialStore, | ||
| 7 | HubConnectionCredential, | ||
| 2 | ConnectionTransientError, | 8 | ConnectionTransientError, |
| 3 | PrimaryConnectionTarget, | 9 | PrimaryConnectionTarget, |
| 4 | } from "@t3tools/client-runtime/connection"; | 10 | } from "@t3tools/client-runtime/connection"; |
| ... | @@ -31,6 +37,63 @@ afterEach(() => { | ... | @@ -31,6 +37,63 @@ afterEach(() => { |
| 31 | }); | 37 | }); |
| 32 | 38 | ||
| 33 | describe("ConnectionStorage.makeCatalogStore", () => { | 39 | describe("ConnectionStorage.makeCatalogStore", () => { |
| 40 | it.effect("restores hub route pointers and the single bearer after reopening storage", () => | ||
| 41 | Effect.gen(function* () { | ||
| 42 | let saved: string | null = null; | ||
| 43 | vi.stubGlobal("window", { | ||
| 44 | desktopBridge: { | ||
| 45 | getConnectionCatalog: async () => saved, | ||
| 46 | setConnectionCatalog: async (raw: string) => { | ||
| 47 | saved = raw; | ||
| 48 | return true; | ||
| 49 | }, | ||
| 50 | }, | ||
| 51 | }); | ||
| 52 | const connectionId = "hub:hub:source"; | ||
| 53 | const hubCredentialId = "hub-session:hub"; | ||
| 54 | const credential = new HubConnectionCredential({ connectionId: hubCredentialId }); | ||
| 55 | const bearer = new BearerConnectionCredential({ token: "single-hub-secret" }); | ||
| 56 | const registration = new BearerConnectionRegistration({ | ||
| 57 | target: new BearerConnectionTarget({ | ||
| 58 | connectionId, | ||
| 59 | environmentId: EnvironmentId.make("source"), | ||
| 60 | label: "Source", | ||
| 61 | }), | ||
| 62 | profile: new BearerConnectionProfile({ | ||
| 63 | connectionId, | ||
| 64 | environmentId: EnvironmentId.make("source"), | ||
| 65 | label: "Source", | ||
| 66 | httpBaseUrl: "https://hub.example/environments/source/", | ||
| 67 | wsBaseUrl: "wss://hub.example/environments/source/", | ||
| 68 | }), | ||
| 69 | credential, | ||
| 70 | }); | ||
| 71 | yield* Effect.gen(function* () { | ||
| 72 | const credentials = yield* CredentialStore.ConnectionCredentialStore; | ||
| 73 | const registrations = yield* Persistence.ConnectionRegistrationStore; | ||
| 74 | yield* credentials.put(hubCredentialId, bearer); | ||
| 75 | yield* registrations.register(registration, [registration.target]); | ||
| 76 | expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential)); | ||
| 77 | }).pipe(Effect.provide(ConnectionStorage.layer), Effect.scoped); | ||
| 78 | |||
| 79 | yield* Effect.gen(function* () { | ||
| 80 | const credentials = yield* CredentialStore.ConnectionCredentialStore; | ||
| 81 | expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential)); | ||
| 82 | expect(yield* credentials.get(hubCredentialId)).toEqual(Option.some(bearer)); | ||
| 83 | }).pipe(Effect.provide(ConnectionStorage.layer), Effect.scoped); | ||
| 84 | expect(JSON.parse(saved!).credentials).toEqual([ | ||
| 85 | { | ||
| 86 | connectionId: hubCredentialId, | ||
| 87 | credential: { _tag: "BearerConnectionCredential", token: bearer.token }, | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | connectionId, | ||
| 91 | credential: { _tag: "HubConnectionCredential", connectionId: hubCredentialId }, | ||
| 92 | }, | ||
| 93 | ]); | ||
| 94 | }), | ||
| 95 | ); | ||
| 96 | |||
| 34 | it.effect("quarantines malformed catalogs and starts from an empty document", () => | 97 | it.effect("quarantines malformed catalogs and starts from an empty document", () => |
| 35 | Effect.gen(function* () { | 98 | Effect.gen(function* () { |
| 36 | const writes: string[] = []; | 99 | const writes: string[] = []; |
apps/web/src/environments/primary/bootstrap.test.ts+25| ... | @@ -127,6 +127,21 @@ describe("environmentBootstrap", () => { | ... | @@ -127,6 +127,21 @@ describe("environmentBootstrap", () => { |
| 127 | ); | 127 | ); |
| 128 | }); | 128 | }); |
| 129 | 129 | ||
| 130 | it("keeps configured environment paths in primary descriptor and auth URLs", async () => { | ||
| 131 | vi.stubEnv("VITE_HTTP_URL", "https://hub.example/prefix/environments/one/?old=1#fragment"); | ||
| 132 | await installDescriptorApi(); | ||
| 133 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); | ||
| 134 | expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( | ||
| 135 | "https://hub.example/prefix/environments/one/.well-known/t3/environment", | ||
| 136 | ); | ||
| 137 | expect(readPrimaryEnvironmentTarget()?.target.wsBaseUrl).toBe( | ||
| 138 | "wss://hub.example/prefix/environments/one/", | ||
| 139 | ); | ||
| 140 | expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session", { token: "ticket" })).toBe( | ||
| 141 | "https://hub.example/prefix/environments/one/api/auth/session?token=ticket", | ||
| 142 | ); | ||
| 143 | }); | ||
| 144 | |||
| 130 | it("derives the websocket url when only VITE_HTTP_URL is configured", async () => { | 145 | it("derives the websocket url when only VITE_HTTP_URL is configured", async () => { |
| 131 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); | 146 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); |
| 132 | await installDescriptorApi(); | 147 | await installDescriptorApi(); |
| ... | @@ -155,6 +170,16 @@ describe("environmentBootstrap", () => { | ... | @@ -155,6 +170,16 @@ describe("environmentBootstrap", () => { |
| 155 | }); | 170 | }); |
| 156 | }); | 171 | }); |
| 157 | 172 | ||
| 173 | it.each(["wss://hub.example/prefix/ws", "wss://hub.example/prefix/"])( | ||
| 174 | "derives HTTP base paths from a configured WebSocket address: %s", | ||
| 175 | (wsBaseUrl) => { | ||
| 176 | vi.stubEnv("VITE_WS_URL", wsBaseUrl); | ||
| 177 | expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( | ||
| 178 | "https://hub.example/prefix/api/auth/session", | ||
| 179 | ); | ||
| 180 | }, | ||
| 181 | ); | ||
| 182 | |||
| 158 | it("keeps an uppercase wss scheme secure when deriving the http url", () => { | 183 | it("keeps an uppercase wss scheme secure when deriving the http url", () => { |
| 159 | vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com"); | 184 | vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com"); |
| 160 | 185 |
apps/web/src/environments/primary/target.ts+19-9| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; |
| 2 | import { | ||
| 3 | deriveWsBaseUrl, | ||
| 4 | environmentEndpointUrl, | ||
| 5 | normalizeHttpBaseUrl, | ||
| 6 | } from "@t3tools/shared/advertisedEndpoint"; | ||
| 2 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 3 | 8 | ||
| 4 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | 9 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; |
| ... | @@ -137,6 +142,9 @@ function swapBaseUrlProtocol( | ... | @@ -137,6 +142,9 @@ function swapBaseUrlProtocol( |
| 137 | source: "configured", | 142 | source: "configured", |
| 138 | urlKind, | 143 | urlKind, |
| 139 | }); | 144 | }); |
| 145 | if ((url.protocol === "ws:" || url.protocol === "wss:") && !url.pathname.endsWith("/")) { | ||
| 146 | url.pathname = url.pathname.replace(/[^/]+$/, ""); | ||
| 147 | } | ||
| 140 | url.protocol = nextProtocol; | 148 | url.protocol = nextProtocol; |
| 141 | return url.toString(); | 149 | return url.toString(); |
| 142 | } | 150 | } |
| ... | @@ -209,17 +217,19 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { | ... | @@ -209,17 +217,19 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { |
| 209 | (configuredWsBaseUrl?.toLowerCase().startsWith("wss:") | 217 | (configuredWsBaseUrl?.toLowerCase().startsWith("wss:") |
| 210 | ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url") | 218 | ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url") |
| 211 | : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url")); | 219 | : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url")); |
| 220 | const normalizedHttpBaseUrl = normalizeHttpBaseUrl( | ||
| 221 | normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), | ||
| 222 | ); | ||
| 212 | const resolvedWsBaseUrl = | 223 | const resolvedWsBaseUrl = |
| 213 | configuredWsBaseUrl ?? | 224 | configuredWsBaseUrl === undefined |
| 214 | (configuredHttpBaseUrl?.toLowerCase().startsWith("https:") | 225 | ? deriveWsBaseUrl(normalizedHttpBaseUrl) |
| 215 | ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:", "http-base-url") | 226 | : normalizeBaseUrl(configuredWsBaseUrl, "configured", "websocket-base-url"); |
| 216 | : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:", "http-base-url")); | ||
| 217 | 227 | ||
| 218 | return { | 228 | return { |
| 219 | source: "configured", | 229 | source: "configured", |
| 220 | target: { | 230 | target: { |
| 221 | httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), | 231 | httpBaseUrl: normalizedHttpBaseUrl, |
| 222 | wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), | 232 | wsBaseUrl: resolvedWsBaseUrl, |
| 223 | }, | 233 | }, |
| 224 | }; | 234 | }; |
| 225 | } | 235 | } |
| ... | @@ -296,11 +306,11 @@ export function resolvePrimaryEnvironmentHttpUrl( | ... | @@ -296,11 +306,11 @@ export function resolvePrimaryEnvironmentHttpUrl( |
| 296 | source: primaryTarget.source, | 306 | source: primaryTarget.source, |
| 297 | urlKind: "http-base-url", | 307 | urlKind: "http-base-url", |
| 298 | }); | 308 | }); |
| 299 | url.pathname = pathname; | 309 | const endpoint = new URL(environmentEndpointUrl(url.toString(), pathname)); |
| 300 | if (searchParams) { | 310 | if (searchParams) { |
| 301 | url.search = new URLSearchParams(searchParams).toString(); | 311 | endpoint.search = new URLSearchParams(searchParams).toString(); |
| 302 | } | 312 | } |
| 303 | return url.toString(); | 313 | return endpoint.toString(); |
| 304 | } | 314 | } |
| 305 | 315 | ||
| 306 | // Null only when the desktop app runs with its local environment disabled; | 316 | // Null only when the desktop app runs with its local environment disabled; |
apps/web/src/hooks/useThreadActionMenu.ts+6-2| ... | @@ -19,12 +19,13 @@ import { resolveSnoozePresets } from "../components/Sidebar.snooze"; | ... | @@ -19,12 +19,13 @@ import { resolveSnoozePresets } from "../components/Sidebar.snooze"; |
| 19 | import { | 19 | import { |
| 20 | buildThreadActionMenuItems, | 20 | buildThreadActionMenuItems, |
| 21 | threadActionRequiresOperate, | 21 | threadActionRequiresOperate, |
| 22 | threadActionIsOrganization, | ||
| 22 | type ThreadActionMenuId, | 23 | type ThreadActionMenuId, |
| 23 | } from "../components/threadActionMenu.logic"; | 24 | } from "../components/threadActionMenu.logic"; |
| 24 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | 25 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 25 | import { threadEnvironment } from "../state/threads"; | 26 | import { threadEnvironment } from "../state/threads"; |
| 26 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | 27 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; |
| 27 | import { readEnvironmentScope } from "../state/session"; | 28 | import { readEnvironmentScope, readThreadOrganizationPermission } from "../state/session"; |
| 28 | import { | 29 | import { |
| 29 | readEnvironmentSupportsAutoSettleOptOut, | 30 | readEnvironmentSupportsAutoSettleOptOut, |
| 30 | readEnvironmentSupportsPinning, | 31 | readEnvironmentSupportsPinning, |
| ... | @@ -148,6 +149,7 @@ export function useThreadActionMenu(input: { | ... | @@ -148,6 +149,7 @@ export function useThreadActionMenu(input: { |
| 148 | const isRegeneratingTitle = thread.titleRegeneration != null; | 149 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 149 | const snoozePresets = resolveSnoozePresets(now, timestampFormat); | 150 | const snoozePresets = resolveSnoozePresets(now, timestampFormat); |
| 150 | const items = buildThreadActionMenuItems({ | 151 | const items = buildThreadActionMenuItems({ |
| 152 | canOrganize: readThreadOrganizationPermission(threadRef.environmentId), | ||
| 151 | canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), | 153 | canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), |
| 152 | branch: thread.branch ?? null, | 154 | branch: thread.branch ?? null, |
| 153 | projectFilter: null, | 155 | projectFilter: null, |
| ... | @@ -166,7 +168,9 @@ export function useThreadActionMenu(input: { | ... | @@ -166,7 +168,9 @@ export function useThreadActionMenu(input: { |
| 166 | const action: ThreadActionMenuId = clicked.value; | 168 | const action: ThreadActionMenuId = clicked.value; |
| 167 | if ( | 169 | if ( |
| 168 | threadActionRequiresOperate(action) && | 170 | threadActionRequiresOperate(action) && |
| 169 | !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) | 171 | !(threadActionIsOrganization(action) |
| 172 | ? readThreadOrganizationPermission(threadRef.environmentId) | ||
| 173 | : readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) | ||
| 170 | ) { | 174 | ) { |
| 171 | failureToast( | 175 | failureToast( |
| 172 | "Thread action unavailable", | 176 | "Thread action unavailable", |
apps/web/src/main.tsx+4-35| ... | @@ -6,7 +6,6 @@ import "./index.css"; | ... | @@ -6,7 +6,6 @@ import "./index.css"; |
| 6 | 6 | ||
| 7 | import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; | 7 | import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; |
| 8 | import { isElectron } from "./env"; | 8 | import { isElectron } from "./env"; |
| 9 | import { hasCloudPublicConfig } from "./cloud/publicConfig"; | ||
| 10 | import { getRouter } from "./router"; | 9 | import { getRouter } from "./router"; |
| 11 | import { | 10 | import { |
| 12 | syncDocumentElectronPlatformClasses, | 11 | syncDocumentElectronPlatformClasses, |
| ... | @@ -27,8 +26,6 @@ if (isElectron) { | ... | @@ -27,8 +26,6 @@ if (isElectron) { |
| 27 | syncDocumentWindowControlsOverlayClass(); | 26 | syncDocumentWindowControlsOverlayClass(); |
| 28 | } | 27 | } |
| 29 | 28 | ||
| 30 | const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; | ||
| 31 | |||
| 32 | // A failed split-chunk fetch usually means the hashed assets went stale under | 29 | // A failed split-chunk fetch usually means the hashed assets went stale under |
| 33 | // a deploy; one guarded reload picks up the fresh index.html. | 30 | // a deploy; one guarded reload picks up the fresh index.html. |
| 34 | let chunkLoadFailed = false; | 31 | let chunkLoadFailed = false; |
| ... | @@ -43,41 +40,13 @@ window.addEventListener("vite:preloadError", (event) => { | ... | @@ -43,41 +40,13 @@ window.addEventListener("vite:preloadError", (event) => { |
| 43 | 40 | ||
| 44 | const app = <AppRoot router={router} />; | 41 | const app = <AppRoot router={router} />; |
| 45 | 42 | ||
| 46 | // Managed auth is cloud-only, and the Electron Clerk provider bundles the full | 43 | export const startup = router |
| 47 | // clerk-js runtime. Loading only the selected runtime as a split chunk keeps | 44 | .load() |
| 48 | // every Clerk byte out of the startup graph for local-mode users, and keeps | 45 | .then(() => { |
| 49 | // the bundled clerk-js out of the browser build entirely. | ||
| 50 | const managedAuthShellModule = | ||
| 51 | clerkPublishableKey && hasCloudPublicConfig() | ||
| 52 | ? isElectron | ||
| 53 | ? import("./components/clerk/ElectronManagedAuthShell") | ||
| 54 | : import("./components/clerk/BrowserManagedAuthShell") | ||
| 55 | : null; | ||
| 56 | |||
| 57 | // The index.html boot splash lives inside #root, and React's first commit | ||
| 58 | // clears it. Resolve everything that first commit needs, the selected | ||
| 59 | // managed-auth runtime and the initial route's split chunks, before | ||
| 60 | // rendering, so the splash holds until real UI paints instead of dropping to | ||
| 61 | // a blank window while chunks download. | ||
| 62 | export const startup = Promise.all([ | ||
| 63 | managedAuthShellModule?.then((module) => module.default) ?? null, | ||
| 64 | router.load(), | ||
| 65 | ]) | ||
| 66 | .then(([ManagedAuthShell]) => { | ||
| 67 | // A route chunk failure still resolves router.load(): the error is parked in | ||
| 68 | // the lazy component and surfaces through the route error boundary. Skip the | ||
| 69 | // paint when a reload is on its way, and only re-arm the guard after a boot | ||
| 70 | // that fetched every chunk it asked for. | ||
| 71 | if (reloadScheduled) return; | 46 | if (reloadScheduled) return; |
| 72 | if (!chunkLoadFailed) clearChunkReloadGuard(); | 47 | if (!chunkLoadFailed) clearChunkReloadGuard(); |
| 73 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | 48 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( |
| 74 | <React.StrictMode> | 49 | <React.StrictMode>{app}</React.StrictMode>, |
| 75 | {ManagedAuthShell && clerkPublishableKey ? ( | ||
| 76 | <ManagedAuthShell publishableKey={clerkPublishableKey}>{app}</ManagedAuthShell> | ||
| 77 | ) : ( | ||
| 78 | app | ||
| 79 | )} | ||
| 80 | </React.StrictMode>, | ||
| 81 | ); | 50 | ); |
| 82 | }) | 51 | }) |
| 83 | .catch((error: unknown) => { | 52 | .catch((error: unknown) => { |
apps/web/src/routes/__root.tsx+2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { HostedServerAutoConnect } from "../components/settings/SelfHostedServer"; | ||
| 1 | import { usePersonalUiSettingsSync } from "../state/uiSettingsSync"; | 2 | import { usePersonalUiSettingsSync } from "../state/uiSettingsSync"; |
| 2 | import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; | 3 | import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; |
| 3 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; | 4 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; |
| ... | @@ -235,6 +236,7 @@ function RootRouteView() { | ... | @@ -235,6 +236,7 @@ function RootRouteView() { |
| 235 | <FontAppearanceSync /> | 236 | <FontAppearanceSync /> |
| 236 | <ProviderAuthCallbackCoordinator /> | 237 | <ProviderAuthCallbackCoordinator /> |
| 237 | <ChatGptWelcomeCoordinator /> | 238 | <ChatGptWelcomeCoordinator /> |
| 239 | <HostedServerAutoConnect /> | ||
| 238 | <FirstRunGate | 240 | <FirstRunGate |
| 239 | enabled={primaryEnvironmentAuthenticated} | 241 | enabled={primaryEnvironmentAuthenticated} |
| 240 | hostedStatic={authGateState.status === "hosted-static"} | 242 | hostedStatic={authGateState.status === "hosted-static"} |
apps/web/src/routes/_chat.index.tsx+1-5| ... | @@ -21,7 +21,6 @@ import { | ... | @@ -21,7 +21,6 @@ import { |
| 21 | } from "../state/entities"; | 21 | } from "../state/entities"; |
| 22 | import { useEnvironments } from "../state/environments"; | 22 | import { useEnvironments } from "../state/environments"; |
| 23 | import { APP_DISPLAY_NAME } from "~/branding"; | 23 | import { APP_DISPLAY_NAME } from "~/branding"; |
| 24 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | ||
| 25 | 24 | ||
| 26 | function ChatIndexRouteView() { | 25 | function ChatIndexRouteView() { |
| 27 | const { authGateState } = Route.useRouteContext(); | 26 | const { authGateState } = Route.useRouteContext(); |
| ... | @@ -133,13 +132,10 @@ export const Route = createFileRoute("/_chat/")({ | ... | @@ -133,13 +132,10 @@ export const Route = createFileRoute("/_chat/")({ |
| 133 | }); | 132 | }); |
| 134 | 133 | ||
| 135 | function HostedStaticOnboardingState() { | 134 | function HostedStaticOnboardingState() { |
| 136 | const cloudEnabled = hasCloudPublicConfig(); | ||
| 137 | const localEnvironmentOff = isLocalEnvironmentDisabled(); | 135 | const localEnvironmentOff = isLocalEnvironmentDisabled(); |
| 138 | const description = localEnvironmentOff | 136 | const description = localEnvironmentOff |
| 139 | ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections." | 137 | ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections." |
| 140 | : cloudEnabled | 138 | : "Open Connections to choose T3 Connect, your own server, or a pairing link from one computer."; |
| 141 | ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." | ||
| 142 | : "Open Connections and add that machine using its pairing link. This app must be able to reach it."; | ||
| 143 | 139 | ||
| 144 | return ( | 140 | return ( |
| 145 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none"> | 141 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none"> |
apps/web/src/state/entities.ts+13-19| ... | @@ -14,7 +14,7 @@ import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/c | ... | @@ -14,7 +14,7 @@ import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/c |
| 14 | import { Atom } from "effect/reactivity"; | 14 | import { Atom } from "effect/reactivity"; |
| 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 16 | import { environmentProjects } from "./projects"; | 16 | import { environmentProjects } from "./projects"; |
| 17 | import { environmentServerConfigsAtom } from "./server"; | 17 | import { environmentCapabilitiesAtom, environmentServerConfigsAtom } from "./server"; |
| 18 | import { | 18 | import { |
| 19 | allEnvironmentProjectSnapshotsReadyAtom, | 19 | allEnvironmentProjectSnapshotsReadyAtom, |
| 20 | allEnvironmentShellsBootstrappedAtom, | 20 | allEnvironmentShellsBootstrappedAtom, |
| ... | @@ -240,8 +240,8 @@ export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentI | ... | @@ -240,8 +240,8 @@ export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentI |
| 240 | 240 | ||
| 241 | export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean { | 241 | export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean { |
| 242 | return ( | 242 | return ( |
| 243 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 243 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadTitleRegeneration === |
| 244 | .threadTitleRegeneration === true | 244 | true |
| 245 | ); | 245 | ); |
| 246 | } | 246 | } |
| 247 | 247 | ||
| ... | @@ -249,8 +249,7 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm | ... | @@ -249,8 +249,7 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm |
| 249 | Same version-skew contract as settlement. */ | 249 | Same version-skew contract as settlement. */ |
| 250 | export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean { | 250 | export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean { |
| 251 | return ( | 251 | return ( |
| 252 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 252 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinning === true |
| 253 | .threadPinning === true | ||
| 254 | ); | 253 | ); |
| 255 | } | 254 | } |
| 256 | 255 | ||
| ... | @@ -258,8 +257,7 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo | ... | @@ -258,8 +257,7 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo |
| 258 | orderKey on thread.pin). Same version-skew contract as settlement. */ | 257 | orderKey on thread.pin). Same version-skew contract as settlement. */ |
| 259 | export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean { | 258 | export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean { |
| 260 | return ( | 259 | return ( |
| 261 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 260 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinReorder === true |
| 262 | .threadPinReorder === true | ||
| 263 | ); | 261 | ); |
| 264 | } | 262 | } |
| 265 | 263 | ||
| ... | @@ -267,15 +265,15 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): | ... | @@ -267,15 +265,15 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): |
| 267 | Same version-skew contract as settlement. */ | 265 | Same version-skew contract as settlement. */ |
| 268 | export function readEnvironmentSupportsAutoSettleOptOut(environmentId: EnvironmentId): boolean { | 266 | export function readEnvironmentSupportsAutoSettleOptOut(environmentId: EnvironmentId): boolean { |
| 269 | return ( | 267 | return ( |
| 270 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 268 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadAutoSettleOptOut === |
| 271 | .threadAutoSettleOptOut === true | 269 | true |
| 272 | ); | 270 | ); |
| 273 | } | 271 | } |
| 274 | 272 | ||
| 275 | export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { | 273 | export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { |
| 276 | return ( | 274 | return ( |
| 277 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 275 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadActiveReorder === |
| 278 | .threadActiveReorder === true | 276 | true |
| 279 | ); | 277 | ); |
| 280 | } | 278 | } |
| 281 | 279 | ||
| ... | @@ -284,18 +282,14 @@ export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentI | ... | @@ -284,18 +282,14 @@ export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentI |
| 284 | so clients under version skew fall back instead of erroring. */ | 282 | so clients under version skew fall back instead of erroring. */ |
| 285 | export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean { | 283 | export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean { |
| 286 | return ( | 284 | return ( |
| 287 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 285 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSettlement === true |
| 288 | .threadSettlement === true | ||
| 289 | ); | 286 | ); |
| 290 | } | 287 | } |
| 291 | 288 | ||
| 292 | /** Whether the environment's server understands thread.snooze/unsnooze. | 289 | /** Whether the environment's server understands thread.snooze/unsnooze. |
| 293 | Same version-skew contract as settlement. */ | 290 | Same version-skew contract as settlement. */ |
| 294 | export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boolean { | 291 | export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boolean { |
| 295 | return ( | 292 | return appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSnooze === true; |
| 296 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | ||
| 297 | .threadSnooze === true | ||
| 298 | ); | ||
| 299 | } | 293 | } |
| 300 | 294 | ||
| 301 | /** Whether the environment's server understands thread.visit/mark-unread and | 295 | /** Whether the environment's server understands thread.visit/mark-unread and |
| ... | @@ -304,8 +298,8 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo | ... | @@ -304,8 +298,8 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo |
| 304 | state instead. */ | 298 | state instead. */ |
| 305 | export function readEnvironmentSupportsVisitedTracking(environmentId: EnvironmentId): boolean { | 299 | export function readEnvironmentSupportsVisitedTracking(environmentId: EnvironmentId): boolean { |
| 306 | return ( | 300 | return ( |
| 307 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 301 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadVisitedTracking === |
| 308 | .threadVisitedTracking === true | 302 | true |
| 309 | ); | 303 | ); |
| 310 | } | 304 | } |
| 311 | 305 |
apps/web/src/state/server.ts+6| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { createHubEnvironmentCapabilitiesAtom } from "@t3tools/client-runtime/state/hub"; | ||
| 2 | import { hubConnection } from "../connection/hub"; | ||
| 1 | import { | 3 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | 4 | DEFAULT_SERVER_SETTINGS, |
| 3 | type EditorId, | 5 | type EditorId, |
| ... | @@ -39,6 +41,10 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ | ... | @@ -39,6 +41,10 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 39 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 41 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 40 | serverConfigValueAtom: serverEnvironment.configValueAtom, | 42 | serverConfigValueAtom: serverEnvironment.configValueAtom, |
| 41 | }); | 43 | }); |
| 44 | export const environmentCapabilitiesAtom = createHubEnvironmentCapabilitiesAtom( | ||
| 45 | environmentServerConfigsAtom, | ||
| 46 | hubConnection, | ||
| 47 | ); | ||
| 42 | 48 | ||
| 43 | interface PrimaryServerState { | 49 | interface PrimaryServerState { |
| 44 | readonly config: ServerConfig | null; | 50 | readonly config: ServerConfig | null; |
apps/web/src/state/session.ts+15| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { hubConnection } from "../connection/hub"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; | 3 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; |
| 3 | import { | 4 | import { |
| 5 | AuthOrchestrationOperateScope, | ||
| 4 | type AuthEnvironmentScope, | 6 | type AuthEnvironmentScope, |
| 5 | type AuthSessionState, | 7 | type AuthSessionState, |
| 6 | type EnvironmentId, | 8 | type EnvironmentId, |
| ... | @@ -101,3 +103,16 @@ export function useEnvironmentSessionState(environmentId: EnvironmentId) { | ... | @@ -101,3 +103,16 @@ export function useEnvironmentSessionState(environmentId: EnvironmentId) { |
| 101 | isPending: result.waiting, | 103 | isPending: result.waiting, |
| 102 | }; | 104 | }; |
| 103 | } | 105 | } |
| 106 | |||
| 107 | export function useThreadOrganizationPermission(environmentId: EnvironmentId | null): boolean { | ||
| 108 | const source = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | ||
| 109 | const central = useAtomValue(hubConnection.organizationPermissionAtom(environmentId)); | ||
| 110 | return source || central; | ||
| 111 | } | ||
| 112 | |||
| 113 | export function readThreadOrganizationPermission(environmentId: EnvironmentId): boolean { | ||
| 114 | return ( | ||
| 115 | readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | ||
| 116 | appAtomRegistry.get(hubConnection.organizationPermissionAtom(environmentId)) | ||
| 117 | ); | ||
| 118 | } |
apps/web/src/state/use-orchestration-command.test.ts+18-1| ... | @@ -10,12 +10,17 @@ import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; | ... | @@ -10,12 +10,17 @@ import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 10 | 10 | ||
| 11 | const state = vi.hoisted(() => ({ | 11 | const state = vi.hoisted(() => ({ |
| 12 | allowed: new Set<string>(), | 12 | allowed: new Set<string>(), |
| 13 | organization: new Set<string>(), | ||
| 13 | readScope: vi.fn(), | 14 | readScope: vi.fn(), |
| 14 | run: vi.fn(), | 15 | run: vi.fn(), |
| 15 | })); | 16 | })); |
| 16 | 17 | ||
| 17 | vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); | 18 | vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); |
| 18 | vi.mock("./session", () => ({ readEnvironmentScope: state.readScope })); | 19 | vi.mock("./session", () => ({ |
| 20 | readEnvironmentScope: state.readScope, | ||
| 21 | readThreadOrganizationPermission: (id: string) => | ||
| 22 | state.organization.has(id) || state.allowed.has(id), | ||
| 23 | })); | ||
| 19 | vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run })); | 24 | vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run })); |
| 20 | 25 | ||
| 21 | import { useOrchestrationCommand } from "./use-orchestration-command"; | 26 | import { useOrchestrationCommand } from "./use-orchestration-command"; |
| ... | @@ -31,6 +36,7 @@ const target = (environmentId: EnvironmentId): Target => ({ | ... | @@ -31,6 +36,7 @@ const target = (environmentId: EnvironmentId): Target => ({ |
| 31 | 36 | ||
| 32 | beforeEach(() => { | 37 | beforeEach(() => { |
| 33 | state.allowed.clear(); | 38 | state.allowed.clear(); |
| 39 | state.organization.clear(); | ||
| 34 | state.run.mockReset().mockResolvedValue(AsyncResult.success("receipt")); | 40 | state.run.mockReset().mockResolvedValue(AsyncResult.success("receipt")); |
| 35 | state.readScope | 41 | state.readScope |
| 36 | .mockReset() | 42 | .mockReset() |
| ... | @@ -83,3 +89,14 @@ it("does not dispatch a later mutation after access changes while awaiting a rec | ... | @@ -83,3 +89,14 @@ it("does not dispatch a later mutation after access changes while awaiting a rec |
| 83 | expect((await mutate(target(second)))._tag).toBe("Failure"); | 89 | expect((await mutate(target(second)))._tag).toBe("Failure"); |
| 84 | expect(state.run).toHaveBeenCalledTimes(1); | 90 | expect(state.run).toHaveBeenCalledTimes(1); |
| 85 | }); | 91 | }); |
| 92 | |||
| 93 | it("accepts the central organization grant only for organization commands and rechecks revocation", async () => { | ||
| 94 | state.organization.add(second); | ||
| 95 | const organize = useOrchestrationCommand(command, { organization: true, reportFailure: false }); | ||
| 96 | const execute = useOrchestrationCommand(command, { reportFailure: false }); | ||
| 97 | expect((await organize(target(second)))._tag).toBe("Success"); | ||
| 98 | expect((await execute(target(second)))._tag).toBe("Failure"); | ||
| 99 | state.organization.clear(); | ||
| 100 | expect((await organize(target(second)))._tag).toBe("Failure"); | ||
| 101 | expect(state.run).toHaveBeenCalledTimes(1); | ||
| 102 | }); |
apps/web/src/state/use-orchestration-command.ts+8-4| ... | @@ -12,7 +12,7 @@ import * as Cause from "effect/Cause"; | ... | @@ -12,7 +12,7 @@ import * as Cause from "effect/Cause"; |
| 12 | import { AsyncResult } from "effect/reactivity"; | 12 | import { AsyncResult } from "effect/reactivity"; |
| 13 | import { useCallback } from "react"; | 13 | import { useCallback } from "react"; |
| 14 | 14 | ||
| 15 | import { readEnvironmentScope } from "./session"; | 15 | import { readEnvironmentScope, readThreadOrganizationPermission } from "./session"; |
| 16 | import { useAtomCommand } from "./use-atom-command"; | 16 | import { useAtomCommand } from "./use-atom-command"; |
| 17 | 17 | ||
| 18 | export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = | 18 | export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = |
| ... | @@ -21,12 +21,16 @@ export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = | ... | @@ -21,12 +21,16 @@ export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = |
| 21 | /** Recheck the target grant for every step of a thread or project mutation. */ | 21 | /** Recheck the target grant for every step of a thread or project mutation. */ |
| 22 | export function useOrchestrationCommand<W extends { readonly environmentId: EnvironmentId }, A, E>( | 22 | export function useOrchestrationCommand<W extends { readonly environmentId: EnvironmentId }, A, E>( |
| 23 | command: AtomCommand<W, A, E>, | 23 | command: AtomCommand<W, A, E>, |
| 24 | options?: string | AtomCommandOptions, | 24 | options?: string | (AtomCommandOptions & { readonly organization?: true }), |
| 25 | ): (value: W) => Promise<AtomCommandResult<A, E | EnvironmentAuthorizationError>> { | 25 | ): (value: W) => Promise<AtomCommandResult<A, E | EnvironmentAuthorizationError>> { |
| 26 | const run = useAtomCommand(command, options); | 26 | const run = useAtomCommand(command, options); |
| 27 | const organization = typeof options === "object" && options.organization === true; | ||
| 27 | return useCallback( | 28 | return useCallback( |
| 28 | async (value: W): Promise<AtomCommandResult<A, E | EnvironmentAuthorizationError>> => { | 29 | async (value: W): Promise<AtomCommandResult<A, E | EnvironmentAuthorizationError>> => { |
| 29 | if (!readEnvironmentScope(value.environmentId, AuthOrchestrationOperateScope)) { | 30 | const permitted = organization |
| 31 | ? readThreadOrganizationPermission(value.environmentId) | ||
| 32 | : readEnvironmentScope(value.environmentId, AuthOrchestrationOperateScope); | ||
| 33 | if (!permitted) { | ||
| 30 | return AsyncResult.failure( | 34 | return AsyncResult.failure( |
| 31 | Cause.fail( | 35 | Cause.fail( |
| 32 | new EnvironmentAuthorizationError({ | 36 | new EnvironmentAuthorizationError({ |
| ... | @@ -38,6 +42,6 @@ export function useOrchestrationCommand<W extends { readonly environmentId: Envi | ... | @@ -38,6 +42,6 @@ export function useOrchestrationCommand<W extends { readonly environmentId: Envi |
| 38 | } | 42 | } |
| 39 | return run(value); | 43 | return run(value); |
| 40 | }, | 44 | }, |
| 41 | [run], | 45 | [run, organization], |
| 42 | ); | 46 | ); |
| 43 | } | 47 | } |
apps/web/test/environmentHttpTest.ts+1| ... | @@ -99,6 +99,7 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc | ... | @@ -99,6 +99,7 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc |
| 99 | ); | 99 | ); |
| 100 | }), | 100 | }), |
| 101 | ) | 101 | ) |
| 102 | .handle("delegatedSession", () => unexpectedEndpoint("auth.delegatedSession")) | ||
| 102 | .handle("token", () => unexpectedEndpoint("auth.token")) | 103 | .handle("token", () => unexpectedEndpoint("auth.token")) |
| 103 | .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) | 104 | .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) |
| 104 | .handle( | 105 | .handle( |
apps/web/vite.config.ts+3-1| ... | @@ -253,7 +253,9 @@ export default defineConfig(() => { | ... | @@ -253,7 +253,9 @@ export default defineConfig(() => { |
| 253 | { | 253 | { |
| 254 | target: devProxyTarget, | 254 | target: devProxyTarget, |
| 255 | changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix), | 255 | changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix), |
| 256 | ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}), | 256 | ...(prefix === "/ws" || prefix === "/api" || prefix === "/environments" |
| 257 | ? { ws: true } | ||
| 258 | : {}), | ||
| 257 | }, | 259 | }, |
| 258 | ]), | 260 | ]), |
| 259 | ), | 261 | ), |
compose.sso.yaml created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | services: | ||
| 2 | knife: | ||
| 3 | environment: | ||
| 4 | T3CODE_SSO_CONFIG: /run/secrets/knife-oidc.json | ||
| 5 | T3CODE_ARCHIVE_STORE_ID: ${KNIFE_ARCHIVE_STORE_ID:?Set the archive store identity} | ||
| 6 | volumes: | ||
| 7 | - type: bind | ||
| 8 | source: ${KNIFE_SSO_CONFIG:?Set the private OIDC configuration path} | ||
| 9 | target: /run/secrets/knife-oidc.json | ||
| 10 | read_only: true | ||
| 11 | bind: | ||
| 12 | create_host_path: false | ||
docs/operations/development.md+78-4| ... | @@ -369,7 +369,81 @@ four minutes without restarting or replacing the running code. A feed outage use | ... | @@ -369,7 +369,81 @@ four minutes without restarting or replacing the running code. A feed outage use |
| 369 | baseline; a checksum or dependency mismatch stops startup. Rebuild the base image | 369 | baseline; a checksum or dependency mismatch stops startup. Rebuild the base image |
| 370 | when the server's locked native dependency closure or container bootstrap changes. | 370 | when the server's locked native dependency closure or container bootstrap changes. |
| 371 | 371 | ||
| 372 | Enroll execution hosts with the private authority using the existing | 372 | Use hosted sign-in below for desktop enrollment and archive uploads. Optional |
| 373 | [`t3 shared` commands](../user/shared-server.md). Each machine gets its own | 373 | [shared provider accounts](../user/shared-server.md#share-agent-accounts) use separate |
| 374 | revocable session credential. The authority owns Codex token refreshes; Claude | 374 | grants; signing into the server does not export local provider credentials. |
| 375 | accounts need setup tokens. Connected hosts synchronize accounts automatically. | 375 | |
| 376 | ### Hosted sign-in | ||
| 377 | |||
| 378 | Register a confidential OpenID Connect application with your identity provider: | ||
| 379 | |||
| 380 | | Setting | Value | | ||
| 381 | | --------------------- | ------------------------------------------------------------------- | | ||
| 382 | | Grant types | `authorization_code`, `refresh_token` | | ||
| 383 | | Response type | `code` | | ||
| 384 | | Callback | `https://knife.example.com/api/auth/oidc/callback`, matched exactly | | ||
| 385 | | PKCE | Required, `S256` | | ||
| 386 | | Client authentication | `client_secret_basic` | | ||
| 387 | | ID token algorithm | `RS256` | | ||
| 388 | | Scopes | `openid offline_access`; allow refresh tokens | | ||
| 389 | |||
| 390 | The issuer callback is HTTPS. `t3code://app/self-hosted/callback` is a separate, | ||
| 391 | proof-bound return to the desktop; do not register it with the issuer. There is no | ||
| 392 | public native client or dynamic client registration. | ||
| 393 | |||
| 394 | Set `T3CODE_SSO_CONFIG` to a private JSON file readable by the server process: | ||
| 395 | |||
| 396 | ```json | ||
| 397 | { | ||
| 398 | "publicUrl": "https://knife.example.com", | ||
| 399 | "issuer": "https://identity.example.com", | ||
| 400 | "clientId": "knife", | ||
| 401 | "clientSecret": "PRIVATE_REGISTERED_CLIENT_SECRET", | ||
| 402 | "ownerSub": "IMMUTABLE_OWNER_SUBJECT", | ||
| 403 | "maxInteractiveAgeSeconds": 2592000, | ||
| 404 | "issuerRevalidationSeconds": 3600 | ||
| 405 | } | ||
| 406 | ``` | ||
| 407 | |||
| 408 | Use the exact issuer string from discovery and its immutable subject for the | ||
| 409 | intended owner. `publicUrl` is the HTTPS origin without a trailing slash. Email, | ||
| 410 | display name, and the first person to visit never establish ownership. Missing | ||
| 411 | owner configuration prevents SSO from starting. Without `T3CODE_SSO_CONFIG`, | ||
| 412 | existing pairing remains available. | ||
| 413 | |||
| 414 | Keep this file outside the checkout, restrict it to its owner (`chmod 600`), and | ||
| 415 | mount it read-only into containers. Pass its container path through | ||
| 416 | `T3CODE_SSO_CONFIG`. Never include it in an image, public build share, or browser | ||
| 417 | configuration. The same deployment needs persistent private server state and an | ||
| 418 | HTTPS proxy that forwards WebSocket upgrades. | ||
| 419 | |||
| 420 | For Compose, set `KNIFE_SSO_CONFIG` to that host file's absolute path and | ||
| 421 | `KNIFE_ARCHIVE_STORE_ID` to the identity stored in the NAS archive directory's | ||
| 422 | pre-existing `.knife-archive-store-id` file. The server never creates this marker; | ||
| 423 | missing or mismatched storage identity prevents catalog publication and local | ||
| 424 | payload eviction. Use the optional overlay: | ||
| 425 | |||
| 426 | ```sh | ||
| 427 | docker compose -f compose.yaml -f compose.sso.yaml up -d --no-build | ||
| 428 | ``` | ||
| 429 | |||
| 430 | The overlay mounts the private configuration read-only and refuses to create a | ||
| 431 | missing host file. Existing deployments can keep using the base Compose file. | ||
| 432 | |||
| 433 | Access credentials last fifteen minutes. Native devices renew with rotating | ||
| 434 | credentials kept in the operating system’s protected storage. By default, devices | ||
| 435 | must complete browser sign-in again after thirty days. The server revalidates the | ||
| 436 | issuer grant during renewal at least once per hour; issuer-side revocation is | ||
| 437 | noticed on that check, and previously issued access can last another fifteen | ||
| 438 | minutes. Local device revocation closes active connections and disables renewal. | ||
| 439 | |||
| 440 | If the issuer is unavailable or a device is lost, run this on the server using its | ||
| 441 | existing local state directory: | ||
| 442 | |||
| 443 | ```sh | ||
| 444 | t3 auth devices revoke-all | ||
| 445 | ``` | ||
| 446 | |||
| 447 | This local recovery command revokes SSO devices and sessions without contacting | ||
| 448 | the issuer. Existing pairing credentials remain valid. It does not change the | ||
| 449 | configured owner or revoke identity-provider accounts. |
docs/user/shared-server.md+20-10| ... | @@ -1,4 +1,16 @@ | ... | @@ -1,4 +1,16 @@ |
| 1 | # Shared accounts | 1 | # Self-hosted server |
| 2 | |||
| 3 | A self-hosted server gives your browser and desktop one address for your machines and archived chats. Each machine keeps its workspaces and running agents. T3 Connect remains optional. | ||
| 4 | |||
| 5 | ## Connect your machines | ||
| 6 | |||
| 7 | On a current Knife desktop, open **Connections**, choose **Self-hosted server**, and enter its HTTPS address. Sign in through the system browser. The desktop enrolls this machine and connects it to the server; private network addresses and transferred token files are unnecessary. Provider credentials stay on the machine unless you explicitly share them. | ||
| 8 | |||
| 9 | Open the same address in a web browser to sign in and see your machines. A sleeping machine stays listed. Synced chats can be settled, snoozed, woken, and pinned while it is offline. The machine must reconnect before it can run work; cleanup from settling waits until then. Disconnecting keeps unsent drafts and leaves direct connections and running agents in place. | ||
| 10 | |||
| 11 | Device access renews automatically until the server requires sign-in again. Revoking a device closes its connections and stops renewal. Existing manually paired machines keep their identities and saved chats; their pairing and `t3 shared hub` commands remain available for recovery. | ||
| 12 | |||
| 13 | ## Share agent accounts | ||
| 2 | 14 | ||
| 3 | A private T3 server can hold managed Codex and Claude accounts for your other hosts. Each host runs agents against its own workspaces. The shared server can run agents too. Provider homes and cached credentials live inside each host’s T3 home; existing CLI homes stay separate. This is opt-in and does not move native CLI history between machines. | 15 | A private T3 server can hold managed Codex and Claude accounts for your other hosts. Each host runs agents against its own workspaces. The shared server can run agents too. Provider homes and cached credentials live inside each host’s T3 home; existing CLI homes stay separate. This is opt-in and does not move native CLI history between machines. |
| 4 | 16 | ||
| ... | @@ -25,14 +37,12 @@ Use `t3 shared status` to list shared accounts, `t3 shared sync` for an immediat | ... | @@ -25,14 +37,12 @@ Use `t3 shared status` to list shared accounts, `t3 shared sync` for an immediat |
| 25 | 37 | ||
| 26 | ## Run in a container | 38 | ## Run in a container |
| 27 | 39 | ||
| 28 | Build from this checkout with `docker build -t the-knife .`. The image includes the web client, server, Codex, and Claude. Mount persistent private storage and your workspaces: | 40 | The image includes the web client, server, Codex, and Claude. Follow the [container and hosted sign-in setup](../operations/development.md#private-server-container), including the Compose SSO overlay, private configuration mount, and archive storage identity. The process runs as UID 1000; give it access to the mounted directories. Put an HTTPS reverse proxy in front of port 3773, including WebSocket upgrades, then connect clients using the server’s HTTPS address. Provider account sharing remains optional. Back up `/data` and `/home/node`, including their private credentials, independently of `/archives`. |
| 29 | 41 | ||
| 30 | ```sh | 42 | ## Offload archived chats |
| 31 | docker run --name the-knife -p 127.0.0.1:3773:3773 \ | 43 | |
| 32 | -e KNIFE_UPDATE_URL=https://files.example.com/public-builds/ \ | 44 | The container’s `T3CODE_ARCHIVE_DIR` points at the private archive mount. Signing a desktop into the self-hosted server also configures its background archive uploads while its local server runs. Keep the archive mount separate from the T3 state mount. Existing hosts using `t3 shared connect` and `T3CODE_ARCHIVE_OFFLOAD=true` keep their saved archive locations. |
| 33 | -v /private/t3:/data -v /private/provider-home:/home/node \ | 45 | |
| 34 | -v /private/archives:/archives \ | 46 | Archived chats upload automatically in the background. Message and tool bodies are removed locally only after verified upload; titles, projects, timeline indexes, and control metadata stay local. Opening a chat fetches its bodies without retaining them. Unarchiving restores the bodies and attachments before the chat can run again. Local attachment bytes stay while an active chat or fork needs them. Workspace media is copied into the archive; your original files are left to the normal workspace lifecycle. |
| 35 | -v /private/workspaces:/workspaces the-knife | ||
| 36 | ``` | ||
| 37 | 47 | ||
| 38 | The process runs as UID 1000; give it access to these directories. Put an HTTPS reverse proxy in front of port 3773, including WebSocket upgrades. Pair clients using the normal T3 pairing flow. Use `docker exec the-knife knife auth session issue …` for server-side account setup. Back up `/data` and `/home/node`, including their private credentials, independently of `/archives`. | 48 | Uploads retry automatically after failures, with delays increasing from one minute to one hour; the queue survives restarts. `t3 shared archive status` shows pending uploads and failures; `t3 shared archive retry [THREAD_ID]` requests an immediate attempt. A missing mount, failed verification, or concurrent chat change retains the local copy. Synced chats and attachments remain readable from the server while their source machine sleeps. Back up the archive mount: its verified objects become the authoritative copy of offloaded bodies. |
packages/client-runtime/package.json+4| ... | @@ -3,6 +3,10 @@ | ... | @@ -3,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./state/hub": { | ||
| 7 | "types": "./src/state/hub.ts", | ||
| 8 | "default": "./src/state/hub.ts" | ||
| 9 | }, | ||
| 6 | "./composerThreadItems": { | 10 | "./composerThreadItems": { |
| 7 | "types": "./src/composerThreadItems.ts", | 11 | "types": "./src/composerThreadItems.ts", |
| 8 | "default": "./src/composerThreadItems.ts" | 12 | "default": "./src/composerThreadItems.ts" |
packages/client-runtime/src/authorization/remote.test.ts+57| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | RemoteEnvironmentAuthInvalidJsonError, | 16 | RemoteEnvironmentAuthInvalidJsonError, |
| 17 | RemoteEnvironmentAuthTimeoutError, | 17 | RemoteEnvironmentAuthTimeoutError, |
| 18 | resolveRemoteWebSocketConnectionUrl, | 18 | resolveRemoteWebSocketConnectionUrl, |
| 19 | resolveRemoteDpopWebSocketConnectionUrl, | ||
| 19 | } from "./remote.ts"; | 20 | } from "./remote.ts"; |
| 20 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | 21 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; |
| 21 | import * as RpcHttp from "../rpc/http.ts"; | 22 | import * as RpcHttp from "../rpc/http.ts"; |
| ... | @@ -90,6 +91,62 @@ const expectFetchCall = ( | ... | @@ -90,6 +91,62 @@ const expectFetchCall = ( |
| 90 | }; | 91 | }; |
| 91 | 92 | ||
| 92 | describe("remote environment authorization", () => { | 93 | describe("remote environment authorization", () => { |
| 94 | it.effect("keeps prefixed descriptor, auth, proof, and socket URLs on one environment", () => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const base = "https://hub.example/prefix/environments/one/"; | ||
| 97 | const ticket = () => | ||
| 98 | Response.json({ ticket: "ws-ticket", expiresAt: "2026-05-01T12:05:00.000Z" }); | ||
| 99 | const fetch = recordedFetch( | ||
| 100 | Response.json({ | ||
| 101 | environmentId: "one", | ||
| 102 | label: "One", | ||
| 103 | platform: { os: "linux", arch: "x64" }, | ||
| 104 | serverVersion: "test", | ||
| 105 | capabilities: { repositoryIdentity: true }, | ||
| 106 | }), | ||
| 107 | Response.json({ | ||
| 108 | access_token: "access", | ||
| 109 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 110 | token_type: "DPoP", | ||
| 111 | expires_in: 3600, | ||
| 112 | scope: "orchestration:read", | ||
| 113 | }), | ||
| 114 | ticket(), | ||
| 115 | ticket(), | ||
| 116 | ); | ||
| 117 | yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: base }).pipe( | ||
| 118 | provideRemoteHttp(fetch.fetchFn), | ||
| 119 | ); | ||
| 120 | yield* exchangeRemoteDpopAccessToken({ | ||
| 121 | httpBaseUrl: base, | ||
| 122 | credential: "bootstrap", | ||
| 123 | dpopProof: "proof", | ||
| 124 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 125 | const bearerSocket = yield* resolveRemoteWebSocketConnectionUrl({ | ||
| 126 | httpBaseUrl: base, | ||
| 127 | wsBaseUrl: base.replace("https:", "wss:"), | ||
| 128 | bearerToken: "bearer", | ||
| 129 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 130 | const dpopSocket = yield* resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 131 | httpBaseUrl: base, | ||
| 132 | wsBaseUrl: base.replace("https:", "wss:"), | ||
| 133 | accessToken: "access", | ||
| 134 | dpopProof: "proof", | ||
| 135 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 136 | expect(fetch.calls.map(([url]) => String(url))).toEqual([ | ||
| 137 | `${base}.well-known/t3/environment`, | ||
| 138 | `${base}oauth/token`, | ||
| 139 | `${base}api/auth/websocket-ticket`, | ||
| 140 | `${base}api/auth/websocket-ticket`, | ||
| 141 | ]); | ||
| 142 | const urls = RpcHttp.makeEnvironmentHttpApiUrlBuilder(base); | ||
| 143 | expect(urls.auth.token()).toBe(`${base}oauth/token`); | ||
| 144 | expect(urls.auth.webSocketTicket()).toBe(`${base}api/auth/websocket-ticket`); | ||
| 145 | expect(bearerSocket).toBe("wss://hub.example/prefix/environments/one/ws?wsTicket=ws-ticket"); | ||
| 146 | expect(dpopSocket).toBe(bearerSocket); | ||
| 147 | }), | ||
| 148 | ); | ||
| 149 | |||
| 93 | it.effect("bootstraps bearer auth against a remote backend", () => | 150 | it.effect("bootstraps bearer auth against a remote backend", () => |
| 94 | Effect.gen(function* () { | 151 | Effect.gen(function* () { |
| 95 | const fetch = recordedFetch( | 152 | const fetch = recordedFetch( |
packages/client-runtime/src/authorization/remote.ts+3-9| ... | @@ -8,7 +8,7 @@ import { | ... | @@ -8,7 +8,7 @@ import { |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 9 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 10 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | 11 | import { environmentEndpointUrl, environmentWebSocketUrl } from "../environment/endpoint.ts"; |
| 12 | import { | 12 | import { |
| 13 | executeEnvironmentHttpRequest, | 13 | executeEnvironmentHttpRequest, |
| 14 | makeEnvironmentHttpApiGroupClient, | 14 | makeEnvironmentHttpApiGroupClient, |
| ... | @@ -214,10 +214,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ... | @@ -214,10 +214,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 214 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | 214 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), |
| 215 | }); | 215 | }); |
| 216 | 216 | ||
| 217 | const url = new URL(input.wsBaseUrl); | 217 | const url = new URL(environmentWebSocketUrl(input.wsBaseUrl)); |
| 218 | if (url.pathname === "" || url.pathname === "/") { | ||
| 219 | url.pathname = "/ws"; | ||
| 220 | } | ||
| 221 | url.searchParams.set("wsTicket", issued.ticket); | 218 | url.searchParams.set("wsTicket", issued.ticket); |
| 222 | appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); | 219 | appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); |
| 223 | return url.toString(); | 220 | return url.toString(); |
| ... | @@ -240,10 +237,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( | ... | @@ -240,10 +237,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 240 | dpopProof: input.dpopProof, | 237 | dpopProof: input.dpopProof, |
| 241 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | 238 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), |
| 242 | }); | 239 | }); |
| 243 | const url = new URL(input.wsBaseUrl); | 240 | const url = new URL(environmentWebSocketUrl(input.wsBaseUrl)); |
| 244 | if (url.pathname === "" || url.pathname === "/") { | ||
| 245 | url.pathname = "/ws"; | ||
| 246 | } | ||
| 247 | url.searchParams.set("wsTicket", issued.ticket); | 241 | url.searchParams.set("wsTicket", issued.ticket); |
| 248 | appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); | 242 | appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); |
| 249 | return url.toString(); | 243 | return url.toString(); |
packages/client-runtime/src/connection/catalog.ts+19-3| ... | @@ -32,7 +32,7 @@ export class BearerConnectionProfile extends Schema.TaggedClass<BearerConnection | ... | @@ -32,7 +32,7 @@ export class BearerConnectionProfile extends Schema.TaggedClass<BearerConnection |
| 32 | * "t3-connect" when the route authenticates with the environment's T3 | 32 | * "t3-connect" when the route authenticates with the environment's T3 |
| 33 | * Connect credential instead of a stored bearer token. | 33 | * Connect credential instead of a stored bearer token. |
| 34 | */ | 34 | */ |
| 35 | authorization: Schema.optionalKey(Schema.Literal("t3-connect")), | 35 | authorization: Schema.optionalKey(Schema.Literals(["t3-connect", "hosted"])), |
| 36 | /** | 36 | /** |
| 37 | * "tailscale" on a learned route the server found on its Tailscale | 37 | * "tailscale" on a learned route the server found on its Tailscale |
| 38 | * interface. A bare 100.64.0.0/10 address could belong to any VPN. | 38 | * interface. A bare 100.64.0.0/10 address could belong to any VPN. |
| ... | @@ -82,7 +82,23 @@ export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnect | ... | @@ -82,7 +82,23 @@ export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnect |
| 82 | }, | 82 | }, |
| 83 | ) {} | 83 | ) {} |
| 84 | 84 | ||
| 85 | export const ConnectionCredential = Schema.Union([BearerConnectionCredential]); | 85 | export class HubConnectionCredential extends Schema.TaggedClass<HubConnectionCredential>()( |
| 86 | "HubConnectionCredential", | ||
| 87 | { | ||
| 88 | connectionId: Schema.String, | ||
| 89 | }, | ||
| 90 | ) {} | ||
| 91 | |||
| 92 | export class HostedConnectionCredential extends Schema.TaggedClass<HostedConnectionCredential>()( | ||
| 93 | "HostedConnectionCredential", | ||
| 94 | { serverUrl: Schema.String }, | ||
| 95 | ) {} | ||
| 96 | |||
| 97 | export const ConnectionCredential = Schema.Union([ | ||
| 98 | BearerConnectionCredential, | ||
| 99 | HubConnectionCredential, | ||
| 100 | HostedConnectionCredential, | ||
| 101 | ]); | ||
| 86 | export type ConnectionCredential = typeof ConnectionCredential.Type; | 102 | export type ConnectionCredential = typeof ConnectionCredential.Type; |
| 87 | 103 | ||
| 88 | export class PrimaryConnectionRegistration extends Schema.TaggedClass<PrimaryConnectionRegistration>()( | 104 | export class PrimaryConnectionRegistration extends Schema.TaggedClass<PrimaryConnectionRegistration>()( |
| ... | @@ -104,7 +120,7 @@ export class BearerConnectionRegistration extends Schema.TaggedClass<BearerConne | ... | @@ -104,7 +120,7 @@ export class BearerConnectionRegistration extends Schema.TaggedClass<BearerConne |
| 104 | { | 120 | { |
| 105 | target: BearerConnectionTarget, | 121 | target: BearerConnectionTarget, |
| 106 | profile: BearerConnectionProfile, | 122 | profile: BearerConnectionProfile, |
| 107 | credential: BearerConnectionCredential, | 123 | credential: ConnectionCredential, |
| 108 | }, | 124 | }, |
| 109 | ) {} | 125 | ) {} |
| 110 | 126 |
packages/client-runtime/src/connection/hub.test.ts created+575| ... | @@ -0,0 +1,575 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | AuthStandardClientScopes, | ||
| 4 | CommandId, | ||
| 5 | EnvironmentId, | ||
| 6 | HubInbox, | ||
| 7 | HubInboxCommand, | ||
| 8 | HubInboxEnvironment, | ||
| 9 | ThreadId, | ||
| 10 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 19 | |||
| 20 | import * as Hub from "./hub.ts"; | ||
| 21 | import * as Registry from "./registry.ts"; | ||
| 22 | import * as Driver from "./driver.ts"; | ||
| 23 | import * as Connectivity from "./connectivity.ts"; | ||
| 24 | import * as Wakeups from "./wakeups.ts"; | ||
| 25 | import * as Profiles from "./profileStore.ts"; | ||
| 26 | import * as Credentials from "./credentialStore.ts"; | ||
| 27 | import * as Capabilities from "../platform/capabilities.ts"; | ||
| 28 | import * as Persistence from "../platform/persistence.ts"; | ||
| 29 | import * as Http from "../rpc/http.ts"; | ||
| 30 | import { | ||
| 31 | BearerConnectionCredential, | ||
| 32 | HostedConnectionCredential, | ||
| 33 | BearerConnectionProfile, | ||
| 34 | BearerConnectionRegistration, | ||
| 35 | RelayConnectionRegistration, | ||
| 36 | type ConnectionCredential, | ||
| 37 | type ConnectionProfile, | ||
| 38 | } from "./catalog.ts"; | ||
| 39 | import { BearerConnectionTarget, RelayConnectionTarget, ConnectionBlockedError } from "./model.ts"; | ||
| 40 | import { connectionRoutes, connectionRouteId } from "./routes.ts"; | ||
| 41 | import { v2Project, v2ThreadShell } from "../state/orchestrationV2TestFixtures.ts"; | ||
| 42 | |||
| 43 | const HUB_ID = EnvironmentId.make("hub"); | ||
| 44 | const BASE = "https://hub.example/knife/"; | ||
| 45 | const encodeInbox = Schema.encodeSync(Schema.toCodecJson(HubInbox)); | ||
| 46 | const encodeInboxEnvironment = Schema.encodeSync(Schema.toCodecJson(HubInboxEnvironment)); | ||
| 47 | const decodeInboxCommand = Schema.decodeUnknownSync(Schema.toCodecJson(HubInboxCommand)); | ||
| 48 | const descriptor = (id: string) => ({ | ||
| 49 | environmentId: EnvironmentId.make(id), | ||
| 50 | label: id, | ||
| 51 | platform: { os: "linux" as const, arch: "x64" as const }, | ||
| 52 | serverVersion: "test", | ||
| 53 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | ||
| 54 | capabilities: { repositoryIdentity: true }, | ||
| 55 | }); | ||
| 56 | |||
| 57 | function harness() { | ||
| 58 | const profiles = new Map<string, ConnectionProfile>(); | ||
| 59 | const credentials = new Map<string, ConnectionCredential>(); | ||
| 60 | const requests: Array<{ | ||
| 61 | url: string; | ||
| 62 | authorization: string | null; | ||
| 63 | method: string; | ||
| 64 | etag: string | null; | ||
| 65 | }> = []; | ||
| 66 | const writes: Array<string> = []; | ||
| 67 | const commands: Array<HubInboxCommand> = []; | ||
| 68 | const cleanups: Array<{ id: EnvironmentId; retainDrafts?: boolean }> = []; | ||
| 69 | const control = { | ||
| 70 | machines: ["laptop", "desktop"], | ||
| 71 | offline: false, | ||
| 72 | badAddress: false, | ||
| 73 | protocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | ||
| 74 | inboxStatus: 200, | ||
| 75 | notModified: false, | ||
| 76 | inboxEtag: '"inbox-v1"', | ||
| 77 | inboxSequence: 5, | ||
| 78 | token: "hub-secret", | ||
| 79 | hostedAuthorization: "Bearer hosted-ephemeral" as string | null, | ||
| 80 | failProfileWrite: false, | ||
| 81 | failCredentialWrite: false, | ||
| 82 | }; | ||
| 83 | const dependencies = Layer.mergeAll( | ||
| 84 | Layer.succeed(Capabilities.HostedAuthAccess, { | ||
| 85 | headers: () => | ||
| 86 | Effect.sync(() => | ||
| 87 | control.hostedAuthorization === null | ||
| 88 | ? { "x-csrf-token": "cookie-proof" } | ||
| 89 | : { authorization: control.hostedAuthorization }, | ||
| 90 | ), | ||
| 91 | }), | ||
| 92 | Http.layerRemoteHttpClient((async (input, init) => { | ||
| 93 | const url = String(input); | ||
| 94 | requests.push({ | ||
| 95 | url, | ||
| 96 | authorization: new Headers(init?.headers).get("authorization"), | ||
| 97 | method: init?.method ?? "GET", | ||
| 98 | etag: new Headers(init?.headers).get("if-none-match"), | ||
| 99 | }); | ||
| 100 | if (url.endsWith("/.well-known/t3/environment")) return Response.json(descriptor(HUB_ID)); | ||
| 101 | if (url.endsWith("/oauth/token")) | ||
| 102 | return Response.json({ | ||
| 103 | access_token: control.token, | ||
| 104 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 105 | token_type: "Bearer", | ||
| 106 | expires_in: 3600, | ||
| 107 | scope: AuthStandardClientScopes.join(" "), | ||
| 108 | }); | ||
| 109 | if (url === `${BASE}api/hub/environments`) { | ||
| 110 | if (control.offline) throw new Error("offline"); | ||
| 111 | return Response.json({ | ||
| 112 | hubEnvironmentId: HUB_ID, | ||
| 113 | environments: control.machines.map((id) => ({ | ||
| 114 | ...descriptor(id), | ||
| 115 | descriptor: { | ||
| 116 | ...descriptor(id), | ||
| 117 | orchestrationProtocolVersion: control.protocolVersion, | ||
| 118 | }, | ||
| 119 | scopes: AuthStandardClientScopes, | ||
| 120 | httpBaseUrl: `${control.badAddress ? "https://elsewhere.example/" : BASE}environments/${id}/`, | ||
| 121 | })), | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | if (url === `${BASE}api/hub/inbox`) { | ||
| 125 | if (control.offline) throw new Error("offline"); | ||
| 126 | if (control.inboxStatus !== 200) return new Response(null, { status: control.inboxStatus }); | ||
| 127 | if ( | ||
| 128 | control.notModified && | ||
| 129 | init?.method !== "POST" && | ||
| 130 | new Headers(init?.headers).get("if-none-match") === control.inboxEtag | ||
| 131 | ) { | ||
| 132 | return new Response(null, { status: 304 }); | ||
| 133 | } | ||
| 134 | const entry: HubInboxEnvironment = { | ||
| 135 | environmentId: EnvironmentId.make("laptop"), | ||
| 136 | snapshot: { | ||
| 137 | schemaVersion: 2, | ||
| 138 | snapshotSequence: control.inboxSequence, | ||
| 139 | projects: [v2Project], | ||
| 140 | threads: [ | ||
| 141 | { | ||
| 142 | ...v2ThreadShell, | ||
| 143 | id: ThreadId.make("chat"), | ||
| 144 | lineage: { | ||
| 145 | rootThreadId: ThreadId.make("chat"), | ||
| 146 | parentThreadId: null, | ||
| 147 | relationshipToParent: null, | ||
| 148 | }, | ||
| 149 | snoozedAt: v2ThreadShell.updatedAt, | ||
| 150 | snoozedUntil: DateTime.makeUnsafe("2030-01-01T00:00:00.000Z"), | ||
| 151 | }, | ||
| 152 | ], | ||
| 153 | archivedThreads: [], | ||
| 154 | }, | ||
| 155 | pendingThreadIds: [], | ||
| 156 | }; | ||
| 157 | if (init?.method === "POST") { | ||
| 158 | commands.push(decodeInboxCommand(JSON.parse(await new Response(init.body).text()))); | ||
| 159 | } | ||
| 160 | const body = | ||
| 161 | init?.method === "POST" | ||
| 162 | ? encodeInboxEnvironment(entry) | ||
| 163 | : encodeInbox({ environments: [entry] }); | ||
| 164 | return Response.json(body, { | ||
| 165 | headers: { etag: control.inboxEtag }, | ||
| 166 | }); | ||
| 167 | } | ||
| 168 | throw new Error(`Unexpected URL ${url}`); | ||
| 169 | }) satisfies typeof fetch), | ||
| 170 | Layer.succeed(Capabilities.ClientPresentation, { | ||
| 171 | metadata: { label: "Phone", deviceType: "mobile", surface: "mobile" }, | ||
| 172 | }), | ||
| 173 | Layer.succeed(Profiles.ConnectionProfileStore, { | ||
| 174 | get: (id) => Effect.succeed(Option.fromUndefinedOr(profiles.get(id))), | ||
| 175 | put: (profile) => | ||
| 176 | Effect.suspend(() => | ||
| 177 | control.failProfileWrite | ||
| 178 | ? Effect.fail( | ||
| 179 | new ConnectionBlockedError({ | ||
| 180 | reason: "configuration", | ||
| 181 | detail: "Profile storage unavailable", | ||
| 182 | }), | ||
| 183 | ) | ||
| 184 | : Effect.sync(() => { | ||
| 185 | profiles.set(profile.connectionId, profile); | ||
| 186 | }), | ||
| 187 | ), | ||
| 188 | remove: (id) => | ||
| 189 | Effect.sync(() => { | ||
| 190 | profiles.delete(id); | ||
| 191 | }), | ||
| 192 | }), | ||
| 193 | Layer.succeed(Credentials.ConnectionCredentialStore, { | ||
| 194 | get: (id) => Effect.succeed(Option.fromUndefinedOr(credentials.get(id))), | ||
| 195 | put: (id, credential) => | ||
| 196 | Effect.suspend(() => | ||
| 197 | control.failCredentialWrite | ||
| 198 | ? Effect.fail( | ||
| 199 | new ConnectionBlockedError({ | ||
| 200 | reason: "configuration", | ||
| 201 | detail: "Credential storage unavailable", | ||
| 202 | }), | ||
| 203 | ) | ||
| 204 | : Effect.sync(() => { | ||
| 205 | credentials.set(id, credential); | ||
| 206 | }), | ||
| 207 | ), | ||
| 208 | remove: (id) => | ||
| 209 | Effect.sync(() => { | ||
| 210 | credentials.delete(id); | ||
| 211 | }), | ||
| 212 | }), | ||
| 213 | Layer.mock(Persistence.ConnectionTargetStore)({ | ||
| 214 | list: Effect.succeed([]), | ||
| 215 | listDisabled: Effect.succeed([]), | ||
| 216 | }), | ||
| 217 | Layer.mock(Persistence.ConnectionRegistrationStore)({ | ||
| 218 | register: (registration) => | ||
| 219 | Effect.sync(() => { | ||
| 220 | writes.push(registration.target.environmentId); | ||
| 221 | if (registration._tag === "BearerConnectionRegistration") { | ||
| 222 | profiles.set(registration.profile.connectionId, registration.profile); | ||
| 223 | credentials.set(registration.target.connectionId, registration.credential); | ||
| 224 | } | ||
| 225 | }), | ||
| 226 | setRoutes: () => Effect.void, | ||
| 227 | remove: () => Effect.void, | ||
| 228 | setEnabled: () => Effect.void, | ||
| 229 | }), | ||
| 230 | Layer.mock(Persistence.EnvironmentCacheStore)({ clear: () => Effect.void }), | ||
| 231 | Layer.succeed(Persistence.EnvironmentOwnedDataCleanup, { | ||
| 232 | clear: (id, options) => | ||
| 233 | Effect.sync(() => { | ||
| 234 | cleanups.push({ id, ...options }); | ||
| 235 | }), | ||
| 236 | }), | ||
| 237 | Layer.mock(Capabilities.SshEnvironmentGateway)({}), | ||
| 238 | Layer.succeed(Connectivity.Connectivity, { | ||
| 239 | status: Effect.succeed("online" as const), | ||
| 240 | changes: Stream.never, | ||
| 241 | }), | ||
| 242 | Layer.succeed(Wakeups.ConnectionWakeups, { changes: Stream.never }), | ||
| 243 | Layer.mock(Driver.ConnectionDriver)({ connect: () => Effect.never }), | ||
| 244 | ); | ||
| 245 | const registry = Registry.layer.pipe(Layer.provideMerge(dependencies)); | ||
| 246 | return { | ||
| 247 | profiles, | ||
| 248 | credentials, | ||
| 249 | requests, | ||
| 250 | writes, | ||
| 251 | commands, | ||
| 252 | cleanups, | ||
| 253 | control, | ||
| 254 | layer: Hub.layer.pipe(Layer.provideMerge(registry)), | ||
| 255 | }; | ||
| 256 | } | ||
| 257 | |||
| 258 | describe("self-hosted server connections", () => { | ||
| 259 | it.effect( | ||
| 260 | "stores only a server marker for SSO and renews native headers through the broker", | ||
| 261 | () => { | ||
| 262 | const test = harness(); | ||
| 263 | return Effect.gen(function* () { | ||
| 264 | const hub = yield* Hub.HubConnection; | ||
| 265 | yield* hub.connectAuthenticated({ httpBaseUrl: BASE }); | ||
| 266 | expect(test.credentials.get(`hub-session:${HUB_ID}`)).toEqual( | ||
| 267 | new HostedConnectionCredential({ serverUrl: BASE }), | ||
| 268 | ); | ||
| 269 | expect(test.requests.some((request) => request.url.endsWith("/oauth/token"))).toBe(false); | ||
| 270 | expect(yield* hub.headers).toEqual({ authorization: "Bearer hosted-ephemeral" }); | ||
| 271 | test.control.hostedAuthorization = "Bearer renewed-ephemeral"; | ||
| 272 | yield* hub.refreshInbox; | ||
| 273 | expect(test.requests.at(-1)?.authorization).toBe("Bearer renewed-ephemeral"); | ||
| 274 | test.control.hostedAuthorization = null; | ||
| 275 | expect(yield* hub.headers).toEqual({ "x-csrf-token": "cookie-proof" }); | ||
| 276 | yield* hub.refreshInbox; | ||
| 277 | expect(test.requests.at(-1)?.authorization).toBe(null); | ||
| 278 | }).pipe(Effect.provide(test.layer)); | ||
| 279 | }, | ||
| 280 | ); | ||
| 281 | |||
| 282 | it.effect("preserves paired access if SSO connection validation or persistence fails", () => { | ||
| 283 | const test = harness(); | ||
| 284 | return Effect.gen(function* () { | ||
| 285 | const hub = yield* Hub.HubConnection; | ||
| 286 | yield* hub.connect({ host: BASE, pairingCode: "code" }); | ||
| 287 | const previous = test.credentials.get(`hub-session:${HUB_ID}`); | ||
| 288 | test.control.offline = true; | ||
| 289 | yield* Effect.option(hub.connectAuthenticated({ httpBaseUrl: BASE })); | ||
| 290 | expect(test.credentials.get(`hub-session:${HUB_ID}`)).toEqual(previous); | ||
| 291 | test.control.offline = false; | ||
| 292 | test.control.failProfileWrite = true; | ||
| 293 | yield* Effect.option(hub.connectAuthenticated({ httpBaseUrl: BASE })); | ||
| 294 | expect(test.credentials.get(`hub-session:${HUB_ID}`)).toEqual(previous); | ||
| 295 | }).pipe(Effect.provide(test.layer)); | ||
| 296 | }); | ||
| 297 | |||
| 298 | it.effect("round-trips timestamped inbox snapshots and organization commands as JSON", () => { | ||
| 299 | const test = harness(); | ||
| 300 | return Effect.gen(function* () { | ||
| 301 | const hub = yield* Hub.HubConnection; | ||
| 302 | yield* hub.connect({ host: BASE, pairingCode: "code" }); | ||
| 303 | const thread = (yield* SubscriptionRef.get(hub.state)).inbox.environments[0]?.snapshot | ||
| 304 | .threads[0]; | ||
| 305 | expect(DateTime.formatIso(thread!.createdAt)).toBe("2026-06-20T00:00:00.000Z"); | ||
| 306 | expect(DateTime.formatIso(thread!.snoozedUntil!)).toBe("2030-01-01T00:00:00.000Z"); | ||
| 307 | const settledAt = DateTime.makeUnsafe("2026-06-21T12:34:56.000Z"); | ||
| 308 | yield* hub.organize({ | ||
| 309 | environmentId: EnvironmentId.make("laptop"), | ||
| 310 | command: { | ||
| 311 | type: "thread.settle", | ||
| 312 | commandId: CommandId.make("settle-with-timestamp"), | ||
| 313 | threadId: ThreadId.make("chat"), | ||
| 314 | settledAt, | ||
| 315 | }, | ||
| 316 | }); | ||
| 317 | expect(test.commands).toEqual([ | ||
| 318 | { | ||
| 319 | environmentId: EnvironmentId.make("laptop"), | ||
| 320 | command: { | ||
| 321 | type: "thread.settle", | ||
| 322 | commandId: CommandId.make("settle-with-timestamp"), | ||
| 323 | threadId: ThreadId.make("chat"), | ||
| 324 | settledAt, | ||
| 325 | }, | ||
| 326 | }, | ||
| 327 | ]); | ||
| 328 | const updated = (yield* SubscriptionRef.get(hub.state)).inbox.environments[0]?.snapshot | ||
| 329 | .threads[0]; | ||
| 330 | expect(DateTime.formatIso(updated!.updatedAt)).toBe("2026-06-20T00:00:00.000Z"); | ||
| 331 | expect(DateTime.formatIso(updated!.snoozedAt!)).toBe("2026-06-20T00:00:00.000Z"); | ||
| 332 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 333 | }); | ||
| 334 | it.effect("accepts organization through the hub while the source has no connection", () => { | ||
| 335 | const test = harness(); | ||
| 336 | return Effect.gen(function* () { | ||
| 337 | const hub = yield* Hub.HubConnection; | ||
| 338 | yield* hub.connect({ host: BASE, pairingCode: "code" }); | ||
| 339 | const result = yield* hub.organize({ | ||
| 340 | environmentId: EnvironmentId.make("laptop"), | ||
| 341 | command: { | ||
| 342 | type: "thread.settle", | ||
| 343 | commandId: CommandId.make("settle-offline"), | ||
| 344 | threadId: ThreadId.make("chat"), | ||
| 345 | }, | ||
| 346 | }); | ||
| 347 | expect(Option.getOrNull(result)).toEqual({ sequence: 5 }); | ||
| 348 | expect(test.requests.at(-1)).toEqual({ | ||
| 349 | url: `${BASE}api/hub/inbox`, | ||
| 350 | authorization: "Bearer hub-secret", | ||
| 351 | method: "POST", | ||
| 352 | etag: null, | ||
| 353 | }); | ||
| 354 | const direct = yield* hub.organize({ | ||
| 355 | environmentId: EnvironmentId.make("direct-only"), | ||
| 356 | command: { | ||
| 357 | type: "thread.unpin", | ||
| 358 | commandId: CommandId.make("unpin-direct"), | ||
| 359 | threadId: ThreadId.make("chat"), | ||
| 360 | }, | ||
| 361 | }); | ||
| 362 | expect(Option.isNone(direct)).toBe(true); | ||
| 363 | test.control.inboxStatus = 403; | ||
| 364 | expect( | ||
| 365 | (yield* Effect.result( | ||
| 366 | hub.organize({ | ||
| 367 | environmentId: EnvironmentId.make("laptop"), | ||
| 368 | command: { | ||
| 369 | type: "thread.pin", | ||
| 370 | commandId: CommandId.make("denied-pin"), | ||
| 371 | threadId: ThreadId.make("chat"), | ||
| 372 | }, | ||
| 373 | }), | ||
| 374 | ))._tag, | ||
| 375 | ).toBe("Failure"); | ||
| 376 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 377 | }); | ||
| 378 | it.effect("revalidates unchanged inbox metadata without replacing the cached snapshot", () => { | ||
| 379 | const test = harness(); | ||
| 380 | return Effect.gen(function* () { | ||
| 381 | const hub = yield* Hub.HubConnection; | ||
| 382 | yield* hub.connect({ host: BASE, pairingCode: "code" }); | ||
| 383 | const inbox = (yield* SubscriptionRef.get(hub.state)).inbox; | ||
| 384 | test.control.notModified = true; | ||
| 385 | yield* hub.refreshInbox; | ||
| 386 | expect(test.requests.at(-1)?.etag).toBe('"inbox-v1"'); | ||
| 387 | expect((yield* SubscriptionRef.get(hub.state)).inbox).toBe(inbox); | ||
| 388 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 389 | }); | ||
| 390 | it.effect("reuses the validated body after a reconnect fails to persist its profile", () => { | ||
| 391 | const test = harness(); | ||
| 392 | return Effect.gen(function* () { | ||
| 393 | const hub = yield* Hub.HubConnection; | ||
| 394 | yield* hub.connect({ host: BASE, pairingCode: "code" }); | ||
| 395 | test.control.inboxSequence = 6; | ||
| 396 | test.control.inboxEtag = '"inbox-v2"'; | ||
| 397 | test.control.token = "new-account-token"; | ||
| 398 | test.control.failProfileWrite = true; | ||
| 399 | yield* hub.connect({ host: BASE, pairingCode: "new-code" }).pipe(Effect.flip); | ||
| 400 | expect( | ||
| 401 | (yield* SubscriptionRef.get(hub.state)).inbox.environments[0]?.snapshot.snapshotSequence, | ||
| 402 | ).toBe(5); | ||
| 403 | test.control.notModified = true; | ||
| 404 | yield* hub.refreshInbox; | ||
| 405 | expect(test.requests.at(-1)?.etag).toBe('"inbox-v2"'); | ||
| 406 | expect( | ||
| 407 | (yield* SubscriptionRef.get(hub.state)).inbox.environments[0]?.snapshot.snapshotSequence, | ||
| 408 | ).toBe(6); | ||
| 409 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 410 | }); | ||
| 411 | |||
| 412 | it.effect( | ||
| 413 | "never sends a validator fetched with another credential after failed pairing persistence", | ||
| 414 | () => { | ||
| 415 | const test = harness(); | ||
| 416 | return Effect.gen(function* () { | ||
| 417 | const hub = yield* Hub.HubConnection; | ||
| 418 | yield* hub.connect({ host: BASE, pairingCode: "code" }); | ||
| 419 | test.control.inboxSequence = 6; | ||
| 420 | test.control.inboxEtag = '"inbox-v2"'; | ||
| 421 | test.control.token = "new-account-token"; | ||
| 422 | test.control.failCredentialWrite = true; | ||
| 423 | yield* hub.connect({ host: BASE, pairingCode: "new-code" }).pipe(Effect.flip); | ||
| 424 | test.control.notModified = true; | ||
| 425 | yield* hub.refreshInbox; | ||
| 426 | expect(test.requests.at(-1)?.authorization).toBe("Bearer hub-secret"); | ||
| 427 | expect(test.requests.at(-1)?.etag).toBeNull(); | ||
| 428 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 429 | }, | ||
| 430 | ); | ||
| 431 | it.effect( | ||
| 432 | "clears an outdated machine's compatibility rejection after its advertised protocol updates", | ||
| 433 | () => { | ||
| 434 | const test = harness(); | ||
| 435 | test.control.protocolVersion = ORCHESTRATION_PROTOCOL_VERSION - 1; | ||
| 436 | return Effect.gen(function* () { | ||
| 437 | const hub = yield* Hub.HubConnection; | ||
| 438 | const registry = yield* Registry.EnvironmentRegistry; | ||
| 439 | yield* hub.connect({ host: BASE, pairingCode: "code" }); | ||
| 440 | const id = EnvironmentId.make("laptop"); | ||
| 441 | expect( | ||
| 442 | (yield* SubscriptionRef.get(registry.entries)).get(id)?.unsupportedReason, | ||
| 443 | ).toBeDefined(); | ||
| 444 | test.control.protocolVersion = ORCHESTRATION_PROTOCOL_VERSION; | ||
| 445 | yield* hub.refresh; | ||
| 446 | expect( | ||
| 447 | (yield* SubscriptionRef.get(registry.entries)).get(id)?.unsupportedReason, | ||
| 448 | ).toBeUndefined(); | ||
| 449 | yield* registry.setEnabled(id, true); | ||
| 450 | expect((yield* SubscriptionRef.get(registry.entries)).get(id)?.enabled).toBe(true); | ||
| 451 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 452 | }, | ||
| 453 | ); | ||
| 454 | it.effect( | ||
| 455 | "pairs once for two machines, links one secret, and leaves unchanged routes running on refresh", | ||
| 456 | () => { | ||
| 457 | const test = harness(); | ||
| 458 | return Effect.gen(function* () { | ||
| 459 | const hub = yield* Hub.HubConnection; | ||
| 460 | const registry = yield* Registry.EnvironmentRegistry; | ||
| 461 | yield* hub.connect({ host: BASE, pairingCode: "one-use-code" }); | ||
| 462 | const entries = yield* SubscriptionRef.get(registry.entries); | ||
| 463 | expect([...entries.keys()]).toEqual(["laptop", "desktop"]); | ||
| 464 | expect( | ||
| 465 | test.requests.filter((request) => request.url.endsWith("/oauth/token")), | ||
| 466 | ).toHaveLength(1); | ||
| 467 | expect( | ||
| 468 | [...test.credentials.values()].filter( | ||
| 469 | (value) => value._tag === "BearerConnectionCredential", | ||
| 470 | ), | ||
| 471 | ).toEqual([new BearerConnectionCredential({ token: "hub-secret" })]); | ||
| 472 | expect(test.credentials.get("hub:hub:laptop")).toMatchObject({ | ||
| 473 | _tag: "HubConnectionCredential", | ||
| 474 | connectionId: "hub-session:hub", | ||
| 475 | }); | ||
| 476 | expect( | ||
| 477 | test.requests.find((request) => request.url.endsWith("/api/hub/environments")) | ||
| 478 | ?.authorization, | ||
| 479 | ).toBe("Bearer hub-secret"); | ||
| 480 | yield* hub.refresh; | ||
| 481 | expect(test.writes).toEqual(["laptop", "desktop"]); | ||
| 482 | expect(yield* SubscriptionRef.get(registry.entries)).toBe(entries); | ||
| 483 | const restored = yield* Hub.make; | ||
| 484 | expect((yield* SubscriptionRef.get(restored.state)).connection?.httpBaseUrl).toBe(BASE); | ||
| 485 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 486 | }, | ||
| 487 | ); | ||
| 488 | |||
| 489 | it.effect( | ||
| 490 | "keeps saved machines during an outage and discovers later enrollment on refresh", | ||
| 491 | () => { | ||
| 492 | const test = harness(); | ||
| 493 | return Effect.gen(function* () { | ||
| 494 | const hub = yield* Hub.HubConnection; | ||
| 495 | const registry = yield* Registry.EnvironmentRegistry; | ||
| 496 | yield* hub.connect({ host: BASE, pairingCode: "one-use-code" }); | ||
| 497 | const entries = yield* SubscriptionRef.get(registry.entries); | ||
| 498 | test.control.offline = true; | ||
| 499 | expect((yield* Effect.result(hub.refresh))._tag).toBe("Failure"); | ||
| 500 | expect(yield* SubscriptionRef.get(registry.entries)).toBe(entries); | ||
| 501 | expect((yield* SubscriptionRef.get(hub.state)).error).toContain("Couldn't reach"); | ||
| 502 | test.control.offline = false; | ||
| 503 | test.control.machines.push("new-machine"); | ||
| 504 | yield* hub.refresh; | ||
| 505 | expect([...(yield* SubscriptionRef.get(registry.entries)).keys()]).toEqual([ | ||
| 506 | "laptop", | ||
| 507 | "desktop", | ||
| 508 | "new-machine", | ||
| 509 | ]); | ||
| 510 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 511 | }, | ||
| 512 | ); | ||
| 513 | |||
| 514 | it.effect( | ||
| 515 | "rejects a directory that sends machine traffic outside the paired origin without saving credentials", | ||
| 516 | () => { | ||
| 517 | const test = harness(); | ||
| 518 | test.control.badAddress = true; | ||
| 519 | return Effect.gen(function* () { | ||
| 520 | const hub = yield* Hub.HubConnection; | ||
| 521 | const registry = yield* Registry.EnvironmentRegistry; | ||
| 522 | expect((yield* Effect.result(hub.connect({ host: BASE, pairingCode: "code" })))._tag).toBe( | ||
| 523 | "Failure", | ||
| 524 | ); | ||
| 525 | expect(test.credentials.size).toBe(0); | ||
| 526 | expect((yield* SubscriptionRef.get(registry.entries)).size).toBe(0); | ||
| 527 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 528 | }, | ||
| 529 | ); | ||
| 530 | |||
| 531 | it.effect( | ||
| 532 | "disconnects only hub-owned routes, preserving directly paired and T3 Connect routes", | ||
| 533 | () => { | ||
| 534 | const test = harness(); | ||
| 535 | return Effect.gen(function* () { | ||
| 536 | const hub = yield* Hub.HubConnection; | ||
| 537 | const registry = yield* Registry.EnvironmentRegistry; | ||
| 538 | yield* hub.connect({ host: BASE, pairingCode: "code" }); | ||
| 539 | const id = EnvironmentId.make("laptop"); | ||
| 540 | const connectionId = "direct-laptop"; | ||
| 541 | yield* registry.register( | ||
| 542 | new BearerConnectionRegistration({ | ||
| 543 | target: new BearerConnectionTarget({ | ||
| 544 | environmentId: id, | ||
| 545 | label: "Laptop", | ||
| 546 | connectionId, | ||
| 547 | }), | ||
| 548 | profile: new BearerConnectionProfile({ | ||
| 549 | environmentId: id, | ||
| 550 | label: "Laptop", | ||
| 551 | connectionId, | ||
| 552 | httpBaseUrl: "https://laptop.example/", | ||
| 553 | wsBaseUrl: "wss://laptop.example/", | ||
| 554 | }), | ||
| 555 | credential: new BearerConnectionCredential({ token: "direct-secret" }), | ||
| 556 | }), | ||
| 557 | ); | ||
| 558 | yield* registry.register( | ||
| 559 | new RelayConnectionRegistration({ | ||
| 560 | target: new RelayConnectionTarget({ environmentId: id, label: "Laptop" }), | ||
| 561 | }), | ||
| 562 | ); | ||
| 563 | yield* hub.disconnect; | ||
| 564 | const entries = yield* SubscriptionRef.get(registry.entries); | ||
| 565 | expect([...entries.keys()]).toEqual([id]); | ||
| 566 | expect( | ||
| 567 | connectionRoutes(entries.get(id)!).map((route) => connectionRouteId(route.target)), | ||
| 568 | ).toEqual([connectionId, "relay"]); | ||
| 569 | expect(test.cleanups).toEqual([{ id: "desktop", retainDrafts: true }]); | ||
| 570 | expect(test.credentials.has("hub-session:hub")).toBe(false); | ||
| 571 | expect((yield* SubscriptionRef.get(hub.state)).connection).toBeNull(); | ||
| 572 | }).pipe(Effect.provide(test.layer), Effect.scoped); | ||
| 573 | }, | ||
| 574 | ); | ||
| 575 | }); | ||
packages/client-runtime/src/connection/hub.ts created+580| ... | @@ -0,0 +1,580 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | HubDirectory, | ||
| 4 | HubInbox, | ||
| 5 | HubInboxCommand, | ||
| 6 | HubInboxEnvironment, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Equal from "effect/Equal"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Schema from "effect/Schema"; | ||
| 14 | import * as Semaphore from "effect/Semaphore"; | ||
| 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 16 | import { HttpBody, HttpClient } from "effect/http"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | BearerConnectionCredential, | ||
| 20 | BearerConnectionProfile, | ||
| 21 | BearerConnectionRegistration, | ||
| 22 | HubConnectionCredential, | ||
| 23 | HostedConnectionCredential, | ||
| 24 | } from "./catalog.ts"; | ||
| 25 | import { | ||
| 26 | BearerConnectionTarget, | ||
| 27 | ConnectionBlockedError, | ||
| 28 | ConnectionTransientError, | ||
| 29 | } from "./model.ts"; | ||
| 30 | import type { ConnectionAttemptError } from "./model.ts"; | ||
| 31 | import { preparePairingRegistration, type PairingConnectionInput } from "./onboarding.ts"; | ||
| 32 | import * as CredentialStore from "./credentialStore.ts"; | ||
| 33 | import * as ProfileStore from "./profileStore.ts"; | ||
| 34 | import * as Registry from "./registry.ts"; | ||
| 35 | import { connectionRouteId, connectionRoutes } from "./routes.ts"; | ||
| 36 | import { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; | ||
| 37 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | ||
| 38 | import { deriveWsBaseUrl, environmentEndpointUrl } from "../environment/endpoint.ts"; | ||
| 39 | import * as ClientCapabilities from "../platform/capabilities.ts"; | ||
| 40 | |||
| 41 | const HUB_PROFILE_ID = "self-hosted-server"; | ||
| 42 | const isConnectionAttemptError = Schema.is( | ||
| 43 | Schema.Union([ConnectionBlockedError, ConnectionTransientError]), | ||
| 44 | ); | ||
| 45 | const encodeInboxCommand = Schema.encodeEffect(Schema.toCodecJson(HubInboxCommand)); | ||
| 46 | const decodeInbox = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInbox)); | ||
| 47 | const decodeInboxEnvironment = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInboxEnvironment)); | ||
| 48 | |||
| 49 | export interface HubServerConnection { | ||
| 50 | readonly environmentId: EnvironmentId; | ||
| 51 | readonly label: string; | ||
| 52 | readonly httpBaseUrl: string; | ||
| 53 | readonly authorization?: "t3-connect" | "hosted"; | ||
| 54 | } | ||
| 55 | |||
| 56 | export interface HubConnectionState { | ||
| 57 | readonly connection: HubServerConnection | null; | ||
| 58 | readonly directory: HubDirectory | null; | ||
| 59 | readonly inbox: HubInbox; | ||
| 60 | readonly refreshing: boolean; | ||
| 61 | readonly error: string | null; | ||
| 62 | } | ||
| 63 | |||
| 64 | export const EMPTY_HUB_CONNECTION_STATE: HubConnectionState = { | ||
| 65 | connection: null, | ||
| 66 | directory: null, | ||
| 67 | inbox: { environments: [] }, | ||
| 68 | refreshing: false, | ||
| 69 | error: null, | ||
| 70 | }; | ||
| 71 | |||
| 72 | export class HubConnection extends Context.Service< | ||
| 73 | HubConnection, | ||
| 74 | { | ||
| 75 | readonly state: SubscriptionRef.SubscriptionRef<HubConnectionState>; | ||
| 76 | readonly connect: ( | ||
| 77 | input: PairingConnectionInput, | ||
| 78 | ) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 79 | readonly connectAuthenticated: (input: { | ||
| 80 | readonly httpBaseUrl: string; | ||
| 81 | }) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 82 | readonly headers: Effect.Effect<Record<string, string>, ConnectionAttemptError>; | ||
| 83 | readonly refresh: Effect.Effect<void, ConnectionAttemptError>; | ||
| 84 | readonly refreshInbox: Effect.Effect<void, ConnectionAttemptError>; | ||
| 85 | readonly organize: ( | ||
| 86 | input: HubInboxCommand, | ||
| 87 | ) => Effect.Effect<Option.Option<{ readonly sequence: number }>, ConnectionAttemptError>; | ||
| 88 | readonly disconnect: Effect.Effect<void, ConnectionAttemptError>; | ||
| 89 | } | ||
| 90 | >()("@t3tools/client-runtime/connection/hub/HubConnection") {} | ||
| 91 | |||
| 92 | const hubCredentialId = (environmentId: EnvironmentId) => `hub-session:${environmentId}`; | ||
| 93 | const hubRoutePrefix = (environmentId: EnvironmentId) => `hub:${environmentId}:`; | ||
| 94 | |||
| 95 | export const make = Effect.gen(function* () { | ||
| 96 | const profiles = yield* ProfileStore.ConnectionProfileStore; | ||
| 97 | const credentials = yield* CredentialStore.ConnectionCredentialStore; | ||
| 98 | const registry = yield* Registry.EnvironmentRegistry; | ||
| 99 | const http = yield* HttpClient.HttpClient; | ||
| 100 | const presentation = yield* ClientCapabilities.ClientPresentation; | ||
| 101 | const lock = yield* Semaphore.make(1); | ||
| 102 | const saved = Option.getOrNull(yield* profiles.get(HUB_PROFILE_ID)); | ||
| 103 | const connection = saved?._tag === "BearerConnectionProfile" ? saved : null; | ||
| 104 | const state = yield* SubscriptionRef.make<HubConnectionState>({ | ||
| 105 | ...EMPTY_HUB_CONNECTION_STATE, | ||
| 106 | connection, | ||
| 107 | }); | ||
| 108 | let inboxCache: | ||
| 109 | | { | ||
| 110 | readonly url: string; | ||
| 111 | readonly token: string; | ||
| 112 | readonly etag: string | undefined; | ||
| 113 | readonly inbox: HubInbox; | ||
| 114 | } | ||
| 115 | | undefined; | ||
| 116 | |||
| 117 | const currentCredential = Effect.fn("HubConnection.currentCredential")(function* ( | ||
| 118 | profile: HubServerConnection, | ||
| 119 | ) { | ||
| 120 | const credential = Option.getOrNull( | ||
| 121 | yield* credentials.get(hubCredentialId(profile.environmentId)), | ||
| 122 | ); | ||
| 123 | if ( | ||
| 124 | credential?._tag !== "BearerConnectionCredential" && | ||
| 125 | credential?._tag !== "HostedConnectionCredential" | ||
| 126 | ) { | ||
| 127 | return yield* new ConnectionBlockedError({ | ||
| 128 | reason: "authentication", | ||
| 129 | detail: "Sign in to this server to restore access.", | ||
| 130 | }); | ||
| 131 | } | ||
| 132 | return credential; | ||
| 133 | }); | ||
| 134 | |||
| 135 | const headersFor = (credential: BearerConnectionCredential | HostedConnectionCredential) => | ||
| 136 | credential._tag === "BearerConnectionCredential" | ||
| 137 | ? Effect.succeed({ authorization: `Bearer ${credential.token}` } as Record<string, string>) | ||
| 138 | : Effect.gen(function* () { | ||
| 139 | const access = yield* Effect.serviceOption(ClientCapabilities.HostedAuthAccess); | ||
| 140 | if (Option.isNone(access)) | ||
| 141 | return yield* new ConnectionBlockedError({ | ||
| 142 | reason: "authentication", | ||
| 143 | detail: "Sign in to this server to reconnect.", | ||
| 144 | }); | ||
| 145 | return yield* access.value.headers(credential.serverUrl); | ||
| 146 | }); | ||
| 147 | |||
| 148 | const requestInbox = Effect.fn("HubConnection.inbox")( | ||
| 149 | function* ( | ||
| 150 | profile: HubServerConnection, | ||
| 151 | credential: BearerConnectionCredential | HostedConnectionCredential, | ||
| 152 | ) { | ||
| 153 | const url = environmentEndpointUrl(profile.httpBaseUrl, "/api/hub/inbox"); | ||
| 154 | const headers = yield* headersFor(credential); | ||
| 155 | const cacheIdentity = headers.authorization ?? headers["x-csrf-token"] ?? "cookie"; | ||
| 156 | const cached = | ||
| 157 | inboxCache?.url === url && inboxCache.token === cacheIdentity ? inboxCache : undefined; | ||
| 158 | const response = yield* http.get(url, { | ||
| 159 | headers: { | ||
| 160 | ...headers, | ||
| 161 | ...(cached?.etag === undefined ? {} : { "if-none-match": cached.etag }), | ||
| 162 | }, | ||
| 163 | }); | ||
| 164 | if (response.status === 304 && cached?.etag !== undefined) return cached.inbox; | ||
| 165 | if (response.status === 401 || response.status === 403) { | ||
| 166 | return yield* new ConnectionBlockedError({ | ||
| 167 | reason: "authentication", | ||
| 168 | detail: "Sign in to this server to restore access.", | ||
| 169 | }); | ||
| 170 | } | ||
| 171 | if (response.status !== 200) { | ||
| 172 | return yield* new ConnectionTransientError({ | ||
| 173 | reason: "remote-unavailable", | ||
| 174 | detail: "Couldn't load chats from this server. Try again.", | ||
| 175 | }); | ||
| 176 | } | ||
| 177 | const inbox = yield* response.json.pipe(Effect.flatMap(decodeInbox)); | ||
| 178 | inboxCache = { url, token: cacheIdentity, etag: response.headers.etag, inbox }; | ||
| 179 | return inbox; | ||
| 180 | }, | ||
| 181 | Effect.timeout("15 seconds"), | ||
| 182 | Effect.mapError((error) => | ||
| 183 | isConnectionAttemptError(error) | ||
| 184 | ? error | ||
| 185 | : new ConnectionTransientError({ | ||
| 186 | reason: "remote-unavailable", | ||
| 187 | detail: "Couldn't load chats from this server. Try again.", | ||
| 188 | }), | ||
| 189 | ), | ||
| 190 | ); | ||
| 191 | |||
| 192 | const requestDirectory = Effect.fn("HubConnection.directory")( | ||
| 193 | function* ( | ||
| 194 | profile: HubServerConnection, | ||
| 195 | credential: BearerConnectionCredential | HostedConnectionCredential, | ||
| 196 | ) { | ||
| 197 | const response = yield* http | ||
| 198 | .get(environmentEndpointUrl(profile.httpBaseUrl, "/api/hub/environments"), { | ||
| 199 | headers: yield* headersFor(credential), | ||
| 200 | }) | ||
| 201 | .pipe( | ||
| 202 | Effect.mapError( | ||
| 203 | () => | ||
| 204 | new ConnectionTransientError({ | ||
| 205 | reason: "remote-unavailable", | ||
| 206 | detail: "Couldn't reach this server. Check the address and try again.", | ||
| 207 | }), | ||
| 208 | ), | ||
| 209 | ); | ||
| 210 | if (response.status === 401 || response.status === 403) { | ||
| 211 | return yield* new ConnectionBlockedError({ | ||
| 212 | reason: "authentication", | ||
| 213 | detail: "Sign in to this server to restore access.", | ||
| 214 | }); | ||
| 215 | } | ||
| 216 | if (response.status === 404) { | ||
| 217 | return yield* new ConnectionBlockedError({ | ||
| 218 | reason: "unsupported", | ||
| 219 | detail: "This server can't share other machines. Pair it as a single machine.", | ||
| 220 | }); | ||
| 221 | } | ||
| 222 | if (response.status !== 200) { | ||
| 223 | return yield* new ConnectionTransientError({ | ||
| 224 | reason: "remote-unavailable", | ||
| 225 | detail: "The server is unavailable. Try again.", | ||
| 226 | }); | ||
| 227 | } | ||
| 228 | const directory = yield* response.json.pipe( | ||
| 229 | Effect.flatMap(Schema.decodeUnknownEffect(HubDirectory)), | ||
| 230 | Effect.mapError( | ||
| 231 | () => | ||
| 232 | new ConnectionBlockedError({ | ||
| 233 | reason: "configuration", | ||
| 234 | detail: "The server returned an invalid machine list.", | ||
| 235 | }), | ||
| 236 | ), | ||
| 237 | ); | ||
| 238 | if (directory.hubEnvironmentId !== profile.environmentId) { | ||
| 239 | return yield* new ConnectionBlockedError({ | ||
| 240 | reason: "configuration", | ||
| 241 | detail: "This address reaches a different server. Check the address and pair again.", | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | const seen = new Set<EnvironmentId>(); | ||
| 245 | for (const machine of directory.environments) { | ||
| 246 | const expected = environmentEndpointUrl( | ||
| 247 | profile.httpBaseUrl, | ||
| 248 | `/environments/${encodeURIComponent(machine.environmentId)}/`, | ||
| 249 | ); | ||
| 250 | if ( | ||
| 251 | seen.has(machine.environmentId) || | ||
| 252 | machine.descriptor.environmentId !== machine.environmentId || | ||
| 253 | machine.httpBaseUrl !== expected | ||
| 254 | ) { | ||
| 255 | return yield* new ConnectionBlockedError({ | ||
| 256 | reason: "configuration", | ||
| 257 | detail: "The server returned an invalid machine address.", | ||
| 258 | }); | ||
| 259 | } | ||
| 260 | seen.add(machine.environmentId); | ||
| 261 | } | ||
| 262 | return directory; | ||
| 263 | }, | ||
| 264 | Effect.timeoutOrElse({ | ||
| 265 | duration: "15 seconds", | ||
| 266 | orElse: () => | ||
| 267 | Effect.fail( | ||
| 268 | new ConnectionTransientError({ | ||
| 269 | reason: "timeout", | ||
| 270 | detail: "The server took too long to respond. Try again.", | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | |||
| 276 | const removeRoutes = Effect.fn("HubConnection.removeRoutes")(function* ( | ||
| 277 | environmentId: EnvironmentId, | ||
| 278 | keep: ReadonlySet<string> = new Set(), | ||
| 279 | ) { | ||
| 280 | const entries = yield* SubscriptionRef.get(registry.entries); | ||
| 281 | for (const [id, entry] of entries) { | ||
| 282 | for (const route of connectionRoutes(entry)) { | ||
| 283 | const routeId = connectionRouteId(route.target); | ||
| 284 | if (routeId.startsWith(hubRoutePrefix(environmentId)) && !keep.has(routeId)) { | ||
| 285 | yield* registry.removeRoute(id, routeId, { retainDrafts: true }); | ||
| 286 | } | ||
| 287 | } | ||
| 288 | } | ||
| 289 | }); | ||
| 290 | |||
| 291 | const reconcile = Effect.fn("HubConnection.reconcile")(function* ( | ||
| 292 | profile: HubServerConnection, | ||
| 293 | directory: HubDirectory, | ||
| 294 | ) { | ||
| 295 | const keep = new Set<string>(); | ||
| 296 | for (const machine of directory.environments) { | ||
| 297 | const connectionId = `${hubRoutePrefix(profile.environmentId)}${machine.environmentId}`; | ||
| 298 | keep.add(connectionId); | ||
| 299 | const registration = new BearerConnectionRegistration({ | ||
| 300 | target: new BearerConnectionTarget({ | ||
| 301 | connectionId, | ||
| 302 | environmentId: machine.environmentId, | ||
| 303 | label: machine.label, | ||
| 304 | }), | ||
| 305 | profile: new BearerConnectionProfile({ | ||
| 306 | connectionId, | ||
| 307 | environmentId: machine.environmentId, | ||
| 308 | label: machine.label, | ||
| 309 | httpBaseUrl: machine.httpBaseUrl, | ||
| 310 | wsBaseUrl: deriveWsBaseUrl(machine.httpBaseUrl), | ||
| 311 | }), | ||
| 312 | credential: new HubConnectionCredential({ | ||
| 313 | connectionId: hubCredentialId(profile.environmentId), | ||
| 314 | }), | ||
| 315 | }); | ||
| 316 | const entry = (yield* SubscriptionRef.get(registry.entries)).get(machine.environmentId); | ||
| 317 | const unchanged = | ||
| 318 | entry !== undefined && | ||
| 319 | connectionRoutes(entry).some( | ||
| 320 | (route) => | ||
| 321 | Equal.equals(route.target, registration.target) && | ||
| 322 | Option.isSome(route.profile) && | ||
| 323 | Equal.equals(route.profile.value, registration.profile), | ||
| 324 | ); | ||
| 325 | if (!unchanged) yield* registry.register(registration); | ||
| 326 | const current = (yield* SubscriptionRef.get(registry.entries)).get(machine.environmentId); | ||
| 327 | if ( | ||
| 328 | current !== undefined && | ||
| 329 | connectionRoutes(current).every((route) => connectionRouteId(route.target) === connectionId) | ||
| 330 | ) { | ||
| 331 | yield* registry.setCompatibility( | ||
| 332 | machine.environmentId, | ||
| 333 | orchestrationProtocolCompatibilityError(machine.descriptor), | ||
| 334 | ); | ||
| 335 | } | ||
| 336 | } | ||
| 337 | yield* removeRoutes(profile.environmentId, keep); | ||
| 338 | }); | ||
| 339 | |||
| 340 | const reportError = (error: { readonly message: string }) => | ||
| 341 | SubscriptionRef.update(state, (current) => ({ ...current, error: error.message })); | ||
| 342 | const finish = SubscriptionRef.update(state, (current) => ({ ...current, refreshing: false })); | ||
| 343 | const mapPersistence = (error: { readonly message: string }) => | ||
| 344 | new ConnectionBlockedError({ reason: "configuration", detail: error.message }); | ||
| 345 | |||
| 346 | const refresh = lock.withPermits(1)( | ||
| 347 | Effect.gen(function* () { | ||
| 348 | const current = yield* SubscriptionRef.get(state); | ||
| 349 | if (current.connection === null) return; | ||
| 350 | yield* SubscriptionRef.update(state, (value) => ({ | ||
| 351 | ...value, | ||
| 352 | refreshing: true, | ||
| 353 | error: null, | ||
| 354 | })); | ||
| 355 | const credential = yield* currentCredential(current.connection); | ||
| 356 | const directory = yield* requestDirectory(current.connection, credential); | ||
| 357 | yield* reconcile(current.connection, directory).pipe(Effect.mapError(mapPersistence)); | ||
| 358 | const inbox = yield* requestInbox(current.connection, credential); | ||
| 359 | yield* SubscriptionRef.update(state, (value) => ({ ...value, directory, inbox })); | ||
| 360 | }).pipe(Effect.tapError(reportError), Effect.ensuring(finish)), | ||
| 361 | ); | ||
| 362 | |||
| 363 | const connect = (input: PairingConnectionInput) => | ||
| 364 | lock.withPermits(1)( | ||
| 365 | Effect.gen(function* () { | ||
| 366 | yield* SubscriptionRef.update(state, (value) => ({ | ||
| 367 | ...value, | ||
| 368 | refreshing: true, | ||
| 369 | error: null, | ||
| 370 | })); | ||
| 371 | const registration = yield* preparePairingRegistration(input).pipe( | ||
| 372 | Effect.provideService(ClientCapabilities.ClientPresentation, presentation), | ||
| 373 | Effect.provideService(HttpClient.HttpClient, http), | ||
| 374 | Effect.mapError((error) => | ||
| 375 | error._tag === "ConnectionBlockedError" && error.reason === "authentication" | ||
| 376 | ? new ConnectionBlockedError({ | ||
| 377 | reason: "authentication", | ||
| 378 | detail: | ||
| 379 | "The pairing code is invalid or expired. Create a new code on the server.", | ||
| 380 | }) | ||
| 381 | : error, | ||
| 382 | ), | ||
| 383 | ); | ||
| 384 | const profile = registration.profile; | ||
| 385 | const credential = registration.credential; | ||
| 386 | if (credential._tag !== "BearerConnectionCredential") | ||
| 387 | return yield* new ConnectionBlockedError({ | ||
| 388 | reason: "configuration", | ||
| 389 | detail: "The pairing credential is invalid.", | ||
| 390 | }); | ||
| 391 | const directory = yield* requestDirectory(profile, credential); | ||
| 392 | inboxCache = undefined; | ||
| 393 | const inbox = yield* requestInbox(profile, credential); | ||
| 394 | const previous = (yield* SubscriptionRef.get(state)).connection; | ||
| 395 | yield* credentials.put(hubCredentialId(profile.environmentId), credential); | ||
| 396 | yield* reconcile(profile, directory).pipe(Effect.mapError(mapPersistence)); | ||
| 397 | yield* profiles.put( | ||
| 398 | new BearerConnectionProfile({ ...profile, connectionId: HUB_PROFILE_ID }), | ||
| 399 | ); | ||
| 400 | yield* SubscriptionRef.set(state, { | ||
| 401 | connection: profile, | ||
| 402 | directory, | ||
| 403 | inbox, | ||
| 404 | refreshing: false, | ||
| 405 | error: null, | ||
| 406 | }); | ||
| 407 | for (const machine of directory.environments) { | ||
| 408 | yield* registry.retryNow(machine.environmentId); | ||
| 409 | } | ||
| 410 | if (previous !== null && previous.environmentId !== profile.environmentId) { | ||
| 411 | yield* removeRoutes(previous.environmentId).pipe(Effect.mapError(mapPersistence)); | ||
| 412 | yield* credentials.remove(hubCredentialId(previous.environmentId)); | ||
| 413 | } | ||
| 414 | }).pipe(Effect.tapError(reportError), Effect.ensuring(finish)), | ||
| 415 | ); | ||
| 416 | |||
| 417 | const connectAuthenticated = (input: { readonly httpBaseUrl: string }) => | ||
| 418 | lock.withPermits(1)( | ||
| 419 | Effect.gen(function* () { | ||
| 420 | const descriptor = yield* fetchRemoteEnvironmentDescriptor(input).pipe( | ||
| 421 | Effect.provideService(HttpClient.HttpClient, http), | ||
| 422 | Effect.mapError(mapPersistence), | ||
| 423 | ); | ||
| 424 | const credential = new HostedConnectionCredential({ serverUrl: input.httpBaseUrl }); | ||
| 425 | const profile = new BearerConnectionProfile({ | ||
| 426 | connectionId: HUB_PROFILE_ID, | ||
| 427 | authorization: "hosted", | ||
| 428 | environmentId: descriptor.environmentId, | ||
| 429 | label: descriptor.label, | ||
| 430 | httpBaseUrl: input.httpBaseUrl, | ||
| 431 | wsBaseUrl: deriveWsBaseUrl(input.httpBaseUrl), | ||
| 432 | }); | ||
| 433 | const directory = yield* requestDirectory(profile, credential); | ||
| 434 | inboxCache = undefined; | ||
| 435 | const inbox = yield* requestInbox(profile, credential); | ||
| 436 | const previous = (yield* SubscriptionRef.get(state)).connection; | ||
| 437 | const credentialId = hubCredentialId(profile.environmentId); | ||
| 438 | const previousCredential = yield* credentials.get(credentialId); | ||
| 439 | let credentialWritten = false; | ||
| 440 | yield* Effect.gen(function* () { | ||
| 441 | yield* credentials.put(credentialId, credential); | ||
| 442 | credentialWritten = true; | ||
| 443 | yield* reconcile(profile, directory).pipe(Effect.mapError(mapPersistence)); | ||
| 444 | yield* profiles.put(profile); | ||
| 445 | }).pipe( | ||
| 446 | Effect.tapError(() => | ||
| 447 | !credentialWritten | ||
| 448 | ? Effect.void | ||
| 449 | : Option.isSome(previousCredential) | ||
| 450 | ? credentials.put(credentialId, previousCredential.value) | ||
| 451 | : credentials.remove(credentialId), | ||
| 452 | ), | ||
| 453 | ); | ||
| 454 | |||
| 455 | yield* SubscriptionRef.set(state, { | ||
| 456 | connection: profile, | ||
| 457 | directory, | ||
| 458 | inbox, | ||
| 459 | refreshing: false, | ||
| 460 | error: null, | ||
| 461 | }); | ||
| 462 | if (previous !== null && previous.environmentId !== profile.environmentId) { | ||
| 463 | yield* removeRoutes(previous.environmentId).pipe(Effect.mapError(mapPersistence)); | ||
| 464 | yield* credentials.remove(hubCredentialId(previous.environmentId)); | ||
| 465 | } | ||
| 466 | }).pipe(Effect.tapError(reportError), Effect.ensuring(finish)), | ||
| 467 | ); | ||
| 468 | |||
| 469 | const disconnect = lock.withPermits(1)( | ||
| 470 | Effect.gen(function* () { | ||
| 471 | const current = (yield* SubscriptionRef.get(state)).connection; | ||
| 472 | if (current === null) return; | ||
| 473 | yield* removeRoutes(current.environmentId).pipe(Effect.mapError(mapPersistence)); | ||
| 474 | yield* profiles.remove(HUB_PROFILE_ID); | ||
| 475 | yield* credentials.remove(hubCredentialId(current.environmentId)); | ||
| 476 | inboxCache = undefined; | ||
| 477 | yield* SubscriptionRef.set(state, EMPTY_HUB_CONNECTION_STATE); | ||
| 478 | }).pipe(Effect.tapError(reportError)), | ||
| 479 | ); | ||
| 480 | |||
| 481 | const refreshInbox = lock.withPermit( | ||
| 482 | Effect.gen(function* () { | ||
| 483 | const current = yield* SubscriptionRef.get(state); | ||
| 484 | if (current.connection === null) return; | ||
| 485 | const credential = yield* currentCredential(current.connection); | ||
| 486 | const inbox = yield* requestInbox(current.connection, credential); | ||
| 487 | yield* SubscriptionRef.update(state, (value) => ({ ...value, inbox, error: null })); | ||
| 488 | }).pipe(Effect.tapError(reportError)), | ||
| 489 | ); | ||
| 490 | |||
| 491 | const organize = (input: HubInboxCommand) => | ||
| 492 | lock.withPermit( | ||
| 493 | Effect.gen(function* () { | ||
| 494 | const current = yield* SubscriptionRef.get(state); | ||
| 495 | if ( | ||
| 496 | current.connection === null || | ||
| 497 | !current.directory?.environments.some( | ||
| 498 | (entry) => entry.environmentId === input.environmentId, | ||
| 499 | ) | ||
| 500 | ) | ||
| 501 | return Option.none(); | ||
| 502 | const credential = yield* currentCredential(current.connection); | ||
| 503 | const encoded = yield* encodeInboxCommand(input); | ||
| 504 | const response = yield* http.post( | ||
| 505 | environmentEndpointUrl(current.connection.httpBaseUrl, "/api/hub/inbox"), | ||
| 506 | { | ||
| 507 | headers: yield* headersFor(credential), | ||
| 508 | body: HttpBody.jsonUnsafe(encoded), | ||
| 509 | }, | ||
| 510 | ); | ||
| 511 | if (response.status === 401 || response.status === 403) { | ||
| 512 | return yield* new ConnectionBlockedError({ | ||
| 513 | reason: "authentication", | ||
| 514 | detail: | ||
| 515 | "This server connection cannot change chats. Pair again with access to change threads.", | ||
| 516 | }); | ||
| 517 | } | ||
| 518 | if (response.status !== 200) { | ||
| 519 | return yield* new ConnectionTransientError({ | ||
| 520 | reason: "remote-unavailable", | ||
| 521 | detail: "Couldn't save this chat change on the server. Try again.", | ||
| 522 | }); | ||
| 523 | } | ||
| 524 | const entry = yield* response.json.pipe(Effect.flatMap(decodeInboxEnvironment)); | ||
| 525 | if (entry.environmentId !== input.environmentId) { | ||
| 526 | return yield* new ConnectionBlockedError({ | ||
| 527 | reason: "configuration", | ||
| 528 | detail: "The server returned a different machine. Refresh and try again.", | ||
| 529 | }); | ||
| 530 | } | ||
| 531 | inboxCache = undefined; | ||
| 532 | yield* SubscriptionRef.update(state, (value) => ({ | ||
| 533 | ...value, | ||
| 534 | inbox: { | ||
| 535 | environments: [ | ||
| 536 | ...value.inbox.environments.filter( | ||
| 537 | (item) => item.environmentId !== entry.environmentId, | ||
| 538 | ), | ||
| 539 | entry, | ||
| 540 | ], | ||
| 541 | }, | ||
| 542 | error: null, | ||
| 543 | })); | ||
| 544 | return Option.some({ sequence: entry.snapshot.snapshotSequence }); | ||
| 545 | }).pipe( | ||
| 546 | Effect.timeout("15 seconds"), | ||
| 547 | Effect.mapError((error) => | ||
| 548 | isConnectionAttemptError(error) | ||
| 549 | ? error | ||
| 550 | : new ConnectionTransientError({ | ||
| 551 | reason: "remote-unavailable", | ||
| 552 | detail: "Couldn't save this chat change on the server. Try again.", | ||
| 553 | }), | ||
| 554 | ), | ||
| 555 | ), | ||
| 556 | ); | ||
| 557 | |||
| 558 | const headers = Effect.gen(function* () { | ||
| 559 | const connection = (yield* SubscriptionRef.get(state)).connection; | ||
| 560 | if (connection === null) | ||
| 561 | return yield* new ConnectionBlockedError({ | ||
| 562 | reason: "configuration", | ||
| 563 | detail: "Connect your server to open archived chats.", | ||
| 564 | }); | ||
| 565 | return yield* headersFor(yield* currentCredential(connection)); | ||
| 566 | }); | ||
| 567 | |||
| 568 | return HubConnection.of({ | ||
| 569 | state, | ||
| 570 | connect, | ||
| 571 | connectAuthenticated, | ||
| 572 | headers, | ||
| 573 | refresh, | ||
| 574 | refreshInbox, | ||
| 575 | organize, | ||
| 576 | disconnect, | ||
| 577 | }); | ||
| 578 | }); | ||
| 579 | |||
| 580 | export const layer = Layer.effect(HubConnection, make); | ||
packages/client-runtime/src/connection/index.ts+1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | export * from "./catalog.ts"; | 1 | export * from "./catalog.ts"; |
| 2 | export { HubConnection } from "./hub.ts"; | ||
| 2 | export * as Connectivity from "./connectivity.ts"; | 3 | export * as Connectivity from "./connectivity.ts"; |
| 3 | export * as CredentialStore from "./credentialStore.ts"; | 4 | export * as CredentialStore from "./credentialStore.ts"; |
| 4 | export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; | 5 | export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; |
packages/client-runtime/src/connection/layer.ts+15| ... | @@ -15,6 +15,8 @@ import * as PlatformConnectionSource from "../platform/source.ts"; | ... | @@ -15,6 +15,8 @@ import * as PlatformConnectionSource from "../platform/source.ts"; |
| 15 | import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; | 15 | import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; |
| 16 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | 16 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 17 | import * as RpcSession from "../rpc/session.ts"; | 17 | import * as RpcSession from "../rpc/session.ts"; |
| 18 | import * as Hub from "./hub.ts"; | ||
| 19 | import * as Wakeups from "./wakeups.ts"; | ||
| 18 | 20 | ||
| 19 | export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscoveredCompatibility")( | 21 | export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscoveredCompatibility")( |
| 20 | function* () { | 22 | function* () { |
| ... | @@ -78,10 +80,12 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { | ... | @@ -78,10 +80,12 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { |
| 78 | ); | 80 | ); |
| 79 | const layerRegistry = EnvironmentRegistry.layer.pipe(Layer.provide(layerDriver)); | 81 | const layerRegistry = EnvironmentRegistry.layer.pipe(Layer.provide(layerDriver)); |
| 80 | const layerOnboarding = ConnectionOnboarding.layer.pipe(Layer.provide(layerRegistry)); | 82 | const layerOnboarding = ConnectionOnboarding.layer.pipe(Layer.provide(layerRegistry)); |
| 83 | const layerHub = Hub.layer.pipe(Layer.provide(layerRegistry)); | ||
| 81 | const layerConnectionServices = Layer.mergeAll( | 84 | const layerConnectionServices = Layer.mergeAll( |
| 82 | layerRegistry, | 85 | layerRegistry, |
| 83 | RelayEnvironmentDiscovery.layer, | 86 | RelayEnvironmentDiscovery.layer, |
| 84 | layerOnboarding, | 87 | layerOnboarding, |
| 88 | layerHub, | ||
| 85 | // Exposed for updating hosts too old to connect through the driver. | 89 | // Exposed for updating hosts too old to connect through the driver. |
| 86 | ConnectionResolver.layer, | 90 | ConnectionResolver.layer, |
| 87 | ); | 91 | ); |
| ... | @@ -89,8 +93,19 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { | ... | @@ -89,8 +93,19 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { |
| 89 | Effect.gen(function* () { | 93 | Effect.gen(function* () { |
| 90 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | 94 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; |
| 91 | const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; | 95 | const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; |
| 96 | const hub = yield* Hub.HubConnection; | ||
| 97 | const wakeups = yield* Wakeups.ConnectionWakeups; | ||
| 92 | yield* watchDiscoveredCompatibility().pipe(Effect.forkScoped); | 98 | yield* watchDiscoveredCompatibility().pipe(Effect.forkScoped); |
| 93 | yield* registry.start; | 99 | yield* registry.start; |
| 100 | yield* Stream.merge(Stream.tick("1 minute"), wakeups.changes).pipe( | ||
| 101 | Stream.runForEach(() => hub.refresh.pipe(Effect.ignore)), | ||
| 102 | Effect.forkScoped, | ||
| 103 | ); | ||
| 104 | yield* hub.refresh.pipe(Effect.ignore, Effect.forkScoped); | ||
| 105 | yield* Stream.tick("15 seconds").pipe( | ||
| 106 | Stream.runForEach(() => hub.refreshInbox.pipe(Effect.ignore)), | ||
| 107 | Effect.forkScoped, | ||
| 108 | ); | ||
| 94 | yield* platformSource.registrations.pipe( | 109 | yield* platformSource.registrations.pipe( |
| 95 | Stream.runForEach(registry.reconcilePlatform), | 110 | Stream.runForEach(registry.reconcilePlatform), |
| 96 | Effect.forkScoped, | 111 | Effect.forkScoped, |
packages/client-runtime/src/connection/onboarding.test.ts+34-2| ... | @@ -271,6 +271,35 @@ describe("connection onboarding", () => { | ... | @@ -271,6 +271,35 @@ describe("connection onboarding", () => { |
| 271 | }), | 271 | }), |
| 272 | ); | 272 | ); |
| 273 | 273 | ||
| 274 | it.effect("keeps routes for different prefixes on the same hub distinct", () => | ||
| 275 | Effect.gen(function* () { | ||
| 276 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; | ||
| 277 | const registrations = []; | ||
| 278 | for (const name of ["one", "two"]) { | ||
| 279 | registrations.push( | ||
| 280 | yield* preparePairingRegistration({ | ||
| 281 | host: `https://hub.example/prefix/environments/${name}/`, | ||
| 282 | pairingCode: "pairing-token", | ||
| 283 | }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, layerPairingHttp(calls)))), | ||
| 284 | ); | ||
| 285 | } | ||
| 286 | expect(registrations.map((registration) => registration.target.connectionId)).toEqual([ | ||
| 287 | "bearer:environment-paired:https://hub.example/prefix/environments/one", | ||
| 288 | "bearer:environment-paired:https://hub.example/prefix/environments/two", | ||
| 289 | ]); | ||
| 290 | expect(registrations[0]?.profile).toMatchObject({ | ||
| 291 | httpBaseUrl: "https://hub.example/prefix/environments/one/", | ||
| 292 | wsBaseUrl: "wss://hub.example/prefix/environments/one/", | ||
| 293 | }); | ||
| 294 | expect(calls.map((call) => call.url)).toEqual([ | ||
| 295 | "https://hub.example/prefix/environments/one/.well-known/t3/environment", | ||
| 296 | "https://hub.example/prefix/environments/one/oauth/token", | ||
| 297 | "https://hub.example/prefix/environments/two/.well-known/t3/environment", | ||
| 298 | "https://hub.example/prefix/environments/two/oauth/token", | ||
| 299 | ]); | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | |||
| 274 | it.effect("turns a switched-off environment back on when it is paired again", () => | 303 | it.effect("turns a switched-off environment back on when it is paired again", () => |
| 275 | Effect.gen(function* () { | 304 | Effect.gen(function* () { |
| 276 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | 305 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; |
| ... | @@ -434,6 +463,9 @@ describe("connection onboarding", () => { | ... | @@ -434,6 +463,9 @@ describe("connection onboarding", () => { |
| 434 | pairingCode: "pairing-token", | 463 | pairingCode: "pairing-token", |
| 435 | }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, httpLayer))); | 464 | }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, httpLayer))); |
| 436 | 465 | ||
| 466 | if (registration.credential._tag !== "BearerConnectionCredential") { | ||
| 467 | throw new Error("Pairing must issue a bearer credential."); | ||
| 468 | } | ||
| 437 | const session = yield* fetchRemoteSessionState({ | 469 | const session = yield* fetchRemoteSessionState({ |
| 438 | httpBaseUrl: registration.profile.httpBaseUrl, | 470 | httpBaseUrl: registration.profile.httpBaseUrl, |
| 439 | bearerToken: registration.credential.token, | 471 | bearerToken: registration.credential.token, |
| ... | @@ -589,8 +621,8 @@ describe("connection onboarding", () => { | ... | @@ -589,8 +621,8 @@ describe("connection onboarding", () => { |
| 589 | profile: { | 621 | profile: { |
| 590 | environmentId, | 622 | environmentId, |
| 591 | label: "Renamed environment", | 623 | label: "Renamed environment", |
| 592 | httpBaseUrl: "http://100.65.180.100:3773/", | 624 | httpBaseUrl: "http://100.65.180.100:3773/path/", |
| 593 | wsBaseUrl: "ws://100.65.180.100:3773/", | 625 | wsBaseUrl: "ws://100.65.180.100:3773/path/", |
| 594 | }, | 626 | }, |
| 595 | credential: { token: "bearer-token" }, | 627 | credential: { token: "bearer-token" }, |
| 596 | }); | 628 | }); |
packages/client-runtime/src/connection/onboarding.ts+2-1| ... | @@ -98,7 +98,8 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso | ... | @@ -98,7 +98,8 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso |
| 98 | * reuses the id and replaces that route. | 98 | * reuses the id and replaces that route. |
| 99 | */ | 99 | */ |
| 100 | function bearerConnectionId(environmentId: EnvironmentId, httpBaseUrl: string): string { | 100 | function bearerConnectionId(environmentId: EnvironmentId, httpBaseUrl: string): string { |
| 101 | return `bearer:${environmentId}:${new URL(httpBaseUrl).origin}`; | 101 | const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); |
| 102 | return `bearer:${environmentId}:${url.origin}${url.pathname.replace(/\/+$/, "")}`; | ||
| 102 | } | 103 | } |
| 103 | 104 | ||
| 104 | function differentMachineError(label: string) { | 105 | function differentMachineError(label: string) { |
packages/client-runtime/src/connection/presentation.test.ts+16| ... | @@ -135,6 +135,22 @@ describe("connection presentation", () => { | ... | @@ -135,6 +135,22 @@ describe("connection presentation", () => { |
| 135 | ).toBe("https://tunnel.example.test/mcp"); | 135 | ).toBe("https://tunnel.example.test/mcp"); |
| 136 | }); | 136 | }); |
| 137 | 137 | ||
| 138 | it("keeps the selected environment prefix in MCP addresses", () => { | ||
| 139 | const entry = { | ||
| 140 | ...ENTRY, | ||
| 141 | profile: Option.some( | ||
| 142 | new BearerConnectionProfile({ | ||
| 143 | connectionId: TARGET.connectionId, | ||
| 144 | environmentId: TARGET.environmentId, | ||
| 145 | label: TARGET.label, | ||
| 146 | httpBaseUrl: "https://hub.example/prefix/environments/one/", | ||
| 147 | wsBaseUrl: "wss://hub.example/prefix/environments/one/", | ||
| 148 | }), | ||
| 149 | ), | ||
| 150 | }; | ||
| 151 | expect(environmentMcpUrl({ entry })).toBe("https://hub.example/prefix/environments/one/mcp"); | ||
| 152 | }); | ||
| 153 | |||
| 138 | it("distinguishes initial connection, reconnect, and retry errors", () => { | 154 | it("distinguishes initial connection, reconnect, and retry errors", () => { |
| 139 | expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ | 155 | expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ |
| 140 | phase: "connecting", | 156 | phase: "connecting", |
packages/client-runtime/src/connection/presentation.ts+2-6| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { ServerConfig } from "@t3tools/contracts"; | 1 | import type { ServerConfig } from "@t3tools/contracts"; |
| 2 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 3 | 3 | ||
| 4 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | ||
| 4 | import type { ConnectionCatalogEntry } from "./catalog.ts"; | 5 | import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 5 | import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts"; | 6 | import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts"; |
| 6 | import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; | 7 | import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; |
| ... | @@ -122,16 +123,11 @@ export function environmentMcpUrl(input: { | ... | @@ -122,16 +123,11 @@ export function environmentMcpUrl(input: { |
| 122 | } | 123 | } |
| 123 | 124 | ||
| 124 | function mcpUrlFromBase(httpBaseUrl: string): string | null { | 125 | function mcpUrlFromBase(httpBaseUrl: string): string | null { |
| 125 | let url: URL; | ||
| 126 | try { | 126 | try { |
| 127 | url = new URL(httpBaseUrl); | 127 | return environmentEndpointUrl(httpBaseUrl, "/mcp"); |
| 128 | } catch { | 128 | } catch { |
| 129 | return null; | 129 | return null; |
| 130 | } | 130 | } |
| 131 | url.pathname = "/mcp"; | ||
| 132 | url.search = ""; | ||
| 133 | url.hash = ""; | ||
| 134 | return url.toString(); | ||
| 135 | } | 131 | } |
| 136 | 132 | ||
| 137 | export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { | 133 | export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { |
packages/client-runtime/src/connection/registry.test.ts+10-7| ... | @@ -393,9 +393,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( | ... | @@ -393,9 +393,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 393 | ...PREPARED, | 393 | ...PREPARED, |
| 394 | environmentId: target.environmentId, | 394 | environmentId: target.environmentId, |
| 395 | label: target.label, | 395 | label: target.label, |
| 396 | httpAuthorization: Option.isSome(credential) | 396 | httpAuthorization: |
| 397 | ? { _tag: "Bearer", token: credential.value.token } | 397 | Option.isSome(credential) && credential.value._tag === "BearerConnectionCredential" |
| 398 | : null, | 398 | ? { _tag: "Bearer", token: credential.value.token } |
| 399 | : null, | ||
| 399 | target, | 400 | target, |
| 400 | }; | 401 | }; |
| 401 | if (options?.prepareError) return yield* options.prepareError; | 402 | if (options?.prepareError) return yield* options.prepareError; |
| ... | @@ -1375,11 +1376,13 @@ describe("EnvironmentRegistry", () => { | ... | @@ -1375,11 +1376,13 @@ describe("EnvironmentRegistry", () => { |
| 1375 | target._tag !== "BearerConnectionTarget" || storedCredentials === undefined | 1376 | target._tag !== "BearerConnectionTarget" || storedCredentials === undefined |
| 1376 | ? Effect.succeed(undefined) | 1377 | ? Effect.succeed(undefined) |
| 1377 | : Ref.get(storedCredentials).pipe( | 1378 | : Ref.get(storedCredentials).pipe( |
| 1378 | Effect.map((current) => | 1379 | Effect.map((current) => { |
| 1379 | current.get(target.connectionId)?.token === "old-bearer" | 1380 | const credential = current.get(target.connectionId); |
| 1381 | return credential?._tag === "BearerConnectionCredential" && | ||
| 1382 | credential.token === "old-bearer" | ||
| 1380 | ? new ConnectionBlockedError({ reason: "authentication", detail: "revoked" }) | 1383 | ? new ConnectionBlockedError({ reason: "authentication", detail: "revoked" }) |
| 1381 | : undefined, | 1384 | : undefined; |
| 1382 | ), | 1385 | }), |
| 1383 | ), | 1386 | ), |
| 1384 | }); | 1387 | }); |
| 1385 | storedCredentials = harness.storedCredentials; | 1388 | storedCredentials = harness.storedCredentials; |
packages/client-runtime/src/connection/registry.ts+18-3| ... | @@ -126,6 +126,7 @@ export class EnvironmentRegistry extends Context.Service< | ... | @@ -126,6 +126,7 @@ export class EnvironmentRegistry extends Context.Service< |
| 126 | readonly removeRoute: ( | 126 | readonly removeRoute: ( |
| 127 | environmentId: EnvironmentId, | 127 | environmentId: EnvironmentId, |
| 128 | routeId: string, | 128 | routeId: string, |
| 129 | options?: { readonly retainDrafts?: boolean }, | ||
| 129 | ) => Effect.Effect< | 130 | ) => Effect.Effect< |
| 130 | void, | 131 | void, |
| 131 | | Persistence.ConnectionPersistenceError | 132 | | Persistence.ConnectionPersistenceError |
| ... | @@ -728,7 +729,15 @@ export const make = Effect.gen(function* () { | ... | @@ -728,7 +729,15 @@ export const make = Effect.gen(function* () { |
| 728 | state.lastFailure.reason === "authentication") | 729 | state.lastFailure.reason === "authentication") |
| 729 | ) { | 730 | ) { |
| 730 | yield* scope.supervisor.retryNow; | 731 | yield* scope.supervisor.retryNow; |
| 731 | } else if (state.phase === "connected") { | 732 | } else if ( |
| 733 | state.phase === "connected" && | ||
| 734 | registration.credential._tag === "HubConnectionCredential" | ||
| 735 | ) { | ||
| 736 | yield* scope.supervisor.retryNow; | ||
| 737 | } else if ( | ||
| 738 | state.phase === "connected" && | ||
| 739 | registration.credential._tag === "BearerConnectionCredential" | ||
| 740 | ) { | ||
| 732 | const token = registration.credential.token; | 741 | const token = registration.credential.token; |
| 733 | yield* SubscriptionRef.updateSome(scope.supervisor.prepared, (current) => | 742 | yield* SubscriptionRef.updateSome(scope.supervisor.prepared, (current) => |
| 734 | Option.isSome(current) && | 743 | Option.isSome(current) && |
| ... | @@ -844,6 +853,7 @@ export const make = Effect.gen(function* () { | ... | @@ -844,6 +853,7 @@ export const make = Effect.gen(function* () { |
| 844 | /** Forgets a user-saved environment. Callers hold its lease lock. */ | 853 | /** Forgets a user-saved environment. Callers hold its lease lock. */ |
| 845 | const removeLocked = Effect.fn("EnvironmentRegistry.removeLocked")(function* ( | 854 | const removeLocked = Effect.fn("EnvironmentRegistry.removeLocked")(function* ( |
| 846 | environmentId: EnvironmentId, | 855 | environmentId: EnvironmentId, |
| 856 | options?: { readonly retainDrafts?: boolean }, | ||
| 847 | ) { | 857 | ) { |
| 848 | if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { | 858 | if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { |
| 849 | return yield* new PlatformEnvironmentRemovalError({ | 859 | return yield* new PlatformEnvironmentRemovalError({ |
| ... | @@ -875,7 +885,7 @@ export const make = Effect.gen(function* () { | ... | @@ -875,7 +885,7 @@ export const make = Effect.gen(function* () { |
| 875 | }), | 885 | }), |
| 876 | ), | 886 | ), |
| 877 | ), | 887 | ), |
| 878 | ownedDataCleanup.clear(environmentId), | 888 | ownedDataCleanup.clear(environmentId, options), |
| 879 | ], | 889 | ], |
| 880 | { concurrency: "unbounded", discard: true }, | 890 | { concurrency: "unbounded", discard: true }, |
| 881 | ); | 891 | ); |
| ... | @@ -918,6 +928,10 @@ export const make = Effect.gen(function* () { | ... | @@ -918,6 +928,10 @@ export const make = Effect.gen(function* () { |
| 918 | readonly activeRoute: ConnectionRoute; | 928 | readonly activeRoute: ConnectionRoute; |
| 919 | readonly reported: ReadonlyArray<ReportedEndpoint>; | 929 | readonly reported: ReadonlyArray<ReportedEndpoint>; |
| 920 | }) { | 930 | }) { |
| 931 | if (connectionRouteId(input.activeRoute.target).startsWith("hub:")) { | ||
| 932 | return Option.none<ConnectionCatalogEntry>(); | ||
| 933 | } | ||
| 934 | |||
| 921 | return yield* withLeaseLock( | 935 | return yield* withLeaseLock( |
| 922 | input.environmentId, | 936 | input.environmentId, |
| 923 | Effect.gen(function* () { | 937 | Effect.gen(function* () { |
| ... | @@ -980,6 +994,7 @@ export const make = Effect.gen(function* () { | ... | @@ -980,6 +994,7 @@ export const make = Effect.gen(function* () { |
| 980 | const removeRoute = Effect.fn("EnvironmentRegistry.removeRoute")(function* ( | 994 | const removeRoute = Effect.fn("EnvironmentRegistry.removeRoute")(function* ( |
| 981 | environmentId: EnvironmentId, | 995 | environmentId: EnvironmentId, |
| 982 | routeId: string, | 996 | routeId: string, |
| 997 | options?: { readonly retainDrafts?: boolean }, | ||
| 983 | ) { | 998 | ) { |
| 984 | // One lock for the whole decision: a route registered between "this was | 999 | // One lock for the whole decision: a route registered between "this was |
| 985 | // the last route" and the removal must not be deleted with it. | 1000 | // the last route" and the removal must not be deleted with it. |
| ... | @@ -991,7 +1006,7 @@ export const make = Effect.gen(function* () { | ... | @@ -991,7 +1006,7 @@ export const make = Effect.gen(function* () { |
| 991 | const route = routes.find((candidate) => connectionRouteId(candidate.target) === routeId); | 1006 | const route = routes.find((candidate) => connectionRouteId(candidate.target) === routeId); |
| 992 | if (route === undefined) return; | 1007 | if (route === undefined) return; |
| 993 | const remaining = routesAfterRemoving(routes, routeId); | 1008 | const remaining = routesAfterRemoving(routes, routeId); |
| 994 | if (remaining.length === 0) return yield* removeLocked(environmentId); | 1009 | if (remaining.length === 0) return yield* removeLocked(environmentId, options); |
| 995 | yield* replaceRoutesLocked(entry, remaining); | 1010 | yield* replaceRoutesLocked(entry, remaining); |
| 996 | const profile = Option.getOrNull(route.profile); | 1011 | const profile = Option.getOrNull(route.profile); |
| 997 | if (profile !== null && isSshConnectionProfile(profile)) { | 1012 | if (profile !== null && isSshConnectionProfile(profile)) { |
packages/client-runtime/src/connection/resolver.test.ts+30| ... | @@ -17,6 +17,7 @@ import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | ... | @@ -17,6 +17,7 @@ import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 17 | import { | 17 | import { |
| 18 | BearerConnectionCredential, | 18 | BearerConnectionCredential, |
| 19 | BearerConnectionProfile, | 19 | BearerConnectionProfile, |
| 20 | HubConnectionCredential, | ||
| 20 | type ConnectionCatalogEntry, | 21 | type ConnectionCatalogEntry, |
| 21 | SshConnectionProfile, | 22 | SshConnectionProfile, |
| 22 | type ConnectionCredential, | 23 | type ConnectionCredential, |
| ... | @@ -186,6 +187,35 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o | ... | @@ -186,6 +187,35 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 186 | }); | 187 | }); |
| 187 | 188 | ||
| 188 | describe("ConnectionResolver", () => { | 189 | describe("ConnectionResolver", () => { |
| 190 | it.effect("resolves hub routes through one privately stored credential", () => | ||
| 191 | Effect.gen(function* () { | ||
| 192 | const connectionId = "hub:hub:environment-1"; | ||
| 193 | const profile = new BearerConnectionProfile({ | ||
| 194 | connectionId, | ||
| 195 | environmentId: ENVIRONMENT_ID, | ||
| 196 | label: "Machine", | ||
| 197 | httpBaseUrl: "https://hub.example/environments/environment-1/", | ||
| 198 | wsBaseUrl: "wss://hub.example/environments/environment-1/", | ||
| 199 | }); | ||
| 200 | const dependencies = yield* makeDependencies({ | ||
| 201 | credentials: [ | ||
| 202 | [connectionId, new HubConnectionCredential({ connectionId: "hub-session:hub" })], | ||
| 203 | ["hub-session:hub", new BearerConnectionCredential({ token: "single-hub-secret" })], | ||
| 204 | ], | ||
| 205 | }); | ||
| 206 | const resolver = yield* ConnectionResolver.ConnectionResolver.pipe( | ||
| 207 | Effect.provide(dependencies), | ||
| 208 | ); | ||
| 209 | const target = new BearerConnectionTarget({ | ||
| 210 | connectionId, | ||
| 211 | environmentId: ENVIRONMENT_ID, | ||
| 212 | label: "Machine", | ||
| 213 | }); | ||
| 214 | const prepared = yield* resolver.prepare(catalogEntry(target, Option.some(profile))); | ||
| 215 | expect(prepared.httpAuthorization).toEqual({ _tag: "Bearer", token: "single-hub-secret" }); | ||
| 216 | expect(prepared.httpBaseUrl).toBe(profile.httpBaseUrl); | ||
| 217 | }), | ||
| 218 | ); | ||
| 189 | it.effect("blocks an old host during discovery before opening orchestration RPC", () => | 219 | it.effect("blocks an old host during discovery before opening orchestration RPC", () => |
| 190 | Effect.gen(function* () { | 220 | Effect.gen(function* () { |
| 191 | const layerBroker = yield* makeDependencies({ descriptorProtocolVersion: null }); | 221 | const layerBroker = yield* makeDependencies({ descriptorProtocolVersion: null }); |
packages/client-runtime/src/connection/resolver.ts+58-14| ... | @@ -45,6 +45,7 @@ import { | ... | @@ -45,6 +45,7 @@ import { |
| 45 | orchestrationProtocolCompatibilityError, | 45 | orchestrationProtocolCompatibilityError, |
| 46 | } from "./compatibility.ts"; | 46 | } from "./compatibility.ts"; |
| 47 | import { credentialConnectionId } from "./routes.ts"; | 47 | import { credentialConnectionId } from "./routes.ts"; |
| 48 | import { deriveWsBaseUrl, environmentWebSocketUrl } from "../environment/endpoint.ts"; | ||
| 48 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | 49 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; |
| 49 | 50 | ||
| 50 | export class ConnectionResolver extends Context.Service< | 51 | export class ConnectionResolver extends Context.Service< |
| ... | @@ -72,13 +73,10 @@ const isSshProfile = Schema.is(SshConnectionProfile); | ... | @@ -72,13 +73,10 @@ const isSshProfile = Schema.is(SshConnectionProfile); |
| 72 | const isBearerCredential = Schema.is(BearerConnectionCredential); | 73 | const isBearerCredential = Schema.is(BearerConnectionCredential); |
| 73 | 74 | ||
| 74 | function primarySocketUrl( | 75 | function primarySocketUrl( |
| 75 | target: PrimaryConnectionTarget, | 76 | target: { readonly wsBaseUrl: string }, |
| 76 | clientMetadata: AuthClientPresentationMetadata | undefined, | 77 | clientMetadata: AuthClientPresentationMetadata | undefined, |
| 77 | ): string { | 78 | ): string { |
| 78 | const url = new URL(target.wsBaseUrl); | 79 | const url = new URL(environmentWebSocketUrl(target.wsBaseUrl)); |
| 79 | if (url.pathname === "" || url.pathname === "/") { | ||
| 80 | url.pathname = "/ws"; | ||
| 81 | } | ||
| 82 | appendClientConnectionParams(url, clientMetadata, "direct"); | 80 | appendClientConnectionParams(url, clientMetadata, "direct"); |
| 83 | return url.toString(); | 81 | return url.toString(); |
| 84 | } | 82 | } |
| ... | @@ -120,6 +118,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary | ... | @@ -120,6 +118,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary |
| 120 | const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { | 118 | const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { |
| 121 | const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; | 119 | const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; |
| 122 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | 120 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 121 | const presentation = yield* ClientCapabilities.ClientPresentation; | ||
| 123 | 122 | ||
| 124 | return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( | 123 | return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( |
| 125 | entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, | 124 | entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, |
| ... | @@ -148,15 +147,60 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") | ... | @@ -148,15 +147,60 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") |
| 148 | return { ...authorized, target } satisfies PreparedConnection; | 147 | return { ...authorized, target } satisfies PreparedConnection; |
| 149 | } | 148 | } |
| 150 | // A learned route borrows the credential of the route it was learned from. | 149 | // A learned route borrows the credential of the route it was learned from. |
| 151 | const credential = yield* credentials.get(credentialConnectionId(target.connectionId)).pipe( | 150 | const savedCredential = yield* credentials |
| 152 | Effect.flatMap( | 151 | .get(credentialConnectionId(target.connectionId)) |
| 153 | Option.match({ | 152 | .pipe( |
| 154 | onNone: () => Effect.fail(credentialMissingError(target.connectionId)), | 153 | Effect.flatMap( |
| 155 | onSome: Effect.succeed, | 154 | Option.match({ |
| 156 | }), | 155 | onNone: () => Effect.fail(credentialMissingError(target.connectionId)), |
| 157 | ), | 156 | onSome: Effect.succeed, |
| 158 | ); | 157 | }), |
| 159 | if (!isBearerCredential(credential)) { | 158 | ), |
| 159 | ); | ||
| 160 | const credential = | ||
| 161 | savedCredential._tag === "HubConnectionCredential" | ||
| 162 | ? Option.getOrNull(yield* credentials.get(savedCredential.connectionId)) | ||
| 163 | : savedCredential; | ||
| 164 | if (credential?._tag === "HostedConnectionCredential") { | ||
| 165 | const service = yield* Effect.serviceOption(ClientCapabilities.HostedAuthAccess); | ||
| 166 | if (Option.isNone(service)) return yield* credentialMissingError(target.connectionId); | ||
| 167 | const destination = new URL(profile.httpBaseUrl); | ||
| 168 | const server = new URL(credential.serverUrl); | ||
| 169 | const prefix = server.pathname.replace(/\/+$/, ""); | ||
| 170 | if ( | ||
| 171 | destination.origin !== server.origin || | ||
| 172 | (destination.pathname !== prefix && !destination.pathname.startsWith(`${prefix}/`)) || | ||
| 173 | profile.wsBaseUrl !== deriveWsBaseUrl(profile.httpBaseUrl) | ||
| 174 | ) { | ||
| 175 | return yield* new ConnectionBlockedError({ | ||
| 176 | reason: "configuration", | ||
| 177 | detail: "This connection belongs to a different server. Sign in again.", | ||
| 178 | }); | ||
| 179 | } | ||
| 180 | const headers = yield* service.value.headers(credential.serverUrl); | ||
| 181 | const bearer = headers.authorization?.replace(/^Bearer /, ""); | ||
| 182 | if (bearer) { | ||
| 183 | return { | ||
| 184 | ...(yield* remote.authorizeBearer({ | ||
| 185 | expectedEnvironmentId: target.environmentId, | ||
| 186 | httpBaseUrl: profile.httpBaseUrl, | ||
| 187 | wsBaseUrl: profile.wsBaseUrl, | ||
| 188 | bearerToken: bearer, | ||
| 189 | connectionMethod: "direct", | ||
| 190 | })), | ||
| 191 | target, | ||
| 192 | } satisfies PreparedConnection; | ||
| 193 | } | ||
| 194 | return { | ||
| 195 | environmentId: target.environmentId, | ||
| 196 | label: profile.label, | ||
| 197 | httpBaseUrl: profile.httpBaseUrl, | ||
| 198 | socketUrl: primarySocketUrl(profile, presentation.metadata), | ||
| 199 | httpAuthorization: null, | ||
| 200 | target, | ||
| 201 | } satisfies PreparedConnection; | ||
| 202 | } | ||
| 203 | if (credential === null || !isBearerCredential(credential)) { | ||
| 160 | return yield* credentialMissingError(target.connectionId); | 204 | return yield* credentialMissingError(target.connectionId); |
| 161 | } | 205 | } |
| 162 | const authorized = yield* remote.authorizeBearer({ | 206 | const authorized = yield* remote.authorizeBearer({ |
packages/client-runtime/src/connection/routes.test.ts+13| ... | @@ -104,6 +104,19 @@ describe("learned routes", () => { | ... | @@ -104,6 +104,19 @@ describe("learned routes", () => { |
| 104 | }); | 104 | }); |
| 105 | }); | 105 | }); |
| 106 | 106 | ||
| 107 | it("retains distinct learned environment bases on the same origin", () => { | ||
| 108 | const reported = [ | ||
| 109 | { httpBaseUrl: "https://hub.example/prefix/environments/one/" }, | ||
| 110 | { httpBaseUrl: "https://hub.example/prefix/environments/two/" }, | ||
| 111 | ]; | ||
| 112 | const routes = mergeLearnedRoutes({ entry: relayOnly, activeRoute: RELAY, reported, allowInsecure: false })!; | ||
| 113 | expect(routes.slice(0, 2).map((route) => profileOf(route))).toMatchObject([ | ||
| 114 | { httpBaseUrl: reported[0]!.httpBaseUrl, wsBaseUrl: "wss://hub.example/prefix/environments/one/" }, | ||
| 115 | { httpBaseUrl: reported[1]!.httpBaseUrl, wsBaseUrl: "wss://hub.example/prefix/environments/two/" }, | ||
| 116 | ]); | ||
| 117 | expect(mergeLearnedRoutes({ entry: entryWithRoutes(relayOnly, routes), activeRoute: RELAY, reported, allowInsecure: false })).toBeNull(); | ||
| 118 | }); | ||
| 119 | |||
| 107 | it("replaces a learned LAN address when the server reports a new one", () => { | 120 | it("replaces a learned LAN address when the server reports a new one", () => { |
| 108 | const first = mergeLearnedRoutes({ | 121 | const first = mergeLearnedRoutes({ |
| 109 | entry: relayOnly, | 122 | entry: relayOnly, |
packages/client-runtime/src/connection/routes.ts+12-9| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | isSharedAddressSpaceHost, | 4 | isSharedAddressSpaceHost, |
| 5 | isTailnetHost, | 5 | isTailnetHost, |
| 6 | } from "@t3tools/shared/hostClassification"; | 6 | } from "@t3tools/shared/hostClassification"; |
| 7 | import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; | ||
| 7 | import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; | 8 | import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; |
| 8 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 9 | 10 | ||
| ... | @@ -261,13 +262,13 @@ export function mergeLearnedRoutes(input: { | ... | @@ -261,13 +262,13 @@ export function mergeLearnedRoutes(input: { |
| 261 | ? credentialConnectionId(active.connectionId) | 262 | ? credentialConnectionId(active.connectionId) |
| 262 | : undefined; | 263 | : undefined; |
| 263 | 264 | ||
| 264 | // Usable reported addresses, by origin. | 265 | // Usable reported environment bases. |
| 265 | const reported = new Map<string, URL>(); | 266 | const reported = new Map<string, URL>(); |
| 266 | const tailscaleOrigins = new Set<string>(); | 267 | const tailscaleOrigins = new Set<string>(); |
| 267 | for (const endpoint of input.reported) { | 268 | for (const endpoint of input.reported) { |
| 268 | let url: URL; | 269 | let url: URL; |
| 269 | try { | 270 | try { |
| 270 | url = new URL(endpoint.httpBaseUrl); | 271 | url = new URL(normalizeHttpBaseUrl(endpoint.httpBaseUrl)); |
| 271 | } catch { | 272 | } catch { |
| 272 | continue; | 273 | continue; |
| 273 | } | 274 | } |
| ... | @@ -275,8 +276,9 @@ export function mergeLearnedRoutes(input: { | ... | @@ -275,8 +276,9 @@ export function mergeLearnedRoutes(input: { |
| 275 | if (url.protocol === "http:" && !input.allowInsecure) continue; | 276 | if (url.protocol === "http:" && !input.allowInsecure) continue; |
| 276 | // A loopback address names whichever device opens it, never the server. | 277 | // A loopback address names whichever device opens it, never the server. |
| 277 | if (isLocalLoopbackHost(url.hostname)) continue; | 278 | if (isLocalLoopbackHost(url.hostname)) continue; |
| 278 | reported.set(url.origin, url); | 279 | const address = url.href.replace(/\/+$/, ""); |
| 279 | if (endpoint.kind === "tailnet") tailscaleOrigins.add(url.origin); | 280 | reported.set(address, url); |
| 281 | if (endpoint.kind === "tailnet") tailscaleOrigins.add(address); | ||
| 280 | } | 282 | } |
| 281 | const normalized = (url: string) => url.replace(/\/+$/, ""); | 283 | const normalized = (url: string) => url.replace(/\/+$/, ""); |
| 282 | const known = new Set( | 284 | const known = new Set( |
| ... | @@ -300,11 +302,12 @@ export function mergeLearnedRoutes(input: { | ... | @@ -300,11 +302,12 @@ export function mergeLearnedRoutes(input: { |
| 300 | }); | 302 | }); |
| 301 | let next: ReadonlyArray<ConnectionRoute> = kept; | 303 | let next: ReadonlyArray<ConnectionRoute> = kept; |
| 302 | for (const url of reported.values()) { | 304 | for (const url of reported.values()) { |
| 303 | if (known.has(url.origin)) continue; | 305 | const httpBaseUrl = url.href; |
| 304 | const httpBaseUrl = `${url.origin}/`; | 306 | const address = httpBaseUrl.replace(/\/+$/, ""); |
| 307 | if (known.has(address)) continue; | ||
| 305 | const connectionId = learnedConnectionId( | 308 | const connectionId = learnedConnectionId( |
| 306 | entry.target.environmentId, | 309 | entry.target.environmentId, |
| 307 | url.origin, | 310 | address, |
| 308 | sharedCredential, | 311 | sharedCredential, |
| 309 | ); | 312 | ); |
| 310 | next = insertRoute(next, { | 313 | next = insertRoute(next, { |
| ... | @@ -319,10 +322,10 @@ export function mergeLearnedRoutes(input: { | ... | @@ -319,10 +322,10 @@ export function mergeLearnedRoutes(input: { |
| 319 | environmentId: entry.target.environmentId, | 322 | environmentId: entry.target.environmentId, |
| 320 | label: entry.target.label, | 323 | label: entry.target.label, |
| 321 | httpBaseUrl, | 324 | httpBaseUrl, |
| 322 | wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`, | 325 | wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), |
| 323 | learned: true, | 326 | learned: true, |
| 324 | ...(authorization === undefined ? {} : { authorization }), | 327 | ...(authorization === undefined ? {} : { authorization }), |
| 325 | ...(tailscaleOrigins.has(url.origin) ? { network: "tailscale" as const } : {}), | 328 | ...(tailscaleOrigins.has(address) ? { network: "tailscale" as const } : {}), |
| 326 | }), | 329 | }), |
| 327 | ), | 330 | ), |
| 328 | }); | 331 | }); |
packages/client-runtime/src/environment/endpoint.test.ts+26-3| ... | @@ -4,6 +4,8 @@ import { | ... | @@ -4,6 +4,8 @@ import { |
| 4 | classifyHostedHttpsCompatibility, | 4 | classifyHostedHttpsCompatibility, |
| 5 | createAdvertisedEndpoint, | 5 | createAdvertisedEndpoint, |
| 6 | deriveWsBaseUrl, | 6 | deriveWsBaseUrl, |
| 7 | environmentEndpointUrl, | ||
| 8 | environmentWebSocketUrl, | ||
| 7 | normalizeHttpBaseUrl, | 9 | normalizeHttpBaseUrl, |
| 8 | } from "./endpoint.ts"; | 10 | } from "./endpoint.ts"; |
| 9 | 11 | ||
| ... | @@ -16,12 +18,33 @@ const coreProvider = { | ... | @@ -16,12 +18,33 @@ const coreProvider = { |
| 16 | 18 | ||
| 17 | describe("advertised endpoint helpers", () => { | 19 | describe("advertised endpoint helpers", () => { |
| 18 | it("normalizes HTTP and WebSocket base URLs", () => { | 20 | it("normalizes HTTP and WebSocket base URLs", () => { |
| 19 | expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/"); | 21 | expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe( |
| 20 | expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/"); | 22 | "https://example.com/path/", |
| 21 | expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/"); | 23 | ); |
| 24 | expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/socket/"); | ||
| 25 | expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/api/"); | ||
| 22 | expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); | 26 | expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); |
| 23 | }); | 27 | }); |
| 24 | 28 | ||
| 29 | it.each(["https", "http", "wss", "ws"])("preserves the base path for %s routes", (scheme) => { | ||
| 30 | const base = `${scheme}://hub.example/prefix/environments/one///?old=1#fragment`; | ||
| 31 | expect(environmentEndpointUrl(base, "/api/assets/a%2Fb?signature=abc#preview")).toBe( | ||
| 32 | `${scheme}://hub.example/prefix/environments/one/api/assets/a%2Fb?signature=abc#preview`, | ||
| 33 | ); | ||
| 34 | expect(environmentEndpointUrl(base, ".well-known/t3/environment")).toBe( | ||
| 35 | `${scheme}://hub.example/prefix/environments/one/.well-known/t3/environment`, | ||
| 36 | ); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("distinguishes a canonical WebSocket base from an existing socket endpoint", () => { | ||
| 40 | expect(environmentWebSocketUrl("wss://hub.example/prefix/environments/one/")).toBe( | ||
| 41 | "wss://hub.example/prefix/environments/one/ws", | ||
| 42 | ); | ||
| 43 | expect(environmentWebSocketUrl("wss://remote.example/ws?transport=1")).toBe( | ||
| 44 | "wss://remote.example/ws?transport=1", | ||
| 45 | ); | ||
| 46 | }); | ||
| 47 | |||
| 25 | it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => { | 48 | it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => { |
| 26 | expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe( | 49 | expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe( |
| 27 | "mixed-content-blocked", | 50 | "mixed-content-blocked", |
packages/client-runtime/src/environment/endpoint.ts-8| ... | @@ -1,9 +1 @@ | ... | @@ -1,9 +1 @@ |
| 1 | export * from "@t3tools/shared/advertisedEndpoint"; | 1 | export * from "@t3tools/shared/advertisedEndpoint"; |
| 2 | |||
| 3 | export const environmentEndpointUrl = (httpBaseUrl: string, pathname: string): string => { | ||
| 4 | const url = new URL(httpBaseUrl); | ||
| 5 | url.pathname = pathname; | ||
| 6 | url.search = ""; | ||
| 7 | url.hash = ""; | ||
| 8 | return url.toString(); | ||
| 9 | }; |
packages/client-runtime/src/operations/commands.ts+23-2| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | CommandId, | 4 | CommandId, |
| 5 | CheckpointId, | 5 | CheckpointId, |
| 6 | CheckpointScopeId, | 6 | CheckpointScopeId, |
| 7 | HubOrganizationCommand, | ||
| 7 | ORCHESTRATION_V2_WS_METHODS, | 8 | ORCHESTRATION_V2_WS_METHODS, |
| 8 | OrchestrationV2CheckpointUnavailableError, | 9 | OrchestrationV2CheckpointUnavailableError, |
| 9 | WS_METHODS, | 10 | WS_METHODS, |
| ... | @@ -32,9 +33,15 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend | ... | @@ -32,9 +33,15 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend |
| 32 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; | 33 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; |
| 33 | import * as Crypto from "effect/Crypto"; | 34 | import * as Crypto from "effect/Crypto"; |
| 34 | import * as Effect from "effect/Effect"; | 35 | import * as Effect from "effect/Effect"; |
| 36 | import * as Option from "effect/Option"; | ||
| 37 | import * as Schema from "effect/Schema"; | ||
| 35 | 38 | ||
| 39 | import * as Hub from "../connection/hub.ts"; | ||
| 40 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | ||
| 36 | import { getInitialServerConfig, request } from "../rpc/client.ts"; | 41 | import { getInitialServerConfig, request } from "../rpc/client.ts"; |
| 37 | 42 | ||
| 43 | const isHubOrganizationCommand = Schema.is(HubOrganizationCommand); | ||
| 44 | |||
| 38 | interface CommandMetadata { | 45 | interface CommandMetadata { |
| 39 | readonly commandId?: CommandId; | 46 | readonly commandId?: CommandId; |
| 40 | readonly createdAt?: string; | 47 | readonly createdAt?: string; |
| ... | @@ -270,8 +277,22 @@ const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(fun | ... | @@ -270,8 +277,22 @@ const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(fun |
| 270 | return CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)); | 277 | return CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)); |
| 271 | }); | 278 | }); |
| 272 | 279 | ||
| 273 | const dispatch = (command: OrchestrationV2Command) => | 280 | const dispatch = Effect.fn("EnvironmentCommands.dispatch")(function* ( |
| 274 | request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command); | 281 | command: OrchestrationV2Command, |
| 282 | ) { | ||
| 283 | if (isHubOrganizationCommand(command)) { | ||
| 284 | const hub = yield* Effect.serviceOption(Hub.HubConnection); | ||
| 285 | if (Option.isSome(hub)) { | ||
| 286 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 287 | const result = yield* hub.value.organize({ | ||
| 288 | environmentId: supervisor.target.environmentId, | ||
| 289 | command, | ||
| 290 | }); | ||
| 291 | if (Option.isSome(result)) return result.value; | ||
| 292 | } | ||
| 293 | } | ||
| 294 | return yield* request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command); | ||
| 295 | }); | ||
| 275 | 296 | ||
| 276 | const getProjection = (threadId: ThreadId) => | 297 | const getProjection = (threadId: ThreadId) => |
| 277 | request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); | 298 | request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); |
packages/client-runtime/src/platform/capabilities.ts+9| ... | @@ -76,3 +76,12 @@ export class SshEnvironmentGateway extends Context.Service< | ... | @@ -76,3 +76,12 @@ export class SshEnvironmentGateway extends Context.Service< |
| 76 | ) => Effect.Effect<void, ConnectionAttemptError>; | 76 | ) => Effect.Effect<void, ConnectionAttemptError>; |
| 77 | } | 77 | } |
| 78 | >()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {} | 78 | >()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {} |
| 79 | |||
| 80 | export class HostedAuthAccess extends Context.Service< | ||
| 81 | HostedAuthAccess, | ||
| 82 | { | ||
| 83 | readonly headers: ( | ||
| 84 | serverUrl: string, | ||
| 85 | ) => Effect.Effect<Record<string, string>, ConnectionAttemptError>; | ||
| 86 | } | ||
| 87 | >()("@t3tools/client-runtime/platform/capabilities/HostedAuthAccess") {} |
packages/client-runtime/src/platform/persistence.ts+4-1| ... | @@ -146,7 +146,10 @@ export class EnvironmentCacheStore extends Context.Service< | ... | @@ -146,7 +146,10 @@ export class EnvironmentCacheStore extends Context.Service< |
| 146 | >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} | 146 | >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} |
| 147 | 147 | ||
| 148 | export class EnvironmentOwnedDataCleanup extends Context.Reference<{ | 148 | export class EnvironmentOwnedDataCleanup extends Context.Reference<{ |
| 149 | readonly clear: (environmentId: EnvironmentId) => Effect.Effect<void>; | 149 | readonly clear: ( |
| 150 | environmentId: EnvironmentId, | ||
| 151 | options?: { readonly retainDrafts?: boolean }, | ||
| 152 | ) => Effect.Effect<void>; | ||
| 150 | }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { | 153 | }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { |
| 151 | defaultValue: () => ({ | 154 | defaultValue: () => ({ |
| 152 | clear: () => Effect.void, | 155 | clear: () => Effect.void, |
packages/client-runtime/src/rpc/http.ts+4-11| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | type EnvironmentResourceNotFoundError, | 8 | type EnvironmentResourceNotFoundError, |
| 9 | type EnvironmentScopeRequiredError, | 9 | type EnvironmentScopeRequiredError, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { normalizeHttpBaseUrl } from "@t3tools/shared/advertisedEndpoint"; | ||
| 11 | import * as HttpObservability from "@t3tools/shared/httpObservability"; | 12 | import * as HttpObservability from "@t3tools/shared/httpObservability"; |
| 12 | import * as Data from "effect/Data"; | 13 | import * as Data from "effect/Data"; |
| 13 | import * as Duration from "effect/Duration"; | 14 | import * as Duration from "effect/Duration"; |
| ... | @@ -86,17 +87,9 @@ export const layerRemoteHttpClient = ( | ... | @@ -86,17 +87,9 @@ export const layerRemoteHttpClient = ( |
| 86 | HttpObservability.layer, | 87 | HttpObservability.layer, |
| 87 | ); | 88 | ); |
| 88 | 89 | ||
| 89 | const remoteApiBaseUrl = (httpBaseUrl: string): string => { | ||
| 90 | const url = new URL(httpBaseUrl); | ||
| 91 | url.pathname = "/"; | ||
| 92 | url.search = ""; | ||
| 93 | url.hash = ""; | ||
| 94 | return url.toString(); | ||
| 95 | }; | ||
| 96 | |||
| 97 | export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => | 90 | export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => |
| 98 | HttpApiClient.make(EnvironmentHttpApi, { | 91 | HttpApiClient.make(EnvironmentHttpApi, { |
| 99 | baseUrl: remoteApiBaseUrl(httpBaseUrl), | 92 | baseUrl: normalizeHttpBaseUrl(httpBaseUrl), |
| 100 | }); | 93 | }); |
| 101 | 94 | ||
| 102 | export const makeEnvironmentHttpApiGroupClient = < | 95 | export const makeEnvironmentHttpApiGroupClient = < |
| ... | @@ -109,14 +102,14 @@ export const makeEnvironmentHttpApiGroupClient = < | ... | @@ -109,14 +102,14 @@ export const makeEnvironmentHttpApiGroupClient = < |
| 109 | HttpApiClient.group(EnvironmentHttpApi, { | 102 | HttpApiClient.group(EnvironmentHttpApi, { |
| 110 | httpClient, | 103 | httpClient, |
| 111 | group, | 104 | group, |
| 112 | baseUrl: remoteApiBaseUrl(httpBaseUrl), | 105 | baseUrl: normalizeHttpBaseUrl(httpBaseUrl), |
| 113 | }), | 106 | }), |
| 114 | ); | 107 | ); |
| 115 | 108 | ||
| 116 | /** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */ | 109 | /** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */ |
| 117 | export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) => | 110 | export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) => |
| 118 | HttpApiClient.urlBuilder(EnvironmentHttpApi, { | 111 | HttpApiClient.urlBuilder(EnvironmentHttpApi, { |
| 119 | baseUrl: remoteApiBaseUrl(httpBaseUrl), | 112 | baseUrl: normalizeHttpBaseUrl(httpBaseUrl), |
| 120 | }); | 113 | }); |
| 121 | 114 | ||
| 122 | const failRemoteRequest = ( | 115 | const failRemoteRequest = ( |
packages/client-runtime/src/state/archiveAssetHttp.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import { | ||
| 2 | ArchiveAssetCreateUrlInput, | ||
| 3 | ArchiveError, | ||
| 4 | AssetCreateUrlResult, | ||
| 5 | type AssetResource, | ||
| 6 | type EnvironmentId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 11 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 12 | import * as HubConnection from "../connection/hub.ts"; | ||
| 13 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | ||
| 14 | |||
| 15 | const encodeInput = Schema.encodeEffect(Schema.toCodecJson(ArchiveAssetCreateUrlInput)); | ||
| 16 | export const fetchArchivedAssetUrl = Effect.fn("clientRuntime.state.fetchArchivedAssetUrl")( | ||
| 17 | function* (environmentId: EnvironmentId, resource: AssetResource) { | ||
| 18 | const hub = yield* HubConnection.HubConnection; | ||
| 19 | const connection = (yield* SubscriptionRef.get(hub.state)).connection; | ||
| 20 | if (!connection) return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 21 | const http = yield* HttpClient.HttpClient; | ||
| 22 | const result = yield* HttpClientRequest.post( | ||
| 23 | `${connection.httpBaseUrl}/api/archive/assets`, | ||
| 24 | ).pipe( | ||
| 25 | HttpClientRequest.setHeaders(yield* hub.headers), | ||
| 26 | HttpClientRequest.bodyJson(yield* encodeInput({ environmentId, resource })), | ||
| 27 | Effect.flatMap(http.execute), | ||
| 28 | Effect.provideService(FetchHttpClient.RequestInit, { | ||
| 29 | credentials: "include", | ||
| 30 | redirect: "error", | ||
| 31 | }), | ||
| 32 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 33 | Effect.flatMap(HttpClientResponse.schemaBodyJson(AssetCreateUrlResult)), | ||
| 34 | Effect.timeout("10 seconds"), | ||
| 35 | ); | ||
| 36 | return { | ||
| 37 | ...result, | ||
| 38 | relativeUrl: environmentEndpointUrl(connection.httpBaseUrl, result.relativeUrl), | ||
| 39 | }; | ||
| 40 | }, | ||
| 41 | ); | ||
packages/client-runtime/src/state/archiveThreadHttp.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import { | ||
| 2 | ArchiveError, | ||
| 3 | OrchestrationV2ThreadDetailSnapshot, | ||
| 4 | type EnvironmentId, | ||
| 5 | type ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 10 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 11 | import * as HubConnection from "../connection/hub.ts"; | ||
| 12 | |||
| 13 | export const fetchArchivedThreadSnapshot = Effect.fn( | ||
| 14 | "clientRuntime.state.fetchArchivedThreadSnapshot", | ||
| 15 | )(function* (environmentId: EnvironmentId, threadId: ThreadId) { | ||
| 16 | const hub = yield* HubConnection.HubConnection; | ||
| 17 | const connection = (yield* SubscriptionRef.get(hub.state)).connection; | ||
| 18 | if (!connection) return yield* new ArchiveError({ reason: "unavailable" }); | ||
| 19 | const http = yield* HttpClient.HttpClient; | ||
| 20 | return yield* HttpClientRequest.get( | ||
| 21 | `${connection.httpBaseUrl}/api/archive/catalog/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`, | ||
| 22 | ).pipe( | ||
| 23 | HttpClientRequest.setHeaders(yield* hub.headers), | ||
| 24 | http.execute, | ||
| 25 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), | ||
| 26 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 27 | Effect.flatMap( | ||
| 28 | HttpClientResponse.schemaBodyJson(Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot)), | ||
| 29 | ), | ||
| 30 | Effect.filterOrFail( | ||
| 31 | (snapshot) => | ||
| 32 | snapshot.projection.thread.id === threadId && | ||
| 33 | snapshot.projection.thread.archivedAt !== null, | ||
| 34 | () => new ArchiveError({ reason: "integrity" }), | ||
| 35 | ), | ||
| 36 | Effect.timeout("10 seconds"), | ||
| 37 | ); | ||
| 38 | }); | ||
packages/client-runtime/src/state/assets.test.ts+129| ... | @@ -11,6 +11,8 @@ import { | ... | @@ -11,6 +11,8 @@ import { |
| 11 | ThreadId, | 11 | ThreadId, |
| 12 | WS_METHODS, | 12 | WS_METHODS, |
| 13 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 15 | import * as HubConnection from "../connection/hub.ts"; | ||
| 14 | import * as Cause from "effect/Cause"; | 16 | import * as Cause from "effect/Cause"; |
| 15 | import * as Effect from "effect/Effect"; | 17 | import * as Effect from "effect/Effect"; |
| 16 | import * as Stream from "effect/Stream"; | 18 | import * as Stream from "effect/Stream"; |
| ... | @@ -29,14 +31,29 @@ import { | ... | @@ -29,14 +31,29 @@ import { |
| 29 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 31 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 30 | import type { RpcSession } from "../rpc/session.ts"; | 32 | import type { RpcSession } from "../rpc/session.ts"; |
| 31 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 33 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 34 | import { createOrchestrationEnvironmentAtoms } from "./orchestration.ts"; | ||
| 35 | import { v2Project, v2ThreadShell, v2Now } from "./orchestrationV2TestFixtures.ts"; | ||
| 32 | import { createProjectFaviconCache } from "../projectFaviconCache.ts"; | 36 | import { createProjectFaviconCache } from "../projectFaviconCache.ts"; |
| 33 | import { | 37 | import { |
| 34 | createAssetEnvironmentAtoms, | 38 | createAssetEnvironmentAtoms, |
| 35 | createProjectFaviconUrlAtomFamily, | 39 | createProjectFaviconUrlAtomFamily, |
| 36 | InvalidAssetCollectionKeyError, | 40 | InvalidAssetCollectionKeyError, |
| 37 | parseAssetCollectionKey, | 41 | parseAssetCollectionKey, |
| 42 | resolveAssetUrl, | ||
| 38 | } from "./assets.ts"; | 43 | } from "./assets.ts"; |
| 39 | 44 | ||
| 45 | describe("asset addresses", () => { | ||
| 46 | it("resolves signed environment paths through the proxy while retaining external assets", () => { | ||
| 47 | const base = "https://hub.example/prefix/environments/one/"; | ||
| 48 | expect(resolveAssetUrl(base, "/api/assets/a%2Fb?signature=signed")).toBe( | ||
| 49 | `${base}api/assets/a%2Fb?signature=signed`, | ||
| 50 | ); | ||
| 51 | expect(resolveAssetUrl(base, "https://other.example/api/assets/signed")).toBe( | ||
| 52 | "https://other.example/api/assets/signed", | ||
| 53 | ); | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | |||
| 40 | describe("asset collection keys", () => { | 57 | describe("asset collection keys", () => { |
| 41 | it("preserves malformed JSON and its native cause", () => { | 58 | it("preserves malformed JSON and its native cause", () => { |
| 42 | const key = "not-json"; | 59 | const key = "not-json"; |
| ... | @@ -60,6 +77,118 @@ describe("asset collection keys", () => { | ... | @@ -60,6 +77,118 @@ describe("asset collection keys", () => { |
| 60 | }); | 77 | }); |
| 61 | 78 | ||
| 62 | describe("createAssetEnvironmentAtoms", () => { | 79 | describe("createAssetEnvironmentAtoms", () => { |
| 80 | it.effect("issues NAS attachment URLs without waiting for a sleeping source session", () => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const environmentId = EnvironmentId.make("sleeping-laptop"); | ||
| 83 | const resource = { | ||
| 84 | _tag: "attachment" as const, | ||
| 85 | attachmentId: "stored-image", | ||
| 86 | fileName: "picture.png", | ||
| 87 | }; | ||
| 88 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 89 | target: new PrimaryConnectionTarget({ | ||
| 90 | environmentId, | ||
| 91 | label: "Laptop", | ||
| 92 | httpBaseUrl: "https://source.example.test", | ||
| 93 | wsBaseUrl: "wss://source.example.test", | ||
| 94 | }), | ||
| 95 | state: yield* SubscriptionRef.make<SupervisorConnectionState>({ | ||
| 96 | ...AVAILABLE_CONNECTION_STATE, | ||
| 97 | phase: "offline", | ||
| 98 | }), | ||
| 99 | session: yield* SubscriptionRef.make(Option.none<RpcSession>()), | ||
| 100 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 101 | connect: Effect.void, | ||
| 102 | disconnect: Effect.void, | ||
| 103 | retryNow: Effect.void, | ||
| 104 | }); | ||
| 105 | const environments = EnvironmentRegistry.EnvironmentRegistry.of({ | ||
| 106 | run: (_id, effect) => | ||
| 107 | Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 108 | followStream: (_id, stream) => | ||
| 109 | Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 110 | } as EnvironmentRegistry.EnvironmentRegistry["Service"]); | ||
| 111 | const hubState = yield* SubscriptionRef.make<HubConnection.HubConnectionState>({ | ||
| 112 | ...HubConnection.EMPTY_HUB_CONNECTION_STATE, | ||
| 113 | inbox: { | ||
| 114 | environments: [ | ||
| 115 | { | ||
| 116 | environmentId, | ||
| 117 | pendingThreadIds: [], | ||
| 118 | snapshot: { | ||
| 119 | schemaVersion: 2, | ||
| 120 | snapshotSequence: 17, | ||
| 121 | projects: [v2Project], | ||
| 122 | threads: [], | ||
| 123 | archivedThreads: [{ ...v2ThreadShell, archivedAt: v2Now }], | ||
| 124 | }, | ||
| 125 | }, | ||
| 126 | ], | ||
| 127 | }, | ||
| 128 | connection: { | ||
| 129 | environmentId: EnvironmentId.make("nas"), | ||
| 130 | label: "Archive", | ||
| 131 | httpBaseUrl: "https://archive.example.test", | ||
| 132 | }, | ||
| 133 | }); | ||
| 134 | const hub = HubConnection.HubConnection.of({ | ||
| 135 | state: hubState, | ||
| 136 | headers: Effect.succeed({ authorization: "Bearer ephemeral" }), | ||
| 137 | connect: () => Effect.void, | ||
| 138 | connectAuthenticated: () => Effect.void, | ||
| 139 | refresh: Effect.void, | ||
| 140 | refreshInbox: Effect.void, | ||
| 141 | organize: () => Effect.succeedNone, | ||
| 142 | disconnect: Effect.void, | ||
| 143 | }); | ||
| 144 | const client = HttpClient.make((request) => | ||
| 145 | Effect.sync(() => { | ||
| 146 | expect(request.method).toBe("POST"); | ||
| 147 | expect(request.url).toBe("https://archive.example.test/api/archive/assets"); | ||
| 148 | expect(request.headers.authorization).toBe("Bearer ephemeral"); | ||
| 149 | if (request.body._tag !== "Uint8Array") throw new Error("Expected JSON body"); | ||
| 150 | expect(JSON.parse(new TextDecoder().decode(request.body.body))).toEqual({ | ||
| 151 | environmentId, | ||
| 152 | resource, | ||
| 153 | }); | ||
| 154 | return HttpClientResponse.fromWeb( | ||
| 155 | request, | ||
| 156 | Response.json({ | ||
| 157 | relativeUrl: "/api/archive/assets/signed/picture.png", | ||
| 158 | expiresAt: 4_000_000_000_000, | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | }), | ||
| 162 | ); | ||
| 163 | const runtime = Atom.runtime( | ||
| 164 | Layer.mergeAll( | ||
| 165 | Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), | ||
| 166 | Layer.succeed(HubConnection.HubConnection, hub), | ||
| 167 | Layer.succeed(HttpClient.HttpClient, client), | ||
| 168 | ), | ||
| 169 | ); | ||
| 170 | const registry = AtomRegistry.make(); | ||
| 171 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | ||
| 172 | const query = createAssetEnvironmentAtoms(runtime).createUrl({ | ||
| 173 | environmentId, | ||
| 174 | input: { resource }, | ||
| 175 | }); | ||
| 176 | const result = yield* AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); | ||
| 177 | expect(result.relativeUrl).toBe( | ||
| 178 | "https://archive.example.test/api/archive/assets/signed/picture.png", | ||
| 179 | ); | ||
| 180 | const archiveQuery = createOrchestrationEnvironmentAtoms(runtime).archivedShellSnapshot({ | ||
| 181 | environmentId, | ||
| 182 | input: {}, | ||
| 183 | }); | ||
| 184 | const discovered = yield* AtomRegistry.getResult(registry, archiveQuery, { | ||
| 185 | suspendOnWaiting: true, | ||
| 186 | }); | ||
| 187 | expect(discovered.threads.map((thread) => thread.id)).toEqual([v2ThreadShell.id]); | ||
| 188 | expect(discovered.projects[0]?.id).toBe(v2Project.id); | ||
| 189 | }).pipe(Effect.scoped), | ||
| 190 | ); | ||
| 191 | |||
| 63 | it.effect.each([ | 192 | it.effect.each([ |
| 64 | { name: "missing video", path: "/tmp/clip.mp4", fallback: true }, | 193 | { name: "missing video", path: "/tmp/clip.mp4", fallback: true }, |
| 65 | { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true }, | 194 | { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true }, |
packages/client-runtime/src/state/assets.ts+86-7| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | ArchiveError, | ||
| 2 | type AssetCreateUrlInput, | 3 | type AssetCreateUrlInput, |
| 3 | type AssetCreateUrlResult, | 4 | type AssetCreateUrlResult, |
| 4 | type AssetImageDimensions, | 5 | type AssetImageDimensions, |
| ... | @@ -13,6 +14,9 @@ import { | ... | @@ -13,6 +14,9 @@ import { |
| 13 | getProjectFaviconResourceKey, | 14 | getProjectFaviconResourceKey, |
| 14 | isProjectFaviconFallbackUrl, | 15 | isProjectFaviconFallbackUrl, |
| 15 | } from "@t3tools/shared/projectFavicon"; | 16 | } from "@t3tools/shared/projectFavicon"; |
| 17 | import { HttpClient } from "effect/http"; | ||
| 18 | import * as HubConnection from "../connection/hub.ts"; | ||
| 19 | import { fetchArchivedAssetUrl } from "./archiveAssetHttp.ts"; | ||
| 16 | import * as Effect from "effect/Effect"; | 20 | import * as Effect from "effect/Effect"; |
| 17 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 18 | import * as Result from "effect/Result"; | 22 | import * as Result from "effect/Result"; |
| ... | @@ -21,9 +25,11 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | ... | @@ -21,9 +25,11 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 21 | 25 | ||
| 22 | import * as EnvironmentRegistry from "../connection/registry.ts"; | 26 | import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 23 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 27 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 24 | import { request } from "../rpc/client.ts"; | 28 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; |
| 29 | import { request, EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | ||
| 25 | import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; | 30 | import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; |
| 26 | import { createEnvironmentQueryAtomFamily } from "./runtime.ts"; | 31 | import { createEnvironmentQueryAtomFamily } from "./runtime.ts"; |
| 32 | import { authorizeHubSignedUrl } from "./hubSignedUrl.ts"; | ||
| 27 | 33 | ||
| 28 | const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; | 34 | const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; |
| 29 | const ASSET_URL_STALE_TIME_MS = 5 * 60_000; | 35 | const ASSET_URL_STALE_TIME_MS = 5 * 60_000; |
| ... | @@ -44,6 +50,7 @@ export class InvalidAssetCollectionKeyError extends Schema.TaggedError<InvalidAs | ... | @@ -44,6 +50,7 @@ export class InvalidAssetCollectionKeyError extends Schema.TaggedError<InvalidAs |
| 44 | const decodeAssetCollectionKey = Schema.decodeUnknownSync( | 50 | const decodeAssetCollectionKey = Schema.decodeUnknownSync( |
| 45 | Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]), | 51 | Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]), |
| 46 | ); | 52 | ); |
| 53 | const decodeAssetUrlKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, AssetResource])); | ||
| 47 | 54 | ||
| 48 | export function parseAssetCollectionKey( | 55 | export function parseAssetCollectionKey( |
| 49 | key: string, | 56 | key: string, |
| ... | @@ -57,7 +64,7 @@ export function parseAssetCollectionKey( | ... | @@ -57,7 +64,7 @@ export function parseAssetCollectionKey( |
| 57 | 64 | ||
| 58 | export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { | 65 | export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { |
| 59 | try { | 66 | try { |
| 60 | return new URL(relativeUrl, httpBaseUrl).toString(); | 67 | return environmentEndpointUrl(httpBaseUrl, relativeUrl); |
| 61 | } catch { | 68 | } catch { |
| 62 | return null; | 69 | return null; |
| 63 | } | 70 | } |
| ... | @@ -145,15 +152,28 @@ export function createAssetEnvironmentAtoms<R, E>( | ... | @@ -145,15 +152,28 @@ export function createAssetEnvironmentAtoms<R, E>( |
| 145 | readonly httpBaseUrl: string; | 152 | readonly httpBaseUrl: string; |
| 146 | } | null>, | 153 | } | null>, |
| 147 | ) { | 154 | ) { |
| 155 | const fromArchive = Effect.fn("assets.fromArchive")(function* (input: AssetCreateUrlInput) { | ||
| 156 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 157 | const hub = yield* Effect.serviceOption(HubConnection.HubConnection); | ||
| 158 | const http = yield* Effect.serviceOption(HttpClient.HttpClient); | ||
| 159 | if (Option.isNone(hub) || Option.isNone(http)) return Option.none<AssetCreateUrlResult>(); | ||
| 160 | return yield* fetchArchivedAssetUrl(supervisor.target.environmentId, input.resource).pipe( | ||
| 161 | Effect.provideService(HubConnection.HubConnection, hub.value), | ||
| 162 | Effect.provideService(HttpClient.HttpClient, http.value), | ||
| 163 | Effect.option, | ||
| 164 | ); | ||
| 165 | }); | ||
| 148 | const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) { | 166 | const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) { |
| 167 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 149 | const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result); | 168 | const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result); |
| 150 | if (Result.isSuccess(result)) return result.success; | 169 | if (Result.isSuccess(result)) return yield* authorizeHubSignedUrl(result.success); |
| 170 | const archived = yield* fromArchive(input); | ||
| 171 | if (Option.isSome(archived)) return archived.value; | ||
| 151 | const error = result.failure; | 172 | const error = result.failure; |
| 152 | const resource = input.resource; | 173 | const resource = input.resource; |
| 153 | const local = localMediaEnvironment | 174 | const local = localMediaEnvironment |
| 154 | ? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment) | 175 | ? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment) |
| 155 | : null; | 176 | : null; |
| 156 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 157 | if ( | 177 | if ( |
| 158 | !local || | 178 | !local || |
| 159 | local.environmentId === supervisor.target.environmentId || | 179 | local.environmentId === supervisor.target.environmentId || |
| ... | @@ -170,20 +190,79 @@ export function createAssetEnvironmentAtoms<R, E>( | ... | @@ -170,20 +190,79 @@ export function createAssetEnvironmentAtoms<R, E>( |
| 170 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | 190 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; |
| 171 | const asset = yield* registry.run( | 191 | const asset = yield* registry.run( |
| 172 | local.environmentId, | 192 | local.environmentId, |
| 173 | request(WS_METHODS.assetsCreateUrl, input), | 193 | request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.flatMap(authorizeHubSignedUrl)), |
| 174 | ); | 194 | ); |
| 175 | // Callers resolve against the thread's server, so preserve the local server's origin. | 195 | // Callers resolve against the thread's server, so preserve the local server's origin. |
| 176 | return { ...asset, relativeUrl: new URL(asset.relativeUrl, local.httpBaseUrl).href }; | 196 | return { ...asset, relativeUrl: environmentEndpointUrl(local.httpBaseUrl, asset.relativeUrl) }; |
| 177 | }); | 197 | }); |
| 178 | const createUrl = createEnvironmentQueryAtomFamily(runtime, { | 198 | const createUrlQuery = createEnvironmentQueryAtomFamily(runtime, { |
| 179 | label: "environment-data:assets:create-url", | 199 | label: "environment-data:assets:create-url", |
| 180 | execute, | 200 | execute, |
| 201 | executeDisconnected: (input) => | ||
| 202 | fromArchive(input).pipe( | ||
| 203 | Effect.flatMap( | ||
| 204 | Option.match({ | ||
| 205 | onSome: Effect.succeed, | ||
| 206 | onNone: () => | ||
| 207 | EnvironmentSupervisor.EnvironmentSupervisor.pipe( | ||
| 208 | Effect.flatMap((supervisor) => | ||
| 209 | Effect.fail( | ||
| 210 | new EnvironmentRpcUnavailableError({ | ||
| 211 | environmentId: supervisor.target.environmentId, | ||
| 212 | message: new ArchiveError({ reason: "unavailable" }).message, | ||
| 213 | }), | ||
| 214 | ), | ||
| 215 | ), | ||
| 216 | ), | ||
| 217 | }), | ||
| 218 | ), | ||
| 219 | ), | ||
| 181 | staleTimeMs: ASSET_URL_STALE_TIME_MS, | 220 | staleTimeMs: ASSET_URL_STALE_TIME_MS, |
| 182 | idleTtlMs: ASSET_URL_IDLE_TTL_MS, | 221 | idleTtlMs: ASSET_URL_IDLE_TTL_MS, |
| 183 | refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, | 222 | refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, |
| 184 | refreshTrigger: ({ input }) => | 223 | refreshTrigger: ({ input }) => |
| 185 | input.resource._tag === "media-file" ? localMediaEnvironment : undefined, | 224 | input.resource._tag === "media-file" ? localMediaEnvironment : undefined, |
| 186 | }); | 225 | }); |
| 226 | const signedUrlFamily = Atom.family((key: string) => { | ||
| 227 | const [environmentId, resource] = decodeAssetUrlKey(JSON.parse(key)); | ||
| 228 | const query = createUrlQuery({ environmentId, input: { resource } }); | ||
| 229 | return Atom.transform( | ||
| 230 | query, | ||
| 231 | (get) => { | ||
| 232 | const result = get(query); | ||
| 233 | if ( | ||
| 234 | AsyncResult.isSuccess(result) && | ||
| 235 | !result.waiting && | ||
| 236 | (new URL(result.value.relativeUrl, "http://relative.invalid").searchParams.has( | ||
| 237 | "wsTicket", | ||
| 238 | ) || | ||
| 239 | new URL(result.value.relativeUrl, "http://relative.invalid").pathname.includes( | ||
| 240 | "/api/archive/assets/", | ||
| 241 | )) | ||
| 242 | ) { | ||
| 243 | // @effect-diagnostics-next-line globalDate:off - Atom expiry schedules outside an Effect runtime. | ||
| 244 | const now = Date.now(); | ||
| 245 | const remaining = Math.min( | ||
| 246 | ASSET_URL_REFRESH_INTERVAL_MS, | ||
| 247 | Math.max(0, result.value.expiresAt - now - 60_000), | ||
| 248 | ); | ||
| 249 | if (remaining === 0) { | ||
| 250 | get.refresh(query); | ||
| 251 | return get.once(query); | ||
| 252 | } | ||
| 253 | // @effect-diagnostics-next-line globalTimers:off - Atom lifetime owns the refresh timer. | ||
| 254 | const handle = setTimeout(() => get.refresh(query), remaining); | ||
| 255 | get.addFinalizer(() => clearTimeout(handle)); | ||
| 256 | } | ||
| 257 | return result; | ||
| 258 | }, | ||
| 259 | { initialValueTarget: query }, | ||
| 260 | ).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); | ||
| 261 | }); | ||
| 262 | const createUrl = (target: { | ||
| 263 | readonly environmentId: EnvironmentId; | ||
| 264 | readonly input: AssetCreateUrlInput; | ||
| 265 | }) => signedUrlFamily(JSON.stringify([target.environmentId, target.input.resource])); | ||
| 187 | const createUrlsFamily = Atom.family((key: string) => { | 266 | const createUrlsFamily = Atom.family((key: string) => { |
| 188 | const [environmentId, resources] = parseAssetCollectionKey(key); | 267 | const [environmentId, resources] = parseAssetCollectionKey(key); |
| 189 | return Atom.make((get) => | 268 | return Atom.make((get) => |
packages/client-runtime/src/state/attachments.ts+7| ... | @@ -7,8 +7,11 @@ import { | ... | @@ -7,8 +7,11 @@ import { |
| 7 | type EnvironmentId, | 7 | type EnvironmentId, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | 9 | import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 10 | import * as Effect from "effect/Effect"; | ||
| 10 | 11 | ||
| 11 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 12 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 13 | import { requestGuarded } from "../rpc/client.ts"; | ||
| 14 | import { authorizeHubSignedUrl } from "./hubSignedUrl.ts"; | ||
| 12 | import { | 15 | import { |
| 13 | createEnvironmentRpcCommand, | 16 | createEnvironmentRpcCommand, |
| 14 | executeAtomQuery, | 17 | executeAtomQuery, |
| ... | @@ -29,6 +32,10 @@ export function createAttachmentEnvironmentAtoms<R, E>( | ... | @@ -29,6 +32,10 @@ export function createAttachmentEnvironmentAtoms<R, E>( |
| 29 | createUploadUrl: createEnvironmentRpcCommand(runtime, { | 32 | createUploadUrl: createEnvironmentRpcCommand(runtime, { |
| 30 | label: "environment-command:attachments:create-upload-url", | 33 | label: "environment-command:attachments:create-upload-url", |
| 31 | tag: WS_METHODS.attachmentsCreateUploadUrl, | 34 | tag: WS_METHODS.attachmentsCreateUploadUrl, |
| 35 | execute: (input) => | ||
| 36 | requestGuarded(WS_METHODS.attachmentsCreateUploadUrl, input).pipe( | ||
| 37 | Effect.flatMap(authorizeHubSignedUrl), | ||
| 38 | ), | ||
| 32 | }), | 39 | }), |
| 33 | remove: createEnvironmentRpcCommand(runtime, { | 40 | remove: createEnvironmentRpcCommand(runtime, { |
| 34 | label: "environment-command:attachments:delete", | 41 | label: "environment-command:attachments:delete", |
packages/client-runtime/src/state/environmentHttpAuth.test.ts+26| ... | @@ -23,6 +23,7 @@ import * as Tracer from "effect/Tracer"; | ... | @@ -23,6 +23,7 @@ import * as Tracer from "effect/Tracer"; |
| 23 | import { TestClock } from "effect/testing"; | 23 | import { TestClock } from "effect/testing"; |
| 24 | import type { HttpClient } from "effect/http"; | 24 | import type { HttpClient } from "effect/http"; |
| 25 | 25 | ||
| 26 | import { HostedAuthAccess } from "../platform/capabilities.ts"; | ||
| 26 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | 27 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 27 | import { | 28 | import { |
| 28 | ConnectionTransientError, | 29 | ConnectionTransientError, |
| ... | @@ -629,6 +630,31 @@ describe("authenticated environment HTTP requests", () => { | ... | @@ -629,6 +630,31 @@ describe("authenticated environment HTTP requests", () => { |
| 629 | }), | 630 | }), |
| 630 | ); | 631 | ); |
| 631 | 632 | ||
| 633 | it.effect("adds current hosted CSRF to cookie-authenticated environment requests", () => | ||
| 634 | Effect.gen(function* () { | ||
| 635 | const harness = makeHarness(() => Response.json(SESSION)); | ||
| 636 | const servers: string[] = []; | ||
| 637 | yield* fetchEnvironmentSessionState({ | ||
| 638 | prepared: { ...PREPARED, httpAuthorization: null }, | ||
| 639 | signer: Option.none(), | ||
| 640 | }).pipe( | ||
| 641 | Effect.provide(harness.httpLayer), | ||
| 642 | Effect.provideService(HostedAuthAccess, { | ||
| 643 | headers: (server) => | ||
| 644 | Effect.sync(() => { | ||
| 645 | servers.push(server); | ||
| 646 | return { "x-csrf-token": "current-cookie-proof" }; | ||
| 647 | }), | ||
| 648 | }), | ||
| 649 | ); | ||
| 650 | expect(servers).toEqual([PREPARED.httpBaseUrl]); | ||
| 651 | expect(new Headers(harness.calls[0]!.init.headers).get("x-csrf-token")).toBe( | ||
| 652 | "current-cookie-proof", | ||
| 653 | ); | ||
| 654 | expect(harness.calls[0]!.init.credentials).toBe("include"); | ||
| 655 | }), | ||
| 656 | ); | ||
| 657 | |||
| 632 | it.effect("keeps the caller's timeout while waiting for renewal", () => | 658 | it.effect("keeps the caller's timeout while waiting for renewal", () => |
| 633 | Effect.gen(function* () { | 659 | Effect.gen(function* () { |
| 634 | const harness = makeHarness(() => Response.json(SESSION)); | 660 | const harness = makeHarness(() => Response.json(SESSION)); |
packages/client-runtime/src/state/environmentHttpAuth.ts+7-1| ... | @@ -8,6 +8,7 @@ import * as Result from "effect/Result"; | ... | @@ -8,6 +8,7 @@ import * as Result from "effect/Result"; |
| 8 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | 8 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 9 | import { FetchHttpClient, type HttpMethod } from "effect/http"; | 9 | import { FetchHttpClient, type HttpMethod } from "effect/http"; |
| 10 | 10 | ||
| 11 | import { HostedAuthAccess } from "../platform/capabilities.ts"; | ||
| 11 | import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | 12 | import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; |
| 12 | import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts"; | 13 | import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts"; |
| 13 | import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; | 14 | import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| ... | @@ -21,6 +22,7 @@ import { | ... | @@ -21,6 +22,7 @@ import { |
| 21 | } from "../rpc/http.ts"; | 22 | } from "../rpc/http.ts"; |
| 22 | 23 | ||
| 23 | export interface EnvironmentHttpAuthHeaders { | 24 | export interface EnvironmentHttpAuthHeaders { |
| 25 | readonly "x-csrf-token"?: string; | ||
| 24 | readonly authorization?: string; | 26 | readonly authorization?: string; |
| 25 | readonly dpop?: string; | 27 | readonly dpop?: string; |
| 26 | } | 28 | } |
| ... | @@ -71,7 +73,11 @@ const buildEnvironmentAuthHeaders = ( | ... | @@ -71,7 +73,11 @@ const buildEnvironmentAuthHeaders = ( |
| 71 | ): Effect.Effect<EnvironmentHttpAuthHeaders, RemoteEnvironmentAuthFetchError> => | 73 | ): Effect.Effect<EnvironmentHttpAuthHeaders, RemoteEnvironmentAuthFetchError> => |
| 72 | Effect.gen(function* () { | 74 | Effect.gen(function* () { |
| 73 | if (authorization === null) { | 75 | if (authorization === null) { |
| 74 | return {}; | 76 | const hosted = yield* Effect.serviceOption(HostedAuthAccess); |
| 77 | if (Option.isNone(hosted)) return {}; | ||
| 78 | return yield* hosted.value | ||
| 79 | .headers(new URL(url).origin) | ||
| 80 | .pipe(Effect.orElseSucceed(() => ({}))); | ||
| 75 | } | 81 | } |
| 76 | if (authorization._tag === "Bearer") { | 82 | if (authorization._tag === "Bearer") { |
| 77 | return { authorization: `Bearer ${authorization.token}` }; | 83 | return { authorization: `Bearer ${authorization.token}` }; |
packages/client-runtime/src/state/hub-capabilities.test.ts created+125| ... | @@ -0,0 +1,125 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | AuthOrchestrationOperateScope, | ||
| 4 | EnvironmentId, | ||
| 5 | ExecutionEnvironmentDescriptor, | ||
| 6 | type ServerConfig, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Fiber from "effect/Fiber"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 13 | import { Atom, AtomRegistry } from "effect/reactivity"; | ||
| 14 | |||
| 15 | import { HubConnection, type HubConnectionState } from "../connection/hub.ts"; | ||
| 16 | import { createHubConnectionAtoms, createHubEnvironmentCapabilitiesAtom } from "./hub.ts"; | ||
| 17 | |||
| 18 | const hubId = EnvironmentId.make("hub"); | ||
| 19 | const id = EnvironmentId.make("offline"); | ||
| 20 | const descriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor)({ | ||
| 21 | environmentId: id, | ||
| 22 | label: "Offline machine", | ||
| 23 | platform: { os: "darwin", arch: "arm64" }, | ||
| 24 | serverVersion: "test", | ||
| 25 | capabilities: {}, | ||
| 26 | }); | ||
| 27 | const initial = { | ||
| 28 | connection: { environmentId: hubId, label: "Hub", httpBaseUrl: "https://hub.example/" }, | ||
| 29 | directory: { | ||
| 30 | hubEnvironmentId: hubId, | ||
| 31 | environments: [ | ||
| 32 | { | ||
| 33 | environmentId: id, | ||
| 34 | label: "Offline machine", | ||
| 35 | httpBaseUrl: "https://hub.example/environments/offline/", | ||
| 36 | scopes: [AuthOrchestrationOperateScope], | ||
| 37 | descriptor, | ||
| 38 | }, | ||
| 39 | ], | ||
| 40 | }, | ||
| 41 | inbox: { | ||
| 42 | environments: [ | ||
| 43 | { | ||
| 44 | environmentId: id, | ||
| 45 | pendingThreadIds: [], | ||
| 46 | snapshot: { | ||
| 47 | schemaVersion: 1, | ||
| 48 | snapshotSequence: 0, | ||
| 49 | projects: [], | ||
| 50 | threads: [], | ||
| 51 | archivedThreads: [], | ||
| 52 | }, | ||
| 53 | }, | ||
| 54 | ], | ||
| 55 | }, | ||
| 56 | refreshing: false, | ||
| 57 | error: null, | ||
| 58 | } satisfies HubConnectionState; | ||
| 59 | |||
| 60 | describe("central organization availability", () => { | ||
| 61 | it.effect( | ||
| 62 | "permits offline organization from the directory grant without a source configuration", | ||
| 63 | () => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const state = yield* SubscriptionRef.make<HubConnectionState>(initial); | ||
| 66 | const runtime = Atom.runtime( | ||
| 67 | Layer.succeed(HubConnection, { | ||
| 68 | state, | ||
| 69 | connect: () => Effect.void, | ||
| 70 | connectAuthenticated: () => Effect.void, | ||
| 71 | headers: Effect.succeed({}), | ||
| 72 | refresh: Effect.void, | ||
| 73 | refreshInbox: Effect.void, | ||
| 74 | disconnect: Effect.void, | ||
| 75 | organize: () => Effect.succeedNone, | ||
| 76 | }), | ||
| 77 | ); | ||
| 78 | const hub = createHubConnectionAtoms(runtime); | ||
| 79 | const configs = Atom.make<ReadonlyMap<EnvironmentId, ServerConfig>>(new Map()); | ||
| 80 | const capabilities = createHubEnvironmentCapabilitiesAtom(configs, hub); | ||
| 81 | const registry = AtomRegistry.make(); | ||
| 82 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | ||
| 83 | registry.mount(hub.organizationPermissionAtom(id)); | ||
| 84 | yield* AtomRegistry.getResult(registry, hub.stateAtom); | ||
| 85 | |||
| 86 | expect(registry.get(hub.organizationPermissionAtom(id))).toBe(true); | ||
| 87 | expect(registry.get(hub.organizationPermissionAtom(EnvironmentId.make("other")))).toBe( | ||
| 88 | false, | ||
| 89 | ); | ||
| 90 | expect(registry.get(configs).size).toBe(0); | ||
| 91 | expect(registry.get(capabilities).get(id)).toMatchObject({ | ||
| 92 | threadSettlement: true, | ||
| 93 | threadSnooze: true, | ||
| 94 | threadPinning: true, | ||
| 95 | threadPinReorder: true, | ||
| 96 | threadActiveReorder: true, | ||
| 97 | threadAutoSettleOptOut: true, | ||
| 98 | }); | ||
| 99 | |||
| 100 | expect(registry.get(capabilities).get(id)?.threadTitleRegeneration).not.toBe(true); | ||
| 101 | const revoked = yield* Effect.forkChild( | ||
| 102 | Effect.callback<void>((resume) => { | ||
| 103 | const unsubscribe = registry.subscribe( | ||
| 104 | hub.organizationPermissionAtom(id), | ||
| 105 | () => { | ||
| 106 | if (!registry.get(hub.organizationPermissionAtom(id))) resume(Effect.void); | ||
| 107 | }, | ||
| 108 | { immediate: true }, | ||
| 109 | ); | ||
| 110 | return Effect.sync(unsubscribe); | ||
| 111 | }), | ||
| 112 | ); | ||
| 113 | yield* SubscriptionRef.set(state, { | ||
| 114 | ...initial, | ||
| 115 | directory: { | ||
| 116 | ...initial.directory, | ||
| 117 | environments: initial.directory.environments.map((entry) => ({ ...entry, scopes: [] })), | ||
| 118 | }, | ||
| 119 | }); | ||
| 120 | yield* Fiber.join(revoked); | ||
| 121 | expect(registry.get(hub.organizationPermissionAtom(id))).toBe(false); | ||
| 122 | expect(registry.get(capabilities).get(id)?.threadSettlement).not.toBe(true); | ||
| 123 | }).pipe(Effect.scoped), | ||
| 124 | ); | ||
| 125 | }); | ||
packages/client-runtime/src/state/hub.ts created+173| ... | @@ -0,0 +1,173 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import * as Stream from "effect/Stream"; | ||
| 4 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 5 | import { AsyncResult, Atom } from "effect/reactivity"; | ||
| 6 | import { | ||
| 7 | AuthOrchestrationOperateScope, | ||
| 8 | HUB_THREAD_ORGANIZATION_FIELDS, | ||
| 9 | type EnvironmentId, | ||
| 10 | type ExecutionEnvironmentCapabilities, | ||
| 11 | type HubInboxEnvironment, | ||
| 12 | type ServerConfig, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import * as Equal from "effect/Equal"; | ||
| 15 | import * as Struct from "effect/Struct"; | ||
| 16 | |||
| 17 | import { HubConnection, EMPTY_HUB_CONNECTION_STATE } from "../connection/hub.ts"; | ||
| 18 | import type { PairingConnectionInput } from "../connection/onboarding.ts"; | ||
| 19 | import { createAtomCommandScheduler, createRuntimeCommand } from "./runtime.ts"; | ||
| 20 | import type { EnvironmentShellState } from "./shell.ts"; | ||
| 21 | |||
| 22 | export type { HubConnectionState, HubServerConnection } from "../connection/hub.ts"; | ||
| 23 | export type { PairingConnectionInput } from "../connection/onboarding.ts"; | ||
| 24 | |||
| 25 | export function applyHubInboxEnvironment( | ||
| 26 | state: EnvironmentShellState, | ||
| 27 | entry: HubInboxEnvironment | undefined, | ||
| 28 | ): EnvironmentShellState { | ||
| 29 | if (entry === undefined) return state; | ||
| 30 | const local = Option.getOrNull(state.snapshot); | ||
| 31 | const snapshot = | ||
| 32 | local !== null && | ||
| 33 | (state.status === "live" || local.snapshotSequence > entry.snapshot.snapshotSequence) | ||
| 34 | ? local | ||
| 35 | : entry.snapshot; | ||
| 36 | const pending = new Set(entry.pendingThreadIds); | ||
| 37 | const organization = new Map(entry.snapshot.threads.map((thread) => [thread.id, thread])); | ||
| 38 | let changed = false; | ||
| 39 | const threads = snapshot.threads.map((thread) => { | ||
| 40 | const central = organization.get(thread.id); | ||
| 41 | if (!pending.has(thread.id) || central === undefined) return thread; | ||
| 42 | const fields = Struct.pick(central, HUB_THREAD_ORGANIZATION_FIELDS); | ||
| 43 | if (Equal.equals(fields, Struct.pick(thread, HUB_THREAD_ORGANIZATION_FIELDS))) return thread; | ||
| 44 | changed = true; | ||
| 45 | return { ...thread, ...fields }; | ||
| 46 | }); | ||
| 47 | const next = changed ? { ...snapshot, threads } : snapshot; | ||
| 48 | if (local === next) return state; | ||
| 49 | return { | ||
| 50 | ...state, | ||
| 51 | snapshot: Option.some(next), | ||
| 52 | status: state.status === "live" ? "live" : "cached", | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | export function createHubConnectionAtoms<R, E>(runtime: Atom.AtomRuntime<HubConnection | R, E>) { | ||
| 57 | const stateAtom = runtime.atom( | ||
| 58 | Stream.unwrap(HubConnection.pipe(Effect.map((hub) => SubscriptionRef.changes(hub.state)))), | ||
| 59 | { initialValue: EMPTY_HUB_CONNECTION_STATE }, | ||
| 60 | ); | ||
| 61 | const stateValueAtom = Atom.make((get) => | ||
| 62 | Option.getOrElse(AsyncResult.value(get(stateAtom)), () => EMPTY_HUB_CONNECTION_STATE), | ||
| 63 | ); | ||
| 64 | const inboxValueAtom = Atom.make((get) => get(stateValueAtom).inbox); | ||
| 65 | const organizationEnvironmentIdsAtom = Atom.make((get) => { | ||
| 66 | const state = get(stateValueAtom); | ||
| 67 | if (state.connection === null) return new Set<EnvironmentId>(); | ||
| 68 | const permitted = new Set( | ||
| 69 | state.directory?.environments.flatMap((entry) => | ||
| 70 | entry.scopes.includes(AuthOrchestrationOperateScope) ? [entry.environmentId] : [], | ||
| 71 | ), | ||
| 72 | ); | ||
| 73 | return new Set( | ||
| 74 | state.inbox.environments.flatMap((entry) => | ||
| 75 | permitted.has(entry.environmentId) ? [entry.environmentId] : [], | ||
| 76 | ), | ||
| 77 | ); | ||
| 78 | }).pipe( | ||
| 79 | Atom.withEquality<Set<EnvironmentId>>( | ||
| 80 | (left, right) => left.size === right.size && [...left].every((id) => right.has(id)), | ||
| 81 | ), | ||
| 82 | ); | ||
| 83 | const organizationPermissionAtom = Atom.family((environmentId: EnvironmentId | null) => | ||
| 84 | Atom.make((get) => | ||
| 85 | environmentId === null ? false : get(organizationEnvironmentIdsAtom).has(environmentId), | ||
| 86 | ), | ||
| 87 | ); | ||
| 88 | const scheduler = createAtomCommandScheduler(); | ||
| 89 | const concurrency = { mode: "serial" as const, key: () => "hub" }; | ||
| 90 | const connect = createRuntimeCommand(runtime, { | ||
| 91 | label: "hub:connect", | ||
| 92 | scheduler, | ||
| 93 | concurrency, | ||
| 94 | execute: (input: PairingConnectionInput) => | ||
| 95 | HubConnection.pipe(Effect.flatMap((hub) => hub.connect(input))), | ||
| 96 | }); | ||
| 97 | const connectAuthenticated = createRuntimeCommand(runtime, { | ||
| 98 | label: "hub:sign-in", | ||
| 99 | scheduler, | ||
| 100 | concurrency, | ||
| 101 | execute: (input: { readonly httpBaseUrl: string }) => | ||
| 102 | HubConnection.pipe(Effect.flatMap((hub) => hub.connectAuthenticated(input))), | ||
| 103 | }); | ||
| 104 | const refresh = createRuntimeCommand(runtime, { | ||
| 105 | label: "hub:refresh", | ||
| 106 | scheduler, | ||
| 107 | concurrency, | ||
| 108 | execute: (_input: void) => HubConnection.pipe(Effect.flatMap((hub) => hub.refresh)), | ||
| 109 | }); | ||
| 110 | const disconnect = createRuntimeCommand(runtime, { | ||
| 111 | label: "hub:disconnect", | ||
| 112 | scheduler, | ||
| 113 | concurrency, | ||
| 114 | execute: (_input: void) => HubConnection.pipe(Effect.flatMap((hub) => hub.disconnect)), | ||
| 115 | }); | ||
| 116 | return { | ||
| 117 | stateAtom, | ||
| 118 | stateValueAtom, | ||
| 119 | inboxValueAtom, | ||
| 120 | organizationEnvironmentIdsAtom, | ||
| 121 | organizationPermissionAtom, | ||
| 122 | connect, | ||
| 123 | connectAuthenticated, | ||
| 124 | refresh, | ||
| 125 | disconnect, | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | |||
| 129 | export function createHubEnvironmentCapabilitiesAtom( | ||
| 130 | configsAtom: Atom.Atom<ReadonlyMap<EnvironmentId, ServerConfig>>, | ||
| 131 | hub: Pick< | ||
| 132 | ReturnType<typeof createHubConnectionAtoms>, | ||
| 133 | "stateValueAtom" | "organizationEnvironmentIdsAtom" | ||
| 134 | >, | ||
| 135 | ) { | ||
| 136 | return Atom.make((get): ReadonlyMap<EnvironmentId, ExecutionEnvironmentCapabilities> => { | ||
| 137 | const capabilities = new Map( | ||
| 138 | get(hub.stateValueAtom).directory?.environments.map((entry) => [ | ||
| 139 | entry.environmentId, | ||
| 140 | entry.descriptor.capabilities, | ||
| 141 | ]), | ||
| 142 | ); | ||
| 143 | for (const [id, config] of get(configsAtom)) | ||
| 144 | capabilities.set(id, config.environment.capabilities); | ||
| 145 | for (const id of get(hub.organizationEnvironmentIdsAtom)) { | ||
| 146 | const current = capabilities.get(id); | ||
| 147 | if (current === undefined) continue; | ||
| 148 | capabilities.set(id, { | ||
| 149 | ...current, | ||
| 150 | threadSettlement: true, | ||
| 151 | threadSnooze: true, | ||
| 152 | threadPinning: true, | ||
| 153 | threadPinReorder: true, | ||
| 154 | threadActiveReorder: true, | ||
| 155 | threadAutoSettleOptOut: true, | ||
| 156 | }); | ||
| 157 | } | ||
| 158 | return capabilities; | ||
| 159 | }).pipe( | ||
| 160 | Atom.withEquality<ReadonlyMap<EnvironmentId, ExecutionEnvironmentCapabilities>>( | ||
| 161 | (left, right) => | ||
| 162 | left.size === right.size && | ||
| 163 | [...left].every(([id, capabilities]) => { | ||
| 164 | const other = right.get(id); | ||
| 165 | return ( | ||
| 166 | other !== undefined && | ||
| 167 | Object.keys(capabilities).length === Object.keys(other).length && | ||
| 168 | Object.entries(capabilities).every(([key, value]) => Reflect.get(other, key) === value) | ||
| 169 | ); | ||
| 170 | }), | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | } | ||
packages/client-runtime/src/state/hubSignedUrl.test.ts created+234| ... | @@ -0,0 +1,234 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | AuthStandardClientScopes, | ||
| 4 | EnvironmentId, | ||
| 5 | EnvironmentAuthorizationError, | ||
| 6 | WS_METHODS, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Stream from "effect/Stream"; | ||
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 14 | import { Atom, AtomRegistry } from "effect/reactivity"; | ||
| 15 | import { vi } from "vite-plus/test"; | ||
| 16 | |||
| 17 | import * as EnvironmentRegistry from "../connection/registry.ts"; | ||
| 18 | import { | ||
| 19 | AVAILABLE_CONNECTION_STATE, | ||
| 20 | BearerConnectionTarget, | ||
| 21 | type PreparedConnection, | ||
| 22 | type SupervisorConnectionState, | ||
| 23 | } from "../connection/model.ts"; | ||
| 24 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | ||
| 25 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 26 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 27 | import { layerRemoteHttpClient } from "../rpc/http.ts"; | ||
| 28 | import { createAssetEnvironmentAtoms, resolveAssetUrl } from "./assets.ts"; | ||
| 29 | import { createAttachmentEnvironmentAtoms, runAttachmentUploadCycle } from "./attachments.ts"; | ||
| 30 | import { runAtomCommand } from "./runtime.ts"; | ||
| 31 | |||
| 32 | const environmentId = EnvironmentId.make("laptop"); | ||
| 33 | const base = "https://hub.example/knife/environments/laptop/"; | ||
| 34 | |||
| 35 | const harness = (hub: boolean, allowed = true) => | ||
| 36 | Effect.gen(function* () { | ||
| 37 | const tickets = new Map<string, number>(); | ||
| 38 | const calls = { assets: 0, uploads: 0, tickets: 0 }; | ||
| 39 | const target = new BearerConnectionTarget({ | ||
| 40 | environmentId, | ||
| 41 | label: "Laptop", | ||
| 42 | connectionId: hub ? "hub:home:laptop" : "direct:laptop", | ||
| 43 | }); | ||
| 44 | const prepared: PreparedConnection = { | ||
| 45 | environmentId, | ||
| 46 | label: "Laptop", | ||
| 47 | httpBaseUrl: base, | ||
| 48 | socketUrl: `${base.replace(/^http/, "ws")}ws`, | ||
| 49 | httpAuthorization: { _tag: "Bearer", token: "root-session" }, | ||
| 50 | target, | ||
| 51 | }; | ||
| 52 | const fetcher: typeof fetch = async (input, init) => { | ||
| 53 | const url = new URL(String(input)); | ||
| 54 | if (url.pathname.endsWith("/api/auth/session")) | ||
| 55 | return Response.json({ | ||
| 56 | authenticated: true, | ||
| 57 | auth: { | ||
| 58 | policy: "remote-reachable", | ||
| 59 | bootstrapMethods: [], | ||
| 60 | sessionMethods: [], | ||
| 61 | sessionCookieName: "test", | ||
| 62 | }, | ||
| 63 | scopes: allowed ? AuthStandardClientScopes : [], | ||
| 64 | permissions: allowed ? AuthStandardClientScopes : [], | ||
| 65 | }); | ||
| 66 | if (url.pathname.endsWith("/api/auth/websocket-ticket")) { | ||
| 67 | expect(new Headers(init?.headers).get("authorization")).toBe("Bearer root-session"); | ||
| 68 | const ticket = `media-${++calls.tickets}`; | ||
| 69 | const expiresAt = DateTime.toEpochMillis(DateTime.nowUnsafe()) + 5 * 60_000; | ||
| 70 | tickets.set(ticket, expiresAt); | ||
| 71 | return Response.json({ | ||
| 72 | ticket, | ||
| 73 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAt)), | ||
| 74 | }); | ||
| 75 | } | ||
| 76 | if ( | ||
| 77 | url.pathname.includes("/api/assets/") || | ||
| 78 | url.pathname.includes("/api/attachments/upload/") | ||
| 79 | ) { | ||
| 80 | const authorized = | ||
| 81 | !hub || | ||
| 82 | (tickets.get(url.searchParams.get("wsTicket") ?? "") ?? 0) > | ||
| 83 | DateTime.toEpochMillis(DateTime.nowUnsafe()); | ||
| 84 | return new Response(authorized ? "file bytes" : "unauthorized", { | ||
| 85 | status: authorized ? 200 : 401, | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | throw new Error(`Unexpected URL ${url}`); | ||
| 89 | }; | ||
| 90 | const client = { | ||
| 91 | [WS_METHODS.assetsCreateUrl]: () => | ||
| 92 | Effect.sync(() => { | ||
| 93 | calls.assets++; | ||
| 94 | return { | ||
| 95 | relativeUrl: "/api/assets/leaf-signed/photo.png", | ||
| 96 | expiresAt: DateTime.toEpochMillis(DateTime.nowUnsafe()) + 3_600_000, | ||
| 97 | }; | ||
| 98 | }), | ||
| 99 | [WS_METHODS.attachmentsCreateUploadUrl]: () => | ||
| 100 | !allowed | ||
| 101 | ? Effect.fail( | ||
| 102 | new EnvironmentAuthorizationError({ | ||
| 103 | requiredScope: "orchestration:operate", | ||
| 104 | message: "Not allowed", | ||
| 105 | }), | ||
| 106 | ) | ||
| 107 | : Effect.sync(() => { | ||
| 108 | calls.uploads++; | ||
| 109 | return { | ||
| 110 | attachmentId: "pending", | ||
| 111 | relativeUrl: "/api/attachments/upload/leaf-signed", | ||
| 112 | expiresAt: DateTime.toEpochMillis(DateTime.nowUnsafe()) + 600_000, | ||
| 113 | }; | ||
| 114 | }), | ||
| 115 | } as unknown as WsRpcProtocolClient; | ||
| 116 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 117 | target, | ||
| 118 | state: yield* SubscriptionRef.make<SupervisorConnectionState>({ | ||
| 119 | ...AVAILABLE_CONNECTION_STATE, | ||
| 120 | phase: "connected" as const, | ||
| 121 | }), | ||
| 122 | prepared: yield* SubscriptionRef.make(Option.some(prepared)), | ||
| 123 | session: yield* SubscriptionRef.make(Option.some({ client } as RpcSession)), | ||
| 124 | connect: Effect.void, | ||
| 125 | disconnect: Effect.void, | ||
| 126 | retryNow: Effect.void, | ||
| 127 | }); | ||
| 128 | const environments = EnvironmentRegistry.EnvironmentRegistry.of({ | ||
| 129 | run: (_id, effect) => | ||
| 130 | Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 131 | followStream: (_id, stream) => | ||
| 132 | Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 133 | } as EnvironmentRegistry.EnvironmentRegistry["Service"]); | ||
| 134 | const runtime = Atom.runtime( | ||
| 135 | Layer.merge( | ||
| 136 | layerRemoteHttpClient(fetcher), | ||
| 137 | Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | const registry = AtomRegistry.make(); | ||
| 141 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | ||
| 142 | const assets = createAssetEnvironmentAtoms(runtime); | ||
| 143 | const attachments = createAttachmentEnvironmentAtoms(runtime); | ||
| 144 | const query = assets.createUrl({ | ||
| 145 | environmentId, | ||
| 146 | input: { resource: { _tag: "attachment", attachmentId: "photo" } }, | ||
| 147 | }); | ||
| 148 | return { registry, assets, attachments, query, fetcher, calls }; | ||
| 149 | }); | ||
| 150 | |||
| 151 | describe("hub signed media capabilities", () => { | ||
| 152 | it.effect("authorizes image reads and guarded byte uploads through the public hub prefix", () => | ||
| 153 | Effect.scoped( | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const test = yield* harness(true); | ||
| 156 | const asset = yield* AtomRegistry.getResult(test.registry, test.query, { | ||
| 157 | suspendOnWaiting: true, | ||
| 158 | }); | ||
| 159 | const url = resolveAssetUrl(base, asset.relativeUrl)!; | ||
| 160 | expect(url).toContain(`${base}api/assets/leaf-signed/photo.png?wsTicket=`); | ||
| 161 | expect(asset.expiresAt).toBeLessThanOrEqual( | ||
| 162 | DateTime.toEpochMillis(DateTime.nowUnsafe()) + 5 * 60_000, | ||
| 163 | ); | ||
| 164 | expect((yield* Effect.promise(() => test.fetcher(url))).status).toBe(200); | ||
| 165 | expect((yield* Effect.promise(() => test.fetcher(url.split("?")[0]!))).status).toBe(401); | ||
| 166 | const upload = yield* Effect.promise(() => | ||
| 167 | runAttachmentUploadCycle({ | ||
| 168 | registry: test.registry, | ||
| 169 | createUploadUrl: test.attachments.createUploadUrl, | ||
| 170 | remove: test.attachments.remove, | ||
| 171 | environmentId, | ||
| 172 | upload: { type: "file", name: "notes.txt", mimeType: "text/plain", sizeBytes: 3 }, | ||
| 173 | resolveUploadUrl: (relativeUrl) => resolveAssetUrl(base, relativeUrl), | ||
| 174 | transport: (uploadUrl) => ({ | ||
| 175 | done: test.fetcher(uploadUrl, { method: "PUT", body: "abc" }).then((response) => { | ||
| 176 | expect(response.status).toBe(200); | ||
| 177 | }), | ||
| 178 | abort: () => {}, | ||
| 179 | }), | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | expect(upload).toEqual({ status: "uploaded", attachmentId: "pending" }); | ||
| 183 | expect(test.calls).toEqual({ assets: 1, uploads: 1, tickets: 2 }); | ||
| 184 | }), | ||
| 185 | ), | ||
| 186 | ); | ||
| 187 | |||
| 188 | it.effect("does not mint a hub ticket when the server refuses the upload", () => | ||
| 189 | Effect.scoped( | ||
| 190 | Effect.gen(function* () { | ||
| 191 | const test = yield* harness(true, false); | ||
| 192 | const result = yield* Effect.promise(() => | ||
| 193 | runAtomCommand( | ||
| 194 | test.registry, | ||
| 195 | test.attachments.createUploadUrl, | ||
| 196 | { | ||
| 197 | environmentId, | ||
| 198 | input: { type: "file", name: "notes.txt", mimeType: "text/plain", sizeBytes: 3 }, | ||
| 199 | }, | ||
| 200 | { reportFailure: false }, | ||
| 201 | ), | ||
| 202 | ); | ||
| 203 | expect(result._tag).toBe("Failure"); | ||
| 204 | expect(test.calls).toEqual({ assets: 0, uploads: 0, tickets: 0 }); | ||
| 205 | }), | ||
| 206 | ), | ||
| 207 | ); | ||
| 208 | |||
| 209 | it.effect.each([true, false])( | ||
| 210 | "refreshes ticket-bound images before expiry without shortening direct asset caching (hub=%s)", | ||
| 211 | (hub) => | ||
| 212 | Effect.scoped( | ||
| 213 | Effect.gen(function* () { | ||
| 214 | vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); | ||
| 215 | yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); | ||
| 216 | const test = yield* harness(hub); | ||
| 217 | const unmount = test.registry.mount(test.query); | ||
| 218 | yield* Effect.addFinalizer(() => Effect.sync(unmount)); | ||
| 219 | yield* AtomRegistry.getResult(test.registry, test.query, { suspendOnWaiting: true }); | ||
| 220 | vi.advanceTimersByTime(4 * 60_000); | ||
| 221 | yield* Effect.yieldNow; | ||
| 222 | const asset = yield* AtomRegistry.getResult(test.registry, test.query, { | ||
| 223 | suspendOnWaiting: true, | ||
| 224 | }); | ||
| 225 | expect(test.calls.assets).toBe(hub ? 2 : 1); | ||
| 226 | expect(test.calls.tickets).toBe(hub ? 2 : 0); | ||
| 227 | expect( | ||
| 228 | (yield* Effect.promise(() => test.fetcher(resolveAssetUrl(base, asset.relativeUrl)!))) | ||
| 229 | .status, | ||
| 230 | ).toBe(200); | ||
| 231 | }), | ||
| 232 | ), | ||
| 233 | ); | ||
| 234 | }); | ||
packages/client-runtime/src/state/hubSignedUrl.ts created+50| ... | @@ -0,0 +1,50 @@ | ||
| 1 | import * as DateTime from "effect/DateTime"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 5 | import { HttpClient } from "effect/http"; | ||
| 6 | |||
| 7 | import { issueRemoteWebSocketTicket } from "../authorization/remote.ts"; | ||
| 8 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | ||
| 9 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | ||
| 10 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | ||
| 11 | |||
| 12 | export const authorizeHubSignedUrl = Effect.fn("clientRuntime.state.authorizeHubSignedUrl")( | ||
| 13 | function* <A extends { readonly relativeUrl: string; readonly expiresAt: number }>(result: A) { | ||
| 14 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 15 | const prepared = Option.getOrNull(yield* SubscriptionRef.get(supervisor.prepared)); | ||
| 16 | const target = prepared?.target ?? supervisor.target; | ||
| 17 | if (target._tag !== "BearerConnectionTarget" || !target.connectionId.startsWith("hub:")) { | ||
| 18 | return result; | ||
| 19 | } | ||
| 20 | |||
| 21 | const unavailable = () => | ||
| 22 | new EnvironmentRpcUnavailableError({ | ||
| 23 | environmentId: target.environmentId, | ||
| 24 | message: "Reconnect to this server to access its files.", | ||
| 25 | }); | ||
| 26 | if (prepared?.httpAuthorization?._tag !== "Bearer") return yield* unavailable(); | ||
| 27 | |||
| 28 | const client = yield* Effect.serviceOption(HttpClient.HttpClient); | ||
| 29 | if (Option.isNone(client)) return yield* unavailable(); | ||
| 30 | |||
| 31 | const ticket = yield* issueRemoteWebSocketTicket({ | ||
| 32 | httpBaseUrl: prepared.httpBaseUrl, | ||
| 33 | bearerToken: prepared.httpAuthorization.token, | ||
| 34 | }).pipe( | ||
| 35 | Effect.provideService(HttpClient.HttpClient, client.value), | ||
| 36 | Effect.mapError(unavailable), | ||
| 37 | ); | ||
| 38 | const base = new URL(prepared.httpBaseUrl); | ||
| 39 | const url = new URL(environmentEndpointUrl(prepared.httpBaseUrl, result.relativeUrl)); | ||
| 40 | if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) | ||
| 41 | return yield* unavailable(); | ||
| 42 | url.searchParams.set("wsTicket", ticket.ticket); | ||
| 43 | |||
| 44 | return { | ||
| 45 | ...result, | ||
| 46 | relativeUrl: url.href, | ||
| 47 | expiresAt: Math.min(result.expiresAt, DateTime.toEpochMillis(ticket.expiresAt)), | ||
| 48 | }; | ||
| 49 | }, | ||
| 50 | ); | ||
packages/client-runtime/src/state/orchestration.ts+34-4| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ORCHESTRATION_V2_WS_METHODS, type ThreadId } from "@t3tools/contracts"; | 1 | import { ArchiveError, ORCHESTRATION_V2_WS_METHODS, type ThreadId } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 4 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 4 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| ... | @@ -7,6 +7,7 @@ import { Atom } from "effect/reactivity"; | ... | @@ -7,6 +7,7 @@ import { Atom } from "effect/reactivity"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | createEnvironmentRpcCommand, | 9 | createEnvironmentRpcCommand, |
| 10 | createEnvironmentQueryAtomFamily, | ||
| 10 | createEnvironmentRpcQueryAtomFamily, | 11 | createEnvironmentRpcQueryAtomFamily, |
| 11 | createEnvironmentRpcSubscriptionAtomFamily, | 12 | createEnvironmentRpcSubscriptionAtomFamily, |
| 12 | createEnvironmentCommand, | 13 | createEnvironmentCommand, |
| ... | @@ -15,7 +16,12 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; | ... | @@ -15,7 +16,12 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 15 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 16 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 16 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | 17 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 17 | import * as ManagedRelayDpopSigner from "../relay/managedRelay.ts"; | 18 | import * as ManagedRelayDpopSigner from "../relay/managedRelay.ts"; |
| 18 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | 19 | import * as HubConnection from "../connection/hub.ts"; |
| 20 | import { | ||
| 21 | request, | ||
| 22 | EnvironmentRpcUnavailableError, | ||
| 23 | type EnvironmentRpcInput, | ||
| 24 | } from "../rpc/client.ts"; | ||
| 19 | import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts"; | 25 | import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts"; |
| 20 | 26 | ||
| 21 | export function createOrchestrationEnvironmentAtoms<R, E>( | 27 | export function createOrchestrationEnvironmentAtoms<R, E>( |
| ... | @@ -109,9 +115,33 @@ export function createOrchestrationEnvironmentAtoms<R, E>( | ... | @@ -109,9 +115,33 @@ export function createOrchestrationEnvironmentAtoms<R, E>( |
| 109 | staleTimeMs: 30_000, | 115 | staleTimeMs: 30_000, |
| 110 | idleTtlMs: 60_000, | 116 | idleTtlMs: 60_000, |
| 111 | }), | 117 | }), |
| 112 | archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { | 118 | archivedShellSnapshot: createEnvironmentQueryAtomFamily(runtime, { |
| 113 | label: "environment-data:orchestration:archived-shell-snapshot", | 119 | label: "environment-data:orchestration:archived-shell-snapshot", |
| 114 | tag: ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, | 120 | execute: ( |
| 121 | input: EnvironmentRpcInput<typeof ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot>, | ||
| 122 | ) => request(ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, input), | ||
| 123 | executeDisconnected: () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | ||
| 126 | const unavailable = () => | ||
| 127 | new EnvironmentRpcUnavailableError({ | ||
| 128 | environmentId: supervisor.target.environmentId, | ||
| 129 | message: new ArchiveError({ reason: "unavailable" }).message, | ||
| 130 | }); | ||
| 131 | const hub = yield* Effect.serviceOption(HubConnection.HubConnection); | ||
| 132 | if (Option.isNone(hub)) return yield* unavailable(); | ||
| 133 | yield* hub.value.refreshInbox.pipe(Effect.ignore); | ||
| 134 | const entry = (yield* SubscriptionRef.get(hub.value.state)).inbox.environments.find( | ||
| 135 | (entry) => entry.environmentId === supervisor.target.environmentId, | ||
| 136 | ); | ||
| 137 | if (!entry) return yield* unavailable(); | ||
| 138 | return { | ||
| 139 | schemaVersion: entry.snapshot.schemaVersion, | ||
| 140 | snapshotSequence: entry.snapshot.snapshotSequence, | ||
| 141 | projects: entry.snapshot.projects, | ||
| 142 | threads: entry.snapshot.archivedThreads, | ||
| 143 | }; | ||
| 144 | }), | ||
| 115 | }), | 145 | }), |
| 116 | }; | 146 | }; |
| 117 | } | 147 | } |
packages/client-runtime/src/state/runtime.ts+12| ... | @@ -66,6 +66,10 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit< | ... | @@ -66,6 +66,10 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit< |
| 66 | input: Input, | 66 | input: Input, |
| 67 | emit: (value: A) => Effect.Effect<void>, | 67 | emit: (value: A) => Effect.Effect<void>, |
| 68 | ) => Effect.Effect<A, E, R>; | 68 | ) => Effect.Effect<A, E, R>; |
| 69 | readonly executeDisconnected?: ( | ||
| 70 | input: Input, | ||
| 71 | emit: (value: A) => Effect.Effect<void>, | ||
| 72 | ) => Effect.Effect<A, E, R>; | ||
| 69 | readonly persistence?: QueryPersistence<A>; | 73 | readonly persistence?: QueryPersistence<A>; |
| 70 | readonly staleTimeMs?: number; | 74 | readonly staleTimeMs?: number; |
| 71 | readonly idleTtlMs?: number; | 75 | readonly idleTtlMs?: number; |
| ... | @@ -592,6 +596,14 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( | ... | @@ -592,6 +596,14 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( |
| 592 | > => { | 596 | > => { |
| 593 | if (connection === null) return Effect.never; | 597 | if (connection === null) return Effect.never; |
| 594 | const [connectionState, session] = connection; | 598 | const [connectionState, session] = connection; |
| 599 | if ( | ||
| 600 | (connectionState.phase !== "connected" || Option.isNone(session)) && | ||
| 601 | options.executeDisconnected | ||
| 602 | ) | ||
| 603 | return runInEnvironment( | ||
| 604 | target.environmentId, | ||
| 605 | options.executeDisconnected(target.input, emit), | ||
| 606 | ); | ||
| 595 | switch (connectionState.phase) { | 607 | switch (connectionState.phase) { |
| 596 | case "connected": | 608 | case "connected": |
| 597 | return Option.isSome(session) | 609 | return Option.isSome(session) |
packages/client-runtime/src/state/shell.ts+19-3| ... | @@ -23,6 +23,8 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; | ... | @@ -23,6 +23,8 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 23 | import { connectionProjectionPhase } from "../connection/model.ts"; | 23 | import { connectionProjectionPhase } from "../connection/model.ts"; |
| 24 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 24 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 25 | import * as ConnectionWakeups from "../connection/wakeups.ts"; | 25 | import * as ConnectionWakeups from "../connection/wakeups.ts"; |
| 26 | import * as Hub from "../connection/hub.ts"; | ||
| 27 | import { applyHubInboxEnvironment } from "./hub.ts"; | ||
| 26 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; | 28 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| 27 | import * as Persistence from "../platform/persistence.ts"; | 29 | import * as Persistence from "../platform/persistence.ts"; |
| 28 | import { runCachePersistence } from "./cachePersistence.ts"; | 30 | import { runCachePersistence } from "./cachePersistence.ts"; |
| ... | @@ -428,9 +430,23 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") | ... | @@ -428,9 +430,23 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 428 | }); | 430 | }); |
| 429 | 431 | ||
| 430 | function shellStateChanges(environmentId: EnvironmentId) { | 432 | function shellStateChanges(environmentId: EnvironmentId) { |
| 431 | return followStreamInEnvironment( | 433 | return Stream.unwrap( |
| 432 | environmentId, | 434 | Effect.gen(function* () { |
| 433 | Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), | 435 | const source = followStreamInEnvironment( |
| 436 | environmentId, | ||
| 437 | Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), | ||
| 438 | ); | ||
| 439 | const hub = yield* Effect.serviceOption(Hub.HubConnection); | ||
| 440 | if (Option.isNone(hub)) return source; | ||
| 441 | return Stream.zipLatest(source, SubscriptionRef.changes(hub.value.state)).pipe( | ||
| 442 | Stream.map(([state, central]) => | ||
| 443 | applyHubInboxEnvironment( | ||
| 444 | state, | ||
| 445 | central.inbox.environments.find((entry) => entry.environmentId === environmentId), | ||
| 446 | ), | ||
| 447 | ), | ||
| 448 | ); | ||
| 449 | }), | ||
| 434 | ); | 450 | ); |
| 435 | } | 451 | } |
| 436 | 452 |
packages/client-runtime/src/state/threadCommands.test.ts+42| ... | @@ -24,6 +24,7 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; | ... | @@ -24,6 +24,7 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 24 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 24 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 25 | import type { RpcSession } from "../rpc/session.ts"; | 25 | import type { RpcSession } from "../rpc/session.ts"; |
| 26 | import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; | 26 | import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; |
| 27 | import { applyHubInboxEnvironment } from "./hub.ts"; | ||
| 27 | 28 | ||
| 28 | const ENVIRONMENT_ID = EnvironmentId.make("remote"); | 29 | const ENVIRONMENT_ID = EnvironmentId.make("remote"); |
| 29 | const THREAD_ID = ThreadId.make("thread"); | 30 | const THREAD_ID = ThreadId.make("thread"); |
| ... | @@ -80,6 +81,47 @@ const SNAPSHOT: OrchestrationV2ShellSnapshot = { | ... | @@ -80,6 +81,47 @@ const SNAPSHOT: OrchestrationV2ShellSnapshot = { |
| 80 | ], | 81 | ], |
| 81 | }; | 82 | }; |
| 82 | 83 | ||
| 84 | describe("central inbox shell state", () => { | ||
| 85 | const entry = { | ||
| 86 | environmentId: ENVIRONMENT_ID, | ||
| 87 | pendingThreadIds: [THREAD_ID], | ||
| 88 | snapshot: { | ||
| 89 | ...SNAPSHOT, | ||
| 90 | threads: SNAPSHOT.threads.map((thread) => ({ | ||
| 91 | ...thread, | ||
| 92 | settledOverride: "settled" as const, | ||
| 93 | settledAt: NOW, | ||
| 94 | })), | ||
| 95 | }, | ||
| 96 | }; | ||
| 97 | |||
| 98 | it("shows cached chat metadata on a new client with no source snapshot", () => { | ||
| 99 | const state = applyHubInboxEnvironment( | ||
| 100 | { | ||
| 101 | snapshot: Option.none(), | ||
| 102 | status: "empty", | ||
| 103 | error: Option.none(), | ||
| 104 | }, | ||
| 105 | entry, | ||
| 106 | ); | ||
| 107 | expect(state.status).toBe("cached"); | ||
| 108 | expect(Option.getOrNull(state.snapshot)?.threads[0]?.settledOverride).toBe("settled"); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("applies pending organization without hiding live execution", () => { | ||
| 112 | const live = { | ||
| 113 | ...SNAPSHOT, | ||
| 114 | threads: SNAPSHOT.threads.map((thread) => ({ ...thread, status: "running" as const })), | ||
| 115 | }; | ||
| 116 | const state = { snapshot: Option.some(live), status: "live" as const, error: Option.none() }; | ||
| 117 | const merged = Option.getOrThrow(applyHubInboxEnvironment(state, entry).snapshot); | ||
| 118 | expect(merged.threads[0]?.settledOverride).toBe("settled"); | ||
| 119 | expect(merged.threads[0]?.status).toBe("running"); | ||
| 120 | expect(merged.snapshotSequence).toBe(live.snapshotSequence); | ||
| 121 | expect(applyHubInboxEnvironment(state, { ...entry, pendingThreadIds: [] })).toBe(state); | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 83 | const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* () { | 125 | const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* () { |
| 84 | const requests = yield* Queue.unbounded<{ | 126 | const requests = yield* Queue.unbounded<{ |
| 85 | command: OrchestrationV2Command; | 127 | command: OrchestrationV2Command; |
packages/client-runtime/src/state/threads-sync.test.ts+71-2| ... | @@ -1,11 +1,11 @@ | ... | @@ -1,11 +1,11 @@ |
| 1 | import { | 1 | import { |
| 2 | OrchestrationV2ThreadDetailSnapshot, | ||
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 3 | EventId, | 4 | EventId, |
| 4 | MessageId, | 5 | MessageId, |
| 5 | ORCHESTRATION_V2_WS_METHODS, | 6 | ORCHESTRATION_V2_WS_METHODS, |
| 6 | ThreadId, | 7 | ThreadId, |
| 7 | TurnItemId, | 8 | TurnItemId, |
| 8 | type OrchestrationV2ThreadDetailSnapshot, | ||
| 9 | type OrchestrationV2ThreadProjection, | 9 | type OrchestrationV2ThreadProjection, |
| 10 | type OrchestrationV2ThreadStreamItem, | 10 | type OrchestrationV2ThreadStreamItem, |
| 11 | type OrchestrationV2TurnItem, | 11 | type OrchestrationV2TurnItem, |
| ... | @@ -19,6 +19,8 @@ import * as Fiber from "effect/Fiber"; | ... | @@ -19,6 +19,8 @@ import * as Fiber from "effect/Fiber"; |
| 19 | import * as Option from "effect/Option"; | 19 | import * as Option from "effect/Option"; |
| 20 | import * as Queue from "effect/Queue"; | 20 | import * as Queue from "effect/Queue"; |
| 21 | import * as Ref from "effect/Ref"; | 21 | import * as Ref from "effect/Ref"; |
| 22 | import * as Schema from "effect/Schema"; | ||
| 23 | import * as HubConnection from "../connection/hub.ts"; | ||
| 22 | import * as Scope from "effect/Scope"; | 24 | import * as Scope from "effect/Scope"; |
| 23 | import * as Stream from "effect/Stream"; | 25 | import * as Stream from "effect/Stream"; |
| 24 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 26 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| ... | @@ -46,6 +48,9 @@ import { | ... | @@ -46,6 +48,9 @@ import { |
| 46 | } from "./threads.ts"; | 48 | } from "./threads.ts"; |
| 47 | import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; | 49 | import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; |
| 48 | 50 | ||
| 51 | const encodeArchiveSnapshot = Schema.encodeEffect( | ||
| 52 | Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot), | ||
| 53 | ); | ||
| 49 | const TARGET = new PrimaryConnectionTarget({ | 54 | const TARGET = new PrimaryConnectionTarget({ |
| 50 | environmentId: EnvironmentId.make("environment-1"), | 55 | environmentId: EnvironmentId.make("environment-1"), |
| 51 | label: "Test environment", | 56 | label: "Test environment", |
| ... | @@ -99,6 +104,8 @@ function awaitThreadState( | ... | @@ -99,6 +104,8 @@ function awaitThreadState( |
| 99 | } | 104 | } |
| 100 | 105 | ||
| 101 | const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { | 106 | const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { |
| 107 | readonly hub?: HubConnection.HubConnection["Service"]; | ||
| 108 | readonly offline?: boolean; | ||
| 102 | readonly cached?: OrchestrationV2ThreadProjection; | 109 | readonly cached?: OrchestrationV2ThreadProjection; |
| 103 | readonly cachedHistory?: { | 110 | readonly cachedHistory?: { |
| 104 | readonly historyCursor: string | null; | 111 | readonly historyCursor: string | null; |
| ... | @@ -155,7 +162,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o | ... | @@ -155,7 +162,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 155 | ), | 162 | ), |
| 156 | } as unknown as WsRpcProtocolClient; | 163 | } as unknown as WsRpcProtocolClient; |
| 157 | const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | 164 | const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( |
| 158 | Option.some(testSession(client, options)), | 165 | options?.offline ? Option.none() : Option.some(testSession(client, options)), |
| 159 | ); | 166 | ); |
| 160 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>( | 167 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>( |
| 161 | Option.some(PREPARED), | 168 | Option.some(PREPARED), |
| ... | @@ -244,6 +251,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o | ... | @@ -244,6 +251,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 244 | Effect.provideService(ThreadHistoryController.ThreadHistoryController, historyController), | 251 | Effect.provideService(ThreadHistoryController.ThreadHistoryController, historyController), |
| 245 | ); | 252 | ); |
| 246 | } | 253 | } |
| 254 | if (options?.hub) | ||
| 255 | makeThreadState = makeThreadState.pipe( | ||
| 256 | Effect.provideService(HubConnection.HubConnection, options.hub), | ||
| 257 | ); | ||
| 247 | const threadState = yield* makeThreadState; | 258 | const threadState = yield* makeThreadState; |
| 248 | yield* SubscriptionRef.changes(threadState).pipe( | 259 | yield* SubscriptionRef.changes(threadState).pipe( |
| 249 | Stream.runForEach((state) => | 260 | Stream.runForEach((state) => |
| ... | @@ -320,6 +331,64 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { | ... | @@ -320,6 +331,64 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { |
| 320 | }; | 331 | }; |
| 321 | 332 | ||
| 322 | describe("EnvironmentThreads", () => { | 333 | describe("EnvironmentThreads", () => { |
| 334 | it.effect( | ||
| 335 | "opens a NAS archived transcript without a source session and resumes its source sequence after reconnect", | ||
| 336 | () => | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const archived = { | ||
| 339 | ...BASE_PROJECTION, | ||
| 340 | thread: { | ||
| 341 | ...BASE_PROJECTION.thread, | ||
| 342 | archivedAt: DateTime.makeUnsafe("2026-06-20T02:00:00Z"), | ||
| 343 | }, | ||
| 344 | }; | ||
| 345 | const archiveSnapshot = { snapshotSequence: 81, projection: archived }; | ||
| 346 | const hubState = yield* SubscriptionRef.make<HubConnection.HubConnectionState>({ | ||
| 347 | ...HubConnection.EMPTY_HUB_CONNECTION_STATE, | ||
| 348 | connection: { | ||
| 349 | environmentId: EnvironmentId.make("archive-hub"), | ||
| 350 | label: "Archive", | ||
| 351 | httpBaseUrl: "https://archive.example.test", | ||
| 352 | }, | ||
| 353 | }); | ||
| 354 | const hub = HubConnection.HubConnection.of({ | ||
| 355 | state: hubState, | ||
| 356 | headers: Effect.succeed({ authorization: "Bearer ephemeral-archive-access" }), | ||
| 357 | connect: () => Effect.void, | ||
| 358 | connectAuthenticated: () => Effect.void, | ||
| 359 | refresh: Effect.void, | ||
| 360 | refreshInbox: Effect.void, | ||
| 361 | organize: () => Effect.succeedNone, | ||
| 362 | disconnect: Effect.void, | ||
| 363 | }); | ||
| 364 | const json = yield* encodeArchiveSnapshot(archiveSnapshot); | ||
| 365 | const http = HttpClient.make((request) => | ||
| 366 | Effect.sync(() => { | ||
| 367 | expect(request.url).toBe( | ||
| 368 | `https://archive.example.test/api/archive/catalog/${TARGET.environmentId}/${THREAD_ID}`, | ||
| 369 | ); | ||
| 370 | expect(request.headers.authorization).toBe("Bearer ephemeral-archive-access"); | ||
| 371 | return HttpClientResponse.fromWeb(request, Response.json(json)); | ||
| 372 | }), | ||
| 373 | ); | ||
| 374 | const h = yield* makeHarness({ hub, offline: true, historyHttpClient: http }); | ||
| 375 | const state = yield* awaitThreadState(h.observed, (value) => Option.isSome(value.data)); | ||
| 376 | expect(state.status).toBe("cached"); | ||
| 377 | expect(Option.getOrThrow(state.data)).toEqual(archived); | ||
| 378 | expect(yield* Ref.get(h.subscriptionCount)).toBe(0); | ||
| 379 | expect(yield* Ref.get(h.loaderCalls)).toBe(0); | ||
| 380 | yield* h.replaceSession; | ||
| 381 | yield* Queue.offer(h.inputs, titleUpdated("Source reconnected", 82)); | ||
| 382 | const live = yield* awaitThreadState( | ||
| 383 | h.observed, | ||
| 384 | (value) => | ||
| 385 | Option.isSome(value.data) && value.data.value.thread.title === "Source reconnected", | ||
| 386 | ); | ||
| 387 | expect(live.status).toBe("live"); | ||
| 388 | expect(yield* Ref.get(h.lastSubscribeAfterSequence)).toBe(81); | ||
| 389 | }), | ||
| 390 | ); | ||
| 391 | |||
| 323 | it.effect("loads the server thread when its local cache read fails", () => | 392 | it.effect("loads the server thread when its local cache read fails", () => |
| 324 | Effect.gen(function* () { | 393 | Effect.gen(function* () { |
| 325 | const h = yield* makeHarness({ | 394 | const h = yield* makeHarness({ |
packages/client-runtime/src/state/threads.ts+48-5| ... | @@ -44,6 +44,8 @@ import { | ... | @@ -44,6 +44,8 @@ import { |
| 44 | mergeOlderHistoryIntoProjection, | 44 | mergeOlderHistoryIntoProjection, |
| 45 | type ThreadHistoryMeta, | 45 | type ThreadHistoryMeta, |
| 46 | } from "./threadHistoryMerge.ts"; | 46 | } from "./threadHistoryMerge.ts"; |
| 47 | import { fetchArchivedThreadSnapshot } from "./archiveThreadHttp.ts"; | ||
| 48 | import * as HubConnection from "../connection/hub.ts"; | ||
| 47 | import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; | 49 | import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; |
| 48 | import { | 50 | import { |
| 49 | EMPTY_ENVIRONMENT_THREAD_STATE, | 51 | EMPTY_ENVIRONMENT_THREAD_STATE, |
| ... | @@ -178,6 +180,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | ... | @@ -178,6 +180,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 178 | ThreadHistoryController.ThreadHistoryController, | 180 | ThreadHistoryController.ThreadHistoryController, |
| 179 | ); | 181 | ); |
| 180 | const httpClient = yield* Effect.serviceOption(HttpClient.HttpClient); | 182 | const httpClient = yield* Effect.serviceOption(HttpClient.HttpClient); |
| 183 | const archiveHub = yield* Effect.serviceOption(HubConnection.HubConnection); | ||
| 181 | const dpopSigner = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); | 184 | const dpopSigner = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); |
| 182 | const remoteAuthorization = yield* Effect.serviceOption( | 185 | const remoteAuthorization = yield* Effect.serviceOption( |
| 183 | RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, | 186 | RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, |
| ... | @@ -359,6 +362,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | ... | @@ -359,6 +362,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 359 | options?: { | 362 | options?: { |
| 360 | /** Socket/full snapshots: drop progressive meta with the new timeline. */ | 363 | /** Socket/full snapshots: drop progressive meta with the new timeline. */ |
| 361 | readonly resetHistory?: boolean; | 364 | readonly resetHistory?: boolean; |
| 365 | readonly status?: "cached"; | ||
| 362 | /** | 366 | /** |
| 363 | * Explicit progressive meta installed atomically with the projection | 367 | * Explicit progressive meta installed atomically with the projection |
| 364 | * (bounded HTTP). Wins over resetHistory when both are supplied. | 368 | * (bounded HTTP). Wins over resetHistory when both are supplied. |
| ... | @@ -381,11 +385,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | ... | @@ -381,11 +385,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 381 | ...previous, | 385 | ...previous, |
| 382 | data: Option.some(thread), | 386 | data: Option.some(thread), |
| 383 | // Buffered values from a failed attempt can arrive after its error. | 387 | // Buffered values from a failed attempt can arrive after its error. |
| 384 | status: Option.isSome(previous.error) | 388 | status: |
| 385 | ? ("cached" as const) | 389 | options?.status ?? |
| 386 | : waiting | 390 | (Option.isSome(previous.error) |
| 387 | ? ("synchronizing" as const) | 391 | ? ("cached" as const) |
| 388 | : ("live" as const), | 392 | : waiting |
| 393 | ? ("synchronizing" as const) | ||
| 394 | : ("live" as const)), | ||
| 389 | error: previous.error, | 395 | error: previous.error, |
| 390 | history, | 396 | history, |
| 391 | }; | 397 | }; |
| ... | @@ -771,6 +777,43 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | ... | @@ -771,6 +777,43 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 771 | Effect.forkScoped, | 777 | Effect.forkScoped, |
| 772 | ); | 778 | ); |
| 773 | 779 | ||
| 780 | if (Option.isSome(archiveHub) && Option.isSome(httpClient)) { | ||
| 781 | yield* Stream.merge( | ||
| 782 | SubscriptionRef.changes(supervisor.state).pipe(Stream.map(() => undefined)), | ||
| 783 | SubscriptionRef.changes(archiveHub.value.state).pipe(Stream.map(() => undefined)), | ||
| 784 | ).pipe( | ||
| 785 | Stream.mapEffect(() => | ||
| 786 | Effect.gen(function* () { | ||
| 787 | const current = yield* SubscriptionRef.get(state); | ||
| 788 | if (current.status === "live" || current.status === "deleted") return; | ||
| 789 | if (Option.isSome(current.data) && !current.history.hasMoreHistory) return; | ||
| 790 | const snapshot = yield* fetchArchivedThreadSnapshot(environmentId, threadId).pipe( | ||
| 791 | Effect.provideService(HubConnection.HubConnection, archiveHub.value), | ||
| 792 | Effect.provideService(HttpClient.HttpClient, httpClient.value), | ||
| 793 | Effect.option, | ||
| 794 | ); | ||
| 795 | if (Option.isNone(snapshot)) return; | ||
| 796 | yield* applyLock.withPermits(1)( | ||
| 797 | Effect.gen(function* () { | ||
| 798 | const current = yield* SubscriptionRef.get(state); | ||
| 799 | if ( | ||
| 800 | current.status === "live" || | ||
| 801 | current.status === "deleted" || | ||
| 802 | snapshot.value.snapshotSequence < (yield* SubscriptionRef.get(lastSequence)) | ||
| 803 | ) | ||
| 804 | return; | ||
| 805 | yield* SubscriptionRef.set(lastSequence, snapshot.value.snapshotSequence); | ||
| 806 | yield* setThread(snapshot.value.projection, { resetHistory: true, status: "cached" }); | ||
| 807 | yield* remember; | ||
| 808 | }), | ||
| 809 | ); | ||
| 810 | }), | ||
| 811 | ), | ||
| 812 | Stream.runDrain, | ||
| 813 | Effect.forkScoped, | ||
| 814 | ); | ||
| 815 | } | ||
| 816 | |||
| 774 | const foregroundResubscriptions = Option.match(wakeups, { | 817 | const foregroundResubscriptions = Option.match(wakeups, { |
| 775 | onNone: () => Stream.never, | 818 | onNone: () => Stream.never, |
| 776 | onSome: (service) => | 819 | onSome: (service) => |
packages/client-runtime/src/webhookAddress.test.ts+6| ... | @@ -21,6 +21,12 @@ describe("webhookAddress", () => { | ... | @@ -21,6 +21,12 @@ describe("webhookAddress", () => { |
| 21 | expect(result.note).toContain("Tailscale"); | 21 | expect(result.note).toContain("Tailscale"); |
| 22 | }); | 22 | }); |
| 23 | 23 | ||
| 24 | it("keeps the environment prefix in direct webhook addresses", () => { | ||
| 25 | expect( | ||
| 26 | webhookAddress(endpoint(null), "https://hub.example/prefix/environments/one/").address, | ||
| 27 | ).toBe(`https://hub.example/prefix/environments/one${path}`); | ||
| 28 | }); | ||
| 29 | |||
| 24 | it("says only this computer can call a loopback address", () => { | 30 | it("says only this computer can call a loopback address", () => { |
| 25 | const result = webhookAddress(endpoint(null), "http://127.0.0.1:3773/"); | 31 | const result = webhookAddress(endpoint(null), "http://127.0.0.1:3773/"); |
| 26 | expect(result.copyable).toBe(true); | 32 | expect(result.copyable).toBe(true); |
packages/client-runtime/src/webhookAddress.ts+2-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { environmentEndpointUrl } from "./environment/endpoint.ts"; | ||
| 1 | import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts"; | 2 | import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts"; |
| 2 | import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification"; | 3 | import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification"; |
| 3 | 4 | ||
| ... | @@ -29,7 +30,7 @@ export function webhookAddress( | ... | @@ -29,7 +30,7 @@ export function webhookAddress( |
| 29 | note: "Link this environment to T3 Connect for a public URL.", | 30 | note: "Link this environment to T3 Connect for a public URL.", |
| 30 | }; | 31 | }; |
| 31 | } | 32 | } |
| 32 | const url = new URL(endpoint.path, httpBaseUrl); | 33 | const url = new URL(environmentEndpointUrl(httpBaseUrl, endpoint.path)); |
| 33 | return { | 34 | return { |
| 34 | address: url.href, | 35 | address: url.href, |
| 35 | copyable: true, | 36 | copyable: true, |
packages/contracts/src/auth.ts+17| ... | @@ -81,6 +81,9 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; | ... | @@ -81,6 +81,9 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; |
| 81 | 81 | ||
| 82 | export const AuthOrchestrationReadScope = "orchestration:read" as const; | 82 | export const AuthOrchestrationReadScope = "orchestration:read" as const; |
| 83 | export const AuthOrchestrationOperateScope = "orchestration:operate" as const; | 83 | export const AuthOrchestrationOperateScope = "orchestration:operate" as const; |
| 84 | export const AuthEnvironmentConnectScope = "environment:connect" as const; | ||
| 85 | export const AuthArchiveReadScope = "archive:read" as const; | ||
| 86 | export const AuthArchiveWriteScope = "archive:write" as const; | ||
| 84 | export const AuthSettingsWriteScope = "settings:write" as const; | 87 | export const AuthSettingsWriteScope = "settings:write" as const; |
| 85 | export const AuthProvidersManageScope = "providers:manage" as const; | 88 | export const AuthProvidersManageScope = "providers:manage" as const; |
| 86 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; | 89 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; |
| ... | @@ -98,8 +101,11 @@ export const AuthAccessWriteScope = "access:write" as const; | ... | @@ -98,8 +101,11 @@ export const AuthAccessWriteScope = "access:write" as const; |
| 98 | export const AuthRelayReadScope = "relay:read" as const; | 101 | export const AuthRelayReadScope = "relay:read" as const; |
| 99 | export const AuthRelayWriteScope = "relay:write" as const; | 102 | export const AuthRelayWriteScope = "relay:write" as const; |
| 100 | export const AuthEnvironmentScope = Schema.Literals([ | 103 | export const AuthEnvironmentScope = Schema.Literals([ |
| 104 | AuthEnvironmentConnectScope, | ||
| 101 | AuthOrchestrationReadScope, | 105 | AuthOrchestrationReadScope, |
| 102 | AuthOrchestrationOperateScope, | 106 | AuthOrchestrationOperateScope, |
| 107 | AuthArchiveReadScope, | ||
| 108 | AuthArchiveWriteScope, | ||
| 103 | AuthSettingsWriteScope, | 109 | AuthSettingsWriteScope, |
| 104 | AuthProvidersManageScope, | 110 | AuthProvidersManageScope, |
| 105 | AuthEnvironmentMaintainScope, | 111 | AuthEnvironmentMaintainScope, |
| ... | @@ -152,6 +158,8 @@ const authScopeResponseFields = { | ... | @@ -152,6 +158,8 @@ const authScopeResponseFields = { |
| 152 | // Only clients talking to an old server use these parent checks. Servers never | 158 | // Only clients talking to an old server use these parent checks. Servers never |
| 153 | // expand stored grants, and an explicitly empty permissions array grants nothing. | 159 | // expand stored grants, and an explicitly empty permissions array grants nothing. |
| 154 | const legacyParents: Partial<Record<AuthEnvironmentScope, AuthEnvironmentScope>> = { | 160 | const legacyParents: Partial<Record<AuthEnvironmentScope, AuthEnvironmentScope>> = { |
| 161 | [AuthArchiveReadScope]: AuthOrchestrationReadScope, | ||
| 162 | [AuthArchiveWriteScope]: AuthOrchestrationOperateScope, | ||
| 155 | [AuthFilesystemReadScope]: AuthOrchestrationReadScope, | 163 | [AuthFilesystemReadScope]: AuthOrchestrationReadScope, |
| 156 | [AuthDiagnosticsReadScope]: AuthOrchestrationReadScope, | 164 | [AuthDiagnosticsReadScope]: AuthOrchestrationReadScope, |
| 157 | [AuthSettingsWriteScope]: AuthOrchestrationOperateScope, | 165 | [AuthSettingsWriteScope]: AuthOrchestrationOperateScope, |
| ... | @@ -205,6 +213,8 @@ export function sessionHasLegacyPermissions(session: SessionGrantInput): boolean | ... | @@ -205,6 +213,8 @@ export function sessionHasLegacyPermissions(session: SessionGrantInput): boolean |
| 205 | export const AuthStandardClientScopes = [ | 213 | export const AuthStandardClientScopes = [ |
| 206 | AuthOrchestrationReadScope, | 214 | AuthOrchestrationReadScope, |
| 207 | AuthOrchestrationOperateScope, | 215 | AuthOrchestrationOperateScope, |
| 216 | AuthArchiveReadScope, | ||
| 217 | AuthArchiveWriteScope, | ||
| 208 | AuthSettingsWriteScope, | 218 | AuthSettingsWriteScope, |
| 209 | AuthProvidersManageScope, | 219 | AuthProvidersManageScope, |
| 210 | AuthEnvironmentMaintainScope, | 220 | AuthEnvironmentMaintainScope, |
| ... | @@ -218,6 +228,7 @@ export const AuthStandardClientScopes = [ | ... | @@ -218,6 +228,7 @@ export const AuthStandardClientScopes = [ |
| 218 | AuthRelayReadScope, | 228 | AuthRelayReadScope, |
| 219 | ] as const; | 229 | ] as const; |
| 220 | export const AuthAdministrativeScopes = [ | 230 | export const AuthAdministrativeScopes = [ |
| 231 | AuthEnvironmentConnectScope, | ||
| 221 | ...AuthStandardClientScopes, | 232 | ...AuthStandardClientScopes, |
| 222 | AuthAccessReadScope, | 233 | AuthAccessReadScope, |
| 223 | AuthAccessWriteScope, | 234 | AuthAccessWriteScope, |
| ... | @@ -458,6 +469,12 @@ export const AuthCreatePairingCredentialInput = Schema.Struct({ | ... | @@ -458,6 +469,12 @@ export const AuthCreatePairingCredentialInput = Schema.Struct({ |
| 458 | }); | 469 | }); |
| 459 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; | 470 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; |
| 460 | 471 | ||
| 472 | export const AuthCreateDelegatedSessionInput = Schema.Struct({ | ||
| 473 | label: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 474 | scopes: AuthGrantScopes, | ||
| 475 | }); | ||
| 476 | export type AuthCreateDelegatedSessionInput = typeof AuthCreateDelegatedSessionInput.Type; | ||
| 477 | |||
| 461 | export const AuthSessionState = Schema.Struct({ | 478 | export const AuthSessionState = Schema.Struct({ |
| 462 | authenticated: Schema.Boolean, | 479 | authenticated: Schema.Boolean, |
| 463 | auth: ServerAuthDescriptor, | 480 | auth: ServerAuthDescriptor, |
packages/contracts/src/desktopHostedAuth.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | export const DesktopHostedAuthState = Schema.Struct({ | ||
| 4 | serverUrl: Schema.NullOr(Schema.String), | ||
| 5 | phase: Schema.Literals(["idle", "waiting", "connected", "error"]), | ||
| 6 | error: Schema.NullOr(Schema.String), | ||
| 7 | }); | ||
| 8 | export type DesktopHostedAuthState = typeof DesktopHostedAuthState.Type; | ||
| 9 | |||
| 10 | export const SelfHostedConnectionStatus = Schema.Struct({ | ||
| 11 | configured: Schema.Boolean, | ||
| 12 | connected: Schema.Boolean, | ||
| 13 | expiresAt: Schema.NullOr(Schema.String), | ||
| 14 | }); | ||
| 15 | export type SelfHostedConnectionStatus = typeof SelfHostedConnectionStatus.Type; | ||
packages/contracts/src/environmentHttp.ts+13| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | AuthBrowserSessionResult, | 15 | AuthBrowserSessionResult, |
| 16 | AuthClientSession, | 16 | AuthClientSession, |
| 17 | AuthCreatePairingCredentialInput, | 17 | AuthCreatePairingCredentialInput, |
| 18 | AuthCreateDelegatedSessionInput, | ||
| 18 | AuthMcpApprovalDecisionRequest, | 19 | AuthMcpApprovalDecisionRequest, |
| 19 | AuthMcpApprovalDetails, | 20 | AuthMcpApprovalDetails, |
| 20 | AuthMcpApprovalError, | 21 | AuthMcpApprovalError, |
| ... | @@ -74,17 +75,20 @@ import { | ... | @@ -74,17 +75,20 @@ import { |
| 74 | } from "./relay.ts"; | 75 | } from "./relay.ts"; |
| 75 | 76 | ||
| 76 | const OptionalBearerHeaders = Schema.Struct({ | 77 | const OptionalBearerHeaders = Schema.Struct({ |
| 78 | "x-csrf-token": Schema.optionalKey(Schema.String), | ||
| 77 | authorization: Schema.optionalKey(Schema.String), | 79 | authorization: Schema.optionalKey(Schema.String), |
| 78 | dpop: Schema.optionalKey(Schema.String), | 80 | dpop: Schema.optionalKey(Schema.String), |
| 79 | }); | 81 | }); |
| 80 | 82 | ||
| 81 | const OrchestrationProtocolHeaders = Schema.Struct({ | 83 | const OrchestrationProtocolHeaders = Schema.Struct({ |
| 84 | "x-csrf-token": Schema.optionalKey(Schema.String), | ||
| 82 | authorization: Schema.optionalKey(Schema.String), | 85 | authorization: Schema.optionalKey(Schema.String), |
| 83 | dpop: Schema.optionalKey(Schema.String), | 86 | dpop: Schema.optionalKey(Schema.String), |
| 84 | [ORCHESTRATION_PROTOCOL_HEADER]: Schema.Literal(ORCHESTRATION_PROTOCOL_VERSION_TEXT), | 87 | [ORCHESTRATION_PROTOCOL_HEADER]: Schema.Literal(ORCHESTRATION_PROTOCOL_VERSION_TEXT), |
| 85 | }); | 88 | }); |
| 86 | 89 | ||
| 87 | const OptionalDpopProofHeaders = Schema.Struct({ | 90 | const OptionalDpopProofHeaders = Schema.Struct({ |
| 91 | "x-csrf-token": Schema.optionalKey(Schema.String), | ||
| 88 | dpop: Schema.optionalKey(Schema.String), | 92 | dpop: Schema.optionalKey(Schema.String), |
| 89 | }); | 93 | }); |
| 90 | 94 | ||
| ... | @@ -374,6 +378,7 @@ const EnvironmentProjectMutationErrors = [ | ... | @@ -374,6 +378,7 @@ const EnvironmentProjectMutationErrors = [ |
| 374 | ] as const; | 378 | ] as const; |
| 375 | 379 | ||
| 376 | export interface EnvironmentSessionPrincipalShape { | 380 | export interface EnvironmentSessionPrincipalShape { |
| 381 | readonly parentSessionId?: AuthSessionId; | ||
| 377 | readonly sessionId: AuthSessionId; | 382 | readonly sessionId: AuthSessionId; |
| 378 | readonly subject: string; | 383 | readonly subject: string; |
| 379 | readonly method: ServerAuthSessionMethod; | 384 | readonly method: ServerAuthSessionMethod; |
| ... | @@ -496,6 +501,14 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") | ... | @@ -496,6 +501,14 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") |
| 496 | error: EnvironmentPairingCredentialErrors, | 501 | error: EnvironmentPairingCredentialErrors, |
| 497 | }).middleware(EnvironmentAuthenticatedAuth), | 502 | }).middleware(EnvironmentAuthenticatedAuth), |
| 498 | ) | 503 | ) |
| 504 | .add( | ||
| 505 | HttpApiEndpoint.post("delegatedSession", "/api/auth/delegated-session", { | ||
| 506 | headers: OptionalBearerHeaders, | ||
| 507 | payload: AuthCreateDelegatedSessionInput, | ||
| 508 | success: AuthAccessTokenResult, | ||
| 509 | error: EnvironmentPairingCredentialErrors, | ||
| 510 | }).middleware(EnvironmentAuthenticatedAuth), | ||
| 511 | ) | ||
| 499 | .add( | 512 | .add( |
| 500 | HttpApiEndpoint.get("pairingLinks", "/api/auth/pairing-links", { | 513 | HttpApiEndpoint.get("pairingLinks", "/api/auth/pairing-links", { |
| 501 | headers: OptionalBearerHeaders, | 514 | headers: OptionalBearerHeaders, |
packages/contracts/src/hostedAuth.ts created+70| ... | @@ -0,0 +1,70 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { AuthClientPresentationMetadata, AuthGrantScopes } from "./auth.ts"; | ||
| 3 | import { EnvironmentId } from "./baseSchemas.ts"; | ||
| 4 | |||
| 5 | const ProofChallenge = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/u)); | ||
| 6 | const ProofVerifier = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9._~-]{43,128}$/u)); | ||
| 7 | |||
| 8 | export const HostedAuthDeviceStart = Schema.Struct({ | ||
| 9 | codeChallenge: ProofChallenge, | ||
| 10 | state: ProofChallenge, | ||
| 11 | client: AuthClientPresentationMetadata, | ||
| 12 | }); | ||
| 13 | export type HostedAuthDeviceStart = typeof HostedAuthDeviceStart.Type; | ||
| 14 | |||
| 15 | export const HostedAuthDeviceStartResult = Schema.Struct({ authorizationUrl: Schema.String }); | ||
| 16 | export type HostedAuthDeviceStartResult = typeof HostedAuthDeviceStartResult.Type; | ||
| 17 | |||
| 18 | export const HostedAuthDeviceExchange = Schema.Struct({ | ||
| 19 | code: ProofChallenge, | ||
| 20 | codeVerifier: ProofVerifier, | ||
| 21 | }); | ||
| 22 | export type HostedAuthDeviceExchange = typeof HostedAuthDeviceExchange.Type; | ||
| 23 | |||
| 24 | export const HostedAuthDeviceRefresh = Schema.Struct({ refreshToken: Schema.NonEmptyString }); | ||
| 25 | export type HostedAuthDeviceRefresh = typeof HostedAuthDeviceRefresh.Type; | ||
| 26 | |||
| 27 | export const HostedAuthGrant = Schema.Struct({ | ||
| 28 | deviceId: Schema.String, | ||
| 29 | accessToken: Schema.String, | ||
| 30 | refreshToken: Schema.String, | ||
| 31 | expiresAt: Schema.DateTimeUtc, | ||
| 32 | }); | ||
| 33 | export type HostedAuthGrant = typeof HostedAuthGrant.Type; | ||
| 34 | |||
| 35 | export const HostedAuthDevice = Schema.Struct({ | ||
| 36 | deviceId: Schema.String, | ||
| 37 | client: AuthClientPresentationMetadata, | ||
| 38 | createdAt: Schema.DateTimeUtc, | ||
| 39 | expiresAt: Schema.DateTimeUtc, | ||
| 40 | revokedAt: Schema.NullOr(Schema.DateTimeUtc), | ||
| 41 | current: Schema.Boolean, | ||
| 42 | environmentId: Schema.optionalKey(EnvironmentId), | ||
| 43 | }); | ||
| 44 | export type HostedAuthDevice = typeof HostedAuthDevice.Type; | ||
| 45 | |||
| 46 | export const HostedAuthAccount = Schema.Struct({ | ||
| 47 | accountId: Schema.String, | ||
| 48 | deviceId: Schema.String, | ||
| 49 | csrfToken: Schema.String, | ||
| 50 | expiresAt: Schema.DateTimeUtc, | ||
| 51 | }); | ||
| 52 | export type HostedAuthAccount = typeof HostedAuthAccount.Type; | ||
| 53 | |||
| 54 | export const HostedAuthTokenRequest = Schema.Struct({ scopes: AuthGrantScopes }); | ||
| 55 | export type HostedAuthTokenRequest = typeof HostedAuthTokenRequest.Type; | ||
| 56 | |||
| 57 | export class HostedAuthError extends Schema.TaggedError<HostedAuthError>()("HostedAuthError", { | ||
| 58 | reason: Schema.Literals([ | ||
| 59 | "disabled", | ||
| 60 | "configuration", | ||
| 61 | "identity", | ||
| 62 | "transaction", | ||
| 63 | "credential", | ||
| 64 | "forbidden", | ||
| 65 | "reauthentication", | ||
| 66 | "upstream", | ||
| 67 | "storage", | ||
| 68 | ]), | ||
| 69 | cause: Schema.optionalKey(Schema.Redacted(Schema.Defect())), | ||
| 70 | }) {} | ||
packages/contracts/src/hub.ts created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { AuthEnvironmentScopes } from "./auth.ts"; | ||
| 3 | import { EnvironmentId, ThreadId } from "./baseSchemas.ts"; | ||
| 4 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | ||
| 5 | import { OrchestrationV2Command, OrchestrationV2ShellSnapshot } from "./orchestrationV2.ts"; | ||
| 6 | |||
| 7 | export const HUB_THREAD_ORGANIZATION_FIELDS = [ | ||
| 8 | "settledOverride", | ||
| 9 | "settledAt", | ||
| 10 | "unsettledAt", | ||
| 11 | "snoozedUntil", | ||
| 12 | "snoozedAt", | ||
| 13 | "pinnedAt", | ||
| 14 | "pinOrderKey", | ||
| 15 | "activeOrderKey", | ||
| 16 | "autoSettleDisabledAt", | ||
| 17 | ] as const; | ||
| 18 | |||
| 19 | const organizationCommandTypes = [ | ||
| 20 | "thread.settle", | ||
| 21 | "thread.unsettle", | ||
| 22 | "thread.snooze", | ||
| 23 | "thread.unsnooze", | ||
| 24 | "thread.pin", | ||
| 25 | "thread.unpin", | ||
| 26 | "thread.pin.reorder", | ||
| 27 | "thread.active.reorder", | ||
| 28 | "thread.auto-settle.set", | ||
| 29 | ] as const; | ||
| 30 | |||
| 31 | export const HubOrganizationCommand = OrchestrationV2Command.pipe( | ||
| 32 | Schema.refine( | ||
| 33 | ( | ||
| 34 | command, | ||
| 35 | ): command is Extract< | ||
| 36 | OrchestrationV2Command, | ||
| 37 | { readonly type: (typeof organizationCommandTypes)[number] } | ||
| 38 | > => organizationCommandTypes.some((type) => type === command.type), | ||
| 39 | ), | ||
| 40 | ); | ||
| 41 | export type HubOrganizationCommand = typeof HubOrganizationCommand.Type; | ||
| 42 | |||
| 43 | export const HubInboxCommand = Schema.Struct({ | ||
| 44 | environmentId: EnvironmentId, | ||
| 45 | command: HubOrganizationCommand, | ||
| 46 | }); | ||
| 47 | export type HubInboxCommand = typeof HubInboxCommand.Type; | ||
| 48 | |||
| 49 | export const HubInboxEnvironment = Schema.Struct({ | ||
| 50 | environmentId: EnvironmentId, | ||
| 51 | snapshot: OrchestrationV2ShellSnapshot, | ||
| 52 | pendingThreadIds: Schema.Array(ThreadId), | ||
| 53 | }); | ||
| 54 | export type HubInboxEnvironment = typeof HubInboxEnvironment.Type; | ||
| 55 | |||
| 56 | export const HubInbox = Schema.Struct({ environments: Schema.Array(HubInboxEnvironment) }); | ||
| 57 | export type HubInbox = typeof HubInbox.Type; | ||
| 58 | |||
| 59 | export const HubDirectory = Schema.Struct({ | ||
| 60 | hubEnvironmentId: EnvironmentId, | ||
| 61 | environments: Schema.Array( | ||
| 62 | Schema.Struct({ | ||
| 63 | environmentId: EnvironmentId, | ||
| 64 | label: Schema.NonEmptyString, | ||
| 65 | httpBaseUrl: Schema.NonEmptyString, | ||
| 66 | scopes: AuthEnvironmentScopes, | ||
| 67 | descriptor: ExecutionEnvironmentDescriptor, | ||
| 68 | }), | ||
| 69 | ), | ||
| 70 | }); | ||
| 71 | export type HubDirectory = typeof HubDirectory.Type; | ||
| 72 | |||
| 73 | export const HubManagementRequest = Schema.Union([ | ||
| 74 | Schema.Struct({ type: Schema.Literal("hub.configure"), publicUrl: Schema.NonEmptyString }), | ||
| 75 | Schema.Struct({ type: Schema.Literal("hub.status") }), | ||
| 76 | Schema.Struct({ | ||
| 77 | type: Schema.Literal("hub.enroll"), | ||
| 78 | url: Schema.NonEmptyString, | ||
| 79 | environmentId: EnvironmentId, | ||
| 80 | tokenFile: Schema.NonEmptyString, | ||
| 81 | }), | ||
| 82 | Schema.Struct({ type: Schema.Literal("hub.remove"), environmentId: EnvironmentId }), | ||
| 83 | ]); | ||
| 84 | export type HubManagementRequest = typeof HubManagementRequest.Type; | ||
| 85 | |||
| 86 | export class HubError extends Schema.TaggedError<HubError>()("HubError", { | ||
| 87 | reason: Schema.Literals([ | ||
| 88 | "configuration", | ||
| 89 | "address", | ||
| 90 | "identity", | ||
| 91 | "credential", | ||
| 92 | "not-found", | ||
| 93 | "upstream", | ||
| 94 | "storage", | ||
| 95 | ]), | ||
| 96 | cause: Schema.optionalKey(Schema.Redacted(Schema.Defect())), | ||
| 97 | }) { | ||
| 98 | override get message(): string { | ||
| 99 | switch (this.reason) { | ||
| 100 | case "configuration": | ||
| 101 | return "Configure the server's public URL before enrolling machines."; | ||
| 102 | case "address": | ||
| 103 | return "Use an HTTP or HTTPS address without a password, query, or fragment."; | ||
| 104 | case "identity": | ||
| 105 | return "The machine identity changed. Check its address before enrolling it."; | ||
| 106 | case "credential": | ||
| 107 | return "The machine access grant is unavailable. Enroll it with a new grant."; | ||
| 108 | case "not-found": | ||
| 109 | return "This machine was removed. Refresh the server's machine list."; | ||
| 110 | case "upstream": | ||
| 111 | return "The machine is unreachable. Check its private network connection."; | ||
| 112 | case "storage": | ||
| 113 | return "The server could not save its machines. Check its storage permissions."; | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
packages/contracts/src/index.ts+6| ... | @@ -70,3 +70,9 @@ export * from "./scriptProgress.ts"; | ... | @@ -70,3 +70,9 @@ export * from "./scriptProgress.ts"; |
| 70 | 70 | ||
| 71 | export * from "./uiSettings.ts"; | 71 | export * from "./uiSettings.ts"; |
| 72 | export * from "./sharedAccounts.ts"; | 72 | export * from "./sharedAccounts.ts"; |
| 73 | |||
| 74 | export * from "./transcriptArchive.ts"; | ||
| 75 | export * from "./hub.ts"; | ||
| 76 | export * from "./hostedAuth.ts"; | ||
| 77 | export * from "./desktopHostedAuth.ts"; | ||
| 78 | export * from "./outbound.ts"; |
packages/contracts/src/ipc.ts+6| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { DesktopHostedAuthState } from "./desktopHostedAuth.ts"; | ||
| 1 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 2 | 3 | ||
| 3 | import { SnapShotSource } from "./chatAttachment.ts"; | 4 | import { SnapShotSource } from "./chatAttachment.ts"; |
| ... | @@ -1127,6 +1128,11 @@ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); | ... | @@ -1127,6 +1128,11 @@ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); |
| 1127 | export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; | 1128 | export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; |
| 1128 | 1129 | ||
| 1129 | export interface DesktopBridge { | 1130 | export interface DesktopBridge { |
| 1131 | beginHostedAuth?: (server: string) => Promise<void>; | ||
| 1132 | getHostedAuthState?: () => Promise<DesktopHostedAuthState>; | ||
| 1133 | getHostedAccessToken?: (server: string) => Promise<string>; | ||
| 1134 | cancelHostedAuth?: () => Promise<void>; | ||
| 1135 | disconnectHostedAuth?: () => Promise<void>; | ||
| 1130 | supportsLocalTerminal?: (input: { | 1136 | supportsLocalTerminal?: (input: { |
| 1131 | readonly httpBaseUrl: string; | 1137 | readonly httpBaseUrl: string; |
| 1132 | readonly environmentId: string; | 1138 | readonly environmentId: string; |
packages/contracts/src/outbound.ts created+32| ... | @@ -0,0 +1,32 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { AuthEnvironmentScopes } from "./auth.ts"; | ||
| 3 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | ||
| 4 | |||
| 5 | export const OutboundSourceMessage = Schema.Union([ | ||
| 6 | Schema.Struct({ type: Schema.Literal("register"), descriptor: ExecutionEnvironmentDescriptor }), | ||
| 7 | Schema.Struct({ | ||
| 8 | type: Schema.Literal("grant"), | ||
| 9 | id: Schema.String.check(Schema.isUUID()), | ||
| 10 | token: Schema.NonEmptyString.check(Schema.isMaxLength(16_384)), | ||
| 11 | }), | ||
| 12 | Schema.Struct({ type: Schema.Literal("pong") }), | ||
| 13 | ]); | ||
| 14 | export type OutboundSourceMessage = typeof OutboundSourceMessage.Type; | ||
| 15 | |||
| 16 | export const OutboundHubMessage = Schema.Union([ | ||
| 17 | Schema.Struct({ type: Schema.Literal("connect"), id: Schema.String.check(Schema.isUUID()) }), | ||
| 18 | Schema.Struct({ | ||
| 19 | type: Schema.Literal("grant"), | ||
| 20 | id: Schema.String.check(Schema.isUUID()), | ||
| 21 | scopes: AuthEnvironmentScopes, | ||
| 22 | }), | ||
| 23 | Schema.Struct({ type: Schema.Literal("ping") }), | ||
| 24 | ]); | ||
| 25 | export type OutboundHubMessage = typeof OutboundHubMessage.Type; | ||
| 26 | |||
| 27 | export const HostedSourceConnection = Schema.Struct({ | ||
| 28 | serverUrl: Schema.NonEmptyString.check(Schema.isMaxLength(2_048)), | ||
| 29 | authorization: Schema.NonEmptyString.check(Schema.isMaxLength(16_384)), | ||
| 30 | expiresAt: Schema.DateTimeUtc, | ||
| 31 | }); | ||
| 32 | export type HostedSourceConnection = typeof HostedSourceConnection.Type; | ||
packages/contracts/src/transcriptArchive.ts created+76| ... | @@ -0,0 +1,76 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { OrchestrationV2ThreadShell } from "./orchestrationV2.ts"; | ||
| 3 | import { AssetResource } from "./assets.ts"; | ||
| 4 | import { OrchestrationProjectShell } from "./orchestrationProject.ts"; | ||
| 5 | import { EnvironmentId, ThreadId } from "./baseSchemas.ts"; | ||
| 6 | |||
| 7 | export const ArchiveObjectHash = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u)); | ||
| 8 | export const ArchiveObjectReceipt = Schema.Struct({ | ||
| 9 | hash: ArchiveObjectHash, | ||
| 10 | size: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | ||
| 11 | }); | ||
| 12 | export const ArchiveManagementRequest = Schema.Union([ | ||
| 13 | Schema.Struct({ type: Schema.Literal("archive.status") }), | ||
| 14 | Schema.Struct({ type: Schema.Literal("archive.retry"), threadId: Schema.optionalKey(ThreadId) }), | ||
| 15 | ]); | ||
| 16 | export type ArchiveManagementRequest = typeof ArchiveManagementRequest.Type; | ||
| 17 | export const ArchiveStatus = Schema.Struct({ | ||
| 18 | enabled: Schema.Boolean, | ||
| 19 | offloaded: Schema.Int, | ||
| 20 | pending: Schema.Int, | ||
| 21 | queue: Schema.Array(Schema.Struct({ threadId: ThreadId, nextAttemptAt: Schema.Number })), | ||
| 22 | storage: Schema.Struct({ | ||
| 23 | allocatedDatabaseBytes: Schema.Number, | ||
| 24 | attachmentsBytes: Schema.Number, | ||
| 25 | nativeProviderLogBytes: Schema.Number, | ||
| 26 | }), | ||
| 27 | lastReclamation: Schema.NullOr( | ||
| 28 | Schema.Struct({ | ||
| 29 | beforeAllocatedBytes: Schema.Number, | ||
| 30 | afterAllocatedBytes: Schema.Number, | ||
| 31 | }), | ||
| 32 | ), | ||
| 33 | failures: Schema.Array( | ||
| 34 | Schema.Struct({ threadId: ThreadId, attempts: Schema.Int, reason: Schema.String }), | ||
| 35 | ), | ||
| 36 | }); | ||
| 37 | export type ArchiveStatus = typeof ArchiveStatus.Type; | ||
| 38 | |||
| 39 | export const ArchiveAssetCreateUrlInput = Schema.Struct({ | ||
| 40 | environmentId: EnvironmentId, | ||
| 41 | resource: AssetResource, | ||
| 42 | }); | ||
| 43 | |||
| 44 | export const ArchivePublication = Schema.Struct({ | ||
| 45 | environmentId: EnvironmentId, | ||
| 46 | manifestHash: ArchiveObjectHash, | ||
| 47 | project: OrchestrationProjectShell, | ||
| 48 | }); | ||
| 49 | export type ArchivePublication = typeof ArchivePublication.Type; | ||
| 50 | export const ArchiveCatalogEntry = Schema.Struct({ | ||
| 51 | environmentId: EnvironmentId, | ||
| 52 | manifestHash: ArchiveObjectHash, | ||
| 53 | throughSequence: Schema.Number, | ||
| 54 | thread: OrchestrationV2ThreadShell, | ||
| 55 | project: OrchestrationProjectShell, | ||
| 56 | }); | ||
| 57 | export type ArchiveCatalogEntry = typeof ArchiveCatalogEntry.Type; | ||
| 58 | export const ArchiveCatalogResult = Schema.Struct({ entries: Schema.Array(ArchiveCatalogEntry) }); | ||
| 59 | |||
| 60 | export class ArchiveError extends Schema.TaggedError<ArchiveError>()("ArchiveError", { | ||
| 61 | reason: Schema.Literals(["unavailable", "integrity", "changed", "storage"]), | ||
| 62 | cause: Schema.optionalKey(Schema.Redacted(Schema.Defect())), | ||
| 63 | }) { | ||
| 64 | override get message() { | ||
| 65 | switch (this.reason) { | ||
| 66 | case "unavailable": | ||
| 67 | return "The transcript archive is unavailable. Reconnect to its server and retry."; | ||
| 68 | case "integrity": | ||
| 69 | return "The archived transcript failed verification. Retry or check the archive storage."; | ||
| 70 | case "changed": | ||
| 71 | return "The chat changed while it was being archived. Its local copy was retained."; | ||
| 72 | case "storage": | ||
| 73 | return "The transcript archive could not be saved. Check its mounted storage and retry."; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
packages/shared/package.json+4| ... | @@ -3,6 +3,10 @@ | ... | @@ -3,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./hostedServerUrl": { | ||
| 7 | "types": "./src/hostedServerUrl.ts", | ||
| 8 | "import": "./src/hostedServerUrl.ts" | ||
| 9 | }, | ||
| 6 | "./forkUpdateDirectory": { | 10 | "./forkUpdateDirectory": { |
| 7 | "types": "./src/forkUpdateDirectory.ts", | 11 | "types": "./src/forkUpdateDirectory.ts", |
| 8 | "import": "./src/forkUpdateDirectory.ts" | 12 | "import": "./src/forkUpdateDirectory.ts" |
packages/shared/src/advertisedEndpoint.ts+15-1| ... | @@ -33,12 +33,26 @@ export function normalizeHttpBaseUrl(rawValue: string): string { | ... | @@ -33,12 +33,26 @@ export function normalizeHttpBaseUrl(rawValue: string): string { |
| 33 | throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`); | 33 | throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`); |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | url.pathname = "/"; | 36 | url.pathname = `${url.pathname.replace(/\/+$/, "")}/`; |
| 37 | url.search = ""; | 37 | url.search = ""; |
| 38 | url.hash = ""; | 38 | url.hash = ""; |
| 39 | return url.toString(); | 39 | return url.toString(); |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | export function environmentEndpointUrl(baseUrl: string, path: string): string { | ||
| 43 | const url = new URL(baseUrl); | ||
| 44 | url.pathname = `${url.pathname.replace(/\/+$/, "")}/`; | ||
| 45 | url.search = ""; | ||
| 46 | url.hash = ""; | ||
| 47 | return new URL(path.replace(/^\/(?!\/)/, ""), url).toString(); | ||
| 48 | } | ||
| 49 | |||
| 50 | /** Canonical bases end in a slash; existing socket endpoints already include their route. */ | ||
| 51 | export function environmentWebSocketUrl(wsBaseUrl: string): string { | ||
| 52 | const url = new URL(wsBaseUrl); | ||
| 53 | return url.pathname.endsWith("/") ? environmentEndpointUrl(wsBaseUrl, "/ws") : url.toString(); | ||
| 54 | } | ||
| 55 | |||
| 42 | export function deriveWsBaseUrl(httpBaseUrl: string): string { | 56 | export function deriveWsBaseUrl(httpBaseUrl: string): string { |
| 43 | const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); | 57 | const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); |
| 44 | url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; | 58 | url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; |
packages/shared/src/devProxy.ts+9-1| ... | @@ -8,7 +8,14 @@ | ... | @@ -8,7 +8,14 @@ |
| 8 | * prefix only Vite knows gets answered with index.html; a prefix only the | 8 | * prefix only Vite knows gets answered with index.html; a prefix only the |
| 9 | * server knows redirect-loops through the proxy. | 9 | * server knows redirect-loops through the proxy. |
| 10 | */ | 10 | */ |
| 11 | export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws", "/mcp"] as const; | 11 | export const DEV_PROXIED_PATH_PREFIXES = [ |
| 12 | "/api", | ||
| 13 | "/oauth", | ||
| 14 | "/.well-known", | ||
| 15 | "/ws", | ||
| 16 | "/mcp", | ||
| 17 | "/environments", | ||
| 18 | ] as const; | ||
| 12 | 19 | ||
| 13 | /** | 20 | /** |
| 14 | * Prefixes the proxy must forward with the browser's own Host. MCP OAuth | 21 | * Prefixes the proxy must forward with the browser's own Host. MCP OAuth |
| ... | @@ -22,6 +29,7 @@ export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new S | ... | @@ -22,6 +29,7 @@ export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new S |
| 22 | "/.well-known", | 29 | "/.well-known", |
| 23 | "/mcp", | 30 | "/mcp", |
| 24 | "/ws", | 31 | "/ws", |
| 32 | "/environments", | ||
| 25 | ]); | 33 | ]); |
| 26 | 34 | ||
| 27 | export function isDevProxiedPath(pathname: string): boolean { | 35 | export function isDevProxiedPath(pathname: string): boolean { |
packages/shared/src/hostedServerUrl.ts created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | export function normalizeHostedServer(value: string): string { | ||
| 2 | const url = new URL(value); | ||
| 3 | if ( | ||
| 4 | url.protocol !== "https:" || | ||
| 5 | url.username || | ||
| 6 | url.password || | ||
| 7 | url.search || | ||
| 8 | url.hash || | ||
| 9 | url.pathname !== "/" | ||
| 10 | ) { | ||
| 11 | throw new Error("Enter an HTTPS server address without a path."); | ||
| 12 | } | ||
| 13 | return url.origin; | ||
| 14 | } | ||
packages/shared/src/remote.test.ts+19| ... | @@ -21,6 +21,25 @@ describe("remote", () => { | ... | @@ -21,6 +21,25 @@ describe("remote", () => { |
| 21 | }); | 21 | }); |
| 22 | }); | 22 | }); |
| 23 | 23 | ||
| 24 | it.each([ | ||
| 25 | { pairingUrl: "https://hub.example/prefix/environments/one/pair#token=pairing-token" }, | ||
| 26 | { pairingUrl: "https://hub.example/prefix/environments/one/#token=pairing-token" }, | ||
| 27 | { | ||
| 28 | pairingUrl: | ||
| 29 | "https://app.example/pair?host=https%3A%2F%2Fhub.example%2Fprefix%2Fenvironments%2Fone%2F#token=pairing-token", | ||
| 30 | }, | ||
| 31 | { | ||
| 32 | host: "wss://hub.example/prefix/environments/one///?old=1#fragment", | ||
| 33 | pairingCode: "pairing-token", | ||
| 34 | }, | ||
| 35 | ])("preserves a proxied environment base through pairing: %o", (input) => { | ||
| 36 | expect(resolveRemotePairingTarget(input)).toEqual({ | ||
| 37 | credential: "pairing-token", | ||
| 38 | httpBaseUrl: "https://hub.example/prefix/environments/one/", | ||
| 39 | wsBaseUrl: "wss://hub.example/prefix/environments/one/", | ||
| 40 | }); | ||
| 41 | }); | ||
| 42 | |||
| 24 | it("accepts pairing urls that still use a query token", () => { | 43 | it("accepts pairing urls that still use a query token", () => { |
| 25 | expect( | 44 | expect( |
| 26 | resolveRemotePairingTarget({ | 45 | resolveRemotePairingTarget({ |
packages/shared/src/remote.ts+8-35| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "./advertisedEndpoint.ts"; | ||
| 1 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 2 | 3 | ||
| 3 | const PAIRING_TOKEN_PARAM = "token"; | 4 | const PAIRING_TOKEN_PARAM = "token"; |
| ... | @@ -97,38 +98,9 @@ const normalizeRemoteBaseUrl = ( | ... | @@ -97,38 +98,9 @@ const normalizeRemoteBaseUrl = ( |
| 97 | protocol: url.protocol, | 98 | protocol: url.protocol, |
| 98 | }); | 99 | }); |
| 99 | } | 100 | } |
| 100 | url.pathname = "/"; | ||
| 101 | url.search = ""; | ||
| 102 | url.hash = ""; | ||
| 103 | return url; | 101 | return url; |
| 104 | }; | 102 | }; |
| 105 | 103 | ||
| 106 | const toHttpBaseUrl = (url: URL): string => { | ||
| 107 | const next = new URL(url.toString()); | ||
| 108 | if (next.protocol === "ws:") { | ||
| 109 | next.protocol = "http:"; | ||
| 110 | } else if (next.protocol === "wss:") { | ||
| 111 | next.protocol = "https:"; | ||
| 112 | } | ||
| 113 | next.pathname = "/"; | ||
| 114 | next.search = ""; | ||
| 115 | next.hash = ""; | ||
| 116 | return next.toString(); | ||
| 117 | }; | ||
| 118 | |||
| 119 | const toWsBaseUrl = (url: URL): string => { | ||
| 120 | const next = new URL(url.toString()); | ||
| 121 | if (next.protocol === "http:") { | ||
| 122 | next.protocol = "ws:"; | ||
| 123 | } else if (next.protocol === "https:") { | ||
| 124 | next.protocol = "wss:"; | ||
| 125 | } | ||
| 126 | next.pathname = "/"; | ||
| 127 | next.search = ""; | ||
| 128 | next.hash = ""; | ||
| 129 | return next.toString(); | ||
| 130 | }; | ||
| 131 | |||
| 132 | export interface ResolvedRemotePairingTarget { | 104 | export interface ResolvedRemotePairingTarget { |
| 133 | readonly credential: string; | 105 | readonly credential: string; |
| 134 | readonly httpBaseUrl: string; | 106 | readonly httpBaseUrl: string; |
| ... | @@ -211,8 +183,8 @@ export const resolveRemotePairingTarget = (input: { | ... | @@ -211,8 +183,8 @@ export const resolveRemotePairingTarget = (input: { |
| 211 | ); | 183 | ); |
| 212 | return { | 184 | return { |
| 213 | credential: hostedPairingRequest.token, | 185 | credential: hostedPairingRequest.token, |
| 214 | httpBaseUrl: toHttpBaseUrl(hostedBackendUrl), | 186 | httpBaseUrl: normalizeHttpBaseUrl(hostedBackendUrl.toString()), |
| 215 | wsBaseUrl: toWsBaseUrl(hostedBackendUrl), | 187 | wsBaseUrl: deriveWsBaseUrl(hostedBackendUrl.toString()), |
| 216 | }; | 188 | }; |
| 217 | } | 189 | } |
| 218 | 190 | ||
| ... | @@ -220,10 +192,11 @@ export const resolveRemotePairingTarget = (input: { | ... | @@ -220,10 +192,11 @@ export const resolveRemotePairingTarget = (input: { |
| 220 | if (!credential) { | 192 | if (!credential) { |
| 221 | throw new RemotePairingTokenMissingError({ host: url.host }); | 193 | throw new RemotePairingTokenMissingError({ host: url.host }); |
| 222 | } | 194 | } |
| 195 | url.pathname = url.pathname.replace(/\/pair\/?$/, "/"); | ||
| 223 | return { | 196 | return { |
| 224 | credential, | 197 | credential, |
| 225 | httpBaseUrl: toHttpBaseUrl(url), | 198 | httpBaseUrl: normalizeHttpBaseUrl(url.toString()), |
| 226 | wsBaseUrl: toWsBaseUrl(url), | 199 | wsBaseUrl: deriveWsBaseUrl(url.toString()), |
| 227 | }; | 200 | }; |
| 228 | } | 201 | } |
| 229 | 202 | ||
| ... | @@ -239,7 +212,7 @@ export const resolveRemotePairingTarget = (input: { | ... | @@ -239,7 +212,7 @@ export const resolveRemotePairingTarget = (input: { |
| 239 | 212 | ||
| 240 | return { | 213 | return { |
| 241 | credential: pairingCode, | 214 | credential: pairingCode, |
| 242 | httpBaseUrl: toHttpBaseUrl(normalizedHost), | 215 | httpBaseUrl: normalizeHttpBaseUrl(normalizedHost.toString()), |
| 243 | wsBaseUrl: toWsBaseUrl(normalizedHost), | 216 | wsBaseUrl: deriveWsBaseUrl(normalizedHost.toString()), |
| 244 | }; | 217 | }; |
| 245 | }; | 218 | }; |