authorclover caruso <git@paperclover.net> 2026-10-16 11:23:10-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log7caaa8c6b763db8a1e8934de235f956e13ff789d
tree43dbb2df2d5c92115e508d4f73e841dad2d3e74a
parent58df35b5187753f5f3de7499ef1287785ca6fbcc
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat: connect devices and archive chats through a self-hosted server

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-sol

214 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";
14import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";14import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
15import * as DesktopAppActivation from "./DesktopAppActivation.ts";15import * as DesktopAppActivation from "./DesktopAppActivation.ts";
16import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";16import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
17import * as DesktopHostedAuth from "./DesktopHostedAuth.ts";
17import * as DesktopClerk from "./DesktopClerk.ts";18import * as DesktopClerk from "./DesktopClerk.ts";
18import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";19import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
19import * as DesktopWindow from "../window/DesktopWindow.ts";20import * as DesktopWindow from "../window/DesktopWindow.ts";
...@@ -320,6 +321,7 @@ const startup = Effect.gen(function* () {...@@ -320,6 +321,7 @@ const startup = Effect.gen(function* () {
320321
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;
324326
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.
2import { describe, expect, it } from "@effect/vitest";
3import * as NodePath from "@effect/platform-node/NodePath";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
10import * as DesktopHostedAuth from "./DesktopHostedAuth.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12import * as ElectronApp from "../electron/ElectronApp.ts";
13import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
14import * as ElectronShell from "../electron/ElectronShell.ts";
15import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
16import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
17import type { DesktopBackendInstance } from "../backend/DesktopBackendPool.ts";
18import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts";
19
20const server = "https://knife.example.test";
21const encoder = new TextEncoder();
22const decoder = new TextDecoder();
23
24function 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
140describe("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 @@
1import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl";
2import {
3 DesktopHostedAuthState,
4 AuthArchiveReadScope,
5 AuthArchiveWriteScope,
6 AuthEnvironmentConnectScope,
7 HostedAuthDeviceStartResult,
8 HostedAuthGrant,
9 PRIMARY_LOCAL_ENVIRONMENT_ID,
10} from "@t3tools/contracts";
11import * as Clock from "effect/Clock";
12import * as Context from "effect/Context";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as Schema from "effect/Schema";
20import * as Semaphore from "effect/Semaphore";
21import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
22import * as ElectronApp from "../electron/ElectronApp.ts";
23import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
24import * as ElectronShell from "../electron/ElectronShell.ts";
25import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
26import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts";
27import * as DesktopEnvironment from "./DesktopEnvironment.ts";
28import * as HostProcess from "@t3tools/shared/HostProcess";
29import {
30 createHostedAuthAttempt,
31 isHostedAuthCallback,
32 readHostedAuthCallback,
33 type HostedAuthAttempt,
34} from "./hostedAuthHandoff.ts";
35
36const SavedCredential = Schema.Struct({
37 serverUrl: Schema.String,
38 deviceId: Schema.String,
39 refreshToken: Schema.String,
40});
41
42const encodeSavedCredential = Schema.encodeEffect(Schema.fromJsonString(SavedCredential));
43
44export class DesktopHostedAuthError extends Schema.TaggedError<DesktopHostedAuthError>()(
45 "DesktopHostedAuthError",
46 { message: Schema.String },
47) {}
48
49export 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
62export 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
363export const layer = Layer.effect(DesktopHostedAuth, make);
apps/desktop/src/app/hostedAuthHandoff.test.ts created+71
...@@ -0,0 +1,71 @@
1import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl";
2// @effect-diagnostics nodeBuiltinImport:off - Verify the desktop-only PKCE bytes independently.
3import * as NodeCrypto from "node:crypto";
4import { describe, expect, it } from "@effect/vitest";
5import {
6 createHostedAuthAttempt,
7 isHostedAuthCallback,
8 readHostedAuthCallback,
9} from "./hostedAuthHandoff.ts";
10
11const server = "https://knife.example.test";
12const callback = (state: string, origin = server) =>
13 `t3code://app/self-hosted/callback?${new URLSearchParams({ code: "one-time-proof", state, server: origin })}`;
14
15describe("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.
2import * as NodeCrypto from "node:crypto";
3
4import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl";
5
6export 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
17export type HostedAuthAttempt = ReturnType<typeof createHostedAuthAttempt>;
18
19export 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
34export 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 @@
1import {
2 cancelHostedAuth,
3 beginHostedAuth,
4 getHostedAuthState,
5 getHostedAccessToken,
6 disconnectHostedAuth,
7} from "./methods/hostedAuth.ts";
1import { openVideo } from "./methods/media.ts";8import { openVideo } from "./methods/media.ts";
2import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
310
...@@ -104,6 +111,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -104,6 +111,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
104111
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
126export const DESKTOP_TERMINAL_CHANNEL = "desktop:terminal";126export const DESKTOP_TERMINAL_CHANNEL = "desktop:terminal";
127127
128export const OPEN_VIDEO_CHANNEL = "desktop:open-video";128export const OPEN_VIDEO_CHANNEL = "desktop:open-video";
129
130export const BEGIN_HOSTED_AUTH_CHANNEL = "desktop:hosted-auth:begin";
131export const GET_HOSTED_AUTH_STATE_CHANNEL = "desktop:hosted-auth:state";
132export const GET_HOSTED_ACCESS_TOKEN_CHANNEL = "desktop:hosted-auth:access-token";
133export const DISCONNECT_HOSTED_AUTH_CHANNEL = "desktop:hosted-auth:disconnect";
134
135export const CANCEL_HOSTED_AUTH_CHANNEL = "desktop:hosted-auth:cancel";
apps/desktop/src/ipc/methods/hostedAuth.ts created+48
...@@ -0,0 +1,48 @@
1import { DesktopHostedAuthState } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import * as DesktopHostedAuth from "../../app/DesktopHostedAuth.ts";
5import * as IpcChannels from "../channels.ts";
6import * as DesktopIpc from "../DesktopIpc.ts";
7
8export 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});
16export 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});
24export 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});
32export 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
41export 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";
39import * as DesktopWebLinks from "./app/DesktopWebLinks.ts";39import * as DesktopWebLinks from "./app/DesktopWebLinks.ts";
40import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";40import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
41import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";41import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
42import * as DesktopHostedAuth from "./app/DesktopHostedAuth.ts";
42import * as DesktopClerk from "./app/DesktopClerk.ts";43import * as DesktopClerk from "./app/DesktopClerk.ts";
43import * as DesktopCliCommand from "./app/DesktopCliCommand.ts";44import * as DesktopCliCommand from "./app/DesktopCliCommand.ts";
44import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";45import * 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);
223224
224const layerDesktopApplication = Layer.mergeAll(225const 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";
2import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice";2import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice";
3import * as Linking from "expo-linking";3import * as Linking from "expo-linking";
4import * as SplashScreen from "expo-splash-screen";4import * as SplashScreen from "expo-splash-screen";
5import { useEffect } from "react";5import { useCallback, useEffect, useRef, type RefObject } from "react";
6import { StatusBar } from "react-native";6import { StatusBar } from "react-native";
7import { GestureHandlerRootView } from "react-native-gesture-handler";7import { GestureHandlerRootView } from "react-native-gesture-handler";
8import { KeyboardProvider } from "react-native-keyboard-controller";8import { KeyboardProvider } from "react-native-keyboard-controller";
9import { SafeAreaProvider } from "react-native-safe-area-context";9import { SafeAreaProvider } from "react-native-safe-area-context";
10import { createStaticNavigation } from "@react-navigation/native";10import { createStaticNavigation, type NavigationState } from "@react-navigation/native";
1111
12import { RegistryContext } from "@effect/atom-react";12import { RegistryContext } from "@effect/atom-react";
13import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet";13import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet";
...@@ -61,13 +61,20 @@ function SplashScreenCoordinator() {...@@ -61,13 +61,20 @@ function SplashScreenCoordinator() {
6161
62export default function App() {62export 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 }, []);
6468
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}
7784
78function AppContent() {85function 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();
8191
...@@ -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 @@
1import { createHubConnectionAtoms } from "@t3tools/client-runtime/state/hub";
2
3import { connectionAtomRuntime } from "./runtime";
4
5export 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
254const layerEnvironmentOwnedDataCleanup = Layer.succeed(254const 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 @@
1import {
2 BearerConnectionCredential,
3 BearerConnectionProfile,
4 BearerConnectionRegistration,
5 BearerConnectionTarget,
6 CredentialStore,
7 HubConnectionCredential,
8} from "@t3tools/client-runtime/connection";
9import { EnvironmentId } from "@t3tools/contracts";
10import { Persistence } from "@t3tools/client-runtime/platform";
11import * as Option from "effect/Option";
1import { describe, expect, it } from "@effect/vitest";12import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
3import { vi } from "vite-plus/test";14import { 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}));
1425
26import * as ConnectionStorage from "./storage";
15import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store";27import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store";
16import * as MobileSecureStorage from "../persistence/mobile-secure-storage";28import * as MobileSecureStorage from "../persistence/mobile-secure-storage";
1729
...@@ -34,6 +46,62 @@ function makeStorage(initial: Readonly<Record<string, string>>) {...@@ -34,6 +46,62 @@ function makeStorage(initial: Readonly<Record<string, string>>) {
34}46}
3547
36describe("mobile connection catalog storage", () => {48describe("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}));
2828
29vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} }));29vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} }));
30vi.mock("../../state/preferences", () => ({ mobilePreferencesAtom: {} }));
30vi.mock("../../state/use-composer-drafts", () => ({31vi.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 @@
1import { ClerkProvider, useAuth } from "@clerk/expo";1import { ClerkProvider, useAuth } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";2import { tokenCache } from "@clerk/expo/token-cache";
3import { useAtomValue } from "@effect/atom-react";
3import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";4import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";
4import {5import {
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";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
11import { type ReactNode, useEffect, useRef } from "react";12import { type ReactNode, useEffect, useRef } from "react";
13import { AsyncResult } from "effect/reactivity";
1214
13import { runtime } from "../../lib/runtime";15import { runtime } from "../../lib/runtime";
14import { appAtomRegistry } from "../../state/atom-registry";16import { appAtomRegistry } from "../../state/atom-registry";
...@@ -25,6 +27,10 @@ import {...@@ -25,6 +27,10 @@ import {
25import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";27import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
26import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";28import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
27import { removeCloudEnvironments } from "./cloud-drafts";29import { removeCloudEnvironments } from "./cloud-drafts";
30import { environmentCatalog } from "../../connection/catalog";
31import { mobilePreferencesAtom } from "../../state/preferences";
32import { CloudAuthAvailableContext } from "./cloudAvailability";
33import { shouldEnableCloudAuth } from "./connectionService";
2834
29function resetManagedRelayTokenCache() {35function 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}
197203
198export function CloudAuthProvider(props: { readonly children: ReactNode }) {204export 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 );
202216
203 useEffect(() => {217 useEffect(() => {
204 if (!publishableKey || !relayUrl) {218 if (!enabled) {
205 deactivateCloudRelayAccount();219 deactivateCloudRelayAccount();
206 }220 }
207 }, [publishableKey, relayUrl]);221 }, [enabled]);
208222
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 }
212230
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";
14import { splitEnvironmentSections } from "../connection/environmentSections";14import { splitEnvironmentSections } from "../connection/environmentSections";
15import { useConnectionController } from "../connection/useConnectionController";15import { useConnectionController } from "../connection/useConnectionController";
16import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut";16import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut";
17import { hasCloudPublicConfig } from "./publicConfig";17import { useCloudAuthAvailable } from "./cloudAvailability";
1818
19/**19/**
20 * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes20 * 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 */
26export function ConnectOnboardingRouteScreen() {26export function ConnectOnboardingRouteScreen() {
27 const navigation = useNavigation();27 const navigation = useNavigation();
28 const cloudAvailable = useCloudAuthAvailable();
2829
29 // The route is deep-linkable; without cloud config the sheet would present30 // 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]);
4142
42 return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null;43 return cloudAvailable ? <ConfiguredConnectOnboardingRouteScreen /> : null;
43}44}
4445
45function ConfiguredConnectOnboardingRouteScreen() {46function ConfiguredConnectOnboardingRouteScreen() {
apps/mobile/src/features/cloud/cloudAvailability.ts created+7
...@@ -0,0 +1,7 @@
1import { createContext, useContext } from "react";
2
3export const CloudAuthAvailableContext = createContext(false);
4
5export function useCloudAuthAvailable(): boolean {
6 return useContext(CloudAuthAvailableContext);
7}
apps/mobile/src/features/cloud/connectionService.test.ts created+46
...@@ -0,0 +1,46 @@
1import {
2 BearerConnectionTarget,
3 RelayConnectionTarget,
4 type ConnectionCatalogEntry,
5} from "@t3tools/client-runtime/connection";
6import { EnvironmentId } from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import { describe, expect, it } from "vite-plus/test";
9
10import { shouldEnableCloudAuth } from "./connectionService";
11
12const 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
22describe("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 @@
1import { connectionRoutes, type ConnectionCatalogEntry } from "@t3tools/client-runtime/connection";
2
3export 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";
21import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";21import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
22import { serverEnvironment } from "../../state/server";22import { serverEnvironment } from "../../state/server";
23import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";23import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
24import { hasCloudPublicConfig } from "../cloud/publicConfig";24import { useCloudAuthAvailable } from "../cloud/cloudAvailability";
25import { ConnectionStatusDot } from "./ConnectionStatusDot";25import { ConnectionStatusDot } from "./ConnectionStatusDot";
26import { environmentStatusText } from "./environmentStatusText";26import { environmentStatusText } from "./environmentStatusText";
27import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";27import { 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 */
54export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) {54export 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 whether57 // 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 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { useAuth } from "@clerk/expo";2import { useAuth } from "@clerk/expo";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import { CameraView, useCameraPermissions } from "expo-camera";4import { CameraView, useCameraPermissions } from "expo-camera";
4import * as Haptics from "expo-haptics";5import * as Haptics from "expo-haptics";
5import {6import {
...@@ -30,6 +31,11 @@ import { SymbolView } from "../../components/AppSymbol";...@@ -30,6 +31,11 @@ import { SymbolView } from "../../components/AppSymbol";
30import { hasCloudPublicConfig } from "../cloud/publicConfig";31import { hasCloudPublicConfig } from "../cloud/publicConfig";
31import { CloudEnvironmentRows } from "./CloudEnvironmentRows";32import { CloudEnvironmentRows } from "./CloudEnvironmentRows";
32import { splitEnvironmentSections } from "./environmentSections";33import { splitEnvironmentSections } from "./environmentSections";
34import { MaterialButton } from "../../components/MaterialButton";
35import { useCloudAuthAvailable } from "../cloud/cloudAvailability";
36import { hubConnection } from "../../connection/hub";
37import { useAtomCommand } from "../../state/use-atom-command";
38import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3339
34type ConnectionsNewRouteParams = {40type 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 production66 // 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.Success190 ? 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 );
183218
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]);
196231
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 <SettingsScreen326 <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 <PairingInputRow372 <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 <PairingInputRow382 <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>
274406
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() {
393532
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 <CloudEnvironmentRows536 <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 <Text577 <Text
442 accessibilityElementsHidden578 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";
10import { useRemoteConnections } from "../../state/use-remote-environment-registry";10import { useRemoteConnections } from "../../state/use-remote-environment-registry";
11import { LocalEnvironmentList } from "./LocalEnvironmentList";11import { LocalEnvironmentList } from "./LocalEnvironmentList";
12import { GitHubRoutingSettings } from "./GitHubRoutingSettings";12import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
13import { SelfHostedServerSection } from "./SelfHostedServerSection";
1314
14export function ConnectionsRouteScreen() {15export 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 <LocalEnvironmentList64 <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 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import type { ComponentProps } from "react";2import type { ComponentProps } from "react";
3import { useAtomValue } from "@effect/atom-react";
3import { View } from "react-native";4import { View } from "react-native";
45
5import { SymbolView } from "../../components/AppSymbol";6import { SymbolView } from "../../components/AppSymbol";
6import { AppText as Text } from "../../components/AppText";7import { AppText as Text } from "../../components/AppText";
7import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";8import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";
9import { hubConnection } from "../../connection/hub";
810
9type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>;11type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>;
1012
...@@ -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 <View31 <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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { Alert, View } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { ListRowLayout } from "../../components/ListRowLayout";
6import { hubConnection } from "../../connection/hub";
7import { useAtomCommand } from "../../state/use-atom-command";
8import { SettingsRow } from "../settings/components/SettingsRow";
9import { SettingsSection } from "../settings/components/SettingsSection";
10
11export 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});
5252
53describe("parsePairingUrl", () => {53describe("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 } {
6363
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
3import { StackActions, useNavigation } from "@react-navigation/native";3import { StackActions, useNavigation } from "@react-navigation/native";
4import { useCallback, useEffect, useLayoutEffect, useRef } from "react";4import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
5import { View } from "react-native";5import { View } from "react-native";
6import { useAtomSet } from "@effect/atom-react";
67
7import { hasCloudPublicConfig } from "../cloud/publicConfig";8import { hasCloudPublicConfig } from "../cloud/publicConfig";
8import { T3ConnectProfilePage } from "../cloud/T3ConnectProfilePage";9import { T3ConnectProfilePage } from "../cloud/T3ConnectProfilePage";
10import { useCloudAuthAvailable } from "../cloud/cloudAvailability";
11import { updateMobilePreferencesAtom } from "../../state/preferences";
912
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.
11const USER_PROFILE_CUSTOM_PAGES = [14const USER_PROFILE_CUSTOM_PAGES = [
...@@ -19,14 +22,18 @@ const USER_PROFILE_CUSTOM_PAGES = [...@@ -19,14 +22,18 @@ const USER_PROFILE_CUSTOM_PAGES = [
1922
20export function SettingsAuthRouteScreen() {23export function SettingsAuthRouteScreen() {
21 const navigation = useNavigation();24 const navigation = useNavigation();
25 const cloudAvailable = useCloudAuthAvailable();
26 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
2227
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]);
2835
29 return hasCloudPublicConfig() ? <ConfiguredSettingsAuthRouteScreen /> : null;36 return cloudAvailable ? <ConfiguredSettingsAuthRouteScreen /> : null;
30}37}
3138
32function ConfiguredSettingsAuthRouteScreen() {39function 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";
13import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";13import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
14import { LocalEnvironmentList } from "../connection/LocalEnvironmentList";14import { LocalEnvironmentList } from "../connection/LocalEnvironmentList";
15import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";15import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";
16import { SelfHostedServerSection } from "../connection/SelfHostedServerSection";
16import { splitEnvironmentSections } from "../connection/environmentSections";17import { splitEnvironmentSections } from "../connection/environmentSections";
17import { useUniwindTheme } from "../../lib/useUniwindTheme";18import { useUniwindTheme } from "../../lib/useUniwindTheme";
18import { useRemoteConnections } from "../../state/use-remote-environment-registry";19import { useRemoteConnections } from "../../state/use-remote-environment-registry";
...@@ -162,6 +163,7 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -162,6 +163,7 @@ export function SettingsEnvironmentsRouteScreen() {
162 ) : undefined163 ) : undefined
163 }164 }
164 >165 >
166 <SelfHostedServerSection />
165 <LocalEnvironmentList167 <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";
34import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";34import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
35import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";35import { resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
36import { useCloudAuthAvailable } from "../cloud/cloudAvailability";
36import { runtime } from "../../lib/runtime";37import { runtime } from "../../lib/runtime";
37import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";38import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
38import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";39import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
...@@ -59,7 +60,7 @@ function useDeviceRegistered(): boolean {...@@ -59,7 +60,7 @@ function useDeviceRegistered(): boolean {
59}60}
6061
61export function SettingsNotificationsRouteScreen() {62export function SettingsNotificationsRouteScreen() {
62 if (!hasCloudPublicConfig()) {63 if (!useCloudAuthAvailable()) {
63 return (64 return (
64 <SettingsScreen title="Notifications">65 <SettingsScreen title="Notifications">
65 <ScrollView66 <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";
5import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping";5import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
77
8import { hasCloudPublicConfig } from "../cloud/publicConfig";8import { useCloudAuthAvailable } from "../cloud/cloudAvailability";
9import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";9import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
10import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";10import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
11import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";11import { 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";
21export function SettingsRouteScreen() {21export 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";
13import { AppText as Text } from "../../components/AppText";13import { AppText as Text } from "../../components/AppText";
14import { SymbolView } from "../../components/AppSymbol";14import { SymbolView } from "../../components/AppSymbol";
15import { scopedThreadKey } from "../../lib/scopedEntities";15import { scopedThreadKey } from "../../lib/scopedEntities";
16import { environmentServerConfigsAtom } from "../../state/server";16import { environmentCapabilitiesAtom } from "../../state/server";
17import { environmentThreadShells } from "../../state/threads";17import { environmentThreadShells } from "../../state/threads";
18import {18import {
19 pendingThreadOrderAtom,19 pendingThreadOrderAtom,
...@@ -151,7 +151,7 @@ function DragHandle(props: {...@@ -151,7 +151,7 @@ function DragHandle(props: {
151export function ThreadArrangementSheet(props: { onClose: () => void }) {151export 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 while222 // 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.threadPinReorder228 ? supported.threadPinReorder
222 : !workingShelfEnabled && config.environment.capabilities.threadActiveReorder229 : !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.threadSettlement308 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 = thread432 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.capabilities489 capabilitiesByEnvironment.get(thread.environmentId)
484 .threadPinReorder ||490 ?.threadPinReorder ||
485 configs.get(thread.environmentId)?.environment.capabilities491 capabilitiesByEnvironment.get(thread.environmentId)
486 .threadActiveReorder492 ?.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
34import { cn } from "../../lib/cn";34import { cn } from "../../lib/cn";
35import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";35import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
36import { useUniwindTheme } from "../../lib/useUniwindTheme";36import { useUniwindTheme } from "../../lib/useUniwindTheme";
37import { useEnvironmentScope } from "../../state/session";37import { useEnvironmentScope, useThreadOrganizationPermission } from "../../state/session";
38import type { PendingNewTask } from "../../state/use-pending-new-tasks";38import type { PendingNewTask } from "../../state/use-pending-new-tasks";
39import { useThreadPr } from "../../state/use-thread-pr";39import { useThreadPr } from "../../state/use-thread-pr";
40import { useSwipeRowDormant } from "../home/swipe-row-activation";40import { 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 = !canOperateThread966 const swipeAccessibilityHint = !canOrganizeThread
966 ? "Opens the thread"967 ? "Opens the thread"
967 : secondaryAction === null968 : 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 );
13031304
1304 if (!canOperateThread) return rowContent(() => {});1305 if (!canOrganizeThread) return rowContent(() => {});
13051306
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 <ControlPillMenu1333 <ControlPillMenu
1333 actions={[1334 actions={(
1334 ...(thread.branch1335 [
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 ...(snoozedRow1345 { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
1345 ? snoozedMenuActions1346 ...(snoozedRow
1346 : !props.settlementSupported1347 ? snoozedMenuActions
1347 ? legacyMenuActions1348 : !props.settlementSupported
1348 : canUnsettle1349 ? legacyMenuActions
1349 ? slimMenuActions1350 : canUnsettle
1350 : swipeActions.secondary === "snooze"1351 ? slimMenuActions
1351 ? snoozableCardMenuActions1352 : 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 shouldOpenOnLongPress1365 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);
55const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);55const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);
5656
57export interface Preferences {57export 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<
139140
140function sanitizePreferences(parsed: Preferences): Preferences {141function 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 } = {};
171173
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 @@
1import { createHubEnvironmentCapabilitiesAtom } from "@t3tools/client-runtime/state/hub";
2import { hubConnection } from "../connection/hub";
1import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";3import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
2import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";4import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
35
...@@ -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});
20export const environmentCapabilitiesAtom = createHubEnvironmentCapabilitiesAtom(
21 environmentServerConfigsAtom,
22 hubConnection,
23);
1824
19export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom(25export 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});
21vi.mock("../connection/hub", () => ({
22 hubConnection: {
23 organizationPermissionAtom: Atom.family((_id: EnvironmentId | null) =>
24 Atom.make(false).pipe(Atom.keepAlive),
25 ),
26 },
27}));
21vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));28vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));
22vi.mock("@t3tools/client-runtime/state/session", () => ({29vi.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 @@
1import { hubConnection } from "../connection/hub";
1import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";3import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import {4import {
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
84export 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
90export 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
228it("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 @@
1import {1import {
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;
2425
25function selectEnvironment(config: ServerConfig) {26const EMPTY_PROVIDERS: ThreadListProvider[] = [];
27
28function 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}
4045
...@@ -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. */
97export function createThreadListEnvironmentsAtom(102export 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});
24vi.mock("./server", async () => {24vi.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});
28vi.mock("./use-thread-outbox", async () => {33vi.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";
9import { getThreadListV2OrderedSection } from "../features/threads/threadListV2";9import { getThreadListV2OrderedSection } from "../features/threads/threadListV2";
10import { appAtomRegistry } from "./atom-registry";10import { appAtomRegistry } from "./atom-registry";
11import { environmentServerConfigsAtom } from "./server";11import { threadListEnvironmentsAtom } from "./server";
12import { environmentThreadShells } from "./threads";12import { environmentThreadShells } from "./threads";
13import { queuedThreadKeysAtom } from "./use-thread-outbox";13import { queuedThreadKeysAtom } from "./use-thread-outbox";
1414
...@@ -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 @@
1import { ArchiveError } from "@t3tools/contracts";
2import * as ArchiveClient from "./ArchiveClient.ts";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { HttpServerResponse } from "effect/http";
6import {
7 injectMcpAppCookieBootstrap,
8 injectMcpAppThemeBootstrap,
9 MCP_APP_MAX_HTML_BYTES,
10} from "@t3tools/shared/mcpApp";
11
12export 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 @@
1import {
2 ArchiveError,
3 ArchiveObjectHash,
4 AuthSessionId,
5 EnvironmentId,
6 ThreadId,
7 type AssetCreateUrlResult,
8 type AssetResource,
9} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Redacted from "effect/Redacted";
16import * as Schema from "effect/Schema";
17import * as ArchiveCatalog from "./ArchiveCatalog.ts";
18import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
19import * as SessionStore from "../auth/SessionStore.ts";
20import {
21 base64UrlDecodeUtf8,
22 base64UrlEncode,
23 signPayload,
24 timingSafeEqualBase64Url,
25} from "../auth/utils.ts";
26
27const 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});
40const encodeClaims = Schema.encodeSync(Schema.fromJsonString(Claims));
41const decodeClaims = Schema.decodeUnknownOption(Schema.fromJsonString(Claims));
42const storageError = (cause: unknown) =>
43 new ArchiveError({ reason: "storage", cause: Redacted.make(cause) });
44
45export 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
66export 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 @@
1import {
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
12import * as NodeCrypto from "node:crypto";
13import * as NodePath from "@effect/platform-node/NodePath";
14import { isWindowsAbsolutePath } from "@t3tools/shared/path";
15import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview";
16import { parseAttachmentIdFromRelativePath } from "../attachmentStore.ts";
17import * as HostProcess from "@t3tools/shared/HostProcess";
18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";
23import * as Path from "effect/Path";
24import * as Redacted from "effect/Redacted";
25import * as Schema from "effect/Schema";
26import * as Semaphore from "effect/Semaphore";
27import * as Stream from "effect/Stream";
28import * as SqlClient from "effect/sql/SqlClient";
29import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
30import * as SqlitePersistence from "../persistence/Sqlite.ts";
31import * as ArchiveObjects from "./ArchiveObjects.ts";
32import { Bundle, Row } from "./ArchiveFormat.ts";
33
34const decodeBundle = Schema.decodeEffect(Schema.fromJsonString(Bundle));
35const decodeRows = Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Row)));
36const decodeEntry = Schema.decodeEffect(
37 Schema.fromJsonString(Schema.toCodecJson(ArchiveCatalogEntry)),
38);
39const encodeEntry = Schema.encodeEffect(
40 Schema.fromJsonString(Schema.toCodecJson(ArchiveCatalogEntry)),
41);
42const isArchiveError = Schema.is(ArchiveError);
43const storageError = (cause: unknown) =>
44 isArchiveError(cause)
45 ? cause
46 : new ArchiveError({ reason: "storage", cause: Redacted.make(cause) });
47
48export 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
91export 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 @@
1import { ArchiveError, ArchiveObjectHash, ArchiveObjectReceipt } from "@t3tools/contracts";
2// Incremental hashes keep media uploads bounded; Effect Crypto hashes whole buffers.
3// @effect-diagnostics nodeBuiltinImport:off
4import * as NodeCrypto from "node:crypto";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import type * as Scope from "effect/Scope";
14import { HttpClient, HttpClientRequest, HttpClientResponse, FetchHttpClient } from "effect/http";
15import * as ArchiveConnection from "./ArchiveConnection.ts";
16import * as ArchiveCatalog from "./ArchiveCatalog.ts";
17import {
18 ArchiveCatalogEntry,
19 type ArchivePublication,
20 type EnvironmentId,
21 type ThreadId,
22} from "@t3tools/contracts";
23import * as ArchiveObjects from "./ArchiveObjects.ts";
24
25const decodeHash = Schema.decodeEffect(ArchiveObjectHash);
26const storageError = (cause: unknown) =>
27 new ArchiveError({ reason: "storage", cause: Redacted.make(cause) });
28const connectionError = (cause: unknown) =>
29 new ArchiveError({ reason: "unavailable", cause: Redacted.make(cause) });
30
31export 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
65export 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 @@
1import { ArchiveError } from "@t3tools/contracts";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Redacted from "effect/Redacted";
8import * as SharedServer from "../environment/SharedServer.ts";
9
10export 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
21export 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 @@
1import { ArchiveObjectHash, ThreadId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4export const payloadTables = ["messages", "turn_items", "subagents"] as const;
5export const Table = Schema.Literals(payloadTables);
6const Cell = Schema.Union([Schema.String, Schema.Number, Schema.Null]);
7export const Row = Schema.Record(Schema.String, Cell);
8export const Segment = Schema.Struct({ table: Table, rows: Schema.Array(Row) });
9export const Metadata = Schema.Array(Segment);
10export 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;
29export const CanonicalTable = Schema.Literals(
30 canonicalTables.map((name) => `orchestration_v2_projection_${name}`),
31);
32export 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 @@
1import {
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";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Redacted from "effect/Redacted";
19import * as Schema from "effect/Schema";
20import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
21import * as HostedAuth from "../auth/HostedAuth.ts";
22import { archiveRouteErrors, authenticateRawRouteWithScope, assetFileResponse } from "../http.ts";
23import * as ArchiveAssets from "./ArchiveAssets.ts";
24import * as Option from "effect/Option";
25import * as ArchiveCatalog from "./ArchiveCatalog.ts";
26
27const 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};
36const decodeParams = Schema.decodeUnknownEffect(
37 Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId }),
38);
39const params = HttpRouter.params.pipe(Effect.flatMap(decodeParams));
40const catalogJson = json(ArchiveCatalogResult);
41const entryJson = json(ArchiveCatalogEntry);
42const assetJson = json(AssetCreateUrlResult);
43const snapshotJson = json(OrchestrationV2ThreadDetailSnapshot);
44const isHostedAuthError = Schema.is(HostedAuthError);
45const withdrawal = HttpServerRequest.schemaBodyJson(
46 Schema.Struct({ manifestHash: ArchiveObjectHash }),
47);
48const 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
59export 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 @@
1import { ArchiveError, ArchiveObjectHash, ArchiveObjectReceipt } from "@t3tools/contracts";
2// Incremental hashes keep media uploads bounded; Effect Crypto hashes whole buffers.
3// @effect-diagnostics nodeBuiltinImport:off
4import * as NodeCrypto from "node:crypto";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Config from "effect/Config";
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as Redacted from "effect/Redacted";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16
17const decodeHash = Schema.decodeEffect(ArchiveObjectHash);
18const isArchiveError = Schema.is(ArchiveError);
19const storageError = (cause: unknown) =>
20 new ArchiveError({ reason: "storage", cause: Redacted.make(cause) });
21
22export 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
36export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 AuthArchiveReadScope,
5 AuthArchiveWriteScope,
6 AuthOrchestrationReadScope,
7 AuthOrchestrationOperateScope,
8} from "@t3tools/contracts";
9import * as ConfigProvider from "effect/ConfigProvider";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import { HttpRouter } from "effect/http";
15import * as ServerConfig from "../config.ts";
16import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
17import { layerArchiveUploadRoute, layerArchiveDownloadRoute } from "../http.ts";
18import * as ArchiveObjects from "./ArchiveObjects.ts";
19
20it.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 @@
1import { ArchiveError, type ArchiveStatus } from "@t3tools/contracts";
2// Allocated filesystem blocks are unavailable through Effect FileSystem.
3// @effect-diagnostics nodeBuiltinImport:off
4import * as NodeFSP from "node:fs/promises";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Config from "effect/Config";
7import * as FileSystem from "effect/FileSystem";
8import * as Clock from "effect/Clock";
9import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Redacted from "effect/Redacted";
14import * as Schema from "effect/Schema";
15import * as SqlClient from "effect/sql/SqlClient";
16import * as ServerConfig from "../config.ts";
17import * as ServerSettings from "../serverSettings.ts";
18
19const storageError = (cause: unknown) =>
20 new ArchiveError({ reason: "storage", cause: Redacted.make(cause) });
21const isMissingFile = Schema.is(Schema.Struct({ code: Schema.Literal("ENOENT") }));
22const Home = Schema.Struct({
23 homePath: Schema.optionalKey(Schema.String),
24 shadowHomePath: Schema.optionalKey(Schema.String),
25});
26const decodeHome = Schema.decodeOption(Home);
27
28export 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
36export 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 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Stream from "effect/Stream";
4import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
5import * as TranscriptArchive from "./TranscriptArchive.ts";
6import type { ThreadId } from "@t3tools/contracts";
7
8export 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 @@
1import { ArchiveError } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as SourceConnection from "../environment/SourceConnection.ts";
7import * as ArchiveConnection from "./ArchiveConnection.ts";
8
9export 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 @@
1import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import {
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";
29import * as TestClock from "effect/testing/TestClock";
30import * as ArchiveCatalog from "./ArchiveCatalog.ts";
31import * as Hub from "../environment/Hub.ts";
32import * as HubInbox from "../environment/HubInbox.ts";
33import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
34import * as HostedAuth from "../auth/HostedAuth.ts";
35import * as HostedAuthConfig from "../auth/HostedAuthConfig.ts";
36import * as SessionStore from "../auth/SessionStore.ts";
37import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
38import * as ArchiveAssets from "./ArchiveAssets.ts";
39import * as ArchiveHttp from "./ArchiveHttp.ts";
40import * as Option from "effect/Option";
41import * as Redacted from "effect/Redacted";
42import * as Schema from "effect/Schema";
43import * as ConfigProvider from "effect/ConfigProvider";
44import * as DateTime from "effect/DateTime";
45import * as Deferred from "effect/Deferred";
46import * as Fiber from "effect/Fiber";
47import * as HostProcess from "@t3tools/shared/HostProcess";
48import * as ArchiveConnection from "./ArchiveConnection.ts";
49import * as SharedServer from "../environment/SharedServer.ts";
50import * as ServerSettings from "../serverSettings.ts";
51import * as ArchiveClient from "./ArchiveClient.ts";
52import * as Effect from "effect/Effect";
53import * as FileSystem from "effect/FileSystem";
54import * as Layer from "effect/Layer";
55import * as Path from "effect/Path";
56import * as PlatformError from "effect/PlatformError";
57import * as Crypto from "effect/Crypto";
58import * as SqlClient from "effect/sql/SqlClient";
59import * as Stream from "effect/Stream";
60import { HttpClient, HttpRouter } from "effect/http";
61import * as ServerConfig from "../config.ts";
62import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
63import * as SqlitePersistence from "../persistence/Sqlite.ts";
64import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
65import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts";
66import * as EventStore from "../orchestration-v2/EventStore.ts";
67import * as EventSink from "../orchestration-v2/EventSink.ts";
68import * as ApplicationEvents from "../persistence/OrchestrationEventStore.ts";
69import * as ArchivedProjectionStore from "./ArchivedProjectionStore.ts";
70import * as ArchiveObjects from "./ArchiveObjects.ts";
71import * as ArchiveRuntime from "./runtimeLayer.ts";
72import * as TranscriptArchive from "./TranscriptArchive.ts";
73import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
74import { createDeterministicAttachmentId } from "../attachmentStore.ts";
75import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "../assets/AssetAccess.ts";
76import * as NativeAppIconResolver from "../assets/NativeAppIconResolver.ts";
77import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
78import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
79
80const decodeAssetUrl = Schema.decodeUnknownEffect(AssetCreateUrlResult);
81const decodeCatalog = Schema.decodeUnknownEffect(Schema.toCodecJson(ArchiveCatalogResult));
82const decodeSnapshot = Schema.decodeUnknownEffect(
83 Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot),
84);
85const threadId = ThreadId.make("archive-review");
86const 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
135const 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
230it.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 @@
1import { ArchiveError, ProjectId } from "@t3tools/contracts";
2import * as ArchiveClient from "./ArchiveClient.ts";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
5import { type ArchiveManagementRequest, type ArchiveStatus, ThreadId } from "@t3tools/contracts";
6import {
7 extractMarkdownLinkHrefs,
8 resolveMarkdownFileLinkTarget,
9} from "@t3tools/shared/markdownLinks";
10import {
11 isWorkspaceImagePreviewPath,
12 audioMimeTypeFromExtension,
13 isWorkspaceVideoPreviewPath,
14} from "@t3tools/shared/filePreview";
15import * as Clock from "effect/Clock";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as Exit from "effect/Exit";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as Path from "effect/Path";
23import * as Redacted from "effect/Redacted";
24import * as Schema from "effect/Schema";
25import * as Stream from "effect/Stream";
26import * as SqlClient from "effect/sql/SqlClient";
27import {
28 resolveAttachmentPathById,
29 attachmentPathsById,
30 threadHtmlRenderAttachmentIds,
31 parseAttachmentIdFromRelativePath,
32 parseThreadSegmentFromAttachmentId,
33 toSafeThreadAttachmentSegment,
34} from "../attachmentStore.ts";
35import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
36import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
37import * as ArchiveStorage from "./ArchiveStorage.ts";
38import * as ServerConfig from "../config.ts";
39
40import {
41 payloadTables,
42 Table,
43 Segment,
44 Row,
45 Metadata,
46 canonicalTables,
47 CanonicalTable,
48 Bundle,
49} from "./ArchiveFormat.ts";
50
51type 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};
59const decodeMetadata = Schema.decodeEffect(Schema.fromJsonString(Metadata));
60const decodeBundle = Schema.decodeEffect(Schema.fromJsonString(Bundle));
61const encodeBundle = Schema.encodeEffect(Schema.fromJsonString(Bundle));
62const encodeRows = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Row)));
63const decodeRows = Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Row)));
64const RecordJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
65const decodeRecord = Schema.decodeSync(RecordJson);
66const encodeRecord = Schema.encodeSync(RecordJson);
67const encodeMetadata = Schema.encodeSync(Schema.fromJsonString(Metadata));
68const ids = {
69 messages: "message_id",
70 turn_items: "turn_item_id",
71 subagents: "subagent_id",
72} as const;
73const isArchiveError = Schema.is(ArchiveError);
74const 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. */
80function 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
120export 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
148const 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
765export 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. */
768export 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 @@
1import * as Layer from "effect/Layer";
2import * as SharedServer from "../environment/SharedServer.ts";
3import * as ArchiveObjects from "./ArchiveObjects.ts";
4import * as ArchiveClient from "./ArchiveClient.ts";
5import * as ArchiveCatalog from "./ArchiveCatalog.ts";
6import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
7import * as ArchiveStorage from "./ArchiveStorage.ts";
8import * as TranscriptArchive from "./TranscriptArchive.ts";
9
10export 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";
2626
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import * as ServerConfig from "../config.ts";28import * as ServerConfig from "../config.ts";
29import * as TranscriptArchive from "../archive/TranscriptArchive.ts";
30import * as Option from "effect/Option";
29import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";31import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
30import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";32import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
31import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";33import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
...@@ -106,6 +108,10 @@ const oversizedScreenshotItem = {...@@ -106,6 +108,10 @@ const oversizedScreenshotItem = {
106};108};
107109
108const layerTest = Layer.mergeAll(110const 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";
57import * as ServerSecretStore from "../auth/ServerSecretStore.ts";57import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
58import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";58import {
59 attachmentPathsById,
60 parseAttachmentFileExtension,
61 resolveAttachmentPathById,
62} from "../attachmentStore.ts";
59import * as ServerConfig from "../config.ts";63import * as ServerConfig from "../config.ts";
64import * as TranscriptArchive from "../archive/TranscriptArchive.ts";
60import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";65import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
61import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";66import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
62import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";67import * 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]);
98103
104const 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});
99const AssetClaimsSchema = Schema.Union([116const 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);
176const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);194const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
177195
178export type ResolvedAsset =196export 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);
513532
533const 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
563const 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
514export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {599export 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;
529614
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 }
821968
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});
848971
849export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (972export 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;
866989
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 = isIcns1103 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}
184184
185export function resolveAttachmentPathById(input: {185export 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;204export 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}
212210
213export type AttachmentClaimPlan =211export 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}
312310
313function parseAttachmentIdFromRelativePath(relativePath: string): string | null {311export 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
46import { verifyRequestDpopProof } from "./dpop.ts";47import { verifyRequestDpopProof } from "./dpop.ts";
47import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts";48import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts";
48import * as SqlitePersistence from "../persistence/Sqlite.ts";49import * as SqlitePersistence from "../persistence/Sqlite.ts";
50import * as HostedAuthConfig from "./HostedAuthConfig.ts";
51import { signPayload, timingSafeEqualBase64Url } from "./utils.ts";
4952
50const DEFAULT_SESSION_SUBJECT = "cli-issued-session";53const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
51export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";54export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
...@@ -106,6 +109,8 @@ export interface McpClientSession {...@@ -106,6 +109,8 @@ export interface McpClientSession {
106}109}
107110
108export interface AuthenticatedSession {111export 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 | ServerAuthInternalError507 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);
742757
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 );
770789
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 };
10341104
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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { EnvironmentId, HostedAuthAccount } from "@t3tools/contracts";
4import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose";
5import * as Crypto from "effect/Crypto";
6import * as Base64Url from "effect/encoding/Base64Url";
7import * as DateTime from "effect/DateTime";
8import * as FileSystem from "effect/FileSystem";
9import * as ConfigProvider from "effect/ConfigProvider";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Redacted from "effect/Redacted";
17import * as Schema from "effect/Schema";
18import * as TestClock from "effect/testing/TestClock";
19import { HttpClient, HttpClientResponse, HttpRouter, HttpServerRequest } from "effect/http";
20import * as ServerConfig from "../config.ts";
21import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
22import * as SqlitePersistence from "../persistence/Sqlite.ts";
23import * as EnvironmentAuth from "./EnvironmentAuth.ts";
24import * as HostedAuth from "./HostedAuth.ts";
25import * as HostedAuthConfig from "./HostedAuthConfig.ts";
26import * as HostedAuthHttp from "./HostedAuthHttp.ts";
27import * as ServerSecretStore from "./ServerSecretStore.ts";
28import * as SessionStore from "./SessionStore.ts";
29
30const 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};
39const client = { label: "Test computer", deviceType: "desktop" } as const;
40const verifier = "v".repeat(43);
41const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
42
43const 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
147const 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 });
171const 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
185it.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 @@
1import {
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";
18import { createLocalJWKSet, jwtVerify } from "jose";
19import * as Context from "effect/Context";
20import * as Crypto from "effect/Crypto";
21import * as DateTime from "effect/DateTime";
22import * as Duration from "effect/Duration";
23import * as Effect from "effect/Effect";
24import * as Base64Url from "effect/encoding/Base64Url";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27import * as Redacted from "effect/Redacted";
28import * as Schema from "effect/Schema";
29import * as SqlClient from "effect/sql/SqlClient";
30import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
31import * as KeyedLock from "@t3tools/shared/KeyedLock";
32import type { AuthenticatedSession } from "./EnvironmentAuth.ts";
33import * as HostedAuthConfig from "./HostedAuthConfig.ts";
34import * as ServerSecretStore from "./ServerSecretStore.ts";
35import * as SessionStore from "./SessionStore.ts";
36import { signPayload } from "./utils.ts";
37
38const isHostedAuthError = Schema.is(HostedAuthError);
39const encodeClient = Schema.encodeEffect(Schema.fromJsonString(AuthClientPresentationMetadata));
40
41const 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});
51const 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});
57const 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});
67type Device = typeof Device.Type;
68type NativeRequest = { readonly input: HostedAuthDeviceStart; readonly expiresAt: number };
69type 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};
77type Handoff = {
78 readonly input: HostedAuthDeviceStart;
79 readonly refreshToken: string;
80 readonly expiresAt: number;
81};
82
83export 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
133const 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
739export const layer = Layer.effect(HostedAuth, make);
apps/server/src/auth/HostedAuthConfig.ts created+79
...@@ -0,0 +1,79 @@
1import * as Config from "effect/Config";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Redacted from "effect/Redacted";
8import * as Schema from "effect/Schema";
9import { HostedAuthError } from "@t3tools/contracts";
10
11export const HOSTED_SESSION_COOKIE = "__Host-knife-session";
12export const HOSTED_TRANSACTION_COOKIE = "__Host-knife-oidc";
13export const HOSTED_CSRF_SECRET = "hosted-auth-csrf";
14
15const 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
29export 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
42const 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
79export const layer = Layer.effect(HostedAuthConfig, make);
apps/server/src/auth/HostedAuthHttp.ts created+255
...@@ -0,0 +1,255 @@
1import {
2 HostedAuthAccount,
3 HostedAuthDevice,
4 HostedAuthDeviceExchange,
5 HostedAuthDeviceRefresh,
6 HostedAuthDeviceStart,
7 HostedAuthDeviceStartResult,
8 HostedAuthError,
9 HostedAuthGrant,
10 HostedAuthTokenRequest,
11} from "@t3tools/contracts";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
17import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
18import * as EnvironmentAuth from "./EnvironmentAuth.ts";
19import * as HostedAuth from "./HostedAuth.ts";
20import {
21 HostedAuthConfig,
22 HOSTED_SESSION_COOKIE,
23 HOSTED_TRANSACTION_COOKIE,
24} from "./HostedAuthConfig.ts";
25import { deriveAuthClientMetadata } from "./utils.ts";
26
27const isHostedAuthError = Schema.is(HostedAuthError);
28
29const headers = {
30 "cache-control": "no-store",
31 pragma: "no-cache",
32 "referrer-policy": "no-referrer",
33};
34const cookieOptions = { secure: true, httpOnly: true, sameSite: "lax", path: "/" } as const;
35const 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 );
66const authenticate = Effect.gen(function* () {
67 const request = yield* HttpServerRequest.HttpServerRequest;
68 return yield* (yield* EnvironmentAuth.EnvironmentAuth).authenticateHttpRequest(request);
69});
70const 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);
77const 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
83export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";2import {
3 AuthAdministrativeScopes,
4 AuthStandardClientScopes,
5 EnvironmentId,
6} from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";7import { expect, it } from "@effect/vitest";
4import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
9import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";11import * as Fiber from "effect/Fiber";
7import * as Deferred from "effect/Deferred";12import * 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 @@
1import {1import {
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}
5253
53export interface VerifiedSession {54export 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);
548556
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 }
850935
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 }
9601052
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* authSessions1100 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 );
10181120
...@@ -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* authSessions1160 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";
31import * as ServerSecretStore from "./ServerSecretStore.ts";32import * as ServerSecretStore from "./ServerSecretStore.ts";
32import * as AuthHttp from "./http.ts";33import * as AuthHttp from "./http.ts";
3334
35const 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
34const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough";43const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough";
35class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}44class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}
3645
...@@ -68,6 +77,7 @@ const layerRoutes = HttpApiBuilder.layer(AuthTestApi).pipe(...@@ -68,6 +77,7 @@ const layerRoutes = HttpApiBuilder.layer(AuthTestApi).pipe(
68);77);
6978
70const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));79const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
80const decodeAccessToken = Schema.decodeUnknownSync(AuthAccessTokenResult);
71const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, string>>) =>81const 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,
78it.effect("sets the selected browser session cookies through the HTTP route", () =>88it.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
244it.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";
8import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import * as References from "effect/References";10import * as References from "effect/References";
11import { FetchHttpClient } from "effect/http";
11import { Argument, Command, Flag, GlobalFlag } from "effect/cli";12import { Argument, Command, Flag, GlobalFlag } from "effect/cli";
1213
13import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";14import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
15import * as HostedAuth from "../auth/HostedAuth.ts";
16import * as HostedAuthConfig from "../auth/HostedAuthConfig.ts";
1417
15import {18import {
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);
245248
249const 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
246export const authCommand = Command.make("auth").pipe(277export 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";
29import * as ProcessRunner from "../processRunner.ts";29import * as ProcessRunner from "../processRunner.ts";
30import * as ServerOwnership from "../serverOwnership.ts";30import * as ServerOwnership from "../serverOwnership.ts";
31import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";31import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
32import * as ArchiveRuntime from "../archive/runtimeLayer.ts";
33import * as ArchiveConnection from "../archive/ArchiveConnection.ts";
34import * as SharedServer from "../environment/SharedServer.ts";
32import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";35import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
33import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";36import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
34import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";37import * 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);
202205
203const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe(206const 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 @@
1import {1import {
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";
19import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";30import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
20import { authLocationFlags, resolveCliAuthConfig, type CliAuthLocationFlags } from "./config.ts";31import { authLocationFlags, resolveCliAuthConfig, type CliAuthLocationFlags } from "./config.ts";
2132
22const encodeStatus = Schema.encodeEffect(Schema.fromJsonString(SharedProfilesStatus));33const encodeStatus = Schema.encodeEffect(
34 Schema.fromJsonString(Schema.Union([SharedProfilesStatus, ArchiveStatus, HubDirectory])),
35);
2336
24const run = (flags: CliAuthLocationFlags, request: SharedProfilesRequest) =>37const 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 === 20067 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 });
6293
94const archiveStatus = Command.make("status", authLocationFlags).pipe(
95 Command.withHandler((flags) => run(flags, { type: "archive.status" })),
96);
97const 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);
109const archive = Command.make("archive").pipe(
110 Command.withSubcommands([archiveStatus, archiveRetry]),
111);
63const status = Command.make("status", authLocationFlags).pipe(112const 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);
117166
167const 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
118export const sharedCommand = Command.make("shared").pipe(208export 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 @@
1import { assert, it } from "@effect/vitest";
2import {
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";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import { HttpClient, HttpClientResponse } from "effect/http";
22import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
23import * as ServerEnvironment from "./ServerEnvironment.ts";
24import * as Hub from "./Hub.ts";
25import * as OutboundHub from "./OutboundHub.ts";
26import * as HostedAuthConfig from "../auth/HostedAuthConfig.ts";
27
28const leafId = EnvironmentId.make("leaf-a");
29const 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};
37const session = (scopes: ReadonlyArray<AuthEnvironmentScope> = AuthAdministrativeScopes) => ({
38 sessionId: AuthSessionId.make("phone"),
39 subject: "phone",
40 method: "bearer-access-token" as const,
41 scopes,
42});
43
44const 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
166it.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
186it.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
197it.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
229it.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
247it.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
263it.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
282it.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
302it.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
320it.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 @@
1import {
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";
17import * as Context from "effect/Context";
18import * as Clock from "effect/Clock";
19import * as Deferred from "effect/Deferred";
20import * as Effect from "effect/Effect";
21import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";
24import * as Schema from "effect/Schema";
25import * as Redacted from "effect/Redacted";
26import * as Semaphore from "effect/Semaphore";
27import {
28 FetchHttpClient,
29 HttpBody,
30 HttpClient,
31 HttpClientRequest,
32 HttpClientResponse,
33} from "effect/http";
34
35import type { AuthenticatedSession } from "../auth/EnvironmentAuth.ts";
36import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
37import * as ServerEnvironment from "./ServerEnvironment.ts";
38import * as OutboundHub from "./OutboundHub.ts";
39import * as HostedAuthConfig from "../auth/HostedAuthConfig.ts";
40
41const isHubError = Schema.is(HubError);
42const descriptorsEqual = Schema.toEquivalence(ExecutionEnvironmentDescriptor);
43
44const Machine = Schema.Struct({
45 url: Schema.String,
46 token: Schema.NonEmptyString,
47 descriptor: ExecutionEnvironmentDescriptor,
48 scopes: AuthEnvironmentScopes,
49});
50type Machine = typeof Machine.Type;
51const Configuration = Schema.Struct({
52 publicUrl: Schema.String,
53 machines: Schema.Array(Machine),
54});
55const jsonConfiguration = Schema.fromJsonString(Configuration);
56
57export 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
86const 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
105const 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. */
111export 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
417export const layer = Layer.effect(Hub, make);
apps/server/src/environment/HubHttp.ts created+89
...@@ -0,0 +1,89 @@
1import {
2 AuthAccessWriteScope,
3 EnvironmentId,
4 HubError,
5 HubManagementRequest,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
10import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
11import * as HubProxy from "./HubProxy.ts";
12import * as Schema from "effect/Schema";
13import * as Hub from "./Hub.ts";
14
15const isHubError = Schema.is(HubError);
16
17const 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
46const authenticate = Effect.gen(function* () {
47 const auth = yield* EnvironmentAuth.EnvironmentAuth;
48 const request = yield* HttpServerRequest.HttpServerRequest;
49 return yield* auth.authenticateWebSocketUpgrade(request);
50});
51
52const 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
62const 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
74const 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
85export 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 @@
1import { assert, it } from "@effect/vitest";
2import {
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";
18import * as NodeServices from "@effect/platform-node/NodeServices";
19import * as ConfigProvider from "effect/ConfigProvider";
20import * as ArchiveCatalog from "../archive/ArchiveCatalog.ts";
21import * as ArchiveObjects from "../archive/ArchiveObjects.ts";
22import * as Context from "effect/Context";
23import * as DateTime from "effect/DateTime";
24import * as Deferred from "effect/Deferred";
25import * as Effect from "effect/Effect";
26import * as Fiber from "effect/Fiber";
27import * as Layer from "effect/Layer";
28import * as Schema from "effect/Schema";
29import * as TestClock from "effect/testing/TestClock";
30import { HttpClient, HttpClientResponse } from "effect/http";
31import * as SqlitePersistence from "../persistence/Sqlite.ts";
32import * as Hub from "./Hub.ts";
33import * as HubInbox from "./HubInbox.ts";
34import { hubInboxThread as thread } from "./HubInbox.testkit.ts";
35
36const encodeSnapshot = Schema.encodeEffect(Schema.toCodecJson(OrchestrationV2ShellSnapshot));
37const decodeCommand = Schema.decodeEffect(
38 Schema.fromJsonString(Schema.toCodecJson(HubOrganizationCommand)),
39);
40
41const environmentId = EnvironmentId.make("laptop");
42const threadId = ThreadId.make("chat");
43const session = {
44 sessionId: AuthSessionId.make("phone"),
45 subject: "phone",
46 method: "bearer-access-token" as const,
47 scopes: AuthAdministrativeScopes,
48};
49
50const 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
238it.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
251it.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
289it.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
306it.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
319it.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
337it.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
361it.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
385it.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
405it.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
424it.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
442it.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
469it.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
495it.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
516it.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 @@
1import { OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3import * as Schema from "effect/Schema";
4
5const threadId = ThreadId.make("chat");
6export 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 @@
1import {
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";
21import * as Clock from "effect/Clock";
22import * as Context from "effect/Context";
23import * as DateTime from "effect/DateTime";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26import * as Redacted from "effect/Redacted";
27import * as Schedule from "effect/Schedule";
28import * as Schema from "effect/Schema";
29import * as Semaphore from "effect/Semaphore";
30import * as SqlClient from "effect/sql/SqlClient";
31import * as Struct from "effect/Struct";
32import {
33 FetchHttpClient,
34 HttpBody,
35 HttpClient,
36 HttpClientRequest,
37 HttpClientResponse,
38} from "effect/http";
39import type { AuthenticatedSession } from "../auth/EnvironmentAuth.ts";
40import * as Hub from "./Hub.ts";
41import * as ArchiveCatalog from "../archive/ArchiveCatalog.ts";
42import { ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION } from "../orchestration-v2/ProjectionStore.ts";
43
44const Organization = Schema.Struct(
45 Struct.pick(OrchestrationV2ThreadShell.fields, HUB_THREAD_ORGANIZATION_FIELDS),
46);
47type Organization = typeof Organization.Type;
48const jsonOrganization = Schema.fromJsonString(Schema.toCodecJson(Organization));
49const jsonSnapshot = Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ShellSnapshot));
50const SourceActivity = Schema.Struct({
51 userMessageAt: Schema.NullOr(Schema.Number),
52 completedAt: Schema.NullOr(Schema.Number),
53 pendingRequestId: Schema.NullOr(Schema.String),
54});
55const jsonSourceActivity = Schema.fromJsonString(SourceActivity);
56const encodeCommand = Schema.encodeEffect(Schema.toCodecJson(HubOrganizationCommand));
57const isHubError = Schema.is(HubError);
58const 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
70const internalSession: AuthenticatedSession = {
71 sessionId: AuthSessionId.make("hub-inbox"),
72 subject: "hub-inbox",
73 method: "bearer-access-token",
74 scopes: AuthAdministrativeScopes,
75};
76
77export 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
93export 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
107const 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
162const 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
191const 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
272const storageError = (cause: unknown) =>
273 new HubInboxError({ reason: "storage", cause: Redacted.make(cause) });
274
275const 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
661export const layer = Layer.effect(HubInbox, make);
apps/server/src/environment/HubInboxHttp.test.ts created+126
...@@ -0,0 +1,126 @@
1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 AuthOrchestrationOperateScope,
5 AuthOrchestrationReadScope,
6 AuthSessionId,
7 type HubOrganizationCommand,
8 HubInbox as HubInboxSchema,
9 HubInboxEnvironment,
10 EnvironmentId,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as DateTime from "effect/DateTime";
14import * as Layer from "effect/Layer";
15import * as Schema from "effect/Schema";
16import { HttpRouter } from "effect/http";
17import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
18import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
19import * as HubInbox from "./HubInbox.ts";
20import * as HubInboxHttp from "./HubInboxHttp.ts";
21import { hubInboxThread } from "./HubInbox.testkit.ts";
22
23const decodeInbox = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInboxSchema));
24
25it.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 @@
1import {
2 AuthOrchestrationOperateScope,
3 HubError,
4 HubInboxCommand,
5 HubOrganizationCommand,
6 HubInbox as HubInboxSchema,
7 HubInboxEnvironment,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Crypto from "effect/Crypto";
11import * as Hex from "effect/encoding/Hex";
12import * as Layer from "effect/Layer";
13import * as Schema from "effect/Schema";
14import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
15import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
16import * as HubInbox from "./HubInbox.ts";
17import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
18
19const isHubError = Schema.is(HubError);
20const isInboxError = Schema.is(HubInbox.HubInboxError);
21const isDispatchError = Schema.is(Orchestrator.OrchestratorDispatchError);
22const encodeInbox = Schema.encodeEffect(Schema.fromJsonString(Schema.toCodecJson(HubInboxSchema)));
23const encodeEnvironment = Schema.encodeEffect(Schema.toCodecJson(HubInboxEnvironment));
24
25const authenticate = Effect.gen(function* () {
26 const auth = yield* EnvironmentAuth.EnvironmentAuth;
27 return yield* auth.authenticateWebSocketUpgrade(yield* HttpServerRequest.HttpServerRequest);
28});
29
30const 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
76export 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 @@
1import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { AuthOrchestrationReadScope, EnvironmentId } from "@t3tools/contracts";
4import { computeDpopAccessTokenHash, computeDpopJwkThumbprint } from "@t3tools/shared/dpop";
5import * as Clock from "effect/Clock";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http";
10import { exportJWK, generateKeyPair, SignJWT } from "jose";
11import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
12import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
13import * as SessionStore from "../auth/SessionStore.ts";
14import * as ServerConfig from "../config.ts";
15import * as SqlitePersistence from "../persistence/Sqlite.ts";
16import * as ServerEnvironment from "./ServerEnvironment.ts";
17import * as Hub from "./Hub.ts";
18import * as HubHttp from "./HubHttp.ts";
19import * as HubProxy from "./HubProxy.ts";
20
21it.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 @@
1import { expect, it } from "@effect/vitest";
2import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
3import { NodeWS } from "@effect/platform-node/NodeSocket";
4import { AuthOrchestrationReadScope, AuthSessionId, EnvironmentId } from "@t3tools/contracts";
5import * as Context from "effect/Context";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http";
10import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
11import * as SessionStore from "../auth/SessionStore.ts";
12import * as Hub from "./Hub.ts";
13import * as HubHttp from "./HubHttp.ts";
14import * as HubProxy from "./HubProxy.ts";
15
16it.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 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 AuthOrchestrationReadScope,
4 AuthOrchestrationOperateScope,
5 AuthSessionId,
6 EnvironmentId,
7 ORCHESTRATION_PROTOCOL_VERSION,
8 type ExecutionEnvironmentDescriptor,
9} from "@t3tools/contracts";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Stream from "effect/Stream";
14import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http";
15import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
16import * as SessionStore from "../auth/SessionStore.ts";
17import * as Hub from "./Hub.ts";
18import * as HubHttp from "./HubHttp.ts";
19import * as HubProxy from "./HubProxy.ts";
20
21const 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};
29const 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};
35const session = {
36 sessionId: AuthSessionId.make("phone"),
37 subject: "phone",
38 method: "bearer-access-token" as const,
39 scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope],
40};
41
42const 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
142describe("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 @@
1import { EnvironmentId, HubError, authScopeResponse } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as Redacted from "effect/Redacted";
8import * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";
10import * as Socket from "effect/socket/Socket";
11import * as NodeSocket from "@effect/platform-node/NodeSocket";
12import {
13 FetchHttpClient,
14 HttpClient,
15 HttpClientRequest,
16 HttpServerRequest,
17 HttpServerResponse,
18} from "effect/http";
19import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
20import * as SessionStore from "../auth/SessionStore.ts";
21import * as Hub from "./Hub.ts";
22
23const isHubError = Schema.is(HubError);
24
25export 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
42const 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
77const 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
83const 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
232export const layer = Layer.effect(HubProxy, make);
apps/server/src/environment/OutboundHub.test.ts created+242
...@@ -0,0 +1,242 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeSocket from "@effect/platform-node/NodeSocket";
4import { expect, it } from "@effect/vitest";
5import {
6 AuthEnvironmentConnectScope,
7 AuthOrchestrationReadScope,
8 AuthAccessWriteScope,
9 AuthSessionId,
10 EnvironmentId,
11 ORCHESTRATION_PROTOCOL_VERSION,
12 OutboundHubMessage,
13 type OutboundSourceMessage,
14} from "@t3tools/contracts";
15import * as Context from "effect/Context";
16import * as Deferred from "effect/Deferred";
17import * as Effect from "effect/Effect";
18import * as Fiber from "effect/Fiber";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Queue from "effect/Queue";
22import * as Schema from "effect/Schema";
23import * as Socket from "effect/socket/Socket";
24import {
25 FetchHttpClient,
26 HttpClient,
27 HttpClientRequest,
28 HttpRouter,
29 HttpServer,
30 HttpServerRequest,
31 HttpServerResponse,
32} from "effect/http";
33import * as HostedAuth from "../auth/HostedAuth.ts";
34import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
35import * as SessionStore from "../auth/SessionStore.ts";
36import * as OutboundHub from "./OutboundHub.ts";
37import { bridge } from "./OutboundSocket.ts";
38
39const decodeHubMessage = Schema.decodeEffect(Schema.fromJsonString(OutboundHubMessage));
40
41const 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});
60const 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};
67const 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
76const 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
154it.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
188it.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
225it.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 @@
1import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer";
2import {
3 AuthEnvironmentConnectScope,
4 AuthStandardClientScopes,
5 EnvironmentId,
6 ExecutionEnvironmentDescriptor,
7 HubError,
8 OutboundSourceMessage,
9 OutboundHubMessage,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as Clock from "effect/Clock";
13import * as Crypto from "effect/Crypto";
14import * as Deferred from "effect/Deferred";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Redacted from "effect/Redacted";
19import * as Schema from "effect/Schema";
20import * as Scope from "effect/Scope";
21import * as Semaphore from "effect/Semaphore";
22import * as Socket from "effect/socket/Socket";
23import type { AuthenticatedSession } from "../auth/EnvironmentAuth.ts";
24import * as HostedAuth from "../auth/HostedAuth.ts";
25import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
26import * as SessionStore from "../auth/SessionStore.ts";
27import { bridge } from "./OutboundSocket.ts";
28
29const encodeHub = Schema.encodeSync(Schema.fromJsonString(OutboundHubMessage));
30const decodeSource = Schema.decodeEffect(Schema.fromJsonString(OutboundSourceMessage));
31
32const Enrollment = Schema.Struct({
33 deviceId: Schema.String,
34 descriptor: ExecutionEnvironmentDescriptor,
35});
36const Stored = Schema.fromJsonString(Schema.Array(Enrollment));
37const decodeStored = Schema.decodeEffect(Stored);
38const encodeStored = Schema.encodeEffect(Stored);
39type Enrollment = typeof Enrollment.Type;
40type 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
49export 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
77const isHubError = Schema.is(HubError);
78const failure = (cause: unknown) =>
79 isHubError(cause) ? cause : new HubError({ reason: "upstream", cause: Redacted.make(cause) });
80
81const 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
295export const layer = Layer.effect(OutboundHub, make);
apps/server/src/environment/OutboundSocket.test.ts created+114
...@@ -0,0 +1,114 @@
1import { expect, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Queue from "effect/Queue";
6import * as Socket from "effect/socket/Socket";
7import { bridge } from "./OutboundSocket.ts";
8
9type Frame = string | Uint8Array;
10const 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};
24const 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});
45const 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
61it.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
85it.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
103it.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 @@
1import { HubError } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Semaphore from "effect/Semaphore";
4import * as Socket from "effect/socket/Socket";
5
6const CHUNK_BYTES = 64 * 1024;
7const WINDOW_CHUNKS = 16;
8
9/** ACKs follow TCP drain, bounding WebSocket's otherwise unbounded send buffer. */
10export 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 @@
1import * as NodeSocket from "@effect/platform-node/NodeSocket";
2import {
3 AuthStandardClientScopes,
4 HubError,
5 OutboundHubMessage,
6 type AuthSessionId,
7 type HostedSourceConnection,
8 OutboundSourceMessage,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Schedule from "effect/Schedule";
16import * as Schema from "effect/Schema";
17import * as Semaphore from "effect/Semaphore";
18import * as Stream from "effect/Stream";
19import { HttpServer } from "effect/http";
20import * as Socket from "effect/socket/Socket";
21import * as SessionStore from "../auth/SessionStore.ts";
22import * as ServerActivation from "../serverActivation.ts";
23import { bridge } from "./OutboundSocket.ts";
24import * as ServerEnvironment from "./ServerEnvironment.ts";
25import * as SourceConnection from "./SourceConnection.ts";
26
27const encodeSource = Schema.encodeSync(Schema.fromJsonString(OutboundSourceMessage));
28const decodeHub = Schema.decodeEffect(Schema.fromJsonString(OutboundHubMessage));
29
30export 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 @@
1import { SharedAccountError } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
9
10const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString });
11const decode = Schema.decodeEffect(Schema.fromJsonString(Connection));
12const encode = Schema.encodeEffect(Schema.fromJsonString(Connection));
13
14export 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
23export 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 @@
1import { expect, it } from "@effect/vitest";
2import { HostedSourceConnection } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import { HttpClient, HttpClientResponse } from "effect/http";
9import * as SourceConnection from "./SourceConnection.ts";
10
11const sourceCodec = Schema.toCodecJson(HostedSourceConnection);
12const encodeSource = Schema.encodeEffect(sourceCodec);
13const decodeSource = Schema.decodeEffect(sourceCodec);
14const scopes = ["archive:read", "archive:write", "environment:connect"];
15const 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
71it.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
89it.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
103it.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
118it.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 @@
1import {
2 AuthArchiveReadScope,
3 AuthArchiveWriteScope,
4 AuthEnvironmentConnectScope,
5 HostedSourceConnection,
6 HubError,
7} from "@t3tools/contracts";
8import * as Context from "effect/Context";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import type * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
18
19const 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);
27const sourceScopes = [AuthArchiveReadScope, AuthArchiveWriteScope, AuthEnvironmentConnectScope];
28
29export 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
40const 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
115export const layer = Layer.effect(SourceConnection, make);
apps/server/src/environment/SourceConnectionHttp.ts created+95
...@@ -0,0 +1,95 @@
1import {
2 AuthAccessReadScope,
3 AuthAccessWriteScope,
4 AuthEnvironmentConnectScope,
5 HostedSourceConnection,
6 HubError,
7 SelfHostedConnectionStatus,
8} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as Schema from "effect/Schema";
14import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
15import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
16import * as OutboundHub from "./OutboundHub.ts";
17import * as SourceConnection from "./SourceConnection.ts";
18
19const isHubError = Schema.is(HubError);
20const statusResponse = HttpServerResponse.schemaJson(SelfHostedConnectionStatus);
21
22const 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
41const 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
74const 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
89export 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 @@
1import * as Mime from "effect/http/Mime";1import * as Mime from "effect/http/Mime";
2import {2import {
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";
35import { OtlpTracer, OtlpSerialization } from "effect/observability";42import { OtlpTracer, OtlpSerialization } from "effect/observability";
3643
37import * as ServerConfig from "./config.ts";44import * as ServerConfig from "./config.ts";
45import * as ArchiveObjects from "./archive/ArchiveObjects.ts";
46import * as ArchiveAssetResponse from "./archive/ArchiveAssetResponse.ts";
47import * as TranscriptArchive from "./archive/TranscriptArchive.ts";
38import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts";48import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts";
39import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts";49import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts";
40import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";50import { 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}
309319
310const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) =>320export 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 });
329343
344const isArchiveError = Schema.is(ArchiveError);
345const isArchiveAuthError = Schema.is(
346 Schema.Union([
347 EnvironmentAuthInvalidError,
348 EnvironmentInternalError,
349 EnvironmentScopeRequiredError,
350 ]),
351);
352export 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
370export 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
394export 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
412export 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
330export const layerSharedAccountRoute = HttpRouter.add(458export 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";
8import * as SqlClient from "effect/sql/SqlClient";8import * as SqlClient from "effect/sql/SqlClient";
99
10import * as EventStore from "./EventStore.ts";10import * as EventStore from "./EventStore.ts";
11import * as TranscriptArchive from "../archive/TranscriptArchive.ts";
11import * as ProjectionStore from "./ProjectionStore.ts";12import * as ProjectionStore from "./ProjectionStore.ts";
1213
13export interface ProjectionVerificationV2 {14export 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 SET196 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_json3938 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";
27import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";27import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
28import * as Orchestrator from "./Orchestrator.ts";28import * as Orchestrator from "./Orchestrator.ts";
29import * as ProjectionStore from "./ProjectionStore.ts";29import * as ProjectionStore from "./ProjectionStore.ts";
30import * as ArchivedProjectionStore from "../archive/ArchivedProjectionStore.ts";
30import * as ProjectionMaintenance from "./ProjectionMaintenance.ts";31import * as ProjectionMaintenance from "./ProjectionMaintenance.ts";
31import * as ProjectStore from "./ProjectStore.ts";32import * as ProjectStore from "./ProjectStore.ts";
32import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";33import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
...@@ -72,7 +73,7 @@ const layerCommandReceiptStoreProvided = CommandReceiptStore.layerFromApplicatio...@@ -72,7 +73,7 @@ const layerCommandReceiptStoreProvided = CommandReceiptStore.layerFromApplicatio
72const layerStores = Layer.mergeAll(73const 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(
82export const layerEventSink = EventSink.layerFromStores.pipe(Layer.provide(layerStores));83export const layerEventSink = EventSink.layerFromStores.pipe(Layer.provide(layerStores));
83const layerEventSinkProvided = layerEventSink;84const layerEventSinkProvided = layerEventSink;
84const layerProjectionMaintenanceProvided = ProjectionMaintenance.layer.pipe(85const layerProjectionMaintenanceProvided = ProjectionMaintenance.layer.pipe(
86 Layer.provide(ProjectionStore.layer),
85 Layer.provide(layerStores),87 Layer.provide(layerStores),
86);88);
87const layerLegacyV1ThreadImporterProvided = LegacyV1ThreadImporter.layer.pipe(89const 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);
138140
139const layerProviderAuthServiceProvided = ProviderAuthService.layer.pipe(141const layerProviderAuthServiceProvided = ProviderAuthService.layer.pipe(
140 Layer.provide(Layer.merge(ProjectionStore.layer, layerProviderSessionManagerProvided)),142 Layer.provide(Layer.merge(ArchivedProjectionStore.layer, layerProviderSessionManagerProvided)),
141);143);
142144
143const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe(145const 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);
169171
170const layerProviderTurnControlServiceProvided = ProviderTurnControlService.layer.pipe(172const layerProviderTurnControlServiceProvided = ProviderTurnControlService.layer.pipe(
171 Layer.provide(Layer.merge(ProjectionStore.layer, layerProviderSessionManagerProvided)),173 Layer.provide(Layer.merge(ArchivedProjectionStore.layer, layerProviderSessionManagerProvided)),
172);174);
173const layerRuntimeRequestServiceProvided = RuntimeRequestService.layer.pipe(175const layerRuntimeRequestServiceProvided = RuntimeRequestService.layer.pipe(
174 Layer.provide(Layer.merge(ProjectionStore.layer, layerProviderSessionManagerProvided)),176 Layer.provide(Layer.merge(ArchivedProjectionStore.layer, layerProviderSessionManagerProvided)),
175);177);
176const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.pipe(178const 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);
200const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe(202const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe(
201 Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ProjectionStore.layer)),203 Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ArchivedProjectionStore.layer)),
202);204);
203205
204const layerOrchestratorProvided = Orchestrator.layer.pipe(206const 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
3333
34export const AuthSessionRecord = Schema.Struct({34export 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;
4648
47export const CreateAuthSessionInput = Schema.Struct({49export 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<
134138
135const AuthSessionDbRow = Schema.Struct({139const 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({
151157
152const AuthSessionRawDbRow = Schema.Struct({158const 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);
171function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord {179function 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 SELECT271 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_sessions288 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 });
276296
...@@ -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 SELECT321 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_sessions338 FROM auth_sessions
317 WHERE revoked_at IS NULL339 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 DESC347 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_sessions381 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 NULL384 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 );
481509
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";
76import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts";76import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts";
77import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts";77import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts";
78import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts";78import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts";
79import Migration0063 from "./Migrations/063_TranscriptArchives.ts";
80import Migration0064 from "./Migrations/064_AuthSessionDelegation.ts";
81import Migration0065 from "./Migrations/065_HubInbox.ts";
82import Migration0066 from "./Migrations/066_HostedAuthDevices.ts";
83import Migration0067 from "./Migrations/067_ArchiveCatalogAndSync.ts";
7984
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;
156166
157export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);167export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/063_TranscriptArchives.ts created+26
...@@ -0,0 +1,26 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4export 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 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4export 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 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4export 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 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4export 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 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4export 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 SELECT643 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_type648 SELECT ${restoredPayloadJson} AS payload_json, event_type
646 FROM orchestration_events649 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 SELECT669 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_water672 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";
19import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";19import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
20import * as ServerSecretStore from "../auth/ServerSecretStore.ts";20import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
21import * as SharedAccounts from "./SharedAccounts.ts";21import * as SharedAccounts from "./SharedAccounts.ts";
22import * as SharedServer from "../environment/SharedServer.ts";
2223
23const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString });24const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString });
24const CachedAccess = Schema.Struct({ revision: Schema.Int, credentials: SharedAccountAccess });25const 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});
3031
31const decodeConnection = Schema.decodeEffect(Schema.fromJsonString(Connection));
32const encodeConnection = Schema.encodeEffect(Schema.fromJsonString(Connection));
33const decodeAccess = Schema.decodeEffect(Schema.fromJsonString(CachedAccess));32const decodeAccess = Schema.decodeEffect(Schema.fromJsonString(CachedAccess));
34const encodeAccess = Schema.encodeEffect(Schema.fromJsonString(CachedAccess));33const encodeAccess = Schema.encodeEffect(Schema.fromJsonString(CachedAccess));
35const decodeCommit = Schema.decodeEffect(Schema.fromJsonString(SharedAccountCommit));34const 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>();
6261
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 );
7364
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 });
137117
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: secrets292 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});
321295
322export const layer = Layer.effect(SharedAccountClient, make);296export 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 @@
1import * as TranscriptArchive from "./archive/TranscriptArchive.ts";
2import * as Hub from "./environment/Hub.ts";
3import * as HubProxy from "./environment/HubProxy.ts";
4import * as HubHttp from "./environment/HubHttp.ts";
5import * as HubInbox from "./environment/HubInbox.ts";
6import * as HubInboxHttp from "./environment/HubInboxHttp.ts";
7import * as ArchiveRuntime from "./archive/runtimeLayer.ts";
8import * as ArchiveHttp from "./archive/ArchiveHttp.ts";
9import * as ArchiveAssets from "./archive/ArchiveAssets.ts";
10import * as HostedArchiveConnection from "./archive/HostedArchiveConnection.ts";
11import * as HostedAuth from "./auth/HostedAuth.ts";
12import * as HostedAuthConfig from "./auth/HostedAuthConfig.ts";
13import * as HostedAuthHttp from "./auth/HostedAuthHttp.ts";
14import * as OutboundHub from "./environment/OutboundHub.ts";
15import * as OutboundSource from "./environment/OutboundSource.ts";
16import * as SourceConnection from "./environment/SourceConnection.ts";
17import * as SourceConnectionHttp from "./environment/SourceConnectionHttp.ts";
18import * as SharedServer from "./environment/SharedServer.ts";
1import * as SharedAccounts from "./provider/SharedAccounts.ts";19import * as SharedAccounts from "./provider/SharedAccounts.ts";
2import * as SharedAccountClient from "./provider/SharedAccountClient.ts";20import * as SharedAccountClient from "./provider/SharedAccountClient.ts";
3import * as ManagedProviderHome from "./provider/ManagedProviderHome.ts";21import * as ManagedProviderHome from "./provider/ManagedProviderHome.ts";
...@@ -450,6 +468,7 @@ const layerProjectFaviconResolver = ProjectFaviconResolver.layer.pipe(...@@ -450,6 +468,7 @@ const layerProjectFaviconResolver = ProjectFaviconResolver.layer.pipe(
450const layerServerEnvironment = ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer));468const layerServerEnvironment = ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer));
451469
452const layerAuth = EnvironmentAuth.layer.pipe(470const 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);
554573
555const layerRuntimeCoreDependenciesBase = Layer.mergeAll(574const 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);
649681
650const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(682const 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 old688 Layer.provideMerge(OutboundHub.layer),
674 // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but689 Layer.provideMerge(HostedAuth.layer.pipe(Layer.provide(layerPersistence))),
675 // the rewritten registry reads snapshots off the instance registry and690 Layer.provideMerge(HostedAuthConfig.layer),
676 // no longer transitively provides it. Exposing it at the runtime level691 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);
707763
708const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe(764const 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 );
11331198
1134 return layerServerApplication.pipe(1199 return layerServerApplication.pipe(
apps/server/src/storageCleanup.test.ts+67-21
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import * as ConfigProvider from "effect/ConfigProvider";
4import * as Deferred from "effect/Deferred";5import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";6import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";7import * 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);
465493
466describe("storage cleanup reports and local file policies", () => {494describe("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 {
18import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";18import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
19import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";19import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
20import * as Context from "effect/Context";20import * as Context from "effect/Context";
21import * as Config from "effect/Config";
21import * as Deferred from "effect/Deferred";22import * as Deferred from "effect/Deferred";
22import * as Layer from "effect/Layer";23import * as Layer from "effect/Layer";
23import * as Clock from "effect/Clock";24import * 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 per583 // 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";
22
3import { BrowserProfileReporter } from "./browser/BrowserProfileReporter";3import { BrowserProfileReporter } from "./browser/BrowserProfileReporter";
4import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";4import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
5import { CloudAuthentication } from "./cloud/CloudAuthentication";
5import { QuitHoldOverlay } from "./components/QuitHoldOverlay";6import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
6import { AppAtomRegistryProvider } from "./rpc/atomRegistry";7import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
7import type { AppRouter } from "./router";8import 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
2import { act, useEffect, type ReactNode } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6const mocks = vi.hoisted(() => ({ loads: 0, mounts: 0 }));
7vi.mock("../env", () => ({ isElectron: false }));
8vi.mock("./publicConfig", () => ({
9 hasCloudPublicConfig: () => true,
10 resolveCloudPublicConfig: () => ({ clerkPublishableKey: "test-key" }),
11}));
12vi.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
24let root: Root;
25let container: HTMLDivElement;
26
27beforeEach(() => {
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});
36afterEach(async () => {
37 await act(async () => root.unmount());
38 container.remove();
39});
40
41it("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
72it("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
83it("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 @@
1import { lazy, Suspense, type ReactNode } from "react";
2
3import { isElectron } from "../env";
4import { useCloudAuthentication } from "./activation";
5import { hasCloudPublicConfig, resolveCloudPublicConfig } from "./publicConfig";
6
7const 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. */
14export 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 @@
1import { useAuth, useClerk } from "@clerk/react";
2import { useEffect } from "react";
3
4import { isElectron } from "../env";
5import { useEnvironments } from "../state/environments";
6import { resolveClerkSignInProps } from "../components/clerk/authRedirect";
7import {
8 activateCloudAuthentication,
9 finishCloudSignInRequest,
10 useCloudAuthentication,
11} from "./activation";
12import { CloudAuthBoundary } from "./CloudAuthBoundary";
13import { ManagedRelayAuthProvider } from "./managedAuth";
14
15export 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
30function 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 @@
1import { useSyncExternalStore } from "react";
2
3import { hasCloudPublicConfig } from "./publicConfig";
4
5const STORAGE_KEY = "knife:t3-connect-enabled";
6const listeners = new Set<() => void>();
7
8function restoredOptIn(): boolean {
9 try {
10 return globalThis.localStorage?.getItem(STORAGE_KEY) === "true";
11 } catch {
12 return false;
13 }
14}
15
16let state = { enabled: restoredOptIn(), signInRequested: false };
17
18export 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
31export 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
41export function finishCloudSignInRequest(): void {
42 if (!state.signInRequested) return;
43 state = { ...state, signInRequested: false };
44 for (const listener of listeners) listener();
45}
46
47export 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";
12import { runtime } from "../lib/runtime";12import { runtime } from "../lib/runtime";
13import { appAtomRegistry } from "../rpc/atomRegistry";13import { appAtomRegistry } from "../rpc/atomRegistry";
14import { useAtomCommand } from "../state/use-atom-command";14import { useAtomCommand } from "../state/use-atom-command";
15import { deactivateCloudAuthentication } from "./activation";
15import { resolveRelayClerkTokenOptions } from "./publicConfig";16import { resolveRelayClerkTokenOptions } from "./publicConfig";
1617
17export function deactivateManagedRelayAuthentication(): void {18export 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
7172
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
413import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";413import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
414import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";414import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
415import { useEnvironmentQuery } from "../state/query";415import { useEnvironmentQuery } from "../state/query";
416import { useEnvironmentScope } from "~/state/session";416import { useEnvironmentScope, useThreadOrganizationPermission } from "~/state/session";
417import {417import {
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
583import { appAtomRegistry } from "../rpc/atomRegistry";584import { appAtomRegistry } from "../rpc/atomRegistry";
584import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";585import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";
585import { assetEnvironment } from "../state/assets";586import { assetEnvironment } from "../state/assets";
586import { readEnvironmentScope, readPreparedConnection } from "../state/session";587import {
588 readEnvironmentScope,
589 readPreparedConnection,
590 readThreadOrganizationPermission,
591} from "../state/session";
587import { useAtomCommand } from "../state/use-atom-command";592import { useAtomCommand } from "../state/use-atom-command";
588import { useOrchestrationCommand } from "../state/use-orchestration-command";593import { useOrchestrationCommand } from "../state/use-orchestration-command";
589import { useAtomQueryRunner } from "../state/use-atom-query-runner";594import { 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 thread7284 // 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 }
81508153
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 }
81768179
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";
167import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";167import {
168 environmentCapabilitiesAtom,
169 environmentServerConfigsAtom,
170 primaryServerKeybindingsAtom,
171} from "../state/server";
168import { environmentProjects } from "../state/projects";172import { environmentProjects } from "../state/projects";
169import { vcsEnvironment } from "../state/vcs";173import { vcsEnvironment } from "../state/vcs";
170import { threadEnvironment } from "../state/threads";174import { threadEnvironment } from "../state/threads";
171import { useEnvironmentQuery } from "../state/query";175import { useEnvironmentQuery } from "../state/query";
172import { useThreadSearch } from "../state/queries";176import { useThreadSearch } from "../state/queries";
173import { useOrchestrationCommand } from "../state/use-orchestration-command";177import { useOrchestrationCommand } from "../state/use-orchestration-command";
174import { readEnvironmentScope, useEnvironmentScope } from "../state/session";178import {
179 readEnvironmentScope,
180 useEnvironmentScope,
181 readThreadOrganizationPermission,
182 useThreadOrganizationPermission,
183} from "../state/session";
175import {184import {
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";
192import {202import {
193 animateSidebarLayoutChanges,203 animateSidebarLayoutChanges,
...@@ -315,16 +325,20 @@ const inboxReturns = createInboxReturnTracker();...@@ -315,16 +325,20 @@ const inboxReturns = createInboxReturnTracker();
315325
316function canOperateThreads(326function 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}
323336
324function checkThreadOperations(337function 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 would682 // 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 never1564 // 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 button1572 // If the thread becomes blocked while the popover is open, the button
1558 // unmounts without firing onOpenChange(false). Deriving the flag keeps a1573 // unmounts without firing onOpenChange(false). Deriving the flag keeps a
1559 // stale true from permanently hiding the status label / pinning the1574 // 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 <TooltipTrigger1777 <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 user2854 // or descriptor not loaded yet) never classify as settled: the user
2839 // could neither un-settle nor pin them, so auto-settling them would2855 // 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.capabilities4071 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.capabilities4328 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 ...(unpinMenuItem4352 ...(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 ...(canSnoozeSelection4360 ...(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 without4651 // 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.capabilities4660 environmentCapabilities.get(thread.environmentId)?.threadAutoSettleOptOut === true;
4640 .threadAutoSettleOptOut === true;
4641 const supportsTitleRegeneration =4661 const supportsTitleRegeneration =
4642 serverConfigs.get(thread.environmentId)?.environment.capabilities4662 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.capabilities5356 environmentCapabilities.get(thread.environmentId)
5333 .threadSettlement === true5357 ?.threadSettlement === true
5334 }5358 }
5335 snoozeSupported={5359 snoozeSupported={
5336 serverConfigs.get(thread.environmentId)?.environment.capabilities5360 environmentCapabilities.get(thread.environmentId)?.threadSnooze ===
5337 .threadSnooze === true5361 true
5338 }5362 }
5339 pinningSupported={5363 pinningSupported={
5340 serverConfigs.get(thread.environmentId)?.environment.capabilities5364 environmentCapabilities.get(thread.environmentId)?.threadPinning ===
5341 .threadPinning === true5365 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 @@
1import { beginHostedSignIn } from "../../connection/hostedAuth";
1import { Alert, AlertDescription } from "../ui/alert";2import { Alert, AlertDescription } from "../ui/alert";
2import type { AuthSessionState } from "@t3tools/contracts";3import type { AuthSessionState } from "@t3tools/contracts";
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";4import { 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}
3031
31export function PairingRouteSurface({32export 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
88function 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 @@
1import { ClerkProvider } from "@clerk/react";1import { ClerkProvider } from "@clerk/react";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
33
4import { ManagedRelayAuthProvider } from "../../cloud/managedAuth";
5import { clerkAppearance } from "./clerkAppearance";4import { clerkAppearance } from "./clerkAppearance";
65
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 */
13export default function BrowserManagedAuthShell({6export 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";
2import { ClerkProvider } from "@clerk/electron/react";2import { ClerkProvider } from "@clerk/electron/react";
3import type { ReactNode } from "react";3import type { ReactNode } from "react";
44
5import { ManagedRelayAuthProvider } from "../../cloud/managedAuth";
6import { clerkAppearance } from "./clerkAppearance";5import { clerkAppearance } from "./clerkAppearance";
76
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 */
14export default function ElectronManagedAuthShell({8export 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 @@
1import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary";
2import { activateCloudAuthentication } from "../../cloud/activation";
1import { UserButton, useAuth } from "@clerk/react";3import { UserButton, useAuth } from "@clerk/react";
2import { LogInIcon } from "lucide-react";4import { LogInIcon } from "lucide-react";
35
...@@ -9,13 +11,32 @@ import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";...@@ -9,13 +11,32 @@ import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";
9export function T3ConnectSidebarSignIn() {11export function T3ConnectSidebarSignIn() {
10 if (!hasCloudPublicConfig()) return null;12 if (!hasCloudPublicConfig()) return null;
1113
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}
1431
15export function T3ConnectSidebarAvatar() {32export function T3ConnectSidebarAvatar() {
16 if (!hasCloudPublicConfig()) return null;33 if (!hasCloudPublicConfig()) return null;
1734
18 return <ConfiguredT3ConnectSidebarAvatar />;35 return (
36 <CloudAuthBoundary>
37 <ConfiguredT3ConnectSidebarAvatar />
38 </CloudAuthBoundary>
39 );
19}40}
2041
21function ConfiguredT3ConnectSidebarAvatar() {42function ConfiguredT3ConnectSidebarAvatar() {
apps/web/src/components/cloud/ConnectCliAuthSurface.tsx+18-1
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary";
2import { activateCloudAuthentication } from "../../cloud/activation";
1import { useAuth, useClerk } from "@clerk/react";3import { useAuth, useClerk } from "@clerk/react";
2import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";4import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
3import { useCallback, useEffect, useRef, useState } from "react";5import { 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 waiting46 * 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 */
47export function ConnectCliAuthorizeSurface() {49function 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
117export 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 @@
1import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary";
1import { useAuth } from "@clerk/react";2import { useAuth } from "@clerk/react";
2import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
3import {4import { AuthRelayReadScope, AuthRelayWriteScope, type AuthSessionState } from "@t3tools/contracts";
4 AuthAdministrativeScopes,
5 AuthRelayReadScope,
6 AuthRelayWriteScope,
7 type AuthSessionState,
8} from "@t3tools/contracts";
9import { AsyncResult, Atom } from "effect/reactivity";5import { AsyncResult, Atom } from "effect/reactivity";
10import { useEffect, useRef, useState } from "react";6import { useEffect, useRef, useState } from "react";
117
...@@ -43,7 +39,11 @@ import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } fro...@@ -43,7 +39,11 @@ import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } fro
43export function ConnectOnboardingDialog() {39export function ConnectOnboardingDialog() {
44 if (!hasCloudPublicConfig()) return null;40 if (!hasCloudPublicConfig()) return null;
4541
46 return <ConfiguredConnectOnboardingDialog />;42 return (
43 <CloudAuthBoundary>
44 <ConfiguredConnectOnboardingDialog />
45 </CloudAuthBoundary>
46 );
47}47}
4848
49type OnboardingStep = "publish" | "devices";49type 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}));
3838
39vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null }));
39vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined }));40vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
40vi.mock("@t3tools/client-runtime/state/session", () => ({41vi.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}));
5151
52vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null }));
52vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined }));53vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
53vi.mock("@t3tools/client-runtime/state/session", () => ({54vi.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}));
15vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null }));
15vi.mock("../../state/session", () => ({16vi.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 @@
1import { RemoteConnectionFlow } from "../settings/SelfHostedServer";
2import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary";
1import { useAuth } from "@clerk/react";3import { useAuth } from "@clerk/react";
2import { useAtomValue } from "@effect/atom-react";4import { useAtomValue } from "@effect/atom-react";
3import type {5import 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 <ConnectionStep260 <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({
297297
298function ConnectionStep({298function 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 = environments321 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 <ConnectAccountOption384 <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 <CollapsibleTrigger406 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 <ChevronRightIcon413 <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 <PairingForm419 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 <Button445 <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 @@
1import { RemoteConnectionFlow, SelfHostedServerSettings } from "./SelfHostedServer";
2import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary";
3import { activateCloudAuthentication } from "../../cloud/activation";
1import { SessionPermissions } from "./SessionPermissions";4import { SessionPermissions } from "./SessionPermissions";
2import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";5import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
3import {6import {
...@@ -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}
20082023
2009function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) {2024function 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}
20442055
2045export function ConnectionsSettings() {2056export 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 <SettingsSection4091 <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 {routeTarget4132 {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 {desktopBridge4146 {!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 <RemoveT3ConnectEnvironmentDialog4199 <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 @@
1import { StrictMode, act } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
4
5const state = vi.hoisted(() => ({
6 connect: vi.fn(),
7 connection: null,
8}));
9vi.mock("@effect/atom-react", () => ({ useAtomValue: () => ({ connection: state.connection }) }));
10vi.mock("../../connection/hub", () => ({
11 hubConnection: { stateValueAtom: {}, connectAuthenticated: state.connect },
12}));
13vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.connect }));
14vi.mock("../../cloud/activation", () => ({ activateCloudAuthentication: vi.fn() }));
15vi.mock("../../cloud/publicConfig", () => ({
16 hasCloudPublicConfig: () => false,
17 resolveCloudPublicConfig: () => ({}),
18}));
19vi.mock("../../environments/primary/desktopAuth", () => ({
20 readDesktopPrimaryBearerToken: vi.fn(),
21}));
22vi.mock("../../environments/primary/target", () => ({ resolvePrimaryEnvironmentHttpUrl: vi.fn() }));
23
24vi.mock("./settingsLayout", () => ({ SettingsRow: () => null, SettingsSection: () => null }));
25
26import { HostedServerAutoConnect } from "./SelfHostedServer";
27
28let renderer: ReactTestRenderer | undefined;
29const server = "https://knife.example.test";
30
31beforeEach(() => {
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
37afterEach(async () => {
38 await act(() => renderer?.unmount());
39 renderer = undefined;
40 vi.unstubAllGlobals();
41});
42
43it("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
66it("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 @@
1import { normalizeHostedServer } from "@t3tools/shared/hostedServerUrl";
2import { formatBytes } from "../../lib/formatBytes";
3import { ArchiveStatus, SelfHostedConnectionStatus } from "@t3tools/contracts";
4import * as Schema from "effect/Schema";
5import { readDesktopPrimaryBearerToken } from "../../environments/primary/desktopAuth";
6import { resolvePrimaryEnvironmentHttpUrl } from "../../environments/primary/target";
7import type { HostedAuthDevice } from "@t3tools/contracts";
8import {
9 beginHostedSignIn,
10 hostedDevices,
11 hostedAuthHeaders,
12 hostedAccountRequest,
13 forgetHostedWebSession,
14} from "../../connection/hostedAuth";
15import { useAtomValue } from "@effect/atom-react";
16import {
17 isAtomCommandInterrupted,
18 squashAtomCommandFailure,
19} from "@t3tools/client-runtime/state/runtime";
20import { CloudIcon, LinkIcon, ServerIcon } from "lucide-react";
21import { useEffect, useEffectEvent, useId, useRef, useState, type ReactNode } from "react";
22
23import { activateCloudAuthentication } from "../../cloud/activation";
24import { hasCloudPublicConfig } from "../../cloud/publicConfig";
25import { hubConnection } from "../../connection/hub";
26import { useAtomCommand } from "../../state/use-atom-command";
27import { Button } from "../ui/button";
28import { Input } from "../ui/input";
29import { Radio, RadioGroup } from "../ui/radio-group";
30import { SettingsRow, SettingsSection } from "./settingsLayout";
31
32export 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
135function 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
259export 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
305const decodeSyncStatus = Schema.decodeUnknownSync(SelfHostedConnectionStatus);
306const decodeArchiveStatus = Schema.decodeUnknownSync(Schema.toCodecJson(ArchiveStatus));
307
308const 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
324function 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
417export 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 });
1616
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");
1925
apps/web/src/components/settings/pairingUrls.ts+2-2
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import { environmentEndpointUrl } from "@t3tools/shared/advertisedEndpoint";
1import { buildHostedPairingUrl } from "../../hostedPairing";2import { buildHostedPairingUrl } from "../../hostedPairing";
2import { setPairingTokenOnUrl } from "../../pairingUrl";3import { setPairingTokenOnUrl } from "../../pairingUrl";
34
4export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string {5export 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}
99
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 {
88
9const baseState: ThreadActionMenuState = {9const 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 );
8183
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
255it("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: {
7171
72export interface ThreadActionMenuState {72export 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 scoped77 * 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}
116117
118export 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-click127 * 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.canOperate261 return items.map((item) =>
253 ? items262 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.children268 ? { 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 @@
1import { normalizeHostedServer } from "@t3tools/shared/hostedServerUrl";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3import { forgetHostedWebSession, hostedAuthHeaders } from "./hostedAuth";
4
5afterEach(() => {
6 forgetHostedWebSession();
7 vi.unstubAllGlobals();
8 vi.useRealTimers();
9});
10
11const server = "https://knife.example.test";
12const account = (csrfToken: string, expiresAt: string) => ({
13 accountId: "owner",
14 deviceId: "browser",
15 csrfToken,
16 expiresAt,
17});
18
19describe("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 @@
1import { HostedAuthAccount, HostedAuthDevice } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3import * as Schema from "effect/Schema";
4
5const decodeAccount = Schema.decodeUnknownSync(Schema.toCodecJson(HostedAuthAccount));
6const decodeDevices = Schema.decodeUnknownSync(Schema.toCodecJson(Schema.Array(HostedAuthDevice)));
7
8let account: { server: string; value: HostedAuthAccount } | null = null;
9let browserRequest: { server: string; value: Promise<HostedAuthAccount> } | null = null;
10
11export 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
77export 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
94export async function hostedDevices(server: string) {
95 const response = await hostedAccountRequest(server, "/api/auth/devices");
96 return decodeDevices(await response.json());
97}
98
99export 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
109export function forgetHostedWebSession() {
110 account = null;
111 browserRequest = null;
112}
apps/web/src/connection/hub.ts created+5
...@@ -0,0 +1,5 @@
1import { createHubConnectionAtoms } from "@t3tools/client-runtime/state/hub";
2
3import { connectionAtomRuntime } from "./runtime";
4
5export 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";
40import * as Stream from "effect/Stream";40import * as Stream from "effect/Stream";
41import { FetchHttpClient } from "effect/http";41import { FetchHttpClient } from "effect/http";
4242
43import { hostedAuthHeaders } from "./hostedAuth";
43import { APP_VERSION } from "../branding";44import { APP_VERSION } from "../branding";
44import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";45import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";
45import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";46import * 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}
7172
73const 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
72const layerConnectivity = Connectivity.layer({85const 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(
745const layerEnvironmentOwnedDataCleanup = Layer.succeed(758const 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(
770type ConnectionPlatformLayerSource =783type ConnectionPlatformLayerSource =
771 | typeof ConnectionStorage.layer784 | typeof ConnectionStorage.layer
772 | typeof layerConnectivity785 | typeof layerConnectivity
786 | typeof layerHostedAuthAccess
773 | typeof layerWakeups787 | typeof layerWakeups
774 | typeof layerCapabilities788 | typeof layerCapabilities
775 | typeof layerPlatformConnectionSource789 | 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 @@
1import {1import {
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});
3238
33describe("ConnectionStorage.makeCatalogStore", () => {39describe("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 });
129129
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 });
157172
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");
160185
apps/web/src/environments/primary/target.ts+19-9
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";1import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";
2import {
3 deriveWsBaseUrl,
4 environmentEndpointUrl,
5 normalizeHttpBaseUrl,
6} from "@t3tools/shared/advertisedEndpoint";
2import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
38
4import { isLocalEnvironmentDisabled } from "../../localEnvironment";9import { 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"));
217227
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}
305315
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";
19import {19import {
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";
24import { stackedThreadToast, toastManager } from "../components/ui/toast";25import { stackedThreadToast, toastManager } from "../components/ui/toast";
25import { threadEnvironment } from "../state/threads";26import { threadEnvironment } from "../state/threads";
26import { useOrchestrationCommand } from "../state/use-orchestration-command";27import { useOrchestrationCommand } from "../state/use-orchestration-command";
27import { readEnvironmentScope } from "../state/session";28import { readEnvironmentScope, readThreadOrganizationPermission } from "../state/session";
28import {29import {
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";
66
7import { prepareProviderAuthDelivery } from "./providerAuthDelivery";7import { prepareProviderAuthDelivery } from "./providerAuthDelivery";
8import { isElectron } from "./env";8import { isElectron } from "./env";
9import { hasCloudPublicConfig } from "./cloud/publicConfig";
10import { getRouter } from "./router";9import { getRouter } from "./router";
11import {10import {
12 syncDocumentElectronPlatformClasses,11 syncDocumentElectronPlatformClasses,
...@@ -27,8 +26,6 @@ if (isElectron) {...@@ -27,8 +26,6 @@ if (isElectron) {
27 syncDocumentWindowControlsOverlayClass();26 syncDocumentWindowControlsOverlayClass();
28}27}
2928
30const 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 under29// 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.
34let chunkLoadFailed = false;31let chunkLoadFailed = false;
...@@ -43,41 +40,13 @@ window.addEventListener("vite:preloadError", (event) => {...@@ -43,41 +40,13 @@ window.addEventListener("vite:preloadError", (event) => {
4340
44const app = <AppRoot router={router} />;41const app = <AppRoot router={router} />;
4542
46// Managed auth is cloud-only, and the Electron Clerk provider bundles the full43export const startup = router
47// clerk-js runtime. Loading only the selected runtime as a split chunk keeps44 .load()
48// every Clerk byte out of the startup graph for local-mode users, and keeps45 .then(() => {
49// the bundled clerk-js out of the browser build entirely.
50const 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.
62export 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 @@
1import { HostedServerAutoConnect } from "../components/settings/SelfHostedServer";
1import { usePersonalUiSettingsSync } from "../state/uiSettingsSync";2import { usePersonalUiSettingsSync } from "../state/uiSettingsSync";
2import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";3import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";
3import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";4import { 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 <FirstRunGate240 <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";
22import { useEnvironments } from "../state/environments";22import { useEnvironments } from "../state/environments";
23import { APP_DISPLAY_NAME } from "~/branding";23import { APP_DISPLAY_NAME } from "~/branding";
24import { hasCloudPublicConfig } from "~/cloud/publicConfig";
2524
26function ChatIndexRouteView() {25function 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});
134133
135function HostedStaticOnboardingState() {134function HostedStaticOnboardingState() {
136 const cloudEnabled = hasCloudPublicConfig();
137 const localEnvironmentOff = isLocalEnvironmentDisabled();135 const localEnvironmentOff = isLocalEnvironmentDisabled();
138 const description = localEnvironmentOff136 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 : cloudEnabled138 : "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.";
143139
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
14import { Atom } from "effect/reactivity";14import { Atom } from "effect/reactivity";
15import { appAtomRegistry } from "../rpc/atomRegistry";15import { appAtomRegistry } from "../rpc/atomRegistry";
16import { environmentProjects } from "./projects";16import { environmentProjects } from "./projects";
17import { environmentServerConfigsAtom } from "./server";17import { environmentCapabilitiesAtom, environmentServerConfigsAtom } from "./server";
18import {18import {
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
240240
241export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {241export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {
242 return (242 return (
243 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities243 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadTitleRegeneration ===
244 .threadTitleRegeneration === true244 true
245 );245 );
246}246}
247247
...@@ -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. */
250export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean {250export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean {
251 return (251 return (
252 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities252 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinning === true
253 .threadPinning === true
254 );253 );
255}254}
256255
...@@ -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. */
259export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean {258export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean {
260 return (259 return (
261 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities260 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinReorder === true
262 .threadPinReorder === true
263 );261 );
264}262}
265263
...@@ -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. */
268export function readEnvironmentSupportsAutoSettleOptOut(environmentId: EnvironmentId): boolean {266export function readEnvironmentSupportsAutoSettleOptOut(environmentId: EnvironmentId): boolean {
269 return (267 return (
270 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities268 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadAutoSettleOptOut ===
271 .threadAutoSettleOptOut === true269 true
272 );270 );
273}271}
274272
275export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {273export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {
276 return (274 return (
277 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities275 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadActiveReorder ===
278 .threadActiveReorder === true276 true
279 );277 );
280}278}
281279
...@@ -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. */
285export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean {283export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean {
286 return (284 return (
287 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities285 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSettlement === true
288 .threadSettlement === true
289 );286 );
290}287}
291288
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. */
294export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boolean {291export 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}
300294
301/** Whether the environment's server understands thread.visit/mark-unread and295/** 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. */
305export function readEnvironmentSupportsVisitedTracking(environmentId: EnvironmentId): boolean {299export function readEnvironmentSupportsVisitedTracking(environmentId: EnvironmentId): boolean {
306 return (300 return (
307 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities301 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadVisitedTracking ===
308 .threadVisitedTracking === true302 true
309 );303 );
310}304}
311305
apps/web/src/state/server.ts+6
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { createHubEnvironmentCapabilitiesAtom } from "@t3tools/client-runtime/state/hub";
2import { hubConnection } from "../connection/hub";
1import {3import {
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});
44export const environmentCapabilitiesAtom = createHubEnvironmentCapabilitiesAtom(
45 environmentServerConfigsAtom,
46 hubConnection,
47);
4248
43interface PrimaryServerState {49interface 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 @@
1import { hubConnection } from "../connection/hub";
1import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";3import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import {4import {
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
107export 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
113export 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";
1010
11const state = vi.hoisted(() => ({11const 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}));
1617
17vi.mock("react", () => ({ useCallback: (callback: unknown) => callback }));18vi.mock("react", () => ({ useCallback: (callback: unknown) => callback }));
18vi.mock("./session", () => ({ readEnvironmentScope: state.readScope }));19vi.mock("./session", () => ({
20 readEnvironmentScope: state.readScope,
21 readThreadOrganizationPermission: (id: string) =>
22 state.organization.has(id) || state.allowed.has(id),
23}));
19vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run }));24vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run }));
2025
21import { useOrchestrationCommand } from "./use-orchestration-command";26import { useOrchestrationCommand } from "./use-orchestration-command";
...@@ -31,6 +36,7 @@ const target = (environmentId: EnvironmentId): Target => ({...@@ -31,6 +36,7 @@ const target = (environmentId: EnvironmentId): Target => ({
3136
32beforeEach(() => {37beforeEach(() => {
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.readScope41 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
93it("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";
12import { AsyncResult } from "effect/reactivity";12import { AsyncResult } from "effect/reactivity";
13import { useCallback } from "react";13import { useCallback } from "react";
1414
15import { readEnvironmentScope } from "./session";15import { readEnvironmentScope, readThreadOrganizationPermission } from "./session";
16import { useAtomCommand } from "./use-atom-command";16import { useAtomCommand } from "./use-atom-command";
1717
18export const ORCHESTRATION_OPERATE_DENIED_MESSAGE =18export 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. */
22export function useOrchestrationCommand<W extends { readonly environmentId: EnvironmentId }, A, E>(22export 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 @@
1services:
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
369baseline; a checksum or dependency mismatch stops startup. Rebuild the base image369baseline; a checksum or dependency mismatch stops startup. Rebuild the base image
370when the server's locked native dependency closure or container bootstrap changes.370when the server's locked native dependency closure or container bootstrap changes.
371371
372Enroll execution hosts with the private authority using the existing372Use hosted sign-in below for desktop enrollment and archive uploads. Optional
373[`t3 shared` commands](../user/shared-server.md). Each machine gets its own373[shared provider accounts](../user/shared-server.md#share-agent-accounts) use separate
374revocable session credential. The authority owns Codex token refreshes; Claude374grants; signing into the server does not export local provider credentials.
375accounts need setup tokens. Connected hosts synchronize accounts automatically.375
376### Hosted sign-in
377
378Register 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
390The issuer callback is HTTPS. `t3code://app/self-hosted/callback` is a separate,
391proof-bound return to the desktop; do not register it with the issuer. There is no
392public native client or dynamic client registration.
393
394Set `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
408Use the exact issuer string from discovery and its immutable subject for the
409intended owner. `publicUrl` is the HTTPS origin without a trailing slash. Email,
410display name, and the first person to visit never establish ownership. Missing
411owner configuration prevents SSO from starting. Without `T3CODE_SSO_CONFIG`,
412existing pairing remains available.
413
414Keep this file outside the checkout, restrict it to its owner (`chmod 600`), and
415mount 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
417configuration. The same deployment needs persistent private server state and an
418HTTPS proxy that forwards WebSocket upgrades.
419
420For 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
422pre-existing `.knife-archive-store-id` file. The server never creates this marker;
423missing or mismatched storage identity prevents catalog publication and local
424payload eviction. Use the optional overlay:
425
426```sh
427docker compose -f compose.yaml -f compose.sso.yaml up -d --no-build
428```
429
430The overlay mounts the private configuration read-only and refuses to create a
431missing host file. Existing deployments can keep using the base Compose file.
432
433Access credentials last fifteen minutes. Native devices renew with rotating
434credentials kept in the operating system’s protected storage. By default, devices
435must complete browser sign-in again after thirty days. The server revalidates the
436issuer grant during renewal at least once per hour; issuer-side revocation is
437noticed on that check, and previously issued access can last another fifteen
438minutes. Local device revocation closes active connections and disables renewal.
439
440If the issuer is unavailable or a device is lost, run this on the server using its
441existing local state directory:
442
443```sh
444t3 auth devices revoke-all
445```
446
447This local recovery command revokes SSO devices and sessions without contacting
448the issuer. Existing pairing credentials remain valid. It does not change the
449configured owner or revoke identity-provider accounts.
docs/user/shared-server.md+20-10
...@@ -1,4 +1,16 @@...@@ -1,4 +1,16 @@
1# Shared accounts1# Self-hosted server
2
3A 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
7On 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
9Open 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
11Device 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
214
3A 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.15A 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.
416
...@@ -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
2537
26## Run in a container38## Run in a container
2739
28Build 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:40The 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`.
2941
30```sh42## Offload archived chats
31docker run --name the-knife -p 127.0.0.1:3773:3773 \43
32 -e KNIFE_UPDATE_URL=https://files.example.com/public-builds/ \44The 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 \46Archived 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```
3747
38The 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`.48Uploads 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";
20import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";21import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
21import * as RpcHttp from "../rpc/http.ts";22import * as RpcHttp from "../rpc/http.ts";
...@@ -90,6 +91,62 @@ const expectFetchCall = (...@@ -90,6 +91,62 @@ const expectFetchCall = (
90};91};
9192
92describe("remote environment authorization", () => {93describe("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";
9import { encodeOAuthScope } from "@t3tools/shared/oauthScope";9import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import { environmentEndpointUrl } from "../environment/endpoint.ts";11import { environmentEndpointUrl, environmentWebSocketUrl } from "../environment/endpoint.ts";
12import {12import {
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 });
216216
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 T332 * "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 Tailscale37 * "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) {}
8484
85export const ConnectionCredential = Schema.Union([BearerConnectionCredential]);85export class HubConnectionCredential extends Schema.TaggedClass<HubConnectionCredential>()(
86 "HubConnectionCredential",
87 {
88 connectionId: Schema.String,
89 },
90) {}
91
92export class HostedConnectionCredential extends Schema.TaggedClass<HostedConnectionCredential>()(
93 "HostedConnectionCredential",
94 { serverUrl: Schema.String },
95) {}
96
97export const ConnectionCredential = Schema.Union([
98 BearerConnectionCredential,
99 HubConnectionCredential,
100 HostedConnectionCredential,
101]);
86export type ConnectionCredential = typeof ConnectionCredential.Type;102export type ConnectionCredential = typeof ConnectionCredential.Type;
87103
88export class PrimaryConnectionRegistration extends Schema.TaggedClass<PrimaryConnectionRegistration>()(104export 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) {}
110126
packages/client-runtime/src/connection/hub.test.ts created+575
...@@ -0,0 +1,575 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 AuthStandardClientScopes,
4 CommandId,
5 EnvironmentId,
6 HubInbox,
7 HubInboxCommand,
8 HubInboxEnvironment,
9 ThreadId,
10 ORCHESTRATION_PROTOCOL_VERSION,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as DateTime from "effect/DateTime";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
17import * as Stream from "effect/Stream";
18import * as SubscriptionRef from "effect/SubscriptionRef";
19
20import * as Hub from "./hub.ts";
21import * as Registry from "./registry.ts";
22import * as Driver from "./driver.ts";
23import * as Connectivity from "./connectivity.ts";
24import * as Wakeups from "./wakeups.ts";
25import * as Profiles from "./profileStore.ts";
26import * as Credentials from "./credentialStore.ts";
27import * as Capabilities from "../platform/capabilities.ts";
28import * as Persistence from "../platform/persistence.ts";
29import * as Http from "../rpc/http.ts";
30import {
31 BearerConnectionCredential,
32 HostedConnectionCredential,
33 BearerConnectionProfile,
34 BearerConnectionRegistration,
35 RelayConnectionRegistration,
36 type ConnectionCredential,
37 type ConnectionProfile,
38} from "./catalog.ts";
39import { BearerConnectionTarget, RelayConnectionTarget, ConnectionBlockedError } from "./model.ts";
40import { connectionRoutes, connectionRouteId } from "./routes.ts";
41import { v2Project, v2ThreadShell } from "../state/orchestrationV2TestFixtures.ts";
42
43const HUB_ID = EnvironmentId.make("hub");
44const BASE = "https://hub.example/knife/";
45const encodeInbox = Schema.encodeSync(Schema.toCodecJson(HubInbox));
46const encodeInboxEnvironment = Schema.encodeSync(Schema.toCodecJson(HubInboxEnvironment));
47const decodeInboxCommand = Schema.decodeUnknownSync(Schema.toCodecJson(HubInboxCommand));
48const 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
57function 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
258describe("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 @@
1import {
2 EnvironmentId,
3 HubDirectory,
4 HubInbox,
5 HubInboxCommand,
6 HubInboxEnvironment,
7} from "@t3tools/contracts";
8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10import * as Equal from "effect/Equal";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as Schema from "effect/Schema";
14import * as Semaphore from "effect/Semaphore";
15import * as SubscriptionRef from "effect/SubscriptionRef";
16import { HttpBody, HttpClient } from "effect/http";
17
18import {
19 BearerConnectionCredential,
20 BearerConnectionProfile,
21 BearerConnectionRegistration,
22 HubConnectionCredential,
23 HostedConnectionCredential,
24} from "./catalog.ts";
25import {
26 BearerConnectionTarget,
27 ConnectionBlockedError,
28 ConnectionTransientError,
29} from "./model.ts";
30import type { ConnectionAttemptError } from "./model.ts";
31import { preparePairingRegistration, type PairingConnectionInput } from "./onboarding.ts";
32import * as CredentialStore from "./credentialStore.ts";
33import * as ProfileStore from "./profileStore.ts";
34import * as Registry from "./registry.ts";
35import { connectionRouteId, connectionRoutes } from "./routes.ts";
36import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
37import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
38import { deriveWsBaseUrl, environmentEndpointUrl } from "../environment/endpoint.ts";
39import * as ClientCapabilities from "../platform/capabilities.ts";
40
41const HUB_PROFILE_ID = "self-hosted-server";
42const isConnectionAttemptError = Schema.is(
43 Schema.Union([ConnectionBlockedError, ConnectionTransientError]),
44);
45const encodeInboxCommand = Schema.encodeEffect(Schema.toCodecJson(HubInboxCommand));
46const decodeInbox = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInbox));
47const decodeInboxEnvironment = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInboxEnvironment));
48
49export interface HubServerConnection {
50 readonly environmentId: EnvironmentId;
51 readonly label: string;
52 readonly httpBaseUrl: string;
53 readonly authorization?: "t3-connect" | "hosted";
54}
55
56export 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
64export const EMPTY_HUB_CONNECTION_STATE: HubConnectionState = {
65 connection: null,
66 directory: null,
67 inbox: { environments: [] },
68 refreshing: false,
69 error: null,
70};
71
72export 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
92const hubCredentialId = (environmentId: EnvironmentId) => `hub-session:${environmentId}`;
93const hubRoutePrefix = (environmentId: EnvironmentId) => `hub:${environmentId}:`;
94
95export 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
580export const layer = Layer.effect(HubConnection, make);
packages/client-runtime/src/connection/index.ts+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export * from "./catalog.ts";1export * from "./catalog.ts";
2export { HubConnection } from "./hub.ts";
2export * as Connectivity from "./connectivity.ts";3export * as Connectivity from "./connectivity.ts";
3export * as CredentialStore from "./credentialStore.ts";4export * as CredentialStore from "./credentialStore.ts";
4export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts";5export { 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";
15import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";15import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
16import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";16import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
17import * as RpcSession from "../rpc/session.ts";17import * as RpcSession from "../rpc/session.ts";
18import * as Hub from "./hub.ts";
19import * as Wakeups from "./wakeups.ts";
1820
19export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscoveredCompatibility")(21export 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 );
273273
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)));
436465
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 */
100function bearerConnectionId(environmentId: EnvironmentId, httpBaseUrl: string): string {100function 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}
103104
104function differentMachineError(label: string) {105function 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 });
137137
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 @@
1import type { ServerConfig } from "@t3tools/contracts";1import type { ServerConfig } from "@t3tools/contracts";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
33
4import { environmentEndpointUrl } from "../environment/endpoint.ts";
4import type { ConnectionCatalogEntry } from "./catalog.ts";5import type { ConnectionCatalogEntry } from "./catalog.ts";
5import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts";6import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts";
6import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts";7import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts";
...@@ -122,16 +123,11 @@ export function environmentMcpUrl(input: {...@@ -122,16 +123,11 @@ export function environmentMcpUrl(input: {
122}123}
123124
124function mcpUrlFromBase(httpBaseUrl: string): string | null {125function 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}
136132
137export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null {133export 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 === undefined1376 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.ConnectionPersistenceError132 | 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 was999 // 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";
17import {17import {
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});
187188
188describe("ConnectionResolver", () => {189describe("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";
47import { credentialConnectionId } from "./routes.ts";47import { credentialConnectionId } from "./routes.ts";
48import { deriveWsBaseUrl, environmentWebSocketUrl } from "../environment/endpoint.ts";
48import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";49import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
4950
50export class ConnectionResolver extends Context.Service<51export class ConnectionResolver extends Context.Service<
...@@ -72,13 +73,10 @@ const isSshProfile = Schema.is(SshConnectionProfile);...@@ -72,13 +73,10 @@ const isSshProfile = Schema.is(SshConnectionProfile);
72const isBearerCredential = Schema.is(BearerConnectionCredential);73const isBearerCredential = Schema.is(BearerConnectionCredential);
7374
74function primarySocketUrl(75function 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
120const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () {118const 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;
123122
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 });
106106
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";
7import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts";
7import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";8import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
8import * as Option from "effect/Option";9import * as Option from "effect/Option";
910
...@@ -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;
263264
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";
911
...@@ -16,12 +18,33 @@ const coreProvider = {...@@ -16,12 +18,33 @@ const coreProvider = {
1618
17describe("advertised endpoint helpers", () => {19describe("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 });
2428
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 @@
1export * from "@t3tools/shared/advertisedEndpoint";1export * from "@t3tools/shared/advertisedEndpoint";
2
3export 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
32import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";33import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
33import * as Crypto from "effect/Crypto";34import * as Crypto from "effect/Crypto";
34import * as Effect from "effect/Effect";35import * as Effect from "effect/Effect";
36import * as Option from "effect/Option";
37import * as Schema from "effect/Schema";
3538
39import * as Hub from "../connection/hub.ts";
40import * as EnvironmentSupervisor from "../connection/supervisor.ts";
36import { getInitialServerConfig, request } from "../rpc/client.ts";41import { getInitialServerConfig, request } from "../rpc/client.ts";
3742
43const isHubOrganizationCommand = Schema.is(HubOrganizationCommand);
44
38interface CommandMetadata {45interface 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});
272279
273const dispatch = (command: OrchestrationV2Command) =>280const 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});
275296
276const getProjection = (threadId: ThreadId) =>297const 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
80export 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") {}
147147
148export class EnvironmentOwnedDataCleanup extends Context.Reference<{148export 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";
11import { normalizeHttpBaseUrl } from "@t3tools/shared/advertisedEndpoint";
11import * as HttpObservability from "@t3tools/shared/httpObservability";12import * as HttpObservability from "@t3tools/shared/httpObservability";
12import * as Data from "effect/Data";13import * as Data from "effect/Data";
13import * as Duration from "effect/Duration";14import * 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 );
8889
89const 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
97export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>90export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>
98 HttpApiClient.make(EnvironmentHttpApi, {91 HttpApiClient.make(EnvironmentHttpApi, {
99 baseUrl: remoteApiBaseUrl(httpBaseUrl),92 baseUrl: normalizeHttpBaseUrl(httpBaseUrl),
100 });93 });
10194
102export const makeEnvironmentHttpApiGroupClient = <95export 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 );
115108
116/** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */109/** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */
117export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) =>110export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) =>
118 HttpApiClient.urlBuilder(EnvironmentHttpApi, {111 HttpApiClient.urlBuilder(EnvironmentHttpApi, {
119 baseUrl: remoteApiBaseUrl(httpBaseUrl),112 baseUrl: normalizeHttpBaseUrl(httpBaseUrl),
120 });113 });
121114
122const failRemoteRequest = (115const failRemoteRequest = (
packages/client-runtime/src/state/archiveAssetHttp.ts created+41
...@@ -0,0 +1,41 @@
1import {
2 ArchiveAssetCreateUrlInput,
3 ArchiveError,
4 AssetCreateUrlResult,
5 type AssetResource,
6 type EnvironmentId,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";
10import * as SubscriptionRef from "effect/SubscriptionRef";
11import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
12import * as HubConnection from "../connection/hub.ts";
13import { environmentEndpointUrl } from "../environment/endpoint.ts";
14
15const encodeInput = Schema.encodeEffect(Schema.toCodecJson(ArchiveAssetCreateUrlInput));
16export 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 @@
1import {
2 ArchiveError,
3 OrchestrationV2ThreadDetailSnapshot,
4 type EnvironmentId,
5 type ThreadId,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
9import * as SubscriptionRef from "effect/SubscriptionRef";
10import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
11import * as HubConnection from "../connection/hub.ts";
12
13export 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";
14import { HttpClient, HttpClientResponse } from "effect/http";
15import * as HubConnection from "../connection/hub.ts";
14import * as Cause from "effect/Cause";16import * as Cause from "effect/Cause";
15import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
16import * as Stream from "effect/Stream";18import * as Stream from "effect/Stream";
...@@ -29,14 +31,29 @@ import {...@@ -29,14 +31,29 @@ import {
29import * as EnvironmentSupervisor from "../connection/supervisor.ts";31import * as EnvironmentSupervisor from "../connection/supervisor.ts";
30import type { RpcSession } from "../rpc/session.ts";32import type { RpcSession } from "../rpc/session.ts";
31import type { WsRpcProtocolClient } from "../rpc/protocol.ts";33import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
34import { createOrchestrationEnvironmentAtoms } from "./orchestration.ts";
35import { v2Project, v2ThreadShell, v2Now } from "./orchestrationV2TestFixtures.ts";
32import { createProjectFaviconCache } from "../projectFaviconCache.ts";36import { createProjectFaviconCache } from "../projectFaviconCache.ts";
33import {37import {
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";
3944
45describe("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
40describe("asset collection keys", () => {57describe("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});
6178
62describe("createAssetEnvironmentAtoms", () => {79describe("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 @@
1import {1import {
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";
17import { HttpClient } from "effect/http";
18import * as HubConnection from "../connection/hub.ts";
19import { fetchArchivedAssetUrl } from "./archiveAssetHttp.ts";
16import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
17import * as Option from "effect/Option";21import * as Option from "effect/Option";
18import * as Result from "effect/Result";22import * 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";
2125
22import * as EnvironmentRegistry from "../connection/registry.ts";26import * as EnvironmentRegistry from "../connection/registry.ts";
23import * as EnvironmentSupervisor from "../connection/supervisor.ts";27import * as EnvironmentSupervisor from "../connection/supervisor.ts";
24import { request } from "../rpc/client.ts";28import { environmentEndpointUrl } from "../environment/endpoint.ts";
29import { request, EnvironmentRpcUnavailableError } from "../rpc/client.ts";
25import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";30import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";
26import { createEnvironmentQueryAtomFamily } from "./runtime.ts";31import { createEnvironmentQueryAtomFamily } from "./runtime.ts";
32import { authorizeHubSignedUrl } from "./hubSignedUrl.ts";
2733
28const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;34const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
29const ASSET_URL_STALE_TIME_MS = 5 * 60_000;35const 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
44const decodeAssetCollectionKey = Schema.decodeUnknownSync(50const decodeAssetCollectionKey = Schema.decodeUnknownSync(
45 Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]),51 Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]),
46);52);
53const decodeAssetUrlKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, AssetResource]));
4754
48export function parseAssetCollectionKey(55export function parseAssetCollectionKey(
49 key: string,56 key: string,
...@@ -57,7 +64,7 @@ export function parseAssetCollectionKey(...@@ -57,7 +64,7 @@ export function parseAssetCollectionKey(
5764
58export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null {65export 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 = localMediaEnvironment174 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";
9import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";9import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
10import * as Effect from "effect/Effect";
1011
11import type { EnvironmentRegistry } from "../connection/registry.ts";12import type { EnvironmentRegistry } from "../connection/registry.ts";
13import { requestGuarded } from "../rpc/client.ts";
14import { authorizeHubSignedUrl } from "./hubSignedUrl.ts";
12import {15import {
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";
23import { TestClock } from "effect/testing";23import { TestClock } from "effect/testing";
24import type { HttpClient } from "effect/http";24import type { HttpClient } from "effect/http";
2525
26import { HostedAuthAccess } from "../platform/capabilities.ts";
26import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";27import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
27import {28import {
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 );
631632
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";
8import { withRelayClientTracing } from "@t3tools/shared/relayTracing";8import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
9import { FetchHttpClient, type HttpMethod } from "effect/http";9import { FetchHttpClient, type HttpMethod } from "effect/http";
1010
11import { HostedAuthAccess } from "../platform/capabilities.ts";
11import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts";12import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
12import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts";13import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts";
13import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";14import 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";
2223
23export interface EnvironmentHttpAuthHeaders {24export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 AuthOrchestrationOperateScope,
4 EnvironmentId,
5 ExecutionEnvironmentDescriptor,
6 type ServerConfig,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import * as SubscriptionRef from "effect/SubscriptionRef";
13import { Atom, AtomRegistry } from "effect/reactivity";
14
15import { HubConnection, type HubConnectionState } from "../connection/hub.ts";
16import { createHubConnectionAtoms, createHubEnvironmentCapabilitiesAtom } from "./hub.ts";
17
18const hubId = EnvironmentId.make("hub");
19const id = EnvironmentId.make("offline");
20const descriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor)({
21 environmentId: id,
22 label: "Offline machine",
23 platform: { os: "darwin", arch: "arm64" },
24 serverVersion: "test",
25 capabilities: {},
26});
27const 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
60describe("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 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Stream from "effect/Stream";
4import * as SubscriptionRef from "effect/SubscriptionRef";
5import { AsyncResult, Atom } from "effect/reactivity";
6import {
7 AuthOrchestrationOperateScope,
8 HUB_THREAD_ORGANIZATION_FIELDS,
9 type EnvironmentId,
10 type ExecutionEnvironmentCapabilities,
11 type HubInboxEnvironment,
12 type ServerConfig,
13} from "@t3tools/contracts";
14import * as Equal from "effect/Equal";
15import * as Struct from "effect/Struct";
16
17import { HubConnection, EMPTY_HUB_CONNECTION_STATE } from "../connection/hub.ts";
18import type { PairingConnectionInput } from "../connection/onboarding.ts";
19import { createAtomCommandScheduler, createRuntimeCommand } from "./runtime.ts";
20import type { EnvironmentShellState } from "./shell.ts";
21
22export type { HubConnectionState, HubServerConnection } from "../connection/hub.ts";
23export type { PairingConnectionInput } from "../connection/onboarding.ts";
24
25export 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
56export 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
129export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 AuthStandardClientScopes,
4 EnvironmentId,
5 EnvironmentAuthorizationError,
6 WS_METHODS,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as DateTime from "effect/DateTime";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";
14import { Atom, AtomRegistry } from "effect/reactivity";
15import { vi } from "vite-plus/test";
16
17import * as EnvironmentRegistry from "../connection/registry.ts";
18import {
19 AVAILABLE_CONNECTION_STATE,
20 BearerConnectionTarget,
21 type PreparedConnection,
22 type SupervisorConnectionState,
23} from "../connection/model.ts";
24import * as EnvironmentSupervisor from "../connection/supervisor.ts";
25import type { RpcSession } from "../rpc/session.ts";
26import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
27import { layerRemoteHttpClient } from "../rpc/http.ts";
28import { createAssetEnvironmentAtoms, resolveAssetUrl } from "./assets.ts";
29import { createAttachmentEnvironmentAtoms, runAttachmentUploadCycle } from "./attachments.ts";
30import { runAtomCommand } from "./runtime.ts";
31
32const environmentId = EnvironmentId.make("laptop");
33const base = "https://hub.example/knife/environments/laptop/";
34
35const 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
151describe("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 @@
1import * as DateTime from "effect/DateTime";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as SubscriptionRef from "effect/SubscriptionRef";
5import { HttpClient } from "effect/http";
6
7import { issueRemoteWebSocketTicket } from "../authorization/remote.ts";
8import * as EnvironmentSupervisor from "../connection/supervisor.ts";
9import { environmentEndpointUrl } from "../environment/endpoint.ts";
10import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
11
12export 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 @@
1import { ORCHESTRATION_V2_WS_METHODS, type ThreadId } from "@t3tools/contracts";1import { ArchiveError, ORCHESTRATION_V2_WS_METHODS, type ThreadId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";3import * as Option from "effect/Option";
4import * as SubscriptionRef from "effect/SubscriptionRef";4import * as SubscriptionRef from "effect/SubscriptionRef";
...@@ -7,6 +7,7 @@ import { Atom } from "effect/reactivity";...@@ -7,6 +7,7 @@ import { Atom } from "effect/reactivity";
77
8import {8import {
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";
15import * as EnvironmentSupervisor from "../connection/supervisor.ts";16import * as EnvironmentSupervisor from "../connection/supervisor.ts";
16import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";17import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
17import * as ManagedRelayDpopSigner from "../relay/managedRelay.ts";18import * as ManagedRelayDpopSigner from "../relay/managedRelay.ts";
18import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";19import * as HubConnection from "../connection/hub.ts";
20import {
21 request,
22 EnvironmentRpcUnavailableError,
23 type EnvironmentRpcInput,
24} from "../rpc/client.ts";
19import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts";25import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts";
2026
21export function createOrchestrationEnvironmentAtoms<R, E>(27export 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";
23import { connectionProjectionPhase } from "../connection/model.ts";23import { connectionProjectionPhase } from "../connection/model.ts";
24import * as EnvironmentSupervisor from "../connection/supervisor.ts";24import * as EnvironmentSupervisor from "../connection/supervisor.ts";
25import * as ConnectionWakeups from "../connection/wakeups.ts";25import * as ConnectionWakeups from "../connection/wakeups.ts";
26import * as Hub from "../connection/hub.ts";
27import { applyHubInboxEnvironment } from "./hub.ts";
26import { safeErrorLogAttributes } from "../errors/safeLog.ts";28import { safeErrorLogAttributes } from "../errors/safeLog.ts";
27import * as Persistence from "../platform/persistence.ts";29import * as Persistence from "../platform/persistence.ts";
28import { runCachePersistence } from "./cachePersistence.ts";30import { 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});
429431
430function shellStateChanges(environmentId: EnvironmentId) {432function 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}
436452
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";
24import * as EnvironmentSupervisor from "../connection/supervisor.ts";24import * as EnvironmentSupervisor from "../connection/supervisor.ts";
25import type { RpcSession } from "../rpc/session.ts";25import type { RpcSession } from "../rpc/session.ts";
26import { createThreadEnvironmentAtoms } from "./threadCommands.ts";26import { createThreadEnvironmentAtoms } from "./threadCommands.ts";
27import { applyHubInboxEnvironment } from "./hub.ts";
2728
28const ENVIRONMENT_ID = EnvironmentId.make("remote");29const ENVIRONMENT_ID = EnvironmentId.make("remote");
29const THREAD_ID = ThreadId.make("thread");30const THREAD_ID = ThreadId.make("thread");
...@@ -80,6 +81,47 @@ const SNAPSHOT: OrchestrationV2ShellSnapshot = {...@@ -80,6 +81,47 @@ const SNAPSHOT: OrchestrationV2ShellSnapshot = {
80 ],81 ],
81};82};
8283
84describe("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
83const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* () {125const 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 @@
1import {1import {
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";
19import * as Option from "effect/Option";19import * as Option from "effect/Option";
20import * as Queue from "effect/Queue";20import * as Queue from "effect/Queue";
21import * as Ref from "effect/Ref";21import * as Ref from "effect/Ref";
22import * as Schema from "effect/Schema";
23import * as HubConnection from "../connection/hub.ts";
22import * as Scope from "effect/Scope";24import * as Scope from "effect/Scope";
23import * as Stream from "effect/Stream";25import * as Stream from "effect/Stream";
24import * as SubscriptionRef from "effect/SubscriptionRef";26import * as SubscriptionRef from "effect/SubscriptionRef";
...@@ -46,6 +48,9 @@ import {...@@ -46,6 +48,9 @@ import {
46} from "./threads.ts";48} from "./threads.ts";
47import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";49import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";
4850
51const encodeArchiveSnapshot = Schema.encodeEffect(
52 Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot),
53);
49const TARGET = new PrimaryConnectionTarget({54const 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}
100105
101const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {106const 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};
321332
322describe("EnvironmentThreads", () => {333describe("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";
47import { fetchArchivedThreadSnapshot } from "./archiveThreadHttp.ts";
48import * as HubConnection from "../connection/hub.ts";
47import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";49import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";
48import {50import {
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 projection367 * 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 : waiting390 (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 );
773779
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 });
2323
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 @@
1import { environmentEndpointUrl } from "./environment/endpoint.ts";
1import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts";2import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts";
2import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification";3import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification";
34
...@@ -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;
8181
82export const AuthOrchestrationReadScope = "orchestration:read" as const;82export const AuthOrchestrationReadScope = "orchestration:read" as const;
83export const AuthOrchestrationOperateScope = "orchestration:operate" as const;83export const AuthOrchestrationOperateScope = "orchestration:operate" as const;
84export const AuthEnvironmentConnectScope = "environment:connect" as const;
85export const AuthArchiveReadScope = "archive:read" as const;
86export const AuthArchiveWriteScope = "archive:write" as const;
84export const AuthSettingsWriteScope = "settings:write" as const;87export const AuthSettingsWriteScope = "settings:write" as const;
85export const AuthProvidersManageScope = "providers:manage" as const;88export const AuthProvidersManageScope = "providers:manage" as const;
86export const AuthEnvironmentMaintainScope = "environment:maintain" as const;89export 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;
98export const AuthRelayReadScope = "relay:read" as const;101export const AuthRelayReadScope = "relay:read" as const;
99export const AuthRelayWriteScope = "relay:write" as const;102export const AuthRelayWriteScope = "relay:write" as const;
100export const AuthEnvironmentScope = Schema.Literals([103export 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 never158// 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.
154const legacyParents: Partial<Record<AuthEnvironmentScope, AuthEnvironmentScope>> = {160const 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
205export const AuthStandardClientScopes = [213export 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;
220export const AuthAdministrativeScopes = [230export 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});
459export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;470export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;
460471
472export const AuthCreateDelegatedSessionInput = Schema.Struct({
473 label: Schema.optionalKey(TrimmedNonEmptyString),
474 scopes: AuthGrantScopes,
475});
476export type AuthCreateDelegatedSessionInput = typeof AuthCreateDelegatedSessionInput.Type;
477
461export const AuthSessionState = Schema.Struct({478export 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 @@
1import * as Schema from "effect/Schema";
2
3export 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});
8export type DesktopHostedAuthState = typeof DesktopHostedAuthState.Type;
9
10export const SelfHostedConnectionStatus = Schema.Struct({
11 configured: Schema.Boolean,
12 connected: Schema.Boolean,
13 expiresAt: Schema.NullOr(Schema.String),
14});
15export 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";
7576
76const OptionalBearerHeaders = Schema.Struct({77const 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});
8082
81const OrchestrationProtocolHeaders = Schema.Struct({83const 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});
8689
87const OptionalDpopProofHeaders = Schema.Struct({90const 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});
9094
...@@ -374,6 +378,7 @@ const EnvironmentProjectMutationErrors = [...@@ -374,6 +378,7 @@ const EnvironmentProjectMutationErrors = [
374] as const;378] as const;
375379
376export interface EnvironmentSessionPrincipalShape {380export 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 @@
1import * as Schema from "effect/Schema";
2import { AuthClientPresentationMetadata, AuthGrantScopes } from "./auth.ts";
3import { EnvironmentId } from "./baseSchemas.ts";
4
5const ProofChallenge = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/u));
6const ProofVerifier = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9._~-]{43,128}$/u));
7
8export const HostedAuthDeviceStart = Schema.Struct({
9 codeChallenge: ProofChallenge,
10 state: ProofChallenge,
11 client: AuthClientPresentationMetadata,
12});
13export type HostedAuthDeviceStart = typeof HostedAuthDeviceStart.Type;
14
15export const HostedAuthDeviceStartResult = Schema.Struct({ authorizationUrl: Schema.String });
16export type HostedAuthDeviceStartResult = typeof HostedAuthDeviceStartResult.Type;
17
18export const HostedAuthDeviceExchange = Schema.Struct({
19 code: ProofChallenge,
20 codeVerifier: ProofVerifier,
21});
22export type HostedAuthDeviceExchange = typeof HostedAuthDeviceExchange.Type;
23
24export const HostedAuthDeviceRefresh = Schema.Struct({ refreshToken: Schema.NonEmptyString });
25export type HostedAuthDeviceRefresh = typeof HostedAuthDeviceRefresh.Type;
26
27export const HostedAuthGrant = Schema.Struct({
28 deviceId: Schema.String,
29 accessToken: Schema.String,
30 refreshToken: Schema.String,
31 expiresAt: Schema.DateTimeUtc,
32});
33export type HostedAuthGrant = typeof HostedAuthGrant.Type;
34
35export 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});
44export type HostedAuthDevice = typeof HostedAuthDevice.Type;
45
46export const HostedAuthAccount = Schema.Struct({
47 accountId: Schema.String,
48 deviceId: Schema.String,
49 csrfToken: Schema.String,
50 expiresAt: Schema.DateTimeUtc,
51});
52export type HostedAuthAccount = typeof HostedAuthAccount.Type;
53
54export const HostedAuthTokenRequest = Schema.Struct({ scopes: AuthGrantScopes });
55export type HostedAuthTokenRequest = typeof HostedAuthTokenRequest.Type;
56
57export 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 @@
1import * as Schema from "effect/Schema";
2import { AuthEnvironmentScopes } from "./auth.ts";
3import { EnvironmentId, ThreadId } from "./baseSchemas.ts";
4import { ExecutionEnvironmentDescriptor } from "./environment.ts";
5import { OrchestrationV2Command, OrchestrationV2ShellSnapshot } from "./orchestrationV2.ts";
6
7export 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
19const 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
31export 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);
41export type HubOrganizationCommand = typeof HubOrganizationCommand.Type;
42
43export const HubInboxCommand = Schema.Struct({
44 environmentId: EnvironmentId,
45 command: HubOrganizationCommand,
46});
47export type HubInboxCommand = typeof HubInboxCommand.Type;
48
49export const HubInboxEnvironment = Schema.Struct({
50 environmentId: EnvironmentId,
51 snapshot: OrchestrationV2ShellSnapshot,
52 pendingThreadIds: Schema.Array(ThreadId),
53});
54export type HubInboxEnvironment = typeof HubInboxEnvironment.Type;
55
56export const HubInbox = Schema.Struct({ environments: Schema.Array(HubInboxEnvironment) });
57export type HubInbox = typeof HubInbox.Type;
58
59export 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});
71export type HubDirectory = typeof HubDirectory.Type;
72
73export 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]);
84export type HubManagementRequest = typeof HubManagementRequest.Type;
85
86export 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";
7070
71export * from "./uiSettings.ts";71export * from "./uiSettings.ts";
72export * from "./sharedAccounts.ts";72export * from "./sharedAccounts.ts";
73
74export * from "./transcriptArchive.ts";
75export * from "./hub.ts";
76export * from "./hostedAuth.ts";
77export * from "./desktopHostedAuth.ts";
78export * from "./outbound.ts";
packages/contracts/src/ipc.ts+6
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import type { DesktopHostedAuthState } from "./desktopHostedAuth.ts";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
23
3import { SnapShotSource } from "./chatAttachment.ts";4import { 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"]);
1127export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type;1128export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type;
11281129
1129export interface DesktopBridge {1130export 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 @@
1import * as Schema from "effect/Schema";
2import { AuthEnvironmentScopes } from "./auth.ts";
3import { ExecutionEnvironmentDescriptor } from "./environment.ts";
4
5export 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]);
14export type OutboundSourceMessage = typeof OutboundSourceMessage.Type;
15
16export 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]);
25export type OutboundHubMessage = typeof OutboundHubMessage.Type;
26
27export 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});
32export type HostedSourceConnection = typeof HostedSourceConnection.Type;
packages/contracts/src/transcriptArchive.ts created+76
...@@ -0,0 +1,76 @@
1import * as Schema from "effect/Schema";
2import { OrchestrationV2ThreadShell } from "./orchestrationV2.ts";
3import { AssetResource } from "./assets.ts";
4import { OrchestrationProjectShell } from "./orchestrationProject.ts";
5import { EnvironmentId, ThreadId } from "./baseSchemas.ts";
6
7export const ArchiveObjectHash = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u));
8export const ArchiveObjectReceipt = Schema.Struct({
9 hash: ArchiveObjectHash,
10 size: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
11});
12export 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]);
16export type ArchiveManagementRequest = typeof ArchiveManagementRequest.Type;
17export 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});
37export type ArchiveStatus = typeof ArchiveStatus.Type;
38
39export const ArchiveAssetCreateUrlInput = Schema.Struct({
40 environmentId: EnvironmentId,
41 resource: AssetResource,
42});
43
44export const ArchivePublication = Schema.Struct({
45 environmentId: EnvironmentId,
46 manifestHash: ArchiveObjectHash,
47 project: OrchestrationProjectShell,
48});
49export type ArchivePublication = typeof ArchivePublication.Type;
50export const ArchiveCatalogEntry = Schema.Struct({
51 environmentId: EnvironmentId,
52 manifestHash: ArchiveObjectHash,
53 throughSequence: Schema.Number,
54 thread: OrchestrationV2ThreadShell,
55 project: OrchestrationProjectShell,
56});
57export type ArchiveCatalogEntry = typeof ArchiveCatalogEntry.Type;
58export const ArchiveCatalogResult = Schema.Struct({ entries: Schema.Array(ArchiveCatalogEntry) });
59
60export 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 }
3535
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}
4141
42export 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. */
51export function environmentWebSocketUrl(wsBaseUrl: string): string {
52 const url = new URL(wsBaseUrl);
53 return url.pathname.endsWith("/") ? environmentEndpointUrl(wsBaseUrl, "/ws") : url.toString();
54}
55
42export function deriveWsBaseUrl(httpBaseUrl: string): string {56export 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 the8 * 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 */
11export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws", "/mcp"] as const;11export const DEV_PROXIED_PATH_PREFIXES = [
12 "/api",
13 "/oauth",
14 "/.well-known",
15 "/ws",
16 "/mcp",
17 "/environments",
18] as const;
1219
13/**20/**
14 * Prefixes the proxy must forward with the browser's own Host. MCP OAuth21 * 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]);
2634
27export function isDevProxiedPath(pathname: string): boolean {35export function isDevProxiedPath(pathname: string): boolean {
packages/shared/src/hostedServerUrl.ts created+14
...@@ -0,0 +1,14 @@
1export 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 });
2323
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 @@
1import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "./advertisedEndpoint.ts";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
23
3const PAIRING_TOKEN_PARAM = "token";4const 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};
105103
106const 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
119const 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
132export interface ResolvedRemotePairingTarget {104export 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 }
218190
...@@ -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 }
229202
...@@ -239,7 +212,7 @@ export const resolveRemotePairingTarget = (input: {...@@ -239,7 +212,7 @@ export const resolveRemotePairingTarget = (input: {
239212
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};